Skip to content

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

Description

@thomas50317

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 = ... 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.

Environment Details

  • ADK Library Version: 2.11 (google-adk)
  • Desktop OS: Windows 10
  • Python Version: 3.11.9

Activity

  1. vishal-bulbule commented on Oct 28, 2026

    @vishal-bulbule
    Contributor

    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:

    1. 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.
    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 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.

  2. thomas50317 commented on Oct 28, 2026

    @thomas50317
    Author

    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.

  3. surajksharma07 commented on Oct 28, 2026

    @surajksharma07
    Collaborator

    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.

  4. added a commit that references this issue on Oct 28, 2026
    f0c2f0c
  5. vishal-bulbule commented on Oct 28, 2026

    @vishal-bulbule
    Contributor

    @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):

    Case main first version updated #7789
    Two turns, different targets, each target called once fail pass pass
    Router is the terminal node, reply surfaced once fail pass pass
    Target with output_schema and output_key fail pass pass
    Nested transfer (lead, translate, formal) fail pass pass
    Target transfers back to lead fail fail (resumable) pass
    Tool confirmation in the target, then resume fail fail pass
    Long-running tool in the target, then resume fail fail pass

    Real model (gemini-3.5-flash on Agent Platform, the graph from the issue, model calls counted per agent):

    Scenario main updated #7789
    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.

  6. surajksharma07 commented on Oct 29, 2026

    @surajksharma07
    Collaborator

    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.

  7. vishal-bulbule commented on Oct 29, 2026

    @vishal-bulbule
    Contributor

    @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.

    So 411a849 on #7789:

    • 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.

    New tests:

    Test previous #7789 now
    test_single_turn_transfer_keeps_task_mode_target (task target keeps finish_task) fail (DynamicNodeFailError) pass
    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.

  8. thomas50317 commented on Oct 30, 2026

    @thomas50317
    Author

    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.

  9. surajksharma07 commented on Oct 30, 2026

    @surajksharma07
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions