Skip to content

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

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

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