Skip to content

fix(tools): run an AgentTool under the caller's RunConfig - #1563

Open
innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/agent-tool-caller-run-config
Open

innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/agent-tool-caller-run-config

Conversation

@innoprej

@innoprej innoprej commented Sep 26, 2026 •

Copy link
Copy Markdown

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:

AgentTool.runAsync starts the wrapped agent with the three-argument Runner.runAsync, which uses RunConfig.builder().build(). An agent used as a tool therefore always runs with the RunConfig defaults (maxLlmCalls 500, ToolExecutionMode.NONE, empty customMetadata), whatever the caller passed to Runner.runAsync. For example, with maxLlmCalls(5) on the caller, the wrapped agent in the issue's reproduction makes 500 LLM calls before the run fails.

Solution:

Pass the caller's RunConfig (toolContext.invocationContext().runConfig()) to the four-argument runAsync, as adk-python does since google/adk-python@983c280. The current adk-python code, including the unary change below, is agent_tool.py L291-L315.

  • The nested Runner still creates its own InvocationContext, so maxLlmCalls bounds the wrapped agent's LLM calls on their own instead of sharing the caller's count. adk-python counts them the same way.
  • If the caller streams, the nested run gets a copy of its RunConfig with StreamingMode.NONE, as in google/adk-python@0d5752b. The nested run's events are not forwarded to the caller, and the tool result is read from the last one, which a streaming model need not fill with the whole response: Gemini ends a stream with an aggregated response, but the contrib LangChain4j model emits each chunk as its own response. BIDI belongs to runLive; in the runAsync flow it would make BaseLlmFlow send the nested agent's function calls to Functions.handleFunctionCallsLive. A caller that does not stream has its RunConfig passed through as is.
  • adk-python also turns off support_cfc for the nested run. Java's RunConfig has no such field.
  • groupFunctionResponsesInHistoryOverride (deprecated) now reaches the nested run as well: when the caller sets it, it also decides how the wrapped agent's function calls and responses are laid out in its requests, as it already does for sub-agents. The remaining fields do not change the nested run: autoCreateSession (the session is created before the run), saveInputBlobsAsArtifacts (the request is text only), and the modality, speech, avatar and transcription settings, which only end up in the nested request's liveConnectConfig and are not read by the unary generateContent call.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally. (core; one Windows-only failure that is also on main, see the table below)

Two new tests in AgentToolTest wrap a TestBaseAgent and check the RunConfig on the InvocationContext it receives:

  • call_propagatesCallerRunConfig: the caller's toolExecutionMode(SEQUENTIAL), maxLlmCalls(7) and customMetadata reach the wrapped agent.
  • call_withStreamingRunConfig_runsAgentWithoutStreaming: both SSE and BIDI callers' RunConfig reach the wrapped agent with only streamingMode changed to NONE.

Windows 11, Microsoft OpenJDK 17.0.19, Maven 4.0.0-rc-3 via mvnw:

