Skip to content

Commit 1a18907

Browse files
Merge pull request #1412 from svetanis:feature/save-files-as-artifacts-plugin
PiperOrigin-RevId: 1007985183
2 parents 2c84291 + 272ea6c commit 1a18907

4 files changed

Lines changed: 1151 additions & 1 deletion

File tree

‎core/src/main/java/com/google/adk/agents/BaseAgent.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,8 @@ private Maybe<Event> callCallback(
429429
.switchIfEmpty(
430430
Maybe.defer(
431431
() -> {
432-
if (callbackContext.state().hasDelta()) {
432+
if (callbackContext.state().hasDelta()
433+
|| !callbackContext.eventActions().artifactDelta().isEmpty()) {
433434
Event.Builder eventBuilder =
434435
Event.builder()
435436
.id(invocationContext.newUuid())
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.adk.plugins;
17+
18+
import static java.util.Comparator.naturalOrder;
19+
20+
import com.google.adk.agents.BaseAgent;
21+
import com.google.adk.agents.CallbackContext;
22+
import com.google.adk.agents.InvocationContext;
23+
import com.google.adk.artifacts.BaseArtifactService;
24+
import com.google.adk.sessions.SessionKey;
25+
import com.google.common.base.Ascii;
26+
import com.google.common.collect.ImmutableList;
27+
import com.google.common.collect.ImmutableMap;
28+
import com.google.common.collect.ImmutableSet;
29+
import com.google.genai.types.Blob;
30+
import com.google.genai.types.Content;
31+
import com.google.genai.types.FileData;
32+
import com.google.genai.types.Part;
33+
import io.reactivex.rxjava3.core.Flowable;
34+
import io.reactivex.rxjava3.core.Maybe;
35+
import io.reactivex.rxjava3.core.Single;
36+
import java.math.BigDecimal;
37+
import java.math.RoundingMode;
38+
import java.util.LinkedHashMap;
39+
import java.util.Map;
40+
import java.util.Optional;
41+
import org.slf4j.Logger;
42+
import org.slf4j.LoggerFactory;
43+
44+
/**
45+
* Saves files attached to user messages as artifacts, replacing each with a placeholder that names
46+
* the artifact so agents can load it later, such as with {@link
47+
* com.google.adk.tools.LoadArtifactsTool}. Each file is saved under its {@link Blob#displayName()
48+
* display name}, where a {@code user:} prefix shares it across the user's sessions, or under a
49+
* generated name if it has no display name; files over 20 MB are replaced with an error note
50+
* instead. Mirrors Python ADK's {@code SaveFilesAsArtifactsPlugin}.
51+
*/
52+
public class SaveFilesAsArtifactsPlugin extends BasePlugin {
53+
54+
private static final String DEFAULT_NAME = "save_files_as_artifacts_plugin";
55+
56+
private static final Logger logger = LoggerFactory.getLogger(SaveFilesAsArtifactsPlugin.class);
57+
58+
private static final int BYTES_PER_MB = 1024 * 1024;
59+
60+
// The Gemini API's size limit for inline data.
61+
private static final int MAX_FILE_SIZE_MB = 20;
62+
private static final int MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * BYTES_PER_MB;
63+
64+
// The URI schemes models can fetch: Vertex reads `gs`, hosted endpoints `https` and `http`.
65+
private static final ImmutableSet<String> MODEL_ACCESSIBLE_URI_SCHEMES =
66+
ImmutableSet.of("gs", "https", "http");
67+
68+
private static final String PENDING_DELTA_KEY = "%s:pending_delta";
69+
private static final String GENERATED_FILE_NAME = "artifact_%s_%d";
70+
private static final String PLACEHOLDER = "[Uploaded Artifact: \"%s\"]";
71+
private static final String UPLOAD_ERROR =
72+
"[Upload Error: File %s (%s MB) exceeds the maximum supported size of %dMB. Please upload a"
73+
+ " smaller file.]";
74+
75+
private final boolean attachFileReference;
76+
private final String pendingDeltaKey;
77+
78+
public SaveFilesAsArtifactsPlugin() {
79+
this(/* attachFileReference= */ true);
80+
}
81+
82+
public SaveFilesAsArtifactsPlugin(boolean attachFileReference) {
83+
this(attachFileReference, DEFAULT_NAME);
84+
}
85+
86+
/**
87+
* Creates the plugin.
88+
*
89+
* @param attachFileReference Whether to also attach a {@link FileData} reference to each saved
90+
* file when the artifact service stores it at a URI the model can read ({@code gs}, {@code
91+
* https} or {@code http}).
92+
* @param name The unique name of the plugin instance.
93+
*/
94+
public SaveFilesAsArtifactsPlugin(boolean attachFileReference, String name) {
95+
super(name);
96+
this.attachFileReference = attachFileReference;
97+
this.pendingDeltaKey = PENDING_DELTA_KEY.formatted(name);
98+
}
99+
100+
@Override
101+
public Maybe<Content> onUserMessageCallback(
102+
InvocationContext invocationContext, Content userMessage) {
103+
BaseArtifactService artifactService = invocationContext.artifactService();
104+
if (artifactService == null) {
105+
logger.warn("Artifact service is not set. SaveFilesAsArtifactsPlugin will not be enabled.");
106+
return Maybe.empty();
107+
}
108+
return Maybe.defer(
109+
() -> {
110+
ImmutableList<Part> parts =
111+
ImmutableList.copyOf(userMessage.parts().orElse(ImmutableList.of()));
112+
Map<String, Integer> savedVersions = new LinkedHashMap<>();
113+
return Flowable.range(0, parts.size())
114+
.concatMapSingle(
115+
index ->
116+
newParts(
117+
invocationContext,
118+
artifactService,
119+
parts.get(index),
120+
index,
121+
savedVersions))
122+
.concatMapIterable(replacements -> replacements)
123+
.toList()
124+
.filter(newParts -> !newParts.equals(parts))
125+
.map(
126+
newParts -> {
127+
if (!savedVersions.isEmpty()) {
128+
// Unlike session state, which Python uses, per-invocation data leaves no
129+
// entry behind.
130+
invocationContext
131+
.callbackContextData()
132+
.put(
133+
pendingDeltaKey,
134+
new PendingDelta(ImmutableMap.copyOf(savedVersions)));
135+
}
136+
return userMessage.toBuilder().parts(ImmutableList.copyOf(newParts)).build();
137+
});
138+
});
139+
}
140+
141+
/**
142+
* Reports the versions saved from the user message on the event of the first agent to run, since
143+
* a plugin cannot add actions to the user's own event.
144+
*/
145+
@Override
146+
public Maybe<Content> beforeAgentCallback(BaseAgent agent, CallbackContext callbackContext) {
147+
return Maybe.fromAction(
148+
() -> {
149+
if (callbackContext.invocationContext().callbackContextData().remove(pendingDeltaKey)
150+
instanceof PendingDelta pending) {
151+
callbackContext.eventActions().artifactDelta().putAll(pending.versions());
152+
}
153+
});
154+
}
155+
156+
/** Returns the parts that take {@code part}'s place, recording any version saved. */
157+
private Single<ImmutableList<Part>> newParts(
158+
InvocationContext invocationContext,
159+
BaseArtifactService artifactService,
160+
Part part,
161+
int index,
162+
Map<String, Integer> savedVersions) {
163+
Optional<Blob> inlineData = part.inlineData();
164+
if (inlineData.isEmpty()) {
165+
return Single.just(ImmutableList.of(part));
166+
}
167+
String fileName =
168+
inlineData
169+
.flatMap(Blob::displayName)
170+
.filter(displayName -> !displayName.isEmpty())
171+
.orElseGet(
172+
() -> GENERATED_FILE_NAME.formatted(invocationContext.invocationId(), index));
173+
int size = inlineData.flatMap(Blob::data).map(data -> data.length).orElse(0);
174+
if (size > MAX_FILE_SIZE_BYTES) {
175+
logger.warn(
176+
"The file in part {} is {} bytes, over the limit; it was not saved.", index, size);
177+
return Single.just(
178+
ImmutableList.of(
179+
Part.fromText(UPLOAD_ERROR.formatted(fileName, megabytes(size), MAX_FILE_SIZE_MB))));
180+
}
181+
SessionKey sessionKey = invocationContext.session().sessionKey();
182+
return Single.defer(() -> save(artifactService, sessionKey, fileName, part))
183+
.map(
184+
saved -> {
185+
saved.version().ifPresent(version -> savedVersions.put(fileName, version));
186+
ImmutableList.Builder<Part> replacement = ImmutableList.builder();
187+
replacement.add(Part.fromText(PLACEHOLDER.formatted(fileName)));
188+
saved.reference().ifPresent(replacement::add);
189+
return replacement.build();
190+
})
191+
.onErrorReturn(
192+
error -> {
193+
// The exception message can contain the user-chosen file name, so log only the type.
194+
logger.error(
195+
"Failed to save the file in part {} ({}); keeping the original part.",
196+
index,
197+
error.getClass().getSimpleName());
198+
return ImmutableList.of(part);
199+
});
200+
}
201+
202+
/**
203+
* Saves {@code part} under {@code fileName}. When {@code attachFileReference} is enabled, this
204+
* uses {@link BaseArtifactService#saveAndReloadArtifact} to get the stored file's URI and then
205+
* reads back the latest version, since the service cannot look up the URI of a given version.
206+
*/
207+
private Single<SavedFile> save(
208+
BaseArtifactService artifactService, SessionKey sessionKey, String fileName, Part part) {
209+
if (!attachFileReference) {
210+
return artifactService
211+
.saveArtifact(sessionKey, fileName, part)
212+
.map(version -> new SavedFile(Optional.of(version), Optional.empty()));
213+
}
214+
return artifactService
215+
.saveAndReloadArtifact(sessionKey, fileName, part)
216+
.flatMap(
217+
stored -> {
218+
Optional<FileData> storedFile = stored.fileData();
219+
Optional<Part> reference =
220+
storedFile
221+
.flatMap(FileData::fileUri)
222+
.filter(SaveFilesAsArtifactsPlugin::isModelAccessibleUri)
223+
.map(
224+
uri -> {
225+
FileData.Builder fileData =
226+
FileData.builder().displayName(fileName).fileUri(uri);
227+
part.inlineData()
228+
.flatMap(Blob::mimeType)
229+
.filter(type -> !type.isEmpty())
230+
.or(() -> storedFile.flatMap(FileData::mimeType))
231+
.ifPresent(fileData::mimeType);
232+
return Part.builder().fileData(fileData.build()).build();
233+
});
234+
// The file is saved by now, so failing to read its version only leaves it out of
235+
// the delta.
236+
return Single.defer(() -> artifactService.listVersions(sessionKey, fileName))
237+
.map(versions -> versions.stream().max(naturalOrder()))
238+
.doOnSuccess(
239+
version -> {
240+
if (version.isEmpty()) {
241+
logger.warn("Saved a file but the service listed no versions of it.");
242+
}
243+
})
244+
.onErrorReturn(
245+
error -> {
246+
logger.warn(
247+
"Saved a file but could not list its versions ({}).",
248+
error.getClass().getSimpleName());
249+
return Optional.empty();
250+
})
251+
.map(version -> new SavedFile(version, reference));
252+
});
253+
}
254+
255+
private static boolean isModelAccessibleUri(String uri) {
256+
int colon = uri.indexOf(':');
257+
return colon > 0
258+
&& MODEL_ACCESSIBLE_URI_SCHEMES.contains(Ascii.toLowerCase(uri.substring(0, colon)));
259+
}
260+
261+
/**
262+
* Formats {@code bytes} in megabytes to two decimal places, rounding half to even like Python.
263+
*/
264+
private static String megabytes(int bytes) {
265+
return BigDecimal.valueOf(bytes)
266+
.divide(BigDecimal.valueOf(BYTES_PER_MB))
267+
.setScale(2, RoundingMode.HALF_EVEN)
268+
.toPlainString();
269+
}
270+
271+
private record SavedFile(Optional<Integer> version, Optional<Part> reference) {}
272+
273+
private record PendingDelta(ImmutableMap<String, Integer> versions) {}
274+
}

0 commit comments

Comments
 (0)