From 30d7ddd6e0ecdcc5a3b2dc779e404669dd502c32 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Wed, 24 Dec 2025 11:26:45 +0000 Subject: [PATCH 1/2] feat: Add Javadoc for the client modules --- client/base/src/main/java/io/a2a/A2A.java | 269 ++++++++-- .../src/main/java/io/a2a/client/Client.java | 478 ++++++++++++++++++ .../java/io/a2a/client/ClientBuilder.java | 213 ++++++++ .../main/java/io/a2a/client/ClientEvent.java | 71 +++ .../main/java/io/a2a/client/MessageEvent.java | 59 ++- .../main/java/io/a2a/client/TaskEvent.java | 80 ++- .../java/io/a2a/client/TaskUpdateEvent.java | 125 ++++- .../io/a2a/client/config/ClientConfig.java | 298 ++++++++++- .../transport/grpc/GrpcTransportConfig.java | 91 ++++ .../grpc/GrpcTransportConfigBuilder.java | 178 +++++++ .../jsonrpc/JSONRPCTransportConfig.java | 64 ++- .../JSONRPCTransportConfigBuilder.java | 94 +++- .../transport/rest/RestTransportConfig.java | 62 +++ .../rest/RestTransportConfigBuilder.java | 92 ++++ .../transport/spi/ClientTransportConfig.java | 48 +- .../spi/ClientTransportConfigBuilder.java | 70 +++ 16 files changed, 2248 insertions(+), 44 deletions(-) diff --git a/client/base/src/main/java/io/a2a/A2A.java b/client/base/src/main/java/io/a2a/A2A.java index 086db1c6a..02f212656 100644 --- a/client/base/src/main/java/io/a2a/A2A.java +++ b/client/base/src/main/java/io/a2a/A2A.java @@ -16,47 +16,121 @@ /** - * Constants and utility methods related to the A2A protocol. + * Utility class providing convenience methods for working with the A2A Protocol. + *

+ * This class offers static helper methods for common A2A operations: + *

+ *

+ * These utilities simplify client code by providing concise alternatives to the builder + * APIs for routine operations. + *

+ * Example usage: + *

{@code
+ * // Get agent card
+ * AgentCard card = A2A.getAgentCard("http://localhost:9999");
+ *
+ * // Create and send a user message
+ * Message userMsg = A2A.toUserMessage("What's the weather today?");
+ * client.sendMessage(userMsg);
+ *
+ * // Create a message with context and task IDs
+ * Message contextMsg = A2A.createUserTextMessage(
+ *     "Continue the conversation",
+ *     "session-123",  // contextId
+ *     "task-456"      // taskId
+ * );
+ * client.sendMessage(contextMsg);
+ * }
+ * + * @see Message + * @see AgentCard + * @see io.a2a.client.Client */ public class A2A { /** - * Convert the given text to a user message. + * Create a simple user message from text. + *

+ * This is the most common way to create messages when sending requests to agents. + * The message will have: + *

+ *

+ * Example: + *

{@code
+     * Message msg = A2A.toUserMessage("Tell me a joke");
+     * client.sendMessage(msg);
+     * }
* - * @param text the message text - * @return the user message + * @param text the message text (required) + * @return a user message with the specified text + * @see #toUserMessage(String, String) + * @see #createUserTextMessage(String, String, String) */ public static Message toUserMessage(String text) { return toMessage(text, Message.Role.USER, null); } /** - * Convert the given text to a user message. + * Create a user message from text with a specific message ID. + *

+ * Use this when you need to control the message ID for tracking or correlation purposes. + *

+ * Example: + *

{@code
+     * String messageId = UUID.randomUUID().toString();
+     * Message msg = A2A.toUserMessage("Process this request", messageId);
+     * // Store messageId for later correlation
+     * client.sendMessage(msg);
+     * }
* - * @param text the message text + * @param text the message text (required) * @param messageId the message ID to use - * @return the user message + * @return a user message with the specified text and ID + * @see #toUserMessage(String) */ public static Message toUserMessage(String text, String messageId) { return toMessage(text, Message.Role.USER, messageId); } /** - * Convert the given text to an agent message. + * Create a simple agent message from text. + *

+ * This is typically used in testing or when constructing agent responses programmatically. + * Most client applications receive agent messages via {@link io.a2a.client.MessageEvent} + * rather than creating them manually. + *

+ * Example: + *

{@code
+     * // Testing scenario
+     * Message agentResponse = A2A.toAgentMessage("Here's the answer: 42");
+     * }
* - * @param text the message text - * @return the agent message + * @param text the message text (required) + * @return an agent message with the specified text + * @see #toAgentMessage(String, String) */ public static Message toAgentMessage(String text) { return toMessage(text, Message.Role.AGENT, null); } /** - * Convert the given text to an agent message. + * Create an agent message from text with a specific message ID. + *

+ * Example: + *

{@code
+     * Message agentResponse = A2A.toAgentMessage("Processing complete", "msg-789");
+     * }
* - * @param text the message text + * @param text the message text (required) * @param messageId the message ID to use - * @return the agent message + * @return an agent message with the specified text and ID */ public static Message toAgentMessage(String text, String messageId) { return toMessage(text, Message.Role.AGENT, messageId); @@ -64,11 +138,46 @@ public static Message toAgentMessage(String text, String messageId) { /** * Create a user message with text content and optional context and task IDs. + *

+ * This method is useful when continuing a conversation or working with a specific task: + *

+ *

+ * Example - continuing a conversation: + *

{@code
+     * // First message creates context
+     * Message msg1 = A2A.toUserMessage("What's your name?");
+     * client.sendMessage(msg1);
+     * String contextId = ...; // Get from response
+     *
+     * // Follow-up message uses contextId
+     * Message msg2 = A2A.createUserTextMessage(
+     *     "What else can you do?",
+     *     contextId,
+     *     null  // no specific task
+     * );
+     * client.sendMessage(msg2);
+     * }
+ *

+ * Example - adding to an existing task: + *

{@code
+     * Message msg = A2A.createUserTextMessage(
+     *     "Add this information too",
+     *     "session-123",
+     *     "task-456"  // Continue working on this task
+     * );
+     * client.sendMessage(msg);
+     * }
* * @param text the message text (required) * @param contextId the context ID to use (optional) * @param taskId the task ID to use (optional) - * @return the user message + * @return a user message with the specified text, context, and task IDs + * @see #createAgentTextMessage(String, String, String) + * @see Message#contextId() + * @see Message#taskId() */ public static Message createUserTextMessage(String text, String contextId, String taskId) { return toMessage(text, Message.Role.USER, null, contextId, taskId); @@ -76,11 +185,14 @@ public static Message createUserTextMessage(String text, String contextId, Strin /** * Create an agent message with text content and optional context and task IDs. + *

+ * This is typically used in testing or when constructing agent responses programmatically. * * @param text the message text (required) * @param contextId the context ID to use (optional) * @param taskId the task ID to use (optional) - * @return the agent message + * @return an agent message with the specified text, context, and task IDs + * @see #createUserTextMessage(String, String, String) */ public static Message createAgentTextMessage(String text, String contextId, String taskId) { return toMessage(text, Message.Role.AGENT, null, contextId, taskId); @@ -88,11 +200,26 @@ public static Message createAgentTextMessage(String text, String contextId, Stri /** * Create an agent message with custom parts and optional context and task IDs. + *

+ * This method allows creating messages with multiple parts (text, images, files, etc.) + * instead of just simple text. Useful for complex agent responses or testing. + *

+ * Example - message with text and image: + *

{@code
+     * List> parts = List.of(
+     *     new TextPart("Here's a chart of the data:"),
+     *     new ImagePart("https://example.com/chart.png", "Chart showing sales data")
+     * );
+     * Message msg = A2A.createAgentPartsMessage(parts, "session-123", "task-456");
+     * }
* - * @param parts the message parts (required) + * @param parts the message parts (required, must not be empty) * @param contextId the context ID to use (optional) * @param taskId the task ID to use (optional) - * @return the agent message + * @return an agent message with the specified parts, context, and task IDs + * @throws IllegalArgumentException if parts is null or empty + * @see io.a2a.spec.Part + * @see io.a2a.spec.TextPart */ public static Message createAgentPartsMessage(List> parts, String contextId, String taskId) { if (parts == null || parts.isEmpty()) { @@ -130,47 +257,131 @@ private static Message toMessage(List> parts, Message.Role role, String } /** - * Get the agent card for an A2A agent. + * Retrieve the agent card for an A2A agent. + *

+ * This is the standard way to discover an agent's capabilities before creating a client. + * The agent card is fetched from the well-known endpoint: {@code /.well-known/agent-card.json} + *

+ * Example: + *

{@code
+     * // Get agent card
+     * AgentCard card = A2A.getAgentCard("http://localhost:9999");
+     *
+     * // Check capabilities
+     * System.out.println("Agent: " + card.name());
+     * System.out.println("Supports streaming: " + card.capabilities().streaming());
+     *
+     * // Create client
+     * Client client = Client.builder(card)
+     *     .withTransport(...)
+     *     .build();
+     * }
* * @param agentUrl the base URL for the agent whose agent card we want to retrieve * @return the agent card - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard schema + * @throws io.a2a.spec.A2AClientError if an HTTP error occurs fetching the card + * @throws io.a2a.spec.A2AClientJSONError if the response body cannot be decoded as JSON or validated against the AgentCard schema + * @see #getAgentCard(A2AHttpClient, String) + * @see #getAgentCard(String, String, java.util.Map) + * @see AgentCard */ public static AgentCard getAgentCard(String agentUrl) throws A2AClientError, A2AClientJSONError { return getAgentCard(new JdkA2AHttpClient(), agentUrl); } /** - * Get the agent card for an A2A agent. + * Retrieve the agent card using a custom HTTP client. + *

+ * Use this variant when you need to customize HTTP behavior (timeouts, SSL configuration, + * connection pooling, etc.). + *

+ * Example: + *

{@code
+     * A2AHttpClient customClient = new CustomHttpClient()
+     *     .withTimeout(Duration.ofSeconds(10))
+     *     .withSSLContext(mySSLContext);
+     *
+     * AgentCard card = A2A.getAgentCard(customClient, "https://secure-agent.com");
+     * }
* * @param httpClient the http client to use * @param agentUrl the base URL for the agent whose agent card we want to retrieve * @return the agent card - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard schema + * @throws io.a2a.spec.A2AClientError if an HTTP error occurs fetching the card + * @throws io.a2a.spec.A2AClientJSONError if the response body cannot be decoded as JSON or validated against the AgentCard schema + * @see io.a2a.client.http.A2AHttpClient */ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl) throws A2AClientError, A2AClientJSONError { return getAgentCard(httpClient, agentUrl, null, null); } /** - * Get the agent card for an A2A agent. + * Retrieve the agent card with custom path and authentication. + *

+ * Use this variant when: + *

    + *
  • The agent card is at a non-standard location
  • + *
  • Authentication is required to access the agent card
  • + *
+ *

+ * Example with authentication: + *

{@code
+     * Map authHeaders = Map.of(
+     *     "Authorization", "Bearer my-api-token",
+     *     "X-API-Key", "my-api-key"
+     * );
+     *
+     * AgentCard card = A2A.getAgentCard(
+     *     "https://secure-agent.com",
+     *     null,  // Use default path
+     *     authHeaders
+     * );
+     * }
+ *

+ * Example with custom path: + *

{@code
+     * AgentCard card = A2A.getAgentCard(
+     *     "https://agent.com",
+     *     "api/v2/agent-info",  // Custom path
+     *     null  // No auth needed
+     * );
+     * // Fetches from: https://agent.com/api/v2/agent-info
+     * }
* * @param agentUrl the base URL for the agent whose agent card we want to retrieve * @param relativeCardPath optional path to the agent card endpoint relative to the base * agent URL, defaults to ".well-known/agent-card.json" * @param authHeaders the HTTP authentication headers to use * @return the agent card - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard schema + * @throws io.a2a.spec.A2AClientError if an HTTP error occurs fetching the card + * @throws io.a2a.spec.A2AClientJSONError if the response body cannot be decoded as JSON or validated against the AgentCard schema */ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, Map authHeaders) throws A2AClientError, A2AClientJSONError { return getAgentCard(new JdkA2AHttpClient(), agentUrl, relativeCardPath, authHeaders); } /** - * Get the agent card for an A2A agent. + * Retrieve the agent card with full customization options. + *

+ * This is the most flexible variant, allowing customization of: + *

    + *
  • HTTP client implementation
  • + *
  • Agent card endpoint path
  • + *
  • Authentication headers
  • + *
+ *

+ * Example: + *

{@code
+     * A2AHttpClient customClient = new CustomHttpClient();
+     * Map authHeaders = Map.of("Authorization", "Bearer token");
+     *
+     * AgentCard card = A2A.getAgentCard(
+     *     customClient,
+     *     "https://agent.com",
+     *     "custom/agent-card",
+     *     authHeaders
+     * );
+     * }
* * @param httpClient the http client to use * @param agentUrl the base URL for the agent whose agent card we want to retrieve @@ -178,8 +389,8 @@ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, M * agent URL, defaults to ".well-known/agent-card.json" * @param authHeaders the HTTP authentication headers to use * @return the agent card - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard schema + * @throws io.a2a.spec.A2AClientError if an HTTP error occurs fetching the card + * @throws io.a2a.spec.A2AClientJSONError if the response body cannot be decoded as JSON or validated against the AgentCard schema */ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, String relativeCardPath, Map authHeaders) throws A2AClientError, A2AClientJSONError { A2ACardResolver resolver = new A2ACardResolver(httpClient, agentUrl, "", relativeCardPath, authHeaders); diff --git a/client/base/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java index 2a82b872e..98d52be39 100644 --- a/client/base/src/main/java/io/a2a/client/Client.java +++ b/client/base/src/main/java/io/a2a/client/Client.java @@ -35,12 +35,157 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +/** + * A client for communicating with A2A agents using the Agent2Agent Protocol. + *

+ * The Client class provides the primary API for sending messages to agents, managing tasks, + * configuring push notifications, and subscribing to task updates. It abstracts the underlying + * transport protocol (JSON-RPC, gRPC, REST) and provides a consistent interface for all + * agent interactions. + *

+ * Key capabilities: + *

    + *
  • Message exchange: Send messages to agents and receive responses via event consumers
  • + *
  • Task management: Query, list, and cancel tasks
  • + *
  • Streaming support: Real-time event streaming when both client and server support it
  • + *
  • Push notifications: Configure webhooks for task state changes
  • + *
  • Resubscription: Resume receiving events for ongoing tasks after disconnection
  • + *
+ *

+ * Creating a client: Use {@link #builder(AgentCard)} to create instances: + *

{@code
+ * // 1. Get agent card
+ * AgentCard card = A2A.getAgentCard("http://localhost:9999");
+ *
+ * // 2. Build and configure client
+ * Client client = Client.builder(card)
+ *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
+ *     .addConsumer((event, agentCard) -> {
+ *         if (event instanceof MessageEvent me) {
+ *             Message msg = me.getMessage();
+ *             System.out.println("Agent response: " + msg.parts());
+ *         } else if (event instanceof TaskUpdateEvent tue) {
+ *             Task task = tue.getTask();
+ *             System.out.println("Task " + task.id() + " is " + task.status().state());
+ *         }
+ *     })
+ *     .build();
+ *
+ * // 3. Send messages
+ * client.sendMessage(A2A.toUserMessage("Tell me a joke"));
+ *
+ * // 4. Clean up when done
+ * client.close();
+ * }
+ *

+ * Event consumption model: Responses from the agent are delivered as {@link ClientEvent} + * instances to the registered consumers: + *

    + *
  • {@link MessageEvent} - contains agent response messages with content parts
  • + *
  • {@link TaskEvent} - contains complete task state (typically final state)
  • + *
  • {@link TaskUpdateEvent} - contains incremental task updates (status or artifact changes)
  • + *
+ *

+ * Streaming vs blocking: The client supports two communication modes: + *

    + *
  • Blocking: {@link #sendMessage} blocks until the agent completes the task
  • + *
  • Streaming: {@link #sendMessage} returns immediately, events delivered asynchronously + * to consumers as the agent processes the request
  • + *
+ * The mode is determined by {@link ClientConfig#isStreaming()} AND {@link io.a2a.spec.AgentCapabilities#streaming()}. + * Both must be {@code true} for streaming mode; otherwise blocking mode is used. + *

+ * Task lifecycle example: + *

{@code
+ * client.addConsumer((event, card) -> {
+ *     if (event instanceof TaskUpdateEvent tue) {
+ *         TaskState state = tue.getTask().status().state();
+ *         switch (state) {
+ *             case SUBMITTED -> System.out.println("Task created");
+ *             case WORKING -> System.out.println("Agent is processing...");
+ *             case COMPLETED -> System.out.println("Task finished");
+ *             case FAILED -> System.err.println("Task failed: " +
+ *                 tue.getTask().status().message());
+ *         }
+ *         
+ *         // Check for new artifacts
+ *         if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent update) {
+ *             Artifact artifact = update.artifact();
+ *             System.out.println("New content: " + artifact.parts());
+ *         }
+ *     }
+ * });
+ * }
+ *

+ * Push notifications: Configure webhooks to receive task updates: + *

{@code
+ * // Configure push notifications for a task
+ * PushNotificationConfig pushConfig = new PushNotificationConfig(
+ *     "https://my-app.com/webhooks/task-updates",
+ *     Map.of("Authorization", "Bearer my-token")
+ * );
+ *
+ * // Send message with push notifications
+ * client.sendMessage(
+ *     A2A.toUserMessage("Process this data"),
+ *     pushConfig,
+ *     null,  // metadata
+ *     null   // context
+ * );
+ * }
+ *

+ * Resubscription after disconnection: + *

{@code
+ * // Original request
+ * client.sendMessage(A2A.toUserMessage("Long-running task"));
+ * // ... client disconnects ...
+ *
+ * // Later, reconnect and resume receiving events
+ * String taskId = "task-123";  // From original request
+ * client.resubscribe(
+ *     new TaskIdParams(taskId),
+ *     List.of((event, card) -> {
+ *         // Process events from where we left off
+ *     }),
+ *     null,  // error handler
+ *     null   // context
+ * );
+ * }
+ *

+ * Thread safety: Client instances are thread-safe and can be used concurrently from + * multiple threads. Event consumers must also be thread-safe as they may be invoked concurrently + * for different tasks. + *

+ * Resource management: Clients hold resources (HTTP connections, gRPC channels, etc.) + * and should be closed when no longer needed: + *

{@code
+ * try (Client client = Client.builder(card)...build()) {
+ *     client.sendMessage(...);
+ * } // Automatically closed
+ * }
+ * + * @see ClientBuilder + * @see ClientEvent + * @see MessageEvent + * @see TaskEvent + * @see TaskUpdateEvent + * @see io.a2a.A2A + */ public class Client extends AbstractClient { private final ClientConfig clientConfig; private final ClientTransport clientTransport; private AgentCard agentCard; + /** + * Package-private constructor used by {@link ClientBuilder#build()}. + * + * @param agentCard the agent card for the target agent + * @param clientConfig the client configuration + * @param clientTransport the transport protocol implementation + * @param consumers the event consumers + * @param streamingErrorHandler the error handler for streaming scenarios + */ Client(AgentCard agentCard, ClientConfig clientConfig, ClientTransport clientTransport, List> consumers, @Nullable Consumer streamingErrorHandler) { super(consumers, streamingErrorHandler); @@ -51,6 +196,25 @@ public class Client extends AbstractClient { this.clientTransport = clientTransport; } + /** + * Create a new builder for constructing a client instance. + *

+ * This is the primary entry point for creating clients. The builder provides a fluent + * API for configuring transports, event consumers, and client behavior. + *

+ * Example: + *

{@code
+     * AgentCard card = A2A.getAgentCard("http://localhost:9999");
+     * Client client = Client.builder(card)
+     *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
+     *     .addConsumer((event, agentCard) -> processEvent(event))
+     *     .build();
+     * }
+ * + * @param agentCard the agent card describing the agent to communicate with + * @return a new builder instance + * @see ClientBuilder + */ public static ClientBuilder builder(AgentCard agentCard) { return new ClientBuilder(agentCard); } @@ -64,6 +228,51 @@ public void sendMessage(@NonNull Message request, sendMessage(messageSendParams, consumers, streamingErrorHandler, context); } + /** + * Send a message to the agent. + *

+ * This is the primary method for communicating with an agent. The behavior depends on + * whether streaming is enabled: + *

    + *
  • Streaming mode: Returns immediately, events delivered asynchronously to consumers
  • + *
  • Blocking mode: Blocks until the agent completes the task, then invokes consumers
  • + *
+ * Streaming mode is active when both {@link ClientConfig#isStreaming()} AND + * {@link io.a2a.spec.AgentCapabilities#streaming()} are {@code true}. + *

+ * Simple example: + *

{@code
+     * Message userMessage = A2A.toUserMessage("What's the weather?");
+     * client.sendMessage(userMessage, null, null, null);
+     * // Events delivered to consumers registered during client construction
+     * }
+ *

+ * With push notifications: + *

{@code
+     * PushNotificationConfig pushConfig = new PushNotificationConfig(
+     *     "https://my-app.com/webhook",
+     *     Map.of("Authorization", "Bearer token")
+     * );
+     * client.sendMessage(userMessage, pushConfig, null, null);
+     * }
+ *

+ * With metadata: + *

{@code
+     * Map metadata = Map.of(
+     *     "userId", "user-123",
+     *     "sessionId", "session-456"
+     * );
+     * client.sendMessage(userMessage, null, metadata, null);
+     * }
+ * + * @param request the message to send (required) + * @param pushNotificationConfiguration webhook configuration for task updates (optional) + * @param metadata custom metadata to attach to the request (optional) + * @param context custom call context for request interceptors (optional) + * @throws A2AClientException if the message cannot be sent or if the agent returns an error + * @see #sendMessage(Message, List, Consumer, ClientCallContext) + * @see PushNotificationConfig + */ @Override public void sendMessage(@NonNull Message request, @Nullable PushNotificationConfig pushNotificationConfiguration, @@ -80,45 +289,277 @@ public void sendMessage(@NonNull Message request, sendMessage(messageSendParams, consumers, streamingErrorHandler, context); } + /** + * Retrieve a specific task by ID. + *

+ * This method queries the agent for the current state of a task. It's useful for: + *

    + *
  • Checking the status of a task after disconnection
  • + *
  • Retrieving task results without subscribing to events
  • + *
  • Polling for task completion (when streaming is not available)
  • + *
+ *

+ * Example: + *

{@code
+     * Task task = client.getTask(new TaskQueryParams("task-123"));
+     * if (task.status().state() == TaskState.COMPLETED) {
+     *     Artifact result = task.artifact();
+     *     System.out.println("Result: " + result.parts());
+     * } else if (task.status().state() == TaskState.FAILED) {
+     *     System.err.println("Task failed: " + task.status().message());
+     * }
+     * }
+ * + * @param request the task query parameters containing the task ID + * @param context custom call context for request interceptors (optional) + * @return the current task state + * @throws A2AClientException if the task is not found or if a communication error occurs + * @see TaskQueryParams + * @see Task + */ @Override public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) throws A2AClientException { return clientTransport.getTask(request, context); } + /** + * List tasks for the current session or context. + *

+ * This method retrieves multiple tasks based on filter criteria. Useful for: + *

    + *
  • Viewing all tasks in a session/context
  • + *
  • Finding tasks by state (e.g., all failed tasks)
  • + *
  • Paginating through large task lists
  • + *
+ *

+ * Example: + *

{@code
+     * // List all tasks for a context
+     * ListTasksParams params = new ListTasksParams(
+     *     "session-123",  // contextId
+     *     null,           // state filter (null = all states)
+     *     10,             // limit
+     *     null            // offset
+     * );
+     * ListTasksResult result = client.listTasks(params);
+     * for (Task task : result.tasks()) {
+     *     System.out.println(task.id() + ": " + task.status().state());
+     * }
+     * }
+ * + * @param request the list parameters with optional filters + * @param context custom call context for request interceptors (optional) + * @return the list of tasks matching the criteria + * @throws A2AClientException if a communication error occurs + * @see ListTasksParams + * @see ListTasksResult + */ @Override public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallContext context) throws A2AClientException { return clientTransport.listTasks(request, context); } + /** + * Request cancellation of a task. + *

+ * This method sends a cancellation request to the agent for the specified task. The agent + * may or may not honor the request depending on its implementation and the task's current state. + *

+ * Important notes: + *

    + *
  • Cancellation is a request, not a guarantee - agents may decline or be unable to cancel
  • + *
  • Some agents don't support cancellation and will return {@link io.a2a.spec.UnsupportedOperationError}
  • + *
  • Tasks in final states (COMPLETED, FAILED, CANCELED) cannot be canceled
  • + *
  • The returned task will have state CANCELED if the cancellation succeeded
  • + *
+ *

+ * Example: + *

{@code
+     * try {
+     *     Task canceledTask = client.cancelTask(new TaskIdParams("task-123"));
+     *     if (canceledTask.status().state() == TaskState.CANCELED) {
+     *         System.out.println("Task successfully canceled");
+     *     }
+     * } catch (A2AClientException e) {
+     *     if (e.getCause() instanceof UnsupportedOperationError) {
+     *         System.err.println("Agent does not support cancellation");
+     *     } else if (e.getCause() instanceof TaskNotFoundError) {
+     *         System.err.println("Task not found");
+     *     }
+     * }
+     * }
+ * + * @param request the task ID to cancel + * @param context custom call context for request interceptors (optional) + * @return the task with CANCELED status if successful + * @throws A2AClientException if the task cannot be canceled or if a communication error occurs + * @see TaskIdParams + * @see io.a2a.spec.UnsupportedOperationError + * @see io.a2a.spec.TaskNotFoundError + */ @Override public Task cancelTask(TaskIdParams request, @Nullable ClientCallContext context) throws A2AClientException { return clientTransport.cancelTask(request, context); } + /** + * Configure push notifications for a task. + *

+ * Push notifications allow your application to receive task updates via webhook instead + * of maintaining an active connection. When configured, the agent will POST events to + * the specified URL as the task progresses. + *

+ * Example: + *

{@code
+     * TaskPushNotificationConfig config = new TaskPushNotificationConfig(
+     *     "task-123",
+     *     new PushNotificationConfig(
+     *         "https://my-app.com/webhooks/task-updates",
+     *         Map.of(
+     *             "Authorization", "Bearer my-webhook-secret",
+     *             "X-App-ID", "my-app"
+     *         )
+     *     )
+     * );
+     * client.setTaskPushNotificationConfiguration(config);
+     * }
+ * + * @param request the push notification configuration for the task + * @param context custom call context for request interceptors (optional) + * @return the stored configuration (may include server-assigned IDs) + * @throws A2AClientException if the configuration cannot be set + * @see TaskPushNotificationConfig + * @see PushNotificationConfig + */ @Override public TaskPushNotificationConfig setTaskPushNotificationConfiguration( TaskPushNotificationConfig request, @Nullable ClientCallContext context) throws A2AClientException { return clientTransport.setTaskPushNotificationConfiguration(request, context); } + /** + * Retrieve the push notification configuration for a task. + *

+ * Example: + *

{@code
+     * GetTaskPushNotificationConfigParams params =
+     *     new GetTaskPushNotificationConfigParams("task-123");
+     * TaskPushNotificationConfig config =
+     *     client.getTaskPushNotificationConfiguration(params);
+     * System.out.println("Webhook URL: " +
+     *     config.pushNotificationConfig().url());
+     * }
+ * + * @param request the parameters specifying which task's configuration to retrieve + * @param context custom call context for request interceptors (optional) + * @return the push notification configuration for the task + * @throws A2AClientException if the configuration cannot be retrieved + * @see GetTaskPushNotificationConfigParams + */ @Override public TaskPushNotificationConfig getTaskPushNotificationConfiguration( GetTaskPushNotificationConfigParams request, @Nullable ClientCallContext context) throws A2AClientException { return clientTransport.getTaskPushNotificationConfiguration(request, context); } + /** + * List all push notification configurations, optionally filtered by task or context. + *

+ * Example: + *

{@code
+     * // List all configurations for a context
+     * ListTaskPushNotificationConfigParams params =
+     *     new ListTaskPushNotificationConfigParams("session-123", null, 10, null);
+     * ListTaskPushNotificationConfigResult result =
+     *     client.listTaskPushNotificationConfigurations(params);
+     * for (TaskPushNotificationConfig config : result.configurations()) {
+     *     System.out.println("Task " + config.taskId() + " -> " +
+     *         config.pushNotificationConfig().url());
+     * }
+     * }
+ * + * @param request the list parameters with optional filters + * @param context custom call context for request interceptors (optional) + * @return the list of push notification configurations + * @throws A2AClientException if the configurations cannot be retrieved + * @see ListTaskPushNotificationConfigParams + */ @Override public ListTaskPushNotificationConfigResult listTaskPushNotificationConfigurations( ListTaskPushNotificationConfigParams request, @Nullable ClientCallContext context) throws A2AClientException { return clientTransport.listTaskPushNotificationConfigurations(request, context); } + /** + * Delete push notification configurations. + *

+ * This method removes push notification configurations for the specified tasks or context. + * After deletion, the agent will stop sending webhook notifications for those tasks. + *

+ * Example: + *

{@code
+     * // Delete configuration for a specific task
+     * DeleteTaskPushNotificationConfigParams params =
+     *     new DeleteTaskPushNotificationConfigParams(
+     *         null,           // contextId (null = not filtering by context)
+     *         List.of("task-123", "task-456")  // specific task IDs
+     *     );
+     * client.deleteTaskPushNotificationConfigurations(params);
+     * }
+ * + * @param request the delete parameters specifying which configurations to remove + * @param context custom call context for request interceptors (optional) + * @throws A2AClientException if the configurations cannot be deleted + * @see DeleteTaskPushNotificationConfigParams + */ @Override public void deleteTaskPushNotificationConfigurations( DeleteTaskPushNotificationConfigParams request, @Nullable ClientCallContext context) throws A2AClientException { clientTransport.deleteTaskPushNotificationConfigurations(request, context); } + /** + * Resubscribe to an existing task to receive remaining events. + *

+ * This method is useful when a client disconnects during a long-running task and wants to + * resume receiving events without starting a new task. The agent will deliver any events + * that occurred since the original subscription. + *

+ * Requirements: + *

    + *
  • Both {@link ClientConfig#isStreaming()} and {@link io.a2a.spec.AgentCapabilities#streaming()} + * must be {@code true}
  • + *
  • The task must still exist and not be in a final state (or the agent must support + * historical event replay)
  • + *
+ *

+ * Example: + *

{@code
+     * // Original request (client1)
+     * client1.sendMessage(A2A.toUserMessage("Analyze this dataset"));
+     * String taskId = ...; // Save task ID from TaskEvent
+     * // ... client1 disconnects ...
+     *
+     * // Later, reconnect (client2)
+     * client2.resubscribe(
+     *     new TaskIdParams(taskId),
+     *     List.of((event, card) -> {
+     *         if (event instanceof TaskUpdateEvent tue) {
+     *             System.out.println("Resumed - status: " +
+     *                 tue.getTask().status().state());
+     *         }
+     *     }),
+     *     throwable -> System.err.println("Resubscribe error: " + throwable),
+     *     null
+     * );
+     * }
+ * + * @param request the task ID to resubscribe to + * @param consumers the event consumers for processing events (required) + * @param streamingErrorHandler error handler for streaming errors (optional) + * @param context custom call context for request interceptors (optional) + * @throws A2AClientException if resubscription is not supported or if the task cannot be found + */ @Override public void resubscribe(@NonNull TaskIdParams request, @NonNull List> consumers, @@ -140,12 +581,49 @@ public void resubscribe(@NonNull TaskIdParams request, clientTransport.resubscribe(request, eventHandler, overriddenErrorHandler, context); } + /** + * Retrieve the agent's current agent card. + *

+ * This method fetches the latest agent card from the agent. The card may have changed since + * client construction (e.g., new skills added, capabilities updated). The client's internal + * reference is updated to the newly retrieved card. + *

+ * Example: + *

{@code
+     * AgentCard updatedCard = client.getAgentCard(null);
+     * System.out.println("Agent version: " + updatedCard.version());
+     * System.out.println("Skills: " + updatedCard.skills().size());
+     * }
+ * + * @param context custom call context for request interceptors (optional) + * @return the agent's current agent card + * @throws A2AClientException if the agent card cannot be retrieved + * @see AgentCard + */ @Override public AgentCard getAgentCard(@Nullable ClientCallContext context) throws A2AClientException { agentCard = clientTransport.getAgentCard(context); return agentCard; } + /** + * Close this client and release all associated resources. + *

+ * This method closes the underlying transport (HTTP connections, gRPC channels, etc.) + * and releases any other resources held by the client. After calling this method, the + * client instance should not be used further. + *

+ * Important: Always close clients when done to avoid resource leaks: + *

{@code
+     * Client client = Client.builder(card)...build();
+     * try {
+     *     client.sendMessage(...);
+     * } finally {
+     *     client.close();
+     * }
+     * // Or use try-with-resources if Client implements AutoCloseable
+     * }
+ */ @Override public void close() { clientTransport.close(); diff --git a/client/base/src/main/java/io/a2a/client/ClientBuilder.java b/client/base/src/main/java/io/a2a/client/ClientBuilder.java index b05b44ca1..c8d2ab6be 100644 --- a/client/base/src/main/java/io/a2a/client/ClientBuilder.java +++ b/client/base/src/main/java/io/a2a/client/ClientBuilder.java @@ -21,6 +21,77 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +/** + * Builder for creating instances of {@link Client} to communicate with A2A agents. + *

+ * ClientBuilder provides a fluent API for configuring and creating client instances that + * communicate with A2A agents. It handles transport negotiation, event consumer registration, + * and client configuration in a type-safe manner. + *

+ * Key responsibilities: + *

    + *
  • Transport selection and negotiation between client and server capabilities
  • + *
  • Event consumer registration for processing agent responses
  • + *
  • Error handler configuration for streaming scenarios
  • + *
  • Client behavior configuration (streaming, polling, preferences)
  • + *
+ *

+ * Transport Selection: The builder automatically negotiates the best transport protocol + * based on the agent's {@link AgentCard} and the client's configured transports. By default, + * the server's preferred transport (first in {@link AgentCard#supportedInterfaces()}) is used. + * This can be changed by setting {@link ClientConfig#isUseClientPreference()} to {@code true}. + *

+ * Typical usage pattern: + *

{@code
+ * // 1. Get the agent card
+ * AgentCard card = A2A.getAgentCard("http://localhost:9999");
+ *
+ * // 2. Build client with transport and event consumer
+ * Client client = Client.builder(card)
+ *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
+ *     .addConsumer((event, agentCard) -> {
+ *         if (event instanceof MessageEvent me) {
+ *             System.out.println("Received: " + me.getMessage().parts());
+ *         } else if (event instanceof TaskUpdateEvent tue) {
+ *             System.out.println("Task status: " + tue.getTask().status().state());
+ *         }
+ *     })
+ *     .build();
+ *
+ * // 3. Send messages
+ * client.sendMessage(A2A.toUserMessage("Hello agent!"));
+ * }
+ *

+ * Multiple transports: You can configure multiple transports for fallback: + *

{@code
+ * Client client = Client.builder(card)
+ *     .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder()
+ *         .channelFactory(ManagedChannelBuilder::forAddress))
+ *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
+ *     .clientConfig(new ClientConfig.Builder()
+ *         .setUseClientPreference(true)  // Try client's preferred order
+ *         .build())
+ *     .build();
+ * }
+ *

+ * Error handling: For streaming scenarios, configure an error handler to process exceptions: + *

{@code
+ * Client client = Client.builder(card)
+ *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
+ *     .streamingErrorHandler(throwable -> {
+ *         System.err.println("Stream error: " + throwable.getMessage());
+ *     })
+ *     .build();
+ * }
+ *

+ * Thread safety: ClientBuilder is not thread-safe and should only be used from a single + * thread during client construction. The resulting {@link Client} instance is thread-safe. + * + * @see Client + * @see ClientConfig + * @see ClientEvent + * @see io.a2a.client.transport.spi.ClientTransport + */ public class ClientBuilder { private static final Map>> transportProviderRegistry = new HashMap<>(); @@ -42,40 +113,182 @@ public class ClientBuilder { private final Map, ClientTransportConfig> clientTransports = new LinkedHashMap<>(); + /** + * Package-private constructor used by {@link Client#builder(AgentCard)}. + * + * @param agentCard the agent card for the agent this client will communicate with (required) + */ ClientBuilder(@NonNull AgentCard agentCard) { this.agentCard = agentCard; } + /** + * Configure a transport protocol using a builder for type-safe configuration. + *

+ * Multiple transports can be configured to support fallback scenarios. The actual transport + * used is negotiated based on the agent's capabilities and the {@link ClientConfig}. + *

+ * Example: + *

{@code
+     * builder.withTransport(JSONRPCTransport.class,
+     *     new JSONRPCTransportConfigBuilder()
+     *         .httpClient(customHttpClient)
+     *         .addInterceptor(loggingInterceptor));
+     * }
+ * + * @param clazz the transport class to configure + * @param configBuilder the transport configuration builder + * @param the transport type + * @return this builder for method chaining + */ public ClientBuilder withTransport(Class clazz, ClientTransportConfigBuilder, ?> configBuilder) { return withTransport(clazz, configBuilder.build()); } + /** + * Configure a transport protocol with a pre-built configuration. + *

+ * Multiple transports can be configured to support fallback scenarios. The actual transport + * used is negotiated based on the agent's capabilities and the {@link ClientConfig}. + *

+ * Example: + *

{@code
+     * JSONRPCTransportConfig config = new JSONRPCTransportConfig(myHttpClient);
+     * builder.withTransport(JSONRPCTransport.class, config);
+     * }
+ * + * @param clazz the transport class to configure + * @param config the transport configuration + * @param the transport type + * @return this builder for method chaining + */ public ClientBuilder withTransport(Class clazz, ClientTransportConfig config) { clientTransports.put(clazz, config); return this; } + /** + * Add a single event consumer to process events from the agent. + *

+ * Consumers receive {@link ClientEvent} instances (MessageEvent, TaskEvent, TaskUpdateEvent) + * along with the agent's {@link AgentCard}. Multiple consumers can be registered and will + * be invoked in registration order. + *

+ * Example: + *

{@code
+     * builder.addConsumer((event, card) -> {
+     *     if (event instanceof MessageEvent me) {
+     *         String text = me.getMessage().parts().stream()
+     *             .filter(p -> p instanceof TextPart)
+     *             .map(p -> ((TextPart) p).text())
+     *             .collect(Collectors.joining());
+     *         System.out.println("Agent: " + text);
+     *     }
+     * });
+     * }
+ * + * @param consumer the event consumer to add + * @return this builder for method chaining + * @see ClientEvent + * @see MessageEvent + * @see TaskEvent + * @see TaskUpdateEvent + */ public ClientBuilder addConsumer(BiConsumer consumer) { this.consumers.add(consumer); return this; } + /** + * Add multiple event consumers to process events from the agent. + *

+ * Consumers receive {@link ClientEvent} instances and are invoked in the order they + * appear in the list. + * + * @param consumers the list of event consumers to add + * @return this builder for method chaining + * @see #addConsumer(BiConsumer) + */ public ClientBuilder addConsumers(List> consumers) { this.consumers.addAll(consumers); return this; } + /** + * Configure an error handler for streaming scenarios. + *

+ * This handler is invoked when errors occur during streaming event consumption. It's only + * applicable when the client and agent both support streaming. For non-streaming scenarios, + * errors are thrown directly as {@link A2AClientException}. + *

+ * Example: + *

{@code
+     * builder.streamingErrorHandler(throwable -> {
+     *     if (throwable instanceof A2AClientException e) {
+     *         log.error("A2A error: " + e.getMessage(), e);
+     *     } else {
+     *         log.error("Unexpected error: " + throwable.getMessage(), throwable);
+     *     }
+     * });
+     * }
+ * + * @param streamErrorHandler the error handler for streaming errors + * @return this builder for method chaining + */ public ClientBuilder streamingErrorHandler(Consumer streamErrorHandler) { this.streamErrorHandler = streamErrorHandler; return this; } + /** + * Configure client behavior such as streaming mode, polling, and transport preference. + *

+ * The configuration controls how the client communicates with the agent: + *

    + *
  • Streaming vs blocking mode
  • + *
  • Polling for updates vs receiving events
  • + *
  • Client vs server transport preference
  • + *
  • Output modes, history length, and metadata
  • + *
+ *

+ * Example: + *

{@code
+     * ClientConfig config = new ClientConfig.Builder()
+     *     .setStreaming(true)  // Enable streaming if server supports it
+     *     .setUseClientPreference(true)  // Use client's transport order
+     *     .setHistoryLength(10)  // Request last 10 messages of context
+     *     .build();
+     * builder.clientConfig(config);
+     * }
+ * + * @param clientConfig the client configuration + * @return this builder for method chaining + * @see ClientConfig + */ public ClientBuilder clientConfig(@NonNull ClientConfig clientConfig) { this.clientConfig = clientConfig; return this; } + /** + * Build the configured {@link Client} instance. + *

+ * This method performs transport negotiation between the client's configured transports + * and the agent's {@link AgentCard#supportedInterfaces()}. The selection algorithm: + *

    + *
  1. If {@link ClientConfig#isUseClientPreference()} is {@code true}, iterate through + * client transports in registration order and select the first one the server supports
  2. + *
  3. Otherwise, iterate through server interfaces in preference order (first entry + * in {@link AgentCard#supportedInterfaces()}) and select the first one the client supports
  4. + *
+ *

+ * Important: At least one transport must be configured via {@link #withTransport}, + * otherwise this method throws {@link A2AClientException}. + * + * @return the configured client instance + * @throws A2AClientException if no compatible transport is found or if transport configuration is missing + */ public Client build() throws A2AClientException { if (this.clientConfig == null) { this.clientConfig = new ClientConfig.Builder().build(); diff --git a/client/base/src/main/java/io/a2a/client/ClientEvent.java b/client/base/src/main/java/io/a2a/client/ClientEvent.java index dcaae9495..2275a7f2e 100644 --- a/client/base/src/main/java/io/a2a/client/ClientEvent.java +++ b/client/base/src/main/java/io/a2a/client/ClientEvent.java @@ -1,4 +1,75 @@ package io.a2a.client; +/** + * A sealed interface representing events received by an A2A client from an agent. + *

+ * ClientEvent is the base type for all events that clients receive during agent interactions. + * The sealed interface ensures type safety by restricting implementations to three known subtypes: + *

    + *
  • {@link MessageEvent} - contains complete messages with content parts
  • + *
  • {@link TaskEvent} - contains complete task state, typically final states
  • + *
  • {@link TaskUpdateEvent} - contains incremental task updates (status or artifact changes)
  • + *
+ *

+ * Event flow: When a client sends a message to an agent, the agent's response is delivered + * as a stream of ClientEvent instances to registered event consumers. The event type and sequence + * depend on the agent's capabilities and the task's lifecycle: + *

+ * Simple blocking response: + *

+ * User → Agent
+ * Agent → MessageEvent (contains agent's text response)
+ * 
+ *

+ * Streaming task execution: + *

+ * User → Agent
+ * Agent → TaskEvent (SUBMITTED)
+ * Agent → TaskUpdateEvent (WORKING)
+ * Agent → TaskUpdateEvent (artifact update with partial results)
+ * Agent → TaskUpdateEvent (artifact update with more results)
+ * Agent → TaskUpdateEvent (COMPLETED)
+ * 
+ *

+ * Typical usage pattern: + *

{@code
+ * client.addConsumer((event, agentCard) -> {
+ *     switch (event) {
+ *         case MessageEvent me -> {
+ *             // Simple message response
+ *             System.out.println("Response: " + me.getMessage().parts());
+ *         }
+ *         case TaskEvent te -> {
+ *             // Complete task state (usually final)
+ *             Task task = te.getTask();
+ *             System.out.println("Task " + task.id() + ": " + task.status().state());
+ *         }
+ *         case TaskUpdateEvent tue -> {
+ *             // Incremental update
+ *             Task currentTask = tue.getTask();
+ *             UpdateEvent update = tue.getUpdateEvent();
+ *
+ *             if (update instanceof TaskStatusUpdateEvent statusUpdate) {
+ *                 System.out.println("Status changed to: " +
+ *                     currentTask.status().state());
+ *             } else if (update instanceof TaskArtifactUpdateEvent artifactUpdate) {
+ *                 System.out.println("New content: " +
+ *                     artifactUpdate.artifact().parts());
+ *             }
+ *         }
+ *     }
+ * });
+ * }
+ *

+ * Legacy vs current protocol: In older versions of the A2A protocol, agents returned + * {@link MessageEvent} for simple responses and {@link TaskEvent} for task-based responses. + * The current protocol (v1.0+) uses {@link TaskUpdateEvent} for streaming updates during + * task execution, providing finer-grained visibility into agent progress. + * + * @see MessageEvent + * @see TaskEvent + * @see TaskUpdateEvent + * @see ClientBuilder#addConsumer(java.util.function.BiConsumer) + */ public sealed interface ClientEvent permits MessageEvent, TaskEvent, TaskUpdateEvent { } diff --git a/client/base/src/main/java/io/a2a/client/MessageEvent.java b/client/base/src/main/java/io/a2a/client/MessageEvent.java index 9a0370995..1db7b39fd 100644 --- a/client/base/src/main/java/io/a2a/client/MessageEvent.java +++ b/client/base/src/main/java/io/a2a/client/MessageEvent.java @@ -3,21 +3,74 @@ import io.a2a.spec.Message; /** - * A message event received by a client. + * A client event containing an agent's message response. + *

+ * MessageEvent represents a complete message from the agent, typically containing text, images, + * or other content parts. This event type is used in two scenarios: + *

    + *
  1. Simple blocking responses: When the agent completes a request immediately and + * returns a message without task tracking
  2. + *
  3. Legacy protocol support: Older agents may return messages instead of task updates
  4. + *
+ *

+ * Example usage: + *

{@code
+ * client.addConsumer((event, agentCard) -> {
+ *     if (event instanceof MessageEvent me) {
+ *         Message msg = me.getMessage();
+ *         
+ *         // Extract text content
+ *         String text = msg.parts().stream()
+ *             .filter(p -> p instanceof TextPart)
+ *             .map(p -> ((TextPart) p).text())
+ *             .collect(Collectors.joining());
+ *         
+ *         System.out.println("Agent response: " + text);
+ *         
+ *         // Check for images
+ *         msg.parts().stream()
+ *             .filter(p -> p instanceof ImagePart)
+ *             .forEach(p -> System.out.println("Image: " + ((ImagePart) p).url()));
+ *     }
+ * });
+ * }
+ *

+ * Message structure: The contained {@link Message} includes: + *

    + *
  • role: AGENT (indicating it's from the agent)
  • + *
  • parts: List of content parts (text, images, files, etc.)
  • + *
  • contextId: Optional session identifier
  • + *
  • taskId: Optional associated task ID
  • + *
  • metadata: Optional custom metadata from the agent
  • + *
+ *

+ * Streaming vs blocking: In streaming mode with task tracking, you're more likely to + * receive {@link TaskUpdateEvent} instances instead of MessageEvent. MessageEvent is primarily + * used for simple, synchronous request-response interactions. + * + * @see ClientEvent + * @see Message + * @see io.a2a.spec.Part + * @see io.a2a.spec.TextPart */ public final class MessageEvent implements ClientEvent { private final Message message; /** - * A message event. + * Create a message event. * - * @param message the message received + * @param message the message received from the agent (required) */ public MessageEvent(Message message) { this.message = message; } + /** + * Get the message contained in this event. + * + * @return the agent's message + */ public Message getMessage() { return message; } diff --git a/client/base/src/main/java/io/a2a/client/TaskEvent.java b/client/base/src/main/java/io/a2a/client/TaskEvent.java index a18392841..4da4ef04f 100644 --- a/client/base/src/main/java/io/a2a/client/TaskEvent.java +++ b/client/base/src/main/java/io/a2a/client/TaskEvent.java @@ -5,22 +5,96 @@ import io.a2a.spec.Task; /** - * A task event received by a client. + * A client event containing the complete state of a task. + *

+ * TaskEvent represents a snapshot of a task's full state at a point in time. This event type + * is typically received in two scenarios: + *

    + *
  1. Final task state: When a task reaches a terminal state (COMPLETED, FAILED, CANCELED), + * the agent may send a TaskEvent with the complete final state
  2. + *
  3. Non-streaming mode: When streaming is disabled, the client receives a single + * TaskEvent containing the final result after the agent completes processing
  4. + *
+ *

+ * Contrast with TaskUpdateEvent: While {@link TaskUpdateEvent} provides incremental + * updates during task execution (status changes, new artifacts), TaskEvent provides the + * complete task state in a single event. + *

+ * Example usage: + *

{@code
+ * client.addConsumer((event, agentCard) -> {
+ *     if (event instanceof TaskEvent te) {
+ *         Task task = te.getTask();
+ *         
+ *         // Check task state
+ *         TaskState state = task.status().state();
+ *         switch (state) {
+ *             case COMPLETED -> {
+ *                 // Task finished successfully
+ *                 if (task.artifact() != null) {
+ *                     System.out.println("Result: " + task.artifact().parts());
+ *                 }
+ *             }
+ *             case FAILED -> {
+ *                 // Task failed
+ *                 String error = task.status().message();
+ *                 System.err.println("Task failed: " + error);
+ *             }
+ *             case CANCELED -> {
+ *                 System.out.println("Task was canceled");
+ *             }
+ *             default -> {
+ *                 System.out.println("Task in state: " + state);
+ *             }
+ *         }
+ *     }
+ * });
+ * }
+ *

+ * Task contents: The contained {@link Task} includes: + *

    + *
  • id: Unique task identifier
  • + *
  • status: Current state (SUBMITTED, WORKING, COMPLETED, FAILED, CANCELED, etc.)
  • + *
  • artifact: Task results (if available)
  • + *
  • contextId: Associated session/context identifier
  • + *
  • metadata: Custom task metadata
  • + *
  • history: Optional state transition history
  • + *
+ *

+ * Terminal states: When a task reaches a final state, no further updates will be + * received for that task: + *

    + *
  • COMPLETED - task finished successfully
  • + *
  • FAILED - task encountered an error
  • + *
  • CANCELED - task was canceled by user or system
  • + *
  • REJECTED - task was rejected (e.g., authorization failure)
  • + *
+ * + * @see ClientEvent + * @see Task + * @see TaskUpdateEvent + * @see io.a2a.spec.TaskState + * @see io.a2a.spec.TaskStatus */ public final class TaskEvent implements ClientEvent { private final Task task; /** - * A client task event. + * Create a task event. * - * @param task the task received + * @param task the task state received from the agent (required) */ public TaskEvent(Task task) { checkNotNullParam("task", task); this.task = task; } + /** + * Get the task contained in this event. + * + * @return the complete task state + */ public Task getTask() { return task; } diff --git a/client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java b/client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java index c45650822..e9efe2404 100644 --- a/client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java +++ b/client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java @@ -6,7 +6,99 @@ import io.a2a.spec.UpdateEvent; /** - * A task update event received by a client. + * A client event containing an incremental update to a task. + *

+ * TaskUpdateEvent represents a change to a task's state during execution. It provides both + * the current complete task state and the specific update that triggered this event. This + * event type is the primary mechanism for tracking task progress in streaming scenarios. + *

+ * Two types of updates: + *

    + *
  • {@link io.a2a.spec.TaskStatusUpdateEvent} - task state changed (e.g., SUBMITTED → WORKING → COMPLETED)
  • + *
  • {@link io.a2a.spec.TaskArtifactUpdateEvent} - new content/results available
  • + *
+ *

+ * Streaming task lifecycle example: + *

{@code
+ * client.sendMessage(A2A.toUserMessage("Summarize this document"));
+ *
+ * // Client receives sequence of TaskUpdateEvents:
+ * 1. TaskUpdateEvent(task=Task[status=SUBMITTED], updateEvent=TaskStatusUpdateEvent)
+ * 2. TaskUpdateEvent(task=Task[status=WORKING], updateEvent=TaskStatusUpdateEvent)
+ * 3. TaskUpdateEvent(task=Task[status=WORKING, artifact=[partial]], updateEvent=TaskArtifactUpdateEvent)
+ * 4. TaskUpdateEvent(task=Task[status=WORKING, artifact=[more content]], updateEvent=TaskArtifactUpdateEvent)
+ * 5. TaskUpdateEvent(task=Task[status=COMPLETED, artifact=[final]], updateEvent=TaskStatusUpdateEvent)
+ * }
+ *

+ * Example usage - tracking progress: + *

{@code
+ * client.addConsumer((event, agentCard) -> {
+ *     if (event instanceof TaskUpdateEvent tue) {
+ *         Task currentTask = tue.getTask();
+ *         UpdateEvent update = tue.getUpdateEvent();
+ *         
+ *         // Handle status changes
+ *         if (update instanceof TaskStatusUpdateEvent statusUpdate) {
+ *             TaskState newState = currentTask.status().state();
+ *             System.out.println("Task " + currentTask.id() + " → " + newState);
+ *             
+ *             if (newState == TaskState.COMPLETED) {
+ *                 System.out.println("Final result: " +
+ *                     currentTask.artifact().parts());
+ *             } else if (newState == TaskState.FAILED) {
+ *                 System.err.println("Error: " +
+ *                     currentTask.status().message());
+ *             }
+ *         }
+ *         
+ *         // Handle new content
+ *         if (update instanceof TaskArtifactUpdateEvent artifactUpdate) {
+ *             Artifact newContent = artifactUpdate.artifact();
+ *             System.out.println("New content received: " + newContent.parts());
+ *             
+ *             // For streaming text generation
+ *             newContent.parts().stream()
+ *                 .filter(p -> p instanceof TextPart)
+ *                 .map(p -> ((TextPart) p).text())
+ *                 .forEach(System.out::print);  // Print incrementally
+ *         }
+ *     }
+ * });
+ * }
+ *

+ * Reconstructing complete state: The {@link #getTask()} method returns the task with + * all updates applied up to this point. The client automatically maintains the complete + * task state by merging updates, so consumers don't need to manually track changes: + *

{@code
+ * // Each TaskUpdateEvent contains the fully updated task
+ * TaskUpdateEvent event1 // task has status=WORKING, artifact=null
+ * TaskUpdateEvent event2 // task has status=WORKING, artifact=[chunk1]
+ * TaskUpdateEvent event3 // task has status=WORKING, artifact=[chunk1, chunk2]
+ * TaskUpdateEvent event4 // task has status=COMPLETED, artifact=[chunk1, chunk2, final]
+ * }
+ *

+ * Artifact updates: When {@link io.a2a.spec.TaskArtifactUpdateEvent} is received, + * the artifact may be: + *

    + *
  • Incremental: New parts appended to existing artifact (common for streaming text)
  • + *
  • Replacement: Entire artifact replaced (less common)
  • + *
+ * The {@link #getTask()} always reflects the current complete artifact state. + *

+ * Status transitions: Common task state transitions: + *

+ * SUBMITTED → WORKING → COMPLETED
+ * SUBMITTED → WORKING → FAILED
+ * SUBMITTED → WORKING → CANCELED
+ * SUBMITTED → AUTH_REQUIRED → (waiting for auth) → WORKING → COMPLETED
+ * 
+ * + * @see ClientEvent + * @see Task + * @see io.a2a.spec.UpdateEvent + * @see io.a2a.spec.TaskStatusUpdateEvent + * @see io.a2a.spec.TaskArtifactUpdateEvent + * @see io.a2a.spec.TaskState */ public final class TaskUpdateEvent implements ClientEvent { @@ -14,10 +106,15 @@ public final class TaskUpdateEvent implements ClientEvent { private final UpdateEvent updateEvent; /** - * A task update event. + * Create a task update event. + *

+ * This constructor is typically called internally by the client framework when processing + * update events from the agent. The {@code task} parameter contains the complete current + * state with all updates applied, while {@code updateEvent} contains the specific change + * that triggered this event. * - * @param task the current task - * @param updateEvent the update event received for the current task + * @param task the current complete task state with all updates applied (required) + * @param updateEvent the specific update that triggered this event (required) */ public TaskUpdateEvent(Task task, UpdateEvent updateEvent) { checkNotNullParam("task", task); @@ -26,10 +123,30 @@ public TaskUpdateEvent(Task task, UpdateEvent updateEvent) { this.updateEvent = updateEvent; } + /** + * Get the current complete task state. + *

+ * The returned task reflects all updates received up to this point, including the + * update contained in this event. Consumers can use this method to access the + * complete current state without manually tracking changes. + * + * @return the task with all updates applied + */ public Task getTask() { return task; } + /** + * Get the specific update that triggered this event. + *

+ * This will be either: + *

    + *
  • {@link io.a2a.spec.TaskStatusUpdateEvent} - indicates a state transition
  • + *
  • {@link io.a2a.spec.TaskArtifactUpdateEvent} - indicates new content available
  • + *
+ * + * @return the update event + */ public UpdateEvent getUpdateEvent() { return updateEvent; } diff --git a/client/base/src/main/java/io/a2a/client/config/ClientConfig.java b/client/base/src/main/java/io/a2a/client/config/ClientConfig.java index 823548c23..d9ffd7e6e 100644 --- a/client/base/src/main/java/io/a2a/client/config/ClientConfig.java +++ b/client/base/src/main/java/io/a2a/client/config/ClientConfig.java @@ -9,7 +9,132 @@ import org.jspecify.annotations.Nullable; /** - * Configuration for the A2A client factory. + * Configuration for controlling A2A client behavior and communication preferences. + *

+ * ClientConfig defines how the client communicates with agents, including streaming mode, + * transport preference, output modes, and request metadata. The configuration is immutable + * and constructed using the {@link Builder} pattern. + *

+ * Key configuration options: + *

    + *
  • Streaming: Enable/disable real-time event streaming (default: true)
  • + *
  • Polling: Use polling instead of blocking for updates (default: false)
  • + *
  • Transport preference: Client vs server transport priority (default: server preference)
  • + *
  • Output modes: Acceptable content types (text, audio, image, etc.)
  • + *
  • History length: Number of previous messages to include as context
  • + *
  • Push notifications: Default webhook configuration for task updates
  • + *
  • Metadata: Custom metadata attached to all requests
  • + *
+ *

+ * Streaming mode: Controls whether the client uses streaming or blocking communication. + * Streaming mode requires both the client configuration AND the agent's capabilities to support it: + *

{@code
+ * // Enable streaming (if agent also supports it)
+ * ClientConfig config = new ClientConfig.Builder()
+ *     .setStreaming(true)
+ *     .build();
+ *
+ * // Actual mode = config.streaming && agentCard.capabilities().streaming()
+ * }
+ * When streaming is enabled and supported, the client receives events asynchronously as the + * agent processes the request. When disabled, the client blocks until the task completes. + *

+ * Transport preference: Controls which transport protocol is selected when multiple + * options are available: + *

{@code
+ * // Default: Use server's preferred transport (first in AgentCard.supportedInterfaces)
+ * ClientConfig serverPref = new ClientConfig.Builder()
+ *     .setUseClientPreference(false)
+ *     .build();
+ *
+ * // Use client's preferred transport (order of withTransport() calls)
+ * ClientConfig clientPref = new ClientConfig.Builder()
+ *     .setUseClientPreference(true)
+ *     .build();
+ *
+ * Client client = Client.builder(card)
+ *     .withTransport(GrpcTransport.class, grpcConfig)      // Client preference 1
+ *     .withTransport(JSONRPCTransport.class, jsonConfig)   // Client preference 2
+ *     .clientConfig(clientPref)
+ *     .build();
+ * // With useClientPreference=true, tries gRPC first, then JSON-RPC
+ * // With useClientPreference=false, uses server's order from AgentCard
+ * }
+ *

+ * Output modes: Specify which content types the client can handle: + *

{@code
+ * ClientConfig config = new ClientConfig.Builder()
+ *     .setAcceptedOutputModes(List.of("text", "image", "audio"))
+ *     .build();
+ * // Agent will only return text, image, or audio content
+ * }
+ *

+ * Conversation history: Request previous messages as context: + *

{@code
+ * ClientConfig config = new ClientConfig.Builder()
+ *     .setHistoryLength(10)  // Include last 10 messages
+ *     .build();
+ * }
+ * This is useful for maintaining conversation context across multiple requests in the same session. + *

+ * Push notifications: Configure default webhook for all task updates: + *

{@code
+ * PushNotificationConfig pushConfig = new PushNotificationConfig(
+ *     "https://my-app.com/webhooks/tasks",
+ *     Map.of("Authorization", "Bearer my-token")
+ * );
+ * ClientConfig config = new ClientConfig.Builder()
+ *     .setPushNotificationConfig(pushConfig)
+ *     .build();
+ * // All sendMessage() calls will use this webhook config
+ * }
+ *

+ * Custom metadata: Attach metadata to all requests: + *

{@code
+ * Map metadata = Map.of(
+ *     "userId", "user-123",
+ *     "sessionId", "session-456",
+ *     "clientVersion", "1.0.0"
+ * );
+ * ClientConfig config = new ClientConfig.Builder()
+ *     .setMetadata(metadata)
+ *     .build();
+ * // Metadata is included in every message sent
+ * }
+ *

+ * Complete example: + *

{@code
+ * ClientConfig config = new ClientConfig.Builder()
+ *     .setStreaming(true)                         // Enable streaming
+ *     .setUseClientPreference(true)               // Use client transport order
+ *     .setAcceptedOutputModes(List.of("text"))    // Text responses only
+ *     .setHistoryLength(5)                        // Last 5 messages as context
+ *     .setMetadata(Map.of("userId", "user-123"))  // Custom metadata
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .clientConfig(config)
+ *     .withTransport(JSONRPCTransport.class, transportConfig)
+ *     .build();
+ * }
+ *

+ * Default values: + *

    + *
  • streaming: {@code true}
  • + *
  • polling: {@code false}
  • + *
  • useClientPreference: {@code false} (server preference)
  • + *
  • acceptedOutputModes: empty list (accept all)
  • + *
  • historyLength: {@code null} (no history)
  • + *
  • pushNotificationConfig: {@code null} (no push notifications)
  • + *
  • metadata: empty map
  • + *
+ *

+ * Thread safety: ClientConfig is immutable and thread-safe. Multiple clients can + * share the same configuration instance. + * + * @see io.a2a.client.Client + * @see io.a2a.client.ClientBuilder + * @see PushNotificationConfig */ public class ClientConfig { @@ -31,38 +156,123 @@ private ClientConfig(Builder builder) { this.metadata = builder.metadata; } + /** + * Check if streaming mode is enabled. + *

+ * Note: Actual streaming requires both this configuration AND agent support + * ({@link io.a2a.spec.AgentCapabilities#streaming()}). + * + * @return {@code true} if streaming is enabled (default) + */ public boolean isStreaming() { return streaming; } + /** + * Check if polling mode is enabled for task updates. + *

+ * When polling is enabled, the client can poll for task status updates instead of + * blocking or streaming. This is useful for asynchronous workflows where the client + * doesn't need immediate results. + * + * @return {@code true} if polling is enabled, {@code false} by default + */ public boolean isPolling() { return polling; } + /** + * Check if client transport preference is enabled. + *

+ * When {@code true}, the client iterates through its configured transports (in the order + * they were added via {@link io.a2a.client.ClientBuilder#withTransport}) and selects the first one + * the agent supports. + *

+ * When {@code false} (default), the agent's preferred transport is used (first entry + * in {@link io.a2a.spec.AgentCard#supportedInterfaces()}). + * + * @return {@code true} if using client preference, {@code false} for server preference (default) + */ public boolean isUseClientPreference() { return useClientPreference; } + /** + * Get the list of accepted output modes. + *

+ * This list specifies which content types the client can handle (e.g., "text", "audio", + * "image", "video"). An empty list means all modes are accepted. + *

+ * The agent will only return content in the specified modes. For example, if only "text" + * is specified, the agent won't return images or audio. + * + * @return the list of accepted output modes (never null, but may be empty) + */ public List getAcceptedOutputModes() { return acceptedOutputModes; } + /** + * Get the default push notification configuration. + *

+ * If set, this webhook configuration will be used for all sendMessage + * calls unless overridden with a different configuration. + * + * @return the push notification config, or {@code null} if not configured + * @see io.a2a.client.Client#sendMessage(io.a2a.spec.Message, io.a2a.spec.PushNotificationConfig, java.util.Map, io.a2a.client.transport.spi.interceptors.ClientCallContext) + */ public @Nullable PushNotificationConfig getPushNotificationConfig() { return pushNotificationConfig; } + /** + * Get the conversation history length. + *

+ * This value specifies how many previous messages should be included as context + * when sending a new message. For example, a value of 10 means the agent receives + * the last 10 messages in the conversation for context. + * + * @return the history length, or {@code null} if not configured (no history) + */ public @Nullable Integer getHistoryLength() { return historyLength; } + /** + * Get the custom metadata attached to all requests. + *

+ * This metadata is included in every message sent by the client. It can contain + * user IDs, session identifiers, client version, or any other custom data. + * + * @return the metadata map (never null, but may be empty) + */ public Map getMetadata() { return metadata; } + /** + * Create a new builder for constructing ClientConfig instances. + * + * @return a new builder + */ public static Builder builder() { return new Builder(); } + /** + * Builder for creating {@link ClientConfig} instances. + *

+ * All configuration options have sensible defaults and are optional. Use this builder + * to override specific settings as needed. + *

+ * Example: + *

{@code
+     * ClientConfig config = new ClientConfig.Builder()
+     *     .setStreaming(true)
+     *     .setHistoryLength(10)
+     *     .build();
+     * }
+ */ public static class Builder { private @Nullable Boolean streaming; private @Nullable Boolean polling; @@ -72,41 +282,127 @@ public static class Builder { private @Nullable Integer historyLength; private Map metadata = new HashMap<>(); + /** + * Enable or disable streaming mode. + *

+ * When enabled, the client will use streaming communication if the agent also + * supports it. When disabled, the client uses blocking request-response mode. + * + * @param streaming {@code true} to enable streaming (default), {@code false} to disable + * @return this builder for method chaining + */ public Builder setStreaming(@Nullable Boolean streaming) { this.streaming = streaming; return this; } + /** + * Enable or disable polling mode for task updates. + *

+ * When enabled, the client can poll for task status instead of blocking or streaming. + * Useful for asynchronous workflows. + * + * @param polling {@code true} to enable polling, {@code false} otherwise (default) + * @return this builder for method chaining + */ public Builder setPolling(@Nullable Boolean polling) { this.polling = polling; return this; } + /** + * Set whether to use client or server transport preference. + *

+ * When {@code true}, the client's transport order (from {@link io.a2a.client.ClientBuilder#withTransport} + * calls) takes priority. When {@code false} (default), the server's preferred transport + * (first in {@link io.a2a.spec.AgentCard#supportedInterfaces()}) is used. + * + * @param useClientPreference {@code true} for client preference, {@code false} for server preference (default) + * @return this builder for method chaining + */ public Builder setUseClientPreference(@Nullable Boolean useClientPreference) { this.useClientPreference = useClientPreference; return this; } + /** + * Set the accepted output modes. + *

+ * Specify which content types the client can handle (e.g., "text", "audio", "image"). + * An empty list (default) means all modes are accepted. + *

+ * The provided list is copied, so subsequent modifications won't affect this configuration. + * + * @param acceptedOutputModes the list of accepted output modes + * @return this builder for method chaining + */ public Builder setAcceptedOutputModes(List acceptedOutputModes) { this.acceptedOutputModes = new ArrayList<>(acceptedOutputModes); return this; } + /** + * Set the default push notification configuration. + *

+ * This webhook configuration will be used for all sendMessage calls + * unless overridden. The agent will POST task update events to the specified URL. + * + * @param pushNotificationConfig the push notification configuration + * @return this builder for method chaining + * @see io.a2a.client.Client#sendMessage(io.a2a.spec.Message, io.a2a.spec.PushNotificationConfig, java.util.Map, io.a2a.client.transport.spi.interceptors.ClientCallContext) + */ public Builder setPushNotificationConfig(PushNotificationConfig pushNotificationConfig) { this.pushNotificationConfig = pushNotificationConfig; return this; } + /** + * Set the conversation history length. + *

+ * Specify how many previous messages should be included as context when sending + * a new message. For example, 10 means the last 10 messages are sent to the agent + * for context. + * + * @param historyLength the number of previous messages to include (must be positive) + * @return this builder for method chaining + */ public Builder setHistoryLength(Integer historyLength) { this.historyLength = historyLength; return this; } + /** + * Set custom metadata to be included in all requests. + *

+ * This metadata is attached to every message sent by the client. Useful for + * tracking user IDs, session identifiers, client version, etc. + *

+ * The provided map is copied, so subsequent modifications won't affect this configuration. + * + * @param metadata the custom metadata map + * @return this builder for method chaining + */ public Builder setMetadata(Map metadata) { this.metadata = metadata; return this; } + /** + * Build the ClientConfig with the configured settings. + *

+ * Any unset options will use their default values: + *

    + *
  • streaming: {@code true}
  • + *
  • polling: {@code false}
  • + *
  • useClientPreference: {@code false}
  • + *
  • acceptedOutputModes: empty list
  • + *
  • pushNotificationConfig: {@code null}
  • + *
  • historyLength: {@code null}
  • + *
  • metadata: empty map
  • + *
+ * + * @return the configured ClientConfig instance + */ public ClientConfig build() { return new ClientConfig(this); } diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java index a1bc3373c..605f7abb0 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java @@ -6,15 +6,106 @@ import io.a2a.util.Assert; import io.grpc.Channel; +/** + * Configuration for the gRPC transport protocol. + *

+ * This configuration class allows customization of the gRPC channel factory used for + * communication with A2A agents. Unlike other transports, gRPC requires a channel factory + * to be explicitly provided - there is no default implementation. + *

+ * Channel Factory Requirement: You must provide a {@code Function} + * that creates gRPC channels from agent URLs. This gives you full control over channel + * configuration including connection pooling, TLS, load balancing, and interceptors. + *

+ * Basic usage with ManagedChannel: + *

{@code
+ * // Simple insecure channel for development
+ * Function channelFactory = url -> {
+ *     String target = extractTarget(url); // e.g., "localhost:9999"
+ *     return ManagedChannelBuilder.forTarget(target)
+ *         .usePlaintext()
+ *         .build();
+ * };
+ *
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(GrpcTransport.class, config)
+ *     .build();
+ * }
+ *

+ * Production configuration with TLS and timeouts: + *

{@code
+ * Function channelFactory = url -> {
+ *     String target = extractTarget(url);
+ *     return ManagedChannelBuilder.forTarget(target)
+ *         .useTransportSecurity()
+ *         .keepAliveTime(30, TimeUnit.SECONDS)
+ *         .idleTimeout(5, TimeUnit.MINUTES)
+ *         .maxInboundMessageSize(10 * 1024 * 1024) // 10MB
+ *         .build();
+ * };
+ *
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .build();
+ * }
+ *

+ * With load balancing and connection pooling: + *

{@code
+ * Function channelFactory = url -> {
+ *     String target = extractTarget(url);
+ *     return ManagedChannelBuilder.forTarget(target)
+ *         .defaultLoadBalancingPolicy("round_robin")
+ *         .maxInboundMessageSize(50 * 1024 * 1024)
+ *         .keepAliveTime(30, TimeUnit.SECONDS)
+ *         .keepAliveTimeout(10, TimeUnit.SECONDS)
+ *         .build();
+ * };
+ * }
+ *

+ * With interceptors: + *

{@code
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .addInterceptor(new LoggingInterceptor())
+ *     .addInterceptor(new AuthInterceptor(apiKey))
+ *     .build();
+ * }
+ *

+ * Channel Lifecycle: The channel factory creates channels on-demand when the client + * connects to an agent. You are responsible for shutting down channels when the client is + * closed. Consider using {@code ManagedChannel.shutdown()} in a cleanup hook. + * + * @see GrpcTransportConfigBuilder + * @see GrpcTransport + * @see io.a2a.client.transport.spi.ClientTransportConfig + * @see io.grpc.ManagedChannelBuilder + */ public class GrpcTransportConfig extends ClientTransportConfig { private final Function channelFactory; + /** + * Create a gRPC transport configuration with a custom channel factory. + *

+ * Consider using {@link GrpcTransportConfigBuilder} instead for a more fluent API. + * + * @param channelFactory function to create gRPC channels from agent URLs (must not be null) + * @throws IllegalArgumentException if channelFactory is null + */ public GrpcTransportConfig(Function channelFactory) { Assert.checkNotNullParam("channelFactory", channelFactory); this.channelFactory = channelFactory; } + /** + * Get the configured channel factory. + * + * @return the channel factory function + */ public Function getChannelFactory() { return this.channelFactory; } diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java index 138fadb1f..9ffcc1285 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java @@ -7,10 +7,179 @@ import io.grpc.Channel; import org.jspecify.annotations.Nullable; +/** + * Builder for creating {@link GrpcTransportConfig} instances. + *

+ * This builder provides a fluent API for configuring the gRPC transport protocol. + * Unlike other transports, gRPC requires a channel factory to be explicitly provided - + * the {@link #channelFactory(Function)} method must be called before {@link #build()}. + *

+ * The channel factory gives you complete control over gRPC channel configuration: + *

    + *
  • Connection management: Connection pooling, keep-alive settings
  • + *
  • Security: TLS configuration, client certificates
  • + *
  • Performance: Message size limits, compression, load balancing
  • + *
  • Timeouts: Deadline configuration, idle timeout
  • + *
  • Interceptors: Request/response transformation, authentication
  • + *
+ *

+ * Basic development setup (insecure): + *

{@code
+ * // Simple channel for local development
+ * Function channelFactory = url -> {
+ *     // Extract "localhost:9999" from "http://localhost:9999"
+ *     String target = url.replaceAll("^https?://", "");
+ *     return ManagedChannelBuilder.forTarget(target)
+ *         .usePlaintext()  // No TLS
+ *         .build();
+ * };
+ *
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(GrpcTransport.class, config)
+ *     .build();
+ * }
+ *

+ * Production setup with TLS and connection pooling: + *

{@code
+ * Function channelFactory = url -> {
+ *     String target = extractTarget(url);
+ *     return ManagedChannelBuilder.forTarget(target)
+ *         .useTransportSecurity()  // Enable TLS
+ *         .keepAliveTime(30, TimeUnit.SECONDS)
+ *         .keepAliveTimeout(10, TimeUnit.SECONDS)
+ *         .idleTimeout(5, TimeUnit.MINUTES)
+ *         .maxInboundMessageSize(10 * 1024 * 1024)  // 10MB messages
+ *         .build();
+ * };
+ *
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .build();
+ * }
+ *

+ * With custom SSL certificates: + *

{@code
+ * SslContext sslContext = GrpcSslContexts.forClient()
+ *     .trustManager(new File("ca.crt"))
+ *     .keyManager(new File("client.crt"), new File("client.key"))
+ *     .build();
+ *
+ * Function channelFactory = url -> {
+ *     String target = extractTarget(url);
+ *     return NettyChannelBuilder.forTarget(target)
+ *         .sslContext(sslContext)
+ *         .build();
+ * };
+ *
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .build();
+ * }
+ *

+ * With load balancing and health checks: + *

{@code
+ * Function channelFactory = url -> {
+ *     String target = extractTarget(url);
+ *     return ManagedChannelBuilder.forTarget(target)
+ *         .defaultLoadBalancingPolicy("round_robin")
+ *         .enableRetry()
+ *         .maxRetryAttempts(3)
+ *         .build();
+ * };
+ *
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .build();
+ * }
+ *

+ * With A2A interceptors: + *

{@code
+ * GrpcTransportConfig config = new GrpcTransportConfigBuilder()
+ *     .channelFactory(channelFactory)
+ *     .addInterceptor(new LoggingInterceptor())
+ *     .addInterceptor(new MetricsInterceptor())
+ *     .addInterceptor(new AuthenticationInterceptor(apiKey))
+ *     .build();
+ * }
+ *

+ * Direct usage in ClientBuilder: + *

{@code
+ * // Channel factory inline
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder()
+ *         .channelFactory(url -> ManagedChannelBuilder
+ *             .forTarget(extractTarget(url))
+ *             .usePlaintext()
+ *             .build())
+ *         .addInterceptor(loggingInterceptor))
+ *     .build();
+ * }
+ *

+ * Channel Lifecycle Management: + *

{@code
+ * // Store channels for cleanup
+ * Map channels = new ConcurrentHashMap<>();
+ *
+ * Function channelFactory = url -> {
+ *     return channels.computeIfAbsent(url, u -> {
+ *         String target = extractTarget(u);
+ *         return ManagedChannelBuilder.forTarget(target)
+ *             .usePlaintext()
+ *             .build();
+ *     });
+ * };
+ *
+ * // Cleanup when done
+ * Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ *     channels.values().forEach(ManagedChannel::shutdown);
+ * }));
+ * }
+ * + * @see GrpcTransportConfig + * @see GrpcTransport + * @see io.a2a.client.transport.spi.ClientTransportConfigBuilder + * @see io.grpc.ManagedChannelBuilder + * @see io.grpc.Channel + */ public class GrpcTransportConfigBuilder extends ClientTransportConfigBuilder { private @Nullable Function channelFactory; + /** + * Set the channel factory for creating gRPC channels. + *

+ * This method is required - {@link #build()} will throw {@link IllegalStateException} + * if the channel factory is not set. + *

+ * The factory function receives the agent's URL (e.g., "http://localhost:9999") and must + * return a configured {@link Channel}. You are responsible for: + *

    + *
  • Extracting the target address from the URL
  • + *
  • Configuring TLS and security settings
  • + *
  • Setting connection pool and timeout parameters
  • + *
  • Managing channel lifecycle and shutdown
  • + *
+ *

+ * Example: + *

{@code
+     * Function factory = url -> {
+     *     String target = url.replaceAll("^https?://", "");
+     *     return ManagedChannelBuilder.forTarget(target)
+     *         .usePlaintext()
+     *         .build();
+     * };
+     *
+     * builder.channelFactory(factory);
+     * }
+ * + * @param channelFactory function to create gRPC channels from agent URLs (must not be null) + * @return this builder for method chaining + * @throws IllegalArgumentException if channelFactory is null + */ public GrpcTransportConfigBuilder channelFactory(Function channelFactory) { Assert.checkNotNullParam("channelFactory", channelFactory); @@ -19,6 +188,15 @@ public GrpcTransportConfigBuilder channelFactory(Function chann return this; } + /** + * Build the gRPC transport configuration. + *

+ * The channel factory must have been set via {@link #channelFactory(Function)} before + * calling this method. Any configured interceptors are transferred to the configuration. + * + * @return the configured gRPC transport configuration + * @throws IllegalStateException if the channel factory was not set + */ @Override public GrpcTransportConfig build() { if (channelFactory == null) { diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java index 0705faf20..909ff079e 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java @@ -4,19 +4,81 @@ import io.a2a.client.transport.spi.ClientTransportConfig; import org.jspecify.annotations.Nullable; +/** + * Configuration for the JSON-RPC transport protocol. + *

+ * This configuration class allows customization of the HTTP client used for JSON-RPC + * communication with A2A agents. If no HTTP client is specified, the default JDK-based + * implementation is used. + *

+ * Basic usage: + *

{@code
+ * // Use default HTTP client
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(JSONRPCTransport.class, config)
+ *     .build();
+ * }
+ *

+ * Custom HTTP client: + *

{@code
+ * // Custom HTTP client with timeouts
+ * A2AHttpClient customClient = new CustomHttpClient()
+ *     .withConnectTimeout(Duration.ofSeconds(10))
+ *     .withReadTimeout(Duration.ofSeconds(30));
+ *
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .httpClient(customClient)
+ *     .build();
+ * }
+ *

+ * With interceptors: + *

{@code
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .httpClient(customClient)
+ *     .addInterceptor(new LoggingInterceptor())
+ *     .addInterceptor(new AuthInterceptor("Bearer token"))
+ *     .build();
+ * }
+ * + * @see JSONRPCTransportConfigBuilder + * @see JSONRPCTransport + * @see A2AHttpClient + * @see io.a2a.client.http.JdkA2AHttpClient + */ public class JSONRPCTransportConfig extends ClientTransportConfig { private final @Nullable A2AHttpClient httpClient; + /** + * Create a JSON-RPC transport configuration with the default HTTP client. + *

+ * The default JDK-based HTTP client will be used. Consider using + * {@link JSONRPCTransportConfigBuilder} instead for a more fluent API. + */ public JSONRPCTransportConfig() { this.httpClient = null; } + /** + * Create a JSON-RPC transport configuration with a custom HTTP client. + *

+ * Consider using {@link JSONRPCTransportConfigBuilder} instead for a more fluent API. + * + * @param httpClient the HTTP client to use for JSON-RPC requests + */ public JSONRPCTransportConfig(A2AHttpClient httpClient) { this.httpClient = httpClient; } + /** + * Get the configured HTTP client. + * + * @return the HTTP client, or {@code null} if using the default + */ public @Nullable A2AHttpClient getHttpClient() { return httpClient; } -} \ No newline at end of file +} diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java index 24ced1242..9cd5fae5a 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java @@ -5,15 +5,107 @@ import io.a2a.client.transport.spi.ClientTransportConfigBuilder; import org.jspecify.annotations.Nullable; +/** + * Builder for creating {@link JSONRPCTransportConfig} instances. + *

+ * This builder provides a fluent API for configuring the JSON-RPC transport protocol. + * All configuration options are optional - if not specified, sensible defaults are used: + *

    + *
  • HTTP client: {@link JdkA2AHttpClient} (JDK's built-in HTTP client)
  • + *
  • Interceptors: None
  • + *
+ *

+ * Basic usage: + *

{@code
+ * // Minimal configuration (uses all defaults)
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(JSONRPCTransport.class, config)
+ *     .build();
+ * }
+ *

+ * Custom HTTP client: + *

{@code
+ * // Configure custom HTTP client for connection pooling, timeouts, etc.
+ * A2AHttpClient httpClient = new ApacheHttpClient()
+ *     .withConnectionTimeout(Duration.ofSeconds(10))
+ *     .withMaxConnections(50);
+ *
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .httpClient(httpClient)
+ *     .build();
+ * }
+ *

+ * With interceptors: + *

{@code
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .addInterceptor(new LoggingInterceptor())
+ *     .addInterceptor(new MetricsInterceptor())
+ *     .addInterceptor(new RetryInterceptor(3))
+ *     .build();
+ * }
+ *

+ * Direct usage in ClientBuilder: + *

{@code
+ * // Can pass builder directly to withTransport()
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()
+ *         .httpClient(customClient)
+ *         .addInterceptor(loggingInterceptor))
+ *     .build();
+ * }
+ * + * @see JSONRPCTransportConfig + * @see JSONRPCTransport + * @see A2AHttpClient + * @see io.a2a.client.http.JdkA2AHttpClient + */ public class JSONRPCTransportConfigBuilder extends ClientTransportConfigBuilder { private @Nullable A2AHttpClient httpClient; + /** + * Set the HTTP client to use for JSON-RPC requests. + *

+ * Custom HTTP clients can provide: + *

    + *
  • Connection pooling and reuse
  • + *
  • Custom timeout configuration
  • + *
  • SSL/TLS configuration
  • + *
  • Proxy support
  • + *
  • Custom header handling
  • + *
+ *

+ * If not specified, the default {@link JdkA2AHttpClient} is used. + *

+ * Example: + *

{@code
+     * A2AHttpClient client = new CustomHttpClient()
+     *     .withConnectTimeout(Duration.ofSeconds(5))
+     *     .withReadTimeout(Duration.ofSeconds(30))
+     *     .withConnectionPool(10, 50);
+     *
+     * builder.httpClient(client);
+     * }
+ * + * @param httpClient the HTTP client to use + * @return this builder for method chaining + */ public JSONRPCTransportConfigBuilder httpClient(A2AHttpClient httpClient) { this.httpClient = httpClient; return this; } + /** + * Build the JSON-RPC transport configuration. + *

+ * If no HTTP client was configured, the default {@link JdkA2AHttpClient} is used. + * Any configured interceptors are transferred to the configuration. + * + * @return the configured JSON-RPC transport configuration + */ @Override public JSONRPCTransportConfig build() { // No HTTP client provided, fallback to the default one (JDK-based implementation) @@ -25,4 +117,4 @@ public JSONRPCTransportConfig build() { config.setInterceptors(this.interceptors); return config; } -} \ No newline at end of file +} diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java index d097b010f..241541a32 100644 --- a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java +++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java @@ -4,18 +4,80 @@ import io.a2a.client.transport.spi.ClientTransportConfig; import org.jspecify.annotations.Nullable; +/** + * Configuration for the REST transport protocol. + *

+ * This configuration class allows customization of the HTTP client used for RESTful + * communication with A2A agents. If no HTTP client is specified, the default JDK-based + * implementation is used. + *

+ * Basic usage: + *

{@code
+ * // Use default HTTP client
+ * RestTransportConfig config = new RestTransportConfigBuilder()
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(RestTransport.class, config)
+ *     .build();
+ * }
+ *

+ * Custom HTTP client: + *

{@code
+ * // Custom HTTP client with timeouts
+ * A2AHttpClient customClient = new CustomHttpClient()
+ *     .withConnectTimeout(Duration.ofSeconds(10))
+ *     .withReadTimeout(Duration.ofSeconds(30));
+ *
+ * RestTransportConfig config = new RestTransportConfigBuilder()
+ *     .httpClient(customClient)
+ *     .build();
+ * }
+ *

+ * With interceptors: + *

{@code
+ * RestTransportConfig config = new RestTransportConfigBuilder()
+ *     .httpClient(customClient)
+ *     .addInterceptor(new LoggingInterceptor())
+ *     .addInterceptor(new AuthInterceptor("Bearer token"))
+ *     .build();
+ * }
+ * + * @see RestTransportConfigBuilder + * @see RestTransport + * @see A2AHttpClient + * @see io.a2a.client.http.JdkA2AHttpClient + */ public class RestTransportConfig extends ClientTransportConfig { private final @Nullable A2AHttpClient httpClient; + /** + * Create a REST transport configuration with the default HTTP client. + *

+ * The default JDK-based HTTP client will be used. Consider using + * {@link RestTransportConfigBuilder} instead for a more fluent API. + */ public RestTransportConfig() { this.httpClient = null; } + /** + * Create a REST transport configuration with a custom HTTP client. + *

+ * Consider using {@link RestTransportConfigBuilder} instead for a more fluent API. + * + * @param httpClient the HTTP client to use for REST requests + */ public RestTransportConfig(A2AHttpClient httpClient) { this.httpClient = httpClient; } + /** + * Get the configured HTTP client. + * + * @return the HTTP client, or {@code null} if using the default + */ public @Nullable A2AHttpClient getHttpClient() { return httpClient; } diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java index 68150f189..855de0ca6 100644 --- a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java +++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java @@ -5,15 +5,107 @@ import io.a2a.client.transport.spi.ClientTransportConfigBuilder; import org.jspecify.annotations.Nullable; +/** + * Builder for creating {@link RestTransportConfig} instances. + *

+ * This builder provides a fluent API for configuring the REST transport protocol. + * All configuration options are optional - if not specified, sensible defaults are used: + *

    + *
  • HTTP client: {@link JdkA2AHttpClient} (JDK's built-in HTTP client)
  • + *
  • Interceptors: None
  • + *
+ *

+ * Basic usage: + *

{@code
+ * // Minimal configuration (uses all defaults)
+ * RestTransportConfig config = new RestTransportConfigBuilder()
+ *     .build();
+ *
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(RestTransport.class, config)
+ *     .build();
+ * }
+ *

+ * Custom HTTP client: + *

{@code
+ * // Configure custom HTTP client for connection pooling, timeouts, etc.
+ * A2AHttpClient httpClient = new ApacheHttpClient()
+ *     .withConnectionTimeout(Duration.ofSeconds(10))
+ *     .withMaxConnections(50);
+ *
+ * RestTransportConfig config = new RestTransportConfigBuilder()
+ *     .httpClient(httpClient)
+ *     .build();
+ * }
+ *

+ * With interceptors: + *

{@code
+ * RestTransportConfig config = new RestTransportConfigBuilder()
+ *     .addInterceptor(new LoggingInterceptor())
+ *     .addInterceptor(new MetricsInterceptor())
+ *     .addInterceptor(new RetryInterceptor(3))
+ *     .build();
+ * }
+ *

+ * Direct usage in ClientBuilder: + *

{@code
+ * // Can pass builder directly to withTransport()
+ * Client client = Client.builder(agentCard)
+ *     .withTransport(RestTransport.class, new RestTransportConfigBuilder()
+ *         .httpClient(customClient)
+ *         .addInterceptor(loggingInterceptor))
+ *     .build();
+ * }
+ * + * @see RestTransportConfig + * @see RestTransport + * @see A2AHttpClient + * @see io.a2a.client.http.JdkA2AHttpClient + */ public class RestTransportConfigBuilder extends ClientTransportConfigBuilder { private @Nullable A2AHttpClient httpClient; + /** + * Set the HTTP client to use for REST requests. + *

+ * Custom HTTP clients can provide: + *

    + *
  • Connection pooling and reuse
  • + *
  • Custom timeout configuration
  • + *
  • SSL/TLS configuration
  • + *
  • Proxy support
  • + *
  • Custom header handling
  • + *
+ *

+ * If not specified, the default {@link JdkA2AHttpClient} is used. + *

+ * Example: + *

{@code
+     * A2AHttpClient client = new CustomHttpClient()
+     *     .withConnectTimeout(Duration.ofSeconds(5))
+     *     .withReadTimeout(Duration.ofSeconds(30))
+     *     .withConnectionPool(10, 50);
+     *
+     * builder.httpClient(client);
+     * }
+ * + * @param httpClient the HTTP client to use + * @return this builder for method chaining + */ public RestTransportConfigBuilder httpClient(A2AHttpClient httpClient) { this.httpClient = httpClient; return this; } + /** + * Build the REST transport configuration. + *

+ * If no HTTP client was configured, the default {@link JdkA2AHttpClient} is used. + * Any configured interceptors are transferred to the configuration. + * + * @return the configured REST transport configuration + */ @Override public RestTransportConfig build() { // No HTTP client provided, fallback to the default one (JDK-based implementation) diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java index c04b52882..15315725f 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java @@ -6,17 +6,61 @@ import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; /** - * Configuration for an A2A client transport. + * Base configuration class for A2A client transport protocols. + *

+ * This abstract class provides common configuration functionality for all transport implementations + * (JSON-RPC, gRPC, REST). It manages request/response interceptors that can be used for logging, + * metrics, authentication, and other cross-cutting concerns. + *

+ * Interceptors: Transport configurations support adding interceptors that can inspect and + * modify requests/responses. Interceptors are invoked in the order they were added: + *

{@code
+ * // Example: Add logging and authentication interceptors
+ * config.setInterceptors(List.of(
+ *     new LoggingInterceptor(),
+ *     new AuthenticationInterceptor("Bearer token")
+ * ));
+ * }
+ *

+ * Concrete implementations typically extend this class to add transport-specific configuration + * such as HTTP clients, gRPC channels, or connection pools. + *

+ * Thread safety: Configuration instances should be treated as immutable after construction. + * The interceptor list is copied defensively to prevent external modification. + * + * @param the transport type this configuration is for + * @see ClientTransportConfigBuilder + * @see io.a2a.client.transport.spi.interceptors.ClientCallInterceptor */ public abstract class ClientTransportConfig { protected List interceptors = new ArrayList<>(); + /** + * Set the list of request/response interceptors. + *

+ * Interceptors are invoked in the order they appear in the list, allowing for + * controlled processing chains (e.g., authentication before logging). + *

+ * The provided list is copied to prevent external modifications from affecting + * this configuration. + * + * @param interceptors the list of interceptors to use (will be copied) + * @see ClientTransportConfigBuilder#addInterceptor(ClientCallInterceptor) + */ public void setInterceptors(List interceptors) { this.interceptors = new ArrayList<>(interceptors); } + /** + * Get the list of configured interceptors. + *

+ * Returns the internal list of interceptors. Modifications to the returned list + * will affect this configuration. + * + * @return the list of configured interceptors (never null, but may be empty) + */ public List getInterceptors() { return interceptors; } -} \ No newline at end of file +} diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java index 6dbd9ea60..fabdab765 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java @@ -5,11 +5,67 @@ import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; +/** + * Base builder class for constructing transport configuration instances. + *

+ * This abstract builder provides common functionality for building transport configurations, + * particularly interceptor management. Concrete builders extend this class to add + * transport-specific configuration options. + *

+ * Self-typed builder pattern: This class uses the "self-typed" or "curiously recurring + * template pattern" to enable method chaining in subclasses while maintaining type safety: + *

{@code
+ * JSONRPCTransportConfig config = new JSONRPCTransportConfigBuilder()
+ *     .addInterceptor(loggingInterceptor)  // Returns JSONRPCTransportConfigBuilder
+ *     .httpClient(myHttpClient)            // Returns JSONRPCTransportConfigBuilder
+ *     .build();                            // Returns JSONRPCTransportConfig
+ * }
+ *

+ * Interceptor ordering: Interceptors are invoked in the order they were added: + *

{@code
+ * builder
+ *     .addInterceptor(authInterceptor)    // Runs first
+ *     .addInterceptor(loggingInterceptor) // Runs second
+ *     .addInterceptor(metricsInterceptor);// Runs third
+ * }
+ * + * @param the transport configuration type this builder creates + * @param the concrete builder type (for method chaining) + * @see ClientTransportConfig + * @see io.a2a.client.transport.spi.interceptors.ClientCallInterceptor + */ public abstract class ClientTransportConfigBuilder, B extends ClientTransportConfigBuilder> { protected List interceptors = new ArrayList<>(); + /** + * Add a request/response interceptor to this transport configuration. + *

+ * Interceptors can be used for cross-cutting concerns such as: + *

    + *
  • Logging requests and responses
  • + *
  • Adding authentication headers
  • + *
  • Collecting metrics and telemetry
  • + *
  • Request/response transformation
  • + *
  • Error handling and retry logic
  • + *
+ *

+ * Interceptors are invoked in the order they were added. If {@code interceptor} is + * {@code null}, this method is a no-op (for convenience in conditional addition). + *

+ * Example: + *

{@code
+     * builder
+     *     .addInterceptor(new LoggingInterceptor())
+     *     .addInterceptor(authToken != null ? new AuthInterceptor(authToken) : null)
+     *     .addInterceptor(new MetricsInterceptor());
+     * }
+ * + * @param interceptor the interceptor to add (null values are ignored) + * @return this builder for method chaining + * @see io.a2a.client.transport.spi.interceptors.ClientCallInterceptor + */ public B addInterceptor(ClientCallInterceptor interceptor) { if (interceptor != null) { this.interceptors.add(interceptor); @@ -18,5 +74,19 @@ public B addInterceptor(ClientCallInterceptor interceptor) { return (B) this; } + /** + * Build the transport configuration with all configured options. + *

+ * Concrete implementations should: + *

    + *
  1. Validate required configuration (e.g., gRPC channel factory)
  2. + *
  3. Apply defaults for optional configuration (e.g., HTTP client)
  4. + *
  5. Create the configuration instance
  6. + *
  7. Transfer interceptors to the configuration
  8. + *
+ * + * @return the configured transport configuration instance + * @throws IllegalStateException if required configuration is missing + */ public abstract T build(); } From 67818f81695f9f33a11f79104982adccb9c91df0 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Wed, 24 Dec 2025 12:42:19 +0000 Subject: [PATCH 2/2] Gemini feedback --- .../java/io/a2a/client/AbstractClient.java | 2 +- .../src/main/java/io/a2a/client/Client.java | 40 ++++++++++++------- .../transport/spi/ClientTransportConfig.java | 8 ++-- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/client/base/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java index b61623668..75fe48956 100644 --- a/client/base/src/main/java/io/a2a/client/AbstractClient.java +++ b/client/base/src/main/java/io/a2a/client/AbstractClient.java @@ -31,7 +31,7 @@ * transport protocol. It supports sending messages, managing tasks, and * handling event streams. */ -public abstract class AbstractClient { +public abstract class AbstractClient implements AutoCloseable { protected final @NonNull List> consumers; protected final @Nullable Consumer streamingErrorHandler; diff --git a/client/base/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java index 98d52be39..9976133e5 100644 --- a/client/base/src/main/java/io/a2a/client/Client.java +++ b/client/base/src/main/java/io/a2a/client/Client.java @@ -52,30 +52,40 @@ *
  • Resubscription: Resume receiving events for ongoing tasks after disconnection
  • * *

    - * Creating a client: Use {@link #builder(AgentCard)} to create instances: + * Resource management: Client implements {@link AutoCloseable} and should be used with + * try-with-resources to ensure proper cleanup: *

    {@code
    - * // 1. Get agent card
      * AgentCard card = A2A.getAgentCard("http://localhost:9999");
      *
    - * // 2. Build and configure client
    + * try (Client client = Client.builder(card)
    + *         .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
    + *         .addConsumer((event, agentCard) -> {
    + *             if (event instanceof MessageEvent me) {
    + *                 System.out.println("Response: " + me.getMessage().parts());
    + *             }
    + *         })
    + *         .build()) {
    + *
    + *     // Send messages - client automatically closed when done
    + *     client.sendMessage(A2A.toUserMessage("Tell me a joke"));
    + * }
    + * }
    + *

    + * Manual resource management: If not using try-with-resources, call {@link #close()} + * explicitly when done: + *

    {@code
      * Client client = Client.builder(card)
      *     .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
      *     .addConsumer((event, agentCard) -> {
    - *         if (event instanceof MessageEvent me) {
    - *             Message msg = me.getMessage();
    - *             System.out.println("Agent response: " + msg.parts());
    - *         } else if (event instanceof TaskUpdateEvent tue) {
    - *             Task task = tue.getTask();
    - *             System.out.println("Task " + task.id() + " is " + task.status().state());
    - *         }
    + *         // Handle events
      *     })
      *     .build();
      *
    - * // 3. Send messages
    - * client.sendMessage(A2A.toUserMessage("Tell me a joke"));
    - *
    - * // 4. Clean up when done
    - * client.close();
    + * try {
    + *     client.sendMessage(A2A.toUserMessage("Tell me a joke"));
    + * } finally {
    + *     client.close();  // Always close to release resources
    + * }
      * }
    *

    * Event consumption model: Responses from the agent are delivered as {@link ClientEvent} diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java index 15315725f..9c05fac59 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java @@ -55,12 +55,12 @@ public void setInterceptors(List interceptors) { /** * Get the list of configured interceptors. *

    - * Returns the internal list of interceptors. Modifications to the returned list - * will affect this configuration. + * Returns an unmodifiable view of the interceptor list. Attempting to modify + * the returned list will throw {@link UnsupportedOperationException}. * - * @return the list of configured interceptors (never null, but may be empty) + * @return an unmodifiable list of configured interceptors (never null, but may be empty) */ public List getInterceptors() { - return interceptors; + return java.util.Collections.unmodifiableList(interceptors); } }