google / google/adk-python

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

Offen
#6,496 2 Kommentare 4 Reaktionen 2 zugewiesene Personen Beansprucht von @wukath Auf GitHub ansehen
core needs review
Vorherrschende Sprache
Python
Sterne
21.5k
Forks
4k
Ø Merge
1 T. 14 Std.
Gemergte PRs (30 T.)
37

Beschreibung

## 🔴 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_scope`** is documented as internal
(*"DO NOT USE THIS FIELD DIRECTLY"*, `events/event.py`) and is not
serialized by `VertexAiSessionService`, so it cannot carry a durable
visibility contract on persisted sessions. Related bug class: #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):**

```python
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 #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)

```python
"""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: #5989 — branchless/scopeless events appended by `single_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).

Beitragsleitfaden

Beitragsleitfaden öffnen

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.