Skip to content

[Agent] Fix tool call budget being shared across concurrent streaming calls - #2425

Merged
wachterjohannes merged 1 commit into
symfony:mainfrom
ousamabenyounes:fix/issue-2421
Aug 22, 2026
Merged

[Agent] Fix tool call budget being shared across concurrent streaming calls#2425
wachterjohannes merged 1 commit into
symfony:mainfrom
ousamabenyounes:fix/issue-2421

Conversation

@ousamabenyounes

Copy link
Copy Markdown
Contributor
Q A
Bug fix? yes
New feature? no
Docs? no
Issues Fix #2421
License MIT

Runner::$iterations lives on the Runner, which is a shared service, and is reset in run() when
nestingLevel is 0. In the streaming path the tool calling loop only starts when the returned
StreamResult is consumed, which happens much later. Two streaming calls started before either one is
consumed therefore share a single budget: the second call's reset runs while the first has not started
counting, and the first then exhausts the counter the second inherits.

With maxToolCalls: 3, counting real tool rounds per stream:

stream A -> capped after 3 tool rounds
stream B -> capped after 0 tool rounds

Stream B is refused before executing a single tool.

The fix

The budget now belongs to one outermost agent call instead of to the Runner instance. A
ToolCallBudget is created in run() at nesting level 0, captured there, and passed into the stream
listener closure, so the loop always spends the budget of the call it belongs to — whenever the stream
is consumed.

handleToolCalls() installs the budget it was handed for the duration of the loop and restores the
previously active one when it unwinds, so nested agent calls keep continuing on the budget of the loop
they belong to rather than opening a fresh one. That is the part the obvious "reset when nesting
level becomes 1" fix gets wrong: in the streaming path the parent's handleToolCalls() returns before
the nested round runs, so nesting has already unwound by the time the next round starts.

$sources and $nestingLevel are deliberately left on the Runner for now: $nestingLevel correctly
tracks the synchronous call stack (handleToolCalls() has no suspension point, so it always runs to
completion once the listener fires), and I could not construct a failing case for $sources under
concurrent streams — each level already attaches and resets it before unwinding. Happy to move them
into the same per-call object if you prefer that as one deliberate change.

Test verification (RED → GREEN)

RED — new test on unmodified main, production code untouched (git status --porcelain -- src/agent/src/ empty):

$ vendor/bin/phpunit -c src/agent/phpunit.xml.dist --filter testMaxIterationsLimitIsNotSharedBetweenConcurrentStreams
PHPUnit 11.5.56 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

There was 1 failure:

1) Symfony\AI\Agent\Tests\Execution\RunnerTest::testMaxIterationsLimitIsNotSharedBetweenConcurrentStreams
Failed asserting that 3 is identical to 6.

src/agent/tests/Execution/RunnerTest.php:527

FAILURES!
Tests: 1, Assertions: 2, Failures: 1.

The first assertion passes and the second fails at 3, which is exactly the reported behaviour: the
first stream spends its 3 rounds, the second gets 0.

GREEN — same test with the fix:

$ vendor/bin/phpunit -c src/agent/phpunit.xml.dist --filter testMaxIterationsLimitIsNotSharedBetweenConcurrentStreams
.                                                                   1 / 1 (100%)

OK (1 test, 2 assertions)

No regressions in the component — main was OK (276 tests, 656 assertions), the branch is:

$ vendor/bin/phpunit -c src/agent/phpunit.xml.dist
OK (277 tests, 658 assertions)

php-cs-fixer reports 0 fixable files and phpstan analyse reports no errors for src/agent.

@carsonbot carsonbot added Status: Needs Review Agent Issues & PRs about the AI Agent component Bug Something isn't working labels Aug 21, 2026

@wachterjohannes wachterjohannes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @ousamabenyounes, this is the right shape and it lands the fix where I could not: the budget now belongs to the call rather than to the shared Runner, and capturing it into a local before it reaches the listener closure is exactly what survives the streaming unwind.

Verified with the reproduction from #2421, unchanged:

before: stream A -> capped after 3 tool rounds
        stream B -> capped after 0 tool rounds
after:  stream A -> capped after 3 tool rounds
        stream B -> capped after 3 tool rounds

Your new test also fails on main exactly as you describe and passes with the fix. Verified locally, all green.

On $sources: I tried to break it with two concurrent streams collecting different sources and could not either, each result keeps its own. Same output before and after this change, so leaving it in place is right.

One comment inline.

Comment thread src/agent/src/Execution/Runner.php Outdated
wachterjohannes added a commit to wachterjohannes/symfony-ai that referenced this pull request Aug 22, 2026
"Has the author responded?" is not answered by the issue-comment thread. A push
leaves no comment at all, and a reply to an inline review comment lives on the
pulls comments endpoint, not the issues one. Checking only the latter reports no
activity for a PR whose author pushed a fix and replied inline, which is exactly
what happened on symfony#2425.

pr-review gains an explicit re-check section listing the four signals and the
instruction to compare the head SHA against the reviewed commit before saying
anything.

pr-triage gains the consequence for bucketing: GitHub keeps CHANGES_REQUESTED
until a new review is submitted, so a PR whose head moved since the last review
has already been responded to. That one is actionable and belongs in the queue,
not in the excluded bucket.

@wachterjohannes wachterjohannes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comment reads well, and putting it on the assignment rather than above the method is the right spot, that is where someone would change it. Re-verified, all green.

Thanks for taking this on from the issue and for working out the suspension-point argument independently, that was the part that made the naive fix look correct.

@wachterjohannes

Copy link
Copy Markdown
Member

Thank you @ousamabenyounes.

@wachterjohannes
wachterjohannes merged commit b0acb75 into symfony:main Aug 22, 2026
25 checks passed
wachterjohannes added a commit to wachterjohannes/symfony-ai that referenced this pull request Aug 28, 2026
"Has the author responded?" is not answered by the issue-comment thread. A push
leaves no comment at all, and a reply to an inline review comment lives on the
pulls comments endpoint, not the issues one. Checking only the latter reports no
activity for a PR whose author pushed a fix and replied inline, which is exactly
what happened on symfony#2425.

pr-review gains an explicit re-check section listing the four signals and the
instruction to compare the head SHA against the reviewed commit before saying
anything.

pr-triage gains the consequence for bucketing: GitHub keeps CHANGES_REQUESTED
until a new review is submitted, so a PR whose head moved since the last review
has already been responded to. That one is actionable and belongs in the queue,
not in the excluded bucket.
wachterjohannes added a commit to wachterjohannes/symfony-ai that referenced this pull request Aug 29, 2026
"Has the author responded?" is not answered by the issue-comment thread. A push
leaves no comment at all, and a reply to an inline review comment lives on the
pulls comments endpoint, not the issues one. Checking only the latter reports no
activity for a PR whose author pushed a fix and replied inline, which is exactly
what happened on symfony#2425.

pr-review gains an explicit re-check section listing the four signals and the
instruction to compare the head SHA against the reviewed commit before saying
anything.

pr-triage gains the consequence for bucketing: GitHub keeps CHANGES_REQUESTED
until a new review is submitted, so a PR whose head moved since the last review
has already been responded to. That one is actionable and belongs in the queue,
not in the excluded bucket.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Issues & PRs about the AI Agent component Bug Something isn't working Status: Reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Agent] Tool call budget is shared across concurrent streaming calls

3 participants