Repository navigation
[Feature] Opt-in visibility of sibling agents' final responses across branches #6496
Description
🔴 Required Information
Is your feature request related to a specific problem?
Yes. Consider a common multi-agent chat pattern: several specialist sub-agents
share one session, and a root agent dispatches each user turn to one of them,
forking a sub-branch per delegation. Event.branch isolation is the
documented tool for exactly this ("Branch is used when multiple sub-agent
shouldn't see their peer agents' conversation history", events/event.py) —
but it is all-or-nothing at the event level. A sibling agent later in the same
conversation sees the user's turns (branch=None) but not the previous
agent's final answers: they are filtered out together with that agent's tool
internals, because both live on the same foreign branch.
Concrete example — a travel assistant with two specialists sharing one
session: bookings (answers questions about the user's reservations via an
internal reservations-lookup tool) and local_guide (recommends places to
eat and visit, no access to reservation systems):
T1 user -> bookings: "When do I land in Tokyo?"
bookings -> [reservation_lookup call]
[raw result: fare-class codes, internal record IDs, PNR data]
bookings -> "You land on Tuesday at 14:05 local time."
T2 user -> local_guide: "Great - dinner recommendations near my hotel
for that evening?"
To answer T2 sensibly, local_guide must know what the user was told at
T1 ("that evening" = Tuesday evening, after a 14:05 arrival). With branch
isolation, it only sees the user's questions: it knows the user asked about
landing in Tokyo, but not the answer. The end user, looking at their chat
window, sees both — so the agent is strictly less informed than the human it
is talking to.
At the same time, local_guide must not see the reservation_lookup
call and its raw result (internal record IDs, fare-class codes, PNR data):
guardrails covering that content exist only on bookings, and a
restaurant-recommendation agent has no business carrying raw reservation
records in its prompt.
The existing knobs don't offer this middle ground:
- No branch fork: then everything is visible, and
_present_other_agent_message(flows/llm_flows/contents.py) verbalizes
foreign tool calls and tool results into the model context
("[bookings] called tool reservation_lookup with parameters ...",
"[bookings] tool returned result: ..."). The raw reservation record lands
verbatim in the restaurant agent's prompt. isolation_scopeis documented as internal
("DO NOT USE THIS FIELD DIRECTLY",events/event.py) and is not
serialized byVertexAiSessionService, so it cannot carry a durable
visibility contract on persisted sessions. Related bug class: Concurrent tool-using LlmAgents run viactx.run_nodecrash with "No function call event found for function responses ids" (single_turn appends branchless/scopeless user event to shared session.events) #5989
(branchless/scopeless events become visible to every branch and scope).output_key+ instruction templating shares only the last answer per
agent under a static key — not the conversation flow.
What is missing is a middle setting between "everything" and "nothing":
share only what the end user has already seen (final response text), never the
tool internals.
Describe the Solution You'd Like
An opt-in, prose-only visibility of sibling final responses during content
assembly. Two possible shapes (either would work):
Option A — consumer-side flag on LlmAgent (preferred):
local_guide = LlmAgent(
name="local_guide",
include_sibling_final_responses=True, # default False, today's behavior
...
)Semantics in _get_contents: an event from a foreign branch is included iff
event.is_final_response()is true, and- its content carries only text parts (no
function_call,
nofunction_response,thoughtparts excluded — the same part filtering
thatoutput_keyapplies inLlmAgent.__maybe_save_output_to_state), and - it is presented via the existing
_present_other_agent_messagepath
(role='user',"[author] said: ..."), so authorship stays unambiguous.
With the flag set, local_guide's context at T2 would contain:
user: "When do I land in Tokyo?"
user: "For context: [bookings] said: You land on Tuesday at 14:05 local time."
user: "Great - dinner recommendations near my hotel for that evening?"
— and still no trace of the reservation_lookup call or its raw result.
User events and same-branch behavior are unchanged. Default False keeps
today's behavior exactly.
Option B — producer-side marker: a public EventActions field (e.g.
share_final_response_across_branches: bool) that the framework sets on final
response events of agents which opted in (here: bookings); the branch filter
then admits only events carrying the marker. This variant makes the contract
durable in the session log itself (and trivially serializable, unlike
isolation_scope).
Impact on your work
We run a multi-agent chat following the pattern above (root dispatcher, one
shared session, sub-branch per delegated turn). Branch isolation is
load-bearing for us: one specialist's tool events contain content that other
specialists must never see, because the guardrails built for that content
exist only on the producing agent.
Without this feature we maintain a custom Q&A log in session.state (the root
agent appends {question, agent, answer} after each turn; other agents read
it via an instruction provider). It works and stays on public APIs, but it
reimplements, outside the framework, a filtered view the content builder could
provide natively — and any ADK adopter with a routing-style multi-agent chat
will need the same thing. Not timeline-critical (the workaround holds), but it
would let us delete workaround code and would make branch-isolated multi-agent
chats viable out of the box.
Willingness to contribute
Yes — we are open to submitting a PR for Option A (flag + content-builder
filter + tests) if maintainers agree on the shape.
🟡 Recommended Information
Describe Alternatives You've Considered
- No branch fork + everything visible: leaks verbalized tool internals of
sibling agents into every model context (see above); also inflates prompts
with foreign tool traffic. isolation_scope: explicitly internal and not persisted by
VertexAiSessionService; also all-or-nothing, not prose-only.output_key+{key?}instruction templating: last answer only, one
static key per agent; no conversation flow, no ordering.- Custom Q&A log in
session.state(our current workaround): the root
agent is the single writer, sub-agents read via an instruction provider.
Works, but is framework-external duplication of context assembly. EventsCompactionConfig: summarizes within a visibility scope; does
not cross branches and is LLM-paraphrased rather than verbatim.- MemoryService /
PreloadMemoryTool: designed for cross-session memory;
asynchronous ingestion and LLM-extracted paraphrase make it unsuitable for
faithful same-conversation continuity.
Why the native hub pattern is not a substitute
A natural response would be: "use the built-in hub pattern instead — a chat
root LlmAgent with sub-agents as tools (mode='single_turn'/'task') or
transfer_to_agent; the root's own history then carries the conversation
flow." That pattern is valuable, but it cannot replace a branch-forking
dispatcher for a whole class of applications, for independent reasons:
- Some deployments need the dispatch decision to be deterministic, not
model-made. When reaching a given specialist is an entitlement
(per-user feature gating), when some entry points must force a specific
specialist, or when per-agent attribution feeds billing/quotas, the
"who handles this turn" decision must be auditable code with measurable
quality (scores, thresholds, regression sets) — not a tool choice made by
an LLM whose behavior shifts with model versions and prompt phrasing.
The framework already embraces customBaseAgentorchestration as a
first-class pattern; this request fills its continuity gap. - The hub taxes every turn with an extra LLM pass. The root model runs
before any specialist token is produced (time-to-first-token on streaming
answers), and trivial turns (greetings, out-of-scope deflections) can no
longer be served template-only, because the root is a model. - Specialists-as-tools don't see the conversation at all — they receive a
request string packed by the root model. In the travel example,bookings
would receive a synthetic request like "check arrival time for the user's
Tokyo flight" instead of the dialogue itself: continuity becomes a
generative paraphrase of the conversation, which is exactly what this
request tries to avoid (faithful, verbatim Q&A flow). - The hub's own isolation currently has the inverse problem. A chat root
runs withbranch=None, and the branch filter's empty-branch early-return
admits every event — so the root's context includes sub-agents' verbalized
tool calls and raw tool results (the raw reservation record again).
mode='single_turn'sets noisolation_scopeon the sub-agent, and the
taskmode's scope is not serialized byVertexAiSessionService(see
also Concurrent tool-using LlmAgents run viactx.run_nodecrash with "No function call event found for function responses ids" (single_turn appends branchless/scopeless user event to shared session.events) #5989 for this bug class). So even teams willing to accept
model-driven dispatch don't get "internals stay private" out of the box
today.
In short: LLM-driven hubs and deterministic dispatchers are complementary
routing models, and only the framework can make prose-level continuity work
for the second one — which is why we propose it as a content-builder feature
rather than another application-side workaround.
Minimal reproducer (current behavior)
"""Shows that a sibling agent does not see another agent's final answer.
ADK 2.4.0/2.5.0. The same script with include_sibling_final_responses=True
(proposed) would include "[bookings] said: You land on Tuesday at 14:05..."
in local_guide's contents, and still exclude the reservation_lookup
call/result events.
"""
import asyncio
from google.adk.agents import BaseAgent, LlmAgent
from google.adk.events import Event
from google.adk.runners import InMemoryRunner
from google.genai import types
class Dispatcher(BaseAgent):
"""Delegates turn 1 to bookings, turn 2 to local_guide, forking branches."""
async def _run_async_impl(self, ctx):
target = bookings if not ctx.session.state.get("t1_done") else local_guide
ctx.branch = f"{self.name}.{target.name}"
async for event in target.run_async(ctx):
yield event
yield Event(author=self.name,
actions=dict(state_delta={"t1_done": True}))
def reservation_lookup(booking_ref: str) -> dict:
"""Stands in for a tool whose output must stay agent-private."""
return {"pnr": "ABC123", "fare_class": "W/OPAQUE",
"arrival": "TUE 14:05 NRT", "internal_record_id": 998877}
bookings = LlmAgent(name="bookings", model="gemini-2.5-flash",
tools=[reservation_lookup],
instruction="Answer questions about the user's "
"reservations using reservation_lookup.")
local_guide = LlmAgent(name="local_guide", model="gemini-2.5-flash",
instruction="Recommend restaurants and activities.")
root = Dispatcher(name="dispatcher", sub_agents=[bookings, local_guide])
async def main():
runner = InMemoryRunner(agent=root)
session = await runner.session_service.create_session(
app_name=runner.app_name, user_id="u1")
async for _ in runner.run_async(
user_id="u1", session_id=session.id,
new_message=types.Content(role="user", parts=[
types.Part(text="When do I land in Tokyo?")])):
pass
# Turn 2: local_guide's contents (inspect via before_model_callback or
# _get_contents) include the user turns from T1/T2 but NOT bookings'
# final answer - it sits on branch "dispatcher.bookings" and is filtered
# out together with the reservation_lookup call/result events.
async for _ in runner.run_async(
user_id="u1", session_id=session.id,
new_message=types.Content(role="user", parts=[
types.Part(text="Dinner recommendations near my hotel "
"for that evening?")])):
pass
asyncio.run(main())Observed: local_guide's model request contains the T1 user question but no
trace of bookings' answer — "that evening" cannot be resolved. Desired
(opt-in): the final answer text is present, attributed via the
"[bookings] said: ..." convention, while the reservation_lookup call and
its raw result (PNR, fare class, internal record id) remain excluded.
Additional context
- Related issue: Concurrent tool-using LlmAgents run via
ctx.run_nodecrash with "No function call event found for function responses ids" (single_turn appends branchless/scopeless user event to shared session.events) #5989 — branchless/scopeless events appended bysingle_turn
became visible to every branch/scope; it shows visibility semantics in
multi-agent trees are a real-world pain point and that event-level
visibility contracts need to be explicit and serializable. - The part-level filtering needed here already exists in two places and could
be reused:LlmAgent.__maybe_save_output_to_state(text-only, no thoughts)
and_present_other_agent_message(attribution format).
Activity
llalitkumarrr commentedon Jul 28, 2026
Hello @iWhyDuck
Thank you for sharing your inputs, we will review your request and get back to you if additional information is required.
iWhyDuck commentedon Aug 26, 2026
@wukath did you check it? :)
Samecho commentedon Oct 14, 2026
I checked the current branch filter on
7d3658e; a final text response ondispatcher.firstis still hidden fromdispatcher.second. #7242 covers visibility across completed parallel stages on v1, but explicitly keeps sibling lanes isolated, so it does not add the opt-in requested here.I can help with the text-only scope. Would you prefer the opt-in on the receiving agent or its run configuration? I would preserve the default isolation and exclude thought parts, tool calls, and tool responses. That choice matters because a run-wide flag would expose sibling answers to every agent in the invocation.
iWhyDuck commentedon Oct 15, 2026
Thanks for checking against current
main. The receiving agent, please — Option A in the issue. A run-wide flag would also apply to a second delegation within the same turn and to agents called insingle_turn/taskmode, which today start from an empty context; per-agent opt-in keeps both unchanged. Agreed on the scope: default isolation untouched, text parts only, no thought parts, nofunction_call/function_response, presented through_present_other_agent_message. One request: please keep the predicate in a single helper so it can be unit-tested on its own (final event carrying a function response withskip_summarization, partial events, thought parts). Happy to review the PR.perf(agents): prepare sibling receiver context once per request
DeanChensj commentedon Oct 20, 2026
Thanks for the thorough write-up — the gap is real:
branchisolation is all-or-nothing, so a specialist's final answer gets hidden together with its tool traffic.We're not going to add a receiver-side flag on
LlmAgentfor this. The principle we want to keep is producer-side: a delegated agent's final answer is the return value of that delegation, so it belongs on the dispatcher's branch, while the tool calls/results stay on the sub-branch. Once the answer sits on the dispatcher's branch, the existing ancestor-visibility rule makes it visible to every later delegation, with the usual[bookings] said:attribution, and no new configuration is needed.For the custom
Dispatcher(BaseAgent)in your reproducer this works today — you own the child's event stream, so re-stamp the final answer before yielding it:No duplicate events,
branchis persisted by every session service, thought parts are stripped by the normal other-agent presentation, and thereservation_lookupcall/result stay invisible tolocal_guide. This should let you delete thesession.stateQ&A log.On the framework side, for the Workflow form of the same pattern (
ctx.run_node(target, use_sub_branch=True, use_as_output=True)), we're landing a fix so that the child's output event is published on the calling node's branch instead of the sub-branch; that coversFunctionNode/ single-turn / structured-output children. A broader, event-level notion of "visible to the user vs. internal work" is the longer-term direction and will be designed separately rather than as a per-agent flag.Closing this issue in favour of the workaround above plus that fix; please reopen if the re-stamp approach does not cover your setup. And thanks @yinx65588-boop for PR #7551 — the "must not leak" test cases from it are being reused.
fix(workflow): publish a delegated output on the claiming node's branch
iWhyDuck commentedon Oct 21, 2026
@DeanChensj Thanks for the detailed answer. I tested the suggested re-stamp offline on v2.12.0 and on
main@a221ccd(with 7c53386), using a dispatcher equivalent to the one in your comment. Both versions behave the same. Below are the cases it does not cover, followed by what we are building instead.1. Turns resumed by a function response bypass the dispatcher
bookingshas an ordinary human-in-the-loop tool:The flow is: T1 asks
bookings, the user approves ({'confirmed': True}), and T2 goes tolocal_guide. The session with the re-stamp:None(runners.py:213-219), whilebookingsresumes onroot.bookingsand reads only its own branch (flows/llm_flows/tools/_confirmation.py:272).agents/_agent_router.py:164-195), so the answer stays onroot.bookings:What
local_guidegets in T2 (fence markers trimmed) is the tool response and not the answer:The same result holds with the predicate from section 2 and a non-
Noneparent branch: the approval works, the answer stays onroot.bookings, andlocal_guidesees only the two user questions. A re-stamp that lives in the dispatcher cannot reach turns the runner resumes without it.2.
is_final_response()counts tool events as finalADK sets
skip_summarizationitself in several places, among them:FunctionToolconfirmation (tools/function_tool.py:391);McpToolpauses (tools/mcp_tool/mcp_tool.py:593);get_user_choice,exit_loop,AgentTool(skip_summarization=True);a2a/converters/to_adk_event.py:604).When the result carries data, it reaches the next agent verbatim. Our probe tool sets the flag itself, as
AgentTool(skip_summarization=True)would:7c53386 does not have this problem, because it moves only the declared output of an explicitly claimed child (
workflow/_node_runner.py:527-537):A
BaseAgentequivalent would need a text-only predicate:3. With the dispatcher as root,
parent_branchisNoneThe re-stamped answer then looks like a user turn, or like an event that lost its branch (#5989). 7c53386 behaves the same way here: with the dispatcher as root, the output is published with
branch=Noneboth viaRunner(node=dispatcher)and viaWorkflow(edges=[(START, dispatcher)]).4. The Workflow form shares only
single_turnoutputsThis matches the scope you announced. In practice it means a
chatspecialist never hands its answer on:bookings/local_guidelocal_guidesees in T2single_turn/chat[bookings] said: You land on Tuesday at 14:05.chat/chatWhen do I land in Tokyo?/Dinner that evening?(no answer)task/chat5. A stamp is fixed when it is written
A shared parent branch (
root.secure) can group receivers, but that decision is made when the answer is written. Here is a rule that changes later:paymentssees the answerpaymentsdoes not see itpaymentsdoes not see it6. What we are building instead
Branches stay untouched, and each receiver gets an explicit block derived from
session.eventson every read. A sketch:Today our router reads this derived view on every turn, and the receiving-agent side is still in progress. Measured with a minimal derivation on v2.12.0, in the same confirmation flow as section 1 and without any re-stamp: the approval works, and
local_guidegetschatspecialists under any rootsingle_turn[bookings] said:Pairs are matched by message id, not by position. A blocked question drops out, a blocked or template answer leaves its question unanswered, and for a regenerated question the newest answer wins.
One cost remains. User turns are branchless, so
local_guideseesWhen do I land in Tokyo?twice, once natively and once in the block. On our side we could avoid that only by rewritingllm_request.contentsin abefore_model_callback, which means re-implementing the content builder.Could you reopen? Section 1 shows that a dispatcher-side stamp cannot cover turns the runner resumes without the dispatcher. That leaves two options that do cover them: a receiver-side opt-in in the content builder (Option A, with the predicate from section 2), or a framework hook that publishes the answer on every path, resumed turns included. I'm happy to send a PR for Option A with tests for the cases above.