Run main This change
mvn -pl core test -Dtest=AgentToolTest 29 run, 2 failures: both new tests (the wrapped agent gets toolExecutionMode=NONE, maxLlmCalls=500, customMetadata={}) 29 run, 0 failures
mvn -pl core test -Dmaven.test.failure.ignore=true not rerun in full default-test and basic each run 1885 tests, 24 skipped, 1 failure: LocalSkillSourceTest.testListResources, a Windows-only failure that is also on main (#1541; fix in #1542). Without the flag the build stops at that failure in default-test. Of the other four surefire executions, three pass and apigee-llm-proxy-url runs no tests (#1557).

Manual End-to-End (E2E) Tests:

Not tested against a live model. The reproduction in the issue drives the whole path (Runner → root LlmAgent → AgentTool → nested Runner → wrapped LlmAgent) with TestLlm doubles and maxLlmCalls(5) on the caller. The wrapped agent's model always asks for a tool again and answers on an RxJava IO thread, so the loop on main runs into the 500-call limit rather than overflowing the stack first:

main This change
LLM calls made by the wrapped agent 500 5
Run ends with LlmCallsLimitExceededException: Max number of llm calls limit of 500 exceeded LlmCallsLimitExceededException: Max number of llm calls limit of 5 exceeded

When the model double answers on the calling thread instead, main fails earlier with a StackOverflowError, after about 270–290 nested calls in my runs; with this change it stops after 5.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • 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. (core; see the table for the one failure that is also on main)
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

This changes behavior for callers that set a lower maxLlmCalls or a toolExecutionMode: both now apply inside agent tools too, which is what adk-python does. groupFunctionResponsesInHistoryOverride, when set, now applies there as well, and a caller that sets maxLlmCalls to 0 or less (no limit) now also lifts the 500-call cap that agent tools had. #1434 adds a cancellationToken to RunConfig; with this change that token is handed to the wrapped agent as well.

@hemasekhar-p hemasekhar-p self-assigned this Sep 28, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @innoprej, We appreciate your contribution and the effort you put into this pull request. It is currently under review by our team. We will update you if any additional details are needed. Thank you.

@dosadczuk dosadczuk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix and the detailed write-up. This matches what adk-python does, and the tests look good. Two small, optional suggestions inline.

Comment on lines +191 to +194
// The agent runs as part of the caller's invocation, so it follows the caller's RunConfig
// instead of the defaults; maxLlmCalls still counts the nested run on its own. It always runs
// unary, though: its events are not forwarded and the result is read from the last one, which
// in a streamed run may hold only the final chunk.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we shorten this to one line? The nested runner actually starts its own invocation, so "runs as part of the caller's invocation" is a bit misleading, and the longer explanation is already in the commit message. Maybe:

Suggested change
// The agent runs as part of the caller's invocation, so it follows the caller's RunConfig
// instead of the defaults; maxLlmCalls still counts the nested run on its own. It always runs
// unary, though: its events are not forwarded and the result is read from the last one, which
// in a streamed run may hold only the final chunk.
// Follow the caller's RunConfig but run unary: only the last event becomes the result.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, replaced the four-line comment with your suggested one-liner. The longer explanation of the nested invocation and per-invocation limit remains in the commit message.

}

@Test
public void call_withStreamingRunConfig_runsAgentWithoutStreaming() throws Exception {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind covering BIDI here too, if possible, for example by running this test with both SSE and BIDI? BIDI is the case that would actually break (the nested function calls would go to handleFunctionCallsLive), and right now the test would still pass if the check only handled SSE.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the existing test now runs with both SSE and BIDI and checks that only streamingMode changes to NONE while maxLlmCalls is preserved. I also temporarily changed the implementation to normalize SSE only: the test failed on BIDI, then passed after restoring the implementation. AgentToolTest passes all 29 tests in both the default-test and basic executions. The PR's testing description now states that both modes are covered.

@dosadczuk dosadczuk added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. needs update and removed needs review labels Sep 30, 2026
AgentTool.runAsync started the wrapped agent with the three-argument
Runner.runAsync, so the nested run always used the RunConfig defaults:
a maxLlmCalls ceiling of 500, ToolExecutionMode.NONE and no
customMetadata, whatever the caller had set.

Pass the caller's RunConfig to the nested run instead, as adk-python
does since google/adk-python@983c280. The nested run is its own
invocation, so maxLlmCalls counts its LLM calls apart from the
caller's.

Run the nested agent unary even when the caller streams, as adk-python
does since google/adk-python@0d5752b. Its events are not forwarded and
the tool result is read from the last one, which a streaming model
need not fill with the whole response: the contrib LangChain4j model
emits each chunk as its own response. BIDI would also send the nested
agent's function calls to the live-only handler.

- Cover SSE and BIDI callers and keep the RunConfig comment concise.
@innoprej
innoprej force-pushed the fix/agent-tool-caller-run-config branch from b7195cc to 18560ba Compare September 30, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs update waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AgentTool runs the wrapped agent with the default RunConfig instead of the caller's

3 participants