Skip to content

Commit 89a2cc1

Browse files
authored
Merge pull request #1049 from dgageot/a2a-tools
Support remote a2a tool
2 parents aebc1a8 + c234caa commit 89a2cc1

File tree

12 files changed

+382
-9
lines changed

12 files changed

+382
-9
lines changed

cagent-schema.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,8 @@
351351
"shell",
352352
"todo",
353353
"fetch",
354-
"api"
354+
"api",
355+
"a2a"
355356
]
356357
},
357358
"instruction": {
@@ -457,6 +458,15 @@
457458
"type": "integer",
458459
"description": "Timeout in seconds for the fetch tool",
459460
"minimum": 1
461+
},
462+
"url": {
463+
"type": "string",
464+
"description": "URL for the a2a tool",
465+
"format": "uri"
466+
},
467+
"name": {
468+
"type": "string",
469+
"description": "Name for the a2a tool"
460470
}
461471
},
462472
"additionalProperties": false,
@@ -521,6 +531,22 @@
521531
]
522532
}
523533
]
534+
},
535+
{
536+
"allOf": [
537+
{
538+
"properties": {
539+
"type": {
540+
"const": "a2a"
541+
}
542+
}
543+
},
544+
{
545+
"required": [
546+
"url"
547+
]
548+
}
549+
]
524550
}
525551
]
526552
},

cmd/root/a2a.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/spf13/cobra"
77

88
"github.com/docker/cagent/pkg/a2a"
9+
"github.com/docker/cagent/pkg/cli"
910
"github.com/docker/cagent/pkg/config"
1011
"github.com/docker/cagent/pkg/server"
1112
"github.com/docker/cagent/pkg/telemetry"
@@ -43,6 +44,7 @@ func (f *a2aFlags) runA2ACommand(cmd *cobra.Command, args []string) error {
4344
telemetry.TrackCommand("a2a", args)
4445

4546
ctx := cmd.Context()
47+
out := cli.NewPrinter(cmd.OutOrStdout())
4648
agentFilename := args[0]
4749

4850
// Listen as early as possible
@@ -55,5 +57,6 @@ func (f *a2aFlags) runA2ACommand(cmd *cobra.Command, args []string) error {
5557
_ = ln.Close()
5658
}()
5759

60+
out.Println("Listening on", ln.Addr().String())
5861
return a2a.Run(ctx, agentFilename, f.agentName, &f.runConfig, ln)
5962
}

cmd/root/debug.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ func (f *debugFlags) runDebugToolsetsCommand(cmd *cobra.Command, args []string)
9999

100100
out.Printf("%d tool(s) for %s:\n", len(tools), agent.Name())
101101
for _, tool := range tools {
102-
out.Println(" +", tool.Name)
102+
out.Println(" +", tool.Name, "-", tool.Description)
103103
}
104104
}
105105

