From 971de791507276f7a5a61cfc79a1cf296c056301 Mon Sep 17 00:00:00 2001 From: Koichi73 Date: Thu, 16 Apr 2026 21:29:00 +0900 Subject: [PATCH] feat: add a2a_state_forwarding sample Demonstrate how to forward client-side session state to a remote ADK agent over the A2A protocol via request metadata. The client uses a RequestInterceptor to copy whitelisted state keys into A2A metadata, and the server injects them back into session state through a before_agent_callback so that instruction template placeholders like {user_name} resolve correctly. --- .../samples/a2a_state_forwarding/README.md | 124 ++++++++++++++++++ .../samples/a2a_state_forwarding/__init__.py | 15 +++ .../samples/a2a_state_forwarding/agent.py | 81 ++++++++++++ .../remote_a2a/greet_agent/__init__.py | 15 +++ .../remote_a2a/greet_agent/agent.json | 17 +++ .../remote_a2a/greet_agent/agent.py | 45 +++++++ 6 files changed, 297 insertions(+) create mode 100644 contributing/samples/a2a_state_forwarding/README.md create mode 100644 contributing/samples/a2a_state_forwarding/__init__.py create mode 100644 contributing/samples/a2a_state_forwarding/agent.py create mode 100644 contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/__init__.py create mode 100644 contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.json create mode 100644 contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.py diff --git a/contributing/samples/a2a_state_forwarding/README.md b/contributing/samples/a2a_state_forwarding/README.md new file mode 100644 index 0000000000..4e7a00f78f --- /dev/null +++ b/contributing/samples/a2a_state_forwarding/README.md @@ -0,0 +1,124 @@ +# A2A State Forwarding Sample Agent + +This sample demonstrates how to forward **client-side session state** to a +remote ADK agent over the **Agent-to-Agent (A2A)** protocol using A2A request +metadata. + +## Overview + +ADK's A2A transport is stateless: when a `RemoteA2aAgent` sends a message to +a remote agent, the caller's `session.state` is **not** automatically copied +to the remote side. If the remote agent expects state values such as +`user_name` (e.g. to resolve an `instruction` template placeholder like +`{user_name}`), those values will be missing and the agent will not behave +as intended. + +This sample shows a working client-and-server configuration for bridging +that gap: + +- The **client** attaches a `RequestInterceptor` that copies a whitelisted + subset of `session.state` into the outgoing A2A request metadata. +- The **server** reads the incoming metadata (which ADK exposes as + `run_config.custom_metadata['a2a_metadata']`) from a `before_agent_callback` + and writes the values back into its own session state so the remote agent's + `instruction` template can resolve them. + +This pattern was confirmed as the recommended approach by an ADK maintainer +in [google/adk-python#3098](https://github.com/google/adk-python/issues/3098), +following the metadata-propagation work in commit +[`ba631764`](https://github.com/google/adk-python/commit/ba631764a5be0c045de0d0be40330c7be8292a71). +This sample complements that discussion by showing both sides wired together +in a working minimal example. + +## Architecture + +``` +┌─────────────── Client (adk web) ───────────────┐ +│ │ +│ root_agent (Agent) │ +│ └─ before_agent_callback: seed state │ +│ state["user_name"] = "Alice" │ +│ └─ sub_agent: greet_agent (RemoteA2aAgent) │ +│ └─ RequestInterceptor.before_request │ +│ whitelisted session.state keys │ +│ └─→ parameters.request_metadata │ +│ │ +└──────────────────────┬─────────────────────────┘ + │ A2A JSON-RPC + │ (MessageSendParams.metadata) + ▼ +┌─────────── Remote agent (adk api_server --a2a) ┐ +│ │ +│ ADK converts incoming metadata to: │ +│ run_config.custom_metadata['a2a_metadata'] │ +│ │ +│ greet_agent (Agent) │ +│ └─ before_agent_callback │ +│ run_config.custom_metadata[...] │ +│ └─→ callback_context.state["user_name"] │ +│ │ +│ instruction "Hello {user_name}! ..." │ +│ resolves to "Hello Alice! ..." │ +│ │ +└────────────────────────────────────────────────┘ +``` + +## Key Features + +### 1. Bridging Session State Across A2A + +The remote `greet_agent` depends on `user_name` in its session state (used +here as an `instruction` template placeholder `{user_name}`). No +A2A-specific code is required beyond the one `before_agent_callback` that +copies incoming metadata into session state. + +### 2. Uses ADK Public APIs Only + +Both sides of the sample rely on public ADK APIs: + +- Client: `RemoteA2aAgent`, `A2aRemoteAgentConfig`, `RequestInterceptor`, + `ParametersConfig` (from `google.adk.a2a.agent.config`). +- Server: `callback_context.run_config.custom_metadata['a2a_metadata']` + (populated automatically by ADK's A2A request converter). + +No monkey-patching, no private attribute access. + +## Setup and Usage + +### Prerequisites + +1. **Start the remote greet_agent A2A server**: + + ```bash + adk api_server --a2a --port 8001 contributing/samples/a2a_state_forwarding/remote_a2a + ``` + +2. **Run the main agent** (in a separate terminal): + + ```bash + adk web contributing/samples/ + ``` + +### Example Interaction + +``` +User: Please greet me. +Bot: Hello Alice! How are you doing today? +``` + +The root agent seeds `user_name = "Alice"` into its own session state, the +`RequestInterceptor` forwards it through the A2A request metadata, and the +remote `greet_agent` resolves its `{user_name}` template from that value. + +## Limitations + +- **One-way only.** This sample covers client → server state forwarding. The + reverse direction (server → client) is not supported by the plain + `to_a2a()` / `adk api_server --a2a` path because it does not expose an + `after_agent` execute-interceptor hook. Applications that need bidirectional + state sync must build a custom `A2aAgentExecutor` with an + `ExecuteInterceptor`. +- **Keep the whitelist tight.** The whitelist is a deliberate design choice, + not a nicety. Do not replace `ALLOWED_FORWARD_KEYS` with `dict(ctx.session.state)` + in production — doing so will leak whatever the caller happens to have in + their session to every remote agent they call. diff --git a/contributing/samples/a2a_state_forwarding/__init__.py b/contributing/samples/a2a_state_forwarding/__init__.py new file mode 100644 index 0000000000..4015e47d6e --- /dev/null +++ b/contributing/samples/a2a_state_forwarding/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/a2a_state_forwarding/agent.py b/contributing/samples/a2a_state_forwarding/agent.py new file mode 100644 index 0000000000..e1033126d4 --- /dev/null +++ b/contributing/samples/a2a_state_forwarding/agent.py @@ -0,0 +1,81 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any + +from a2a.types import Message as A2AMessage +from google.adk.a2a.agent.config import A2aRemoteAgentConfig +from google.adk.a2a.agent.config import ParametersConfig +from google.adk.a2a.agent.config import RequestInterceptor +from google.adk.agents.callback_context import CallbackContext +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.llm_agent import Agent +from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH +from google.adk.agents.remote_a2a_agent import RemoteA2aAgent + +# Only these session state keys are forwarded to the remote agent as A2A +# request metadata. Keeping the list explicit prevents accidentally leaking +# unrelated state (credentials, internal flags, large blobs, etc.) across the +# service boundary. +ALLOWED_FORWARD_KEYS: frozenset[str] = frozenset({"user_name"}) + + +async def _forward_state_as_a2a_metadata( + ctx: InvocationContext, + a2a_request: A2AMessage, + parameters: ParametersConfig, +) -> tuple[A2AMessage, ParametersConfig]: + """Forward whitelisted session state keys through A2A request metadata.""" + payload: dict[str, Any] = { + key: value + for key, value in ctx.session.state.items() + if key in ALLOWED_FORWARD_KEYS + } + if payload: + parameters.request_metadata = { + **(parameters.request_metadata or {}), + **payload, + } + return a2a_request, parameters + + +greet_agent = RemoteA2aAgent( + name="greet_agent", + description="Greets the user using a name taken from session state.", + agent_card=( + f"http://localhost:8001/a2a/greet_agent{AGENT_CARD_WELL_KNOWN_PATH}" + ), + config=A2aRemoteAgentConfig( + request_interceptors=[ + RequestInterceptor(before_request=_forward_state_as_a2a_metadata), + ] + ), +) + + +def _seed_state(callback_context: CallbackContext) -> None: + """Seed demo session state so the remote agent has something to greet.""" + callback_context.state.setdefault("user_name", "Alice") + + +root_agent = Agent( + model="gemini-2.5-flash", + name="root_agent", + instruction=( + "You are a helpful assistant. When the user asks to be greeted," + " delegate to the greet_agent sub-agent." + ), + sub_agents=[greet_agent], + before_agent_callback=_seed_state, +) diff --git a/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/__init__.py b/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/__init__.py new file mode 100644 index 0000000000..4015e47d6e --- /dev/null +++ b/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.json b/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.json new file mode 100644 index 0000000000..7f2081f409 --- /dev/null +++ b/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.json @@ -0,0 +1,17 @@ +{ + "capabilities": {}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["application/json"], + "description": "An agent that greets the user using a name taken from session state. Demonstrates how A2A request metadata can be forwarded into the remote agent's session state so that instruction templates (e.g. {user_name}) resolve correctly across the A2A boundary.", + "name": "greet_agent", + "skills": [ + { + "id": "greet_user", + "name": "Greet User", + "description": "Greets the user by name using a value provided via session state / A2A request metadata.", + "tags": ["greeting", "state", "a2a", "metadata"] + } + ], + "url": "http://localhost:8001/a2a/greet_agent", + "version": "1.0.0" +} diff --git a/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.py b/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.py new file mode 100644 index 0000000000..b4bcf6814e --- /dev/null +++ b/contributing/samples/a2a_state_forwarding/remote_a2a/greet_agent/agent.py @@ -0,0 +1,45 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.adk.agents.callback_context import CallbackContext +from google.adk.agents.llm_agent import Agent + + +def _inject_metadata_into_state(callback_context: CallbackContext) -> None: + """Expand incoming A2A metadata into the session state. + + ADK's request_converter places A2A request metadata under + `run_config.custom_metadata['a2a_metadata']`. We copy those entries into + session state so that the agent's `instruction` template can resolve + placeholders like `{user_name}` from values the caller provided. + """ + run_config = callback_context.run_config + if run_config is None or not run_config.custom_metadata: + return + a2a_metadata = run_config.custom_metadata.get("a2a_metadata") or {} + for key, value in a2a_metadata.items(): + callback_context.state[key] = value + + +root_agent = Agent( + model="gemini-2.5-flash", + name="greet_agent", + description="Greets the user using a name provided in session state.", + instruction=( + "Greet the user exactly in the following format, without any extra" + " text:\n" + "Hello {user_name}! How are you doing today?" + ), + before_agent_callback=_inject_metadata_into_state, +)