From 091ccb417caeee5854086f380ff1506f0cd427e6 Mon Sep 17 00:00:00 2001 From: Emmanuel Hugonnet Date: Fri, 25 Sep 2026 13:27:53 +0200 Subject: [PATCH] fix(multitenancy): move @Tenant to server-common to resolve CDI ambiguity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the @Tenant qualifier from extras/multitenancy to server-common so CdiUtils.resolveDefault() can filter tenant-qualified beans at all @PublicAgentCard/@ExtendedAgentCard injection points. This makes @Tenant("x") @PublicAgentCard safe on tenant cards — previously it made the default card injection ambiguous, causing 404 on the default endpoint. - @PublicAgentCard tenant cards take precedence over bare @Tenant cards - Reject duplicate tenant @ExtendedAgentCard registrations - Throw when no default AgentExecutor bean exists - Widen A2AServerRoutes catch to handle all server errors Fixes #1175 Signed-off-by: Emmanuel Hugonnet --- docs/content/dev/extra/multi-tenancy.md | 11 +- docs/content/dev/extras.md | 2 +- docs/content/dev/multi-tenancy.md | 2 +- .../multitenancy/CdiAgentCardRouter.java | 78 +++++++-- .../multitenancy/CdiAgentExecutorRouter.java | 17 +- .../sdk/extras/multitenancy/package-info.java | 5 + .../multitenancy/CdiAgentCardRouterTest.java | 155 ++++++++++++++++++ .../CdiAgentExecutorRouterTest.java | 46 +++++- .../tests/MultiTenantAgentCardProducer.java | 7 +- .../MultiTenantAgentExecutorProducer.java | 2 +- .../grpc/quarkus/QuarkusGrpcHandler.java | 10 +- .../server/apps/quarkus/A2AServerRoutes.java | 3 +- .../sdk/server/AgentCardCacheMetadata.java | 4 +- .../sdk/server/AgentCardValidator.java | 13 +- .../sdk/server}/multitenancy/Tenant.java | 2 +- .../sdk/server/multitenancy/package-info.java | 4 + .../a2aproject/sdk/server/util/CdiUtils.java | 40 +++++ .../jsonrpc/handler/JSONRPCHandler.java | 6 +- .../transport/rest/handler/RestHandler.java | 11 +- 19 files changed, 363 insertions(+), 55 deletions(-) create mode 100644 extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java rename {extras/multitenancy/src/main/java/org/a2aproject/sdk/extras => server-common/src/main/java/org/a2aproject/sdk/server}/multitenancy/Tenant.java (97%) create mode 100644 server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java diff --git a/docs/content/dev/extra/multi-tenancy.md b/docs/content/dev/extra/multi-tenancy.md index b7ccf2b0d..04f836c2b 100644 --- a/docs/content/dev/extra/multi-tenancy.md +++ b/docs/content/dev/extra/multi-tenancy.md @@ -12,7 +12,9 @@ Lets a single A2A server serve multiple tenants with different agent behavior | Artifact ID | Description | |-------------|-------------| -| `a2a-java-extras-multitenancy` | `@Tenant` qualifier, `CdiAgentExecutorRouter`, `CdiAgentCardRouter` | +| `a2a-java-extras-multitenancy` | `CdiAgentExecutorRouter`, `CdiAgentCardRouter` | + +> **Note:** The `@Tenant` qualifier itself lives in the core SDK (`server-common` module, package `org.a2aproject.sdk.server.multitenancy`) so that the transport layer can filter tenant-specific beans when resolving the default card. The extras module provides the CDI routers that dispatch requests to the matching `@Tenant`-qualified beans. ### Add Dependency @@ -104,16 +106,17 @@ public class MyAgentCards { return AgentCard.builder().name("Acme Agent")...build(); } - // Tenant-specific public card — no @PublicAgentCard qualifier (see note below) + // Tenant-specific public card — @PublicAgentCard is optional but recommended @Produces @Tenant("acme") + @PublicAgentCard public AgentCard acmePublicCard() { return AgentCard.builder().name("Acme Agent")...build(); } } ``` -> **Note:** Tenant-specific public cards must **not** carry `@PublicAgentCard` — that qualifier is reserved for the single default public card. Adding it to a `@Tenant` bean causes CDI ambiguity. +> **Tip:** Tenant-specific public cards may carry `@PublicAgentCard` — the transport layer filters out `@Tenant`-qualified beans when resolving the default card, so there is no CDI ambiguity. Using `@PublicAgentCard` on tenant cards is preferred because it makes the intent explicit and gives them priority over bare `@Tenant` cards in `CdiAgentCardRouter`. ## Routing Rules @@ -132,7 +135,7 @@ public class MyAgentCards { | `getExtendedAgentCard` with no tenant | Default `@ExtendedAgentCard` | | `getExtendedAgentCard` with `tenant: "acme"` | `@Tenant("acme") @ExtendedAgentCard`, or default | | `GET /.well-known/agent-card.json` | Default `@PublicAgentCard` | -| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme")` card (no `@PublicAgentCard`), or default | +| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme") @PublicAgentCard` card (preferred), or bare `@Tenant("acme")` card, or default | ## Accessing the Tenant in AgentExecutor diff --git a/docs/content/dev/extras.md b/docs/content/dev/extras.md index ff379350b..c8daf3470 100644 --- a/docs/content/dev/extras.md +++ b/docs/content/dev/extras.md @@ -42,7 +42,7 @@ Replaces the default `InMemoryQueueManager` with event replication across multip ## [Multi-Tenancy](../extra/multi-tenancy) -Serve multiple tenants from a single A2A server with per-tenant `AgentExecutor` and `AgentCard` routing. The module (`a2a-java-extras-multitenancy`) provides a `@Tenant` CDI qualifier and automatic routing — requests are dispatched to tenant-specific beans based on the `tenant` field in the request payload, with fallback to the default beans for unknown tenants. +Serve multiple tenants from a single A2A server with per-tenant `AgentExecutor` and `AgentCard` routing. The `@Tenant` CDI qualifier is in the core SDK (`server-common`); the extras module (`a2a-java-extras-multitenancy`) provides automatic routing — requests are dispatched to tenant-specific beans based on the `tenant` field in the request payload, with fallback to the default beans for unknown tenants. ## [OpenTelemetry](../extra/opentelemetry) diff --git a/docs/content/dev/multi-tenancy.md b/docs/content/dev/multi-tenancy.md index ac462b3ab..308d25f6b 100644 --- a/docs/content/dev/multi-tenancy.md +++ b/docs/content/dev/multi-tenancy.md @@ -8,6 +8,6 @@ layout: page Multi-tenancy lets a single A2A server provide different agent behavior per tenant. Each tenant can have its own `AgentExecutor` (business logic) and `AgentCard` (capabilities, skills, metadata). Requests without a recognized tenant automatically fall back to the default beans. -This feature is provided by the `a2a-java-extras-multitenancy` extras module, which registers two CDI routers — `CdiAgentExecutorRouter` and `CdiAgentCardRouter` — that dispatch each request to the matching `@Tenant`-qualified bean. +The `@Tenant` CDI qualifier is in the core SDK (`org.a2aproject.sdk.server.multitenancy`). The `a2a-java-extras-multitenancy` extras module registers two CDI routers — `CdiAgentExecutorRouter` and `CdiAgentCardRouter` — that dispatch each request to the matching `@Tenant`-qualified bean. For setup instructions, configuration reference, and code examples, see the **[Multi-Tenancy extras page](../extra/multi-tenancy)**. diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java index c7b99d764..50e16fa4d 100644 --- a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java +++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java @@ -1,7 +1,8 @@ package org.a2aproject.sdk.extras.multitenancy; import java.lang.annotation.Annotation; -import java.util.Set; +import java.util.ArrayList; +import java.util.List; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; @@ -12,6 +13,7 @@ import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.spec.AgentCard; import org.jspecify.annotations.Nullable; @@ -20,10 +22,18 @@ * CDI-based {@link AgentCardRouter} that resolves tenant-specific {@link AgentCard} beans. *

