Skip to content

feat(agent-tool): stream sub-agent events live (parity with adk-python #3991)#334

Open
cstavaru wants to merge 1 commit into
google:mainfrom
softactivate:feat/agent-tool-event-streaming
Open

feat(agent-tool): stream sub-agent events live (parity with adk-python #3991)#334
cstavaru wants to merge 1 commit into
google:mainfrom
softactivate:feat/agent-tool-event-streaming

Conversation

@cstavaru
Copy link
Copy Markdown

@cstavaru cstavaru commented May 8, 2026

Summary

Brings the JS SDK to parity with adk-python PR
#3991
("Stream
sub-agent events from AgentTool").

Today, when an LlmAgent invokes a sub-agent via AgentTool, every
intermediate event the sub-agent produces (model output, function
calls, nested AgentTool invocations, function responses) is consumed
inside AgentTool.runAsync() and never reaches the parent runner —
the parent only sees a single final function_response event with
the merged final text. This makes it impossible to render real-time
sub-agent activity in a CLI/UI, and is the JS counterpart to the
exact problem adk-python fixed in #3991.

Changes

core/src/agents/functions.ts

  • Add handleFunctionCallsStreamingAsync(), an
    AsyncGenerator<Event> variant of handleFunctionCallsAsync().
    It splits the incoming function-call event into AgentTool calls
    and regular tool calls. For each AgentTool call it iterates
    AgentTool.runAsyncWithEvents() and yields every event live, while
    tracking the last content event in order to build the final tool
    response. Regular tool calls continue to delegate to
    handleFunctionCallList.
  • The contract is: the terminal yield is always either the single
    function-response event or the merged
    parallel-function-response event. Callers can therefore keep a
    one-step buffer to capture it as functionResponseEvent.

core/src/tools/agent_tool.ts

  • Refactor AgentTool.runAsync():
    • Extract runner/session setup into a private
      setupRunnerAndSession().
    • Extract result merging (filter thoughts, apply output schema)
      into a public buildToolResultFromContent() helper that
      handleFunctionCallsStreamingAsync reuses.
  • Add AgentTool.runAsyncWithEvents(): an AsyncGenerator<Event>
    that yields the sub-agent's events as they are produced and does
    not build the tool result itself.
  • Pass invocationContext.runConfig down into the inner
    runner.runAsync() so the sub-agent inherits the parent's run
    configuration (matches the Python implementation).

core/src/agents/llm_agent.ts

  • Switch the function-call handling site from
    handleFunctionCallsAsync to handleFunctionCallsStreamingAsync.
  • Consume the generator with a one-step buffer, yielding every
    intermediate sub-agent event to the parent runner and retaining
    only the terminal event as the functionResponseEvent for the
    rest of the flow.
let functionResponseEvent: Event | null = null;
let pendingEvent: Event | null = null;
for await (const event of handleFunctionCallsStreamingAsync({...})) {
  if (invocationContext.abortSignal?.aborted) return;
  if (pendingEvent) yield pendingEvent;
  pendingEvent = event;
}
functionResponseEvent = pendingEvent;

Behavior

  • Before: runner only sees [function_call_event, function_response_event] for an AgentTool invocation.
  • After: runner sees
    [function_call_event, ...all sub-agent events..., function_response_event].
    The final function_response_event is byte-for-byte the same as
    before (still built by buildToolResultFromContent), so any code
    that only cared about the response event keeps working.

Verification

  • npm run lint — clean
  • npm run build — clean
  • Manually verified in a CLI that uses an LlmAgent with an
    AgentTool-wrapped sub-agent: intermediate events from the
    sub-agent now stream into the parent runner exactly as a top-level
    agent's events would, and the final tool response is unchanged.

Refs

…n #3991)

Previously, when an LlmAgent invoked a sub-agent via AgentTool, all of
the sub-agent's intermediate events (model output, tool calls, nested
AgentTool invocations, tool responses) were swallowed by AgentTool.runAsync
and only a single final function-response event was emitted to the parent
runner. This made it impossible to surface real-time sub-agent activity to
the user, and broke parity with the Python ADK once it shipped the
streaming variant.

This change mirrors adk-python PR #3991
(google/adk-python#3991) for the JS SDK:

functions.ts
- Add handleFunctionCallsStreamingAsync(), an AsyncGenerator<Event>
  variant of handleFunctionCallsAsync(). It separates AgentTool calls
  from regular tool calls; for each AgentTool call it iterates
  AgentTool.runAsyncWithEvents() and yields every event live, while
  tracking the last content event to build the final tool-response.
  Regular tool calls continue to delegate to handleFunctionCallList.
  The terminal yield is always either the single response event or
  the merged parallel-function-response event - this contract lets
  the caller capture it with a one-step buffer.

agent_tool.ts
- Refactor AgentTool.runAsync(): split the runner/session setup into
  a private setupRunnerAndSession() and the result extraction into a
  public buildToolResultFromContent() helper.
- Add AgentTool.runAsyncWithEvents(): an AsyncGenerator<Event> that
  yields the sub-agent's events as they are produced, without building
  the tool result. Callers (handleFunctionCallsStreamingAsync) are
  responsible for assembling the final response via
  buildToolResultFromContent().
- Pass invocationContext.runConfig down into runner.runAsync() so the
  sub-agent inherits the parent's run configuration, matching the
  Python implementation.

llm_agent.ts
- Switch the function-call handling site from handleFunctionCallsAsync
  to handleFunctionCallsStreamingAsync. Use a one-step buffer over
  the generator so every intermediate sub-agent event is yielded to
  the parent runner, and only the final function-response event is
  retained as functionResponseEvent for the subsequent processing.

Refs: google/adk-python#3991
@google-cla
Copy link
Copy Markdown

google-cla Bot commented May 8, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

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.

1 participant