google / google/adk-java

RemoteA2AAgent never attaches Message.metadata() - no way to propagate any custom data to the remote agent

Abierto
#1,438 1 comentario 0 reacciones 1 asignado Reclamado por @hemasekhar-p Ver en GitHub
needs review
Lenguaje dominante
Java
Estrellas
1.7k
Forks
420
Merge medio
4 d 12 h
PR fusionados (30 d)
31

Descripción

## 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

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.