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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion core/src/main/java/com/google/adk/flows/llmflows/Contents.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,29 @@ private ImmutableList<Content> getCurrentTurnContents(
List<Event> events,
String agentName,
boolean groupFunctionResponses) {
Set<String> 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<String> 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) {
Expand Down Expand Up @@ -185,14 +205,19 @@ private ImmutableList<Content> getContents(
}

// Merge after compaction, which may end mid-run, and before other agents are narrated.
Set<String> narratedCallIds = new HashSet<>();
List<Event> 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);
}
}
Expand Down Expand Up @@ -612,6 +637,20 @@ private static List<Event> recoverCompactedFunctionCalls(
return result;
}

/** The non-empty IDs of {@code event}'s function calls. */
private static ImmutableList<String> 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<String> 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()
Expand Down
210 changes: 210 additions & 0 deletions core/src/test/java/com/google/adk/flows/llmflows/ContentsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Content> 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<Content> 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<Content> 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<Content> 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<Content> 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<Event> events =
Expand Down Expand Up @@ -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<Content> 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<Content> 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<Content> 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<Event> events =
ImmutableList.of(
createUserEvent("u1", "Query 1"),
createFunctionResponseEvent(
USER, "fr1", "ask_user", "missing", ImmutableMap.of("result", "yes")));

List<Content> 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<Event> events =
ImmutableList.of(
createUserEvent("u1", "Query 1"),
createFunctionCallEvent("agent_10", "fc1", "ask_user", "call1"),
createUserEvent("u2", "Query 2"),
offBranchAnswer);

List<Content> 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");
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions core/src/test/java/com/google/adk/workflow/WorkflowHitlTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Event> 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<Event> 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 =
Expand Down
Loading