openclaw-a2a 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/index.js +952 -0
- package/package.json +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomas Grasl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# openclaw-a2a
|
|
2
|
+
|
|
3
|
+
> What happens when AI agents start talking to each other?
|
|
4
|
+
> Let's find out.
|
|
5
|
+
|
|
6
|
+
[](https://github.com/freema/openclaw-a2a/actions/workflows/ci.yml)
|
|
7
|
+
[](https://www.npmjs.com/package/openclaw-a2a)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
**openclaw-a2a** is a bridge that lets any A2A-compatible agent chat with your
|
|
13
|
+
self-hosted [OpenClaw](https://openclaw.ai) assistant. Think of it as giving your
|
|
14
|
+
OpenClaw a phone number that other agents can call.
|
|
15
|
+
|
|
16
|
+
This is a **beta experiment**. We're exploring the bleeding edge of agent-to-agent
|
|
17
|
+
communication using Google's [A2A protocol](https://google.github.io/A2A/) v1.0.
|
|
18
|
+
The JS SDK is still on v0.3, so we implemented v1.0 from scratch. YOLO.
|
|
19
|
+
|
|
20
|
+
## The Idea
|
|
21
|
+
|
|
22
|
+
You already have OpenClaw running. Maybe on your laptop, maybe on a server.
|
|
23
|
+
It's smart, it has access to your tools, and it talks to your LLM.
|
|
24
|
+
|
|
25
|
+
Now imagine another agent — running somewhere else, built by someone else —
|
|
26
|
+
wants to ask your OpenClaw something. That's what A2A is for.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Google ADK Agent ──┐
|
|
30
|
+
CrewAI Agent ──────┤── A2A Protocol ──> openclaw-a2a ──> OpenClaw Gateway
|
|
31
|
+
Your Custom Agent ─┤ |
|
|
32
|
+
Claude.ai* ────────┘ (yes, streaming!)
|
|
33
|
+
|
|
34
|
+
* via A2A-MCP bridge (because Claude speaks MCP, not A2A... yet)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
### npm
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g openclaw-a2a@beta
|
|
43
|
+
|
|
44
|
+
# Start the bridge
|
|
45
|
+
openclaw-a2a --openclaw-url http://127.0.0.1:18789 --token your-token
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Docker
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
docker run --rm -p 3100:3100 \
|
|
52
|
+
-e OPENCLAW_URL=http://host.docker.internal:18789 \
|
|
53
|
+
-e OPENCLAW_GATEWAY_TOKEN=your-token \
|
|
54
|
+
ghcr.io/freema/openclaw-a2a:beta
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### From source
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/freema/openclaw-a2a.git
|
|
61
|
+
cd openclaw-a2a
|
|
62
|
+
npm install
|
|
63
|
+
cp .env.example .env # edit with your settings
|
|
64
|
+
npm run dev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Try It
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Discover the agent
|
|
71
|
+
curl http://localhost:3100/.well-known/agent-card.json | jq .
|
|
72
|
+
|
|
73
|
+
# Send a message (A2A v1.0)
|
|
74
|
+
curl -X POST http://localhost:3100/a2a \
|
|
75
|
+
-H "Content-Type: application/json" \
|
|
76
|
+
-H "A2A-Version: 1.0" \
|
|
77
|
+
-d '{
|
|
78
|
+
"jsonrpc": "2.0", "id": "1", "method": "SendMessage",
|
|
79
|
+
"params": {
|
|
80
|
+
"message": {
|
|
81
|
+
"messageId": "test-1",
|
|
82
|
+
"role": "ROLE_USER",
|
|
83
|
+
"parts": [{ "text": "Hello from A2A v1.0!" }]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}'
|
|
87
|
+
|
|
88
|
+
# Stream a response
|
|
89
|
+
curl -N -X POST http://localhost:3100/a2a \
|
|
90
|
+
-H "Content-Type: application/json" \
|
|
91
|
+
-H "A2A-Version: 1.0" \
|
|
92
|
+
-d '{
|
|
93
|
+
"jsonrpc": "2.0", "id": "2", "method": "SendStreamingMessage",
|
|
94
|
+
"params": {
|
|
95
|
+
"message": {
|
|
96
|
+
"messageId": "test-2",
|
|
97
|
+
"role": "ROLE_USER",
|
|
98
|
+
"parts": [{ "text": "Stream me something cool!" }]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
| Env Variable | Default | Description |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `OPENCLAW_URL` | — | OpenClaw Gateway URL (required) |
|
|
109
|
+
| `OPENCLAW_GATEWAY_TOKEN` | — | Bearer token for gateway auth |
|
|
110
|
+
| `OPENCLAW_INSTANCES` | — | JSON array for multi-instance routing |
|
|
111
|
+
| `PORT` | `3100` | Server port |
|
|
112
|
+
| `HOST` | `0.0.0.0` | Server host |
|
|
113
|
+
| `PUBLIC_URL` | `http://localhost:3100` | Public URL for Agent Card |
|
|
114
|
+
| `DEBUG` | `false` | Enable debug logging |
|
|
115
|
+
|
|
116
|
+
### Multi-instance
|
|
117
|
+
|
|
118
|
+
Route requests to different OpenClaw instances based on message metadata:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
export OPENCLAW_INSTANCES='[
|
|
122
|
+
{"name":"prod","url":"http://prod:18789","token":"t1","default":true},
|
|
123
|
+
{"name":"staging","url":"http://staging:18789","token":"t2"}
|
|
124
|
+
]'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Then send with `"metadata": { "instance": "staging" }` in your A2A message.
|
|
128
|
+
|
|
129
|
+
### CLI Options
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
openclaw-a2a --help
|
|
133
|
+
|
|
134
|
+
Options:
|
|
135
|
+
--port, -p Server port [number]
|
|
136
|
+
--host Server host [string]
|
|
137
|
+
--openclaw-url OpenClaw Gateway URL [string]
|
|
138
|
+
--token OpenClaw Gateway token [string]
|
|
139
|
+
--debug Enable debug logging [boolean]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## What's Inside
|
|
143
|
+
|
|
144
|
+
This implements the **A2A v1.0 specification** — the full protocol, not a subset:
|
|
145
|
+
|
|
146
|
+
| Feature | Status |
|
|
147
|
+
|---|---|
|
|
148
|
+
| Agent Card discovery (`.well-known/agent-card.json`) | Done |
|
|
149
|
+
| SendMessage (sync) | Done |
|
|
150
|
+
| SendStreamingMessage (SSE) | Done |
|
|
151
|
+
| GetTask / ListTasks | Done |
|
|
152
|
+
| CancelTask | Done |
|
|
153
|
+
| Multi-turn conversations (INPUT_REQUIRED) | Done |
|
|
154
|
+
| Multi-instance routing | Done |
|
|
155
|
+
| A2A-Version header validation | Done |
|
|
156
|
+
| Push notifications | Stub (returns UNSUPPORTED) |
|
|
157
|
+
| Extended Agent Card | Stub (returns UNSUPPORTED) |
|
|
158
|
+
| SubscribeToTask | Stub (returns UNSUPPORTED) |
|
|
159
|
+
|
|
160
|
+
### v1.0 Spec Compliance
|
|
161
|
+
|
|
162
|
+
We implement A2A v1.0 directly from the [proto spec](https://github.com/a2aproject/A2A):
|
|
163
|
+
|
|
164
|
+
- PascalCase method names (`SendMessage`, not `message/send`)
|
|
165
|
+
- SCREAMING_SNAKE enum values (`TASK_STATE_COMPLETED`, not `completed`)
|
|
166
|
+
- Part discrimination by field presence (not `kind`)
|
|
167
|
+
- `Task.id` (not `taskId`)
|
|
168
|
+
- `A2A-Version` header with fallback to `0.3` (rejected — we only support 1.0)
|
|
169
|
+
- Cursor-based pagination for ListTasks
|
|
170
|
+
- `supportedInterfaces[]` in Agent Card (not top-level `url`)
|
|
171
|
+
|
|
172
|
+
## Who Can Talk To This?
|
|
173
|
+
|
|
174
|
+
### Native A2A clients
|
|
175
|
+
|
|
176
|
+
| Client | Notes |
|
|
177
|
+
|---|---|
|
|
178
|
+
| [Google ADK](https://google.github.io/adk-docs/) | `RemoteA2aAgent` — most mature |
|
|
179
|
+
| [CrewAI](https://crewai.com/) >= v1.10 | Built-in A2A support |
|
|
180
|
+
| [Microsoft Agent Framework](https://github.com/microsoft/agents) | .NET + Python |
|
|
181
|
+
| [BeeAI](https://beeai.dev/) | A2A adapter |
|
|
182
|
+
| [LangGraph](https://langchain-ai.github.io/langgraph/) | Server + client |
|
|
183
|
+
| curl / any HTTP client | JSON-RPC over HTTP |
|
|
184
|
+
|
|
185
|
+
### Via A2A-MCP bridge (for Claude)
|
|
186
|
+
|
|
187
|
+
Claude doesn't speak A2A natively (it speaks MCP). But there's a bridge:
|
|
188
|
+
|
|
189
|
+
1. Run `openclaw-a2a` (this project) — gives OpenClaw an A2A interface
|
|
190
|
+
2. Add an [A2A-MCP bridge](https://github.com/GongRzhe/A2A-MCP-Server) to Claude
|
|
191
|
+
3. Claude discovers and chats with your OpenClaw through A2A
|
|
192
|
+
|
|
193
|
+
Is this useful? Maybe. Is it cool? Definitely.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
# Dev server (hot reload)
|
|
199
|
+
npm run dev
|
|
200
|
+
|
|
201
|
+
# Run tests (watch mode)
|
|
202
|
+
npm run test
|
|
203
|
+
|
|
204
|
+
# Full quality check
|
|
205
|
+
npm run check:all # lint + typecheck + test + build
|
|
206
|
+
|
|
207
|
+
# Format
|
|
208
|
+
npm run format
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Testing
|
|
212
|
+
|
|
213
|
+
- **84 unit + integration tests** — run without external dependencies
|
|
214
|
+
- **E2E tests** — require a real OpenClaw Gateway (optional)
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Unit + integration (no gateway needed)
|
|
218
|
+
npm run test:run
|
|
219
|
+
|
|
220
|
+
# E2E (requires gateway on localhost:18789)
|
|
221
|
+
export OPENCLAW_URL=http://127.0.0.1:18789
|
|
222
|
+
export OPENCLAW_GATEWAY_TOKEN=your-token
|
|
223
|
+
npm run test:e2e
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Docker Deployment
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
# Build locally
|
|
230
|
+
docker build -t openclaw-a2a .
|
|
231
|
+
|
|
232
|
+
# Or use Docker Compose
|
|
233
|
+
cp .env.example .env
|
|
234
|
+
docker compose up -d
|
|
235
|
+
|
|
236
|
+
# Dev mode (build from source)
|
|
237
|
+
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Sister Project
|
|
241
|
+
|
|
242
|
+
This is the A2A sibling of [openclaw-mcp](https://github.com/freema/openclaw-mcp),
|
|
243
|
+
which bridges OpenClaw to Claude via MCP. Different protocols, same OpenClaw.
|
|
244
|
+
|
|
245
|
+
| | openclaw-mcp | openclaw-a2a |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| Protocol | MCP (Anthropic) | A2A v1.0 (Google/Linux Foundation) |
|
|
248
|
+
| For | Claude.ai, Claude Desktop | Any A2A agent |
|
|
249
|
+
| Port | 3000 | 3100 |
|
|
250
|
+
| Status | Stable | Beta |
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
MCP = how an agent talks to TOOLS (vertical: agent → tools)
|
|
254
|
+
A2A = how AGENTS talk to EACH OTHER (horizontal: agent ↔ agent)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
They're complementary, not competing.
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
|
|
7
|
+
// src/config/index.ts
|
|
8
|
+
function parseInstances() {
|
|
9
|
+
const raw = process.env.OPENCLAW_INSTANCES;
|
|
10
|
+
if (raw) {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
14
|
+
throw new Error("OPENCLAW_INSTANCES must be a non-empty JSON array");
|
|
15
|
+
}
|
|
16
|
+
for (const inst of parsed) {
|
|
17
|
+
if (!inst.name || !inst.url) {
|
|
18
|
+
throw new Error(`Each instance must have "name" and "url". Got: ${JSON.stringify(inst)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const defaults = parsed.filter((i) => i.default);
|
|
22
|
+
if (defaults.length === 0) {
|
|
23
|
+
parsed[0].default = true;
|
|
24
|
+
}
|
|
25
|
+
return parsed;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e.message.includes("OPENCLAW_INSTANCES")) throw e;
|
|
28
|
+
throw new Error(`Failed to parse OPENCLAW_INSTANCES: ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const url = process.env.OPENCLAW_URL;
|
|
32
|
+
if (!url) {
|
|
33
|
+
throw new Error("OPENCLAW_URL or OPENCLAW_INSTANCES must be set");
|
|
34
|
+
}
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
name: "default",
|
|
38
|
+
url: url.replace(/\/+$/, ""),
|
|
39
|
+
token: process.env.OPENCLAW_GATEWAY_TOKEN ?? "",
|
|
40
|
+
default: true
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
function loadConfig() {
|
|
45
|
+
const port = parseInt(process.env.PORT ?? "3100", 10);
|
|
46
|
+
const host = process.env.HOST ?? "0.0.0.0";
|
|
47
|
+
const debug = process.env.DEBUG === "true";
|
|
48
|
+
const publicUrl = (process.env.PUBLIC_URL ?? `http://localhost:${port}`).replace(/\/+$/, "");
|
|
49
|
+
const instances = parseInstances();
|
|
50
|
+
return { port, host, debug, publicUrl, instances };
|
|
51
|
+
}
|
|
52
|
+
function getDefaultInstance(config2) {
|
|
53
|
+
return config2.instances.find((i) => i.default) ?? config2.instances[0];
|
|
54
|
+
}
|
|
55
|
+
function getInstanceByName(config2, name) {
|
|
56
|
+
return config2.instances.find((i) => i.name === name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/server/index.ts
|
|
60
|
+
import express from "express";
|
|
61
|
+
|
|
62
|
+
// src/a2a/executor.ts
|
|
63
|
+
import { v4 as uuid } from "uuid";
|
|
64
|
+
|
|
65
|
+
// src/utils/logger.ts
|
|
66
|
+
var debugEnabled = false;
|
|
67
|
+
function setDebug(enabled) {
|
|
68
|
+
debugEnabled = enabled;
|
|
69
|
+
}
|
|
70
|
+
function formatLog(level, message, data) {
|
|
71
|
+
const entry = {
|
|
72
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
73
|
+
level,
|
|
74
|
+
message
|
|
75
|
+
};
|
|
76
|
+
if (data) Object.assign(entry, data);
|
|
77
|
+
return JSON.stringify(entry);
|
|
78
|
+
}
|
|
79
|
+
function log(message, data) {
|
|
80
|
+
console.log(formatLog("info", message, data));
|
|
81
|
+
}
|
|
82
|
+
function logError(message, error, data) {
|
|
83
|
+
const extra = { ...data };
|
|
84
|
+
if (error instanceof Error) {
|
|
85
|
+
extra.error = error.message;
|
|
86
|
+
extra.stack = error.stack;
|
|
87
|
+
} else if (error !== void 0) {
|
|
88
|
+
extra.error = String(error);
|
|
89
|
+
}
|
|
90
|
+
console.error(formatLog("error", message, extra));
|
|
91
|
+
}
|
|
92
|
+
function logDebug(message, data) {
|
|
93
|
+
if (debugEnabled) {
|
|
94
|
+
console.log(formatLog("debug", message, data));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/openclaw/client.ts
|
|
99
|
+
var DEFAULT_TIMEOUT = 12e4;
|
|
100
|
+
var MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
|
|
101
|
+
var OpenClawError = class extends Error {
|
|
102
|
+
statusCode;
|
|
103
|
+
constructor(message, statusCode) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = "OpenClawError";
|
|
106
|
+
this.statusCode = statusCode;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var OpenClawClient = class {
|
|
110
|
+
baseUrl;
|
|
111
|
+
token;
|
|
112
|
+
timeout;
|
|
113
|
+
constructor(instance, timeout = DEFAULT_TIMEOUT) {
|
|
114
|
+
this.baseUrl = instance.url.replace(/\/+$/, "");
|
|
115
|
+
this.token = instance.token;
|
|
116
|
+
this.timeout = timeout;
|
|
117
|
+
}
|
|
118
|
+
async health() {
|
|
119
|
+
const res = await fetch(`${this.baseUrl}/health`, {
|
|
120
|
+
signal: AbortSignal.timeout(5e3)
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
throw new OpenClawError(`Health check failed: ${res.status}`, res.status);
|
|
124
|
+
}
|
|
125
|
+
return await res.json();
|
|
126
|
+
}
|
|
127
|
+
async chat(message) {
|
|
128
|
+
const url = `${this.baseUrl}/v1/chat/completions`;
|
|
129
|
+
const body = {
|
|
130
|
+
model: "openclaw",
|
|
131
|
+
messages: [{ role: "user", content: message }],
|
|
132
|
+
stream: false
|
|
133
|
+
};
|
|
134
|
+
logDebug("OpenClaw chat request", { url, messageLength: message.length });
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(url, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: {
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
...this.token ? { Authorization: `Bearer ${this.token}` } : {}
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
signal: controller.signal
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const text = await res.text().catch(() => "");
|
|
149
|
+
throw new OpenClawError(
|
|
150
|
+
`OpenClaw API error: ${res.status} ${res.statusText}${text ? ` \u2014 ${text}` : ""}`,
|
|
151
|
+
res.status
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const contentLength = res.headers.get("content-length");
|
|
155
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
|
|
156
|
+
throw new OpenClawError(
|
|
157
|
+
`Response too large: ${contentLength} bytes (max ${MAX_RESPONSE_SIZE})`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
logDebug("OpenClaw chat response", { id: data.id, choices: data.choices?.length });
|
|
162
|
+
return data;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (e instanceof OpenClawError) throw e;
|
|
165
|
+
if (e.name === "AbortError") {
|
|
166
|
+
throw new OpenClawError(`Request timeout after ${this.timeout}ms`);
|
|
167
|
+
}
|
|
168
|
+
throw new OpenClawError(`Connection error: ${e.message}`);
|
|
169
|
+
} finally {
|
|
170
|
+
clearTimeout(timeoutId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async *chatStream(message, abortSignal) {
|
|
174
|
+
const url = `${this.baseUrl}/v1/chat/completions`;
|
|
175
|
+
const body = {
|
|
176
|
+
model: "openclaw",
|
|
177
|
+
messages: [{ role: "user", content: message }],
|
|
178
|
+
stream: true
|
|
179
|
+
};
|
|
180
|
+
logDebug("OpenClaw stream request", { url, messageLength: message.length });
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
183
|
+
if (abortSignal) {
|
|
184
|
+
abortSignal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetch(url, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
...this.token ? { Authorization: `Bearer ${this.token}` } : {}
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify(body),
|
|
194
|
+
signal: controller.signal
|
|
195
|
+
});
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const text = await res.text().catch(() => "");
|
|
198
|
+
throw new OpenClawError(
|
|
199
|
+
`OpenClaw API error: ${res.status} ${res.statusText}${text ? ` \u2014 ${text}` : ""}`,
|
|
200
|
+
res.status
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (!res.body) {
|
|
204
|
+
throw new OpenClawError("Response body is null \u2014 streaming not supported?");
|
|
205
|
+
}
|
|
206
|
+
const reader = res.body.getReader();
|
|
207
|
+
const decoder = new TextDecoder();
|
|
208
|
+
let buffer = "";
|
|
209
|
+
while (true) {
|
|
210
|
+
const { done, value } = await reader.read();
|
|
211
|
+
if (done) break;
|
|
212
|
+
buffer += decoder.decode(value, { stream: true });
|
|
213
|
+
const lines = buffer.split("\n");
|
|
214
|
+
buffer = lines.pop() ?? "";
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
if (!line.startsWith("data: ")) continue;
|
|
217
|
+
const data = line.slice(6).trim();
|
|
218
|
+
if (data === "[DONE]") return;
|
|
219
|
+
if (!data) continue;
|
|
220
|
+
try {
|
|
221
|
+
const chunk = JSON.parse(data);
|
|
222
|
+
const content = chunk.choices?.[0]?.delta?.content;
|
|
223
|
+
if (content) yield content;
|
|
224
|
+
} catch {
|
|
225
|
+
logError("Failed to parse SSE chunk", void 0, { data });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e instanceof OpenClawError) throw e;
|
|
231
|
+
if (e.name === "AbortError") {
|
|
232
|
+
logDebug("Stream aborted");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
throw new OpenClawError(`Stream error: ${e.message}`);
|
|
236
|
+
} finally {
|
|
237
|
+
clearTimeout(timeoutId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// src/a2a/errors.ts
|
|
243
|
+
var A2A_ERROR_CODES = {
|
|
244
|
+
// Standard JSON-RPC
|
|
245
|
+
PARSE_ERROR: -32700,
|
|
246
|
+
INVALID_REQUEST: -32600,
|
|
247
|
+
METHOD_NOT_FOUND: -32601,
|
|
248
|
+
INVALID_PARAMS: -32602,
|
|
249
|
+
INTERNAL_ERROR: -32603,
|
|
250
|
+
// A2A specific
|
|
251
|
+
TASK_NOT_FOUND: -32001,
|
|
252
|
+
TASK_NOT_CANCELABLE: -32002,
|
|
253
|
+
PUSH_NOTIFICATION_NOT_SUPPORTED: -32003,
|
|
254
|
+
UNSUPPORTED_OPERATION: -32004,
|
|
255
|
+
CONTENT_TYPE_NOT_SUPPORTED: -32005,
|
|
256
|
+
EXTENSION_SUPPORT_REQUIRED: -32008,
|
|
257
|
+
VERSION_NOT_SUPPORTED: -32009
|
|
258
|
+
};
|
|
259
|
+
var A2AError = class extends Error {
|
|
260
|
+
code;
|
|
261
|
+
data;
|
|
262
|
+
constructor(code, message, data) {
|
|
263
|
+
super(message);
|
|
264
|
+
this.name = "A2AError";
|
|
265
|
+
this.code = code;
|
|
266
|
+
this.data = data;
|
|
267
|
+
}
|
|
268
|
+
toJSON() {
|
|
269
|
+
return {
|
|
270
|
+
code: this.code,
|
|
271
|
+
message: this.message,
|
|
272
|
+
...this.data !== void 0 ? { data: this.data } : {}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/a2a/types/enums.ts
|
|
278
|
+
var TERMINAL_STATES = /* @__PURE__ */ new Set([
|
|
279
|
+
"TASK_STATE_COMPLETED" /* COMPLETED */,
|
|
280
|
+
"TASK_STATE_FAILED" /* FAILED */,
|
|
281
|
+
"TASK_STATE_CANCELED" /* CANCELED */,
|
|
282
|
+
"TASK_STATE_REJECTED" /* REJECTED */
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
// src/a2a/executor.ts
|
|
286
|
+
var HEARTBEAT_INTERVAL = 15e3;
|
|
287
|
+
var OpenClawExecutor = class {
|
|
288
|
+
constructor(config2, taskStore) {
|
|
289
|
+
this.config = config2;
|
|
290
|
+
this.taskStore = taskStore;
|
|
291
|
+
}
|
|
292
|
+
clients = /* @__PURE__ */ new Map();
|
|
293
|
+
canceledTasks = /* @__PURE__ */ new Set();
|
|
294
|
+
getClient(instance) {
|
|
295
|
+
let client = this.clients.get(instance.name);
|
|
296
|
+
if (!client) {
|
|
297
|
+
client = new OpenClawClient(instance);
|
|
298
|
+
this.clients.set(instance.name, client);
|
|
299
|
+
}
|
|
300
|
+
return client;
|
|
301
|
+
}
|
|
302
|
+
resolveInstance(metadata) {
|
|
303
|
+
const instanceName = metadata?.instance;
|
|
304
|
+
if (instanceName) {
|
|
305
|
+
const inst = getInstanceByName(this.config, instanceName);
|
|
306
|
+
if (!inst) {
|
|
307
|
+
throw new A2AError(A2A_ERROR_CODES.INVALID_PARAMS, `Unknown instance: "${instanceName}"`);
|
|
308
|
+
}
|
|
309
|
+
return inst;
|
|
310
|
+
}
|
|
311
|
+
return getDefaultInstance(this.config);
|
|
312
|
+
}
|
|
313
|
+
extractText(message) {
|
|
314
|
+
return message.parts.filter((p) => p.text !== void 0).map((p) => p.text).join("\n");
|
|
315
|
+
}
|
|
316
|
+
cancelTask(taskId) {
|
|
317
|
+
this.canceledTasks.add(taskId);
|
|
318
|
+
}
|
|
319
|
+
isCanceled(taskId) {
|
|
320
|
+
return this.canceledTasks.has(taskId);
|
|
321
|
+
}
|
|
322
|
+
async execute(context, eventBus) {
|
|
323
|
+
const { task, contextId, userMessage } = context;
|
|
324
|
+
const text = this.extractText(userMessage);
|
|
325
|
+
if (!text) {
|
|
326
|
+
this.publishFailed(eventBus, task.id, contextId, "Empty message \u2014 no text parts found");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (this.isCanceled(task.id)) {
|
|
330
|
+
this.publishCanceled(eventBus, task.id, contextId);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const instance = this.resolveInstance(userMessage.metadata);
|
|
335
|
+
const client = this.getClient(instance);
|
|
336
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_WORKING" /* WORKING */);
|
|
337
|
+
const response = await client.chat(text);
|
|
338
|
+
const content = response.choices[0]?.message?.content ?? "";
|
|
339
|
+
if (this.isCanceled(task.id)) {
|
|
340
|
+
this.publishCanceled(eventBus, task.id, contextId);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
eventBus.publish({
|
|
344
|
+
artifactUpdate: {
|
|
345
|
+
taskId: task.id,
|
|
346
|
+
contextId,
|
|
347
|
+
artifact: {
|
|
348
|
+
artifactId: uuid(),
|
|
349
|
+
parts: [{ text: content }]
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
task.artifacts = [{ artifactId: uuid(), parts: [{ text: content }] }];
|
|
354
|
+
if (this.isInputRequired(content)) {
|
|
355
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_INPUT_REQUIRED" /* INPUT_REQUIRED */, {
|
|
356
|
+
messageId: uuid(),
|
|
357
|
+
role: "ROLE_AGENT" /* AGENT */,
|
|
358
|
+
parts: [{ text: content }]
|
|
359
|
+
});
|
|
360
|
+
task.status = { state: "TASK_STATE_INPUT_REQUIRED" /* INPUT_REQUIRED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
361
|
+
this.taskStore.set(task);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_COMPLETED" /* COMPLETED */);
|
|
365
|
+
task.status = { state: "TASK_STATE_COMPLETED" /* COMPLETED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
366
|
+
this.taskStore.set(task);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
logError("Executor error", e, { taskId: task.id });
|
|
369
|
+
const msg = e instanceof OpenClawError ? e.message : "Internal execution error";
|
|
370
|
+
this.publishFailed(eventBus, task.id, contextId, msg);
|
|
371
|
+
task.status = { state: "TASK_STATE_FAILED" /* FAILED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
372
|
+
this.taskStore.set(task);
|
|
373
|
+
} finally {
|
|
374
|
+
eventBus.finish();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async executeStreaming(context, eventBus) {
|
|
378
|
+
const { task, contextId, userMessage } = context;
|
|
379
|
+
const text = this.extractText(userMessage);
|
|
380
|
+
if (!text) {
|
|
381
|
+
this.publishFailed(eventBus, task.id, contextId, "Empty message \u2014 no text parts found");
|
|
382
|
+
eventBus.finish();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (this.isCanceled(task.id)) {
|
|
386
|
+
this.publishCanceled(eventBus, task.id, contextId);
|
|
387
|
+
eventBus.finish();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const abortController = new AbortController();
|
|
391
|
+
const heartbeat = setInterval(() => {
|
|
392
|
+
if (!eventBus.finished) {
|
|
393
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_WORKING" /* WORKING */);
|
|
394
|
+
}
|
|
395
|
+
}, HEARTBEAT_INTERVAL);
|
|
396
|
+
try {
|
|
397
|
+
const instance = this.resolveInstance(userMessage.metadata);
|
|
398
|
+
const client = this.getClient(instance);
|
|
399
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_WORKING" /* WORKING */);
|
|
400
|
+
const artifactId = uuid();
|
|
401
|
+
let fullContent = "";
|
|
402
|
+
for await (const chunk of client.chatStream(text, abortController.signal)) {
|
|
403
|
+
if (this.isCanceled(task.id)) {
|
|
404
|
+
abortController.abort();
|
|
405
|
+
this.publishCanceled(eventBus, task.id, contextId);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
fullContent += chunk;
|
|
409
|
+
eventBus.publish({
|
|
410
|
+
artifactUpdate: {
|
|
411
|
+
taskId: task.id,
|
|
412
|
+
contextId,
|
|
413
|
+
artifact: { artifactId, parts: [{ text: chunk }] },
|
|
414
|
+
append: true
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (!this.isCanceled(task.id)) {
|
|
419
|
+
eventBus.publish({
|
|
420
|
+
artifactUpdate: {
|
|
421
|
+
taskId: task.id,
|
|
422
|
+
contextId,
|
|
423
|
+
artifact: { artifactId, parts: [{ text: "" }] },
|
|
424
|
+
lastChunk: true
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
task.artifacts = [{ artifactId, parts: [{ text: fullContent }] }];
|
|
428
|
+
if (this.isInputRequired(fullContent)) {
|
|
429
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_INPUT_REQUIRED" /* INPUT_REQUIRED */, {
|
|
430
|
+
messageId: uuid(),
|
|
431
|
+
role: "ROLE_AGENT" /* AGENT */,
|
|
432
|
+
parts: [{ text: fullContent }]
|
|
433
|
+
});
|
|
434
|
+
task.status = { state: "TASK_STATE_INPUT_REQUIRED" /* INPUT_REQUIRED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
435
|
+
this.taskStore.set(task);
|
|
436
|
+
} else {
|
|
437
|
+
this.publishStatus(eventBus, task.id, contextId, "TASK_STATE_COMPLETED" /* COMPLETED */);
|
|
438
|
+
task.status = { state: "TASK_STATE_COMPLETED" /* COMPLETED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
439
|
+
this.taskStore.set(task);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} catch (e) {
|
|
443
|
+
logError("Streaming executor error", e, { taskId: task.id });
|
|
444
|
+
const msg = e instanceof OpenClawError ? e.message : "Internal execution error";
|
|
445
|
+
this.publishFailed(eventBus, task.id, contextId, msg);
|
|
446
|
+
task.status = { state: "TASK_STATE_FAILED" /* FAILED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
447
|
+
this.taskStore.set(task);
|
|
448
|
+
} finally {
|
|
449
|
+
clearInterval(heartbeat);
|
|
450
|
+
eventBus.finish();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// Multi-turn detection heuristic
|
|
454
|
+
// Returns true if the response indicates more user input is needed
|
|
455
|
+
isInputRequired(content) {
|
|
456
|
+
if (!content) return false;
|
|
457
|
+
if (content.includes("[INPUT_REQUIRED]") || content.includes("[NEEDS_INPUT]")) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
const trimmed = content.trim();
|
|
461
|
+
const lastSentence = trimmed.split(/[.!]\s/).pop()?.trim() ?? "";
|
|
462
|
+
if (lastSentence.endsWith("?") && trimmed.length < 500) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
publishStatus(eventBus, taskId, contextId, state, message) {
|
|
468
|
+
const status = {
|
|
469
|
+
state,
|
|
470
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
471
|
+
...message ? { message } : {}
|
|
472
|
+
};
|
|
473
|
+
eventBus.publish({ statusUpdate: { taskId, contextId, status } });
|
|
474
|
+
logDebug("Status update", { taskId, state });
|
|
475
|
+
}
|
|
476
|
+
publishFailed(eventBus, taskId, contextId, errorMsg) {
|
|
477
|
+
this.publishStatus(eventBus, taskId, contextId, "TASK_STATE_FAILED" /* FAILED */, {
|
|
478
|
+
messageId: uuid(),
|
|
479
|
+
role: "ROLE_AGENT" /* AGENT */,
|
|
480
|
+
parts: [{ text: errorMsg }]
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
publishCanceled(eventBus, taskId, contextId) {
|
|
484
|
+
this.publishStatus(eventBus, taskId, contextId, "TASK_STATE_CANCELED" /* CANCELED */);
|
|
485
|
+
const task = this.taskStore.get(taskId);
|
|
486
|
+
if (task) {
|
|
487
|
+
task.status = { state: "TASK_STATE_CANCELED" /* CANCELED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
488
|
+
this.taskStore.set(task);
|
|
489
|
+
}
|
|
490
|
+
this.canceledTasks.delete(taskId);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// src/a2a/router.ts
|
|
495
|
+
import { Router } from "express";
|
|
496
|
+
|
|
497
|
+
// src/a2a/agent-card.ts
|
|
498
|
+
function buildAgentCard(config2) {
|
|
499
|
+
return {
|
|
500
|
+
name: "OpenClaw A2A Bridge",
|
|
501
|
+
description: "A2A v1.0 bridge to OpenClaw AI assistant gateway",
|
|
502
|
+
version: true ? "0.1.0-beta.1" : "0.1.0-beta.1",
|
|
503
|
+
provider: {
|
|
504
|
+
organization: "OpenClaw",
|
|
505
|
+
url: "https://github.com/freema/openclaw-a2a"
|
|
506
|
+
},
|
|
507
|
+
supportedInterfaces: [
|
|
508
|
+
{
|
|
509
|
+
url: config2.publicUrl,
|
|
510
|
+
protocolBinding: "JSONRPC",
|
|
511
|
+
protocolVersion: "1.0"
|
|
512
|
+
}
|
|
513
|
+
],
|
|
514
|
+
capabilities: {
|
|
515
|
+
streaming: true,
|
|
516
|
+
pushNotifications: false,
|
|
517
|
+
stateTransitionHistory: false,
|
|
518
|
+
extendedAgentCard: false
|
|
519
|
+
},
|
|
520
|
+
defaultInputModes: ["text/plain"],
|
|
521
|
+
defaultOutputModes: ["text/plain"],
|
|
522
|
+
skills: [
|
|
523
|
+
{
|
|
524
|
+
id: "openclaw-chat",
|
|
525
|
+
name: "OpenClaw Chat",
|
|
526
|
+
description: "Chat with OpenClaw AI assistant",
|
|
527
|
+
tags: ["chat", "ai", "assistant"],
|
|
528
|
+
examples: ["Hello!", "What can you help me with?"]
|
|
529
|
+
}
|
|
530
|
+
]
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/a2a/request-handler.ts
|
|
535
|
+
import { v4 as uuid2 } from "uuid";
|
|
536
|
+
|
|
537
|
+
// src/a2a/event-bus.ts
|
|
538
|
+
var ExecutionEventBus = class {
|
|
539
|
+
listeners = [];
|
|
540
|
+
finishListeners = [];
|
|
541
|
+
_finished = false;
|
|
542
|
+
get finished() {
|
|
543
|
+
return this._finished;
|
|
544
|
+
}
|
|
545
|
+
on(listener) {
|
|
546
|
+
this.listeners.push(listener);
|
|
547
|
+
return () => {
|
|
548
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
onFinish(listener) {
|
|
552
|
+
if (this._finished) {
|
|
553
|
+
listener();
|
|
554
|
+
return () => {
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
this.finishListeners.push(listener);
|
|
558
|
+
return () => {
|
|
559
|
+
this.finishListeners = this.finishListeners.filter((l) => l !== listener);
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
publish(event) {
|
|
563
|
+
for (const listener of this.listeners) {
|
|
564
|
+
listener(event);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
finish() {
|
|
568
|
+
this._finished = true;
|
|
569
|
+
for (const listener of this.finishListeners) {
|
|
570
|
+
listener();
|
|
571
|
+
}
|
|
572
|
+
this.listeners = [];
|
|
573
|
+
this.finishListeners = [];
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/a2a/sse.ts
|
|
578
|
+
var SSE_HEADERS = {
|
|
579
|
+
"Content-Type": "text/event-stream",
|
|
580
|
+
"Cache-Control": "no-cache",
|
|
581
|
+
Connection: "keep-alive",
|
|
582
|
+
"X-Accel-Buffering": "no"
|
|
583
|
+
};
|
|
584
|
+
function formatSSEEvent(data) {
|
|
585
|
+
return `data: ${JSON.stringify(data)}
|
|
586
|
+
|
|
587
|
+
`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/a2a/request-handler.ts
|
|
591
|
+
function createRequestHandler(config2, taskStore, executor) {
|
|
592
|
+
return async (req, res) => {
|
|
593
|
+
try {
|
|
594
|
+
resolveAndValidateVersion(req);
|
|
595
|
+
const rpcReq = parseJsonRpcRequest(req.body);
|
|
596
|
+
logDebug("JSON-RPC request", { method: rpcReq.method, id: rpcReq.id });
|
|
597
|
+
const method = rpcReq.method;
|
|
598
|
+
switch (method) {
|
|
599
|
+
case "SendMessage":
|
|
600
|
+
return await handleSendMessage(rpcReq, res, config2, taskStore, executor);
|
|
601
|
+
case "SendStreamingMessage":
|
|
602
|
+
return await handleSendStreamingMessage(rpcReq, res, config2, taskStore, executor);
|
|
603
|
+
case "GetTask":
|
|
604
|
+
return handleGetTask(rpcReq, res, taskStore);
|
|
605
|
+
case "ListTasks":
|
|
606
|
+
return handleListTasks(rpcReq, res, taskStore);
|
|
607
|
+
case "CancelTask":
|
|
608
|
+
return handleCancelTask(rpcReq, res, taskStore, executor);
|
|
609
|
+
case "SubscribeToTask":
|
|
610
|
+
return handleSubscribeToTask(rpcReq, res);
|
|
611
|
+
case "CreateTaskPushNotificationConfig":
|
|
612
|
+
case "GetTaskPushNotificationConfig":
|
|
613
|
+
case "ListTaskPushNotificationConfigs":
|
|
614
|
+
case "DeleteTaskPushNotificationConfig":
|
|
615
|
+
return sendJsonRpcError(
|
|
616
|
+
res,
|
|
617
|
+
rpcReq.id,
|
|
618
|
+
A2A_ERROR_CODES.PUSH_NOTIFICATION_NOT_SUPPORTED,
|
|
619
|
+
"Push notifications are not supported"
|
|
620
|
+
);
|
|
621
|
+
case "GetExtendedAgentCard":
|
|
622
|
+
return sendJsonRpcError(
|
|
623
|
+
res,
|
|
624
|
+
rpcReq.id,
|
|
625
|
+
A2A_ERROR_CODES.UNSUPPORTED_OPERATION,
|
|
626
|
+
"Extended agent card is not supported"
|
|
627
|
+
);
|
|
628
|
+
default:
|
|
629
|
+
return sendJsonRpcError(
|
|
630
|
+
res,
|
|
631
|
+
rpcReq.id,
|
|
632
|
+
A2A_ERROR_CODES.METHOD_NOT_FOUND,
|
|
633
|
+
`Unknown method: ${method}`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
} catch (e) {
|
|
637
|
+
if (e instanceof A2AError) {
|
|
638
|
+
return sendJsonRpcError(res, req.body?.id ?? null, e.code, e.message, e.data);
|
|
639
|
+
}
|
|
640
|
+
logError("Unhandled request error", e);
|
|
641
|
+
return sendJsonRpcError(
|
|
642
|
+
res,
|
|
643
|
+
req.body?.id ?? null,
|
|
644
|
+
A2A_ERROR_CODES.INTERNAL_ERROR,
|
|
645
|
+
"Internal server error"
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function resolveAndValidateVersion(req) {
|
|
651
|
+
const raw = req.header("A2A-Version")?.trim() || req.query["A2A-Version"];
|
|
652
|
+
const version = raw || "0.3";
|
|
653
|
+
if (version !== "1.0") {
|
|
654
|
+
throw new A2AError(
|
|
655
|
+
A2A_ERROR_CODES.VERSION_NOT_SUPPORTED,
|
|
656
|
+
`A2A version "${version}" is not supported. Supported versions: ["1.0"]`,
|
|
657
|
+
{ supportedVersions: ["1.0"] }
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
return version;
|
|
661
|
+
}
|
|
662
|
+
function parseJsonRpcRequest(body) {
|
|
663
|
+
if (!body || typeof body !== "object") {
|
|
664
|
+
throw new A2AError(A2A_ERROR_CODES.PARSE_ERROR, "Invalid JSON");
|
|
665
|
+
}
|
|
666
|
+
if (body.jsonrpc !== "2.0") {
|
|
667
|
+
throw new A2AError(
|
|
668
|
+
A2A_ERROR_CODES.INVALID_REQUEST,
|
|
669
|
+
'Missing or invalid jsonrpc version (must be "2.0")'
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
if (!body.method || typeof body.method !== "string") {
|
|
673
|
+
throw new A2AError(A2A_ERROR_CODES.INVALID_REQUEST, "Missing or invalid method");
|
|
674
|
+
}
|
|
675
|
+
return body;
|
|
676
|
+
}
|
|
677
|
+
async function handleSendMessage(rpcReq, res, config2, taskStore, executor) {
|
|
678
|
+
const params = rpcReq.params;
|
|
679
|
+
if (!params?.message) {
|
|
680
|
+
return sendJsonRpcError(
|
|
681
|
+
res,
|
|
682
|
+
rpcReq.id,
|
|
683
|
+
A2A_ERROR_CODES.INVALID_PARAMS,
|
|
684
|
+
"Missing message in params"
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
const context = createTaskContext(params, taskStore);
|
|
688
|
+
const eventBus = new ExecutionEventBus();
|
|
689
|
+
await executor.execute(context, eventBus);
|
|
690
|
+
const task = taskStore.get(context.task.id) ?? context.task;
|
|
691
|
+
return sendJsonRpcResult(res, rpcReq.id, task);
|
|
692
|
+
}
|
|
693
|
+
async function handleSendStreamingMessage(rpcReq, res, config2, taskStore, executor) {
|
|
694
|
+
const params = rpcReq.params;
|
|
695
|
+
if (!params?.message) {
|
|
696
|
+
return sendJsonRpcError(
|
|
697
|
+
res,
|
|
698
|
+
rpcReq.id,
|
|
699
|
+
A2A_ERROR_CODES.INVALID_PARAMS,
|
|
700
|
+
"Missing message in params"
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const context = createTaskContext(params, taskStore);
|
|
704
|
+
const eventBus = new ExecutionEventBus();
|
|
705
|
+
res.writeHead(200, SSE_HEADERS);
|
|
706
|
+
eventBus.on((event) => {
|
|
707
|
+
const rpcResponse = {
|
|
708
|
+
jsonrpc: "2.0",
|
|
709
|
+
id: rpcReq.id,
|
|
710
|
+
result: event
|
|
711
|
+
};
|
|
712
|
+
res.write(formatSSEEvent(rpcResponse));
|
|
713
|
+
});
|
|
714
|
+
eventBus.onFinish(() => {
|
|
715
|
+
res.end();
|
|
716
|
+
});
|
|
717
|
+
executor.executeStreaming(context, eventBus).catch((e) => {
|
|
718
|
+
logError("Streaming execution error", e);
|
|
719
|
+
if (!res.writableEnded) {
|
|
720
|
+
res.end();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function handleGetTask(rpcReq, res, taskStore) {
|
|
725
|
+
const params = rpcReq.params;
|
|
726
|
+
if (!params?.id) {
|
|
727
|
+
return sendJsonRpcError(res, rpcReq.id, A2A_ERROR_CODES.INVALID_PARAMS, "Missing task id");
|
|
728
|
+
}
|
|
729
|
+
const task = taskStore.get(params.id);
|
|
730
|
+
if (!task) {
|
|
731
|
+
return sendJsonRpcError(
|
|
732
|
+
res,
|
|
733
|
+
rpcReq.id,
|
|
734
|
+
A2A_ERROR_CODES.TASK_NOT_FOUND,
|
|
735
|
+
`Task not found: ${params.id}`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
if (params.historyLength !== void 0 && task.history) {
|
|
739
|
+
task.history = task.history.slice(-params.historyLength);
|
|
740
|
+
}
|
|
741
|
+
return sendJsonRpcResult(res, rpcReq.id, task);
|
|
742
|
+
}
|
|
743
|
+
function handleListTasks(rpcReq, res, taskStore) {
|
|
744
|
+
const params = rpcReq.params ?? {};
|
|
745
|
+
const result = taskStore.list({
|
|
746
|
+
contextId: params.contextId,
|
|
747
|
+
taskStates: params.taskStates,
|
|
748
|
+
cursor: params.cursor,
|
|
749
|
+
pageSize: params.pageSize
|
|
750
|
+
});
|
|
751
|
+
return sendJsonRpcResult(res, rpcReq.id, result);
|
|
752
|
+
}
|
|
753
|
+
function handleCancelTask(rpcReq, res, taskStore, executor) {
|
|
754
|
+
const params = rpcReq.params;
|
|
755
|
+
if (!params?.id) {
|
|
756
|
+
return sendJsonRpcError(res, rpcReq.id, A2A_ERROR_CODES.INVALID_PARAMS, "Missing task id");
|
|
757
|
+
}
|
|
758
|
+
const task = taskStore.get(params.id);
|
|
759
|
+
if (!task) {
|
|
760
|
+
return sendJsonRpcError(
|
|
761
|
+
res,
|
|
762
|
+
rpcReq.id,
|
|
763
|
+
A2A_ERROR_CODES.TASK_NOT_FOUND,
|
|
764
|
+
`Task not found: ${params.id}`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (TERMINAL_STATES.has(task.status.state)) {
|
|
768
|
+
return sendJsonRpcError(
|
|
769
|
+
res,
|
|
770
|
+
rpcReq.id,
|
|
771
|
+
A2A_ERROR_CODES.TASK_NOT_CANCELABLE,
|
|
772
|
+
`Task is in terminal state: ${task.status.state}`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
executor.cancelTask(params.id);
|
|
776
|
+
task.status = { state: "TASK_STATE_CANCELED" /* CANCELED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
777
|
+
taskStore.set(task);
|
|
778
|
+
return sendJsonRpcResult(res, rpcReq.id, task);
|
|
779
|
+
}
|
|
780
|
+
function handleSubscribeToTask(rpcReq, res) {
|
|
781
|
+
return sendJsonRpcError(
|
|
782
|
+
res,
|
|
783
|
+
rpcReq.id,
|
|
784
|
+
A2A_ERROR_CODES.UNSUPPORTED_OPERATION,
|
|
785
|
+
"SubscribeToTask is not yet supported"
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
function createTaskContext(params, taskStore) {
|
|
789
|
+
const message = params.message;
|
|
790
|
+
let contextId;
|
|
791
|
+
let existingTask;
|
|
792
|
+
if (message.taskId) {
|
|
793
|
+
existingTask = taskStore.get(message.taskId);
|
|
794
|
+
if (existingTask) {
|
|
795
|
+
contextId = message.contextId ?? existingTask.contextId;
|
|
796
|
+
} else {
|
|
797
|
+
contextId = message.contextId ?? uuid2();
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
contextId = message.contextId ?? uuid2();
|
|
801
|
+
}
|
|
802
|
+
const task = existingTask ?? {
|
|
803
|
+
id: uuid2(),
|
|
804
|
+
contextId,
|
|
805
|
+
status: { state: "TASK_STATE_SUBMITTED" /* SUBMITTED */, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
806
|
+
history: [],
|
|
807
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
808
|
+
};
|
|
809
|
+
if (!task.history) task.history = [];
|
|
810
|
+
task.history.push(message);
|
|
811
|
+
taskStore.set(task);
|
|
812
|
+
return { task, contextId, userMessage: message };
|
|
813
|
+
}
|
|
814
|
+
function sendJsonRpcResult(res, id, result) {
|
|
815
|
+
const response = { jsonrpc: "2.0", id, result };
|
|
816
|
+
res.json(response);
|
|
817
|
+
}
|
|
818
|
+
function sendJsonRpcError(res, id, code, message, data) {
|
|
819
|
+
const response = {
|
|
820
|
+
jsonrpc: "2.0",
|
|
821
|
+
id: id ?? 0,
|
|
822
|
+
error: { code, message, ...data !== void 0 ? { data } : {} }
|
|
823
|
+
};
|
|
824
|
+
res.json(response);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/a2a/router.ts
|
|
828
|
+
function createA2ARouter(config2, taskStore, executor) {
|
|
829
|
+
const router = Router();
|
|
830
|
+
const handler = createRequestHandler(config2, taskStore, executor);
|
|
831
|
+
router.get("/.well-known/agent-card.json", (_req, res) => {
|
|
832
|
+
res.json(buildAgentCard({ publicUrl: config2.publicUrl }));
|
|
833
|
+
});
|
|
834
|
+
router.post("/a2a", handler);
|
|
835
|
+
return router;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/a2a/task-store.ts
|
|
839
|
+
var InMemoryTaskStore = class {
|
|
840
|
+
tasks = /* @__PURE__ */ new Map();
|
|
841
|
+
get(id) {
|
|
842
|
+
return this.tasks.get(id);
|
|
843
|
+
}
|
|
844
|
+
set(task) {
|
|
845
|
+
task.lastModified = (/* @__PURE__ */ new Date()).toISOString();
|
|
846
|
+
this.tasks.set(task.id, task);
|
|
847
|
+
}
|
|
848
|
+
list(options) {
|
|
849
|
+
let all = Array.from(this.tasks.values());
|
|
850
|
+
if (options?.contextId) {
|
|
851
|
+
all = all.filter((t) => t.contextId === options.contextId);
|
|
852
|
+
}
|
|
853
|
+
if (options?.taskStates && options.taskStates.length > 0) {
|
|
854
|
+
const states = new Set(options.taskStates);
|
|
855
|
+
all = all.filter((t) => states.has(t.status.state));
|
|
856
|
+
}
|
|
857
|
+
all.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
858
|
+
const pageSize = options?.pageSize ?? 50;
|
|
859
|
+
let startIndex = 0;
|
|
860
|
+
if (options?.cursor) {
|
|
861
|
+
const cursorIndex = all.findIndex((t) => t.id === options.cursor);
|
|
862
|
+
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
|
|
863
|
+
}
|
|
864
|
+
const page = all.slice(startIndex, startIndex + pageSize);
|
|
865
|
+
const nextCursor = startIndex + pageSize < all.length ? all[startIndex + pageSize - 1]?.id : void 0;
|
|
866
|
+
return { tasks: page, nextCursor };
|
|
867
|
+
}
|
|
868
|
+
delete(id) {
|
|
869
|
+
return this.tasks.delete(id);
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
// src/server/index.ts
|
|
874
|
+
function createApp(config2) {
|
|
875
|
+
const app = express();
|
|
876
|
+
const taskStore = new InMemoryTaskStore();
|
|
877
|
+
const executor = new OpenClawExecutor(config2, taskStore);
|
|
878
|
+
app.use(express.json({ limit: "10mb" }));
|
|
879
|
+
app.get("/health", (_req, res) => {
|
|
880
|
+
res.json({
|
|
881
|
+
status: "ok",
|
|
882
|
+
version: "0.1.0-beta.1",
|
|
883
|
+
a2aVersion: "1.0",
|
|
884
|
+
uptime: process.uptime()
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
app.get("/instances", (_req, res) => {
|
|
888
|
+
res.json(
|
|
889
|
+
config2.instances.map((i) => ({
|
|
890
|
+
name: i.name,
|
|
891
|
+
url: i.url,
|
|
892
|
+
default: i.default ?? false
|
|
893
|
+
}))
|
|
894
|
+
);
|
|
895
|
+
});
|
|
896
|
+
app.use(createA2ARouter(config2, taskStore, executor));
|
|
897
|
+
return { app, taskStore, executor };
|
|
898
|
+
}
|
|
899
|
+
function startServer(config2) {
|
|
900
|
+
const { app } = createApp(config2);
|
|
901
|
+
const server = app.listen(config2.port, config2.host, () => {
|
|
902
|
+
log("Server started", {
|
|
903
|
+
port: config2.port,
|
|
904
|
+
host: config2.host,
|
|
905
|
+
publicUrl: config2.publicUrl,
|
|
906
|
+
instances: config2.instances.length
|
|
907
|
+
});
|
|
908
|
+
log(`Agent Card: ${config2.publicUrl}/.well-known/agent-card.json`);
|
|
909
|
+
log(`A2A endpoint: ${config2.publicUrl}/a2a`);
|
|
910
|
+
});
|
|
911
|
+
const shutdown = (signal) => {
|
|
912
|
+
log(`Received ${signal}, shutting down...`);
|
|
913
|
+
server.close(() => {
|
|
914
|
+
log("Server closed");
|
|
915
|
+
process.exit(0);
|
|
916
|
+
});
|
|
917
|
+
setTimeout(() => {
|
|
918
|
+
log("Forced shutdown");
|
|
919
|
+
process.exit(1);
|
|
920
|
+
}, 1e4);
|
|
921
|
+
};
|
|
922
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
923
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
924
|
+
return server;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/index.ts
|
|
928
|
+
var argv = yargs(hideBin(process.argv)).scriptName("openclaw-a2a").version(true ? "0.1.0-beta.1" : "0.1.0-beta.1").option("port", {
|
|
929
|
+
alias: "p",
|
|
930
|
+
type: "number",
|
|
931
|
+
description: "Server port"
|
|
932
|
+
}).option("host", {
|
|
933
|
+
type: "string",
|
|
934
|
+
description: "Server host"
|
|
935
|
+
}).option("openclaw-url", {
|
|
936
|
+
type: "string",
|
|
937
|
+
description: "OpenClaw Gateway URL"
|
|
938
|
+
}).option("token", {
|
|
939
|
+
type: "string",
|
|
940
|
+
description: "OpenClaw Gateway token"
|
|
941
|
+
}).option("debug", {
|
|
942
|
+
type: "boolean",
|
|
943
|
+
description: "Enable debug logging"
|
|
944
|
+
}).parseSync();
|
|
945
|
+
if (argv.port) process.env.PORT = String(argv.port);
|
|
946
|
+
if (argv.host) process.env.HOST = argv.host;
|
|
947
|
+
if (argv["openclaw-url"]) process.env.OPENCLAW_URL = argv["openclaw-url"];
|
|
948
|
+
if (argv.token) process.env.OPENCLAW_GATEWAY_TOKEN = argv.token;
|
|
949
|
+
if (argv.debug) process.env.DEBUG = "true";
|
|
950
|
+
var config = loadConfig();
|
|
951
|
+
setDebug(config.debug);
|
|
952
|
+
startServer(config);
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-a2a",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "A2A v1.0 bridge for OpenClaw AI assistant — agent-to-agent communication",
|
|
5
|
+
"author": "Tomas Grasl <https://www.tomasgrasl.cz/>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"openclaw-a2a": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx watch src/index.ts",
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"clean": "rm -rf dist",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"lint": "eslint src --ext .ts",
|
|
19
|
+
"lint:fix": "eslint src --ext .ts --fix",
|
|
20
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
21
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
22
|
+
"check": "npm run lint:fix && npm run typecheck",
|
|
23
|
+
"check:all": "npm run check && npm run test:run && npm run build",
|
|
24
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
25
|
+
"test": "vitest",
|
|
26
|
+
"test:run": "vitest run",
|
|
27
|
+
"test:e2e": "vitest run --config tests/e2e/vitest.config.ts"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"a2a",
|
|
31
|
+
"a2a-protocol",
|
|
32
|
+
"agent-to-agent",
|
|
33
|
+
"openclaw",
|
|
34
|
+
"ai-agent",
|
|
35
|
+
"bridge",
|
|
36
|
+
"google-a2a"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20.0.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"express": "^4.21.2",
|
|
43
|
+
"uuid": "^11.1.0",
|
|
44
|
+
"yargs": "^17.7.2"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/express": "^4.17.21",
|
|
48
|
+
"@types/node": "^20.11.0",
|
|
49
|
+
"@types/supertest": "^6.0.2",
|
|
50
|
+
"@types/uuid": "^10.0.0",
|
|
51
|
+
"@types/yargs": "^17.0.32",
|
|
52
|
+
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
53
|
+
"@typescript-eslint/parser": "^6.21.0",
|
|
54
|
+
"eslint": "^8.57.1",
|
|
55
|
+
"eslint-config-prettier": "^9.1.0",
|
|
56
|
+
"eslint-plugin-prettier": "^5.2.1",
|
|
57
|
+
"prettier": "^3.3.3",
|
|
58
|
+
"supertest": "^7.1.0",
|
|
59
|
+
"tsup": "^8.0.0",
|
|
60
|
+
"tsx": "^4.7.0",
|
|
61
|
+
"typescript": "^5.3.3",
|
|
62
|
+
"vitest": "^2.0.0"
|
|
63
|
+
},
|
|
64
|
+
"files": [
|
|
65
|
+
"dist",
|
|
66
|
+
"README.md",
|
|
67
|
+
"LICENSE"
|
|
68
|
+
],
|
|
69
|
+
"homepage": "https://github.com/freema/openclaw-a2a#readme",
|
|
70
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/freema/openclaw-a2a.git"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public",
|
|
76
|
+
"tag": "beta"
|
|
77
|
+
}
|
|
78
|
+
}
|