Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ public final class A2AHeaders {
* HTTP header name for a push notification token.
*/
public static final String X_A2A_NOTIFICATION_TOKEN = "X-A2A-Notification-Token";


/**
* Metadata header identifying the target agent ID for transports without per-path
* routing (e.g. gRPC).
*/
public static final String X_A2A_AGENT_ID = "X-A2A-Agent-Id";

private A2AHeaders() {
// Utility class
}
Expand Down
10 changes: 10 additions & 0 deletions reference/grpc/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Security dependencies for auth tests -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
package org.a2aproject.sdk.server.grpc.quarkus;

import static java.util.Locale.ROOT;

import java.util.concurrent.Executor;

import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;

import org.a2aproject.sdk.common.A2AHeaders;
import org.a2aproject.sdk.server.ExtendedAgentCard;
import org.a2aproject.sdk.server.PublicAgentCard;
import org.a2aproject.sdk.server.grpc.quarkus.registry.GrpcAgent;
import org.a2aproject.sdk.server.grpc.quarkus.registry.MultiAgentRegistry;
import org.a2aproject.sdk.server.requesthandlers.RequestHandler;
import org.a2aproject.sdk.server.util.async.Internal;
import org.a2aproject.sdk.spec.AgentCard;
import org.a2aproject.sdk.spec.InvalidRequestError;
import org.a2aproject.sdk.transport.grpc.context.GrpcContextKeys;
import org.a2aproject.sdk.transport.grpc.handler.CallContextFactory;
import org.a2aproject.sdk.transport.grpc.handler.GrpcHandler;
import io.grpc.Context;
import io.grpc.Metadata;
import io.quarkus.grpc.GrpcService;
import io.quarkus.grpc.RegisterInterceptor;
import io.quarkus.security.Authenticated;
Expand Down Expand Up @@ -74,10 +83,14 @@
@Blocking
public class QuarkusGrpcHandler extends GrpcHandler {

private final AgentCard agentCard;
private final AgentCard extendedAgentCard;
private final RequestHandler requestHandler;
private static final Metadata.Key<String> AGENT_ID_KEY =
Metadata.Key.of(A2AHeaders.X_A2A_AGENT_ID.toLowerCase(ROOT), Metadata.ASCII_STRING_MARSHALLER);

private final Instance<AgentCard> agentCardInstance;
private final Instance<AgentCard> extendedAgentCardInstance;
private final Instance<RequestHandler> requestHandlerInstance;
private final Instance<CallContextFactory> callContextFactoryInstance;
private final Instance<MultiAgentRegistry> multiAgentRegistryInstance;
private final Executor executor;

/**
Expand All @@ -99,42 +112,70 @@ public class QuarkusGrpcHandler extends GrpcHandler {
* <li>{@code callContextFactoryInstance} - Custom context factory (can be unsatisfied)</li>
* </ul>
*
* @param agentCard the public agent card (qualified with {@code @PublicAgentCard})
* @param agentCard the public agent card instance (qualified with {@code @PublicAgentCard}); may be
* unresolvable when every agent is served through a {@link MultiAgentRegistry}
* @param extendedAgentCard the extended agent card instance (qualified with {@code @ExtendedAgentCard})
* @param requestHandler the request handler for protocol operations
* @param requestHandler the request handler instance for protocol operations; may be unresolvable
* when every agent is served through a {@link MultiAgentRegistry}
* @param callContextFactoryInstance the call context factory instance (optional)
* @param multiAgentRegistryInstance the multi-agent registry instance (optional)
* @param executor the executor for async operations (qualified with {@code @Internal})
*/
@Inject
public QuarkusGrpcHandler(@PublicAgentCard AgentCard agentCard,
public QuarkusGrpcHandler(@PublicAgentCard Instance<AgentCard> agentCard,
@ExtendedAgentCard Instance<AgentCard> extendedAgentCard,
RequestHandler requestHandler,
Instance<RequestHandler> requestHandler,
Instance<CallContextFactory> callContextFactoryInstance,
Instance<MultiAgentRegistry> multiAgentRegistryInstance,
@Internal Executor executor) {
this.agentCard = agentCard;
if (extendedAgentCard != null && extendedAgentCard.isResolvable()) {
this.extendedAgentCard = extendedAgentCard.get();
} else {
this.extendedAgentCard = null;
}
this.requestHandler = requestHandler;
this.agentCardInstance = agentCard;
this.extendedAgentCardInstance = extendedAgentCard;
this.requestHandlerInstance = requestHandler;
this.callContextFactoryInstance = callContextFactoryInstance;
this.multiAgentRegistryInstance = multiAgentRegistryInstance;
this.executor = executor;
}

@Override
protected RequestHandler getRequestHandler() {
return requestHandler;
return resolveAgent().requestHandler();
}

@Override
protected AgentCard getAgentCard() {
return agentCard;
return resolveAgent().agentCard();
}

@Override
protected AgentCard getExtendedAgentCard() {
return extendedAgentCard;
return resolveAgent().extendedAgentCard();
}

/**
* Resolves the agent for the current call: by {@code X-A2A-Agent-Id} header via
* {@link MultiAgentRegistry} if present, else the default single-agent beans.
*
* @throws InvalidRequestError if no agent could be resolved for this call
*/
private GrpcAgent resolveAgent() {
if (multiAgentRegistryInstance.isResolvable()) {
String agentId = currentAgentId();
GrpcAgent agent = agentId != null ? multiAgentRegistryInstance.get().getAgents().get(agentId) : null;
if (agent != null) {
return agent;
}
}
if (agentCardInstance.isResolvable() && requestHandlerInstance.isResolvable()) {
AgentCard extendedAgentCard = extendedAgentCardInstance.isResolvable() ? extendedAgentCardInstance.get() : null;
return new GrpcAgent(agentCardInstance.get(), extendedAgentCard, requestHandlerInstance.get());
}
throw new InvalidRequestError("No agent configured for this request");
}

/** @return the {@code X-A2A-Agent-Id} header from the current call's metadata, or null */
private @Nullable String currentAgentId() {
Metadata metadata = GrpcContextKeys.METADATA_KEY.get(Context.current());
return metadata != null ? metadata.get(AGENT_ID_KEY) : null;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.a2aproject.sdk.server.grpc.quarkus.registry;

import org.a2aproject.sdk.server.requesthandlers.RequestHandler;
import org.a2aproject.sdk.spec.AgentCard;
import org.a2aproject.sdk.util.Assert;
import org.jspecify.annotations.Nullable;

/**
* Bundles the pieces needed to serve a single agent over gRPC: its request handler and
* agent card(s). Used by {@link MultiAgentRegistry} to describe each registered agent.
*/
public record GrpcAgent(AgentCard agentCard, @Nullable AgentCard extendedAgentCard, RequestHandler requestHandler) {
public GrpcAgent {
Assert.checkNotNullParam("agentCard", agentCard);
Assert.checkNotNullParam("requestHandler", requestHandler);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.a2aproject.sdk.server.grpc.quarkus.registry;

import java.util.Map;

/**
* Registry for supporting multiple agents behind a single Quarkus gRPC service.
* If a CDI bean implements this interface, incoming calls are dispatched to the agent
* identified by the {@code X-A2A-Agent-Id} metadata header. Calls that don't carry the
* header, or name an agent not present in the registry, fall back to the default
* single-agent {@link org.a2aproject.sdk.spec.AgentCard} / {@link org.a2aproject.sdk.server.requesthandlers.RequestHandler}
* beans, if configured.
*/
public interface MultiAgentRegistry {
/**
* @return a map of agent ID (as sent in the {@code X-A2A-Agent-Id} header) to their {@link GrpcAgent}
*/
Map<String, GrpcAgent> getAgents();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package org.a2aproject.sdk.server.grpc.quarkus;

import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.util.Map;
import java.util.concurrent.Executor;

import jakarta.enterprise.inject.Instance;

import org.a2aproject.sdk.server.grpc.quarkus.registry.GrpcAgent;
import org.a2aproject.sdk.server.grpc.quarkus.registry.MultiAgentRegistry;
import org.a2aproject.sdk.server.requesthandlers.RequestHandler;
import org.a2aproject.sdk.spec.AgentCard;
import org.a2aproject.sdk.spec.InvalidRequestError;
import org.a2aproject.sdk.transport.grpc.context.GrpcContextKeys;
import io.grpc.Context;
import io.grpc.Metadata;
import org.junit.jupiter.api.Test;

/**
* Unit test for {@link QuarkusGrpcHandler}'s multi-agent dispatch logic: selecting the
* agent to serve a call based on the {@code X-A2A-Agent-Id} metadata header, with fallback
* to the default single-agent beans.
*/
public class QuarkusGrpcHandlerTest {

private static final Metadata.Key<String> AGENT_ID_KEY =
Metadata.Key.of("x-a2a-agent-id", Metadata.ASCII_STRING_MARSHALLER);

@SuppressWarnings("unchecked")
private Instance<AgentCard> instanceOf(AgentCard value) {
Instance<AgentCard> instance = mock(Instance.class);
when(instance.isResolvable()).thenReturn(value != null);
when(instance.get()).thenReturn(value);
return instance;
}

@SuppressWarnings("unchecked")
private Instance<RequestHandler> instanceOf(RequestHandler value) {
Instance<RequestHandler> instance = mock(Instance.class);
when(instance.isResolvable()).thenReturn(value != null);
when(instance.get()).thenReturn(value);
return instance;
}

@SuppressWarnings("unchecked")
private Instance<MultiAgentRegistry> instanceOf(MultiAgentRegistry value) {
Instance<MultiAgentRegistry> instance = mock(Instance.class);
when(instance.isResolvable()).thenReturn(value != null);
when(instance.get()).thenReturn(value);
return instance;
}

@SuppressWarnings("unchecked")
private <T> T runWithAgentIdHeader(String agentId, java.util.function.Supplier<T> action) {
Metadata metadata = new Metadata();
if (agentId != null) {
metadata.put(AGENT_ID_KEY, agentId);
}
Context context = Context.current().withValue(GrpcContextKeys.METADATA_KEY, metadata);
Context previous = context.attach();
try {
return action.get();
} finally {
context.detach(previous);
}
}

@Test
public void testMultiAgentMode_DispatchesByAgentIdHeader() {
AgentCard agentACard = mock(AgentCard.class);
RequestHandler agentARequestHandler = mock(RequestHandler.class);
GrpcAgent agentA = new GrpcAgent(agentACard, null, agentARequestHandler);

AgentCard agentBCard = mock(AgentCard.class);
RequestHandler agentBRequestHandler = mock(RequestHandler.class);
GrpcAgent agentB = new GrpcAgent(agentBCard, null, agentBRequestHandler);

MultiAgentRegistry registry = mock(MultiAgentRegistry.class);
when(registry.getAgents()).thenReturn(Map.of("agentA", agentA, "agentB", agentB));

QuarkusGrpcHandler handler = new QuarkusGrpcHandler(
instanceOf((AgentCard) null),
instanceOf((AgentCard) null),
instanceOf((RequestHandler) null),
mock(Instance.class),
instanceOf(registry),
mock(Executor.class));

runWithAgentIdHeader("agentB", () -> {
assertSame(agentBRequestHandler, handler.getRequestHandler());
assertSame(agentBCard, handler.getAgentCard());
return null;
});
}

@Test
public void testMultiAgentMode_UnknownAgentId_FallsBackToDefaultHandler() {
AgentCard defaultCard = mock(AgentCard.class);
RequestHandler defaultRequestHandler = mock(RequestHandler.class);

MultiAgentRegistry registry = mock(MultiAgentRegistry.class);
when(registry.getAgents()).thenReturn(Map.of());

QuarkusGrpcHandler handler = new QuarkusGrpcHandler(
instanceOf(defaultCard),
instanceOf((AgentCard) null),
instanceOf(defaultRequestHandler),
mock(Instance.class),
instanceOf(registry),
mock(Executor.class));

runWithAgentIdHeader("unknown-agent", () -> {
assertSame(defaultRequestHandler, handler.getRequestHandler());
assertSame(defaultCard, handler.getAgentCard());
return null;
});
}

@Test
public void testMultiAgentMode_UnknownAgentId_NoDefaultHandler_Throws() {
MultiAgentRegistry registry = mock(MultiAgentRegistry.class);
when(registry.getAgents()).thenReturn(Map.of());

QuarkusGrpcHandler handler = new QuarkusGrpcHandler(
instanceOf((AgentCard) null),
instanceOf((AgentCard) null),
instanceOf((RequestHandler) null),
mock(Instance.class),
instanceOf(registry),
mock(Executor.class));

runWithAgentIdHeader("unknown-agent", () ->
assertThrows(InvalidRequestError.class, handler::getRequestHandler));
}

@Test
public void testSingleAgentMode_NoRegistry_UsesDefaultHandler() {
AgentCard defaultCard = mock(AgentCard.class);
RequestHandler defaultRequestHandler = mock(RequestHandler.class);

QuarkusGrpcHandler handler = new QuarkusGrpcHandler(
instanceOf(defaultCard),
instanceOf((AgentCard) null),
instanceOf(defaultRequestHandler),
mock(Instance.class),
instanceOf((MultiAgentRegistry) null),
mock(Executor.class));

assertSame(defaultRequestHandler, handler.getRequestHandler());
assertSame(defaultCard, handler.getAgentCard());
}
}
Loading
Loading