-
Notifications
You must be signed in to change notification settings - Fork 102
feat: Make the client transport agnostic #231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Summary of Changes
Hello @fjuma, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!
This pull request significantly refactors the A2A Java client to be transport-agnostic, enabling seamless communication with agents using either JSON-RPC or gRPC. It introduces a modular design with dedicated client transport implementations and a ClientFactory for dynamic client instantiation. The changes aim to enhance flexibility, align the Java client API with the Python SDK, and improve overall extensibility by leveraging Java's ServiceLoader for transport discovery.
Highlights
- Transport Agnostic Client: The client has been refactored to be transport-agnostic, allowing it to communicate with A2A agents using different protocols (e.g., JSON-RPC, gRPC) interchangeably.
- New Transport Interfaces and Implementations: New interfaces
ClientTransportandClientTransportProviderhave been introduced, along with concrete implementations for JSON-RPC and gRPC, each residing in its own dedicated client module. - Dynamic Client Creation with ClientFactory: A
ClientFactoryis now responsible for dynamically creatingClientinstances based on the agent's capabilities (from its agent card), client preferences, and available transport providers discovered via Java's ServiceLoader mechanism. - Client API Refactoring: The core
ClientAPI has undergone significant changes, adopting a design similar to the Python SDK client refactoring, enhancing consistency across SDKs. - Enhanced Client Configuration and Interception: New configuration and interception capabilities have been added through
ClientCallContext,ClientCallInterceptor, andPayloadAndHeadersclasses, enabling fine-grained control over client calls. - Module Restructuring and Class Relocation: Existing HTTP client and Server-Sent Events (SSE) related classes have been renamed and relocated into new, more organized modules (
client-http,client-transport/grpc,client-transport/jsonrpc). - Removal of Legacy Client Implementations: The previous monolithic
A2AClientandA2AGrpcClientclasses have been removed, replaced by the new modular and transport-agnosticClientimplementation. - New Specification Classes: Several new specification-related classes have been introduced, including
A2AClientException(and its specialized subclasses),TransportProtocol, andUpdateEvent, to support the new client architecture and error handling.
Using Gemini Code Assist
The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.
Invoking Gemini
You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.
| Feature | Command | Description |
|---|---|---|
| Code Review | /gemini review |
Performs a code review for the current pull request in its current state. |
| Pull Request Summary | /gemini summary |
Provides a summary of the current pull request in its current state. |
| Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments. |
| Help | /gemini help |
Displays a list of available commands. |
Customization
To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.
Limitations & Feedback
Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.
You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.
Footnotes
-
Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩
|
Hi @brasseld, thanks very much for your PR! I've taken your branch and combined our changes together as described in the PR description. Please take a look and let me know what you think. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This is a significant and well-structured refactoring to make the client transport-agnostic. The introduction of ClientTransport and ClientTransportProvider interfaces using Java's ServiceLoader is a great approach for extensibility. The separation of concerns into different modules (config, http, transports) improves the project structure and maintainability.
My review focuses on a few key areas for improvement:
- Immutability: The new configuration and context classes (
ClientConfig,ClientCallContext,PayloadAndHeaders) should be made immutable to prevent unexpected side effects and improve thread safety. I've provided suggestions to create defensive copies and return unmodifiable collections. - Performance: There's a potential performance issue in the
JSONRPCTransportwhere the agent card might be fetched repeatedly. I've pointed this out with a suggestion for caching.
Overall, this is a solid piece of work that greatly improves the client architecture. Addressing the points above will make it even more robust and efficient.
| } | ||
|
|
||
| @Override | ||
| public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation of getAgentCard can be inefficient. If the agent supports an extended card, this method will make an HTTP request to fetch it on every call, even if it has been fetched before because the check for supportsAuthenticatedExtendedCard and subsequent fetch is outside the if (agentCard == null) block. The agent card should be fetched and cached on the first call. Consider refactoring the logic to fetch the agent card (and the extended one if supported) only if it hasn't been fetched before.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
| public ClientCallContext(Map<String, Object> state, Map<String, String> headers) { | ||
| this.state = state; | ||
| this.headers = headers; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ClientCallContext class is not immutable because the maps passed to the constructor are assigned directly. This could lead to them being modified from outside the class after construction. To improve robustness, you should create defensive copies of these collections and handle null inputs gracefully.
| public ClientCallContext(Map<String, Object> state, Map<String, String> headers) { | |
| this.state = state; | |
| this.headers = headers; | |
| } | |
| public ClientCallContext(Map<String, Object> state, Map<String, String> headers) { | |
| this.state = state == null ? java.util.Collections.emptyMap() : new java.util.HashMap<>(state); | |
| this.headers = headers == null ? java.util.Collections.emptyMap() : new java.util.HashMap<>(headers); | |
| } |
| public Map<String, Object> getState() { | ||
| return state; | ||
| } | ||
|
|
||
| public Map<String, String> getHeaders() { | ||
| return headers; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To ensure immutability, the getters for collections should return unmodifiable views. This prevents callers from modifying the internal state of the ClientCallContext object.
| public Map<String, Object> getState() { | |
| return state; | |
| } | |
| public Map<String, String> getHeaders() { | |
| return headers; | |
| } | |
| public Map<String, Object> getState() { | |
| return java.util.Collections.unmodifiableMap(state); | |
| } | |
| public Map<String, String> getHeaders() { | |
| return java.util.Collections.unmodifiableMap(headers); | |
| } |
| public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, | ||
| List<String> supportedTransports, Boolean useClientPreference, | ||
| List<String> acceptedOutputModes, PushNotificationConfig pushNotificationConfig, | ||
| Integer historyLength, Map<String, Object> metadata) { | ||
| this.streaming = streaming == null ? true : streaming; | ||
| this.polling = polling == null ? false : polling; | ||
| this.httpClient = httpClient; | ||
| this.channel = channel; | ||
| this.supportedTransports = supportedTransports; | ||
| this.useClientPreference = useClientPreference == null ? false : useClientPreference; | ||
| this.acceptedOutputModes = acceptedOutputModes; | ||
| this.pushNotificationConfig = pushNotificationConfig; | ||
| this.historyLength = historyLength; | ||
| this.metadata = metadata; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ClientConfig class is not fully immutable. The collections (supportedTransports, acceptedOutputModes, metadata) are assigned directly in the constructor. This could lead to them being modified from outside the class after construction.
To improve robustness, you should create defensive copies of these collections. Also, consider handling null inputs for these collections gracefully, for example by initializing them to empty collections.
| public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, | |
| List<String> supportedTransports, Boolean useClientPreference, | |
| List<String> acceptedOutputModes, PushNotificationConfig pushNotificationConfig, | |
| Integer historyLength, Map<String, Object> metadata) { | |
| this.streaming = streaming == null ? true : streaming; | |
| this.polling = polling == null ? false : polling; | |
| this.httpClient = httpClient; | |
| this.channel = channel; | |
| this.supportedTransports = supportedTransports; | |
| this.useClientPreference = useClientPreference == null ? false : useClientPreference; | |
| this.acceptedOutputModes = acceptedOutputModes; | |
| this.pushNotificationConfig = pushNotificationConfig; | |
| this.historyLength = historyLength; | |
| this.metadata = metadata; | |
| } | |
| public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, | |
| List<String> supportedTransports, Boolean useClientPreference, | |
| List<String> acceptedOutputModes, PushNotificationConfig pushNotificationConfig, | |
| Integer historyLength, Map<String, Object> metadata) { | |
| this.streaming = streaming == null ? true : streaming; | |
| this.polling = polling == null ? false : polling; | |
| this.httpClient = httpClient; | |
| this.channel = channel; | |
| this.supportedTransports = supportedTransports == null ? java.util.Collections.emptyList() : new java.util.ArrayList<>(supportedTransports); | |
| this.useClientPreference = useClientPreference == null ? false : useClientPreference; | |
| this.acceptedOutputModes = acceptedOutputModes == null ? java.util.Collections.emptyList() : new java.util.ArrayList<>(acceptedOutputModes); | |
| this.pushNotificationConfig = pushNotificationConfig; | |
| this.historyLength = historyLength; | |
| this.metadata = metadata == null ? java.util.Collections.emptyMap() : new java.util.HashMap<>(metadata); | |
| } |
| public List<String> getSupportedTransports() { | ||
| return supportedTransports; | ||
| } | ||
|
|
||
| public boolean isUseClientPreference() { | ||
| return useClientPreference; | ||
| } | ||
|
|
||
| public List<String> getAcceptedOutputModes() { | ||
| return acceptedOutputModes; | ||
| } | ||
|
|
||
| public PushNotificationConfig getPushNotificationConfig() { | ||
| return pushNotificationConfig; | ||
| } | ||
|
|
||
| public Integer getHistoryLength() { | ||
| return historyLength; | ||
| } | ||
|
|
||
| public Map<String, Object> getMetadata() { | ||
| return metadata; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To ensure immutability, the getters for collections should return unmodifiable views. This prevents callers from modifying the internal state of the ClientConfig object.
public List<String> getSupportedTransports() {
return java.util.Collections.unmodifiableList(supportedTransports);
}
public boolean isUseClientPreference() {
return useClientPreference;
}
public List<String> getAcceptedOutputModes() {
return java.util.Collections.unmodifiableList(acceptedOutputModes);
}
public PushNotificationConfig getPushNotificationConfig() {
return pushNotificationConfig;
}
public Integer getHistoryLength() {
return historyLength;
}
public Map<String, Object> getMetadata() {
return java.util.Collections.unmodifiableMap(metadata);
}| public PayloadAndHeaders(Object payload, Map<String, String> headers) { | ||
| this.payload = payload; | ||
| this.headers = headers; | ||
| } | ||
|
|
||
| public Object getPayload() { | ||
| return payload; | ||
| } | ||
|
|
||
| public Map<String, String> getHeaders() { | ||
| return headers; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PayloadAndHeaders class is not immutable. The headers map is assigned directly in the constructor and returned directly by the getter. This can lead to unexpected behavior if the map is modified outside the class.
To make the class immutable, you should create a defensive copy of the map in the constructor and return an unmodifiable view of the map from the getter.
| public PayloadAndHeaders(Object payload, Map<String, String> headers) { | |
| this.payload = payload; | |
| this.headers = headers; | |
| } | |
| public Object getPayload() { | |
| return payload; | |
| } | |
| public Map<String, String> getHeaders() { | |
| return headers; | |
| } | |
| public PayloadAndHeaders(Object payload, Map<String, String> headers) { | |
| this.payload = payload; | |
| this.headers = headers == null ? java.util.Collections.emptyMap() : new java.util.HashMap<>(headers); | |
| } | |
| public Object getPayload() { | |
| return payload; | |
| } | |
| public Map<String, String> getHeaders() { | |
| return java.util.Collections.unmodifiableMap(headers); | |
| } |
5beb29c to
565014c
Compare
|
Looks OK @fjuma My only concern at the moment is the accumultation of client-* modules, I'm wondering if that would not be easier to have the following structure instead: Also, I'm a bit unsure about having the client-http module outside a transport... Even if I do get the fact that retrieving an AgentCard can only be done through a normal HTTP call from the A2ACardResolver. But what if we were to provide an other HTTP implementation based on vertx, okhttp, .... ? Looking at the client structure, I would probably opt for something similar for the server side... |
|
I'm also not a big fan of the client instanciation with the ClientFactory and the ClientConfig as these have referenbce to A2AHttpClient and Channel, potentially forcing to load useless dependencies. We probably need some inheritance here, make ClientConfig as abstract, and provide specific implementation for GrpcClientConfig and JsonRpcClientConfig |
|
Anyway, what you did is already a great start, we can look at improving it next! 👏 @fjuma |
Thanks for the feedback! Good point, yes I can update this. There's also other code in the client directory right now, I can probably just move that into its own directory within client.
I kept the http related code outside of the jsonrpc transport because it will also be used for the HTTP+JSON transport (e.g., #229). cc @ehsavoie |
Right, this is part of my todo list in the PR description, to avoid the unneeded dependencies. Will make sure to also avoid the http client dependency here as well, thanks for pointing that out! |
Yeah, I do get your point, but still wondering if that's the right approach. Again, my use-case is for us to provide other HTTP client implementation, pretty sure that this may come sooner rather than later. What about having something like this: Then the agentCardResolver would make use of an HttpTransport. (just htinking, need to look a bit more into it). |
Yeah I see your point, that structure makes sense, will think more about that. |
Actually, |
|
Ok, let's keep it outside for now, we can look into it later, after this refactor. |
8b35a6e to
d2f875e
Compare
|
I've updated the directory structure for the client side as discussed at the end of last week. I'm now working through the other items from the todo list in the PR description. |
|
I've updated |
161453e to
d6796bc
Compare
|
/gemini review |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This is a significant and well-structured refactoring to make the client transport-agnostic. The introduction of the ClientTransport SPI and the modularization of JSON-RPC and gRPC transports are excellent improvements. The overall architecture is much cleaner and more extensible. My review focuses on the new client implementation, where I've identified a couple of bugs related to state management in ClientTaskManager and some areas for code improvement to reduce duplication and enhance robustness, particularly in the gRPC transport implementation. The refactoring of the test suite to use the new client API is also a great step forward.
| List<Message> history = task.getHistory(); | ||
| history.add(taskStatusUpdateEvent.getStatus().message()); | ||
| taskBuilder.history(history); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The history list from the original task object is being modified directly. This is a side effect that can lead to unexpected behavior, as it alters the state of an object that should be treated as immutable within this context. It's safer to create a new list from the existing history before adding new items.
| List<Message> history = task.getHistory(); | |
| history.add(taskStatusUpdateEvent.getStatus().message()); | |
| taskBuilder.history(history); | |
| List<Message> history = new ArrayList<>(task.getHistory()); | |
| history.add(taskStatusUpdateEvent.getStatus().message()); | |
| taskBuilder.history(history); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
| public void sendMessage(Message request, ClientCallContext context) throws A2AClientException { | ||
| MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() | ||
| .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) | ||
| .blocking(clientConfig.isPolling()) | ||
| .historyLength(clientConfig.getHistoryLength()) | ||
| .pushNotification(clientConfig.getPushNotificationConfig()) | ||
| .build(); | ||
|
|
||
| MessageSendParams messageSendParams = new MessageSendParams.Builder() | ||
| .message(request) | ||
| .configuration(messageSendConfiguration) | ||
| .metadata(clientConfig.getMetadata()) | ||
| .build(); | ||
|
|
||
| sendMessage(messageSendParams, null, null, context); | ||
| } | ||
|
|
||
| @Override | ||
| public void sendMessage(Message request, List<BiConsumer<ClientEvent, AgentCard>> consumers, | ||
| Consumer<Throwable> streamingErrorHandler, ClientCallContext context) throws A2AClientException { | ||
| MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() | ||
| .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) | ||
| .blocking(clientConfig.isPolling()) | ||
| .historyLength(clientConfig.getHistoryLength()) | ||
| .pushNotification(clientConfig.getPushNotificationConfig()) | ||
| .build(); | ||
|
|
||
| MessageSendParams messageSendParams = new MessageSendParams.Builder() | ||
| .message(request) | ||
| .configuration(messageSendConfiguration) | ||
| .metadata(clientConfig.getMetadata()) | ||
| .build(); | ||
|
|
||
| sendMessage(messageSendParams, consumers, streamingErrorHandler, context); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
| // Extract the actual error type from the description if possible | ||
| // (using description because the same code can map to multiple errors - | ||
| // see GrpcHandler#handleError) | ||
| if (description != null) { | ||
| if (description.contains("TaskNotFoundError")) { | ||
| return new A2AClientException(errorPrefix + description, new TaskNotFoundError()); | ||
| } else if (description.contains("UnsupportedOperationError")) { | ||
| return new A2AClientException(errorPrefix + description, new UnsupportedOperationError()); | ||
| } else if (description.contains("InvalidParamsError")) { | ||
| return new A2AClientException(errorPrefix + description, new InvalidParamsError()); | ||
| } else if (description.contains("InvalidRequestError")) { | ||
| return new A2AClientException(errorPrefix + description, new InvalidRequestError()); | ||
| } else if (description.contains("MethodNotFoundError")) { | ||
| return new A2AClientException(errorPrefix + description, new MethodNotFoundError()); | ||
| } else if (description.contains("TaskNotCancelableError")) { | ||
| return new A2AClientException(errorPrefix + description, new TaskNotCancelableError()); | ||
| } else if (description.contains("PushNotificationNotSupportedError")) { | ||
| return new A2AClientException(errorPrefix + description, new PushNotificationNotSupportedError()); | ||
| } else if (description.contains("JSONParseError")) { | ||
| return new A2AClientException(errorPrefix + description, new JSONParseError()); | ||
| } else if (description.contains("ContentTypeNotSupportedError")) { | ||
| return new A2AClientException(errorPrefix + description, new ContentTypeNotSupportedError(null, description, null)); | ||
| } else if (description.contains("InvalidAgentResponseError")) { | ||
| return new A2AClientException(errorPrefix + description, new InvalidAgentResponseError(null, description, null)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Relying on string matching (.contains()) on the error description to determine the error type is brittle and can easily break if the server-side error messages change. A more robust approach would be to use gRPC's rich error model (e.g., Status with details in Metadata) to pass structured error details from the server to the client. This would allow for reliable error handling without depending on specific string formats.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a comment on why this is the approach we're taking for now.
| public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException { | ||
| // TODO: Determine how to handle retrieving the authenticated extended agent card | ||
| return agentCard; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getAgentCard method has a TODO and currently returns the cached agentCard without attempting to fetch an authenticated extended card. This means the gRPC transport will not support extended agent cards, which might be a feature gap compared to other transports. This should be implemented to ensure feature parity.
| public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, | ||
| String agentUrl, List<ClientCallInterceptor> interceptors) { | ||
| // not making use of the interceptors for gRPC for now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The gRPC transport provider currently ignores the interceptors parameter. While gRPC has its own interceptor mechanism (ClientInterceptor), not using the common ClientCallInterceptor interface could lead to inconsistencies for cross-cutting concerns like authentication or logging across different transports. It would be beneficial to adapt the provided interceptors to gRPC's ClientInterceptor to maintain a unified interception model.
2f075ee to
79cbb48
Compare
… and also split out the http client code into a separate module
…text, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.
… to make use of it, and remove the http-client and grpc dependencies from the client config module
…t to be able to make use of the appropriate client based on the transport and update the JSONRPC and gRPC tests to extend this
…otificationConfig
…roto#pushNotificationConfig
…gets cached once retrieved
…d some convenience methods to AbstractClient
…t other server implementations that also support JSONRPC can make use of these tests. These tests will be skipped when testing other transports
c826f3a to
26ab76d
Compare
Description
This is still work in progress and builds upon the changes from #228 (thanks very much for the PR @brasseld!).
This PR essentially does the following:
ClientTransportandClientTransportProviderinterfacesClientFactorythat can be used to create aClientbased on the agent card, client preferences, and available client transport providers from service loader discovery (i.e., we check what transports are supported by the server using the agent card, compare to preferences that can be specified viaClientConfigand what client transport providers are available and then instantiate the client based on that) (similar to the Python SDK client refactoring)ClientAPI have been introduced, following a similar approach to the changes and concepts introduced in the Python SDK client refactoring (e.g. see refactor: Refactor client code to support multi-transport interactions and simplify client interfaces a2a-python#342).sendMessagemethod and detect whether or not the streaming or non-streaming transport method should be used based on the capabilities specified by the agent card and client preferences.I still need to do the following:
AbstractA2AServerTestso that it makes use of aClientcreated using the appropriate transport for the test. This will allow us to use the same end-to-end tests for all transports.ClientConfigto no longer depend onio.grpc.Channel- I'll look into a different way to configure theChannelfor gRPC to avoid needing the grpc dependency inclient-config. While looking at this, we should also do something similar for theA2AHttpClientconfig to avoid that dep too.Fixes #230 🦕