Skip to content

Commit 93e521a

Browse files
ehsavoieclaude
andauthored
fix(multitenancy): resolve agent card via router when no default bean… (#1184)
fix(multitenancy): resolve agent cards via router in all transports - Thread AgentCardRouter through handlers for tenant-scoped card resolution - Map TenantNotFoundException to client errors instead of HTTP 500 - Gate extended card router on non-blank tenant; fall back to CDI bean otherwise - Track per-card validation with ConcurrentHashMap instead of single AtomicBoolean Fixes #1182 Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c78c472 commit 93e521a

9 files changed

Lines changed: 719 additions & 192 deletions

File tree

‎server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java‎

Lines changed: 129 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@
55
import java.util.List;
66
import java.util.ServiceLoader;
77
import java.util.Set;
8-
import java.util.concurrent.atomic.AtomicBoolean;
8+
import java.util.concurrent.ConcurrentHashMap;
99
import java.util.function.Consumer;
1010
import java.util.function.Supplier;
1111
import java.util.logging.Logger;
1212
import java.util.stream.Collectors;
1313

1414
import jakarta.enterprise.inject.Instance;
1515

16+
import org.a2aproject.sdk.server.multitenancy.AgentCardRouter;
17+
import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException;
1618
import org.a2aproject.sdk.server.util.CdiUtils;
1719
import org.a2aproject.sdk.spec.AgentCard;
18-
import org.jspecify.annotations.Nullable;
1920
import org.a2aproject.sdk.spec.AgentInterface;
2021
import org.a2aproject.sdk.spec.TransportProtocol;
22+
import org.jspecify.annotations.Nullable;
2123

2224
/**
2325
* Validates AgentCard transport configuration against available transport endpoints.
@@ -35,41 +37,52 @@ public class AgentCardValidator {
3537
public static final String SKIP_GRPC_PROPERTY = "org.a2aproject.sdk.transport.grpc.skipValidation";
3638
public static final String SKIP_REST_PROPERTY = "org.a2aproject.sdk.transport.rest.skipValidation";
3739

40+
/**
41+
* Creates a new thread-safe set for tracking which {@link AgentCard} instances have already
42+
* been validated. Each distinct card is validated at most once; subsequent requests for the
43+
* same card skip validation.
44+
*
45+
* @return a concurrent set suitable for use as the {@code validatedCards} parameter
46+
*/
47+
public static Set<AgentCard> newValidatedCardsSet() {
48+
return ConcurrentHashMap.newKeySet();
49+
}
50+
3851
/**
3952
* Resolves an {@link AgentCard} from the given {@link Instance} and validates its transport
40-
* configuration exactly once using the default {@link #validateTransportConfiguration} check.
41-
* The {@code transportValidated} guard is reset on failure so validation can be retried on
42-
* the next call.
53+
* configuration once per distinct card using the default
54+
* {@link #validateTransportConfiguration} check. On failure the card is removed from the
55+
* set so validation can be retried on the next call.
4356
*
4457
* @param agentCardInstance the CDI instance holding the agent card
45-
* @param transportValidated atomic guard ensuring validation runs once
58+
* @param validatedCards set tracking which cards have already been validated
4659
* @return the resolved agent card
4760
*/
4861
public static AgentCard resolveAndValidateOnce(Instance<AgentCard> agentCardInstance,
49-
AtomicBoolean transportValidated) {
50-
return resolveAndValidateOnce(agentCardInstance::get, transportValidated,
62+
Set<AgentCard> validatedCards) {
63+
return resolveAndValidateOnce(agentCardInstance::get, validatedCards,
5164
AgentCardValidator::validateTransportConfiguration);
5265
}
5366

5467
/**
5568
* Obtains an {@link AgentCard} from the given supplier and applies the provided validator
56-
* exactly once. The {@code transportValidated} guard is reset on failure so validation can
57-
* be retried on the next call.
69+
* once per distinct card. On failure the card is removed from the set so validation can be
70+
* retried on the next call.
5871
*
5972
* @param agentCardSupplier supplier that produces the agent card
60-
* @param transportValidated atomic guard ensuring validation runs once
73+
* @param validatedCards set tracking which cards have already been validated
6174
* @param validator validation logic to apply on first access
6275
* @return the resolved agent card
6376
*/
6477
public static AgentCard resolveAndValidateOnce(Supplier<AgentCard> agentCardSupplier,
65-
AtomicBoolean transportValidated,
78+
Set<AgentCard> validatedCards,
6679
Consumer<AgentCard> validator) {
6780
AgentCard card = agentCardSupplier.get();
68-
if (transportValidated.compareAndSet(false, true)) {
81+
if (validatedCards.add(card)) {
6982
try {
7083
validator.accept(card);
7184
} catch (RuntimeException e) {
72-
transportValidated.set(false);
85+
validatedCards.remove(card);
7386
throw e;
7487
}
7588
}
@@ -82,22 +95,118 @@ public static AgentCard resolveAndValidateOnce(Supplier<AgentCard> agentCardSupp
8295
*
8396
* @param publicCard the CDI instance for the {@code @PublicAgentCard}
8497
* @param extendedCard the CDI instance for the {@code @ExtendedAgentCard}, may be {@code null}
85-
* @param transportValidated atomic guard ensuring validation runs once
98+
* @param validatedCards set tracking which cards have already been validated
8699
* @return the resolved agent card
87100
* @throws IllegalStateException if neither card is available
88101
*/
89102
public static AgentCard resolveWithFallback(Instance<AgentCard> publicCard,
90103
@Nullable Instance<AgentCard> extendedCard,
91-
AtomicBoolean transportValidated) {
104+
Set<AgentCard> validatedCards) {
105+
return resolveWithFallback(publicCard, extendedCard, null, null, validatedCards);
106+
}
107+
108+
/**
109+
* Convenience overload that uses the default {@link #validateTransportConfiguration} validator.
110+
*
111+
* @see #resolveWithFallback(Instance, Instance, AgentCardRouter, String, Set, Consumer)
112+
*/
113+
public static AgentCard resolveWithFallback(Instance<AgentCard> publicCard,
114+
@Nullable Instance<AgentCard> extendedCard,
115+
@Nullable AgentCardRouter agentCardRouter,
116+
@Nullable String tenant,
117+
Set<AgentCard> validatedCards) {
118+
return resolveWithFallback(publicCard, extendedCard, agentCardRouter, tenant, validatedCards,
119+
AgentCardValidator::validateTransportConfiguration);
120+
}
121+
122+
/**
123+
* Resolves an agent card using one of two resolution strategies depending on whether a
124+
* tenant-scoped request is being made, and validates using the supplied validator.
125+
*
126+
* @param publicCard the CDI instance for the {@code @PublicAgentCard}
127+
* @param extendedCard the CDI instance for the {@code @ExtendedAgentCard}, may be {@code null}
128+
* @param agentCardRouter optional router for tenant-specific card resolution
129+
* @param tenant the tenant identifier, may be {@code null}
130+
* @param validatedCards set tracking which cards have already been validated
131+
* @param validator validation logic to apply on first access of each distinct card
132+
* @return the resolved agent card
133+
* @throws TenantNotFoundException if a non-blank tenant is specified, a router is available,
134+
* but neither a public nor extended card is registered for that tenant
135+
* @throws IllegalStateException if no card can be resolved (non-tenant-scoped path)
136+
* @see #resolveTenantScoped(AgentCardRouter, String, Set, Consumer)
137+
* @see #resolveDefaultWithRouterFallback(Instance, Instance, AgentCardRouter, String, Set, Consumer)
138+
*/
139+
public static AgentCard resolveWithFallback(Instance<AgentCard> publicCard,
140+
@Nullable Instance<AgentCard> extendedCard,
141+
@Nullable AgentCardRouter agentCardRouter,
142+
@Nullable String tenant,
143+
Set<AgentCard> validatedCards,
144+
Consumer<AgentCard> validator) {
145+
if (tenant != null && !tenant.isBlank() && agentCardRouter != null) {
146+
return resolveTenantScoped(agentCardRouter, tenant, validatedCards, validator);
147+
}
148+
return resolveDefaultWithRouterFallback(publicCard, extendedCard, agentCardRouter, tenant,
149+
validatedCards, validator);
150+
}
151+
152+
/**
153+
* Tenant-scoped resolution: only the {@link AgentCardRouter} is consulted. CDI default beans
154+
* are <em>not</em> used as fallbacks — doing so would let the request proceed against the
155+
* wrong tenant's card.
156+
* <ol>
157+
* <li>Tenant-specific public card via the router</li>
158+
* <li>Tenant-specific extended card via the router</li>
159+
* <li>{@link TenantNotFoundException} if neither is registered</li>
160+
* </ol>
161+
*/
162+
private static AgentCard resolveTenantScoped(AgentCardRouter agentCardRouter, String tenant,
163+
Set<AgentCard> validatedCards, Consumer<AgentCard> validator) {
164+
AgentCard routerCard = agentCardRouter.resolvePublicCard(tenant);
165+
if (routerCard != null) {
166+
return resolveAndValidateOnce(() -> routerCard, validatedCards, validator);
167+
}
168+
AgentCard routerExtCard = agentCardRouter.resolveExtendedCard(tenant);
169+
if (routerExtCard != null) {
170+
return resolveAndValidateOnce(() -> routerExtCard, validatedCards, validator);
171+
}
172+
throw new TenantNotFoundException(tenant);
173+
}
174+
175+
/**
176+
* Non-tenant-scoped resolution with optional router fallback:
177+
* <ol>
178+
* <li>Unqualified {@code @PublicAgentCard} CDI bean</li>
179+
* <li>Router's public card (when no default bean exists)</li>
180+
* <li>Unqualified {@code @ExtendedAgentCard} CDI bean</li>
181+
* <li>Router's extended card (when no default bean exists)</li>
182+
* <li>{@link IllegalStateException} if nothing resolves</li>
183+
* </ol>
184+
*/
185+
private static AgentCard resolveDefaultWithRouterFallback(Instance<AgentCard> publicCard,
186+
@Nullable Instance<AgentCard> extendedCard,
187+
@Nullable AgentCardRouter agentCardRouter,
188+
@Nullable String tenant,
189+
Set<AgentCard> validatedCards,
190+
Consumer<AgentCard> validator) {
92191
AgentCard resolved = CdiUtils.resolveDefault(publicCard);
93192
if (resolved != null) {
94-
return resolveAndValidateOnce(() -> resolved, transportValidated,
95-
AgentCardValidator::validateTransportConfiguration);
193+
return resolveAndValidateOnce(() -> resolved, validatedCards, validator);
194+
}
195+
if (agentCardRouter != null) {
196+
AgentCard routerCard = agentCardRouter.resolvePublicCard(tenant);
197+
if (routerCard != null) {
198+
return resolveAndValidateOnce(() -> routerCard, validatedCards, validator);
199+
}
96200
}
97201
AgentCard extResolved = CdiUtils.resolveDefault(extendedCard);
98202
if (extResolved != null) {
99-
return resolveAndValidateOnce(() -> extResolved, transportValidated,
100-
AgentCardValidator::validateTransportConfiguration);
203+
return resolveAndValidateOnce(() -> extResolved, validatedCards, validator);
204+
}
205+
if (agentCardRouter != null) {
206+
AgentCard routerExtCard = agentCardRouter.resolveExtendedCard(tenant);
207+
if (routerExtCard != null) {
208+
return resolveAndValidateOnce(() -> routerExtCard, validatedCards, validator);
209+
}
101210
}
102211
throw new IllegalStateException(NO_AGENT_CARD_MESSAGE);
103212
}

‎server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,15 @@ public class DefaultRequestHandler implements RequestHandler {
262262
*/
263263
int reconciliationTimeoutSeconds;
264264

265+
// Only used inside initConfig() (CDI lifecycle). In static create() paths this field
266+
// remains null and is never accessed, hence the NullAway suppression.
267+
@SuppressWarnings("NullAway")
268+
private @Nullable Instance<AgentExecutor> agentExecutorInstance;
269+
265270
// Fields set by constructor injection cannot be final. We need a noargs constructor for
266271
// Jakarta compatibility, and it seems that making fields set by constructor injection
267272
// final, is not proxyable in all runtimes
268-
private AgentExecutor agentExecutor;
273+
private @Nullable AgentExecutor agentExecutor;
269274
private TaskStore taskStore;
270275
private QueueManager queueManager;
271276
private PushNotificationConfigStore pushConfigStore;
@@ -298,6 +303,7 @@ public class DefaultRequestHandler implements RequestHandler {
298303
@SuppressWarnings("NullAway")
299304
protected DefaultRequestHandler() {
300305
// For CDI proxy creation
306+
this.agentExecutorInstance = null;
301307
this.agentExecutor = null;
302308
this.taskStore = null;
303309
this.queueManager = null;
@@ -313,22 +319,40 @@ protected DefaultRequestHandler() {
313319
* releases; application code should use {@link #builder()} to configure and create a handler.
314320
*/
315321
@Inject
316-
public DefaultRequestHandler(AgentExecutor agentExecutor, TaskStore taskStore,
322+
public DefaultRequestHandler(@Any Instance<AgentExecutor> agentExecutorInstance, TaskStore taskStore,
317323
QueueManager queueManager, PushNotificationConfigStore pushConfigStore,
318324
MainEventBusProcessor mainEventBusProcessor,
319325
@Internal Executor executor,
320326
@EventConsumerExecutor Executor eventConsumerExecutor) {
327+
this.agentExecutorInstance = agentExecutorInstance;
328+
this.agentExecutor = null;
329+
this.taskStore = taskStore;
330+
this.queueManager = queueManager;
331+
this.pushConfigStore = pushConfigStore;
332+
this.mainEventBusProcessor = mainEventBusProcessor;
333+
this.executor = executor;
334+
this.eventConsumerExecutor = eventConsumerExecutor;
335+
this.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, false, null);
336+
this.mainEventBusProcessor.start();
337+
}
338+
339+
/**
340+
* Constructor used by the {@link Builder} and tests.
341+
* The builder always supplies a concrete executor, so no CDI resolution is needed.
342+
*/
343+
DefaultRequestHandler(AgentExecutor agentExecutor, TaskStore taskStore,
344+
QueueManager queueManager, PushNotificationConfigStore pushConfigStore,
345+
MainEventBusProcessor mainEventBusProcessor,
346+
Executor executor,
347+
Executor eventConsumerExecutor) {
348+
this.agentExecutorInstance = null;
321349
this.agentExecutor = agentExecutor;
322350
this.taskStore = taskStore;
323351
this.queueManager = queueManager;
324352
this.pushConfigStore = pushConfigStore;
325353
this.mainEventBusProcessor = mainEventBusProcessor;
326354
this.executor = executor;
327355
this.eventConsumerExecutor = eventConsumerExecutor;
328-
// TODO In Python this is also a constructor parameter defaulting to this SimpleRequestContextBuilder
329-
// implementation if the parameter is null. Skip that for now, since otherwise I get CDI errors, and
330-
// I am unsure about the correct scope.
331-
// Also reworked to make a Supplier since otherwise the builder gets polluted with wrong tasks
332356
this.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, false, null);
333357
this.mainEventBusProcessor.start();
334358
}
@@ -344,6 +368,9 @@ void initConfig() {
344368
configProvider.getValue(A2A_BLOCKING_RECONCILIATION_TIMEOUT_SECONDS));
345369
authorizationProvider = CdiUtils.getIfResolvable(authorizationProviderInstance);
346370
agentExecutorRouter = CdiUtils.getIfResolvable(agentExecutorRouterInstance);
371+
if (agentExecutor == null && agentExecutorInstance != null) {
372+
agentExecutor = CdiUtils.resolveDefault(agentExecutorInstance);
373+
}
347374
pushNotificationsEnabled = Boolean.parseBoolean(
348375
configProvider.getValue(A2A_PUSH_NOTIFICATIONS_ENABLED));
349376

@@ -1436,10 +1463,16 @@ private MessageSendSetup initMessageSend(MessageSendParams params, ServerCallCon
14361463
return new MessageSendSetup(taskManager, task, requestContext);
14371464
}
14381465

1439-
private AgentExecutor resolveAgentExecutor(@Nullable String tenant) {
1466+
AgentExecutor resolveAgentExecutor(@Nullable String tenant) {
14401467
if (agentExecutorRouter != null) {
14411468
return agentExecutorRouter.resolve(tenant);
14421469
}
1470+
if (agentExecutor == null) {
1471+
throw new IllegalStateException(
1472+
"No AgentExecutor available. Either provide an unqualified AgentExecutor "
1473+
+ "CDI bean, or add the multitenancy extension (a2a-java-sdk-extras-multitenancy) "
1474+
+ "which provides an AgentExecutorRouter for @Tenant-qualified beans.");
1475+
}
14431476
return agentExecutor;
14441477
}
14451478

0 commit comments

Comments
 (0)