Skip to content

feat(agents): opt into final text from sibling branches - #7551

Open
yinx65588-boop wants to merge 4 commits into
google:mainfrom
yinx65588-boop:feat/sibling-final-responses
Open

yinx65588-boop wants to merge 4 commits into
google:mainfrom
yinx65588-boop:feat/sibling-final-responses

Conversation

@yinx65588-boop

@yinx65588-boop yinx65588-boop commented Oct 15, 2026 •

Copy link
Copy Markdown

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 (default False) 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.

local_guide = LlmAgent(
    name="local_guide",
    model="gemini-2.5-flash",
    include_sibling_final_responses=True,
)

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:

  • Add 37 behavior cases covering opt-in visibility, attribution/order, immutable stored events, thought/signature removal, tool/media exclusion, branch/scope boundaries, no-history behavior, rewinds, and tool sub-branches.
  • Relevant regression selection: 342 passed, 15 warnings, Windows/Python 3.11.9.
  • Build the wheel, install it in a fresh virtual environment, verify imports come from the installed package, and run the new test file: 37 passed, 2 warnings.
  • Run the applicable pre-commit checks. A local Windows launcher adapter invokes the existing shell hook through Git Bash instead of its Unix-only /bin/bash shebang; no hooks are disabled and the repository configuration is unchanged.
  • Full Linux unit suite passed across Python 3.11–3.14 for current contribution head 106150aa49e5b818e6a131a8a98f093fa7a869ed in the refreshed contributor fork validation. It uses the upstream test extra, upstream lancedb installation exclusion, and the complete tests/unittests suite. Verified that the validation commit differs from this PR head only by its auxiliary workflow file, which is excluded from this PR.
Python Passed Skipped Xfailed Xpassed
3.11 19,547 91 17 2
3.12 19,538 92 17 2
3.13 19,538 92 17 2
3.14 19,538 92 17 2

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.

  • Windows tox progressed 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.
python -m pytest tests/unittests/flows/llm_flows/context tests/unittests/agents/test_llm_agent_include_contents.py tests/unittests/agents/test_llm_agent_output_save.py tests/unittests/agents/test_llm_agent_single_turn_subagents.py tests/unittests/agents/test_base_agent.py tests/unittests/agents/test_context.py -q --tb=short

Observed output:

342 passed, 15 warnings in 16.39s

End-to-End Evidence:

test_runner_shares_only_the_bookings_answer_with_the_guide executes two real InMemoryRunner turns, a real reservations tool, and real session persistence. Only the external model boundary is scripted. Run both opt-in states with:

python -m pytest tests/unittests/flows/llm_flows/context/test_contents_sibling_final_responses.py -k runner -vv

The guide's actual model request contains [bookings] said: and the public arrival answer only with opt-in enabled. Neither request contains PRIVATE_PNR or PRIVATE_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

  • Read the contribution guide and perform an AI-assisted self-review.
  • Add regression tests and verify the relevant tests locally.
  • Build and verify the installed wheel in a clean virtual environment.
  • Complete the refreshed Linux unit-test matrix on Python 3.11–3.14 for the current head; upstream required checks remain subject to maintainer approval.
  • Confirm maintainer agreement on the public API and direct-sibling scope.
  • Submit accompanying documentation: google/adk-docs#2416. Full strict MkDocs build and Pagefind indexing passed locally; the documentation PR remains draft pending implementation/API agreement.
  • Complete any additional manual E2E evidence requested by reviewers.

Additional context

Synced with upstream through f78149a; current contribution head is 106150aa49e5b818e6a131a8a98f093fa7a869ed. 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 --strict passed including 244 indexed pages; its Python example executes against this implementation.

Refreshed incremental static check against upstream f78149a under 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.

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
@google-cla

google-cla Bot commented Oct 15, 2026

Copy link
Copy Markdown

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +448 to +484
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Comment on lines +585 to +609
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants