diff --git a/samples/java/agents/README.md b/samples/java/agents/README.md index d405553a3..95d23ec90 100644 --- a/samples/java/agents/README.md +++ b/samples/java/agents/README.md @@ -22,6 +22,9 @@ Each agent can be run as its own A2A server with the instructions in its README. * [**Magic 8 Ball Agent (Security)**](magic_8_ball_security/README.md) Sample agent that can respond to yes/no questions by consulting a Magic 8 Ball. This sample demonstrates how to secure an A2A server with Keycloak using bearer token authentication and it shows how to configure an A2A client to specify the token when sending requests. +* [**Currency Exchange Rates (Multi-turn Conversations)**](currency_exchange_rates/README.md) + Sample agent to provide currency conversion. This agent demonstrates multi-turn dialogue and streaming responses. + ## Disclaimer Important: The sample code provided is for demonstration purposes and illustrates the diff --git a/samples/java/agents/currency_exchange_rates/README.md b/samples/java/agents/currency_exchange_rates/README.md new file mode 100644 index 000000000..8928cad35 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/README.md @@ -0,0 +1,144 @@ +# Currency Exchange Rates + +This sample demonstrates a currency conversion agent exposed through the A2A protocol. +It showcases conversational interactions with support for multi-turn dialogue and streaming responses. +The agent is written using Quarkus LangChain4j and makes use of the [A2A Java](https://github.com/a2aproject/a2a-java) SDK. + +## How It Works + +This agent uses an Ollama LLM (for example, qwen2.5:7b) to provide currency exchange information. +The A2A protocol enables standardized interaction with the agent, allowing clients to send requests and receive real-time updates. + +```mermaid +sequenceDiagram + participant Client as A2A Client + participant Server as A2A Server + participant API as Frankfurter API + + Client->>Server: Send task with currency query + + alt Complete Information + Server->>API: Call get_exchange_rate tool + API->>Server: Return exchange rate data + Server->>Server: Process data & return result + Server->>Client: Respond with currency information + else Incomplete Information + Server->>Server: Request additional input + Server->>Client: Set state to "input-required" + Client->>Server: Send additional information + Server->>API: Call get_exchange_rate tool + API->>Server: Return exchange rate data + Server->>Server: Process data & return result + Server->>Client: Respond with currency information + end + + alt With Streaming + Note over Client,Server: Real-time status updates + Server->>Client: "Looking up exchange rates..." + Server->>Client: "Processing exchange rates..." + Server->>Client: Final result + end +``` + +## Key Features + +- **Multi-turn Conversations**: Agent can request additional information when needed +- **Real-time Streaming**: Provides status updates during processing +- **Conversational Memory**: Maintains context across interactions +- **Currency Exchange Tool**: Integrates with Frankfurter API for real-time rates + +## Prerequisites + +- Java 17 or higher +- Access to an LLM (Ollama) +- Ollama installed (see [Download Ollama](https://ollama.com/download)) + +## Running the Sample + +This sample consists of an A2A server agent, which is in the `server` directory, and an A2A client, +which is in the `client` directory. + +### Running Ollama + +1. Launch the Ollama server by default on port 11434 ([Local Ollama server](http://localhost:11434)) + + ```bash + ollama serve + ``` + +2. In this example, we use the model qwen2.5:7b. Download this model and run it + + ```bash + ollama pull qwen2.5:7b + ollama run qwen2.5:7b + ``` + +### Running the A2A Server Agent + +1. Navigate to the `currency_exchange_rates` sample directory: + + ```bash + cd samples/java/agents/currency_exchange_rates/server + ``` + +2. Start the A2A server agent + + **NOTE:** + By default, the agent will start on port 10000. To override this, add the `-Dquarkus.http.port=YOUR_PORT` + option at the end of the command below. + + ```bash + mvn quarkus:dev + ``` + +### Running the A2A Java Client + +The Java `TestClient` communicates with the Currency Agent using the A2A Java SDK. + +Since the A2A server agent's [transport](samples/java/agents/currency_exchange_rates/server/pom.xml) is JsonRpc and since our client +also [supports](client/src/main/java/com/samples/a2a/TestClient.java) gRPC and JsonRpc, the JsonRpc transport will be used. + +1. Make sure you have [JBang installed](https://www.jbang.dev/documentation/guide/latest/installation.html) + +2. Run the client using the JBang script: + + ```bash + cd samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client + jbang TestClientRunner.java + ``` +## Expected Client Output + +The Java A2A client will: +1. Connect to the currency agent +2. Fetch the agent card +3. Automatically select JsonRpc as the transport to be used +4. Send the message "how much is 10 USD in INR?" +5. Display the exchange rate result from the agent +6. Send the message "How much is the exchange rate for 1 USD?" +7. Receive the request for additional input "Please specify the currency you want to convert USD to" +8. Send additional information "CAD" +9. Display the exchange rate result from the agent + +## Learn More + +- [A2A Protocol Documentation](https://a2a-protocol.org/) +- [Frankfurter API](https://www.frankfurter.app/docs/) +- [Ollama](https://docs.ollama.com/) + +## Disclaimer +Important: The sample code provided is for demonstration purposes and illustrates the +mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, +it is critical to treat any agent operating outside of your direct control as a +potentially untrusted entity. + +All data received from an external agent—including but not limited to its AgentCard, +messages, artifacts, and task statuses—should be handled as untrusted input. For +example, a malicious agent could provide an AgentCard containing crafted data in its +fields (e.g., description, name, skills.description). If this data is used without +sanitization to construct prompts for a Large Language Model (LLM), it could expose +your application to prompt injection attacks. Failure to properly validate and +sanitize this data before use can introduce security vulnerabilities into your +application. + +Developers are responsible for implementing appropriate security measures, such as +input validation and secure handling of credentials to protect their systems and users. diff --git a/samples/java/agents/currency_exchange_rates/client/pom.xml b/samples/java/agents/currency_exchange_rates/client/pom.xml new file mode 100644 index 000000000..f274ecdbe --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/client/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + com.samples.a2a + currency_exchange_rates + 0.1.0 + + + currency-agent-client + Currency Agent Client + A2A Currency Agent Test Client + + + + io.github.a2asdk + a2a-java-sdk-client + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + ${io.a2a.sdk.version} + + + com.fasterxml.jackson.core + jackson-databind + + + io.grpc + grpc-netty-shaded + runtime + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.codehaus.mojo + exec-maven-plugin + + com.samples.a2a.TestClient + + + + + diff --git a/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/TestClient.java b/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/TestClient.java new file mode 100644 index 000000000..dc3706e10 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/TestClient.java @@ -0,0 +1,260 @@ +package com.samples.a2a.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.a2a.A2A; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskEvent; +import io.a2a.client.TaskUpdateEvent; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.http.A2ACardResolver; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Artifact; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskStatusUpdateEvent; +import io.a2a.spec.TextPart; +import io.a2a.spec.UpdateEvent; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; + +import java.io.EOFException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Creates an A2A client that sends a test message to the A2A server agent. + */ +public final class TestClient { + /** + * The default server URL to use. + */ + private static final String DEFAULT_SERVER_URL = "http://localhost:10000"; + + /** + * Object mapper to use. + */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TestClient() { + // this avoids a lint issue + } + + /** + * Client entry point. + * + * @param args is not taken into account + */ + public static void main(final String[] args) { + String serverUrl = DEFAULT_SERVER_URL; + + try { + System.out.println("Connecting to currency agent at: " + serverUrl); + + // Fetch the public agent card + AgentCard publicAgentCard = + new A2ACardResolver(serverUrl).getAgentCard(); + System.out.printf(""" + Successfully fetched public agent card: + %s + Using public agent card for client initialization.%n + """, OBJECT_MAPPER.writeValueAsString(publicAgentCard)); + + ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("text")) + .build(); + + // Create and send the message without INPUT_REQUIRED + runSingleTurnDemo(publicAgentCard, clientConfig); + + // Create and send the message with INPUT_REQUIRED + runMultiTurnDemo(publicAgentCard, clientConfig); + } catch (Exception e) { + System.err.println("An error occurred: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static void runMultiTurnDemo(final AgentCard publicAgentCard, + final ClientConfig clientConfig) { + StreamingClient streamingClient1 = getStreamingClient(publicAgentCard, + clientConfig); + StreamingClient streamingClient2 = getStreamingClient(publicAgentCard, + clientConfig); + + MessageResponse message1 = sendMessage(streamingClient1.client(), + "How much is the exchange rate for 1 USD?", null, null, + streamingClient1.messageResponse()); + + // Resubscribe to a task to send additional information for this task + streamingClient2.client().resubscribe(new TaskIdParams(message1.taskId()), + streamingClient2.consumers(), streamingClient2.streamingErrorHandler()); + sendMessage(streamingClient2.client(), "CAD", message1.contextId(), + message1.taskId(), streamingClient2.messageResponse()); + } + + private static void runSingleTurnDemo(final AgentCard publicAgentCard, + final ClientConfig clientConfig) { + StreamingClient streamingClient = getStreamingClient(publicAgentCard, + clientConfig); + sendMessage(streamingClient.client(), "how much is 10 USD in INR?", + null, null, streamingClient.messageResponse()); + } + + private static StreamingClient getStreamingClient( + final AgentCard publicAgentCard, final ClientConfig clientConfig) { + // Create a CompletableFuture to handle async response + CompletableFuture messageResponse = + new CompletableFuture<>(); + + // Create consumers for handling client events + List> consumers = + getConsumers(messageResponse); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = error -> { + if (!isStreamClosedError(error)) { + System.out.printf("Streaming error occurred: %s%n", error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + } + }; + + // Create channel factory + Function channelFactory = + agentUrl -> ManagedChannelBuilder.forTarget(agentUrl) + .usePlaintext().build(); + + // Create the client with both JSON-RPC and gRPC transport support + Client client = Client.builder(publicAgentCard) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler) + .withTransport(GrpcTransport.class, + new GrpcTransportConfig(channelFactory)) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig()) + .clientConfig(clientConfig) + .build(); + return new StreamingClient(messageResponse, consumers, + streamingErrorHandler, client); + } + + private static boolean isStreamClosedError(final Throwable throwable) { + // Unwrap the CompletionException + Throwable cause = throwable; + + while (cause != null) { + if (cause instanceof EOFException) { + return true; + } + if (cause instanceof IOException && cause.getMessage() != null + && cause.getMessage().contains("cancelled")) { + // stream is closed upon cancellation + return true; + } + cause = cause.getCause(); + } + return false; + } + + private record StreamingClient( + CompletableFuture messageResponse, + List> consumers, + Consumer streamingErrorHandler, Client client) { + } + + // Format for the response + public record MessageResponse(String message, String contextId, + String taskId) { + } + + private static MessageResponse + sendMessage(final Client client, + final String messageText, + final String contextId, + final String taskId, + final CompletableFuture messageResponse) { + Message message = A2A.toUserMessage(messageText); + + System.out.printf("Sending message: %s, %s, %s%n", messageText, contextId, + taskId); + client.sendMessage(message); + System.out.println("Message sent successfully. Waiting for response..."); + + try { + // Wait for response with timeout + MessageResponse responseText = messageResponse.get(); + System.out.printf("Message response: %s%n", responseText.message()); + return responseText; + } catch (Exception e) { + System.err.printf("Failed to get response: %s%n", e.getMessage()); + throw new RuntimeException(e); + } + } + + private static List> + getConsumers(final CompletableFuture messageResponse) { + List> consumers = new ArrayList<>(); + consumers.add( + (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + Message responseMessage = messageEvent.getMessage(); + String text = extractTextFromParts(responseMessage.getParts()); + System.out.println("Received message: " + text); + messageResponse.complete( + new MessageResponse(text, null, null)); + } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { + UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + if (updateEvent instanceof TaskStatusUpdateEvent + taskStatusUpdateEvent) { + System.out.println("Received status-update: " + + taskStatusUpdateEvent.getStatus().state().asString()); + if (taskStatusUpdateEvent.isFinal()) { + StringBuilder builder = new StringBuilder(); + List artifacts = + taskUpdateEvent.getTask().getArtifacts(); + for (Artifact artifact : artifacts) { + builder.append(extractTextFromParts(artifact.parts())); + } + messageResponse.complete(new MessageResponse(builder.toString(), + taskStatusUpdateEvent.getContextId(), + taskStatusUpdateEvent.getTaskId())); + } + } else if (updateEvent instanceof TaskArtifactUpdateEvent + taskArtifactUpdateEvent) { + List> parts = taskArtifactUpdateEvent.getArtifact().parts(); + String text = extractTextFromParts(parts); + System.out.println("Received artifact-update: " + text); + } + } else if (event instanceof TaskEvent taskEvent) { + System.out.println("Received task event: " + + taskEvent.getTask().getId()); + } + } + ); + return consumers; + } + + private static String extractTextFromParts(final List> parts) { + final StringBuilder textBuilder = new StringBuilder(); + if (parts != null) { + for (final Part part : parts) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } +} diff --git a/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/TestClientRunner.java new file mode 100644 index 000000000..ba2df7e66 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -0,0 +1,42 @@ +package com.samples.a2a.client; +/// usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.2.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.2.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.2.Final +//DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 +//DEPS io.grpc:grpc-netty-shaded:1.69.1 +//SOURCES TestClient.java + +/** + * JBang script to run the A2A TestClient example for the Currency Agent. This + * script automatically handles the dependencies and runs the client. + * + *

