dsh-a2a-groups 0.0.0-stage → 0.1.2
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/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2A 1.0 HTTP endpoint: routing, authentication, version gate, dispatch.
|
|
3
|
+
*
|
|
4
|
+
* This module is the membrane. On one side is a JSON-RPC speaker that knows only
|
|
5
|
+
* A2A; on the other is a skill registry whose entries run inside this DSH host.
|
|
6
|
+
* Everything risky about that membrane is decided here, once:
|
|
7
|
+
* who may call (token or loopback), which protocol they speak, how big a body
|
|
8
|
+
* they may send, how long a skill may run, and what a peer sees when it all goes
|
|
9
|
+
* wrong.
|
|
10
|
+
*
|
|
11
|
+
* Boundaries:
|
|
12
|
+
* - It never imports DSH. Host capability arrives as an injected `registrar`, so the same
|
|
13
|
+
* code serves a DSH plugin and a standalone Node host — see `createServer`'s doc.
|
|
14
|
+
* - It never formats a reply. Skills produce `{text, artifacts, finalState}`;
|
|
15
|
+
* `wire.js` turns that into 1.0 envelopes. A handler that hand-built JSON here
|
|
16
|
+
* would be the first place a protocol field could drift.
|
|
17
|
+
* - The agent card is public on purpose. A2A discovery happens before the caller
|
|
18
|
+
* has credentials; requiring a token for the card would make the endpoint
|
|
19
|
+
* undiscoverable through the gateways this plugin exists to talk to.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-a2a/server
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
RPC_ERRORS,
|
|
26
|
+
PROTOCOL_VERSION,
|
|
27
|
+
IMPLIED_LEGACY_VERSION,
|
|
28
|
+
artifactUpdate,
|
|
29
|
+
makeArtifact,
|
|
30
|
+
redactAll,
|
|
31
|
+
rpcError,
|
|
32
|
+
rpcResult,
|
|
33
|
+
safeEqual,
|
|
34
|
+
statusUpdate,
|
|
35
|
+
textOfMessage,
|
|
36
|
+
toWireTask,
|
|
37
|
+
writeComment,
|
|
38
|
+
writeSse,
|
|
39
|
+
writeSseHead,
|
|
40
|
+
} from './wire.js';
|
|
41
|
+
import { A2A_PATH, CARD_ALIAS_PATH, CARD_PATH, buildCard, resolveAgentUrl } from './card.js';
|
|
42
|
+
|
|
43
|
+
/** @typedef {import('./wire.js').TaskRow} TaskRow */
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Something with the usual three log levels, any of which may be absent.
|
|
47
|
+
*
|
|
48
|
+
* Declared here because `logger?.warn` is called on an injected value that this
|
|
49
|
+
* module never constructs: naming the shape is what tells a reader the fields
|
|
50
|
+
* are optional on purpose rather than forgotten.
|
|
51
|
+
*
|
|
52
|
+
* @typedef {object} A2aLogger
|
|
53
|
+
* @property {(message: string) => void} [info]
|
|
54
|
+
* @property {(message: string) => void} [warn]
|
|
55
|
+
* @property {(message: string) => void} [debug]
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One `node:http` request, reduced to what this module actually reads.
|
|
60
|
+
*
|
|
61
|
+
* Narrower than the real `IncomingMessage` on purpose: a checker that knows this
|
|
62
|
+
* shape can verify every property access here, and any handler that reaches for
|
|
63
|
+
* a field outside this list becomes a visible decision instead of a silent one.
|
|
64
|
+
*
|
|
65
|
+
* @typedef {object} A2aRequest
|
|
66
|
+
* @property {string} [method] - HTTP verb.
|
|
67
|
+
* @property {string} [url] - request target.
|
|
68
|
+
* @property {Record<string, string|string[]|undefined>} [headers] - lower-cased header bag.
|
|
69
|
+
* @property {{remoteAddress?: string}} [socket] - peer address, for the loopback gate.
|
|
70
|
+
* @property {boolean} [readableEnded] - whether the body was fully consumed.
|
|
71
|
+
* @property {(event: string, fn: () => void) => void} [once] - one-shot listener.
|
|
72
|
+
* @property {(event: string, fn: () => void) => void} [off] - listener removal.
|
|
73
|
+
* @property {AsyncIterable<Buffer|string>} [body] - the request IS an async iterable of body chunks; see `readBody`.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One `node:http` response, reduced to what this module writes.
|
|
78
|
+
*
|
|
79
|
+
* @typedef {object} A2aResponse
|
|
80
|
+
* @property {number} [statusCode] - status to send.
|
|
81
|
+
* @property {boolean} [headersSent] - whether the head went out already.
|
|
82
|
+
* @property {boolean} [writableEnded] - whether `end()` was called.
|
|
83
|
+
* @property {boolean} [destroyed] - whether the socket is gone.
|
|
84
|
+
* @property {(name: string, value: string) => void} [setHeader] - header setter.
|
|
85
|
+
* @property {() => void} [flushHeaders] - force the head out, for SSE.
|
|
86
|
+
* @property {(chunk?: string) => boolean} [write] - body write.
|
|
87
|
+
* @property {(body?: string) => void} [end] - finish the response.
|
|
88
|
+
* @property {(event: string, fn: () => void) => void} [once]
|
|
89
|
+
* @property {(event: string, fn: () => void) => void} [off]
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The four registrations this module needs from a route table.
|
|
94
|
+
*
|
|
95
|
+
* @typedef {object} A2aRegistrar
|
|
96
|
+
* @property {(route: {kind: 'exact'|'prefix', path: string, handler: Function}) => (() => void)} register
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A JSON-RPC `params` bag, as the four supported methods shape it.
|
|
101
|
+
*
|
|
102
|
+
* Every field is optional because a malformed call is answered with a coded
|
|
103
|
+
* error, not a crash: the handlers check for themselves rather than trusting the
|
|
104
|
+
* shape. Declaring the fields still buys the important half — a handler that
|
|
105
|
+
* reads `params.id` when the caller promised `params.message` now reads a
|
|
106
|
+
* known-optional field instead of an unknown one.
|
|
107
|
+
*
|
|
108
|
+
* @typedef {object} A2aParams
|
|
109
|
+
* @property {A2aMessage} [message] - SendMessage / SendStreamingMessage payload.
|
|
110
|
+
* @property {string} [id] - GetTask / CancelTask target.
|
|
111
|
+
* @property {string} [taskId] - legacy alias for `id`.
|
|
112
|
+
* @property {string} [name] - 0.3 spelling of `id`.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One inbound A2A 1.0 message.
|
|
117
|
+
*
|
|
118
|
+
* @typedef {object} A2aMessage
|
|
119
|
+
* @property {'user'|'agent'} [role]
|
|
120
|
+
* @property {string} [messageId]
|
|
121
|
+
* @property {string} [contextId]
|
|
122
|
+
* @property {Array<{kind: string, text?: string}>} [parts]
|
|
123
|
+
* @property {Record<string, any>} [metadata]
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What a skill registry must offer: dispatch by id, and a list for the card.
|
|
128
|
+
*
|
|
129
|
+
* @typedef {object} A2aSkillRegistry
|
|
130
|
+
* @property {(id: string) => any} get
|
|
131
|
+
* @property {() => any[]} list
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One callable skill.
|
|
136
|
+
*
|
|
137
|
+
* @typedef {object} A2aSkill
|
|
138
|
+
* @property {string} id
|
|
139
|
+
* @property {string} [name]
|
|
140
|
+
* @property {string} [description]
|
|
141
|
+
* @property {(input: object, context: object) => Promise<object|string>} run
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The task store this module drives.
|
|
146
|
+
*
|
|
147
|
+
* @typedef {object} A2aTaskStore
|
|
148
|
+
* @property {(input?: object) => any} create
|
|
149
|
+
* @property {(id: string, patch?: object) => any} update
|
|
150
|
+
* @property {(id: string, artifact: object) => any} attachArtifact
|
|
151
|
+
* @property {(id: string) => any} get
|
|
152
|
+
* @property {(id: string) => any} cancel
|
|
153
|
+
* @property {() => boolean} flush
|
|
154
|
+
* @property {number} [size]
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The two shapes `admit()` can return: a runnable admission, or a refusal.
|
|
159
|
+
*
|
|
160
|
+
* Declared as one optional-laden type rather than `object` because the callers
|
|
161
|
+
* immediately narrow on `admitted.error`: with `object`, every `.task`/`.signal`
|
|
162
|
+
* read on the success branch was an error, and the refusal branch could not be
|
|
163
|
+
* distinguished from a success that merely lacked `error`.
|
|
164
|
+
*
|
|
165
|
+
* @typedef {object} A2aAdmission
|
|
166
|
+
* @property {TaskRow} [task] - the opened task row.
|
|
167
|
+
* @property {A2aSkill} [skill] - resolved skill.
|
|
168
|
+
* @property {string} [skillId]
|
|
169
|
+
* @property {string} [text] - flattened request text.
|
|
170
|
+
* @property {A2aMessage} [message] - the original 1.0 message.
|
|
171
|
+
* @property {AbortSignal} [signal] - timeout/disconnect cancellation.
|
|
172
|
+
* @property {Promise<any>} [timeout] - independent deadline rejection.
|
|
173
|
+
* @property {() => void} [cleanup] - clears the timer and listeners.
|
|
174
|
+
* @property {{code: number, message: string, data?: object}} [error] - refusal.
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Arguments to `admit()`.
|
|
179
|
+
*
|
|
180
|
+
* @typedef {object} A2aAdmitInput
|
|
181
|
+
* @property {A2aResponse} res - the HTTP response, for the disconnect signal.
|
|
182
|
+
* @property {A2aParams} params - JSON-RPC params.
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Operator endpoint: liveness plus an authenticated flush.
|
|
187
|
+
*
|
|
188
|
+
* Deliberately outside the A2A namespace: a monitor asking 'are you alive' should not
|
|
189
|
+
* have to look like an A2A peer, and a backup script should not have to build a
|
|
190
|
+
* JSON-RPC envelope to ask for a flush.
|
|
191
|
+
*/
|
|
192
|
+
export const HEALTH_PATH = '/__health';
|
|
193
|
+
|
|
194
|
+
/** When this module was loaded, for the uptime an operator sees. */
|
|
195
|
+
const STARTED_AT = Date.now();
|
|
196
|
+
|
|
197
|
+
/** Methods this deployment answers. Anything else is `-32601`. */
|
|
198
|
+
const SUPPORTED_METHODS = Object.freeze([
|
|
199
|
+
'SendMessage',
|
|
200
|
+
'SendStreamingMessage',
|
|
201
|
+
'GetTask',
|
|
202
|
+
'CancelTask',
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
/** Method names that stream; used by the `streaming: false` gate. */
|
|
206
|
+
const STREAMING_METHODS = Object.freeze(new Set(['SendStreamingMessage']));
|
|
207
|
+
|
|
208
|
+
/** Header the protocol version travels in. */
|
|
209
|
+
const VERSION_HEADER = 'a2a-version';
|
|
210
|
+
|
|
211
|
+
/** Default request body ceiling (1 MiB): one text message, not a file upload. */
|
|
212
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
213
|
+
|
|
214
|
+
/** Default per-skill execution ceiling (10 min). */
|
|
215
|
+
const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
216
|
+
|
|
217
|
+
/** Keep-alive cadence for a long stream, below the usual 60 s proxy idle cut. */
|
|
218
|
+
const HEARTBEAT_MS = 15000;
|
|
219
|
+
|
|
220
|
+
/** Default skill when the caller names none. */
|
|
221
|
+
const DEFAULT_SKILL = 'session/ask';
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The options `createServer` accepts.
|
|
225
|
+
*
|
|
226
|
+
* Kept as one named type rather than a pile of `@param` lines because the object
|
|
227
|
+
* is DESTRUCTURED in the signature: TypeScript only validates a destructured
|
|
228
|
+
* parameter against a single declared type, so per-property `@param` entries
|
|
229
|
+
* leave the parameter's shape `{}` and every read of `config`/`tasks`/`skills`
|
|
230
|
+
* reports "does not exist". Naming the type also makes the `profileProvider`
|
|
231
|
+
* and `onFlush` seams — the two injectable hooks a non-DSH host uses — visible
|
|
232
|
+
* in one place instead of being implicit.
|
|
233
|
+
*
|
|
234
|
+
* @typedef {object} A2aServerOptions
|
|
235
|
+
* @property {object} [ctx] - Host context; only `ctx.webServer` is read, as the default registrar.
|
|
236
|
+
* @property {A2aRegistrar} [registrar] - route table; defaults to `ctx?.webServer`.
|
|
237
|
+
* @property {string} [where] - where this server runs, for error messages.
|
|
238
|
+
* @property {A2aLogger} [logger] - logger with `info` / `warn` / `debug`.
|
|
239
|
+
* @property {object} [config] - normalized plugin config (§3 of the contract).
|
|
240
|
+
* @property {object} [deliverer] - from `createDeliverer`.
|
|
241
|
+
* @property {A2aTaskStore} [tasks] - a `TaskStore`.
|
|
242
|
+
* @property {A2aSkillRegistry} [skills] - skill registry `{get, list}`.
|
|
243
|
+
* @property {() => object} [profileProvider] - layer-B profile for the card, or a throw.
|
|
244
|
+
* @property {() => Promise<void>|void} [onFlush] - operator flush hook behind `/__health`.
|
|
245
|
+
*/
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build the A2A server facade.
|
|
249
|
+
*
|
|
250
|
+
* The facade needs a way to add four routes and nothing else, so the route table is
|
|
251
|
+
* injected instead of reached for on `ctx`. That single indirection is what lets the
|
|
252
|
+
* SAME server code run in two very different homes:
|
|
253
|
+
*
|
|
254
|
+
* - inside DSH (a member instance, and the embedded host): pass
|
|
255
|
+
* `ctx.webServer` as `registrar`;
|
|
256
|
+
* - as a standalone Node program (a production group host): pass an object whose
|
|
257
|
+
* `register` attaches to a plain `http.Server`.
|
|
258
|
+
*
|
|
259
|
+
* @param {A2aServerOptions} options - server options.
|
|
260
|
+
* @returns {{attach: () => () => void, detach: () => void, card: () => object, info: () => object}} the facade.
|
|
261
|
+
*/
|
|
262
|
+
export function createServer({
|
|
263
|
+
ctx,
|
|
264
|
+
registrar = /** @type {A2aRegistrar|undefined} */ (ctx?.webServer),
|
|
265
|
+
where = 'inside DSH',
|
|
266
|
+
logger,
|
|
267
|
+
config,
|
|
268
|
+
deliverer,
|
|
269
|
+
tasks,
|
|
270
|
+
skills,
|
|
271
|
+
profileProvider,
|
|
272
|
+
onFlush,
|
|
273
|
+
} = {}) {
|
|
274
|
+
const serverConfig = /** @type {any} */ (config)?.server ?? {};
|
|
275
|
+
const maxBodyBytes = positiveInt(serverConfig.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
|
|
276
|
+
const taskTimeoutMs = positiveInt(serverConfig.taskTimeoutMs, DEFAULT_TASK_TIMEOUT_MS);
|
|
277
|
+
const token = typeof serverConfig.token === 'string' ? serverConfig.token.trim() : '';
|
|
278
|
+
const streaming = serverConfig.streaming !== false;
|
|
279
|
+
|
|
280
|
+
/** @type {Array<() => void>} disposers for every registered route. */
|
|
281
|
+
let disposers = [];
|
|
282
|
+
let attached = false;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Attach every route to the Host webserver.
|
|
286
|
+
*
|
|
287
|
+
* Registration order carries no request-facing meaning (the webserver matches
|
|
288
|
+
* exact-first, then longest prefix), so the three registrations are listed
|
|
289
|
+
* longest-path-first purely for readability. The `/a2a/.well-known/` prefix
|
|
290
|
+
* route is what turns an unknown discovery path into a JSON 404 instead of the
|
|
291
|
+
* SPA's HTML shell, which would make a probing peer think it got a card.
|
|
292
|
+
*
|
|
293
|
+
* @returns {() => void} a disposer removing every route this call added.
|
|
294
|
+
*/
|
|
295
|
+
function attach() {
|
|
296
|
+
if (attached) return detach;
|
|
297
|
+
// The route table comes from an injected registrar rather than from `ctx`: a
|
|
298
|
+
// group host runs as a standalone Node program with a plain `http.Server`, and a
|
|
299
|
+
// member instance runs inside DSH and uses `ctx.webServer`. Both pass the same
|
|
300
|
+
// four registrations, so the A2A surface is identical either way.
|
|
301
|
+
const register = registrar?.register;
|
|
302
|
+
if (typeof register !== 'function') {
|
|
303
|
+
throw new Error(`dsh-a2a: no route registrar was provided; the A2A endpoint cannot attach (${where})`);
|
|
304
|
+
}
|
|
305
|
+
if (token === '') {
|
|
306
|
+
warn('no server.token configured; the A2A endpoint accepts loopback callers only. Set config.server.token to expose it.');
|
|
307
|
+
}
|
|
308
|
+
disposers = [
|
|
309
|
+
register.call(registrar, { kind: 'exact', path: CARD_PATH, handler: handleCard }),
|
|
310
|
+
register.call(registrar, { kind: 'exact', path: CARD_ALIAS_PATH, handler: handleCard }),
|
|
311
|
+
register.call(registrar, { kind: 'prefix', path: `${A2A_PATH}/.well-known/`, handler: handleWellKnownFallback }),
|
|
312
|
+
register.call(registrar, { kind: 'exact', path: A2A_PATH, handler: handleRpc }),
|
|
313
|
+
// An operator needs two things a card cannot give: "are you alive" without
|
|
314
|
+
// speaking A2A, and "make sure what you have in memory is on disk before I copy
|
|
315
|
+
// the file". Both are authenticated — a flush is a write and must not be
|
|
316
|
+
// triggerable by anyone who can reach the port.
|
|
317
|
+
register.call(registrar, { kind: 'exact', path: HEALTH_PATH, handler: handleHealth }),
|
|
318
|
+
];
|
|
319
|
+
attached = true;
|
|
320
|
+
info();
|
|
321
|
+
return detach;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Remove every route this facade registered.
|
|
326
|
+
*
|
|
327
|
+
* A disposed route must leave the Host clean: a plugin reload re-registers the
|
|
328
|
+
* same paths, and the webserver throws on a duplicate, so a missed disposer
|
|
329
|
+
* turns "reload the plugin" into a startup failure.
|
|
330
|
+
*
|
|
331
|
+
* @returns {void}
|
|
332
|
+
*/
|
|
333
|
+
function detach() {
|
|
334
|
+
for (const dispose of disposers.reverse()) {
|
|
335
|
+
try {
|
|
336
|
+
if (typeof dispose === 'function') dispose();
|
|
337
|
+
} catch (error) {
|
|
338
|
+
warn('failed to dispose a route', error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
disposers = [];
|
|
342
|
+
attached = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Build the current agent card.
|
|
347
|
+
* @returns {object} the card document.
|
|
348
|
+
*/
|
|
349
|
+
function card() {
|
|
350
|
+
return buildCard({
|
|
351
|
+
config,
|
|
352
|
+
skills,
|
|
353
|
+
url: resolveAgentUrl({ config, origin: originProvider() }),
|
|
354
|
+
name: /** @type {any} */ (config)?.agent?.name,
|
|
355
|
+
description: /** @type {any} */ (config)?.agent?.description,
|
|
356
|
+
version: /** @type {any} */ (config)?.agent?.version,
|
|
357
|
+
// Layer B: derived per read, never stored. A provider that throws must not
|
|
358
|
+
// take the card down — a peer needs the skills list even if the identity
|
|
359
|
+
// aggregation is unhappy, so a failed profile degrades to "no extension".
|
|
360
|
+
profile: readProfile(profileProvider),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Describe this endpoint for a human or for a diagnostics tool.
|
|
366
|
+
* @returns {object} a credential-free summary.
|
|
367
|
+
*/
|
|
368
|
+
function info() {
|
|
369
|
+
const summary = {
|
|
370
|
+
url: resolveAgentUrl({ config, origin: originProvider() }) || A2A_PATH,
|
|
371
|
+
path: A2A_PATH,
|
|
372
|
+
card: CARD_PATH,
|
|
373
|
+
attached,
|
|
374
|
+
streaming,
|
|
375
|
+
tokenConfigured: token !== '',
|
|
376
|
+
loopbackOnly: token === '',
|
|
377
|
+
maxBodyBytes,
|
|
378
|
+
taskTimeoutMs,
|
|
379
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
380
|
+
methods: [...SUPPORTED_METHODS],
|
|
381
|
+
skills: typeof skills?.list === 'function' ? skills.list().map((skill) => skill?.id).filter(Boolean) : [],
|
|
382
|
+
tasks: typeof tasks?.size === 'number' ? tasks.size : 0,
|
|
383
|
+
};
|
|
384
|
+
if (typeof logger?.debug === 'function') logger.debug(`dsh-a2a: server ${attached ? 'attached' : 'detached'} at ${summary.url}`);
|
|
385
|
+
return summary;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Serve the agent card.
|
|
390
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
391
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
392
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
393
|
+
*/
|
|
394
|
+
async function handleCard(req, res) {
|
|
395
|
+
if (!isReadMethod(req?.method)) {
|
|
396
|
+
writeJson(res, 405, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'the agent card is served over GET'), { allow: 'GET, HEAD' });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
writeJson(res, 200, card());
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Answer any other `/.well-known/` path with a JSON 404.
|
|
404
|
+
*
|
|
405
|
+
* This route exists to keep a probing peer on the wire format it expects. The
|
|
406
|
+
* SPA fallback would otherwise answer 200 with HTML, and a client that parsed
|
|
407
|
+
* the body would report a confusing schema error instead of "no such document".
|
|
408
|
+
*
|
|
409
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
410
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
411
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
412
|
+
*/
|
|
413
|
+
async function handleWellKnownFallback(req, res) {
|
|
414
|
+
const path = pathOf(req);
|
|
415
|
+
writeJson(res, 404, rpcError(null, RPC_ERRORS.INVALID_REQUEST, `no A2A discovery document at ${path}`));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Operator endpoint: liveness, and a flush before a backup copies the file.
|
|
420
|
+
*
|
|
421
|
+
* Two things an operator cannot get from the card:
|
|
422
|
+
*
|
|
423
|
+
* - "are you alive" without speaking A2A (a monitor should not have to build a
|
|
424
|
+
* JSON-RPC envelope to ask a yes/no question);
|
|
425
|
+
* - "put what you hold in memory on disk now", so `cp groups.json` is guaranteed
|
|
426
|
+
* to capture the latest state rather than whatever was flushed last.
|
|
427
|
+
*
|
|
428
|
+
* Both are behind the same token as `/a2a`. A flush is a write, and a write endpoint
|
|
429
|
+
* that anyone on the network can trigger is a way to make the host do disk I/O on
|
|
430
|
+
* demand. `POST` is required for the flush so a crawler or a browser prefetch cannot
|
|
431
|
+
* trigger it by accident.
|
|
432
|
+
*
|
|
433
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
434
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
435
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
436
|
+
*/
|
|
437
|
+
async function handleHealth(req, res) {
|
|
438
|
+
const verdict = authenticate(req);
|
|
439
|
+
if (verdict.ok !== true) {
|
|
440
|
+
writeJson(res, 401, { error: 'unauthorized', reason: verdict.reason }, { 'www-authenticate': 'Bearer' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const flushRequested = String(req?.method ?? 'GET').toUpperCase() === 'POST';
|
|
444
|
+
if (!isReadMethod(req?.method) && !flushRequested) {
|
|
445
|
+
writeJson(res, 405, { error: 'method_not_allowed' }, { allow: 'GET, HEAD, POST' });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
let flushed = false;
|
|
449
|
+
let flushError;
|
|
450
|
+
if (flushRequested && typeof onFlush === 'function') {
|
|
451
|
+
try {
|
|
452
|
+
await onFlush();
|
|
453
|
+
flushed = true;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
// Report the failure instead of a 500: a backup script can then decide to
|
|
456
|
+
// skip the copy, which is far better than copying a stale file silently.
|
|
457
|
+
flushError = error instanceof Error ? error.message : String(error);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
writeJson(res, flushError === undefined ? 200 : 500, {
|
|
461
|
+
ok: flushError === undefined,
|
|
462
|
+
agent: typeof config?.agent?.name === 'string' ? config.agent.name : undefined,
|
|
463
|
+
version: PROTOCOL_VERSION,
|
|
464
|
+
uptimeSeconds: Math.round((Date.now() - STARTED_AT) / 1000),
|
|
465
|
+
skills: typeof skills?.list === 'function' ? skills.list().length : 0,
|
|
466
|
+
...(flushRequested ? { flushed, ...(flushError === undefined ? {} : { flushError }) } : {}),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Handle one JSON-RPC call.
|
|
472
|
+
*
|
|
473
|
+
* Order is deliberate: auth, then size, then version, then parse. Authenticating
|
|
474
|
+
* before reading the body keeps an unauthenticated peer from making this Host
|
|
475
|
+
* buffer a megabyte, and versioning before parsing means a 0.3 caller gets a
|
|
476
|
+
* precise `-32003` instead of an `-32600` that hides the real problem.
|
|
477
|
+
*
|
|
478
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
479
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
480
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
481
|
+
*/
|
|
482
|
+
async function handleRpc(req, res) {
|
|
483
|
+
res?.setHeader?.('A2A-Version', PROTOCOL_VERSION);
|
|
484
|
+
if (!isWriteMethod(req?.method)) {
|
|
485
|
+
writeJson(res, 405, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'the JSON-RPC endpoint is POST-only'), { allow: 'POST' });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const auth = authenticate(req);
|
|
489
|
+
if (!auth.ok) {
|
|
490
|
+
writeJson(res, 401, rpcError(null, RPC_ERRORS.UNAUTHENTICATED, auth.reason), { 'www-authenticate': 'Bearer' });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
let raw;
|
|
494
|
+
try {
|
|
495
|
+
raw = await readBody(req, maxBodyBytes);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error?.code === 'body_too_large') {
|
|
498
|
+
writeJson(res, 413, rpcError(null, RPC_ERRORS.INVALID_REQUEST, `request body exceeds ${maxBodyBytes} bytes`));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
writeJson(res, 400, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'could not read the request body'));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
let envelope;
|
|
505
|
+
try {
|
|
506
|
+
envelope = JSON.parse(raw);
|
|
507
|
+
} catch {
|
|
508
|
+
writeJson(res, 400, rpcError(null, RPC_ERRORS.PARSE, 'request body is not valid JSON'));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (Array.isArray(envelope)) {
|
|
512
|
+
// A2A 1.0 is one call per request; a batch has no single task to bind the
|
|
513
|
+
// reply to, so answering anything but an error would invent semantics.
|
|
514
|
+
writeJson(res, 400, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'batch requests are not supported'));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (envelope === null || typeof envelope !== 'object') {
|
|
518
|
+
writeJson(res, 400, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'request must be a JSON object'));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const id = envelope.id ?? null;
|
|
522
|
+
if (envelope.jsonrpc !== '2.0') {
|
|
523
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_REQUEST, 'jsonrpc must be "2.0"'));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const version = versionOf(req);
|
|
527
|
+
if (version !== PROTOCOL_VERSION) {
|
|
528
|
+
const detail = version === IMPLIED_LEGACY_VERSION
|
|
529
|
+
? `missing A2A-Version header implies ${IMPLIED_LEGACY_VERSION}`
|
|
530
|
+
: `requested ${version}`;
|
|
531
|
+
writeJson(
|
|
532
|
+
res,
|
|
533
|
+
200,
|
|
534
|
+
rpcError(id, RPC_ERRORS.VERSION_UNSUPPORTED, `this agent speaks A2A ${PROTOCOL_VERSION} only (${detail})`, {
|
|
535
|
+
supported: [PROTOCOL_VERSION],
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const method = envelope.method;
|
|
541
|
+
if (typeof method !== 'string' || method === '') {
|
|
542
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_REQUEST, 'method must be a non-empty string'));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (!SUPPORTED_METHODS.includes(method)) {
|
|
546
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, `unknown method "${method}"`, { method }));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (!streaming && STREAMING_METHODS.has(method)) {
|
|
550
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, 'streaming is disabled on this agent', { method }));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (method === 'GetTask') return respond(handleGetTask(req, res, id, envelope.params));
|
|
554
|
+
if (method === 'CancelTask') return respond(handleCancelTask(req, res, id, envelope.params));
|
|
555
|
+
if (method === 'SendStreamingMessage') return handleStreaming(req, res, id, envelope.params);
|
|
556
|
+
return respond(handleSendMessage(req, res, id, envelope.params));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Wrap one handler so an unexpected throw still produces a JSON-RPC error.
|
|
561
|
+
*
|
|
562
|
+
* Without this a skill bug would surface as the webserver's bare 400, which
|
|
563
|
+
* tells an A2A peer nothing and looks like a network fault.
|
|
564
|
+
*
|
|
565
|
+
* @param {Promise<void>} work - handler promise.
|
|
566
|
+
* @returns {Promise<void>} resolves when the handler finished or failed.
|
|
567
|
+
*/
|
|
568
|
+
async function respond(work) {
|
|
569
|
+
try {
|
|
570
|
+
await work;
|
|
571
|
+
} catch (error) {
|
|
572
|
+
warn('unhandled error while answering an A2A call', error);
|
|
573
|
+
// The handler may already have written; a second write would throw.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Unary `SendMessage`: run one skill to completion and return the task.
|
|
579
|
+
*
|
|
580
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
581
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
582
|
+
* @param {string|number|null} id - request id.
|
|
583
|
+
* @param {A2aParams} params - `{message}`.
|
|
584
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
585
|
+
*/
|
|
586
|
+
async function handleSendMessage(req, res, id, params) {
|
|
587
|
+
const admitted = await admit({ res, params });
|
|
588
|
+
if (admitted.error !== undefined) {
|
|
589
|
+
writeJson(res, 200, rpcError(id, admitted.error.code, admitted.error.message, admitted.error.data));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const { task, skill, skillId, text, message } = admitted;
|
|
593
|
+
try {
|
|
594
|
+
await Promise.race([
|
|
595
|
+
runSkill({ task, skill, skillId, text, message, signal: admitted.signal, res: null, id }),
|
|
596
|
+
admitted.timeout,
|
|
597
|
+
]);
|
|
598
|
+
writeJson(res, 200, rpcResult(id, { task: toWireTask(tasks.get(task.id) ?? task) }));
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const failure = classify(error);
|
|
601
|
+
tasks.update(task.id, { state: 'failed', note: failure.message });
|
|
602
|
+
tasks.flush();
|
|
603
|
+
writeJson(res, 200, rpcError(id, failure.code, failure.message, { ...failure.data, taskId: task.id }));
|
|
604
|
+
} finally {
|
|
605
|
+
admitted.cleanup();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Streaming `SendStreamingMessage`: frame the same run as SSE.
|
|
611
|
+
*
|
|
612
|
+
* The four committed frame kinds are `submitted`, `working`, one `artifactUpdate`
|
|
613
|
+
* per artifact, and the terminal `statusUpdate` carrying `final: true`. A peer
|
|
614
|
+
* that only waits for `final` still works, and a peer that renders progress gets
|
|
615
|
+
* something real instead of a single blob at the end.
|
|
616
|
+
*
|
|
617
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
618
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
619
|
+
* @param {string|number|null} id - request id.
|
|
620
|
+
* @param {A2aParams} params - `{message}`.
|
|
621
|
+
* @returns {Promise<void>} resolves when the stream ended.
|
|
622
|
+
*/
|
|
623
|
+
async function handleStreaming(req, res, id, params) {
|
|
624
|
+
const admitted = await admit({ res, params });
|
|
625
|
+
if (admitted.error !== undefined) {
|
|
626
|
+
// Nothing has been streamed yet, so this is still a clean HTTP error.
|
|
627
|
+
writeJson(res, 200, rpcError(id, admitted.error.code, admitted.error.message, admitted.error.data));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const { task, skill, skillId, text, message } = admitted;
|
|
631
|
+
const contextId = task.contextId;
|
|
632
|
+
writeSseHead(res);
|
|
633
|
+
writeSse(res, rpcResult(id, { statusUpdate: statusUpdate({ taskId: task.id, contextId, state: 'submitted', at: task.createdAt }) }));
|
|
634
|
+
tasks.update(task.id, { state: 'working' });
|
|
635
|
+
writeSse(res, rpcResult(id, { statusUpdate: statusUpdate({ taskId: task.id, contextId, state: 'working' }) }));
|
|
636
|
+
const heartbeat = setInterval(() => {
|
|
637
|
+
writeComment(res, `keep-alive ${new Date().toISOString()}`);
|
|
638
|
+
}, HEARTBEAT_MS);
|
|
639
|
+
heartbeat.unref?.();
|
|
640
|
+
const stop = () => clearInterval(heartbeat);
|
|
641
|
+
res?.once?.('close', stop);
|
|
642
|
+
try {
|
|
643
|
+
const outcome = await Promise.race([
|
|
644
|
+
runSkill({ task, skill, skillId, text, message, signal: admitted.signal, res, id }),
|
|
645
|
+
admitted.timeout,
|
|
646
|
+
]);
|
|
647
|
+
for (const artifact of outcome.artifacts) {
|
|
648
|
+
writeSse(
|
|
649
|
+
res,
|
|
650
|
+
rpcResult(id, {
|
|
651
|
+
artifactUpdate: artifactUpdate({ taskId: task.id, contextId, artifact, append: false, lastChunk: true }),
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const finalState = outcome.finalState ?? 'completed';
|
|
656
|
+
const settled = tasks.update(task.id, { state: finalState }) ?? task;
|
|
657
|
+
writeSse(
|
|
658
|
+
res,
|
|
659
|
+
rpcResult(id, {
|
|
660
|
+
statusUpdate: statusUpdate({
|
|
661
|
+
taskId: task.id,
|
|
662
|
+
contextId,
|
|
663
|
+
state: finalState,
|
|
664
|
+
at: settled.updatedAt,
|
|
665
|
+
final: true,
|
|
666
|
+
}),
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
const failure = classify(error);
|
|
671
|
+
const settled = tasks.update(task.id, { state: 'failed', note: failure.message }) ?? task;
|
|
672
|
+
writeSse(
|
|
673
|
+
res,
|
|
674
|
+
rpcResult(id, {
|
|
675
|
+
statusUpdate: statusUpdate({
|
|
676
|
+
taskId: task.id,
|
|
677
|
+
contextId,
|
|
678
|
+
state: 'failed',
|
|
679
|
+
note: failure.message,
|
|
680
|
+
at: settled.updatedAt,
|
|
681
|
+
final: true,
|
|
682
|
+
}),
|
|
683
|
+
}),
|
|
684
|
+
);
|
|
685
|
+
} finally {
|
|
686
|
+
stop();
|
|
687
|
+
res?.off?.('close', stop);
|
|
688
|
+
tasks.flush();
|
|
689
|
+
admitted.cleanup();
|
|
690
|
+
if (res?.writableEnded !== true) res?.end?.();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* `GetTask`: read one task by id.
|
|
696
|
+
*
|
|
697
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
698
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
699
|
+
* @param {string|number|null} id - request id.
|
|
700
|
+
* @param {A2aParams} params - `{id}` (also tolerates `taskId` and 0.3's `name`).
|
|
701
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
702
|
+
*/
|
|
703
|
+
async function handleGetTask(req, res, id, params) {
|
|
704
|
+
const taskId = taskIdOf(params);
|
|
705
|
+
if (taskId === '') {
|
|
706
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, 'GetTask requires params.id'));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const task = tasks.get(taskId);
|
|
710
|
+
if (task === undefined) {
|
|
711
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, `no task "${taskId}"`, { code: 'task_not_found', taskId }));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
writeJson(res, 200, rpcResult(id, { task: toWireTask(task) }));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* `CancelTask`: cancel a task that has not settled.
|
|
719
|
+
*
|
|
720
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
721
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
722
|
+
* @param {string|number|null} id - request id.
|
|
723
|
+
* @param {A2aParams} params - `{id}`.
|
|
724
|
+
* @returns {Promise<void>} resolves when the response is written.
|
|
725
|
+
*/
|
|
726
|
+
async function handleCancelTask(req, res, id, params) {
|
|
727
|
+
const taskId = taskIdOf(params);
|
|
728
|
+
if (taskId === '') {
|
|
729
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, 'CancelTask requires params.id'));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const { task } = tasks.cancel(taskId);
|
|
733
|
+
if (task === undefined) {
|
|
734
|
+
writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, `no task "${taskId}"`, { code: 'task_not_found', taskId }));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
tasks.flush();
|
|
738
|
+
writeJson(res, 200, rpcResult(id, { task: toWireTask(task) }));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Validate one `SendMessage` request and open a task for it.
|
|
743
|
+
*
|
|
744
|
+
* Task creation happens before the skill runs so that every path after this
|
|
745
|
+
* point — success, timeout, failure, cancellation — has a task id to attach
|
|
746
|
+
* the outcome to. A peer that disconnects mid-call can still `GetTask` the id
|
|
747
|
+
* it guessed from a submission frame.
|
|
748
|
+
*
|
|
749
|
+
* @param {A2aAdmitInput} input - admission inputs; the response carries the disconnect signal.
|
|
750
|
+
* @returns {Promise<A2aAdmission>} `{task, skill, text, signal, timeout, cleanup}` or `{error}`.
|
|
751
|
+
*/
|
|
752
|
+
async function admit({ res, params }) {
|
|
753
|
+
const message = params?.message;
|
|
754
|
+
if (message === null || typeof message !== 'object' || Array.isArray(message)) {
|
|
755
|
+
return { error: { code: RPC_ERRORS.INVALID_PARAMS, message: 'SendMessage requires params.message' } };
|
|
756
|
+
}
|
|
757
|
+
if (!Array.isArray(message.parts) || message.parts.length === 0) {
|
|
758
|
+
return { error: { code: RPC_ERRORS.INVALID_PARAMS, message: 'params.message.parts must be a non-empty array' } };
|
|
759
|
+
}
|
|
760
|
+
const skillId = skillIdOf(message);
|
|
761
|
+
const skill = typeof skills?.get === 'function' ? skills.get(skillId) : undefined;
|
|
762
|
+
if (skill === undefined || skill === null) {
|
|
763
|
+
const known = typeof skills?.list === 'function' ? skills.list().map((entry) => entry?.id) : [];
|
|
764
|
+
return {
|
|
765
|
+
error: {
|
|
766
|
+
code: RPC_ERRORS.INVALID_PARAMS,
|
|
767
|
+
message: `unknown skill "${skillId}"`,
|
|
768
|
+
data: { code: 'unknown_skill', skill: skillId, known },
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
const text = textOfMessage(message);
|
|
773
|
+
const contextId = typeof message.contextId === 'string' && message.contextId !== '' ? message.contextId : undefined;
|
|
774
|
+
const task = tasks.create({ contextId, metadata: message.metadata });
|
|
775
|
+
return { task, skill, skillId, text, message, ...openCancellation({ res, taskId: task.id, skillId }) };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Arm the two ways a skill run can be cut short: a timeout and a disconnect.
|
|
780
|
+
*
|
|
781
|
+
* The timeout is enforced with an independent rejection rather than relying on
|
|
782
|
+
* the skill to honour its `AbortSignal` — a skill stuck in synchronous work or
|
|
783
|
+
* awaiting a promise it cannot cancel would otherwise hold the task open past
|
|
784
|
+
* its own deadline forever. The abort still fires so a cooperative skill can
|
|
785
|
+
* wind down, but the race is what guarantees the caller is answered.
|
|
786
|
+
*
|
|
787
|
+
* Disconnect detection watches the *response*, not the request. `req.close`
|
|
788
|
+
* also fires on a normal, fully-read request, so keying off it would abort
|
|
789
|
+
* every successful call; `res.close` fires when the connection goes away, and
|
|
790
|
+
* `res.writableEnded` tells us whether we had already answered.
|
|
791
|
+
*
|
|
792
|
+
* @param {object} input - cancellation inputs.
|
|
793
|
+
* @param {A2aResponse} input.res - the response, for the disconnect signal.
|
|
794
|
+
* @param {string} input.taskId - task being run.
|
|
795
|
+
* @param {string} input.skillId - skill being run.
|
|
796
|
+
* @returns {{signal: AbortSignal, timeout: Promise<any>, cleanup: () => void}} cancellation handles.
|
|
797
|
+
*/
|
|
798
|
+
function openCancellation({ res, taskId, skillId }) {
|
|
799
|
+
const controller = new AbortController();
|
|
800
|
+
let timer;
|
|
801
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
802
|
+
timer = setTimeout(() => {
|
|
803
|
+
const error = new Error(`skill "${skillId}" exceeded the ${taskTimeoutMs} ms task timeout`);
|
|
804
|
+
error.code = 'task_timeout';
|
|
805
|
+
controller.abort(error);
|
|
806
|
+
reject(error);
|
|
807
|
+
}, taskTimeoutMs);
|
|
808
|
+
timer.unref?.();
|
|
809
|
+
});
|
|
810
|
+
// A rejection nobody raced against must not become an unhandled rejection.
|
|
811
|
+
timeout.catch(() => {});
|
|
812
|
+
const onClose = () => {
|
|
813
|
+
if (res?.writableEnded === true) return;
|
|
814
|
+
controller.abort(new Error(`the A2A caller disconnected while task ${taskId} was running`));
|
|
815
|
+
};
|
|
816
|
+
res?.once?.('close', onClose);
|
|
817
|
+
const cleanup = () => {
|
|
818
|
+
clearTimeout(timer);
|
|
819
|
+
res?.off?.('close', onClose);
|
|
820
|
+
};
|
|
821
|
+
return { signal: controller.signal, timeout, cleanup };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Run one skill and normalize its result into wire artifacts.
|
|
826
|
+
*
|
|
827
|
+
* A skill may return a bare string (the common case), `{text, artifacts}`, or
|
|
828
|
+
* `{finalState}`. Normalizing here keeps every skill free of wire vocabulary
|
|
829
|
+
* and guarantees that every successful run produces at least one artifact —
|
|
830
|
+
* a completed task with no artifacts looks like a bug to a peer.
|
|
831
|
+
*
|
|
832
|
+
* The state transition is recorded on the task *before* the caller serializes
|
|
833
|
+
* it, so the `task` a unary caller receives and the `statusUpdate` a streaming
|
|
834
|
+
* caller receives are the same facts read from one place.
|
|
835
|
+
*
|
|
836
|
+
* `context.emit` is the streaming half of the skill contract. It is offered
|
|
837
|
+
* only when a stream exists; a skill that needs it (a subscription) throws
|
|
838
|
+
* `not_streaming` otherwise, which is truthful — a unary call has nowhere to
|
|
839
|
+
* put an unsolicited frame. `untilCancelled` is what lets a long-lived skill
|
|
840
|
+
* hold its stream open without owning the timeout policy.
|
|
841
|
+
*
|
|
842
|
+
* @param {object} input - run inputs.
|
|
843
|
+
* @param {object} input.task - task row.
|
|
844
|
+
* @param {A2aSkill} input.skill - registry skill.
|
|
845
|
+
* @param {string} input.skillId - resolved skill id.
|
|
846
|
+
* @param {string} input.text - request text.
|
|
847
|
+
* @param {object} input.message - the original 1.0 message.
|
|
848
|
+
* @param {AbortSignal} input.signal - cancellation and timeout.
|
|
849
|
+
* @param {object|null} input.res - response, for skills that stream themselves.
|
|
850
|
+
* @param {string|number|null} [input.id] - JSON-RPC id, echoed on each emitted frame.
|
|
851
|
+
* @returns {Promise<{artifacts: object[], finalState: string}>} the normalized outcome.
|
|
852
|
+
* @throws {Error} when the skill throws, aborts, or times out.
|
|
853
|
+
*/
|
|
854
|
+
async function runSkill({ task, skill, skillId, text, message, signal, res, id }) {
|
|
855
|
+
const streaming = res !== null && res !== undefined && typeof res.write === 'function';
|
|
856
|
+
const context = {
|
|
857
|
+
taskId: task.id,
|
|
858
|
+
contextId: task.contextId,
|
|
859
|
+
messageId: typeof message?.messageId === 'string' ? message.messageId : undefined,
|
|
860
|
+
skill: skillId,
|
|
861
|
+
signal,
|
|
862
|
+
deliverer,
|
|
863
|
+
tasks,
|
|
864
|
+
logger,
|
|
865
|
+
now: () => Date.now(),
|
|
866
|
+
server: { card, info },
|
|
867
|
+
res,
|
|
868
|
+
request: { text, skill: skillId, message },
|
|
869
|
+
/**
|
|
870
|
+
* Stream one skill-defined event as an artifact frame.
|
|
871
|
+
*
|
|
872
|
+
* The artifact name carries the skill's own event name so a subscriber can
|
|
873
|
+
* tell a `post` from anything else a future skill emits; the payload is a
|
|
874
|
+
* single JSON text part, which is what keeps the wire shape stable no
|
|
875
|
+
* matter what the skill puts inside.
|
|
876
|
+
*
|
|
877
|
+
* @param {string} name - skill-defined event name, e.g. `post`.
|
|
878
|
+
* @param {unknown} body - event payload.
|
|
879
|
+
* @returns {void}
|
|
880
|
+
*/
|
|
881
|
+
emit: streaming
|
|
882
|
+
? (name, body) => {
|
|
883
|
+
const artifact = makeArtifact({
|
|
884
|
+
name: typeof name === 'string' && name !== '' ? name : 'event',
|
|
885
|
+
text: typeof body === 'string' ? body : JSON.stringify(body ?? null),
|
|
886
|
+
});
|
|
887
|
+
writeSse(
|
|
888
|
+
res,
|
|
889
|
+
rpcResult(id ?? null, {
|
|
890
|
+
artifactUpdate: artifactUpdate({ taskId: task.id, contextId: task.contextId, artifact, append: false, lastChunk: true }),
|
|
891
|
+
}),
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
: undefined,
|
|
895
|
+
/**
|
|
896
|
+
* Resolve when the caller goes away or the task deadline passes.
|
|
897
|
+
*
|
|
898
|
+
* Rejection is the honest outcome: a cancelled subscription was not
|
|
899
|
+
* "finished", and swallowing the abort would make the skill report a
|
|
900
|
+
* normal completion for work that never completed.
|
|
901
|
+
*
|
|
902
|
+
* @returns {Promise<never>} never resolves; rejects with the abort reason.
|
|
903
|
+
*/
|
|
904
|
+
untilCancelled: () =>
|
|
905
|
+
new Promise((_resolve, reject) => {
|
|
906
|
+
if (signal === undefined || signal === null) {
|
|
907
|
+
reject(new Error('this skill requires a cancellable stream'));
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (signal.aborted) {
|
|
911
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
signal.addEventListener(
|
|
915
|
+
'abort',
|
|
916
|
+
() => reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')),
|
|
917
|
+
{ once: true },
|
|
918
|
+
);
|
|
919
|
+
}),
|
|
920
|
+
};
|
|
921
|
+
let result;
|
|
922
|
+
try {
|
|
923
|
+
// `data` is the caller's structured payload, unwrapped from the metadata
|
|
924
|
+
// envelope: the wire carries `metadata: {skill, data}`, and a skill that
|
|
925
|
+
// had to reach through `.data.data` would be inventing a second shape for
|
|
926
|
+
// the same idea. `metadata` is passed alongside for a skill that genuinely
|
|
927
|
+
// needs the whole envelope.
|
|
928
|
+
result = await skill.run(
|
|
929
|
+
{
|
|
930
|
+
text,
|
|
931
|
+
message,
|
|
932
|
+
data: isPlainObject(task.metadata?.data) ? task.metadata.data : {},
|
|
933
|
+
metadata: task.metadata,
|
|
934
|
+
skill: skillId,
|
|
935
|
+
messageId: message?.messageId,
|
|
936
|
+
},
|
|
937
|
+
context,
|
|
938
|
+
);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
// A timeout or disconnect aborts the signal; report that reason rather
|
|
941
|
+
// than whatever the skill happened to throw while unwinding.
|
|
942
|
+
if (signal?.aborted === true && signal.reason instanceof Error) throw signal.reason;
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
// A skill that finished after its deadline must not overwrite the failure
|
|
946
|
+
// the caller was already told about.
|
|
947
|
+
if (signal?.aborted === true) throw signal.reason instanceof Error ? signal.reason : new Error('aborted');
|
|
948
|
+
const artifacts = normalizeArtifacts(result);
|
|
949
|
+
for (const artifact of artifacts) tasks.attachArtifact(task.id, artifact);
|
|
950
|
+
// A skill's structured result rides on the task's own metadata, under a key of
|
|
951
|
+
// its own. It has to travel: `group/join` returns a member credential and
|
|
952
|
+
// `group/read` returns entries, and neither belongs in a text artifact a model
|
|
953
|
+
// would then have to parse back out. Mutated on the live task rather than
|
|
954
|
+
// through `update()`, which owns state transitions and nothing else.
|
|
955
|
+
if (isPlainObject(result?.meta)) {
|
|
956
|
+
const live = tasks.get(task.id);
|
|
957
|
+
if (live !== undefined) live.metadata = { ...(live.metadata ?? {}), result: result.meta };
|
|
958
|
+
}
|
|
959
|
+
const finalState = typeof result?.finalState === 'string' && result.finalState !== '' ? result.finalState : 'completed';
|
|
960
|
+
tasks.update(task.id, { state: finalState });
|
|
961
|
+
return { artifacts, finalState };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Decide whether a request may call this endpoint.
|
|
966
|
+
*
|
|
967
|
+
* With a token configured, the token is the only gate. Without one, loopback is
|
|
968
|
+
* the gate: an endpoint that silently accepts the whole LAN because an operator
|
|
969
|
+
* left a field blank is the classic way this class of plugin becomes an
|
|
970
|
+
* incident. The refusal names the reason (missing vs wrong credential) but
|
|
971
|
+
* never echoes what was presented.
|
|
972
|
+
*
|
|
973
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
974
|
+
* @returns {{ok: true} | {ok: false, reason: string}} the verdict.
|
|
975
|
+
*/
|
|
976
|
+
function authenticate(req) {
|
|
977
|
+
if (token === '') {
|
|
978
|
+
if (isLoopback(req?.socket?.remoteAddress)) return { ok: true };
|
|
979
|
+
return { ok: false, reason: 'this agent is bound to loopback; set config.server.token to accept remote callers' };
|
|
980
|
+
}
|
|
981
|
+
const presented = presentedToken(req);
|
|
982
|
+
if (presented === '') return { ok: false, reason: 'missing x-api-key or Authorization: Bearer credential' };
|
|
983
|
+
if (!safeEqual(presented, token)) return { ok: false, reason: 'invalid credential' };
|
|
984
|
+
return { ok: true };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Origin provider that tolerates a webserver whose listener has not bound. */
|
|
988
|
+
function originProvider() {
|
|
989
|
+
const webServer = ctx?.webServer;
|
|
990
|
+
if (webServer === null || typeof webServer !== 'object') return undefined;
|
|
991
|
+
if (typeof webServer.getOrigin !== 'function') return undefined;
|
|
992
|
+
return () => webServer.getOrigin();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Log a warning, scrubbing credentials out of any error detail. */
|
|
996
|
+
function warn(message, error) {
|
|
997
|
+
if (typeof logger?.warn !== 'function') return;
|
|
998
|
+
const detail = error === undefined ? '' : `: ${scrub(errorMessage(error))}`;
|
|
999
|
+
logger.warn(`dsh-a2a: ${scrub(message)}${detail}`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return { attach, detach, card, info };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Read the request body up to a hard byte ceiling.
|
|
1008
|
+
*
|
|
1009
|
+
* The ceiling is enforced while reading, not from `Content-Length`: a peer can
|
|
1010
|
+
* lie about the header, and buffering whatever it sends is the whole attack this
|
|
1011
|
+
* check exists to stop.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
1014
|
+
* @param {number} limit - maximum bytes.
|
|
1015
|
+
* @returns {Promise<string>} the decoded body.
|
|
1016
|
+
* @throws {Error} with `code: 'body_too_large'` when the ceiling is exceeded.
|
|
1017
|
+
*/
|
|
1018
|
+
async function readBody(req, limit) {
|
|
1019
|
+
if (req === null || typeof req !== 'object') return '';
|
|
1020
|
+
const declared = Number(req.headers?.['content-length']);
|
|
1021
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
1022
|
+
const error = new Error('body too large');
|
|
1023
|
+
error.code = 'body_too_large';
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
const chunks = [];
|
|
1027
|
+
let total = 0;
|
|
1028
|
+
for await (const chunk of /** @type {AsyncIterable<Buffer|string>} */ (req)) {
|
|
1029
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1030
|
+
total += buffer.length;
|
|
1031
|
+
if (total > limit) {
|
|
1032
|
+
const error = new Error('body too large');
|
|
1033
|
+
error.code = 'body_too_large';
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
chunks.push(buffer);
|
|
1037
|
+
}
|
|
1038
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Write a JSON response, guarding against a double write when the peer left.
|
|
1043
|
+
* @param {A2aResponse} res - `node:http` response.
|
|
1044
|
+
* @param {number} status - HTTP status.
|
|
1045
|
+
* @param {object} payload - JSON body.
|
|
1046
|
+
* @param {object} [headers] - extra headers.
|
|
1047
|
+
* @returns {void}
|
|
1048
|
+
*/
|
|
1049
|
+
function writeJson(res, status, payload, headers) {
|
|
1050
|
+
if (res === null || typeof res !== 'object') return;
|
|
1051
|
+
if (res.writableEnded === true || res.destroyed === true) return;
|
|
1052
|
+
try {
|
|
1053
|
+
if (res.headersSent !== true) {
|
|
1054
|
+
res.statusCode = status;
|
|
1055
|
+
res.setHeader?.('Content-Type', 'application/json; charset=utf-8');
|
|
1056
|
+
res.setHeader?.('Cache-Control', 'no-store');
|
|
1057
|
+
if (headers !== null && typeof headers === 'object') {
|
|
1058
|
+
for (const [key, value] of Object.entries(headers)) res.setHeader?.(key, value);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
res.end(JSON.stringify(payload));
|
|
1062
|
+
} catch {
|
|
1063
|
+
// The socket is gone; nothing left to report to.
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Extract the presented credential from either accepted header.
|
|
1069
|
+
*
|
|
1070
|
+
* `Authorization: Bearer` wins when both are present because it is the standard
|
|
1071
|
+
* one; a caller that sets both is usually a proxy adding a header.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
1074
|
+
* @returns {string} the credential, or `''`.
|
|
1075
|
+
*/
|
|
1076
|
+
function presentedToken(req) {
|
|
1077
|
+
const headers = req?.headers;
|
|
1078
|
+
if (headers === null || typeof headers !== 'object') return '';
|
|
1079
|
+
const apiKey = firstHeader(headers['x-api-key']);
|
|
1080
|
+
if (apiKey !== '') return apiKey;
|
|
1081
|
+
const authorization = firstHeader(headers.authorization);
|
|
1082
|
+
if (authorization === '') return '';
|
|
1083
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
1084
|
+
return match === null ? '' : match[1].trim();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Read one header as a trimmed string, tolerating the array form Node uses for
|
|
1089
|
+
* repeated headers.
|
|
1090
|
+
* @param {string|string[]|undefined} value - header value.
|
|
1091
|
+
* @returns {string} the value, or `''`.
|
|
1092
|
+
*/
|
|
1093
|
+
function firstHeader(value) {
|
|
1094
|
+
if (Array.isArray(value)) {
|
|
1095
|
+
for (const entry of value) {
|
|
1096
|
+
if (typeof entry === 'string' && entry.trim() !== '') return entry.trim();
|
|
1097
|
+
}
|
|
1098
|
+
return '';
|
|
1099
|
+
}
|
|
1100
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Read the `A2A-Version` header, defaulting to the implied legacy version.
|
|
1105
|
+
* @param {A2aRequest} req - `node:http` request.
|
|
1106
|
+
* @returns {string} the version the caller speaks.
|
|
1107
|
+
*/
|
|
1108
|
+
function versionOf(req) {
|
|
1109
|
+
const value = firstHeader(req?.headers?.[VERSION_HEADER]);
|
|
1110
|
+
return value === '' ? IMPLIED_LEGACY_VERSION : value;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Extract the skill id from a message's metadata.
|
|
1115
|
+
* @param {object} message - A2A message.
|
|
1116
|
+
* @returns {string} the skill id, defaulting to `session/ask`.
|
|
1117
|
+
*/
|
|
1118
|
+
function skillIdOf(message) {
|
|
1119
|
+
const candidate = message?.metadata?.skill;
|
|
1120
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim();
|
|
1121
|
+
return DEFAULT_SKILL;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Extract a task id from either the 1.0 (`id`) or legacy (`taskId`) spelling.
|
|
1126
|
+
* @param {A2aParams} params - JSON-RPC params.
|
|
1127
|
+
* @returns {string} the task id, or `''`.
|
|
1128
|
+
*/
|
|
1129
|
+
function taskIdOf(params) {
|
|
1130
|
+
for (const key of ['id', 'taskId', 'name']) {
|
|
1131
|
+
const value = params?.[key];
|
|
1132
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
1133
|
+
}
|
|
1134
|
+
return '';
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Whether a method may be answered from cache semantics (read-only). */
|
|
1138
|
+
function isReadMethod(method) {
|
|
1139
|
+
return method === 'GET' || method === 'HEAD';
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Whether a method may carry a body. */
|
|
1143
|
+
function isWriteMethod(method) {
|
|
1144
|
+
return method === 'POST';
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Whether an address is loopback, including the IPv4-mapped IPv6 forms Node
|
|
1149
|
+
* reports for a `127.0.0.1` connection on a dual-stack listener.
|
|
1150
|
+
* @param {string|undefined} address - `socket.remoteAddress`.
|
|
1151
|
+
* @returns {boolean} true when the peer is this machine.
|
|
1152
|
+
*/
|
|
1153
|
+
function isLoopback(address) {
|
|
1154
|
+
if (typeof address !== 'string' || address === '') return false;
|
|
1155
|
+
const value = address.trim().toLowerCase();
|
|
1156
|
+
if (value === '::1' || value === '::ffff:127.0.0.1' || value === '0:0:0:0:0:0:0:1') return true;
|
|
1157
|
+
const match = /^(?:\.)?(?:ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
|
|
1158
|
+
if (match !== null) return Number(match[1]) === 127;
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Read the request pathname without a URL parse (the Host may have rewritten it). */
|
|
1163
|
+
function pathOf(req) {
|
|
1164
|
+
const raw = typeof req?.url === 'string' ? req.url : '';
|
|
1165
|
+
const cut = raw.split('?')[0];
|
|
1166
|
+
return cut === '' ? '/' : cut;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Classify a thrown value into a JSON-RPC error.
|
|
1171
|
+
*
|
|
1172
|
+
* A skill that wants a specific A2A code raises an error with a string `code`
|
|
1173
|
+
* from the contract's table; everything else is an internal failure. Returning
|
|
1174
|
+
* `-32004` with `data.code` is what lets a caller distinguish "you are over your
|
|
1175
|
+
* post budget" from "this plugin crashed", without parsing prose.
|
|
1176
|
+
*
|
|
1177
|
+
* @param {unknown} error - the thrown value.
|
|
1178
|
+
* @returns {{code: number, message: string, data: object}} the JSON-RPC error fields.
|
|
1179
|
+
*/
|
|
1180
|
+
function classify(error) {
|
|
1181
|
+
const message = errorMessage(error);
|
|
1182
|
+
const raw = typeof error?.code === 'string' ? error.code : '';
|
|
1183
|
+
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
|
|
1184
|
+
return { code: RPC_ERRORS.INTERNAL, message, data: { code: 'aborted' } };
|
|
1185
|
+
}
|
|
1186
|
+
// A deadline or a disconnect is this Host's failure, not a business refusal;
|
|
1187
|
+
// the stable sub-code still tells the peer it may retry.
|
|
1188
|
+
if (raw === 'task_timeout' || raw === 'aborted') {
|
|
1189
|
+
return { code: RPC_ERRORS.INTERNAL, message, data: { code: raw } };
|
|
1190
|
+
}
|
|
1191
|
+
if (raw !== '') {
|
|
1192
|
+
return { code: RPC_ERRORS.REJECTED, message, data: { code: raw } };
|
|
1193
|
+
}
|
|
1194
|
+
if (typeof error?.status === 'number' && error.status === 401) {
|
|
1195
|
+
return { code: RPC_ERRORS.UNAUTHENTICATED, message, data: { code: 'unauthenticated' } };
|
|
1196
|
+
}
|
|
1197
|
+
return { code: RPC_ERRORS.INTERNAL, message: message === '' ? 'skill execution failed' : message, data: { code: 'internal' } };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Render a thrown value as a message string. */
|
|
1201
|
+
function errorMessage(error) {
|
|
1202
|
+
if (error instanceof Error) return error.message;
|
|
1203
|
+
if (typeof error === 'string') return error;
|
|
1204
|
+
if (error === null || error === undefined) return '';
|
|
1205
|
+
try {
|
|
1206
|
+
return JSON.stringify(error);
|
|
1207
|
+
} catch {
|
|
1208
|
+
return String(error);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Normalize whatever a skill returned into wire artifact values.
|
|
1214
|
+
*
|
|
1215
|
+
* Skills are ordinary code, often written by another branch of this project, so
|
|
1216
|
+
* the accepted shapes are generous: a bare string, `{text}`, `{artifacts: [...]}`
|
|
1217
|
+
* with either pre-built parts or a `text` field. The one rule enforced here is
|
|
1218
|
+
* that a successful run yields at least one artifact — an empty `artifacts` array
|
|
1219
|
+
* on a completed task reads as a silent failure to every A2A client.
|
|
1220
|
+
*
|
|
1221
|
+
* @param {unknown} result - value returned by `skill.run()`.
|
|
1222
|
+
* @returns {object[]} zero or more artifact values.
|
|
1223
|
+
*/
|
|
1224
|
+
function normalizeArtifacts(result) {
|
|
1225
|
+
const artifacts = [];
|
|
1226
|
+
if (typeof result === 'string') {
|
|
1227
|
+
if (result !== '') artifacts.push(makeArtifact({ name: 'reply', text: result }));
|
|
1228
|
+
return artifacts;
|
|
1229
|
+
}
|
|
1230
|
+
if (result === null || typeof result !== 'object') return artifacts;
|
|
1231
|
+
const declared = Array.isArray(result.artifacts) ? result.artifacts : [];
|
|
1232
|
+
for (const entry of declared) {
|
|
1233
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
1234
|
+
if (typeof entry.artifactId === 'string' && entry.artifactId !== '' && Array.isArray(entry.parts)) {
|
|
1235
|
+
artifacts.push(entry);
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
artifacts.push(
|
|
1239
|
+
makeArtifact({
|
|
1240
|
+
artifactId: typeof entry.artifactId === 'string' ? entry.artifactId : undefined,
|
|
1241
|
+
name: typeof entry.name === 'string' && entry.name !== '' ? entry.name : 'reply',
|
|
1242
|
+
parts: Array.isArray(entry.parts) ? entry.parts : undefined,
|
|
1243
|
+
text: typeof entry.text === 'string' ? entry.text : '',
|
|
1244
|
+
}),
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
if (artifacts.length === 0 && typeof result.text === 'string' && result.text !== '') {
|
|
1248
|
+
artifacts.push(makeArtifact({ name: typeof result.name === 'string' && result.name !== '' ? result.name : 'reply', text: result.text }));
|
|
1249
|
+
}
|
|
1250
|
+
return artifacts;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Mask credential-looking `key=value` or `key: value` pairs in a log line.
|
|
1255
|
+
*
|
|
1256
|
+
* Server-side logging is the last place a token can escape: an upstream error
|
|
1257
|
+
* message, a skill's own error, or a misconfigured card all flow through here.
|
|
1258
|
+
* @param {string} text - candidate log text.
|
|
1259
|
+
* @returns {string} text with secrets replaced by a short digest.
|
|
1260
|
+
*/
|
|
1261
|
+
function scrub(text) {
|
|
1262
|
+
return redactAll(String(text ?? ''), [process.env?.A2A_LOG_SECRET]);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Coerce a config number into a positive integer, falling back on nonsense. */
|
|
1266
|
+
function positiveInt(value, fallback) {
|
|
1267
|
+
if (!Number.isFinite(value) || value <= 0) return fallback;
|
|
1268
|
+
return Math.floor(value);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Whether a value is a plain JSON object (not an array, not null).
|
|
1273
|
+
*
|
|
1274
|
+
* Used to decide whether a caller's `metadata.data` is structured input or
|
|
1275
|
+
* absent: an array or a primitive there is a client mistake, and passing it
|
|
1276
|
+
* through as `data` would make every skill re-check the same thing.
|
|
1277
|
+
*
|
|
1278
|
+
* @param {unknown} value
|
|
1279
|
+
* @returns {boolean}
|
|
1280
|
+
*/
|
|
1281
|
+
function isPlainObject(value) {
|
|
1282
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Read the layer B profile, tolerating a provider that fails.
|
|
1287
|
+
*
|
|
1288
|
+
* A card is how a peer discovers what this agent can do; losing the whole card
|
|
1289
|
+
* because the identity aggregation hit a corrupt file would be the wrong trade.
|
|
1290
|
+
* A failed read therefore degrades to "no profile", which the card renders as its
|
|
1291
|
+
* default name and description.
|
|
1292
|
+
*
|
|
1293
|
+
* @param {(() => object)|undefined} provider - returns the profile, or undefined.
|
|
1294
|
+
* @returns {object|undefined} the profile, or undefined.
|
|
1295
|
+
*/
|
|
1296
|
+
function readProfile(provider) {
|
|
1297
|
+
if (typeof provider !== 'function') return undefined;
|
|
1298
|
+
try {
|
|
1299
|
+
const value = provider();
|
|
1300
|
+
return value === null || value === undefined ? undefined : value;
|
|
1301
|
+
} catch {
|
|
1302
|
+
return undefined;
|
|
1303
|
+
}
|
|
1304
|
+
}
|