Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fe377b8
Add WebSocket endpoint for chatbot and Todo.description
emmanuelbernard Sep 24, 2025
4e661b6
Add agent protocol between fron and back
emmanuelbernard Sep 25, 2025
eea04ca
Added description and agent
phillip-kruger Sep 29, 2025
7cc9dcc
Clarify possible cause of problem when AI fallback is used
emmanuelbernard Sep 30, 2025
8273049
Log AI calls and allow OpenAI key
emmanuelbernard Sep 30, 2025
775db18
Move emitter call in the emitter block to avoid NPE
emmanuelbernard Sep 30, 2025
a453731
Fix typos in protocol spec
emmanuelbernard Sep 30, 2025
9306fe1
Add Json Message on backend
phillip-kruger Sep 30, 2025
d479202
Create A2A client stub
emmanuelbernard Sep 30, 2025
139ae82
Add test failing to subscribe on OnOpen
emmanuelbernard Sep 30, 2025
40bc84e
Use Vert.X bus instead of Multi for communication across beans
emmanuelbernard Oct 1, 2025
ab97b6a
Add AgentSelector AI Service
emmanuelbernard Oct 1, 2025
624321d
Finish support for end to end user to A2A agent server flow
emmanuelbernard Oct 2, 2025
822bc63
Support A2A server not connected
emmanuelbernard Oct 2, 2025
2355baa
Fix issue with user sent message in case we find no matching agent
emmanuelbernard Oct 2, 2025
f871dff
Use AgentCard to populate the AgentSelector prompt
emmanuelbernard Oct 2, 2025
565883f
Edit task edit feature
phillip-kruger Oct 3, 2025
8ce417e
Add second agent Movie Agent
emmanuelbernard Oct 3, 2025
7bbff80
Rename agent_request to agent_message and move packages for websocket…
emmanuelbernard Oct 3, 2025
a4f2145
Support absent A2A servers and render AgentProducer more generic
emmanuelbernard Oct 3, 2025
5fb6474
Clean code for demo
emmanuelbernard Oct 6, 2025
878b127
Rename addTAskArtifact
emmanuelbernard Oct 6, 2025
52e0fd4
Rename to A2AClient for clarity
emmanuelbernard Oct 7, 2025
12309ba
Replace type if structure with switch case
emmanuelbernard Oct 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.quarkus

# Avoid ignoring Maven wrapper jar file (.jar files are usually ignored)
!/.mvn/wrapper/maven-wrapper.jar
Expand All @@ -31,3 +32,10 @@ ObjectStore
/transcript.txt
/openai.sh
/podman.sh

# macOS
.DS_Store

# Environment
.env
.envrc
33 changes: 33 additions & 0 deletions agent-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Agent protocol between the front end and the back end

The protocol is WebSocket based.
The URL is /todo-agent/{todoId} (might change and drop the todoId at some point)

## Protocol messages

The protocol payload will be JSON with the following format

```
{ "kind": "test",
"todoId": 123,
"payload": "Some String"
}
```

`payload` is optional unless explicitly mentioned, `kind` and `todoId` are mandatory

The kinds of messages are as follow:
* `initialize` : a message the client must send right after opening the websocket
* `cancel` : a message to send when the user clicks on `Cancel AI` ; after which the websocket connection is closed from the client side
* `activity_log` : message sent by the server to the client when an activity info is to be displayed. Must have a `payload` field, this is text sent from the server that is to be displayed in **grey**, these strings are tokens / short and need to be concatenated in the "activity" screen, the server is responsible for sending `\n\n` (in a separate message or appended) when a message made of several tokens ends.
* `agent_message` : message sent by the server to the client when a request to the user is made. Must have a `payload` field, this is text sent from the server that is to be displayed in **dark**, these strings are tokens / short strings and need to be concatenated in the "activity" screen, the server is responsible for sending `\n\n` (in a separate message or appended) when a message made of several tokens ends.
* `user_message` : message sent by the client to the server when a user hits send on a chat message. Must have a `payload` field. The whole chat message is sent as one message.

## UX and interaction

