Fix: preserve EventActions when a long-running tool returns no response - #571
Open
AmaadMartin wants to merge 9 commits into
Open
Fix: preserve EventActions when a long-running tool returns no response#571AmaadMartin wants to merge 9 commits into
AmaadMartin wants to merge 9 commits into
Conversation
added 5 commits
July 29, 2026 11:04
A long-running tool that returns no response had every mutation it recorded on its tool context (state/artifact deltas, auth or confirmation requests, transfer, escalation, skipSummarization) silently discarded, because the per-call loop skipped straight past the only place actions are attached to an event. Emit a content-less event carrying just those actions when the tool left them non-default, keep emitting nothing when it did not, and make the auth / confirmation event generators tolerate a content-less event. Narrow the step loop's empty-metadata escape hatch to genuinely empty events so the actions-only event still terminates the step.
Unit tests for the isDefaultEventActions predicate, the actions-only event (single, mixed and all-silent batches, both call orders), the content-less path through the auth and confirmation event generators, and the step-loop termination guard. Adds a runner-level integration test proving the state delta is persisted and the credential request reaches the client.
Fold the per-field actions-only assertions into one parameterised case, drop the batch and auth-guard tests that re-exercise an already covered path, inline a single-use fixture, and make the counting mock fail loudly instead of replaying its last turn forever.
Parameterise the isDefaultEventActions non-default cases and keep a single call order for the mixed batch, which selects the same code path either way.
Drop the public barrel re-export until an out-of-package caller exists, fold the stateDelta case into the parameterised table, and trim the comment lines that restated the code.
added 4 commits
July 31, 2026 15:04
Temporarily restores the files this branch touches to their state at the merge base so the following merge of upstream/main applies without conflicts. Reapplied in the commit after the merge.
Restores the parked changes on top of upstream's normalized callback handling. The long-running skip now tests `functionResponse == null` rather than falsiness, so the actions-only event is emitted from inside that nullish branch; a falsy-but-present response keeps taking the normal response path introduced upstream. Drops the branch's null-response and undefined-response regression tests: upstream now covers both branches directly.
Replaces the last `as unknown as Session` this branch added with the repo's own factory, so the helper carries a real Session.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
No existing issue.
2. Or, if no issue exists, describe the change:
Problem:
When a long-running tool returns no response, every
EventActionsmutation it recorded on itsToolContextis silently discarded.In
handleFunctionCallList(core/src/agents/functions.ts) the per-call loop short-circuits ontool.isLongRunning && !functionResponsebefore the function-response event is built, and thatcreateEvent(...)is the only placeactions: toolContext.actionsis ever attached. So a tool that callstoolContext.state.set(...),saveArtifact(...),requestCredential(...),requestConfirmation(...), or that setsskipSummarization/escalate/transferToAgent, and then returns nothing, loses all of it:event.actions.stateDeltawhen an event is appended.LlmAgent.postprocessreturns early and no auth or confirmation request ever reaches the client.Solution:
Emit a content-less (actions-only) event carrying just those actions, matching the Python SDK's intended behavior and the content-less event shape ADK JS already supports (
getContentsskips events withoutcontent.role).isDefaultEventActions(core/src/events/event_actions.ts) reports whether anEventActionsis still entirely at its defaults. An explicitly set falsy scalar such asescalate: falsecounts as non-default; that keeps the predicate an honest object-vs-default comparison and is harmless, since the resulting event has no content and changes no loop or escalation behavior. It is kept module-internal — it is not added to the package's public exports incore/src/common.tsuntil a caller outside the package needs it.handleFunctionCallListcontributes a content-less event with the tool's actions when they are non-default, and still contributes nothing (returningnullfor a lone call) when they are not, so the existing "no event for a pending long-running call" contract is preserved.generateAuthEventandgenerateRequestConfirmationEventnow readcontent?.role ?? 'user'instead ofcontent!.role. Those are the two functionspostprocesscalls on the returned event, i.e. exactly the auth / confirmation paths this fix unblocks, and they would otherwise throw aTypeErroron a content-less event.'user'is the role every function-response event they consume is already built with. No new validation or throw sites were added.LlmAgent'sisEmptyMetadataEventcheck (core/src/agents/llm_agent.ts) is narrowed withisDefaultEventActions(lastEvent.actions). The actions-only event matches the shape of the trailing empty streaming STOP chunk that clause exists for (agent-authored, not partial, no content parts, in a step that had tool calls), so without this the loop would suppress the break and issue an extra model turn while the long-running call is still pending. A trailing empty STOP chunk carries default actions, so the streaming behavior the clause was added for is unchanged. SettingendInvocationinpostprocesswas rejected as an alternative because it would kill thetransferToAgentfollow-up that this fix makes reachable for long-running tools.mergeParallelFunctionResponseEventsneeded no change — it already guards onevent.content && event.content.partsand merges every event's actions — so that tolerance is pinned by test rather than by edit.Testing Plan
Unit Tests:
I have added or updated unit tests for my change.
All unit tests pass locally.
core/test/events/event_actions_test.tscoversisDefaultEventActionsfor default actions and for every non-default field, including the explicitly-false scalar case.core/test/agents/functions_test.tscovers: a silent long-running tool that touches nothing still yieldsnull; one that records astateDelta,skipSummarization,transferToAgentor a tool confirmation yields a content-less event carrying it; a mixed batch merges the silent tool's actions into an event whose content holds only the responding tool's part; a long-running tool that does respond and a non-long-running tool returningundefinedboth behave exactly as before; and both event generators producerole: 'user'from a content-less event.core/test/agents/llm_agent_test.tsdrives the agent with a turn-counting stub model and asserts the step loop stops after the actions-only event (model called exactly once, no second-turn text), and that a trailing empty chunk with default actions still lets the loop continue.core/test/agents/long_running_tool_actions_integration_test.tsruns the whole path throughInMemoryRunner: the state delta of a silent long-running tool is persisted to the session, and itsrequestCredentialcall surfaces anadk_request_credentialfunction call to the client — neither of which happened before.Commands run locally on this commit:
Manual End-to-End (E2E) Tests:
Register a long-running tool that mutates its tool context and returns nothing, then run an agent that calls it:
Ask the agent to start the job and iterate the events. Before this change the run produced no tool event and
session.state['pendingJob']wasundefined; now the run emits one event withcontent === undefinedandactions.stateDelta = {pendingJob: 'job-123'}, the session state containspendingJob, and the agent does not take an extra model turn while the call is pending. This scenario is also asserted automatically incore/test/agents/long_running_tool_actions_integration_test.ts.Checklist
Additional context
This change is behavior-preserving for every tool that already returns a response, and for long-running tools that record no actions: in both cases the emitted events are byte-for-byte what they were before. The only new event shape is a content-less event, which the existing content assembly (
getContents) and parallel-response merge (mergeParallelFunctionResponseEvents) already tolerate.