Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5be939f
feat: add initial A2A runtime integration
rdobrik Mar 12, 2026
1f14452
feat: add outbound A2A tool client support
rdobrik Mar 13, 2026
1f86198
Align agent A2A with 1.0.0
rdobrik Mar 14, 2026
c79eef6
Document multi-agent catalog setup
rdobrik Mar 14, 2026
58da109
Initial plan
Copilot Mar 17, 2026
17799f9
Address PR review feedback: fix A2A runtime, tool client, and catalog…
Copilot Mar 17, 2026
b039537
Update docs/roadmap.md
rdobrik Mar 17, 2026
881b8ba
Initial plan
Copilot Mar 17, 2026
f3d5e93
Potential fix for pull request finding
rdobrik Mar 17, 2026
9981ce1
Potential fix for pull request finding
rdobrik Mar 17, 2026
1c07aaa
Potential fix for pull request finding
rdobrik Mar 17, 2026
6f3b362
Potential fix for pull request finding
rdobrik Mar 17, 2026
282ec11
fix: use CopyOnWriteArrayList for thread-safe conversation history in…
Copilot Mar 17, 2026
03503d2
fix: use CopyOnWriteArrayList for thread-safe conversation history in…
Copilot Mar 17, 2026
11d5c78
chore: remove accidentally committed maven wrapper binaries
Copilot Mar 17, 2026
ee19bfb
Initial plan
Copilot Mar 17, 2026
bfbe89c
Initial plan
Copilot Mar 17, 2026
20f063a
Normalize trimmed values for all required A2AExposedAgentSpec fields
Copilot Mar 17, 2026
9fa7ea2
Fix persistence backend compatibility and response gateway tests
rdobrik Mar 18, 2026
54a688c
Merge pull request #6 from dscope-io/copilot/sub-pr-1-one-more-time
rdobrik Mar 18, 2026
0c49bdd
Merge pull request #4 from dscope-io/copilot/sub-pr-1-another-one
rdobrik Mar 18, 2026
4b35fe8
Merge pull request #3 from dscope-io/copilot/sub-pr-1-again
rdobrik Mar 18, 2026
9f4377a
Merge branch 'codex/add-a2a-support' into copilot/sub-pr-1
rdobrik Mar 18, 2026
ef12136
Merge pull request #2 from dscope-io/copilot/sub-pr-1
rdobrik Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .mvn/wrapper/maven-wrapper.jar
Binary file not shown.
18 changes: 18 additions & 0 deletions .mvn/wrapper/maven-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion camel-agent-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<parent>
<groupId>io.dscope.camel</groupId>
<artifactId>camel-agent</artifactId>
<version>0.5.0</version>
<version>0.6.0</version>
</parent>

<artifactId>camel-agent-core</artifactId>
Expand Down Expand Up @@ -50,6 +50,11 @@
<artifactId>camel-mcp</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>io.dscope.camel</groupId>
<artifactId>camel-a2a-component</artifactId>
<version>${a2a.version}</version>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<A2AExposedAgentSpec> agents;
private final Map<String, A2AExposedAgentSpec> byId;
private final A2AExposedAgentSpec defaultAgent;

public A2AExposedAgentCatalog(List<A2AExposedAgentSpec> agents) {
if (agents == null || agents.isEmpty()) {
throw new IllegalArgumentException("A2A exposed-agents catalog must contain at least one agent");
}
Map<String, A2AExposedAgentSpec> 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<A2AExposedAgentSpec> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<A2AExposedAgentSpec> 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<A2AExposedAgentSpec> agents;
public List<A2AExposedAgentSpec> exposedAgents;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> skills = List.of();
private Map<String, Object> 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<String> getSkills() {
return skills == null ? List.of() : skills;
}

public void setSkills(List<String> skills) {
this.skills = skills == null ? List.of() : List.copyOf(skills);
}

public Map<String, Object> getMetadata() {
return metadata == null ? Map.of() : metadata;
}

public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
}
}
Loading