fix(multitenancy): support @PublicAgentCard on tenant-specific cards by ehsavoie · Pull Request #1176 · a2aproject/a2a-java · GitHub
Skip to content
Merged
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
11 changes: 7 additions & 4 deletions docs/content/dev/extra/multi-tenancy.md
2 changes: 1 addition & 1 deletion docs/content/dev/extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/content/dev/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -20,10 +22,18 @@
* CDI-based {@link AgentCardRouter} that resolves tenant-specific {@link AgentCard} beans.
* <p>
* 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:
* <ul>
* <li>carry {@code @PublicAgentCard} (preferred — these cards are resolved before bare
* tenant cards, even if they also carry {@code @ExtendedAgentCard}), or</li>
* <li>carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard} (bare
* tenant card).</li>
* </ul>
* 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.
* <p>
* 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
Expand Down Expand Up @@ -57,6 +67,9 @@ void init() {
if (selected.isResolvable()) {
return selected.get();
}
if (selected.isAmbiguous()) {
throw duplicateTenantBean("@ExtendedAgentCard", tenant);
}
return defaultExtendedCard;
}

Expand All @@ -65,16 +78,53 @@ void init() {
if (tenant == null || tenant.isBlank()) {
return defaultPublicCard;
}
for (Instance.Handle<AgentCard> handle : allCards.handles()) {
Set<Annotation> 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<AgentCard> publicHandle = null;
Instance.Handle<AgentCard> bareHandle = null;
List<Instance.Handle<AgentCard>> allHandles = new ArrayList<>();
try {
for (Instance.Handle<AgentCard> 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<AgentCard> selected = publicHandle != null ? publicHandle : bareHandle;
return selected != null ? selected.get() : null;
} finally {
Instance.Handle<AgentCard> kept = publicHandle != null ? publicHandle : bareHandle;
for (Instance.Handle<AgentCard> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -36,12 +37,26 @@ void init() {
@Override
public AgentExecutor resolve(@Nullable String tenant) {
if (tenant == null || tenant.isBlank()) {
return defaultExecutor;
return requireDefaultExecutor();
}
Instance<AgentExecutor> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@NullMarked
package org.a2aproject.sdk.extras.multitenancy;

import org.jspecify.annotations.NullMarked;

Loading
Loading