Skip to content

Commit 8ffe210

Browse files
committed
feat(plugins): port SaveFilesAsArtifactsPlugin from adk-python
1 parent 2b87d65 commit 8ffe210

4 files changed

Lines changed: 1038 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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 com.google.common.collect.ImmutableMap.toImmutableMap;
19+
20+
import com.google.adk.agents.CallbackContext;
21+
import com.google.adk.agents.InvocationContext;
22+
import com.google.common.collect.ImmutableMap;
23+
import java.util.Map;
24+
25+
/**
26+
* Carries artifact versions from {@code onUserMessageCallback}, which has no {@link
27+
* com.google.adk.events.EventActions} to write to, across to {@code beforeAgentCallback}, which
28+
* does.
29+
*
30+
* <p>The hand-off rides on the session state under a {@link
31+
* com.google.adk.sessions.State#TEMP_PREFIX} key, so {@code BaseSessionService.appendEvent} skips
32+
* it when applying the state delta and the bookkeeping never reaches persisted session state. The
33+
* key also carries the invocation id, so concurrent invocations sharing one session cannot read
34+
* each other's pending versions.
35+
*
36+
* <p>Draining overwrites the entry with an empty map rather than removing it. {@code State.remove}
37+
* would write the {@code State.REMOVED} sentinel into the event's state delta, which does not
38+
* survive a JSON round trip.
39+
*/
40+
final class PendingArtifactDelta {
41+
42+
private static final String KEY = "temp:%s:pending_delta:%s";
43+
44+
private PendingArtifactDelta() {}
45+
46+
/** Stores the versions produced during this invocation. */
47+
static void stash(
48+
InvocationContext invocationContext, String pluginName, ImmutableMap<String, Integer> delta) {
49+
invocationContext
50+
.session()
51+
.state()
52+
.put(key(pluginName, invocationContext.invocationId()), delta);
53+
}
54+
55+
/**
56+
* Returns the stored versions and clears them, so only the first agent callback reports them.
57+
*
58+
* <p>The write is guarded on there being something to clear, and the guard is load-bearing: a
59+
* write through {@link CallbackContext#state()} sets the state delta, and {@code BaseAgent} emits
60+
* an event for any before-agent callback that left one. Clearing unconditionally would therefore
61+
* emit an event carrying an empty artifact delta for every agent after the first.
62+
*/
63+
static ImmutableMap<String, Integer> drain(CallbackContext callbackContext, String pluginName) {
64+
String key = key(pluginName, callbackContext.invocationId());
65+
ImmutableMap<String, Integer> pending = read(callbackContext.state().get(key));
66+
if (!pending.isEmpty()) {
67+
callbackContext.state().put(key, ImmutableMap.of());
68+
}
69+
return pending;
70+
}
71+
72+
/**
73+
* Discards versions left behind by an invocation that ended before any agent ran, which happens
74+
* when a {@code beforeRunCallback} on another plugin halts the run. There is no {@link
75+
* com.google.adk.events.EventActions} to report them on at that point, so they are dropped rather
76+
* than returned.
77+
*
78+
* <p>Only writes when something is actually stashed: an unconditional write would add an entry
79+
* for every invocation that uploaded nothing, which is more state noise than the leak it
80+
* prevents.
81+
*/
82+
static void clear(InvocationContext invocationContext, String pluginName) {
83+
Map<String, Object> state = invocationContext.session().state();
84+
String key = key(pluginName, invocationContext.invocationId());
85+
if (!read(state.get(key)).isEmpty()) {
86+
state.put(key, ImmutableMap.of());
87+
}
88+
}
89+
90+
private static String key(String pluginName, String invocationId) {
91+
return KEY.formatted(pluginName, invocationId);
92+
}
93+
94+
private static ImmutableMap<String, Integer> read(Object stashed) {
95+
if (!(stashed instanceof Map<?, ?> entries)) {
96+
return ImmutableMap.of();
97+
}
98+
return entries.entrySet().stream()
99+
.filter(PendingArtifactDelta::isVersionEntry)
100+
.collect(
101+
toImmutableMap(entry -> (String) entry.getKey(), entry -> (Integer) entry.getValue()));
102+
}
103+
104+
/** State values survive a JSON round trip untyped, so each entry is checked before it is kept. */
105+
private static boolean isVersionEntry(Map.Entry<?, ?> entry) {
106+
return entry.getKey() instanceof String && entry.getValue() instanceof Integer;
107+
}
108+
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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 com.google.common.collect.ImmutableList.toImmutableList;
19+
import static com.google.common.collect.ImmutableMap.toImmutableMap;
20+
21+
import com.google.adk.agents.BaseAgent;
22+
import com.google.adk.agents.CallbackContext;
23+
import com.google.adk.agents.InvocationContext;
24+
import com.google.common.collect.ImmutableList;
25+
import com.google.common.collect.ImmutableMap;
26+
import com.google.genai.types.Blob;
27+
import com.google.genai.types.Content;
28+
import com.google.genai.types.Part;
29+
import io.reactivex.rxjava3.core.Completable;
30+
import io.reactivex.rxjava3.core.Flowable;
31+
import io.reactivex.rxjava3.core.Maybe;
32+
import io.reactivex.rxjava3.core.Single;
33+
import java.util.List;
34+
import java.util.Optional;
35+
import org.slf4j.Logger;
36+
import org.slf4j.LoggerFactory;
37+
38+
/**
39+
* Plugin that saves files embedded in user messages as artifacts.
40+
*
41+
* <p>This allows users to upload files in the chat experience and have those files available to the
42+
* agent within the current session. Each {@code inlineData} part of the incoming user message is
43+
* written to the configured {@link com.google.adk.artifacts.BaseArtifactService} and replaced, in
44+
* the message that reaches the model, by a short text placeholder naming the artifact. The bytes
45+
* themselves are therefore stored once and not resent on every turn.
46+
*
47+
* <p>The artifact name is taken from {@link Blob#displayName()} when present, so an uploaded {@code
48+
* report.pdf} is stored under that name. When the blob carries no display name, a name is generated
49+
* from the invocation id and the part index. Uploading the same name again saves a new version of
50+
* it, and the version saved is what this plugin reports for that name.
51+
*
52+
* <p>Add the {@code load_artifacts} tool to the agent, or load the artifacts from your own tool, to
53+
* let the model read the stored bytes back.
54+
*
55+
* <p>Register it on the runner:
56+
*
57+
* <pre>{@code
58+
* Runner runner =
59+
* Runner.builder()
60+
* .agent(agent)
61+
* .appName("my-app")
62+
* .artifactService(new InMemoryArtifactService())
63+
* .sessionService(new InMemorySessionService())
64+
* .plugins(new SaveFilesAsArtifactsPlugin())
65+
* .build();
66+
* }</pre>
67+
*
68+
* <p>The plugin is a no-op when no artifact service is configured on the runner.
69+
*/
70+
public class SaveFilesAsArtifactsPlugin extends BasePlugin {
71+
72+
/** Name used when the plugin is constructed without an explicit one. Matches adk-python's. */
73+
public static final String DEFAULT_NAME = "save_files_as_artifacts_plugin";
74+
75+
private static final Logger logger = LoggerFactory.getLogger(SaveFilesAsArtifactsPlugin.class);
76+
77+
private static final String GENERATED_FILE_NAME = "artifact_%s_%d";
78+
private static final String PLACEHOLDER_TEXT = "[Uploaded Artifact: \"%s\"]";
79+
80+
public SaveFilesAsArtifactsPlugin() {
81+
this(DEFAULT_NAME);
82+
}
83+
84+
public SaveFilesAsArtifactsPlugin(String name) {
85+
super(name);
86+
}
87+
88+
@Override
89+
public Maybe<Content> onUserMessageCallback(
90+
InvocationContext invocationContext, Content userMessage) {
91+
if (invocationContext.artifactService() == null) {
92+
logger.warn("No artifact service is configured; plugin '{}' is disabled.", getName());
93+
return Maybe.empty();
94+
}
95+
ImmutableList<Part> parts =
96+
ImmutableList.copyOf(userMessage.parts().orElse(ImmutableList.of()));
97+
if (parts.stream().noneMatch(SaveFilesAsArtifactsPlugin::hasInlineData)) {
98+
return Maybe.empty();
99+
}
100+
return Flowable.range(0, parts.size())
101+
.concatMapSingle(index -> savePart(invocationContext, parts.get(index), index))
102+
.collect(toImmutableList())
103+
.map(results -> rebuildMessage(invocationContext, userMessage, results))
104+
.filter(Optional::isPresent)
105+
.map(Optional::get);
106+
}
107+
108+
/**
109+
* Records the artifact versions stashed by {@link #onUserMessageCallback} on the first event
110+
* actions of the invocation. {@code onUserMessageCallback} runs before any {@link
111+
* com.google.adk.events.EventActions} exists, so the versions cannot be reported from there.
112+
*
113+
* <p>The reporting is a side effect and the return is always empty, deliberately: {@code
114+
* PluginManager} stops at the first plugin that returns a value, so returning content here would
115+
* both skip every later plugin's callback and halt the agent.
116+
*/
117+
@Override
118+
public Maybe<Content> beforeAgentCallback(BaseAgent agent, CallbackContext callbackContext) {
119+
PendingArtifactDelta.drain(callbackContext, getName())
120+
.forEach(callbackContext.eventActions().artifactDelta()::put);
121+
return Maybe.empty();
122+
}
123+
124+
/**
125+
* Discards a stash that {@link #beforeAgentCallback} never got to report, which happens when a
126+
* {@code beforeRunCallback} on another plugin halts the invocation before any agent runs.
127+
*
128+
* <p>Only a run that completes reaches this hook; {@link #onRunErrorCallback} clears the same
129+
* stash when one fails.
130+
*/
131+
@Override
132+
public Completable afterRunCallback(InvocationContext invocationContext) {
133+
PendingArtifactDelta.clear(invocationContext, getName());
134+
return Completable.complete();
135+
}
136+
137+
/**
138+
* Discards the stash when the invocation fails, the window {@link #afterRunCallback} never sees.
139+
*
140+
* <p>The versions cannot be reported from here: this hook is handed an {@link InvocationContext},
141+
* which carries no {@link com.google.adk.events.EventActions} to write an artifact delta to. The
142+
* clear is idempotent, so a run reaching both hooks is no different from one reaching either.
143+
*/
144+
@Override
145+
public Completable onRunErrorCallback(InvocationContext invocationContext, Throwable error) {
146+
PendingArtifactDelta.clear(invocationContext, getName());
147+
return Completable.complete();
148+
}
149+
150+
/** Saves one part if it carries inline data, leaving every other part untouched. */
151+
private Single<SavedPart> savePart(InvocationContext invocationContext, Part part, int index) {
152+
if (!hasInlineData(part)) {
153+
return Single.just(SavedPart.unchanged(part));
154+
}
155+
String fileName = resolveFileName(invocationContext, part, index);
156+
return invocationContext
157+
.artifactService()
158+
.saveArtifact(
159+
invocationContext.appName(),
160+
invocationContext.userId(),
161+
invocationContext.session().id(),
162+
fileName,
163+
part)
164+
.map(version -> SavedPart.saved(placeholderFor(fileName), fileName, version))
165+
.onErrorReturn(error -> keepOriginal(part, fileName, error));
166+
}
167+
168+
/** A failed save must not fail the invocation: the original part is passed through unchanged. */
169+
private SavedPart keepOriginal(Part part, String fileName, Throwable error) {
170+
logger.error("Failed to save artifact '{}'; keeping the original part.", fileName, error);
171+
return SavedPart.unchanged(part);
172+
}
173+
174+
/** Returns the rewritten message, or empty when no part was actually offloaded. */
175+
private Optional<Content> rebuildMessage(
176+
InvocationContext invocationContext, Content userMessage, List<SavedPart> results) {
177+
ImmutableMap<String, Integer> delta = toArtifactDelta(results);
178+
if (delta.isEmpty()) {
179+
return Optional.empty();
180+
}
181+
PendingArtifactDelta.stash(invocationContext, getName(), delta);
182+
ImmutableList<Part> parts = results.stream().map(SavedPart::part).collect(toImmutableList());
183+
return Optional.of(userMessage.toBuilder().parts(parts).build());
184+
}
185+
186+
private static ImmutableMap<String, Integer> toArtifactDelta(List<SavedPart> results) {
187+
return results.stream()
188+
.filter(SavedPart::isSaved)
189+
.collect(
190+
toImmutableMap(SavedPart::savedFileName, SavedPart::version, (older, newer) -> newer));
191+
}
192+
193+
private static String resolveFileName(InvocationContext invocationContext, Part part, int index) {
194+
return part.inlineData()
195+
.flatMap(Blob::displayName)
196+
.filter(displayName -> !displayName.isEmpty())
197+
.orElseGet(() -> GENERATED_FILE_NAME.formatted(invocationContext.invocationId(), index));
198+
}
199+
200+
private static Part placeholderFor(String fileName) {
201+
return Part.fromText(PLACEHOLDER_TEXT.formatted(fileName));
202+
}
203+
204+
private static boolean hasInlineData(Part part) {
205+
return part.inlineData().isPresent();
206+
}
207+
208+
/** One input part after the offload attempt: either untouched, or replaced by a placeholder. */
209+
private record SavedPart(Part part, Optional<String> fileName, int version) {
210+
211+
static SavedPart unchanged(Part part) {
212+
return new SavedPart(part, Optional.empty(), 0);
213+
}
214+
215+
static SavedPart saved(Part placeholder, String fileName, int version) {
216+
return new SavedPart(placeholder, Optional.of(fileName), version);
217+
}
218+
219+
boolean isSaved() {
220+
return fileName.isPresent();
221+
}
222+
223+
String savedFileName() {
224+
return fileName.orElseThrow();
225+
}
226+
}
227+
}

0 commit comments

Comments
 (0)