eventOpt;
+ try {
+ eventOpt = ResponseConverter.clientEventToEvent(clientEvent, invocationContext);
+ } catch (Throwable t) {
+ logger.warn("Failed to convert A2A event", t);
+ handleError("Failed to convert the remote agent's response", t);
+ if (!dispatchingSynchronously || Thread.currentThread() != subscribeThread) {
+ // Delivered by the transport rather than from inside sendMessage: rethrow so the
+ // transport tears the connection down. Synchronous dispatch is the one case where
+ // Flowable.create has already failed the flow, and rethrowing into it would only add
+ // an undeliverable via RxJavaPlugins.
+ throw t;
+ }
+ return;
+ }
+ emit(clientEvent, eventOpt);
+ }
+
+ /**
+ * Emits a converted event.
+ *
+ * Outside the conversion guard above only because of the {@code emitter} calls: RxJava's
+ * contract is that a downstream {@code onNext} does not throw, so such a throw is the
+ * subscriber's bug and must not be fed back to that same subscriber as {@code onError}. The
+ * surrounding aggregation bookkeeping is ADK's own; none of it throws on peer input today.
+ */
+ private void emit(ClientEvent clientEvent, Optional eventOpt) {
eventOpt.ifPresent(
event -> {
addMetadata(event, clientEvent);
@@ -522,13 +581,15 @@ private Event createAggregatedEvent(Content content, @Nullable ClientEvent trigg
}
private static boolean isCompleted(ClientEvent event) {
- TaskState executionState = TaskState.UNKNOWN;
+ TaskState state;
if (event instanceof TaskEvent taskEvent) {
- executionState = taskEvent.getTask().getStatus().state();
+ state = taskEvent.getTask().getStatus().state();
} else if (event instanceof TaskUpdateEvent updateEvent) {
- executionState = updateEvent.getTask().getStatus().state();
+ state = updateEvent.getTask().getStatus().state();
+ } else {
+ return false;
}
- return executionState.equals(TaskState.COMPLETED);
+ return state.isFinal() || state == TaskState.INPUT_REQUIRED || state == TaskState.AUTH_REQUIRED;
}
private static ImmutableList eventParts(Event event) {
diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java b/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
index c8d20dbdd..ef9318a26 100644
--- a/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
+++ b/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
@@ -150,7 +150,9 @@ private static Optional handleTaskUpdate(
return messageToEvent(value, context, PENDING_STATES.contains(taskState));
});
- if (statusEvent.isFinal()) {
+ if (statusEvent.isFinal()
+ || taskState == TaskState.INPUT_REQUIRED
+ || taskState == TaskState.AUTH_REQUIRED) {
messageEvent =
messageEvent
.map(Event::toBuilder)
@@ -256,7 +258,9 @@ public static Event taskToEvent(Task task, InvocationContext invocationContext)
ImmutableList finalParts = genaiParts.build();
boolean isFinal =
- task.getStatus().state().isFinal() || task.getStatus().state() == TaskState.INPUT_REQUIRED;
+ task.getStatus().state().isFinal()
+ || task.getStatus().state() == TaskState.INPUT_REQUIRED
+ || task.getStatus().state() == TaskState.AUTH_REQUIRED;
if (finalParts.isEmpty() && !isFinal) {
return emptyEvent(invocationContext);
@@ -264,7 +268,8 @@ public static Event taskToEvent(Task task, InvocationContext invocationContext)
if (!finalParts.isEmpty()) {
eventBuilder.content(fromModelParts(finalParts));
}
- if (task.getStatus().state() == TaskState.INPUT_REQUIRED) {
+ if (task.getStatus().state() == TaskState.INPUT_REQUIRED
+ || task.getStatus().state() == TaskState.AUTH_REQUIRED) {
eventBuilder.longRunningToolIds(longRunningToolIds.build());
}
eventBuilder.turnComplete(isFinal);
diff --git a/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java b/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java
index 8d6c9b062..8edc25684 100644
--- a/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java
+++ b/a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java
@@ -18,6 +18,7 @@
import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
@@ -25,6 +26,7 @@
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
+import com.google.adk.a2a.common.A2AClientError;
import com.google.adk.a2a.common.A2AMetadata;
import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.CallbackContext;
@@ -44,6 +46,7 @@
import com.google.genai.types.Part;
import io.a2a.client.Client;
import io.a2a.client.ClientEvent;
+import io.a2a.client.MessageEvent;
import io.a2a.client.TaskEvent;
import io.a2a.client.TaskUpdateEvent;
import io.a2a.spec.AgentCapabilities;
@@ -51,6 +54,7 @@
import io.a2a.spec.Artifact;
import io.a2a.spec.DataPart;
import io.a2a.spec.FilePart;
+import io.a2a.spec.FileWithBytes;
import io.a2a.spec.FileWithUri;
import io.a2a.spec.Message;
import io.a2a.spec.Task;
@@ -61,11 +65,17 @@
import io.a2a.spec.TextPart;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Maybe;
+import io.reactivex.rxjava3.plugins.RxJavaPlugins;
+import io.reactivex.rxjava3.subscribers.TestSubscriber;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.junit.Before;
@@ -300,6 +310,125 @@ public void runAsync_handlesTasksWithMultipartArtifact() {
assertResponseMetadata(events.get(0));
}
+ @Test
+ public void runAsync_whenConversionThrowsOnCallingThread_reportsError() {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(consumer -> consumer.accept(unconvertibleEvent(), agentCard));
+
+ agent
+ .runAsync(invocationContext)
+ .test()
+ .awaitDone(5, SECONDS)
+ .assertError(A2AClientError.class)
+ .assertError(e -> e.getCause() instanceof IllegalArgumentException);
+ }
+
+ @Test
+ public void runAsync_whenConversionThrowsOnCallingThread_doesNotSignalTwice() {
+ List undeliverable = Collections.synchronizedList(new ArrayList<>());
+ io.reactivex.rxjava3.functions.Consumer super Throwable> previousHandler =
+ RxJavaPlugins.getErrorHandler();
+ RxJavaPlugins.setErrorHandler(undeliverable::add);
+ try {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(consumer -> consumer.accept(unconvertibleEvent(), agentCard));
+
+ agent
+ .runAsync(invocationContext)
+ .test()
+ .awaitDone(5, SECONDS)
+ .assertError(A2AClientError.class);
+ } finally {
+ // Process-global; reset before asserting so a failure cannot leak it into the rest of the
+ // suite.
+ RxJavaPlugins.setErrorHandler(previousHandler);
+ }
+ // Rethrowing on the subscribe thread would land here via FlowableCreate.
+ assertThat(undeliverable).isEmpty();
+ }
+
+ @Test
+ public void runAsync_whenConversionThrowsOnSubscribeThreadAfterSendMessage_rethrows() {
+ RemoteA2AAgent agent = createAgent();
+ AtomicReference> captured = new AtomicReference<>();
+ mockStreamResponse(captured::set); // capture, do not invoke
+
+ TestSubscriber subscriber = agent.runAsync(invocationContext).test();
+
+ // Subscribe thread, but sendMessage has already returned: the transport still needs the throw.
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> captured.get().accept(unconvertibleEvent(), agentCard));
+ subscriber.assertError(A2AClientError.class);
+ }
+
+ @Test
+ public void runAsync_whenConversionThrowsOnTransportThreadDuringSendMessage_rethrows() {
+ RemoteA2AAgent agent = createAgent();
+ AtomicReference escaped = new AtomicReference<>();
+ mockStreamResponse(
+ consumer -> {
+ Thread thread = new Thread(() -> consumer.accept(unconvertibleEvent(), agentCard));
+ thread.setName("a2a-fake-transport");
+ thread.setUncaughtExceptionHandler((t, e) -> escaped.set(e));
+ thread.start();
+ try {
+ thread.join(); // deliver while sendMessage is still on the stack
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+
+ agent
+ .runAsync(invocationContext)
+ .test()
+ .awaitDone(5, SECONDS)
+ .assertError(A2AClientError.class);
+ assertThat(escaped.get()).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ public void runAsync_whenConversionThrowsOnTransportThread_failsStream()
+ throws InterruptedException {
+ RemoteA2AAgent agent = createAgent();
+ CountDownLatch delivered = new CountDownLatch(1);
+ // Released once subscribe has returned, so delivery is deterministically *after* sendMessage.
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicReference deliveryThread = new AtomicReference<>();
+ AtomicReference escaped = new AtomicReference<>();
+ mockStreamResponse(
+ consumer -> {
+ Thread thread =
+ new Thread(
+ () -> {
+ try {
+ release.await();
+ consumer.accept(unconvertibleEvent(), agentCard);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ delivered.countDown();
+ }
+ });
+ thread.setName("a2a-fake-transport");
+ thread.setUncaughtExceptionHandler((t, e) -> escaped.set(e));
+ thread.start();
+ deliveryThread.set(thread);
+ });
+
+ TestSubscriber subscriber = agent.runAsync(invocationContext).test();
+ release.countDown();
+ assertThat(delivered.await(5, SECONDS)).isTrue();
+
+ subscriber
+ .awaitDone(5, SECONDS)
+ .assertError(A2AClientError.class)
+ .assertError(e -> e.getCause() instanceof IllegalArgumentException);
+ deliveryThread.get().join();
+ // The failure must also leave the handler, so the transport can tear the connection down.
+ assertThat(escaped.get()).isInstanceOf(IllegalArgumentException.class);
+ }
+
@Test
public void runAsync_handlesNonFinalStatusUpdatesAsThoughts() {
RemoteA2AAgent agent = createAgent();
@@ -761,6 +890,47 @@ private ClientEvent createFinalEvent(String text) {
return createTestEvent(new TextPart(text), TaskState.COMPLETED, false, false);
}
+ private ClientEvent createTerminalStatusUpdateEvent(String message, TaskState state) {
+ Task task =
+ new Task.Builder()
+ .id("task-id-1")
+ .contextId("context-1")
+ .status(new TaskStatus(state))
+ .build();
+ TaskStatusUpdateEvent statusUpdate =
+ new TaskStatusUpdateEvent.Builder()
+ .taskId("task-id-1")
+ .contextId("context-1")
+ .status(
+ new TaskStatus(
+ state,
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(new TextPart(message)))
+ .build(),
+ null))
+ .build();
+ return new TaskUpdateEvent(task, statusUpdate);
+ }
+
+ private ClientEvent createFailedEvent(String errorMessage) {
+ return createTerminalStatusUpdateEvent(errorMessage, TaskState.FAILED);
+ }
+
+ private ClientEvent createCanceledEvent(String message) {
+ return createTerminalStatusUpdateEvent(message, TaskState.CANCELED);
+ }
+
+ private ClientEvent createMessageEvent(String text) {
+ Message message =
+ new Message.Builder()
+ .messageId("msg-id-1")
+ .role(Message.Role.AGENT)
+ .parts(new TextPart(text))
+ .build();
+ return new MessageEvent(message);
+ }
+
private ClientEvent createTestEvent(
io.a2a.spec.Part> part, TaskState state, boolean append, boolean lastChunk) {
Artifact artifact =
@@ -788,6 +958,100 @@ private ClientEvent createTestEvent(
return new TaskUpdateEvent(task, updateEvent);
}
+ @Test
+ public void runAsync_terminatesOnFailureTaskState() {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(
+ consumer -> {
+ consumer.accept(createPartialEvent("Processing data...", true, false), agentCard);
+ consumer.accept(createFailedEvent("Internal Server Error"), agentCard);
+ });
+
+ List events = agent.runAsync(invocationContext).toList().blockingGet();
+
+ // The stream must terminate cleanly and include the final event with error info.
+ assertThat(events).hasSize(2);
+ assertText(events.get(0), "Processing data...");
+ assertText(events.get(1), "Processing data..."); // aggregated/merged
+ assertThat(events.get(1).errorMessage().orElse(null)).isEqualTo("Internal Server Error");
+ }
+
+ @Test
+ public void runAsync_terminatesOnCanceledTaskState() {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(
+ consumer -> {
+ consumer.accept(createPartialEvent("Processing data...", true, false), agentCard);
+ consumer.accept(createCanceledEvent("Execution Canceled"), agentCard);
+ });
+
+ List events = agent.runAsync(invocationContext).toList().blockingGet();
+
+ // The stream must terminate cleanly and include the final event with cancellation info.
+ assertThat(events).hasSize(3);
+ assertText(events.get(0), "Processing data...");
+ assertText(events.get(1), "Processing data..."); // aggregated
+ assertText(events.get(2), "Execution Canceled"); // terminal canceled event
+ }
+
+ @Test
+ public void runAsync_terminatesOnInputRequiredTaskState() {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(
+ consumer -> {
+ consumer.accept(createPartialEvent("Processing data...", true, false), agentCard);
+ consumer.accept(
+ createTerminalStatusUpdateEvent("User Action Needed", TaskState.INPUT_REQUIRED),
+ agentCard);
+ });
+
+ List events = agent.runAsync(invocationContext).toList().blockingGet();
+
+ assertThat(events).hasSize(3);
+ assertText(events.get(0), "Processing data...");
+ assertText(events.get(1), "Processing data..."); // aggregated
+ assertText(events.get(2), "User Action Needed");
+ }
+
+ @Test
+ public void runAsync_terminatesOnRejectedTaskState() {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(
+ consumer -> {
+ consumer.accept(createPartialEvent("Analyzing request...", true, false), agentCard);
+ consumer.accept(
+ createTerminalStatusUpdateEvent("Execution Rejected by Policy", TaskState.REJECTED),
+ agentCard);
+ });
+
+ List events = agent.runAsync(invocationContext).toList().blockingGet();
+
+ assertThat(events).hasSize(3);
+ assertText(events.get(0), "Analyzing request...");
+ assertText(events.get(1), "Analyzing request..."); // aggregated
+ assertText(events.get(2), "Execution Rejected by Policy");
+ }
+
+ @Test
+ public void runAsync_doesNotTerminateOnMessageEvent() {
+ RemoteA2AAgent agent = createAgent();
+ mockStreamResponse(
+ consumer -> {
+ consumer.accept(createPartialEvent("Processing data...", true, false), agentCard);
+ consumer.accept(createMessageEvent("Standard chat update message"), agentCard);
+ consumer.accept(createFinalEvent("Done"), agentCard);
+ });
+
+ List events = agent.runAsync(invocationContext).toList().blockingGet();
+
+ // The stream must not terminate early on MessageEvent, and run until createFinalEvent.
+ assertThat(events).hasSize(4);
+ assertText(events.get(0), "Processing data...");
+ assertText(events.get(1), "Processing data..."); // aggregated (flushed)
+ assertText(events.get(2), "Standard chat update message"); // message event
+ assertText(events.get(3), "Done"); // terminal completed event
+ }
+
private RemoteA2AAgent.Builder getAgentBuilder() {
return RemoteA2AAgent.builder().name("remote-agent").a2aClient(mockClient).agentCard(agentCard);
}
@@ -796,6 +1060,15 @@ private RemoteA2AAgent createAgent() {
return getAgentBuilder().streaming(true).build();
}
+ /** An event whose file part carries invalid base64, so {@code PartConverter} cannot decode it. */
+ private ClientEvent unconvertibleEvent() {
+ return createTestEvent(
+ new FilePart(new FileWithBytes("text/plain", "bad.txt", "!!!")),
+ TaskState.WORKING,
+ true,
+ false);
+ }
+
@SuppressWarnings("unchecked") // cast for Mockito
private void mockStreamResponse(Consumer> responseProducer) {
doAnswer(
diff --git a/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java b/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java
index 9b854b616..c57866c99 100644
--- a/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java
+++ b/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java
@@ -359,6 +359,43 @@ public void taskToEvent_withInputRequired_parsesLongRunningToolIds() {
assertThat(event.longRunningToolIds().get()).containsExactly("call_123", "msg_123");
}
+ @Test
+ public void taskToEvent_withAuthRequired_parsesLongRunningToolIds() {
+ ImmutableMap data =
+ ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of());
+ ImmutableMap metadata =
+ ImmutableMap.of(
+ A2AMetadataKey.TYPE.getType(),
+ "function_call",
+ A2AMetadataKey.IS_LONG_RUNNING.getType(),
+ true);
+ DataPart dataPart = new DataPart(data, metadata);
+ ImmutableMap statusData =
+ ImmutableMap.of("name", "messageTools", "id", "msg_123", "args", ImmutableMap.of());
+ ImmutableMap statusMetadata =
+ ImmutableMap.of(
+ A2AMetadataKey.TYPE.getType(),
+ "function_call",
+ A2AMetadataKey.IS_LONG_RUNNING.getType(),
+ true);
+ DataPart statusDataPart = new DataPart(statusData, statusMetadata);
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(statusDataPart))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.AUTH_REQUIRED, statusMessage, null);
+
+ Artifact artifact =
+ new Artifact.Builder().artifactId("artifact-1").parts(ImmutableList.of(dataPart)).build();
+ Task task = testTask().status(status).artifacts(ImmutableList.of(artifact)).build();
+
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+ assertThat(event).isNotNull();
+ assertThat(event.longRunningToolIds().get()).containsExactly("call_123", "msg_123");
+ assertThat(event.turnComplete()).hasValue(true);
+ }
+
@Test
public void taskToEvent_withDataPartWithoutMetadata_fallsBackToInlineJson() {
DataPart dataPart =
@@ -597,6 +634,52 @@ public void clientEventToEvent_withFinalTaskStatusUpdateEvent_withoutMessage_ret
assertThat(resultEvent.turnComplete()).hasValue(true);
}
+ @Test
+ public void
+ clientEventToEvent_withAuthRequiredTaskStatusUpdateEvent_evenIfNonFinal_returnsTurnComplete() {
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(new TextPart("Auth required message")))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.AUTH_REQUIRED, statusMessage, null);
+ TaskStatusUpdateEvent updateEvent =
+ testTaskStatusUpdateEvent().isFinal(false).status(status).build();
+
+ TaskUpdateEvent event = new TaskUpdateEvent(testTask().status(status).build(), updateEvent);
+
+ Optional optionalEvent = ResponseConverter.clientEventToEvent(event, invocationContext);
+ assertThat(optionalEvent).isPresent();
+ Event resultEvent = optionalEvent.get();
+ assertThat(resultEvent.content().get().parts().get().get(0).text())
+ .hasValue("Auth required message");
+ assertThat(resultEvent.partial().orElse(false)).isFalse();
+ assertThat(resultEvent.turnComplete()).hasValue(true);
+ }
+
+ @Test
+ public void
+ clientEventToEvent_withInputRequiredTaskStatusUpdateEvent_evenIfNonFinal_returnsTurnComplete() {
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(new TextPart("Input required message")))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.INPUT_REQUIRED, statusMessage, null);
+ TaskStatusUpdateEvent updateEvent =
+ testTaskStatusUpdateEvent().isFinal(false).status(status).build();
+
+ TaskUpdateEvent event = new TaskUpdateEvent(testTask().status(status).build(), updateEvent);
+
+ Optional optionalEvent = ResponseConverter.clientEventToEvent(event, invocationContext);
+ assertThat(optionalEvent).isPresent();
+ Event resultEvent = optionalEvent.get();
+ assertThat(resultEvent.content().get().parts().get().get(0).text())
+ .hasValue("Input required message");
+ assertThat(resultEvent.partial().orElse(false)).isFalse();
+ assertThat(resultEvent.turnComplete()).hasValue(true);
+ }
+
@Test
public void clientEventToEvent_withNonFinalTaskStatusUpdateEvent_withoutMessage_returnsEmpty() {
TaskStatus status = new TaskStatus(TaskState.WORKING, null, null);
diff --git a/contrib/firestore-session-service/pom.xml b/contrib/firestore-session-service/pom.xml
index 2f6f2e8e3..6dba2ec2b 100644
--- a/contrib/firestore-session-service/pom.xml
+++ b/contrib/firestore-session-service/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../pom.xml
diff --git a/contrib/langchain4j/pom.xml b/contrib/langchain4j/pom.xml
index a609bacec..f69e1877b 100644
--- a/contrib/langchain4j/pom.xml
+++ b/contrib/langchain4j/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../pom.xml
diff --git a/contrib/planners/pom.xml b/contrib/planners/pom.xml
index 55df8381a..7d59ac8a9 100644
--- a/contrib/planners/pom.xml
+++ b/contrib/planners/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../pom.xml
diff --git a/contrib/samples/a2a_basic/pom.xml b/contrib/samples/a2a_basic/pom.xml
index 36ea15bf7..5d4804549 100644
--- a/contrib/samples/a2a_basic/pom.xml
+++ b/contrib/samples/a2a_basic/pom.xml
@@ -5,7 +5,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
..
diff --git a/contrib/samples/a2a_server/pom.xml b/contrib/samples/a2a_server/pom.xml
index f1b762a5b..5a19461f2 100644
--- a/contrib/samples/a2a_server/pom.xml
+++ b/contrib/samples/a2a_server/pom.xml
@@ -5,7 +5,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
..
diff --git a/contrib/samples/configagent/pom.xml b/contrib/samples/configagent/pom.xml
index a2674b357..cc1c77999 100644
--- a/contrib/samples/configagent/pom.xml
+++ b/contrib/samples/configagent/pom.xml
@@ -5,7 +5,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
..
diff --git a/contrib/samples/github/adkprtriaging/pom.xml b/contrib/samples/github/adkprtriaging/pom.xml
index d58918d96..82ea614b0 100644
--- a/contrib/samples/github/adkprtriaging/pom.xml
+++ b/contrib/samples/github/adkprtriaging/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/samples/github/adkreleasedocs/pom.xml b/contrib/samples/github/adkreleasedocs/pom.xml
index fc9a39f2e..8d4027cc6 100644
--- a/contrib/samples/github/adkreleasedocs/pom.xml
+++ b/contrib/samples/github/adkreleasedocs/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/samples/github/adkspam/pom.xml b/contrib/samples/github/adkspam/pom.xml
index e78be4b54..b5100a883 100644
--- a/contrib/samples/github/adkspam/pom.xml
+++ b/contrib/samples/github/adkspam/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/samples/github/adkstale/pom.xml b/contrib/samples/github/adkstale/pom.xml
index 154a54b9d..55b06cf4c 100644
--- a/contrib/samples/github/adkstale/pom.xml
+++ b/contrib/samples/github/adkstale/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/samples/github/adktriaging/pom.xml b/contrib/samples/github/adktriaging/pom.xml
index 550b2a04a..70f106529 100644
--- a/contrib/samples/github/adktriaging/pom.xml
+++ b/contrib/samples/github/adktriaging/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/samples/github/githubtools/pom.xml b/contrib/samples/github/githubtools/pom.xml
index 409918e81..eb477d7b8 100644
--- a/contrib/samples/github/githubtools/pom.xml
+++ b/contrib/samples/github/githubtools/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/samples/helloworld/pom.xml b/contrib/samples/helloworld/pom.xml
index 334cb51b6..724df6ab9 100644
--- a/contrib/samples/helloworld/pom.xml
+++ b/contrib/samples/helloworld/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-samples
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
..
diff --git a/contrib/samples/mcpfilesystem/pom.xml b/contrib/samples/mcpfilesystem/pom.xml
index c3f277a1b..7210fe7a2 100644
--- a/contrib/samples/mcpfilesystem/pom.xml
+++ b/contrib/samples/mcpfilesystem/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../..
diff --git a/contrib/samples/pom.xml b/contrib/samples/pom.xml
index a926d3b85..60c996f72 100644
--- a/contrib/samples/pom.xml
+++ b/contrib/samples/pom.xml
@@ -5,7 +5,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../..
diff --git a/contrib/spring-ai/pom.xml b/contrib/spring-ai/pom.xml
index 176aafcea..fb2260f02 100644
--- a/contrib/spring-ai/pom.xml
+++ b/contrib/spring-ai/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../pom.xml
diff --git a/core/pom.xml b/core/pom.xml
index 910310660..4ffe176d4 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
google-adk
diff --git a/core/src/main/java/com/google/adk/Version.java b/core/src/main/java/com/google/adk/Version.java
index 48ceca967..9a196e60b 100644
--- a/core/src/main/java/com/google/adk/Version.java
+++ b/core/src/main/java/com/google/adk/Version.java
@@ -22,7 +22,7 @@
*/
public final class Version {
// Don't touch this, release-please should keep it up to date.
- public static final String JAVA_ADK_VERSION = "1.7.1"; // x-release-please-released-version
+ public static final String JAVA_ADK_VERSION = "1.8.0"; // x-release-please-released-version
private Version() {}
}
diff --git a/core/src/main/java/com/google/adk/flows/llmflows/Contents.java b/core/src/main/java/com/google/adk/flows/llmflows/Contents.java
index 1f81bfa3e..f0bfcd09a 100644
--- a/core/src/main/java/com/google/adk/flows/llmflows/Contents.java
+++ b/core/src/main/java/com/google/adk/flows/llmflows/Contents.java
@@ -155,7 +155,10 @@ private ImmutableList getContents(
// TODO: Skip auth events.
if (isOtherAgentReply(agentName, event)) {
- filteredEvents.add(convertForeignEvent(event));
+ Event foreignEvent = convertForeignEvent(event);
+ if (foreignEvent != null) {
+ filteredEvents.add(foreignEvent);
+ }
} else {
filteredEvents.add(event);
}
@@ -180,8 +183,9 @@ private ImmutableList getContents(
*
* This can happen to the events that only changed session state. When both content and
* transcriptions are empty, the event will be considered as empty. The content is considered
- * empty if none of its parts contain text, inline data, file data, function call, or function
- * response. Parts with only thoughts are also considered empty.
+ * empty if none of its parts contain text, inline data, file data, function call, function
+ * response, server-side tool call, or server-side tool response. Parts with only thoughts are
+ * also considered empty.
*
* @param event the event to check.
* @return {@code true} if the event is considered to have empty content, {@code false} otherwise.
@@ -205,12 +209,16 @@ private boolean isEmptyContent(Event event) {
*
*
* - It has no meaningful content (text, inline_data, file_data, function_call,
- * function_response, executable_code, or code_execution_result), OR
- *
- It is marked as a thought AND does not contain function_call or function_response
+ * function_response, tool_call, tool_response, executable_code, or code_execution_result)
+ * and no thought_signature, OR
+ *
- It is marked as a thought AND does not contain function_call, function_response,
+ * tool_call, tool_response or thought_signature
*
*
* Function calls and responses are never invisible, even if marked as thought, because they
- * represent actions that need to be executed or results that need to be processed.
+ * represent actions that need to be executed or results that need to be processed. Parts carrying
+ * a thought signature, and server-side tool calls and their responses, are never invisible
+ * either, because the caller is required to echo them back on the next request.
*
* @param part the part to check.
* @return {@code true} if the part is invisible, {@code false} otherwise.
@@ -219,6 +227,18 @@ private boolean isPartInvisible(Part part) {
if (part.functionCall().isPresent() || part.functionResponse().isPresent()) {
return false;
}
+
+ // A thought signature is opaque state to hand back verbatim, and it routinely arrives on a part
+ // with nothing else in it, so it has to be checked before the emptiness test below.
+ if (part.thoughtSignature().map(signature -> signature.length > 0).orElse(false)) {
+ return false;
+ }
+
+ // Server-side tool calls/responses must be echoed back to the model.
+ if (part.toolCall().isPresent() || part.toolResponse().isPresent()) {
+ return false;
+ }
+
return part.thought().orElse(false)
|| !(part.text().isPresent()
|| part.inlineData().isPresent()
@@ -387,8 +407,13 @@ private static boolean isOtherAgentReply(String agentName, Event event) {
&& !event.author().equals("user");
}
- /** Converts an {@code event} authored by another agent to a 'contextual-only' event. */
- private static Event convertForeignEvent(Event event) {
+ /**
+ * Converts an {@code event} authored by another agent to a 'contextual-only' event.
+ *
+ *
Returns {@code null} when nothing but the "For context:" preamble survives the conversion,
+ * so the caller drops the event instead of sending a preamble with no context after it.
+ */
+ private static @Nullable Event convertForeignEvent(Event event) {
if (event.content().isEmpty()
|| event.content().get().parts().isEmpty()
|| event.content().get().parts().get().isEmpty()) {
@@ -401,9 +426,14 @@ private static Event convertForeignEvent(Event event) {
String originalAuthor = event.author();
for (Part part : event.content().get().parts().get()) {
- if (part.text().isPresent()
- && !part.text().get().isEmpty()
- && !part.thought().orElse(false)) {
+ // Thoughts belong to the agent that produced them and are never narrated, whatever else the
+ // part carries. ADK Python and ADK Kotlin both skip them before the branches below.
+ if (part.thought().orElse(false)) {
+ continue;
+ }
+ // Blank text is not narrated: such a part is a signature carrier, and a bare "said:" would
+ // both pollute the prompt and keep the event alive on nothing.
+ if (part.text().map(text -> !text.isBlank()).orElse(false)) {
parts.add(Part.fromText(String.format("[%s] said: %s", originalAuthor, part.text().get())));
} else if (part.functionCall().isPresent()) {
FunctionCall functionCall = part.functionCall().get();
@@ -423,9 +453,18 @@ private static Event convertForeignEvent(Event event) {
originalAuthor,
functionResponse.name().orElse("unknown_tool"),
functionResponse.response().map(Contents::convertMapToJson).orElse("{}"))));
- } else {
+ } else if (part.inlineData().isPresent()
+ || part.fileData().isPresent()
+ || part.executableCode().isPresent()
+ || part.codeExecutionResult().isPresent()) {
parts.add(part);
}
+ // Anything else - a bare signature, a server-side call - belongs to the model instance that
+ // produced it, so claiming it for another agent would be wrong.
+ }
+
+ if (parts.size() == 1) {
+ return null;
}
Content content = Content.builder().role("user").parts(parts).build();
@@ -443,9 +482,13 @@ private static String convertMapToJson(Map struct) {
private static boolean isEventBelongsToBranch(@Nullable String invocationBranch, Event event) {
@Nullable String eventBranch = event.branch().orElse(null);
+ // Branches are dot-joined agent names, so a raw prefix match would make "root.agent_10" belong
+ // to the branch "root.agent_1". Require either an exact match, or a prefix that ends on a
+ // segment boundary.
return Strings.isNullOrEmpty(invocationBranch)
|| Strings.isNullOrEmpty(eventBranch)
- || invocationBranch.startsWith(eventBranch);
+ || invocationBranch.equals(eventBranch)
+ || invocationBranch.startsWith(eventBranch + ".");
}
/**
diff --git a/core/src/main/java/com/google/adk/models/Gemini.java b/core/src/main/java/com/google/adk/models/Gemini.java
index 36267551c..8b4d95298 100644
--- a/core/src/main/java/com/google/adk/models/Gemini.java
+++ b/core/src/main/java/com/google/adk/models/Gemini.java
@@ -321,6 +321,24 @@ private static final class StreamingResponseAggregator {
private final StringBuilder currentTextBuffer = new StringBuilder();
// Always reassigned in accumulateParts() before it is read; the initializer is never observed.
private boolean currentTextIsThought = false;
+
+ /**
+ * Returns whether the part is the empty-text terminator Gemini 3 ends a stream with: empty text
+ * and nothing else worth keeping. Compared by rebuilding rather than against a single literal,
+ * so a terminator that also carries an explicit {@code thought=false} is still recognised.
+ */
+ private static boolean isStreamTerminator(Part part) {
+ if (!part.text().map(String::isEmpty).orElse(false)) {
+ return false;
+ }
+ Part.Builder terminator = Part.builder().text("");
+ part.thought().ifPresent(terminator::thought);
+ return terminator.build().equals(part);
+ }
+
+ // Signature of the buffered text run, kept apart from the call's slot below so an interleaved
+ // chunk cannot flush one part carrying the other's signature.
+ private byte[] currentTextThoughtSignature = null;
private byte[] currentThoughtSignature = null;
private GenerateContentResponse lastRawResponse = null;
@@ -407,11 +425,11 @@ private static String generateClientFunctionCallId() {
/**
* Accumulates content from incoming parts: text, function calls, and any other content part
- * (inline image/audio data, file data, code execution, server-side tool calls/responses, and
- * future part types). Standalone thought-signature/thought parts are the one exception: their
- * signature is captured and re-attached to the last real part in {@link #processFinalResponse},
- * so they are not emitted on their own. Function-call parts passed to this method are expected
- * to already have IDs (see {@link #ensureFunctionCallIds}).
+ * (inline image/audio data, file data, code execution, server-side tool calls/responses,
+ * standalone thought signatures, and future part types), which are appended verbatim as ADK
+ * Python does. The empty-text part that ends a Gemini 3 stream is the one thing dropped.
+ * Function-call parts passed to this method are expected to already have IDs (see {@link
+ * #ensureFunctionCallIds}).
*
* @return true if any content part was present, false otherwise.
*/
@@ -421,38 +439,33 @@ private boolean accumulateParts(List parts) {
String text = part.text().orElse("");
if (!text.isEmpty()) {
hasContent = true;
- // The signature belongs to this text; capture it so flushTextBufferToSequence attaches
- // it.
- part.thoughtSignature().ifPresent(sig -> currentThoughtSignature = sig);
boolean isThought = part.thought().orElse(false);
- // Immediately flush the active text buffer to preserve the exact interleaved blocks of
- // text/thoughts.
+ // Flush before capturing this chunk's signature below, or the signature of the run
+ // starting here lands on the run being flushed.
if (!currentTextBuffer.isEmpty() && isThought != currentTextIsThought) {
flushTextBufferToSequence();
}
if (currentTextBuffer.isEmpty()) {
currentTextIsThought = isThought;
}
+ // Keep the first signature of the run, as ADK Python does; the merged part takes it in
+ // flushTextBufferToSequence.
+ if (currentTextThoughtSignature == null
+ && part.thoughtSignature().map(sig -> sig.length > 0).orElse(false)) {
+ currentTextThoughtSignature = part.thoughtSignature().get();
+ }
currentTextBuffer.append(text);
} else if (part.functionCall().isPresent()) {
hasContent = true;
processFunctionCallPart(part);
- } else if (part.text().isEmpty() && !part.thought().orElse(false)) {
- // Mirror ADK Python's catch-all: preserve any part that is not text or a function call
- // (inline image/audio data, file data, code execution, server-side tool calls/responses,
- // future part types) rather than an allowlist that silently drops unlisted types. Flush
- // buffered text first so parts keep their order, then append the part verbatim keeping
- // any
- // thoughtSignature it carries. The signature is intentionally not captured into
- // currentThoughtSignature, which would leak it onto the preceding part.
+ } else if (isStreamTerminator(part)) {
+ // Gemini 3 ends a stream with a bare empty text part; it carries nothing to keep.
+ } else {
+ // Everything else is appended as the model sent it, signature included. Relocating a
+ // signature onto a neighbouring part would hand it back on a part the model never signed.
hasContent = true;
flushTextBufferToSequence();
accumulatedSequence.add(part);
- } else {
- // Standalone thought/thought-signature part with no renderable content: not emitted on
- // its
- // own; capture its signature to re-attach to the last real part in processFinalResponse.
- part.thoughtSignature().ifPresent(sig -> currentThoughtSignature = sig);
}
}
return hasContent;
@@ -476,7 +489,8 @@ private void processFunctionCallPart(Part part) {
|| (currentFcName != null && !hasName);
if (streamedPart) {
// Capture the thought signature from the first chunk that carries one.
- if (part.thoughtSignature().isPresent() && currentThoughtSignature == null) {
+ if (currentThoughtSignature == null
+ && part.thoughtSignature().map(sig -> sig.length > 0).orElse(false)) {
currentThoughtSignature = part.thoughtSignature().get();
}
processStreamingFunctionCall(fc);
@@ -605,9 +619,9 @@ private void flushTextBufferToSequence() {
if (!currentTextBuffer.isEmpty()) {
Part.Builder partBuilder =
Part.builder().text(currentTextBuffer.toString()).thought(currentTextIsThought);
- if (currentThoughtSignature != null) {
- partBuilder.thoughtSignature(currentThoughtSignature);
- currentThoughtSignature = null;
+ if (currentTextThoughtSignature != null) {
+ partBuilder.thoughtSignature(currentTextThoughtSignature);
+ currentTextThoughtSignature = null;
}
accumulatedSequence.add(partBuilder.build());
currentTextBuffer.setLength(0);
@@ -652,18 +666,9 @@ private Flowable processFinalResponse() {
return Flowable.just(finalResponseBuilder.build());
}
- // If the final chunk carries a thoughtSignature (e.g. from a preceding function call or
- // thought), attach it to the last accumulated part in the sequence.
- GeminiUtil.getPart0FromLlmResponse(currentResponse)
- .flatMap(Part::thoughtSignature)
- .ifPresent(
- signature -> {
- int targetIndex = accumulatedSequence.size() - 1;
- Part targetPart = accumulatedSequence.get(targetIndex);
- accumulatedSequence.set(
- targetIndex, targetPart.toBuilder().thoughtSignature(signature).build());
- });
-
+ // No re-attach of the final chunk's signature: every part now keeps the signature the model
+ // put on it, so reading part 0 and stamping the last part could only mis-attribute one. ADK
+ // Python and the ADK Kotlin sibling have no equivalent either.
return Flowable.just(
finalResponseBuilder
.content(Content.builder().role("model").parts(accumulatedSequence).build())
diff --git a/core/src/main/java/com/google/adk/plugins/Plugin.java b/core/src/main/java/com/google/adk/plugins/Plugin.java
index c9cda5680..e4e0b5e4d 100644
--- a/core/src/main/java/com/google/adk/plugins/Plugin.java
+++ b/core/src/main/java/com/google/adk/plugins/Plugin.java
@@ -87,6 +87,16 @@ default Completable afterRunCallback(InvocationContext invocationContext) {
return Completable.complete();
}
+ /**
+ * Callback executed when a run encounters an error.
+ *
+ * @param invocationContext The context for the entire invocation.
+ * @param error The exception that was raised.
+ */
+ default Completable onRunErrorCallback(InvocationContext invocationContext, Throwable error) {
+ return Completable.complete();
+ }
+
/**
* Method executed when the runner is closed.
*
diff --git a/core/src/main/java/com/google/adk/plugins/PluginManager.java b/core/src/main/java/com/google/adk/plugins/PluginManager.java
index 8d0366e9a..345e16a6d 100644
--- a/core/src/main/java/com/google/adk/plugins/PluginManager.java
+++ b/core/src/main/java/com/google/adk/plugins/PluginManager.java
@@ -143,6 +143,27 @@ public Completable afterRunCallback(InvocationContext invocationContext) {
.compose(Tracing.withContext(capturedContext));
}
+ public Completable runOnRunErrorCallback(InvocationContext invocationContext, Throwable error) {
+ return onRunErrorCallback(invocationContext, error);
+ }
+
+ @Override
+ public Completable onRunErrorCallback(InvocationContext invocationContext, Throwable error) {
+ Context capturedContext = Context.current();
+ return Flowable.fromIterable(plugins)
+ .concatMapCompletable(
+ plugin ->
+ plugin
+ .onRunErrorCallback(invocationContext, error)
+ .doOnError(
+ e ->
+ logger.error(
+ "[{}] Error during callback 'onRunErrorCallback'",
+ plugin.getName(),
+ e)))
+ .compose(Tracing.withContext(capturedContext));
+ }
+
@Override
public Completable close() {
Context capturedContext = Context.current();
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 5b1902e36..48eb9fad6 100644
--- a/core/src/main/java/com/google/adk/runner/Runner.java
+++ b/core/src/main/java/com/google/adk/runner/Runner.java
@@ -557,7 +557,13 @@ protected Flowable runAsyncImpl(
.flatMapPublisher(
event ->
runAgentWithUpdatedSession(initialContext, session, event, rootAgent)
- .compose(Tracing.withContext(capturedContext)));
+ .compose(Tracing.withContext(capturedContext)))
+ .doOnError(
+ throwable ->
+ this.pluginManager
+ .runOnRunErrorCallback(initialContext, throwable)
+ .onErrorComplete()
+ .subscribe());
})
.doOnError(
throwable -> {
@@ -802,6 +808,10 @@ protected Flowable runLiveImpl(
Span span = Span.current();
span.setStatus(StatusCode.ERROR, "Error in runLive Flowable execution");
span.recordException(throwable);
+ this.pluginManager
+ .runOnRunErrorCallback(invocationContext, throwable)
+ .onErrorComplete()
+ .subscribe();
})
.compose(Tracing.withContext(capturedContext));
});
diff --git a/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java b/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
index 5d2c3d5fc..ce7655333 100644
--- a/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
+++ b/core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
@@ -19,6 +19,7 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Correspondence.transforming;
import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertThrows;
import com.google.adk.agents.InvocationContext;
@@ -33,10 +34,13 @@
import com.google.adk.sessions.Session;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Blob;
import com.google.genai.types.Content;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.Part;
+import com.google.genai.types.ToolCall;
+import com.google.genai.types.ToolResponse;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
@@ -946,6 +950,294 @@ public void processRequest_notEmptyContent() {
assertThat(contents).containsExactly(e.content().get());
}
+ // On models that return a signature for every part, it arrives on parts holding nothing else.
+ // Dropping those as "empty" loses the reasoning the model expects back on the next turn.
+ @Test
+ public void processRequest_contentFreeThoughtSignatureEvent_notSkipped() {
+ Event signatureEvent =
+ createModelEvent(
+ "e2", Part.builder().thoughtSignature("call-context".getBytes(UTF_8)).build());
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Summarize the video."), signatureEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(2);
+ assertThat(contents.get(1).parts().get().get(0).thoughtSignature())
+ .hasValue("call-context".getBytes(UTF_8));
+ }
+
+ // A thought part carrying a signature is kept for the same reason, even though a bare thought
+ // part is dropped.
+ @Test
+ public void processRequest_thoughtWithSignatureEvent_notSkipped() {
+ Event thoughtEvent =
+ createModelEvent(
+ "e2",
+ Part.builder()
+ .thought(true)
+ .text("Let me check the frame at 0:05.")
+ .thoughtSignature("thought-sig".getBytes(UTF_8))
+ .build());
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Summarize the video."), thoughtEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(2);
+ assertThat(contents.get(1).parts().get().get(0).thoughtSignature())
+ .hasValue("thought-sig".getBytes(UTF_8));
+ }
+
+ // The caller must echo server-side tool parts back, so dropping them as "empty" makes the model
+ // redo the work or fail on a call with no matching response.
+ @Test
+ public void processRequest_serverSideToolCallAndResponseEvents_notSkipped() {
+ Event toolCallEvent =
+ createModelEvent(
+ "e2",
+ Part.builder()
+ .toolCall(
+ ToolCall.builder()
+ .id("tc1")
+ .args(ImmutableMap.of("url", "https://example.com"))
+ .build())
+ .build());
+ Event toolResponseEvent =
+ createModelEvent(
+ "e3",
+ Part.builder()
+ .toolResponse(
+ ToolResponse.builder()
+ .id("tc1")
+ .response(ImmutableMap.of("content", "page text"))
+ .build())
+ .build());
+ ImmutableList events =
+ ImmutableList.of(
+ createUserEvent("e1", "Summarize the linked page."), toolCallEvent, toolResponseEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(3);
+ assertThat(contents.get(1).parts().get().get(0).toolCall().get().id()).hasValue("tc1");
+ ToolResponse toolResponse = contents.get(2).parts().get().get(0).toolResponse().get();
+ assertThat(toolResponse.id()).hasValue("tc1");
+ assertThat(toolResponse.response()).hasValue(ImmutableMap.of("content", "page text"));
+ }
+
+ // The echo-back contract holds regardless of how the model labels the part, so a thought marking
+ // must not drop it.
+ @Test
+ public void processRequest_serverSideToolCallMarkedAsThought_notSkipped() {
+ Event toolCallEvent =
+ createModelEvent(
+ "e2",
+ Part.builder()
+ .thought(true)
+ .toolCall(
+ ToolCall.builder()
+ .id("tc1")
+ .args(ImmutableMap.of("url", "https://example.com"))
+ .build())
+ .build());
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Summarize the linked page."), toolCallEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(2);
+ assertThat(contents.get(1).parts().get().get(0).toolCall().get().id()).hasValue("tc1");
+ }
+
+ // A server-side call belongs to the model instance that made it, so the other-agent path must
+ // keep dropping it rather than claiming the call on this agent's behalf.
+ @Test
+ public void processRequest_serverSideToolCallFromOtherAgent_isDropped() {
+ Event otherAgentToolCall =
+ Event.builder()
+ .id("e2")
+ .author(OTHER_AGENT)
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(
+ ImmutableList.of(
+ Part.builder()
+ .toolCall(
+ ToolCall.builder()
+ .id("tc1")
+ .args(ImmutableMap.of("url", "https://example.com"))
+ .build())
+ .build()))
+ .build())
+ .invocationId("invocationId")
+ .build();
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Summarize the linked page."), otherAgentToolCall);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(1);
+ assertThat(contents.get(0).parts().get().get(0).text()).hasValue("Summarize the linked page.");
+ }
+
+ @Test
+ public void processRequest_serverSideToolCallWithThoughtFromOtherAgent_isDropped() {
+ Event otherAgentToolCall =
+ Event.builder()
+ .id("e2")
+ .author(OTHER_AGENT)
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(
+ ImmutableList.of(
+ Part.builder().thought(true).text("Let me look it up.").build(),
+ Part.builder()
+ .toolCall(
+ ToolCall.builder()
+ .id("tc1")
+ .args(ImmutableMap.of("url", "https://example.com"))
+ .build())
+ .build()))
+ .build())
+ .invocationId("invocationId")
+ .build();
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Summarize the linked page."), otherAgentToolCall);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(1);
+ assertThat(contents.get(0).parts().get().get(0).text()).hasValue("Summarize the linked page.");
+ }
+
+ // A thought-marked function call from another agent must not be narrated: the thought guard runs
+ // before the branches that would turn it into "[agent] called tool ...".
+ @Test
+ public void processRequest_thoughtMarkedFunctionCallFromOtherAgent_isDropped() {
+ Event otherAgentEvent =
+ Event.builder()
+ .id("e2")
+ .author(OTHER_AGENT)
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(
+ ImmutableList.of(
+ Part.builder()
+ .thought(true)
+ .functionCall(
+ FunctionCall.builder()
+ .name("lookup")
+ .args(ImmutableMap.of("q", "x"))
+ .build())
+ .build()))
+ .build())
+ .invocationId("invocationId")
+ .build();
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "What is in the picture?"), otherAgentEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(1);
+ }
+
+ // Whitespace-only text is not content either, so the carrier that carries it must not be narrated
+ // as a bare "said:". Matches what the emptiness rule already treats as blank.
+ @Test
+ public void processRequest_blankTextSignaturePartFromOtherAgent_isNotNarrated() {
+ Event otherAgentEvent =
+ Event.builder()
+ .id("e2")
+ .author(OTHER_AGENT)
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(
+ ImmutableList.of(
+ Part.builder()
+ .text(" ")
+ .thoughtSignature(new byte[] {7, 7, 7})
+ .build()))
+ .build())
+ .invocationId("invocationId")
+ .build();
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Summarize the video."), otherAgentEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(1);
+ }
+
+ // Another agent's reasoning belongs to that agent and is never narrated: only the answer text
+ // beside it may be attributed.
+ @Test
+ public void processRequest_thoughtTextFromOtherAgent_isNotNarrated() {
+ Event otherAgentEvent =
+ Event.builder()
+ .id("e2")
+ .author(OTHER_AGENT)
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(
+ ImmutableList.of(
+ Part.builder().thought(true).text("Let me check the map.").build(),
+ Part.fromText("It is in Paris.")))
+ .build())
+ .invocationId("invocationId")
+ .build();
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "Where is it?"), otherAgentEvent);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(2);
+ assertThat(
+ contents.get(1).parts().get().stream()
+ .map(part -> part.text().orElse(""))
+ .collect(toImmutableList()))
+ .containsExactly("For context:", "[" + OTHER_AGENT + "] said: It is in Paris.");
+ }
+
+ // The other-agent path still narrates what it can: media parts pass through unchanged, so the
+ // drop above is about attribution rather than a blanket filter.
+ @Test
+ public void processRequest_mediaPartFromOtherAgent_isKept() {
+ Event otherAgentImage =
+ Event.builder()
+ .id("e2")
+ .author(OTHER_AGENT)
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(
+ ImmutableList.of(
+ Part.builder()
+ .inlineData(
+ Blob.builder()
+ .mimeType("image/png")
+ .data(new byte[] {1, 2, 3})
+ .build())
+ .build()))
+ .build())
+ .invocationId("invocationId")
+ .build();
+ ImmutableList events =
+ ImmutableList.of(createUserEvent("e1", "What is in the picture?"), otherAgentImage);
+
+ List contents = runContentsProcessor(events);
+
+ assertThat(contents).hasSize(2);
+ assertThat(contents.get(1).parts().get()).hasSize(2);
+ assertThat(contents.get(1).parts().get().get(0).text()).hasValue("For context:");
+ assertThat(contents.get(1).parts().get().get(1).inlineData()).isPresent();
+ }
+
@Test
public void processRequest_concurrentReadAndWrite_noException() throws Exception {
LlmAgent agent =
@@ -1008,6 +1300,61 @@ public Stream stream() {
var unused = contentsProcessor.processRequest(context, initialRequest).blockingGet();
}
+ @Test
+ public void processRequest_siblingBranchSharesNamePrefix_excludesSiblingEvent() {
+ Event siblingEvent =
+ createBranchedAgentEvent("agent_1", "e1", "sibling output", "root.agent_1");
+
+ List result =
+ runContentsProcessorOnBranch(ImmutableList.of(siblingEvent), "agent_10", "root.agent_10");
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ public void processRequest_sameBranch_includesEvent() {
+ Event ownEvent = createBranchedAgentEvent("agent_10", "e1", "own output", "root.agent_10");
+
+ List result =
+ runContentsProcessorOnBranch(ImmutableList.of(ownEvent), "agent_10", "root.agent_10");
+
+ assertThat(result).isEqualTo(eventsToContents(ImmutableList.of(ownEvent)));
+ }
+
+ @Test
+ public void processRequest_ancestorBranch_includesEvent() {
+ Event ancestorEvent = createBranchedAgentEvent("agent_10", "e1", "ancestor output", "root");
+
+ List result =
+ runContentsProcessorOnBranch(ImmutableList.of(ancestorEvent), "agent_10", "root.agent_10");
+
+ assertThat(result).isEqualTo(eventsToContents(ImmutableList.of(ancestorEvent)));
+ }
+
+ @Test
+ public void processRequest_eventWithoutBranch_includesEvent() {
+ Event userEvent = createUserEvent("u1", "user input");
+
+ List result =
+ runContentsProcessorOnBranch(ImmutableList.of(userEvent), "agent_10", "root.agent_10");
+
+ assertThat(result).isEqualTo(eventsToContents(ImmutableList.of(userEvent)));
+ }
+
+ @Test
+ public void processRequest_noInvocationBranch_includesBranchedEvent() {
+ Event siblingEvent =
+ createBranchedAgentEvent("agent_1", "e1", "sibling output", "root.agent_1");
+
+ List result =
+ runContentsProcessorOnBranch(ImmutableList.of(siblingEvent), "agent_10", null);
+
+ assertThat(result)
+ .containsExactly(
+ Content.fromParts(
+ Part.fromText("For context:"), Part.fromText("[agent_1] said: sibling output")));
+ }
+
private static Event createUserEvent(String id, String text) {
return Event.builder()
.id(id)
@@ -1028,6 +1375,15 @@ private static Event createUserEvent(
.build();
}
+ private static Event createModelEvent(String id, Part part) {
+ return Event.builder()
+ .id(id)
+ .author(AGENT)
+ .content(Content.builder().role("model").parts(ImmutableList.of(part)).build())
+ .invocationId("invocationId")
+ .build();
+ }
+
private static Event createAgentEvent(String id, String text) {
return createAgentEvent(AGENT, id, text);
}
@@ -1042,6 +1398,18 @@ private static Event createAgentEvent(String agent, String id, String text) {
.build();
}
+ private static Event createBranchedAgentEvent(
+ String agent, String id, String text, String branch) {
+ return Event.builder()
+ .id(id)
+ .author(agent)
+ .content(
+ Content.builder().role("model").parts(ImmutableList.of(Part.fromText(text))).build())
+ .invocationId("invocationId")
+ .branch(branch)
+ .build();
+ }
+
private static Event createFunctionCallEvent(String id, String toolName, String callId) {
return createFunctionCallEvent(AGENT, id, toolName, callId);
}
@@ -1226,6 +1594,31 @@ private List runContentsProcessorGrouped(List events) {
return result.updatedRequest().contents();
}
+ private List runContentsProcessorOnBranch(
+ List events, String agentName, String invocationBranch) {
+ LlmAgent agent =
+ LlmAgent.builder()
+ .name(agentName)
+ .includeContents(LlmAgent.IncludeContents.DEFAULT)
+ .build();
+ Session session =
+ sessionService.createSession("test-app", "test-user", null, "test-session").blockingGet();
+ session.events().addAll(events);
+ InvocationContext context =
+ InvocationContext.builder()
+ .invocationId("test-invocation")
+ .agent(agent)
+ .session(session)
+ .sessionService(sessionService)
+ .branch(invocationBranch)
+ .build();
+
+ LlmRequest initialRequest = LlmRequest.builder().build();
+ RequestProcessor.RequestProcessingResult result =
+ contentsProcessor.processRequest(context, initialRequest).blockingGet();
+ return result.updatedRequest().contents();
+ }
+
private List runContentsProcessorWithModel(
List events, String modelName, RunConfig runConfig) {
LlmAgent agent =
diff --git a/core/src/test/java/com/google/adk/models/GeminiTest.java b/core/src/test/java/com/google/adk/models/GeminiTest.java
index a965e5b68..a56628493 100644
--- a/core/src/test/java/com/google/adk/models/GeminiTest.java
+++ b/core/src/test/java/com/google/adk/models/GeminiTest.java
@@ -920,8 +920,7 @@ public void processRawResponses_thoughtAndTextWithStop_onlyFinalTextIncludesUsag
}
@Test
- public void
- processRawResponses_thoughtThenEmptyWithSignatureAndStop_flushesThoughtWithSignature() {
+ public void processRawResponses_thoughtThenSignatureAndStop_keepsSignatureOnItsOwnPart() {
GenerateContentResponseUsageMetadata metadata1 = createUsageMetadata(5, 10, 15);
GenerateContentResponseUsageMetadata metadata2 = createUsageMetadata(5, 20, 25);
GenerateContentResponse chunk1 = toResponseWithThoughtText("Thinking", metadata1);
@@ -948,7 +947,16 @@ public void processRawResponses_thoughtAndTextWithStop_onlyFinalTextIncludesUsag
assertLlmResponses(
llmResponses,
isPartialThoughtResponseWithUsageMetadata("Thinking", metadata1),
- isFinalThoughtResponseWithUsageMetadataAndSignature("Thinking", metadata2, "sig"));
+ isPartialSignatureResponse("sig"),
+ response -> {
+ ImmutableList parts = ImmutableList.copyOf(response.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).text()).hasValue("Thinking");
+ assertThat(parts.get(0).thoughtSignature()).isEmpty();
+ assertThat(parts.get(1).thoughtSignature()).hasValue("sig".getBytes(UTF_8));
+ assertThat(response.usageMetadata()).hasValue(metadata2);
+ return true;
+ });
}
@Test
@@ -998,7 +1006,7 @@ public void processRawResponses_thoughtAndTextWithStop_onlyFinalTextIncludesUsag
@Test
public void
- processRawResponses_thoughtThenFunctionCallWithSignatureAndStop_attachesSignatureToFunctionCall() {
+ processRawResponses_thoughtThenFunctionCallThenSignature_keepsSignatureOnItsOwnPart() {
GenerateContentResponseUsageMetadata metadata1 = createUsageMetadata(5, 10, 15);
GenerateContentResponseUsageMetadata metadata2 = createUsageMetadata(5, 20, 25);
GenerateContentResponse chunk1 = toResponseWithThoughtText("Thinking", metadata1);
@@ -1028,8 +1036,17 @@ public void processRawResponses_thoughtAndTextWithStop_onlyFinalTextIncludesUsag
llmResponses,
isPartialThoughtResponseWithUsageMetadata("Thinking", metadata1),
isPartialFunctionCallResponse("my_tool"),
- isFinalThoughtAndFunctionCallResponseWithUsageMetadataAndSignature(
- "Thinking", metadata2, "sig", "my_tool"));
+ isPartialSignatureResponse("sig"),
+ response -> {
+ ImmutableList parts = ImmutableList.copyOf(response.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).text()).hasValue("Thinking");
+ assertThat(parts.get(1).functionCall().get().name()).hasValue("my_tool");
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ assertThat(parts.get(2).thoughtSignature()).hasValue("sig".getBytes(UTF_8));
+ assertThat(response.usageMetadata()).hasValue(metadata2);
+ return true;
+ });
}
@Test
@@ -1065,9 +1082,630 @@ public void processRawResponses_emptyPartsThenSignature_doesNotThrowException()
assertLlmResponses(
llmResponses,
isEmptyResponse(),
+ isPartialSignatureResponse("sig"),
isFinalThoughtResponseWithUsageMetadataAndSignature("", metadata, "sig"));
}
+ // Consecutive text chunks are merged into a single part the aggregator builds from scratch, so a
+ // thought signature the chunks carried is lost unless it is copied across. The model expects its
+ // signature back verbatim; without it, it redoes the reasoning the signature stood for. Mirrors
+ // ADK Python's TestStreamingThoughtSignature.
+ @Test
+ public void processRawResponses_signatureOnMergedText_isPreserved() {
+ GenerateContentResponse chunk1 = toResponseWithTextAndSignature("At minute 5 ", "text-sig");
+ GenerateContentResponse chunk2 =
+ toResponseWithText("the presenter speaks.", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).text()).hasValue("At minute 5 the presenter speaks.");
+ assertThat(parts.get(0).thoughtSignature()).hasValue("text-sig".getBytes(UTF_8));
+ }
+
+ // The signature can land on any chunk of the run, not just the first.
+ @Test
+ public void processRawResponses_signatureOnLaterTextChunk_isPreserved() {
+ GenerateContentResponse chunk1 = toResponseWithText("At minute 5 ");
+ GenerateContentResponse chunk2 = toResponseWithTextAndSignature("the presenter ", "late-sig");
+ GenerateContentResponse chunk3 = toResponseWithText("speaks.", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).text()).hasValue("At minute 5 the presenter speaks.");
+ assertThat(parts.get(0).thoughtSignature()).hasValue("late-sig".getBytes(UTF_8));
+ }
+
+ // A merged part carries one signature; the run keeps the first it saw, as ADK Python does.
+ @Test
+ public void processRawResponses_multipleSignaturesInOneRun_keepsTheFirst() {
+ GenerateContentResponse chunk1 = toResponseWithTextAndSignature("At minute 5 ", "first-sig");
+ GenerateContentResponse chunk2 = toResponseWithTextAndSignature("the presenter ", "second-sig");
+ GenerateContentResponse chunk3 =
+ toResponse(
+ Candidate.builder()
+ .content(
+ Content.builder()
+ .parts(Part.fromFunctionCall("done", ImmutableMap.of()))
+ .build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("first-sig".getBytes(UTF_8));
+ }
+
+ // A thought run and an answer run flush separately and must not swap signatures: the answer's
+ // signature arrives on the chunk that triggers the flush of the thought.
+ @Test
+ public void processRawResponses_thoughtAndAnswerRuns_keepTheirOwnSignatures() {
+ GenerateContentResponse chunk1 =
+ toResponse(
+ Part.builder()
+ .text("Let me check.")
+ .thought(true)
+ .thoughtSignature("thought-sig".getBytes(UTF_8))
+ .build());
+ GenerateContentResponse chunk2 =
+ toResponseWithTextAndSignature("It is a dog.", "answer-sig", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thought()).hasValue(true);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("thought-sig".getBytes(UTF_8));
+ assertThat(parts.get(1).thoughtSignature()).hasValue("answer-sig".getBytes(UTF_8));
+ }
+
+ // A signature-only thought part keeps its signature on itself, as ADK Python does, rather than
+ // having it relocated onto the text around it.
+ @Test
+ public void processRawResponses_standaloneSignatureMidTextRun_keepsItsOwnSignature() {
+ GenerateContentResponse chunk1 = toResponseWithText("At minute 5 ");
+ GenerateContentResponse chunk2 =
+ toResponse(
+ Part.builder().thought(true).thoughtSignature("carried-sig".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk3 =
+ toResponseWithText("the presenter speaks.", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).text()).hasValue("At minute 5 ");
+ assertThat(parts.get(0).thoughtSignature()).isEmpty();
+ assertThat(parts.get(1).thoughtSignature()).hasValue("carried-sig".getBytes(UTF_8));
+ assertThat(parts.get(2).text()).hasValue("the presenter speaks.");
+ assertThat(parts.get(2).thoughtSignature()).isEmpty();
+ }
+
+ // A text chunk arriving mid-stream of a function call must not take the call's signature with it:
+ // the two runs flush together and each keeps its own.
+ @Test
+ public void processRawResponses_textInterleavedWithStreamedCall_keepsBothSignatures() {
+ GenerateContentResponse chunk1 =
+ toResponse(
+ Part.builder()
+ .functionCall(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(
+ PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build())
+ .thoughtSignature("fc-sig".getBytes(UTF_8))
+ .build());
+ GenerateContentResponse chunk2 = toResponseWithTextAndSignature("Working on it.", "text-sig");
+ GenerateContentResponse chunk3 =
+ toResponse(
+ Candidate.builder()
+ .content(
+ Content.builder()
+ .parts(
+ functionCallPart(
+ FunctionCall.builder()
+ .partialArgs(
+ PartialArg.builder()
+ .jsonPath("$.q")
+ .stringValue("lo")
+ .build())
+ .willContinue(false)
+ .build()))
+ .build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).text()).hasValue("Working on it.");
+ assertThat(parts.get(0).thoughtSignature()).hasValue("text-sig".getBytes(UTF_8));
+ assertThat(parts.get(1).functionCall().get().name()).hasValue("search");
+ assertThat(parts.get(1).thoughtSignature()).hasValue("fc-sig".getBytes(UTF_8));
+ }
+
+ // A signature-only part with no text run open must not be dropped, and the streamed call that
+ // follows must not inherit its signature.
+ @Test
+ public void processRawResponses_standaloneSignatureThenStreamedCall_keepsItOnItsOwnPart() {
+ GenerateContentResponse chunk1 =
+ toResponse(Part.builder().thought(true).thoughtSignature("sig-A".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk2 =
+ toResponse(
+ functionCallPart(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build()));
+ GenerateContentResponse chunk3 =
+ toResponse(
+ Candidate.builder()
+ .content(
+ Content.builder()
+ .parts(
+ functionCallPart(
+ FunctionCall.builder()
+ .partialArgs(
+ PartialArg.builder()
+ .jsonPath("$.q")
+ .stringValue("lo")
+ .build())
+ .willContinue(false)
+ .build()))
+ .build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("sig-A".getBytes(UTF_8));
+ assertThat(parts.get(1).functionCall().get().name()).hasValue("search");
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ }
+
+ // Two runs inside the final chunk each keep their own signature: the final-chunk re-attach must
+ // not stamp the first run's signature over the second's.
+ @Test
+ public void processRawResponses_thoughtAndAnswerInFinalChunk_keepTheirOwnSignatures() {
+ Part thought =
+ Part.builder()
+ .text("Let me check.")
+ .thought(true)
+ .thoughtSignature("thought-sig".getBytes(UTF_8))
+ .build();
+ Part answer =
+ Part.builder().text("It is a dog.").thoughtSignature("answer-sig".getBytes(UTF_8)).build();
+ GenerateContentResponse chunk =
+ toResponse(
+ Candidate.builder()
+ .content(Content.builder().parts(thought, answer).build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("thought-sig".getBytes(UTF_8));
+ assertThat(parts.get(1).thoughtSignature()).hasValue("answer-sig".getBytes(UTF_8));
+ }
+
+ // A carrier between two text runs ends the first and is emitted on its own; neither run's
+ // signature moves, so nothing is attributed to a part the model did not sign.
+ @Test
+ public void processRawResponses_carrierBetweenTwoRuns_isEmittedOnItsOwn() {
+ GenerateContentResponse chunk1 = toResponseWithTextAndSignature("Hello", "sig-A");
+ GenerateContentResponse chunk2 =
+ toResponse(Part.builder().thought(true).thoughtSignature("sig-B".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk3 = toResponseWithText(" world", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).text()).hasValue("Hello");
+ assertThat(parts.get(0).thoughtSignature()).hasValue("sig-A".getBytes(UTF_8));
+ assertThat(parts.get(1).thoughtSignature()).hasValue("sig-B".getBytes(UTF_8));
+ assertThat(parts.get(2).text()).hasValue(" world");
+ assertThat(parts.get(2).thoughtSignature()).isEmpty();
+ }
+
+ // An empty signature must not occupy the run's slot and block the real one behind it.
+ @Test
+ public void processRawResponses_emptySignatureThenRealOne_keepsTheRealOne() {
+ GenerateContentResponse chunk1 = toResponseWithTextAndSignature("Hel", "");
+ GenerateContentResponse chunk2 =
+ toResponseWithTextAndSignature("lo", "real-sig", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("real-sig".getBytes(UTF_8));
+ }
+
+ // A signature the aggregator already placed must not be handed out again by the final-chunk
+ // re-attach when the last part happens to be unsigned.
+ @Test
+ public void processRawResponses_signedThenUnsignedRunInFinalChunk_doesNotDuplicate() {
+ Part signed = Part.builder().text("A").thoughtSignature("sig-1".getBytes(UTF_8)).build();
+ Part unsigned = Part.builder().text("B").thought(true).build();
+ GenerateContentResponse chunk =
+ toResponse(
+ Candidate.builder()
+ .content(Content.builder().parts(signed, unsigned).build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("sig-1".getBytes(UTF_8));
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ }
+
+ // A carrier's signature stays on the carrier: neither the call after it nor the text after that
+ // may end up carrying the same bytes.
+ @Test
+ public void processRawResponses_carrierThenCallThenText_doesNotDuplicate() {
+ GenerateContentResponse chunk1 =
+ toResponse(Part.builder().thought(true).thoughtSignature("sig-A".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk2 =
+ toResponse(
+ functionCallPart(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build()));
+ GenerateContentResponse chunk3 =
+ toResponse(
+ functionCallPart(
+ FunctionCall.builder()
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("lo").build())
+ .willContinue(false)
+ .build()));
+ GenerateContentResponse chunk4 = toResponseWithText("Done.", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3, chunk4);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("sig-A".getBytes(UTF_8));
+ assertThat(parts.get(1).functionCall()).isPresent();
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ assertThat(parts.get(2).text()).hasValue("Done.");
+ assertThat(parts.get(2).thoughtSignature()).isEmpty();
+ }
+
+ // An empty text part that also carries payload must survive: Optional.isEmpty() is false for
+ // text="", so such a part misses the catch-all unless the emptiness is tested on the value.
+ @Test
+ public void processRawResponses_emptyTextPartWithInlineData_isKept() {
+ GenerateContentResponse chunk1 = toResponseWithText("Here.");
+ GenerateContentResponse chunk2 =
+ toResponse(
+ Part.builder()
+ .text("")
+ .inlineData(Blob.builder().mimeType("image/png").data(new byte[] {1, 2}).build())
+ .build());
+ GenerateContentResponse chunk3 = toResponseWithText("", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(1).inlineData()).isPresent();
+ }
+
+ // A signature appears on exactly one part: the one the model put it on. Neither the text run
+ // after the carrier nor the call after that may emit the same bytes.
+ @Test
+ public void processRawResponses_carriedSignature_isNotEmittedOnTwoParts() {
+ GenerateContentResponse chunk1 =
+ toResponse(Part.builder().thought(true).thoughtSignature("sig-A".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk2 = toResponseWithText("Working on it.");
+ GenerateContentResponse chunk3 =
+ toResponse(
+ functionCallPart(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build()));
+ GenerateContentResponse chunk4 =
+ toResponse(
+ Candidate.builder()
+ .content(
+ Content.builder()
+ .parts(
+ functionCallPart(
+ FunctionCall.builder()
+ .partialArgs(
+ PartialArg.builder()
+ .jsonPath("$.q")
+ .stringValue("lo")
+ .build())
+ .willContinue(false)
+ .build()))
+ .build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3, chunk4);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("sig-A".getBytes(UTF_8));
+ assertThat(parts.get(1).text()).hasValue("Working on it.");
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ assertThat(parts.get(2).functionCall()).isPresent();
+ assertThat(parts.get(2).thoughtSignature()).isEmpty();
+ }
+
+ // A streamed call that carries its own signature keeps it, and the carrier before it keeps its
+ // own: two signatures in, two signatures out, neither displaced.
+ @Test
+ public void processRawResponses_streamedCallKeepsItsOwnSignatureAfterACarrier() {
+ GenerateContentResponse chunk1 =
+ toResponse(Part.builder().thought(true).thoughtSignature("sig-A".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk2 =
+ toResponse(
+ Part.builder()
+ .functionCall(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(
+ PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build())
+ .thoughtSignature("fc-B".getBytes(UTF_8))
+ .build());
+ GenerateContentResponse chunk3 =
+ toResponse(
+ Candidate.builder()
+ .content(
+ Content.builder()
+ .parts(
+ functionCallPart(
+ FunctionCall.builder()
+ .partialArgs(
+ PartialArg.builder()
+ .jsonPath("$.q")
+ .stringValue("lo")
+ .build())
+ .willContinue(false)
+ .build()))
+ .build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("sig-A".getBytes(UTF_8));
+ assertThat(parts.get(1).thoughtSignature()).hasValue("fc-B".getBytes(UTF_8));
+ }
+
+ // Server-side media tools return signatures on parts holding nothing else. Such a part must
+ // survive as its own part rather than being folded into the surrounding text.
+ @Test
+ public void processRawResponses_contentFreeSignaturePart_isKept() {
+ GenerateContentResponse chunk1 = toResponseWithText("At minute 5 the presenter speaks.");
+ GenerateContentResponse chunk2 =
+ toResponse(Part.builder().thoughtSignature("call-context".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk3 = toResponseWithText("", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).isEmpty();
+ assertThat(parts.get(1).thoughtSignature()).hasValue("call-context".getBytes(UTF_8));
+ }
+
+ // The other half of the rule above: an empty text part carrying nothing at all only marks the end
+ // of a Gemini 3 stream, so it must not reach the caller as a part of its own.
+ @Test
+ public void processRawResponses_bareEmptyTextPart_isDropped() {
+ GenerateContentResponse chunk1 = toResponseWithText("Let me check.");
+ GenerateContentResponse chunk2 = toResponseWithText("", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).text()).hasValue("Let me check.");
+ }
+
+ // The wire shape the standard Gemini API actually sends for the same thing: the signature rides
+ // on a part whose text is present but empty, which Optional.isEmpty() does not recognise.
+ @Test
+ public void processRawResponses_emptyTextSignaturePart_isKept() {
+ GenerateContentResponse chunk1 = toResponseWithText("The answer is 42.");
+ GenerateContentResponse chunk2 =
+ toResponse(
+ Part.builder().text("").thoughtSignature("trailing-sig".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk3 = toResponseWithText("", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).thoughtSignature()).isEmpty();
+ assertThat(parts.get(1).thoughtSignature()).hasValue("trailing-sig".getBytes(UTF_8));
+ }
+
+ // Three parts, two signatures, and no relocation: the carrier keeps its own and the signed text
+ // run behind the call keeps its own.
+ @Test
+ public void processRawResponses_carrierThenCallThenSignedText_keepsEachSignatureInPlace() {
+ GenerateContentResponse chunk1 =
+ toResponse(Part.builder().thought(true).thoughtSignature("carry".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk2 =
+ toResponse(
+ functionCallPart(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build()));
+ GenerateContentResponse chunk3 =
+ toResponse(
+ functionCallPart(
+ FunctionCall.builder()
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("lo").build())
+ .willContinue(false)
+ .build()));
+ GenerateContentResponse chunk4 =
+ toResponseWithTextAndSignature("Here you go.", "text-B", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3, chunk4);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("carry".getBytes(UTF_8));
+ assertThat(parts.get(1).functionCall()).isPresent();
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ assertThat(parts.get(2).thoughtSignature()).hasValue("text-B".getBytes(UTF_8));
+ }
+
+ // Same invariant with a complete rather than a streamed call. The model did not sign the call,
+ // so the call goes out unsigned rather than inheriting the thought's signature.
+ @Test
+ public void processRawResponses_carrierThenCompleteCall_leavesTheCallUnsigned() {
+ GenerateContentResponse chunk1 =
+ toResponse(Part.builder().thought(true).thoughtSignature("carry".getBytes(UTF_8)).build());
+ GenerateContentResponse chunk2 =
+ toResponse(functionCallPart(FunctionCall.builder().name("search").id("fc-1").build()));
+ GenerateContentResponse chunk3 =
+ toResponseWithTextAndSignature("Here you go.", "text-B", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("carry".getBytes(UTF_8));
+ assertThat(parts.get(1).functionCall()).isPresent();
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ assertThat(parts.get(2).thoughtSignature()).hasValue("text-B".getBytes(UTF_8));
+ }
+
+ // A thought-marked server-side tool call carries payload the model has to see again, so only the
+ // marker and the signature may be folded away - the part itself has to reach the session intact.
+ @Test
+ public void processRawResponses_thoughtMarkedServerSideToolCall_survivesTheStream() {
+ Part toolCallPart =
+ Part.builder()
+ .thought(true)
+ .toolCall(ToolCall.builder().id("tc1").build())
+ .thoughtSignature("tool-sig".getBytes(UTF_8))
+ .build();
+ GenerateContentResponse chunk1 = toResponse(toolCallPart);
+ GenerateContentResponse chunk2 = toResponseWithText("Found it.", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0)).isEqualTo(toolCallPart);
+ assertThat(parts.get(1).text()).hasValue("Found it.");
+ }
+
+ // A zero-length signature must not occupy the streamed call's own slot and block the real one
+ // behind it, the same rule the text run's slot follows.
+ @Test
+ public void processRawResponses_emptySignatureThenRealOneOnAStreamedCall_keepsTheRealOne() {
+ GenerateContentResponse chunk1 =
+ toResponse(
+ Part.builder()
+ .functionCall(
+ FunctionCall.builder()
+ .name("search")
+ .partialArgs(
+ PartialArg.builder().jsonPath("$.q").stringValue("hel").build())
+ .willContinue(true)
+ .build())
+ .thoughtSignature(new byte[0])
+ .build());
+ GenerateContentResponse chunk2 =
+ toResponse(
+ Part.builder()
+ .functionCall(
+ FunctionCall.builder()
+ .partialArgs(PartialArg.builder().jsonPath("$.q").stringValue("lo").build())
+ .willContinue(false)
+ .build())
+ .thoughtSignature("real-sig".getBytes(UTF_8))
+ .build());
+ // The stream ends unsigned, so the final-chunk re-attach cannot supply the signature and the
+ // assertion is about the call's own slot rather than a fallback filling the gap.
+ GenerateContentResponse chunk3 = toResponseWithText("", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("real-sig".getBytes(UTF_8));
+ }
+
+ // The stream terminator is recognised by shape, not by identity: one that also carries an
+ // explicit thought marker is still nothing to keep.
+ @Test
+ public void processRawResponses_emptyTextPartWithExplicitThoughtFalse_isDropped() {
+ GenerateContentResponse chunk1 = toResponseWithText("Let me check.");
+ GenerateContentResponse chunk2 = toResponse(Part.builder().text("").thought(false).build());
+ GenerateContentResponse chunk3 = toResponseWithText("", FinishReason.Known.STOP);
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk1, chunk2, chunk3);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).text()).hasValue("Let me check.");
+ }
+
+ // A multi-part final chunk already carries each call's own signature. The re-attach reads part0
+ // only, so it must not stamp the first call's signature onto the last one.
+ @Test
+ public void processRawResponses_multiCallFinalChunkSignedOnPart0_doesNotStampTheLastCall() {
+ Part signedCall =
+ Part.builder()
+ .functionCall(FunctionCall.builder().name("get_weather").id("fc-0").build())
+ .thoughtSignature("call-sig".getBytes(UTF_8))
+ .build();
+ Part secondCall =
+ functionCallPart(FunctionCall.builder().name("get_weather").id("fc-1").build());
+ Part thirdCall =
+ functionCallPart(FunctionCall.builder().name("get_weather").id("fc-2").build());
+ GenerateContentResponse chunk =
+ toResponse(
+ Candidate.builder()
+ .content(Content.builder().parts(signedCall, secondCall, thirdCall).build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build());
+
+ LlmResponse finalResponse = aggregateFinalResponse(chunk);
+
+ ImmutableList parts = ImmutableList.copyOf(finalResponse.content().get().parts().get());
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).thoughtSignature()).hasValue("call-sig".getBytes(UTF_8));
+ assertThat(parts.get(1).thoughtSignature()).isEmpty();
+ assertThat(parts.get(2).thoughtSignature()).isEmpty();
+ }
+
@Test
public void functionCallThenEmptyTextWithStop_emitsPartialThenFinalAggregatedFunctionCall() {
Flowable rawResponses =
@@ -1303,6 +1941,16 @@ private static Predicate isFinalThoughtResponseWithUsageMetadata(
};
}
+ /** A partial chunk holding nothing but a thought marker and a signature. */
+ private static Predicate isPartialSignatureResponse(String expectedSignature) {
+ return response -> {
+ assertThat(response.partial()).hasValue(true);
+ assertThat(GeminiUtil.getPart0FromLlmResponse(response).flatMap(Part::thoughtSignature))
+ .hasValue(expectedSignature.getBytes(UTF_8));
+ return true;
+ };
+ }
+
private static Predicate isFinalThoughtResponseWithUsageMetadataAndSignature(
String expectedText,
GenerateContentResponseUsageMetadata expectedMetadata,
@@ -1477,6 +2125,28 @@ private GenerateContentResponse toResponseWithText(
.build();
}
+ private GenerateContentResponse toResponseWithTextAndSignature(String text, String signature) {
+ return toResponse(
+ Part.builder().text(text).thoughtSignature(signature.getBytes(UTF_8)).build());
+ }
+
+ private GenerateContentResponse toResponseWithTextAndSignature(
+ String text, String signature, FinishReason.Known finishReason) {
+ Part part = Part.builder().text(text).thoughtSignature(signature.getBytes(UTF_8)).build();
+ return toResponse(
+ Candidate.builder()
+ .content(Content.builder().parts(part).build())
+ .finishReason(new FinishReason(finishReason))
+ .build());
+ }
+
+ /** Runs the chunks through the aggregator and returns the final (non-partial) response. */
+ private static LlmResponse aggregateFinalResponse(GenerateContentResponse... chunks) {
+ return Iterables.getLast(
+ ImmutableList.copyOf(
+ Gemini.processRawResponses(Flowable.fromArray(chunks)).blockingIterable()));
+ }
+
private static Part functionCallPart(FunctionCall functionCall) {
return Part.builder().functionCall(functionCall).build();
}
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 a2e90a1f7..3870d3461 100644
--- a/core/src/test/java/com/google/adk/runner/RunnerTest.java
+++ b/core/src/test/java/com/google/adk/runner/RunnerTest.java
@@ -31,6 +31,7 @@
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -370,6 +371,33 @@ public void afterRunCallback_error() {
verify(plugin).afterRunCallback(any());
}
+ @Test
+ public void onRunErrorCallback_isCalled() {
+ Exception exception = new Exception("test run error");
+ TestLlm failingTestLlm = createTestLlm(Flowable.error(exception));
+ LlmAgent failingAgent = createTestAgentBuilder(failingTestLlm).build();
+
+ Runner failingRunner =
+ Runner.builder()
+ .app(
+ App.builder()
+ .name("test")
+ .rootAgent(failingAgent)
+ .plugins(ImmutableList.of(plugin))
+ .build())
+ .sessionService(this.runner.sessionService())
+ .build();
+
+ when(plugin.onRunErrorCallback(any(), any())).thenReturn(Completable.complete());
+
+ failingRunner
+ .runAsync("user", session.id(), createContent("from user"))
+ .test()
+ .assertError(exception);
+
+ verify(plugin).onRunErrorCallback(any(), eq(exception));
+ }
+
@Test
public void onUserMessageCallback_success() {
when(plugin.onUserMessageCallback(any(), any())).thenReturn(Maybe.just(pluginContent));
diff --git a/dev/pom.xml b/dev/pom.xml
index 7386e8eb3..aba1d13a7 100644
--- a/dev/pom.xml
+++ b/dev/pom.xml
@@ -18,7 +18,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
google-adk-dev
diff --git a/maven_plugin/examples/custom_tools/pom.xml b/maven_plugin/examples/custom_tools/pom.xml
index 2d9427378..78c6f500e 100644
--- a/maven_plugin/examples/custom_tools/pom.xml
+++ b/maven_plugin/examples/custom_tools/pom.xml
@@ -4,7 +4,7 @@
com.example
custom-tools-example
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
jar
ADK Custom Tools Example
diff --git a/maven_plugin/examples/simple-agent/pom.xml b/maven_plugin/examples/simple-agent/pom.xml
index a42ed255f..f6480247c 100644
--- a/maven_plugin/examples/simple-agent/pom.xml
+++ b/maven_plugin/examples/simple-agent/pom.xml
@@ -4,7 +4,7 @@
com.example
simple-adk-agent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
jar
Simple ADK Agent Example
diff --git a/maven_plugin/pom.xml b/maven_plugin/pom.xml
index 552388ccb..e4adca2a2 100644
--- a/maven_plugin/pom.xml
+++ b/maven_plugin/pom.xml
@@ -5,7 +5,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../pom.xml
diff --git a/pom.xml b/pom.xml
index 31fe8a93f..4ed57dab5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
pom
Google Agent Development Kit Maven Parent POM
@@ -42,6 +42,9 @@
17
${java.version}
UTF-8
+
+ ${project.basedir}/target
3.6.0
1.11.1
@@ -299,6 +302,7 @@
2.2.4
+ ${adk.build.directory}
diff --git a/tutorials/city-time-weather/pom.xml b/tutorials/city-time-weather/pom.xml
index c17c86e86..626a60c4c 100644
--- a/tutorials/city-time-weather/pom.xml
+++ b/tutorials/city-time-weather/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../pom.xml
diff --git a/tutorials/live-audio-single-agent/pom.xml b/tutorials/live-audio-single-agent/pom.xml
index dfb7cd407..50cebaadd 100644
--- a/tutorials/live-audio-single-agent/pom.xml
+++ b/tutorials/live-audio-single-agent/pom.xml
@@ -20,7 +20,7 @@
com.google.adk
google-adk-parent
- 1.7.2-SNAPSHOT
+ 1.8.1-SNAPSHOT
../../pom.xml