dsh-a2a-server 0.1.1 → 0.3.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/CHANGELOG.md +28 -0
- package/CHANGELOG.zh-CN.md +28 -0
- package/README.md +151 -54
- package/README.zh-CN.md +113 -53
- package/cordis.patch.yml +10 -6
- package/lib/briefing.d.ts +39 -0
- package/lib/briefing.d.ts.map +1 -0
- package/lib/briefing.js +108 -0
- package/lib/briefing.js.map +1 -0
- package/lib/config.d.ts +69 -22
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +76 -6
- package/lib/config.js.map +1 -1
- package/lib/contexts.d.ts +9 -4
- package/lib/contexts.d.ts.map +1 -1
- package/lib/contexts.js +7 -13
- package/lib/contexts.js.map +1 -1
- package/lib/index.d.ts +1 -26
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +420 -408
- package/lib/index.js.map +1 -1
- package/lib/management.d.ts +5 -0
- package/lib/management.d.ts.map +1 -0
- package/lib/management.js +74 -0
- package/lib/management.js.map +1 -0
- package/lib/projection.d.ts +67 -22
- package/lib/projection.d.ts.map +1 -1
- package/lib/projection.js +30 -11
- package/lib/projection.js.map +1 -1
- package/lib/protocol/card.d.ts +25 -7
- package/lib/protocol/card.d.ts.map +1 -1
- package/lib/protocol/card.js +35 -14
- package/lib/protocol/card.js.map +1 -1
- package/lib/protocol/index.d.ts +10 -10
- package/lib/protocol/index.d.ts.map +1 -1
- package/lib/protocol/index.js +8 -8
- package/lib/protocol/index.js.map +1 -1
- package/lib/protocol/jsonrpc.d.ts +54 -6
- package/lib/protocol/jsonrpc.d.ts.map +1 -1
- package/lib/protocol/jsonrpc.js +70 -8
- package/lib/protocol/jsonrpc.js.map +1 -1
- package/lib/protocol/parse.d.ts +79 -0
- package/lib/protocol/parse.d.ts.map +1 -0
- package/lib/protocol/parse.js +249 -0
- package/lib/protocol/parse.js.map +1 -0
- package/lib/protocol/sse.d.ts +6 -3
- package/lib/protocol/sse.d.ts.map +1 -1
- package/lib/protocol/sse.js +4 -2
- package/lib/protocol/sse.js.map +1 -1
- package/lib/protocol/wire.d.ts +165 -54
- package/lib/protocol/wire.d.ts.map +1 -1
- package/lib/protocol/wire.js +54 -9
- package/lib/protocol/wire.js.map +1 -1
- package/lib/router.d.ts +55 -1
- package/lib/router.d.ts.map +1 -1
- package/lib/router.js +419 -137
- package/lib/router.js.map +1 -1
- package/lib/security.d.ts +37 -3
- package/lib/security.d.ts.map +1 -1
- package/lib/security.js +48 -4
- package/lib/security.js.map +1 -1
- package/lib/state.d.ts +37 -0
- package/lib/state.d.ts.map +1 -0
- package/lib/state.js +146 -0
- package/lib/state.js.map +1 -0
- package/lib/tasks.d.ts +4 -0
- package/lib/tasks.d.ts.map +1 -1
- package/lib/tasks.js +23 -5
- package/lib/tasks.js.map +1 -1
- package/lib/types.d.ts +12 -3
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +1 -1
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +20 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +20 -0
- package/lib/version.js.map +1 -0
- package/lib/workspace-read.d.ts +9 -0
- package/lib/workspace-read.d.ts.map +1 -0
- package/lib/workspace-read.js +152 -0
- package/lib/workspace-read.js.map +1 -0
- package/package.json +39 -30
- package/lib/protocol/normalize.d.ts +0 -72
- package/lib/protocol/normalize.d.ts.map +0 -1
- package/lib/protocol/normalize.js +0 -278
- package/lib/protocol/normalize.js.map +0 -1
package/lib/router.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP + JSON-RPC dispatch.
|
|
2
|
+
* HTTP + JSON-RPC dispatch for the A2A v1.0 binding.
|
|
3
3
|
*
|
|
4
4
|
* Deliberately free of Cordis: everything it needs arrives through
|
|
5
5
|
* {@link RouterDeps}, so the RPC semantics can be unit-tested without booting a
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module dsh-a2a/router
|
|
10
10
|
*/
|
|
11
|
-
import { A2AContextId, ERR_INVALID_PARAMS, ERR_INVALID_REQUEST, ERR_METHOD_NOT_FOUND, ERR_PARSE, ERR_PUSH_NOT_SUPPORTED, ERR_TASK_NOT_CANCELABLE,
|
|
12
|
-
import { filterInbound, redactOutbound } from "./security.js";
|
|
11
|
+
import { A2AContextId, A2A_PROTOCOL_VERSION, ERR_EXTENDED_CARD_NOT_CONFIGURED, ERR_INVALID_PARAMS, ERR_INVALID_REQUEST, ERR_METHOD_NOT_FOUND, ERR_PARSE, ERR_PUSH_NOT_SUPPORTED, ERR_TASK_NOT_CANCELABLE, A2ARpcError, jsonRpcError, jsonRpcResult, legacyMethodReplacement, nowIso, parseListTasksParams, parseSendMessageRequest, parseTenant, partsToText, resolveMethod, sseFrame, streamArtifactUpdate, streamStatusUpdate, streamTask, taskNotFound, unsupportedOperation, versionNotSupported, } from "./protocol/index.js";
|
|
12
|
+
import { constantTimeEqual, filterInbound, redactOutbound } from "./security.js";
|
|
13
13
|
/** Largest request body accepted, before parsing. */
|
|
14
14
|
const MAX_BODY_BYTES = 1_000_000;
|
|
15
|
+
/** `ListTasks` page size when a peer names none, per the spec's default. */
|
|
16
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
15
17
|
/**
|
|
16
18
|
* Build the router.
|
|
17
19
|
* @param deps - the injected plugin capabilities.
|
|
@@ -28,6 +30,10 @@ export function createRouter(deps) {
|
|
|
28
30
|
});
|
|
29
31
|
res.end(text);
|
|
30
32
|
};
|
|
33
|
+
/** Answer a JSON-RPC failure, carrying its A2A reason when it has one. */
|
|
34
|
+
const sendRpcError = (res, id, error) => {
|
|
35
|
+
sendJson(res, error.httpStatus, jsonRpcError(id, error.code, error.message, error.data));
|
|
36
|
+
};
|
|
31
37
|
/**
|
|
32
38
|
* Answer an authentication-class failure.
|
|
33
39
|
*
|
|
@@ -46,8 +52,9 @@ export function createRouter(deps) {
|
|
|
46
52
|
for await (const chunk of req) {
|
|
47
53
|
const buffer = chunk;
|
|
48
54
|
size += buffer.length;
|
|
49
|
-
if (size > MAX_BODY_BYTES)
|
|
50
|
-
throw new A2ARpcError(ERR_PARSE, 'payload too large', 413);
|
|
55
|
+
if (size > MAX_BODY_BYTES) {
|
|
56
|
+
throw new A2ARpcError(ERR_PARSE, 'payload too large', { httpStatus: 413 });
|
|
57
|
+
}
|
|
51
58
|
chunks.push(buffer);
|
|
52
59
|
}
|
|
53
60
|
return Buffer.concat(chunks).toString('utf8');
|
|
@@ -61,7 +68,7 @@ export function createRouter(deps) {
|
|
|
61
68
|
}
|
|
62
69
|
sendJson(res, 200, deps.cardFor(req.headers.host));
|
|
63
70
|
};
|
|
64
|
-
const serveRpc = async (req, res) => {
|
|
71
|
+
const serveRpc = async (req, res, ownerChannel = false) => {
|
|
65
72
|
if (req.method !== 'POST') {
|
|
66
73
|
sendJson(res, 405, jsonRpcError(null, ERR_INVALID_REQUEST, 'method not allowed'));
|
|
67
74
|
return;
|
|
@@ -69,12 +76,22 @@ export function createRouter(deps) {
|
|
|
69
76
|
let peer;
|
|
70
77
|
try {
|
|
71
78
|
deps.assertOpen();
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
const presented = typeof req.headers.authorization === 'string'
|
|
80
|
+
? /^Bearer\s+(.+)$/i.exec(req.headers.authorization.trim())?.[1]
|
|
81
|
+
: undefined;
|
|
82
|
+
// The owner's token wins before the visitor table, so a visitor named
|
|
83
|
+
// "owner" can never shadow the console's identity.
|
|
84
|
+
const ownerSecret = await deps.resolveOwnerSecret?.();
|
|
85
|
+
if (ownerChannel && presented !== undefined && ownerSecret !== undefined && constantTimeEqual(presented, ownerSecret)) {
|
|
86
|
+
peer = 'owner';
|
|
87
|
+
}
|
|
88
|
+
else if (!ownerChannel && !(presented !== undefined && ownerSecret !== undefined && constantTimeEqual(presented, ownerSecret))) {
|
|
89
|
+
peer = deps.identify(presented, await deps.resolvePeerSecrets());
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
92
|
catch (error) {
|
|
77
|
-
|
|
93
|
+
deps.logger.warn(`a2a: credential resolution failed: ${String(error)}`);
|
|
94
|
+
sendJson(res, 503, jsonRpcError(null, ERR_INVALID_REQUEST, 'authentication unavailable'));
|
|
78
95
|
return;
|
|
79
96
|
}
|
|
80
97
|
if (peer === undefined) {
|
|
@@ -85,11 +102,9 @@ export function createRouter(deps) {
|
|
|
85
102
|
sendAuthFailure(res, 429, 'rate limit exceeded');
|
|
86
103
|
return;
|
|
87
104
|
}
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// lock out every peer on a config that never mentioned trustedPeers.
|
|
92
|
-
if (config.trustedPeers !== undefined && config.trustedPeers.length > 0
|
|
105
|
+
// The owner bypasses the visitor allow list: the console IS the trust.
|
|
106
|
+
if (peer !== 'owner'
|
|
107
|
+
&& config.trustedPeers !== undefined
|
|
93
108
|
&& !config.trustedPeers.includes(peer)) {
|
|
94
109
|
sendAuthFailure(res, 403, `peer "${peer}" is not trusted`);
|
|
95
110
|
return;
|
|
@@ -123,23 +138,40 @@ export function createRouter(deps) {
|
|
|
123
138
|
sendJson(res, 200, jsonRpcError(id, ERR_INVALID_PARAMS, 'params must be an object'));
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
141
|
+
// Version negotiation. The spec says an EMPTY header means 0.3 — but this
|
|
142
|
+
// interface declares only 1.0 on its card, and a v0.3 peer could not read
|
|
143
|
+
// the reply anyway. So an absent header is taken as 1.0 (the interface a
|
|
144
|
+
// client just discovered), and an explicit version this interface does not
|
|
145
|
+
// serve gets the spec's own VersionNotSupportedError rather than a reply in
|
|
146
|
+
// a spelling the caller cannot parse.
|
|
126
147
|
const version = req.headers['a2a-version'];
|
|
127
|
-
if (typeof version === 'string' && version.length > 0
|
|
128
|
-
&& !['
|
|
129
|
-
|
|
148
|
+
if (typeof version === 'string' && version.trim().length > 0
|
|
149
|
+
&& !['1.0', '1.0.0'].includes(version.trim())) {
|
|
150
|
+
sendRpcError(res, id, versionNotSupported(version.trim(), A2A_PROTOCOL_VERSION));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const operation = resolveMethod(method);
|
|
154
|
+
if (operation === undefined) {
|
|
155
|
+
const replacement = legacyMethodReplacement(method);
|
|
156
|
+
sendJson(res, 200, jsonRpcError(id, ERR_METHOD_NOT_FOUND, replacement === undefined
|
|
157
|
+
? `method not found: ${method}`
|
|
158
|
+
: `method not found: ${method}; this agent speaks A2A `
|
|
159
|
+
+ `${A2A_PROTOCOL_VERSION}, which renamed it to ${replacement}`));
|
|
130
160
|
return;
|
|
131
161
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
162
|
+
// This card's interface declares no tenant, so a peer that names one has
|
|
163
|
+
// been routed somewhere it did not intend.
|
|
164
|
+
const tenant = parseTenant(params);
|
|
165
|
+
if (tenant !== undefined) {
|
|
166
|
+
sendJson(res, 200, jsonRpcError(id, ERR_INVALID_PARAMS, `this interface declares no tenant; remove tenant: ${tenant}`));
|
|
135
167
|
return;
|
|
136
168
|
}
|
|
137
169
|
try {
|
|
138
|
-
await dispatch(
|
|
170
|
+
await dispatch(operation, id, params, peer, req, res);
|
|
139
171
|
}
|
|
140
172
|
catch (error) {
|
|
141
173
|
if (error instanceof A2ARpcError) {
|
|
142
|
-
|
|
174
|
+
sendRpcError(res, id, error);
|
|
143
175
|
return;
|
|
144
176
|
}
|
|
145
177
|
deps.logger.warn(`a2a: ${method} failed: ${String(error)}`);
|
|
@@ -157,6 +189,11 @@ export function createRouter(deps) {
|
|
|
157
189
|
if (rawContextId === undefined)
|
|
158
190
|
return deps.createActivation(peer);
|
|
159
191
|
const found = deps.contexts.lookup(A2AContextId(rawContextId), peer);
|
|
192
|
+
if (found === 'unknown') {
|
|
193
|
+
const restored = await deps.restoreActivation(rawContextId, peer);
|
|
194
|
+
if (restored !== undefined)
|
|
195
|
+
return restored;
|
|
196
|
+
}
|
|
160
197
|
if (found === 'unknown' || found === 'forbidden') {
|
|
161
198
|
throw new A2ARpcError(ERR_INVALID_PARAMS, `unknown contextId: ${rawContextId}`);
|
|
162
199
|
}
|
|
@@ -168,7 +205,7 @@ export function createRouter(deps) {
|
|
|
168
205
|
* A settled task has no slot — `settleSlot` removes it — so this is only the
|
|
169
206
|
* first half of a lookup. Operations that merely READ a task fall through to
|
|
170
207
|
* the projection ({@link readTask}); operations that act on a running task
|
|
171
|
-
* (cancel,
|
|
208
|
+
* (cancel, subscribe) need the live slot and stop here.
|
|
172
209
|
*/
|
|
173
210
|
const requireSlot = (rawTaskId, peer) => {
|
|
174
211
|
const found = findSlot(rawTaskId, peer);
|
|
@@ -188,17 +225,36 @@ export function createRouter(deps) {
|
|
|
188
225
|
}
|
|
189
226
|
return undefined;
|
|
190
227
|
};
|
|
191
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Validate the `id` parameter shape.
|
|
230
|
+
*
|
|
231
|
+
* v1.0 spells every task reference `id`. The v0.3 `taskId` is NOT accepted:
|
|
232
|
+
* a peer still sending it would also be sending a v0.3 message body and
|
|
233
|
+
* expecting a v0.3 reply, and a half-understood request is worse than a
|
|
234
|
+
* refused one.
|
|
235
|
+
*/
|
|
192
236
|
const requireTaskId = (rawTaskId) => {
|
|
193
237
|
if (typeof rawTaskId !== 'string' || rawTaskId.length === 0) {
|
|
194
|
-
throw new A2ARpcError(ERR_INVALID_PARAMS, '
|
|
238
|
+
throw new A2ARpcError(ERR_INVALID_PARAMS, 'id is required');
|
|
195
239
|
}
|
|
196
240
|
return rawTaskId;
|
|
197
241
|
};
|
|
242
|
+
/** The current wire shape of an in-flight task. */
|
|
243
|
+
const liveTask = (activation, slot) => {
|
|
244
|
+
const state = slot.forcedState ?? (slot.turn === undefined
|
|
245
|
+
? 'TASK_STATE_SUBMITTED'
|
|
246
|
+
: 'TASK_STATE_WORKING');
|
|
247
|
+
return deps.taskSnapshot(activation, slot, state, redactArtifacts(slot.texts.length > 0
|
|
248
|
+
? [{
|
|
249
|
+
artifactId: `${slot.taskId}-result`,
|
|
250
|
+
parts: [{ text: slot.texts.join('\n'), mediaType: 'text/plain' }],
|
|
251
|
+
}]
|
|
252
|
+
: []));
|
|
253
|
+
};
|
|
198
254
|
/**
|
|
199
255
|
* Read a task's current state, live slot or not.
|
|
200
256
|
*
|
|
201
|
-
* Falls back to the durable projection, which is what makes `
|
|
257
|
+
* Falls back to the durable projection, which is what makes `GetTask`
|
|
202
258
|
* answerable after settlement — and, once persistence is composed, after a
|
|
203
259
|
* restart. A task belonging to another peer reports exactly like an absent
|
|
204
260
|
* one, so ownership cannot be probed by comparing responses.
|
|
@@ -209,15 +265,8 @@ export function createRouter(deps) {
|
|
|
209
265
|
if (activation.peer !== peer)
|
|
210
266
|
continue;
|
|
211
267
|
const slot = activation.slots.get(taskId);
|
|
212
|
-
if (slot !== undefined)
|
|
213
|
-
|
|
214
|
-
return deps.taskSnapshot(activation, slot, state, redactArtifacts(slot.texts.length > 0
|
|
215
|
-
? [{
|
|
216
|
-
artifactId: `${taskId}-result`,
|
|
217
|
-
parts: [{ kind: 'text', text: slot.texts.join('\n') }],
|
|
218
|
-
}]
|
|
219
|
-
: []));
|
|
220
|
-
}
|
|
268
|
+
if (slot !== undefined)
|
|
269
|
+
return liveTask(activation, slot);
|
|
221
270
|
const projected = deps.readProjectedTask?.(activation, taskId);
|
|
222
271
|
// Text read back from the log leaves the process just like live text
|
|
223
272
|
// does, so it goes through the same scrub.
|
|
@@ -225,167 +274,373 @@ export function createRouter(deps) {
|
|
|
225
274
|
return { ...projected, artifacts: redactArtifacts(projected.artifacts ?? []) };
|
|
226
275
|
}
|
|
227
276
|
}
|
|
277
|
+
const retained = deps.retainedTasks(peer).find(task => task.id === taskId);
|
|
278
|
+
if (retained !== undefined)
|
|
279
|
+
return { ...retained, artifacts: redactArtifacts(retained.artifacts ?? []) };
|
|
228
280
|
throw taskNotFound(taskId);
|
|
229
281
|
};
|
|
230
|
-
const dispatch = async (operation,
|
|
282
|
+
const dispatch = async (operation, id, params, peer, req, res) => {
|
|
231
283
|
switch (operation) {
|
|
232
284
|
case 'send':
|
|
233
285
|
case 'stream': {
|
|
234
|
-
const parsed =
|
|
286
|
+
const parsed = parseSendMessageRequest(params);
|
|
235
287
|
if (parsed === undefined)
|
|
236
288
|
throw new A2ARpcError(ERR_INVALID_PARAMS, 'message is required');
|
|
237
289
|
const raw = partsToText(parsed.message.parts);
|
|
238
290
|
if (raw.trim().length === 0) {
|
|
239
291
|
throw new A2ARpcError(ERR_INVALID_PARAMS, 'message carries no readable content');
|
|
240
292
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
artifactId: 'rejected',
|
|
246
|
-
parts: [{
|
|
247
|
-
kind: 'text',
|
|
248
|
-
text: `context ${activation.contextId} exceeded ${config.maxContextTurns} turns; `
|
|
249
|
-
+ 'start a new context or raise maxContextTurns',
|
|
250
|
-
}],
|
|
251
|
-
}]);
|
|
252
|
-
sendJson(res, 200, jsonRpcResult(id, renderTask(rejected, dialect)));
|
|
253
|
-
return;
|
|
293
|
+
if (raw.length > config.maxInputChars)
|
|
294
|
+
throw new A2ARpcError(ERR_INVALID_PARAMS, 'message exceeds input limit', { httpStatus: 413 });
|
|
295
|
+
if (operation === 'stream' && streams.size >= config.maxConcurrentTasks * config.maxStreamsPerTask) {
|
|
296
|
+
throw new A2ARpcError(ERR_INVALID_REQUEST, 'stream capacity reached', { httpStatus: 429 });
|
|
254
297
|
}
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
298
|
+
const release = deps.reserveTask(peer);
|
|
299
|
+
let handedOff = false;
|
|
300
|
+
let selected;
|
|
301
|
+
try {
|
|
302
|
+
// A message may address a context directly, or name a task and let the
|
|
303
|
+
// server infer the context from it. A task that already settled cannot
|
|
304
|
+
// take further messages, which the spec spells UnsupportedOperation.
|
|
305
|
+
const activation = parsed.message.taskId === undefined
|
|
306
|
+
? await resolveContext(parsed.message.contextId, peer)
|
|
307
|
+
: continueTask(parsed.message.taskId, peer);
|
|
308
|
+
selected = activation;
|
|
309
|
+
if (activation.slots.size > 0)
|
|
310
|
+
throw new A2ARpcError(ERR_INVALID_REQUEST, 'context already has an active task', { httpStatus: 409 });
|
|
311
|
+
const turn = deps.turns.track(activation.contextId);
|
|
312
|
+
// The owner's caretaker context is long-lived by design (scheduled
|
|
313
|
+
// briefing upkeep); the visitor turn cap does not apply to it.
|
|
314
|
+
if (peer !== 'owner' && turn > config.maxContextTurns) {
|
|
315
|
+
const rejected = deps.taskSnapshot(activation, { taskId: 'rejected' }, 'TASK_STATE_REJECTED', [{
|
|
316
|
+
artifactId: 'rejected',
|
|
317
|
+
parts: [{
|
|
318
|
+
text: `context ${activation.contextId} exceeded ${config.maxContextTurns} turns; `
|
|
319
|
+
+ 'start a new context or raise maxContextTurns',
|
|
320
|
+
mediaType: 'text/plain',
|
|
321
|
+
}],
|
|
322
|
+
}]);
|
|
323
|
+
sendJson(res, 200, jsonRpcResult(id, { task: rejected }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const slot = await deps.submit(activation, filterInbound(raw), peer);
|
|
327
|
+
slot.onSettled(release);
|
|
328
|
+
handedOff = true;
|
|
329
|
+
if (operation === 'stream') {
|
|
330
|
+
openStream(res, id, slot, activation);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// v1.0 inverted the send-mode knob: operations BLOCK by default and a
|
|
334
|
+
// client opts out with `returnImmediately`. The deployment's `sendMode`
|
|
335
|
+
// is only the default for a client that expresses no preference.
|
|
336
|
+
//
|
|
337
|
+
// The spec still lets a server answer a blocking request with a
|
|
338
|
+
// non-terminal task, which is exactly what the timeout below does: the
|
|
339
|
+
// task keeps running and the peer polls.
|
|
340
|
+
const requested = parsed.configuration?.returnImmediately;
|
|
341
|
+
const shouldBlock = requested === undefined ? config.sendMode === 'block' : !requested;
|
|
342
|
+
const settlement = shouldBlock
|
|
343
|
+
? await withTimeout(slot.settled, config.blockTimeoutMs)
|
|
344
|
+
: undefined;
|
|
345
|
+
const task = settlement === undefined
|
|
346
|
+
// Not an error: the peer polls GetTask or subscribes, and the task
|
|
347
|
+
// keeps running. Erroring here would discard real work.
|
|
348
|
+
? deps.taskSnapshot(activation, slot, 'TASK_STATE_WORKING', [])
|
|
349
|
+
: deps.taskSnapshot(activation, slot, settlement.state, redactArtifacts(settlement.artifacts), settlement.stopReason);
|
|
350
|
+
// v1.0 wraps the result: SendMessage answers with a task OR a message,
|
|
351
|
+
// and the member name is what tells them apart.
|
|
352
|
+
sendJson(res, 200, jsonRpcResult(id, { task }));
|
|
258
353
|
return;
|
|
259
354
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// reject this if the task is long-running"), which is exactly what the
|
|
267
|
-
// timeout below does: the task keeps running and the peer polls.
|
|
268
|
-
const requested = parsed.configuration?.blocking;
|
|
269
|
-
const shouldBlock = requested ?? config.sendMode === 'block';
|
|
270
|
-
const settlement = shouldBlock
|
|
271
|
-
? await withTimeout(slot.settled, config.blockTimeoutMs)
|
|
272
|
-
: undefined;
|
|
273
|
-
const task = settlement === undefined
|
|
274
|
-
// Not an error: the peer polls tasks/get or resubscribes, and the task
|
|
275
|
-
// keeps running. Erroring here would discard real work.
|
|
276
|
-
? deps.taskSnapshot(activation, slot, 'working', [])
|
|
277
|
-
: deps.taskSnapshot(activation, slot, settlement.state, redactArtifacts(settlement.artifacts), settlement.stopReason);
|
|
278
|
-
sendJson(res, 200, jsonRpcResult(id, renderTask(task, dialect)));
|
|
279
|
-
return;
|
|
355
|
+
finally {
|
|
356
|
+
if (selected)
|
|
357
|
+
deps.releaseActivation(selected);
|
|
358
|
+
if (!handedOff)
|
|
359
|
+
release();
|
|
360
|
+
}
|
|
280
361
|
}
|
|
281
362
|
case 'get': {
|
|
282
|
-
const task = readTask(params?.['
|
|
283
|
-
sendJson(res, 200, jsonRpcResult(id,
|
|
363
|
+
const task = readTask(params?.['id'], peer);
|
|
364
|
+
sendJson(res, 200, jsonRpcResult(id, task));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
case 'list': {
|
|
368
|
+
sendJson(res, 200, jsonRpcResult(id, listTasks(parseListTasksParams(params), peer)));
|
|
284
369
|
return;
|
|
285
370
|
}
|
|
286
371
|
case 'cancel': {
|
|
287
|
-
const { activation, slot } = requireSlot(params?.['
|
|
288
|
-
if (slot.done)
|
|
289
|
-
throw new A2ARpcError(ERR_TASK_NOT_CANCELABLE, 'task already reached a terminal state');
|
|
372
|
+
const { activation, slot } = requireSlot(params?.['id'], peer);
|
|
373
|
+
if (slot.done) {
|
|
374
|
+
throw new A2ARpcError(ERR_TASK_NOT_CANCELABLE, 'task already reached a terminal state', { reason: 'TASK_NOT_CANCELABLE', metadata: { taskId: slot.taskId } });
|
|
375
|
+
}
|
|
290
376
|
deps.cancel(activation, slot);
|
|
291
|
-
const task = deps.taskSnapshot(activation, slot, '
|
|
292
|
-
sendJson(res, 200, jsonRpcResult(id,
|
|
377
|
+
const task = deps.taskSnapshot(activation, slot, 'TASK_STATE_CANCELED', []);
|
|
378
|
+
sendJson(res, 200, jsonRpcResult(id, task));
|
|
293
379
|
return;
|
|
294
380
|
}
|
|
295
|
-
case '
|
|
296
|
-
const rawTaskId = params?.['
|
|
381
|
+
case 'subscribe': {
|
|
382
|
+
const rawTaskId = params?.['id'];
|
|
297
383
|
const live = findSlot(rawTaskId, peer);
|
|
298
384
|
if (live !== undefined) {
|
|
299
|
-
openStream(res, id,
|
|
385
|
+
openStream(res, id, live.slot, live.activation);
|
|
300
386
|
return;
|
|
301
387
|
}
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
388
|
+
// Settled, or never existed. readTask separates the two, and a settled
|
|
389
|
+
// task is refused rather than streamed: v1.0 states a subscription to a
|
|
390
|
+
// terminal task is an UnsupportedOperation, and the outcome is still
|
|
391
|
+
// one GetTask away.
|
|
305
392
|
const task = readTask(rawTaskId, peer);
|
|
306
|
-
|
|
307
|
-
return;
|
|
393
|
+
throw unsupportedOperation(`task ${task.id} is in terminal state ${task.status.state}; read it with GetTask`);
|
|
308
394
|
}
|
|
309
|
-
case '
|
|
395
|
+
case 'push_create':
|
|
310
396
|
case 'push_get':
|
|
311
397
|
case 'push_list':
|
|
312
398
|
case 'push_delete': {
|
|
313
399
|
// Advertised as unsupported on the card, so a compliant peer never
|
|
314
400
|
// reaches this; answering with the spec's own code keeps one that does
|
|
315
401
|
// from guessing.
|
|
316
|
-
|
|
402
|
+
if (config.push.enabled) {
|
|
403
|
+
throw unsupportedOperation('push notifications are not enabled on this deployment');
|
|
404
|
+
}
|
|
405
|
+
throw new A2ARpcError(ERR_PUSH_NOT_SUPPORTED, 'push notifications are not enabled on this deployment', { reason: 'PUSH_NOTIFICATION_NOT_SUPPORTED' });
|
|
406
|
+
}
|
|
407
|
+
case 'extended_card': {
|
|
408
|
+
const extended = deps.extendedCardFor(req.headers.host);
|
|
409
|
+
if (extended === undefined) {
|
|
410
|
+
throw new A2ARpcError(ERR_EXTENDED_CARD_NOT_CONFIGURED, 'this deployment declares no extended agent card', { reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED' });
|
|
411
|
+
}
|
|
412
|
+
sendJson(res, 200, jsonRpcResult(id, extended));
|
|
413
|
+
return;
|
|
317
414
|
}
|
|
318
415
|
default:
|
|
319
|
-
throw new A2ARpcError(ERR_METHOD_NOT_FOUND, `unsupported operation: ${operation}`);
|
|
416
|
+
throw new A2ARpcError(ERR_METHOD_NOT_FOUND, `unsupported operation: ${String(operation)}`);
|
|
320
417
|
}
|
|
321
418
|
};
|
|
322
|
-
/**
|
|
323
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Resolve the context that owns a task a message continues.
|
|
421
|
+
*
|
|
422
|
+
* A task here spans one submitted message, so "continuing" one means adding a
|
|
423
|
+
* message to the SAME context, not reopening the same task id. A settled task
|
|
424
|
+
* is refused outright, which is the rule v1.0 states for messages addressed to
|
|
425
|
+
* a terminal task.
|
|
426
|
+
* @param rawTaskId - the `taskId` the message carried.
|
|
427
|
+
* @param peer - the authenticated identity.
|
|
428
|
+
* @returns the owning context.
|
|
429
|
+
*/
|
|
430
|
+
const continueTask = (rawTaskId, peer) => {
|
|
431
|
+
const live = findSlot(rawTaskId, peer);
|
|
432
|
+
if (live !== undefined)
|
|
433
|
+
return live.activation;
|
|
434
|
+
const settled = readTask(rawTaskId, peer);
|
|
435
|
+
throw unsupportedOperation(`task ${settled.id} is in terminal state ${settled.status.state} `
|
|
436
|
+
+ 'and cannot accept further messages; send to its contextId instead');
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* List the tasks this peer may see, newest first.
|
|
440
|
+
*
|
|
441
|
+
* Scope includes live slots and retained checkpoints, always peer-owned.
|
|
442
|
+
* @param filters - the parsed `ListTasks` parameters.
|
|
443
|
+
* @param peer - the authenticated identity.
|
|
444
|
+
* @returns one page of results plus its cursor.
|
|
445
|
+
*/
|
|
446
|
+
const listTasks = (filters, peer) => {
|
|
447
|
+
const collected = [];
|
|
448
|
+
for (const activation of deps.contexts.values()) {
|
|
449
|
+
if (activation.peer !== peer)
|
|
450
|
+
continue;
|
|
451
|
+
if (filters.contextId !== undefined && activation.contextId !== filters.contextId)
|
|
452
|
+
continue;
|
|
453
|
+
for (const slot of activation.slots.values())
|
|
454
|
+
collected.push(liveTask(activation, slot));
|
|
455
|
+
for (const projected of deps.readProjectedTasks?.(activation) ?? []) {
|
|
456
|
+
// A live slot is authoritative over its own projected row.
|
|
457
|
+
if (activation.slots.has(projected.id))
|
|
458
|
+
continue;
|
|
459
|
+
collected.push({ ...projected, artifacts: redactArtifacts(projected.artifacts ?? []) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const existing = new Set(collected.map(task => task.id));
|
|
463
|
+
for (const task of deps.retainedTasks(peer)) {
|
|
464
|
+
if (!existing.has(task.id) && (filters.contextId === undefined || task.contextId === filters.contextId)) {
|
|
465
|
+
collected.push({ ...task, artifacts: redactArtifacts(task.artifacts ?? []) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const matching = collected
|
|
469
|
+
.filter(task => filters.status === undefined || task.status.state === filters.status)
|
|
470
|
+
.filter(task => filters.statusTimestampAfter === undefined
|
|
471
|
+
|| task.status.timestamp >= filters.statusTimestampAfter)
|
|
472
|
+
// Newest first, with the id as a tiebreak so the cursor below is total.
|
|
473
|
+
.sort((a, b) => a.status.timestamp === b.status.timestamp
|
|
474
|
+
? (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
|
|
475
|
+
: (a.status.timestamp < b.status.timestamp ? 1 : -1));
|
|
476
|
+
const after = decodeCursor(filters.pageToken);
|
|
477
|
+
const remaining = after === undefined
|
|
478
|
+
? matching
|
|
479
|
+
: matching.filter(task => task.status.timestamp < after.timestamp
|
|
480
|
+
|| (task.status.timestamp === after.timestamp && task.id < after.taskId));
|
|
481
|
+
const pageSize = filters.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
482
|
+
const page = remaining.slice(0, pageSize);
|
|
483
|
+
const last = page[page.length - 1];
|
|
484
|
+
const more = remaining.length > page.length;
|
|
485
|
+
return {
|
|
486
|
+
// `includeArtifacts` defaults to false, and the spec is explicit that the
|
|
487
|
+
// member must then be ABSENT rather than an empty array.
|
|
488
|
+
tasks: page.map((task) => {
|
|
489
|
+
if (filters.includeArtifacts === true)
|
|
490
|
+
return task;
|
|
491
|
+
const { artifacts: _artifacts, ...rest } = task;
|
|
492
|
+
return rest;
|
|
493
|
+
}),
|
|
494
|
+
// Always present; empty string is how "no more pages" is spelled.
|
|
495
|
+
nextPageToken: more && last !== undefined ? encodeCursor(last) : '',
|
|
496
|
+
pageSize,
|
|
497
|
+
totalSize: matching.length,
|
|
498
|
+
};
|
|
499
|
+
};
|
|
500
|
+
/** Open an SSE stream and push this task's transitions until it settles. */
|
|
501
|
+
const openStream = (res, id, slot, activation) => {
|
|
502
|
+
if ([...streams].filter(channel => channel.taskId === slot.taskId).length >= config.maxStreamsPerTask) {
|
|
503
|
+
throw new A2ARpcError(ERR_INVALID_REQUEST, 'task subscription limit reached', { httpStatus: 429 });
|
|
504
|
+
}
|
|
324
505
|
res.writeHead(200, {
|
|
325
506
|
'content-type': 'text/event-stream',
|
|
326
507
|
'cache-control': 'no-cache',
|
|
327
508
|
connection: 'keep-alive',
|
|
509
|
+
'x-accel-buffering': 'no',
|
|
328
510
|
});
|
|
329
511
|
const channel = {
|
|
330
|
-
res, id,
|
|
512
|
+
res, id, taskId: slot.taskId, contextId: activation.contextId, cleanup: () => { },
|
|
331
513
|
};
|
|
332
514
|
streams.add(channel);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
515
|
+
let unsubscribe = () => { };
|
|
516
|
+
const cleanup = () => {
|
|
517
|
+
streams.delete(channel);
|
|
518
|
+
clearInterval(heartbeat);
|
|
519
|
+
unsubscribe();
|
|
520
|
+
res.off('close', cleanup);
|
|
521
|
+
res.off('error', cleanup);
|
|
522
|
+
};
|
|
523
|
+
const write = (frame) => {
|
|
524
|
+
if (!streams.has(channel) || res.destroyed) {
|
|
525
|
+
cleanup();
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
if (res.writableLength + Buffer.byteLength(frame) > config.maxStreamBufferBytes) {
|
|
529
|
+
cleanup();
|
|
530
|
+
res.destroy();
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
res.write(frame);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
cleanup();
|
|
538
|
+
res.destroy();
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
return true;
|
|
542
|
+
};
|
|
543
|
+
const heartbeat = setInterval(() => {
|
|
544
|
+
if (res.writableLength === 0)
|
|
545
|
+
write(': heartbeat\n\n');
|
|
546
|
+
}, config.streamHeartbeatMs);
|
|
547
|
+
heartbeat.unref();
|
|
548
|
+
channel.cleanup = cleanup;
|
|
549
|
+
res.once('close', cleanup);
|
|
550
|
+
res.once('error', cleanup);
|
|
551
|
+
// v1.0 requires the Task object itself as the first frame, so a subscriber
|
|
552
|
+
// never has to call GetTask to learn where the task stood when it joined.
|
|
553
|
+
if (!write(sseFrame(id, streamTask(liveTask(activation, slot)))))
|
|
554
|
+
return;
|
|
555
|
+
unsubscribe = slot.onSettled((settlement) => {
|
|
341
556
|
if (!streams.has(channel))
|
|
342
557
|
return;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
558
|
+
redactArtifacts(settlement.artifacts).forEach((artifact, index) => {
|
|
559
|
+
write(sseFrame(id, streamArtifactUpdate({
|
|
560
|
+
taskId: slot.taskId,
|
|
561
|
+
contextId: activation.contextId,
|
|
562
|
+
artifact,
|
|
563
|
+
index,
|
|
564
|
+
lastChunk: true,
|
|
565
|
+
})));
|
|
566
|
+
});
|
|
567
|
+
write(sseFrame(id, streamStatusUpdate({
|
|
348
568
|
taskId: slot.taskId,
|
|
349
569
|
contextId: activation.contextId,
|
|
350
570
|
status: { state: settlement.state, timestamp: nowIso() },
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
571
|
+
})));
|
|
572
|
+
cleanup();
|
|
573
|
+
// v1.0 dropped the `final` flag: closing the stream IS the terminal
|
|
574
|
+
// signal, so the close must follow the terminal status immediately.
|
|
354
575
|
res.end();
|
|
355
576
|
});
|
|
356
577
|
};
|
|
357
578
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
579
|
+
* Serve the monitoring surface: GET `{basePath}/admin/contexts` and
|
|
580
|
+
* `{basePath}/admin/contexts/<contextId>`.
|
|
581
|
+
*
|
|
582
|
+
* The carrier must be local IPC and the request must carry the admin token.
|
|
583
|
+
* The body of a failure is
|
|
584
|
+
* plain JSON, not a JSON-RPC envelope — this surface is not A2A.
|
|
363
585
|
*/
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
'content-type': '
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
586
|
+
const serveAdmin = async (req, res) => {
|
|
587
|
+
const fail = (status, code, message) => {
|
|
588
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
589
|
+
res.end(JSON.stringify({ error: { code, message } }));
|
|
590
|
+
};
|
|
591
|
+
try {
|
|
592
|
+
if (deps.resolveOwnerSecret === undefined || deps.adminSnapshot === undefined) {
|
|
593
|
+
fail(404, 'not_mounted', 'the admin surface is not configured on this deployment');
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (req.method !== 'GET') {
|
|
597
|
+
res.setHeader('allow', 'GET');
|
|
598
|
+
fail(405, 'method_not_allowed', 'the admin surface is read-only');
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
// This handler is registered exclusively on the local IPC listener.
|
|
602
|
+
const expected = await deps.resolveOwnerSecret?.();
|
|
603
|
+
if (expected === undefined) {
|
|
604
|
+
fail(401, 'admin_token_unconfigured', 'admin.tokenEnv resolves to no credential');
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const presented = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1];
|
|
608
|
+
if (presented === undefined || !constantTimeEqual(presented, expected)) {
|
|
609
|
+
fail(401, 'unauthorized', 'a valid admin token is required');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const snapshot = deps.adminSnapshot();
|
|
613
|
+
const wanted = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname
|
|
614
|
+
.replace(/\/+$/, '')
|
|
615
|
+
.split('/').filter(Boolean).slice(-1)[0] ?? '');
|
|
616
|
+
// `contexts` itself lists everything; a contextId narrows to one context.
|
|
617
|
+
const view = wanted === '' || wanted === 'contexts'
|
|
618
|
+
? snapshot
|
|
619
|
+
: {
|
|
620
|
+
generatedAt: snapshot.generatedAt,
|
|
621
|
+
contexts: snapshot.contexts.filter(context => context.contextId === wanted),
|
|
622
|
+
};
|
|
623
|
+
if (view.contexts.length === 0 && wanted !== '' && wanted !== 'contexts') {
|
|
624
|
+
fail(404, 'not_found', `no resident context "${wanted}"`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
628
|
+
res.end(JSON.stringify(view));
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
deps.logger.error(`a2a: admin request failed: ${String(error)}`);
|
|
632
|
+
fail(500, 'internal', 'internal error');
|
|
633
|
+
}
|
|
378
634
|
};
|
|
379
635
|
const closeStreams = () => {
|
|
380
636
|
for (const channel of streams) {
|
|
637
|
+
channel.cleanup();
|
|
381
638
|
try {
|
|
382
|
-
channel.res.write(sseFrame(channel.id,
|
|
383
|
-
kind: 'status-update',
|
|
639
|
+
channel.res.write(sseFrame(channel.id, streamStatusUpdate({
|
|
384
640
|
taskId: channel.taskId,
|
|
385
641
|
contextId: A2AContextId(channel.contextId),
|
|
386
|
-
status: { state: '
|
|
387
|
-
|
|
388
|
-
}, channel.dialect)));
|
|
642
|
+
status: { state: 'TASK_STATE_CANCELED', timestamp: nowIso() },
|
|
643
|
+
})));
|
|
389
644
|
channel.res.end();
|
|
390
645
|
}
|
|
391
646
|
catch {
|
|
@@ -394,7 +649,34 @@ export function createRouter(deps) {
|
|
|
394
649
|
}
|
|
395
650
|
streams.clear();
|
|
396
651
|
};
|
|
397
|
-
return { serveCard, serveRpc, closeStreams };
|
|
652
|
+
return { serveCard, serveRpc, serveOwnerRpc: (req, res) => serveRpc(req, res, true), serveAdmin, closeStreams };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Encode a page cursor.
|
|
656
|
+
*
|
|
657
|
+
* The token is opaque by contract, so it is base64url of the sort key that
|
|
658
|
+
* produced it — a real cursor rather than an offset, which is what keeps a page
|
|
659
|
+
* boundary stable while tasks are being added ahead of it.
|
|
660
|
+
* @param task - the last task on the page just returned.
|
|
661
|
+
* @returns the token a peer sends back as `pageToken`.
|
|
662
|
+
*/
|
|
663
|
+
function encodeCursor(task) {
|
|
664
|
+
return Buffer.from(`${task.status.timestamp}|${task.id}`, 'utf8').toString('base64url');
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Decode a page cursor.
|
|
668
|
+
* @param token - the `pageToken` a peer sent, if any.
|
|
669
|
+
* @returns the sort key to resume after, or undefined for the first page.
|
|
670
|
+
* @throws {A2ARpcError} when the token is not one this server issued.
|
|
671
|
+
*/
|
|
672
|
+
function decodeCursor(token) {
|
|
673
|
+
if (token === undefined)
|
|
674
|
+
return undefined;
|
|
675
|
+
const decoded = Buffer.from(token, 'base64url').toString('utf8');
|
|
676
|
+
const split = decoded.lastIndexOf('|');
|
|
677
|
+
if (split <= 0)
|
|
678
|
+
throw new A2ARpcError(ERR_INVALID_PARAMS, 'pageToken is not a cursor this server issued');
|
|
679
|
+
return { timestamp: decoded.slice(0, split), taskId: decoded.slice(split + 1) };
|
|
398
680
|
}
|
|
399
681
|
/**
|
|
400
682
|
* Scrub credential-shaped text from artifacts before they leave the process.
|
|
@@ -404,7 +686,7 @@ export function createRouter(deps) {
|
|
|
404
686
|
function redactArtifacts(artifacts) {
|
|
405
687
|
return artifacts.map(artifact => ({
|
|
406
688
|
...artifact,
|
|
407
|
-
parts: artifact.parts.map(part =>
|
|
689
|
+
parts: artifact.parts.map(part => 'text' in part ? { ...part, text: redactOutbound(part.text) } : part),
|
|
408
690
|
}));
|
|
409
691
|
}
|
|
410
692
|
/**
|