Here is the UX and interaction
* the UI will have one popup but bigger
* it will allow you to edit or add a description and have `mark as done`, `delete` and `Do with AI` set of buttons
* when clicking on `Do with AI` the UI will expand a bit below to show a "chat UI" which will act as the activity window and that's when the connection with the websocket is initialized
* This activity shows logs from the server and so called agent requests (for context), this is when a user would send a chat message to the server
* the `do with AI` button is replaced by a `Cancel AI work` button
38 changes: 29 additions & 9 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
<name>TODOS Application</name>
<properties>
<compiler-plugin.version>3.11.0</compiler-plugin.version>
<maven.compiler.release>17</maven.compiler.release>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus</quarkus.platform.group-id>
<quarkus.platform.version>3.28.1</quarkus.platform.version>
<quarkus-langchain4j.version>1.2.0</quarkus-langchain4j.version>
<a2a-sdk.version>0.3.0.Beta1</a2a-sdk.version>
<skipITs>true</skipITs>
<surefire-plugin.version>3.1.2</surefire-plugin.version>
<failsafe-plugin.version>2.22.2</failsafe-plugin.version>
Expand All @@ -41,6 +42,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jsonb</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
Expand Down Expand Up @@ -81,11 +86,27 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-openai</artifactId>
<version>${quarkus-langchain4j.version}</version>
</dependency>


<dependency>
<groupId>io.github.a2asdk</groupId>
<artifactId>a2a-java-sdk-client</artifactId>
<version>${a2a-sdk.version}</version>
</dependency>
<dependency>
<groupId>io.github.a2asdk</groupId>
<artifactId>a2a-java-sdk-client-transport-jsonrpc</artifactId>
<version>${a2a-sdk.version}</version>
</dependency>


<!-- For the UI -->
Expand All @@ -95,6 +116,11 @@
<version>5.3.3</version>
<scope>runtime</scope>
</dependency>-->
<dependency>
<groupId>org.mvnpm.at.mvnpm</groupId>
<artifactId>vaadin-webcomponents</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-web-dependency-locator</artifactId>
Expand All @@ -104,12 +130,6 @@
<artifactId>quarkus-web-bundler</artifactId>
<version>1.7.3</version>
</dependency>-->
<dependency>
<groupId>org.mvnpm.at.mvnpm</groupId>
<artifactId>vaadin-webcomponents</artifactId>
<!--<scope>provided</scope>-->
<scope>runtime</scope>
</dependency>

<!-- Testing -->
<dependency>
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/io/quarkus/sample/Todo.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotBlank;
import java.util.List;

import org.eclipse.microprofile.openapi.annotations.media.Schema;

