Skip to content

[Feature] Opt-in visibility of sibling agents' final responses across branches #6496

Closed as not planned

Description

@iWhyDuck

🔴 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:

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,
    no function_response, thought parts excluded — the same part filtering
    that output_key applies in LlmAgent.__maybe_save_output_to_state), and
  • it is presented via the existing _present_other_agent_message path
    (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

  1. 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.
  2. isolation_scope: explicitly internal and not persisted by
    VertexAiSessionService; also all-or-nothing, not prose-only.
  3. output_key + {key?} instruction templating: last answer only, one
    static key per agent; no conversation flow, no ordering.
  4. 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.
  5. EventsCompactionConfig: summarizes within a visibility scope; does
    not cross branches and is LLM-paraphrased rather than verbatim.
  6. 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:

  1. 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 custom BaseAgent orchestration as a
    first-class pattern; this request fills its continuity gap.
  2. 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.
  3. 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).
  4. The hub's own isolation currently has the inverse problem. A chat root
    runs with branch=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 no isolation_scope on the sub-agent, and the
    task mode's scope is not serialized by VertexAiSessionService (see
    also Concurrent tool-using LlmAgents run via ctx.run_node crash 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

Activity

  1. llalitkumarrr commented on Jul 28, 2026

    @llalitkumarrr
    Collaborator

    Hello @iWhyDuck

    Thank you for sharing your inputs, we will review your request and get back to you if additional information is required.

  2. added
    core[Component] This issue is related to the core interface and implementation
    needs review[Status] The PR/issue is awaiting review from the maintainer
    on Jul 28, 2026
  3. iWhyDuck commented on Aug 26, 2026

    @iWhyDuck
    Author

    @wukath did you check it? :)

  4. Samecho commented on Oct 14, 2026

    @Samecho
    Contributor

    I checked the current branch filter on 7d3658e; a final text response on dispatcher.first is still hidden from dispatcher.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.

  5. iWhyDuck commented on Oct 15, 2026

    @iWhyDuck
    Author

    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 in single_turn/task mode, 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, no function_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 with skip_summarization, partial events, thought parts). Happy to review the PR.

  6. DeanChensj commented on Oct 20, 2026

    @DeanChensj
    Collaborator

    Thanks for the thorough write-up — the gap is real: branch isolation 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 LlmAgent for 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:

    async def _run_async_impl(self, ctx):
        parent_branch = ctx.branch
        ctx.branch = f"{self.name}.{target.name}"
        async for event in target.run_async(ctx):
            if event.is_final_response():
                event.branch = parent_branch   # the answer is the dispatcher's output
            yield event

    No duplicate events, branch is persisted by every session service, thought parts are stripped by the normal other-agent presentation, and the reservation_lookup call/result stay invisible to local_guide. This should let you delete the session.state Q&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 covers FunctionNode / 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.

  7. iWhyDuck commented on Oct 21, 2026

    @iWhyDuck
    Author

    @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

    bookings has an ordinary human-in-the-loop tool:

    bookings = LlmAgent(
        name="bookings",
        tools=[FunctionTool(reservation_lookup, require_confirmation=True)],
        ...
    )
    
    async for event in target.run_async(ctx):      # dispatcher, re-stamp as suggested
        if event.is_final_response():
            event.branch = parent_branch
        yield event

    The flow is: T1 asks bookings, the user approves ({'confirmed': True}), and T2 goes to local_guide. The session with the re-stamp:

    bookings 'root.bookings'  FC:reservation_lookup{...}
    bookings None             FC:adk_request_confirmation{...}       <- re-stamped
    bookings None             FR:reservation_lookup:{'error': 'This tool call requires confirmation, ...'}  <- re-stamped
    user     None             FR:adk_request_confirmation:{'confirmed': True}   <- inherits None
    
    • The first approval is ignored. It takes the branch of the re-stamped request, None (runners.py:213-219), while bookings resumes on root.bookings and reads only its own branch (flows/llm_flows/tools/_confirmation.py:272).
    • The second approval runs the tool, but the dispatcher is not on the path. A turn whose last event is a function response goes straight to the agent that made the call (agents/_agent_router.py:164-195), so the answer stays on root.bookings:
    user     'root.bookings'  FR:adk_request_confirmation:{'confirmed': True}
    bookings 'root.bookings'  FR:reservation_lookup:{'pnr': ..., 'fare': ..., 'passport': ...}
    bookings 'root.bookings'  'You land on Tuesday at 14:05.'          <- never re-stamped
    

    What local_guide gets in T2 (fence markers trimmed) is the tool response and not the answer:

    user: When do I land in Tokyo?
    user: For context: below is a transcript of what another agent did ...
          [bookings] `reservation_lookup` tool returned result:
          {'error': 'This tool call requires confirmation, please approve or reject.'}
    user: Dinner that evening?
    

    The same result holds with the predicate from section 2 and a non-None parent branch: the approval works, the answer stays on root.bookings, and local_guide sees 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 final

    # events/event.py:295
    def is_final_response(self) -> bool:
        if self.actions.skip_summarization or self.long_running_tool_ids:
            return True

    ADK sets skip_summarization itself in several places, among them:

    • FunctionTool confirmation (tools/function_tool.py:391);
    • McpTool pauses (tools/mcp_tool/mcp_tool.py:593);
    • get_user_choice, exit_loop, AgentTool(skip_summarization=True);
    • the A2A converter (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:

    [bookings] `reservation_lookup` tool returned result:
    {'pnr': 'PNR-XYZ123', 'fare': 'Y-INT'}
    

    7c53386 does not have this problem, because it moves only the declared output of an explicitly claimed child (workflow/_node_runner.py:527-537):

    if event.output is not None:
      event.node_info.output_for = [ctx.node_path] + ctx._output_for_ancestors
      if ctx._output_for_ancestors and not event.get_function_responses():
        claimer = _explicit_output_claimer(ctx)
        if claimer is not None:
          event.branch = claimer._invocation_context.branch

    A BaseAgent equivalent would need a text-only predicate:

    def is_shareable_answer(event: Event) -> bool:
        parts = event.content.parts if event.content else []
        return (
            not event.partial
            and not event.long_running_tool_ids
            and all(p.text is not None for p in parts)          # text only, no code/inline data
            and any(p.text and not p.thought for p in parts)
        )

    3. With the dispatcher as root, parent_branch is None

    # flows/llm_flows/context/_contents.py:760
    if not invocation_branch or not event.branch:
        return True              # branchless = visible to everyone

    The 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=None both via Runner(node=dispatcher) and via Workflow(edges=[(START, dispatcher)]).

    4. The Workflow form shares only single_turn outputs

    This matches the scope you announced. In practice it means a chat specialist never hands its answer on:

    bookings / local_guide What local_guide sees in T2
    single_turn / chat [bookings] said: You land on Tuesday at 14:05.
    chat / chat When do I land in Tokyo? / Dinner that evening? (no answer)
    task / chat no answer

    5. 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:

    T1      bookings: "You land on Tuesday at 14:05."      (stamped onto root)
    later:  add `payments`, which must not see booking answers
    
    sessions written before the change new sessions
    re-stamp payments sees the answer depends on new stamping code
    rule applied at read time payments does not see it payments does not see it

    6. What we are building instead

    Branches stay untouched, and each receiver gets an explicit block derived from session.events on every read. A sketch:

    def conversation_so_far(ctx: ReadonlyContext) -> str:
        pairs = derive_qa_pairs(ctx.session.events)          # question -> final text answer, by message id
        pairs = [p for p in pairs if p.answered_by != "local_guide"]
        return render(pairs)                                  # no agent names
    
    local_guide = LlmAgent(name="local_guide", instruction=conversation_so_far, ...)

    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_guide gets

    SYSTEM: Earlier in this conversation:
    [question] When do I land in Tokyo?
    [answer given] You land on Tuesday at 14:05.
    
    re-stamp derived view
    tool calls, results, confirmations moved when "final" (measured) never candidates
    approval flow (section 1) first approval ignored, answer not shared (measured) works, answer shared (measured)
    agent events without a branch yes (measured) none
    rule change reaches existing sessions no yes
    chat specialists under any root Workflow form: only from single_turn yes
    agent names in the prompt [bookings] said: none

    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_guide sees When do I land in Tokyo? twice, once natively and once in the block. On our side we could avoid that only by rewriting llm_request.contents in a before_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.

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

Metadata

Metadata

Labels

core[Component] This issue is related to the core interface and implementationneeds review[Status] The PR/issue is awaiting review from the maintainer

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions