Repository navigation
feat(agents): opt into final text from sibling branches - #7551
yinx65588-boop wants to merge 4 commits into
Conversation
Let receiving agents retain conversation continuity across isolated sibling branches without importing tool internals or thought parts. Share a text-only request view using the existing attributed other-agent presentation, while preserving task and single-turn isolation and the default visibility rules. Add behavior and deterministic two-turn Runner tests. Refs google#6496
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces the include_sibling_final_responses option, allowing receiving agents to opt into final text responses from sibling branches in their chat history while excluding private tool internals, thoughts, and media. The changes include updating agent configurations, implementing the logic to filter and project sibling responses, and adding comprehensive unit tests. The review feedback suggests optimizing performance by precomputing the branch path and run IDs outside the event iteration loop instead of parsing them on every iteration.
| def _get_sibling_final_response( | ||
| current_branch: str | None, | ||
| event: Event, | ||
| agent_name: str, | ||
| tool_call_ids: set[str], | ||
| ) -> Event | None: | ||
| """Projects a sibling's final answer onto public text parts only. | ||
|
|
||
| This is a request-only view: the stored event, including any thought parts | ||
| and signatures, is never changed. A tool result can report itself as final | ||
| through skip_summarization, so finality alone is insufficient. | ||
| """ | ||
| if ( | ||
| not current_branch | ||
| or not event.branch | ||
| or event.author in ('user', agent_name) | ||
| or event.isolation_scope is not None | ||
| or event.partial | ||
| or event.long_running_tool_ids | ||
| or event.actions.compaction | ||
| or not event.is_final_response() | ||
| or not event.content | ||
| or event.content.role != 'model' | ||
| or not event.content.parts | ||
| ): | ||
| return None | ||
|
|
||
| current_path = _BranchPath.from_string(current_branch) | ||
| event_path = _BranchPath.from_string(event.branch) | ||
| if ( | ||
| current_path.parent is None | ||
| or current_path.parent != event_path.parent | ||
| or current_path == event_path | ||
| or current_path.run_ids.intersection(tool_call_ids) | ||
| or event_path.run_ids.intersection(tool_call_ids) | ||
| ): | ||
| return None |
There was a problem hiding this comment.
To avoid redundant string parsing and object creation inside the loop, we can pass the pre-parsed current_path and current_run_ids to _get_sibling_final_response instead of parsing current_branch and computing run_ids on every single iteration.
def _get_sibling_final_response(
current_path: _BranchPath | None,
event: Event,
agent_name: str,
tool_call_ids: set[str],
current_run_ids: set[str],
) -> Event | None:
"""Projects a sibling's final answer onto public text parts only.
This is a request-only view: the stored event, including any thought parts
and signatures, is never changed. A tool result can report itself as final
through skip_summarization, so finality alone is insufficient.
"""
if (
current_path is None
or not event.branch
or event.author in ('user', agent_name)
or event.isolation_scope is not None
or event.partial
or event.long_running_tool_ids
or event.actions.compaction
or not event.is_final_response()
or not event.content
or event.content.role != 'model'
or not event.content.parts
):
return None
event_path = _BranchPath.from_string(event.branch)
if (
current_path.parent is None
or current_path.parent != event_path.parent
or current_path == event_path
or current_run_ids.intersection(tool_call_ids)
or event_path.run_ids.intersection(tool_call_ids)
):
return None| raw_filtered_events = [] | ||
| for event in rewind_filtered_events: | ||
| if _should_include_event_in_context( | ||
| current_branch, | ||
| event, | ||
| isolation_scope=isolation_scope, | ||
| node_path=node_path, | ||
| include_thoughts=( | ||
| include_thoughts_from_other_agents | ||
| and _is_other_agent_reply(agent_name, event) | ||
| ), | ||
| ) and not _is_tool_sub_branch_event( | ||
| current_branch, event, agent_name, tool_call_ids | ||
| ): | ||
| raw_filtered_events.append(event) | ||
| elif ( | ||
| include_sibling_final_responses | ||
| and isolation_scope is None | ||
| and not is_single_turn | ||
| ): | ||
| sibling_answer = _get_sibling_final_response( | ||
| current_branch, event, agent_name, tool_call_ids | ||
| ) | ||
| ] | ||
| if sibling_answer is not None: | ||
| raw_filtered_events.append(sibling_answer) |
There was a problem hiding this comment.
Precompute current_path and current_run_ids once outside the loop to optimize performance and avoid redundant computations on every event iteration.
current_path = _BranchPath.from_string(current_branch) if current_branch else None
current_run_ids = current_path.run_ids if current_path else set()
raw_filtered_events = []
for event in rewind_filtered_events:
if _should_include_event_in_context(
current_branch,
event,
isolation_scope=isolation_scope,
node_path=node_path,
include_thoughts=(
include_thoughts_from_other_agents
and _is_other_agent_reply(agent_name, event)
),
) and not _is_tool_sub_branch_event(
current_branch, event, agent_name, tool_call_ids
):
raw_filtered_events.append(event)
elif (
include_sibling_final_responses
and isolation_scope is None
and not is_single_turn
):
sibling_answer = _get_sibling_final_response(
current_path, event, agent_name, tool_call_ids, current_run_ids
)
if sibling_answer is not None:
raw_filtered_events.append(sibling_answer)Parse the receiver branch and check its tool ancestry outside the event loop when sibling sharing is enabled. Reuse that path for projecting final answers without adding branch preparation to the default path. Cover ordinary run IDs, receivers inside tool ancestors, and receivers run as tools. Group the sibling-answer cases with the contents test module prefix. Refs google#6496
Link to Issue or Description of Change
Related: #6496
Problem: With branch-isolated specialist agents, a later specialist sees the user's earlier questions but cannot see another specialist's final answer. Removing branch isolation also exposes tool calls and raw tool results.
Solution: Add
LlmAgent.include_sibling_final_responses(defaultFalse) on the receiving agent. In unscoped chat history, include final non-thought text from direct sibling branches with the same parent, using the existing attributed/quoted other-agent presentation. Reject partial, tool/media, pending-tool, compaction, and scoped events. Construct a request-only text view without rewriting stored events. Task/single-turn isolation and the no-history/server-side interaction paths retain their existing behavior.Ready for maintainer review of the API and scope. The initial scope is direct siblings, rather than every foreign branch. An existing contributor has expressed interest in #6496; ownership and possible overlap remain to be coordinated in review.
Testing Plan
Unit Tests:
/bin/bashshebang; no hooks are disabled and the repository configuration is unchanged.106150aa49e5b818e6a131a8a98f093fa7a869edin the refreshed contributor fork validation. It uses the upstream test extra, upstream lancedb installation exclusion, and the completetests/unittestssuite. Verified that the validation commit differs from this PR head only by its auxiliary workflow file, which is excluded from this PR.All four jobs concluded
success; xfail/xpass outcomes are reported as emitted by the existing suite. This fork run does not replace upstream repository checks, which still require maintainer approval to execute.toxprogressed into the full suite after fixing dependency setup. It exposed platform-specific CLI failures; eight were reproduced on untouched upstream source (gcloud command suffix, path separators, and symlink privileges). The partial local run was stopped when the Linux matrix became available. It is not reported as a passing full suite.Observed output:
End-to-End Evidence:
test_runner_shares_only_the_bookings_answer_with_the_guideexecutes two realInMemoryRunnerturns, a real reservations tool, and real session persistence. Only the external model boundary is scripted. Run both opt-in states with:The guide's actual model request contains
[bookings] said:and the public arrival answer only with opt-in enabled. Neither request containsPRIVATE_PNRorPRIVATE_THOUGHT, while the stored session retains both. The same assertions pass against the installed wheel. No live model API calls were made; this is deterministic runtime integration evidence, not a live-model E2E claim.Checklist
Additional context
Synced with upstream through
f78149a; current contribution head is106150aa49e5b818e6a131a8a98f093fa7a869ed. The sync required no conflict resolution or additional feature-code changes. Default root/ancestor visibility and event persistence formats are unchanged. Mixed tool/media events are excluded as a whole; otherwise plain-text answers are projected without thought text or signatures. Developed with AI assistance.Follow-up review improvements
Prepare the receiving branch and its tool-ancestry eligibility once per request, only when sibling sharing is enabled. Pass the prepared path into the existing text-projection helper instead of reparsing the receiver branch for every event. This addresses the redundant receiver work identified in both automated review threads. Add regressions for ordinary run IDs, receivers beneath a tool ancestor, and receivers invoked as tools. Rename the test module to use the existing contents-module prefix.
Companion documentation PR #2416 updates the existing agent-context guide, marks the feature unreleased, and documents its boundaries.
mkdocs build --strictpassed including 244 indexed pages; its Python example executes against this implementation.Refreshed incremental static check against upstream
f78149aunder the same local dependency environment (mypy src): upstream and PR each report 296 normalized errors, with zero new errors. This does not claim that the upstream project is type-error-free or that every upstream CI type-check environment has passed.