Skip to content

Workflow: node returning None in a later loop iteration leaves its previous output for JoinNode聽#7515

Open

Description

@AkshayShah03

馃敶 Required Information

Describe the Bug:
In a looping workflow, when a node returns None on a later iteration, a downstream JoinNode still receives that node's output from the previous iteration.

Workflow._handle_completion (src/google/adk/workflow/_workflow.py) only writes to loop_state.node_outputs when the output is not None:

if child_ctx.output is not None:
  loop_state.node_outputs[node_name] = child_ctx.output

So the old value stays in place, and the join reads it with loop_state.node_outputs.get(p). On the first iteration the same node returning None gives the join None. On later iterations it gives stale data, so the result depends on what happened in an earlier pass.

This is the follow-up I mentioned in #7498. It is a separate bug: it reproduces on main with or without #7499.

Steps to Reproduce:

  1. pip install google-adk==2.11.0
  2. Run the snippet below. a returns "A1" on the first iteration and None on the second.

Expected Behavior:

{'a': 'A1', 'b': 'B1'}
{'a': None, 'b': 'B2'}

Observed Behavior:

{'a': 'A1', 'b': 'B1'}
{'a': 'A1', 'b': 'B2'}

Environment Details:

  • ADK Library Version: 2.11.0 (also reproduced on main at b3cbb47)
  • Desktop OS: macOS
  • Python Version: 3.12.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (function nodes only)

馃煛 Optional Information

Minimal Reproduction Code:

import asyncio

from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.workflow import Edge, JoinNode, START, Workflow, node
from google.genai import types

iteration = 0
join_inputs = []


def a(node_input):
  global iteration
  iteration += 1
  return "A1" if iteration == 1 else None  # no output on iteration 2


def b(node_input):
  return f"B{iteration}"


def check(node_input):
  join_inputs.append(node_input)
  if len(join_inputs) < 2:
    yield Event(route="again")
  else:
    yield Event(route="done", output="finished")


def end(node_input):
  return node_input


A, B, C, E = node(a), node(b), node(check), node(end)
J = JoinNode(name="J")
wf = Workflow(
    name="wf",
    edges=[
        Edge(from_node=START, to_node=A),
        Edge(from_node=START, to_node=B),
        Edge(from_node=A, to_node=J),
        Edge(from_node=B, to_node=J),
        Edge(from_node=J, to_node=C),
        Edge(from_node=C, to_node=A, route="again"),
        Edge(from_node=C, to_node=B, route="again"),
        Edge(from_node=C, to_node=E, route="done"),
    ],
)


async def main():
  runner = Runner(app_name="app", node=wf, session_service=InMemorySessionService())
  session = await runner.session_service.create_session(app_name="app", user_id="u")
  async for _ in runner.run_async(
      user_id="u",
      session_id=session.id,
      new_message=types.Content(role="user", parts=[types.Part(text="go")]),
  ):
    pass
  for i in join_inputs:
    print(dict(sorted(i.items())))


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Proposed fix (looking for guidance before I open a PR)

I'd like to fix this, but there are a few ways to do it and they behave differently, so I'd like to check which one fits the design first.

  1. Remove the entry when a node completes with None (loop_state.node_outputs.pop(node_name, None)). A key in node_outputs would still mean "this node produced output", so _finalize and _has_terminal_output keep working as they do now. The join gets None through .get(), which matches what it gets on the first iteration. This is my preferred option: it changes one line and only affects nodes that run more than once.
  2. Always store the output, including None. This is simpler, but it changes how terminal outputs work. A terminal node that returns None would count as having produced output, which affects _has_terminal_output and the "multiple terminal nodes produced output" check.
  3. Clear the join's predecessor outputs when the join fires. This only affects joins, but node_outputs could still go stale for other readers, such as a terminal node that runs more than once.

Questions:

Once a direction is confirmed, I'll open a PR with a unit test in tests/unittests/workflow/test_join_node.py.

Activity

  1. DeanChensj commented on Oct 12, 2026

    @DeanChensj
    Collaborator

    Thanks for the clean repro and the option analysis. Option 1 is the right call: a key in node_outputs should keep meaning "this run produced output", so terminal-output detection is unaffected and the join sees None exactly as it does on the first iteration.

    No separate PR is needed: we're folding this into the internal import of #7499. During that review the same one-line fix (loop_state.node_outputs.pop(node_name, None) in _handle_completion when a run completes without output) was added alongside your join change, and the change description will carry Fixes #7515, so this issue will close automatically when it merges. Please leave it open until then so it stays tracked.

    For context, both issues share a root cause: _LoopState caches per-node status and output by name and was written with DAGs in mind, so nothing resets those entries when a node starts a new run inside a loop. We'll track a broader cleanup for that separately.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions