diff --git a/core/src/main/java/com/google/adk/runner/Runner.java b/core/src/main/java/com/google/adk/runner/Runner.java index 89e8e3c15..d58ca2e4d 100644 --- a/core/src/main/java/com/google/adk/runner/Runner.java +++ b/core/src/main/java/com/google/adk/runner/Runner.java @@ -344,9 +344,61 @@ public Completable close() { return Completable.mergeDelayError(completables); } + /** + * A user message prepared for persistence, together with the artifact saves it scheduled. + * + * @param message the message to append: a copy when inline blobs were replaced by placeholders, + * otherwise the original + * @param artifactSaves the saves to run before the event is appended + */ + private record OffloadedMessage(Content message, Completable artifactSaves) { + + /** Nothing to offload: append the caller's message as-is. */ + static OffloadedMessage unchanged(Content message) { + return new OffloadedMessage(message, Completable.complete()); + } + } + + /** + * Saves each inline blob in {@code message} as an artifact and returns a copy of the + * message in which every blob is replaced by a file name placeholder. + * + *

The given message is never modified: its parts list may be immutable, and a caller does not + * expect the message it passed to {@code runAsync} to be rewritten. + * + *

Only called when {@link #artifactService} is non-null. + */ + private OffloadedMessage offloadInputBlobs( + Session session, Content message, String invocationId) { + // Save each inline blob as an artifact and replace it with a file name placeholder in a + // copy of the parts list. The caller's message is never modified. + List parts = new ArrayList<>(message.parts().get()); + Completable saveArtifactsFlow = Completable.complete(); + for (int i = 0; i < parts.size(); i++) { + Part part = parts.get(i); + if (part.inlineData().isEmpty()) { + continue; + } + String fileName = "artifact_" + invocationId + "_" + i; + saveArtifactsFlow = + saveArtifactsFlow.andThen( + this.artifactService + .saveArtifact(this.appName, session.userId(), session.id(), fileName, part) + .ignoreElement()); + + parts.set( + i, Part.fromText("Uploaded file: " + fileName + ". It has been saved to the artifacts")); + } + return new OffloadedMessage( + message.toBuilder().parts(ImmutableList.copyOf(parts)).build(), saveArtifactsFlow); + } + /** * Appends a new user message to the session history with optional state delta. * + *

The given {@code newMessage} is never modified; when inline blobs are saved as artifacts, + * the appended event carries a copy in which the blob data is replaced by placeholders. + * * @throws IllegalArgumentException if message has no parts. */ private Single appendNewMessageToSession( @@ -357,38 +409,18 @@ private Single appendNewMessageToSession( @Nullable Map stateDelta) { checkArgument(newMessage.parts().isPresent(), "No parts in the new_message."); - Completable saveArtifactsFlow = Completable.complete(); - if (this.artifactService != null && saveInputBlobsAsArtifacts) { - // The runner directly saves the artifacts (if applicable) in the user message and replaces - // the artifact data with a file name placeholder. - for (int i = 0; i < newMessage.parts().get().size(); i++) { - Part part = newMessage.parts().get().get(i); - if (part.inlineData().isEmpty()) { - continue; - } - String fileName = "artifact_" + invocationContext.invocationId() + "_" + i; - saveArtifactsFlow = - saveArtifactsFlow.andThen( - this.artifactService - .saveArtifact(this.appName, session.userId(), session.id(), fileName, part) - .ignoreElement()); - - newMessage - .parts() - .get() - .set( - i, - Part.fromText( - "Uploaded file: " + fileName + ". It has been saved to the artifacts")); - } - } + OffloadedMessage offloaded = + this.artifactService != null && saveInputBlobsAsArtifacts + ? offloadInputBlobs(session, newMessage, invocationContext.invocationId()) + : OffloadedMessage.unchanged(newMessage); + // Appends only. We do not yield the event because it's not from the model. Event.Builder eventBuilder = Event.builder() .id(Event.generateEventId()) .invocationId(invocationContext.invocationId()) .author("user") - .content(newMessage); + .content(offloaded.message()); // Add state delta if provided if (stateDelta != null && !stateDelta.isEmpty()) { @@ -396,8 +428,9 @@ private Single appendNewMessageToSession( EventActions.builder().stateDelta(new ConcurrentHashMap<>(stateDelta)).build()); } - return saveArtifactsFlow.andThen( - this.sessionService.appendEvent(session, eventBuilder.build())); + return offloaded + .artifactSaves() + .andThen(this.sessionService.appendEvent(session, eventBuilder.build())); } /** See {@link #runAsync(String, String, Content, RunConfig, Map)}. */ diff --git a/core/src/test/java/com/google/adk/runner/RunnerTest.java b/core/src/test/java/com/google/adk/runner/RunnerTest.java index 38485a5a7..c6f1a3314 100644 --- a/core/src/test/java/com/google/adk/runner/RunnerTest.java +++ b/core/src/test/java/com/google/adk/runner/RunnerTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,6 +50,7 @@ import com.google.adk.apps.App; import com.google.adk.apps.ResumabilityConfig; import com.google.adk.artifacts.BaseArtifactService; +import com.google.adk.artifacts.InMemoryArtifactService; import com.google.adk.events.Event; import com.google.adk.flows.llmflows.Functions; import com.google.adk.models.LlmRequest; @@ -3042,6 +3043,233 @@ public void runner_executesSaveArtifactFlow() { assertThat(simplifyEvents(events.values())).containsExactly("test agent: from llm"); } + private static final String BLOB_MIME_TYPE = "example/octet-stream"; + private static final String BLOB_PAYLOAD = "blob payload"; + private static final String PLACEHOLDER_PREFIX = "Uploaded file: "; + + private static Part blobPart() { + return Part.fromBytes(BLOB_PAYLOAD.getBytes(UTF_8), BLOB_MIME_TYPE); + } + + /** + * A message whose parts list is immutable: {@code Content.Builder.parts(List)} stores the + * caller's list without copying it. + */ + private static Content immutablePartsMessage() { + return Content.builder() + .role("user") + .parts(ImmutableList.of(Part.fromText("hello"), blobPart())) + .build(); + } + + /** A message whose parts list genai itself collected into an {@code ImmutableList}. */ + private static Content partBuilderPartsMessage() { + return Content.builder() + .role("user") + .parts(Part.fromText("hello").toBuilder(), blobPart().toBuilder()) + .build(); + } + + /** + * A message whose parts list accepts {@code set}. Used where the assertion is that the runner + * leaves the caller's message alone: with an immutable list the runner could not have modified it + * either way, so only a mutable one distinguishes copying from rewriting in place. + */ + private static Content mutablePartsMessage() { + return Content.builder() + .role("user") + .parts(new ArrayList<>(ImmutableList.of(Part.fromText("hello"), blobPart()))) + .build(); + } + + private static RunConfig saveInputBlobs(boolean enabled) { + return RunConfig.builder().saveInputBlobsAsArtifacts(enabled).build(); + } + + private Runner runnerWithArtifactService(BaseArtifactService artifactService) { + return Runner.builder() + .app(App.builder().name("test").rootAgent(agent).build()) + .artifactService(artifactService) + .build(); + } + + private static ImmutableList artifactNames( + BaseArtifactService artifactService, String sessionId) { + return ImmutableList.copyOf( + artifactService.listArtifactKeys("test", "user", sessionId).blockingGet().filenames()); + } + + /** The parts of the user message that was actually appended to the session. */ + private static List appendedUserParts(Runner runner, String sessionId) { + Session stored = + runner + .sessionService() + .getSession("test", "user", sessionId, Optional.empty()) + .blockingGet(); + return stored.events().stream() + .filter(event -> event.author().equals("user")) + .findFirst() + .flatMap(Event::content) + .flatMap(Content::parts) + .orElseThrow(() -> new AssertionError("No user message was appended to the session.")); + } + + @Test + public void saveInputBlobsAsArtifacts_immutablePartsList_savesArtifactAndCompletes() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + + var events = + runner + .runAsync("user", testSession.id(), immutablePartsMessage(), saveInputBlobs(true)) + .test(); + + events.assertComplete(); + assertThat(artifactNames(artifactService, testSession.id())).hasSize(1); + } + + @Test + public void saveInputBlobsAsArtifacts_partBuilderPartsList_savesArtifactAndCompletes() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + + var events = + runner + .runAsync("user", testSession.id(), partBuilderPartsMessage(), saveInputBlobs(true)) + .test(); + + events.assertComplete(); + assertThat(artifactNames(artifactService, testSession.id())).hasSize(1); + } + + @Test + public void saveInputBlobsAsArtifacts_doesNotModifyCallerMessage() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + Content callerMessage = mutablePartsMessage(); + + var events = + runner.runAsync("user", testSession.id(), callerMessage, saveInputBlobs(true)).test(); + + events.assertComplete(); + assertThat(callerMessage.parts().get().get(1).inlineData()).isPresent(); + assertThat(callerMessage.parts().get().get(1).text()).isEmpty(); + } + + @Test + public void saveInputBlobsAsArtifacts_appendedEventReplacesBlobWithPlaceholder() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + + var unused = + runner + .runAsync("user", testSession.id(), immutablePartsMessage(), saveInputBlobs(true)) + .test(); + + List appended = appendedUserParts(runner, testSession.id()); + assertThat(appended).hasSize(2); + assertThat(appended.get(1).inlineData()).isEmpty(); + assertThat(appended.get(1).text().orElse("")).startsWith(PLACEHOLDER_PREFIX); + } + + @Test + public void saveInputBlobsAsArtifacts_storesBlobVerbatim() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + + var events = + runner + .runAsync("user", testSession.id(), immutablePartsMessage(), saveInputBlobs(true)) + .test(); + + // Assert completion too: the artifact is written eagerly, before the parts list is rewritten, + // so + // without this the test would pass even on a run that died partway through. + events.assertComplete(); + String fileName = artifactNames(artifactService, testSession.id()).get(0); + Part stored = + artifactService.loadArtifact("test", "user", testSession.id(), fileName).blockingGet(); + assertThat(new String(stored.inlineData().get().data().get(), UTF_8)).isEqualTo(BLOB_PAYLOAD); + } + + @Test + public void saveInputBlobsAsArtifacts_textOnlyMessage_passesThroughUnchanged() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + Content callerMessage = Content.fromParts(Part.fromText("hello")); + + var events = + runner.runAsync("user", testSession.id(), callerMessage, saveInputBlobs(true)).test(); + + events.assertComplete(); + assertThat(artifactNames(artifactService, testSession.id())).isEmpty(); + List appended = appendedUserParts(runner, testSession.id()); + assertThat(appended).hasSize(1); + assertThat(appended.get(0).text()).hasValue("hello"); + assertThat(callerMessage.parts().get().get(0).text()).hasValue("hello"); + } + + @Test + public void saveInputBlobsAsArtifacts_disabledWithTextOnlyMessage_passesThroughUnchanged() { + // The default path for every ordinary agent call: no blob, and the option at its default false. + // The runner must not touch the message at all. + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + Content callerMessage = Content.fromParts(Part.fromText("hello")); + + var events = + runner.runAsync("user", testSession.id(), callerMessage, saveInputBlobs(false)).test(); + + events.assertComplete(); + assertThat(artifactNames(artifactService, testSession.id())).isEmpty(); + List appended = appendedUserParts(runner, testSession.id()); + assertThat(appended).hasSize(1); + assertThat(appended.get(0).text()).hasValue("hello"); + assertThat(callerMessage.parts().get().get(0).text()).hasValue("hello"); + } + + @Test + public void saveInputBlobsAsArtifacts_disabled_keepsBlobAndSavesNothing() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + + var events = + runner + .runAsync("user", testSession.id(), immutablePartsMessage(), saveInputBlobs(false)) + .test(); + + events.assertComplete(); + assertThat(artifactNames(artifactService, testSession.id())).isEmpty(); + assertThat(appendedUserParts(runner, testSession.id()).get(1).inlineData()).isPresent(); + } + + @Test + public void saveInputBlobsAsArtifacts_fromPartsConstruction_savesArtifactAndCompletes() { + BaseArtifactService artifactService = new InMemoryArtifactService(); + Runner runner = runnerWithArtifactService(artifactService); + Session testSession = runner.sessionService().createSession("test", "user").blockingGet(); + + Content fromPartsMessage = Content.fromParts(Part.fromText("hello"), blobPart()); + + var events = + runner.runAsync("user", testSession.id(), fromPartsMessage, saveInputBlobs(true)).test(); + + events.assertComplete(); + assertThat(artifactNames(artifactService, testSession.id())).hasSize(1); + List appended = appendedUserParts(runner, testSession.id()); + assertThat(appended).hasSize(2); + assertThat(appended.get(1).inlineData()).isEmpty(); + assertThat(appended.get(1).text().orElse("")).startsWith(PLACEHOLDER_PREFIX); + } + @Test public void runAsync_partialEvent_streamedButNotPassedToSessionService() { // The model streams a partial event followed by the final aggregated event in one turn.