* Extended cards are resolved via {@code @Tenant("x") @ExtendedAgentCard}-qualified beans. - * Tenant-specific public cards are resolved via {@code @Tenant("x")}-qualified beans that - * carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard}. The {@code @PublicAgentCard} - * qualifier must NOT be used on tenant-specific public cards because it would cause CDI - * ambiguity on injection points requesting the default public card. + * Tenant-specific public cards are resolved by matching beans that carry the + * {@code @Tenant("x")} qualifier and either: + *

+ * Using {@code @Tenant("x") @PublicAgentCard} on a tenant-specific bean is safe because + * both the router's default-card lookup and the transport handler injection points use + * {@link org.a2aproject.sdk.server.util.CdiUtils#resolveDefault} to exclude + * {@code @Tenant}-qualified beans when resolving the default card. *

* Returns the default public card from {@link #resolvePublicCard} when the tenant is * {@code null} or blank. Returns {@code null} when a non-blank tenant does not match @@ -57,6 +67,9 @@ void init() { if (selected.isResolvable()) { return selected.get(); } + if (selected.isAmbiguous()) { + throw duplicateTenantBean("@ExtendedAgentCard", tenant); + } return defaultExtendedCard; } @@ -65,16 +78,53 @@ void init() { if (tenant == null || tenant.isBlank()) { return defaultPublicCard; } - for (Instance.Handle handle : allCards.handles()) { - Set qualifiers = handle.getBean().getQualifiers(); - boolean matchesTenant = qualifiers.stream() - .anyMatch(a -> a instanceof Tenant t && tenant.equals(t.value())); - if (matchesTenant - && qualifiers.stream().noneMatch(ExtendedAgentCard.class::isInstance) - && qualifiers.stream().noneMatch(PublicAgentCard.class::isInstance)) { - return handle.get(); + Instance.Handle publicHandle = null; + Instance.Handle bareHandle = null; + List> allHandles = new ArrayList<>(); + try { + for (Instance.Handle handle : allCards.handles()) { + allHandles.add(handle); + boolean matchesTenant = false; + boolean hasPublic = false; + boolean hasExtended = false; + for (Annotation q : handle.getBean().getQualifiers()) { + if (q instanceof Tenant t && tenant.equals(t.value())) { + matchesTenant = true; + } else if (q instanceof PublicAgentCard) { + hasPublic = true; + } else if (q instanceof ExtendedAgentCard) { + hasExtended = true; + } + } + if (!matchesTenant) { + continue; + } + if (hasPublic) { + if (publicHandle != null) { + throw duplicateTenantBean("@PublicAgentCard", tenant); + } + publicHandle = handle; + } else if (!hasExtended) { + if (bareHandle != null) { + throw duplicateTenantBean("bare public", tenant); + } + bareHandle = handle; + } + } + Instance.Handle selected = publicHandle != null ? publicHandle : bareHandle; + return selected != null ? selected.get() : null; + } finally { + Instance.Handle kept = publicHandle != null ? publicHandle : bareHandle; + for (Instance.Handle h : allHandles) { + if (h != kept) { + h.close(); + } } } - return null; + } + + private static IllegalStateException duplicateTenantBean(String cardType, String tenant) { + return new IllegalStateException( + String.format("Multiple tenant %s cards detected for tenant '%s'", cardType, tenant)); } } diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java index cfbc21818..dd4cb1a23 100644 --- a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java +++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java @@ -8,6 +8,7 @@ import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.a2aproject.sdk.server.util.CdiUtils; import org.jspecify.annotations.Nullable; @@ -36,12 +37,26 @@ void init() { @Override public AgentExecutor resolve(@Nullable String tenant) { if (tenant == null || tenant.isBlank()) { - return defaultExecutor; + return requireDefaultExecutor(); } Instance selected = allExecutors.select(new Tenant.Literal(tenant)); if (selected.isResolvable()) { return selected.get(); } + return requireDefaultExecutor(); + } + + /** + * Returns the default executor, throwing if none was discovered at startup. + * + * @return the default executor, never {@code null} + * @throws IllegalStateException if no default {@link AgentExecutor} bean exists + */ + private AgentExecutor requireDefaultExecutor() { + if (defaultExecutor == null) { + throw new IllegalStateException( + "No default AgentExecutor bean found — provide an AgentExecutor bean without @Tenant qualifier"); + } return defaultExecutor; } } diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java new file mode 100644 index 000000000..8b08494af --- /dev/null +++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java @@ -0,0 +1,5 @@ +@NullMarked +package org.a2aproject.sdk.extras.multitenancy; + +import org.jspecify.annotations.NullMarked; + diff --git a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java index 0d8b770e2..ddcec2afd 100644 --- a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java +++ b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.Collections; import java.util.List; @@ -12,6 +13,7 @@ import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; @@ -113,6 +115,58 @@ void publicCardNoDefaultReturnsNull() { assertNull(router.resolvePublicCard("unknown")); } + @Test + void publicCardWithPublicQualifierResolvesToTenantSpecific() { + startContainer(DefaultPublicAndExtendedCardProducer.class, PublicQualifiedTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("acme-public-qualified", router.resolvePublicCard("acme").name()); + } + + @Test + void publicCardWithBothPublicAndExtendedQualifierResolvesToTenantSpecific() { + startContainer(DefaultPublicAndExtendedCardProducer.class, PublicAndExtendedTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("acme-both", router.resolvePublicCard("acme").name()); + } + + @Test + void explicitPublicCardTakesPrecedenceOverBareTenantCard() { + startContainer(DefaultPublicAndExtendedCardProducer.class, BareAndPublicQualifiedTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("acme-public-qualified", router.resolvePublicCard("acme").name()); + } + + @Test + void duplicatePublicQualifiedCardsThrows() { + startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicatePublicQualifiedTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertThrows(IllegalStateException.class, () -> router.resolvePublicCard("acme")); + } + + @Test + void duplicateBareTenantCardsThrows() { + startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicateBareTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertThrows(IllegalStateException.class, () -> router.resolvePublicCard("acme")); + } + + @Test + void duplicateExtendedTenantCardsThrows() { + startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicateExtendedTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertThrows(IllegalStateException.class, () -> router.resolveExtendedCard("acme")); + } + + @Test + void dualQualifiedCardResolvedByBothPublicAndExtended() { + startContainer(DefaultPublicAndExtendedCardProducer.class, PublicAndExtendedTenantCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + AgentCard publicCard = router.resolvePublicCard("acme"); + AgentCard extendedCard = router.resolveExtendedCard("acme"); + assertEquals("acme-both", publicCard.name()); + assertEquals("acme-both", extendedCard.name()); + } + private static AgentCard buildCard(String name) { return AgentCard.builder() .name(name) @@ -179,4 +233,105 @@ AgentCard acmeExtendedCard() { return buildCard("acme-extended"); } } + + static class DefaultPublicAndExtendedCardProducer { + + @Produces + @PublicAgentCard + AgentCard defaultPublicCard() { + return buildCard("default-public"); + } + + @Produces + @ExtendedAgentCard + AgentCard defaultExtendedCard() { + return buildCard("default-extended"); + } + } + + static class PublicQualifiedTenantCardProducer { + + @Produces + @Tenant("acme") + @PublicAgentCard + AgentCard acmePublicCard() { + return buildCard("acme-public-qualified"); + } + } + + static class PublicAndExtendedTenantCardProducer { + + @Produces + @Tenant("acme") + @PublicAgentCard + @ExtendedAgentCard + AgentCard acmeBothCard() { + return buildCard("acme-both"); + } + } + + static class BareAndPublicQualifiedTenantCardProducer { + + @Produces + @Tenant("acme") + AgentCard acmeBareCard() { + return buildCard("acme-bare"); + } + + @Produces + @Tenant("acme") + @PublicAgentCard + AgentCard acmePublicCard() { + return buildCard("acme-public-qualified"); + } + } + + static class DuplicatePublicQualifiedTenantCardProducer { + + @Produces + @Tenant("acme") + @PublicAgentCard + AgentCard acmePublicCard1() { + return buildCard("acme-public-1"); + } + + @Produces + @Tenant("acme") + @PublicAgentCard + AgentCard acmePublicCard2() { + return buildCard("acme-public-2"); + } + } + + static class DuplicateBareTenantCardProducer { + + @Produces + @Tenant("acme") + AgentCard acmeBareCard1() { + return buildCard("acme-bare-1"); + } + + @Produces + @Tenant("acme") + AgentCard acmeBareCard2() { + return buildCard("acme-bare-2"); + } + } + + static class DuplicateExtendedTenantCardProducer { + + @Produces + @Tenant("acme") + @ExtendedAgentCard + AgentCard acmeExtendedCard1() { + return buildCard("acme-extended-1"); + } + + @Produces + @Tenant("acme") + @ExtendedAgentCard + AgentCard acmeExtendedCard2() { + return buildCard("acme-extended-2"); + } + } } diff --git a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java index 1e7c78157..4799952c6 100644 --- a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java +++ b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.extras.multitenancy; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.List; @@ -8,34 +9,39 @@ import jakarta.enterprise.inject.se.SeContainer; import jakarta.enterprise.inject.se.SeContainerInitializer; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.TextPart; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class CdiAgentExecutorRouterTest { private SeContainer container; - @BeforeEach - void startContainer() { - container = SeContainerInitializer.newInstance() + private void startContainer(Class... beanClasses) { + SeContainerInitializer initializer = SeContainerInitializer.newInstance() .disableDiscovery() - .addBeanClasses(CdiAgentExecutorRouter.class, ExecutorProducer.class) - .initialize(); + .addBeanClasses(CdiAgentExecutorRouter.class); + for (Class beanClass : beanClasses) { + initializer.addBeanClasses(beanClass); + } + container = initializer.initialize(); } @AfterEach void closeContainer() { - container.close(); + if (container != null) { + container.close(); + } } @Test void knownTenantResolvesToTenantSpecificExecutor() { + startContainer(ExecutorProducer.class); CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); AgentExecutor resolved = router.resolve("acme"); assertSame(ExecutorProducer.ACME, resolved); @@ -43,23 +49,40 @@ void knownTenantResolvesToTenantSpecificExecutor() { @Test void unknownTenantFallsBackToDefault() { + startContainer(ExecutorProducer.class); CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); assertSame(ExecutorProducer.DEFAULT, router.resolve("unknown")); } @Test void nullTenantReturnsDefault() { + startContainer(ExecutorProducer.class); CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); assertSame(ExecutorProducer.DEFAULT, router.resolve(null)); } @Test void blankTenantReturnsDefault() { + startContainer(ExecutorProducer.class); CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); assertSame(ExecutorProducer.DEFAULT, router.resolve("")); assertSame(ExecutorProducer.DEFAULT, router.resolve(" ")); } + @Test + void noDefaultExecutorThrowsOnNullTenant() { + startContainer(TenantOnlyExecutorProducer.class); + CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); + assertThrows(IllegalStateException.class, () -> router.resolve(null)); + } + + @Test + void noDefaultExecutorThrowsOnUnknownTenant() { + startContainer(TenantOnlyExecutorProducer.class); + CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); + assertThrows(IllegalStateException.class, () -> router.resolve("unknown")); + } + static class ExecutorProducer { static final AgentExecutor DEFAULT = new LabelExecutor("default"); @@ -77,6 +100,15 @@ AgentExecutor acmeExecutor() { } } + static class TenantOnlyExecutorProducer { + + @Produces + @Tenant("acme") + AgentExecutor acmeExecutor() { + return new LabelExecutor("acme"); + } + } + static class LabelExecutor implements AgentExecutor { private final String label; diff --git a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java index f4d57be44..fcd45ae21 100644 --- a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java +++ b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java @@ -9,7 +9,7 @@ import jakarta.enterprise.inject.Produces; import jakarta.inject.Singleton; -import org.a2aproject.sdk.extras.multitenancy.Tenant; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.spec.AgentCapabilities; @@ -23,8 +23,9 @@ * The transport interface stamped onto every card is chosen from the {@code preferred-transport} * key in {@code /a2a-requesthandler-test.properties}, so one producer serves every transport module. * - *

Router contract: per-tenant public cards carry {@code @Tenant} only (never {@code @PublicAgentCard}), - * per-tenant extended cards carry {@code @Tenant @ExtendedAgentCard}. + *

Router contract: per-tenant public cards carry either {@code @Tenant} alone or + * {@code @Tenant @PublicAgentCard}; explicitly public cards take precedence. Per-tenant extended + * cards carry {@code @Tenant @ExtendedAgentCard}. */ @Singleton public class MultiTenantAgentCardProducer { diff --git a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java index 4e17bb76d..b1b4f63bc 100644 --- a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java +++ b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java @@ -5,7 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; -import org.a2aproject.sdk.extras.multitenancy.Tenant; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.spec.TextPart; diff --git a/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java b/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java index 7fcb1256c..5485f0cce 100644 --- a/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java +++ b/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java @@ -134,18 +134,12 @@ protected RequestHandler getRequestHandler() { @Override protected @Nullable AgentCard getAgentCard() { - if (!agentCard.isResolvable()) { - return null; - } - return agentCard.get(); + return CdiUtils.resolveDefault(agentCard); } @Override protected AgentCard getExtendedAgentCard() { - if (extendedAgentCard != null && extendedAgentCard.isResolvable()) { - return extendedAgentCard.get(); - } - return null; + return CdiUtils.resolveDefault(extendedAgentCard); } @Override diff --git a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java index fe3ff53f7..4c5d4ce5e 100644 --- a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java @@ -263,7 +263,8 @@ void setupRoutes(@Observes Router router) { ctx.response().setStatusCode(404).putHeader(CONTENT_TYPE, "text/plain").end(e.getResponseMessage()); } catch (IllegalArgumentException e) { ctx.response().setStatusCode(400).putHeader(CONTENT_TYPE, "text/plain").end(e.getMessage()); - } catch (JsonProcessingException e) { + } catch (Exception e) { + LOGGER.error("Internal error while resolving agent card", e); ctx.response().setStatusCode(500).putHeader(CONTENT_TYPE, "text/plain").end("Internal Server Error"); } }); diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java index 561dcff94..183cedfdd 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java @@ -93,9 +93,7 @@ void init() { // 2. Direct constructor: agentCard and config already set if (agentCard == null && agentCardInstance != null) { - // CDI path — in multitenancy setups, multiple @PublicAgentCard beans may exist, - // making the injection point ambiguous; getIfResolvable returns null in that case. - this.agentCard = CdiUtils.getIfResolvable(agentCardInstance); + this.agentCard = CdiUtils.resolveDefault(agentCardInstance); this.config = CdiUtils.getIfResolvable(configInstance); if (this.agentCard == null || this.config == null) { return; diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java index c61cc8acc..856f13328 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java @@ -13,6 +13,7 @@ import jakarta.enterprise.inject.Instance; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.spec.AgentCard; import org.jspecify.annotations.Nullable; import org.a2aproject.sdk.spec.AgentInterface; @@ -88,11 +89,15 @@ public static AgentCard resolveAndValidateOnce(Supplier agentCardSupp public static AgentCard resolveWithFallback(Instance publicCard, @Nullable Instance extendedCard, AtomicBoolean transportValidated) { - if (publicCard.isResolvable()) { - return resolveAndValidateOnce(publicCard, transportValidated); + AgentCard resolved = CdiUtils.resolveDefault(publicCard); + if (resolved != null) { + return resolveAndValidateOnce(() -> resolved, transportValidated, + AgentCardValidator::validateTransportConfiguration); } - if (extendedCard != null && extendedCard.isResolvable()) { - return resolveAndValidateOnce(extendedCard, transportValidated); + AgentCard extResolved = CdiUtils.resolveDefault(extendedCard); + if (extResolved != null) { + return resolveAndValidateOnce(() -> extResolved, transportValidated, + AgentCardValidator::validateTransportConfiguration); } throw new IllegalStateException(NO_AGENT_CARD_MESSAGE); } diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/Tenant.java similarity index 97% rename from extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java rename to server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/Tenant.java index f79182bfc..c920ef0ef 100644 --- a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/Tenant.java @@ -1,4 +1,4 @@ -package org.a2aproject.sdk.extras.multitenancy; +package org.a2aproject.sdk.server.multitenancy; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java new file mode 100644 index 000000000..e2c367866 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.a2aproject.sdk.server.multitenancy; + +import org.jspecify.annotations.NullMarked; diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java b/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java index c42e2db9d..059bc0a22 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java @@ -9,6 +9,7 @@ import jakarta.enterprise.inject.Instance; import jakarta.inject.Singleton; +import org.a2aproject.sdk.server.multitenancy.Tenant; import org.jspecify.annotations.Nullable; /** @@ -35,6 +36,45 @@ private CdiUtils() { return null; } + /** + * Resolves the default bean from a potentially ambiguous {@link Instance}. + *

+ * When the instance is resolvable (exactly one match), returns it directly. + * When ambiguous, iterates over all matching beans and returns the single bean + * that does not carry a {@link Tenant} qualifier. This allows the default bean + * to be resolved even when tenant-specific beans make the injection point ambiguous. + * + * @param instance the CDI instance to resolve from + * @param the bean type + * @return the resolved default bean, or {@code null} if none was found + * @throws IllegalStateException if multiple non-tenant beans are found + */ + public static @Nullable T resolveDefault(@Nullable Instance instance) { + if (instance == null) { + return null; + } + if (instance.isResolvable()) { + return instance.get(); + } + if (!instance.isAmbiguous()) { + return null; + } + T defaultBean = null; + for (Instance.Handle handle : instance.handles()) { + boolean hasTenant = handle.getBean().getQualifiers().stream() + .anyMatch(Tenant.class::isInstance); + if (hasTenant) { + continue; + } + if (defaultBean != null) { + throw new IllegalStateException( + "Multiple default beans detected — ensure only one bean without @Tenant qualifier exists"); + } + defaultBean = handle.get(); + } + return defaultBean; + } + /** * Finds the single default bean of a given type, excluding beans that carry a specific * qualifier and optionally requiring another. Warns on duplicate defaults and validates diff --git a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java index cdc2f0eb7..2f51a748a 100644 --- a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java @@ -772,10 +772,12 @@ private void validateVersionAndExtensions(ServerCallContext context) throws A2AE } LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'"); } - if (!agentCardInstance.isResolvable()) { + AgentCard card = CdiUtils.resolveDefault(agentCardInstance); + if (card == null) { return null; } - return AgentCardValidator.resolveAndValidateOnce(agentCardInstance, transportValidated); + return AgentCardValidator.resolveAndValidateOnce(() -> card, transportValidated, + AgentCardValidator::validateTransportConfiguration); } private AgentCard resolveAgentCard() { diff --git a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java index b35beb8c2..2b5c338c8 100644 --- a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java @@ -860,10 +860,11 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, @Nullabl } return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card)); } - if (extendedAgentCard == null || !extendedAgentCard.isResolvable()) { + AgentCard extCard = CdiUtils.resolveDefault(extendedAgentCard); + if (extCard == null) { throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); } - return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(extendedAgentCard.get())); + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(extCard)); } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable t) { @@ -940,11 +941,13 @@ public HTTPRestResponse getAgentCard(@Nullable String tenant) { } LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'"); } - if (!agentCardInstance.isResolvable()) { + AgentCard card = CdiUtils.resolveDefault(agentCardInstance); + if (card == null) { return new HTTPRestResponse(404, "text/plain", "Public agent card not configured"); } return new HTTPRestResponse(200, APPLICATION_JSON, - JsonUtil.toJson(AgentCardValidator.resolveAndValidateOnce(agentCardInstance, transportValidated)), + JsonUtil.toJson(AgentCardValidator.resolveAndValidateOnce(() -> card, transportValidated, + AgentCardValidator::validateTransportConfiguration)), cacheMetadata.getHttpHeadersMap()); } catch (TenantNotFoundException e) { return new HTTPRestResponse(404, "text/plain", e.getResponseMessage());