e2e/cagent_a2a_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func TestA2AServer_AgentCard(t *testing.T) {
4444
_, runConfig := startRecordingAIProxy(t)
4545
agentCard := startA2AServer(t, "testdata/basic.yaml", runConfig)
4646

47-
assert.Equal(t, "root", agentCard.Name)
47+
assert.Equal(t, "basic", agentCard.Name)
4848
assert.NotEmpty(t, agentCard.Description)
4949
assert.Equal(t, a2a.TransportProtocolJSONRPC, agentCard.PreferredTransport)
5050
assert.Contains(t, agentCard.URL, "/invoke")

e2e/cagent_debug_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func TestDebug_Toolsets_Todo(t *testing.T) {
2525

2626
output := cagentDebug(t, "toolsets", "testdata/todo_tools.yaml")
2727

28-
require.Equal(t, "2 tool(s) for root:\n + create_todo\n + list_todos\n", output)
28+
require.Equal(t, "2 tool(s) for root:\n + create_todo - Create a new todo item with a description\n + list_todos - List all current todos with their status\n", output)
2929
}
3030

3131
func cagentDebug(t *testing.T, moreArgs ...string) string {

examples/tic-tac-toe.yaml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env cagent run
2+
3+
metadata:
4+
readme: |
5+
This example demonstrates a Tic-Tac-Toe game between two agents, "player_blue" and "player_red",
6+
organized by a root agent. The root agent manages the game state, enforces the rules, and declares the winner or a draw.
7+
Each player agent makes moves based on the current state of the board.
8+
9+
To run the demo:
10+
cagent a2a --port 8080 ./examples/tic-tac-toe.yaml --agent player_blue
11+
cagent a2a --port 8081 ./examples/tic-tac-toe.yaml --agent player_red
12+
cagent run --yolo ./examples/tic-tac-toe.yaml 'Go!'
13+
14+
agents:
15+
# Game Organizer
16+
root:
17+
model: anthropic/claude-opus-4-5
18+
description: A Tic-Tac-Toe game organizer
19+
instruction: |
20+
<identity>
21+
You are a Tic-Tac-Toe game organizer. Your goal is to make your two subagents play Tic-Tac-Toe against each other.
22+
You keep track of the game state, enforce the rules, and declare the winner or a draw at the end of the game.
23+
You communicate with your two subagents, "player_blue" and "player_red", to get their moves and update the game board accordingly.
24+
</identity>
25+
26+
<game>
27+
On each player's first turn, you inform them of their assigned mark: "X" for player_blue and "O" for player_red.
28+
</game>
29+
30+
<preparation>
31+
Before the game, you must prepare an html page that visually represents the Tic-Tac-Toe board.
32+
- Make it retro and super flashy!
33+
- Serve this page on a local web server that you runs as a background job. (Stop it at the end of the game.)
34+
- Always work in /tmp
35+
- IMPORTANT: Make sure you can ping the page before starting the game. We don't want users to miss the game!
36+
- The page shows the user the current state of the board and is updated live with javascript and live-reload.
37+
- On each turn, you update the page to reflect the current state of the board. It MUST auto-refresh with you update the state/
38+
- IMPORTANT: You must open a browser on this URL before the players start to play, for the user to follow the game.
39+
- (The url can't be on port 8080 or 8081, as those are used by the players' agents.)
40+
</preparation>
41+
add_environment_info: true
42+
toolsets:
43+
- type: shell
44+
- type: filesystem
45+
- type: a2a
46+
name: player_blue
47+
url: http://localhost:8080/
48+
- type: a2a
49+
name: player_red
50+
url: http://localhost:8081/
51+
52+
# BLUE Player
53+
player_blue:
54+
model: openai/gpt-4.1-nano
55+
description: A Tic-Tac-Toe player
56+
instruction: |
57+
You know how to play Tic-Tac-Toe. You will be playing against another agent in a game organized by a third agent.
58+
When it's your turn, you will receive the current state of the board and you need to choose your move by specifying
59+
the row and column (0-indexed) where you want to place your mark (X or O).
60+
61+
# RED Player
62+
player_red:
63+
model: anthropic/claude-haiku-4-5
64+
description: A Tic-Tac-Toe player
65+
instruction: |
66+
You know how to play Tic-Tac-Toe. You will be playing against another agent in a game organized by a third agent.
67+
When it's your turn, you will receive the current state of the board and you need to choose your move by specifying
68+
the row and column (0-indexed) where you want to place your mark (X or O).

pkg/a2a/server.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"net"
88
"net/http"
99
"net/url"
10+
"path/filepath"
11+
"strings"
1012

1113
"github.com/a2aproject/a2a-go/a2a"
1214
"github.com/a2aproject/a2a-go/a2asrv"
@@ -48,11 +50,18 @@ func Run(ctx context.Context, agentFilename, agentName string, runConfig *config
4850

4951
slog.Debug("A2A server listening", "url", baseURL.String())
5052

53+
name := strings.TrimSuffix(filepath.Base(agentFilename), filepath.Ext(agentFilename))
54+
5155
agentPath := "/invoke"
5256
agentCard := &a2a.AgentCard{
53-
Name: adkAgent.Name(),
54-
Description: adkAgent.Description(),
55-
Skills: adka2a.BuildAgentSkills(adkAgent),
57+
Name: name,
58+
Description: adkAgent.Description(),
59+
Skills: []a2a.AgentSkill{{
60+
ID: name,
61+
Name: "main",
62+
Description: adkAgent.Description(),
63+
Tags: []string{"llm", "cagent"},
64+
}},
5665
PreferredTransport: a2a.TransportProtocolJSONRPC,
5766
URL: baseURL.JoinPath(agentPath).String(),
5867
Capabilities: a2a.AgentCapabilities{Streaming: true},
@@ -63,7 +72,7 @@ func Run(ctx context.Context, agentFilename, agentName string, runConfig *config
6372

6473
executor := newExecutorWrapper(adka2a.ExecutorConfig{
6574
RunnerConfig: runner.Config{
66-
AppName: adkAgent.Name(),
75+
AppName: name,
6776
Agent: adkAgent,
6877
SessionService: session.InMemoryService(),
6978
},

pkg/config/latest/types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ type Toolset struct {
133133
Remote Remote `json:"remote,omitempty"`
134134
Config any `json:"config,omitempty"`
135135

136+
// For the `a2a` tool
137+
Name string `json:"name,omitempty"`
138+
URL string `json:"url,omitempty"`
139+
136140
// For `shell`, `script` or `mcp` tools
137141
Env map[string]string `json:"env,omitempty"`
138142

pkg/config/latest/validate.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,21 @@ func (t *Toolset) validate() error {
5757
if t.Ref != "" && t.Type != "mcp" {
5858
return errors.New("ref can only be used with type 'mcp'")
5959
}
60-
if (t.Remote.URL != "" || t.Remote.TransportType != "" || len(t.Remote.Headers) > 0) && t.Type != "mcp" {
60+
if (t.Remote.URL != "" || t.Remote.TransportType != "") && t.Type != "mcp" {
6161
return errors.New("remote can only be used with type 'mcp'")
6262
}
63+
if (len(t.Remote.Headers) > 0) && (t.Type != "mcp" && t.Type != "a2a") {
64+
return errors.New("headers can only be used with type 'mcp' or 'a2a'")
65+
}
6366
if t.Config != nil && t.Type != "mcp" {
6467
return errors.New("config can only be used with type 'mcp'")
6568
}
69+
if t.URL != "" && t.Type != "a2a" {
70+
return errors.New("url can only be used with type 'a2a'")
71+
}
72+
if t.Name != "" && t.Type != "a2a" {
73+
return errors.New("name can only be used with type 'a2a'")
74+
}
6675

6776
switch t.Type {
6877
case "memory":
@@ -90,6 +99,10 @@ func (t *Toolset) validate() error {
9099
if t.Ref != "" && !strings.Contains(t.Ref, "docker:") {
91100
return errors.New("only docker refs are supported for MCP tools, e.g., 'docker:context7'")
92101
}
102+
case "a2a":
103+
if t.URL == "" {
104+
return errors.New("a2a toolset requires a url to be set")
105+
}
93106
}
94107

95108
return nil

pkg/httpclient/client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ func WithHeader(key, value string) Opt {
4343
}
4444
}
4545

46+
func WithHeaders(headers map[string]string) Opt {
47+
return func(o *HTTPOptions) {
48+
for k, v := range headers {
49+
o.Header.Add(k, v)
50+
}
51+
}
52+
}
53+
4654
func WithProxiedBaseURL(value string) Opt {
4755
return func(o *HTTPOptions) {
4856
o.Header.Set("X-Cagent-Forward", value)

0 commit comments

Comments
 (0)