Please make sure you read the contribution guide and file the issues in the right place.
Contribution guide.
🔴 Required Information
Describe the Bug:
When a model issues parallel tool calls in one turn and one tool sets endOfAgent to end the
invocation, that stop request is silently overwritten by any other tool in the same turn that did not
set it — unless the stop-requesting tool is listed last. The agent then runs on past the stop: at
least one more model call, possibly more tool calls with real side effects.
The cause is one line in EventActions.Builder.merge
(EventActions.java:396):
this.endOfAgent = other.endOfAgent(); // last writer wins
endOfAgent is a primitive boolean, so "never set" and "explicitly false" are the same value: a
tool that never touched it overwrites a true set earlier in the same merge. This bites only in the
parallel-fold path
(Functions.java:578-583),
which folds responses in the order the model listed the calls — so it is deterministic, not a race.
Steps to Reproduce:
Offline, deterministic, no API key. A real LlmAgent, real FunctionTools and a real
InMemoryRunner; only the model is a stub, emitting parallel calls in a chosen order and counting how
many times it is asked to generate.
- Register two tools:
finish (sets endOfAgent(true) via ToolContext) and noop (does
nothing).
- Drive the same agent once per call order below. Observable per run: how many times the model was
called — 1 = stopped, 2 = the stop was lost. Runs with the stop requested last are controls.
Expected Behavior:
If any tool in the turn requested the stop, the merged event should carry endOfAgent = true,
regardless of order — every run below should call the model once.
Observed Behavior:
Run A (2 calls, stop first) [finish, noop] merged endOfAgent=false model calls: 2 (kept running)
Run B (2 calls, stop last) [noop, finish] merged endOfAgent=true model calls: 1 (stopped) <- control
Run C (3 calls, stop first) [finish, noop, noop] merged endOfAgent=false model calls: 2 (kept running)
Run D (3 calls, stop middle) [noop, finish, noop] merged endOfAgent=false model calls: 2 (kept running)
Run E (3 calls, stop last) [noop, noop, finish] merged endOfAgent=true model calls: 1 (stopped) <- control
Run F (two stops, none last) [finish, finish, noop] merged endOfAgent=false model calls: 2 (kept running)
Stopped only when the stop was requested last (B, E); every other order kept running. The controls
stop correctly, so tool order is the only variable.
Environment Details:
- ADK Library Version:
1.7.1-SNAPSHOT built from main.
- OS: Windows (platform-independent).
- TS Version: N/A (Java).
Model Information:
- N/A — downstream of the model; reproduces offline with a stub.
🟡 Optional Information
Proposed Fix:
OR the flag instead of overwriting it, so it accumulates like every other field in the method:
this.endOfAgent = this.endOfAgent || other.endOfAgent();
Once any tool has requested the stop, it stays requested, regardless of order.
Test Coverage: no existing test covers this (EndInvocationActionTest uses a single call, never
reaching the fold); a PR would add one asserting the merged flag is order-independent.
Minimal Reproduction Code: the merge in isolation — no runner or model needed (the agent-level
effect is the table above):
// Two parallel tool responses: one asked to end the invocation, one did nothing.
EventActions requestedStop = EventActions.builder().endOfAgent(true).build();
EventActions didNothing = EventActions.builder().build();
boolean stopSurvives =
EventActions.builder()
.merge(requestedStop)
.merge(didNothing) // overwrites endOfAgent back to false
.build()
.endOfAgent();
// stopSurvives == false on current main; expected true.
How often has this issue occurred?: Always (100%) — deterministic whenever the model emits
parallel tool calls with the stop-requesting tool not last.
Please make sure you read the contribution guide and file the issues in the right place.
Contribution guide.
🔴 Required Information
Describe the Bug:
When a model issues parallel tool calls in one turn and one tool sets
endOfAgentto end theinvocation, that stop request is silently overwritten by any other tool in the same turn that did not
set it — unless the stop-requesting tool is listed last. The agent then runs on past the stop: at
least one more model call, possibly more tool calls with real side effects.
The cause is one line in
EventActions.Builder.merge(
EventActions.java:396):endOfAgentis a primitiveboolean, so "never set" and "explicitly false" are the same value: atool that never touched it overwrites a
trueset earlier in the same merge. This bites only in theparallel-fold path
(
Functions.java:578-583),which folds responses in the order the model listed the calls — so it is deterministic, not a race.
Steps to Reproduce:
Offline, deterministic, no API key. A real
LlmAgent, realFunctionTools and a realInMemoryRunner; only the model is a stub, emitting parallel calls in a chosen order and counting howmany times it is asked to generate.
finish(setsendOfAgent(true)viaToolContext) andnoop(doesnothing).
called —
1= stopped,2= the stop was lost. Runs with the stop requested last are controls.Expected Behavior:
If any tool in the turn requested the stop, the merged event should carry
endOfAgent = true,regardless of order — every run below should call the model once.
Observed Behavior:
Stopped only when the stop was requested last (B, E); every other order kept running. The controls
stop correctly, so tool order is the only variable.
Environment Details:
1.7.1-SNAPSHOTbuilt frommain.Model Information:
🟡 Optional Information
Proposed Fix:
OR the flag instead of overwriting it, so it accumulates like every other field in the method:
Once any tool has requested the stop, it stays requested, regardless of order.
Test Coverage: no existing test covers this (
EndInvocationActionTestuses a single call, neverreaching the fold); a PR would add one asserting the merged flag is order-independent.
Minimal Reproduction Code: the merge in isolation — no runner or model needed (the agent-level
effect is the table above):
How often has this issue occurred?: Always (100%) — deterministic whenever the model emits
parallel tool calls with the stop-requesting tool not last.