Skip to content

fix: EventActions.merge clobbers endOfAgent, dropping a tool's stop request in parallel tool calls #1375

Description

@svetanis

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.

  1. Register two tools: finish (sets endOfAgent(true) via ToolContext) and noop (does
    nothing).
  2. 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.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions