diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..7164225 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..7918ad7 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.0/maven-wrapper-3.3.0.jar diff --git a/README.md b/README.md index 899f02a..079cb69 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,71 @@ This activates `-Pdscope-local` for local DScope dependency alignment used by ru agent:agentId?blueprint=classpath:agents/support/agent.md&persistenceMode=redis_jdbc&strictSchema=true&timeoutMs=30000&streaming=true ``` +## Multi-Agent Plan Catalog + +Runtime can resolve agents from a catalog instead of a single blueprint: + +```yaml +agent: + agents-config: classpath:agents/agents.yaml + blueprint: classpath:agents/support/agent.md # optional legacy fallback +``` + +Catalog behavior: + +- multiple named plans +- multiple versions per plan +- one default plan +- one default version per plan +- sticky conversation selection persisted as `conversation.plan.selected` + +Request entrypoints can pass `planName` and `planVersion`. When omitted, runtime uses sticky selection for the conversation, then catalog defaults. + +## A2A Runtime + +Camel Agent now integrates `camel-a2a-component` as a first-class protocol bridge. + +Runtime config: + +```yaml +agent: + runtime: + a2a: + enabled: true + public-base-url: http://localhost:8080 + exposed-agents-config: classpath:agents/a2a-exposed-agents.yaml +``` + +Exposed-agent config is separate from `agents.yaml`. It maps public A2A identities to local plans: + +```yaml +agents: + - agentId: support-ticket-service + name: Support Ticket Service + defaultAgent: true + planName: ticketing + planVersion: v1 +``` + +Inbound endpoints: + +- `POST /a2a/rpc` +- `GET /a2a/sse/{taskId}` +- `GET /.well-known/agent-card.json` + +Outbound behavior: + +- blueprint tools can target `a2a:` endpoints +- runtime persists remote task/conversation correlation +- audit trail records outbound/inbound A2A transitions + +Shared infrastructure behavior: + +- agent-side A2A classes stay in `camel-agent-core` +- generic task/session/event persistence comes from `camel-a2a-component` +- if `a2aTaskService`, `a2aTaskEventService`, and `a2aPushConfigService` are already bound, Camel Agent reuses them instead of creating a private task space +- this allows multiple agent flows and non-agent routes to share the same A2A session/task runtime + ## Persistence Defaults Default mode is `redis_jdbc` (Redis fast path + JDBC source-of-truth behavior inherited from `camel-persistence`). @@ -134,6 +199,19 @@ See sample-specific usage and test guidance in: - `samples/agent-support-service/README.md` +For local no-key A2A demo runs, the sample also includes: + +- `io.dscope.camel.agent.samples.DemoA2ATicketGateway` + +Use it to simulate support-agent -> A2A ticket-service -> local ticket route behavior without a live model backend: + +```bash +./mvnw -q -f samples/agent-support-service/pom.xml \ + -Dagent.runtime.spring-ai.gateway-class=io.dscope.camel.agent.samples.DemoA2ATicketGateway \ + -Dagent.runtime.routes-include-pattern=classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ticket-service.camel.yaml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/admin-platform.camel.yaml \ + exec:java +``` + ## Spring AI Runtime Config (Sample) `samples/agent-support-service/src/main/resources/application.yaml` configures runtime provider routing: @@ -220,7 +298,7 @@ mvn -U -f pom.xml verify Sample integration tests verify route selection and context carry-over behavior: - first prompt asks for knowledge base help, route/tool selected: `kb.search` -- second prompt asks to file a ticket, route/tool selected: `support.ticket.open` +- second prompt asks to file a ticket, route/tool selected: `support.ticket.manage` - second-turn LLM evaluation context includes first-turn KB result - negative case: direct ticket prompt without prior KB turn does not inject KB context diff --git a/camel-agent-core/pom.xml b/camel-agent-core/pom.xml index a5cddb5..2c8ce06 100644 --- a/camel-agent-core/pom.xml +++ b/camel-agent-core/pom.xml @@ -6,7 +6,7 @@ io.dscope.camel camel-agent - 0.5.0 + 0.6.0 camel-agent-core @@ -50,6 +50,11 @@ camel-mcp 1.4.0 + + io.dscope.camel + camel-a2a-component + ${a2a.version} + org.junit.jupiter diff --git a/camel-agent-core/src/generated/resources/META-INF/io/dscope/camel/agent/component/agent.json b/camel-agent-core/src/generated/resources/META-INF/io/dscope/camel/agent/component/agent.json index cfb828e..1019bb1 100644 --- a/camel-agent-core/src/generated/resources/META-INF/io/dscope/camel/agent/component/agent.json +++ b/camel-agent-core/src/generated/resources/META-INF/io/dscope/camel/agent/component/agent.json @@ -11,7 +11,7 @@ "supportLevel": "Stable", "groupId": "io.dscope.camel", "artifactId": "camel-agent-core", - "version": "0.5.0", + "version": "0.6.0", "scheme": "agent", "extendsScheme": "", "syntax": "agent:agentId", diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalog.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalog.java new file mode 100644 index 0000000..f1bb12e --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalog.java @@ -0,0 +1,71 @@ +package io.dscope.camel.agent.a2a; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class A2AExposedAgentCatalog { + + private final List agents; + private final Map byId; + private final A2AExposedAgentSpec defaultAgent; + + public A2AExposedAgentCatalog(List agents) { + if (agents == null || agents.isEmpty()) { + throw new IllegalArgumentException("A2A exposed-agents catalog must contain at least one agent"); + } + Map mapped = new LinkedHashMap<>(); + A2AExposedAgentSpec resolvedDefault = null; + for (A2AExposedAgentSpec agent : agents) { + if (agent == null) { + throw new IllegalArgumentException("A2A exposed-agents entries must not be null"); + } + String agentId = required(agent.getAgentId(), "agentId"); + agent.setAgentId(agentId); + agent.setName(required(agent.getName(), "name")); + agent.setPlanName(required(agent.getPlanName(), "planName")); + agent.setPlanVersion(required(agent.getPlanVersion(), "planVersion")); + if (mapped.putIfAbsent(agentId, agent) != null) { + throw new IllegalArgumentException("Duplicate A2A exposed agentId: " + agentId); + } + if (agent.isDefaultAgent()) { + if (resolvedDefault != null) { + throw new IllegalArgumentException("Exactly one A2A exposed agent must be marked default"); + } + resolvedDefault = agent; + } + } + if (resolvedDefault == null) { + throw new IllegalArgumentException("One A2A exposed agent must be marked default"); + } + this.agents = List.copyOf(agents); + this.byId = Map.copyOf(mapped); + this.defaultAgent = resolvedDefault; + } + + public List agents() { + return agents; + } + + public A2AExposedAgentSpec defaultAgent() { + return defaultAgent; + } + + public A2AExposedAgentSpec requireAgent(String agentId) { + if (agentId == null || agentId.isBlank()) { + return defaultAgent; + } + A2AExposedAgentSpec resolved = byId.get(agentId.trim()); + if (resolved == null) { + throw new IllegalArgumentException("Unknown A2A exposed agent: " + agentId); + } + return resolved; + } + + private String required(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("A2A exposed agent " + field + " is required"); + } + return value.trim(); + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalogLoader.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalogLoader.java new file mode 100644 index 0000000..5b11225 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalogLoader.java @@ -0,0 +1,55 @@ +package io.dscope.camel.agent.a2a; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public final class A2AExposedAgentCatalogLoader { + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + public A2AExposedAgentCatalog load(String location) { + if (location == null || location.isBlank()) { + throw new IllegalArgumentException("agent.runtime.a2a.exposed-agents-config is required when A2A is enabled"); + } + try (InputStream stream = open(location.trim())) { + if (stream == null) { + throw new IllegalArgumentException("A2A exposed-agents config not found: " + location); + } + Root root = YAML_MAPPER.readValue(stream, Root.class); + List agents = root == null + ? List.of() + : root.agents != null && !root.agents.isEmpty() + ? root.agents + : root.exposedAgents; + return new A2AExposedAgentCatalog(agents == null ? List.of() : agents); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed to load A2A exposed-agents config: " + location, e); + } + } + + private InputStream open(String location) throws Exception { + if (location.startsWith("classpath:")) { + return getClass().getClassLoader().getResourceAsStream(location.substring("classpath:".length())); + } + if (location.startsWith("http://") || location.startsWith("https://")) { + return new URL(location).openStream(); + } + if (location.startsWith("file:")) { + return Files.newInputStream(Path.of(URI.create(location))); + } + return Files.newInputStream(Path.of(location)); + } + + private static final class Root { + public List agents; + public List exposedAgents; + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentSpec.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentSpec.java new file mode 100644 index 0000000..4fc7488 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AExposedAgentSpec.java @@ -0,0 +1,89 @@ +package io.dscope.camel.agent.a2a; + +import java.util.List; +import java.util.Map; + +public class A2AExposedAgentSpec { + + private String agentId; + private String name; + private String description; + private String version; + private boolean defaultAgent; + private String planName; + private String planVersion; + private List skills = List.of(); + private Map metadata = Map.of(); + + public String getAgentId() { + return agentId; + } + + public void setAgentId(String agentId) { + this.agentId = agentId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public boolean isDefaultAgent() { + return defaultAgent; + } + + public void setDefaultAgent(boolean defaultAgent) { + this.defaultAgent = defaultAgent; + } + + public String getPlanName() { + return planName; + } + + public void setPlanName(String planName) { + this.planName = planName; + } + + public String getPlanVersion() { + return planVersion; + } + + public void setPlanVersion(String planVersion) { + this.planVersion = planVersion; + } + + public List getSkills() { + return skills == null ? List.of() : skills; + } + + public void setSkills(List skills) { + this.skills = skills == null ? List.of() : List.copyOf(skills); + } + + public Map getMetadata() { + return metadata == null ? Map.of() : metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AParentConversationNotifier.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AParentConversationNotifier.java new file mode 100644 index 0000000..f78d29b --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AParentConversationNotifier.java @@ -0,0 +1,121 @@ +package io.dscope.camel.agent.a2a; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.dscope.camel.agent.api.PersistenceFacade; +import io.dscope.camel.agent.model.AgentEvent; +import java.time.Instant; +import java.util.UUID; + +final class A2AParentConversationNotifier { + + private final PersistenceFacade persistenceFacade; + private final ObjectMapper objectMapper; + + A2AParentConversationNotifier(PersistenceFacade persistenceFacade, ObjectMapper objectMapper) { + this.persistenceFacade = persistenceFacade; + this.objectMapper = objectMapper; + } + + void notifyParent(String parentConversationId, + String childConversationId, + String remoteTaskId, + String agentId, + String planName, + String planVersion, + String aguiSessionId, + String aguiRunId, + String aguiThreadId, + String replyText) { + if (persistenceFacade == null || parentConversationId == null || parentConversationId.isBlank()) { + return; + } + if (childConversationId != null && childConversationId.equals(parentConversationId)) { + return; + } + + Instant now = Instant.now(); + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("conversationId", parentConversationId); + payload.put("role", "assistant"); + payload.put("source", "a2a.ticketing"); + payload.put("sessionId", fallback(aguiSessionId)); + payload.put("runId", fallback(aguiRunId)); + payload.put("threadId", fallback(aguiThreadId)); + payload.put("text", summarize(replyText)); + payload.put("at", now.toString()); + + ObjectNode a2a = payload.putObject("a2a"); + a2a.put("agentId", fallback(agentId)); + a2a.put("planName", fallback(planName)); + a2a.put("planVersion", fallback(planVersion)); + a2a.put("taskId", fallback(remoteTaskId)); + a2a.put("linkedConversationId", fallback(childConversationId)); + + JsonNode widget = ticketWidget(replyText); + if (widget != null) { + payload.set("widget", widget); + } + + persistenceFacade.appendEvent( + new AgentEvent(parentConversationId, remoteTaskId, "conversation.assistant.message", payload, now), + UUID.randomUUID().toString() + ); + } + + private JsonNode ticketWidget(String replyText) { + if (replyText == null || replyText.isBlank()) { + return null; + } + try { + JsonNode parsed = objectMapper.readTree(replyText); + if (!parsed.isObject()) { + return null; + } + ObjectNode widget = objectMapper.createObjectNode(); + widget.put("template", "ticket-card"); + widget.set("data", parsed); + return widget; + } catch (Exception ignored) { + return null; + } + } + + private String summarize(String replyText) { + if (replyText == null || replyText.isBlank()) { + return "Ticket service posted an update."; + } + try { + JsonNode parsed = objectMapper.readTree(replyText); + if (parsed.isObject()) { + String ticketId = parsed.path("ticketId").asText(""); + String status = parsed.path("status").asText(""); + String message = parsed.path("message").asText(""); + StringBuilder summary = new StringBuilder(); + if (!ticketId.isBlank()) { + summary.append(ticketId); + } + if (!status.isBlank()) { + if (summary.length() > 0) { + summary.append(" "); + } + summary.append("is ").append(status); + } + if (!message.isBlank()) { + if (summary.length() > 0) { + summary.append(". "); + } + summary.append(message); + } + return summary.length() == 0 ? replyText.trim() : summary.toString(); + } + } catch (Exception ignored) { + } + return replyText.trim(); + } + + private String fallback(String value) { + return value == null ? "" : value; + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AToolClient.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AToolClient.java new file mode 100644 index 0000000..ee84127 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AToolClient.java @@ -0,0 +1,395 @@ +package io.dscope.camel.agent.a2a; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.dscope.camel.agent.api.PersistenceFacade; +import io.dscope.camel.agent.config.CorrelationKeys; +import io.dscope.camel.agent.model.AgentEvent; +import io.dscope.camel.agent.model.ExecutionContext; +import io.dscope.camel.agent.model.ToolResult; +import io.dscope.camel.agent.model.ToolSpec; +import io.dscope.camel.agent.registry.CorrelationRegistry; +import io.dscope.camel.a2a.A2AEndpoint; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.camel.CamelContext; + +public final class A2AToolClient { + + private final CamelContext camelContext; + private final ObjectMapper objectMapper; + private final PersistenceFacade persistenceFacade; + private final A2AToolContext toolContext; + private final HttpClient httpClient; + + public A2AToolClient(CamelContext camelContext, + ObjectMapper objectMapper, + PersistenceFacade persistenceFacade, + A2AToolContext toolContext) { + this(camelContext, objectMapper, persistenceFacade, toolContext, HttpClient.newHttpClient()); + } + + A2AToolClient(CamelContext camelContext, + ObjectMapper objectMapper, + PersistenceFacade persistenceFacade, + A2AToolContext toolContext, + HttpClient httpClient) { + this.camelContext = camelContext; + this.objectMapper = objectMapper; + this.persistenceFacade = persistenceFacade; + this.toolContext = toolContext == null ? A2AToolContext.EMPTY : toolContext; + this.httpClient = httpClient == null ? HttpClient.newHttpClient() : httpClient; + } + + public ToolResult execute(String target, ToolSpec toolSpec, JsonNode arguments, ExecutionContext context) { + if (camelContext == null) { + throw new IllegalStateException("A2A tool execution requires CamelContext"); + } + try { + A2AEndpoint endpoint = camelContext.getEndpoint(target, A2AEndpoint.class); + if (endpoint == null) { + throw new IllegalArgumentException("Unable to resolve A2A endpoint: " + target); + } + String remoteRpcUrl = normalizeRpcUrl(endpoint.getConfiguration().getRemoteUrl()); + String remoteAgentId = endpoint.getAgent(); + String method = determineMethod(arguments); + ObjectNode params = buildParams(arguments, method, remoteAgentId, context); + String remoteConversationId = text(params, "conversationId"); + + appendEvent(context, "conversation.a2a.outbound.started", Map.of( + "toolName", toolSpec.name(), + "target", target, + "remoteUrl", remoteRpcUrl, + "method", method, + "remoteAgentId", remoteAgentId, + "remoteConversationId", remoteConversationId + )); + + ObjectNode envelope = objectMapper.createObjectNode(); + envelope.put("jsonrpc", "2.0"); + envelope.put("method", method); + envelope.put("id", UUID.randomUUID().toString()); + envelope.set("params", params); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(remoteRpcUrl)) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))) + .header("Content-Type", "application/json"); + String authToken = endpoint.getConfiguration().getAuthToken(); + if (authToken != null && !authToken.isBlank()) { + requestBuilder.header("Authorization", "Bearer " + authToken.trim()); + } + + HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IllegalStateException("Remote A2A call failed with HTTP " + response.statusCode() + ": " + response.body()); + } + JsonNode root = objectMapper.readTree(response.body()); + if (root.hasNonNull("error")) { + throw new IllegalStateException("Remote A2A call failed: " + root.path("error").toString()); + } + + JsonNode result = root.path("result"); + bindCorrelation(context.conversationId(), remoteAgentId, remoteConversationId, result); + appendEvent(context, "conversation.a2a.outbound.completed", Map.of( + "toolName", toolSpec.name(), + "target", target, + "remoteUrl", remoteRpcUrl, + "method", method, + "remoteAgentId", remoteAgentId, + "remoteConversationId", remoteConversationId, + "remoteTaskId", remoteTaskId(result) + )); + + String content = extractContent(result); + return new ToolResult(content, result, List.of()); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("A2A tool execution failed for target " + target, e); + } + } + + private ObjectNode buildParams(JsonNode arguments, String method, String remoteAgentId, ExecutionContext context) { + ObjectNode normalized = arguments != null && arguments.isObject() + ? ((ObjectNode) arguments.deepCopy()) + : objectMapper.createObjectNode(); + normalized.remove("method"); + + return switch (method) { + case "SendMessage", "SendStreamingMessage" -> buildSendParams(normalized, remoteAgentId, context); + case "GetTask", "CancelTask", "SubscribeToTask" -> ensureTaskId(normalized, context); + default -> normalized; + }; + } + + private ObjectNode buildSendParams(ObjectNode arguments, String remoteAgentId, ExecutionContext context) { + ObjectNode params = arguments.deepCopy(); + ObjectNode metadata = params.has("metadata") && params.get("metadata").isObject() + ? (ObjectNode) params.get("metadata") + : objectMapper.createObjectNode(); + params.set("metadata", metadata); + + ObjectNode camelAgent = metadata.has("camelAgent") && metadata.get("camelAgent").isObject() + ? (ObjectNode) metadata.get("camelAgent") + : objectMapper.createObjectNode(); + metadata.set("camelAgent", camelAgent); + + CorrelationRegistry registry = CorrelationRegistry.global(); + String linkedConversationId = registry.resolve(context.conversationId(), CorrelationKeys.A2A_LINKED_CONVERSATION_ID, ""); + String parentConversationId = fallback(context.conversationId()); + String rootConversationId = firstNonBlank( + registry.resolve(context.conversationId(), CorrelationKeys.A2A_ROOT_CONVERSATION_ID, ""), + parentConversationId + ); + String aguiSessionId = registry.resolve(context.conversationId(), CorrelationKeys.AGUI_SESSION_ID, ""); + String aguiRunId = registry.resolve(context.conversationId(), CorrelationKeys.AGUI_RUN_ID, ""); + String aguiThreadId = registry.resolve(context.conversationId(), CorrelationKeys.AGUI_THREAD_ID, ""); + + camelAgent.put("localConversationId", fallback(context.conversationId())); + camelAgent.put("linkedConversationId", fallback(linkedConversationId)); + camelAgent.put("parentConversationId", parentConversationId); + camelAgent.put("rootConversationId", rootConversationId); + camelAgent.put("traceId", fallback(context.traceId())); + camelAgent.put("aguiSessionId", fallback(aguiSessionId)); + camelAgent.put("aguiRunId", fallback(aguiRunId)); + camelAgent.put("aguiThreadId", fallback(aguiThreadId)); + if (!toolContext.planName().isBlank()) { + camelAgent.put("planName", toolContext.planName()); + metadata.put("planName", toolContext.planName()); + } + if (!toolContext.planVersion().isBlank()) { + camelAgent.put("planVersion", toolContext.planVersion()); + metadata.put("planVersion", toolContext.planVersion()); + } + if (!toolContext.agentName().isBlank()) { + camelAgent.put("agentName", toolContext.agentName()); + metadata.put("agentName", toolContext.agentName()); + } + if (!toolContext.agentVersion().isBlank()) { + camelAgent.put("agentVersion", toolContext.agentVersion()); + metadata.put("agentVersion", toolContext.agentVersion()); + } + metadata.put("agentId", remoteAgentId == null ? "" : remoteAgentId); + camelAgent.put("agentId", remoteAgentId == null ? "" : remoteAgentId); + metadata.put("linkedConversationId", fallback(linkedConversationId)); + metadata.put("parentConversationId", parentConversationId); + metadata.put("rootConversationId", rootConversationId); + metadata.put("aguiSessionId", fallback(aguiSessionId)); + metadata.put("aguiRunId", fallback(aguiRunId)); + metadata.put("aguiThreadId", fallback(aguiThreadId)); + + if (!params.hasNonNull("conversationId")) { + String existingRemoteConversationId = + registry.resolve(context.conversationId(), CorrelationKeys.A2A_REMOTE_CONVERSATION_ID, ""); + params.put("conversationId", firstNonBlank(existingRemoteConversationId, context.conversationId())); + } + if (!params.hasNonNull("idempotencyKey")) { + params.put("idempotencyKey", UUID.randomUUID().toString()); + } + if (!params.has("message") || !params.get("message").isObject()) { + params.set("message", buildMessage(arguments, remoteAgentId, context)); + } + return params; + } + + private ObjectNode buildMessage(ObjectNode arguments, String remoteAgentId, ExecutionContext context) { + String text = firstNonBlank( + text(arguments, "text"), + text(arguments, "prompt"), + text(arguments, "input"), + arguments.isValueNode() ? arguments.asText("") : "" + ); + ObjectNode part = objectMapper.createObjectNode(); + part.put("partId", UUID.randomUUID().toString()); + part.put("type", "text"); + part.put("mimeType", "text/plain"); + part.put("text", text); + + ArrayNode parts = objectMapper.createArrayNode(); + parts.add(part); + + ObjectNode metadata = objectMapper.createObjectNode(); + metadata.put("agentId", fallback(remoteAgentId)); + metadata.put("localConversationId", fallback(context.conversationId())); + metadata.put("traceId", fallback(context.traceId())); + + ObjectNode message = objectMapper.createObjectNode(); + message.put("messageId", UUID.randomUUID().toString()); + message.put("role", "user"); + message.set("parts", parts); + message.set("metadata", metadata); + message.put("createdAt", Instant.now().toString()); + return message; + } + + private ObjectNode ensureTaskId(ObjectNode params, ExecutionContext context) { + if (!params.hasNonNull("taskId")) { + String remoteTaskId = CorrelationRegistry.global().resolve(context.conversationId(), CorrelationKeys.A2A_REMOTE_TASK_ID, ""); + if (remoteTaskId.isBlank()) { + throw new IllegalArgumentException("A2A follow-up call requires taskId or an existing correlated remote task"); + } + params.put("taskId", remoteTaskId); + } + return params; + } + + private void bindCorrelation(String conversationId, String remoteAgentId, String remoteConversationId, JsonNode result) { + if (conversationId == null || conversationId.isBlank()) { + return; + } + CorrelationRegistry registry = CorrelationRegistry.global(); + if (remoteAgentId != null && !remoteAgentId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_AGENT_ID, remoteAgentId); + } + if (remoteConversationId != null && !remoteConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_REMOTE_CONVERSATION_ID, remoteConversationId); + } + String remoteTaskId = remoteTaskId(result); + if (!remoteTaskId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_REMOTE_TASK_ID, remoteTaskId); + } + String linkedConversationId = firstNonBlank( + text(result.path("task").path("metadata").path("camelAgent"), "linkedConversationId"), + text(result.path("task").path("metadata").path("camelAgent"), "localConversationId"), + text(result.path("task").path("metadata"), "linkedConversationId") + ); + if (!linkedConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_LINKED_CONVERSATION_ID, linkedConversationId); + } + String parentConversationId = firstNonBlank( + text(result.path("task").path("metadata").path("camelAgent"), "parentConversationId"), + text(result.path("task").path("metadata"), "parentConversationId") + ); + if (!parentConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_PARENT_CONVERSATION_ID, parentConversationId); + } + String rootConversationId = firstNonBlank( + text(result.path("task").path("metadata").path("camelAgent"), "rootConversationId"), + text(result.path("task").path("metadata"), "rootConversationId") + ); + if (!rootConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_ROOT_CONVERSATION_ID, rootConversationId); + } + } + + private void appendEvent(ExecutionContext context, String type, Map payload) { + if (persistenceFacade == null || context == null || context.conversationId() == null || context.conversationId().isBlank()) { + return; + } + persistenceFacade.appendEvent( + new AgentEvent(context.conversationId(), context.taskId(), type, objectMapper.valueToTree(payload), Instant.now()), + UUID.randomUUID().toString() + ); + } + + private String determineMethod(JsonNode arguments) { + String method = arguments == null ? "" : text(arguments, "method"); + return method.isBlank() ? "SendMessage" : method; + } + + private String normalizeRpcUrl(String remoteUrl) { + if (remoteUrl == null || remoteUrl.isBlank()) { + throw new IllegalArgumentException("A2A remoteUrl is required"); + } + URI uri = URI.create(remoteUrl.trim()); + String scheme = uri.getScheme(); + String httpScheme = switch (scheme == null ? "" : scheme) { + case "ws" -> "http"; + case "wss" -> "https"; + default -> scheme; + }; + String path = uri.getPath() == null || uri.getPath().isBlank() ? "/a2a" : uri.getPath(); + if (!path.endsWith("/rpc")) { + path = path.endsWith("/") ? path + "rpc" : path + "/rpc"; + } + String authority = uri.getHost(); + if (authority == null || authority.isBlank()) { + throw new IllegalArgumentException("A2A remoteUrl must include a host: " + remoteUrl); + } + if (uri.getPort() >= 0) { + authority = authority + ":" + uri.getPort(); + } + String query = uri.getQuery() == null || uri.getQuery().isBlank() ? "" : "?" + uri.getQuery(); + return httpScheme + "://" + authority + path + query; + } + + private String extractContent(JsonNode result) { + if (result == null || result.isNull() || result.isMissingNode()) { + return ""; + } + List candidates = List.of( + result.path("task").path("latestMessage"), + result.path("task").path("messages").path(result.path("task").path("messages").size() - 1), + result.path("task"), + result + ); + for (JsonNode candidate : candidates) { + String text = messageText(candidate); + if (!text.isBlank()) { + return text; + } + } + return result.isValueNode() ? result.asText("") : result.toPrettyString(); + } + + private String messageText(JsonNode message) { + if (message == null || !message.isObject()) { + return ""; + } + if (message.hasNonNull("text")) { + return message.path("text").asText(""); + } + JsonNode parts = message.path("parts"); + if (!parts.isArray()) { + return ""; + } + StringBuilder combined = new StringBuilder(); + for (JsonNode part : parts) { + String text = part.path("text").asText(""); + if (!text.isBlank()) { + if (combined.length() > 0) { + combined.append('\n'); + } + combined.append(text); + } + } + return combined.toString(); + } + + private String remoteTaskId(JsonNode result) { + return firstNonBlank( + text(result, "taskId"), + text(result.path("task"), "taskId") + ); + } + + private String text(JsonNode node, String field) { + if (node == null || node.isNull() || node.isMissingNode()) { + return ""; + } + JsonNode value = node.path(field); + return value.isMissingNode() || value.isNull() ? "" : value.asText(""); + } + + private String fallback(String value) { + return value == null ? "" : value; + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AToolContext.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AToolContext.java new file mode 100644 index 0000000..65c2761 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/A2AToolContext.java @@ -0,0 +1,10 @@ +package io.dscope.camel.agent.a2a; + +public record A2AToolContext( + String planName, + String planVersion, + String agentName, + String agentVersion +) { + public static final A2AToolContext EMPTY = new A2AToolContext("", "", "", ""); +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2AAgentCardCatalog.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2AAgentCardCatalog.java new file mode 100644 index 0000000..f71a8cd --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2AAgentCardCatalog.java @@ -0,0 +1,107 @@ +package io.dscope.camel.agent.a2a; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.a2a.catalog.AgentCardCatalog; +import io.dscope.camel.a2a.catalog.AgentCardPolicyChecker; +import io.dscope.camel.a2a.catalog.AgentCardSignatureVerifier; +import io.dscope.camel.a2a.catalog.AgentCardSigner; +import io.dscope.camel.a2a.catalog.AllowAllAgentCardPolicyChecker; +import io.dscope.camel.a2a.catalog.AllowAllAgentCardSignatureVerifier; +import io.dscope.camel.a2a.catalog.DefaultAgentCardCatalog; +import io.dscope.camel.a2a.catalog.NoopAgentCardSigner; +import io.dscope.camel.a2a.model.AgentCard; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class AgentA2AAgentCardCatalog implements AgentCardCatalog { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final A2AExposedAgentCatalog exposedAgentCatalog; + private final String endpointUrl; + private final AgentCardSigner signer; + private final AgentCardSignatureVerifier verifier; + private final AgentCardPolicyChecker policyChecker; + + public AgentA2AAgentCardCatalog(A2AExposedAgentCatalog exposedAgentCatalog, String endpointUrl) { + this(exposedAgentCatalog, endpointUrl, new NoopAgentCardSigner(), new AllowAllAgentCardSignatureVerifier(), new AllowAllAgentCardPolicyChecker()); + } + + public AgentA2AAgentCardCatalog(A2AExposedAgentCatalog exposedAgentCatalog, + String endpointUrl, + AgentCardSigner signer, + AgentCardSignatureVerifier verifier, + AgentCardPolicyChecker policyChecker) { + this.exposedAgentCatalog = exposedAgentCatalog; + this.endpointUrl = endpointUrl; + this.signer = signer == null ? new NoopAgentCardSigner() : signer; + this.verifier = verifier == null ? new AllowAllAgentCardSignatureVerifier() : verifier; + this.policyChecker = policyChecker == null ? new AllowAllAgentCardPolicyChecker() : policyChecker; + } + + @Override + public AgentCard getDiscoveryCard() { + return enrich(baseCatalog().getDiscoveryCard(), false); + } + + @Override + public AgentCard getExtendedCard() { + return enrich(baseCatalog().getExtendedCard(), true); + } + + @Override + public String getCardSignature(AgentCard card) { + return baseCatalog().getCardSignature(card); + } + + private DefaultAgentCardCatalog baseCatalog() { + A2AExposedAgentSpec defaultAgent = exposedAgentCatalog.defaultAgent(); + return new DefaultAgentCardCatalog( + defaultAgent.getAgentId(), + defaultAgent.getName(), + defaultAgent.getDescription(), + endpointUrl, + signer, + verifier, + policyChecker + ); + } + + private AgentCard enrich(AgentCard card, boolean extended) { + A2AExposedAgentSpec defaultAgent = exposedAgentCatalog.defaultAgent(); + Map metadata = new LinkedHashMap<>(); + if (card.getMetadata() != null && !card.getMetadata().isEmpty()) { + metadata.putAll(card.getMetadata()); + } + metadata.put("discovery", true); + metadata.put("extended", extended); + metadata.put("defaultAgentId", defaultAgent.getAgentId()); + metadata.put("agents", exposedAgentCatalog.agents().stream().map(this::cardMetadata).toList()); + card.setVersion(defaultVersion(defaultAgent)); + card.setMetadata(metadata); + return card; + } + + private Map cardMetadata(A2AExposedAgentSpec spec) { + Map data = new LinkedHashMap<>(); + data.put("agentId", spec.getAgentId()); + data.put("name", spec.getName()); + data.put("description", spec.getDescription() == null ? "" : spec.getDescription()); + data.put("version", defaultVersion(spec)); + data.put("default", spec.isDefaultAgent()); + data.put("planName", spec.getPlanName()); + data.put("planVersion", spec.getPlanVersion()); + data.put("skills", spec.getSkills()); + if (!spec.getMetadata().isEmpty()) { + data.put("metadata", spec.getMetadata()); + } + return data; + } + + private String defaultVersion(A2AExposedAgentSpec spec) { + if (spec.getVersion() != null && !spec.getVersion().isBlank()) { + return spec.getVersion(); + } + return spec.getPlanVersion(); + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2AProtocolSupport.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2AProtocolSupport.java new file mode 100644 index 0000000..05e8bd5 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2AProtocolSupport.java @@ -0,0 +1,669 @@ +package io.dscope.camel.agent.a2a; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.api.PersistenceFacade; +import io.dscope.camel.agent.config.AgentHeaders; +import io.dscope.camel.agent.config.CorrelationKeys; +import io.dscope.camel.agent.registry.CorrelationRegistry; +import io.dscope.camel.agent.runtime.A2ARuntimeProperties; +import io.dscope.camel.agent.runtime.AgentPlanCatalog; +import io.dscope.camel.agent.runtime.AgentPlanSelectionResolver; +import io.dscope.camel.a2a.A2AComponentApplicationSupport; +import io.dscope.camel.a2a.catalog.AgentCardCatalog; +import io.dscope.camel.a2a.config.A2AExchangeProperties; +import io.dscope.camel.a2a.config.A2AProtocolMethods; +import io.dscope.camel.a2a.model.Message; +import io.dscope.camel.a2a.model.Part; +import io.dscope.camel.a2a.model.Task; +import io.dscope.camel.a2a.model.TaskSubscription; +import io.dscope.camel.a2a.model.dto.CancelTaskRequest; +import io.dscope.camel.a2a.model.dto.CancelTaskResponse; +import io.dscope.camel.a2a.model.dto.GetTaskRequest; +import io.dscope.camel.a2a.model.dto.GetTaskResponse; +import io.dscope.camel.a2a.model.dto.ListTasksRequest; +import io.dscope.camel.a2a.model.dto.ListTasksResponse; +import io.dscope.camel.a2a.model.dto.SendMessageRequest; +import io.dscope.camel.a2a.model.dto.SendMessageResponse; +import io.dscope.camel.a2a.model.dto.SendStreamingMessageRequest; +import io.dscope.camel.a2a.model.dto.SendStreamingMessageResponse; +import io.dscope.camel.a2a.model.dto.SubscribeToTaskRequest; +import io.dscope.camel.a2a.model.dto.SubscribeToTaskResponse; +import io.dscope.camel.a2a.processor.A2AErrorProcessor; +import io.dscope.camel.a2a.processor.A2AInvalidParamsException; +import io.dscope.camel.a2a.processor.A2AJsonRpcEnvelopeProcessor; +import io.dscope.camel.a2a.processor.A2AMethodDispatchProcessor; +import io.dscope.camel.a2a.processor.AgentCardDiscoveryProcessor; +import io.dscope.camel.a2a.processor.CreatePushNotificationConfigProcessor; +import io.dscope.camel.a2a.processor.DeletePushNotificationConfigProcessor; +import io.dscope.camel.a2a.processor.GetExtendedAgentCardProcessor; +import io.dscope.camel.a2a.processor.GetPushNotificationConfigProcessor; +import io.dscope.camel.a2a.processor.ListPushNotificationConfigsProcessor; +import io.dscope.camel.a2a.processor.A2ATaskSseProcessor; +import io.dscope.camel.a2a.service.A2APushNotificationConfigService; +import io.dscope.camel.a2a.service.A2ATaskService; +import io.dscope.camel.a2a.service.InMemoryA2ATaskService; +import io.dscope.camel.a2a.service.InMemoryPushNotificationConfigService; +import io.dscope.camel.a2a.service.InMemoryTaskEventService; +import io.dscope.camel.a2a.service.PersistentA2ATaskEventService; +import io.dscope.camel.a2a.service.PersistentA2ATaskService; +import io.dscope.camel.a2a.service.TaskEventService; +import io.dscope.camel.a2a.service.WebhookPushNotificationNotifier; +import io.dscope.camel.persistence.core.FlowStateStore; +import io.dscope.camel.persistence.core.FlowStateStoreFactory; +import io.dscope.camel.persistence.core.PersistenceConfiguration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Properties; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.main.Main; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class AgentA2AProtocolSupport { + + private static final Logger LOGGER = LoggerFactory.getLogger(AgentA2AProtocolSupport.class); + + private AgentA2AProtocolSupport() { + } + + public static void bindIfEnabled(Main main, + Properties properties, + A2ARuntimeProperties runtimeProperties, + PersistenceFacade persistenceFacade, + AgentPlanSelectionResolver planSelectionResolver, + ObjectMapper objectMapper) { + if (runtimeProperties == null || !runtimeProperties.enabled()) { + return; + } + A2AExposedAgentCatalog exposedAgentCatalog = + new A2AExposedAgentCatalogLoader().load(runtimeProperties.exposedAgentsConfig()); + validatePlanMappings(planSelectionResolver, runtimeProperties, exposedAgentCatalog); + + SharedA2AInfrastructure shared = resolveSharedInfrastructure(main, properties); + AgentA2ATaskAdapter taskAdapter = new AgentA2ATaskAdapter(shared.taskService(), persistenceFacade, objectMapper); + + AgentCardCatalog agentCardCatalog = + new AgentA2AAgentCardCatalog(exposedAgentCatalog, runtimeProperties.rpcEndpointUrl()); + + Processor sendMessageProcessor = new SendMessageProcessor( + runtimeProperties.agentEndpointUri(), + exposedAgentCatalog, + taskAdapter, + objectMapper, + new A2AParentConversationNotifier(persistenceFacade, objectMapper) + ); + Processor sendStreamingMessageProcessor = new SendStreamingMessageProcessor( + sendMessageProcessor, + taskAdapter, + shared.taskEventService(), + objectMapper, + runtimeProperties + ); + Processor getTaskProcessor = exchange -> { + GetTaskRequest request = objectMapper.convertValue(requiredParams(exchange, "GetTask requires params object"), GetTaskRequest.class); + if (request.getTaskId() == null || request.getTaskId().isBlank()) { + throw new A2AInvalidParamsException("GetTask requires taskId"); + } + Task task = taskAdapter.getTask(request.getTaskId()); + GetTaskResponse response = new GetTaskResponse(); + response.setTask(task); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + }; + Processor listTasksProcessor = exchange -> { + Object params = exchange.getProperty(A2AExchangeProperties.NORMALIZED_PARAMS); + ListTasksRequest request = params == null ? new ListTasksRequest() : objectMapper.convertValue(params, ListTasksRequest.class); + if (request.getLimit() != null && request.getLimit() <= 0) { + throw new A2AInvalidParamsException("ListTasks limit must be greater than zero"); + } + ListTasksResponse response = new ListTasksResponse(); + response.setTasks(taskAdapter.listTasks(request.getState(), request.getLimit())); + response.setNextCursor(null); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + }; + Processor cancelTaskProcessor = exchange -> { + CancelTaskRequest request = objectMapper.convertValue(requiredParams(exchange, "CancelTask requires params object"), CancelTaskRequest.class); + if (request.getTaskId() == null || request.getTaskId().isBlank()) { + throw new A2AInvalidParamsException("CancelTask requires taskId"); + } + Task task = taskAdapter.cancelTask(request.getTaskId(), request.getReason()); + taskAdapter.appendConversationEvent( + conversationId(task), + task.getTaskId(), + "conversation.a2a.task.canceled", + Map.of( + "taskId", task.getTaskId(), + "reason", request.getReason() == null ? "" : request.getReason() + ) + ); + CancelTaskResponse response = new CancelTaskResponse(); + response.setTask(task); + response.setCanceled(true); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + }; + Processor subscribeToTaskProcessor = exchange -> { + SubscribeToTaskRequest request = objectMapper.convertValue(requiredParams(exchange, "SubscribeToTask requires params object"), + SubscribeToTaskRequest.class); + if (request.getTaskId() == null || request.getTaskId().isBlank()) { + throw new A2AInvalidParamsException("SubscribeToTask requires taskId"); + } + taskAdapter.getTask(request.getTaskId()); + long afterSequence = request.getAfterSequence() == null ? 0L : Math.max(0L, request.getAfterSequence()); + TaskSubscription subscription = shared.taskEventService().createSubscription(request.getTaskId(), afterSequence); + SubscribeToTaskResponse response = new SubscribeToTaskResponse(); + response.setSubscriptionId(subscription.getSubscriptionId()); + response.setTaskId(request.getTaskId()); + response.setAfterSequence(afterSequence); + response.setTerminal(shared.taskEventService().isTaskTerminal(request.getTaskId())); + response.setStreamUrl(buildStreamUrl(runtimeProperties, request.getTaskId(), subscription.getSubscriptionId(), afterSequence, request.getLimit())); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + }; + Processor createPushConfigProcessor = new CreatePushNotificationConfigProcessor(shared.pushConfigService()); + Processor getPushConfigProcessor = new GetPushNotificationConfigProcessor(shared.pushConfigService()); + Processor listPushConfigsProcessor = new ListPushNotificationConfigsProcessor(shared.pushConfigService()); + Processor deletePushConfigProcessor = new DeletePushNotificationConfigProcessor(shared.pushConfigService()); + Processor getExtendedAgentCardProcessor = new GetExtendedAgentCardProcessor(agentCardCatalog); + + Map methods = Map.ofEntries( + Map.entry(A2AProtocolMethods.SEND_MESSAGE, sendMessageProcessor), + Map.entry(A2AProtocolMethods.SEND_STREAMING_MESSAGE, sendStreamingMessageProcessor), + Map.entry(A2AProtocolMethods.GET_TASK, getTaskProcessor), + Map.entry(A2AProtocolMethods.LIST_TASKS, listTasksProcessor), + Map.entry(A2AProtocolMethods.CANCEL_TASK, cancelTaskProcessor), + Map.entry(A2AProtocolMethods.SUBSCRIBE_TO_TASK, subscribeToTaskProcessor), + Map.entry(A2AProtocolMethods.CREATE_PUSH_NOTIFICATION_CONFIG, createPushConfigProcessor), + Map.entry(A2AProtocolMethods.GET_PUSH_NOTIFICATION_CONFIG, getPushConfigProcessor), + Map.entry(A2AProtocolMethods.LIST_PUSH_NOTIFICATION_CONFIGS, listPushConfigsProcessor), + Map.entry(A2AProtocolMethods.DELETE_PUSH_NOTIFICATION_CONFIG, deletePushConfigProcessor), + Map.entry(A2AProtocolMethods.GET_EXTENDED_AGENT_CARD, getExtendedAgentCardProcessor) + ); + + bind(main, A2AComponentApplicationSupport.BEAN_TASK_EVENT_SERVICE, shared.taskEventService()); + bind(main, A2AComponentApplicationSupport.BEAN_PUSH_CONFIG_SERVICE, shared.pushConfigService()); + bind(main, A2AComponentApplicationSupport.BEAN_TASK_SERVICE, shared.taskService()); + bind(main, A2AComponentApplicationSupport.BEAN_AGENT_CARD_CATALOG, agentCardCatalog); + bind(main, A2AComponentApplicationSupport.BEAN_CREATE_PUSH_CONFIG_PROCESSOR, createPushConfigProcessor); + bind(main, A2AComponentApplicationSupport.BEAN_GET_PUSH_CONFIG_PROCESSOR, getPushConfigProcessor); + bind(main, A2AComponentApplicationSupport.BEAN_LIST_PUSH_CONFIGS_PROCESSOR, listPushConfigsProcessor); + bind(main, A2AComponentApplicationSupport.BEAN_DELETE_PUSH_CONFIG_PROCESSOR, deletePushConfigProcessor); + bind(main, A2AComponentApplicationSupport.BEAN_ENVELOPE_PROCESSOR, new A2AJsonRpcEnvelopeProcessor(A2AProtocolMethods.CORE_METHODS)); + bind(main, A2AComponentApplicationSupport.BEAN_ERROR_PROCESSOR, new A2AErrorProcessor()); + bind(main, A2AComponentApplicationSupport.BEAN_METHOD_PROCESSOR, new A2AMethodDispatchProcessor(methods)); + bind(main, A2AComponentApplicationSupport.BEAN_SSE_PROCESSOR, new A2ATaskSseProcessor(shared.taskEventService())); + bind(main, A2AComponentApplicationSupport.BEAN_AGENT_CARD_DISCOVERY_PROCESSOR, new AgentCardDiscoveryProcessor(agentCardCatalog)); + bind(main, A2AComponentApplicationSupport.BEAN_GET_EXTENDED_AGENT_CARD_PROCESSOR, getExtendedAgentCardProcessor); + + LOGGER.info("Agent runtime A2A support bound: agents={}, rpc={}, agentCard={}", + exposedAgentCatalog.agents().size(), + runtimeProperties.rpcEndpointUrl(), + runtimeProperties.publicBaseUrl() + runtimeProperties.agentCardPath()); + } + + private static void validatePlanMappings(AgentPlanSelectionResolver planSelectionResolver, + A2ARuntimeProperties runtimeProperties, + A2AExposedAgentCatalog exposedAgentCatalog) { + if (planSelectionResolver == null) { + throw new IllegalArgumentException("A2A runtime requires AgentPlanSelectionResolver"); + } + if (runtimeProperties.plansConfig() == null || runtimeProperties.plansConfig().isBlank()) { + throw new IllegalArgumentException("A2A runtime requires agent.agents-config for exposed plan mappings"); + } + AgentPlanCatalog catalog = planSelectionResolver.loadCatalog(runtimeProperties.plansConfig()); + for (A2AExposedAgentSpec spec : exposedAgentCatalog.agents()) { + catalog.requireVersion(spec.getPlanName(), spec.getPlanVersion()); + } + } + + private static SharedA2AInfrastructure resolveSharedInfrastructure(Main main, Properties properties) { + TaskEventService taskEventService = + main.lookup(A2AComponentApplicationSupport.BEAN_TASK_EVENT_SERVICE, TaskEventService.class); + A2ATaskService taskService = + main.lookup(A2AComponentApplicationSupport.BEAN_TASK_SERVICE, A2ATaskService.class); + A2APushNotificationConfigService pushConfigService = + main.lookup(A2AComponentApplicationSupport.BEAN_PUSH_CONFIG_SERVICE, A2APushNotificationConfigService.class); + + if (taskEventService != null && taskService != null && pushConfigService != null) { + return new SharedA2AInfrastructure(taskEventService, taskService, pushConfigService); + } + + PersistenceConfiguration persistenceConfiguration = PersistenceConfiguration.fromProperties( + properties == null ? new Properties() : properties + ); + if (taskEventService == null) { + if (persistenceConfiguration.enabled()) { + FlowStateStore stateStore = FlowStateStoreFactory.create(persistenceConfiguration); + taskEventService = new PersistentA2ATaskEventService(stateStore); + } else { + taskEventService = new InMemoryTaskEventService(); + } + } + if (taskService == null) { + if (persistenceConfiguration.enabled()) { + FlowStateStore stateStore = FlowStateStoreFactory.create(persistenceConfiguration); + taskService = new PersistentA2ATaskService(stateStore, taskEventService, persistenceConfiguration.rehydrationPolicy()); + } else { + taskService = new InMemoryA2ATaskService(taskEventService); + } + } + if (pushConfigService == null) { + pushConfigService = new InMemoryPushNotificationConfigService(new WebhookPushNotificationNotifier()); + taskEventService.addListener(pushConfigService::onTaskEvent); + } + return new SharedA2AInfrastructure(taskEventService, taskService, pushConfigService); + } + + private static void bind(Main main, String beanName, Object bean) { + if (main.lookup(beanName, Object.class) == null) { + main.bind(beanName, bean); + } + } + + private record SharedA2AInfrastructure(TaskEventService taskEventService, + A2ATaskService taskService, + A2APushNotificationConfigService pushConfigService) { + } + + private static Object requiredParams(Exchange exchange, String message) { + Object params = exchange.getProperty(A2AExchangeProperties.NORMALIZED_PARAMS); + if (params == null) { + throw new A2AInvalidParamsException(message); + } + return params; + } + + private static String buildStreamUrl(A2ARuntimeProperties runtimeProperties, + String taskId, + String subscriptionId, + long afterSequence, + Integer limit) { + String suffix = limit == null ? "" : "&limit=" + limit; + return runtimeProperties.sseBaseUrl() + + "/" + + taskId + + "?subscriptionId=" + + subscriptionId + + "&afterSequence=" + + afterSequence + + suffix; + } + + private static String conversationId(Task task) { + if (task == null || task.getMetadata() == null) { + return ""; + } + Object camelAgent = task.getMetadata().get("camelAgent"); + if (camelAgent instanceof Map map) { + Object conversationId = map.get("localConversationId"); + return conversationId == null ? "" : String.valueOf(conversationId); + } + return ""; + } + + private static final class SendMessageProcessor implements Processor { + + private final String agentEndpointUri; + private final A2AExposedAgentCatalog exposedAgentCatalog; + private final AgentA2ATaskAdapter taskAdapter; + private final ObjectMapper objectMapper; + private final A2AParentConversationNotifier parentConversationNotifier; + + private SendMessageProcessor(String agentEndpointUri, + A2AExposedAgentCatalog exposedAgentCatalog, + AgentA2ATaskAdapter taskAdapter, + ObjectMapper objectMapper, + A2AParentConversationNotifier parentConversationNotifier) { + this.agentEndpointUri = agentEndpointUri; + this.exposedAgentCatalog = exposedAgentCatalog; + this.taskAdapter = taskAdapter; + this.objectMapper = objectMapper; + this.parentConversationNotifier = parentConversationNotifier; + } + + @Override + public void process(Exchange exchange) throws Exception { + SendMessageRequest request = objectMapper.convertValue(requiredParams(exchange, "SendMessage requires params object"), SendMessageRequest.class); + if (request.getMessage() == null) { + throw new A2AInvalidParamsException("SendMessage requires message"); + } + + A2AExposedAgentSpec exposedAgent = resolveExposedAgent(request); + String localConversationId = firstNonBlank( + metadataValue(request.getMetadata(), "linkedConversationId"), + metadataValue(request.getMetadata(), "camelAgent.linkedConversationId"), + UUID.randomUUID().toString() + ); + String parentConversationId = firstNonBlank( + metadataValue(request.getMetadata(), "parentConversationId"), + metadataValue(request.getMetadata(), "camelAgent.parentConversationId") + ); + String rootConversationId = firstNonBlank( + metadataValue(request.getMetadata(), "rootConversationId"), + metadataValue(request.getMetadata(), "camelAgent.rootConversationId"), + parentConversationId, + localConversationId + ); + String remoteConversationId = firstNonBlank(request.getConversationId(), metadataValue(request.getMetadata(), "remoteConversationId")); + String aguiSessionId = firstNonBlank( + metadataValue(request.getMetadata(), "aguiSessionId"), + metadataValue(request.getMetadata(), "camelAgent.aguiSessionId") + ); + String aguiRunId = firstNonBlank( + metadataValue(request.getMetadata(), "aguiRunId"), + metadataValue(request.getMetadata(), "camelAgent.aguiRunId") + ); + String aguiThreadId = firstNonBlank( + metadataValue(request.getMetadata(), "aguiThreadId"), + metadataValue(request.getMetadata(), "camelAgent.aguiThreadId") + ); + String userText = extractMessageText(request.getMessage()); + + bindCorrelation(localConversationId, exposedAgent.getAgentId(), remoteConversationId, "", parentConversationId, rootConversationId); + + Map headers = new LinkedHashMap<>(); + headers.put(AgentHeaders.CONVERSATION_ID, localConversationId); + headers.put(AgentHeaders.PLAN_NAME, exposedAgent.getPlanName()); + headers.put(AgentHeaders.PLAN_VERSION, exposedAgent.getPlanVersion()); + headers.put(AgentHeaders.A2A_AGENT_ID, exposedAgent.getAgentId()); + headers.put(AgentHeaders.A2A_REMOTE_CONVERSATION_ID, remoteConversationId); + headers.put(AgentHeaders.A2A_REMOTE_TASK_ID, ""); + headers.put(AgentHeaders.A2A_LINKED_CONVERSATION_ID, localConversationId); + headers.put(AgentHeaders.A2A_PARENT_CONVERSATION_ID, parentConversationId); + headers.put(AgentHeaders.A2A_ROOT_CONVERSATION_ID, rootConversationId); + if (!aguiSessionId.isBlank()) { + headers.put(AgentHeaders.AGUI_SESSION_ID, aguiSessionId); + } + if (!aguiRunId.isBlank()) { + headers.put(AgentHeaders.AGUI_RUN_ID, aguiRunId); + } + if (!aguiThreadId.isBlank()) { + headers.put(AgentHeaders.AGUI_THREAD_ID, aguiThreadId); + } + + Map metadata = taskMetadata( + exposedAgent, + localConversationId, + remoteConversationId, + "", + parentConversationId, + rootConversationId, + aguiSessionId, + aguiRunId, + aguiThreadId, + request.getMetadata() + ); + + Task acceptedTask = taskAdapter.accept(request, metadata); + if (taskAdapter.isResponseCompleted(acceptedTask)) { + SendMessageResponse response = new SendMessageResponse(); + response.setTask(acceptedTask); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + return; + } + String taskId = acceptedTask.getTaskId(); + headers.put(AgentHeaders.A2A_REMOTE_TASK_ID, taskId); + bindCorrelation(localConversationId, exposedAgent.getAgentId(), remoteConversationId, taskId, parentConversationId, rootConversationId); + + taskAdapter.appendConversationEvent( + localConversationId, + taskId, + "conversation.a2a.request.accepted", + Map.of( + "agentId", exposedAgent.getAgentId(), + "planName", exposedAgent.getPlanName(), + "planVersion", exposedAgent.getPlanVersion(), + "remoteConversationId", remoteConversationId == null ? "" : remoteConversationId, + "parentConversationId", parentConversationId, + "rootConversationId", rootConversationId, + "message", userText + ) + ); + + String agentReply; + ProducerTemplate producerTemplate = exchange.getContext().createProducerTemplate(); + try { + Object response = producerTemplate.requestBodyAndHeaders(agentEndpointUri, userText, headers); + agentReply = response == null ? "" : String.valueOf(response); + } finally { + producerTemplate.stop(); + } + + Message assistantMessage = assistantMessage(request.getMessage(), agentReply); + Map completedMetadata = taskMetadata( + exposedAgent, + localConversationId, + remoteConversationId, + taskId, + parentConversationId, + rootConversationId, + aguiSessionId, + aguiRunId, + aguiThreadId, + request.getMetadata() + ); + Task task = taskAdapter.complete( + acceptedTask, + assistantMessage, + completedMetadata, + "Camel Agent completed the task" + ); + + taskAdapter.appendConversationEvent( + localConversationId, + taskId, + "conversation.a2a.response.completed", + taskMetadata( + exposedAgent, + localConversationId, + remoteConversationId, + taskId, + parentConversationId, + rootConversationId, + aguiSessionId, + aguiRunId, + aguiThreadId, + request.getMetadata() + ) + ); + parentConversationNotifier.notifyParent( + parentConversationId, + localConversationId, + taskId, + exposedAgent.getAgentId(), + exposedAgent.getPlanName(), + exposedAgent.getPlanVersion(), + aguiSessionId, + aguiRunId, + aguiThreadId, + agentReply + ); + + SendMessageResponse response = new SendMessageResponse(); + response.setTask(task); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + } + + private A2AExposedAgentSpec resolveExposedAgent(SendMessageRequest request) { + String requestedAgentId = firstNonBlank( + metadataValue(request.getMetadata(), "agentId"), + metadataValue(request.getMetadata(), "a2aAgentId"), + metadataValue(request.getMetadata(), "camelAgent.agentId"), + request.getMessage() == null ? "" : metadataValue(request.getMessage().getMetadata(), "agentId"), + request.getMessage() == null ? "" : metadataValue(request.getMessage().getMetadata(), "camelAgent.agentId") + ); + return exposedAgentCatalog.requireAgent(requestedAgentId); + } + + private void bindCorrelation(String conversationId, + String agentId, + String remoteConversationId, + String remoteTaskId, + String parentConversationId, + String rootConversationId) { + CorrelationRegistry registry = CorrelationRegistry.global(); + registry.bind(conversationId, CorrelationKeys.A2A_AGENT_ID, agentId); + registry.bind(conversationId, CorrelationKeys.A2A_LINKED_CONVERSATION_ID, conversationId); + if (remoteConversationId != null && !remoteConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_REMOTE_CONVERSATION_ID, remoteConversationId); + } + if (remoteTaskId != null && !remoteTaskId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_REMOTE_TASK_ID, remoteTaskId); + } + if (parentConversationId != null && !parentConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_PARENT_CONVERSATION_ID, parentConversationId); + } + if (rootConversationId != null && !rootConversationId.isBlank()) { + registry.bind(conversationId, CorrelationKeys.A2A_ROOT_CONVERSATION_ID, rootConversationId); + } + } + + private Map taskMetadata(A2AExposedAgentSpec exposedAgent, + String localConversationId, + String remoteConversationId, + String taskId, + String parentConversationId, + String rootConversationId, + String aguiSessionId, + String aguiRunId, + String aguiThreadId, + Map requestMetadata) { + Map metadata = new LinkedHashMap<>(); + if (requestMetadata != null && !requestMetadata.isEmpty()) { + metadata.putAll(requestMetadata); + } + Map camelAgent = new LinkedHashMap<>(); + camelAgent.put("agentId", exposedAgent.getAgentId()); + camelAgent.put("planName", exposedAgent.getPlanName()); + camelAgent.put("planVersion", exposedAgent.getPlanVersion()); + camelAgent.put("localConversationId", localConversationId); + camelAgent.put("linkedConversationId", localConversationId); + camelAgent.put("remoteConversationId", remoteConversationId == null ? "" : remoteConversationId); + camelAgent.put("remoteTaskId", taskId); + camelAgent.put("parentConversationId", parentConversationId == null ? "" : parentConversationId); + camelAgent.put("rootConversationId", rootConversationId == null ? "" : rootConversationId); + camelAgent.put("aguiSessionId", aguiSessionId == null ? "" : aguiSessionId); + camelAgent.put("aguiRunId", aguiRunId == null ? "" : aguiRunId); + camelAgent.put("aguiThreadId", aguiThreadId == null ? "" : aguiThreadId); + camelAgent.put("selectedAt", Instant.now().toString()); + metadata.put("camelAgent", camelAgent); + metadata.put("agentId", exposedAgent.getAgentId()); + metadata.put("planName", exposedAgent.getPlanName()); + metadata.put("planVersion", exposedAgent.getPlanVersion()); + metadata.put("linkedConversationId", localConversationId); + metadata.put("remoteConversationId", remoteConversationId == null ? "" : remoteConversationId); + metadata.put("remoteTaskId", taskId); + metadata.put("parentConversationId", parentConversationId == null ? "" : parentConversationId); + metadata.put("rootConversationId", rootConversationId == null ? "" : rootConversationId); + metadata.put("aguiSessionId", aguiSessionId == null ? "" : aguiSessionId); + metadata.put("aguiRunId", aguiRunId == null ? "" : aguiRunId); + metadata.put("aguiThreadId", aguiThreadId == null ? "" : aguiThreadId); + return metadata; + } + + private Message assistantMessage(Message requestMessage, String replyText) { + Message response = new Message(); + response.setMessageId(UUID.randomUUID().toString()); + response.setRole("assistant"); + response.setInReplyTo(requestMessage == null ? null : requestMessage.getMessageId()); + response.setCreatedAt(Instant.now().toString()); + Part part = new Part(); + part.setPartId(UUID.randomUUID().toString()); + part.setType("text"); + part.setMimeType("text/plain"); + part.setText(replyText == null ? "" : replyText); + response.setParts(List.of(part)); + return response; + } + + private String extractMessageText(Message message) { + if (message == null || message.getParts() == null) { + return ""; + } + return message.getParts().stream() + .filter(part -> part != null && part.getText() != null && !part.getText().isBlank()) + .map(Part::getText) + .reduce((left, right) -> left + "\n" + right) + .orElse(""); + } + + private String metadataValue(Map metadata, String key) { + if (metadata == null || metadata.isEmpty() || key == null || key.isBlank()) { + return ""; + } + try { + JsonNode root = objectMapper.valueToTree(metadata); + JsonNode current = root; + for (String part : key.split("\\.")) { + current = current.path(part); + } + if (current.isMissingNode() || current.isNull()) { + return ""; + } + return current.asText(""); + } catch (Exception ignored) { + return ""; + } + } + } + + private static final class SendStreamingMessageProcessor implements Processor { + + private final Processor sendMessageProcessor; + private final AgentA2ATaskAdapter taskAdapter; + private final TaskEventService taskEventService; + private final ObjectMapper objectMapper; + private final A2ARuntimeProperties runtimeProperties; + + private SendStreamingMessageProcessor(Processor sendMessageProcessor, + AgentA2ATaskAdapter taskAdapter, + TaskEventService taskEventService, + ObjectMapper objectMapper, + A2ARuntimeProperties runtimeProperties) { + this.sendMessageProcessor = sendMessageProcessor; + this.taskAdapter = taskAdapter; + this.taskEventService = taskEventService; + this.objectMapper = objectMapper; + this.runtimeProperties = runtimeProperties; + } + + @Override + public void process(Exchange exchange) throws Exception { + SendStreamingMessageRequest request = objectMapper.convertValue(requiredParams(exchange, "SendStreamingMessage requires params object"), + SendStreamingMessageRequest.class); + if (request.getMessage() == null) { + throw new A2AInvalidParamsException("SendStreamingMessage requires message"); + } + SendMessageRequest adapted = new SendMessageRequest(); + adapted.setMessage(request.getMessage()); + adapted.setConversationId(request.getConversationId()); + adapted.setIdempotencyKey(request.getIdempotencyKey()); + adapted.setMetadata(request.getMetadata()); + exchange.setProperty(A2AExchangeProperties.NORMALIZED_PARAMS, objectMapper.valueToTree(adapted)); + sendMessageProcessor.process(exchange); + SendMessageResponse sendMessageResponse = objectMapper.convertValue( + exchange.getProperty(A2AExchangeProperties.METHOD_RESULT), + SendMessageResponse.class + ); + Task task = sendMessageResponse.getTask(); + TaskSubscription subscription = taskEventService.createSubscription(task.getTaskId(), 0L); + SendStreamingMessageResponse response = new SendStreamingMessageResponse(); + response.setTask(taskAdapter.getTask(task.getTaskId())); + response.setSubscriptionId(subscription.getSubscriptionId()); + response.setStreamUrl(buildStreamUrl(runtimeProperties, task.getTaskId(), subscription.getSubscriptionId(), 0L, null)); + exchange.setProperty(A2AExchangeProperties.METHOD_RESULT, response); + } + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2ATaskAdapter.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2ATaskAdapter.java new file mode 100644 index 0000000..f9b802c --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/a2a/AgentA2ATaskAdapter.java @@ -0,0 +1,159 @@ +package io.dscope.camel.agent.a2a; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.api.PersistenceFacade; +import io.dscope.camel.agent.model.AgentEvent; +import io.dscope.camel.a2a.model.Message; +import io.dscope.camel.a2a.model.Task; +import io.dscope.camel.a2a.model.TaskState; +import io.dscope.camel.a2a.model.dto.CancelTaskRequest; +import io.dscope.camel.a2a.model.dto.ListTasksRequest; +import io.dscope.camel.a2a.model.dto.SendMessageRequest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +final class AgentA2ATaskAdapter { + + private final io.dscope.camel.a2a.service.A2ATaskService taskService; + private final PersistenceFacade persistenceFacade; + private final ObjectMapper objectMapper; + + AgentA2ATaskAdapter(io.dscope.camel.a2a.service.A2ATaskService taskService, + PersistenceFacade persistenceFacade, + ObjectMapper objectMapper) { + this.taskService = taskService; + this.persistenceFacade = persistenceFacade; + this.objectMapper = objectMapper; + } + + Task accept(SendMessageRequest request, Map metadata) { + SendMessageRequest effectiveRequest = new SendMessageRequest(); + effectiveRequest.setMessage(request == null ? null : request.getMessage()); + effectiveRequest.setConversationId(request == null ? null : request.getConversationId()); + effectiveRequest.setIdempotencyKey(request == null ? null : request.getIdempotencyKey()); + effectiveRequest.setMetadata(mergeMetadata(request == null ? null : request.getMetadata(), metadata)); + Task task = taskService.sendMessage(effectiveRequest); + mergeMetadata(task, metadata); + return task; + } + + boolean isResponseCompleted(Task task) { + return "true".equalsIgnoreCase(metadataText(task, "camelAgent.responseCompleted")); + } + + Task getTask(String taskId) { + return taskService.getTask(taskId); + } + + List listTasks(String state, Integer limit) { + ListTasksRequest request = new ListTasksRequest(); + request.setState(state); + request.setLimit(limit); + return taskService.listTasks(request); + } + + Task cancelTask(String taskId, String reason) { + CancelTaskRequest request = new CancelTaskRequest(); + request.setTaskId(taskId); + request.setReason(reason); + return taskService.cancelTask(request); + } + + Task complete(Task task, + Message responseMessage, + Map metadata, + String completionMessage) { + mergeMetadata(task, metadata); + task.setLatestMessage(responseMessage); + task.setMessages(combineMessages(task, responseMessage)); + task.setUpdatedAt(Instant.now().toString()); + if (task.getMetadata() != null) { + Object camelAgent = task.getMetadata().get("camelAgent"); + if (camelAgent instanceof Map map) { + @SuppressWarnings("unchecked") + Map mutable = new LinkedHashMap<>((Map) map); + mutable.put("responseCompleted", true); + task.getMetadata().put("camelAgent", mutable); + } + task.getMetadata().put("responseCompleted", true); + } + taskService.transitionTask(task.getTaskId(), TaskState.COMPLETED, completionMessage); + return taskService.getTask(task.getTaskId()); + } + + void appendConversationEvent(String conversationId, + String taskId, + String type, + Map payload) { + if (persistenceFacade == null || conversationId == null || conversationId.isBlank()) { + return; + } + persistenceFacade.appendEvent( + new AgentEvent(conversationId, taskId, type, objectMapper.valueToTree(payload == null ? Map.of() : payload), Instant.now()), + UUID.randomUUID().toString() + ); + } + + private List combineMessages(Task task, Message responseMessage) { + List messages = new ArrayList<>(); + if (task != null && task.getMessages() != null && !task.getMessages().isEmpty()) { + messages.addAll(task.getMessages()); + } else if (task != null && task.getLatestMessage() != null) { + messages.add(task.getLatestMessage()); + } + if (responseMessage != null) { + messages.add(responseMessage); + } + return messages; + } + + @SuppressWarnings("unchecked") + private void mergeMetadata(Task task, Map metadata) { + Map merged = mergeMetadata(task == null ? null : task.getMetadata(), metadata); + if (task != null) { + task.setMetadata(merged); + } + } + + @SuppressWarnings("unchecked") + private Map mergeMetadata(Map existingMetadata, Map incomingMetadata) { + if ((existingMetadata == null || existingMetadata.isEmpty()) && (incomingMetadata == null || incomingMetadata.isEmpty())) { + return existingMetadata == null ? new LinkedHashMap<>() : new LinkedHashMap<>(existingMetadata); + } + Map merged = new LinkedHashMap<>(); + if (existingMetadata != null && !existingMetadata.isEmpty()) { + merged.putAll(existingMetadata); + } + if (incomingMetadata != null && !incomingMetadata.isEmpty()) { + merged.putAll(incomingMetadata); + } + Object camelAgent = merged.get("camelAgent"); + if (camelAgent instanceof Map incomingCamelAgent) { + Map existingCamelAgent = existingMetadata != null + && existingMetadata.get("camelAgent") instanceof Map existing + ? new LinkedHashMap<>((Map) existing) + : new LinkedHashMap<>(); + existingCamelAgent.putAll((Map) incomingCamelAgent); + merged.put("camelAgent", existingCamelAgent); + } + return merged; + } + private String metadataText(Task task, String path) { + try { + JsonNode metadata = objectMapper.valueToTree(task == null ? Map.of() : task.getMetadata()); + String[] parts = path.split("\\."); + JsonNode current = metadata; + for (String part : parts) { + current = current.path(part); + } + return current.isMissingNode() || current.isNull() ? "" : current.asText(""); + } catch (Exception ignored) { + return ""; + } + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditConversationViewProcessor.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditConversationViewProcessor.java index 84d2623..0f9ec78 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditConversationViewProcessor.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditConversationViewProcessor.java @@ -80,6 +80,7 @@ public void process(Exchange exchange) throws Exception { List> perspective = new ArrayList<>(); String effectiveBlueprint = resolveBlueprint(conversationId); AuditMetadataSupport.AgentStepMetadata currentAgentState = AuditMetadataSupport.deriveAgentStepMetadata(events, effectiveBlueprint); + AuditMetadataSupport.A2ACorrelationMetadata currentA2aState = AuditMetadataSupport.deriveA2ACorrelation(events); AuditMetadataSupport.AgentStepMetadata stepAgentState = AuditMetadataSupport.AgentStepMetadata.fromBlueprint( effectiveBlueprint, AuditMetadataSupport.loadBlueprintMetadata(effectiveBlueprint) @@ -122,6 +123,7 @@ public void process(Exchange exchange) throws Exception { "messages", perspective )); response.put("agent", currentAgentState.asMap()); + response.put("a2a", currentA2aState.asMap()); exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/json"); exchange.getMessage().setBody(objectMapper.writeValueAsString(response)); diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditMetadataSupport.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditMetadataSupport.java index e4ca901..5f54606 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditMetadataSupport.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/audit/AuditMetadataSupport.java @@ -171,6 +171,18 @@ static Map buildConversationMetadata(String conversationId, metadata.put("eventCount", events.size()); metadata.put("firstEventAt", firstEventAt == null ? "" : firstEventAt.toString()); metadata.put("lastEventAt", lastEventAt == null ? "" : lastEventAt.toString()); + A2ACorrelationMetadata a2a = deriveA2ACorrelation(events); + if (a2a.present()) { + metadata.put("conversationKind", "a2a-linked"); + metadata.put("a2aAgentId", a2a.agentId()); + metadata.put("a2aRemoteConversationId", a2a.remoteConversationId()); + metadata.put("a2aRemoteTaskId", a2a.remoteTaskId()); + metadata.put("a2aLinkedConversationId", a2a.linkedConversationId()); + metadata.put("a2aParentConversationId", a2a.parentConversationId()); + metadata.put("a2aRootConversationId", a2a.rootConversationId()); + } else { + metadata.put("conversationKind", "standard"); + } if (!agentStepMetadata.planName().isBlank()) { metadata.put("planName", agentStepMetadata.planName()); } @@ -183,6 +195,21 @@ static Map buildConversationMetadata(String conversationId, return metadata; } + static A2ACorrelationMetadata deriveA2ACorrelation(List events) { + if (events == null) { + return A2ACorrelationMetadata.EMPTY; + } + A2ACorrelationMetadata current = A2ACorrelationMetadata.EMPTY; + for (AgentEvent event : events) { + JsonNode correlation = extractCorrelation(event == null ? null : event.payload()); + if (correlation == null) { + continue; + } + current = current.merge(correlation); + } + return current; + } + static AgentStepMetadata deriveAgentStepMetadata(List events, String blueprintUri) { AgentStepMetadata state = AgentStepMetadata.fromBlueprint(blueprintUri, loadBlueprintMetadata(blueprintUri)); if (events == null) { @@ -239,6 +266,24 @@ private static String text(JsonNode node, String field) { return value.isMissingNode() || value.isNull() ? "" : value.asText(""); } + private static JsonNode extractCorrelation(JsonNode payload) { + if (payload == null || payload.isNull() || payload.isMissingNode()) { + return null; + } + List candidates = List.of( + payload.path("correlation"), + payload.path("_correlation"), + payload.path("payload").path("correlation"), + payload.path("payload").path("_correlation") + ); + for (JsonNode candidate : candidates) { + if (candidate != null && candidate.isObject()) { + return candidate; + } + } + return null; + } + private static String firstNonBlank(String... values) { for (String value : values) { if (value != null && !value.isBlank()) { @@ -306,4 +351,56 @@ Map asMap() { return data; } } + + record A2ACorrelationMetadata(String agentId, + String remoteConversationId, + String remoteTaskId, + String linkedConversationId, + String parentConversationId, + String rootConversationId) { + private static final A2ACorrelationMetadata EMPTY = new A2ACorrelationMetadata("", "", "", "", "", ""); + + boolean present() { + return !agentId.isBlank() + || !remoteConversationId.isBlank() + || !remoteTaskId.isBlank() + || !linkedConversationId.isBlank() + || !parentConversationId.isBlank() + || !rootConversationId.isBlank(); + } + + A2ACorrelationMetadata merge(JsonNode node) { + return new A2ACorrelationMetadata( + firstNonBlank(agentId, text(node, "a2aAgentId")), + firstNonBlank(remoteConversationId, text(node, "a2aRemoteConversationId")), + firstNonBlank(remoteTaskId, text(node, "a2aRemoteTaskId")), + firstNonBlank(linkedConversationId, text(node, "a2aLinkedConversationId")), + firstNonBlank(parentConversationId, text(node, "a2aParentConversationId")), + firstNonBlank(rootConversationId, text(node, "a2aRootConversationId")) + ); + } + + Map asMap() { + Map data = new LinkedHashMap<>(); + if (!agentId.isBlank()) { + data.put("agentId", agentId); + } + if (!remoteConversationId.isBlank()) { + data.put("remoteConversationId", remoteConversationId); + } + if (!remoteTaskId.isBlank()) { + data.put("remoteTaskId", remoteTaskId); + } + if (!linkedConversationId.isBlank()) { + data.put("linkedConversationId", linkedConversationId); + } + if (!parentConversationId.isBlank()) { + data.put("parentConversationId", parentConversationId); + } + if (!rootConversationId.isBlank()) { + data.put("rootConversationId", rootConversationId); + } + return data; + } + } } diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentComponent.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentComponent.java index 0b173ef..341365a 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentComponent.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentComponent.java @@ -7,6 +7,7 @@ import io.dscope.camel.agent.api.PersistenceFacade; import io.dscope.camel.agent.api.ToolExecutor; import io.dscope.camel.agent.api.ToolRegistry; +import io.dscope.camel.agent.a2a.A2AToolContext; import io.dscope.camel.agent.blueprint.MarkdownBlueprintLoader; import io.dscope.camel.agent.executor.TemplateAwareCamelToolExecutor; import io.dscope.camel.agent.kernel.DefaultAgentKernel; @@ -70,7 +71,7 @@ public AgentKernel resolveKernel(AgentEndpoint endpoint, ResolvedAgentPlan resol String cacheKey = (resolvedPlan == null || resolvedPlan.legacyMode()) ? "legacy|" + blueprintLocation : resolvedPlan.planName() + "|" + resolvedPlan.planVersion() + "|" + blueprintLocation; - return kernelCache.computeIfAbsent(cacheKey, ignored -> buildKernel(blueprintLocation)); + return kernelCache.computeIfAbsent(cacheKey, ignored -> buildKernel(blueprintLocation, resolvedPlan)); } public AgentPlanSelectionResolver planResolver() { @@ -106,7 +107,7 @@ private Optional findRegistry(Class type) { return Optional.ofNullable(getCamelContext().getRegistry().findSingleByType(type)); } - private AgentKernel buildKernel(String blueprintLocation) { + private AgentKernel buildKernel(String blueprintLocation, ResolvedAgentPlan resolvedPlan) { BlueprintLoader blueprintLoader = new MarkdownBlueprintLoader(); AgentBlueprint loadedBlueprint = applyRealtimeFallback(blueprintLoader.load(blueprintLocation)); @@ -115,9 +116,9 @@ private AgentKernel buildKernel(String blueprintLocation) { AgentBlueprint blueprint = McpToolDiscoveryResolver.resolve(loadedBlueprint, producerTemplate, mapper); ToolRegistry toolRegistry = new DefaultToolRegistry(blueprint.tools()); - ToolExecutor toolExecutor = createToolExecutor(producerTemplate, mapper, blueprint); AiModelClient aiModelClient = findRegistry(AiModelClient.class).orElseGet(StaticAiModelClient::new); PersistenceFacade persistenceFacade = persistenceFacade(); + ToolExecutor toolExecutor = createToolExecutor(producerTemplate, mapper, blueprint, persistenceFacade, resolvedPlan); return new DefaultAgentKernel( blueprint, @@ -132,13 +133,22 @@ private AgentKernel buildKernel(String blueprintLocation) { private ToolExecutor createToolExecutor(ProducerTemplate producerTemplate, ObjectMapper objectMapper, - AgentBlueprint blueprint) { + AgentBlueprint blueprint, + PersistenceFacade persistenceFacade, + ResolvedAgentPlan resolvedPlan) { return findRegistry(ToolExecutor.class) .orElseGet(() -> new TemplateAwareCamelToolExecutor( getCamelContext(), producerTemplate, objectMapper, - defaultIfNull(blueprint.jsonRouteTemplates(), List.of()) + defaultIfNull(blueprint.jsonRouteTemplates(), List.of()), + persistenceFacade, + new A2AToolContext( + resolvedPlan == null || resolvedPlan.legacyMode() ? "" : defaultIfBlank(resolvedPlan.planName(), ""), + resolvedPlan == null || resolvedPlan.legacyMode() ? "" : defaultIfBlank(resolvedPlan.planVersion(), ""), + defaultIfBlank(blueprint.name(), ""), + defaultIfBlank(blueprint.version(), "") + ) )); } diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentProducer.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentProducer.java index 85225e6..dcdbaad 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentProducer.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/component/AgentProducer.java @@ -96,6 +96,12 @@ private void registerCorrelation(String conversationId, Exchange exchange) { String aguiSessionId = exchange.getMessage().getHeader(AgentHeaders.AGUI_SESSION_ID, String.class); String aguiRunId = exchange.getMessage().getHeader(AgentHeaders.AGUI_RUN_ID, String.class); String aguiThreadId = exchange.getMessage().getHeader(AgentHeaders.AGUI_THREAD_ID, String.class); + String a2aAgentId = exchange.getMessage().getHeader(AgentHeaders.A2A_AGENT_ID, String.class); + String a2aRemoteConversationId = exchange.getMessage().getHeader(AgentHeaders.A2A_REMOTE_CONVERSATION_ID, String.class); + String a2aRemoteTaskId = exchange.getMessage().getHeader(AgentHeaders.A2A_REMOTE_TASK_ID, String.class); + String a2aLinkedConversationId = exchange.getMessage().getHeader(AgentHeaders.A2A_LINKED_CONVERSATION_ID, String.class); + String a2aParentConversationId = exchange.getMessage().getHeader(AgentHeaders.A2A_PARENT_CONVERSATION_ID, String.class); + String a2aRootConversationId = exchange.getMessage().getHeader(AgentHeaders.A2A_ROOT_CONVERSATION_ID, String.class); if (aguiSessionId != null && !aguiSessionId.isBlank()) { correlationRegistry.bind(conversationId, CorrelationKeys.AGUI_SESSION_ID, aguiSessionId); @@ -106,5 +112,23 @@ private void registerCorrelation(String conversationId, Exchange exchange) { if (aguiThreadId != null && !aguiThreadId.isBlank()) { correlationRegistry.bind(conversationId, CorrelationKeys.AGUI_THREAD_ID, aguiThreadId); } + if (a2aAgentId != null && !a2aAgentId.isBlank()) { + correlationRegistry.bind(conversationId, CorrelationKeys.A2A_AGENT_ID, a2aAgentId); + } + if (a2aRemoteConversationId != null && !a2aRemoteConversationId.isBlank()) { + correlationRegistry.bind(conversationId, CorrelationKeys.A2A_REMOTE_CONVERSATION_ID, a2aRemoteConversationId); + } + if (a2aRemoteTaskId != null && !a2aRemoteTaskId.isBlank()) { + correlationRegistry.bind(conversationId, CorrelationKeys.A2A_REMOTE_TASK_ID, a2aRemoteTaskId); + } + if (a2aLinkedConversationId != null && !a2aLinkedConversationId.isBlank()) { + correlationRegistry.bind(conversationId, CorrelationKeys.A2A_LINKED_CONVERSATION_ID, a2aLinkedConversationId); + } + if (a2aParentConversationId != null && !a2aParentConversationId.isBlank()) { + correlationRegistry.bind(conversationId, CorrelationKeys.A2A_PARENT_CONVERSATION_ID, a2aParentConversationId); + } + if (a2aRootConversationId != null && !a2aRootConversationId.isBlank()) { + correlationRegistry.bind(conversationId, CorrelationKeys.A2A_ROOT_CONVERSATION_ID, a2aRootConversationId); + } } } diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/config/AgentHeaders.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/config/AgentHeaders.java index 3ec4b82..7478b98 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/config/AgentHeaders.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/config/AgentHeaders.java @@ -9,6 +9,12 @@ public final class AgentHeaders { public static final String AGUI_SESSION_ID = "agent.agui.sessionId"; public static final String AGUI_RUN_ID = "agent.agui.runId"; public static final String AGUI_THREAD_ID = "agent.agui.threadId"; + public static final String A2A_AGENT_ID = "agent.a2a.agentId"; + public static final String A2A_REMOTE_CONVERSATION_ID = "agent.a2a.remoteConversationId"; + public static final String A2A_REMOTE_TASK_ID = "agent.a2a.remoteTaskId"; + public static final String A2A_LINKED_CONVERSATION_ID = "agent.a2a.linkedConversationId"; + public static final String A2A_PARENT_CONVERSATION_ID = "agent.a2a.parentConversationId"; + public static final String A2A_ROOT_CONVERSATION_ID = "agent.a2a.rootConversationId"; public static final String PLAN_NAME = "agent.planName"; public static final String PLAN_VERSION = "agent.planVersion"; public static final String RESOLVED_PLAN_NAME = "agent.resolvedPlanName"; diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/config/CorrelationKeys.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/config/CorrelationKeys.java index 8797ef0..fb6f48e 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/config/CorrelationKeys.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/config/CorrelationKeys.java @@ -5,7 +5,13 @@ public final class CorrelationKeys { public static final String AGUI_SESSION_ID = "agui.sessionId"; public static final String AGUI_RUN_ID = "agui.runId"; public static final String AGUI_THREAD_ID = "agui.threadId"; + public static final String A2A_AGENT_ID = "a2a.agentId"; + public static final String A2A_REMOTE_CONVERSATION_ID = "a2a.remoteConversationId"; + public static final String A2A_REMOTE_TASK_ID = "a2a.remoteTaskId"; + public static final String A2A_LINKED_CONVERSATION_ID = "a2a.linkedConversationId"; + public static final String A2A_PARENT_CONVERSATION_ID = "a2a.parentConversationId"; + public static final String A2A_ROOT_CONVERSATION_ID = "a2a.rootConversationId"; private CorrelationKeys() { } -} \ No newline at end of file +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/CamelToolExecutor.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/CamelToolExecutor.java index d9776b5..f338257 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/CamelToolExecutor.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/CamelToolExecutor.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.dscope.camel.agent.api.ToolExecutor; +import io.dscope.camel.agent.a2a.A2AToolClient; +import io.dscope.camel.agent.a2a.A2AToolContext; +import io.dscope.camel.agent.api.PersistenceFacade; import io.dscope.camel.agent.config.AgentHeaders; import io.dscope.camel.agent.model.ExecutionContext; import io.dscope.camel.agent.model.ToolResult; @@ -12,6 +15,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.apache.camel.CamelContext; import org.apache.camel.ProducerTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,17 +24,32 @@ public class CamelToolExecutor implements ToolExecutor { private static final Logger LOGGER = LoggerFactory.getLogger(CamelToolExecutor.class); + private final CamelContext camelContext; private final ProducerTemplate producerTemplate; private final ObjectMapper objectMapper; + private final A2AToolClient a2aToolClient; public CamelToolExecutor(ProducerTemplate producerTemplate, ObjectMapper objectMapper) { + this(null, producerTemplate, objectMapper, null, A2AToolContext.EMPTY); + } + + public CamelToolExecutor(CamelContext camelContext, + ProducerTemplate producerTemplate, + ObjectMapper objectMapper, + PersistenceFacade persistenceFacade, + A2AToolContext a2aToolContext) { + this.camelContext = camelContext; this.producerTemplate = producerTemplate; this.objectMapper = objectMapper; + this.a2aToolClient = new A2AToolClient(camelContext, objectMapper, persistenceFacade, a2aToolContext); } @Override public ToolResult execute(ToolSpec toolSpec, JsonNode arguments, ExecutionContext context) { String target = target(toolSpec); + if (isA2ATarget(target)) { + return a2aToolClient.execute(target, toolSpec, arguments, context); + } Map headers = new HashMap<>(); headers.put(AgentHeaders.CONVERSATION_ID, context.conversationId()); headers.put(AgentHeaders.TASK_ID, context.taskId()); @@ -104,6 +123,10 @@ private boolean isMcpTarget(String target) { return target != null && target.startsWith("mcp:"); } + private boolean isA2ATarget(String target) { + return target != null && target.startsWith("a2a:"); + } + private String argumentShape(Object arguments) { if (arguments == null) { return "null"; diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/TemplateAwareCamelToolExecutor.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/TemplateAwareCamelToolExecutor.java index 1d158b5..06d44e4 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/TemplateAwareCamelToolExecutor.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/executor/TemplateAwareCamelToolExecutor.java @@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.dscope.camel.agent.api.ToolExecutor; +import io.dscope.camel.agent.a2a.A2AToolContext; +import io.dscope.camel.agent.api.PersistenceFacade; import io.dscope.camel.agent.config.AgentHeaders; import io.dscope.camel.agent.model.ExecutionContext; import io.dscope.camel.agent.model.JsonRouteTemplateSpec; @@ -38,10 +40,19 @@ public TemplateAwareCamelToolExecutor(CamelContext camelContext, ProducerTemplate producerTemplate, ObjectMapper objectMapper, List templates) { + this(camelContext, producerTemplate, objectMapper, templates, null, A2AToolContext.EMPTY); + } + + public TemplateAwareCamelToolExecutor(CamelContext camelContext, + ProducerTemplate producerTemplate, + ObjectMapper objectMapper, + List templates, + PersistenceFacade persistenceFacade, + A2AToolContext a2aToolContext) { this.camelContext = camelContext; this.producerTemplate = producerTemplate; this.objectMapper = objectMapper; - this.delegate = new CamelToolExecutor(producerTemplate, objectMapper); + this.delegate = new CamelToolExecutor(camelContext, producerTemplate, objectMapper, persistenceFacade, a2aToolContext); this.templatesByToolName = new HashMap<>(); if (templates != null) { for (JsonRouteTemplateSpec template : templates) { diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/kernel/InMemoryPersistenceFacade.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/kernel/InMemoryPersistenceFacade.java index 4b2c59b..35122c1 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/kernel/InMemoryPersistenceFacade.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/kernel/InMemoryPersistenceFacade.java @@ -5,7 +5,7 @@ import io.dscope.camel.agent.model.DynamicRouteState; import io.dscope.camel.agent.model.TaskState; import java.time.Instant; -import java.util.ArrayList; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -21,7 +21,7 @@ public class InMemoryPersistenceFacade implements PersistenceFacade { @Override public void appendEvent(AgentEvent event, String idempotencyKey) { - conversations.computeIfAbsent(event.conversationId(), key -> new ArrayList<>()).add(event); + conversations.computeIfAbsent(event.conversationId(), key -> new CopyOnWriteArrayList<>()).add(event); } @Override diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/A2ARuntimeProperties.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/A2ARuntimeProperties.java new file mode 100644 index 0000000..3ee3cf1 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/A2ARuntimeProperties.java @@ -0,0 +1,168 @@ +package io.dscope.camel.agent.runtime; + +import java.util.Properties; + +public record A2ARuntimeProperties( + boolean enabled, + String host, + int port, + String publicBaseUrl, + String rpcPath, + String ssePath, + String agentCardPath, + String agentEndpointUri, + String exposedAgentsConfig, + String plansConfig, + String legacyBlueprint +) { + public static A2ARuntimeProperties from(Properties properties) { + String plansConfig = property(properties, "agent.agents-config", ""); + String legacyBlueprint = property(properties, "agent.blueprint", "classpath:agents/support/agent.md"); + String host = firstNonBlank( + property(properties, "agent.runtime.a2a.host", ""), + property(properties, "agent.runtime.a2a.bind-host", ""), + property(properties, "agent.runtime.a2a.bindHost", ""), + "0.0.0.0" + ); + int port = intProperty( + properties, + firstPresentKey( + properties, + "agent.runtime.a2a.port", + "agent.runtime.a2a.bind-port", + "agent.runtime.a2a.bindPort", + "agent.audit.api.port", + "agui.rpc.port" + ), + 8080 + ); + String publicBaseUrl = firstNonBlank( + property(properties, "agent.runtime.a2a.public-base-url", ""), + property(properties, "agent.runtime.a2a.publicBaseUrl", ""), + "http://localhost:" + port + ); + return new A2ARuntimeProperties( + booleanProperty(properties, "agent.runtime.a2a.enabled", false), + host, + port, + publicBaseUrl, + normalizePath(firstNonBlank( + property(properties, "agent.runtime.a2a.rpc-path", ""), + property(properties, "agent.runtime.a2a.rpcPath", ""), + "/a2a/rpc" + )), + normalizePath(firstNonBlank( + property(properties, "agent.runtime.a2a.sse-path", ""), + property(properties, "agent.runtime.a2a.ssePath", ""), + "/a2a/sse" + )), + normalizePath(firstNonBlank( + property(properties, "agent.runtime.a2a.agent-card-path", ""), + property(properties, "agent.runtime.a2a.agentCardPath", ""), + "/.well-known/agent-card.json" + )), + firstNonBlank( + property(properties, "agent.runtime.a2a.agent-endpoint-uri", ""), + property(properties, "agent.runtime.a2a.agentEndpointUri", ""), + defaultAgentEndpointUri(plansConfig, legacyBlueprint) + ), + firstNonBlank( + property(properties, "agent.runtime.a2a.exposed-agents-config", ""), + property(properties, "agent.runtime.a2a.exposedAgentsConfig", "") + ), + plansConfig, + legacyBlueprint + ); + } + + public String rpcEndpointUrl() { + return trimTrailingSlash(publicBaseUrl) + normalizePath(rpcPath); + } + + public String sseBaseUrl() { + return trimTrailingSlash(publicBaseUrl) + normalizePath(ssePath); + } + + private static String defaultAgentEndpointUri(String plansConfig, String legacyBlueprint) { + StringBuilder uri = new StringBuilder("agent:a2a"); + if ((plansConfig != null && !plansConfig.isBlank()) || (legacyBlueprint != null && !legacyBlueprint.isBlank())) { + uri.append('?'); + boolean hasQuery = false; + if (plansConfig != null && !plansConfig.isBlank()) { + uri.append("plansConfig=").append(plansConfig); + hasQuery = true; + } + if (legacyBlueprint != null && !legacyBlueprint.isBlank()) { + if (hasQuery) { + uri.append('&'); + } + uri.append("blueprint=").append(legacyBlueprint); + } + } + return uri.toString(); + } + + private static String property(Properties properties, String key, String fallback) { + String value = properties == null ? null : properties.getProperty(key); + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private static int intProperty(Properties properties, String key, int fallback) { + if (key == null || key.isBlank()) { + return fallback; + } + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + return fallback; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static boolean booleanProperty(Properties properties, String key, boolean fallback) { + String value = properties == null ? null : properties.getProperty(key); + if (value == null || value.isBlank()) { + return fallback; + } + return Boolean.parseBoolean(value.trim()); + } + + private static String firstPresentKey(Properties properties, String... keys) { + for (String key : keys) { + if (properties != null && properties.getProperty(key) != null) { + return key; + } + } + return keys.length == 0 ? null : keys[0]; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private static String normalizePath(String value) { + if (value == null || value.isBlank()) { + return "/"; + } + return value.startsWith("/") ? value.trim() : "/" + value.trim(); + } + + private static String trimTrailingSlash(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String trimmed = value.trim(); + if (trimmed.endsWith("/")) { + return trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentA2ARuntimeRouteBuilder.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentA2ARuntimeRouteBuilder.java new file mode 100644 index 0000000..9970f1b --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentA2ARuntimeRouteBuilder.java @@ -0,0 +1,44 @@ +package io.dscope.camel.agent.runtime; + +import io.dscope.camel.a2a.A2AComponentApplicationSupport; +import org.apache.camel.builder.RouteBuilder; + +public class AgentA2ARuntimeRouteBuilder extends RouteBuilder { + + private final A2ARuntimeProperties properties; + + public AgentA2ARuntimeRouteBuilder(A2ARuntimeProperties properties) { + this.properties = properties; + } + + @Override + public void configure() { + onException(Exception.class) + .handled(true) + .to("bean:" + A2AComponentApplicationSupport.BEAN_ERROR_PROCESSOR); + + from(undertowUri(properties.rpcPath(), "POST")) + .routeId("agent-a2a-rpc") + .convertBodyTo(String.class) + .to("bean:" + A2AComponentApplicationSupport.BEAN_ENVELOPE_PROCESSOR) + .to("bean:" + A2AComponentApplicationSupport.BEAN_METHOD_PROCESSOR); + + from(undertowUri(properties.ssePath() + "/{taskId}", "GET")) + .routeId("agent-a2a-sse") + .to("bean:" + A2AComponentApplicationSupport.BEAN_SSE_PROCESSOR); + + from(undertowUri(properties.agentCardPath(), "GET")) + .routeId("agent-a2a-agent-card") + .to("bean:" + A2AComponentApplicationSupport.BEAN_AGENT_CARD_DISCOVERY_PROCESSOR); + } + + private String undertowUri(String path, String method) { + return "undertow:http://" + + properties.host() + + ":" + + properties.port() + + path + + "?httpMethodRestrict=" + + method; + } +} diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentRuntimeBootstrap.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentRuntimeBootstrap.java index 06947a3..96abaef 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentRuntimeBootstrap.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/AgentRuntimeBootstrap.java @@ -1,6 +1,7 @@ package io.dscope.camel.agent.runtime; import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.a2a.AgentA2AProtocolSupport; import io.dscope.camel.agent.agui.AgentAgUiPreRunTextProcessor; import io.dscope.camel.agent.audit.AuditAgentBlueprintProcessor; import io.dscope.camel.agent.audit.AuditAgentCatalogProcessor; @@ -79,6 +80,15 @@ public static void bootstrap(Main main, String applicationYamlPath) throws Excep ObjectMapper objectMapper = existingObjectMapper(main); main.bind("objectMapper", objectMapper); + TicketLifecycleProcessor ticketLifecycleProcessor = null; + if (main.getCamelContext() != null && main.getCamelContext().getRegistry() != null) { + ticketLifecycleProcessor = main.getCamelContext() + .getRegistry() + .lookupByNameAndType("ticketLifecycleProcessor", TicketLifecycleProcessor.class); + } + if (ticketLifecycleProcessor == null) { + main.bind("ticketLifecycleProcessor", new TicketLifecycleProcessor(objectMapper)); + } PersistenceFacade persistenceFacade = existingPersistenceFacade(main); if (persistenceFacade == null) { @@ -165,7 +175,7 @@ public static void bootstrap(Main main, String applicationYamlPath) throws Excep ); bindAiModelClientIfConfigured(main, properties, objectMapper); - bindOptionalAgUiAndRealtime(main, properties); + bindOptionalAgUiRealtimeAndA2a(main, properties, persistenceFacade, planSelectionResolver, objectMapper); boolean agentRoutesEnabled = Boolean.parseBoolean(properties.getProperty("agent.runtime.agent-routes-enabled", "true")); if (agentRoutesEnabled) { @@ -174,6 +184,13 @@ public static void bootstrap(Main main, String applicationYamlPath) throws Excep } else { LOGGER.info("Agent runtime route builder disabled by configuration"); } + A2ARuntimeProperties a2aRuntimeProperties = A2ARuntimeProperties.from(properties); + if (a2aRuntimeProperties.enabled()) { + main.configure().addRoutesBuilder(new AgentA2ARuntimeRouteBuilder(a2aRuntimeProperties)); + LOGGER.info("Agent runtime A2A route builder enabled"); + } else { + LOGGER.info("Agent runtime A2A route builder disabled by configuration"); + } LOGGER.info("Agent runtime bootstrap completed"); } @@ -444,7 +461,11 @@ private static void bindAiModelClientIfConfigured(Main main, Properties properti } } - private static void bindOptionalAgUiAndRealtime(Main main, Properties properties) { + private static void bindOptionalAgUiRealtimeAndA2a(Main main, + Properties properties, + PersistenceFacade persistenceFacade, + AgentPlanSelectionResolver planSelectionResolver, + ObjectMapper objectMapper) { if (booleanPropertyWithAliases(properties, true, "agent.runtime.agui.bind-default-beans", "agent.runtime.agui.bindDefaultBeans" @@ -542,6 +563,15 @@ && isBlank(properties.getProperty("agent.runtime.realtime.requireInitSession"))) ); bindSipProcessorsIfMissing(main, sipInitBeanName, sipTranscriptBeanName, sipCallEndBeanName); } + + AgentA2AProtocolSupport.bindIfEnabled( + main, + properties, + A2ARuntimeProperties.from(properties), + persistenceFacade, + planSelectionResolver, + objectMapper + ); } private static void bindAgUiDefaultsIfAvailable(Main main) { diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/RuntimeResourceBootstrapper.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/RuntimeResourceBootstrapper.java index 182995a..f2930ea 100644 --- a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/RuntimeResourceBootstrapper.java +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/RuntimeResourceBootstrapper.java @@ -24,6 +24,7 @@ final class RuntimeResourceBootstrapper { private static final String PROP_BLUEPRINT = "agent.blueprint"; private static final String PROP_AGENTS_CONFIG = "agent.agents-config"; + private static final String PROP_A2A_EXPOSED_AGENTS_CONFIG = "agent.runtime.a2a.exposed-agents-config"; private static final String PROP_ROUTES_PATTERN = "agent.runtime.routes-include-pattern"; private static final String PROP_KAMELET_URLS = "agent.runtime.kamelets-include-pattern"; private static final String PROP_KAMELET_URLS_ALIAS = "agent.runtime.kameletsIncludePattern"; @@ -38,6 +39,7 @@ static Properties resolve(Properties source) { BootstrapWorkspace workspace = new BootstrapWorkspace(); resolveAgentsConfig(resolved, workspace); + resolveA2aExposedAgentsConfig(resolved, workspace); resolveBlueprint(resolved, workspace); resolveRoutes(resolved, workspace); resolveKamelets(resolved, workspace); @@ -70,6 +72,17 @@ private static void resolveAgentsConfig(Properties properties, BootstrapWorkspac stageRemoteBlueprintsFromCatalog(properties, workspace, replacement); } + private static void resolveA2aExposedAgentsConfig(Properties properties, BootstrapWorkspace workspace) { + String config = trimToNull(properties.getProperty(PROP_A2A_EXPOSED_AGENTS_CONFIG)); + if (config == null || !isHttpUrl(config)) { + return; + } + Path staged = workspace.downloadTo("agents", config, ".yaml"); + String replacement = toFileUri(staged); + properties.setProperty(PROP_A2A_EXPOSED_AGENTS_CONFIG, replacement); + LOGGER.info("Runtime resource bootstrap: staged a2a exposed-agents config {} -> {}", config, replacement); + } + private static void stageRemoteBlueprintsFromCatalog(Properties properties, BootstrapWorkspace workspace, String catalogLocation) { try { AgentPlanCatalog catalog = new AgentPlanCatalogLoader().load(catalogLocation); diff --git a/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/TicketLifecycleProcessor.java b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/TicketLifecycleProcessor.java new file mode 100644 index 0000000..f86ee26 --- /dev/null +++ b/camel-agent-core/src/main/java/io/dscope/camel/agent/runtime/TicketLifecycleProcessor.java @@ -0,0 +1,168 @@ +package io.dscope.camel.agent.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.dscope.camel.agent.config.AgentHeaders; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.zip.CRC32; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.Processor; + +/** + * Small stateful ticket lifecycle processor used by the sample app. + * It keeps per-conversation ticket state so the A2A ticket service can + * demonstrate open/update/close/status flows across turns. + */ +public class TicketLifecycleProcessor implements Processor { + + private final ObjectMapper objectMapper; + private final ConcurrentMap ticketsByConversationId = new ConcurrentHashMap<>(); + + public TicketLifecycleProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void process(Exchange exchange) throws Exception { + Message in = exchange.getIn(); + String conversationId = readConversationId(in); + String query = normalizeQuery(in.getBody()); + + TicketAction action = determineAction(query); + TicketRecord current = ticketsByConversationId.compute(conversationId, (key, existing) -> applyAction(key, existing, action, query)); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("ticketId", current.ticketId()); + payload.put("status", current.status()); + payload.put("action", action.name()); + payload.put("summary", current.summary()); + payload.put("assignedQueue", current.assignedQueue()); + payload.put("message", messageFor(action)); + payload.put("notifyClient", action == TicketAction.UPDATE || action == TicketAction.CLOSE || action == TicketAction.STATUS); + payload.put("requiresCustomerAction", action == TicketAction.UPDATE && query.toLowerCase(Locale.ROOT).contains("confirm")); + payload.put("conversationId", conversationId); + payload.put("updatedAt", Instant.now().toString()); + + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/json"); + exchange.getMessage().setBody(objectMapper.writeValueAsString(payload)); + } + + private TicketRecord applyAction(String conversationId, TicketRecord existing, TicketAction action, String query) { + TicketRecord current = existing != null ? existing : createRecord(conversationId, query); + return switch (action) { + case OPEN -> createRecord(conversationId, query); + case UPDATE -> new TicketRecord( + current.ticketId(), + "IN_PROGRESS", + firstNonBlank(query, current.summary()), + current.assignedQueue() + ); + case CLOSE -> new TicketRecord( + current.ticketId(), + "CLOSED", + firstNonBlank(query, current.summary()), + current.assignedQueue() + ); + case STATUS -> current; + }; + } + + private TicketRecord createRecord(String conversationId, String query) { + return new TicketRecord( + ticketIdFor(conversationId), + "OPEN", + firstNonBlank(query, "Customer requested support follow-up"), + queueFor(query) + ); + } + + private String ticketIdFor(String conversationId) { + CRC32 crc = new CRC32(); + crc.update(conversationId.getBytes(StandardCharsets.UTF_8)); + return "TCK-" + Long.toUnsignedString(crc.getValue(), 36).toUpperCase(Locale.ROOT); + } + + private String queueFor(String query) { + String normalized = query.toLowerCase(Locale.ROOT); + if (normalized.contains("bill") || normalized.contains("refund") || normalized.contains("invoice") || normalized.contains("payment")) { + return "BILLING-L2"; + } + if (normalized.contains("security") || normalized.contains("fraud")) { + return "SECURITY-L2"; + } + return "L1-SUPPORT"; + } + + private TicketAction determineAction(String query) { + String normalized = query.toLowerCase(Locale.ROOT); + if (containsAny(normalized, "close", "closed", "resolve", "resolved", "done with", "cancel ticket")) { + return TicketAction.CLOSE; + } + if (containsAny(normalized, "status", "progress", "check ticket", "where is", "what is happening")) { + return TicketAction.STATUS; + } + if (containsAny(normalized, "update", "add note", "change", "modify", "priority", "reopen")) { + return TicketAction.UPDATE; + } + return TicketAction.OPEN; + } + + private boolean containsAny(String text, String... markers) { + for (String marker : markers) { + if (text.contains(marker)) { + return true; + } + } + return false; + } + + private String messageFor(TicketAction action) { + return switch (action) { + case OPEN -> "Support ticket created successfully"; + case UPDATE -> "Support ticket updated and routed back to the client conversation"; + case CLOSE -> "Support ticket closed and the client conversation has been updated"; + case STATUS -> "Support ticket status retrieved successfully"; + }; + } + + private String normalizeQuery(Object body) { + if (body instanceof Map map) { + Object query = map.get("query"); + return query == null ? "" : String.valueOf(query).trim(); + } + return body == null ? "" : String.valueOf(body).trim(); + } + + private String readConversationId(Message in) { + String conversationId = in.getHeader(AgentHeaders.CONVERSATION_ID, String.class); + if (conversationId == null || conversationId.isBlank()) { + return "sample-ticket-fallback"; + } + return conversationId.trim(); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private enum TicketAction { + OPEN, + UPDATE, + CLOSE, + STATUS + } + + private record TicketRecord(String ticketId, String status, String summary, String assignedQueue) { + } +} diff --git a/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalogLoaderTest.java b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalogLoaderTest.java new file mode 100644 index 0000000..17198d4 --- /dev/null +++ b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/A2AExposedAgentCatalogLoaderTest.java @@ -0,0 +1,63 @@ +package io.dscope.camel.agent.a2a; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2AExposedAgentCatalogLoaderTest { + + private final A2AExposedAgentCatalogLoader loader = new A2AExposedAgentCatalogLoader(); + + @TempDir + Path tempDir; + + @Test + void loadsAndValidatesExposedAgentsCatalog() throws Exception { + Path config = tempDir.resolve("a2a-agents.yaml"); + Files.writeString(config, """ + agents: + - agentId: support-public + name: Support Public Agent + description: Handles support conversations + defaultAgent: true + version: 1.0.0 + planName: support + planVersion: v1 + skills: + - support + - troubleshooting + - agentId: billing-public + name: Billing Public Agent + description: Handles billing + planName: billing + planVersion: v2 + """); + + A2AExposedAgentCatalog catalog = loader.load(config.toString()); + + assertEquals(2, catalog.agents().size()); + assertEquals("support-public", catalog.defaultAgent().getAgentId()); + assertEquals("billing", catalog.requireAgent("billing-public").getPlanName()); + assertTrue(catalog.requireAgent("").isDefaultAgent()); + } + + @Test + void rejectsCatalogWithoutDefaultAgent() throws Exception { + Path config = tempDir.resolve("a2a-agents-no-default.yaml"); + Files.writeString(config, """ + agents: + - agentId: support-public + name: Support Public Agent + planName: support + planVersion: v1 + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> loader.load(config.toString())); + assertTrue(error.getMessage().contains("default")); + } +} diff --git a/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/A2AToolClientTest.java b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/A2AToolClientTest.java new file mode 100644 index 0000000..13a9526 --- /dev/null +++ b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/A2AToolClientTest.java @@ -0,0 +1,285 @@ +package io.dscope.camel.agent.a2a; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.kernel.InMemoryPersistenceFacade; +import io.dscope.camel.agent.model.ExecutionContext; +import io.dscope.camel.agent.model.ToolPolicy; +import io.dscope.camel.agent.model.ToolResult; +import io.dscope.camel.agent.model.ToolSpec; +import io.dscope.camel.agent.registry.CorrelationRegistry; +import io.dscope.camel.a2a.A2AComponent; +import java.io.IOException; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class A2AToolClientTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @AfterEach + void tearDown() { + CorrelationRegistry.global().clear("conv-a2a"); + } + + @Test + void sendsRemoteA2aMessageAndBindsCorrelation() throws Exception { + CorrelationRegistry.global().bind("conv-a2a", "agui.sessionId", "session-1"); + CorrelationRegistry.global().bind("conv-a2a", "agui.runId", "run-1"); + CorrelationRegistry.global().bind("conv-a2a", "agui.threadId", "thread-1"); + FakeHttpClient httpClient = new FakeHttpClient(List.of(""" + {"jsonrpc":"2.0","result":{"task":{"taskId":"remote-task-1","status":{"state":"COMPLETED"},"latestMessage":{"parts":[{"text":"remote answer"}]},"metadata":{"camelAgent":{"linkedConversationId":"child-a2a-1","parentConversationId":"conv-a2a","rootConversationId":"conv-a2a"}}}},"id":"1"} + """)); + InMemoryPersistenceFacade persistence = new InMemoryPersistenceFacade(); + + try (DefaultCamelContext camelContext = new DefaultCamelContext()) { + camelContext.addComponent("a2a", new A2AComponent()); + camelContext.start(); + + A2AToolClient client = new A2AToolClient( + camelContext, + objectMapper, + persistence, + new A2AToolContext("support", "v1", "Support Agent", "1.0"), + httpClient + ); + + ToolResult result = client.execute( + "a2a:support-public?remoteUrl=http://example.test/a2a", + new ToolSpec("remote.specialist", "Calls remote specialist", null, + "a2a:support-public?remoteUrl=http://example.test/a2a", null, null, new ToolPolicy(false, 0, 1000)), + objectMapper.createObjectNode().put("text", "hello remote"), + new ExecutionContext("conv-a2a", "task-local", "trace-1") + ); + + JsonNode request = objectMapper.readTree(httpClient.lastRequestBody()); + assertEquals("SendMessage", request.path("method").asText()); + assertEquals("support-public", request.path("params").path("metadata").path("agentId").asText()); + assertEquals("support", request.path("params").path("metadata").path("planName").asText()); + assertEquals("session-1", request.path("params").path("metadata").path("aguiSessionId").asText()); + assertEquals("thread-1", request.path("params").path("metadata").path("camelAgent").path("aguiThreadId").asText()); + assertEquals("remote answer", result.content()); + assertEquals("remote-task-1", CorrelationRegistry.global().resolve("conv-a2a", "a2a.remoteTaskId", "")); + assertEquals("child-a2a-1", CorrelationRegistry.global().resolve("conv-a2a", "a2a.linkedConversationId", "")); + assertTrue(persistence.loadConversation("conv-a2a", 10).stream().anyMatch(event -> "conversation.a2a.outbound.started".equals(event.type()))); + assertTrue(persistence.loadConversation("conv-a2a", 10).stream().anyMatch(event -> "conversation.a2a.outbound.completed".equals(event.type()))); + } + } + + @Test + void reusesCorrelatedRemoteTaskIdForFollowUpCalls() throws Exception { + FakeHttpClient httpClient = new FakeHttpClient(List.of(""" + {"jsonrpc":"2.0","result":{"task":{"taskId":"remote-task-2","status":{"state":"COMPLETED"},"latestMessage":{"parts":[{"text":"created"}]}}},"id":"1"} + """, """ + {"jsonrpc":"2.0","result":{"task":{"taskId":"remote-task-2","status":{"state":"COMPLETED"},"latestMessage":{"parts":[{"text":"fetched"}]}}},"id":"2"} + """)); + + try (DefaultCamelContext camelContext = new DefaultCamelContext()) { + camelContext.addComponent("a2a", new A2AComponent()); + camelContext.start(); + + A2AToolClient client = new A2AToolClient( + camelContext, + objectMapper, + new InMemoryPersistenceFacade(), + new A2AToolContext("support", "v1", "Support Agent", "1.0"), + httpClient + ); + + ToolSpec tool = new ToolSpec("remote.specialist", "Calls remote specialist", null, + "a2a:support-public?remoteUrl=http://example.test/a2a", null, null, new ToolPolicy(false, 0, 1000)); + client.execute(tool.endpointUri(), tool, objectMapper.createObjectNode().put("text", "hello"), new ExecutionContext("conv-a2a", null, "trace-1")); + client.execute(tool.endpointUri(), tool, objectMapper.createObjectNode().put("method", "GetTask"), new ExecutionContext("conv-a2a", null, "trace-2")); + + JsonNode request = objectMapper.readTree(httpClient.lastRequestBody()); + assertEquals("GetTask", request.path("method").asText()); + assertEquals("remote-task-2", request.path("params").path("taskId").asText()); + } + } + + private static final class FakeHttpClient extends HttpClient { + + private final Queue responses; + private final Executor executor = Executors.newSingleThreadExecutor(); + private volatile String lastRequestBody = ""; + + private FakeHttpClient(List responses) { + this.responses = new ConcurrentLinkedQueue<>(responses); + } + + String lastRequestBody() { + return lastRequestBody; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return null; + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.of(executor); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + lastRequestBody = request.bodyPublisher() + .map(BodyPublisherReader::read) + .orElse(""); + String responseBody = responses.remove(); + return (HttpResponse) new FakeHttpResponse(request, responseBody); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + throw new UnsupportedOperationException(); + } + } + + private record FakeHttpResponse(HttpRequest request, String body) implements HttpResponse { + @Override + public int statusCode() { + return 200; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(java.util.Map.of(), (a, b) -> true); + } + + @Override + public String body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + } + + private static final class BodyPublisherReader implements java.util.concurrent.Flow.Subscriber { + + private final java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + private java.util.concurrent.Flow.Subscription subscription; + + static String read(HttpRequest.BodyPublisher publisher) { + BodyPublisherReader reader = new BodyPublisherReader(); + publisher.subscribe(reader); + return reader.bytes.toString(StandardCharsets.UTF_8); + } + + @Override + public void onSubscribe(java.util.concurrent.Flow.Subscription subscription) { + this.subscription = subscription; + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(java.nio.ByteBuffer buffer) { + byte[] chunk = new byte[buffer.remaining()]; + buffer.get(chunk); + bytes.write(chunk, 0, chunk.length); + } + + @Override + public void onError(Throwable throwable) { + if (subscription != null) { + subscription.cancel(); + } + throw new IllegalStateException(throwable); + } + + @Override + public void onComplete() { + } + } +} diff --git a/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/AgentA2AProtocolSupportTest.java b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/AgentA2AProtocolSupportTest.java new file mode 100644 index 0000000..c3e96c8 --- /dev/null +++ b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/AgentA2AProtocolSupportTest.java @@ -0,0 +1,119 @@ +package io.dscope.camel.agent.a2a; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.kernel.InMemoryPersistenceFacade; +import io.dscope.camel.agent.runtime.A2ARuntimeProperties; +import io.dscope.camel.agent.runtime.AgentPlanSelectionResolver; +import io.dscope.camel.a2a.A2AComponentApplicationSupport; +import io.dscope.camel.a2a.model.Message; +import io.dscope.camel.a2a.model.Part; +import io.dscope.camel.a2a.model.Task; +import io.dscope.camel.a2a.model.dto.SendMessageRequest; +import io.dscope.camel.a2a.service.A2APushNotificationConfigService; +import io.dscope.camel.a2a.service.A2ATaskService; +import io.dscope.camel.a2a.service.InMemoryA2ATaskService; +import io.dscope.camel.a2a.service.InMemoryPushNotificationConfigService; +import io.dscope.camel.a2a.service.InMemoryTaskEventService; +import io.dscope.camel.a2a.service.TaskEventService; +import io.dscope.camel.a2a.service.WebhookPushNotificationNotifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import org.apache.camel.main.Main; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AgentA2AProtocolSupportTest { + + @TempDir + Path tempDir; + + @Test + void reusesPreboundSharedTaskInfrastructure() throws Exception { + Path plansConfig = tempDir.resolve("agents.yaml"); + Files.writeString(plansConfig, """ + plans: + - name: support + default: true + versions: + - version: v1 + default: true + blueprint: classpath:agents/support/agent.md + """); + Path exposedAgentsConfig = tempDir.resolve("a2a-agents.yaml"); + Files.writeString(exposedAgentsConfig, """ + agents: + - agentId: support-public + name: Support Public Agent + description: Handles support requests + defaultAgent: true + version: 1.0.0 + planName: support + planVersion: v1 + """); + + Main main = new Main(); + InMemoryTaskEventService taskEventService = new InMemoryTaskEventService(); + A2ATaskService taskService = new InMemoryA2ATaskService(taskEventService); + A2APushNotificationConfigService pushConfigService = + new InMemoryPushNotificationConfigService(new WebhookPushNotificationNotifier()); + taskEventService.addListener(pushConfigService::onTaskEvent); + + main.bind(A2AComponentApplicationSupport.BEAN_TASK_EVENT_SERVICE, taskEventService); + main.bind(A2AComponentApplicationSupport.BEAN_TASK_SERVICE, taskService); + main.bind(A2AComponentApplicationSupport.BEAN_PUSH_CONFIG_SERVICE, pushConfigService); + + InMemoryPersistenceFacade persistence = new InMemoryPersistenceFacade(); + ObjectMapper objectMapper = new ObjectMapper(); + AgentPlanSelectionResolver planSelectionResolver = new AgentPlanSelectionResolver(persistence, objectMapper); + + AgentA2AProtocolSupport.bindIfEnabled( + main, + new Properties(), + new A2ARuntimeProperties( + true, + "0.0.0.0", + 8080, + "http://localhost:8080", + "/a2a/rpc", + "/a2a/sse", + "/.well-known/agent-card.json", + "direct:agent", + exposedAgentsConfig.toString(), + plansConfig.toString(), + "classpath:agents/support/agent.md" + ), + persistence, + planSelectionResolver, + objectMapper + ); + + assertSame(taskEventService, main.lookup(A2AComponentApplicationSupport.BEAN_TASK_EVENT_SERVICE, TaskEventService.class)); + assertSame(taskService, main.lookup(A2AComponentApplicationSupport.BEAN_TASK_SERVICE, A2ATaskService.class)); + assertSame(pushConfigService, main.lookup(A2AComponentApplicationSupport.BEAN_PUSH_CONFIG_SERVICE, A2APushNotificationConfigService.class)); + + SendMessageRequest request = new SendMessageRequest(); + request.setMessage(message("user", "Check shared task")); + Task created = taskService.sendMessage(request); + + A2ATaskService boundTaskService = main.lookup(A2AComponentApplicationSupport.BEAN_TASK_SERVICE, A2ATaskService.class); + assertEquals(created.getTaskId(), boundTaskService.getTask(created.getTaskId()).getTaskId()); + } + + private static Message message(String role, String text) { + Part part = new Part(); + part.setType("text"); + part.setMimeType("text/plain"); + part.setText(text); + + Message message = new Message(); + message.setRole(role); + message.setParts(List.of(part)); + message.setMessageId(role + "-message-" + text.hashCode()); + return message; + } +} diff --git a/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/AgentA2ATaskAdapterTest.java b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/AgentA2ATaskAdapterTest.java new file mode 100644 index 0000000..d735fc5 --- /dev/null +++ b/camel-agent-core/src/test/java/io/dscope/camel/agent/a2a/AgentA2ATaskAdapterTest.java @@ -0,0 +1,110 @@ +package io.dscope.camel.agent.a2a; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.kernel.InMemoryPersistenceFacade; +import io.dscope.camel.a2a.model.Message; +import io.dscope.camel.a2a.model.Part; +import io.dscope.camel.a2a.model.Task; +import io.dscope.camel.a2a.model.TaskState; +import io.dscope.camel.a2a.model.dto.SendMessageRequest; +import io.dscope.camel.a2a.service.InMemoryA2ATaskService; +import io.dscope.camel.a2a.service.InMemoryTaskEventService; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AgentA2ATaskAdapterTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void acceptsCompletesAndAuditsUsingSharedTaskService() { + InMemoryTaskEventService taskEventService = new InMemoryTaskEventService(); + InMemoryA2ATaskService taskService = new InMemoryA2ATaskService(taskEventService); + InMemoryPersistenceFacade persistence = new InMemoryPersistenceFacade(); + AgentA2ATaskAdapter adapter = new AgentA2ATaskAdapter(taskService, persistence, objectMapper); + + SendMessageRequest request = new SendMessageRequest(); + request.setIdempotencyKey("idem-1"); + request.setMessage(message("user", "Need ticket update")); + request.setMetadata(Map.of("source", "non-agent", "camelAgent", Map.of("origin", "shared"))); + + Task accepted = adapter.accept(request, agentMetadata("conv-1")); + + assertEquals(TaskState.RUNNING, accepted.getStatus().getState()); + assertEquals("support-public", accepted.getMetadata().get("agentId")); + assertEquals("conv-1", nestedText(accepted, "camelAgent", "localConversationId")); + assertEquals("shared", nestedText(accepted, "camelAgent", "origin")); + + Task completed = adapter.complete( + accepted, + message("assistant", "Ticket updated"), + agentMetadata("conv-1"), + "Agent completed task" + ); + + assertEquals(TaskState.COMPLETED, completed.getStatus().getState()); + assertEquals("true", nestedText(completed, "camelAgent", "responseCompleted")); + + adapter.appendConversationEvent("conv-1", completed.getTaskId(), "conversation.a2a.response.completed", Map.of("status", "ok")); + assertEquals(1, persistence.loadConversation("conv-1", 10).size()); + } + + @Test + void reusesSharedTaskServiceIdempotency() { + InMemoryTaskEventService taskEventService = new InMemoryTaskEventService(); + InMemoryA2ATaskService taskService = new InMemoryA2ATaskService(taskEventService); + AgentA2ATaskAdapter adapter = new AgentA2ATaskAdapter(taskService, new InMemoryPersistenceFacade(), objectMapper); + + SendMessageRequest request = new SendMessageRequest(); + request.setIdempotencyKey("idem-2"); + request.setMessage(message("user", "Open ticket")); + + Task first = adapter.accept(request, agentMetadata("conv-2")); + Task second = adapter.accept(request, agentMetadata("conv-2")); + + assertEquals(first.getTaskId(), second.getTaskId()); + assertSame(taskService.getTask(first.getTaskId()), taskService.getTask(second.getTaskId())); + assertTrue(taskService.listTasks(null).size() >= 1); + } + + private static Map agentMetadata(String conversationId) { + Map camelAgent = new LinkedHashMap<>(); + camelAgent.put("localConversationId", conversationId); + camelAgent.put("linkedConversationId", conversationId); + camelAgent.put("planName", "support"); + camelAgent.put("planVersion", "v1"); + Map metadata = new LinkedHashMap<>(); + metadata.put("camelAgent", camelAgent); + metadata.put("agentId", "support-public"); + return metadata; + } + + private static Message message(String role, String text) { + Part part = new Part(); + part.setType("text"); + part.setMimeType("text/plain"); + part.setText(text); + + Message message = new Message(); + message.setRole(role); + message.setParts(List.of(part)); + message.setMessageId(role + "-message-" + text.hashCode()); + return message; + } + + @SuppressWarnings("unchecked") + private static String nestedText(Task task, String topLevelKey, String nestedKey) { + Object topLevel = task.getMetadata().get(topLevelKey); + if (!(topLevel instanceof Map map)) { + return ""; + } + Object value = ((Map) map).get(nestedKey); + return value == null ? "" : String.valueOf(value); + } +} diff --git a/camel-agent-core/src/test/java/io/dscope/camel/agent/runtime/TicketLifecycleProcessorTest.java b/camel-agent-core/src/test/java/io/dscope/camel/agent/runtime/TicketLifecycleProcessorTest.java new file mode 100644 index 0000000..f210336 --- /dev/null +++ b/camel-agent-core/src/test/java/io/dscope/camel/agent/runtime/TicketLifecycleProcessorTest.java @@ -0,0 +1,50 @@ +package io.dscope.camel.agent.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.config.AgentHeaders; +import java.util.Map; +import org.apache.camel.Exchange; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.support.DefaultExchange; +import org.junit.jupiter.api.Test; + +class TicketLifecycleProcessorTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void keepsTicketIdStableAcrossConversationLifecycle() throws Exception { + TicketLifecycleProcessor processor = new TicketLifecycleProcessor(objectMapper); + + try (DefaultCamelContext context = new DefaultCamelContext()) { + context.start(); + + JsonNode created = invoke(processor, context, "child-conv-1", Map.of("query", "Please open a support ticket for login errors")); + JsonNode updated = invoke(processor, context, "child-conv-1", Map.of("query", "Please update the ticket with my latest screenshot")); + JsonNode closed = invoke(processor, context, "child-conv-1", Map.of("query", "Please close the ticket now")); + + assertEquals(created.path("ticketId").asText(), updated.path("ticketId").asText()); + assertEquals(created.path("ticketId").asText(), closed.path("ticketId").asText()); + assertEquals("OPEN", created.path("status").asText()); + assertEquals("IN_PROGRESS", updated.path("status").asText()); + assertEquals("CLOSED", closed.path("status").asText()); + assertTrue(updated.path("notifyClient").asBoolean()); + assertTrue(closed.path("notifyClient").asBoolean()); + } + } + + private JsonNode invoke(TicketLifecycleProcessor processor, + DefaultCamelContext context, + String conversationId, + Map body) throws Exception { + Exchange exchange = new DefaultExchange(context); + exchange.getIn().setHeader(AgentHeaders.CONVERSATION_ID, conversationId); + exchange.getIn().setBody(body); + processor.process(exchange); + return objectMapper.readTree(exchange.getMessage().getBody(String.class)); + } +} diff --git a/camel-agent-persistence-dscope/pom.xml b/camel-agent-persistence-dscope/pom.xml index 95b01e2..be3dc01 100644 --- a/camel-agent-persistence-dscope/pom.xml +++ b/camel-agent-persistence-dscope/pom.xml @@ -6,7 +6,7 @@ io.dscope.camel camel-agent - 0.5.0 + 0.6.0 camel-agent-persistence-dscope diff --git a/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFacade.java b/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFacade.java index 4e3ce09..78088a1 100644 --- a/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFacade.java +++ b/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFacade.java @@ -335,8 +335,22 @@ private ObjectNode correlationMetadata(String conversationId) { String aguiSessionId = registry.resolve(conversationId, CorrelationKeys.AGUI_SESSION_ID, null); String aguiRunId = registry.resolve(conversationId, CorrelationKeys.AGUI_RUN_ID, null); String aguiThreadId = registry.resolve(conversationId, CorrelationKeys.AGUI_THREAD_ID, null); - - if (isBlank(aguiSessionId) && isBlank(aguiRunId) && isBlank(aguiThreadId)) { + String a2aAgentId = registry.resolve(conversationId, CorrelationKeys.A2A_AGENT_ID, null); + String a2aRemoteConversationId = registry.resolve(conversationId, CorrelationKeys.A2A_REMOTE_CONVERSATION_ID, null); + String a2aRemoteTaskId = registry.resolve(conversationId, CorrelationKeys.A2A_REMOTE_TASK_ID, null); + String a2aLinkedConversationId = registry.resolve(conversationId, CorrelationKeys.A2A_LINKED_CONVERSATION_ID, null); + String a2aParentConversationId = registry.resolve(conversationId, CorrelationKeys.A2A_PARENT_CONVERSATION_ID, null); + String a2aRootConversationId = registry.resolve(conversationId, CorrelationKeys.A2A_ROOT_CONVERSATION_ID, null); + + if (isBlank(aguiSessionId) + && isBlank(aguiRunId) + && isBlank(aguiThreadId) + && isBlank(a2aAgentId) + && isBlank(a2aRemoteConversationId) + && isBlank(a2aRemoteTaskId) + && isBlank(a2aLinkedConversationId) + && isBlank(a2aParentConversationId) + && isBlank(a2aRootConversationId)) { return null; } @@ -350,6 +364,24 @@ private ObjectNode correlationMetadata(String conversationId) { if (!isBlank(aguiThreadId)) { correlation.put("aguiThreadId", aguiThreadId); } + if (!isBlank(a2aAgentId)) { + correlation.put("a2aAgentId", a2aAgentId); + } + if (!isBlank(a2aRemoteConversationId)) { + correlation.put("a2aRemoteConversationId", a2aRemoteConversationId); + } + if (!isBlank(a2aRemoteTaskId)) { + correlation.put("a2aRemoteTaskId", a2aRemoteTaskId); + } + if (!isBlank(a2aLinkedConversationId)) { + correlation.put("a2aLinkedConversationId", a2aLinkedConversationId); + } + if (!isBlank(a2aParentConversationId)) { + correlation.put("a2aParentConversationId", a2aParentConversationId); + } + if (!isBlank(a2aRootConversationId)) { + correlation.put("a2aRootConversationId", a2aRootConversationId); + } return correlation; } diff --git a/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFactory.java b/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFactory.java index b70e7fb..064bb4e 100644 --- a/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFactory.java +++ b/camel-agent-persistence-dscope/src/main/java/io/dscope/camel/agent/persistence/dscope/DscopePersistenceFactory.java @@ -23,7 +23,7 @@ public static PersistenceFacade create(Properties properties, ObjectMapper objec Properties effective = new Properties(); effective.putAll(properties); effective.putIfAbsent("camel.persistence.enabled", "true"); - effective.putIfAbsent("camel.persistence.backend", "redis_jdbc"); + effective.put("camel.persistence.backend", normalizeBackend(effective.getProperty("camel.persistence.backend", "redis_jdbc"))); effective.putIfAbsent("agent.audit.granularity", "info"); Properties auditEffective = buildAuditPersistenceProperties(effective); @@ -44,7 +44,7 @@ static FlowStateStore createFlowStateStore(PersistenceConfiguration configuratio } PersistenceBackend backend = configuration.backend(); - if (backend == PersistenceBackend.JDBC || backend == PersistenceBackend.REDIS_JDBC) { + if (isJdbcBacked(backend)) { String ddlResource = resolveSchemaDdlResource(effective, configuration.jdbcUrl()); if (ddlResource != null && !ddlResource.isBlank()) { return new ScriptedJdbcFlowStateStore( @@ -59,6 +59,23 @@ static FlowStateStore createFlowStateStore(PersistenceConfiguration configuratio return FlowStateStoreFactory.create(configuration); } + private static boolean isJdbcBacked(PersistenceBackend backend) { + if (backend == null) { + return false; + } + return backend == PersistenceBackend.JDBC || "REDIS_JDBC".equals(backend.name()); + } + + private static String normalizeBackend(String backend) { + if (backend == null || backend.isBlank()) { + return "jdbc"; + } + if ("redis_jdbc".equalsIgnoreCase(backend) || "redis-jdbc".equalsIgnoreCase(backend)) { + return "jdbc"; + } + return backend; + } + static String resolveSchemaDdlResource(Properties effective, String jdbcUrl) { if (effective != null) { String override = effective.getProperty(SCHEMA_DDL_RESOURCE_PROPERTY); diff --git a/camel-agent-spring-ai/pom.xml b/camel-agent-spring-ai/pom.xml index 1417e25..748fbf0 100644 --- a/camel-agent-spring-ai/pom.xml +++ b/camel-agent-spring-ai/pom.xml @@ -6,7 +6,7 @@ io.dscope.camel camel-agent - 0.5.0 + 0.6.0 camel-agent-spring-ai diff --git a/camel-agent-spring-ai/src/main/java/io/dscope/camel/agent/springai/DscopeChatMemoryRepositoryFactory.java b/camel-agent-spring-ai/src/main/java/io/dscope/camel/agent/springai/DscopeChatMemoryRepositoryFactory.java index 220f549..2bdffca 100644 --- a/camel-agent-spring-ai/src/main/java/io/dscope/camel/agent/springai/DscopeChatMemoryRepositoryFactory.java +++ b/camel-agent-spring-ai/src/main/java/io/dscope/camel/agent/springai/DscopeChatMemoryRepositoryFactory.java @@ -15,8 +15,18 @@ public static ChatMemoryRepository create(Properties properties, ObjectMapper ob Properties effective = new Properties(); effective.putAll(properties); effective.putIfAbsent("camel.persistence.enabled", "true"); - effective.putIfAbsent("camel.persistence.backend", "redis_jdbc"); + effective.put("camel.persistence.backend", normalizeBackend(effective.getProperty("camel.persistence.backend", "redis_jdbc"))); PersistenceConfiguration configuration = PersistenceConfiguration.fromProperties(effective); return new DscopeChatMemoryRepository(FlowStateStoreFactory.create(configuration), objectMapper); } + + private static String normalizeBackend(String backend) { + if (backend == null || backend.isBlank()) { + return "jdbc"; + } + if ("redis_jdbc".equalsIgnoreCase(backend) || "redis-jdbc".equalsIgnoreCase(backend)) { + return "jdbc"; + } + return backend; + } } diff --git a/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/OpenAiRealtimeResponsesGatewayTest.java b/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/OpenAiRealtimeResponsesGatewayTest.java index 114dca6..9ec22cd 100644 --- a/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/OpenAiRealtimeResponsesGatewayTest.java +++ b/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/OpenAiRealtimeResponsesGatewayTest.java @@ -52,7 +52,7 @@ void shouldReturnTextAndToolCallsFromRealtimeEvents() { Assertions.assertTrue(result.message().contains("hello")); Assertions.assertFalse(streamed.isEmpty()); Assertions.assertTrue(relay.closed); - Assertions.assertEquals("wss://api.openai.com/v1/realtime", relay.lastEndpointUri); + Assertions.assertEquals("wss://api.openai.com/v1/responses", relay.lastEndpointUri); Assertions.assertEquals("gpt-realtime", relay.lastModel); } @@ -116,7 +116,7 @@ void shouldUseConfiguredEndpointAndModelOverrides() { try { JsonNode node = MAPPER.readTree(eventJson); return "response.create".equals(node.path("type").asText()) - && "medium".equals(node.path("response").path("reasoning_effort").asText()); + && "medium".equals(node.path("reasoning").path("effort").asText()); } catch (Exception ignored) { return false; } diff --git a/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/ResponsesWsMemoryIntegrationTest.java b/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/ResponsesWsMemoryIntegrationTest.java index 925e737..ab98fc4 100644 --- a/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/ResponsesWsMemoryIntegrationTest.java +++ b/camel-agent-spring-ai/src/test/java/io/dscope/camel/agent/springai/ResponsesWsMemoryIntegrationTest.java @@ -114,7 +114,7 @@ public SpringAiChatGateway.SpringAiChatResult generate(String apiMode, Assertions.assertEquals("Knowledge base result for " + firstPrompt, first.message()); Assertions.assertEquals("Support ticket created successfully", second.message()); - Assertions.assertTrue(secondTurnContext.get().contains("tool.result")); + Assertions.assertTrue(secondTurnContext.get().contains("[Tool result:")); Assertions.assertTrue(secondTurnContext.get().contains("Knowledge base result for " + firstPrompt)); List conversation = persistence.loadConversation("conv-responses-ws-memory", 100); diff --git a/camel-agent-starter/pom.xml b/camel-agent-starter/pom.xml index 2d0f903..54f48de 100644 --- a/camel-agent-starter/pom.xml +++ b/camel-agent-starter/pom.xml @@ -6,7 +6,7 @@ io.dscope.camel camel-agent - 0.5.0 + 0.6.0 camel-agent-starter diff --git a/camel-agent-starter/src/main/java/io/dscope/camel/agent/starter/AgentAutoConfiguration.java b/camel-agent-starter/src/main/java/io/dscope/camel/agent/starter/AgentAutoConfiguration.java index bce9825..c1c24ea 100644 --- a/camel-agent-starter/src/main/java/io/dscope/camel/agent/starter/AgentAutoConfiguration.java +++ b/camel-agent-starter/src/main/java/io/dscope/camel/agent/starter/AgentAutoConfiguration.java @@ -7,6 +7,7 @@ import io.dscope.camel.agent.api.PersistenceFacade; import io.dscope.camel.agent.api.ToolExecutor; import io.dscope.camel.agent.api.ToolRegistry; +import io.dscope.camel.agent.a2a.A2AToolContext; import io.dscope.camel.agent.blueprint.MarkdownBlueprintLoader; import io.dscope.camel.agent.executor.CamelToolExecutor; import io.dscope.camel.agent.kernel.DefaultAgentKernel; @@ -51,7 +52,7 @@ public ObjectMapper objectMapper() { public ChatMemoryRepository chatMemoryRepository(AgentStarterProperties properties, ObjectMapper objectMapper) { Properties config = new Properties(); config.setProperty("camel.persistence.enabled", "true"); - config.setProperty("camel.persistence.backend", properties.getPersistenceMode()); + config.setProperty("camel.persistence.backend", normalizePersistenceBackend(properties.getPersistenceMode())); config.setProperty("agent.audit.granularity", properties.getAuditGranularity()); return DscopeChatMemoryRepositoryFactory.create(config, objectMapper); } @@ -84,7 +85,7 @@ public AiModelClient aiModelClient(ObjectMapper objectMapper) { public PersistenceFacade persistenceFacade(AgentStarterProperties properties, ObjectMapper objectMapper) { Properties config = new Properties(); config.setProperty("camel.persistence.enabled", "true"); - config.setProperty("camel.persistence.backend", properties.getPersistenceMode()); + config.setProperty("camel.persistence.backend", normalizePersistenceBackend(properties.getPersistenceMode())); config.setProperty("agent.audit.granularity", properties.getAuditGranularity()); copyIfPresent(config, "agent.audit.backend", properties.getAuditPersistenceBackend()); copyIfPresent(config, "agent.audit.jdbc.url", properties.getAuditJdbcUrl()); @@ -106,6 +107,16 @@ private void copyIfPresent(Properties config, String key, String value) { } } + private String normalizePersistenceBackend(String backend) { + if (backend == null || backend.isBlank()) { + return "jdbc"; + } + if ("redis_jdbc".equalsIgnoreCase(backend) || "redis-jdbc".equalsIgnoreCase(backend)) { + return "jdbc"; + } + return backend; + } + @Bean @ConditionalOnBean(CamelContext.class) @ConditionalOnMissingBean @@ -117,21 +128,22 @@ public AgentKernel agentKernel(CamelContext camelContext, AgentStarterProperties properties, ObjectMapper objectMapper) { String blueprintLocation = properties.getBlueprint(); + ResolvedAgentPlan resolvedPlan = null; if (properties.getAgentsConfig() != null && !properties.getAgentsConfig().isBlank()) { - ResolvedAgentPlan resolved = planSelectionResolver.resolve( + resolvedPlan = planSelectionResolver.resolve( null, null, null, properties.getAgentsConfig(), properties.getBlueprint() ); - blueprintLocation = resolved.blueprint(); + blueprintLocation = resolvedPlan.blueprint(); } AgentBlueprint loadedBlueprint = blueprintLoader.load(blueprintLocation); ProducerTemplate producerTemplate = camelContext.createProducerTemplate(); AgentBlueprint blueprint = McpToolDiscoveryResolver.resolve(loadedBlueprint, producerTemplate, objectMapper); ToolRegistry toolRegistry = new DefaultToolRegistry(blueprint.tools()); - ToolExecutor toolExecutor = createToolExecutor(camelContext, producerTemplate, objectMapper, blueprint); + ToolExecutor toolExecutor = createToolExecutor(camelContext, producerTemplate, objectMapper, blueprint, persistenceFacade, resolvedPlan); return new DefaultAgentKernel( blueprint, toolRegistry, @@ -148,15 +160,41 @@ public AgentKernel agentKernel(CamelContext camelContext, private ToolExecutor createToolExecutor(CamelContext camelContext, ProducerTemplate producerTemplate, ObjectMapper objectMapper, - AgentBlueprint blueprint) { + AgentBlueprint blueprint, + PersistenceFacade persistenceFacade, + ResolvedAgentPlan resolvedPlan) { try { java.util.List templates = readJsonRouteTemplates(blueprint); Class type = Class.forName("io.dscope.camel.agent.executor.TemplateAwareCamelToolExecutor"); return (ToolExecutor) type - .getConstructor(CamelContext.class, ProducerTemplate.class, ObjectMapper.class, java.util.List.class) - .newInstance(camelContext, producerTemplate, objectMapper, templates); + .getConstructor(CamelContext.class, ProducerTemplate.class, ObjectMapper.class, java.util.List.class, + PersistenceFacade.class, A2AToolContext.class) + .newInstance( + camelContext, + producerTemplate, + objectMapper, + templates, + persistenceFacade, + new A2AToolContext( + resolvedPlan == null || resolvedPlan.legacyMode() ? "" : resolvedPlan.planName(), + resolvedPlan == null || resolvedPlan.legacyMode() ? "" : resolvedPlan.planVersion(), + blueprint.name() == null ? "" : blueprint.name(), + blueprint.version() == null ? "" : blueprint.version() + ) + ); } catch (Exception ignored) { - return new CamelToolExecutor(producerTemplate, objectMapper); + return new CamelToolExecutor( + camelContext, + producerTemplate, + objectMapper, + persistenceFacade, + new A2AToolContext( + resolvedPlan == null || resolvedPlan.legacyMode() ? "" : resolvedPlan.planName(), + resolvedPlan == null || resolvedPlan.legacyMode() ? "" : resolvedPlan.planVersion(), + blueprint.name() == null ? "" : blueprint.name(), + blueprint.version() == null ? "" : blueprint.version() + ) + ); } } diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 1e7211a..8bee3f2 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -34,13 +34,26 @@ - `shouldNotLogVoiceBranchStartedWhenContextUpdateFails`: verifies `updated=false` + skip checkpoints and absence of voice-branch-start log when realtime context update is not applied - `shouldInitializeSessionStartPayloadWhenNoStoredContextExists`: verifies dropped/recreated realtime sessions initialize from incoming `session.start` payload + defaults when no prior context exists - `shouldRestoreRealtimeSessionContextOnSessionStartReconnect`: verifies dropped/recreated realtime sessions are rehydrated with full stored context plus incoming overrides on `session.start` +12. `A2AExposedAgentCatalogLoaderTest` + - validates exposed A2A agent catalog + - enforces one default public A2A agent +13. `AgentA2ATaskAdapterTest` + - adds agent correlation metadata on top of shared `A2ATaskService` + - preserves shared-service idempotency behavior + - appends audit events without owning task persistence +14. `AgentA2AProtocolSupportTest` + - reuses pre-bound shared `a2aTaskService` / `a2aTaskEventService` / `a2aPushConfigService` + - confirms agent runtime does not create a private A2A task space when shared services already exist +15. `A2AToolClientTest` + - outbound `a2a:` tool invocation + - remote task correlation reuse for follow-up calls ## Integration 1. `SpringAiAuditTrailIntegrationTest` - two-turn deterministic scenario: - prompt 1 selects `kb.search` - - prompt 2 selects `support.ticket.open` + - prompt 2 selects `support.ticket.manage` - second prompt evaluation includes first-turn KB result - negative scenario: - ticket as first prompt does not inject KB result context @@ -50,6 +63,14 @@ 3. `redis_jdbc` fallback behavior with Redis miss and JDBC success 4. Restart rehydration scenario for unfinished task 5. AGUI sample transport flow (`/agui/ui` -> `POST /agui/agent` POST+SSE) +6. A2A sample flow + - direct `POST /a2a/rpc` `SendMessage` creates ticket through exposed public A2A service + - follow-up `SendMessage` with same linked conversation updates or closes the same ticket + - `GET /.well-known/agent-card.json` exposes only configured public A2A agents +7. AGUI -> agent -> outbound A2A tool -> ticketing service flow + - support assistant selects `support.ticket.manage` + - outbound A2A audit event is recorded + - assistant output reflects ticket service result 6. Realtime voice UI behavior (`/agui/ui`): - single-toggle start/stop state transitions (`idle`/`live`/`busy`) - VAD pause profile mapping (`fast=800`, `normal=1200`, `patient=1800`) reflected in UI label/status @@ -62,7 +83,7 @@ - assert-style positive check (auto-fail unless `200`): `sid=voice-sync-$RANDOM; curl -s -X POST "http://localhost:8080/realtime/session/$sid/init" -H 'Content-Type: application/json' -d '{"session":{"audio":{"output":{"voice":"cedar"}}}}' >/dev/null; code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:8080/realtime/session/$sid/token" -H 'Content-Type: application/json' -d '{}'); [ "$code" = "200" ] || { echo "expected 200, got $code"; exit 1; }` - assert-style negative check (auto-fail unless `400`): `sid=voice-sync-$RANDOM; curl -s -X POST "http://localhost:8080/realtime/session/$sid/init" -H 'Content-Type: application/json' -d '{"session":{"audio":{"output":{"voice":"nova"}}}}' >/dev/null; code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:8080/realtime/session/$sid/token" -H 'Content-Type: application/json' -d '{}'); [ "$code" = "400" ] || { echo "expected 400, got $code"; exit 1; }` - script alias (positive+negative in one run): `bash scripts/realtime-voice-token-check.sh` (optional args: `bash scripts/realtime-voice-token-check.sh http://localhost:8080 cedar nova`) -7. Realtime event ordering and context-update validation (scenario matrix aligned): +8. Realtime event ordering and context-update validation (scenario matrix aligned): - text-only AGUI path: - verify `user.message` is persisted before tool/assistant lifecycle events - verify final `assistant.message` and `snapshot.written` are emitted in order @@ -93,7 +114,7 @@ Use these during integration test runs (for example with logs in `/tmp/agent-sup 6. AGUI pre-run fallback decisions: - `grep -nE 'AGUI pre-run started|AGUI pre-run primary agent response|AGUI pre-run fallback triggered|AGUI pre-run deterministic fallback route|AGUI pre-run completed' /tmp/agent-support-live.log` 7. End-to-end voice-to-ticket trace: - - `grep -nE 'Realtime transcript received|Realtime transcript routed|Kernel realtime transcript routing|Kernel tool execution started:.*support.ticket.open|Kernel tool execution completed:.*support.ticket.open|Kernel final assistant message ready' /tmp/agent-support-live.log` + - `grep -nE 'Realtime transcript received|Realtime transcript routed|Kernel realtime transcript routing|Kernel tool execution started:.*support.ticket.manage|Kernel tool execution completed:.*support.ticket.manage|Kernel final assistant message ready' /tmp/agent-support-live.log` 8. Single-command triage (sorted, timestamped signal lines): - `grep -nE 'Realtime relay|Realtime event received|Realtime transcript|Kernel |AGUI pre-run|Agent runtime bootstrap' /tmp/agent-support-live.log | sort -t: -k1,1n` 9. Live monitoring (low-noise follow): @@ -103,4 +124,52 @@ Use these during integration test runs (for example with logs in `/tmp/agent-sup 11. Live monitoring with colorized source tags: - `tail -f /tmp/agent-support-live.log | awk 'BEGIN{reset="\033[0m"; cRelay="\033[36m"; cKernel="\033[35m"; cAgui="\033[33m"; cBoot="\033[32m"; cRt="\033[34m"} /Realtime relay/{print cRelay "[relay] " $0 reset; next} /Kernel /{print cKernel "[kernel] " $0 reset; next} /AGUI pre-run/{print cAgui "[agui] " $0 reset; next} /Agent runtime bootstrap/{print cBoot "[bootstrap] " $0 reset; next} /Realtime (event received|transcript)/{print cRt "[realtime] " $0 reset; next}'` 12. Non-ANSI fallback (CI/plain logs): - - `tail -f /tmp/agent-support-live.log | awk '/Realtime relay/{print "[relay] " $0; next} /Kernel /{print "[kernel] " $0; next} /AGUI pre-run/{print "[agui] " $0; next} /Agent runtime bootstrap/{print "[bootstrap] " $0; next} /Realtime (event received|transcript)/{print "[realtime] " $0; next}'` \ No newline at end of file + - `tail -f /tmp/agent-support-live.log | awk '/Realtime relay/{print "[relay] " $0; next} /Kernel /{print "[kernel] " $0; next} /AGUI pre-run/{print "[agui] " $0; next} /Agent runtime bootstrap/{print "[bootstrap] " $0; next} /Realtime (event received|transcript)/{print "[realtime] " $0; next}'` + +## A2A Manual Smoke + +1. Start sample with a live model or the demo gateway: + +```bash +./mvnw -q -f samples/agent-support-service/pom.xml \ + -Dagent.runtime.spring-ai.gateway-class=io.dscope.camel.agent.samples.DemoA2ATicketGateway \ + -Dagent.runtime.routes-include-pattern=classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ticket-service.camel.yaml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/admin-platform.camel.yaml \ + exec:java +``` + +2. Confirm health and agent card: + +```bash +curl -sS http://localhost:8080/health +curl -sS http://localhost:8080/.well-known/agent-card.json +``` + +3. Open ticket through direct A2A: + +```bash +curl -sS -X POST http://localhost:8080/a2a/rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"conversationId":"demo-a2a-1","message":{"messageId":"m1","role":"user","parts":[{"partId":"p1","type":"text","mimeType":"text/plain","text":"Please open a support ticket for my login issue"}]},"metadata":{"agentId":"support-ticket-service"}}}' +``` + +4. Reuse returned `linkedConversationId` to close the same ticket: + +```bash +curl -sS -X POST http://localhost:8080/a2a/rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"2","method":"SendMessage","params":{"conversationId":"demo-a2a-1","message":{"messageId":"m2","role":"user","parts":[{"partId":"p2","type":"text","mimeType":"text/plain","text":"Please close the ticket now"}]},"metadata":{"agentId":"support-ticket-service","linkedConversationId":""}}}' +``` + +5. Drive the same flow through AGUI: + +```bash +curl -sS -X POST http://localhost:8080/agui/agent \ + -H 'Content-Type: application/json' \ + -d '{"threadId":"demo-agui-1","sessionId":"demo-agui-1","planName":"support","planVersion":"v1","messages":[{"role":"user","content":"Please open a support ticket for my login issue"}]}' +``` + +6. Confirm audit recorded outbound A2A: + +```bash +curl -sS 'http://localhost:8080/audit/search?conversationId=demo-agui-1&limit=100' +``` diff --git a/docs/architecture.md b/docs/architecture.md index ba0e733..b24516a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,7 @@ Plan-aware runtime behavior: - first successful resolution appends `conversation.plan.selected` - later explicit overrides append a new `conversation.plan.selected` - audit and runtime refresh derive active blueprint from persisted plan-selection events instead of assuming one global blueprint +- A2A-exposed public agents are mapped separately from the internal plan catalog Runtime bootstrap also binds mutable operational controls and optional archive services: @@ -67,6 +68,63 @@ Internal request headers: `camel-agent-persistence-dscope` maps these flows to `FlowStateStore` operations. +## A2A Integration + +Camel Agent uses `camel-a2a-component` as the protocol/runtime layer and keeps agent-specific behavior on top. + +Agent-side responsibilities: + +- exposed-agent mapping (`agent.runtime.a2a.exposed-agents-config`) +- mapping public A2A agent ids to local `{planName, planVersion}` +- parent/root/linked conversation correlation +- agent audit events and routed-back parent notifications +- AGUI/A2A correlation metadata + +Generic A2A responsibilities delegated to `camel-a2a-component`: + +- task lifecycle +- idempotency +- task event streaming +- push notification config handling +- optional persistence-backed A2A task/event stores +- JSON-RPC envelope, dispatch, SSE, and agent-card plumbing + +Inbound A2A request flow: + +1. `POST /a2a/rpc` receives a core A2A method. +2. `SendMessage` / `SendStreamingMessage` resolve a public A2A agent id from exposed-agent config. +3. The public agent maps to a local `planName` / `planVersion`. +4. Runtime creates or reuses a linked local conversation for the A2A task. +5. The selected local plan is invoked through `agent:`. +6. Result is written back into the shared A2A task service and audit trail. + +Outbound A2A tool flow: + +1. A local agent selects a tool with `endpointUri: a2a:...`. +2. `A2AToolClient` sends an A2A JSON-RPC request to the remote agent. +3. Remote task id / remote conversation id / linked local conversation id are persisted as correlation. +4. Audit records outbound start/completion and linked conversation metadata. + +### Shared Task/Session Model + +Camel Agent does not require a private A2A task store. + +Startup behavior: + +- if `a2aTaskService`, `a2aTaskEventService`, and `a2aPushConfigService` already exist in the Camel registry, runtime reuses them +- otherwise runtime creates shared defaults using the same persistence configuration rules as `camel-a2a-component` + +This design keeps one A2A task/session space available to: + +- agent-backed A2A handlers +- outbound `a2a:` tools +- non-agent Camel routes that also use the same bound A2A services + +Implementation note: + +- `AgentA2ATaskAdapter` is the thin agent-side layer that adds agent metadata and audit behavior on top of the shared `A2ATaskService` +- the older duplicate agent-owned A2A task repository model is no longer the active design + ## Non-Blocking Audit Path Runtime can move audit writes off the request thread: @@ -138,6 +196,15 @@ Correlation between agent conversations and transport identifiers is handled in Debug audit trail includes available correlation metadata in payload (`payload._correlation`). +A2A correlation keys now also include: + +- `a2a.agentId` +- `a2a.remoteConversationId` +- `a2a.remoteTaskId` +- `a2a.linkedConversationId` +- `a2a.parentConversationId` +- `a2a.rootConversationId` + Plan-aware request behavior: - `POST /agui/agent` accepts top-level `planName` / `planVersion` @@ -242,6 +309,14 @@ Per-step audit projections also include the same resolved agent block so operato When async audit is enabled, these projections include both persisted and still-queued events because the read path merges pending async entries before rendering audit responses. +A2A audit additions: + +- `conversation.a2a.request.accepted` +- `conversation.a2a.response.completed` +- `conversation.a2a.outbound.started` +- `conversation.a2a.outbound.completed` +- conversation metadata projection for linked A2A conversations and remote task ids + ### Event Flow Scenarios #### 1) No Voice Agent (Text-only AGUI) diff --git a/docs/pr-drafts/a2a-runtime-integration.md b/docs/pr-drafts/a2a-runtime-integration.md new file mode 100644 index 0000000..6106c03 --- /dev/null +++ b/docs/pr-drafts/a2a-runtime-integration.md @@ -0,0 +1,91 @@ +# A2A Runtime Integration PR Draft + +## Title + +`feat(a2a): add A2A protocol integration to Camel Agent runtime` + +## Description + +## Summary + +This PR adds first-class A2A protocol support to Camel Agent by integrating the existing `camel-a2a-component` into the agent runtime. + +The goal is to let Camel Agent: +- call remote A2A agents as part of tool-driven workflows +- expose local agents over A2A using agent-card based routing +- maintain A2A task/conversation state separately from end-user conversations +- persist A2A correlation and audit metadata across inbound and outbound flows + +## What’s Included + +### Runtime and configuration +- Add `agent.runtime.a2a.*` configuration +- Add exposed A2A agent mapping config separate from `agents.yaml` +- Bind A2A runtime beans and HTTP routes during `AgentRuntimeBootstrap` + +### Inbound A2A support +- Expose: + - `POST /a2a/rpc` + - `GET /a2a/sse/{taskId}` + - `GET /.well-known/agent-card.json` +- Route inbound A2A requests through explicit public agent-card mappings +- Resolve mapped local `{planName, planVersion}` and invoke the local `agent:` component +- Create separate linked local conversations for A2A tasks + +### Outbound A2A support +- Support blueprint tools targeting `a2a:` endpoints +- Persist remote A2A conversation/task identifiers locally +- Propagate local correlation metadata for audit/debugging +- Support follow-up task operations without losing local/remote continuity + +### Audit and correlation +- Add A2A-specific correlation keys +- Record: + - inbound A2A request acceptance + - selected public A2A agent + - mapped local plan/version + - outbound remote A2A call lifecycle + - local/remote conversation and task linkage +- Extend conversation/audit views to show A2A-linked conversations + +## Design choices + +- `agents.yaml` remains the internal local plan catalog +- public A2A exposure uses a separate `agent.runtime.a2a.exposed-agents` mapping +- only explicitly exposed agents are published via agent cards +- inbound routing is based on public agent-card mapping, not payload-supplied plan selection +- A2A conversations are separate local conversations linked to a parent/root conversation when applicable +- v1 supports both A2A client and server behavior +- v1 supports the full current A2A core method surface exposed by `camel-a2a-component` + +## Test plan + +- bootstrap binds A2A runtime beans and routes when enabled +- exposed agent config validates mapped local plan/version entries +- agent-card discovery returns only explicitly exposed agents +- inbound `SendMessage` resolves correct local plan/version +- inbound A2A requests create linked local conversations +- outbound A2A tool calls persist remote task/conversation identifiers +- follow-up A2A task methods preserve local/remote correlation +- audit search/view show A2A-linked conversation metadata +- existing non-A2A AGUI/realtime/agent flows remain unchanged when A2A is disabled + +## Notes + +This PR intentionally reuses the current permissive A2A signer/verifier/policy defaults from `camel-a2a-component`. +Security hardening can follow in a later iteration once the base protocol bridge is stable. + +## Suggested Checklist + +- [ ] Added `agent.runtime.a2a.*` runtime configuration +- [ ] Added exposed-agent mapping separate from `agents.yaml` +- [ ] Bound A2A server routes in runtime bootstrap +- [ ] Added inbound agent-card to local plan/version routing +- [ ] Added outbound `a2a:` tool correlation handling +- [ ] Extended audit and conversation views with A2A metadata +- [ ] Added sample configuration and sample route coverage +- [ ] Added integration and regression tests + +## Suggested Branch Name + +- `feat/a2a-runtime-integration` diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..d72110e --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,7 @@ +# Roadmap + +## Next Version + +- A2A protocol integration for Camel Agent + Draft PR: [docs/pr-drafts/a2a-runtime-integration.md](docs/pr-drafts/a2a-runtime-integration.md) + Scope: first-class inbound and outbound A2A support, exposed-agent mapping, linked A2A conversations, and audit/correlation coverage. diff --git a/mvnw b/mvnw index 1a198cd..1fdbaf2 100755 --- a/mvnw +++ b/mvnw @@ -1,21 +1,332 @@ -#!/usr/bin/env sh -set -eu +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ]; then + + if [ -f /usr/local/etc/mavenrc ]; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ]; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ]; then + . "$HOME/.mavenrc" + fi -if [ -n "${MVN_CMD:-}" ] && [ -x "${MVN_CMD}" ]; then - exec "${MVN_CMD}" "$@" fi -if [ -n "${MAVEN_HOME:-}" ] && [ -x "${MAVEN_HOME}/bin/mvn" ]; then - exec "${MAVEN_HOME}/bin/mvn" "$@" +# OS specific support. $var _must_ be set to either true or false. +cygwin=false +darwin=false +mingw=false +case "$(uname)" in +CYGWIN*) cygwin=true ;; +MINGW*) mingw=true ;; +Darwin*) + darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)" + export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home" + export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ]; then + if [ -r /etc/gentoo-release ]; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin; then + [ -n "$JAVA_HOME" ] \ + && JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] \ + && CLASSPATH=$(cygpath --path --unix "$CLASSPATH") fi -if command -v mvn >/dev/null 2>&1; then - exec mvn "$@" +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] \ + && JAVA_HOME="$( + cd "$JAVA_HOME" || ( + echo "cannot cd into $JAVA_HOME." >&2 + exit 1 + ) + pwd + )" fi -if [ -x "/Users/roman/.sdkman/candidates/maven/current/bin/mvn" ]; then - exec "/Users/roman/.sdkman/candidates/maven/current/bin/mvn" "$@" +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "$javaExecutable" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin; then + javaHome="$(dirname "$javaExecutable")" + javaExecutable="$(cd "$javaHome" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "$javaExecutable")" + fi + javaHome="$(dirname "$javaExecutable")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi fi -echo "Error: Maven executable not found. Set MVN_CMD, MAVEN_HOME, or install Maven on PATH." >&2 -exit 1 +if [ -z "$JAVACMD" ]; then + if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$( + \unset -f command 2>/dev/null + \command -v java + )" + fi +fi + +if [ ! -x "$JAVACMD" ]; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ]; then + echo "Warning: JAVA_HOME environment variable is not set." >&2 +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ]; then + echo "Path not specified to find_maven_basedir" >&2 + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ]; do + if [ -d "$wdir"/.mvn ]; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$( + cd "$wdir/.." || exit 1 + pwd + ) + fi + # end of workaround + done + printf '%s' "$( + cd "$basedir" || exit 1 + pwd + )" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' <"$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1 +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.3.0/maven-wrapper-3.3.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.0/maven-wrapper-3.3.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in wrapperUrl) + wrapperUrl="$safeValue" + break + ;; + esac + done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget >/dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl >/dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in wrapperSha256Sum) + wrapperSha256Sum=$value + break + ;; + esac +done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum >/dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c >/dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c >/dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] \ + && JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] \ + && CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] \ + && MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 86e3e23..b694e6c 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -1,26 +1,206 @@ -@echo off -setlocal - -if defined MVN_CMD if exist "%MVN_CMD%" ( - "%MVN_CMD%" %* - exit /b %ERRORLEVEL% -) - -if defined MAVEN_HOME if exist "%MAVEN_HOME%\bin\mvn.cmd" ( - "%MAVEN_HOME%\bin\mvn.cmd" %* - exit /b %ERRORLEVEL% -) - -where mvn >nul 2>nul -if %ERRORLEVEL% EQU 0 ( - mvn %* - exit /b %ERRORLEVEL% -) - -if exist "C:\Users\roman\.sdkman\candidates\maven\current\bin\mvn.cmd" ( - "C:\Users\roman\.sdkman\candidates\maven\current\bin\mvn.cmd" %* - exit /b %ERRORLEVEL% -) - -echo Error: Maven executable not found. Set MVN_CMD or MAVEN_HOME, or install Maven on PATH. 1>&2 -exit /b 1 +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. >&2 +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. >&2 +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. >&2 +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. >&2 +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.0/maven-wrapper-3.3.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.3.0/maven-wrapper-3.3.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash;"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Error 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Error 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Error 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml index 0ee32cb..8a33924 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.dscope.camel camel-agent - 0.5.0 + 0.6.0 pom Camel Agent @@ -17,6 +17,7 @@ 2.20.0 5.10.2 1.1.0 + 1.0.0 1.0.3 3.5.11 6.2.0 diff --git a/samples/agent-support-service/README.md b/samples/agent-support-service/README.md index b7d3787..45d744a 100644 --- a/samples/agent-support-service/README.md +++ b/samples/agent-support-service/README.md @@ -3,7 +3,9 @@ Runnable Camel Main sample for the `agent:` runtime with: - Spring AI gateway mode (`agent.runtime.ai.mode=spring-ai`) -- local routes for `kb.search` and `support.ticket.open` +- multi-agent plan catalog (`support`, `billing`, `ticketing`) +- local routes for `kb.search` and ticket lifecycle state updates +- inbound and outbound A2A support for ticket management - audit trail persistence via JDBC-backed DScope persistence - AGUI component infrastructure from `io.dscope.camel:camel-ag-ui-component` - simple Copilot-style web UI (`/agui/ui`) that calls `POST /agui/agent` (AGUI POST+SSE stream response) @@ -12,7 +14,11 @@ Runnable Camel Main sample for the `agent:` runtime with: - Java 21 - Maven -- OpenAI key (for live Spring AI runs) +- OpenAI key for live Spring AI runs + +For local simulation without an API key, use the sample demo gateway: + +- `io.dscope.camel.agent.samples.DemoA2ATicketGateway` ## Configure Secrets @@ -51,9 +57,19 @@ Or use the helper script (auto-loads `.agent-secrets.properties`): samples/agent-support-service/run-sample.sh ``` +For local A2A demo mode without an API key: + +```bash +./mvnw -q -f samples/agent-support-service/pom.xml \ + -Dagent.runtime.spring-ai.gateway-class=io.dscope.camel.agent.samples.DemoA2ATicketGateway \ + -Dagent.runtime.routes-include-pattern=classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ticket-service.camel.yaml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/admin-platform.camel.yaml \ + exec:java +``` + Then open: - `http://localhost:8080/agui/ui` for the frontend +- `http://localhost:8080/audit/ui` for audit exploration - Use the single voice toggle button in the UI to start/stop mic streaming to realtime. ## Manual Verification Checklist (Web + Realtime) @@ -77,7 +93,7 @@ lsof -nP -iTCP:8080 -sTCP:LISTEN | cat 4. Web chat check: - Send: `My login is failing, please open a support ticket`. -- Expect assistant response and ticket JSON/widget behavior. +- Expect the support assistant to call the A2A ticketing service and render ticket JSON/widget behavior. 5. Realtime voice check (browser): @@ -99,7 +115,7 @@ curl -s -X POST http://localhost:8080/realtime/session/voice-check-1/event \ 7. Expected fallback behavior without valid OpenAI credentials: - AGUI pre-run falls back to deterministic local routing. -- Ticket-like prompts route to `support.ticket.open`; non-ticket prompts route to `kb.search`. +- With `DemoA2ATicketGateway`, support prompts still route through the multi-agent A2A ticket flow. Or call the same AGUI endpoint used by the frontend directly (POST+SSE stream response): @@ -115,13 +131,6 @@ curl -N -X POST http://localhost:8080/agui/agent \ When assistant text contains ticket JSON, the frontend renders a `ticket-card` widget template. -If OpenAI credentials are missing, AGUI requests automatically fall back to deterministic local routing via the AGUI pre-run processor: - -- ticket-like prompts -> `direct:support-ticket-open` -- other prompts -> `direct:kb-search` - -This keeps `/agui/ui` demo flows working offline. - ## AGUI Frontend Flow Current UI transport model in this sample: @@ -133,6 +142,13 @@ Current UI transport model in this sample: 3. Frontend parses AGUI events (especially `TEXT_MESSAGE_CONTENT`) and renders assistant text. 4. If assistant text contains ticket JSON, frontend renders a `ticket-card` widget. +In the current sample, `support.ticket.manage` is an outbound `a2a:` tool: + +- support/billing agents call `a2a:support-ticket-service` +- exposed A2A agent `support-ticket-service` maps to local plan `ticketing:v1` +- ticketing agent calls local route tool `support.ticket.manage.route` +- ticket lifecycle route updates ticket state and returns JSON + Voice/transcript UX in `/agui/ui`: - Single dynamic voice toggle button with idle/live/busy visual states. @@ -161,7 +177,8 @@ curl https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" ## Routes Used by Tools - `kb.search` -> `direct:kb-search` (`routes/kb-search.camel.yaml`) -- `support.ticket.open` -> `direct:support-ticket-open` (`routes/kb-search-json.camel.xml`) +- `support.ticket.manage.route` -> `direct:support-ticket-manage` (`routes/ticket-service.camel.yaml`) +- `support.ticket.manage` -> `a2a:support-ticket-service?remoteUrl={{agent.runtime.a2a.public-base-url}}/a2a` - `support.mcp` -> `mcp:http://localhost:3001/mcp` (optional MCP seed; runtime discovers concrete MCP tools via `tools/list`) ## MCP Quick Start (Local) @@ -227,6 +244,59 @@ Example event payload shape: - `GET /agui/stream/{runId}` -> optional event stream endpoint for split transport mode - `GET /agui/ui` -> simple Copilot-like frontend with ticket widget rendering +## A2A Endpoints + +- `POST /a2a/rpc` +- `GET /a2a/sse/{taskId}` +- `GET /.well-known/agent-card.json` + +### Direct A2A Smoke + +Open ticket: + +```bash +curl -sS -X POST http://localhost:8080/a2a/rpc \ + -H 'Content-Type: application/json' \ + -d '{ + "jsonrpc":"2.0", + "id":"1", + "method":"SendMessage", + "params":{ + "conversationId":"demo-a2a-1", + "message":{ + "messageId":"m1", + "role":"user", + "parts":[{"partId":"p1","type":"text","mimeType":"text/plain","text":"Please open a support ticket for my login issue"}] + }, + "metadata":{"agentId":"support-ticket-service"} + } + }' +``` + +Close the same ticket by reusing returned `linkedConversationId`: + +```bash +curl -sS -X POST http://localhost:8080/a2a/rpc \ + -H 'Content-Type: application/json' \ + -d '{ + "jsonrpc":"2.0", + "id":"2", + "method":"SendMessage", + "params":{ + "conversationId":"demo-a2a-1", + "message":{ + "messageId":"m2", + "role":"user", + "parts":[{"partId":"p2","type":"text","mimeType":"text/plain","text":"Please close the ticket now"}] + }, + "metadata":{ + "agentId":"support-ticket-service", + "linkedConversationId":"" + } + } + }' +``` + ### AGUI Pre-Run Fallback Quick Check This sample blueprint now includes `aguiPreRun` metadata. You can verify deterministic fallback routing (without valid OpenAI credentials) by calling `POST /agui/agent` with a ticket-oriented prompt: @@ -244,7 +314,7 @@ curl -N -X POST http://localhost:8080/agui/agent \ Expected result: - AGUI pre-run processor invokes the normal agent endpoint first. -- If response indicates key/auth failure (or is empty), fallback selects ticket route based on blueprint `aguiPreRun.fallback.ticketKeywords` and tool metadata (`support.ticket.open` -> `direct:support-ticket-open`). +- If response indicates key/auth failure (or is empty), fallback selects ticket route based on blueprint `aguiPreRun.fallback.ticketKeywords` and tool metadata (`support.ticket.manage` -> `direct:support-ticket-manage`). - SSE output contains assistant text with ticket payload that the sample UI renders as a `ticket-card` widget. ## Realtime Relay Endpoint (Foundation) @@ -288,13 +358,13 @@ Example route patch (YAML): ```yaml - route: - id: support-ticket-open + id: support-ticket-manage-route from: - uri: direct:support-ticket-open + uri: direct:support-ticket-manage steps: - setHeader: name: realtimeSessionUpdate - constant: '{"metadata":{"lastTool":"support.ticket.open"},"audio":{"output":{"voice":"alloy"}}}' + constant: '{"metadata":{"lastTool":"support.ticket.manage.route"},"audio":{"output":{"voice":"alloy"}}}' - setBody: simple: '{"ticketId":"TCK-${exchangeId}","status":"OPEN","summary":"${body[query]}"}' ``` @@ -364,7 +434,7 @@ Covered scenarios: 1. Two-turn routing + memory (deterministic gateway): - prompt 1 asks knowledge base -> tool `kb.search` - - prompt 2 asks ticket -> tool `support.ticket.open` + - prompt 2 asks ticket -> tool `support.ticket.manage` - prompt 2 evaluation includes prompt 1 KB result 2. Negative case: - ticket as first prompt does not inject KB context diff --git a/samples/agent-support-service/pom.xml b/samples/agent-support-service/pom.xml index e928c4d..6681280 100644 --- a/samples/agent-support-service/pom.xml +++ b/samples/agent-support-service/pom.xml @@ -6,7 +6,7 @@ io.dscope.camel camel-agent - 0.1.0-SNAPSHOT + 0.6.0 ../../pom.xml diff --git a/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/DemoA2ATicketGateway.java b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/DemoA2ATicketGateway.java new file mode 100644 index 0000000..4505a13 --- /dev/null +++ b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/DemoA2ATicketGateway.java @@ -0,0 +1,109 @@ +package io.dscope.camel.agent.samples; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dscope.camel.agent.model.AiToolCall; +import io.dscope.camel.agent.model.ToolSpec; +import io.dscope.camel.agent.springai.SpringAiChatGateway; +import java.util.List; +import java.util.function.Consumer; + +public final class DemoA2ATicketGateway implements SpringAiChatGateway { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public SpringAiChatResult generate(String systemPrompt, + String userContext, + List tools, + String model, + Double temperature, + Integer maxTokens, + Consumer streamingTokenCallback) { + String query = extractLastUserMessage(userContext); + ToolSpec selectedTool = selectTool(tools, query); + if (selectedTool == null) { + String message = "Demo gateway did not find a matching tool."; + if (streamingTokenCallback != null) { + streamingTokenCallback.accept(message); + } + return new SpringAiChatResult(message, List.of(), true); + } + + AiToolCall call = new AiToolCall(selectedTool.name(), mapper.createObjectNode().put("query", query)); + if (streamingTokenCallback != null) { + streamingTokenCallback.accept(""); + } + return new SpringAiChatResult("", List.of(call), true); + } + + private ToolSpec selectTool(List tools, String query) { + if (tools == null || tools.isEmpty()) { + return null; + } + String normalized = query == null ? "" : query.toLowerCase(); + if (containsTicketIntent(normalized)) { + ToolSpec routeTool = findTool(tools, "support.ticket.manage.route"); + if (routeTool != null) { + return routeTool; + } + ToolSpec a2aTool = findTool(tools, "support.ticket.manage"); + if (a2aTool != null) { + return a2aTool; + } + } + return tools.get(0); + } + + private ToolSpec findTool(List tools, String name) { + for (ToolSpec tool : tools) { + if (tool != null && name.equals(tool.name())) { + return tool; + } + } + return null; + } + + private boolean containsTicketIntent(String normalized) { + return normalized.contains("ticket") + || normalized.contains("open") + || normalized.contains("create") + || normalized.contains("update") + || normalized.contains("close") + || normalized.contains("status") + || normalized.contains("escalate"); + } + + private String extractLastUserMessage(String userContext) { + if (userContext == null || userContext.isBlank()) { + return ""; + } + String[] lines = userContext.split("\\R"); + for (int i = lines.length - 1; i >= 0; i--) { + String line = lines[i]; + if (line.startsWith("User: ")) { + return toPlainText(line.substring("User: ".length())); + } + if (line.startsWith("user.message: ")) { + return toPlainText(line.substring("user.message: ".length())); + } + } + return toPlainText(userContext); + } + + private String toPlainText(String value) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length() >= 2) { + return trimmed.substring(1, trimmed.length() - 1); + } + try { + JsonNode node = mapper.readTree(trimmed); + return node.isTextual() ? node.asText() : trimmed; + } catch (Exception ignored) { + return trimmed; + } + } +} diff --git a/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/Main.java b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/Main.java index 4c91825..72b8762 100644 --- a/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/Main.java +++ b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/Main.java @@ -1,5 +1,6 @@ package io.dscope.camel.agent.samples; +import com.fasterxml.jackson.databind.ObjectMapper; import io.dscope.camel.agent.runtime.AgentRuntimeBootstrap; public final class Main { @@ -9,11 +10,9 @@ private Main() { public static void main(String[] args) throws Exception { org.apache.camel.main.Main main = new org.apache.camel.main.Main(); - main.bind("supportUiPageProcessor", new SupportUiPageProcessor()); - main.bind("supportSipSessionInitEnvelopeProcessor", new SipSessionInitEnvelopeProcessor()); - main.bind("supportSipTranscriptFinalProcessor", new SipTranscriptFinalProcessor()); - main.bind("supportSipCallEndProcessor", new SipCallEndProcessor()); + main.bind("ticketLifecycleProcessor", new SupportTicketLifecycleProcessor(new ObjectMapper())); AgentRuntimeBootstrap.bootstrap(main, "application.yaml"); + SampleAdminMcpBindings.bindIfMissing(main, "application.yaml"); main.run(args); } } diff --git a/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/SampleAdminMcpBindings.java b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/SampleAdminMcpBindings.java new file mode 100644 index 0000000..d3adcb8 --- /dev/null +++ b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/SampleAdminMcpBindings.java @@ -0,0 +1,312 @@ +package io.dscope.camel.agent.samples; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Properties; +import org.apache.camel.main.Main; + +final class SampleAdminMcpBindings { + + private SampleAdminMcpBindings() { + } + + static void bindIfMissing(Main main, String applicationYamlPath) { + if (main.lookup("mcpError", Object.class) != null) { + return; + } + try { + Object objectMapper = lookup(main, "objectMapper"); + ensureAuditProcessors(main, applicationYamlPath, (ObjectMapper) objectMapper); + Object auditTrailSearchProcessor = required(main, "auditTrailSearchProcessor"); + Object auditConversationListProcessor = required(main, "auditConversationListProcessor"); + Object auditConversationViewProcessor = required(main, "auditConversationViewProcessor"); + Object auditConversationSessionDataProcessor = required(main, "auditConversationSessionDataProcessor"); + Object auditConversationAgentMessageProcessor = required(main, "auditConversationAgentMessageProcessor"); + Object auditAgentBlueprintProcessor = required(main, "auditAgentBlueprintProcessor"); + Object auditAgentCatalogProcessor = required(main, "auditAgentCatalogProcessor"); + Object runtimeAuditGranularityProcessor = required(main, "runtimeAuditGranularityProcessor"); + Object runtimeResourceRefreshProcessor = required(main, "runtimeResourceRefreshProcessor"); + Object runtimeConversationPersistenceProcessor = required(main, "runtimeConversationPersistenceProcessor"); + Object runtimeConversationCloseProcessor = required(main, "runtimeConversationCloseProcessor"); + Object runtimePurgePreviewProcessor = required(main, "runtimePurgePreviewProcessor"); + + Object requestSizeGuardProcessor = newInstance("io.dscope.camel.mcp.processor.McpRequestSizeGuardProcessor"); + Object httpValidatorProcessor = newInstance("io.dscope.camel.mcp.processor.McpHttpValidatorProcessor"); + Object rateLimitProcessor = newInstance("io.dscope.camel.mcp.processor.McpRateLimitProcessor"); + Object jsonRpcEnvelopeProcessor = newInstance("io.dscope.camel.mcp.processor.McpJsonRpcEnvelopeProcessor"); + Object initializeProcessor = newInstance("io.dscope.camel.mcp.processor.McpInitializeProcessor"); + Object pingProcessor = newInstance("io.dscope.camel.mcp.processor.McpPingProcessor"); + Object notificationsInitializedProcessor = newInstance("io.dscope.camel.mcp.processor.McpNotificationsInitializedProcessor"); + Object notificationProcessor = newInstance("io.dscope.camel.mcp.processor.McpNotificationProcessor"); + Object notificationAckProcessor = newInstance("io.dscope.camel.mcp.processor.McpNotificationAckProcessor"); + Object errorProcessor = newInstance("io.dscope.camel.mcp.processor.McpErrorProcessor"); + Object streamProcessor = newInstance("io.dscope.camel.mcp.processor.McpStreamProcessor"); + Object healthStatusProcessor = newInstance( + "io.dscope.camel.mcp.processor.McpHealthStatusProcessor", + new Class[] { rateLimitProcessor.getClass().getInterfaces().length > 0 ? rateLimitProcessor.getClass().getInterfaces()[0] : rateLimitProcessor.getClass() }, + new Object[] { rateLimitProcessor } + ); + + Object uiSessionRegistry = newInstance("io.dscope.camel.mcp.service.McpUiSessionRegistry"); + uiSessionRegistry.getClass().getMethod("start").invoke(uiSessionRegistry); + Object uiInitializeProcessor = newInstance("io.dscope.camel.mcp.processor.McpUiInitializeProcessor", + new Class[] { uiSessionRegistry.getClass() }, + new Object[] { uiSessionRegistry }); + Object uiMessageProcessor = newInstance("io.dscope.camel.mcp.processor.McpUiMessageProcessor", + new Class[] { uiSessionRegistry.getClass() }, + new Object[] { uiSessionRegistry }); + Object uiUpdateModelContextProcessor = newInstance("io.dscope.camel.mcp.processor.McpUiUpdateModelContextProcessor", + new Class[] { uiSessionRegistry.getClass() }, + new Object[] { uiSessionRegistry }); + Object uiToolsCallProcessor = newInstance("io.dscope.camel.mcp.processor.McpUiToolsCallProcessor", + new Class[] { uiSessionRegistry.getClass() }, + new Object[] { uiSessionRegistry }); + Object uiToolsCallPostProcessor = newInstance("io.dscope.camel.mcp.processor.McpUiToolsCallPostProcessor", + new Class[] { uiSessionRegistry.getClass() }, + new Object[] { uiSessionRegistry }); + + Object toolsListProcessor = newInstance("io.dscope.camel.agent.audit.mcp.AuditMcpToolsListProcessor"); + Object toolsCallProcessor = newInstance( + "io.dscope.camel.agent.audit.mcp.AuditMcpToolsCallProcessor", + new Class[] { + ObjectMapper.class, + auditTrailSearchProcessor.getClass(), + auditConversationListProcessor.getClass(), + auditConversationViewProcessor.getClass(), + auditConversationSessionDataProcessor.getClass(), + auditConversationAgentMessageProcessor.getClass(), + auditAgentBlueprintProcessor.getClass(), + auditAgentCatalogProcessor.getClass(), + runtimeAuditGranularityProcessor.getClass(), + runtimeResourceRefreshProcessor.getClass(), + runtimeConversationPersistenceProcessor.getClass(), + runtimeConversationCloseProcessor.getClass(), + runtimePurgePreviewProcessor.getClass() + }, + new Object[] { + objectMapper, + auditTrailSearchProcessor, + auditConversationListProcessor, + auditConversationViewProcessor, + auditConversationSessionDataProcessor, + auditConversationAgentMessageProcessor, + auditAgentBlueprintProcessor, + auditAgentCatalogProcessor, + runtimeAuditGranularityProcessor, + runtimeResourceRefreshProcessor, + runtimeConversationPersistenceProcessor, + runtimeConversationCloseProcessor, + runtimePurgePreviewProcessor + } + ); + Object resourcesListProcessor = newInstance("io.dscope.camel.mcp.processor.McpResourcesListProcessor"); + Object resourcesReadProcessor = newInstance("io.dscope.camel.mcp.processor.McpResourcesReadProcessor"); + + main.bind("mcpRequestSizeGuard", requestSizeGuardProcessor); + main.bind("mcpHttpValidator", httpValidatorProcessor); + main.bind("mcpRateLimit", rateLimitProcessor); + main.bind("mcpJsonRpcEnvelope", jsonRpcEnvelopeProcessor); + main.bind("mcpInitialize", initializeProcessor); + main.bind("mcpPing", pingProcessor); + main.bind("mcpNotificationsInitialized", notificationsInitializedProcessor); + main.bind("mcpNotification", notificationProcessor); + main.bind("mcpNotificationAck", notificationAckProcessor); + main.bind("mcpError", errorProcessor); + main.bind("mcpStream", streamProcessor); + main.bind("mcpHealthStatus", healthStatusProcessor); + main.bind("mcpUiSessionRegistry", uiSessionRegistry); + main.bind("mcpUiInitialize", uiInitializeProcessor); + main.bind("mcpUiMessage", uiMessageProcessor); + main.bind("mcpUiUpdateModelContext", uiUpdateModelContextProcessor); + main.bind("mcpUiToolsCall", uiToolsCallProcessor); + main.bind("mcpUiToolsCallPost", uiToolsCallPostProcessor); + main.bind("mcpToolsList", toolsListProcessor); + main.bind("mcpToolsCall", toolsCallProcessor); + main.bind("mcpResourcesList", resourcesListProcessor); + main.bind("mcpResourcesRead", resourcesReadProcessor); + } catch (Exception e) { + throw new IllegalStateException("Failed to bind sample MCP admin fallback processors", e); + } + } + + private static void ensureAuditProcessors(Main main, String applicationYamlPath, ObjectMapper objectMapper) throws Exception { + Object persistenceFacade = required(main, "persistenceFacade"); + Object planSelectionResolver = lookupFromRegistry(main, "agentPlanSelectionResolver"); + if (planSelectionResolver == null) { + planSelectionResolver = newInstance( + "io.dscope.camel.agent.runtime.AgentPlanSelectionResolver", + new Class[] { Object.class, ObjectMapper.class }, + new Object[] { persistenceFacade, objectMapper } + ); + main.bind("agentPlanSelectionResolver", planSelectionResolver); + } + + Object conversationArchiveService = lookupFromRegistry(main, "conversationArchiveService"); + if (conversationArchiveService == null) { + conversationArchiveService = newInstance( + "io.dscope.camel.agent.runtime.ConversationArchiveService", + new Class[] { Object.class, ObjectMapper.class, boolean.class }, + new Object[] { persistenceFacade, objectMapper, true } + ); + main.bind("conversationArchiveService", conversationArchiveService); + } + + Object runtimeControlState = lookupFromRegistry(main, "runtimeControlState"); + if (runtimeControlState == null) { + runtimeControlState = newInstance( + "io.dscope.camel.agent.runtime.RuntimeControlState", + new Class[] { Object.class }, + new Object[] { loadGranularity(applicationYamlPath) } + ); + main.bind("runtimeControlState", runtimeControlState); + } + + Properties properties = loadProperties(applicationYamlPath); + String plansConfig = trimToNull(properties.getProperty("agent.agents-config")); + String blueprintUri = trimToNull(properties.getProperty("agent.blueprint")); + + bindIfMissing(main, "auditTrailSearchProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditTrailSearchProcessor", + new Class[] { Object.class, ObjectMapper.class, Object.class, String.class, String.class }, + new Object[] { persistenceFacade, objectMapper, planSelectionResolver, plansConfig, blueprintUri } + )); + bindIfMissing(main, "auditConversationListProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditConversationListProcessor", + new Class[] { Object.class, ObjectMapper.class, Object.class, String.class, String.class }, + new Object[] { persistenceFacade, objectMapper, planSelectionResolver, plansConfig, blueprintUri } + )); + bindIfMissing(main, "auditConversationViewProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditConversationViewProcessor", + new Class[] { Object.class, ObjectMapper.class, Object.class, String.class, String.class }, + new Object[] { persistenceFacade, objectMapper, planSelectionResolver, plansConfig, blueprintUri } + )); + bindIfMissing(main, "auditConversationSessionDataProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditConversationSessionDataProcessor", + new Class[] { Object.class, ObjectMapper.class }, + new Object[] { conversationArchiveService, objectMapper } + )); + bindIfMissing(main, "auditConversationAgentMessageProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditConversationAgentMessageProcessor", + new Class[] { Object.class, ObjectMapper.class }, + new Object[] { persistenceFacade, objectMapper } + )); + bindIfMissing(main, "runtimeAuditGranularityProcessor", + newInstance( + "io.dscope.camel.agent.runtime.RuntimeAuditGranularityProcessor", + new Class[] { ObjectMapper.class, Object.class }, + new Object[] { objectMapper, runtimeControlState } + )); + bindIfMissing(main, "runtimeResourceRefreshProcessor", + newInstance( + "io.dscope.camel.agent.runtime.RuntimeResourceRefreshProcessor", + new Class[] { String.class, Object.class, ObjectMapper.class }, + new Object[] { applicationYamlPath, persistenceFacade, objectMapper } + )); + bindIfMissing(main, "runtimeConversationPersistenceProcessor", + newInstance( + "io.dscope.camel.agent.runtime.RuntimeConversationPersistenceProcessor", + new Class[] { ObjectMapper.class, Object.class }, + new Object[] { objectMapper, conversationArchiveService } + )); + bindIfMissing(main, "runtimeConversationCloseProcessor", + newInstance( + "io.dscope.camel.agent.runtime.RuntimeConversationCloseProcessor", + new Class[] { ObjectMapper.class, Object.class }, + new Object[] { objectMapper, persistenceFacade } + )); + bindIfMissing(main, "runtimePurgePreviewProcessor", + newInstance( + "io.dscope.camel.agent.runtime.RuntimePurgePreviewProcessor", + new Class[] { ObjectMapper.class, Object.class }, + new Object[] { objectMapper, persistenceFacade } + )); + bindIfMissing(main, "auditAgentBlueprintProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditAgentBlueprintProcessor", + new Class[] { Object.class, ObjectMapper.class, Object.class, String.class, String.class }, + new Object[] { persistenceFacade, objectMapper, planSelectionResolver, plansConfig, blueprintUri } + )); + bindIfMissing(main, "auditAgentCatalogProcessor", + newInstance( + "io.dscope.camel.agent.audit.AuditAgentCatalogProcessor", + new Class[] { ObjectMapper.class, Object.class, String.class, String.class }, + new Object[] { objectMapper, planSelectionResolver, plansConfig, blueprintUri } + )); + } + + private static void bindIfMissing(Main main, String name, Object value) { + if (lookupFromRegistry(main, name) == null && main.lookup(name, Object.class) == null) { + main.bind(name, value); + } + } + + private static Object loadGranularity(String applicationYamlPath) { + try { + Properties properties = loadProperties(applicationYamlPath); + Class enumType = Class.forName("io.dscope.camel.agent.model.AuditGranularity"); + return enumType.getMethod("from", String.class) + .invoke(null, properties.getProperty("agent.audit.granularity", "debug")); + } catch (Exception ignored) { + try { + Class enumType = Class.forName("io.dscope.camel.agent.model.AuditGranularity"); + return Enum.valueOf((Class) enumType.asSubclass(Enum.class), "DEBUG"); + } catch (Exception nested) { + return null; + } + } + } + + private static Properties loadProperties(String applicationYamlPath) { + try { + Class loader = Class.forName("io.dscope.camel.agent.runtime.ApplicationYamlLoader"); + return (Properties) loader.getMethod("loadFromClasspath", String.class).invoke(null, applicationYamlPath); + } catch (Exception ignored) { + return new Properties(); + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Object lookup(Main main, String name) { + Object value = main.lookup(name, Object.class); + return value != null ? value : new ObjectMapper(); + } + + private static Object required(Main main, String name) { + Object value = main.lookup(name, Object.class); + if (value == null) { + throw new IllegalStateException("Required bean missing after runtime bootstrap: " + name); + } + return value; + } + + private static Object newInstance(String className) throws Exception { + return Class.forName(className).getDeclaredConstructor().newInstance(); + } + + private static Object newInstance(String className, Class[] parameterTypes, Object[] args) throws Exception { + Class type = Class.forName(className); + try { + return type.getDeclaredConstructor(parameterTypes).newInstance(args); + } catch (NoSuchMethodException ignored) { + for (var constructor : type.getDeclaredConstructors()) { + if (constructor.getParameterCount() != args.length) { + continue; + } + constructor.setAccessible(true); + return constructor.newInstance(args); + } + throw ignored; + } + } +} diff --git a/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/SupportTicketLifecycleProcessor.java b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/SupportTicketLifecycleProcessor.java new file mode 100644 index 0000000..b91fc1c --- /dev/null +++ b/samples/agent-support-service/src/main/java/io/dscope/camel/agent/samples/SupportTicketLifecycleProcessor.java @@ -0,0 +1,147 @@ +package io.dscope.camel.agent.samples; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.dscope.camel.agent.config.AgentHeaders; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.zip.CRC32; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.Processor; + +final class SupportTicketLifecycleProcessor implements Processor { + + private final ObjectMapper objectMapper; + private final ConcurrentMap ticketsByConversationId = new ConcurrentHashMap<>(); + + SupportTicketLifecycleProcessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void process(Exchange exchange) throws Exception { + Message in = exchange.getIn(); + String conversationId = readConversationId(in); + String query = normalizeQuery(in.getBody()); + + TicketAction action = determineAction(query); + TicketRecord current = ticketsByConversationId.compute(conversationId, (key, existing) -> applyAction(key, existing, action, query)); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("ticketId", current.ticketId()); + payload.put("status", current.status()); + payload.put("action", action.name()); + payload.put("summary", current.summary()); + payload.put("assignedQueue", current.assignedQueue()); + payload.put("message", messageFor(action)); + payload.put("notifyClient", action == TicketAction.UPDATE || action == TicketAction.CLOSE || action == TicketAction.STATUS); + payload.put("conversationId", conversationId); + payload.put("updatedAt", Instant.now().toString()); + + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/json"); + exchange.getMessage().setBody(objectMapper.writeValueAsString(payload)); + } + + private TicketRecord applyAction(String conversationId, TicketRecord existing, TicketAction action, String query) { + TicketRecord current = existing != null ? existing : createRecord(conversationId, query); + return switch (action) { + case OPEN -> createRecord(conversationId, query); + case UPDATE -> new TicketRecord(current.ticketId(), "IN_PROGRESS", firstNonBlank(query, current.summary()), current.assignedQueue()); + case CLOSE -> new TicketRecord(current.ticketId(), "CLOSED", firstNonBlank(query, current.summary()), current.assignedQueue()); + case STATUS -> current; + }; + } + + private TicketRecord createRecord(String conversationId, String query) { + return new TicketRecord(ticketIdFor(conversationId), "OPEN", firstNonBlank(query, "Customer requested support follow-up"), queueFor(query)); + } + + private String ticketIdFor(String conversationId) { + CRC32 crc = new CRC32(); + crc.update(conversationId.getBytes(StandardCharsets.UTF_8)); + return "TCK-" + Long.toUnsignedString(crc.getValue(), 36).toUpperCase(Locale.ROOT); + } + + private String queueFor(String query) { + String normalized = query.toLowerCase(Locale.ROOT); + if (normalized.contains("bill") || normalized.contains("refund") || normalized.contains("invoice") || normalized.contains("payment")) { + return "BILLING-L2"; + } + if (normalized.contains("security") || normalized.contains("fraud")) { + return "SECURITY-L2"; + } + return "L1-SUPPORT"; + } + + private TicketAction determineAction(String query) { + String normalized = query.toLowerCase(Locale.ROOT); + if (containsAny(normalized, "close", "closed", "resolve", "resolved", "done with", "cancel ticket")) { + return TicketAction.CLOSE; + } + if (containsAny(normalized, "status", "progress", "check ticket", "where is", "what is happening")) { + return TicketAction.STATUS; + } + if (containsAny(normalized, "update", "add note", "change", "modify", "priority", "reopen")) { + return TicketAction.UPDATE; + } + return TicketAction.OPEN; + } + + private boolean containsAny(String text, String... markers) { + for (String marker : markers) { + if (text.contains(marker)) { + return true; + } + } + return false; + } + + private String messageFor(TicketAction action) { + return switch (action) { + case OPEN -> "Support ticket created successfully"; + case UPDATE -> "Support ticket updated and routed back to the client conversation"; + case CLOSE -> "Support ticket closed and the client conversation has been updated"; + case STATUS -> "Support ticket status retrieved successfully"; + }; + } + + private String normalizeQuery(Object body) { + if (body instanceof Map map) { + Object query = map.get("query"); + return query == null ? "" : String.valueOf(query).trim(); + } + return body == null ? "" : String.valueOf(body).trim(); + } + + private String readConversationId(Message in) { + String conversationId = in.getHeader(AgentHeaders.CONVERSATION_ID, String.class); + if (conversationId == null || conversationId.isBlank()) { + return "sample-ticket-fallback"; + } + return conversationId.trim(); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private enum TicketAction { + OPEN, + UPDATE, + CLOSE, + STATUS + } + + private record TicketRecord(String ticketId, String status, String summary, String assignedQueue) { + } +} diff --git a/samples/agent-support-service/src/main/resources/agents/a2a-exposed-agents.yaml b/samples/agent-support-service/src/main/resources/agents/a2a-exposed-agents.yaml new file mode 100644 index 0000000..5d6f157 --- /dev/null +++ b/samples/agent-support-service/src/main/resources/agents/a2a-exposed-agents.yaml @@ -0,0 +1,12 @@ +agents: + - agentId: support-ticket-service + name: Support Ticket Service + description: A2A ticket lifecycle service used by the support and billing assistants + version: 1.0.0 + defaultAgent: true + planName: ticketing + planVersion: v1 + skills: + - ticketing + - support + - escalation diff --git a/samples/agent-support-service/src/main/resources/agents/agents.yaml b/samples/agent-support-service/src/main/resources/agents/agents.yaml index 3ca23be..5145964 100644 --- a/samples/agent-support-service/src/main/resources/agents/agents.yaml +++ b/samples/agent-support-service/src/main/resources/agents/agents.yaml @@ -14,3 +14,8 @@ plans: blueprint: classpath:agents/billing/v1/agent.md - version: v2 blueprint: classpath:agents/billing/v2/agent.md + - name: ticketing + versions: + - version: v1 + default: true + blueprint: classpath:agents/ticketing/v1/agent.md diff --git a/samples/agent-support-service/src/main/resources/agents/billing/v1/agent.md b/samples/agent-support-service/src/main/resources/agents/billing/v1/agent.md index 75421ce..89da0d6 100644 --- a/samples/agent-support-service/src/main/resources/agents/billing/v1/agent.md +++ b/samples/agent-support-service/src/main/resources/agents/billing/v1/agent.md @@ -9,8 +9,8 @@ You are a billing support assistant. Prioritize invoice, payment, subscription, Routing rules: 1. Use `kb.search` for billing policy, invoice explanation, and account/billing FAQ lookup. -2. Use `support.ticket.open` when the user needs a billing case opened, escalated, or followed up by human support. -3. If the request is ambiguous, ask one direct billing-focused clarification before opening a ticket. +2. Use `support.ticket.manage` when the user needs a billing case opened, updated, closed, escalated, or followed up by human support. +3. If the request is ambiguous, ask one direct billing-focused clarification before changing the ticket. 4. Keep responses concise and operational. ## Tools @@ -26,9 +26,9 @@ tools: properties: query: type: string - - name: support.ticket.open - description: Open a support ticket from a user issue and return ticket details - routeId: support-ticket-open + - name: support.ticket.manage + description: Manage a billing support ticket through the sample A2A ticket service + endpointUri: a2a:support-ticket-service?remoteUrl={{agent.runtime.a2a.public-base-url}}/a2a inputSchemaInline: type: object required: [query] @@ -58,7 +58,8 @@ aguiPreRun: fallbackEnabled: true fallback: kbToolName: kb.search - ticketToolName: support.ticket.open - ticketKeywords: [invoice, billing, refund, payment, charge, subscription] + ticketToolName: support.ticket.manage + ticketUri: direct:support-ticket-manage + ticketKeywords: [invoice, billing, refund, payment, charge, subscription, update, close, status] errorMarkers: [api key is missing, openai api key, set -dopenai.api.key] ``` diff --git a/samples/agent-support-service/src/main/resources/agents/billing/v2/agent.md b/samples/agent-support-service/src/main/resources/agents/billing/v2/agent.md index 2ffa6ac..c0339ee 100644 --- a/samples/agent-support-service/src/main/resources/agents/billing/v2/agent.md +++ b/samples/agent-support-service/src/main/resources/agents/billing/v2/agent.md @@ -9,8 +9,8 @@ You are a senior billing assistant. Focus on resolving invoice disputes, refunds Routing rules: 1. Use `kb.search` first for known billing and subscription guidance. -2. Use `support.ticket.open` when the user explicitly asks for follow-up, escalation, or case creation. -3. Summarize the billing issue clearly before creating a ticket. +2. Use `support.ticket.manage` when the user explicitly asks for follow-up, escalation, case creation, a status update, or closure. +3. Summarize the billing issue clearly before changing the ticket. 4. Prefer answer-first responses, then next actions. ## Tools @@ -26,9 +26,9 @@ tools: properties: query: type: string - - name: support.ticket.open - description: Open a support ticket from a user issue and return ticket details - routeId: support-ticket-open + - name: support.ticket.manage + description: Manage a billing support ticket through the sample A2A ticket service + endpointUri: a2a:support-ticket-service?remoteUrl={{agent.runtime.a2a.public-base-url}}/a2a inputSchemaInline: type: object required: [query] @@ -58,7 +58,8 @@ aguiPreRun: fallbackEnabled: true fallback: kbToolName: kb.search - ticketToolName: support.ticket.open - ticketKeywords: [invoice, billing, refund, payment, charge, subscription] + ticketToolName: support.ticket.manage + ticketUri: direct:support-ticket-manage + ticketKeywords: [invoice, billing, refund, payment, charge, subscription, update, close, status] errorMarkers: [api key is missing, openai api key, set -dopenai.api.key] ``` diff --git a/samples/agent-support-service/src/main/resources/agents/support/agent.md b/samples/agent-support-service/src/main/resources/agents/support/agent.md index 78f446b..946d844 100644 --- a/samples/agent-support-service/src/main/resources/agents/support/agent.md +++ b/samples/agent-support-service/src/main/resources/agents/support/agent.md @@ -9,7 +9,7 @@ You are a support assistant that can search local knowledge routes. Routing rules: 1. Use discovered CRM MCP tools from `support.mcp` to look up customer profile/context when the user provides phone number or email. -2. Use `support.ticket.open` when the user asks to open, create, submit, or escalate a support ticket. +2. Use `support.ticket.manage` when the user asks to open, create, update, close, check, submit, or escalate a support ticket. 3. Use `kb.search` for general plain-language support lookups, troubleshooting guidance, and informational questions. 4. Use `support.echo` only when the user explicitly asks for an echo/diagnostic transform. 5. If unclear, return response from LLM call. @@ -32,9 +32,9 @@ tools: properties: query: type: string - - name: support.ticket.open - description: Open a support ticket from a user issue and return ticket details - routeId: support-ticket-open + - name: support.ticket.manage + description: Manage a support ticket over the sample A2A ticket service and return ticket details + endpointUri: a2a:support-ticket-service?remoteUrl={{agent.runtime.a2a.public-base-url}}/a2a inputSchemaInline: type: object required: [query] @@ -112,7 +112,8 @@ aguiPreRun: fallbackEnabled: true fallback: kbToolName: kb.search - ticketToolName: support.ticket.open - ticketKeywords: [ticket, open, create, submit, escalate] + ticketToolName: support.ticket.manage + ticketUri: direct:support-ticket-manage + ticketKeywords: [ticket, open, create, update, close, status, submit, escalate] errorMarkers: [api key is missing, openai api key, set -dopenai.api.key] ``` diff --git a/samples/agent-support-service/src/main/resources/agents/support/v1/agent.md b/samples/agent-support-service/src/main/resources/agents/support/v1/agent.md index 78f446b..946d844 100644 --- a/samples/agent-support-service/src/main/resources/agents/support/v1/agent.md +++ b/samples/agent-support-service/src/main/resources/agents/support/v1/agent.md @@ -9,7 +9,7 @@ You are a support assistant that can search local knowledge routes. Routing rules: 1. Use discovered CRM MCP tools from `support.mcp` to look up customer profile/context when the user provides phone number or email. -2. Use `support.ticket.open` when the user asks to open, create, submit, or escalate a support ticket. +2. Use `support.ticket.manage` when the user asks to open, create, update, close, check, submit, or escalate a support ticket. 3. Use `kb.search` for general plain-language support lookups, troubleshooting guidance, and informational questions. 4. Use `support.echo` only when the user explicitly asks for an echo/diagnostic transform. 5. If unclear, return response from LLM call. @@ -32,9 +32,9 @@ tools: properties: query: type: string - - name: support.ticket.open - description: Open a support ticket from a user issue and return ticket details - routeId: support-ticket-open + - name: support.ticket.manage + description: Manage a support ticket over the sample A2A ticket service and return ticket details + endpointUri: a2a:support-ticket-service?remoteUrl={{agent.runtime.a2a.public-base-url}}/a2a inputSchemaInline: type: object required: [query] @@ -112,7 +112,8 @@ aguiPreRun: fallbackEnabled: true fallback: kbToolName: kb.search - ticketToolName: support.ticket.open - ticketKeywords: [ticket, open, create, submit, escalate] + ticketToolName: support.ticket.manage + ticketUri: direct:support-ticket-manage + ticketKeywords: [ticket, open, create, update, close, status, submit, escalate] errorMarkers: [api key is missing, openai api key, set -dopenai.api.key] ``` diff --git a/samples/agent-support-service/src/main/resources/agents/support/v2/agent.md b/samples/agent-support-service/src/main/resources/agents/support/v2/agent.md index d206737..f64e43d 100644 --- a/samples/agent-support-service/src/main/resources/agents/support/v2/agent.md +++ b/samples/agent-support-service/src/main/resources/agents/support/v2/agent.md @@ -9,7 +9,7 @@ You are a support assistant that can search local knowledge routes and prefer co Routing rules: 1. Use discovered CRM MCP tools from `support.mcp` to look up customer profile/context when the user provides phone number or email. -2. Use `support.ticket.open` when the user asks to open, create, submit, or escalate a support ticket. +2. Use `support.ticket.manage` when the user asks to open, create, update, close, check, submit, or escalate a support ticket. 3. Use `kb.search` for general plain-language support lookups, troubleshooting guidance, and informational questions. 4. Use `support.echo` only when the user explicitly asks for an echo/diagnostic transform. 5. If unclear, return response from LLM call. @@ -27,9 +27,9 @@ tools: properties: query: type: string - - name: support.ticket.open - description: Open a support ticket from a user issue and return ticket details - routeId: support-ticket-open + - name: support.ticket.manage + description: Manage a support ticket over the sample A2A ticket service and return ticket details + endpointUri: a2a:support-ticket-service?remoteUrl={{agent.runtime.a2a.public-base-url}}/a2a inputSchemaInline: type: object required: [query] @@ -65,7 +65,8 @@ aguiPreRun: fallbackEnabled: true fallback: kbToolName: kb.search - ticketToolName: support.ticket.open - ticketKeywords: [ticket, open, create, submit, escalate] + ticketToolName: support.ticket.manage + ticketUri: direct:support-ticket-manage + ticketKeywords: [ticket, open, create, update, close, status, submit, escalate] errorMarkers: [api key is missing, openai api key, set -dopenai.api.key] ``` diff --git a/samples/agent-support-service/src/main/resources/agents/ticketing/v1/agent.md b/samples/agent-support-service/src/main/resources/agents/ticketing/v1/agent.md new file mode 100644 index 0000000..ed012f6 --- /dev/null +++ b/samples/agent-support-service/src/main/resources/agents/ticketing/v1/agent.md @@ -0,0 +1,29 @@ +# Agent: TicketingService + +Version: 1.0.0 + +## System + +You are the support ticket lifecycle service exposed over A2A. + +Routing rules: + +1. Use `support.ticket.manage.route` for every request to open, update, close, or check a ticket. +2. Keep the customer request text intact when you pass it to the route tool. +3. Return the tool result exactly as JSON and do not wrap it in markdown. +4. If the request is unclear, still call the tool so the service can keep the ticket state in sync. + +## Tools + +```yaml +tools: + - name: support.ticket.manage.route + description: Stateful sample ticket lifecycle route for open, update, close, and status operations + routeId: support-ticket-manage + inputSchemaInline: + type: object + required: [query] + properties: + query: + type: string +``` diff --git a/samples/agent-support-service/src/main/resources/application.yaml b/samples/agent-support-service/src/main/resources/application.yaml index a7bbb0c..b36f173 100644 --- a/samples/agent-support-service/src/main/resources/application.yaml +++ b/samples/agent-support-service/src/main/resources/application.yaml @@ -19,14 +19,19 @@ openai: key: ${OPENAI_API_KEY} agent: + agents-config: classpath:agents/agents.yaml runtime: - routes-include-pattern: classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/sip-adapter-platform.camel.yaml + routes-include-pattern: classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ticket-service.camel.yaml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/admin-platform.camel.yaml,classpath:routes/sip-adapter-platform.camel.yaml agent-routes-enabled: true + a2a: + enabled: true + public-base-url: http://localhost:{{agui.rpc.port:8080}} + exposed-agents-config: classpath:agents/a2a-exposed-agents.yaml ai: mode: spring-ai realtime: processor-bean-name: supportRealtimeEventProcessorCore - agent-endpoint-uri: agent:support?blueprint={{agent.blueprint}} + agent-endpoint-uri: agent:support?plansConfig={{agent.agents-config}}&blueprint={{agent.blueprint}} agent-profile-purpose-max-chars: 0 # Server-side TTL for browser WebRTC init context (conversationId -> session template) browser-session-ttl-ms: 600000 @@ -48,12 +53,13 @@ agent: max-backoff-ms: 2000 agui: pre-run: - agent-endpoint-uri: agent:support?blueprint={{agent.blueprint}} + agent-endpoint-uri: agent:support?plansConfig={{agent.agents-config}}&blueprint={{agent.blueprint}} fallback-enabled: true fallback: kb-tool-name: kb.search - ticket-tool-name: support.ticket.open - ticket-keywords: ticket,open,create,submit,escalate + ticket-tool-name: support.ticket.manage + ticket-uri: direct:support-ticket-manage + ticket-keywords: ticket,open,create,update,close,status,submit,escalate error-markers: api key is missing,openai api key,set -dopenai.api.key spring-ai: provider: openai diff --git a/samples/agent-support-service/src/main/resources/frontend/audit.html b/samples/agent-support-service/src/main/resources/frontend/audit.html index f92d3be..862affb 100644 --- a/samples/agent-support-service/src/main/resources/frontend/audit.html +++ b/samples/agent-support-service/src/main/resources/frontend/audit.html @@ -118,7 +118,7 @@
- +
diff --git a/samples/agent-support-service/src/main/resources/frontend/index.html b/samples/agent-support-service/src/main/resources/frontend/index.html index 8bbceee..1f4e037 100644 --- a/samples/agent-support-service/src/main/resources/frontend/index.html +++ b/samples/agent-support-service/src/main/resources/frontend/index.html @@ -157,6 +157,7 @@ let selectedPlanVersion = (runtimeUrl.searchParams.get('planVersion') || '').trim(); let agentCatalog = null; let sessionId = `session-${Date.now()}`; + let ticketUpdatePollTimer = null; let audioContext = null; let mediaStream = null; let sourceNode = null; @@ -168,6 +169,7 @@ let realtimePollInFlight = false; const handledTranscriptKeys = new Set(); const displayedVoiceMessages = new Set(); + const displayedTicketUpdateKeys = new Set(); let lastRelayError = ''; let lastPollError = ''; let lastPollErrorAt = 0; @@ -810,13 +812,17 @@ function renderTicketWidget(data) { const ticketId = normalizeDisplayText(String(data.ticketId ?? 'N/A')); const status = normalizeDisplayText(String(data.status ?? 'OPEN')); + const action = normalizeDisplayText(String(data.action ?? '')); const assignedQueue = normalizeDisplayText(String(data.assignedQueue ?? 'L1-SUPPORT')); const summary = normalizeDisplayText(String(data.summary ?? '')); const message = normalizeDisplayText(String(data.message ?? '')); const card = document.createElement('div'); card.className = 'ticket'; + const title = action + ? `Ticket ${action.charAt(0).toUpperCase()}${action.slice(1).toLowerCase()}` + : 'Ticket Update'; card.innerHTML = ` -

Ticket Created

+

${title}

ID: ${ticketId}
Status: ${status}
Queue: ${assignedQueue}
@@ -826,6 +832,63 @@

Ticket Created

return card; } + function clearTicketUpdatePolling() { + if (ticketUpdatePollTimer) { + clearInterval(ticketUpdatePollTimer); + ticketUpdatePollTimer = null; + } + } + + function ticketUpdateKey(event) { + return [ + event?.timestamp || '', + event?.taskId || '', + event?.source || '', + event?.text || '' + ].join('|'); + } + + async function pollTicketUpdates() { + if (!sessionId) { + return; + } + try { + const params = new URLSearchParams({ + conversationId: sessionId, + sessionId, + limit: '120' + }); + const response = await fetch(`/audit/conversation/session-data?${params.toString()}`); + if (!response.ok) { + return; + } + const payload = await response.json(); + const events = Array.isArray(payload?.events) ? payload.events : []; + for (const event of events) { + if (String(event?.source || '') !== 'a2a.ticketing') { + continue; + } + const key = ticketUpdateKey(event); + if (displayedTicketUpdateKeys.has(key)) { + continue; + } + displayedTicketUpdateKeys.add(key); + const widget = normalizeWidgetCandidate(event?.payload?.widget) || normalizeWidgetCandidate(event?.payload); + tryAddVoiceMessage('agent', String(event?.text || 'Ticket service update'), 'a2a.ticketing', widget, key); + } + } catch (_) { + } + } + + function startTicketUpdatePolling() { + clearTicketUpdatePolling(); + displayedTicketUpdateKeys.clear(); + ticketUpdatePollTimer = setInterval(() => { + pollTicketUpdates(); + }, 2000); + pollTicketUpdates(); + } + function parseSseEvents(payload) { const blocks = payload.split(/\n\n+/).map((chunk) => chunk.trim()).filter(Boolean); const events = []; @@ -1838,6 +1901,7 @@

Ticket Created

const previousSessionId = sessionId; sessionId = `session-${Date.now()}`; + startTicketUpdatePolling(); realtimeEventQueue = Promise.resolve(); setVoiceStatus('WebRTC token failed (400), reinitializing session...'); addMessage('agent', `WebRTC token 400 for ${previousSessionId}; retrying with fresh session ${sessionId}.`); @@ -2939,6 +3003,7 @@

Ticket Created

.then(() => initializeRealtimeSessionContext(buildVoiceSessionPatch())) .catch(() => { }); + startTicketUpdatePolling(); syncInstructionDebugVisibility(); openInstructionDebugForWebRtc(); diff --git a/samples/agent-support-service/src/main/resources/routes/admin-platform.camel.yaml b/samples/agent-support-service/src/main/resources/routes/admin-platform.camel.yaml index c684349..b23e38f 100644 --- a/samples/agent-support-service/src/main/resources/routes/admin-platform.camel.yaml +++ b/samples/agent-support-service/src/main/resources/routes/admin-platform.camel.yaml @@ -283,6 +283,18 @@ id: processSampleAuditConversationAgentMessage ref: auditConversationAgentMessageProcessor +- route: + id: sample-audit-conversation-session-data-api + from: + id: fromSampleAuditConversationSessionData + uri: undertow + parameters: + httpURI: http://0.0.0.0:{{agui.rpc.port:8080}}/audit/conversation/session-data?httpMethodRestrict=GET + steps: + - process: + id: processSampleAuditConversationSessionData + ref: auditConversationSessionDataProcessor + - route: id: sample-audit-agent-catalog-api from: diff --git a/samples/agent-support-service/src/main/resources/routes/kb-search-json.camel.xml b/samples/agent-support-service/src/main/resources/routes/kb-search-json.camel.xml index 2441a8a..94aa852 100644 --- a/samples/agent-support-service/src/main/resources/routes/kb-search-json.camel.xml +++ b/samples/agent-support-service/src/main/resources/routes/kb-search-json.camel.xml @@ -2,8 +2,6 @@ - - {"ticketId":"TCK-${exchangeId}","status":"OPEN","summary":"${body[query]}","assignedQueue":"L1-SUPPORT","message":"Support ticket created successfully"} - + diff --git a/samples/agent-support-service/src/main/resources/routes/ticket-service.camel.yaml b/samples/agent-support-service/src/main/resources/routes/ticket-service.camel.yaml new file mode 100644 index 0000000..978fbec --- /dev/null +++ b/samples/agent-support-service/src/main/resources/routes/ticket-service.camel.yaml @@ -0,0 +1,9 @@ +- route: + id: support-ticket-manage-route + from: + id: fromSupportTicketManage + uri: direct:support-ticket-manage + steps: + - process: + id: processSupportTicketManage + ref: ticketLifecycleProcessor diff --git a/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/AgUiPlaywrightAuditTrailIntegrationTest.java b/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/AgUiPlaywrightAuditTrailIntegrationTest.java index dbeb73d..6278afd 100644 --- a/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/AgUiPlaywrightAuditTrailIntegrationTest.java +++ b/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/AgUiPlaywrightAuditTrailIntegrationTest.java @@ -1,10 +1,6 @@ package io.dscope.camel.agent.samples; import com.fasterxml.jackson.databind.ObjectMapper; -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.BrowserType; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Playwright; import io.dscope.camel.agent.model.AiToolCall; import io.dscope.camel.agent.runtime.AgentRuntimeBootstrap; import io.dscope.camel.agent.springai.SpringAiChatGateway; @@ -18,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; +import java.util.Map; import org.apache.camel.main.Main; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -34,50 +31,43 @@ void shouldMatchCopilotPageInputOutputWithAuditTrail() throws Exception { String queryToken = "pw-audit-" + System.currentTimeMillis(); String prompt = "Please open a support ticket for repeated login failures " + queryToken; main.bind("springAiChatGateway", new DeterministicTicketGateway()); + main.bind("ticketLifecycleProcessor", new SupportTicketLifecycleProcessor(new ObjectMapper())); try { AgentRuntimeBootstrap.bootstrap(main, "ag-ui-playwright-audit-test.yaml"); + SampleAdminMcpBindings.bindIfMissing(main, "ag-ui-playwright-audit-test.yaml"); main.start(); - try (Playwright playwright = Playwright.create()) { - Browser browser = playwright.chromium().launch( - new BrowserType.LaunchOptions() - .setHeadless(true) - ); - try { - Page page = browser.newPage(); - page.navigate("http://127.0.0.1:" + port + "/agui/ui"); - - String conversationId = String.valueOf(page.evaluate("() => String(sessionId || '')")); - Assertions.assertFalse(conversationId.isBlank(), "UI conversation/session id should be initialized"); - - page.locator("#prompt").fill(prompt); - page.locator("button[type='submit']").click(); - - page.locator(".msg.agent").last().waitFor(); - String uiOutput = page.locator(".msg.agent").last().innerText(); - - Assertions.assertTrue( - uiOutput.contains("Support ticket created successfully"), - "UI should render deterministic ticket response" - ); - - String conversationAudit = waitForConversationHit(port, conversationId, queryToken); - Assertions.assertTrue( - containsAnyIgnoreCase(conversationAudit, conversationId, queryToken), - "Audit conversation listing should include input/query token" - ); - - String auditJson = waitForAudit(port, conversationId, "Support ticket created successfully"); - Assertions.assertTrue(auditJson.contains(conversationId), "Audit trail should include same conversation id"); - Assertions.assertTrue( - containsAnyIgnoreCase(auditJson, "Support ticket created successfully", "support.ticket.open"), - "Audit search should include assistant/tool output" - ); - } finally { - browser.close(); - } - } + String conversationId = "agui-http-it-" + System.currentTimeMillis(); + HttpResult uiPage = get(port, "/agui/ui"); + Assertions.assertEquals(200, uiPage.statusCode()); + Assertions.assertTrue(uiPage.body().contains("plan-name"), "UI should expose the plan selector"); + + ObjectMapper mapper = new ObjectMapper(); + String requestBody = mapper.writeValueAsString(Map.of( + "threadId", conversationId, + "sessionId", conversationId, + "messages", List.of(Map.of("role", "user", "content", prompt)) + )); + HttpResult aguiResponse = post(port, "/agui/agent", requestBody); + Assertions.assertEquals(200, aguiResponse.statusCode()); + Assertions.assertTrue( + aguiResponse.body().contains("Support ticket created successfully"), + "AGUI response should include deterministic ticket output" + ); + + String conversationAudit = waitForConversationHit(port, conversationId, queryToken); + Assertions.assertTrue( + containsAnyIgnoreCase(conversationAudit, conversationId, queryToken), + "Audit conversation listing should include input/query token" + ); + + String auditJson = waitForAudit(port, conversationId, "Support ticket created successfully"); + Assertions.assertTrue(auditJson.contains(conversationId), "Audit trail should include same conversation id"); + Assertions.assertTrue( + containsAnyIgnoreCase(auditJson, "Support ticket created successfully", "support.ticket.manage"), + "Audit search should include assistant/tool output" + ); } finally { try { main.stop(); @@ -120,7 +110,7 @@ private static String waitForAudit(int port, String conversationId, String expec ); if (result.statusCode() == 200) { lastBody = result.body(); - if (containsAnyIgnoreCase(lastBody, expectedOutput, "support.ticket.open")) { + if (containsAnyIgnoreCase(lastBody, expectedOutput, "support.ticket.manage")) { return lastBody; } } @@ -150,6 +140,18 @@ private static HttpResult get(int port, String path) throws IOException, Interru return new HttpResult(response.statusCode(), response.body() == null ? "" : response.body()); } + private static HttpResult post(int port, String path, String body) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)) + .build(); + + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + return new HttpResult(response.statusCode(), response.body() == null ? "" : response.body()); + } + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } @@ -176,7 +178,7 @@ public SpringAiChatResult generate(String systemPrompt, Integer maxTokens, java.util.function.Consumer streamingTokenCallback) { String query = userContext == null ? "" : userContext; - AiToolCall call = new AiToolCall("support.ticket.open", mapper.createObjectNode().put("query", query)); + AiToolCall call = new AiToolCall("support.ticket.manage", mapper.createObjectNode().put("query", query)); return new SpringAiChatResult("", List.of(call), true); } } diff --git a/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/McpAdminApiIntegrationTest.java b/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/McpAdminApiIntegrationTest.java index ce5283a..7e84ada 100644 --- a/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/McpAdminApiIntegrationTest.java +++ b/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/McpAdminApiIntegrationTest.java @@ -28,8 +28,10 @@ void shouldServeMcpAdminToolsAndExecuteAuditRoundtrip() throws Exception { System.setProperty("agent.runtime.test-port", Integer.toString(port)); Main main = new Main(); + main.bind("ticketLifecycleProcessor", new SupportTicketLifecycleProcessor(MAPPER)); try { AgentRuntimeBootstrap.bootstrap(main, "ag-ui-playwright-audit-test.yaml"); + SampleAdminMcpBindings.bindIfMissing(main, "ag-ui-playwright-audit-test.yaml"); main.start(); JsonNode initialize = mcpCall(port, "initialize", Map.of( @@ -54,6 +56,7 @@ void shouldServeMcpAdminToolsAndExecuteAuditRoundtrip() throws Exception { Assertions.assertTrue(catalogStructured.path("plans").isArray()); Assertions.assertTrue(containsPlan(catalogStructured.path("plans"), "support")); Assertions.assertTrue(containsPlan(catalogStructured.path("plans"), "billing")); + Assertions.assertTrue(containsPlan(catalogStructured.path("plans"), "ticketing")); Assertions.assertTrue(isDefaultPlan(catalogStructured.path("plans"), "support")); Assertions.assertTrue(isDefaultVersion(catalogStructured.path("plans"), "support", "v1")); diff --git a/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/SpringAiAuditTrailIntegrationTest.java b/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/SpringAiAuditTrailIntegrationTest.java index 5f2e8da..8f4f3db 100644 --- a/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/SpringAiAuditTrailIntegrationTest.java +++ b/samples/agent-support-service/src/test/java/io/dscope/camel/agent/samples/SpringAiAuditTrailIntegrationTest.java @@ -81,7 +81,7 @@ void shouldUseLlmDecisionAndCarryFirstTurnResultIntoSecondTurnContext() { "Now please file a support ticket using that knowledge base result."); Assertions.assertEquals("kb.search", toolName(first.events())); - Assertions.assertEquals("support.ticket.open", toolName(second.events())); + Assertions.assertEquals("support.ticket.manage", toolName(second.events())); Assertions.assertNotNull(gateway.secondTurnContext); Assertions.assertTrue(gateway.secondTurnContext.contains("Knowledge base result for"), "Second turn LLM evaluation should include first-turn KB tool result in context"); @@ -134,7 +134,7 @@ void shouldUseKnowledgeBaseBeforeTicketAndCarryContextAcrossTurns() { .anyMatch(e -> "kb.search".equals(e.payload().path("name").asText()))); Assertions.assertTrue(auditTrail.stream() .filter(e -> "tool.start".equals(e.type())) - .anyMatch(e -> "support.ticket.open".equals(e.payload().path("name").asText()))); + .anyMatch(e -> "support.ticket.manage".equals(e.payload().path("name").asText()))); Assertions.assertTrue(auditTrail.stream() .filter(e -> "tool.start".equals(e.type())) .anyMatch(e -> e.payload().path("arguments").path("query").asText("").contains("Knowledge base result for"))); @@ -178,7 +178,7 @@ void shouldNotInjectKnowledgeBaseContextWhenTicketIsFirstPrompt() { List auditTrail = persistenceFacade.loadConversation(conversationId, 50); Assertions.assertTrue(auditTrail.stream() .filter(e -> "tool.start".equals(e.type())) - .anyMatch(e -> "support.ticket.open".equals(e.payload().path("name").asText()))); + .anyMatch(e -> "support.ticket.manage".equals(e.payload().path("name").asText()))); Assertions.assertFalse(auditTrail.stream() .filter(e -> "tool.start".equals(e.type())) .anyMatch(e -> e.payload().path("arguments").path("query").asText("").contains("Knowledge base result for"))); @@ -194,10 +194,11 @@ private ToolResult executeTool(String toolName, JsonNode arguments, ObjectMapper ObjectNode data = mapper.createObjectNode().put("answer", "Knowledge base result for " + query); return new ToolResult(data.path("answer").asText(), data, List.of()); } - if ("support.ticket.open".equals(toolName)) { + if ("support.ticket.manage".equals(toolName)) { ObjectNode data = mapper.createObjectNode() .put("ticketId", "TCK-" + UUID.randomUUID()) .put("status", "OPEN") + .put("action", "OPEN") .put("summary", query) .put("assignedQueue", "L1-SUPPORT") .put("message", "Support ticket created successfully"); @@ -271,7 +272,7 @@ private List selectTools(String userText, String userContext) { String query = sawKnowledgeBaseInSecondTurn ? "Escalate with prior context: " + kbResult : userText; - return List.of(new AiToolCall("support.ticket.open", objectMapper.createObjectNode().put("query", query))); + return List.of(new AiToolCall("support.ticket.manage", objectMapper.createObjectNode().put("query", query))); } return List.of(); } diff --git a/samples/agent-support-service/src/test/resources/ag-ui-playwright-audit-test.yaml b/samples/agent-support-service/src/test/resources/ag-ui-playwright-audit-test.yaml index 35e8ca9..13c398a 100644 --- a/samples/agent-support-service/src/test/resources/ag-ui-playwright-audit-test.yaml +++ b/samples/agent-support-service/src/test/resources/ag-ui-playwright-audit-test.yaml @@ -11,8 +11,12 @@ agent: audit: granularity: debug runtime: - routes-include-pattern: classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/admin-platform.camel.yaml + routes-include-pattern: classpath:routes/kb-search.camel.yaml,classpath:routes/kb-search-json.camel.xml,classpath:routes/ticket-service.camel.yaml,classpath:routes/ag-ui-platform.camel.yaml,classpath:routes/admin-platform.camel.yaml agent-routes-enabled: true + a2a: + enabled: true + public-base-url: http://127.0.0.1:{{agent.runtime.test-port:18081}} + exposed-agents-config: classpath:agents/a2a-exposed-agents.yaml ai: mode: spring-ai agui: @@ -22,7 +26,7 @@ agent: agent-endpoint-uri: agent:support?plansConfig={{agent.agents-config}}&blueprint={{agent.blueprint}} fallback-enabled: false fallback: - ticket-keywords: ticket,open,create,support,login + ticket-keywords: ticket,open,create,update,close,status,support,login error-markers: api key is missing,openai api key,set -dopenai.api.key realtime: bind-relay: false