google / google/adk-python

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

Đang mở
#6,496 2 bình luận 4 reaction 2 người được giao Được @wukath nhận Xem trên GitHub
core needs review
Ngôn ngữ chính
Python
Star
21.5k
Fork
4k
Merge trung bình
1 ngày 14 giờ
Pull request đã merge (30 ngày)
37

Mô tả

## 🔴 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).

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.