Skip to content

Commit 6bc26df

Browse files
damianmomotgooglecopybara-github
authored andcommitted
fix: route HITL confirmation back to originating sub-agent in workflow agents
When an LlmAgent that uses a tool requiring Human-in-the-Loop confirmation is wrapped inside a non-LlmAgent workflow agent (e.g. SequentialAgent, ParallelAgent, LoopAgent), the runner used to fall back to the root agent on confirmation resumption. This caused 'VerifyException: Tool not found' because the root agent does not have the sub-agent's tools registered. Runner.findAgentToRun now first checks whether the last event is a function response and, if so, routes it back to the agent that emitted the matching function call (looked up by id), regardless of whether that agent's parent chain is fully transferable. This mirrors the Python ADK behaviour in Runner._find_agent_to_run via find_matching_function_call. PiperOrigin-RevId: 921354317
1 parent 49ff63b commit 6bc26df

2 files changed

Lines changed: 148 additions & 5 deletions

File tree

core/src/main/java/com/google/adk/runner/Runner.java

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package com.google.adk.runner;
1818

19+
import static com.google.common.collect.ImmutableSet.toImmutableSet;
20+
1921
import com.google.adk.agents.ActiveStreamingTool;
2022
import com.google.adk.agents.BaseAgent;
2123
import com.google.adk.agents.ContextCacheConfig;
@@ -45,6 +47,8 @@
4547
import com.google.adk.utils.CollectionUtils;
4648
import com.google.common.base.Preconditions;
4749
import com.google.common.collect.ImmutableList;
50+
import com.google.common.collect.ImmutableSet;
51+
import com.google.common.collect.Iterables;
4852
import com.google.common.collect.MapMaker;
4953
import com.google.errorprone.annotations.CanIgnoreReturnValue;
5054
import com.google.genai.types.AudioTranscriptionConfig;
@@ -64,6 +68,7 @@
6468
import java.util.Collections;
6569
import java.util.List;
6670
import java.util.Map;
71+
import java.util.Objects;
6772
import java.util.Optional;
6873
import java.util.concurrent.ConcurrentHashMap;
6974
import java.util.concurrent.ConcurrentMap;
@@ -766,12 +771,15 @@ private boolean isTransferableAcrossAgentTree(BaseAgent agentToRun) {
766771
return true;
767772
}
768773

