Skip to content

Commit 5bbdf57

Browse files
committed
feat(plugins): port SaveFilesAsArtifactsPlugin from adk-python
1 parent e5aba3a commit 5bbdf57

4 files changed

Lines changed: 998 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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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. Artifacts with the same name overwrite each other, and
50+
* a name prefixed with {@code user:} is scoped to the user rather than the session.
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>This does not cover an invocation that <em>fails</em> in that same window: {@code
129+
* afterRunCallback} only runs after successful completion, and adk-java has no agent/run error
130+
* callback to clean up from — see <a
131+
* href="https://github.com/google/adk-java/issues/1316">#1316</a>. A failed upload therefore
132+
* leaves one inert entry in the in-memory session state; it is {@code temp:}-prefixed so it never
133+
* reaches persisted state, and invocation-scoped so no later invocation can read it.
134+
*/
135+
@Override
136+
public Completable afterRunCallback(InvocationContext invocationContext) {
137+
PendingArtifactDelta.clear(invocationContext, getName());
138+
return Completable.complete();
139+
}
140+
141+
/** Saves one part if it carries inline data, leaving every other part untouched. */
142+
private Single<SavedPart> savePart(InvocationContext invocationContext, Part part, int index) {
143+
if (!hasInlineData(part)) {
144+
return Single.just(SavedPart.unchanged(part));
145+
}
146+
String fileName = resolveFileName(invocationContext, part, index);
147+
return invocationContext
148+
.artifactService()
149+
.saveArtifact(
150+
invocationContext.appName(),
151+
invocationContext.userId(),
152+
invocationContext.session().id(),
153+
fileName,
154+
part)
155+
.map(version -> SavedPart.saved(placeholderFor(fileName), fileName, version))
156+
.onErrorReturn(error -> keepOriginal(part, fileName, error));
157+
}
158+
159+
/** A failed save must not fail the invocation: the original part is passed through unchanged. */
160+
private SavedPart keepOriginal(Part part, String fileName, Throwable error) {
161+
logger.error("Failed to save artifact '{}'; keeping the original part.", fileName, error);
162+
return SavedPart.unchanged(part);
163+
}
164+
165+
/** Returns the rewritten message, or empty when no part was actually offloaded. */
166+
private Optional<Content> rebuildMessage(
167+
InvocationContext invocationContext, Content userMessage, List<SavedPart> results) {
168+
ImmutableMap<String, Integer> delta = toArtifactDelta(results);
169+
if (delta.isEmpty()) {
170+
return Optional.empty();
171+
}
172+
PendingArtifactDelta.stash(invocationContext, getName(), delta);
173+
ImmutableList<Part> parts = results.stream().map(SavedPart::part).collect(toImmutableList());
174+
return Optional.of(userMessage.toBuilder().parts(parts).build());
175+
}
176+
177+
private static ImmutableMap<String, Integer> toArtifactDelta(List<SavedPart> results) {
178+
return results.stream()
179+
.filter(SavedPart::isSaved)
180+
.collect(
181+
toImmutableMap(SavedPart::savedFileName, SavedPart::version, (older, newer) -> newer));
182+
}
183+
184+
private static String resolveFileName(InvocationContext invocationContext, Part part, int index) {
185+
return part.inlineData()
186+
.flatMap(Blob::displayName)
187+
.filter(displayName -> !displayName.isEmpty())
188+
.orElseGet(() -> GENERATED_FILE_NAME.formatted(invocationContext.invocationId(), index));
189+
}
190+
191+
private static Part placeholderFor(String fileName) {
192+
return Part.fromText(PLACEHOLDER_TEXT.formatted(fileName));
193+
}
194+
195+
private static boolean hasInlineData(Part part) {
196+
return part.inlineData().isPresent();
197+
}
198+
199+
/** One input part after the offload attempt: either untouched, or replaced by a placeholder. */
200+
private record SavedPart(Part part, Optional<String> fileName, int version) {
201+
202+
static SavedPart unchanged(Part part) {
203+
return new SavedPart(part, Optional.empty(), 0);
204+
}
205+
206+
static SavedPart saved(Part placeholder, String fileName, int version) {
207+
return new SavedPart(placeholder, Optional.of(fileName), version);
208+
}
209+
210+
boolean isSaved() {
211+
return fileName.isPresent();
212+
}
213+
214+
String savedFileName() {
215+
return fileName.orElseThrow();
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)