Skip to content

fix(workflow): pass a single_turn transfer target's reply downstream - #7789

Open
vishal-bulbule wants to merge 3 commits into
google:mainfrom
vishal-bulbule:fix/workflow-transfer-output
Open

vishal-bulbule wants to merge 3 commits into
google:mainfrom
vishal-bulbule:fix/workflow-transfer-output

Conversation

@vishal-bulbule

@vishal-bulbule vishal-bulbule commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

Problem:

A top-level LlmAgent node with sub_agents gets the single_turn mode from build_node. When it transfers, the transfer is handled twice:

  1. run_llm_agent_as_node does not stop on transfer_to_agent in single_turn mode, so the function-call postprocessor runs the target inline inside the node.
  2. NodeRunner also copies transfer_to_agent onto the node context, so DynamicNodeScheduler runs the target again as a separate hop. That hop runs in chat mode, sets no output, and its context is the one Workflow._handle_completion gets.

So for START -> lead(sub_agents) -> capture, capture receives None and every transfer calls the target model twice per turn. On resume (a tool confirmation or long-running tool inside the target) the workflow fast-forwards lead and resumes the target as the scheduler hop only, so the reply is lost there as well.

Solution:

Let the scheduler own the transfer in both the fresh and the resumed path:

  • _llm_agent_wrapper.py: the single_turn branch stops after an event with transfer_to_agent, as the chat and task branches already do. The target no longer runs inline.
  • _dynamic_node_scheduler.py: when the transfer chain starts at a single_turn node, each LlmAgent hop whose mode is None or chat runs as a single_turn copy, with rerun_on_resume=True unless the target set it (what build_node gives an LlmAgent node). Its reply goes through process_llm_agent_output, so the reply of the agent that ends the chain is the node output, and a hop paused on a tool call runs its model again on resume. A parent stamps chat on every LlmAgent sub-agent without a mode, so explicit and defaulted chat targets are both switched; task and single_turn sub-agents are tools rather than transfer targets and keep their mode.

Chains that start in chat or task mode are unchanged. I also tried giving these nodes chat mode in build_node instead, but a chat node waits for an output that never comes, so the downstream node never runs.

Testing Plan

Unit Tests:

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

New tests in tests/unittests/workflow/test_llm_agent_as_node.py (16 cases, most parametrized over is_resumable):

Test main (1d81896) this PR
test_single_turn_transfer_output_reaches_downstream (two turns, different targets, each target called once) fail pass
test_single_turn_transfer_terminal_node_replies_once fail pass
test_single_turn_transfer_back_to_router_reaches_downstream fail pass
test_single_turn_nested_transfer_reaches_downstream fail pass
test_single_turn_transfer_target_structured_output_reaches_downstream (output_schema plus output_key) fail pass
test_single_turn_transfer_target_resumes_after_confirmation fail pass
test_single_turn_transfer_target_resumes_after_long_running_tool fail pass
test_single_turn_transfer_explicit_chat_target_reaches_downstream fail pass
test_single_turn_transfer_keeps_task_mode_target fail pass
test_single_turn_transfer_respects_target_rerun_on_resume fail pass

tests/unittests/workflow, agents, flows, runners and test_runners.py: all pass (Python 3.11).
Full tests/unittests, each top-level directory in its own process (minimal venv, so tests that need optional extras fail the same way on main): 16,280 passed on this branch and 16,269 on main, and the failing set is identical except the new tests, which fail only on main.

Manual End-to-End (E2E) Tests:

The graph from #7782 with gemini-3.5-flash on Agent Platform, a before_model_callback counting model calls per agent:

Scenario main this PR
Two turns (translate, then summarize), not resumable echo gets None both turns; lead 5, translate 2, summarize 7 calls echo gets both replies; lead 2, translate 1, summarize 1 calls
Two turns, resumable echo gets None both turns; lead 6, translate 9, summarize 3 calls same as above
translate tool needs confirmation, resume, not resumable echo gets None after the resume echo gets the translation after the resume; translate 2 calls (before and after the pause)
Same, resumable same as above same as above

On main the call counts vary between runs because transfers bounce between agents; one earlier run on main ended with RecursionError in the postprocessor's inline transfer during the first turn.

Checklist

  • I have read the CONTRIBUTING.md document.
  • 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.

Additional context

The first version of this PR cleared the transfer after the inline run. That fixed fresh runs only: on resume the target runs as the scheduler hop, so the resume cases above still failed. This version removes the inline run for single_turn nodes instead, so there is one path.

A top-level LlmAgent node with sub_agents runs in single_turn mode. Its
transfer target already runs inline inside the node and sets the node
output, but the transfer action also reached DynamicNodeScheduler, which
ran the target a second time as a chat-mode hop. That hop sets no output
and its context is the one the workflow completes with, so the
downstream node received None and the target model was called twice per
turn.

Clear the transfer at the end of the single_turn run so the inline
reply is the node output and the target runs once.

Fixes google#7782
Clearing the transfer after the inline run fixed fresh runs only. On
resume the workflow resumes the transfer target as the scheduler hop,
not inline, so a target paused on a tool confirmation or a long-running
tool still passed None downstream, and a target transferring back to
the router tripped the no_events_after_cancel invariant when resumable.

A single_turn node now stops on transfer_to_agent, as chat and task
modes do, so the target never runs inline. When the transfer chain
starts at a single_turn node, each LlmAgent hop runs as a single_turn
copy with rerun_on_resume, so the reply of the agent that ends the chain
is the node output and a hop paused on a tool call runs its model again
on resume.

Fixes google#7782
Running a hop of a single_turn transfer chain as single_turn applied to
any LlmAgent target, so a task-mode target reached through a
tool-driven transfer lost its task mode and the workflow failed, and a
target's own rerun_on_resume=False was overridden.

Switch only targets whose mode is None or chat. A parent stamps chat on
every LlmAgent sub-agent without a mode, so explicit and defaulted chat
look the same and are both switched; task and single_turn sub-agents
are tools, not transfer targets, and are left alone. Set
rerun_on_resume only when the target did not set it.
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.

Workflow: LlmAgent with sub_agents/transfer loses ctx.output for downstream edges

2 participants