769-
/**
770-
* Returns the agent that should handle the next request based on session history.
771-
*
772-
* @return agent to run.
773-
*/
774+
/** Returns the agent that should handle the next request based on session history. */
774775
private BaseAgent findAgentToRun(Session session, BaseAgent rootAgent) {
776+
// Route function responses back to the originating function-call author so HITL tool
777+
// confirmations resume the sub-agent even through non-LlmAgent ancestors.
778+
Optional<BaseAgent> functionCallAuthor = findFunctionCallAuthor(session, rootAgent);
779+
if (functionCallAuthor.isPresent()) {
780+
return functionCallAuthor.get();
781+
}
782+
775783
List<Event> events = new ArrayList<>(session.events());
776784
Collections.reverse(events);
777785

@@ -802,6 +810,39 @@ private BaseAgent findAgentToRun(Session session, BaseAgent rootAgent) {
802810
return rootAgent;
803811
}
804812

813+
/**
814+
* If the last event is a function response, returns the agent that emitted the matching function
815+
* call (by id), or empty if no match is found in the agent tree.
816+
*/
817+
private static Optional<BaseAgent> findFunctionCallAuthor(Session session, BaseAgent rootAgent) {
818+
List<Event> events = session.events();
819+
if (events.isEmpty()) {
820+
return Optional.empty();
821+
}
822+
ImmutableSet<String> functionResponseIds =
823+
Iterables.getLast(events).functionResponses().stream()
824+
.map(fr -> fr.id().orElse(null))
825+
.filter(Objects::nonNull)
826+
.collect(toImmutableSet());
827+
828+
// Iterate in reverse to prefer the most recent matching call, mirroring Python ADK's
829+
// find_event_by_function_call_id. Function call IDs are unique in normal flows, so this
830+
// is defense-in-depth and not covered by mutation testing.
831+
List<Event> precedingEvents = new ArrayList<>(events.subList(0, events.size() - 1));
832+
Collections.reverse(precedingEvents);
833+
for (Event event : precedingEvents) {
834+
boolean matches =
835+
event.functionCalls().stream()
836+
.map(fc -> fc.id().orElse(null))
837+
.filter(Objects::nonNull)
838+
.anyMatch(functionResponseIds::contains);
839+
if (matches && event.author() != null) {
840+
return rootAgent.findAgent(event.author());
841+
}
842+
}
843+
return Optional.empty();
844+
}
845+
805846
private void addActiveStreamingTools(InvocationContext invocationContext, List<BaseTool> tools) {
806847
tools.stream()
807848
.filter(FunctionTool.class::isInstance)

core/src/test/java/com/google/adk/runner/RunnerTest.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import com.google.adk.agents.LiveRequestQueue;
4343
import com.google.adk.agents.LlmAgent;
4444
import com.google.adk.agents.RunConfig;
45+
import com.google.adk.agents.SequentialAgent;
4546
import com.google.adk.apps.App;
4647
import com.google.adk.artifacts.BaseArtifactService;
4748
import com.google.adk.events.Event;
@@ -1568,6 +1569,107 @@ public void runAsync_withToolConfirmation() {
15681569
.inOrder();
15691570
}
15701571

1572+
// HITL tool confirmation must resume the originating sub-agent even when wrapped inside a
1573+
// non-LlmAgent workflow agent (e.g. SequentialAgent).
1574+
@Test
1575+
public void runAsync_withToolConfirmation_inSequentialAgentSubAgent_resumesSubAgent() {
1576+
TestLlm childTestLlm =
1577+
createTestLlm(
1578+
createFunctionCallLlmResponse(
1579+
"tool_call_id", "echoTool", ImmutableMap.of("message", "hello")),
1580+
createTextLlmResponse("Response after observing tool needs confirmation."),
1581+
createTextLlmResponse("Response after user confirmed."));
1582+
LlmAgent childAgent =
1583+
createTestAgentBuilder(childTestLlm)
1584+
.name("child_agent")
1585+
.tools(FunctionTool.create(Tools.class, "echoTool", /* requireConfirmation= */ true))
1586+
.build();
1587+
SequentialAgent workflowAgent =
1588+
SequentialAgent.builder()
1589+
.name("workflow_agent")
1590+
.subAgents(ImmutableList.of(childAgent))
1591+
.build();
1592+
// Root transfers to workflow_agent to mirror the bug report's control flow.
1593+
TestLlm rootTestLlm =
1594+
createTestLlm(
1595+
createLlmResponse(
1596+
Content.fromParts(
1597+
Part.fromFunctionCall(
1598+
"transfer_to_agent", ImmutableMap.of("agent_name", "workflow_agent")))));
1599+
LlmAgent rootAgent =
1600+
createTestAgentBuilder(rootTestLlm)
1601+
.name("root_agent")
1602+
.subAgents(ImmutableList.of(workflowAgent))
1603+
.build();
1604+
Runner runner =
1605+
Runner.builder().app(App.builder().name("test").rootAgent(rootAgent).build()).build();
1606+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
1607+
1608+
List<Event> eventsBeforeConfirmation =
1609+
runner
1610+
.runAsync("user", session.id(), Content.fromParts(Part.fromText("from user")))
1611+
.toList()
1612+
.blockingGet();
1613+
FunctionCall askUserConfirmationFunctionCall =
1614+
Iterables.getOnlyElement(
1615+
eventsBeforeConfirmation.stream()
1616+
.map(Functions::getAskUserConfirmationFunctionCalls)
1617+
.filter(functionCalls -> !functionCalls.isEmpty())
1618+
.findFirst()
1619+
.get());
1620+
List<Event> eventsAfterConfirmation =
1621+
runner
1622+
.runAsync(
1623+
"user",
1624+
session.id(),
1625+
Content.fromParts(
1626+
Part.builder()
1627+
.functionResponse(
1628+
FunctionResponse.builder()
1629+
.id(askUserConfirmationFunctionCall.id().get())
1630+
.name(askUserConfirmationFunctionCall.name().get())
1631+
.response(ImmutableMap.of("confirmed", true)))
1632+
.build()))
1633+
.toList()
1634+
.blockingGet();
1635+
1636+
// The originating child agent (not the root agent) must execute the tool.
1637+
assertThat(simplifyEvents(eventsAfterConfirmation))
1638+
.containsExactly(
1639+
"child_agent: FunctionResponse(name=echoTool, response={message=hello})",
1640+
"child_agent: Response after user confirmed.")
1641+
.inOrder();
1642+
}
1643+
1644+
// Orphan function responses (id not matching any prior call) should fall back to the root agent.
1645+
@Test
1646+
public void runAsync_withFunctionResponseNotMatchingAnyCall_fallsBackToRootAgent() {
1647+
TestLlm rootLlm = createTestLlm(createTextLlmResponse("after function response"));
1648+
LlmAgent rootAgent = createTestAgentBuilder(rootLlm).name("root_agent").build();
1649+
Runner runner =
1650+
Runner.builder().app(App.builder().name("test").rootAgent(rootAgent).build()).build();
1651+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
1652+
1653+
// Function response with id that does not match any prior function call.
1654+
List<Event> events =
1655+
runner
1656+
.runAsync(
1657+
"user",
1658+
session.id(),
1659+
Content.fromParts(
1660+
Part.builder()
1661+
.functionResponse(
1662+
FunctionResponse.builder()
1663+
.id("non_existent_id")
1664+
.name("orphanFn")
1665+
.response(ImmutableMap.of("x", 1)))
1666+
.build()))
1667+
.toList()
1668+
.blockingGet();
1669+
1670+
assertThat(simplifyEvents(events)).containsExactly("root_agent: after function response");
1671+
}
1672+
15711673
@Test
15721674
public void close_closesPluginsAndCodeExecutors() {
15731675
BasePlugin plugin = mockPlugin("close_test_plugin");

0 commit comments

Comments
 (0)