@Entity
Expand All @@ -16,6 +15,8 @@ public class Todo extends PanacheEntity {
@Column(unique = true)
public String title;

public String description;

public boolean completed;

@Column(name = "ordering")
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/io/quarkus/sample/TodoResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public Response update(@Valid Todo todo, @PathParam("id") Long id) {
entity.completed = todo.completed;
entity.order = todo.order;
entity.title = todo.title;
entity.description = todo.description;
entity.url = todo.url;

return Response.ok(entity).build();
Expand Down Expand Up @@ -114,7 +115,7 @@ public Response deleteOne(@PathParam("id") Long id) {
public Todo suggest() {
Todo suggestion = new Todo();

String title = ai.suggestSomethingTodo(1,"Features of my TODO list application");
String title = ai.suggestSomethingTodo(1,"Quarkus");
title = title.trim();
suggestion.title = title;
suggestion.persistAndFlush();
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/io/quarkus/sample/agents/A2AUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.quarkus.sample.agents;

import io.a2a.spec.Part;
import io.a2a.spec.TextPart;

import java.util.List;

/**
* @author Emmanuel Bernard [email protected]
*/
public class A2AUtils {
public static String extractTextFromParts(final List<Part<?>> 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();
}
}
7 changes: 7 additions & 0 deletions src/main/java/io/quarkus/sample/agents/AGENT.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.quarkus.sample.agents;

public enum AGENT {
WEATHER,
MOVIE,
NONE
}
28 changes: 28 additions & 0 deletions src/main/java/io/quarkus/sample/agents/AgentDescriptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.quarkus.sample.agents;

import io.a2a.spec.AgentCard;

/**
* @author Emmanuel Bernard [email protected]
*/
public class AgentDescriptor {
private final AGENT agent;
private final AgentCard card;

public AgentDescriptor(AGENT agent, AgentCard card) {
this.agent = agent;
this.card = card;
}

public AGENT getAgent() {
return agent;
}

public String getName() {
return card.name();
}

public String getDescription() {
return card.description();
}
}
164 changes: 164 additions & 0 deletions src/main/java/io/quarkus/sample/agents/AgentProducers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package io.quarkus.sample.agents;

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.jsonrpc.JSONRPCTransport;
import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig;
import io.a2a.spec.A2AClientException;
import io.a2a.spec.AgentCard;
import io.a2a.spec.Message;
import io.a2a.spec.TaskArtifactUpdateEvent;
import io.a2a.spec.TaskState;
import io.a2a.spec.TaskStatusUpdateEvent;
import io.a2a.spec.UpdateEvent;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Produces;
import org.eclipse.microprofile.config.inject.ConfigProperty;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;

import static io.quarkus.sample.agents.A2AUtils.extractTextFromParts;

/**
* @author Emmanuel Bernard [email protected]
*/
@ApplicationScoped
public class AgentProducers {

private Map<AGENT, Client> agents = new HashMap<>();

@Inject
AgentsMediator agentsMediator;

@Inject
@ConfigProperty(name = "agent.movie.url")
private String movieUrl;

@Inject
@ConfigProperty(name = "agent.weather.url")
private String weatherUrl;

@Produces
public Map<AGENT,AgentCard> getCards() {
Map<AGENT, AgentCard> cards = new HashMap<>();
addAgentCard(AGENT.WEATHER, weatherUrl, cards);
addAgentCard(AGENT.MOVIE, movieUrl, cards);
return cards;
}

private void addAgentCard(AGENT agent, String url, Map<AGENT, AgentCard> cards) {
try {
AgentCard publicAgentCard = new A2ACardResolver(url).getAgentCard();
Log.infov("Agent Card loaded: {0}", publicAgentCard.name());
cards.put(agent, publicAgentCard);
}
catch (Exception e) {
Log.warnv("Failed reach {0} at {1} because {2}", agent, url, e.getMessage());
}
}


public Client getA2AClient(AGENT agent) throws A2AClientException {
var client = agents.get(agent);
if (client == null) {
client = buildA2AClient(agent);
agents.put(agent, client);
}
return client;
}

private Client buildA2AClient(AGENT agent) throws A2AClientException {
// Create consumers for handling client events
List<BiConsumer<ClientEvent, AgentCard>> consumers = getConsumers();

// Create error handler for streaming errors
Consumer<Throwable> streamingErrorHandler = (error) -> {
Log.errorv("JDK streaming error occured {0}", error.getMessage());
//error.printStackTrace();
};
ClientConfig clientConfig = new ClientConfig.Builder()
.setAcceptedOutputModes(List.of("Text"))
.build();

// Create the client with both JSON-RPC and gRPC transport support.
// The A2A server agent's preferred transport is gRPC, since the client
// also supports gRPC, this is the transport that will get used
Client client = Client.builder(getCard(agent))
.addConsumers(consumers)
.streamingErrorHandler(streamingErrorHandler)
.withTransport(JSONRPCTransport.class,
new JSONRPCTransportConfig())
.clientConfig(clientConfig)
.build();
return client;
}

private AgentCard getCard(AGENT agent) {
return getCards().get(agent);
}

private List<BiConsumer<ClientEvent, AgentCard>> getConsumers() {
List<BiConsumer<ClientEvent, AgentCard>> consumers = new ArrayList<>();
consumers.add(
(event, agentCard) -> {
if (event instanceof MessageEvent messageEvent) {
Message responseMessage = messageEvent.getMessage();
String text = extractTextFromParts(responseMessage.getParts());
Log.infov("Received message: {0}", text);
agentsMediator.receiveMessageFromAgent(responseMessage);
//messageResponse.complete(text);
} else if (event instanceof TaskUpdateEvent taskUpdateEvent) {
UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent();
Log.infov(
"Received TaskUpdateEvent for {0}, status: {1}",
taskUpdateEvent.getTask().getId(),
taskUpdateEvent.getTask().getStatus().state()
);
if (updateEvent
instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) {
var status = taskStatusUpdateEvent.getStatus();
Log.infov( "Received status-update: {0} ", status.state());
agentsMediator.sendToActivityLog(taskStatusUpdateEvent);
if (taskStatusUpdateEvent.isFinal()) {
agentsMediator.sendArtifacts(taskUpdateEvent.getTask());
}
else if (status.state() == TaskState.INPUT_REQUIRED) {
agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), status);
}
} else if (updateEvent instanceof TaskArtifactUpdateEvent
taskArtifactUpdateEvent) {
agentsMediator.sendToActivityLog(taskArtifactUpdateEvent);
agentsMediator.sendArtifacts(taskArtifactUpdateEvent);
Log.infov("Received artifact-update for task {0}: {1}", taskArtifactUpdateEvent.getTaskId(), taskArtifactUpdateEvent.getArtifact().name());
}
} else if (event instanceof TaskEvent taskEvent) {
var task = taskEvent.getTask();
Log.infov("Received task event for {0}: status {1}", task.getId(), task.getStatus().state());
var state = task.getStatus().state();
agentsMediator.sendToActivityLog(taskEvent);
switch (state) {
case COMPLETED -> {
agentsMediator.sendArtifacts(task);
}
case INPUT_REQUIRED -> {
agentsMediator.sendInputRequired(task.getId(), task.getStatus());
}
}
}
});
return consumers;
}

}
Loading