Fix: resolve the exit_loop built-in tool in YAML agent configs - #572
Fix: resolve the exit_loop built-in tool in YAML agent configs#572AmaadMartin wants to merge 4 commits into
Conversation
The conformance agent registry mapped every name in BUILTIN_TOOLS to
undefined, so a YAML agent declaring 'tools: [{name: exit_loop}]' was
instantiated with no tools at all. Split the list into built-ins that
resolve to a real tool object (currently just exit_loop -> EXIT_LOOP,
mirroring adk-python's LlmAgent._resolve_tools) and the server-side
built-ins that must keep being dropped because their processLlmRequest
rejects the replay harness' DummyLlm model name.
Returning the recorded tool response short-circuits callToolAsync, so a tool whose only observable effect is on EventActions never runs during a replay. The plugin already replicated that effect for transfer_to_agent; do the same for exit_loop so escalate and skipSummarization are set, which is what stops the LoopAgent and ends the LlmAgent step loop.
ExitLoopTool is implemented and now resolved from YAML configs, so the skip reason no longer holds. Add a TestRunner suite that replays an in-memory equivalent of the case (LoopAgent -> LlmAgent calling exit_loop) as executable proof, since the conformance corpus itself is not vendored in this repo.
| * `LlmAgent._resolve_tools`, which resolves a bare built-in name to the real | ||
| * tool object. | ||
| */ | ||
| const BUILTIN_TOOLS = new Map<string, BaseTool>([['exit_loop', EXIT_LOOP]]); |
There was a problem hiding this comment.
can it be a plain Record instead of the map? (Record<string, BaseTool>)
There was a problem hiding this comment.
Done, switched to a plain Record in ca75004 (dev/src/integration/agent_registry.ts:34):
const BUILTIN_TOOLS: Record<string, BaseTool> = {exit_loop: EXIT_LOOP};One deviation worth flagging so you can object: the lookup key comes straight from user YAML, so I read it through Object.hasOwn (agent_registry.ts:153) rather than a bare if (BUILTIN_TOOLS[toolConfig.name]). With a bare index, tools: [{name: constructor}] resolves to Object.prototype.constructor and gets handed to LlmAgent as a "tool" instead of falling through to findToolOrThrow. The Map did not have that hazard, so the guard is just paying for the shape change. Object.hasOwn is already the idiom used elsewhere in this package (dev/src/integration/test_runner.ts:170).
Added a regression test for it, should not resolve an inherited Object member as a built-in tool (dev/test/integration/agent_registry_test.ts:297), and confirmed it fails without the guard (the config instantiates with Object as a tool and no error is thrown).
Happy to drop the guard and index directly if you would rather keep the lookup bare.
Review feedback: prefer Record<string, BaseTool> over a Map. The lookup goes through Object.hasOwn so a YAML string naming an inherited member such as 'constructor' still falls through to findToolOrThrow instead of resolving to Object.prototype.constructor. Object.hasOwn is already the guard used elsewhere in this package (test_runner.ts).
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
2. Or, if no issue exists, describe the change:
Problem:
ExitLoopTooland theEXIT_LOOPsingleton already exist in core and are exported from@google/adk, but the conformance harness in thedevpackage still behaves as if they did not.dev/src/integration/agent_registry.tslistedexit_loopin aBUILTIN_TOOLSarray whose every entry was mapped toundefinedand filtered out, so a YAML agent declaring:was instantiated with zero tools. During a conformance replay the recorded
exit_loopfunction call then fails withFunction exit_loop is not found in the toolsDict., which is whyworkflow/loop_001was permanently listed inSKIPPED_TESTSwith the (now false) reasonExitLoopTool is not implemented yet.Solution:
Two changes are required, and both are needed for the case to actually pass.
Registry resolution (
dev/src/integration/agent_registry.ts).BUILTIN_TOOLSbecomes aRecord<string, BaseTool>of built-ins that resolve to a real tool object — currently justexit_loop -> EXIT_LOOP— mirroringadk-python'sLlmAgent._resolve_tools, which resolves a bare, dot-less built-in name to the real tool object. The three purely server-side built-ins (google_search,url_context,google_maps_grounding) move to a separateSKIPPED_BUILTIN_TOOLSlist and keep being dropped on purpose: they are executed by the Gemini backend and theirprocessLlmRequestthrows for a non-Gemini model name, and the replay harness injectsDummyLlm(model namedummy-llm). The table is read throughObject.hasOwnso that a YAML string naming an inherited member such asconstructorfalls through tofindToolOrThrowinstead of resolving toObject.prototype.constructor;Object.hasOwnis already the idiom used elsewhere in this package (dev/src/integration/test_runner.ts).Replay side effects (
dev/src/integration/replay_plugin.ts). This is the non-obvious half — reviewers will otherwise read it as unrelated.ReplayPlugin.beforeToolCallbackreturns the recorded tool response, and incore/src/agents/functions.tsa non-null plugin response short-circuitscallToolAsync. The response recorded for a Python tool returningNoneis the dict{result: None}— a non-null object — soEXIT_LOOP.runAsyncwould never execute and neitherescalatenorskipSummarizationwould be set. WithoutescalatetheLoopAgentnever stops; withoutskipSummarizationthe sub-agent asks the model for one more turn and the replay throwsNo LLM recording found .... Both flags are also deep-compared byvalidateSession. The plugin already replicated this class of side effect fortransfer_to_agent; theifbecomes a smallswitchwith anexit_looparm that sets both flags. (adk-pythonsolves this differently — its replay plugin runs the real tool before returning the recording — but porting that would change execution for every replayed tool, so it is deliberately out of scope here.)With both in place,
workflow/loop_001is removed fromSKIPPED_TESTS; that list now holds exactlytool/example_tool_001,core/multi_005andtool/long_running_tool_001.No
core/change, no public API change, and no new dependency:AgentRegistryandReplayPluginare internal to the devtools conformance harness.Testing Plan
Note on the conformance corpus: the replay corpus (
spec.yaml/generated-recordings.yaml/generated-session.yamltriplets) is not vendored in this repo and is not exercised by.github/workflows/validation.yaml. The realadk integration conformancereplay was NOT executed — no corpus was available in the environment.dev/test/integration/test_runner_test.tswas added as the in-repo, CI-runnable substitute: it drives the realTestRunner,AgentRegistry,ReplayPlugin,LoopAgentandLlmAgentwith no mocks, over an in-memory equivalent of theloop_001case, and asserts the produced session deep-equals the expected one — includingescalate/skipSummarizationon the final function-response event, which is only reachable when both halves of this fix are present.Unit Tests:
I have added or updated unit tests for my change.
All unit tests pass locally.
dev/test/integration/agent_registry_test.ts—exit_loopnow resolves to the sharedEXIT_LOOPinstance; the three server-side built-ins are still dropped; an inherited member name such asconstructoris not resolved as a built-in.should skip built-in toolscase assertedexpect(retrieved.tools.length).toBe(0)forexit_loop— it pinned exactly the behaviour this PR fixes, so it becameshould resolve exit_loop to the EXIT_LOOP built-in tool. Its original assertion is not lost: it survives verbatim in the newshould skip server-side built-in toolscase, which applies it togoogle_search/url_context/google_maps_grounding. No other existing test was changed or removed.dev/test/integration/replay_plugin_test.ts(new) — bothswitcharms, the no-side-effect arm, the not-found rejection, and single-consumption of a recording.dev/test/integration/test_runner_test.ts(new) — the end-to-end loop replay described above.Commands run locally on the pushed commit:
Each half of the fix was verified to be load-bearing by reverting it in isolation and re-running
dev/test/integration/test_runner_test.ts:agent_registry.tsresolution, the run fails withTool exit_loop not found in registry;replay_plugin.tsexit_looparm, the loop never terminates and the run fails withNo LLM recording found for agent refiner_agent at turn 0.Pre-existing failures unrelated to this change: the
tests/integration/build_setup,tests/integration/app_loader,tests/integration/agent_loaderande2esuites fail in this sandbox because they shell out to real builds and to the live Gemini API. They fail on the base commit too, and repeated runs on an unchanged tree produce a different subset each time, so they are environmental rather than caused by this diff.Manual End-to-End (E2E) Tests:
Write a YAML agent config for a refiner agent that exits a loop:
Load it via
AgentRegistry.registerAgentConfig+getAgentand confirm the resultingLlmAgent.toolsis[EXIT_LOOP](before this change it was[]).If you have a conformance corpus available:
workflow/loop_001should now run and pass instead of being reported as skipped.Checklist
Additional context
The two unchecked boxes are accurate rather than oversights: the full end-to-end path is the
adk integration conformancereplay, whose corpus is not vendored in this repo and was not available in this environment, and this change has no dependent downstream modules.