@ryubyte/dsh-a2a 0.1.0
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 +224 -0
- package/README.zh-CN.md +196 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +574 -0
- package/lib/index.js +1896 -0
- package/lib/types/a2a-config.d.ts +107 -0
- package/lib/types/a2a-config.d.ts.map +1 -0
- package/lib/types/card.d.ts +40 -0
- package/lib/types/card.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client.d.ts +52 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/dashboard.d.ts +144 -0
- package/lib/types/dashboard.d.ts.map +1 -0
- package/lib/types/errors.d.ts +24 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/index.d.ts +89 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/jsonrpc.d.ts +40 -0
- package/lib/types/jsonrpc.d.ts.map +1 -0
- package/lib/types/outbound.d.ts +45 -0
- package/lib/types/outbound.d.ts.map +1 -0
- package/lib/types/protocol.d.ts +330 -0
- package/lib/types/protocol.d.ts.map +1 -0
- package/lib/types/server.d.ts +142 -0
- package/lib/types/server.d.ts.map +1 -0
- package/package.json +89 -0
- package/src/a2a-config.ts +201 -0
- package/src/card.ts +143 -0
- package/src/client/index.ts +613 -0
- package/src/client.ts +141 -0
- package/src/dashboard.ts +350 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +659 -0
- package/src/jsonrpc.ts +185 -0
- package/src/outbound.ts +186 -0
- package/src/protocol.ts +308 -0
- package/src/server.ts +467 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1896 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
//#region src/protocol.ts
|
|
5
|
+
/**
|
|
6
|
+
* A2A (Agent2Agent) Protocol v1.0 — TypeScript type definitions.
|
|
7
|
+
*
|
|
8
|
+
* These types mirror the normative source of truth `specification/a2a.proto`
|
|
9
|
+
* (package `lf.a2a.v1`) from https://github.com/a2aproject/A2A, v1.0.0.
|
|
10
|
+
* JSON wire names are camelCase per ProtoJSON serialization; enum values use
|
|
11
|
+
* SCREAMING_SNAKE_CASE as mandated by A2A v1.0.
|
|
12
|
+
*/
|
|
13
|
+
/** The lifecycle states of a Task (a2a.proto `TaskState`). */
|
|
14
|
+
let TaskState = /* @__PURE__ */ function(TaskState) {
|
|
15
|
+
TaskState["UNSPECIFIED"] = "TASK_STATE_UNSPECIFIED";
|
|
16
|
+
TaskState["SUBMITTED"] = "TASK_STATE_SUBMITTED";
|
|
17
|
+
TaskState["WORKING"] = "TASK_STATE_WORKING";
|
|
18
|
+
TaskState["COMPLETED"] = "TASK_STATE_COMPLETED";
|
|
19
|
+
TaskState["FAILED"] = "TASK_STATE_FAILED";
|
|
20
|
+
TaskState["CANCELED"] = "TASK_STATE_CANCELED";
|
|
21
|
+
TaskState["INPUT_REQUIRED"] = "TASK_STATE_INPUT_REQUIRED";
|
|
22
|
+
TaskState["REJECTED"] = "TASK_STATE_REJECTED";
|
|
23
|
+
TaskState["AUTH_REQUIRED"] = "TASK_STATE_AUTH_REQUIRED";
|
|
24
|
+
return TaskState;
|
|
25
|
+
}({});
|
|
26
|
+
function isTerminal(state) {
|
|
27
|
+
return state === "TASK_STATE_COMPLETED" || state === "TASK_STATE_FAILED" || state === "TASK_STATE_CANCELED" || state === "TASK_STATE_REJECTED";
|
|
28
|
+
}
|
|
29
|
+
/** The sender of a Message (a2a.proto `Role`). */
|
|
30
|
+
let Role = /* @__PURE__ */ function(Role) {
|
|
31
|
+
Role["UNSPECIFIED"] = "ROLE_UNSPECIFIED";
|
|
32
|
+
Role["USER"] = "ROLE_USER";
|
|
33
|
+
Role["AGENT"] = "ROLE_AGENT";
|
|
34
|
+
return Role;
|
|
35
|
+
}({});
|
|
36
|
+
/** A2A JSON-RPC method names (v1.0 canonical PascalCase as in a2a.proto RPCs). */
|
|
37
|
+
const A2A_METHODS = {
|
|
38
|
+
sendMessage: "SendMessage",
|
|
39
|
+
sendStreamingMessage: "SendStreamingMessage",
|
|
40
|
+
getTask: "GetTask",
|
|
41
|
+
listTasks: "ListTasks",
|
|
42
|
+
cancelTask: "CancelTask",
|
|
43
|
+
subscribeToTask: "SubscribeToTask",
|
|
44
|
+
getExtendedAgentCard: "GetExtendedAgentCard"
|
|
45
|
+
};
|
|
46
|
+
/** Standard error codes from the A2A JSON-RPC binding. */
|
|
47
|
+
const A2A_ERROR_CODES = {
|
|
48
|
+
INVALID_REQUEST: -32600,
|
|
49
|
+
METHOD_NOT_FOUND: -32601,
|
|
50
|
+
INVALID_PARAMS: -32602,
|
|
51
|
+
INTERNAL_ERROR: -32603,
|
|
52
|
+
TASK_NOT_FOUND: -32001,
|
|
53
|
+
TASK_CANCEL_NOT_ALLOWED: -32002,
|
|
54
|
+
TASK_ALREADY_CANCELED: -32003,
|
|
55
|
+
AGENT_CARD_NOT_FOUND: -32004,
|
|
56
|
+
AGENT_CARD_SIGNATURE_INVALID: -32005,
|
|
57
|
+
CONTEXT_NOT_FOUND: -32006,
|
|
58
|
+
AUTHENTICATION_REQUIRED: -32007,
|
|
59
|
+
PUSH_NOTIFICATION_CONFIG_NOT_FOUND: -32008,
|
|
60
|
+
EXTENSION_NOT_SUPPORTED: -32009,
|
|
61
|
+
UNSUPPORTED_OPERATION: -32010,
|
|
62
|
+
VERSION_NOT_SUPPORTED: -32011
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/errors.ts
|
|
66
|
+
/** Error class for A2A protocol failures, mirroring JSON-RPC error semantics. */
|
|
67
|
+
var A2AError = class A2AError extends Error {
|
|
68
|
+
code;
|
|
69
|
+
data;
|
|
70
|
+
/** True when this error came from a remote agent over the wire. */
|
|
71
|
+
remote;
|
|
72
|
+
constructor(code, message, data, remote = false) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "A2AError";
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.data = data;
|
|
77
|
+
this.remote = remote;
|
|
78
|
+
}
|
|
79
|
+
toJsonRpc() {
|
|
80
|
+
const out = {
|
|
81
|
+
code: this.code,
|
|
82
|
+
message: this.message
|
|
83
|
+
};
|
|
84
|
+
if (this.data !== void 0) out.data = this.data;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
static fromJsonRpc(err) {
|
|
88
|
+
return new A2AError(err.code, err.message, err.data, true);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/card.ts
|
|
93
|
+
function isAgentCard(value) {
|
|
94
|
+
if (typeof value !== "object" || value === null) return false;
|
|
95
|
+
const v = value;
|
|
96
|
+
return typeof v.name === "string" && typeof v.description === "string" && typeof v.version === "string" && Array.isArray(v.supportedInterfaces) && typeof v.capabilities === "object" && v.capabilities !== null && Array.isArray(v.defaultInputModes) && Array.isArray(v.defaultOutputModes) && Array.isArray(v.skills);
|
|
97
|
+
}
|
|
98
|
+
function normalizeUrl(base, path) {
|
|
99
|
+
return `${base.endsWith("/") ? base.slice(0, -1) : base}${path.startsWith("/") ? path : `/${path}`}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fetch and validate an agent's AgentCard.
|
|
103
|
+
*
|
|
104
|
+
* Discovery order (per spec §AgentCard discovery):
|
|
105
|
+
* 1. Probe `GET {origin}/.well-known/agent-card.json` first.
|
|
106
|
+
* 2. Fall back to the exact configured URL.
|
|
107
|
+
* A configured URL that already ends in `agent-card.json` is used directly.
|
|
108
|
+
*/
|
|
109
|
+
async function fetchAgentCard(baseUrl, options = {}) {
|
|
110
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
111
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
112
|
+
const headers = { Accept: "application/json" };
|
|
113
|
+
if (options.bearerToken) headers.Authorization = `Bearer ${options.bearerToken}`;
|
|
114
|
+
const wellKnown = normalizeUrl(baseUrl, "/.well-known/agent-card.json");
|
|
115
|
+
const candidates = baseUrl.endsWith("agent-card.json") || baseUrl.endsWith("agent-card.json/") ? [baseUrl] : [wellKnown, baseUrl];
|
|
116
|
+
let lastError;
|
|
117
|
+
for (const url of candidates) {
|
|
118
|
+
const ctrl = new AbortController();
|
|
119
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetchImpl(url, {
|
|
122
|
+
headers,
|
|
123
|
+
signal: ctrl.signal
|
|
124
|
+
});
|
|
125
|
+
if (res.status === 404) {
|
|
126
|
+
lastError = new A2AError(404, `AgentCard not found at ${url}`, void 0, true);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!res.ok) throw new A2AError(res.status, `AgentCard request failed (${res.status}) at ${url}`, void 0, true);
|
|
130
|
+
const json = await res.json();
|
|
131
|
+
if (!isAgentCard(json)) throw new A2AError(400, `Invalid AgentCard at ${url}: missing required fields`, void 0, true);
|
|
132
|
+
return json;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof Error && err.name === "AbortError") throw new A2AError(408, `AgentCard fetch timed out after ${timeoutMs}ms at ${url}`, void 0, true);
|
|
135
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw lastError ?? new A2AError(404, `AgentCard could not be fetched from ${baseUrl}`, void 0, true);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Pick the best interface to talk to an agent from its AgentCard.
|
|
144
|
+
*
|
|
145
|
+
* Preference order: a JSONRPC interface first, then gRPC, then any other
|
|
146
|
+
* binding; the card's own ordering ("first entry is preferred") is respected
|
|
147
|
+
* within each binding class. Returns the interface and a normalized base URL
|
|
148
|
+
* to post JSON-RPC requests to.
|
|
149
|
+
*/
|
|
150
|
+
function pickInterface(card, preferred) {
|
|
151
|
+
const ifaces = card.supportedInterfaces ?? [];
|
|
152
|
+
if (ifaces.length === 0) throw new A2AError(-32e3, `AgentCard for "${card.name}" declares no supportedInterfaces`);
|
|
153
|
+
const want = preferred ?? "JSONRPC";
|
|
154
|
+
const iface = [...ifaces].sort((a, b) => {
|
|
155
|
+
return (a.protocolBinding === want ? 0 : a.protocolBinding === "GRPC" ? 1 : 2) - (b.protocolBinding === want ? 0 : b.protocolBinding === "GRPC" ? 1 : 2);
|
|
156
|
+
})[0];
|
|
157
|
+
return {
|
|
158
|
+
iface,
|
|
159
|
+
url: iface.url.endsWith("/") ? iface.url.slice(0, -1) : iface.url
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/jsonrpc.ts
|
|
164
|
+
var JsonRpcClient = class {
|
|
165
|
+
url;
|
|
166
|
+
timeoutMs;
|
|
167
|
+
bearerToken;
|
|
168
|
+
fetchImpl;
|
|
169
|
+
seq = 0;
|
|
170
|
+
constructor(url, options = {}) {
|
|
171
|
+
this.url = url;
|
|
172
|
+
this.timeoutMs = options.timeoutMs ?? 6e4;
|
|
173
|
+
this.bearerToken = options.bearerToken;
|
|
174
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
175
|
+
}
|
|
176
|
+
headers(extra) {
|
|
177
|
+
const h = {
|
|
178
|
+
"content-type": "application/json",
|
|
179
|
+
accept: "application/json",
|
|
180
|
+
...extra
|
|
181
|
+
};
|
|
182
|
+
if (this.bearerToken) h.authorization = `Bearer ${this.bearerToken}`;
|
|
183
|
+
return h;
|
|
184
|
+
}
|
|
185
|
+
/** Perform a unary JSON-RPC call. */
|
|
186
|
+
async call(method, params, options = {}) {
|
|
187
|
+
const req = {
|
|
188
|
+
jsonrpc: "2.0",
|
|
189
|
+
id: ++this.seq,
|
|
190
|
+
method,
|
|
191
|
+
params
|
|
192
|
+
};
|
|
193
|
+
const ctrl = options.signal ? new AbortController() : void 0;
|
|
194
|
+
const onAbort = () => ctrl?.abort();
|
|
195
|
+
options.signal?.addEventListener("abort", onAbort);
|
|
196
|
+
const timeout = setTimeout(() => ctrl?.abort(), options.timeoutMs ?? this.timeoutMs);
|
|
197
|
+
try {
|
|
198
|
+
const res = await this.fetchImpl(this.url, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: this.headers(),
|
|
201
|
+
body: JSON.stringify(req),
|
|
202
|
+
signal: ctrl?.signal
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) throw new A2AError(res.status, `A2A JSON-RPC request failed (HTTP ${res.status}) at ${this.url}`, void 0, true);
|
|
205
|
+
const json = await res.json();
|
|
206
|
+
if ("error" in json && json.error) throw A2AError.fromJsonRpc(json.error);
|
|
207
|
+
return json.result;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (err instanceof Error && err.name === "AbortError") throw new A2AError(408, `A2A JSON-RPC call "${method}" timed out`, void 0, true);
|
|
210
|
+
throw err;
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timeout);
|
|
213
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Open a streaming JSON-RPC call (`SendStreamingMessage`) over SSE and
|
|
218
|
+
* yield the `StreamResponse` events as they arrive. The connection is
|
|
219
|
+
* closed when the returned async iterator is broken or the request is
|
|
220
|
+
* cancelled. Events are decoded from SSE `data:` lines (JSON-RPC result
|
|
221
|
+
* frames); per A2A v1.0 the JSON structure uses member names to
|
|
222
|
+
* discriminate `task` / `message` / `statusUpdate` / `artifactUpdate`.
|
|
223
|
+
*/
|
|
224
|
+
async *stream(method, params, options = {}) {
|
|
225
|
+
const req = {
|
|
226
|
+
jsonrpc: "2.0",
|
|
227
|
+
id: ++this.seq,
|
|
228
|
+
method,
|
|
229
|
+
params
|
|
230
|
+
};
|
|
231
|
+
const ctrl = new AbortController();
|
|
232
|
+
const onAbort = () => ctrl.abort();
|
|
233
|
+
options.signal?.addEventListener("abort", onAbort);
|
|
234
|
+
const timeout = setTimeout(() => ctrl.abort(), options.timeoutMs ?? this.timeoutMs);
|
|
235
|
+
let res;
|
|
236
|
+
try {
|
|
237
|
+
res = await this.fetchImpl(this.url, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: this.headers({ accept: "text/event-stream" }),
|
|
240
|
+
body: JSON.stringify(req),
|
|
241
|
+
signal: ctrl.signal
|
|
242
|
+
});
|
|
243
|
+
} catch (err) {
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
246
|
+
if (err instanceof Error && err.name === "AbortError") throw new A2AError(408, `A2A stream "${method}" timed out`, void 0, true);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
if (!res.ok || !res.body) {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
252
|
+
throw new A2AError(res.status, `A2A stream request failed (HTTP ${res.status}) at ${this.url}`, void 0, true);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const reader = res.body.getReader();
|
|
256
|
+
const decoder = new TextDecoder();
|
|
257
|
+
let buffer = "";
|
|
258
|
+
for (;;) {
|
|
259
|
+
const { done, value } = await reader.read();
|
|
260
|
+
if (done) break;
|
|
261
|
+
buffer += decoder.decode(value, { stream: true });
|
|
262
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
263
|
+
buffer = frames.pop() ?? "";
|
|
264
|
+
for (const frame of frames) {
|
|
265
|
+
const data = frame.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
|
|
266
|
+
if (!data) continue;
|
|
267
|
+
let payload;
|
|
268
|
+
try {
|
|
269
|
+
payload = JSON.parse(data);
|
|
270
|
+
} catch {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const envelope = payload;
|
|
274
|
+
if (envelope && "error" in envelope && envelope.error) throw A2AError.fromJsonRpc(envelope.error);
|
|
275
|
+
yield payload;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
clearTimeout(timeout);
|
|
280
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/client.ts
|
|
286
|
+
/**
|
|
287
|
+
* A2A (Agent2Agent) Protocol v1.0 — outbound client.
|
|
288
|
+
*
|
|
289
|
+
* A thin client that discovers an agent via its AgentCard and speaks the
|
|
290
|
+
* JSONRPC binding: SendMessage (sync/async), SendStreamingMessage,
|
|
291
|
+
* GetTask, CancelTask, ListTasks.
|
|
292
|
+
*/
|
|
293
|
+
/** A2A client bound to one remote agent. */
|
|
294
|
+
var A2AClient = class A2AClient {
|
|
295
|
+
card;
|
|
296
|
+
rpc;
|
|
297
|
+
endpointUrl;
|
|
298
|
+
tenant;
|
|
299
|
+
static async connect(agentCardUrlOrBase, options = {}) {
|
|
300
|
+
const card = await fetchAgentCard(agentCardUrlOrBase, options);
|
|
301
|
+
return new A2AClient(card, options);
|
|
302
|
+
}
|
|
303
|
+
constructor(card, options = {}) {
|
|
304
|
+
this.card = card;
|
|
305
|
+
const { iface, url } = pickInterface(card, options.preferredBinding);
|
|
306
|
+
this.endpointUrl = url;
|
|
307
|
+
this.tenant = iface.tenant;
|
|
308
|
+
this.rpc = new JsonRpcClient(url, options);
|
|
309
|
+
}
|
|
310
|
+
/** Send a message; returns a Task or Message per the server's choice. */
|
|
311
|
+
async sendMessage(message, configuration, options = {}) {
|
|
312
|
+
return this.rpc.call("SendMessage", {
|
|
313
|
+
message,
|
|
314
|
+
configuration,
|
|
315
|
+
...this.tenant ? { tenant: this.tenant } : {}
|
|
316
|
+
}, options);
|
|
317
|
+
}
|
|
318
|
+
/** Send a message over a stream, yielding all StreamResponse events. */
|
|
319
|
+
streamMessage(message, configuration = {}, options = {}) {
|
|
320
|
+
return this.rpc.stream("SendStreamingMessage", {
|
|
321
|
+
message,
|
|
322
|
+
configuration,
|
|
323
|
+
...this.tenant ? { tenant: this.tenant } : {}
|
|
324
|
+
}, options);
|
|
325
|
+
}
|
|
326
|
+
async getTask(id, historyLength, options = {}) {
|
|
327
|
+
return this.rpc.call("GetTask", {
|
|
328
|
+
id,
|
|
329
|
+
...historyLength !== void 0 ? { historyLength } : {},
|
|
330
|
+
...this.tenant ? { tenant: this.tenant } : {}
|
|
331
|
+
}, options);
|
|
332
|
+
}
|
|
333
|
+
async listTasks(params = {}, options = {}) {
|
|
334
|
+
return this.rpc.call("ListTasks", {
|
|
335
|
+
...params,
|
|
336
|
+
...this.tenant ? { tenant: this.tenant } : {}
|
|
337
|
+
}, options);
|
|
338
|
+
}
|
|
339
|
+
async cancelTask(id, options = {}) {
|
|
340
|
+
return this.rpc.call("CancelTask", {
|
|
341
|
+
id,
|
|
342
|
+
...this.tenant ? { tenant: this.tenant } : {}
|
|
343
|
+
}, options);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Send a message and wait (polling) until the created task reaches a
|
|
347
|
+
* terminal state. Errors if the task fails or is rejected.
|
|
348
|
+
*/
|
|
349
|
+
async sendAndWait(message, configuration = {}, options = {}) {
|
|
350
|
+
const resp = await this.sendMessage(message, {
|
|
351
|
+
...configuration,
|
|
352
|
+
returnImmediately: true
|
|
353
|
+
}, options);
|
|
354
|
+
if ("message" in resp && resp.message) return {
|
|
355
|
+
id: "message",
|
|
356
|
+
status: {
|
|
357
|
+
state: "TASK_STATE_COMPLETED",
|
|
358
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
359
|
+
},
|
|
360
|
+
artifacts: [{
|
|
361
|
+
artifactId: "message",
|
|
362
|
+
parts: resp.message.parts
|
|
363
|
+
}],
|
|
364
|
+
history: [resp.message]
|
|
365
|
+
};
|
|
366
|
+
const task = "task" in resp && resp.task ? resp.task : void 0;
|
|
367
|
+
if (!task) throw new A2AError(-32602, "SendMessage returned neither a Task nor a Message");
|
|
368
|
+
let t = task;
|
|
369
|
+
for (;;) {
|
|
370
|
+
if (isTerminal(t.status.state)) return t;
|
|
371
|
+
if (t.status.state === "TASK_STATE_INPUT_REQUIRED" || t.status.state === "TASK_STATE_AUTH_REQUIRED") return t;
|
|
372
|
+
if (options.signal?.aborted) throw new A2AError(408, "Task wait aborted");
|
|
373
|
+
await new Promise((r) => setTimeout(r, options.intervalMs ?? 500));
|
|
374
|
+
t = await this.getTask(t.id, 0, options);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/outbound.ts
|
|
380
|
+
function normalizeToolName(raw) {
|
|
381
|
+
const norm = raw.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
|
|
382
|
+
if (norm.length > 0 && /^[A-Za-z0-9_-]+$/.test(norm)) return norm;
|
|
383
|
+
let h = 0;
|
|
384
|
+
for (let i = 0; i < raw.length; i++) h = h * 31 + raw.charCodeAt(i) >>> 0;
|
|
385
|
+
return `_${h.toString(16)}`;
|
|
386
|
+
}
|
|
387
|
+
function skillToToolName(agentName, skill) {
|
|
388
|
+
const raw = `a2a__${agentName}__${skill.id}`;
|
|
389
|
+
const base = raw.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
|
|
390
|
+
if (base === raw || base.length < 64) return base;
|
|
391
|
+
let h = 0;
|
|
392
|
+
for (let i = 0; i < raw.length; i++) h = h * 31 + raw.charCodeAt(i) >>> 0;
|
|
393
|
+
return `${base.slice(0, 56)}_${h.toString(16)}`;
|
|
394
|
+
}
|
|
395
|
+
/** Extract the first meaningful text from message parts. */
|
|
396
|
+
function partsToText(parts) {
|
|
397
|
+
if (!parts) return "";
|
|
398
|
+
return parts.map((p) => {
|
|
399
|
+
if ("text" in p && p.text) return p.text;
|
|
400
|
+
if ("url" in p && p.url) return p.url;
|
|
401
|
+
if ("data" in p && p.data !== void 0) return JSON.stringify(p.data);
|
|
402
|
+
if ("raw" in p && p.raw) return `[raw ${p.mediaType ?? "application/octet-stream"}]`;
|
|
403
|
+
return "";
|
|
404
|
+
}).filter(Boolean).join("\n");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Register all skills of a remote agent on `ctx.tools` and return disposers.
|
|
408
|
+
*/
|
|
409
|
+
async function registerAgentTools(tools, options) {
|
|
410
|
+
const connectionId = crypto.randomUUID();
|
|
411
|
+
const client = await A2AClient.connect(options.agentCardUrl, {
|
|
412
|
+
bearerToken: options.bearerToken,
|
|
413
|
+
timeoutMs: options.timeoutMs
|
|
414
|
+
});
|
|
415
|
+
const card = client.card;
|
|
416
|
+
const skills = card.skills ?? [];
|
|
417
|
+
options.onReady?.({
|
|
418
|
+
connectionId,
|
|
419
|
+
card
|
|
420
|
+
});
|
|
421
|
+
let disposers = [];
|
|
422
|
+
if (options.mapSkills === false || skills.length === 0) {
|
|
423
|
+
const name = normalizeToolName(`a2a__${options.name}__agent`);
|
|
424
|
+
const dispose = tools.register?.(makeTool(name, client, {
|
|
425
|
+
id: "agent",
|
|
426
|
+
name: options.name,
|
|
427
|
+
description: `${card.name}: ${card.description} (no skills advertised)`
|
|
428
|
+
}));
|
|
429
|
+
if (dispose) disposers = [{
|
|
430
|
+
name,
|
|
431
|
+
dispose
|
|
432
|
+
}];
|
|
433
|
+
} else {
|
|
434
|
+
const out = [];
|
|
435
|
+
for (const skill of skills) {
|
|
436
|
+
const name = skillToToolName(options.name, skill);
|
|
437
|
+
const dispose = tools.register?.(makeTool(name, client, skill));
|
|
438
|
+
if (dispose) out.push({
|
|
439
|
+
name,
|
|
440
|
+
dispose
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
disposers = out;
|
|
444
|
+
}
|
|
445
|
+
return disposers.map((r) => ({
|
|
446
|
+
...r,
|
|
447
|
+
connectionId,
|
|
448
|
+
dispose: () => {
|
|
449
|
+
r.dispose();
|
|
450
|
+
options.onDispose?.(connectionId);
|
|
451
|
+
}
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
function makeTool(name, client, skill) {
|
|
455
|
+
return {
|
|
456
|
+
name,
|
|
457
|
+
description: [skill.description, ...skill.examples?.length ? [`Examples: ${skill.examples.join(" | ")}`] : []].filter(Boolean).join("\n"),
|
|
458
|
+
parameters: {
|
|
459
|
+
type: "object",
|
|
460
|
+
properties: { prompt: {
|
|
461
|
+
type: "string",
|
|
462
|
+
description: "The task or instruction to send to the remote agent."
|
|
463
|
+
} },
|
|
464
|
+
required: ["prompt"]
|
|
465
|
+
},
|
|
466
|
+
output: {
|
|
467
|
+
schema: { type: "string" },
|
|
468
|
+
render: (_args, value) => [{
|
|
469
|
+
type: "text",
|
|
470
|
+
text: String(value)
|
|
471
|
+
}]
|
|
472
|
+
},
|
|
473
|
+
async execute(args, exec) {
|
|
474
|
+
const message = {
|
|
475
|
+
messageId: crypto.randomUUID(),
|
|
476
|
+
role: "ROLE_USER",
|
|
477
|
+
parts: [{ text: args.prompt }]
|
|
478
|
+
};
|
|
479
|
+
try {
|
|
480
|
+
const task = await client.sendAndWait(message, {}, { signal: exec.signal });
|
|
481
|
+
const text = partsToText(task.artifacts?.flatMap((a) => a.parts));
|
|
482
|
+
const failureMsg = task.status.message?.parts?.find((p) => "text" in p && p.text);
|
|
483
|
+
if (task.status.state === "TASK_STATE_FAILED") throw new A2AError(-32e3, `Remote agent task failed: ${text || (failureMsg && "text" in failureMsg ? failureMsg.text : "no detail")}`);
|
|
484
|
+
if (task.status.state === "TASK_STATE_INPUT_REQUIRED" || task.status.state === "TASK_STATE_AUTH_REQUIRED") {
|
|
485
|
+
const ask = (failureMsg && "text" in failureMsg ? failureMsg.text : "") || text || "agent awaits input";
|
|
486
|
+
return `[remote agent ${task.status.state}] ${ask}`;
|
|
487
|
+
}
|
|
488
|
+
return text || "(remote agent returned no output)";
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (err instanceof A2AError && err.code === -32001 && err.message.includes("interrupted")) throw new Error(`Remote agent requires input: ${err.message}`);
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/server.ts
|
|
498
|
+
/** In-memory task store with the full A2A Task lifecycle. */
|
|
499
|
+
var TaskStore = class {
|
|
500
|
+
tasks = /* @__PURE__ */ new Map();
|
|
501
|
+
messages = /* @__PURE__ */ new Map();
|
|
502
|
+
create(message) {
|
|
503
|
+
const taskId = crypto.randomUUID();
|
|
504
|
+
const task = {
|
|
505
|
+
id: taskId,
|
|
506
|
+
contextId: message.contextId ?? crypto.randomUUID(),
|
|
507
|
+
status: {
|
|
508
|
+
state: "TASK_STATE_SUBMITTED",
|
|
509
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
510
|
+
},
|
|
511
|
+
artifacts: [],
|
|
512
|
+
history: [message],
|
|
513
|
+
metadata: {}
|
|
514
|
+
};
|
|
515
|
+
this.tasks.set(taskId, task);
|
|
516
|
+
this.messages.set(message.messageId, message);
|
|
517
|
+
return task;
|
|
518
|
+
}
|
|
519
|
+
get(id) {
|
|
520
|
+
return this.tasks.get(id);
|
|
521
|
+
}
|
|
522
|
+
list() {
|
|
523
|
+
return [...this.tasks.values()];
|
|
524
|
+
}
|
|
525
|
+
setStatus(taskId, state, message) {
|
|
526
|
+
const task = this.tasks.get(taskId);
|
|
527
|
+
if (!task) throw new Error(`task ${taskId} not found`);
|
|
528
|
+
task.status = {
|
|
529
|
+
state,
|
|
530
|
+
message,
|
|
531
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
532
|
+
};
|
|
533
|
+
if (message) {
|
|
534
|
+
this.messages.set(message.messageId, message);
|
|
535
|
+
task.history = [...task.history ?? [], message];
|
|
536
|
+
}
|
|
537
|
+
return task;
|
|
538
|
+
}
|
|
539
|
+
addArtifact(taskId, artifact) {
|
|
540
|
+
const task = this.tasks.get(taskId);
|
|
541
|
+
if (!task) throw new Error(`task ${taskId} not found`);
|
|
542
|
+
const existing = task.artifacts?.find((a) => a.artifactId === artifact.artifactId);
|
|
543
|
+
if (existing) existing.parts.push(...artifact.parts);
|
|
544
|
+
else task.artifacts = [...task.artifacts ?? [], {
|
|
545
|
+
artifactId: artifact.artifactId,
|
|
546
|
+
name: artifact.name,
|
|
547
|
+
parts: artifact.parts
|
|
548
|
+
}];
|
|
549
|
+
return task;
|
|
550
|
+
}
|
|
551
|
+
getMessage(id) {
|
|
552
|
+
return this.messages.get(id);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
/** Extract a human-readable source address from a request (when visible). */
|
|
556
|
+
function sourceOf(req) {
|
|
557
|
+
const s = req.socket;
|
|
558
|
+
if (!s?.remoteAddress) return void 0;
|
|
559
|
+
return `${s.remoteAddress}:${s.remotePort ?? ""}`;
|
|
560
|
+
}
|
|
561
|
+
/** A2A JSON-RPC server handler over a raw HTTP request/response pair. */
|
|
562
|
+
var A2AServer = class {
|
|
563
|
+
options;
|
|
564
|
+
store;
|
|
565
|
+
card;
|
|
566
|
+
endpointPath;
|
|
567
|
+
execute;
|
|
568
|
+
listeners = /* @__PURE__ */ new Set();
|
|
569
|
+
baseUrl;
|
|
570
|
+
constructor(options, store = new TaskStore()) {
|
|
571
|
+
this.options = options;
|
|
572
|
+
this.store = store;
|
|
573
|
+
this.execute = options.execute ?? defaultExecutor;
|
|
574
|
+
this.endpointPath = options.endpointPath ?? "/a2a";
|
|
575
|
+
this.baseUrl = options.baseUrl;
|
|
576
|
+
this.card = this.buildCard();
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Update the base URL advertised in the AgentCard after the real listen
|
|
580
|
+
* address is known (e.g. ephemeral port). Mutates `card` in place.
|
|
581
|
+
*/
|
|
582
|
+
setBaseUrl(baseUrl) {
|
|
583
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
584
|
+
this.card.supportedInterfaces = this.card.supportedInterfaces.map((iface) => ({
|
|
585
|
+
...iface,
|
|
586
|
+
url: `${this.baseUrl}${this.endpointPath}`
|
|
587
|
+
}));
|
|
588
|
+
}
|
|
589
|
+
buildCard() {
|
|
590
|
+
const iface = {
|
|
591
|
+
url: `${this.baseUrl.replace(/\/$/, "")}${this.endpointPath}`,
|
|
592
|
+
protocolBinding: "JSONRPC",
|
|
593
|
+
protocolVersion: "1.0"
|
|
594
|
+
};
|
|
595
|
+
const skills = this.options.skills ?? [];
|
|
596
|
+
return {
|
|
597
|
+
name: this.options.agentName,
|
|
598
|
+
description: this.options.agentDescription,
|
|
599
|
+
version: this.options.agentVersion,
|
|
600
|
+
supportedInterfaces: [iface],
|
|
601
|
+
provider: this.options.agentProvider ?? {
|
|
602
|
+
url: "https://deepseek.com",
|
|
603
|
+
organization: "DeepSeek"
|
|
604
|
+
},
|
|
605
|
+
capabilities: {
|
|
606
|
+
streaming: true,
|
|
607
|
+
pushNotifications: false,
|
|
608
|
+
extensions: []
|
|
609
|
+
},
|
|
610
|
+
defaultInputModes: this.options.defaultInputModes ?? ["text/plain"],
|
|
611
|
+
defaultOutputModes: this.options.defaultOutputModes ?? ["text/plain"],
|
|
612
|
+
skills,
|
|
613
|
+
...this.options.iconUrl ? { iconUrl: this.options.iconUrl } : {}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
emit(ev) {
|
|
617
|
+
for (const l of this.listeners) l(ev);
|
|
618
|
+
}
|
|
619
|
+
/** Route an inbound HTTP request; returns true when handled. */
|
|
620
|
+
async handle(req, body) {
|
|
621
|
+
const path = (req.url ?? "").split("?")[0];
|
|
622
|
+
if (req.method === "GET" && (path === "/.well-known/agent-card.json" || path === this.endpointPath + "/card")) return {
|
|
623
|
+
status: 200,
|
|
624
|
+
contentType: "application/json",
|
|
625
|
+
body: JSON.stringify(this.card)
|
|
626
|
+
};
|
|
627
|
+
if (req.method === "POST" && path === this.endpointPath) {
|
|
628
|
+
let payload;
|
|
629
|
+
try {
|
|
630
|
+
payload = JSON.parse(body);
|
|
631
|
+
} catch {
|
|
632
|
+
return this.jsonRpcError(null, A2A_ERROR_CODES.INVALID_REQUEST, "Invalid JSON", "Parse error");
|
|
633
|
+
}
|
|
634
|
+
const rpc = payload;
|
|
635
|
+
if (!rpc || rpc.jsonrpc !== "2.0" || typeof rpc.method !== "string") return this.jsonRpcError(rpc?.id ?? null, A2A_ERROR_CODES.INVALID_REQUEST, "Invalid JSON-RPC request");
|
|
636
|
+
try {
|
|
637
|
+
const result = await this.dispatch(rpc);
|
|
638
|
+
this.options.onInbound?.({
|
|
639
|
+
method: rpc.method,
|
|
640
|
+
headers: req.headers,
|
|
641
|
+
source: sourceOf(req),
|
|
642
|
+
taskIds: extractTaskIds(result),
|
|
643
|
+
streaming: false
|
|
644
|
+
});
|
|
645
|
+
return {
|
|
646
|
+
status: 200,
|
|
647
|
+
contentType: "application/json",
|
|
648
|
+
body: JSON.stringify({
|
|
649
|
+
jsonrpc: "2.0",
|
|
650
|
+
id: rpc.id,
|
|
651
|
+
result
|
|
652
|
+
})
|
|
653
|
+
};
|
|
654
|
+
} catch (err) {
|
|
655
|
+
const e = err;
|
|
656
|
+
return this.jsonRpcError(rpc.id ?? null, typeof e.code === "number" ? e.code : A2A_ERROR_CODES.INTERNAL_ERROR, e.message ?? String(err), e.data);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
status: 404,
|
|
661
|
+
contentType: "text/plain",
|
|
662
|
+
body: "Not Found"
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async handleStream(req, body, onEvent) {
|
|
666
|
+
const path = (req.url ?? "").split("?")[0];
|
|
667
|
+
if (req.method !== "POST" || path !== this.endpointPath) {
|
|
668
|
+
onEvent(`event: error\ndata: ${JSON.stringify({
|
|
669
|
+
code: 404,
|
|
670
|
+
message: "Not Found"
|
|
671
|
+
})}\n\n`);
|
|
672
|
+
return { status: 200 };
|
|
673
|
+
}
|
|
674
|
+
let payload;
|
|
675
|
+
try {
|
|
676
|
+
payload = JSON.parse(body);
|
|
677
|
+
} catch {
|
|
678
|
+
onEvent(`event: error\ndata: ${JSON.stringify({
|
|
679
|
+
code: A2A_ERROR_CODES.INVALID_REQUEST,
|
|
680
|
+
message: "Parse error"
|
|
681
|
+
})}\n\n`);
|
|
682
|
+
return { status: 200 };
|
|
683
|
+
}
|
|
684
|
+
const rpc = payload;
|
|
685
|
+
const method = rpc?.method;
|
|
686
|
+
if (method === A2A_METHODS.sendStreamingMessage || method === A2A_METHODS.subscribeToTask) {
|
|
687
|
+
const taskId = method === A2A_METHODS.subscribeToTask ? rpc.params?.id : await this.ensureTask(rpc.params);
|
|
688
|
+
if (!taskId) {
|
|
689
|
+
onEvent(`event: error\ndata: ${JSON.stringify({
|
|
690
|
+
code: A2A_ERROR_CODES.INVALID_PARAMS,
|
|
691
|
+
message: "Missing message"
|
|
692
|
+
})}\n\n`);
|
|
693
|
+
return { status: 200 };
|
|
694
|
+
}
|
|
695
|
+
const task = this.store.get(taskId);
|
|
696
|
+
if (!task) {
|
|
697
|
+
onEvent(`event: error\ndata: ${JSON.stringify({
|
|
698
|
+
code: A2A_ERROR_CODES.TASK_NOT_FOUND,
|
|
699
|
+
message: `Task ${taskId} not found`
|
|
700
|
+
})}\n\n`);
|
|
701
|
+
return { status: 200 };
|
|
702
|
+
}
|
|
703
|
+
const sub = (ev) => onEvent(`data: ${JSON.stringify(ev)}\n\n`);
|
|
704
|
+
this.listeners.add(sub);
|
|
705
|
+
this.options.onInbound?.({
|
|
706
|
+
method,
|
|
707
|
+
headers: req.headers,
|
|
708
|
+
source: sourceOf(req),
|
|
709
|
+
taskIds: taskId ? [taskId] : [],
|
|
710
|
+
streaming: true
|
|
711
|
+
});
|
|
712
|
+
try {
|
|
713
|
+
if (method === A2A_METHODS.sendStreamingMessage) {
|
|
714
|
+
const msg = rpc.params.message;
|
|
715
|
+
this.runTask(task.id, task.contextId, msg);
|
|
716
|
+
}
|
|
717
|
+
await this.waitTerminal(task.id);
|
|
718
|
+
onEvent(`data: ${JSON.stringify({ task: this.store.get(taskId) })}\n\n`);
|
|
719
|
+
} finally {
|
|
720
|
+
this.listeners.delete(sub);
|
|
721
|
+
}
|
|
722
|
+
return { status: 200 };
|
|
723
|
+
}
|
|
724
|
+
onEvent(`event: error\ndata: ${JSON.stringify({
|
|
725
|
+
code: A2A_ERROR_CODES.METHOD_NOT_FOUND,
|
|
726
|
+
message: `Unknown method ${method}`
|
|
727
|
+
})}\n\n`);
|
|
728
|
+
return { status: 200 };
|
|
729
|
+
}
|
|
730
|
+
async ensureTask(params) {
|
|
731
|
+
if (!params?.message) return void 0;
|
|
732
|
+
const msg = params.message;
|
|
733
|
+
if (msg.taskId && this.store.get(msg.taskId)) return msg.taskId;
|
|
734
|
+
return this.store.create(msg).id;
|
|
735
|
+
}
|
|
736
|
+
async waitTerminal(taskId) {
|
|
737
|
+
for (;;) {
|
|
738
|
+
const t = this.store.get(taskId);
|
|
739
|
+
if (t && isTerminal(t.status.state)) return;
|
|
740
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/** Execute a task in the background, streaming status/artifact updates. */
|
|
744
|
+
async runTask(taskId, contextId, msg) {
|
|
745
|
+
this.store.setStatus(taskId, "TASK_STATE_WORKING");
|
|
746
|
+
this.emit({ statusUpdate: {
|
|
747
|
+
taskId,
|
|
748
|
+
contextId,
|
|
749
|
+
status: this.store.get(taskId).status
|
|
750
|
+
} });
|
|
751
|
+
const signal = new AbortController().signal;
|
|
752
|
+
try {
|
|
753
|
+
const reply = await this.execute({
|
|
754
|
+
message: msg,
|
|
755
|
+
taskId,
|
|
756
|
+
contextId,
|
|
757
|
+
signal
|
|
758
|
+
});
|
|
759
|
+
this.store.addArtifact(taskId, {
|
|
760
|
+
artifactId: "result",
|
|
761
|
+
parts: reply.parts,
|
|
762
|
+
lastChunk: true
|
|
763
|
+
});
|
|
764
|
+
this.store.setStatus(taskId, "TASK_STATE_COMPLETED", reply);
|
|
765
|
+
this.options.onTaskSettled?.(taskId);
|
|
766
|
+
const task = this.store.get(taskId);
|
|
767
|
+
this.emit({ task });
|
|
768
|
+
this.emit({ artifactUpdate: {
|
|
769
|
+
taskId,
|
|
770
|
+
contextId,
|
|
771
|
+
artifact: task.artifacts[task.artifacts.length - 1],
|
|
772
|
+
lastChunk: true
|
|
773
|
+
} });
|
|
774
|
+
} catch (err) {
|
|
775
|
+
const message = {
|
|
776
|
+
messageId: crypto.randomUUID(),
|
|
777
|
+
role: "ROLE_AGENT",
|
|
778
|
+
parts: [{ text: `Task failed: ${err.message ?? String(err)}` }]
|
|
779
|
+
};
|
|
780
|
+
this.store.setStatus(taskId, "TASK_STATE_FAILED", message);
|
|
781
|
+
this.options.onTaskSettled?.(taskId);
|
|
782
|
+
this.emit({ statusUpdate: {
|
|
783
|
+
taskId,
|
|
784
|
+
contextId,
|
|
785
|
+
status: this.store.get(taskId).status
|
|
786
|
+
} });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async dispatch(rpc) {
|
|
790
|
+
const method = rpc.method;
|
|
791
|
+
const params = rpc.params ?? {};
|
|
792
|
+
switch (method) {
|
|
793
|
+
case A2A_METHODS.sendMessage: {
|
|
794
|
+
const msg = params.message;
|
|
795
|
+
if (!msg?.parts?.length) throw {
|
|
796
|
+
code: A2A_ERROR_CODES.INVALID_PARAMS,
|
|
797
|
+
message: "Missing message"
|
|
798
|
+
};
|
|
799
|
+
const taskId = await this.ensureTask(params);
|
|
800
|
+
const task = this.store.get(taskId);
|
|
801
|
+
this.runTask(task.id, task.contextId, msg);
|
|
802
|
+
await this.waitTerminal(task.id);
|
|
803
|
+
return { task: this.store.get(task.id) };
|
|
804
|
+
}
|
|
805
|
+
case A2A_METHODS.getTask: {
|
|
806
|
+
const id = params.id;
|
|
807
|
+
const task = this.store.get(id);
|
|
808
|
+
if (!task) throw {
|
|
809
|
+
code: A2A_ERROR_CODES.TASK_NOT_FOUND,
|
|
810
|
+
message: `Task ${id} not found`
|
|
811
|
+
};
|
|
812
|
+
return task;
|
|
813
|
+
}
|
|
814
|
+
case A2A_METHODS.listTasks: {
|
|
815
|
+
const tasks = this.store.list();
|
|
816
|
+
return {
|
|
817
|
+
tasks,
|
|
818
|
+
nextPageToken: "",
|
|
819
|
+
pageSize: tasks.length,
|
|
820
|
+
totalSize: tasks.length
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
case A2A_METHODS.cancelTask: {
|
|
824
|
+
const id = params.id;
|
|
825
|
+
const task = this.store.get(id);
|
|
826
|
+
if (!task) throw {
|
|
827
|
+
code: A2A_ERROR_CODES.TASK_NOT_FOUND,
|
|
828
|
+
message: `Task ${id} not found`
|
|
829
|
+
};
|
|
830
|
+
if (isTerminal(task.status.state)) throw {
|
|
831
|
+
code: A2A_ERROR_CODES.TASK_CANCEL_NOT_ALLOWED,
|
|
832
|
+
message: `Task ${id} already in terminal state ${task.status.state}`
|
|
833
|
+
};
|
|
834
|
+
this.store.setStatus(id, "TASK_STATE_CANCELED");
|
|
835
|
+
this.options.onTaskSettled?.(id);
|
|
836
|
+
return this.store.get(id);
|
|
837
|
+
}
|
|
838
|
+
case A2A_METHODS.getExtendedAgentCard: return this.card;
|
|
839
|
+
default: throw {
|
|
840
|
+
code: A2A_ERROR_CODES.METHOD_NOT_FOUND,
|
|
841
|
+
message: `Method ${method} not supported`
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
jsonRpcError(id, code, message, data) {
|
|
846
|
+
const err = {
|
|
847
|
+
jsonrpc: "2.0",
|
|
848
|
+
id,
|
|
849
|
+
error: {
|
|
850
|
+
code,
|
|
851
|
+
message,
|
|
852
|
+
...data !== void 0 ? { data } : {}
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
return {
|
|
856
|
+
status: 200,
|
|
857
|
+
contentType: "application/json",
|
|
858
|
+
body: JSON.stringify(err)
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
/** Pull task ids out of a dispatch result for inbound peer tracking. */
|
|
863
|
+
function extractTaskIds(result) {
|
|
864
|
+
if (!result || typeof result !== "object") return [];
|
|
865
|
+
const r = result;
|
|
866
|
+
if (typeof r.task?.id === "string") return [r.task.id];
|
|
867
|
+
if (Array.isArray(r.tasks)) return r.tasks.map((t) => t.id).filter((id) => typeof id === "string");
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
const defaultSkills = [{
|
|
871
|
+
id: "coding",
|
|
872
|
+
name: "Coding",
|
|
873
|
+
description: "Execute coding and shell tasks inside the DSH workspace.",
|
|
874
|
+
tags: ["coding", "shell"],
|
|
875
|
+
inputModes: ["text/plain"],
|
|
876
|
+
outputModes: ["text/plain"]
|
|
877
|
+
}];
|
|
878
|
+
/**
|
|
879
|
+
* Default executor: run the incoming prompt through the system shell and
|
|
880
|
+
* return the captured stdout/stderr as the task artifact. Honors the
|
|
881
|
+
* AbortSignal (task cancellation / shutdown) by killing the child process.
|
|
882
|
+
*/
|
|
883
|
+
const defaultExecutor = async ({ message, signal }) => {
|
|
884
|
+
const text = message.parts.map((p) => "text" in p ? p.text : "").filter(Boolean).join("\n");
|
|
885
|
+
if (!text.trim()) return {
|
|
886
|
+
messageId: crypto.randomUUID(),
|
|
887
|
+
role: "ROLE_AGENT",
|
|
888
|
+
parts: [{ text: "No prompt provided." }]
|
|
889
|
+
};
|
|
890
|
+
try {
|
|
891
|
+
const { execFile } = await import("node:child_process");
|
|
892
|
+
const out = await new Promise((resolve, _reject) => {
|
|
893
|
+
const child = execFile("/bin/sh", ["-c", text], {
|
|
894
|
+
timeout: 3e4,
|
|
895
|
+
maxBuffer: 1048576,
|
|
896
|
+
signal
|
|
897
|
+
}, (err, stdout, stderr) => {
|
|
898
|
+
if (err) {
|
|
899
|
+
if (err.killed || signal?.aborted) {
|
|
900
|
+
resolve(`(aborted) ${stdout}${stderr}`.trim() || "(aborted)");
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
resolve(`(exit ${err.code ?? "?"}) ${stdout}\n${stderr}`);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
resolve(`${stdout}\n${stderr}`.trim());
|
|
907
|
+
});
|
|
908
|
+
signal?.addEventListener("abort", () => child.kill(), { once: true });
|
|
909
|
+
});
|
|
910
|
+
return {
|
|
911
|
+
messageId: crypto.randomUUID(),
|
|
912
|
+
role: "ROLE_AGENT",
|
|
913
|
+
parts: [{ text: out || "(no output)" }]
|
|
914
|
+
};
|
|
915
|
+
} catch (err) {
|
|
916
|
+
return {
|
|
917
|
+
messageId: crypto.randomUUID(),
|
|
918
|
+
role: "ROLE_AGENT",
|
|
919
|
+
parts: [{ text: `Executor error: ${err.message}` }]
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/dashboard.ts
|
|
925
|
+
/** Registry of live A2A connections, both directions. */
|
|
926
|
+
var DashboardRegistry = class {
|
|
927
|
+
peers = /* @__PURE__ */ new Map();
|
|
928
|
+
agents = /* @__PURE__ */ new Map();
|
|
929
|
+
hooks = {};
|
|
930
|
+
seq = 0;
|
|
931
|
+
setHooks(hooks) {
|
|
932
|
+
this.hooks = {
|
|
933
|
+
...this.hooks,
|
|
934
|
+
...hooks
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
/** Record one inbound request (or stream open). Returns the peer id. */
|
|
938
|
+
touchInbound(req, taskIds = []) {
|
|
939
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
940
|
+
const ua = req.headers?.["user-agent"] ?? req.headers?.["User-Agent"] ?? "unknown";
|
|
941
|
+
const remote = req.source;
|
|
942
|
+
const label = peerLabel(ua, remote);
|
|
943
|
+
const key = `${label}|${remote?.replace(/:\d+$/, "") ?? ""}`;
|
|
944
|
+
let peer;
|
|
945
|
+
for (const p of this.peers.values()) {
|
|
946
|
+
const pHost = p.source?.replace(/:\d+$/, "") ?? "";
|
|
947
|
+
if (`${p.label}|${pHost}` === key) {
|
|
948
|
+
peer = p;
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (!peer) {
|
|
953
|
+
const id = `in-${++this.seq}`;
|
|
954
|
+
peer = {
|
|
955
|
+
id,
|
|
956
|
+
label,
|
|
957
|
+
source: remote,
|
|
958
|
+
firstSeen: now,
|
|
959
|
+
lastSeen: now,
|
|
960
|
+
taskCount: 0,
|
|
961
|
+
activeTaskIds: [],
|
|
962
|
+
streaming: false
|
|
963
|
+
};
|
|
964
|
+
this.peers.set(id, peer);
|
|
965
|
+
}
|
|
966
|
+
peer.source = remote ?? peer.source;
|
|
967
|
+
peer.lastSeen = now;
|
|
968
|
+
if (taskIds.length) {
|
|
969
|
+
peer.taskCount += taskIds.length;
|
|
970
|
+
for (const t of taskIds) if (!peer.activeTaskIds.includes(t)) peer.activeTaskIds.push(t);
|
|
971
|
+
}
|
|
972
|
+
return peer.id;
|
|
973
|
+
}
|
|
974
|
+
/** Mark a task finished for a peer (drops it from the active list). */
|
|
975
|
+
finishTask(peerId, taskId) {
|
|
976
|
+
const peer = this.peers.get(peerId);
|
|
977
|
+
if (!peer) return;
|
|
978
|
+
peer.activeTaskIds = peer.activeTaskIds.filter((t) => t !== taskId);
|
|
979
|
+
}
|
|
980
|
+
/** Mark a peer's streaming flag. */
|
|
981
|
+
setStreaming(peerId, streaming) {
|
|
982
|
+
const peer = this.peers.get(peerId);
|
|
983
|
+
if (peer) peer.streaming = streaming;
|
|
984
|
+
}
|
|
985
|
+
/** Remove an inbound peer (the "关闭" action severs tracking). */
|
|
986
|
+
removePeer(id) {
|
|
987
|
+
this.peers.delete(id);
|
|
988
|
+
}
|
|
989
|
+
/** Register or refresh an outbound agent connection. */
|
|
990
|
+
upsertAgent(agent) {
|
|
991
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
992
|
+
let id;
|
|
993
|
+
if (agent.connectionId) {
|
|
994
|
+
for (const [k, v] of this.agents) if (v.connectionId === agent.connectionId) {
|
|
995
|
+
id = k;
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (!id) {
|
|
1000
|
+
id = `out-${++this.seq}`;
|
|
1001
|
+
this.agents.set(id, {
|
|
1002
|
+
...agent,
|
|
1003
|
+
id,
|
|
1004
|
+
lastSeen: now
|
|
1005
|
+
});
|
|
1006
|
+
} else {
|
|
1007
|
+
const existing = this.agents.get(id);
|
|
1008
|
+
this.agents.set(id, {
|
|
1009
|
+
...existing,
|
|
1010
|
+
...agent,
|
|
1011
|
+
skillCount: agent.skillCount > 0 ? agent.skillCount : existing.skillCount,
|
|
1012
|
+
toolCount: agent.toolCount > 0 ? agent.toolCount : existing.toolCount,
|
|
1013
|
+
agentName: agent.agentName ?? existing.agentName,
|
|
1014
|
+
skills: agent.skills ? agent.skills : existing.skills,
|
|
1015
|
+
state: agent.state ?? existing.state,
|
|
1016
|
+
id,
|
|
1017
|
+
lastSeen: now
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return id;
|
|
1021
|
+
}
|
|
1022
|
+
/** Remove an outbound agent by id or connectionId. */
|
|
1023
|
+
removeAgent(connectionId) {
|
|
1024
|
+
for (const [k, v] of this.agents) if (v.connectionId === connectionId || k === connectionId) {
|
|
1025
|
+
this.agents.delete(k);
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
/** Update one agent's state. */
|
|
1031
|
+
setAgentState(connectionId, state) {
|
|
1032
|
+
for (const v of this.agents.values()) if (v.connectionId === connectionId) {
|
|
1033
|
+
v.state = state;
|
|
1034
|
+
v.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/** Touch one agent's last activity. */
|
|
1038
|
+
touchAgent(connectionId) {
|
|
1039
|
+
for (const v of this.agents.values()) if (v.connectionId === connectionId) v.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
|
|
1040
|
+
}
|
|
1041
|
+
snapshot() {
|
|
1042
|
+
return {
|
|
1043
|
+
inbound: [...this.peers.values()].map((p) => ({
|
|
1044
|
+
...p,
|
|
1045
|
+
activeTaskIds: [...p.activeTaskIds]
|
|
1046
|
+
})),
|
|
1047
|
+
outbound: [...this.agents.values()].map((a) => ({ ...a })),
|
|
1048
|
+
at: Date.now()
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
async control(action, target, payload) {
|
|
1052
|
+
switch (action) {
|
|
1053
|
+
case "reconnect-agent": return this.hooks.reconnectAgent ? this.hooks.reconnectAgent(target) : {
|
|
1054
|
+
ok: false,
|
|
1055
|
+
message: "reconnect-agent is not wired"
|
|
1056
|
+
};
|
|
1057
|
+
case "close-agent": return this.hooks.closeAgent ? this.hooks.closeAgent(target) : {
|
|
1058
|
+
ok: false,
|
|
1059
|
+
message: "close-agent is not wired"
|
|
1060
|
+
};
|
|
1061
|
+
case "disable-agent": return this.hooks.disableAgent ? this.hooks.disableAgent(target) : {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
message: "disable-agent is not wired (client mode not mounted)"
|
|
1064
|
+
};
|
|
1065
|
+
case "enable-agent": return this.hooks.enableAgent ? this.hooks.enableAgent(target) : {
|
|
1066
|
+
ok: false,
|
|
1067
|
+
message: "enable-agent is not wired (client mode not mounted)"
|
|
1068
|
+
};
|
|
1069
|
+
case "reconnect-peer": return this.hooks.reconnectPeer ? this.hooks.reconnectPeer(target) : {
|
|
1070
|
+
ok: false,
|
|
1071
|
+
message: "reconnect-peer is not supported"
|
|
1072
|
+
};
|
|
1073
|
+
case "close-peer": return Promise.resolve(this.closePeerLocal(target));
|
|
1074
|
+
case "discover-agent": {
|
|
1075
|
+
const url = payload?.agentCardUrl ?? target;
|
|
1076
|
+
if (!url) return {
|
|
1077
|
+
ok: false,
|
|
1078
|
+
message: "discover-agent requires agentCardUrl"
|
|
1079
|
+
};
|
|
1080
|
+
return this.hooks.discoverAgent ? this.hooks.discoverAgent(url) : {
|
|
1081
|
+
ok: false,
|
|
1082
|
+
message: "discover-agent is not wired"
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
case "add-agent": {
|
|
1086
|
+
const name = payload?.name ?? target;
|
|
1087
|
+
const url = payload?.agentCardUrl;
|
|
1088
|
+
if (!url) return {
|
|
1089
|
+
ok: false,
|
|
1090
|
+
message: "add-agent requires agentCardUrl"
|
|
1091
|
+
};
|
|
1092
|
+
return this.hooks.addAgent ? this.hooks.addAgent(name, url) : {
|
|
1093
|
+
ok: false,
|
|
1094
|
+
message: "add-agent is not wired (client mode not mounted)"
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
case "remove-agent": return this.hooks.removeAgent ? this.hooks.removeAgent(target) : {
|
|
1098
|
+
ok: false,
|
|
1099
|
+
message: "remove-agent is not wired (client mode not mounted)"
|
|
1100
|
+
};
|
|
1101
|
+
case "server-enable": return this.hooks.setServerEnabled ? this.hooks.setServerEnabled(true) : {
|
|
1102
|
+
ok: false,
|
|
1103
|
+
message: "server control is not wired (server mode not mounted)"
|
|
1104
|
+
};
|
|
1105
|
+
case "server-disable": return this.hooks.setServerEnabled ? this.hooks.setServerEnabled(false) : {
|
|
1106
|
+
ok: false,
|
|
1107
|
+
message: "server control is not wired (server mode not mounted)"
|
|
1108
|
+
};
|
|
1109
|
+
case "server-status": return this.hooks.serverStatus ? Promise.resolve(this.hooks.serverStatus()) : {
|
|
1110
|
+
ok: false,
|
|
1111
|
+
message: "server-status is not wired (server mode not mounted)"
|
|
1112
|
+
};
|
|
1113
|
+
default: return {
|
|
1114
|
+
ok: false,
|
|
1115
|
+
message: `unknown action ${action}`
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
closePeerLocal(id) {
|
|
1120
|
+
if (!this.peers.has(id)) return {
|
|
1121
|
+
ok: false,
|
|
1122
|
+
message: `peer ${id} not found`
|
|
1123
|
+
};
|
|
1124
|
+
this.removePeer(id);
|
|
1125
|
+
return {
|
|
1126
|
+
ok: true,
|
|
1127
|
+
message: `peer ${id} closed`
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
function peerLabel(ua, remote) {
|
|
1132
|
+
const lower = ua.toLowerCase();
|
|
1133
|
+
if (lower.includes("python-requests") || lower.includes("a2a-python")) return "Python A2A client";
|
|
1134
|
+
if (lower.includes("node")) return "Node A2A client";
|
|
1135
|
+
if (lower.includes("curl")) return "curl";
|
|
1136
|
+
if (lower.includes("mozilla")) return "Browser";
|
|
1137
|
+
return ua.replace(/[^A-Za-z0-9._-]/g, " ").trim().split(/\s+/).slice(0, 3).join(" ") || (remote ?? "unknown");
|
|
1138
|
+
}
|
|
1139
|
+
let shared;
|
|
1140
|
+
/** Get the process-wide dashboard registry (creating it on first use). */
|
|
1141
|
+
function getSharedRegistry() {
|
|
1142
|
+
if (!shared) shared = new DashboardRegistry();
|
|
1143
|
+
return shared;
|
|
1144
|
+
}
|
|
1145
|
+
//#endregion
|
|
1146
|
+
//#region src/a2a-config.ts
|
|
1147
|
+
/**
|
|
1148
|
+
* dsh-a2a — persisted configuration (`a2a.json`).
|
|
1149
|
+
*
|
|
1150
|
+
* Runtime-editable configuration lives in a per-profile `a2a.json`, so the
|
|
1151
|
+
* plugin's UI changes survive restarts without touching the composition
|
|
1152
|
+
* (`cordis.patch.yml` / `package.json` stay pure entry points).
|
|
1153
|
+
*
|
|
1154
|
+
* File resolution order:
|
|
1155
|
+
* 1. `process.cwd()/a2a.json` — profile dir when launched via `dsh --profile <x>`
|
|
1156
|
+
* 2. `~/.dsh/profiles/<cwd-basename>/a2a.json` — fallback when cwd is not the profile dir
|
|
1157
|
+
* 3. `$DSH_HOME/a2a.json` — last-resort global location
|
|
1158
|
+
*
|
|
1159
|
+
* Shape (all fields optional):
|
|
1160
|
+
* {
|
|
1161
|
+
* "mode": "both", // client | server | both (default both)
|
|
1162
|
+
* "timeoutMs": 8000, // client global default (ms)
|
|
1163
|
+
* "mapSkills": true, // per-skill tools vs one agent-wide tool
|
|
1164
|
+
* "agents": [{ "name", "agentCardUrl", "bearerToken?", "timeoutMs?", "mapSkills?", "enabled?" }],
|
|
1165
|
+
* "server": { "baseUrl"?, "agentName"?, "agentDescription"?, "agentVersion"?,
|
|
1166
|
+
* "endpointPath"?, "skills"?: [{ "id","name","description"?, "tags"? }] },
|
|
1167
|
+
* "dashboard": true
|
|
1168
|
+
* }
|
|
1169
|
+
*/
|
|
1170
|
+
const A2A_CONFIG_FILENAME = "a2a.json";
|
|
1171
|
+
/**
|
|
1172
|
+
* Parse `--profile <name>` (or `--profile=<name>`) from the process argv.
|
|
1173
|
+
*
|
|
1174
|
+
* dsh keeps its launcher flags in the process command line (`node …/dsh
|
|
1175
|
+
* --profile a2a-test --port 3083`), so the active profile name is directly
|
|
1176
|
+
* observable no matter which directory the process was started from. This is
|
|
1177
|
+
* the most reliable way to locate the per-profile config — far better than
|
|
1178
|
+
* guessing from cwd's basename.
|
|
1179
|
+
*/
|
|
1180
|
+
function profileNameFromArgv() {
|
|
1181
|
+
const argv = process.argv ?? [];
|
|
1182
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1183
|
+
const a = argv[i];
|
|
1184
|
+
if (a === "--profile" && i + 1 < argv.length) {
|
|
1185
|
+
const v = argv[i + 1];
|
|
1186
|
+
if (v && !v.startsWith("-")) return v;
|
|
1187
|
+
} else if (a.startsWith("--profile=")) {
|
|
1188
|
+
const v = a.slice(10);
|
|
1189
|
+
if (v) return v;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Resolve the a2a.json path for the current process, without depending on
|
|
1195
|
+
* the launch directory. The plugin must work no matter where `dsh` was
|
|
1196
|
+
* started from (dsh does NOT chdir into the profile directory), so this
|
|
1197
|
+
* prefers the profile named in argv (`--profile <name>`), then walks
|
|
1198
|
+
* outward from cwd, then falls back to the profile dir that matches cwd's
|
|
1199
|
+
* basename, then the harness home.
|
|
1200
|
+
*
|
|
1201
|
+
* Order:
|
|
1202
|
+
* 0. `$DSH_HOME/profiles/<argv --profile>/a2a.json` — the active profile
|
|
1203
|
+
* as named on the command line (most authoritative; works from ANY cwd)
|
|
1204
|
+
* 1. `cwd/a2a.json` — explicit when launched inside a profile dir
|
|
1205
|
+
* 2. outward from cwd: the nearest ancestor holding `a2a.json` — covers
|
|
1206
|
+
* launching from e.g. a repo checkout whose parent chain includes the
|
|
1207
|
+
* profile, or any subdir of the profile
|
|
1208
|
+
* 3. `$DSH_HOME/profiles/<cwd-basename>/a2a.json` — started from the
|
|
1209
|
+
* profile's *parent* (e.g. `~/.dsh/profiles`) or when the profile dir
|
|
1210
|
+
* is cwd but the file hasn't been created yet
|
|
1211
|
+
* 4. `$DSH_HOME/a2a.json` — last-resort global location
|
|
1212
|
+
*
|
|
1213
|
+
* Writes always go to the *resolved* path (the one we read), so a config
|
|
1214
|
+
* loaded from any step is saved back to the same place, never re-resolved
|
|
1215
|
+
* to cwd.
|
|
1216
|
+
*/
|
|
1217
|
+
function resolveConfigPath() {
|
|
1218
|
+
const cwd = process.cwd();
|
|
1219
|
+
const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
1220
|
+
const base = cwd.split(/[\\/]/).pop() ?? "";
|
|
1221
|
+
const profileName = profileNameFromArgv();
|
|
1222
|
+
if (profileName && profileName !== "." && profileName !== ".." && !profileName.includes("/") && !profileName.includes("\\")) {
|
|
1223
|
+
const argvFile = join(home, "profiles", profileName, A2A_CONFIG_FILENAME);
|
|
1224
|
+
if (existsSync(argvFile)) return argvFile;
|
|
1225
|
+
}
|
|
1226
|
+
const cwdFile = join(cwd, A2A_CONFIG_FILENAME);
|
|
1227
|
+
if (existsSync(cwdFile)) return cwdFile;
|
|
1228
|
+
let dir = cwd;
|
|
1229
|
+
for (let i = 0; i < 12; i++) {
|
|
1230
|
+
const up = join(dir, A2A_CONFIG_FILENAME);
|
|
1231
|
+
if (existsSync(up)) return up;
|
|
1232
|
+
const parent = dirname(dir);
|
|
1233
|
+
if (parent === dir) break;
|
|
1234
|
+
dir = parent;
|
|
1235
|
+
}
|
|
1236
|
+
const profileFile = join(home, "profiles", base, A2A_CONFIG_FILENAME);
|
|
1237
|
+
if (base && existsSync(profileFile)) return profileFile;
|
|
1238
|
+
const globalFile = join(home, A2A_CONFIG_FILENAME);
|
|
1239
|
+
if (existsSync(globalFile)) return globalFile;
|
|
1240
|
+
return cwdFile;
|
|
1241
|
+
}
|
|
1242
|
+
/** Read the persisted config (empty object when absent or unreadable). */
|
|
1243
|
+
function loadConfig() {
|
|
1244
|
+
const path = resolveConfigPath();
|
|
1245
|
+
if (!existsSync(path)) return {};
|
|
1246
|
+
try {
|
|
1247
|
+
const raw = readFileSync(path, "utf8");
|
|
1248
|
+
const parsed = JSON.parse(raw);
|
|
1249
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
console.error(`[a2a] failed to read ${path}: ${err.message}`);
|
|
1252
|
+
return {};
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/** Atomically write the persisted config (creates the directory when needed). */
|
|
1256
|
+
function saveConfig(config) {
|
|
1257
|
+
const path = resolveConfigPath();
|
|
1258
|
+
try {
|
|
1259
|
+
const dir = dirname(path);
|
|
1260
|
+
mkdirSync(dir, { recursive: true });
|
|
1261
|
+
const tmp = `${path}.tmp`;
|
|
1262
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
1263
|
+
renameSync(tmp, path);
|
|
1264
|
+
return {
|
|
1265
|
+
ok: true,
|
|
1266
|
+
path
|
|
1267
|
+
};
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
return {
|
|
1270
|
+
ok: false,
|
|
1271
|
+
path,
|
|
1272
|
+
message: err.message
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Merge persisted config on top of the in-composition config, so the file
|
|
1278
|
+
* (UI-editable, survives restart) wins over static defaults.
|
|
1279
|
+
*/
|
|
1280
|
+
function mergePersisted(base, persisted) {
|
|
1281
|
+
const merged = { ...base };
|
|
1282
|
+
if (persisted.mode) merged.mode = persisted.mode;
|
|
1283
|
+
if (persisted.timeoutMs !== void 0) merged.timeoutMs = persisted.timeoutMs;
|
|
1284
|
+
if (persisted.mapSkills !== void 0) merged.mapSkills = persisted.mapSkills;
|
|
1285
|
+
if (persisted.dashboard !== void 0) merged.dashboard = persisted.dashboard;
|
|
1286
|
+
const pServer = persisted.server;
|
|
1287
|
+
if (pServer) {
|
|
1288
|
+
if (pServer.enabled !== void 0) merged.serverEnabled = pServer.enabled;
|
|
1289
|
+
if (pServer.baseUrl) merged.baseUrl = pServer.baseUrl;
|
|
1290
|
+
if (pServer.agentName) merged.agentName = pServer.agentName;
|
|
1291
|
+
if (pServer.agentDescription) merged.agentDescription = pServer.agentDescription;
|
|
1292
|
+
if (pServer.agentVersion) merged.agentVersion = pServer.agentVersion;
|
|
1293
|
+
if (pServer.endpointPath) merged.endpointPath = pServer.endpointPath;
|
|
1294
|
+
if (pServer.skills) merged.skills = pServer.skills;
|
|
1295
|
+
}
|
|
1296
|
+
return merged;
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/index.ts
|
|
1300
|
+
const DEFAULT_ENDPOINT = "/a2a";
|
|
1301
|
+
const DEFAULT_API_PATH = "/a2a/api";
|
|
1302
|
+
/** Whether the shared dashboard API route is currently registered (once per process). */
|
|
1303
|
+
let dashboardApiRegistered = false;
|
|
1304
|
+
const name = "a2a";
|
|
1305
|
+
/**
|
|
1306
|
+
* Services this plugin may read as context properties. Declared so property
|
|
1307
|
+
* reads inside `apply` never trip Cordis's "without inject" guard. The
|
|
1308
|
+
* mode-specific wiring runs inside `ctx.inject(...)` for the single service it
|
|
1309
|
+
* needs, so a composition lacking the other one simply idles that half.
|
|
1310
|
+
*/
|
|
1311
|
+
const inject = ["webServer", "tools"];
|
|
1312
|
+
function apply(ctx, config = {}) {
|
|
1313
|
+
const persisted = loadConfig();
|
|
1314
|
+
const mergedConfig = mergePersisted(config, persisted);
|
|
1315
|
+
const configPath = resolveConfigPath();
|
|
1316
|
+
const mode = mergedConfig.mode ?? "client";
|
|
1317
|
+
const logger = ctx.logger;
|
|
1318
|
+
const dashboard = getSharedRegistry();
|
|
1319
|
+
const wantsClient = mode === "client" || mode === "both";
|
|
1320
|
+
const serverEnabled = mergedConfig.serverEnabled === true;
|
|
1321
|
+
const wantsServer = mode === "server" || mode === "both" || serverEnabled;
|
|
1322
|
+
if (mergedConfig.dashboard !== false) ctx.inject(["webServer"], (ctx) => {
|
|
1323
|
+
if (dashboardApiRegistered) return;
|
|
1324
|
+
dashboardApiRegistered = true;
|
|
1325
|
+
let apiRoute;
|
|
1326
|
+
apiRoute = ctx.webServer.register({
|
|
1327
|
+
kind: "prefix",
|
|
1328
|
+
path: DEFAULT_API_PATH,
|
|
1329
|
+
handler: async (req, res) => {
|
|
1330
|
+
if (!trustedLoopback(req)) {
|
|
1331
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
1332
|
+
res.end("forbidden");
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (req.method === "GET") {
|
|
1336
|
+
res.writeHead(200, {
|
|
1337
|
+
"content-type": "application/json",
|
|
1338
|
+
"cache-control": "no-cache"
|
|
1339
|
+
});
|
|
1340
|
+
res.end(JSON.stringify(dashboard.snapshot()));
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (req.method === "POST") {
|
|
1344
|
+
let payload;
|
|
1345
|
+
try {
|
|
1346
|
+
payload = JSON.parse(await readBody(req));
|
|
1347
|
+
} catch {
|
|
1348
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1349
|
+
res.end(JSON.stringify({
|
|
1350
|
+
ok: false,
|
|
1351
|
+
message: "invalid JSON body"
|
|
1352
|
+
}));
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
const { action, target, ...rest } = payload ?? {};
|
|
1356
|
+
if (!action) {
|
|
1357
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1358
|
+
res.end(JSON.stringify({
|
|
1359
|
+
ok: false,
|
|
1360
|
+
message: "action is required"
|
|
1361
|
+
}));
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const result = await dashboard.control(action, target ?? "", rest);
|
|
1365
|
+
res.writeHead(result.ok ? 200 : 409, { "content-type": "application/json" });
|
|
1366
|
+
res.end(JSON.stringify(result));
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
res.writeHead(405, { "content-type": "text/plain" });
|
|
1370
|
+
res.end("Method Not Allowed");
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
ctx.effect(() => () => {
|
|
1374
|
+
dashboardApiRegistered = false;
|
|
1375
|
+
apiRoute?.();
|
|
1376
|
+
}, "a2a: dashboard api cleanup");
|
|
1377
|
+
});
|
|
1378
|
+
if (wantsClient) {
|
|
1379
|
+
const agentName = mergedConfig.name ?? "remote";
|
|
1380
|
+
const initialAgents = [];
|
|
1381
|
+
for (const a of persisted.agents ?? []) if (a.name && a.agentCardUrl) initialAgents.push({
|
|
1382
|
+
name: a.name,
|
|
1383
|
+
agentCardUrl: a.agentCardUrl,
|
|
1384
|
+
configured: true,
|
|
1385
|
+
enabled: a.enabled !== false,
|
|
1386
|
+
bearerToken: a.bearerToken,
|
|
1387
|
+
timeoutMs: a.timeoutMs,
|
|
1388
|
+
mapSkills: a.mapSkills
|
|
1389
|
+
});
|
|
1390
|
+
if (mergedConfig.agentCardUrl) initialAgents.push({
|
|
1391
|
+
name: agentName,
|
|
1392
|
+
agentCardUrl: mergedConfig.agentCardUrl,
|
|
1393
|
+
configured: true,
|
|
1394
|
+
enabled: true,
|
|
1395
|
+
bearerToken: mergedConfig.bearerToken,
|
|
1396
|
+
timeoutMs: mergedConfig.timeoutMs,
|
|
1397
|
+
mapSkills: mergedConfig.mapSkills
|
|
1398
|
+
});
|
|
1399
|
+
ctx.inject(["tools"], (ctx) => {
|
|
1400
|
+
const live = /* @__PURE__ */ new Map();
|
|
1401
|
+
const configuredAgents = /* @__PURE__ */ new Set();
|
|
1402
|
+
const connect = async (name, agentCardUrl, configured = false, agentOpts = {}) => {
|
|
1403
|
+
let disposers = [];
|
|
1404
|
+
let connectionId;
|
|
1405
|
+
try {
|
|
1406
|
+
const regs = await registerAgentTools(ctx.tools, {
|
|
1407
|
+
name,
|
|
1408
|
+
agentCardUrl,
|
|
1409
|
+
bearerToken: agentOpts.bearerToken ?? mergedConfig.bearerToken,
|
|
1410
|
+
timeoutMs: agentOpts.timeoutMs ?? mergedConfig.timeoutMs,
|
|
1411
|
+
mapSkills: agentOpts.mapSkills ?? mergedConfig.mapSkills,
|
|
1412
|
+
onReady: (info) => {
|
|
1413
|
+
connectionId = info.connectionId;
|
|
1414
|
+
dashboard.upsertAgent({
|
|
1415
|
+
connectionId: info.connectionId,
|
|
1416
|
+
name,
|
|
1417
|
+
agentCardUrl,
|
|
1418
|
+
agentName: info.card.name,
|
|
1419
|
+
skillCount: (info.card.skills ?? []).length,
|
|
1420
|
+
toolCount: 0,
|
|
1421
|
+
state: "connected",
|
|
1422
|
+
configured: configured ? true : void 0,
|
|
1423
|
+
skills: (info.card.skills ?? []).map((s) => ({
|
|
1424
|
+
id: s.id,
|
|
1425
|
+
name: s.name,
|
|
1426
|
+
description: s.description,
|
|
1427
|
+
tags: s.tags
|
|
1428
|
+
}))
|
|
1429
|
+
});
|
|
1430
|
+
},
|
|
1431
|
+
onDispose: (cid) => {
|
|
1432
|
+
dashboard.removeAgent(cid);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
disposers = regs.map((r) => r.dispose);
|
|
1436
|
+
if (connectionId) {
|
|
1437
|
+
dashboard.upsertAgent({
|
|
1438
|
+
connectionId,
|
|
1439
|
+
name,
|
|
1440
|
+
agentCardUrl,
|
|
1441
|
+
skillCount: 0,
|
|
1442
|
+
toolCount: regs.length,
|
|
1443
|
+
state: "connected",
|
|
1444
|
+
configured: configured ? true : void 0
|
|
1445
|
+
});
|
|
1446
|
+
live.set(connectionId, {
|
|
1447
|
+
disposeAll: () => disposers.forEach((d) => d()),
|
|
1448
|
+
name,
|
|
1449
|
+
agentCardUrl,
|
|
1450
|
+
configured,
|
|
1451
|
+
enabled: true
|
|
1452
|
+
});
|
|
1453
|
+
if (configured) configuredAgents.add(connectionId);
|
|
1454
|
+
}
|
|
1455
|
+
logger?.info?.(`[a2a] registered ${regs.length} tool(s) for agent "${name}"`);
|
|
1456
|
+
return connectionId;
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
logger?.error?.(`[a2a] failed to register agent "${name}": ${err.message}`);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
const persistAgents = () => {
|
|
1463
|
+
const next = {
|
|
1464
|
+
...loadConfig(),
|
|
1465
|
+
agents: []
|
|
1466
|
+
};
|
|
1467
|
+
for (const a of initialAgents) if (a.configured) next.agents.push({
|
|
1468
|
+
name: a.name,
|
|
1469
|
+
agentCardUrl: a.agentCardUrl,
|
|
1470
|
+
bearerToken: a.bearerToken,
|
|
1471
|
+
timeoutMs: a.timeoutMs,
|
|
1472
|
+
mapSkills: a.mapSkills,
|
|
1473
|
+
enabled: a.enabled === false ? false : void 0
|
|
1474
|
+
});
|
|
1475
|
+
const res = saveConfig(next);
|
|
1476
|
+
if (!res.ok) logger?.error?.(`[a2a] failed to save ${res.path}: ${res.message}`);
|
|
1477
|
+
};
|
|
1478
|
+
const findInitial = (entry) => {
|
|
1479
|
+
const idx = initialAgents.findIndex((a) => a.configured && a.name === entry.name && a.agentCardUrl === entry.agentCardUrl);
|
|
1480
|
+
if (idx < 0) return void 0;
|
|
1481
|
+
return {
|
|
1482
|
+
idx,
|
|
1483
|
+
item: initialAgents[idx]
|
|
1484
|
+
};
|
|
1485
|
+
};
|
|
1486
|
+
dashboard.setHooks({
|
|
1487
|
+
closeAgent: async (connectionId) => {
|
|
1488
|
+
const entry = live.get(connectionId);
|
|
1489
|
+
if (!entry) return {
|
|
1490
|
+
ok: false,
|
|
1491
|
+
message: `connection ${connectionId} not found`
|
|
1492
|
+
};
|
|
1493
|
+
entry.disposeAll();
|
|
1494
|
+
live.delete(connectionId);
|
|
1495
|
+
dashboard.removeAgent(connectionId);
|
|
1496
|
+
dashboard.setAgentState(connectionId, "disconnected");
|
|
1497
|
+
dashboard.upsertAgent({
|
|
1498
|
+
connectionId,
|
|
1499
|
+
name: entry.name,
|
|
1500
|
+
agentCardUrl: entry.agentCardUrl,
|
|
1501
|
+
skillCount: 0,
|
|
1502
|
+
toolCount: 0,
|
|
1503
|
+
state: "disconnected",
|
|
1504
|
+
enabled: false,
|
|
1505
|
+
configured: entry.configured ? true : void 0
|
|
1506
|
+
});
|
|
1507
|
+
return {
|
|
1508
|
+
ok: true,
|
|
1509
|
+
message: `connection ${connectionId} closed`
|
|
1510
|
+
};
|
|
1511
|
+
},
|
|
1512
|
+
disableAgent: async (connectionId) => {
|
|
1513
|
+
const entry = live.get(connectionId);
|
|
1514
|
+
let name = entry?.name;
|
|
1515
|
+
let url = entry?.agentCardUrl;
|
|
1516
|
+
let configured = entry?.configured ?? false;
|
|
1517
|
+
if (!entry) {
|
|
1518
|
+
const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
1519
|
+
if (!snap) return {
|
|
1520
|
+
ok: false,
|
|
1521
|
+
message: `connection ${connectionId} not found`
|
|
1522
|
+
};
|
|
1523
|
+
name = snap.name;
|
|
1524
|
+
url = snap.agentCardUrl;
|
|
1525
|
+
configured = snap.configured ?? false;
|
|
1526
|
+
}
|
|
1527
|
+
if (entry) {
|
|
1528
|
+
entry.disposeAll();
|
|
1529
|
+
live.delete(connectionId);
|
|
1530
|
+
dashboard.setAgentState(connectionId, "disconnected");
|
|
1531
|
+
} else dashboard.setAgentState(connectionId, "disconnected");
|
|
1532
|
+
const found = name !== void 0 && url !== void 0 ? findInitial({
|
|
1533
|
+
name,
|
|
1534
|
+
agentCardUrl: url
|
|
1535
|
+
}) : void 0;
|
|
1536
|
+
if (found) {
|
|
1537
|
+
found.item.enabled = false;
|
|
1538
|
+
persistAgents();
|
|
1539
|
+
}
|
|
1540
|
+
const prevSnap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
1541
|
+
dashboard.upsertAgent({
|
|
1542
|
+
connectionId,
|
|
1543
|
+
name: name ?? "unknown",
|
|
1544
|
+
agentCardUrl: url ?? "",
|
|
1545
|
+
agentName: prevSnap?.agentName ?? name,
|
|
1546
|
+
skillCount: 0,
|
|
1547
|
+
toolCount: 0,
|
|
1548
|
+
state: "disconnected",
|
|
1549
|
+
enabled: false,
|
|
1550
|
+
configured: configured ? true : void 0
|
|
1551
|
+
});
|
|
1552
|
+
return {
|
|
1553
|
+
ok: true,
|
|
1554
|
+
message: `agent ${connectionId} disabled (config kept)`
|
|
1555
|
+
};
|
|
1556
|
+
},
|
|
1557
|
+
enableAgent: async (connectionId) => {
|
|
1558
|
+
const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
1559
|
+
if (!snap) return {
|
|
1560
|
+
ok: false,
|
|
1561
|
+
message: `connection ${connectionId} not found`
|
|
1562
|
+
};
|
|
1563
|
+
const found = findInitial({
|
|
1564
|
+
name: snap.name,
|
|
1565
|
+
agentCardUrl: snap.agentCardUrl
|
|
1566
|
+
});
|
|
1567
|
+
if (found) {
|
|
1568
|
+
found.item.enabled = true;
|
|
1569
|
+
persistAgents();
|
|
1570
|
+
}
|
|
1571
|
+
dashboard.setAgentState(connectionId, "reconnecting");
|
|
1572
|
+
const newId = await connect(snap.name, snap.agentCardUrl, snap.configured ?? false);
|
|
1573
|
+
if (!newId) return {
|
|
1574
|
+
ok: false,
|
|
1575
|
+
message: `enable of ${connectionId} failed to connect`
|
|
1576
|
+
};
|
|
1577
|
+
dashboard.removeAgent(connectionId);
|
|
1578
|
+
return {
|
|
1579
|
+
ok: true,
|
|
1580
|
+
message: `agent ${connectionId} enabled and reconnected as ${newId}`
|
|
1581
|
+
};
|
|
1582
|
+
},
|
|
1583
|
+
reconnectAgent: async (connectionId) => {
|
|
1584
|
+
const existing = live.get(connectionId);
|
|
1585
|
+
const prevName = existing?.name ?? agentName;
|
|
1586
|
+
const prevUrl = existing?.agentCardUrl ?? mergedConfig.agentCardUrl;
|
|
1587
|
+
const wasConfigured = !!existing?.configured || configuredAgents.has(connectionId);
|
|
1588
|
+
if (existing) existing.disposeAll();
|
|
1589
|
+
live.delete(connectionId);
|
|
1590
|
+
dashboard.setAgentState(connectionId, "reconnecting");
|
|
1591
|
+
const newId = await connect(prevName, prevUrl, wasConfigured);
|
|
1592
|
+
if (!newId) return {
|
|
1593
|
+
ok: false,
|
|
1594
|
+
message: `reconnect of ${connectionId} failed`
|
|
1595
|
+
};
|
|
1596
|
+
dashboard.removeAgent(connectionId);
|
|
1597
|
+
return {
|
|
1598
|
+
ok: true,
|
|
1599
|
+
message: `connection ${connectionId} reconnected as ${newId}`
|
|
1600
|
+
};
|
|
1601
|
+
},
|
|
1602
|
+
addAgent: async (name, agentCardUrl) => {
|
|
1603
|
+
const cid = await connect(name, agentCardUrl, true);
|
|
1604
|
+
if (!cid) return {
|
|
1605
|
+
ok: false,
|
|
1606
|
+
message: `failed to connect to ${agentCardUrl}`
|
|
1607
|
+
};
|
|
1608
|
+
initialAgents.push({
|
|
1609
|
+
name,
|
|
1610
|
+
agentCardUrl,
|
|
1611
|
+
configured: true,
|
|
1612
|
+
enabled: true
|
|
1613
|
+
});
|
|
1614
|
+
persistAgents();
|
|
1615
|
+
return {
|
|
1616
|
+
ok: true,
|
|
1617
|
+
message: `agent "${name}" connected as ${cid} (saved to ${configPath})`
|
|
1618
|
+
};
|
|
1619
|
+
},
|
|
1620
|
+
discoverAgent: async (agentCardUrl) => {
|
|
1621
|
+
try {
|
|
1622
|
+
const card = await fetchAgentCard(agentCardUrl, {
|
|
1623
|
+
timeoutMs: mergedConfig.timeoutMs ?? 8e3,
|
|
1624
|
+
bearerToken: mergedConfig.bearerToken
|
|
1625
|
+
});
|
|
1626
|
+
const iface = pickInterface(card);
|
|
1627
|
+
return {
|
|
1628
|
+
ok: true,
|
|
1629
|
+
message: JSON.stringify({
|
|
1630
|
+
name: card.name,
|
|
1631
|
+
description: card.description,
|
|
1632
|
+
version: card.version,
|
|
1633
|
+
agentCardUrl,
|
|
1634
|
+
endpoint: iface?.url,
|
|
1635
|
+
skills: (card.skills ?? []).map((s) => ({
|
|
1636
|
+
id: s.id,
|
|
1637
|
+
name: s.name,
|
|
1638
|
+
description: s.description
|
|
1639
|
+
})),
|
|
1640
|
+
capabilities: card.capabilities,
|
|
1641
|
+
defaultInputModes: card.defaultInputModes,
|
|
1642
|
+
defaultOutputModes: card.defaultOutputModes
|
|
1643
|
+
})
|
|
1644
|
+
};
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
return {
|
|
1647
|
+
ok: false,
|
|
1648
|
+
message: `discover failed: ${err.message}`
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
removeAgent: async (connectionId) => {
|
|
1653
|
+
const entry = live.get(connectionId);
|
|
1654
|
+
let name;
|
|
1655
|
+
let url;
|
|
1656
|
+
let configured = false;
|
|
1657
|
+
if (entry) {
|
|
1658
|
+
name = entry.name;
|
|
1659
|
+
url = entry.agentCardUrl;
|
|
1660
|
+
configured = entry.configured ?? false;
|
|
1661
|
+
entry.disposeAll();
|
|
1662
|
+
live.delete(connectionId);
|
|
1663
|
+
} else {
|
|
1664
|
+
const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
1665
|
+
if (!snap) return {
|
|
1666
|
+
ok: false,
|
|
1667
|
+
message: `connection ${connectionId} not found`
|
|
1668
|
+
};
|
|
1669
|
+
name = snap.name;
|
|
1670
|
+
url = snap.agentCardUrl;
|
|
1671
|
+
configured = snap.configured ?? false;
|
|
1672
|
+
}
|
|
1673
|
+
if (configured && name !== void 0 && url !== void 0) {
|
|
1674
|
+
const found = findInitial({
|
|
1675
|
+
name,
|
|
1676
|
+
agentCardUrl: url
|
|
1677
|
+
});
|
|
1678
|
+
if (found) {
|
|
1679
|
+
initialAgents.splice(found.idx, 1);
|
|
1680
|
+
persistAgents();
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
dashboard.removeAgent(connectionId);
|
|
1684
|
+
return {
|
|
1685
|
+
ok: true,
|
|
1686
|
+
message: `agent ${connectionId} removed`
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
ctx.effect(() => {
|
|
1691
|
+
for (const a of initialAgents) {
|
|
1692
|
+
if (a.enabled === false) {
|
|
1693
|
+
const phId = `out-cfg-${a.name}-${a.agentCardUrl}`;
|
|
1694
|
+
dashboard.upsertAgent({
|
|
1695
|
+
connectionId: phId,
|
|
1696
|
+
name: a.name,
|
|
1697
|
+
agentCardUrl: a.agentCardUrl,
|
|
1698
|
+
agentName: a.name,
|
|
1699
|
+
skillCount: 0,
|
|
1700
|
+
toolCount: 0,
|
|
1701
|
+
state: "disconnected",
|
|
1702
|
+
enabled: false,
|
|
1703
|
+
configured: true
|
|
1704
|
+
});
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
connect(a.name, a.agentCardUrl, a.configured, {
|
|
1708
|
+
bearerToken: a.bearerToken,
|
|
1709
|
+
timeoutMs: a.timeoutMs,
|
|
1710
|
+
mapSkills: a.mapSkills
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
return () => {
|
|
1714
|
+
for (const { disposeAll } of live.values()) disposeAll();
|
|
1715
|
+
live.clear();
|
|
1716
|
+
};
|
|
1717
|
+
}, "a2a: client tools");
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
if (wantsServer) {
|
|
1721
|
+
const store = new TaskStore();
|
|
1722
|
+
const taskPeer = /* @__PURE__ */ new Map();
|
|
1723
|
+
const pendingSettled = /* @__PURE__ */ new Set();
|
|
1724
|
+
ctx.inject(["webServer"], (ctx) => {
|
|
1725
|
+
ctx.effect(() => {
|
|
1726
|
+
const webServer = ctx.webServer;
|
|
1727
|
+
const baseUrl = mergedConfig.baseUrl ?? `http://${webServer.host === "0.0.0.0" ? "127.0.0.1" : webServer.host}:${webServer.port}`;
|
|
1728
|
+
const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
|
|
1729
|
+
const server = new A2AServer({
|
|
1730
|
+
baseUrl,
|
|
1731
|
+
agentName: mergedConfig.agentName ?? "DSH Agent",
|
|
1732
|
+
agentDescription: mergedConfig.agentDescription ?? "DeepSeek Harness agent exposed over A2A v1.0",
|
|
1733
|
+
agentVersion: mergedConfig.agentVersion ?? "0.1.0",
|
|
1734
|
+
skills: mergedConfig.skills,
|
|
1735
|
+
endpointPath,
|
|
1736
|
+
execute: mergedConfig.execute,
|
|
1737
|
+
onInbound: (facts) => {
|
|
1738
|
+
const peerId = dashboard.touchInbound({
|
|
1739
|
+
headers: facts.headers,
|
|
1740
|
+
source: facts.source
|
|
1741
|
+
}, facts.taskIds);
|
|
1742
|
+
for (const taskId of facts.taskIds) {
|
|
1743
|
+
taskPeer.set(taskId, peerId);
|
|
1744
|
+
if (pendingSettled.delete(taskId)) dashboard.finishTask(peerId, taskId);
|
|
1745
|
+
}
|
|
1746
|
+
},
|
|
1747
|
+
onTaskSettled: (taskId) => {
|
|
1748
|
+
const peerId = taskPeer.get(taskId);
|
|
1749
|
+
if (peerId) {
|
|
1750
|
+
dashboard.finishTask(peerId, taskId);
|
|
1751
|
+
taskPeer.delete(taskId);
|
|
1752
|
+
} else pendingSettled.add(taskId);
|
|
1753
|
+
}
|
|
1754
|
+
}, store);
|
|
1755
|
+
let enabled = true;
|
|
1756
|
+
let disposers = [];
|
|
1757
|
+
const registerRoutes = () => {
|
|
1758
|
+
disposers.push(webServer.register({
|
|
1759
|
+
kind: "exact",
|
|
1760
|
+
path: "/.well-known/agent-card.json",
|
|
1761
|
+
handler: async (_req, res) => {
|
|
1762
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1763
|
+
res.end(JSON.stringify(server.card));
|
|
1764
|
+
}
|
|
1765
|
+
}));
|
|
1766
|
+
disposers.push(webServer.register({
|
|
1767
|
+
kind: "prefix",
|
|
1768
|
+
path: server.endpointPath,
|
|
1769
|
+
handler: async (req, res) => {
|
|
1770
|
+
const isStream = (req.headers.accept ?? "").includes("text/event-stream");
|
|
1771
|
+
if (req.method === "GET" && (req.url ?? "").startsWith(server.endpointPath + "/card")) {
|
|
1772
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1773
|
+
res.end(JSON.stringify(server.card));
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (req.method !== "POST") {
|
|
1777
|
+
res.writeHead(405, { "content-type": "text/plain" });
|
|
1778
|
+
res.end("Method Not Allowed");
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const body = await readBody(req);
|
|
1782
|
+
if (isStream) {
|
|
1783
|
+
res.writeHead(200, {
|
|
1784
|
+
"content-type": "text/event-stream",
|
|
1785
|
+
"cache-control": "no-cache",
|
|
1786
|
+
connection: "keep-alive"
|
|
1787
|
+
});
|
|
1788
|
+
await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
|
|
1789
|
+
res.end();
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
const out = await server.handle(toServerReq(req), body);
|
|
1793
|
+
res.writeHead(out.status, { "content-type": out.contentType });
|
|
1794
|
+
res.end(out.body);
|
|
1795
|
+
}
|
|
1796
|
+
}));
|
|
1797
|
+
};
|
|
1798
|
+
const unregisterRoutes = () => {
|
|
1799
|
+
for (const d of disposers) d();
|
|
1800
|
+
disposers = [];
|
|
1801
|
+
};
|
|
1802
|
+
registerRoutes();
|
|
1803
|
+
logger?.info?.(`[a2a] A2A server mounted: AgentCard at ${baseUrl}/.well-known/agent-card.json, JSON-RPC at ${server.endpointPath}`);
|
|
1804
|
+
const serverCard = () => ({
|
|
1805
|
+
baseUrl,
|
|
1806
|
+
agentName: server.card.name,
|
|
1807
|
+
agentDescription: server.card.description,
|
|
1808
|
+
agentVersion: server.card.version,
|
|
1809
|
+
endpoint: `${baseUrl}${server.endpointPath}`,
|
|
1810
|
+
agentCardUrl: `${baseUrl}/.well-known/agent-card.json`,
|
|
1811
|
+
skills: (server.card.skills ?? []).map((s) => ({
|
|
1812
|
+
id: s.id,
|
|
1813
|
+
name: s.name,
|
|
1814
|
+
description: s.description
|
|
1815
|
+
})),
|
|
1816
|
+
customExecutor: Boolean(mergedConfig.execute)
|
|
1817
|
+
});
|
|
1818
|
+
dashboard.setHooks({
|
|
1819
|
+
setServerEnabled: async (enable) => {
|
|
1820
|
+
if (enable === enabled) return {
|
|
1821
|
+
ok: true,
|
|
1822
|
+
message: `server already ${enable ? "enabled" : "disabled"}`
|
|
1823
|
+
};
|
|
1824
|
+
if (enable) {
|
|
1825
|
+
registerRoutes();
|
|
1826
|
+
enabled = true;
|
|
1827
|
+
logger?.info?.("[a2a] A2A server enabled");
|
|
1828
|
+
return {
|
|
1829
|
+
ok: true,
|
|
1830
|
+
message: "A2A server enabled"
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
unregisterRoutes();
|
|
1834
|
+
enabled = false;
|
|
1835
|
+
logger?.info?.("[a2a] A2A server disabled");
|
|
1836
|
+
return {
|
|
1837
|
+
ok: true,
|
|
1838
|
+
message: "A2A server disabled"
|
|
1839
|
+
};
|
|
1840
|
+
},
|
|
1841
|
+
serverStatus: () => ({
|
|
1842
|
+
ok: true,
|
|
1843
|
+
message: JSON.stringify({
|
|
1844
|
+
enabled,
|
|
1845
|
+
...serverCard()
|
|
1846
|
+
})
|
|
1847
|
+
})
|
|
1848
|
+
});
|
|
1849
|
+
return () => {
|
|
1850
|
+
unregisterRoutes();
|
|
1851
|
+
};
|
|
1852
|
+
}, "a2a: server routes");
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
/** Drain the request body from a Node IncomingMessage. */
|
|
1857
|
+
async function readBody(req) {
|
|
1858
|
+
return new Promise((resolve, reject) => {
|
|
1859
|
+
const chunks = [];
|
|
1860
|
+
req.on("data", (c) => chunks.push(c));
|
|
1861
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1862
|
+
req.once("error", reject);
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
/** Loopback/same-origin trust fence for the dashboard API (no remote write). */
|
|
1866
|
+
function trustedLoopback(req) {
|
|
1867
|
+
const host = req.headers.host;
|
|
1868
|
+
if (!host) return false;
|
|
1869
|
+
const hostname = host.split(":")[0];
|
|
1870
|
+
if (hostname !== "127.0.0.1" && hostname !== "localhost" && hostname !== "::1") return false;
|
|
1871
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1872
|
+
const origin = req.headers.origin;
|
|
1873
|
+
if (!origin) return true;
|
|
1874
|
+
try {
|
|
1875
|
+
return new URL(origin).host === host;
|
|
1876
|
+
} catch {
|
|
1877
|
+
return false;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
/** Adapt a Node IncomingMessage to the A2A server's structural request type. */
|
|
1881
|
+
function toServerReq(req) {
|
|
1882
|
+
const headers = {};
|
|
1883
|
+
for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers[k] = v;
|
|
1884
|
+
else if (Array.isArray(v)) headers[k] = v.join(", ");
|
|
1885
|
+
return {
|
|
1886
|
+
method: req.method,
|
|
1887
|
+
url: req.url,
|
|
1888
|
+
headers,
|
|
1889
|
+
socket: req.socket ? {
|
|
1890
|
+
remoteAddress: req.socket.remoteAddress,
|
|
1891
|
+
remotePort: req.socket.remotePort
|
|
1892
|
+
} : void 0
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
//#endregion
|
|
1896
|
+
export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inject, isTerminal, name, registerAgentTools };
|