Skip to content

Fix: resolve the exit_loop built-in tool in YAML agent configs - #206

Closed
AmaadMartin wants to merge 9 commits into
mainfrom
fix/conformance-exit-loop-builtin-tool
Closed

Fix: resolve the exit_loop built-in tool in YAML agent configs#206
AmaadMartin wants to merge 9 commits into
mainfrom
fix/conformance-exit-loop-builtin-tool

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 29, 2026

Copy link
Copy Markdown
Owner

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):

  • N/A — no existing issue.

2. Or, if no issue exists, describe the change:

Problem:

ExitLoopTool and the EXIT_LOOP singleton already exist in core and are exported from @google/adk, but the conformance harness in the dev package still behaves as if they did not.

dev/src/integration/agent_registry.ts listed exit_loop in a BUILTIN_TOOLS array whose every entry was mapped to undefined and filtered out, so a YAML agent declaring:

tools:
  - name: exit_loop

was instantiated with zero tools. During a conformance replay the recorded exit_loop function call then fails with Function exit_loop is not found in the toolsDict., which is why workflow/loop_001 was permanently listed in SKIPPED_TESTS with the (now false) reason ExitLoopTool is not implemented yet.

Solution:

Two changes are required, and both are needed for the case to actually pass.

  1. Registry resolution (dev/src/integration/agent_registry.ts). BUILTIN_TOOLS becomes a Record<string, BaseTool> of built-ins that resolve to a real tool object — currently just exit_loop -> EXIT_LOOP — mirroring adk-python's LlmAgent._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 separate SKIPPED_BUILTIN_TOOLS list and keep being dropped on purpose: they are executed by the Gemini backend and their processLlmRequest throws for a non-Gemini model name, and the replay harness injects DummyLlm (model name dummy-llm). The table is read through Object.hasOwn so that a YAML string naming an inherited member such as constructor falls through to findToolOrThrow instead of resolving to Object.prototype.constructor; Object.hasOwn is already the idiom used elsewhere in this package (dev/src/integration/test_runner.ts).

  2. Replay side effects (dev/src/integration/replay_plugin.ts). This is the non-obvious half — reviewers will otherwise read it as unrelated. ReplayPlugin.beforeToolCallback returns the recorded tool response, and in core/src/agents/functions.ts a non-null plugin response short-circuits callToolAsync. The response recorded for a Python tool returning None is the dict {result: None} — a non-null object — so EXIT_LOOP.runAsync would never execute and neither escalate nor skipSummarization would be set. Without escalate the LoopAgent never stops; without skipSummarization the sub-agent asks the model for one more turn and the replay throws No LLM recording found .... Both flags are also deep-compared by validateSession. The plugin already replicated this class of side effect for transfer_to_agent; the if becomes a small switch with an exit_loop arm that sets both flags. (adk-python solves 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_001 is removed from SKIPPED_TESTS; that list now holds exactly tool/example_tool_001, core/multi_005 and tool/long_running_tool_001.

No core/ change, no public API change, and no new dependency: AgentRegistry and ReplayPlugin are internal to the devtools conformance harness.

Testing Plan

Note on the conformance corpus: the replay corpus (spec.yaml / generated-recordings.yaml / generated-session.yaml triplets) is not vendored in this repo and is not exercised by .github/workflows/validation.yaml. The real adk integration conformance replay was NOT executed — no corpus was available in the environment. dev/test/integration/test_runner_test.ts was added as the in-repo, CI-runnable substitute: it drives the real TestRunner, AgentRegistry, ReplayPlugin, LoopAgent and LlmAgent with no mocks, over an in-memory equivalent of the loop_001 case, and asserts the produced session deep-equals the expected one — including escalate / skipSummarization on 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.tsexit_loop now resolves to the shared EXIT_LOOP instance; the three server-side built-ins are still dropped; an inherited member name such as constructor is not resolved as a built-in.

    • Rewritten test, called out explicitly: the old should skip built-in tools case asserted expect(retrieved.tools.length).toBe(0) for exit_loop — it pinned exactly the behaviour this PR fixes, so it became should resolve exit_loop to the EXIT_LOOP built-in tool. Its original assertion is not lost: it survives verbatim in the new should skip server-side built-in tools case, which applies it to google_search / url_context / google_maps_grounding. No other existing test was changed or removed.
  • dev/test/integration/replay_plugin_test.ts (new) — both switch arms, 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:

npx vitest run --project unit:dev dev/test/integration   # 4 files, 24 tests passed
npm run build                                            # OK
npm run lint                                             # OK
npm run format:check                                     # OK
npm run docs:check                                       # 0 errors
npx secretlint "**/*"                                    # clean

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:

  • without the agent_registry.ts resolution, the run fails with Tool exit_loop not found in registry;
  • without the replay_plugin.ts exit_loop arm, the loop never terminates and the run fails with No 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_loader and e2e suites 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:

  1. Write a YAML agent config for a refiner agent that exits a loop:

    agent_class: LlmAgent
    name: refiner_agent
    model: gemini-2.5-flash
    description: Refines a poem.
    instruction: Refine the poem, then call exit_loop.
    tools:
      - name: exit_loop
  2. Load it via AgentRegistry.registerAgentConfig + getAgent and confirm the resulting LlmAgent.tools is [EXIT_LOOP] (before this change it was []).

  3. If you have a conformance corpus available:

    npm run build
    npx adk integration conformance --agents_dir <corpus>/agents --tests_dir <corpus>/tests
    

    workflow/loop_001 should now run and pass instead of being reported as skipped.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

The two unchecked boxes are accurate rather than oversights: the full end-to-end path is the adk integration conformance replay, whose corpus is not vendored in this repo and was not available in this environment, and this change has no dependent downstream modules.

AmaadMartin and others added 9 commits July 28, 2026 14:48
* docs: document minimum supported Node.js version in README

Add a short prerequisite note under the Installation section stating that
ADK for TypeScript requires Node.js 18 or newer, so new users know which
Node.js runtime they need before running npm install @google/adk.

The version reflects the mandated fallback: no engines.node field is
declared in any package.json in the repo.

* docs: reference current Node.js LTS instead of a fixed version

Node.js 18 is EOL and any hard-coded minimum version goes stale over time.
Reword the installation prerequisite to point readers at the current Node.js
LTS releases, which stays accurate without future edits.

Addresses PR review feedback on #526.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
…#536)

`ToolAuthHandler` accepted an `authCredential` and then never read it. When
no auth response was present it went straight to `requestCredential()`, so a
credential handed to `OpenAPIToolset`/`RestApiTool` at construction time was
ignored and the tool returned `{pending: true}` on every call. For `apiKey`,
`http` and `serviceAccount` schemes nothing ever resolves that request — no
user interaction is involved — so the tool could never complete.

Fall back to the configured credential when there is no auth response, which
mirrors `_get_auth_response() or self.auth_credential` in adk-python.

Also narrow what gets written to session state. The credential store exists
to avoid repeating work that either cannot be repeated (an auth response is
readable once) or is expensive (an exchange costs a round trip). A static
credential that needed no exchange is neither, so it is no longer persisted —
that would only copy the developer's secret into the session store.
…hon parity (#542)

* Feat: add LoadMcpResourceTool and MCPToolset resource access

Port adk-python's LoadMcpResourceTool to adk-js for cross-language parity.

- Add listResources/getResourceInfo/readResource to MCPToolset, following
  the existing create -> try -> closeSession-in-finally session idiom.
- Add LoadMcpResourceTool (mirrors the in-repo LoadArtifactsTool idiom):
  declares load_mcp_resource({resource_names}), and processLlmRequest injects
  resolved resource contents (text + base64 binary, no decode step) into the
  LlmRequest.
- Export the tool from core/src/index.ts (@google/adk public API).

* test: cover LoadMcpResourceTool and MCPToolset resource access

Add full unit coverage (100% line + branch of the new code):

- load_mcp_resource_tool_test.ts: init, declaration, runAsync (incl. default),
  list injection (incl. empty + swallowed list errors), text/binary/unknown
  content, base64 blob passthrough + default mime type, swallowed read errors,
  and all no-op guard paths (non-matching/absent function response, missing
  parts).
- mcp_toolset_test.ts: listResources/getResourceInfo/readResource happy paths
  and error paths (unknown name, missing URI), plus session-cleanup assertions
  for success and failure (closeSession in finally, no leaked sessions).

* test(e2e): exercise LoadMcpResourceTool against a real MCP server

Add a no-mock end-to-end test that spawns a real MCP server over stdio
(mcp_resource_server.mjs, exposing a text and a binary resource) and drives
the real MCPToolset + LoadMcpResourceTool: listing/resolving/reading resources
and injecting their contents (text + base64 binary) into an LlmRequest.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
* feat(tools): add ExampleTool for few-shot examples

Port adk-python's ExampleTool to adk-js. The tool accepts a static
Example[] or a BaseExampleProvider and, on each outgoing LLM request,
appends a few-shot <EXAMPLES> block (built via buildExampleSi from the
latest user query) to the system instruction. It is never declared to
the model (mirrors PreloadMemoryTool) and is a no-op when no user text
is present. Exported from the public @google/adk API.

* test(tools): cover ExampleTool unit and end-to-end paths

Add Vitest coverage for ExampleTool: static list and provider paths,
model-style passthrough, no-op branches (missing user content, empty
parts, text-less first part), runAsync throwing, and the public export.
Includes an end-to-end block that drives processLlmRequest through a
real Context/InvocationContext (no mocks). 100% line/branch coverage of
the new tool.

* refactor(tools): apply simplicity audit feedback

Use a constructor parameter property for `examples` (repo convention),
and drop the redundant provider end-to-end test whose only unique aspect
was a spy — keeping the no-mock e2e block strictly mock-free. The
provider selection path stays fully covered by the unit tests; the tool
retains 100% line/branch coverage.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
* feat(agents): support clone() for RoutedAgent

RoutedAgent derives its routing targets from config.agents rather than
subAgents, so the inherited BaseAgent.clone() rebuilt the agent from the
already-parented originals and threw "already has a parent agent".

Add a RoutedAgent.clone() override that deep-clones the routing targets
(via a private cloneRoutingTargets helper) and passes them through the
agents override, so super.clone() rebuilds the constructor with fresh,
detached copies that are re-parented onto the clone. The array-vs-record
shape and record keys are preserved so the clone routes identically, and
parent-override rejection plus the detached-root guarantee are still
enforced by the base implementation.

Remove the now-obsolete "documented limitation" test (and its unused
RoutedAgent import) from base_agent_test; positive coverage lives in
routed_agent_test.

* test(agents): cover RoutedAgent.clone()

Add a clone describe suite exercising the new override and the
cloneRoutingTargets helper: array and record forms, deep-clone and
re-parenting of targets, originals left untouched, functional routing on
the clone (record form), verbatim agents override, non-agents overrides,
and parentAgent-override rejection. Includes a no-mock end-to-end case
that clones a RoutedAgent whose targets are real LlmAgents.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
* Feat(tools): add SSRF-safe load_web_page tool for adk-python parity

Ports the adk-python load_web_page tool to adk-js. Fetches a URL and
returns its extracted, readable text, hardened against SSRF:

- only http/https schemes are fetched
- localhost-style hostnames and hosts resolving to non-global IPs
  (private, loopback, link-local, shared/CGNAT, reserved, multicast,
  IPv4-mapped IPv6) are rejected before any connection
- redirects are never followed (redirect: 'manual')
- a configurable timeout (default 30s) bounds every request
- expected failures return the parity string "Failed to fetch url: <url>"
  instead of throwing

Exposes loadWebPage(), the LOAD_WEB_PAGE FunctionTool, and the
LoadWebPageOptions type via the @google/adk public API.

* Refactor(tools): inline single-use failure prefix in load_web_page

Addresses simplicity-audit feedback: the FAILURE_PREFIX constant had a
single caller, so its literal is inlined into failedToFetchMessage, which
remains the sole formatter of the parity failure string.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
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.
@AmaadMartin
AmaadMartin force-pushed the fix/conformance-exit-loop-builtin-tool branch from 730a54d to a519152 Compare July 29, 2026 18:32
@AmaadMartin

Copy link
Copy Markdown
Owner Author

Automated: ported to upstream as google#572.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants