diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f2c43d597..3800c0691 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.7.1" + ".": "1.8.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cace1a7..3e13779af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.8.0](https://github.com/google/adk-java/compare/v1.7.1...v1.8.0) (2026-08-13) + + +### Features + +* Add onRunErrorCallback to ADK Plugin and Runner ([3e6b915](https://github.com/google/adk-java/commit/3e6b9154e089f24daf43c9ded7e4e40483fb7995)) + + +### Bug Fixes + +* **a2a:** drop unparseable A2A metadata instead of aborting conversion ([b75c916](https://github.com/google/adk-java/commit/b75c9169c630ab0450d16aa74898da6b953d0e78)) +* **a2a:** fail the A2A stream in the handler, not via the transport ([faa3482](https://github.com/google/adk-java/commit/faa3482fa70a5f750e4db04335ca5a5091be471e)) +* **a2a:** guard null DataPart metadata in ResponseConverter ([fcfd9bd](https://github.com/google/adk-java/commit/fcfd9bd8b1b5516932b9c5d72a62a191aea88e13)) +* **a2a:** require explicit adk_type metadata to convert A2A DataParts ([b704c5f](https://github.com/google/adk-java/commit/b704c5fc963d315c624b06d97a6a00d963e54cc0)) +* **core:** only resume tool confirmations for calls this agent emitted ([e5aba3a](https://github.com/google/adk-java/commit/e5aba3aa08c5b85a892e0c9164fa0ab8513786fa)) +* keep thought signature and tool call parts through streaming and history ([d7355a7](https://github.com/google/adk-java/commit/d7355a712345864682134762df890bf7b713b8c4)) +* **runner:** build a new message when saving input blobs, instead of writing into the caller's Content ([80c1a21](https://github.com/google/adk-java/commit/80c1a21da378f121fef3af065eb65dce0e0080c9)) +* stop returning exception text to remote A2A peers ([a3df463](https://github.com/google/adk-java/commit/a3df4632c19d857552af3d2c38373aabea9069de)) +* Update default BigQueryLoggerConfig table name and remove default dataset ID ([723a2ef](https://github.com/google/adk-java/commit/723a2ef0c4929879a6bd831287c34002ef02ef00)) +* update stream completion check in A2A SDK to handle all terminal and interrupted task states ([2b87d65](https://github.com/google/adk-java/commit/2b87d65d9704a61ff4668b8c9482a79fef9fe0d4)) + ## [1.7.1](https://github.com/google/adk-java/compare/v1.7.0...v1.7.1) (2026-07-28) diff --git a/README.md b/README.md index 07ff3c5fb..b5747e371 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ If you're using Maven, add the following to your dependencies: com.google.adk google-adk - 1.7.1 + 1.8.0 com.google.adk google-adk-dev - 1.7.1 + 1.8.0 ``` diff --git a/a2a/pom.xml b/a2a/pom.xml index 1232a2d39..d47739a89 100644 --- a/a2a/pom.xml +++ b/a2a/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.7.2-SNAPSHOT + 1.8.1-SNAPSHOT google-adk-a2a diff --git a/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java b/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java index d4e094710..f134ee43b 100644 --- a/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java +++ b/a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java @@ -36,6 +36,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.A2AClientException; @@ -228,14 +229,25 @@ protected Flowable runAsyncImpl(InvocationContext invocationContext) { emitter -> { StreamHandler handler = new StreamHandler( - emitter.serialize(), invocationContext, requestJson, streaming, name()); + emitter.serialize(), + invocationContext, + requestJson, + name(), + /* subscribeThread= */ Thread.currentThread()); ImmutableList> consumers = ImmutableList.of(handler::handleEvent); - a2aClient.sendMessage(originalMessage, consumers, handler::handleError, null); + handler.dispatchSynchronously( + () -> a2aClient.sendMessage(originalMessage, consumers, handler::handleError, null)); }, BackpressureStrategy.BUFFER); } + /** A {@link Runnable} that may throw, so the a2a client's checked exception can propagate. */ + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } + private @Nullable String serializeMessageToJson(Message message) { try { return objectMapper.writeValueAsString(message); @@ -249,8 +261,26 @@ private static class StreamHandler { private final FlowableEmitter emitter; private final InvocationContext invocationContext; private final String requestJson; - private final boolean streaming; private final String agentName; + private final Thread subscribeThread; + + /** True while {@code sendMessage} is running on {@link #subscribeThread}. */ + private volatile boolean dispatchingSynchronously = false; + + /** + * Runs the a2a client call that this handler is the consumer for, recording that any event + * delivered on {@link #subscribeThread} while it is on the stack came from synchronous + * dispatch. Owned here so the window cannot be left open by an edit at the call site. + */ + void dispatchSynchronously(ThrowingRunnable dispatch) throws Exception { + dispatchingSynchronously = true; + try { + dispatch.run(); + } finally { + dispatchingSynchronously = false; + } + } + private boolean done = false; private final StringBuilder textBuffer = new StringBuilder(); private final StringBuilder thoughtsBuffer = new StringBuilder(); @@ -259,16 +289,20 @@ private static class StreamHandler { FlowableEmitter emitter, InvocationContext invocationContext, String requestJson, - boolean streaming, - String agentName) { + String agentName, + Thread subscribeThread) { this.emitter = emitter; this.invocationContext = invocationContext; this.requestJson = requestJson; - this.streaming = streaming; this.agentName = agentName; + this.subscribeThread = subscribeThread; } synchronized void handleError(Throwable e) { + handleError("Failed to communicate with the remote agent", e); + } + + synchronized void handleError(String message, Throwable e) { // Mark the flow as done if it is already cancelled. if (!done) { done = emitter.isCancelled(); @@ -280,7 +314,7 @@ synchronized void handleError(Throwable e) { } // If the error is raised, complete the flow with an error. done = true; - emitter.tryOnError(new A2AClientError("Failed to communicate with the remote agent", e)); + emitter.tryOnError(new A2AClientError(message, e)); } // TODO: b/483038527 - The synchronized block might block the thread, we should optimize for @@ -296,8 +330,33 @@ synchronized void handleEvent(ClientEvent clientEvent, AgentCard unused) { return; } - Optional eventOpt = - ResponseConverter.clientEventToEvent(clientEvent, invocationContext); + Optional 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 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