You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Workflow: LlmAgent with sub_agents/transfer loses ctx.output for downstream edges #7782
In ADK 2.x Workflow, when an LlmAgent graph node has sub_agents and uses transfer_to_agent, the specialist's text is written into the parent's ctx.output during _track_event_in_context, but if that node has a downstream edge, _handle_completion sees child_ctx.output is None. The next node receives empty node_input.
Steps to Reproduce
Minimal graph:
START -> lead (LlmAgent + sub_agents) -> echo (FunctionNode)
fromgoogle.adk.agentsimportLlmAgentfromgoogle.adk.workflowimportWorkflowfromgoogle.adk.workflowimportFunctionNode# or @nodesummarize=LlmAgent(
name="summarize",
description="Summarize input in one sentence.",
instruction="Summarize the user content in one sentence.",
model="...",
)
translate=LlmAgent(
name="translate",
description="Translate input to English.",
instruction="Translate the user content to English.",
model="...",
)
lead=LlmAgent(
name="lead",
instruction=(
"Route the conversation to summarize or translate; ""transfer to only the one that is needed."
),
sub_agents=[summarize, translate],
model="...",
)
defecho(node_input):
return""ifnode_inputisNoneelsestr(node_input)
echo_node=FunctionNode(func=echo, name="echo")
root=Workflow(
name="subagent",
edges=[("START", lead, echo_node)],
)
Run via Runner / adk run with a message that causes transfer (e.g. ask for a translation).
Optional instrumentation:
After ctx.output = ... in workflow/_node_runner.py_track_event_in_context
In workflow/_workflow.py_handle_completion before _buffer_downstream_triggers
Expected Behavior
After lead finishes (including the transferred sub-agent's final text), child_ctx.output should carry that text so the downstream echo node receives it as node_input.
Observed Behavior
Verified log pattern:
TRACK lead None
TRACK lead None
TRACK lead "<translated / summarized text...>" # parent ctx.output IS set
TRACK translate None # transfer hop runs again
COMPLETE lead output= None # Workflow uses last-hop Context
COMPLETE echo output= ''
Session/API still shows the specialist event with e.g.:
author: translate (or summarize)
nodeInfo.path: .../lead@1
messageAsOutput: true
outputFor: [".../lead@1"]
So the content exists as a message, but it is not what _buffer_downstream_triggers passes downstream.
When lead is the terminal node (no downstream edge), the same messageAsOutput event can still surface as the workflow result. Control case without sub_agents (step_a ??echo) propagates ctx.output correctly.
I reproduced this on main (1d81896) with a scripted model and would like to send the fix.
Cause: build_node gives a top-level LlmAgent node the single_turn mode, also when it has sub_agents. In single_turn mode run_llm_agent_as_node does not stop on transfer_to_agent, so the transfer is handled twice:
The function-call postprocessor runs the target inline inside lead. Its reply becomes lead's output, which is the value you saw in _track_event_in_context.
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 receives. Downstream gets None.
Two more effects of the second hop: every transfer calls the target model twice per turn, and with a resumable app the test invariants flag events after end_of_agent.
Proposed fix: at the end of the single_turn branch in run_llm_agent_as_node, clear ctx.actions.transfer_to_agent, because the inline run already produced the node output. Mode resolution stays as it is. I tried switching these nodes to chat mode in build_node instead, but a chat node waits for an output that never comes, so the downstream node never runs at all.
Test: START -> lead(sub_agents) -> capture over two turns, resumable and not. On main, capture receives [None, None] and each target model is called twice. With the fix it receives the target replies and each target is called once.
Thanks @vishal-bulbule for reproducing this and proposing the fix — the double-transfer explanation matches our logs exactly. Appreciate @surajksharma07 for owning the issue; happy to verify the PR on our side when ready.
Reproduced this on 2.13.0 with scripted models too @thomas50317. echo gets None and the translate model is called twice for a single transfer.
As a suggested workaround wrapped run_llm_agent_as_node so that it clears ctx.actions.transfer_to_agent once a single_turn node finishes (same idea as #7789). With that echo got the translated reply and the target model ran only once per turn.
@thomas50317 could you try this on your real graph (multi-turn and with a resumable app if you use one) and let us know if it holds up?
@vishal-bulbule please test those cases thoroughly before #7789 goes to review and will flag it for the maintainers once it’s confirmed.
@surajksharma07 thanks for reproducing it. I tested more cases before review, and they showed that the first version of #7789 (and the same clear-the-transfer workaround) only fixes fresh runs. On resume the workflow fast-forwards lead and resumes the target as the scheduler hop, not inline, so a target that paused on a tool confirmation or a long-running tool still passed None downstream. A target transferring back to lead in a resumable app also tripped the no_events_after_cancel invariant.
I updated #7789 so the scheduler owns the transfer in both paths: a single_turn node now stops on transfer_to_agent like chat and task do, and when the chain starts at a single_turn node the scheduler runs each LlmAgent hop as single_turn (with rerun_on_resume=True), so the reply of the agent that ends the chain is the node output.
Unit tests (scripted models, most run with and without is_resumable):
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, then resume (with and without resumability)
echo gets None after the resume
echo gets the translation after the resume; translate 2 calls (before and after the pause)
On main the call counts vary between runs because the transfers bounce between agents; one earlier run on main hit RecursionError in the postprocessor's inline transfer on the first turn.
The full unit suite has the same failing set as main, apart from the new tests.
@thomas50317 the branch is vishal-bulbule:fix/workflow-transfer-output if you want to run your graph against it.
This is a cleaner fix than the first version @vishal-bulbule. Because the single_turn branch now breaks on transfer_to_agent the postprocessor’s inline run never starts (its own comment already expects the wrapper to do this) so fresh runs and resume go through the same path. Went through the updated diff: it still merges cleanly on current main and 2.13.0 is still affected so upgrading won’t fix this.
One thing before review: the scheduler copies any LlmAgent target to single_turn whenever mode != single_turn so a sub-agent the user explicitly set to chat or task (or to rerun_on_resume=False) gets overridden as well. Could you limit that to target_agent.mode is None or add a test showing the override is intended?
@thomas50317 the clear-the-transfer workaround I suggested earlier only covers fresh runs so please don’t rely on it with a resumable app or with tools that pause for confirmation. Could you run your graph against vishal-bulbule:fix/workflow-transfer-output and tell us what echo receives?
Once that’s sorted will flag #7789 for the maintainers so they can approve the pending CI workflows and review it.
@surajksharma07 good catch, it was a real bug: with a task-mode target reached through a tool-driven transfer, the copy dropped task mode and the workflow failed with DynamicNodeFailError.
Limiting it to target_agent.mode is None would switch nothing, though. LlmAgent.model_post_init sets mode = 'chat' on every LlmAgent sub-agent that has no mode, so a transfer target always arrives as chat, and an explicit mode='chat' looks the same (both have mode in model_fields_set). None shows up for an agent that is no one's sub-agent, such as the router itself when a target transfers back to it.
Switches only targets whose mode is None or chat. task and single_turn sub-agents are not transfer targets (_get_transfer_targets skips DELEGATED_TASK_MODES; they are attached as tools), so the only way to reach one is a tool-driven transfer, and it now keeps its mode.
Sets rerun_on_resume=True only when the target did not set it, using model_fields_set.
test_single_turn_transfer_respects_target_rerun_on_resume (target with rerun_on_resume=False is not rerun on resume)
fail
pass
test_single_turn_transfer_explicit_chat_target_reaches_downstream (an explicit chat target is switched too, as intended)
pass
pass
The earlier cases still pass, along with tests/unittests/workflow, agents, flows, runners and streaming. The full unit suite still has the same failing set as main, apart from the new tests.
Follow-up verification on vishal-bulbule:fix/workflow-transfer-output @ 411a849.
Graph: START → lead (sub_agents: summarize / translate) → echo
Fresh, non-resumable /run:
Translate path — echo received the specialist text (not empty / None).
Summarize path — same.
Summarize with tool — summarize has ReadFileTool; it called tool_read_file, summarized the file, then echo received that summary. Flow: lead → transfer → summarize → tool_read_file → final text (messageAsOutput) → echo.
So for our real graph, including a tool call inside the transferred specialist, #7789 looks good. We still have not covered resumable apps or tool-confirmation / long-running-tool resume.
Thanks @vishal-bulbule, you’re right that mode is None wouldn’t have caught anything. Checked and LlmAgent.model_post_init stamps chat on every modeless sub-agent and _get_transfer_targets already skips task/single_turn ones so limiting the switch to None/chat and only filling rerun_on_resume when it isn’t in model_fields_set looks right. 411a849 also still merges cleanly on current main.
@thomas50317 thanks for testing it on your real graph, good to see the tool call inside summarize going through to echo. If you use a resumable app or tools that need confirmation in prod it’d be great to run one of those too since that’s the path the last two commits changed, otherwise the unit tests cover it.
@DeanChensj could you take a look at #7789 when you get a chance? It’s verified on the reporter’s graph and the 2 CI workflows are still waiting on maintainer approval.
Activity
vishal-bulbule commentedon Oct 28, 2026
I reproduced this on main (1d81896) with a scripted model and would like to send the fix.
Cause:
build_nodegives a top-levelLlmAgentnode thesingle_turnmode, also when it hassub_agents. Insingle_turnmoderun_llm_agent_as_nodedoes not stop ontransfer_to_agent, so the transfer is handled twice:lead. Its reply becomeslead's output, which is the value you saw in_track_event_in_context.NodeRunneralso copiestransfer_to_agentonto the node context, soDynamicNodeSchedulerruns the target again as a separate hop. That hop runs inchatmode, sets no output, and its context is the oneWorkflow._handle_completionreceives. Downstream getsNone.Two more effects of the second hop: every transfer calls the target model twice per turn, and with a resumable app the test invariants flag events after
end_of_agent.Proposed fix: at the end of the
single_turnbranch inrun_llm_agent_as_node, clearctx.actions.transfer_to_agent, because the inline run already produced the node output. Mode resolution stays as it is. I tried switching these nodes tochatmode inbuild_nodeinstead, but achatnode waits for an output that never comes, so the downstream node never runs at all.Test:
START -> lead(sub_agents) -> captureover two turns, resumable and not. On main,capturereceives[None, None]and each target model is called twice. With the fix it receives the target replies and each target is called once.thomas50317 commentedon Oct 28, 2026
Thanks @vishal-bulbule for reproducing this and proposing the fix — the double-transfer explanation matches our logs exactly. Appreciate @surajksharma07 for owning the issue; happy to verify the PR on our side when ready.
surajksharma07 commentedon Oct 28, 2026
Reproduced this on 2.13.0 with scripted models too @thomas50317. echo gets None and the translate model is called twice for a single transfer.
As a suggested workaround wrapped run_llm_agent_as_node so that it clears ctx.actions.transfer_to_agent once a single_turn node finishes (same idea as #7789). With that echo got the translated reply and the target model ran only once per turn.
@thomas50317 could you try this on your real graph (multi-turn and with a resumable app if you use one) and let us know if it holds up?
@vishal-bulbule please test those cases thoroughly before #7789 goes to review and will flag it for the maintainers once it’s confirmed.
fix(workflow): let the scheduler own single_turn transfers on resume too
vishal-bulbule commentedon Oct 28, 2026
@surajksharma07 thanks for reproducing it. I tested more cases before review, and they showed that the first version of #7789 (and the same clear-the-transfer workaround) only fixes fresh runs. On resume the workflow fast-forwards
leadand resumes the target as the scheduler hop, not inline, so a target that paused on a tool confirmation or a long-running tool still passedNonedownstream. A target transferring back toleadin a resumable app also tripped theno_events_after_cancelinvariant.I updated #7789 so the scheduler owns the transfer in both paths: a
single_turnnode now stops ontransfer_to_agentlikechatandtaskdo, and when the chain starts at asingle_turnnode the scheduler runs eachLlmAgenthop assingle_turn(withrerun_on_resume=True), so the reply of the agent that ends the chain is the node output.Unit tests (scripted models, most run with and without
is_resumable):output_schemaandoutput_keyleadReal model (
gemini-3.5-flashon Agent Platform, the graph from the issue, model calls counted per agent):echogetsNoneboth turns; lead 5, translate 2, summarize 7 callsechogets both replies; lead 2, translate 1, summarize 1 callsechogetsNoneboth turns; lead 6, translate 9, summarize 3 callstranslatetool needs confirmation, then resume (with and without resumability)echogetsNoneafter the resumeechogets the translation after the resume; translate 2 calls (before and after the pause)On main the call counts vary between runs because the transfers bounce between agents; one earlier run on main hit
RecursionErrorin the postprocessor's inline transfer on the first turn.The full unit suite has the same failing set as main, apart from the new tests.
@thomas50317 the branch is
vishal-bulbule:fix/workflow-transfer-outputif you want to run your graph against it.surajksharma07 commentedon Oct 29, 2026
This is a cleaner fix than the first version @vishal-bulbule. Because the single_turn branch now breaks on transfer_to_agent the postprocessor’s inline run never starts (its own comment already expects the wrapper to do this) so fresh runs and resume go through the same path. Went through the updated diff: it still merges cleanly on current main and 2.13.0 is still affected so upgrading won’t fix this.
One thing before review: the scheduler copies any LlmAgent target to single_turn whenever mode != single_turn so a sub-agent the user explicitly set to chat or task (or to rerun_on_resume=False) gets overridden as well. Could you limit that to target_agent.mode is None or add a test showing the override is intended?
@thomas50317 the clear-the-transfer workaround I suggested earlier only covers fresh runs so please don’t rely on it with a resumable app or with tools that pause for confirmation. Could you run your graph against vishal-bulbule:fix/workflow-transfer-output and tell us what echo receives?
Once that’s sorted will flag #7789 for the maintainers so they can approve the pending CI workflows and review it.
vishal-bulbule commentedon Oct 29, 2026
@surajksharma07 good catch, it was a real bug: with a
task-mode target reached through a tool-driven transfer, the copy dropped task mode and the workflow failed withDynamicNodeFailError.Limiting it to
target_agent.mode is Nonewould switch nothing, though.LlmAgent.model_post_initsetsmode = 'chat'on everyLlmAgentsub-agent that has no mode, so a transfer target always arrives aschat, and an explicitmode='chat'looks the same (both havemodeinmodel_fields_set).Noneshows up for an agent that is no one's sub-agent, such as the router itself when a target transfers back to it.So 411a849 on #7789:
Noneorchat.taskandsingle_turnsub-agents are not transfer targets (_get_transfer_targetsskipsDELEGATED_TASK_MODES; they are attached as tools), so the only way to reach one is a tool-driven transfer, and it now keeps its mode.rerun_on_resume=Trueonly when the target did not set it, usingmodel_fields_set.New tests:
test_single_turn_transfer_keeps_task_mode_target(task target keepsfinish_task)DynamicNodeFailError)test_single_turn_transfer_respects_target_rerun_on_resume(target withrerun_on_resume=Falseis not rerun on resume)test_single_turn_transfer_explicit_chat_target_reaches_downstream(an explicitchattarget is switched too, as intended)The earlier cases still pass, along with
tests/unittests/workflow,agents,flows,runnersandstreaming. The full unit suite still has the same failing set as main, apart from the new tests.thomas50317 commentedon Oct 30, 2026
Follow-up verification on vishal-bulbule:fix/workflow-transfer-output @ 411a849.
Graph: START → lead (sub_agents: summarize / translate) → echo
Fresh, non-resumable /run:
Translate path — echo received the specialist text (not empty / None).
Summarize path — same.
Summarize with tool — summarize has ReadFileTool; it called tool_read_file, summarized the file, then echo received that summary. Flow: lead → transfer → summarize → tool_read_file → final text (messageAsOutput) → echo.
So for our real graph, including a tool call inside the transferred specialist, #7789 looks good. We still have not covered resumable apps or tool-confirmation / long-running-tool resume.
Thanks @vishal-bulbule and @surajksharma07 for the reproduction, the updated fix, and the guidance on what to verify.
surajksharma07 commentedon Oct 30, 2026
Thanks @vishal-bulbule, you’re right that mode is None wouldn’t have caught anything. Checked and LlmAgent.model_post_init stamps chat on every modeless sub-agent and _get_transfer_targets already skips task/single_turn ones so limiting the switch to None/chat and only filling rerun_on_resume when it isn’t in model_fields_set looks right. 411a849 also still merges cleanly on current main.
@thomas50317 thanks for testing it on your real graph, good to see the tool call inside summarize going through to echo. If you use a resumable app or tools that need confirmation in prod it’d be great to run one of those too since that’s the path the last two commits changed, otherwise the unit tests cover it.
@DeanChensj could you take a look at #7789 when you get a chance? It’s verified on the reporter’s graph and the 2 CI workflows are still waiting on maintainer approval.