Skip to content

[A2A] RemoteA2aAgent(use_legacy=False): extension header written to state['http_kwargs'], ignored by a2a-sdk 1.x transports #7334

Description

@ragaoua

Describe the Bug

With RemoteA2aAgent(use_legacy=False), ADK installs a request interceptor that is supposed to negotiate the new A2A-ADK integration extension (the one documented as the a2a-extension / v2 A2aAgentExecutor). The interceptor (google/adk/a2a/agent/interceptors/new_integration_extension.py:43-51, same code on main @ eb1a002 and on 2.7.1–2.10.0) writes the A2A-Extensions header into:

params.client_call_context.state['http_kwargs']

But on a2a-sdk 1.x, HTTP transports build outbound headers exclusively from ClientCallContext.service_parameters (a2a/client/transports/http_helpers.py:46-47 in 1.1.2):

if context and context.service_parameters:
    http_kwargs['headers'] = context.service_parameters.copy()

Nothing in a2a-sdk 1.x reads client_call_context.state['http_kwargs'], so the extension header never reaches the server. The server therefore keeps running the legacy executor even though the client asked for the new integration. (The gRPC transport likewise reads service_parameters, a2a/client/transports/grpc.py:304-305.)

For reference, a2a-sdk 1.x's own auth interceptor writes Authorization into context.service_parameters (a2a/client/auth/interceptor.py:47-84), which is the convention downstream users follow too.

Steps to Reproduce

Self-contained, in-process, no network or credentials:

import asyncio

import httpx
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

CAPTURED: dict[str, str] = {}


class CaptureMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http":
            headers = {k.decode().lower(): v.decode() for k, v in scope["headers"]}
            if "a2a-extensions" in headers:
                CAPTURED[headers["a2a-extensions"]] = headers["a2a-extensions"]
        await self.app(scope, receive, send)


class Scripted(BaseLlm):
    async def generate_content_async(self, req, stream=False):
        yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text="ok")]))


async def main():
    server = to_a2a(LlmAgent(name="remote", model=Scripted(model="x"), instruction="x"))
    async with server.router.lifespan_context(server):
        remote = RemoteA2aAgent(
            name="remote",
            agent_card="http://localhost:8000/.well-known/agent-card.json",
            use_legacy=False,
            httpx_client=httpx.AsyncClient(
                transport=httpx.ASGITransport(app=CaptureMiddleware(server))
            ),
        )
        runner = Runner(
            app_name="caller",
            agent=remote,
            session_service=InMemorySessionService(),
            auto_create_session=True,
        )
        async for _ in runner.run_async(
            user_id="u", session_id="s",
            new_message=types.Content(role="user", parts=[types.Part(text="hi")]),
        ):
            pass
    print("A2A-Extensions seen by server:", CAPTURED or "NOT RECEIVED")


asyncio.run(main())

Observed Behavior

A2A-Extensions seen by server: NOT RECEIVED

Adding a custom request_interceptors entry that puts the header into params.client_call_context.service_parameters instead makes the server receive it, which confirms the interceptor pipeline runs and the failure is specifically the state['http_kwargs'] write target.

Expected Behavior

RemoteA2aAgent(use_legacy=False) causes the A2A-Extensions: https://google.github.io/adk-docs/a2a/a2a-extension/ header to actually reach the remote server on a2a-sdk 1.x.

Environment Details

  • google-adk: 2.7.1 (pinned in my project); the interceptor source hasn't been updated since
  • a2a-sdk: 1.1.2, 1.1.5 also affected
  • Python: 3.14
  • OS: macOS 15

Impact / Suggested direction

Impact is silent: no error, the server just keeps legacy behavior. This specifically blocks use_legacy=False from doing anything on the 1.x client stack, and it masks verification of the v2-executor bugs tracked in #7247 / #6343 (their suggested workaround of activating the v2 path via use_legacy=False is a no-op on a2a-sdk 1.x).

Suggested fix: in new_integration_extension.py, write the header into params.client_call_context.service_parameters (initializing ClientCallContext if absent) and merge with any existing mapping.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions