Skip to content

Commit 2c3e558

Browse files
authored
a2a: support a2a compatible with adk (#731)
1 parent 9e038bf commit 2c3e558

File tree

14 files changed

+1418
-34
lines changed

14 files changed

+1418
-34
lines changed

agent/a2aagent/a2a_converter.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,13 @@ func processDataPart(part protocol.Part) (content string, toolCall *model.ToolCa
274274
return
275275
}
276276

277+
// Try both standard "type" and ADK-compatible "adk_type" metadata keys
277278
typeVal, hasType := d.Metadata[ia2a.DataPartMetadataTypeKey]
278279
if !hasType {
279-
return
280+
typeVal, hasType = d.Metadata[ia2a.GetADKMetadataKey(ia2a.DataPartMetadataTypeKey)]
281+
if !hasType {
282+
return
283+
}
280284
}
281285

282286
// Convert typeVal to string for comparison

agent/a2aagent/a2a_converter_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,177 @@ func TestConvertDataPartToToolResponse(t *testing.T) {
12501250
}
12511251
}
12521252

1253+
// TestProcessDataPart_ADKMetadataKey tests handling of ADK-compatible metadata keys
1254+
func TestProcessDataPart_ADKMetadataKey(t *testing.T) {
1255+
type testCase struct {
1256+
name string
1257+
dataPart protocol.Part
1258+
validateFunc func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo)
1259+
}
1260+
1261+
tests := []testCase{
1262+
{
1263+
name: "function call with adk_type metadata",
1264+
dataPart: &protocol.DataPart{
1265+
Data: map[string]any{
1266+
"id": "call-adk-1",
1267+
"type": "function",
1268+
"name": "get_weather",
1269+
"args": `{"location": "Beijing"}`,
1270+
},
1271+
Metadata: map[string]any{
1272+
"adk_type": "function_call", // Using ADK-compatible key
1273+
},
1274+
},
1275+
validateFunc: func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo) {
1276+
if toolCall == nil {
1277+
t.Fatal("expected tool call, got nil")
1278+
}
1279+
if toolCall.ID != "call-adk-1" {
1280+
t.Errorf("expected ID 'call-adk-1', got %s", toolCall.ID)
1281+
}
1282+
if toolCall.Function.Name != "get_weather" {
1283+
t.Errorf("expected name 'get_weather', got %s", toolCall.Function.Name)
1284+
}
1285+
if toolResp != nil {
1286+
t.Errorf("expected nil tool response, got %v", toolResp)
1287+
}
1288+
},
1289+
},
1290+
{
1291+
name: "function response with adk_type metadata",
1292+
dataPart: &protocol.DataPart{
1293+
Data: map[string]any{
1294+
"id": "call-adk-2",
1295+
"name": "get_weather",
1296+
"response": "Beijing: Sunny, 20°C",
1297+
},
1298+
Metadata: map[string]any{
1299+
"adk_type": "function_response", // Using ADK-compatible key
1300+
},
1301+
},
1302+
validateFunc: func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo) {
1303+
if content != "Beijing: Sunny, 20°C" {
1304+
t.Errorf("expected content 'Beijing: Sunny, 20°C', got %s", content)
1305+
}
1306+
if toolResp == nil {
1307+
t.Fatal("expected tool response info, got nil")
1308+
}
1309+
if toolResp.id != "call-adk-2" {
1310+
t.Errorf("expected tool response ID 'call-adk-2', got %s", toolResp.id)
1311+
}
1312+
if toolResp.name != "get_weather" {
1313+
t.Errorf("expected tool response name 'get_weather', got %s", toolResp.name)
1314+
}
1315+
if toolCall != nil {
1316+
t.Errorf("expected nil tool call, got %v", toolCall)
1317+
}
1318+
},
1319+
},
1320+
{
1321+
name: "no type metadata",
1322+
dataPart: &protocol.DataPart{
1323+
Data: map[string]any{
1324+
"id": "call-no-type",
1325+
"name": "some_function",
1326+
},
1327+
Metadata: map[string]any{
1328+
"other_key": "other_value",
1329+
},
1330+
},
1331+
validateFunc: func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo) {
1332+
if content != "" {
1333+
t.Errorf("expected empty content, got %s", content)
1334+
}
1335+
if toolCall != nil {
1336+
t.Errorf("expected nil tool call, got %v", toolCall)
1337+
}
1338+
if toolResp != nil {
1339+
t.Errorf("expected nil tool response, got %v", toolResp)
1340+
}
1341+
},
1342+
},
1343+
{
1344+
name: "nil metadata",
1345+
dataPart: &protocol.DataPart{
1346+
Data: map[string]any{
1347+
"id": "call-nil-meta",
1348+
"name": "some_function",
1349+
},
1350+
Metadata: nil,
1351+
},
1352+
validateFunc: func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo) {
1353+
if content != "" {
1354+
t.Errorf("expected empty content, got %s", content)
1355+
}
1356+
if toolCall != nil {
1357+
t.Errorf("expected nil tool call, got %v", toolCall)
1358+
}
1359+
if toolResp != nil {
1360+
t.Errorf("expected nil tool response, got %v", toolResp)
1361+
}
1362+
},
1363+
},
1364+
{
1365+
name: "non-string type value",
1366+
dataPart: &protocol.DataPart{
1367+
Data: map[string]any{
1368+
"id": "call-bad-type",
1369+
"name": "some_function",
1370+
},
1371+
Metadata: map[string]any{
1372+
"type": 12345, // Non-string type value
1373+
},
1374+
},
1375+
validateFunc: func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo) {
1376+
if content != "" {
1377+
t.Errorf("expected empty content, got %s", content)
1378+
}
1379+
if toolCall != nil {
1380+
t.Errorf("expected nil tool call, got %v", toolCall)
1381+
}
1382+
if toolResp != nil {
1383+
t.Errorf("expected nil tool response, got %v", toolResp)
1384+
}
1385+
},
1386+
},
1387+
{
1388+
name: "standard type key takes precedence over adk_type",
1389+
dataPart: &protocol.DataPart{
1390+
Data: map[string]any{
1391+
"id": "call-precedence",
1392+
"type": "function",
1393+
"name": "test_func",
1394+
"args": `{"x": 1}`,
1395+
},
1396+
Metadata: map[string]any{
1397+
"type": "function_call", // Standard key
1398+
"adk_type": "function_response", // ADK key (should be ignored)
1399+
},
1400+
},
1401+
validateFunc: func(t *testing.T, content string, toolCall *model.ToolCall, toolResp *toolResponseInfo) {
1402+
// Should process as function_call, not function_response
1403+
if toolCall == nil {
1404+
t.Fatal("expected tool call, got nil")
1405+
}
1406+
if toolCall.Function.Name != "test_func" {
1407+
t.Errorf("expected name 'test_func', got %s", toolCall.Function.Name)
1408+
}
1409+
if toolResp != nil {
1410+
t.Errorf("expected nil tool response (standard key should take precedence), got %v", toolResp)
1411+
}
1412+
},
1413+
},
1414+
}
1415+
1416+
for _, tc := range tests {
1417+
t.Run(tc.name, func(t *testing.T) {
1418+
content, toolCall, toolResp := processDataPart(tc.dataPart)
1419+
tc.validateFunc(t, content, toolCall, toolResp)
1420+
})
1421+
}
1422+
}
1423+
12531424
// Helper function to create string pointer
12541425
func stringPtr(s string) *string {
12551426
return &s

examples/a2aadk/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# A2A ↔ ADK Interop Example
2+
3+
This example shows how the `trpc-agent-go` A2A client talks to an ADK Python A2A server.
4+
5+
- `trpc_agent/main.go`: connects to the ADK server via `a2aagent` and runs three sample prompts (two chats + one tool call).
6+
- `adk/adk_server.py`: builds an ADK A2A server with streaming + tools and logs user/session metadata.
7+
8+
## Directory layout
9+
10+
```
11+
examples/a2aadk/
12+
├── README.md # This guide
13+
├── trpc_agent/
14+
│ └── main.go # Go client that calls the ADK server
15+
└── adk/
16+
└── adk_server.py # ADK A2A server implementation
17+
```
18+
19+
## Prerequisites
20+
21+
| Component | Version | Notes |
22+
| --------- | ------- | ----- |
23+
| Go | 1.21+ | Needed for `trpc_agent/main.go` |
24+
| Python | 3.10+ (3.11 recommended) | Required by the ADK server |
25+
| Pip deps | See `adk/requirements.txt` | Install via `pip install -r requirements.txt` |
26+
| API key | `OPENAI_API_KEY` | Example server defaults to OpenAI-compatible models |
27+
28+
## Prepare the ADK/Python side
29+
30+
1. (Optional) Create a virtualenv:
31+
```bash
32+
cd trpc-agent-go/examples/a2aadk/adk
33+
python3 -m venv venv
34+
source venv/bin/activate
35+
```
36+
2. Install dependencies:
37+
```bash
38+
pip install -r requirements.txt
39+
```
40+
3. Configure model access:
41+
```bash
42+
export OPENAI_API_KEY="<your-openai-key>"
43+
# Optional overrides
44+
export MODEL_NAME="gpt-4o-mini"
45+
export OPENAI_API_URL="https://your-endpoint/v1"
46+
```
47+
48+
## Start the ADK A2A server
49+
50+
```bash
51+
cd trpc-agent-go/examples/a2aadk/adk
52+
python3 adk_server.py
53+
# Or: uvicorn adk_server:a2a_app --host 0.0.0.0 --port 8081
54+
```
55+
56+
Server highlights:
57+
58+
- Streaming responses plus two example tools: `calculator` and `current_time`.
59+
- `logging_request_converter` wraps `convert_a2a_request_to_agent_run_request` so each request prints `user_id` / `session_id` for debugging.
60+
- You can plug custom `request_converter` / auth modules into `A2aAgentExecutorConfig` to read `X-User-ID` headers and populate ADK sessions.
61+
62+
## Run the Go A2A client
63+
64+
```bash
65+
cd trpc-agent-go
66+
go run ./examples/a2aadk/trpc_agent --url http://localhost:8081
67+
```
68+
69+
Client behavior:
70+
71+
1. Sends three prompts in order (two normal chats plus one tool-using prompt).
72+
2. Streams tool-call/tool-response logs in real time but prints the assistant answer only once at the end.
73+
3. Automatically forwards local `userID` / `sessionID` through the `X-User-ID` HTTP header.
74+
4. Works around ADK's final-event content duplication by capturing content from intermediate events only (see "ADK-specific notes" below).
75+
76+
## ADK-specific notes
77+
78+
| Scenario | Details |
79+
| -------- | ------- |
80+
| No incremental text | ADK A2A events send "full content so far" in every delta (cumulative streaming). The client captures the last valid intermediate event rather than reading deltas incrementally. |
81+
| Final event duplication bug | **Important**: ADK's current A2A implementation may send a malformed final event that duplicates content or prepends the user's question. The client works around this by only capturing content from non-final events and ignoring the final event payload. |
82+
| User propagation | `a2aagent` places `Session.UserID` into the `X-User-ID` header. Override via `a2aagent.WithUserIDHeader(...)` if another header is needed. |
83+
| Reading user ID in ADK | Enable A2A auth/request converter components to extract the header or `AgentRunRequest.user_id` and store it in the session context. `logging_request_converter` in this example is a reference implementation; production setups can inject their own logic via `A2aAgentExecutorConfig`. |
84+
| Session diagnostics | Expect logs like `📋 Session Info - User: xxx, Session: xxx`. They confirm that the header propagated correctly without extra wiring. |
85+
86+
## FAQ
87+
88+
1. **"OPENAI_API_KEY not set" warning**: the server cannot call OpenAI without the env var. Run `export OPENAI_API_KEY=...` before starting `adk_server.py`.
89+
2. **Go client cannot connect**: ensure the ADK server is listening on the `--url` you pass (default `http://localhost:8081`). Adjust either the server port or the flag.
90+
3. **Need to disable ADK compatibility?** This example assumes an ADK peer, so trpc-agent-go keeps the `adk_` metadata prefix enabled. If you target a non-ADK service you can call `a2a.WithADKCompatibility(false)` in your own server, but no change is required here.
91+
92+
After completing the steps above, the Go client and ADK server will interoperate and you can observe tool invocations plus final answers directly in the terminal. Happy debugging!

0 commit comments

Comments
 (0)