+ * Prerequisites: - JBang installed (see + * https://www.jbang.dev/documentation/guide/latest/installation.html) - A + * running Currency Agent server (see README.md for instructions on setting + * up the + * agent) + * + *

+ * Usage: $ jbang TestClientRunner.java + * + *

+ * The script will communicate with the Currency Agent server and send the + * message + * "how much is 10 USD in INR?" to demonstrate the A2A protocol interaction. + */ +public final class TestClientRunner { + + private TestClientRunner() { + // this avoids a lint issue + } + + /** + * Client entry point. + * @param args this methode doesn't take into account these args + */ + public static void main(final String[] args) { + TestClient.main(args); + } +} diff --git a/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/package-info.java b/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/package-info.java new file mode 100644 index 000000000..00e4fd512 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/client/src/main/java/com/samples/a2a/client/package-info.java @@ -0,0 +1,4 @@ +/** + * Currency Agent package. + */ +package com.samples.a2a.client; diff --git a/samples/java/agents/currency_exchange_rates/pom.xml b/samples/java/agents/currency_exchange_rates/pom.xml new file mode 100644 index 000000000..170089fd4 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + + com.samples.a2a + agents-parent + 0.1.0 + + + currency_exchange_rates + pom + + + server + client + + diff --git a/samples/java/agents/currency_exchange_rates/server/pom.xml b/samples/java/agents/currency_exchange_rates/server/pom.xml new file mode 100644 index 000000000..c1166fb94 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + com.samples.a2a + currency_exchange_rates + 0.1.0 + + + currency-agent-server + Currency Agent Server + A2A Currency Agent Server Implementation + + + + io.github.a2asdk + a2a-java-sdk-reference-jsonrpc + ${io.a2a.sdk.version} + + + io.quarkus + quarkus-rest-jackson + + + jakarta.enterprise + jakarta.enterprise.cdi-api + ${jakarta.enterprise.cdi-api.version} + + + jakarta.ws.rs + jakarta.ws.rs-api + + + io.quarkiverse.langchain4j + quarkus-langchain4j-ollama + ${quarkus.langchain4j.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.quarkus + quarkus-maven-plugin + + + + diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgent.java b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgent.java new file mode 100644 index 000000000..aa5c2e787 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgent.java @@ -0,0 +1,58 @@ +package com.samples.a2a.server; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Currency agent interface that provides currency conversions assistance. + */ +@RegisterAiService(tools = CurrencyService.class) +@ApplicationScoped +@SystemMessage(""" + You are a specialized assistant for currency conversions. + Your sole purpose is to use the 'getExchangeRate' tool to answer questions + about currency exchange rates. + If the user asks about anything other than currency conversion or exchange + rates, politely state that you cannot help with that topic and can only assist + with currency-related queries. + Do not attempt to answer unrelated questions or use tools for other purposes. + """) +public interface CurrencyAgent { + + /** + * Handle message and provide currency conversion. + * @param question the users' question + * @return the answer + */ + @SystemMessage(""" + Set response status to input_required if the user needs to provide more + information to complete the request. + Set response status to error if there is an error while processing + the request. + Set response status to completed you have an answer for the currency + exchange rate. + You must respond ONLY in valid JSON. + Do not include explanations, comments, or text outside the JSON object. + Your response MUST follow this JSON schema: + { + "status": "input_required | completed | error", + "message": "" + } + You must follow these rules when answering currency‑conversion questions: + 1. If the user provides BOTH the source currency and the target currency + (example: "How much is 10 USD in INR"), you must answer the question + directly. + 2. If the user provides ONLY the source currency without specifying the + target (example: "How much is the exchange rate for 1 USD"), do NOT + answer the conversion. + Instead, ask the user to provide the missing information. + Your question must be natural and specific to what is missing. + 3. Never assume or guess the target currency. + 4. Never provide an answer until all required currencies are explicitly + stated. + 5. You must replace all placeholders with real values. + """) + ResponseFormat handleRequest(@UserMessage String question); +} diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgentCardProducer.java b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgentCardProducer.java new file mode 100644 index 000000000..b0fc5f45b --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgentCardProducer.java @@ -0,0 +1,62 @@ +package com.samples.a2a.server; + +import io.a2a.server.PublicAgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentSkill; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.List; + +@ApplicationScoped +public final class CurrencyAgentCardProducer { + + /** + * The HTTP port for the agent service. + */ + @ConfigProperty(name = "quarkus.http.port") + private int httpPort; + + /** + * Gets the HTTP port. + * + * @return the HTTP port + */ + public int getHttpPort() { + return httpPort; + } + + /** + * Produces the agent card for the currency agent. + * + * @return the configured agent card + */ + @Produces + @PublicAgentCard + public AgentCard agentCard() { + return new AgentCard.Builder() + .name("Currency Agent") + .description("Assistant for currency conversions") + .url("http://localhost:" + getHttpPort()) + .version("1.0.0") + .capabilities( + new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(true) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of( + new AgentSkill.Builder() + .id("convert_currency") + .name("Currency Exchange Rates Tool") + .description("Helps with exchange values between various currencies") + .tags(List.of("currency conversion", "currency exchange")) + .examples(List.of("What is exchange rate between USD and GBP?")) + .build()) + ) + .build(); + } +} diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgentExecutorProducer.java b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgentExecutorProducer.java new file mode 100644 index 000000000..97b7c57a5 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyAgentExecutorProducer.java @@ -0,0 +1,147 @@ +package com.samples.a2a.server; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.events.EventQueue; +import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.JSONRPCError; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskState; +import io.a2a.spec.TextPart; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + +import java.util.List; + +/** + * Producer for currency agent executor. + */ +@ApplicationScoped +public final class CurrencyAgentExecutorProducer { + + /** + * The currency agent instance. + */ + private final CurrencyAgent agent; + + /** + * Constructor for CurrencyAgentExecutorProducer. + * + * @param currencyAgent the currency agent + */ + public CurrencyAgentExecutorProducer(final CurrencyAgent currencyAgent) { + this.agent = currencyAgent; + } + + /** + * The currency agent instance. + * + * @return the currency agent instance + */ + public CurrencyAgent getCurrencyAgent() { + return agent; + } + + /** + * Produces the agent executor for the currency agent. + * + * @return the configured agent executor + */ + @Produces + public AgentExecutor agentExecutor() { + return new CurrencyAgentExecutor(getCurrencyAgent()); + } + + /** + * Currency agent executor implementation. + */ + private class CurrencyAgentExecutor implements AgentExecutor { + /** + * The currency agent instance. + */ + private final CurrencyAgent agent; + + /** + * Constructor for CurrencyAgentExecutor. + * + * @param currencyAgent the currency agent instance + */ + CurrencyAgentExecutor(final CurrencyAgent currencyAgent) { + this.agent = currencyAgent; + } + + @Override + public void execute(final RequestContext context, + final EventQueue eventQueue) throws JSONRPCError { + executeLoop(context, eventQueue); + } + + void executeLoop(final RequestContext context, + final EventQueue eventQueue) { + var updater = new TaskUpdater(context, eventQueue); + if (context.getTask() == null) { + // Initial message - create task in SUBMITTED → WORKING state + updater.submit(); + updater.startWork(); + + getResponse(context, updater); + } else { + // Subsequent messages - add artifacts + getResponse(context, updater); + } + } + + private void getResponse(final RequestContext context, + final TaskUpdater updater) { + // extract the text from the message + var message = extractTextFromMessage(context.getMessage()); + + // call the currency agent with the message + ResponseFormat response = agent.handleRequest(message); + Log.infof("Response: %s", response); + + // create the response part + TextPart responsePart = new TextPart(response.message(), null); + List> parts = List.of(responsePart); + + // add the response as an artifact + updater.addArtifact(parts); + switch (response.status()) { + case INPUT_REQUIRED -> updater.requiresInput(true); + case COMPLETED -> updater.complete(); + case ERROR -> updater.fail(); + default -> throw new RuntimeException("Unknown status."); + } + } + + private String extractTextFromMessage(final Message message) { + final StringBuilder builder = new StringBuilder(); + if (message.getParts() != null) { + for (final Part part : message.getParts()) { + if (part instanceof TextPart textPart) { + builder.append(textPart.getText()); + } + } + } + return builder.toString(); + } + + @Override + public void cancel(final RequestContext context, + final EventQueue eventQueue) throws JSONRPCError { + var task = context.getTask(); + + if (task.getStatus().state() == TaskState.CANCELED + || task.getStatus().state() == TaskState.COMPLETED) { + // task already canceled or completed + throw new TaskNotCancelableError(); + } + + var updater = new TaskUpdater(context, eventQueue); + updater.cancel(); + } + } +} diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyService.java b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyService.java new file mode 100644 index 000000000..910b3af6d --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/CurrencyService.java @@ -0,0 +1,67 @@ +package com.samples.a2a.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +@ApplicationScoped +public final class CurrencyService { + + /** + * Object mapper to use. + */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * ERROR_CODE_400 to use. + */ + public static final int ERROR_CODE_400 = 400; + /** + * HttpClient to use. + */ + private final HttpClient client = HttpClient.newBuilder().build(); + + /** + * Provides currency conversions from one to another currency. + * + * @param currencyFrom source currency + * @param currencyTo target currency + * @return currency‑conversion rate + */ + @Tool("Provides currency conversions from one to another currency") + public Map getExchangeRate(final String currencyFrom, + final String currencyTo) { + String url = "https://api.frankfurter.app/latest"; + URI uri = URI.create(url + "?from=%s&to=%s".formatted(currencyFrom, + currencyTo)); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .GET() + .build(); + HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= ERROR_CODE_400) { + throw new RuntimeException("Request exchange rate failed with status " + + response.statusCode()); + } + + Map data = OBJECT_MAPPER.readValue(response.body(), + Map.class); + if (data == null || !data.containsKey("rates")) { + throw new RuntimeException( + "Invalid API response format from Frankfurter API"); + } + return data; + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Request exchange rate failed.", e); + } + } +} diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/ResponseFormat.java b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/ResponseFormat.java new file mode 100644 index 000000000..97242104f --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/ResponseFormat.java @@ -0,0 +1,18 @@ +package com.samples.a2a.server; + +public record ResponseFormat(Status status, String message) { + public enum Status { + /** + * INPUT_REQUIRED status. + */ + INPUT_REQUIRED, + /** + * COMPLETED status. + */ + COMPLETED, + /** + * ERROR status. + */ + ERROR + } +} diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/package-info.java b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/package-info.java new file mode 100644 index 000000000..4860241fb --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/java/com/samples/a2a/server/package-info.java @@ -0,0 +1,4 @@ +/** + * Currency Agent package. + */ +package com.samples.a2a.server; diff --git a/samples/java/agents/currency_exchange_rates/server/src/main/resources/application.properties b/samples/java/agents/currency_exchange_rates/server/src/main/resources/application.properties new file mode 100644 index 000000000..02ca86242 --- /dev/null +++ b/samples/java/agents/currency_exchange_rates/server/src/main/resources/application.properties @@ -0,0 +1,4 @@ +quarkus.http.port=10000 +quarkus.langchain4j.ollama.chat-model.model-id=qwen2.5:7b +quarkus.langchain4j.ollama.chat-model.temperature=0 +quarkus.langchain4j.ollama.timeout=180s diff --git a/samples/java/agents/pom.xml b/samples/java/agents/pom.xml index 8e6db5163..193a76053 100644 --- a/samples/java/agents/pom.xml +++ b/samples/java/agents/pom.xml @@ -15,6 +15,7 @@ dice_agent_multi_transport magic_8_ball_security weather_mcp + currency_exchange_rates