Skip to content

fix(agents): share the invocation cost manager so maxLlmCalls spans the whole run#484

Merged
kalenkevich merged 2 commits into
google:mainfrom
sahilsaini5:fix/maxllmcalls-shared-cost-manager
Jul 15, 2026
Merged

fix(agents): share the invocation cost manager so maxLlmCalls spans the whole run#484
kalenkevich merged 2 commits into
google:mainfrom
sahilsaini5:fix/maxllmcalls-shared-cost-manager

Conversation

@sahilsaini5

Copy link
Copy Markdown
Contributor

Summary

maxLlmCalls (default 500) is documented as a limit on the total number of LLM calls for a given run, and InvocationContext is explicitly described as an invocation that "can contain one or multiple agent calls." In practice, though, the cap resets at every agent boundary, so it does not bound a multi-agent run at all.

The LLM-call counter lives on a per-context InvocationCostManager. The field was initialized eagerly (= new InvocationCostManager()) and the constructor never adopted the parent's, so every new InvocationContext starts the counter back at 0. Because a fresh context is created for each sub-agent, agent transfer, and loop iteration (BaseAgent.createInvocationContext, createBranchCtxForSubAgent), the limit is effectively enforced per agent invocation rather than per run.

Impact

A LoopAgent (default maxIterations is unbounded) wrapping a non-terminating LlmAgent never trips the default maxLlmCalls = 500: each iteration builds a new context whose counter resets to ~1, the Max number of llm calls limit ... exceeded guard never fires, and the run makes an unbounded number of LLM calls — exactly the runaway-cost scenario maxLlmCalls exists to prevent. The same reset happens on agent transfer.

Fix

Adopt the parent invocation's cost manager in the InvocationContext constructor when one is present. Child contexts already copy every other parent field (via the context spread in createInvocationContext and the instance pass in createBranchCtxForSubAgent), so reusing the cost manager keeps a single shared counter for the whole invocation. A brand-new invocation (from the Runner or A2A executor) still gets a fresh counter.

This matches adk-python, where the invocation cost manager is shared across agent calls (via model_copy, which keeps the same manager reference).

The change is intentionally minimal and touches no public API: InvocationCostManager stays private, and no new fields are added to the exported InvocationContextParams.

Test plan

  • New core/test/agents/invocation_context_test.ts:
    • counter accumulates across a child context (limit trips on the 3rd call across 2 agents)
    • counter accumulates across a grandchild context (nested sub-agents)
    • a separate invocation starts a fresh counter (isolation preserved)
  • Verified the new tests fail without the fix and pass with it.
  • Full core unit suite green (npx vitest run --project unit:core): 2044 passing.
  • tsc --noEmit, build (declaration emit), ESLint, and Prettier all clean.

…he whole run

`maxLlmCalls` is meant to cap the total number of LLM calls for an entire
invocation, which "can contain one or multiple agent calls" per the
InvocationContext docs. However, the LLM-call counter lives on a per-context
`InvocationCostManager` that the field initializer always constructed fresh, and
the constructor never adopted the parent's. Because a new InvocationContext is
created for every sub-agent, agent transfer and loop iteration
(createInvocationContext / createBranchCtxForSubAgent), the counter reset to 0 at
each agent boundary — so the cap was effectively enforced per agent invocation,
not per run.

Concretely, a LoopAgent wrapping a non-terminating LlmAgent never hit the default
limit of 500: each iteration built a new context with a fresh counter, so the
"Max number of llm calls limit ... exceeded" guard never fired, allowing an
unbounded number of LLM calls (runaway cost).

Fix: adopt the parent invocation's cost manager in the constructor when present.
Child contexts already copy every other parent field via the context spread /
instance pass, so reusing the cost manager keeps a single shared counter for the
whole invocation; a brand-new invocation (Runner, A2A) still starts a fresh one.
This matches adk-python, where the cost manager is shared across agent calls.

Adds regression tests proving the counter accumulates across child and grandchild
contexts while remaining isolated between separate invocations.
@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 Google LLC

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

2026

} as unknown as Session;
}

describe('InvocationContext LLM-call cost tracking', () => {

@kalenkevich kalenkevich Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could you please add a test for your change as well? Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done! Added an end-to-end regression test (enforces maxLlmCalls across a real multi-iteration LoopAgent run) that drives a LoopAgent plus its sub-agent through the real createInvocationContext path. It fails on the pre-fix code (the run goes unbounded) and passes with the shared cost manager. Also bumped the copyright year to 2026. Thanks for the review!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry, but I does not see changes where you create an instance of InvocationContext and passing the InvocationCostManager. Can you add a tests for that as well? Thanks

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

my bad, there is a test for that. Sorry!

Add an end-to-end regression test that drives a LoopAgent + sub-agent
through the real createInvocationContext path, proving maxLlmCalls now
bounds the whole run: it fails on the pre-fix code (the loop runs
unbounded) and passes with the shared cost manager. The existing tests
construct child contexts by hand; this one exercises the actual
agent-run code path the fix targets.

Also bump the test file's copyright year to 2026 to match the repo
convention for files added in 2026.
@kalenkevich
kalenkevich merged commit c46d71b into google:main Jul 15, 2026
12 checks passed
This was referenced Jul 15, 2026
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