RemoteA2AAgent never attaches Message.metadata() - no way to propagate any custom data to the remote agent
- Dominant language
- Java
- Stars
- 1.7k
- Forks
- 420
- Avg merge
- 4d 12h
- Merged PRs (30d)
- 31
Description
## Is your feature request related to a problem? Please describe.
`RemoteA2AAgent` builds the outbound `io.a2a.spec.Message` via `prepareMessage()` / `newA2AMessage()`,
but neither method ever calls `Message.Builder#metadata(...)`. This means there is currently **no way**
for a Java ADK application to pass any custom data - `session.state()`, a user id, a tenant id, an
auth-scoped identifier the remote agent's tools need - to a remote A2A agent. The remote agent's tools
receive the conversation content only; anything the calling agent knows about the current user/session
is silently unavailable on the other side of the A2A boundary.
This is a real limitation for a common pattern: a tool on the remote agent needs to resolve a resource
(e.g. an OAuth access token) that is looked up by a caller-supplied identifier (e.g. `user_id`) stored
in `session.state()`. In-process sub-agent calls get this for free (`session.state()` is shared); A2A
calls get nothing.
Note this is **not** the same as two issues I filed previously, and I want to make the distinction
explicit so it's easy to keep this one scoped:
- #1240 (fixed in `410ff810`) is about the *receiving* side: `AgentExecutor` used to silently drop
incoming `MessageSendParams.metadata()` instead of routing it into `RunConfig.customMetadata()`.
That's fixed - a receiving agent can now read `a2a_metadata` from `RunConfig.customMetadata()` and,
via a native `beforeAgentCallback`, copy whatever it needs into `session.state()`.
- #1258 is about `RemoteA2AAgent` hardcoding the 4th argument (`ClientCallContext`) of
`a2aClient.sendMessage(...)` to `null`, which breaks **transport-level** concerns (HTTP header
resolution, credential services used to authenticate the A2A call itself).
- **This issue** is about a third, independent gap: even with both of the above fixed, the
`Message` object itself - the 1st argument to `sendMessage`, built by `prepareMessage()` - never
gets `.metadata(...)` attached at all. There is no code path, and no builder hook, to put anything
there. Fixing #1258 alone would not address this: `ClientCallContext` and `Message` are separate
parameters built independently.
## Describe the solution you'd like
`adk-python`'s `RemoteA2aAgent` already solves exactly this with an opt-in callback:
```python
# src/google/adk/agents/remote_a2a_agent.py:146-148
a2a_request_meta_provider: Optional[
Callable[[InvocationContext, A2AMessage], dict[str, Any]]
] = None
```
```python
# src/google/adk/agents/remote_a2a_agent.py:739-742
if self._a2a_request_meta_provider:
parameters.request_metadata = self._a2a_request_meta_provider(
ctx, a2a_request
)
```
A caller can implement this to explicitly select what to forward, e.g.:
```python
def my_meta_provider(ctx: InvocationContext, message: A2AMessage) -> dict[str, Any]:
return {"user_id": ctx.session.state.get("user_id")}
remote_agent = RemoteA2aAgent(..., a2a_request_meta_provider=my_meta_provider)
```
I'd like `RemoteA2AAgent` (Java) to expose the equivalent extension point, e.g.:
```java
@FunctionalInterface
public interface A2ARequestMetadataProvider {
Map provide(InvocationContext invocationContext, Message outgoingMessage);
}
RemoteA2AAgent.builder()
...
.requestMetadataProvider((ctx, message) -> Map.of("user_id", ctx.session().state().get("user_id")))
.build();
```
and, inside `prepareMessage()`, call it and attach the result via `.metadata(...)` on the `Message.Builder`.
This is deliberately opt-in and lets the caller pick exactly what crosses the A2A boundary - it does not
ask for `session.state()` to be forwarded automatically or in full, which I understand is intentionally
avoided elsewhere in ADK (per the #1240 resolution comment).
The provider is a plain callback with no fixed/whitelisted set of keys baked into the API - ADK would
simply attach whatever `Map` the caller's implementation returns. It's entirely up to the
application to decide what to include: a single identifier, several selected keys, or (if it chooses to)
all of `session.state()`. This mirrors the full flexibility of `a2a_request_meta_provider` in adk-python -
the library imposes no restriction on which keys or how many can be returned, it only wires the callback
through to `Message.metadata()`.
## Minimal reproducible example (runnable today, no external services needed)
Drop this test method into
`a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java` (it uses only fixtures/imports
already present in that file) and run:
```
mvn -pl a2a test -Dtest=RemoteA2AAgentTest#runAsync_doesNotPropagateSessionStateToOutboundMessage
```
The test **passes today**, which is the bug: it proves `session.state()` - set up exactly the way an
application would populate it via `stateDelta` before running the agent - never reaches the `Message`
sent to the remote peer, even though `mockClient.sendMessage(...)` is the exact call site
`prepareMessage()` feeds.
```java
@Test
@SuppressWarnings("unchecked") // cast for Mockito
public void runAsync_doesNotPropagateSessionStateToOutboundMessage() {
RemoteA2AAgent agent = createAgent();
// Simulates an application that populated session.state() via stateDelta before this run -
// e.g. a user id a remote tool would need to resolve an OAuth token, exactly as it would for
// an in-process sub-agent call.
Session sessionWithState =
Session.builder("session-state-repro")
.appName("demo")
.userId("user")
.state(ImmutableMap.of("user_id", "user-42", "tenant_id", "tenant-7"))
.events(
ImmutableList.of(
Event.builder()
.id("e1")
.author("user")
.content(
Content.builder()
.role("user")
.parts(ImmutableList.of(Part.builder().text("hello").build()))
.build())
.build()))
.build();
InvocationContext context =
InvocationContext.builder()
.sessionService(new InMemorySessionService())
.artifactService(new InMemoryArtifactService())
.pluginManager(new PluginManager())
.invocationId("invocation-state-repro")
.agent(new TestAgent())
.session(sessionWithState)
.runConfig(RunConfig.builder().build())
.build();
mockStreamResponse(consumer -> consumer.accept(createFinalEvent("ok"), agentCard));
var unused = agent.runAsync(context).toList().blockingGet();
ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(mockClient)
.sendMessage(messageCaptor.capture(), any(List.class), any(Consumer.class), any());
Message sentMessage = messageCaptor.getValue();
// BUG: session.state() (user_id, tenant_id) is fully known to invocationContext at this point,
// but prepareMessage()/newA2AMessage() never call .metadata(...), so it never reaches the
// outbound Message. A remote agent's tools have no way to see it - even though the exact same
// data would be visible via session.state() for an in-process sub-agent call.
assertThat(sentMessage.getMetadata()).isAnyOf(null, ImmutableMap.of());
}
```
## Environment
- `google-adk-a2a`: 1.8.0 (current `main`, confirmed present at `RemoteA2AAgent.java:196-213`
(`newA2AMessage` / `prepareMessage`) and `RemoteA2AAgent.java:240` (the `sendMessage` call site))
- Java 17
Contributor guide
Assessment
This issue has not been assessed yet.