From 658fabaaa2d020160dab59fc8a64a81f63628e3c Mon Sep 17 00:00:00 2001 From: Damian Momot Date: Wed, 4 Nov 2026 09:54:57 -0800 Subject: [PATCH] fix: show an agent the user's answer to another agent's call PiperOrigin-RevId: 1013934648 --- .../google/adk/flows/llmflows/Contents.java | 41 +++- .../adk/flows/llmflows/ContentsTest.java | 210 ++++++++++++++++++ .../google/adk/workflow/WorkflowHitlTest.java | 46 ++++ 3 files changed, 296 insertions(+), 1 deletion(-) 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 8aba8c300..3a1a080f8 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 @@ -113,9 +113,29 @@ private ImmutableList getCurrentTurnContents( List events, String agentName, boolean groupFunctionResponses) { + Set visibleCallIds = new HashSet<>(); + for (Event event : events) { + if (shouldIncludeEventInContext(currentBranch, event)) { + visibleCallIds.addAll(callIds(event)); + } + } + // A user's answer to a visible call continues that call's turn instead of starting one. + Set unmatchedAnswerIds = new HashSet<>(); // Find the latest event that starts the current turn and process from there. for (int i = events.size() - 1; i >= 0; i--) { Event event = events.get(i); + if (shouldIncludeEventInContext(currentBranch, event)) { + callIds(event).forEach(unmatchedAnswerIds::remove); + if (event.author().equals(Role.USER)) { + event.functionResponses().stream() + .flatMap(response -> response.id().stream()) + .filter(visibleCallIds::contains) + .forEach(unmatchedAnswerIds::add); + } + } + if (!unmatchedAnswerIds.isEmpty()) { + continue; + } TranscriptionKind kind = transcriptionKind(event); int start = -1; if (kind != null) { @@ -185,14 +205,19 @@ private ImmutableList getContents( } // Merge after compaction, which may end mid-run, and before other agents are narrated. + Set narratedCallIds = new HashSet<>(); List resultEvents = new ArrayList<>(); for (Event event : mergeTranscriptions(filteredEvents)) { - if (isOtherAgentReply(agentName, event)) { + // The user's answer to another agent's call is narrated with that call. + if (isOtherAgentReply(agentName, event) || respondsToAny(event, narratedCallIds)) { + narratedCallIds.addAll(callIds(event)); Event foreignEvent = convertForeignEvent(event); if (foreignEvent != null) { resultEvents.add(foreignEvent); } } else { + // The agent's own call keeps its answer, even if it reuses another agent's call ID. + callIds(event).forEach(narratedCallIds::remove); resultEvents.add(event); } } @@ -612,6 +637,20 @@ private static List recoverCompactedFunctionCalls( return result; } + /** The non-empty IDs of {@code event}'s function calls. */ + private static ImmutableList callIds(Event event) { + return event.functionCalls().stream() + .flatMap(call -> call.id().stream()) + .filter(id -> !id.isEmpty()) + .collect(toImmutableList()); + } + + /** Whether {@code event} has a function response whose ID is in {@code callIds}. */ + private static boolean respondsToAny(Event event, Set callIds) { + return event.functionResponses().stream() + .anyMatch(response -> response.id().map(callIds::contains).orElse(false)); + } + /** Whether the event is a reply from another agent. */ private static boolean isOtherAgentReply(String agentName, Event event) { return !agentName.isEmpty() 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 bba1fbe50..b170861d4 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 @@ -33,6 +33,7 @@ import com.google.adk.models.Model; import com.google.adk.sessions.InMemorySessionService; import com.google.adk.sessions.Session; +import com.google.adk.workflow.NodeInfo; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.genai.types.Blob; @@ -444,6 +445,113 @@ public void convertForeignEvent_eventsFromOtherAgents_returnsContextualOnlyEvent .inOrder(); } + @Test + public void processRequest_userAnswerToNodesCall_isNarratedWithTheCall() { + Event u1 = createUserEvent("u1", "Query 1"); + Event o1 = nodeEvent(createFunctionCallEvent(OTHER_AGENT, "o1", "ask_user", "call1")); + Event fr1 = + createFunctionResponseEvent( + USER, "fr1", "ask_user", "call1", ImmutableMap.of("result", "yes")); + + List result = runContentsProcessor(ImmutableList.of(u1, o1, fr1)); + + assertThat(result) + .containsExactly( + u1.content().get(), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[other_agent] called tool `ask_user` with parameters:", "{}")), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[user] `ask_user` tool returned result:", "{\"result\":\"yes\"}"))) + .inOrder(); + } + + @Test + public void processRequest_userAnswerToOtherAgentsCallOutsideAWorkflow_isNarratedWithTheCall() { + Event u1 = createUserEvent("u1", "Query 1"); + Event o1 = createFunctionCallEvent(OTHER_AGENT, "o1", "ask_user", "call1"); + Event fr1 = + createFunctionResponseEvent( + USER, "fr1", "ask_user", "call1", ImmutableMap.of("result", "yes")); + Event u2 = createUserEvent("u2", "Query 2"); + + List result = runContentsProcessor(ImmutableList.of(u1, o1, fr1, u2)); + + assertThat(result) + .containsExactly( + u1.content().get(), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[other_agent] called tool `ask_user` with parameters:", "{}")), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[user] `ask_user` tool returned result:", "{\"result\":\"yes\"}")), + u2.content().get()) + .inOrder(); + } + + @Test + public void processRequest_userAnswerToOwnCall_staysAFunctionResponse() { + Event u1 = createUserEvent("u1", "Query 1"); + Event fc1 = createFunctionCallEvent("fc1", "approve", "call1"); + Event fr1 = + createFunctionResponseEvent(USER, "fr1", "approve", "call1", ImmutableMap.of("ok", true)); + + List result = runContentsProcessor(ImmutableList.of(u1, fc1, fr1)); + + assertThat(result) + .containsExactly(u1.content().get(), fc1.content().get(), fr1.content().get()) + .inOrder(); + } + + @Test + public void processRequest_userAnswerToOwnCallReusingOtherAgentsCallId_staysAFunctionResponse() { + Event u1 = createUserEvent("u1", "Query 1"); + Event o1 = createFunctionCallEvent(OTHER_AGENT, "o1", "ask_user", "call1"); + Event fr1 = + createFunctionResponseEvent( + USER, "fr1", "ask_user", "call1", ImmutableMap.of("result", "yes")); + Event fc2 = createFunctionCallEvent("fc2", "approve", "call1"); + Event fr2 = + createFunctionResponseEvent(USER, "fr2", "approve", "call1", ImmutableMap.of("ok", true)); + + List result = runContentsProcessor(ImmutableList.of(u1, o1, fr1, fc2, fr2)); + + assertThat(result) + .containsExactly( + u1.content().get(), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[other_agent] called tool `ask_user` with parameters:", "{}")), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[user] `ask_user` tool returned result:", "{\"result\":\"yes\"}")), + fc2.content().get(), + fr2.content().get()) + .inOrder(); + } + + @Test + public void processRequest_userAnswerToOtherAgentsCallWithEmptyId_isLeftOut() { + Event u1 = createUserEvent("u1", "Query 1"); + Event o1 = createFunctionCallEvent(OTHER_AGENT, "o1", "ask_user", ""); + Event fr1 = + createFunctionResponseEvent(USER, "fr1", "ask_user", "", ImmutableMap.of("result", "yes")); + Event u2 = createUserEvent("u2", "Query 2"); + + List result = runContentsProcessor(ImmutableList.of(u1, o1, fr1, u2)); + + assertThat(result) + .containsExactly( + u1.content().get(), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[other_agent] called tool `ask_user` with parameters:", "{}")), + u2.content().get()) + .inOrder(); + } + @Test public void processRequest_includeContentsNone_lastEventIsUser() { ImmutableList events = @@ -537,6 +645,103 @@ public void processRequest_includeContentsNone_asyncFRWithinTurn() { .inOrder(); } + @Test + public void + processRequest_includeContentsNone_userAnswerToOtherAgentsCall_reachesBackToTheCall() { + Event u1 = createUserEvent("u1", "Query 1"); + Event o1 = createFunctionCallEvent(OTHER_AGENT, "o1", "ask_user", "call1"); + Event fr1 = + createFunctionResponseEvent( + USER, "fr1", "ask_user", "call1", ImmutableMap.of("result", "yes")); + + List result = + runContentsProcessorWithIncludeContents( + ImmutableList.of(u1, o1, fr1), LlmAgent.IncludeContents.NONE); + + assertThat(result) + .containsExactly( + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[other_agent] called tool `ask_user` with parameters:", "{}")), + Content.fromParts( + otherAgentPreamblePart(), + otherAgentPart("[user] `ask_user` tool returned result:", "{\"result\":\"yes\"}"))) + .inOrder(); + } + + @Test + public void processRequest_includeContentsNone_userAnswerToOwnCall_reachesBackToTheUsersTurn() { + Event u1 = createUserEvent("u1", "Query 1"); + Event fc1 = createFunctionCallEvent("fc1", "approve", "call1"); + Event fr1 = + createFunctionResponseEvent(USER, "fr1", "approve", "call1", ImmutableMap.of("ok", true)); + + List result = + runContentsProcessorWithIncludeContents( + ImmutableList.of(u1, fc1, fr1), LlmAgent.IncludeContents.NONE); + + assertThat(result) + .containsExactly(u1.content().get(), fc1.content().get(), fr1.content().get()) + .inOrder(); + } + + @Test + public void + processRequest_includeContentsNone_userTurnBetweenCallAndAnswer_doesNotStartTheTurn() { + Event u1 = createUserEvent("u1", "Query 1"); + Event fc1 = createFunctionCallEvent("fc1", "approve", "call1"); + Event u2 = createUserEvent("u2", "Any news?"); + Event a1 = createAgentEvent("a1", "Still waiting."); + Event fr1 = + createFunctionResponseEvent(USER, "fr1", "approve", "call1", ImmutableMap.of("ok", true)); + + List result = + runContentsProcessorWithIncludeContents( + ImmutableList.of(u1, fc1, u2, a1, fr1), LlmAgent.IncludeContents.NONE); + + assertThat(result) + .containsExactly(u1.content().get(), fc1.content().get(), fr1.content().get()) + .inOrder(); + } + + // With no call to reach back to, the unpaired answer still starts the turn and is dropped. + @Test + public void processRequest_includeContentsNone_userAnswerToUnknownCall_doesNotReachBack() { + ImmutableList events = + ImmutableList.of( + createUserEvent("u1", "Query 1"), + createFunctionResponseEvent( + USER, "fr1", "ask_user", "missing", ImmutableMap.of("result", "yes"))); + + List result = + runContentsProcessorWithIncludeContents(events, LlmAgent.IncludeContents.NONE); + + assertThat(result).isEmpty(); + } + + // An answer outside this agent's context does not move its turn back to the call. + @Test + public void processRequest_includeContentsNone_userAnswerOnAnotherBranch_doesNotReachBack() { + Event offBranchAnswer = + createFunctionResponseEvent( + USER, "fr1", "ask_user", "call1", ImmutableMap.of("result", "yes")) + .toBuilder() + .branch("root.agent_1") + .build(); + ImmutableList events = + ImmutableList.of( + createUserEvent("u1", "Query 1"), + createFunctionCallEvent("agent_10", "fc1", "ask_user", "call1"), + createUserEvent("u2", "Query 2"), + offBranchAnswer); + + List contents = + runContentsProcessorOnBranch( + events, "agent_10", "root.agent_10", LlmAgent.IncludeContents.NONE); + + assertThat(turns(contents)).containsExactly("user: Query 2"); + } + @Test public void processRequest_sequentialFCFR_returnsOriginalList() { Event e1 = createUserEvent("e1", "Not important"); @@ -2492,6 +2697,11 @@ private static Event createFunctionCallEvent( .build(); } + /** Returns {@code event} as a workflow node emits it, carrying the node's path. */ + private static Event nodeEvent(Event event) { + return event.toBuilder().nodeInfo(NodeInfo.builder().path("wf@1/ask@1").build()).build(); + } + private static Event createAgentEventWithTextAndFunctionCall( String agent, String id, diff --git a/core/src/test/java/com/google/adk/workflow/WorkflowHitlTest.java b/core/src/test/java/com/google/adk/workflow/WorkflowHitlTest.java index 5c7866522..98ddfe7c1 100644 --- a/core/src/test/java/com/google/adk/workflow/WorkflowHitlTest.java +++ b/core/src/test/java/com/google/adk/workflow/WorkflowHitlTest.java @@ -30,13 +30,17 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import com.google.adk.agents.LlmAgent; import com.google.adk.events.Event; +import com.google.adk.testing.TestLlm; import com.google.adk.testing.TestNode; +import com.google.adk.testing.TestUtils; import com.google.adk.workflow.WorkflowResumeTesting.FailedTurn; import com.google.adk.workflow.WorkflowResumeTesting.WorkflowSession; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; import com.google.genai.types.Content; import com.google.genai.types.FunctionCall; import com.google.genai.types.Schema; @@ -253,6 +257,48 @@ public void resume_jsonNullTextAnswerToANonNullableSchema_failsNamingTheInterrup assertThat(error).hasMessageThat().contains("Validation failed for interrupt ask_1"); } + @Test + public void resume_answeredRequestThenAnLlmAgentNode_givesTheModelTheAnswerAsContext() { + TestLlm model = TestLlm.create(TestUtils.createTextLlmResponse("revised")); + LlmAgent agent = LlmAgent.builder().name("agent").model(model).build(); + Node ask = ask("ask", request("ask_1")); + WorkflowSession session = + new WorkflowSession(workflow(new Edge(START, ask), new Edge(ask, agent))); + session.advance(go()); + + ImmutableList events = + session.turn(requestAnswer("ask_1", ImmutableMap.of("result", "shorter"))); + + String lastTurn = Iterables.getLast(model.getLastRequest().contents()).text(); + assertThat(lastTurn).contains("[user] `adk_request_input` tool returned result:"); + assertThat(lastTurn).contains("shorter"); + assertThat(events.stream().map(Event::stringifyContent)).contains("revised"); + } + + @Test + public void + resume_answeredRequestThenAnLlmAgentNodeWithIncludeContentsNone_givesTheModelTheAnswer() { + TestLlm model = TestLlm.create(TestUtils.createTextLlmResponse("revised")); + LlmAgent agent = + LlmAgent.builder() + .name("agent") + .model(model) + .includeContents(LlmAgent.IncludeContents.NONE) + .build(); + Node ask = ask("ask", request("ask_1")); + WorkflowSession session = + new WorkflowSession(workflow(new Edge(START, ask), new Edge(ask, agent))); + session.advance(go()); + + ImmutableList events = + session.turn(requestAnswer("ask_1", ImmutableMap.of("result", "shorter"))); + + String lastTurn = Iterables.getLast(model.getLastRequest().contents()).text(); + assertThat(lastTurn).contains("[user] `adk_request_input` tool returned result:"); + assertThat(lastTurn).contains("shorter"); + assertThat(events.stream().map(Event::stringifyContent)).contains("revised"); + } + /** A session paused on {@code ask_1}, whose answer must match {@code schema}. */ private static WorkflowSession sessionAwaitingAnswer(Schema schema) { Node ask =