Repository navigation
Description
Describe the Bug
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)
from google.adk.agents import LlmAgent
from google.adk.workflow import Workflow
from google.adk.workflow import FunctionNode # or @node
summarize = 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="...",
)
def echo(node_input):
return "" if node_input is None else str(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 = ...inworkflow/_node_runner.py_track_event_in_context - In
workflow/_workflow.py_handle_completionbefore_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(orsummarize)nodeInfo.path:.../lead@1messageAsOutput: trueoutputFor:[".../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.
Environment Details
- ADK Library Version: 2.11 (
google-adk) - Desktop OS: Windows 10
- Python Version: 3.11.9
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.
fix: pass a single_turn transfer target's reply downstream