Skip to content

fix(a2a): send complete legacy text once - #7542

Open
Samecho wants to merge 2 commits into
google:mainfrom
Samecho:codex/a2a-final-text
Open

Samecho wants to merge 2 commits into
google:mainfrom
Samecho:codex/a2a-final-text

Conversation

@Samecho

@Samecho Samecho commented Oct 14, 2026 •

Copy link
Copy Markdown

Link to Issue or Description of Change

Related to #7247. The proposed scope and timing are described on the issue.

The legacy executor sends the complete final text as both a WORKING message and a final artifact. The caller receives and stores both copies. This holds the latest complete text-only update until another converted event arrives or the run finishes, then delivers the last answer through the final artifact. Earlier messages keep their order and metadata; failures and cancellation flush pending text.

A complete progress message can now wait for the next event or completion. Partial chunks and tool messages remain immediate. Maintainer feedback on that timing tradeoff is still requested. Confirmation requests are covered separately by #7252.

Testing Plan

Added 13 regression cases covering duplicate delivery, distinct identical messages, state-only events, metadata, interrupted runs and real streaming/non-streaming clients. Eight duplicate-text cases fail without the fix; the remaining five pass on both versions.

The Linux validation passed all 16 jobs for 60863f0, including full tox on Python 3.11–3.14, all-files pre-commit, baseline mypy and A2A/MCP compatibility. Full suites passed 19,052 tests on 3.11 and 19,043 on each other version, with existing skips and expected-failure results recorded in the run. Mypy found no new diagnostics. The fork test environment excludes lancedb.

Manual Runner E2E: A scripted model, in-process HTTP transport, to_a2a() and a caller Runner. The setup and command below check plain replies and remote tool calls against legacy and forced-v2 servers. Source checks passed with a2a-sdk 1.2.2 and 0.3.25; the installed-wheel check passed with 1.2.2. Each answer was yielded and persisted once, with one tool call where configured:

yielded_texts:   ['Checking the value.', 'The answer is 42.']
persisted_texts: ['Checking the value.', 'The answer is 42.']
tool_calls: 1
Standalone offline reproduction

Save as repro.py in a development checkout with the test dependencies installed:

python repro.py --source src --output results.json

To check an installed wheel instead, run with that environment's Python and omit --source src. It uses no model API or credentials.

"""Offline Runner -> RemoteA2aAgent -> to_a2a regression probe."""
import argparse
import asyncio
import importlib.metadata
import json
from pathlib import Path
import sys

parser = argparse.ArgumentParser()
parser.add_argument('--source', type=Path)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
if args.source:
    sys.path.insert(0, str(args.source.resolve()))

import httpx
import google.adk.a2a.executor.a2a_agent_executor as executor_module
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
from google.adk.a2a.utils.agent_to_a2a import to_a2a
from google.adk.agents import LlmAgent
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types

ANSWER = 'The answer is 42.'

class ScriptedModel(BaseLlm):
    async def generate_content_async(self, llm_request, stream=False):
        last = llm_request.contents[-1].parts[-1]
        if self.model == 'plain' or last.function_response:
            parts = [types.Part(text=ANSWER)]
        else:
            parts = [types.Part(text='Checking the value.'),
                     types.Part.from_function_call(name='lookup', args={})]
        yield LlmResponse(content=types.Content(role='model', parts=parts))

def model_texts(events):
    return [part.text for event in events
            if event.author == 'remote' and event.content
            and event.content.role == 'model'
            for part in event.content.parts or [] if part.text]

async def probe(use_v2, use_tool):
    calls = []
    def lookup() -> str:
        calls.append('lookup')
        return '42'
    def factory(runner):
        return A2aAgentExecutor(runner=runner, force_new_version=True)
    app = to_a2a(LlmAgent(name='remote',
        model=ScriptedModel(model='tool' if use_tool else 'plain'),
        tools=[lookup] if use_tool else []),
        agent_executor_factory=factory if use_v2 else None)
    async with app.router.lifespan_context(app):
        async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
            remote = RemoteA2aAgent(name='remote',
                agent_card='http://localhost:8000/.well-known/agent-card.json',
                httpx_client=client)
            sessions = InMemorySessionService()
            async with Runner(app_name='caller', agent=remote,
                              session_service=sessions, auto_create_session=True) as runner:
                events = [event async for event in runner.run_async(
                    user_id='u', session_id='s', new_message=types.Content(
                        role='user', parts=[types.Part(text='What is the value?')]))]
                session = await sessions.get_session(app_name='caller', user_id='u', session_id='s')
    yielded = model_texts(events)
    persisted = model_texts(session.events)
    assert yielded.count(ANSWER) == 1, yielded
    assert persisted.count(ANSWER) == 1, persisted
    assert len(calls) == int(use_tool)
    return {'executor': 'v2' if use_v2 else 'legacy',
            'scenario': 'tool' if use_tool else 'plain',
            'yielded_texts': yielded, 'persisted_texts': persisted,
            'answer_yielded': yielded.count(ANSWER),
            'answer_persisted': persisted.count(ANSWER), 'tool_calls': len(calls)}

async def main():
    if args.source:
        assert Path(executor_module.__file__).resolve().is_relative_to(args.source.resolve())
    result = {'source': executor_module.__file__,
              'python': sys.version.split()[0],
              'a2a_sdk': importlib.metadata.version('a2a-sdk'),
              'cases': [await probe(v2, tool) for v2 in (False, True) for tool in (False, True)]}
    args.output.write_text(json.dumps(result, indent=2), encoding='utf-8')
    print(json.dumps(result, indent=2))

asyncio.run(main())

Checklist

  • Read CONTRIBUTING.md and reviewed the diff.
  • Regression tests reproduce the bug; source and installed-wheel Runner checks pass.
  • Full Linux unit matrix and format, type and compatibility checks pass.

Developed and tested with Codex.

Hold the last complete text update for the final artifact, retaining
earlier messages and metadata. Cover stream delivery and caller-session
persistence with both plain replies and remote tool calls.

Related: google#7247
@Samecho

Samecho commented Oct 14, 2026 •

Copy link
Copy Markdown
Author

Could someone rerun the triage job and approve the CI run? The triage job failed with a Gemini 503 UNAVAILABLE.

I reran the checks on my fork for 60863f0 and all 16 jobs passed. The Runner-to-A2A test also passed again, with the final reply appearing once in both the output and the session.

@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 updates the A2aAgentExecutor to prevent duplicate publishing of the final answer by holding the last complete text event as a pending event and only enqueuing it when a subsequent event arrives or during cleanup in an exception/cancellation block. It also propagates the pending event's metadata to the final artifact and includes comprehensive unit tests for these streaming and interruption behaviors. Feedback on the changes suggests using getattr when checking adk_event.partial to avoid potential AttributeErrors if the event object lacks this attribute.

if (
isinstance(e, TaskStatusUpdateEvent)
and task_result_aggregator.task_state == _compat.TS_WORKING
and not adk_event.partial

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

Using adk_event.partial directly can raise an AttributeError if adk_event is None or does not have the partial attribute (e.g., in certain test mocks or custom event implementations). It is safer to use getattr(adk_event, 'partial', False) to ensure robustness.

Suggested change
and not adk_event.partial
and not getattr(adk_event, "partial", False)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Runner.run_async() yields Event instances, and Event inherits partial: Optional[bool] = None from LlmResponse. The field is present even when unset, and the default converter rejects None before this branch. I'm keeping direct access here because it follows the event contract.

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.

1 participant