Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 61 additions & 28 deletions core/src/main/java/com/google/adk/runner/Runner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>copy</em> of the
* message in which every blob is replaced by a file name placeholder.
*
* <p>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.
*
* <p>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<Part> 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.
*
* <p>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<Event> appendNewMessageToSession(
Expand All @@ -357,47 +409,28 @@ private Single<Event> appendNewMessageToSession(
@Nullable Map<String, Object> 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()) {
eventBuilder.actions(
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)}. */
Expand Down
230 changes: 229 additions & 1 deletion core/src/test/java/com/google/adk/runner/RunnerTest.java
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<Part> 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<Part> 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<Part> 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<Part> 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<Part> 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.
Expand Down