Skip to content

fix: accumulate endOfAgent in EventActions.merge so a parallel tool's stop request survives#1376

Merged
copybara-service[bot] merged 1 commit into
google:mainfrom
svetanis:fix/eventactions-endofagent-merge
Jul 24, 2026
Merged

fix: accumulate endOfAgent in EventActions.merge so a parallel tool's stop request survives#1376
copybara-service[bot] merged 1 commit into
google:mainfrom
svetanis:fix/eventactions-endofagent-merge

Conversation

@svetanis

Copy link
Copy Markdown
Contributor

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:

EventActions.Builder.merge overwrites endOfAgent instead of accumulating it
(EventActions.java:396):

this.endOfAgent = other.endOfAgent();          // last writer wins

endOfAgent is the stop signal a tool sets to end an invocation — a public contract (the repo's own
EndInvocationActionTest exercises it). Being a primitive boolean, "never set" and "explicitly
false" are indistinguishable. Its one caller that matters,
Functions.mergeParallelFunctionResponseEvents, folds the responses of parallel tool calls — so
when one tool asks to stop and a later tool in the same turn did not, the later false clobbers the
request and the agent keeps running. Whether the stop survives depends purely on the order the model
listed the calls.

Solution:

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 merged event has requested the stop, it stays requested — order-independent, which the
parallel fold needs. OR is the only operator that works here: AND would let any silent tool veto the
stop; plain assignment is order-dependent — the bug.

The change is that one line, in EventActions.Builder.merge — nothing else.

Known limitation (intended): a tool can no longer withdraw a stop another tool requested in the
same turn.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

New test in EventActionsTest (order-independence of the merged flag), asserting both orderings yield
true:

@Test
public void merge_endOfAgentIsOrderIndependent() {
  EventActions requestsStop = EventActions.builder().endOfAgent(true).build();
  EventActions leavesUnset = EventActions.builder().build();

  EventActions stopFirst = EventActions.builder().merge(requestsStop).merge(leavesUnset).build();
  EventActions stopLast = EventActions.builder().merge(leavesUnset).merge(requestsStop).build();

  assertThat(stopFirst.endOfAgent()).isTrue();
  assertThat(stopLast.endOfAgent()).isTrue();
}

The existing EventActionsTest.merge_mergesAllFields (merges endOfAgent(true) into an unset builder
and asserts true) is unaffected: false || true == true. No existing test depends on
last-writer-wins.

Ran EventActionsTest: 12/12 pass, including the new merge_endOfAgentIsOrderIndependent.

Manual End-to-End (E2E) Tests:

Offline, deterministic, no API key, Node-free. A real LlmAgent + FunctionTools + InMemoryRunner
run across a permutation table, differing only in the order/count of the parallel calls the (stub)
model lists. The observable is the model-call count: a tool asked to stop, so a second call means the
stop was lost. Runs with the stop requested last are controls.

Before fix (main):

Run A (2 calls, stop first)   [finish, noop]         model calls: 2  (kept running)
Run B (2 calls, stop last)    [noop, finish]         model calls: 1  (stopped)   <- control
Run C (3 calls, stop first)   [finish, noop, noop]   model calls: 2  (kept running)
Run D (3 calls, stop middle)  [noop, finish, noop]   model calls: 2  (kept running)
Run E (3 calls, stop last)    [noop, noop, finish]   model calls: 1  (stopped)   <- control
Run F (two stops, none last)  [finish, finish, noop] model calls: 2  (kept running)

The stop survives only when it is requested last (B, E); every other order — including three-tool
turns and two stop requests (F) — keeps running.

After fix (same demo, rebuilt jar):

Run A (2 calls, stop first)   [finish, noop]         model calls: 1  (stopped)
Run B (2 calls, stop last)    [noop, finish]         model calls: 1  (stopped)
Run C (3 calls, stop first)   [finish, noop, noop]   model calls: 1  (stopped)
Run D (3 calls, stop middle)  [noop, finish, noop]   model calls: 1  (stopped)
Run E (3 calls, stop last)    [noop, noop, finish]   model calls: 1  (stopped)
Run F (two stops, none last)  [finish, finish, noop] model calls: 1  (stopped)

Every run now stops after one model call, whatever the order or tool count.

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.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

@hemasekhar-p hemasekhar-p self-assigned this Jul 24, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @svetanis, thank you for your contribution! We appreciate you taking the time to submit this pull request. Currently this PR is under review by our team, we will keep you posted if any additional information is required. thank you.

@kvmilos kvmilos self-assigned this Jul 24, 2026
@copybara-service
copybara-service Bot merged commit 882ea5f into google:main Jul 24, 2026
14 checks passed
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.

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

3 participants