-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Description
Describe the bug
We have encountered a bug when nesting multiple instances of the following architecture:
SequentialAgent[ParallelAgent[A, B], LLMAgent],
where the LLMAgent acts as a reducer of the outputs of A and B.
When we only have one instance of that architecture, as above, it works exactly as expected. However, when nesting multiple of these architectures, the LLMAgent reducers don't see the outputs of Agents A and B in the parallel group before them.
This is because the _is_event_belongs_to_branch function from google.adk.flows.llm_flows.contents seems to be incorrectly filtering out events from the ParallelAgent before it.
In general, nesting parallel agents seem to have event visibility issues where the reducing agents don't always see the context of the agents in the Parallel Agent before them in sequence.
To Reproduce
Please share a minimal code and data to reproduce your problem.
Steps to reproduce the behavior:
We want the following architecture:
# Callback to demonstrate issue
def print_llmrequest_contents(callback_context: CallbackContext, llm_request: LlmRequest):
print("\n\n\n" + "*****"*10)
print(f"Model called in agent: {callback_context.agent_name}")
print(f"Agent branch: {getattr(callback_context, 'branch', 'NO BRANCH')}")
print(f"Total session events: {len(callback_context.session.events)}")
# Print all events with their branches
print("ALL SESSION EVENTS:")
for i, event in enumerate(callback_context.session.events):
print(f" {i}: {event.author} in branch '{event.branch}' said: \"{''.join([part.text for part in event.content.parts]) if event.content and event.content.parts else 'NO CONTENT'}\"")
print("FILTERED CONTENTS SENT TO MODEL:")
for content in llm_request.contents:
print(f"{content.role}: {''.join([part.text for part in content.parts])}")
print("\n\n\n" + "*****"*10)
# Group 1
A = Agent(name='Alice', description='An obedient agent.', instruction='Please say your name and your favorite sport.', model=model)
B = Agent(name='Bob', description='An obedient agent.', instruction='Please say your name and your favorite sport.', model=model)
C = Agent(name='Charlie', description='An obedient agent.', instruction='Please say your name and your favorite sport.', model=model)
# Group 2
D = Agent(name='David', description='An obedient agent.', instruction='Please say your name and your favorite sport.', model=model)
E = Agent(name='Eve', description='An obedient agent.', instruction='Please say your name and your favorite sport.', model=model)
F = Agent(name='Frank', description='An obedient agent.', instruction='Please say your name and your favorite sport.', model=model)
# Parallel Agents
P1 = ParallelAgent(
name='ABC',
description='Parallel group ABC',
sub_agents=[A, B, C],
)
P2 = ParallelAgent(
name='DEF',
description='Parallel group DEF',
sub_agents=[D, E, F],
)
# Reducers
# ABC -> R1
R1 = Agent(name="reducer1", description="Reducer for ABC", instruction="Summarize the responses from agents A, B, and C.", model=model, before_model_callback=print_llmrequest_contents)
S1 = SequentialAgent(name='Group1_Sequential', description="Sequential group for ABC", sub_agents=[P1, R1])
# DEF -> R2
R2 = Agent(name="reducer2", description="Reducer for DEF", instruction="Summarize the responses from agents D, E, and F.", model=model, before_model_callback=print_llmrequest_contents)
S2 = SequentialAgent(name='Group2_Sequential', description="Sequential group for DEF", sub_agents=[P2, R2])
# Running ABC and DEF in parallel
P3 = ParallelAgent(
name='Final_Parallel',
sub_agents=[S1, S2],
)
# Final reduction of previous reducers
R3 = Agent(name="final_reducer", description="Final Reducer", instruction="Summarize the outputs from both groups.", model=model, before_model_callback=print_llmrequest_contents)
S3 = SequentialAgent(name='Final_Sequential', description="Sequential group for final outputs", sub_agents=[P3, R3])So this combines two of those architectures to run in parallel, where each one contains three LLM Agents in Parallel followed by a single LLMAgent reducer in sequence; then both of those reducers go to the final reducer.
Architure looks like:
Sequential1 = [A, B, C] -> Reducer 1
Sequential2 = [D, E, F] -> Reducer 2
[Sequential1, Sequential2] -> Reducer 3
where [] denotes parallel execution.
The bug is that, when you run S3, Reducers R1 and R2 don't see the outputs of the parallel groups that came before them. Although they are present in session.events, they are filtered out and not present in llm_request.contents. However if you just run S1 or S2 (those would each be one instance of the reduce architecture), the reducers DO see the outputs of the parallel groups before them. So its only when nesting these architectures.
Expected behavior
We expect that the parallel sub-agents' outputs are visible to the reducing agent.
Screenshots
Desktop (please complete the following information):
- OS: [e.g. macOS, Linux, Windows] - MacOS
- Python version(python -V): 3.12
- ADK version(pip show google-adk): 1.16
Model Information:
- Are you using LiteLLM: Yes/No -- Yes
- Which model is being used(e.g. gemini-2.5-pro) -- Azure OpenAI 4o
Exploration into the cause and fix
The cause of this situation is happening in this function:
def _is_event_belongs_to_branch(
invocation_branch: Optional[str], event: Event
) -> bool:
"""Check if an event belongs to the current branch.
This is for event context segration between agents. E.g. agent A shouldn't
see output of agent B.
"""
if not invocation_branch or not event.branch:
return True
return invocation_branch.startswith(event.branch)The nested parallel agents have branches named like ParallelAgent.A and ParallelAgent.B; and if A and B are parallel agents themselves, you might see sub branches like ParallelAgent.A.sub1 and ParallelAgent.A.sub2.
When filtering events to decide what contents go to a particular LLM invocation, the above function checks if the invocation branch either equals the event branch; or starts with the event branch. But in the case of the architecture we had, the invocation of the reducer agents would have branch ParallelAgent.A, while the events from the parallel agents before it would have sub branches like ParallelAgent.A.sub1; and since ParallelAgent.A **does not start with ** ParallelAgent.A.sub1, indeed, this is only true in reverse, the reducer on branch ParallelAgent.A doesn't see the events of ParallelAgent.A.sub1.
One proposed solution is to patch this function to perform this check in the other direction as well. If the event's branch starts with the invocation's branch, then accept it too.
def _is_event_belongs_to_branch(invocation_branch, event) -> bool:
"""Check if an event belongs to the current branch.
This is for event context segration between agents. E.g. agent A shouldn't
see output of agent B.
"""
if not invocation_branch or not event.branch:
return True
# Original logic: can see parent branch events
if invocation_branch.startswith(event.branch):
return True
# New logic: can also see child branch events
if event.branch.startswith(invocation_branch):
return True
return FalseIn our testing, this fixes the architecture, allowing the reducers to see the parallel agents' outputs before them. But I'm not sure why this check in the reverse direction wasn't present before, and what this breaks, so I'm hoping to have the discussion about it.