Skip to content

Commit d5f28e6

Browse files
committed
fix(runner): build a new message when saving input blobs instead of writing into the caller's Content
1 parent a3df463 commit d5f28e6

2 files changed

Lines changed: 286 additions & 11 deletions

File tree

core/src/main/java/com/google/adk/runner/Runner.java

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,9 @@ public Completable close() {
347347
/**
348348
* Appends a new user message to the session history with optional state delta.
349349
*
350+
* <p>{@code newMessage} is never modified; when inline blobs are saved as artifacts, the appended
351+
* event carries a copy in which the blob data is replaced by placeholders.
352+
*
350353
* @throws IllegalArgumentException if message has no parts.
351354
*/
352355
private Single<Event> appendNewMessageToSession(
@@ -357,12 +360,16 @@ private Single<Event> appendNewMessageToSession(
357360
@Nullable Map<String, Object> stateDelta) {
358361
checkArgument(newMessage.parts().isPresent(), "No parts in the new_message.");
359362

363+
Content messageToAppend = newMessage;
360364
Completable saveArtifactsFlow = Completable.complete();
361365
if (this.artifactService != null && saveInputBlobsAsArtifacts) {
362366
// The runner directly saves the artifacts (if applicable) in the user message and replaces
363-
// the artifact data with a file name placeholder.
364-
for (int i = 0; i < newMessage.parts().get().size(); i++) {
365-
Part part = newMessage.parts().get().get(i);
367+
// the artifact data with a file name placeholder. The rewrite happens on a copy of the parts
368+
// list: the caller's list may be immutable, and the caller does not expect the message it
369+
// passed to runAsync to be modified.
370+
List<Part> parts = new ArrayList<>(newMessage.parts().get());
371+
for (int i = 0; i < parts.size(); i++) {
372+
Part part = parts.get(i);
366373
if (part.inlineData().isEmpty()) {
367374
continue;
368375
}
@@ -373,22 +380,19 @@ private Single<Event> appendNewMessageToSession(
373380
.saveArtifact(this.appName, session.userId(), session.id(), fileName, part)
374381
.ignoreElement());
375382

376-
newMessage
377-
.parts()
378-
.get()
379-
.set(
380-
i,
381-
Part.fromText(
382-
"Uploaded file: " + fileName + ". It has been saved to the artifacts"));
383+
parts.set(
384+
i,
385+
Part.fromText("Uploaded file: " + fileName + ". It has been saved to the artifacts"));
383386
}
387+
messageToAppend = newMessage.toBuilder().parts(ImmutableList.copyOf(parts)).build();
384388
}
385389
// Appends only. We do not yield the event because it's not from the model.
386390
Event.Builder eventBuilder =
387391
Event.builder()
388392
.id(Event.generateEventId())
389393
.invocationId(invocationContext.invocationId())
390394
.author("user")
391-
.content(newMessage);
395+
.content(messageToAppend);
392396

393397
// Add state delta if provided
394398
if (stateDelta != null && !stateDelta.isEmpty()) {

core/src/test/java/com/google/adk/runner/RunnerTest.java

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import static com.google.adk.testing.TestUtils.createTestLlm;
2424
import static com.google.adk.testing.TestUtils.createTextLlmResponse;
2525
import static com.google.adk.testing.TestUtils.simplifyEvents;
26+
import static com.google.common.collect.ImmutableList.toImmutableList;
2627
import static com.google.common.truth.Truth.assertThat;
2728
import static java.nio.charset.StandardCharsets.UTF_8;
2829
import static java.util.Arrays.stream;
@@ -50,6 +51,7 @@
5051
import com.google.adk.apps.App;
5152
import com.google.adk.apps.ResumabilityConfig;
5253
import com.google.adk.artifacts.BaseArtifactService;
54+
import com.google.adk.artifacts.InMemoryArtifactService;
5355
import com.google.adk.events.Event;
5456
import com.google.adk.flows.llmflows.Functions;
5557
import com.google.adk.models.LlmRequest;
@@ -3042,6 +3044,275 @@ public void runner_executesSaveArtifactFlow() {
30423044
assertThat(simplifyEvents(events.values())).containsExactly("test agent: from llm");
30433045
}
30443046

3047+
private static final String BLOB_MIME_TYPE = "example/octet-stream";
3048+
private static final String BLOB_PAYLOAD = "blob payload";
3049+
private static final String PLACEHOLDER_FORMAT =
3050+
"Uploaded file: %s. It has been saved to the artifacts";
3051+
3052+
private static Part blobPart() {
3053+
return Part.fromBytes(BLOB_PAYLOAD.getBytes(UTF_8), BLOB_MIME_TYPE);
3054+
}
3055+
3056+
/** The text the runner substitutes for the blob it offloaded to {@code fileName}. */
3057+
private static String placeholderFor(String fileName) {
3058+
return PLACEHOLDER_FORMAT.formatted(fileName);
3059+
}
3060+
3061+
/**
3062+
* A message whose parts list is immutable: {@code Content.Builder.parts(List)} stores the
3063+
* caller's list without copying it.
3064+
*/
3065+
private static Content immutablePartsMessage() {
3066+
return Content.builder()
3067+
.role("user")
3068+
.parts(ImmutableList.of(Part.fromText("hello"), blobPart()))
3069+
.build();
3070+
}
3071+
3072+
/** A message whose parts list genai itself collected into an {@code ImmutableList}. */
3073+
private static Content partBuilderPartsMessage() {
3074+
return Content.builder()
3075+
.role("user")
3076+
.parts(Part.fromText("hello").toBuilder(), blobPart().toBuilder())
3077+
.build();
3078+
}
3079+
3080+
/**
3081+
* A message whose parts list accepts {@code set}. Used where the assertion is that the runner
3082+
* leaves the caller's message alone: with an immutable list the runner could not have modified it
3083+
* either way, so only a mutable one distinguishes copying from rewriting in place.
3084+
*/
3085+
private static Content mutablePartsMessage() {
3086+
return Content.builder()
3087+
.role("user")
3088+
.parts(new ArrayList<>(ImmutableList.of(Part.fromText("hello"), blobPart())))
3089+
.build();
3090+
}
3091+
3092+
/**
3093+
* A message carrying two blobs, at part indices 1 and 2. The runner names each artifact after the
3094+
* index of the part it came from, so only a message with more than one blob distinguishes that
3095+
* from a running counter.
3096+
*/
3097+
private static Content twoBlobsMessage() {
3098+
return Content.builder()
3099+
.role("user")
3100+
.parts(ImmutableList.of(Part.fromText("hello"), blobPart(), blobPart()))
3101+
.build();
3102+
}
3103+
3104+
private static RunConfig saveInputBlobs(boolean enabled) {
3105+
return RunConfig.builder().saveInputBlobsAsArtifacts(enabled).build();
3106+
}
3107+
3108+
/**
3109+
* Points {@link #runner} at a runner backed by a fresh {@link InMemoryArtifactService}, with a
3110+
* fresh {@link #session} on it. What the service stored is read back with {@link #artifactNames}
3111+
* and {@link Runner#artifactService()}.
3112+
*/
3113+
private void useRunnerWithArtifactService() {
3114+
this.runner =
3115+
Runner.builder()
3116+
.app(App.builder().name("test").rootAgent(agent).build())
3117+
.artifactService(new InMemoryArtifactService())
3118+
.build();
3119+
this.session = this.runner.sessionService().createSession("test", "user").blockingGet();
3120+
}
3121+
3122+
/** The names of the artifacts saved for {@link #session}. */
3123+
private ImmutableList<String> artifactNames() {
3124+
return ImmutableList.copyOf(
3125+
runner
3126+
.artifactService()
3127+
.listArtifactKeys("test", "user", session.id())
3128+
.blockingGet()
3129+
.filenames());
3130+
}
3131+
3132+
/** The name of the single saved artifact whose file name ends in {@code suffix}. */
3133+
private String artifactNameEndingIn(String suffix) {
3134+
ImmutableList<String> matches =
3135+
artifactNames().stream().filter(name -> name.endsWith(suffix)).collect(toImmutableList());
3136+
assertThat(matches).hasSize(1);
3137+
return matches.get(0);
3138+
}
3139+
3140+
/** The user message that was actually appended to the session. */
3141+
private Content appendedUserMessage() {
3142+
Session stored =
3143+
runner
3144+
.sessionService()
3145+
.getSession("test", "user", session.id(), Optional.empty())
3146+
.blockingGet();
3147+
return stored.events().stream()
3148+
.filter(event -> event.author().equals("user"))
3149+
.findFirst()
3150+
.flatMap(Event::content)
3151+
.orElseThrow(() -> new AssertionError("No user message was appended to the session."));
3152+
}
3153+
3154+
/** The parts of the user message that was actually appended to the session. */
3155+
private List<Part> appendedUserParts() {
3156+
return appendedUserMessage()
3157+
.parts()
3158+
.orElseThrow(() -> new AssertionError("The appended user message has no parts."));
3159+
}
3160+
3161+
/** Asserts the run reached the model and emitted the agent's reply. */
3162+
private static void assertAgentReplied(TestSubscriber<Event> events) {
3163+
events.assertComplete();
3164+
assertThat(simplifyEvents(events.values())).containsExactly("test agent: from llm");
3165+
}
3166+
3167+
@Test
3168+
public void saveInputBlobsAsArtifacts_immutablePartsList_savesArtifactAndCompletes() {
3169+
useRunnerWithArtifactService();
3170+
3171+
var events =
3172+
runner.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(true)).test();
3173+
3174+
assertAgentReplied(events);
3175+
assertThat(artifactNames()).hasSize(1);
3176+
}
3177+
3178+
@Test
3179+
public void saveInputBlobsAsArtifacts_partBuilderPartsList_savesArtifactAndCompletes() {
3180+
useRunnerWithArtifactService();
3181+
3182+
var events =
3183+
runner
3184+
.runAsync("user", session.id(), partBuilderPartsMessage(), saveInputBlobs(true))
3185+
.test();
3186+
3187+
assertAgentReplied(events);
3188+
assertThat(artifactNames()).hasSize(1);
3189+
}
3190+
3191+
@Test
3192+
public void saveInputBlobsAsArtifacts_doesNotModifyCallerMessage() {
3193+
useRunnerWithArtifactService();
3194+
Content callerMessage = mutablePartsMessage();
3195+
3196+
var events = runner.runAsync("user", session.id(), callerMessage, saveInputBlobs(true)).test();
3197+
3198+
assertAgentReplied(events);
3199+
assertThat(artifactNames()).hasSize(1);
3200+
assertThat(callerMessage.parts().get().get(1).inlineData()).isPresent();
3201+
assertThat(callerMessage.parts().get().get(1).text()).isEmpty();
3202+
}
3203+
3204+
@Test
3205+
public void saveInputBlobsAsArtifacts_appendedEventReplacesBlobWithPlaceholder() {
3206+
useRunnerWithArtifactService();
3207+
3208+
var events =
3209+
runner.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(true)).test();
3210+
3211+
assertAgentReplied(events);
3212+
// The appended message is a copy of the caller's, so the role has to survive the copy.
3213+
assertThat(appendedUserMessage().role()).hasValue("user");
3214+
List<Part> appended = appendedUserParts();
3215+
assertThat(appended).hasSize(2);
3216+
assertThat(appended.get(0).text()).hasValue("hello");
3217+
assertThat(appended.get(1).inlineData()).isEmpty();
3218+
assertThat(appended.get(1).text()).hasValue(placeholderFor(artifactNames().get(0)));
3219+
}
3220+
3221+
@Test
3222+
public void saveInputBlobsAsArtifacts_twoBlobs_namesEachArtifactAfterItsPartIndex() {
3223+
useRunnerWithArtifactService();
3224+
3225+
var events =
3226+
runner.runAsync("user", session.id(), twoBlobsMessage(), saveInputBlobs(true)).test();
3227+
3228+
assertAgentReplied(events);
3229+
assertThat(artifactNames()).hasSize(2);
3230+
List<Part> appended = appendedUserParts();
3231+
assertThat(appended).hasSize(3);
3232+
assertThat(appended.get(1).text()).hasValue(placeholderFor(artifactNameEndingIn("_1")));
3233+
assertThat(appended.get(2).text()).hasValue(placeholderFor(artifactNameEndingIn("_2")));
3234+
}
3235+
3236+
@Test
3237+
public void saveInputBlobsAsArtifacts_storesBlobVerbatim() {
3238+
useRunnerWithArtifactService();
3239+
3240+
var events =
3241+
runner.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(true)).test();
3242+
3243+
assertAgentReplied(events);
3244+
assertThat(artifactNames()).hasSize(1);
3245+
Part stored =
3246+
runner
3247+
.artifactService()
3248+
.loadArtifact("test", "user", session.id(), artifactNames().get(0))
3249+
.blockingGet();
3250+
assertThat(new String(stored.inlineData().get().data().get(), UTF_8)).isEqualTo(BLOB_PAYLOAD);
3251+
assertThat(stored.inlineData().get().mimeType()).hasValue(BLOB_MIME_TYPE);
3252+
}
3253+
3254+
@Test
3255+
public void saveInputBlobsAsArtifacts_textOnlyMessage_passesThroughUnchanged() {
3256+
useRunnerWithArtifactService();
3257+
Content callerMessage = Content.fromParts(Part.fromText("hello"));
3258+
3259+
var events = runner.runAsync("user", session.id(), callerMessage, saveInputBlobs(true)).test();
3260+
3261+
assertAgentReplied(events);
3262+
assertThat(artifactNames()).isEmpty();
3263+
List<Part> appended = appendedUserParts();
3264+
assertThat(appended).hasSize(1);
3265+
assertThat(appended.get(0).text()).hasValue("hello");
3266+
assertThat(callerMessage.parts().get().get(0).text()).hasValue("hello");
3267+
}
3268+
3269+
@Test
3270+
public void saveInputBlobsAsArtifacts_disabledWithTextOnlyMessage_passesThroughUnchanged() {
3271+
// The default path for every ordinary agent call: no blob, and the option at its default false.
3272+
// The runner must not touch the message at all.
3273+
useRunnerWithArtifactService();
3274+
Content callerMessage = Content.fromParts(Part.fromText("hello"));
3275+
3276+
var events = runner.runAsync("user", session.id(), callerMessage, saveInputBlobs(false)).test();
3277+
3278+
assertAgentReplied(events);
3279+
assertThat(artifactNames()).isEmpty();
3280+
List<Part> appended = appendedUserParts();
3281+
assertThat(appended).hasSize(1);
3282+
assertThat(appended.get(0).text()).hasValue("hello");
3283+
assertThat(callerMessage.parts().get().get(0).text()).hasValue("hello");
3284+
}
3285+
3286+
@Test
3287+
public void saveInputBlobsAsArtifacts_disabled_keepsBlobAndSavesNothing() {
3288+
useRunnerWithArtifactService();
3289+
3290+
var events =
3291+
runner
3292+
.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(false))
3293+
.test();
3294+
3295+
assertAgentReplied(events);
3296+
assertThat(artifactNames()).isEmpty();
3297+
assertThat(appendedUserParts().get(1).inlineData()).isPresent();
3298+
}
3299+
3300+
@Test
3301+
public void saveInputBlobsAsArtifacts_fromPartsConstruction_savesArtifactAndCompletes() {
3302+
useRunnerWithArtifactService();
3303+
Content fromPartsMessage = Content.fromParts(Part.fromText("hello"), blobPart());
3304+
3305+
var events =
3306+
runner.runAsync("user", session.id(), fromPartsMessage, saveInputBlobs(true)).test();
3307+
3308+
assertAgentReplied(events);
3309+
assertThat(artifactNames()).hasSize(1);
3310+
List<Part> appended = appendedUserParts();
3311+
assertThat(appended).hasSize(2);
3312+
assertThat(appended.get(1).inlineData()).isEmpty();
3313+
assertThat(appended.get(1).text()).hasValue(placeholderFor(artifactNames().get(0)));
3314+
}
3315+
30453316
@Test
30463317
public void runAsync_partialEvent_streamedButNotPassedToSessionService() {
30473318
// The model streams a partial event followed by the final aggregated event in one turn.

0 commit comments

Comments
 (0)