From fe377b8e4cfc29ac9c951577998b87b4400f6753 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Wed, 24 Sep 2025 21:30:37 +0200 Subject: [PATCH 01/24] Add WebSocket endpoint for chatbot and Todo.description Will use WebSocket for bidirectional message to and from server Added a description for Todo for the new UI --- pom.xml | 4 +++ src/main/java/io/quarkus/sample/Todo.java | 2 ++ .../io/quarkus/sample/TodoAgentWebSocket.java | 34 +++++++++++++++++++ src/main/resources/import.sql | 2 +- .../io/quarkus/sample/TodoResourceTest.java | 2 +- 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/quarkus/sample/TodoAgentWebSocket.java diff --git a/pom.xml b/pom.xml index ab322a8..c335df7 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,10 @@ io.quarkus quarkus-rest-jsonb + + io.quarkus + quarkus-websockets-next + io.quarkus quarkus-smallrye-openapi diff --git a/src/main/java/io/quarkus/sample/Todo.java b/src/main/java/io/quarkus/sample/Todo.java index aaf51fa..a37fd79 100644 --- a/src/main/java/io/quarkus/sample/Todo.java +++ b/src/main/java/io/quarkus/sample/Todo.java @@ -16,6 +16,8 @@ public class Todo extends PanacheEntity { @Column(unique = true) public String title; + public String description; + public boolean completed; @Column(name = "ordering") diff --git a/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java new file mode 100644 index 0000000..5b9d2c4 --- /dev/null +++ b/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java @@ -0,0 +1,34 @@ +package io.quarkus.sample; + +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.subscription.MultiEmitter; +import jakarta.enterprise.context.control.ActivateRequestContext; + +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; + + +@WebSocket(path = "/todo-agent/{todoId}") +@ActivateRequestContext +public class TodoAgentWebSocket { + private MultiEmitter emitter; + private Multi agentStream; + + @OnOpen + Multi onOpen(@PathParam String todoId) { + this.agentStream = Multi.createFrom().emitter(emitter -> { + this.emitter = emitter; + // The emitter is now available for use elsewhere + }); + emitter.emit("ok we will find agents for todo " + todoId ); + return agentStream; + } + + @OnTextMessage + void onTextMessage(@PathParam String todoId, String message) { + //do something with the agent context + emitter.emit("Parroting for " + todoId + " : " + message); + } +} diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index 75badaf..a275340 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -1,4 +1,4 @@ INSERT INTO todo(id, title, completed, ordering, url) VALUES (nextval('todo_seq'), 'Introduction to Quarkus', true, 0, null); INSERT INTO todo(id, title, completed, ordering, url) VALUES (nextval('todo_seq'), 'Hibernate with Panache', false, 1, null); INSERT INTO todo(id, title, completed, ordering, url) VALUES (nextval('todo_seq'), 'Visit Quarkus web site', false, 2, 'https://quarkus.io'); -INSERT INTO todo(id, title, completed, ordering, url) VALUES (nextval('todo_seq'), 'Star Quarkus project', false, 3, 'https://github.com/quarkusio/quarkus/'); +INSERT INTO todo(id, title, completed, ordering, description, url) VALUES (nextval('todo_seq'), 'Star Quarkus project', false, 3, 'Go to GitHub. Find quarkus in the search engine. Star the project. And mention it to your friends!', 'https://github.com/quarkusio/quarkus/'); \ No newline at end of file diff --git a/src/test/java/io/quarkus/sample/TodoResourceTest.java b/src/test/java/io/quarkus/sample/TodoResourceTest.java index 4953287..37f219c 100644 --- a/src/test/java/io/quarkus/sample/TodoResourceTest.java +++ b/src/test/java/io/quarkus/sample/TodoResourceTest.java @@ -87,7 +87,7 @@ private static Stream todoItemsToDelete() { Arguments.of(15, 404)); } - private static final String ALL = "[{\"id\":1,\"completed\":true,\"order\":0,\"title\":\"Introduction to Quarkus\"},{\"id\":51,\"completed\":false,\"order\":1,\"title\":\"Hibernate with Panache\"},{\"id\":101,\"completed\":false,\"order\":2,\"title\":\"Visit Quarkus web site\",\"url\":\"https://quarkus.io\"},{\"id\":151,\"completed\":false,\"order\":3,\"title\":\"Star Quarkus project\",\"url\":\"https://github.com/quarkusio/quarkus/\"}]"; + private static final String ALL = "[{\"id\":1,\"completed\":true,\"order\":0,\"title\":\"Introduction to Quarkus\"},{\"id\":51,\"completed\":false,\"order\":1,\"title\":\"Hibernate with Panache\"},{\"id\":101,\"completed\":false,\"order\":2,\"title\":\"Visit Quarkus web site\",\"url\":\"https://quarkus.io\"},{\"id\":151,\"completed\":false,\"description\":\"Go to GitHub. Find quarkus in the search engine. Star the project. And mention it to your friends!\",\"order\":3,\"title\":\"Star Quarkus project\",\"url\":\"https://github.com/quarkusio/quarkus/\"}]"; private static final String ONE = "{\"id\":1,\"completed\":true,\"order\":0,\"title\":\"Introduction to Quarkus\"}"; From 4e661b63f8a49b1fa8c080afac1c6f241d48117c Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Thu, 25 Sep 2025 10:25:55 +0200 Subject: [PATCH 02/24] Add agent protocol between fron and back --- agent-protocol.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 agent-protocol.md diff --git a/agent-protocol.md b/agent-protocol.md new file mode 100644 index 0000000..5f42dd5 --- /dev/null +++ b/agent-protocol.md @@ -0,0 +1,33 @@ +# Agent protocol between the front end and teh 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_request` : 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 hit 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" whcih 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 \ No newline at end of file From eea04ca235d4833860c4edd68b8f915aeddb2c4c Mon Sep 17 00:00:00 2001 From: Phillip Kruger Date: Mon, 29 Sep 2025 16:25:50 +1000 Subject: [PATCH 03/24] Added description and agent Signed-off-by: Phillip Kruger --- pom.xml | 11 +- src/main/java/io/quarkus/sample/Todo.java | 1 - .../java/io/quarkus/sample/TodoResource.java | 3 +- .../io/quarkus/sample/ai/TodoAiService.java | 1 - .../sample/ai/TodoRetrievalAugmentor.java | 1 - src/main/resources/application.properties | 1 + src/main/resources/web/app/todos-cards.js | 11 +- src/main/resources/web/app/todos-detail.js | 387 ++++++++++++++++++ src/main/resources/web/app/todos-task.js | 107 ++++- 9 files changed, 499 insertions(+), 24 deletions(-) create mode 100644 src/main/resources/web/app/todos-detail.js diff --git a/pom.xml b/pom.xml index c335df7..33a7ca4 100644 --- a/pom.xml +++ b/pom.xml @@ -99,6 +99,11 @@ 5.3.3 runtime --> + + org.mvnpm.at.mvnpm + vaadin-webcomponents + runtime + io.quarkus quarkus-web-dependency-locator @@ -108,12 +113,6 @@ quarkus-web-bundler 1.7.3 --> - - org.mvnpm.at.mvnpm - vaadin-webcomponents - - runtime - diff --git a/src/main/java/io/quarkus/sample/Todo.java b/src/main/java/io/quarkus/sample/Todo.java index a37fd79..402be31 100644 --- a/src/main/java/io/quarkus/sample/Todo.java +++ b/src/main/java/io/quarkus/sample/Todo.java @@ -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 diff --git a/src/main/java/io/quarkus/sample/TodoResource.java b/src/main/java/io/quarkus/sample/TodoResource.java index 2bc8b7c..d69a20a 100644 --- a/src/main/java/io/quarkus/sample/TodoResource.java +++ b/src/main/java/io/quarkus/sample/TodoResource.java @@ -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(); @@ -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(); diff --git a/src/main/java/io/quarkus/sample/ai/TodoAiService.java b/src/main/java/io/quarkus/sample/ai/TodoAiService.java index 1c29946..ad9a5dd 100644 --- a/src/main/java/io/quarkus/sample/ai/TodoAiService.java +++ b/src/main/java/io/quarkus/sample/ai/TodoAiService.java @@ -5,7 +5,6 @@ import dev.langchain4j.service.UserMessage; import io.quarkiverse.langchain4j.RegisterAiService; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.context.SessionScoped; import java.time.temporal.ChronoUnit; import org.eclipse.microprofile.faulttolerance.Fallback; import org.eclipse.microprofile.faulttolerance.Timeout; diff --git a/src/main/java/io/quarkus/sample/ai/TodoRetrievalAugmentor.java b/src/main/java/io/quarkus/sample/ai/TodoRetrievalAugmentor.java index 1f6555a..66466cc 100644 --- a/src/main/java/io/quarkus/sample/ai/TodoRetrievalAugmentor.java +++ b/src/main/java/io/quarkus/sample/ai/TodoRetrievalAugmentor.java @@ -7,7 +7,6 @@ import dev.langchain4j.rag.DefaultRetrievalAugmentor; import dev.langchain4j.rag.RetrievalAugmentor; -import dev.langchain4j.rag.query.transformer.CompressingQueryTransformer; @ApplicationScoped public class TodoRetrievalAugmentor implements Supplier { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 22ffcad..22a3c61 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -24,5 +24,6 @@ quarkus.swagger-ui.always-include=true # AI quarkus.langchain4j.openai.api-key=demo +quarkus.langchain4j.openai.chat-model.model-name=gpt-5-mini quarkus.langchain4j.openai.timeout=60s %dev.quarkus.datasource.dev-ui.allow-sql=true diff --git a/src/main/resources/web/app/todos-cards.js b/src/main/resources/web/app/todos-cards.js index 9b4d461..334e02c 100644 --- a/src/main/resources/web/app/todos-cards.js +++ b/src/main/resources/web/app/todos-cards.js @@ -163,7 +163,16 @@ class TodosCards extends LitElement { } _renderItem(task){ - return html`
`; + return html` +
`; } _renderSuggestion(){ diff --git a/src/main/resources/web/app/todos-detail.js b/src/main/resources/web/app/todos-detail.js new file mode 100644 index 0000000..f187f10 --- /dev/null +++ b/src/main/resources/web/app/todos-detail.js @@ -0,0 +1,387 @@ +import {LitElement, html, css} from 'lit'; +import '@vaadin/vaadin-lumo-styles/vaadin-iconset.js'; +import '@vaadin/icon'; +import '@vaadin/icons'; +import '@vaadin/text-field'; +import '@vaadin/message-list'; +import '@vaadin/message-input'; +import '@vaadin/horizontal-layout'; +import '@vaadin/progress-bar'; + +export const WEBSOCKET_BASE = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}`; + +class TodosDetail extends LitElement { + static styles = css` + .dialogContents { + display: flex; + gap: 1rem; + flex-direction: column; + min-height: 50vh; + min-width: 60vw; + justify-content: space-between; + overflow: hidden; + } + .heading { + background: var(--lumo-primary-color-50pct); + padding: 8px; + font-size: var(--lumo-font-size-xl); + color: var(--lumo-success-contrast-color); + border-width: 2px; + border-style: solid; + border-color: var(--lumo-contrast-5pct); + border-radius: 10px; + } + .description { + width: 100%; + } + .done-text { + text-decoration: line-through; + } + .done-icon { + color: var(--lumo-success-color-50pct); + } + .outstanding-icon { + color: var(--lumo-contrast-30pct); + } + .sub { + color: var(--lumo-contrast-70pct); + } + .badgeAndUrl { + display: flex; + justify-content: space-between; + align-items: center; + } + .badge { + background-color: var(--lumo-contrast-50pct); + color: white; + padding: 1px 4px; + text-align: right; + border-radius: 3px; + } + .doWithAiButton { + display: flex; + justify-content: center; + cursor: pointer; + color: var(--lumo-success-text-color); + font-size: var(--lumo-font-size-l); + } + + .doWithAiButton :hover{ + font-size: var(--lumo-font-size-xl); + } + + .aiMessages { + display: flex; + flex-direction: column; + justify-content: end; + flex: 1; + max-height: 50vh; + } + + .aiMessageBoard { + flex: 1; + } + .aiMessageInput { + display: flex; + align-items: center; + justify-content: space-between; + } + vaadin-message-input { + flex: 1; + } + .activityLog { + color: var(--lumo-contrast-50pct); + } + `; + + static properties = { + id: {type: Number}, + task: {type: String}, + description: {type: String}, + order: {type: Number}, + url: {type: String}, + done: {type: Boolean, reflect: true}, + _isInEditMode: {type: Boolean}, + _messageListItems: {type: Array}, + _isConnected: {type: Boolean} + }; + + constructor() { + super(); + this.id = -1; + this.task = ""; + this.description = ""; + this.order = 0; + this.url = null; + this.done = false; + this._isInEditMode = false; + this._messageListItems = []; + this._isConnected = false; + this._webSocket = null; + } + + connectedCallback() { + super.connectedCallback(); + if(this.description){ + this._isInEditMode = false; + }else{ + this._isInEditMode = true; + } + } + + disconnectedCallback() { + this._cancelDoWithAI(); + super.disconnectedCallback(); + } + + render() { + if(this.task){ + return html`
+
+
+ + ${this.task} +
+ ${this._renderDescription()} +
+ ${this._renderCenter()} +
+ ${this.id} + ${this._renderUrl()} +
+
`; + } + } + + _renderDescription(){ + return html` + (this.description = e.detail.value)} + @keydown=${this._maybeSaveOnEnter} + > + ${!this._isInEditMode + ? html`` + : html``} + + `; + } + + _renderCenter(){ + if(this._isConnected){ + return this._renderAIMessageList(); + }else{ + return this._renderDoWithAIButton(); + } + } + + _renderDoWithAIButton(){ + return html`
+ + Do with AI + +
`; + } + + _maybeSaveOnEnter(e) { + if (e.key === 'Enter') { + e.preventDefault(); + this._saveDescription(); + } + } + + _saveDescription(e){ + this._isInEditMode = false; + this._updateTask(); + } + + _editDescription(e){ + this._isInEditMode = true; + } + + _renderUrl(){ + if(this.url){ + return html``; + } + } + + _openUrl(e) { + e.stopPropagation(); + if (this.url) window.open(this.url, '_blank', 'noopener'); + }; + + _icon(){ + if(this.done){ + return "vaadin:check-square-o"; + }else { + return "vaadin:thin-square"; + } + } + + _iconClass(){ + if(this.done){ + return "done-icon"; + }else { + return "outstanding-icon"; + } + } + + _textClass(){ + if(this.done){ + return "done-text"; + }else { + return "outstanding-text"; + } + } + + _updateTask(){ + + let updatedTask = { + id: this.id, + title: this.task, + description: this.description, + completed: this.done, + order: this.order, + url: this.url + }; + + const request = new Request('/api/' + this.id, { + method: 'PATCH', + body: JSON.stringify(updatedTask), + headers: { + 'Content-Type': 'application/json' + } + }); + fetch(request); + } + + _doWithAI(e){ + // TODO: Should this only be possible if there is a description ? + this._webSocket = new WebSocket(WEBSOCKET_BASE + "/todo-agent/" + this.id); + + this._webSocket.addEventListener('open', () => { + this._isConnected = true; + this._sendToBackend('initialize'); + }); + + this._webSocket.addEventListener('message', (event) => { + console.log(event.data); + var agentMessage = JSON.parse(event.data); + + if(agentMessage.kind && agentMessage.kind === "activity_log"){ + this._messageListItems = [...this._messageListItems, this._createLogMessage(agentMessage.payload)]; + }else if(agentMessage.kind && agentMessage.kind === "agent_request"){ + this._messageListItems = [...this._messageListItems, this._createAgentMessage(agentMessage.payload)]; + }else{ + // TODO: Show in messages + console.log("Unknown response " + event.data); + } + }); + + this._webSocket.addEventListener('close', (e) => { + this._webSocket = null; + this._isConnected = false; + }); + + this._webSocket.addEventListener('error', (error) => { + console.log(error); + this._cancelDoWithAI(); + }); + } + + _cancelDoWithAI(){ + if(this._webSocket){ + this._sendToBackend("cancel"); + this._webSocket.close(); + } + this._webSocket = null; + this._isConnected = false; + } + + _renderAIMessageList(){ + return html`
+ +
+ + Cancel +
+
+ `; + } + + _renderProgressBar(message) { + return html` +
+ + + + + +
+ `; + } + + _createUserMessage(text){ + return { + text, + userName: 'User', + userColorIndex: 1 + }; + } + + _createAgentMessage(text) { + return { + text, + userName: 'Assistant', + userColorIndex: 2 + }; + } + + _createLogMessage(text) { + return { + text, + userName: 'Log', + userColorIndex: 3, + className: 'activityLog' + }; + } + + _handleChatSubmit(e){ + const userInput = e.detail.value; + this._messageListItems = [...this._messageListItems, this._createUserMessage(userInput)]; + this._sendToBackend("user_message", userInput); + // TODO: Show progress + } + + _sendToBackend(kind, message = ''){ + if(this._webSocket) { + this._webSocket.send(this._createWsPayload(kind, message)); + } + } + + _createWsPayload(kind, message = ''){ + let wspayload = {}; + if(message) { + wspayload = { + "kind": kind, + "todoId": this.id, + "payload": message + }; + }else{ + wspayload = { + "kind": kind, + "todoId": this.id + }; + } + + return JSON.stringify(wspayload); + } + +} +customElements.define('todos-detail', TodosDetail); \ No newline at end of file diff --git a/src/main/resources/web/app/todos-task.js b/src/main/resources/web/app/todos-task.js index f486a71..79ce31c 100644 --- a/src/main/resources/web/app/todos-task.js +++ b/src/main/resources/web/app/todos-task.js @@ -2,6 +2,9 @@ import {LitElement, html, css} from 'lit'; import '@vaadin/icon'; import '@vaadin/vaadin-lumo-styles/vaadin-iconset.js'; import '@vaadin/icons'; +import '@vaadin/dialog'; +import { dialogFooterRenderer, dialogRenderer } from '@vaadin/dialog/lit.js'; +import './todos-detail.js'; class TodosTask extends LitElement { static styles = css` @@ -40,37 +43,39 @@ class TodosTask extends LitElement { static properties = { id: {type: Number}, task: {type: String}, + description: {type: String}, + order: {type: Number}, + url: {type: String}, done: {type: Boolean, reflect: true}, - _deleteButtonClass: {type: Boolean, attribute: false}, + _deleteButtonClass: {type: String, attribute: false}, + _dialogOpened: {type: Boolean} }; constructor() { super(); this.id = -1; this.task = ""; + this.description = ""; + this.order = 0; + this.url = null; this.done = false; this._deleteButtonClass = "hide"; + this._dialogOpened = false; } connectedCallback() { - super.connectedCallback() + super.connectedCallback(); this.addEventListener('mouseenter', this._handleMouseenter); this.addEventListener('mouseleave', this._handleMouseleave); } render() { if(this.task){ - let icon = "vaadin:thin-square"; - let iconClass = "outstanding-icon"; - let textClass = "outstanding-text"; - if(this.done){ - icon = "vaadin:check-square-o"; - iconClass = "done-icon"; - textClass = "done-text"; - } - return html` - - ${this.task} + return html`${this._renderDialog()} + + ${this.task} ${this._renderDeleteButton()} `; } @@ -80,6 +85,57 @@ class TodosTask extends LitElement { return html``; } + _renderDialog() { + return html` + `; + } + + _renderDialogContents(){ + return html` + `; + } + + _renderDialogFooter(){ + let t = "Mark as done"; + let c = "success-"; + + if(this.done){ + t = "Mark as undone"; + c = ""; + } + + return html` + Delete + ${t} + `; + } + + _renderUrl(){ + if(this.url){ + return html``; + } + } + + _openUrl(e) { + e.stopPropagation(); + if (this.url) window.open(this.url, '_blank', 'noopener'); + }; + _handleMouseenter(){ this._deleteButtonClass = "delete-icon"; } @@ -96,6 +152,31 @@ class TodosTask extends LitElement { _delete(event){ event = new CustomEvent('delete', {detail: this.id, bubbles: true, composed: true}); this.dispatchEvent(event); + this._dialogOpened = false; + } + + _icon(){ + if(this.done){ + return "vaadin:check-square-o"; + }else { + return "vaadin:thin-square"; + } + } + + _iconClass(){ + if(this.done){ + return "done-icon"; + }else { + return "outstanding-icon"; + } + } + + _textClass(){ + if(this.done){ + return "done-text"; + }else { + return "outstanding-text"; + } } } From 7cc9dcccf3e99275c0ee6f747c81c9903ca2ccd5 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 30 Sep 2025 10:44:23 +0200 Subject: [PATCH 04/24] Clarify possible cause of problem when AI fallback is used --- src/main/java/io/quarkus/sample/ai/TodoAiService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/quarkus/sample/ai/TodoAiService.java b/src/main/java/io/quarkus/sample/ai/TodoAiService.java index ad9a5dd..cef1a17 100644 --- a/src/main/java/io/quarkus/sample/ai/TodoAiService.java +++ b/src/main/java/io/quarkus/sample/ai/TodoAiService.java @@ -38,6 +38,6 @@ Do NOT add a new line (\\n). String suggestSomethingTodo(@MemoryId int memoryId, String subject); default String fallback(int memoryId,String subject) { - return "Fix AI integration"; + return "Fix AI integration (missing key?)"; } } \ No newline at end of file From 827304933b099dee018f4ea4f0bb199922bdecc2 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 30 Sep 2025 10:44:57 +0200 Subject: [PATCH 05/24] Log AI calls and allow OpenAI key --- src/main/resources/application.properties | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 22a3c61..5429863 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -23,7 +23,10 @@ quarkus.swagger-ui.always-include=true %prod.quarkus.datasource.username=restcrud # AI -quarkus.langchain4j.openai.api-key=demo +quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY:demo} quarkus.langchain4j.openai.chat-model.model-name=gpt-5-mini quarkus.langchain4j.openai.timeout=60s %dev.quarkus.datasource.dev-ui.allow-sql=true +quarkus.langchain4j.log-requests=true +quarkus.langchain4j.log-responses=true + From 775db18cd1abf55f6194efc3e68603f868d25253 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 30 Sep 2025 10:45:49 +0200 Subject: [PATCH 06/24] Move emitter call in the emitter block to avoid NPE --- src/main/java/io/quarkus/sample/TodoAgentWebSocket.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java index 5b9d2c4..2542d85 100644 --- a/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java @@ -20,9 +20,9 @@ public class TodoAgentWebSocket { Multi onOpen(@PathParam String todoId) { this.agentStream = Multi.createFrom().emitter(emitter -> { this.emitter = emitter; + emitter.emit("ok we will find agents for todo " + todoId ); // The emitter is now available for use elsewhere }); - emitter.emit("ok we will find agents for todo " + todoId ); return agentStream; } From a453731203e4257dcefc104fd0699ce9151a44f0 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 30 Sep 2025 10:52:59 +0200 Subject: [PATCH 07/24] Fix typos in protocol spec --- agent-protocol.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agent-protocol.md b/agent-protocol.md index 5f42dd5..ad667f3 100644 --- a/agent-protocol.md +++ b/agent-protocol.md @@ -1,4 +1,4 @@ -# Agent protocol between the front end and teh back end +# 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) @@ -21,13 +21,13 @@ The kinds of messages are as follow: * `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_request` : 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 hit send on a chat message. Must have a `payload` field. The whole chat message is sent as one message. +* `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" whcih will act as the activity window and that's when the connection with the websocket is initialized +* 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 \ No newline at end of file From 9306fe1d430a3a7c7eaf31efb2fdde18caeff1a4 Mon Sep 17 00:00:00 2001 From: Phillip Kruger Date: Tue, 30 Sep 2025 19:46:27 +1000 Subject: [PATCH 08/24] Add Json Message on backend Signed-off-by: Phillip Kruger --- .../io/quarkus/sample/TodoAgentWebSocket.java | 34 ----------- .../io/quarkus/sample/agent/AgentMessage.java | 5 ++ .../java/io/quarkus/sample/agent/Kind.java | 5 ++ .../sample/agent/TodoAgentWebSocket.java | 56 +++++++++++++++++++ src/main/resources/web/app/todos-detail.js | 2 +- 5 files changed, 67 insertions(+), 35 deletions(-) delete mode 100644 src/main/java/io/quarkus/sample/TodoAgentWebSocket.java create mode 100644 src/main/java/io/quarkus/sample/agent/AgentMessage.java create mode 100644 src/main/java/io/quarkus/sample/agent/Kind.java create mode 100644 src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java diff --git a/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java deleted file mode 100644 index 2542d85..0000000 --- a/src/main/java/io/quarkus/sample/TodoAgentWebSocket.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.quarkus.sample; - -import io.smallrye.mutiny.Multi; -import io.smallrye.mutiny.subscription.MultiEmitter; -import jakarta.enterprise.context.control.ActivateRequestContext; - -import io.quarkus.websockets.next.OnOpen; -import io.quarkus.websockets.next.OnTextMessage; -import io.quarkus.websockets.next.PathParam; -import io.quarkus.websockets.next.WebSocket; - - -@WebSocket(path = "/todo-agent/{todoId}") -@ActivateRequestContext -public class TodoAgentWebSocket { - private MultiEmitter emitter; - private Multi agentStream; - - @OnOpen - Multi onOpen(@PathParam String todoId) { - this.agentStream = Multi.createFrom().emitter(emitter -> { - this.emitter = emitter; - emitter.emit("ok we will find agents for todo " + todoId ); - // The emitter is now available for use elsewhere - }); - return agentStream; - } - - @OnTextMessage - void onTextMessage(@PathParam String todoId, String message) { - //do something with the agent context - emitter.emit("Parroting for " + todoId + " : " + message); - } -} diff --git a/src/main/java/io/quarkus/sample/agent/AgentMessage.java b/src/main/java/io/quarkus/sample/agent/AgentMessage.java new file mode 100644 index 0000000..692a2d9 --- /dev/null +++ b/src/main/java/io/quarkus/sample/agent/AgentMessage.java @@ -0,0 +1,5 @@ +package io.quarkus.sample.agent; + +public record AgentMessage(Kind kind, String todoId, String payload) { + +} diff --git a/src/main/java/io/quarkus/sample/agent/Kind.java b/src/main/java/io/quarkus/sample/agent/Kind.java new file mode 100644 index 0000000..032ca3b --- /dev/null +++ b/src/main/java/io/quarkus/sample/agent/Kind.java @@ -0,0 +1,5 @@ +package io.quarkus.sample.agent; + +public enum Kind { + cancel, activity_log, agent_request, user_message +} diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java new file mode 100644 index 0000000..47d844d --- /dev/null +++ b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java @@ -0,0 +1,56 @@ +package io.quarkus.sample.agent; + +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.subscription.MultiEmitter; +import jakarta.enterprise.context.control.ActivateRequestContext; + +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.PathParam; +import io.quarkus.websockets.next.WebSocket; +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; + + +@WebSocket(path = "/todo-agent/{todoId}") +@ActivateRequestContext +public class TodoAgentWebSocket { + private MultiEmitter emitter; + private Multi agentStream; + private final Jsonb jsonb = JsonbBuilder.create(); + + @OnOpen + Multi onOpen(@PathParam String todoId) { + this.agentStream = Multi.createFrom().emitter(emitter -> { + this.emitter = emitter; + this.sendActivityLogMessage(todoId,"ok we will find agents for todo " + todoId ); + }); + + return agentStream; + } + + @OnTextMessage + void onTextMessage(@PathParam String todoId, String message) { + AgentMessage agentMessage = jsonb.fromJson(message, AgentMessage.class); + + if(agentMessage.kind().equals(Kind.user_message)){ + String userMessage = agentMessage.payload(); + // TODO: Should we check the todoId from the path against the one in the message ? Do we need both ? + this.sendAgentRequestMessage(todoId, "Parroting for " + todoId + " : " + userMessage); + }else if(agentMessage.kind().equals(Kind.cancel)) { + System.out.println(">>>> Cancel !"); + // TODO: Cancel + }else { + System.out.println(">>>> Ignore !"); + // TODO: Ignore ? Maybe add an error type ? We also need to handle json parsing errors + } + } + + private void sendActivityLogMessage(String todoId, String message){ + emitter.emit(jsonb.toJson(new AgentMessage(Kind.activity_log, todoId, message))); + } + + private void sendAgentRequestMessage(String todoId, String message){ + emitter.emit(jsonb.toJson(new AgentMessage(Kind.agent_request, todoId, message))); + } +} diff --git a/src/main/resources/web/app/todos-detail.js b/src/main/resources/web/app/todos-detail.js index f187f10..d07188c 100644 --- a/src/main/resources/web/app/todos-detail.js +++ b/src/main/resources/web/app/todos-detail.js @@ -267,7 +267,6 @@ class TodosDetail extends LitElement { this._webSocket.addEventListener('open', () => { this._isConnected = true; - this._sendToBackend('initialize'); }); this._webSocket.addEventListener('message', (event) => { @@ -302,6 +301,7 @@ class TodosDetail extends LitElement { } this._webSocket = null; this._isConnected = false; + this._messageListItems = []; } _renderAIMessageList(){ From d4792022fc779aeda991f6aca0900ff92cf5949e Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 30 Sep 2025 17:57:09 +0200 Subject: [PATCH 09/24] Create A2A client stub --- pom.xml | 17 +++ .../sample/agents/AgentDispatcher.java | 18 +++ .../sample/agents/WeatherAgentProducer.java | 135 ++++++++++++++++++ src/main/resources/application.properties | 2 + 4 files changed, 172 insertions(+) create mode 100644 src/main/java/io/quarkus/sample/agents/AgentDispatcher.java create mode 100644 src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java diff --git a/pom.xml b/pom.xml index 33a7ca4..354bc5a 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ io.quarkus 3.28.1 1.2.0 + 0.3.0.Beta1 true 3.1.2 2.22.2 @@ -85,11 +86,27 @@ io.quarkus quarkus-websockets
+ + io.quarkus + quarkus-messaging + io.quarkiverse.langchain4j quarkus-langchain4j-openai ${quarkus-langchain4j.version} + + + + io.github.a2asdk + a2a-java-sdk-client + ${a2a-sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-jsonrpc + ${a2a-sdk.version} + diff --git a/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java b/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java new file mode 100644 index 0000000..4d883c2 --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java @@ -0,0 +1,18 @@ +package io.quarkus.sample.agents; + +import io.a2a.spec.AgentCard; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * @author Emmanuel Bernard emmanuel@hibernate.org + */ +@ApplicationScoped +public class AgentDispatcher { + @Inject @WeatherAgentProducer.WeatherAgent + private AgentCard weatherCard; + + public String sendInfo() { + return weatherCard.name(); + } +} diff --git a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java new file mode 100644 index 0000000..d7e123a --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java @@ -0,0 +1,135 @@ +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.http.A2ACardResolver; +import io.a2a.spec.A2AClientError; +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.TaskStatusUpdateEvent; +import io.a2a.spec.TextPart; +import io.a2a.spec.UpdateEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Qualifier; +import jakarta.ws.rs.Produces; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.quarkus.logging.Log; + +/** + * @author Emmanuel Bernard emmanuel@hibernate.org + */ +@ApplicationScoped +public class WeatherAgentProducer { + //private static final Logger LOG = Logger.getLogger(WeatherAgentProducer.class); + + @Target({ElementType.METHOD, ElementType.FIELD}) + @Retention(RetentionPolicy.RUNTIME) + @Qualifier + public @interface WeatherAgent {} + + @Inject + @ConfigProperty(name = "agent.weather.url") + private String url; + + @Produces @WeatherAgent + public AgentCard getCard() throws A2AClientError { + AgentCard publicAgentCard = new A2ACardResolver(url).getAgentCard(); + Log.infov("Weather card loaded: {0}", publicAgentCard.name()); + return publicAgentCard; + } + + + @Produces @WeatherAgent + public Client getA2aClient() throws A2AClientError { + // Create a CompletableFuture to handle async response + final CompletableFuture messageResponse + = new CompletableFuture<>(); + + // Create consumers for handling client events + List> consumers + = getConsumers(messageResponse); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = (error) -> { + System.out.println("Streaming error occurred: " + error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + }; + //Client client = Client.builder(getCard()) + return null; + } + + 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(text); + } 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 textBuilder = new StringBuilder(); + List artifacts + = taskUpdateEvent.getTask().getArtifacts(); + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + String text = textBuilder.toString(); + messageResponse.complete(text); + } + } 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/src/main/resources/application.properties b/src/main/resources/application.properties index 5429863..2f28fbf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,3 +30,5 @@ quarkus.langchain4j.openai.timeout=60s quarkus.langchain4j.log-requests=true quarkus.langchain4j.log-responses=true +# Agents +agent.weather.url = http://localhost:10010 \ No newline at end of file From 139ae82081338ae1ccb4320cbe3b8a88c16fedc9 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 30 Sep 2025 17:59:12 +0200 Subject: [PATCH 10/24] Add test failing to subscribe on OnOpen To test, go to UI, click on a TODO then click on Create with AI green button Then look at stack trace --- .../sample/agent/TodoAgentWebSocket.java | 79 ++++++++++++++++--- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java index 47d844d..bdca870 100644 --- a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java @@ -1,32 +1,86 @@ package io.quarkus.sample.agent; +import io.quarkus.runtime.StartupEvent; +import io.quarkus.sample.agents.AgentDispatcher; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.subscription.MultiEmitter; +import io.smallrye.reactive.messaging.MutinyEmitter; import jakarta.enterprise.context.control.ActivateRequestContext; import io.quarkus.websockets.next.OnOpen; import io.quarkus.websockets.next.OnTextMessage; import io.quarkus.websockets.next.PathParam; import io.quarkus.websockets.next.WebSocket; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; +import org.eclipse.microprofile.reactive.messaging.Channel; +import io.quarkus.logging.Log; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.OnOverflow; @WebSocket(path = "/todo-agent/{todoId}") @ActivateRequestContext public class TodoAgentWebSocket { - private MultiEmitter emitter; - private Multi agentStream; + public static final String WEBSOCKET_CHANNEL = "to-websocket"; + //private MultiEmitter emitter; private final Jsonb jsonb = JsonbBuilder.create(); - + + @Channel(WEBSOCKET_CHANNEL) + @OnOverflow(OnOverflow.Strategy.BUFFER) + private MutinyEmitter emitter; + + @Channel(WEBSOCKET_CHANNEL) + private Multi agentStream; + + //private MultiEmitter emitter; + //private Multi agentStream; + + //@Inject + //private AgentDispatcher agents; + + // init message Multi.createFromItem() + // ensuite Multi du AI service + // concatener les deux Multis + // Multi.createBy().concatenating().streams(Multi.createFrom().item("Starting work"), aiServiceMulti); + //formatter les messages {kind: initialize kind:token + // when end of message in multi, I send \n\n + // kind + //initiliazize + //cancel + //user_request + + + //MultiEmitter / Emittpublicer + // by default in memory + //@Channel(csdcds) + //MutinyEmitter mEmitter; + + //@Channel(csdcds) + //Multi multi; + +// void init(@Observes StartupEvent event) { +// // Subscribe manually +// agentStream.subscribe().with( +// item -> System.out.println("Received: " + item), +// failure -> System.err.println("Error: " + failure) +// ); +// } + @OnOpen - Multi onOpen(@PathParam String todoId) { - this.agentStream = Multi.createFrom().emitter(emitter -> { - this.emitter = emitter; - this.sendActivityLogMessage(todoId,"ok we will find agents for todo " + todoId ); - }); - - return agentStream; + //@Incoming("to-webstocket") + public Multi onOpen(@PathParam String todoId) { + Log.info("Opening of websocket"); +// this.agentStream = Multi.createFrom().emitter(emitter -> { +// //this.emitter = emitter; +// Log.info("emitter " + emitter); +// this.sendActivityLogMessage(todoId,"ok we will find agents for todo " + todoId ); +// }); + return agentStream.onSubscription().invoke( () -> { + this.sendActivityLogMessage(todoId, "ok we will find agents for todo " + todoId); + } ).log(); } @OnTextMessage @@ -47,10 +101,11 @@ void onTextMessage(@PathParam String todoId, String message) { } private void sendActivityLogMessage(String todoId, String message){ - emitter.emit(jsonb.toJson(new AgentMessage(Kind.activity_log, todoId, message))); + emitter.sendAndForget(jsonb.toJson(new AgentMessage(Kind.activity_log, todoId, message))); + Log.info("Activity log message sent"); } private void sendAgentRequestMessage(String todoId, String message){ - emitter.emit(jsonb.toJson(new AgentMessage(Kind.agent_request, todoId, message))); + emitter.sendAndForget(jsonb.toJson(new AgentMessage(Kind.agent_request, todoId, message))); } } From 40bc84e56b02bd76e432b990ca0b612498e60aa2 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Wed, 1 Oct 2025 08:38:50 +0200 Subject: [PATCH 11/24] Use Vert.X bus instead of Multi for communication across beans This simplifies the subscription barrier issues --- .../sample/agent/TodoAgentWebSocket.java | 116 ++++++++++-------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java index bdca870..613151f 100644 --- a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java @@ -2,9 +2,11 @@ import io.quarkus.runtime.StartupEvent; import io.quarkus.sample.agents.AgentDispatcher; -import io.smallrye.mutiny.Multi; -import io.smallrye.mutiny.subscription.MultiEmitter; -import io.smallrye.reactive.messaging.MutinyEmitter; +import io.quarkus.vertx.LocalEventBusCodec; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.WebSocketConnection; +import io.vertx.core.eventbus.EventBus; +import io.vertx.core.eventbus.MessageConsumer; import jakarta.enterprise.context.control.ActivateRequestContext; import io.quarkus.websockets.next.OnOpen; @@ -15,26 +17,72 @@ import jakarta.inject.Inject; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; -import org.eclipse.microprofile.reactive.messaging.Channel; import io.quarkus.logging.Log; -import org.eclipse.microprofile.reactive.messaging.Incoming; -import org.eclipse.microprofile.reactive.messaging.OnOverflow; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @WebSocket(path = "/todo-agent/{todoId}") @ActivateRequestContext public class TodoAgentWebSocket { - public static final String WEBSOCKET_CHANNEL = "to-websocket"; - //private MultiEmitter emitter; - private final Jsonb jsonb = JsonbBuilder.create(); - @Channel(WEBSOCKET_CHANNEL) - @OnOverflow(OnOverflow.Strategy.BUFFER) - private MutinyEmitter emitter; + @Inject + EventBus eventBus; + + Map> consumers = new ConcurrentHashMap<>(); + + @OnOpen + public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connection) { + Log.info("Opening of websocket"); + + MessageConsumer consumer = eventBus.consumer(todoId, message -> { + Log.info("Message received from event bus: " + message.body()); + connection.sendText(message.body()).subscribe().asCompletionStage(); + }); + consumers.put(todoId, consumer); + return new AgentMessage(Kind.activity_log, todoId, "Searching a agent to work on TODO " + todoId); + } + + @OnTextMessage + void onTextMessage(@PathParam String todoId, AgentMessage agentMessage) { + if (agentMessage.kind().equals(Kind.user_message)) { + String userMessage = agentMessage.payload(); + // TODO: Should we check the todoId from the path against the one in the message ? Do we need both ? + this.sendAgentRequestMessage(todoId, "Parroting for " + todoId + " : " + userMessage); + } else if (agentMessage.kind().equals(Kind.cancel)) { + System.out.println(">>>> Cancel !"); + // TODO: Cancel + } else { + System.out.println(">>>> Ignore !"); + // TODO: Ignore ? Maybe add an error type ? We also need to handle json parsing errors + } + } + + @OnClose + void onClose(@PathParam String todoId) { + Log.info("Closing of websocket"); + MessageConsumer consumer = consumers.remove(todoId); + if (consumer != null) { + consumer.unregister(); + } + } + + private void sendAgentRequestMessage(String todoId, String message) { + eventBus.publish(todoId, new AgentMessage(Kind.agent_request, todoId, message)); + } + + private void sendAgentActivityMessage(String todoId, String message) { + eventBus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, message)); + } - @Channel(WEBSOCKET_CHANNEL) - private Multi agentStream; + public void init(@Observes StartupEvent event) { + eventBus.registerDefaultCodec(AgentMessage.class, + new LocalEventBusCodec() { + }); + } +} //private MultiEmitter emitter; //private Multi agentStream; @@ -69,43 +117,3 @@ public class TodoAgentWebSocket { // ); // } - @OnOpen - //@Incoming("to-webstocket") - public Multi onOpen(@PathParam String todoId) { - Log.info("Opening of websocket"); -// this.agentStream = Multi.createFrom().emitter(emitter -> { -// //this.emitter = emitter; -// Log.info("emitter " + emitter); -// this.sendActivityLogMessage(todoId,"ok we will find agents for todo " + todoId ); -// }); - return agentStream.onSubscription().invoke( () -> { - this.sendActivityLogMessage(todoId, "ok we will find agents for todo " + todoId); - } ).log(); - } - - @OnTextMessage - void onTextMessage(@PathParam String todoId, String message) { - AgentMessage agentMessage = jsonb.fromJson(message, AgentMessage.class); - - if(agentMessage.kind().equals(Kind.user_message)){ - String userMessage = agentMessage.payload(); - // TODO: Should we check the todoId from the path against the one in the message ? Do we need both ? - this.sendAgentRequestMessage(todoId, "Parroting for " + todoId + " : " + userMessage); - }else if(agentMessage.kind().equals(Kind.cancel)) { - System.out.println(">>>> Cancel !"); - // TODO: Cancel - }else { - System.out.println(">>>> Ignore !"); - // TODO: Ignore ? Maybe add an error type ? We also need to handle json parsing errors - } - } - - private void sendActivityLogMessage(String todoId, String message){ - emitter.sendAndForget(jsonb.toJson(new AgentMessage(Kind.activity_log, todoId, message))); - Log.info("Activity log message sent"); - } - - private void sendAgentRequestMessage(String todoId, String message){ - emitter.sendAndForget(jsonb.toJson(new AgentMessage(Kind.agent_request, todoId, message))); - } -} From ab97b6adf55799266335e673a4ddcdc0494fb2b9 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Wed, 1 Oct 2025 18:29:27 +0200 Subject: [PATCH 12/24] Add AgentSelector AI Service Use CDI async event to pass info to AgentDispatcher Create a Context holder for per todoId request after move to @ApplicationScope --- .../sample/agent/TodoAgentWebSocket.java | 22 ++-- .../java/io/quarkus/sample/agents/AGENT.java | 6 + .../sample/agents/AgentDispatcher.java | 114 +++++++++++++++++- .../quarkus/sample/agents/AgentSelector.java | 44 +++++++ .../sample/agents/ClientAgentContext.java | 55 +++++++++ .../agents/ClientAgentContextsHolder.java | 21 ++++ .../sample/agents/WeatherAgentProducer.java | 33 +++-- .../io/quarkus/sample/TodoAgentsTest.java | 29 +++++ 8 files changed, 300 insertions(+), 24 deletions(-) create mode 100644 src/main/java/io/quarkus/sample/agents/AGENT.java create mode 100644 src/main/java/io/quarkus/sample/agents/AgentSelector.java create mode 100644 src/main/java/io/quarkus/sample/agents/ClientAgentContext.java create mode 100644 src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java create mode 100644 src/test/java/io/quarkus/sample/TodoAgentsTest.java diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java index 613151f..a53e001 100644 --- a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java @@ -1,7 +1,9 @@ package io.quarkus.sample.agent; import io.quarkus.runtime.StartupEvent; +import io.quarkus.sample.Todo; import io.quarkus.sample.agents.AgentDispatcher; +import io.quarkus.sample.agents.ClientAgentContext; import io.quarkus.vertx.LocalEventBusCodec; import io.quarkus.websockets.next.OnClose; import io.quarkus.websockets.next.WebSocketConnection; @@ -13,10 +15,9 @@ import io.quarkus.websockets.next.OnTextMessage; import io.quarkus.websockets.next.PathParam; import io.quarkus.websockets.next.WebSocket; +import jakarta.enterprise.event.Event; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; -import jakarta.json.bind.Jsonb; -import jakarta.json.bind.JsonbBuilder; import io.quarkus.logging.Log; import java.util.Map; @@ -27,21 +28,29 @@ @ActivateRequestContext public class TodoAgentWebSocket { + @Inject + AgentDispatcher agentDispatcher; + @Inject EventBus eventBus; + @Inject + Event agentEvent; + Map> consumers = new ConcurrentHashMap<>(); @OnOpen public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connection) { - Log.info("Opening of websocket"); + Log.info("Opening of websocket for todo " + todoId); MessageConsumer consumer = eventBus.consumer(todoId, message -> { Log.info("Message received from event bus: " + message.body()); connection.sendText(message.body()).subscribe().asCompletionStage(); }); consumers.put(todoId, consumer); - return new AgentMessage(Kind.activity_log, todoId, "Searching a agent to work on TODO " + todoId); + Todo todo = Todo.findById(Long.parseLong(todoId)); + agentEvent.fireAsync(new ClientAgentContext(todo, todoId)); + return new AgentMessage(Kind.activity_log, todoId, "Searching an agent for '" + todo.title + "'"); } @OnTextMessage @@ -49,10 +58,9 @@ void onTextMessage(@PathParam String todoId, AgentMessage agentMessage) { if (agentMessage.kind().equals(Kind.user_message)) { String userMessage = agentMessage.payload(); // TODO: Should we check the todoId from the path against the one in the message ? Do we need both ? - this.sendAgentRequestMessage(todoId, "Parroting for " + todoId + " : " + userMessage); + agentDispatcher.passUserMessage(todoId, userMessage); } else if (agentMessage.kind().equals(Kind.cancel)) { - System.out.println(">>>> Cancel !"); - // TODO: Cancel + agentDispatcher.cancel(todoId); } else { System.out.println(">>>> Ignore !"); // TODO: Ignore ? Maybe add an error type ? We also need to handle json parsing errors diff --git a/src/main/java/io/quarkus/sample/agents/AGENT.java b/src/main/java/io/quarkus/sample/agents/AGENT.java new file mode 100644 index 0000000..479a4ca --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/AGENT.java @@ -0,0 +1,6 @@ +package io.quarkus.sample.agents; + +public enum AGENT { + WEATHER, + NONE +} diff --git a/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java b/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java index 4d883c2..7aa9b7a 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java +++ b/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java @@ -1,18 +1,124 @@ package io.quarkus.sample.agents; -import io.a2a.spec.AgentCard; +import io.a2a.client.Client; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.Message; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TextPart; +import io.quarkus.logging.Log; +import io.quarkus.sample.Todo; +import io.quarkus.sample.agent.AgentMessage; +import io.quarkus.sample.agent.Kind; +import io.vertx.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.ObservesAsync; import jakarta.inject.Inject; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + /** * @author Emmanuel Bernard emmanuel@hibernate.org */ @ApplicationScoped public class AgentDispatcher { @Inject @WeatherAgentProducer.WeatherAgent - private AgentCard weatherCard; + private Client weatherClient; + private ClientAgentContextsHolder contextsHolder = new ClientAgentContextsHolder(); + + @Inject + private EventBus bus; + + @Inject + AgentSelector agentSelector; + + public void cancel(String todoId) { + ClientAgentContext context = contextsHolder.getContextFromTodoId(todoId); + if (context.getTaskId() != null) { + try { + getCurrentClient(context).cancelTask(new TaskIdParams(context.getTaskId())); + context.reset(); + } catch (A2AClientException e) { + //let's ignore cancellation exception, we are done on our side + } + } + } + + + public CompletionStage onStarAgentEvent(@ObservesAsync ClientAgentContext event) { + contextsHolder.addOrUpdateContext(event); + findAgent(event); + return CompletableFuture.completedFuture(null); + } + + public void findAgent(ClientAgentContext context) { + var todo = context.getTodo(); + var currentAgent = agentSelector.findRelevantAgent(todo.title, todo.description); + context.setCurrentAgent(currentAgent); + Log.infov("Selected agent {0} for todo '{1}'", currentAgent, todo.title); + + var todoId = context.getTodoId(); + switch (currentAgent) { + case WEATHER -> { + bus.publish(todoId,new AgentMessage(Kind.activity_log, todoId, "The weather agent will look into your todo")); + try { + getCurrentClient(context).sendMessage(new Message.Builder() + .role(Message.Role.USER) + .parts(new TextPart(todoAsPrompt(todo))) + .build() + ); + } catch (A2AClientException e) { + bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); + throw new RuntimeException(e); + } + } + case NONE -> { + bus.publish(todoId,new AgentMessage(Kind.activity_log, todoId, "No agent has been found for your need, sorry about that!")); + } + } + } + + private String todoAsPrompt(Todo todo) { + var builder = new StringBuilder(todo.title); + if (todo.description != null) { + builder.append("\n").append(todo.description); + } + return builder.toString(); + } + + private Client getCurrentClient(ClientAgentContext context) { + var todoId = context.getTodoId(); + var currentAgent = context.getCurrentAgent(); + switch (currentAgent) { + case WEATHER -> { + return weatherClient; + } + case NONE -> { + IllegalStateException illegalStateException = new IllegalStateException("An agent shoud have been picked to call getCurrentClient value: " + currentAgent); + bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + illegalStateException.getMessage())); + throw illegalStateException; + } + default -> { + IllegalStateException illegalStateException = new IllegalStateException("Unexcepted agent enum " + currentAgent); + bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + illegalStateException.getMessage())); + throw illegalStateException; + } + } + } - public String sendInfo() { - return weatherCard.name(); + public void passUserMessage(String todoId, String userMessage) { + var context = contextsHolder.getContextFromTodoId(todoId); + Message a2aMessage = new Message.Builder() + .contextId(context.getContextId()) + .taskId(context.getTaskId()) + .role(Message.Role.USER) + .parts(new TextPart(userMessage)) + .build(); + try { + getCurrentClient(context).sendMessage(a2aMessage); + } catch (A2AClientException e) { + bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); + throw new RuntimeException(e); + } } } diff --git a/src/main/java/io/quarkus/sample/agents/AgentSelector.java b/src/main/java/io/quarkus/sample/agents/AgentSelector.java new file mode 100644 index 0000000..93d12ac --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/AgentSelector.java @@ -0,0 +1,44 @@ +package io.quarkus.sample.agents; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; + +@RegisterAiService +public interface AgentSelector { + @UserMessage(""" + You are an agent that must decide which service to call. + Your role is to analyze the task title and description given at the end and find the most suitable service to call. + + If you are uncertain or do not find a suitable service, answer NONE. + + The services are represented by an enum, this is what you must return. + Do not return anything else. Do not even return a newline or a leading field. Only the enum. + + There is the list of services and their description. + WEATHER: + - name: Weather agent + - description: Helps with weather in the USA, give the weather of a city or a region. + + Here is a list of examples and expected output + + Example 1: + Todo title: Find holidays for next winter + Todo descrption: + NONE + + Example 2: + Todo title: Book a movie theater ticket for tonight + Todo description: I like horror movies and superheroes movies + NONE + + Example 3: + Todo title: What is the weather in New York? + Todo description: + WEATHER + + Here is the task I want ou to find a service for + Task title: {todoTitle} + Task description: {todoDescription} + """) + AGENT findRelevantAgent(String todoTitle, String todoDescription); +} diff --git a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java new file mode 100644 index 0000000..49ca042 --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java @@ -0,0 +1,55 @@ +package io.quarkus.sample.agents; + +import io.quarkus.sample.Todo; + +public class ClientAgentContext { + private Todo todo; + private AGENT currentAgent = AGENT.NONE; + private String taskId; + private String contextId; + private String todoId; + + public ClientAgentContext(Todo todo, String todoId) { + this.todo = todo; + this.todoId = todoId; + } + + public Todo getTodo() { + return todo; + } + + public void setCurrentAgent(AGENT currentAgent) { + this.currentAgent = currentAgent; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public void setContextId(String contextId) { + this.contextId = contextId; + } + + public AGENT getCurrentAgent() { + return currentAgent; + } + + public String getTaskId() { + return taskId; + } + + public String getContextId() { + return contextId; + } + + public String getTodoId() { + return todoId; + } + + public void reset() { + taskId = null; + contextId = null; + currentAgent = AGENT.NONE; + //todoID not reset because technically we should ahndle post cancel / post completed recalls + } +} diff --git a/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java b/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java new file mode 100644 index 0000000..f7072ba --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java @@ -0,0 +1,21 @@ +package io.quarkus.sample.agents; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class ClientAgentContextsHolder { + private ConcurrentMap todoIdToContext = new ConcurrentHashMap<>(); + private ConcurrentMap taskIdToContext = new ConcurrentHashMap<>(); + + public ClientAgentContext getContextFromTodoId(String todoId) { + return todoIdToContext.get(todoId); + } + + public void addOrUpdateContext(ClientAgentContext event) { + todoIdToContext.put(event.getTodoId(), event); + if (event.getTaskId() != null) { + taskIdToContext.put(event.getTaskId(), event); + } + } + +} diff --git a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java index d7e123a..5bffbe3 100644 --- a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java +++ b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java @@ -5,22 +5,16 @@ 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.spec.A2AClientError; -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.TaskStatusUpdateEvent; -import io.a2a.spec.TextPart; -import io.a2a.spec.UpdateEvent; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.spec.*; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.inject.Qualifier; import jakarta.ws.rs.Produces; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.jboss.logging.Logger; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -59,7 +53,7 @@ public AgentCard getCard() throws A2AClientError { @Produces @WeatherAgent - public Client getA2aClient() throws A2AClientError { + public Client getA2aClient() throws A2AClientError, A2AClientException { // Create a CompletableFuture to handle async response final CompletableFuture messageResponse = new CompletableFuture<>(); @@ -74,8 +68,21 @@ public Client getA2aClient() throws A2AClientError { error.printStackTrace(); messageResponse.completeExceptionally(error); }; - //Client client = Client.builder(getCard()) - return null; + 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()) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler) + .withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfig()) + .clientConfig(clientConfig) + .build(); + return client; } private static List> getConsumers( diff --git a/src/test/java/io/quarkus/sample/TodoAgentsTest.java b/src/test/java/io/quarkus/sample/TodoAgentsTest.java new file mode 100644 index 0000000..7ac98c3 --- /dev/null +++ b/src/test/java/io/quarkus/sample/TodoAgentsTest.java @@ -0,0 +1,29 @@ +package io.quarkus.sample; + +import io.quarkus.sample.agents.AGENT; +import io.quarkus.sample.agents.AgentSelector; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assertions; + +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.jupiter.api.Assertions.*; + +@QuarkusTest +public class TodoAgentsTest { + + @Inject + AgentSelector agentSelector; + + @Test + public void testMatchingAgent() { + var agent = agentSelector.findRelevantAgent("Find what is the benefit of a healthy life", ""); + assertEquals(AGENT.NONE, agent, "There should be no matching agent"); + agent = agentSelector.findRelevantAgent("Find the weather in San Francisco", ""); + assertEquals(AGENT.WEATHER, agent, "There should be the WEATHER matching agent"); + } +} \ No newline at end of file From 624321d7596ccc6383ac72fb9e7eab6c8ab91f4b Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Thu, 2 Oct 2025 13:57:05 +0200 Subject: [PATCH 13/24] Finish support for end to end user to A2A agent server flow --- .../sample/agent/TodoAgentWebSocket.java | 2 +- .../io/quarkus/sample/agents/A2AUtils.java | 23 ++++++++ .../sample/agents/AgentDispatcher.java | 59 +++++++++++++++++-- .../sample/agents/ClientAgentContext.java | 8 +-- .../agents/ClientAgentContextsHolder.java | 22 +++++-- .../sample/agents/WeatherAgentProducer.java | 56 +++++++++--------- 6 files changed, 127 insertions(+), 43 deletions(-) create mode 100644 src/main/java/io/quarkus/sample/agents/A2AUtils.java diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java index a53e001..464d2ff 100644 --- a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java @@ -50,7 +50,7 @@ public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connect consumers.put(todoId, consumer); Todo todo = Todo.findById(Long.parseLong(todoId)); agentEvent.fireAsync(new ClientAgentContext(todo, todoId)); - return new AgentMessage(Kind.activity_log, todoId, "Searching an agent for '" + todo.title + "'"); + return new AgentMessage(Kind.agent_request, todoId, "Searching an agent for '" + todo.title + "'"); } @OnTextMessage diff --git a/src/main/java/io/quarkus/sample/agents/A2AUtils.java b/src/main/java/io/quarkus/sample/agents/A2AUtils.java new file mode 100644 index 0000000..96f1ff6 --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/A2AUtils.java @@ -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 emmanuel@hibernate.org + */ +public class A2AUtils { + public 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/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java b/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java index 7aa9b7a..e5ff7c9 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java +++ b/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java @@ -1,9 +1,13 @@ package io.quarkus.sample.agents; import io.a2a.client.Client; +import io.a2a.client.TaskEvent; +import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.A2AClientException; +import io.a2a.spec.Artifact; import io.a2a.spec.Message; import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.quarkus.logging.Log; import io.quarkus.sample.Todo; @@ -14,9 +18,12 @@ import jakarta.enterprise.event.ObservesAsync; import jakarta.inject.Inject; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import static io.quarkus.sample.agents.A2AUtils.extractTextFromParts; + /** * @author Emmanuel Bernard emmanuel@hibernate.org */ @@ -37,7 +44,7 @@ public void cancel(String todoId) { if (context.getTaskId() != null) { try { getCurrentClient(context).cancelTask(new TaskIdParams(context.getTaskId())); - context.reset(); + context.resetOnTaskCompletion(); } catch (A2AClientException e) { //let's ignore cancellation exception, we are done on our side } @@ -45,7 +52,7 @@ public void cancel(String todoId) { } - public CompletionStage onStarAgentEvent(@ObservesAsync ClientAgentContext event) { + public CompletionStage onConversationStart(@ObservesAsync ClientAgentContext event) { contextsHolder.addOrUpdateContext(event); findAgent(event); return CompletableFuture.completedFuture(null); @@ -60,10 +67,11 @@ public void findAgent(ClientAgentContext context) { var todoId = context.getTodoId(); switch (currentAgent) { case WEATHER -> { - bus.publish(todoId,new AgentMessage(Kind.activity_log, todoId, "The weather agent will look into your todo")); + bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "The weather agent will look into your todo")); try { getCurrentClient(context).sendMessage(new Message.Builder() .role(Message.Role.USER) + .contextId(context.getContextId()) .parts(new TextPart(todoAsPrompt(todo))) .build() ); @@ -73,7 +81,7 @@ public void findAgent(ClientAgentContext context) { } } case NONE -> { - bus.publish(todoId,new AgentMessage(Kind.activity_log, todoId, "No agent has been found for your need, sorry about that!")); + bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "No agent has been found for your need, sorry about that!")); } } } @@ -94,7 +102,7 @@ private Client getCurrentClient(ClientAgentContext context) { return weatherClient; } case NONE -> { - IllegalStateException illegalStateException = new IllegalStateException("An agent shoud have been picked to call getCurrentClient value: " + currentAgent); + IllegalStateException illegalStateException = new IllegalStateException("An agent should have been picked to call getCurrentClient value: " + currentAgent); bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + illegalStateException.getMessage())); throw illegalStateException; } @@ -121,4 +129,45 @@ public void passUserMessage(String todoId, String userMessage) { throw new RuntimeException(e); } } + + public void receiveMessageFromAgent(Message responseMessage) { + var context = contextsHolder.getContextFromContextId(responseMessage.getContextId()); + var payload = A2AUtils.extractTextFromParts(responseMessage.getParts()); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + } + + public void sendToActivityLog(TaskStatusUpdateEvent taskStatusUpdateEvent) { + var taskId = taskStatusUpdateEvent.getTaskId(); + var context = contextsHolder.getContextFromTaskId(taskId); + if (context == null) { + context = contextsHolder.getContextFromContextId(taskStatusUpdateEvent.getContextId()); + context.setTaskId(taskId); + contextsHolder.addOrUpdateContext(context); + } + String payload = "Received status-update for " + taskId + ": " + + taskStatusUpdateEvent.getStatus().state().asString(); + bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); + } + + public void sendTaskArtifacts(TaskUpdateEvent taskUpdateEvent) { + var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTask().getId()); + StringBuilder textBuilder = new StringBuilder(); + List artifacts = taskUpdateEvent.getTask().getArtifacts(); + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + var payload = textBuilder.toString(); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + } + + public void sendToActivityLog(TaskEvent taskEvent) { + var taskId = taskEvent.getTask().getId(); + var context = contextsHolder.getContextFromTaskId(taskId); + if (context == null) { + context = contextsHolder.getContextFromContextId(taskEvent.getTask().getContextId()); + contextsHolder.addOrUpdateContext(context); + } + String payload = "Received task event for " + taskId; + bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); + } } diff --git a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java index 49ca042..e7741ce 100644 --- a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java +++ b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java @@ -46,10 +46,10 @@ public String getTodoId() { return todoId; } - public void reset() { + public void resetOnTaskCompletion() { + //do not reset context id as it is reuable across tasks + //do not reset current agent as it is reusable for a give task + //do not reset todoId because technically we should ahndle post cancel / post completed recalls taskId = null; - contextId = null; - currentAgent = AGENT.NONE; - //todoID not reset because technically we should ahndle post cancel / post completed recalls } } diff --git a/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java b/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java index f7072ba..251871f 100644 --- a/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java +++ b/src/main/java/io/quarkus/sample/agents/ClientAgentContextsHolder.java @@ -1,21 +1,35 @@ package io.quarkus.sample.agents; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class ClientAgentContextsHolder { private ConcurrentMap todoIdToContext = new ConcurrentHashMap<>(); private ConcurrentMap taskIdToContext = new ConcurrentHashMap<>(); + private ConcurrentMap contextIdToContext = new ConcurrentHashMap<>(); public ClientAgentContext getContextFromTodoId(String todoId) { return todoIdToContext.get(todoId); } - public void addOrUpdateContext(ClientAgentContext event) { - todoIdToContext.put(event.getTodoId(), event); - if (event.getTaskId() != null) { - taskIdToContext.put(event.getTaskId(), event); + public void addOrUpdateContext(ClientAgentContext context) { + //workaround to allow multiplexing from A2A clients + if (context.getContextId() == null) { + context.setContextId(UUID.randomUUID().toString()); } + contextIdToContext.put(context.getContextId(), context); + todoIdToContext.put(context.getTodoId(), context); + if (context.getTaskId() != null) { + taskIdToContext.put(context.getTaskId(), context); + } + } + + public ClientAgentContext getContextFromContextId(String contextId) { + return contextIdToContext.get(contextId); } + public ClientAgentContext getContextFromTaskId(String taskId) { + return taskIdToContext.get(taskId); + } } diff --git a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java index 5bffbe3..e330da6 100644 --- a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java +++ b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java @@ -28,18 +28,21 @@ import io.quarkus.logging.Log; +import static io.quarkus.sample.agents.A2AUtils.*; + /** * @author Emmanuel Bernard emmanuel@hibernate.org */ @ApplicationScoped public class WeatherAgentProducer { - //private static final Logger LOG = Logger.getLogger(WeatherAgentProducer.class); @Target({ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Qualifier public @interface WeatherAgent {} + @Inject AgentDispatcher agentDispatcher; + @Inject @ConfigProperty(name = "agent.weather.url") private String url; @@ -64,9 +67,10 @@ public Client getA2aClient() throws A2AClientError, A2AClientException { // Create error handler for streaming errors Consumer streamingErrorHandler = (error) -> { - System.out.println("Streaming error occurred: " + error.getMessage()); + Log.errorv("JDK streaming error occured {0}", error.getMessage()); + Log.errorv("JDK streaming error occured {0}", error); error.printStackTrace(); - messageResponse.completeExceptionally(error); + //messageResponse.completeExceptionally(error); }; ClientConfig clientConfig = new ClientConfig.Builder() .setAcceptedOutputModes(List.of("Text")) @@ -85,7 +89,7 @@ public Client getA2aClient() throws A2AClientError, A2AClientException { return client; } - private static List> getConsumers( + private List> getConsumers( final CompletableFuture messageResponse) { List> consumers = new ArrayList<>(); consumers.add( @@ -94,7 +98,8 @@ private static List> getConsumers( Message responseMessage = messageEvent.getMessage(); String text = extractTextFromParts(responseMessage.getParts()); System.out.println("Received message: " + text); - messageResponse.complete(text); + agentDispatcher.receiveMessageFromAgent(responseMessage); + //messageResponse.complete(text); } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); if (updateEvent @@ -102,41 +107,34 @@ private static List> getConsumers( System.out.println( "Received status-update: " + taskStatusUpdateEvent.getStatus().state().asString()); + agentDispatcher.sendToActivityLog(taskStatusUpdateEvent); if (taskStatusUpdateEvent.isFinal()) { - StringBuilder textBuilder = new StringBuilder(); - List artifacts - = taskUpdateEvent.getTask().getArtifacts(); - for (Artifact artifact : artifacts) { - textBuilder.append(extractTextFromParts(artifact.parts())); - } - String text = textBuilder.toString(); - messageResponse.complete(text); + //agentDispatcher.sendTaskArtifacts(taskUpdateEvent); +// StringBuilder textBuilder = new StringBuilder(); +// List artifacts +// = taskUpdateEvent.getTask().getArtifacts(); +// for (Artifact artifact : artifacts) { +// textBuilder.append(extractTextFromParts(artifact.parts())); +// } +// String text = textBuilder.toString(); + //messageResponse.complete(text); } } else if (updateEvent instanceof TaskArtifactUpdateEvent taskArtifactUpdateEvent) { - List> parts = taskArtifactUpdateEvent - .getArtifact() - .parts(); - String text = extractTextFromParts(parts); - System.out.println("Received artifact-update: " + text); + agentDispatcher.sendTaskArtifacts(taskUpdateEvent); +// 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()); + agentDispatcher.sendToActivityLog(taskEvent); } }); 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(); - } } From 822bc6326c0d0ad7f43b7895c7212ea8b6fc1eca Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Thu, 2 Oct 2025 14:30:13 +0200 Subject: [PATCH 14/24] Support A2A server not connected Rename to AgentsMediator (from dispatcher) --- .../sample/agent/TodoAgentWebSocket.java | 37 ++++++++++++++++--- ...entDispatcher.java => AgentsMediator.java} | 4 +- .../sample/agents/WeatherAgentProducer.java | 20 ++++------ 3 files changed, 41 insertions(+), 20 deletions(-) rename src/main/java/io/quarkus/sample/agents/{AgentDispatcher.java => AgentsMediator.java} (98%) diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java index 464d2ff..5f8e33e 100644 --- a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java @@ -1,9 +1,11 @@ package io.quarkus.sample.agent; +import io.a2a.spec.A2AClientError; import io.quarkus.runtime.StartupEvent; import io.quarkus.sample.Todo; -import io.quarkus.sample.agents.AgentDispatcher; +import io.quarkus.sample.agents.AgentsMediator; import io.quarkus.sample.agents.ClientAgentContext; +import io.quarkus.sample.agents.WeatherAgentProducer; import io.quarkus.vertx.LocalEventBusCodec; import io.quarkus.websockets.next.OnClose; import io.quarkus.websockets.next.WebSocketConnection; @@ -29,7 +31,7 @@ public class TodoAgentWebSocket { @Inject - AgentDispatcher agentDispatcher; + AgentsMediator agentsMediator; @Inject EventBus eventBus; @@ -37,11 +39,22 @@ public class TodoAgentWebSocket { @Inject Event agentEvent; + @Inject + WeatherAgentProducer weatherAgentProducer; + Map> consumers = new ConcurrentHashMap<>(); + boolean agentsReady = false; @OnOpen public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connection) { Log.info("Opening of websocket for todo " + todoId); + try { + weatherAgentProducer.getCard(); + agentsReady = true; + } catch (A2AClientError e) { + Log.warn("Unable to connect to a2a servers, did you start them?"); + agentsReady = false; + } MessageConsumer consumer = eventBus.consumer(todoId, message -> { Log.info("Message received from event bus: " + message.body()); @@ -50,17 +63,31 @@ public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connect consumers.put(todoId, consumer); Todo todo = Todo.findById(Long.parseLong(todoId)); agentEvent.fireAsync(new ClientAgentContext(todo, todoId)); - return new AgentMessage(Kind.agent_request, todoId, "Searching an agent for '" + todo.title + "'"); + + if (!agentsReady) { + return noAIMessage(todoId); + } + else { + return new AgentMessage(Kind.agent_request, todoId, "Searching an agent for '" + todo.title + "'"); + } + } + + private static AgentMessage noAIMessage(String todoId) { + return new AgentMessage(Kind.agent_request, todoId, "Unable to find started agents, Do with AI is not available."); } @OnTextMessage void onTextMessage(@PathParam String todoId, AgentMessage agentMessage) { + if (!agentsReady) { + eventBus.publish(todoId, noAIMessage(todoId)); + return; + } if (agentMessage.kind().equals(Kind.user_message)) { String userMessage = agentMessage.payload(); // TODO: Should we check the todoId from the path against the one in the message ? Do we need both ? - agentDispatcher.passUserMessage(todoId, userMessage); + agentsMediator.passUserMessage(todoId, userMessage); } else if (agentMessage.kind().equals(Kind.cancel)) { - agentDispatcher.cancel(todoId); + agentsMediator.cancel(todoId); } else { System.out.println(">>>> Ignore !"); // TODO: Ignore ? Maybe add an error type ? We also need to handle json parsing errors diff --git a/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java similarity index 98% rename from src/main/java/io/quarkus/sample/agents/AgentDispatcher.java rename to src/main/java/io/quarkus/sample/agents/AgentsMediator.java index e5ff7c9..2b47422 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentDispatcher.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -28,7 +28,7 @@ * @author Emmanuel Bernard emmanuel@hibernate.org */ @ApplicationScoped -public class AgentDispatcher { +public class AgentsMediator { @Inject @WeatherAgentProducer.WeatherAgent private Client weatherClient; private ClientAgentContextsHolder contextsHolder = new ClientAgentContextsHolder(); @@ -77,7 +77,6 @@ public void findAgent(ClientAgentContext context) { ); } catch (A2AClientException e) { bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); - throw new RuntimeException(e); } } case NONE -> { @@ -126,7 +125,6 @@ public void passUserMessage(String todoId, String userMessage) { getCurrentClient(context).sendMessage(a2aMessage); } catch (A2AClientException e) { bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); - throw new RuntimeException(e); } } diff --git a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java index e330da6..5231a3c 100644 --- a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java +++ b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java @@ -41,7 +41,8 @@ public class WeatherAgentProducer { @Qualifier public @interface WeatherAgent {} - @Inject AgentDispatcher agentDispatcher; + @Inject + AgentsMediator agentsMediator; @Inject @ConfigProperty(name = "agent.weather.url") @@ -57,13 +58,9 @@ public AgentCard getCard() throws A2AClientError { @Produces @WeatherAgent public Client getA2aClient() throws A2AClientError, A2AClientException { - // Create a CompletableFuture to handle async response - final CompletableFuture messageResponse - = new CompletableFuture<>(); - // Create consumers for handling client events List> consumers - = getConsumers(messageResponse); + = getConsumers(); // Create error handler for streaming errors Consumer streamingErrorHandler = (error) -> { @@ -89,8 +86,7 @@ public Client getA2aClient() throws A2AClientError, A2AClientException { return client; } - private List> getConsumers( - final CompletableFuture messageResponse) { + private List> getConsumers() { List> consumers = new ArrayList<>(); consumers.add( (event, agentCard) -> { @@ -98,7 +94,7 @@ private List> getConsumers( Message responseMessage = messageEvent.getMessage(); String text = extractTextFromParts(responseMessage.getParts()); System.out.println("Received message: " + text); - agentDispatcher.receiveMessageFromAgent(responseMessage); + agentsMediator.receiveMessageFromAgent(responseMessage); //messageResponse.complete(text); } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); @@ -107,7 +103,7 @@ private List> getConsumers( System.out.println( "Received status-update: " + taskStatusUpdateEvent.getStatus().state().asString()); - agentDispatcher.sendToActivityLog(taskStatusUpdateEvent); + agentsMediator.sendToActivityLog(taskStatusUpdateEvent); if (taskStatusUpdateEvent.isFinal()) { //agentDispatcher.sendTaskArtifacts(taskUpdateEvent); // StringBuilder textBuilder = new StringBuilder(); @@ -121,7 +117,7 @@ private List> getConsumers( } } else if (updateEvent instanceof TaskArtifactUpdateEvent taskArtifactUpdateEvent) { - agentDispatcher.sendTaskArtifacts(taskUpdateEvent); + agentsMediator.sendTaskArtifacts(taskUpdateEvent); // List> parts = taskArtifactUpdateEvent // .getArtifact() // .parts(); @@ -131,7 +127,7 @@ private List> getConsumers( } else if (event instanceof TaskEvent taskEvent) { System.out.println("Received task event: " + taskEvent.getTask().getId()); - agentDispatcher.sendToActivityLog(taskEvent); + agentsMediator.sendToActivityLog(taskEvent); } }); return consumers; From 2355baa0aa88165c8411e7d978ee3f752bfd6068 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Thu, 2 Oct 2025 15:27:51 +0200 Subject: [PATCH 15/24] Fix issue with user sent message in case we find no matching agent --- .../io/quarkus/sample/agents/AgentsMediator.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 2b47422..0ba073c 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -80,11 +80,15 @@ public void findAgent(ClientAgentContext context) { } } case NONE -> { - bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "No agent has been found for your need, sorry about that!")); + sendNoAgentMessage(todoId); } } } + private void sendNoAgentMessage(String todoId) { + bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "No agent has been found for your need, sorry about that!")); + } + private String todoAsPrompt(Todo todo) { var builder = new StringBuilder(todo.title); if (todo.description != null) { @@ -122,7 +126,12 @@ public void passUserMessage(String todoId, String userMessage) { .parts(new TextPart(userMessage)) .build(); try { - getCurrentClient(context).sendMessage(a2aMessage); + if (context.getCurrentAgent() == AGENT.NONE) { + sendNoAgentMessage(todoId); + } + else { + getCurrentClient(context).sendMessage(a2aMessage); + } } catch (A2AClientException e) { bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); } From f871dff57b918a5937515504e121f018b5766ee5 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Thu, 2 Oct 2025 16:32:01 +0200 Subject: [PATCH 16/24] Use AgentCard to populate the AgentSelector prompt --- .../sample/agents/AgentDescriptor.java | 28 +++++++++++++++++++ .../quarkus/sample/agents/AgentSelector.java | 13 ++++++--- .../quarkus/sample/agents/AgentsMediator.java | 11 +++++++- .../io/quarkus/sample/TodoAgentsTest.java | 28 +++++++++++++++++-- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/quarkus/sample/agents/AgentDescriptor.java diff --git a/src/main/java/io/quarkus/sample/agents/AgentDescriptor.java b/src/main/java/io/quarkus/sample/agents/AgentDescriptor.java new file mode 100644 index 0000000..7fe25c6 --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/AgentDescriptor.java @@ -0,0 +1,28 @@ +package io.quarkus.sample.agents; + +import io.a2a.spec.AgentCard; + +/** + * @author Emmanuel Bernard emmanuel@hibernate.org + */ +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(); + } +} diff --git a/src/main/java/io/quarkus/sample/agents/AgentSelector.java b/src/main/java/io/quarkus/sample/agents/AgentSelector.java index 93d12ac..40c453d 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentSelector.java +++ b/src/main/java/io/quarkus/sample/agents/AgentSelector.java @@ -3,6 +3,8 @@ import dev.langchain4j.service.UserMessage; import io.quarkiverse.langchain4j.RegisterAiService; +import java.util.List; + @RegisterAiService public interface AgentSelector { @UserMessage(""" @@ -15,9 +17,12 @@ public interface AgentSelector { Do not return anything else. Do not even return a newline or a leading field. Only the enum. There is the list of services and their description. - WEATHER: - - name: Weather agent - - description: Helps with weather in the USA, give the weather of a city or a region. + {#for a in agents} + {a.agent}: + - name: {a.name} + - description: {a.description ?: ''} + + {/for} Here is a list of examples and expected output @@ -40,5 +45,5 @@ public interface AgentSelector { Task title: {todoTitle} Task description: {todoDescription} """) - AGENT findRelevantAgent(String todoTitle, String todoDescription); + AGENT findRelevantAgent(String todoTitle, String todoDescription, List agents); } diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 0ba073c..2fe7ea4 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -4,6 +4,7 @@ import io.a2a.client.TaskEvent; import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; import io.a2a.spec.Artifact; import io.a2a.spec.Message; import io.a2a.spec.TaskIdParams; @@ -18,6 +19,7 @@ import jakarta.enterprise.event.ObservesAsync; import jakarta.inject.Inject; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -31,6 +33,8 @@ public class AgentsMediator { @Inject @WeatherAgentProducer.WeatherAgent private Client weatherClient; + @Inject @WeatherAgentProducer.WeatherAgent + private AgentCard weatherCard; private ClientAgentContextsHolder contextsHolder = new ClientAgentContextsHolder(); @Inject @@ -60,7 +64,7 @@ public CompletionStage onConversationStart(@ObservesAsync ClientAgentConte public void findAgent(ClientAgentContext context) { var todo = context.getTodo(); - var currentAgent = agentSelector.findRelevantAgent(todo.title, todo.description); + var currentAgent = agentSelector.findRelevantAgent(todo.title, todo.description, buildDescriptors()); context.setCurrentAgent(currentAgent); Log.infov("Selected agent {0} for todo '{1}'", currentAgent, todo.title); @@ -85,6 +89,11 @@ public void findAgent(ClientAgentContext context) { } } + private List buildDescriptors() { + var weatherDescriptor = new AgentDescriptor(AGENT.WEATHER, weatherCard); + return Arrays.asList(weatherDescriptor); + } + private void sendNoAgentMessage(String todoId) { bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "No agent has been found for your need, sorry about that!")); } diff --git a/src/test/java/io/quarkus/sample/TodoAgentsTest.java b/src/test/java/io/quarkus/sample/TodoAgentsTest.java index 7ac98c3..6ccc327 100644 --- a/src/test/java/io/quarkus/sample/TodoAgentsTest.java +++ b/src/test/java/io/quarkus/sample/TodoAgentsTest.java @@ -1,12 +1,16 @@ package io.quarkus.sample; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; import io.quarkus.sample.agents.AGENT; +import io.quarkus.sample.agents.AgentDescriptor; import io.quarkus.sample.agents.AgentSelector; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Assertions; +import java.util.ArrayList; import java.util.stream.Stream; import static io.restassured.RestAssured.given; @@ -21,9 +25,29 @@ public class TodoAgentsTest { @Test public void testMatchingAgent() { - var agent = agentSelector.findRelevantAgent("Find what is the benefit of a healthy life", ""); + var agents = buildAgentDescriptors(); + var agent = agentSelector.findRelevantAgent("Find what is the benefit of a healthy life", "", agents); assertEquals(AGENT.NONE, agent, "There should be no matching agent"); - agent = agentSelector.findRelevantAgent("Find the weather in San Francisco", ""); + agent = agentSelector.findRelevantAgent("Find the weather in San Francisco", "", agents); assertEquals(AGENT.WEATHER, agent, "There should be the WEATHER matching agent"); } + + private static ArrayList buildAgentDescriptors() { + var card = new AgentCard.Builder() + .name("Weather Agent") + .description("Helps with weather in the USA, give the weather of a city or a region.") + .url("http://example.com") + .capabilities(new AgentCapabilities.Builder().build()) + .protocolVersion("0.3.0") + .version("0.0") + .defaultInputModes(new ArrayList<>()) + .defaultOutputModes(new ArrayList<>()) + .skills(new ArrayList<>()) + .build(); + AgentDescriptor descriptor = new AgentDescriptor(AGENT.WEATHER, card); + var agents = new ArrayList() {{ + add(descriptor); + }}; + return agents; + } } \ No newline at end of file From 565883f1604d2f0ba5aacedb8b8d1ec75e733918 Mon Sep 17 00:00:00 2001 From: Phillip Kruger Date: Fri, 3 Oct 2025 16:52:49 +1000 Subject: [PATCH 17/24] Edit task edit feature Signed-off-by: Phillip Kruger --- src/main/resources/web/app/todos-cards.js | 11 ++- src/main/resources/web/app/todos-task.js | 90 +++++++++++++++++++---- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/main/resources/web/app/todos-cards.js b/src/main/resources/web/app/todos-cards.js index 334e02c..bebdb31 100644 --- a/src/main/resources/web/app/todos-cards.js +++ b/src/main/resources/web/app/todos-cards.js @@ -171,7 +171,8 @@ class TodosCards extends LitElement { url="${task.url}" ?done=${task.completed} @select=${this._toggleSelect} - @delete=${this._deleteItem}> + @delete=${this._deleteItem} + @edit=${this._editItem}>
`; } @@ -312,6 +313,14 @@ class TodosCards extends LitElement { } + _editItem(e) { + let task = this._tasks.find(t => t.id === e.detail.id); + if(task){ + task.title = e.detail.task; + this._updateTask(task); + } + } + _handleDeleteResponse(status) { if(status === 204){ this._fetchAllTasks(); diff --git a/src/main/resources/web/app/todos-task.js b/src/main/resources/web/app/todos-task.js index 79ce31c..d87d143 100644 --- a/src/main/resources/web/app/todos-task.js +++ b/src/main/resources/web/app/todos-task.js @@ -3,6 +3,7 @@ import '@vaadin/icon'; import '@vaadin/vaadin-lumo-styles/vaadin-iconset.js'; import '@vaadin/icons'; import '@vaadin/dialog'; +import '@vaadin/text-field'; import { dialogFooterRenderer, dialogRenderer } from '@vaadin/dialog/lit.js'; import './todos-detail.js'; @@ -30,14 +31,25 @@ class TodosTask extends LitElement { text-decoration: line-through; color: var(--lumo-contrast-50pct); } - .delete-icon { - color: var(--lumo-error-color); + .icon { cursor: pointer; padding-right: 5px; } + .delete-icon { + color: var(--lumo-error-color-50pct); + } + .edit-icon { + font-size: initial; + color: var(--lumo-primary-text-color); + } .hide { visibility:hidden; } + .taskInput { + width: 100%; + padding-right: 5px; + padding-left: 5px; + } `; static properties = { @@ -47,20 +59,22 @@ class TodosTask extends LitElement { order: {type: Number}, url: {type: String}, done: {type: Boolean, reflect: true}, - _deleteButtonClass: {type: String, attribute: false}, - _dialogOpened: {type: Boolean} + _buttonClass: {type: String, attribute: false}, + _dialogOpened: {type: Boolean}, + _isInEditMode: {type: Boolean} }; constructor() { super(); this.id = -1; - this.task = ""; + this.task = null; this.description = ""; this.order = 0; this.url = null; this.done = false; - this._deleteButtonClass = "hide"; + this._buttonClass = "hide"; this._dialogOpened = false; + this._isInEditMode = false; } connectedCallback() { @@ -70,19 +84,46 @@ class TodosTask extends LitElement { } render() { - if(this.task){ - return html`${this._renderDialog()} - - + ${this._renderText()} + `; + } + + _renderText(){ + if(this._isInEditMode){ + return html` (this.task = e.detail.value)} + @keydown=${this._maybeSaveOnEnter} + > + + `; + }else{ + return html` +
+ + ${this.task} - ${this._renderDeleteButton()} - `; + }}">${this.task} +
+
+ ${this._renderEditButton()} + ${this._renderDeleteButton()} +
+ `; } } _renderDeleteButton(){ - return html``; + return html``; + } + + _renderEditButton(){ + return html``; } _renderDialog() { @@ -137,11 +178,11 @@ class TodosTask extends LitElement { }; _handleMouseenter(){ - this._deleteButtonClass = "delete-icon"; + this._buttonClass = "icon"; } _handleMouseleave(){ - this._deleteButtonClass = "hide"; + this._buttonClass = "hide"; } _toggleSelect(event){ @@ -155,6 +196,23 @@ class TodosTask extends LitElement { this._dialogOpened = false; } + _showEdit(event){ + this._isInEditMode = true; + } + + _edit(event){ + event = new CustomEvent('edit', {detail: {id:this.id, task:this.task}, bubbles: true, composed: true}); + this.dispatchEvent(event); + this._isInEditMode = false; + } + + _maybeSaveOnEnter(e) { + if (e.key === 'Enter') { + e.preventDefault(); + this._edit(); + } + } + _icon(){ if(this.done){ return "vaadin:check-square-o"; From 8ce417e3d31219eb242a769c8293b85311f0d4cd Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Fri, 3 Oct 2025 10:44:58 +0200 Subject: [PATCH 18/24] Add second agent Movie Agent Also adapt to different TAsk event behaviors And ad more activity logs --- .../java/io/quarkus/sample/agents/AGENT.java | 1 + .../quarkus/sample/agents/AgentsMediator.java | 92 ++++++++++- .../sample/agents/ClientAgentContext.java | 2 +- .../sample/agents/MovieAgentProducer.java | 156 ++++++++++++++++++ .../sample/agents/WeatherAgentProducer.java | 22 ++- src/main/resources/application.properties | 3 +- 6 files changed, 261 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java diff --git a/src/main/java/io/quarkus/sample/agents/AGENT.java b/src/main/java/io/quarkus/sample/agents/AGENT.java index 479a4ca..399bb3c 100644 --- a/src/main/java/io/quarkus/sample/agents/AGENT.java +++ b/src/main/java/io/quarkus/sample/agents/AGENT.java @@ -2,5 +2,6 @@ public enum AGENT { WEATHER, + MOVIE, NONE } diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 2fe7ea4..9d684e9 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -7,7 +7,10 @@ import io.a2a.spec.AgentCard; import io.a2a.spec.Artifact; import io.a2a.spec.Message; +import io.a2a.spec.Task; +import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.quarkus.logging.Log; @@ -33,8 +36,12 @@ public class AgentsMediator { @Inject @WeatherAgentProducer.WeatherAgent private Client weatherClient; + @Inject @MovieAgentProducer.MovieAgent + private Client movieClient; @Inject @WeatherAgentProducer.WeatherAgent private AgentCard weatherCard; + @Inject @MovieAgentProducer.MovieAgent + private AgentCard movieCard; private ClientAgentContextsHolder contextsHolder = new ClientAgentContextsHolder(); @Inject @@ -48,7 +55,7 @@ public void cancel(String todoId) { if (context.getTaskId() != null) { try { getCurrentClient(context).cancelTask(new TaskIdParams(context.getTaskId())); - context.resetOnTaskCompletion(); + context.resetOnTaskTerminalState(); } catch (A2AClientException e) { //let's ignore cancellation exception, we are done on our side } @@ -70,8 +77,8 @@ public void findAgent(ClientAgentContext context) { var todoId = context.getTodoId(); switch (currentAgent) { - case WEATHER -> { - bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "The weather agent will look into your todo")); + case WEATHER,MOVIE -> { + bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "The " + currentAgent + " agent will look into your todo")); try { getCurrentClient(context).sendMessage(new Message.Builder() .role(Message.Role.USER) @@ -91,7 +98,8 @@ public void findAgent(ClientAgentContext context) { private List buildDescriptors() { var weatherDescriptor = new AgentDescriptor(AGENT.WEATHER, weatherCard); - return Arrays.asList(weatherDescriptor); + var movieDescriptor = new AgentDescriptor(AGENT.MOVIE, movieCard); + return Arrays.asList(weatherDescriptor,movieDescriptor); } private void sendNoAgentMessage(String todoId) { @@ -113,6 +121,9 @@ private Client getCurrentClient(ClientAgentContext context) { case WEATHER -> { return weatherClient; } + case MOVIE -> { + return movieClient; + } case NONE -> { IllegalStateException illegalStateException = new IllegalStateException("An agent should have been picked to call getCurrentClient value: " + currentAgent); bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + illegalStateException.getMessage())); @@ -160,17 +171,59 @@ public void sendToActivityLog(TaskStatusUpdateEvent taskStatusUpdateEvent) { context.setTaskId(taskId); contextsHolder.addOrUpdateContext(context); } + switch (taskStatusUpdateEvent.getStatus().state()) { + case COMPLETED, CANCELED, FAILED, REJECTED, UNKNOWN -> { + context.resetOnTaskTerminalState(); + } + } String payload = "Received status-update for " + taskId + ": " - + taskStatusUpdateEvent.getStatus().state().asString(); + + taskStatusUpdateEvent.getStatus().state().asString() + + (taskStatusUpdateEvent.isFinal()?" (final state)":""); bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); } + public void sendTaskArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { + var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTaskId()); + StringBuilder textBuilder = new StringBuilder(); + var artifact = taskUpdateEvent.getArtifact(); + List artifacts; + if (artifact != null) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + else { + bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), "Received empty task artifact update event for task " + taskUpdateEvent.getTaskId())); + } + var payload = textBuilder.toString(); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + } public void sendTaskArtifacts(TaskUpdateEvent taskUpdateEvent) { var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTask().getId()); StringBuilder textBuilder = new StringBuilder(); List artifacts = taskUpdateEvent.getTask().getArtifacts(); - for (Artifact artifact : artifacts) { - textBuilder.append(extractTextFromParts(artifact.parts())); + if (artifacts != null) { + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + } + else { + textBuilder.append(extractTextFromParts(taskUpdateEvent.getTask().getStatus().message().getParts())); + } + var payload = textBuilder.toString(); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + } + + + public void sendTaskArtifacts(Task task) { + var context = contextsHolder.getContextFromTaskId(task.getId()); + StringBuilder textBuilder = new StringBuilder(); + List artifacts = task.getArtifacts(); + if (artifacts != null) { + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + } + else { + textBuilder.append(extractTextFromParts(task.getStatus().message().getParts())); } var payload = textBuilder.toString(); bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); @@ -183,7 +236,30 @@ public void sendToActivityLog(TaskEvent taskEvent) { context = contextsHolder.getContextFromContextId(taskEvent.getTask().getContextId()); contextsHolder.addOrUpdateContext(context); } - String payload = "Received task event for " + taskId; + switch (taskEvent.getTask().getStatus().state()) { + case COMPLETED, CANCELED, FAILED, REJECTED, UNKNOWN -> { + context.resetOnTaskTerminalState(); + } + } + String payload = "Received task event for " + taskId + " with status " + taskEvent.getTask().getStatus().state(); + bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); + } + + public void sendToActivityLog(TaskArtifactUpdateEvent taskArtifactUpdateEvent) { + var taskId = taskArtifactUpdateEvent.getTaskId(); + var context = contextsHolder.getContextFromTaskId(taskId); + if (context == null) { + context = contextsHolder.getContextFromContextId(taskArtifactUpdateEvent.getContextId()); + contextsHolder.addOrUpdateContext(context); + } + String payload = "Received task artifact update event for " + taskId + " (last chunk = " + taskArtifactUpdateEvent.isLastChunk() + ")"; bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); } + + public void sendInputRequired(String taskId, TaskStatus status) { + var context = contextsHolder.getContextFromTaskId(taskId); + var payload = A2AUtils.extractTextFromParts(status.message().getParts()); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + } + } diff --git a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java index e7741ce..891eac1 100644 --- a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java +++ b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java @@ -46,7 +46,7 @@ public String getTodoId() { return todoId; } - public void resetOnTaskCompletion() { + public void resetOnTaskTerminalState() { //do not reset context id as it is reuable across tasks //do not reset current agent as it is reusable for a give task //do not reset todoId because technically we should ahndle post cancel / post completed recalls diff --git a/src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java b/src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java new file mode 100644 index 0000000..1d92e94 --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java @@ -0,0 +1,156 @@ +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.A2AClientError; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Message; +import io.a2a.spec.Task; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskState; +import io.a2a.spec.TaskStatus; +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.inject.Qualifier; +import jakarta.ws.rs.Produces; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import static io.quarkus.sample.agents.A2AUtils.extractTextFromParts; + +/** + * @author Emmanuel Bernard emmanuel@hibernate.org + */ +@ApplicationScoped +public class MovieAgentProducer { + + @Target({ElementType.METHOD, ElementType.FIELD}) + @Retention(RetentionPolicy.RUNTIME) + @Qualifier + public @interface MovieAgent {} + + @Inject + AgentsMediator agentsMediator; + + @Inject + @ConfigProperty(name = "agent.movie.url") + private String url; + + @Produces @MovieAgent + public AgentCard getCard() throws A2AClientError { + AgentCard publicAgentCard = new A2ACardResolver(url).getAgentCard(); + Log.infov("Weather card loaded: {0}", publicAgentCard.name()); + return publicAgentCard; + } + + + @Produces @MovieAgent + public Client getA2aClient() throws A2AClientError, A2AClientException { + // Create consumers for handling client events + List> consumers + = getConsumers(); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = (error) -> { + Log.errorv("JDK streaming error occured {0}", error); + error.printStackTrace(); + //messageResponse.completeExceptionally(error); + }; + 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()) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler) + .withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfig()) + .clientConfig(clientConfig) + .build(); + return client; + } + + private List> getConsumers() { + 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); + agentsMediator.receiveMessageFromAgent(responseMessage); + //messageResponse.complete(text); + } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { + UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + System.out.println("TaskUpdateEvent: " + event + " \nUpdate event: " + updateEvent); + if (updateEvent + instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { + System.out.println( + "Received status-update: " + + taskStatusUpdateEvent.getStatus().state().asString()); + agentsMediator.sendToActivityLog(taskStatusUpdateEvent); + if (taskStatusUpdateEvent.isFinal()) { + agentsMediator.sendTaskArtifacts(taskUpdateEvent.getTask()); +// StringBuilder textBuilder = new StringBuilder(); +// List artifacts +// = taskUpdateEvent.getTask().getArtifacts(); +// for (Artifact artifact : artifacts) { +// textBuilder.append(extractTextFromParts(artifact.parts())); +// } +// String text = textBuilder.toString(); + //messageResponse.complete(text); + } + else if (taskStatusUpdateEvent.getStatus().state() == TaskState.INPUT_REQUIRED) { + agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), taskStatusUpdateEvent.getStatus()); + } + } else if (updateEvent instanceof TaskArtifactUpdateEvent + taskArtifactUpdateEvent) { + agentsMediator.sendToActivityLog(taskArtifactUpdateEvent); + agentsMediator.sendTaskArtifacts(taskArtifactUpdateEvent); +// List> parts = taskArtifactUpdateEvent +// .getArtifact() +// .parts(); +// String text = extractTextFromParts(parts); +// System.out.println("Received artifact-update: " + text); + } + } else if (event instanceof TaskEvent taskEvent) { + var task = taskEvent.getTask(); + System.out.println("Received task event: " + + task.getId()); + var state = task.getStatus().state(); + agentsMediator.sendToActivityLog(taskEvent); + if (state == TaskState.COMPLETED) { + agentsMediator.sendTaskArtifacts(task); + + } + else if (state == TaskState.INPUT_REQUIRED) { + agentsMediator.sendInputRequired(task.getId(), task.getStatus()); + } + } + }); + return consumers; + } + +} diff --git a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java index 5231a3c..25dc9d6 100644 --- a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java +++ b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java @@ -64,7 +64,6 @@ public Client getA2aClient() throws A2AClientError, A2AClientException { // Create error handler for streaming errors Consumer streamingErrorHandler = (error) -> { - Log.errorv("JDK streaming error occured {0}", error.getMessage()); Log.errorv("JDK streaming error occured {0}", error); error.printStackTrace(); //messageResponse.completeExceptionally(error); @@ -98,6 +97,7 @@ private List> getConsumers() { //messageResponse.complete(text); } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + System.out.println("TaskUpdateEvent: " + event + " \nUpdate event: " + updateEvent); if (updateEvent instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { System.out.println( @@ -105,7 +105,7 @@ private List> getConsumers() { + taskStatusUpdateEvent.getStatus().state().asString()); agentsMediator.sendToActivityLog(taskStatusUpdateEvent); if (taskStatusUpdateEvent.isFinal()) { - //agentDispatcher.sendTaskArtifacts(taskUpdateEvent); + agentsMediator.sendTaskArtifacts(taskUpdateEvent.getTask()); // StringBuilder textBuilder = new StringBuilder(); // List artifacts // = taskUpdateEvent.getTask().getArtifacts(); @@ -115,9 +115,13 @@ private List> getConsumers() { // String text = textBuilder.toString(); //messageResponse.complete(text); } + else if (taskStatusUpdateEvent.getStatus().state() == TaskState.INPUT_REQUIRED) { + agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), taskStatusUpdateEvent.getStatus()); + } } else if (updateEvent instanceof TaskArtifactUpdateEvent taskArtifactUpdateEvent) { - agentsMediator.sendTaskArtifacts(taskUpdateEvent); + agentsMediator.sendToActivityLog(taskArtifactUpdateEvent); + agentsMediator.sendTaskArtifacts(taskArtifactUpdateEvent); // List> parts = taskArtifactUpdateEvent // .getArtifact() // .parts(); @@ -125,12 +129,20 @@ private List> getConsumers() { // System.out.println("Received artifact-update: " + text); } } else if (event instanceof TaskEvent taskEvent) { + var task = taskEvent.getTask(); System.out.println("Received task event: " - + taskEvent.getTask().getId()); + + task.getId()); + var state = task.getStatus().state(); agentsMediator.sendToActivityLog(taskEvent); + if (state == TaskState.COMPLETED) { + agentsMediator.sendTaskArtifacts(task); + + } + else if (state == TaskState.INPUT_REQUIRED) { + agentsMediator.sendInputRequired(task.getId(), task.getStatus()); + } } }); return consumers; } - } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2f28fbf..b27eb9d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -31,4 +31,5 @@ quarkus.langchain4j.log-requests=true quarkus.langchain4j.log-responses=true # Agents -agent.weather.url = http://localhost:10010 \ No newline at end of file +agent.weather.url=http://localhost:10010 +agent.movie.url=http://localhost:41241 \ No newline at end of file From 7bbff80705c8eb5ab2085faadbff13b128313a28 Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Fri, 3 Oct 2025 11:01:27 +0200 Subject: [PATCH 19/24] Rename agent_request to agent_message and move packages for websocket classes --- agent-protocol.md | 2 +- .../java/io/quarkus/sample/agent/Kind.java | 5 ----- .../quarkus/sample/agents/AgentsMediator.java | 18 +++++++++--------- .../webstocket}/AgentMessage.java | 2 +- .../quarkus/sample/agents/webstocket/Kind.java | 5 +++++ .../webstocket}/TodoAgentWebSocket.java | 8 ++++---- src/main/resources/web/app/todos-detail.js | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) delete mode 100644 src/main/java/io/quarkus/sample/agent/Kind.java rename src/main/java/io/quarkus/sample/{agent => agents/webstocket}/AgentMessage.java (63%) create mode 100644 src/main/java/io/quarkus/sample/agents/webstocket/Kind.java rename src/main/java/io/quarkus/sample/{agent => agents/webstocket}/TodoAgentWebSocket.java (95%) diff --git a/agent-protocol.md b/agent-protocol.md index ad667f3..f3e7008 100644 --- a/agent-protocol.md +++ b/agent-protocol.md @@ -20,7 +20,7 @@ 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_request` : 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. +* `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 diff --git a/src/main/java/io/quarkus/sample/agent/Kind.java b/src/main/java/io/quarkus/sample/agent/Kind.java deleted file mode 100644 index 032ca3b..0000000 --- a/src/main/java/io/quarkus/sample/agent/Kind.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.quarkus.sample.agent; - -public enum Kind { - cancel, activity_log, agent_request, user_message -} diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 9d684e9..33d7923 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -15,8 +15,8 @@ import io.a2a.spec.TextPart; import io.quarkus.logging.Log; import io.quarkus.sample.Todo; -import io.quarkus.sample.agent.AgentMessage; -import io.quarkus.sample.agent.Kind; +import io.quarkus.sample.agents.webstocket.AgentMessage; +import io.quarkus.sample.agents.webstocket.Kind; import io.vertx.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.ObservesAsync; @@ -78,7 +78,7 @@ public void findAgent(ClientAgentContext context) { var todoId = context.getTodoId(); switch (currentAgent) { case WEATHER,MOVIE -> { - bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "The " + currentAgent + " agent will look into your todo")); + bus.publish(todoId,new AgentMessage(Kind.agent_message, todoId, "The " + currentAgent + " agent will look into your todo")); try { getCurrentClient(context).sendMessage(new Message.Builder() .role(Message.Role.USER) @@ -103,7 +103,7 @@ private List buildDescriptors() { } private void sendNoAgentMessage(String todoId) { - bus.publish(todoId,new AgentMessage(Kind.agent_request, todoId, "No agent has been found for your need, sorry about that!")); + bus.publish(todoId,new AgentMessage(Kind.agent_message, todoId, "No agent has been found for your need, sorry about that!")); } private String todoAsPrompt(Todo todo) { @@ -160,7 +160,7 @@ public void passUserMessage(String todoId, String userMessage) { public void receiveMessageFromAgent(Message responseMessage) { var context = contextsHolder.getContextFromContextId(responseMessage.getContextId()); var payload = A2AUtils.extractTextFromParts(responseMessage.getParts()); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } public void sendToActivityLog(TaskStatusUpdateEvent taskStatusUpdateEvent) { @@ -194,7 +194,7 @@ public void sendTaskArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), "Received empty task artifact update event for task " + taskUpdateEvent.getTaskId())); } var payload = textBuilder.toString(); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } public void sendTaskArtifacts(TaskUpdateEvent taskUpdateEvent) { var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTask().getId()); @@ -209,7 +209,7 @@ public void sendTaskArtifacts(TaskUpdateEvent taskUpdateEvent) { textBuilder.append(extractTextFromParts(taskUpdateEvent.getTask().getStatus().message().getParts())); } var payload = textBuilder.toString(); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } @@ -226,7 +226,7 @@ public void sendTaskArtifacts(Task task) { textBuilder.append(extractTextFromParts(task.getStatus().message().getParts())); } var payload = textBuilder.toString(); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } public void sendToActivityLog(TaskEvent taskEvent) { @@ -259,7 +259,7 @@ public void sendToActivityLog(TaskArtifactUpdateEvent taskArtifactUpdateEvent) { public void sendInputRequired(String taskId, TaskStatus status) { var context = contextsHolder.getContextFromTaskId(taskId); var payload = A2AUtils.extractTextFromParts(status.message().getParts()); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_request, context.getTodoId(), payload)); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } } diff --git a/src/main/java/io/quarkus/sample/agent/AgentMessage.java b/src/main/java/io/quarkus/sample/agents/webstocket/AgentMessage.java similarity index 63% rename from src/main/java/io/quarkus/sample/agent/AgentMessage.java rename to src/main/java/io/quarkus/sample/agents/webstocket/AgentMessage.java index 692a2d9..08791c1 100644 --- a/src/main/java/io/quarkus/sample/agent/AgentMessage.java +++ b/src/main/java/io/quarkus/sample/agents/webstocket/AgentMessage.java @@ -1,4 +1,4 @@ -package io.quarkus.sample.agent; +package io.quarkus.sample.agents.webstocket; public record AgentMessage(Kind kind, String todoId, String payload) { diff --git a/src/main/java/io/quarkus/sample/agents/webstocket/Kind.java b/src/main/java/io/quarkus/sample/agents/webstocket/Kind.java new file mode 100644 index 0000000..a480ced --- /dev/null +++ b/src/main/java/io/quarkus/sample/agents/webstocket/Kind.java @@ -0,0 +1,5 @@ +package io.quarkus.sample.agents.webstocket; + +public enum Kind { + cancel, activity_log, agent_message, user_message +} diff --git a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java similarity index 95% rename from src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java rename to src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java index 5f8e33e..578df57 100644 --- a/src/main/java/io/quarkus/sample/agent/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java @@ -1,4 +1,4 @@ -package io.quarkus.sample.agent; +package io.quarkus.sample.agents.webstocket; import io.a2a.spec.A2AClientError; import io.quarkus.runtime.StartupEvent; @@ -68,12 +68,12 @@ public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connect return noAIMessage(todoId); } else { - return new AgentMessage(Kind.agent_request, todoId, "Searching an agent for '" + todo.title + "'"); + return new AgentMessage(Kind.agent_message, todoId, "Searching an agent for '" + todo.title + "'"); } } private static AgentMessage noAIMessage(String todoId) { - return new AgentMessage(Kind.agent_request, todoId, "Unable to find started agents, Do with AI is not available."); + return new AgentMessage(Kind.agent_message, todoId, "Unable to find started agents, Do with AI is not available."); } @OnTextMessage @@ -104,7 +104,7 @@ void onClose(@PathParam String todoId) { } private void sendAgentRequestMessage(String todoId, String message) { - eventBus.publish(todoId, new AgentMessage(Kind.agent_request, todoId, message)); + eventBus.publish(todoId, new AgentMessage(Kind.agent_message, todoId, message)); } private void sendAgentActivityMessage(String todoId, String message) { diff --git a/src/main/resources/web/app/todos-detail.js b/src/main/resources/web/app/todos-detail.js index d07188c..42e3c54 100644 --- a/src/main/resources/web/app/todos-detail.js +++ b/src/main/resources/web/app/todos-detail.js @@ -275,7 +275,7 @@ class TodosDetail extends LitElement { if(agentMessage.kind && agentMessage.kind === "activity_log"){ this._messageListItems = [...this._messageListItems, this._createLogMessage(agentMessage.payload)]; - }else if(agentMessage.kind && agentMessage.kind === "agent_request"){ + }else if(agentMessage.kind && agentMessage.kind === "agent_message"){ this._messageListItems = [...this._messageListItems, this._createAgentMessage(agentMessage.payload)]; }else{ // TODO: Show in messages From a4f21457307358f8f2529513d44b3644971d0afa Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Fri, 3 Oct 2025 12:23:41 +0200 Subject: [PATCH 20/24] Support absent A2A servers and render AgentProducer more generic --- ...AgentProducer.java => AgentProducers.java} | 112 +++++++------ .../quarkus/sample/agents/AgentsMediator.java | 49 +++--- .../sample/agents/WeatherAgentProducer.java | 148 ------------------ .../agents/webstocket/TodoAgentWebSocket.java | 18 +-- 4 files changed, 97 insertions(+), 230 deletions(-) rename src/main/java/io/quarkus/sample/agents/{MovieAgentProducer.java => AgentProducers.java} (59%) delete mode 100644 src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java diff --git a/src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java b/src/main/java/io/quarkus/sample/agents/AgentProducers.java similarity index 59% rename from src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java rename to src/main/java/io/quarkus/sample/agents/AgentProducers.java index 1d92e94..bddc7bb 100644 --- a/src/main/java/io/quarkus/sample/agents/MovieAgentProducer.java +++ b/src/main/java/io/quarkus/sample/agents/AgentProducers.java @@ -13,7 +13,6 @@ import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.Message; -import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskState; import io.a2a.spec.TaskStatus; @@ -22,16 +21,13 @@ import io.quarkus.logging.Log; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.inject.Qualifier; import jakarta.ws.rs.Produces; import org.eclipse.microprofile.config.inject.ConfigProperty; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; 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; @@ -41,39 +37,59 @@ * @author Emmanuel Bernard emmanuel@hibernate.org */ @ApplicationScoped -public class MovieAgentProducer { +public class AgentProducers { - @Target({ElementType.METHOD, ElementType.FIELD}) - @Retention(RetentionPolicy.RUNTIME) - @Qualifier - public @interface MovieAgent {} + private Map agents = new HashMap<>(); @Inject AgentsMediator agentsMediator; @Inject @ConfigProperty(name = "agent.movie.url") - private String url; + private String movieUrl; - @Produces @MovieAgent - public AgentCard getCard() throws A2AClientError { - AgentCard publicAgentCard = new A2ACardResolver(url).getAgentCard(); - Log.infov("Weather card loaded: {0}", publicAgentCard.name()); - return publicAgentCard; + @Inject + @ConfigProperty(name = "agent.weather.url") + private String weatherUrl; + + @Produces + public Map getCards() { + Map cards = new HashMap<>(); + addAgentCard(AGENT.WEATHER, weatherUrl, cards); + addAgentCard(AGENT.MOVIE, movieUrl, cards); + return cards; + } + + private void addAgentCard(AGENT agent, String url, Map 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()); + } } - @Produces @MovieAgent - public Client getA2aClient() throws A2AClientError, A2AClientException { + 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> consumers = getConsumers(); // Create error handler for streaming errors Consumer streamingErrorHandler = (error) -> { - Log.errorv("JDK streaming error occured {0}", error); - error.printStackTrace(); - //messageResponse.completeExceptionally(error); + Log.errorv("JDK streaming error occured {0}", error.getMessage()); + //error.printStackTrace(); }; ClientConfig clientConfig = new ClientConfig.Builder() .setAcceptedOutputModes(List.of("Text")) @@ -82,7 +98,7 @@ public Client getA2aClient() throws A2AClientError, A2AClientException { // 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()) + Client client = Client.builder(getCard(agent)) .addConsumers(consumers) .streamingErrorHandler(streamingErrorHandler) .withTransport(JSONRPCTransport.class, @@ -92,6 +108,10 @@ public Client getA2aClient() throws A2AClientError, A2AClientException { return client; } + private AgentCard getCard(AGENT agent) { + return getCards().get(agent); + } + private List> getConsumers() { List> consumers = new ArrayList<>(); consumers.add( @@ -99,54 +119,44 @@ private List> getConsumers() { if (event instanceof MessageEvent messageEvent) { Message responseMessage = messageEvent.getMessage(); String text = extractTextFromParts(responseMessage.getParts()); - System.out.println("Received message: " + text); + Log.infov("Received message: {0}", text); agentsMediator.receiveMessageFromAgent(responseMessage); //messageResponse.complete(text); } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); - System.out.println("TaskUpdateEvent: " + event + " \nUpdate event: " + updateEvent); + Log.infov( + "Received TaskUpdateEvent for {0}, status: {1}", + taskUpdateEvent.getTask().getId(), + taskUpdateEvent.getTask().getStatus().state()); if (updateEvent instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { - System.out.println( - "Received status-update: " - + taskStatusUpdateEvent.getStatus().state().asString()); + var status = taskStatusUpdateEvent.getStatus(); + Log.infov( "Received status-update: {0} ", status.state()); agentsMediator.sendToActivityLog(taskStatusUpdateEvent); if (taskStatusUpdateEvent.isFinal()) { agentsMediator.sendTaskArtifacts(taskUpdateEvent.getTask()); -// StringBuilder textBuilder = new StringBuilder(); -// List artifacts -// = taskUpdateEvent.getTask().getArtifacts(); -// for (Artifact artifact : artifacts) { -// textBuilder.append(extractTextFromParts(artifact.parts())); -// } -// String text = textBuilder.toString(); - //messageResponse.complete(text); } - else if (taskStatusUpdateEvent.getStatus().state() == TaskState.INPUT_REQUIRED) { - agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), taskStatusUpdateEvent.getStatus()); + else if (status.state() == TaskState.INPUT_REQUIRED) { + agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), status); } } else if (updateEvent instanceof TaskArtifactUpdateEvent taskArtifactUpdateEvent) { agentsMediator.sendToActivityLog(taskArtifactUpdateEvent); agentsMediator.sendTaskArtifacts(taskArtifactUpdateEvent); -// List> parts = taskArtifactUpdateEvent -// .getArtifact() -// .parts(); -// String text = extractTextFromParts(parts); -// System.out.println("Received artifact-update: " + text); + Log.infov("Received artifact-update for task {0}: {1}", taskArtifactUpdateEvent.getTaskId(), taskArtifactUpdateEvent.getArtifact().name()); } } else if (event instanceof TaskEvent taskEvent) { var task = taskEvent.getTask(); - System.out.println("Received task event: " - + task.getId()); + Log.infov("Received task event for {0}: status {1}", task.getId(), task.getStatus().state()); var state = task.getStatus().state(); agentsMediator.sendToActivityLog(taskEvent); - if (state == TaskState.COMPLETED) { - agentsMediator.sendTaskArtifacts(task); - - } - else if (state == TaskState.INPUT_REQUIRED) { - agentsMediator.sendInputRequired(task.getId(), task.getStatus()); + switch (state) { + case COMPLETED -> { + agentsMediator.sendTaskArtifacts(task); + } + case INPUT_REQUIRED -> { + agentsMediator.sendInputRequired(task.getId(), task.getStatus()); + } } } }); diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 33d7923..23a3d16 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -4,7 +4,6 @@ import io.a2a.client.TaskEvent; import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.A2AClientException; -import io.a2a.spec.AgentCard; import io.a2a.spec.Artifact; import io.a2a.spec.Message; import io.a2a.spec.Task; @@ -22,7 +21,7 @@ import jakarta.enterprise.event.ObservesAsync; import jakarta.inject.Inject; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -34,14 +33,7 @@ */ @ApplicationScoped public class AgentsMediator { - @Inject @WeatherAgentProducer.WeatherAgent - private Client weatherClient; - @Inject @MovieAgentProducer.MovieAgent - private Client movieClient; - @Inject @WeatherAgentProducer.WeatherAgent - private AgentCard weatherCard; - @Inject @MovieAgentProducer.MovieAgent - private AgentCard movieCard; + @Inject AgentProducers agentProducers; private ClientAgentContextsHolder contextsHolder = new ClientAgentContextsHolder(); @Inject @@ -71,9 +63,19 @@ public CompletionStage onConversationStart(@ObservesAsync ClientAgentConte public void findAgent(ClientAgentContext context) { var todo = context.getTodo(); - var currentAgent = agentSelector.findRelevantAgent(todo.title, todo.description, buildDescriptors()); + var agents = buildDescriptors(); + var proposedAgent = agentSelector.findRelevantAgent(todo.title, todo.description, agents); + AGENT currentAgent; + //verify selected agent is active + if (agents.stream().anyMatch(agentDescriptor -> agentDescriptor.getAgent() == proposedAgent)) { + currentAgent = proposedAgent; + } + else { + currentAgent = AGENT.NONE; + } + context.setCurrentAgent(currentAgent); - Log.infov("Selected agent {0} for todo '{1}'", currentAgent, todo.title); + Log.infov("Selected agent {0} for todo '{1}' ", currentAgent, todo.title); var todoId = context.getTodoId(); switch (currentAgent) { @@ -97,9 +99,11 @@ public void findAgent(ClientAgentContext context) { } private List buildDescriptors() { - var weatherDescriptor = new AgentDescriptor(AGENT.WEATHER, weatherCard); - var movieDescriptor = new AgentDescriptor(AGENT.MOVIE, movieCard); - return Arrays.asList(weatherDescriptor,movieDescriptor); + var result = new ArrayList(); + agentProducers.getCards().forEach( + (agent, card) -> result.add( new AgentDescriptor(agent, card)) + ); + return result; } private void sendNoAgentMessage(String todoId) { @@ -118,11 +122,16 @@ private Client getCurrentClient(ClientAgentContext context) { var todoId = context.getTodoId(); var currentAgent = context.getCurrentAgent(); switch (currentAgent) { - case WEATHER -> { - return weatherClient; - } - case MOVIE -> { - return movieClient; + case WEATHER, MOVIE -> { + try { + return agentProducers.getA2aClient(currentAgent); + } catch (A2AClientException e) { + bus.publish(todoId, new AgentMessage( + Kind.activity_log, todoId, + "A2A Client cannot be built for " + currentAgent + "\n" + e.getMessage()) + ); + throw new RuntimeException(e); + } } case NONE -> { IllegalStateException illegalStateException = new IllegalStateException("An agent should have been picked to call getCurrentClient value: " + currentAgent); diff --git a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java b/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java deleted file mode 100644 index 25dc9d6..0000000 --- a/src/main/java/io/quarkus/sample/agents/WeatherAgentProducer.java +++ /dev/null @@ -1,148 +0,0 @@ -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.*; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.inject.Qualifier; -import jakarta.ws.rs.Produces; -import org.eclipse.microprofile.config.inject.ConfigProperty; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.function.BiConsumer; -import java.util.function.Consumer; - -import io.quarkus.logging.Log; - -import static io.quarkus.sample.agents.A2AUtils.*; - -/** - * @author Emmanuel Bernard emmanuel@hibernate.org - */ -@ApplicationScoped -public class WeatherAgentProducer { - - @Target({ElementType.METHOD, ElementType.FIELD}) - @Retention(RetentionPolicy.RUNTIME) - @Qualifier - public @interface WeatherAgent {} - - @Inject - AgentsMediator agentsMediator; - - @Inject - @ConfigProperty(name = "agent.weather.url") - private String url; - - @Produces @WeatherAgent - public AgentCard getCard() throws A2AClientError { - AgentCard publicAgentCard = new A2ACardResolver(url).getAgentCard(); - Log.infov("Weather card loaded: {0}", publicAgentCard.name()); - return publicAgentCard; - } - - - @Produces @WeatherAgent - public Client getA2aClient() throws A2AClientError, A2AClientException { - // Create consumers for handling client events - List> consumers - = getConsumers(); - - // Create error handler for streaming errors - Consumer streamingErrorHandler = (error) -> { - Log.errorv("JDK streaming error occured {0}", error); - error.printStackTrace(); - //messageResponse.completeExceptionally(error); - }; - 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()) - .addConsumers(consumers) - .streamingErrorHandler(streamingErrorHandler) - .withTransport(JSONRPCTransport.class, - new JSONRPCTransportConfig()) - .clientConfig(clientConfig) - .build(); - return client; - } - - private List> getConsumers() { - 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); - agentsMediator.receiveMessageFromAgent(responseMessage); - //messageResponse.complete(text); - } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { - UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); - System.out.println("TaskUpdateEvent: " + event + " \nUpdate event: " + updateEvent); - if (updateEvent - instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { - System.out.println( - "Received status-update: " - + taskStatusUpdateEvent.getStatus().state().asString()); - agentsMediator.sendToActivityLog(taskStatusUpdateEvent); - if (taskStatusUpdateEvent.isFinal()) { - agentsMediator.sendTaskArtifacts(taskUpdateEvent.getTask()); -// StringBuilder textBuilder = new StringBuilder(); -// List artifacts -// = taskUpdateEvent.getTask().getArtifacts(); -// for (Artifact artifact : artifacts) { -// textBuilder.append(extractTextFromParts(artifact.parts())); -// } -// String text = textBuilder.toString(); - //messageResponse.complete(text); - } - else if (taskStatusUpdateEvent.getStatus().state() == TaskState.INPUT_REQUIRED) { - agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), taskStatusUpdateEvent.getStatus()); - } - } else if (updateEvent instanceof TaskArtifactUpdateEvent - taskArtifactUpdateEvent) { - agentsMediator.sendToActivityLog(taskArtifactUpdateEvent); - agentsMediator.sendTaskArtifacts(taskArtifactUpdateEvent); -// List> parts = taskArtifactUpdateEvent -// .getArtifact() -// .parts(); -// String text = extractTextFromParts(parts); -// System.out.println("Received artifact-update: " + text); - } - } else if (event instanceof TaskEvent taskEvent) { - var task = taskEvent.getTask(); - System.out.println("Received task event: " - + task.getId()); - var state = task.getStatus().state(); - agentsMediator.sendToActivityLog(taskEvent); - if (state == TaskState.COMPLETED) { - agentsMediator.sendTaskArtifacts(task); - - } - else if (state == TaskState.INPUT_REQUIRED) { - agentsMediator.sendInputRequired(task.getId(), task.getStatus()); - } - } - }); - return consumers; - } -} diff --git a/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java index 578df57..f3f077a 100644 --- a/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java @@ -1,11 +1,10 @@ package io.quarkus.sample.agents.webstocket; -import io.a2a.spec.A2AClientError; import io.quarkus.runtime.StartupEvent; import io.quarkus.sample.Todo; +import io.quarkus.sample.agents.AgentProducers; import io.quarkus.sample.agents.AgentsMediator; import io.quarkus.sample.agents.ClientAgentContext; -import io.quarkus.sample.agents.WeatherAgentProducer; import io.quarkus.vertx.LocalEventBusCodec; import io.quarkus.websockets.next.OnClose; import io.quarkus.websockets.next.WebSocketConnection; @@ -40,20 +39,17 @@ public class TodoAgentWebSocket { Event agentEvent; @Inject - WeatherAgentProducer weatherAgentProducer; + AgentProducers agentProducers; + boolean agentsReady = false; Map> consumers = new ConcurrentHashMap<>(); - boolean agentsReady = false; @OnOpen public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connection) { Log.info("Opening of websocket for todo " + todoId); - try { - weatherAgentProducer.getCard(); - agentsReady = true; - } catch (A2AClientError e) { + agentsReady = !agentProducers.getCards().isEmpty(); + if (! agentsReady) { Log.warn("Unable to connect to a2a servers, did you start them?"); - agentsReady = false; } MessageConsumer consumer = eventBus.consumer(todoId, message -> { @@ -62,18 +58,18 @@ public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connect }); consumers.put(todoId, consumer); Todo todo = Todo.findById(Long.parseLong(todoId)); - agentEvent.fireAsync(new ClientAgentContext(todo, todoId)); if (!agentsReady) { return noAIMessage(todoId); } else { + agentEvent.fireAsync(new ClientAgentContext(todo, todoId)); return new AgentMessage(Kind.agent_message, todoId, "Searching an agent for '" + todo.title + "'"); } } private static AgentMessage noAIMessage(String todoId) { - return new AgentMessage(Kind.agent_message, todoId, "Unable to find started agents, Do with AI is not available."); + return new AgentMessage(Kind.agent_message, todoId, "Unable to find A2A agents (are they started), Do with AI is not available."); } @OnTextMessage From 5fb64742d451b19115c4ff8383b7b6e90dc6532c Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Mon, 6 Oct 2025 09:28:57 +0200 Subject: [PATCH 21/24] Clean code for demo --- .../quarkus/sample/agents/AgentProducers.java | 6 +- .../quarkus/sample/agents/AgentSelector.java | 4 +- .../quarkus/sample/agents/AgentsMediator.java | 126 ++++++++---------- .../agents/webstocket/TodoAgentWebSocket.java | 54 +------- 4 files changed, 69 insertions(+), 121 deletions(-) diff --git a/src/main/java/io/quarkus/sample/agents/AgentProducers.java b/src/main/java/io/quarkus/sample/agents/AgentProducers.java index bddc7bb..0309f2f 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentProducers.java +++ b/src/main/java/io/quarkus/sample/agents/AgentProducers.java @@ -83,8 +83,7 @@ public Client getA2aClient(AGENT agent) throws A2AClientException { private Client buildA2aClient(AGENT agent) throws A2AClientException { // Create consumers for handling client events - List> consumers - = getConsumers(); + List> consumers = getConsumers(); // Create error handler for streaming errors Consumer streamingErrorHandler = (error) -> { @@ -127,7 +126,8 @@ private List> getConsumers() { Log.infov( "Received TaskUpdateEvent for {0}, status: {1}", taskUpdateEvent.getTask().getId(), - taskUpdateEvent.getTask().getStatus().state()); + taskUpdateEvent.getTask().getStatus().state() + ); if (updateEvent instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { var status = taskStatusUpdateEvent.getStatus(); diff --git a/src/main/java/io/quarkus/sample/agents/AgentSelector.java b/src/main/java/io/quarkus/sample/agents/AgentSelector.java index 40c453d..22e09a8 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentSelector.java +++ b/src/main/java/io/quarkus/sample/agents/AgentSelector.java @@ -28,7 +28,7 @@ public interface AgentSelector { Example 1: Todo title: Find holidays for next winter - Todo descrption: + Todo description: NONE Example 2: @@ -41,7 +41,7 @@ public interface AgentSelector { Todo description: WEATHER - Here is the task I want ou to find a service for + Here is the task I want you to find a service for Task title: {todoTitle} Task description: {todoDescription} """) diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 23a3d16..97b58a0 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -42,37 +42,19 @@ public class AgentsMediator { @Inject AgentSelector agentSelector; - public void cancel(String todoId) { - ClientAgentContext context = contextsHolder.getContextFromTodoId(todoId); - if (context.getTaskId() != null) { - try { - getCurrentClient(context).cancelTask(new TaskIdParams(context.getTaskId())); - context.resetOnTaskTerminalState(); - } catch (A2AClientException e) { - //let's ignore cancellation exception, we are done on our side - } - } - } - public CompletionStage onConversationStart(@ObservesAsync ClientAgentContext event) { contextsHolder.addOrUpdateContext(event); - findAgent(event); + initiateSubAgentConversation(event); return CompletableFuture.completedFuture(null); } - public void findAgent(ClientAgentContext context) { + public void initiateSubAgentConversation(ClientAgentContext context) { var todo = context.getTodo(); - var agents = buildDescriptors(); - var proposedAgent = agentSelector.findRelevantAgent(todo.title, todo.description, agents); + var agentDescriptors = buildDescriptors(); + var proposedAgent = agentSelector.findRelevantAgent(todo.title, todo.description, agentDescriptors); AGENT currentAgent; - //verify selected agent is active - if (agents.stream().anyMatch(agentDescriptor -> agentDescriptor.getAgent() == proposedAgent)) { - currentAgent = proposedAgent; - } - else { - currentAgent = AGENT.NONE; - } + currentAgent = verifyAgentPresence(agentDescriptors, proposedAgent); context.setCurrentAgent(currentAgent); Log.infov("Selected agent {0} for todo '{1}' ", currentAgent, todo.title); @@ -98,6 +80,18 @@ public void findAgent(ClientAgentContext context) { } } + private static AGENT verifyAgentPresence(List agents, AGENT proposedAgent) { + AGENT currentAgent; + //verify selected agent is active + if (agents.stream().anyMatch(agentDescriptor -> agentDescriptor.getAgent() == proposedAgent)) { + currentAgent = proposedAgent; + } + else { + currentAgent = AGENT.NONE; + } + return currentAgent; + } + private List buildDescriptors() { var result = new ArrayList(); agentProducers.getCards().forEach( @@ -155,42 +149,33 @@ public void passUserMessage(String todoId, String userMessage) { .parts(new TextPart(userMessage)) .build(); try { - if (context.getCurrentAgent() == AGENT.NONE) { - sendNoAgentMessage(todoId); - } - else { - getCurrentClient(context).sendMessage(a2aMessage); + switch (context.getCurrentAgent()) { + case NONE -> sendNoAgentMessage(todoId); + default -> getCurrentClient(context).sendMessage(a2aMessage); } } catch (A2AClientException e) { bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); } } + public void cancel(String todoId) { + ClientAgentContext context = contextsHolder.getContextFromTodoId(todoId); + if (context.getTaskId() != null) { + try { + getCurrentClient(context).cancelTask(new TaskIdParams(context.getTaskId())); + context.resetOnTaskTerminalState(); + } catch (A2AClientException e) { + //let's ignore cancellation exception, we are done on our side + } + } + } + public void receiveMessageFromAgent(Message responseMessage) { var context = contextsHolder.getContextFromContextId(responseMessage.getContextId()); var payload = A2AUtils.extractTextFromParts(responseMessage.getParts()); bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } - public void sendToActivityLog(TaskStatusUpdateEvent taskStatusUpdateEvent) { - var taskId = taskStatusUpdateEvent.getTaskId(); - var context = contextsHolder.getContextFromTaskId(taskId); - if (context == null) { - context = contextsHolder.getContextFromContextId(taskStatusUpdateEvent.getContextId()); - context.setTaskId(taskId); - contextsHolder.addOrUpdateContext(context); - } - switch (taskStatusUpdateEvent.getStatus().state()) { - case COMPLETED, CANCELED, FAILED, REJECTED, UNKNOWN -> { - context.resetOnTaskTerminalState(); - } - } - String payload = "Received status-update for " + taskId + ": " - + taskStatusUpdateEvent.getStatus().state().asString() - + (taskStatusUpdateEvent.isFinal()?" (final state)":""); - bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); - } - public void sendTaskArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTaskId()); StringBuilder textBuilder = new StringBuilder(); @@ -205,22 +190,6 @@ public void sendTaskArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { var payload = textBuilder.toString(); bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } - public void sendTaskArtifacts(TaskUpdateEvent taskUpdateEvent) { - var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTask().getId()); - StringBuilder textBuilder = new StringBuilder(); - List artifacts = taskUpdateEvent.getTask().getArtifacts(); - if (artifacts != null) { - for (Artifact artifact : artifacts) { - textBuilder.append(extractTextFromParts(artifact.parts())); - } - } - else { - textBuilder.append(extractTextFromParts(taskUpdateEvent.getTask().getStatus().message().getParts())); - } - var payload = textBuilder.toString(); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); - } - public void sendTaskArtifacts(Task task) { var context = contextsHolder.getContextFromTaskId(task.getId()); @@ -238,6 +207,31 @@ public void sendTaskArtifacts(Task task) { bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } + public void sendInputRequired(String taskId, TaskStatus status) { + var context = contextsHolder.getContextFromTaskId(taskId); + var payload = A2AUtils.extractTextFromParts(status.message().getParts()); + bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); + } + + public void sendToActivityLog(TaskStatusUpdateEvent taskStatusUpdateEvent) { + var taskId = taskStatusUpdateEvent.getTaskId(); + var context = contextsHolder.getContextFromTaskId(taskId); + if (context == null) { + context = contextsHolder.getContextFromContextId(taskStatusUpdateEvent.getContextId()); + context.setTaskId(taskId); + contextsHolder.addOrUpdateContext(context); + } + switch (taskStatusUpdateEvent.getStatus().state()) { + case COMPLETED, CANCELED, FAILED, REJECTED, UNKNOWN -> { + context.resetOnTaskTerminalState(); + } + } + String payload = "Received status-update for " + taskId + ": " + + taskStatusUpdateEvent.getStatus().state().asString() + + (taskStatusUpdateEvent.isFinal()?" (final state)":""); + bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); + } + public void sendToActivityLog(TaskEvent taskEvent) { var taskId = taskEvent.getTask().getId(); var context = contextsHolder.getContextFromTaskId(taskId); @@ -265,10 +259,6 @@ public void sendToActivityLog(TaskArtifactUpdateEvent taskArtifactUpdateEvent) { bus.publish(context.getTodoId(), new AgentMessage(Kind.activity_log, context.getTodoId(), payload)); } - public void sendInputRequired(String taskId, TaskStatus status) { - var context = contextsHolder.getContextFromTaskId(taskId); - var payload = A2AUtils.extractTextFromParts(status.message().getParts()); - bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); - } + } diff --git a/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java b/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java index f3f077a..b191bcb 100644 --- a/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java +++ b/src/main/java/io/quarkus/sample/agents/webstocket/TodoAgentWebSocket.java @@ -47,10 +47,7 @@ public class TodoAgentWebSocket { @OnOpen public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connection) { Log.info("Opening of websocket for todo " + todoId); - agentsReady = !agentProducers.getCards().isEmpty(); - if (! agentsReady) { - Log.warn("Unable to connect to a2a servers, did you start them?"); - } + MessageConsumer consumer = eventBus.consumer(todoId, message -> { Log.info("Message received from event bus: " + message.body()); @@ -59,7 +56,10 @@ public AgentMessage onOpen(@PathParam String todoId, WebSocketConnection connect consumers.put(todoId, consumer); Todo todo = Todo.findById(Long.parseLong(todoId)); - if (!agentsReady) { + agentsReady = !agentProducers.getCards().isEmpty(); + if (! agentsReady) { + Log.warn("Unable to connect to a2a servers, did you start them?"); + } if (!agentsReady) { return noAIMessage(todoId); } else { @@ -99,52 +99,10 @@ void onClose(@PathParam String todoId) { } } - private void sendAgentRequestMessage(String todoId, String message) { - eventBus.publish(todoId, new AgentMessage(Kind.agent_message, todoId, message)); - } - - private void sendAgentActivityMessage(String todoId, String message) { - eventBus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, message)); - } - public void init(@Observes StartupEvent event) { eventBus.registerDefaultCodec(AgentMessage.class, new LocalEventBusCodec() { }); } -} - //private MultiEmitter emitter; - //private Multi agentStream; - - //@Inject - //private AgentDispatcher agents; - - // init message Multi.createFromItem() - // ensuite Multi du AI service - // concatener les deux Multis - // Multi.createBy().concatenating().streams(Multi.createFrom().item("Starting work"), aiServiceMulti); - //formatter les messages {kind: initialize kind:token - // when end of message in multi, I send \n\n - // kind - //initiliazize - //cancel - //user_request - - - //MultiEmitter / Emittpublicer - // by default in memory - //@Channel(csdcds) - //MutinyEmitter mEmitter; - - //@Channel(csdcds) - //Multi multi; - -// void init(@Observes StartupEvent event) { -// // Subscribe manually -// agentStream.subscribe().with( -// item -> System.out.println("Received: " + item), -// failure -> System.err.println("Error: " + failure) -// ); -// } - +} \ No newline at end of file From 878b12781ff28ce4779b15e2981bff3e60caadaa Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Mon, 6 Oct 2025 15:11:43 +0200 Subject: [PATCH 22/24] Rename addTAskArtifact --- .gitignore | 8 ++++++++ .../java/io/quarkus/sample/agents/AgentProducers.java | 8 +++----- .../java/io/quarkus/sample/agents/AgentsMediator.java | 4 ++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 94f8c05..ece605a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -31,3 +32,10 @@ ObjectStore /transcript.txt /openai.sh /podman.sh + +# macOS +.DS_Store + +# Environment +.env +.envrc diff --git a/src/main/java/io/quarkus/sample/agents/AgentProducers.java b/src/main/java/io/quarkus/sample/agents/AgentProducers.java index 0309f2f..9be72a3 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentProducers.java +++ b/src/main/java/io/quarkus/sample/agents/AgentProducers.java @@ -9,13 +9,11 @@ import io.a2a.client.http.A2ACardResolver; import io.a2a.client.transport.jsonrpc.JSONRPCTransport; import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; -import io.a2a.spec.A2AClientError; 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.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.UpdateEvent; import io.quarkus.logging.Log; @@ -134,7 +132,7 @@ private List> getConsumers() { Log.infov( "Received status-update: {0} ", status.state()); agentsMediator.sendToActivityLog(taskStatusUpdateEvent); if (taskStatusUpdateEvent.isFinal()) { - agentsMediator.sendTaskArtifacts(taskUpdateEvent.getTask()); + agentsMediator.sendArtifacts(taskUpdateEvent.getTask()); } else if (status.state() == TaskState.INPUT_REQUIRED) { agentsMediator.sendInputRequired(taskStatusUpdateEvent.getTaskId(), status); @@ -142,7 +140,7 @@ else if (status.state() == TaskState.INPUT_REQUIRED) { } else if (updateEvent instanceof TaskArtifactUpdateEvent taskArtifactUpdateEvent) { agentsMediator.sendToActivityLog(taskArtifactUpdateEvent); - agentsMediator.sendTaskArtifacts(taskArtifactUpdateEvent); + agentsMediator.sendArtifacts(taskArtifactUpdateEvent); Log.infov("Received artifact-update for task {0}: {1}", taskArtifactUpdateEvent.getTaskId(), taskArtifactUpdateEvent.getArtifact().name()); } } else if (event instanceof TaskEvent taskEvent) { @@ -152,7 +150,7 @@ else if (status.state() == TaskState.INPUT_REQUIRED) { agentsMediator.sendToActivityLog(taskEvent); switch (state) { case COMPLETED -> { - agentsMediator.sendTaskArtifacts(task); + agentsMediator.sendArtifacts(task); } case INPUT_REQUIRED -> { agentsMediator.sendInputRequired(task.getId(), task.getStatus()); diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 97b58a0..6899c68 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -176,7 +176,7 @@ public void receiveMessageFromAgent(Message responseMessage) { bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } - public void sendTaskArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { + public void sendArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { var context = contextsHolder.getContextFromTaskId(taskUpdateEvent.getTaskId()); StringBuilder textBuilder = new StringBuilder(); var artifact = taskUpdateEvent.getArtifact(); @@ -191,7 +191,7 @@ public void sendTaskArtifacts(TaskArtifactUpdateEvent taskUpdateEvent) { bus.publish(context.getTodoId(), new AgentMessage(Kind.agent_message, context.getTodoId(), payload)); } - public void sendTaskArtifacts(Task task) { + public void sendArtifacts(Task task) { var context = contextsHolder.getContextFromTaskId(task.getId()); StringBuilder textBuilder = new StringBuilder(); List artifacts = task.getArtifacts(); From 52e0fd4c55b7631701c3b8969b62dab7130c7d5b Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Tue, 7 Oct 2025 08:43:09 +0200 Subject: [PATCH 23/24] Rename to A2AClient for clarity --- .../quarkus/sample/agents/AgentProducers.java | 6 +++--- .../quarkus/sample/agents/AgentsMediator.java | 17 ++++++++--------- .../sample/agents/ClientAgentContext.java | 4 ++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/quarkus/sample/agents/AgentProducers.java b/src/main/java/io/quarkus/sample/agents/AgentProducers.java index 9be72a3..702330f 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentProducers.java +++ b/src/main/java/io/quarkus/sample/agents/AgentProducers.java @@ -70,16 +70,16 @@ private void addAgentCard(AGENT agent, String url, Map cards) } - public Client getA2aClient(AGENT agent) throws A2AClientException { + public Client getA2AClient(AGENT agent) throws A2AClientException { var client = agents.get(agent); if (client == null) { - client = buildA2aClient(agent); + client = buildA2AClient(agent); agents.put(agent, client); } return client; } - private Client buildA2aClient(AGENT agent) throws A2AClientException { + private Client buildA2AClient(AGENT agent) throws A2AClientException { // Create consumers for handling client events List> consumers = getConsumers(); diff --git a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java index 6899c68..1fa2297 100644 --- a/src/main/java/io/quarkus/sample/agents/AgentsMediator.java +++ b/src/main/java/io/quarkus/sample/agents/AgentsMediator.java @@ -2,7 +2,6 @@ import io.a2a.client.Client; import io.a2a.client.TaskEvent; -import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.A2AClientException; import io.a2a.spec.Artifact; import io.a2a.spec.Message; @@ -56,7 +55,7 @@ public void initiateSubAgentConversation(ClientAgentContext context) { AGENT currentAgent; currentAgent = verifyAgentPresence(agentDescriptors, proposedAgent); - context.setCurrentAgent(currentAgent); + context.setCurrentA2AAgent(currentAgent); Log.infov("Selected agent {0} for todo '{1}' ", currentAgent, todo.title); var todoId = context.getTodoId(); @@ -64,7 +63,7 @@ public void initiateSubAgentConversation(ClientAgentContext context) { case WEATHER,MOVIE -> { bus.publish(todoId,new AgentMessage(Kind.agent_message, todoId, "The " + currentAgent + " agent will look into your todo")); try { - getCurrentClient(context).sendMessage(new Message.Builder() + getCurrentA2AClient(context).sendMessage(new Message.Builder() .role(Message.Role.USER) .contextId(context.getContextId()) .parts(new TextPart(todoAsPrompt(todo))) @@ -112,13 +111,13 @@ private String todoAsPrompt(Todo todo) { return builder.toString(); } - private Client getCurrentClient(ClientAgentContext context) { + private Client getCurrentA2AClient(ClientAgentContext context) { var todoId = context.getTodoId(); - var currentAgent = context.getCurrentAgent(); + var currentAgent = context.getCurrentA2AAgent(); switch (currentAgent) { case WEATHER, MOVIE -> { try { - return agentProducers.getA2aClient(currentAgent); + return agentProducers.getA2AClient(currentAgent); } catch (A2AClientException e) { bus.publish(todoId, new AgentMessage( Kind.activity_log, todoId, @@ -149,9 +148,9 @@ public void passUserMessage(String todoId, String userMessage) { .parts(new TextPart(userMessage)) .build(); try { - switch (context.getCurrentAgent()) { + switch (context.getCurrentA2AAgent()) { case NONE -> sendNoAgentMessage(todoId); - default -> getCurrentClient(context).sendMessage(a2aMessage); + default -> getCurrentA2AClient(context).sendMessage(a2aMessage); } } catch (A2AClientException e) { bus.publish(todoId, new AgentMessage(Kind.activity_log, todoId, "Oops, something failed\n" + e.getMessage())); @@ -162,7 +161,7 @@ public void cancel(String todoId) { ClientAgentContext context = contextsHolder.getContextFromTodoId(todoId); if (context.getTaskId() != null) { try { - getCurrentClient(context).cancelTask(new TaskIdParams(context.getTaskId())); + getCurrentA2AClient(context).cancelTask(new TaskIdParams(context.getTaskId())); context.resetOnTaskTerminalState(); } catch (A2AClientException e) { //let's ignore cancellation exception, we are done on our side diff --git a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java index 891eac1..7411bb8 100644 --- a/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java +++ b/src/main/java/io/quarkus/sample/agents/ClientAgentContext.java @@ -18,7 +18,7 @@ public Todo getTodo() { return todo; } - public void setCurrentAgent(AGENT currentAgent) { + public void setCurrentA2AAgent(AGENT currentAgent) { this.currentAgent = currentAgent; } @@ -30,7 +30,7 @@ public void setContextId(String contextId) { this.contextId = contextId; } - public AGENT getCurrentAgent() { + public AGENT getCurrentA2AAgent() { return currentAgent; } From 12309ba8f6fb47962ef01e99e24e47579cffcddd Mon Sep 17 00:00:00 2001 From: Emmanuel Bernard Date: Wed, 8 Oct 2025 17:27:32 +0200 Subject: [PATCH 24/24] Replace type if structure with switch case --- pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 354bc5a..41f251a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,9 +7,9 @@ TODOS Application 3.11.0 - 17 - 17 - 17 + 21 + 21 + 21 UTF-8 UTF-8 quarkus-bom