@amaster.ai/dsh-a2a 0.1.18 → 0.1.20
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.md +22 -12
- package/lib/legacy-stream.js +100 -0
- package/lib/server.js +37 -7
- package/lib/types/legacy-stream.d.ts +4 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
26
26
|
port: 41241
|
|
27
27
|
basePath: /a2a
|
|
28
28
|
heartbeat:
|
|
29
|
-
enabled: false # opt in to
|
|
29
|
+
enabled: false # opt in to SSE heartbeats
|
|
30
30
|
intervalMs: 15000 # positive integer, at most 2147483647
|
|
31
31
|
cwd: /srv/agent-workspaces
|
|
32
32
|
uploadsDir: '' # file-part upload root; empty = <OS temp>/dsh-a2a-uploads/<date>
|
|
@@ -53,7 +53,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
53
53
|
## Endpoints
|
|
54
54
|
|
|
55
55
|
- `GET /.well-known/agent-card.json` — agent card: A2A 1.0 shape for `A2A-Version: 1.0` clients, the 0.3 shape for headerless clients (the legacy `/.well-known/agent.json` path is served as an alias)
|
|
56
|
-
- `POST <basePath>/` — JSON-RPC. v1 methods: `SendMessage` (blocking by default, `configuration.returnImmediately: true` returns after the first event), `SendStreamingMessage` (SSE), `GetTask`, `ListTasks` (filter + cursor pagination), `CancelTask`, `SubscribeToTask` (the current task is the first event; the bus stays alive while the task is interrupted, so interrupted tasks can be re-followed live). v0.3 spellings (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`, `tasks/resubscribe`) keep working through the compat layer. Legacy `tasks/get` also accepts `contextId` without `id` on this path or root `/`, returning the latest stored task or `result: null` when absent.
|
|
56
|
+
- `POST <basePath>/` — JSON-RPC. v1 methods: `SendMessage` (blocking by default, `configuration.returnImmediately: true` returns after the first event), `SendStreamingMessage` (SSE), `GetTask`, `ListTasks` (filter + cursor pagination), `CancelTask`, `SubscribeToTask` (the current task is the first event; the bus stays alive while the task is interrupted, so interrupted tasks can be re-followed live). v0.3 spellings (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`, `tasks/resubscribe`) keep working through the compat layer. Legacy SSE status updates that end an interaction carry `final: true`, including `input-required` after a completed turn or an approval wait; this ends the stream without making the task terminal. Resubscribe snapshots keep waiting for future events while a live task bus exists. Legacy `tasks/get` also accepts `contextId` without `id` on this path or root `/`, returning the latest stored task or `result: null` when absent.
|
|
57
57
|
|
|
58
58
|
## Behavior notes
|
|
59
59
|
|
|
@@ -73,20 +73,30 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
73
73
|
|
|
74
74
|
## SSE heartbeats
|
|
75
75
|
|
|
76
|
-
Set `heartbeat.enabled: true` to send
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
Set `heartbeat.enabled: true` to send heartbeats every `heartbeat.intervalMs`
|
|
77
|
+
(default 15000 ms). The default is disabled. Intervals must be positive integers
|
|
78
|
+
no greater than 2147483647 ms (Node's timer limit). This covers
|
|
79
|
+
`SendStreamingMessage` / `SubscribeToTask` and their legacy
|
|
80
80
|
`message/stream` / `tasks/resubscribe` equivalents. The timer starts after the
|
|
81
81
|
SDK commits SSE headers; pre-stream validation and failures retain their JSON
|
|
82
82
|
responses. There is no heartbeat while waiting for the SDK's first stream event.
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
Heartbeats are JSON artifact updates with `artifact.name === "heartbeat"`, so
|
|
85
|
+
clients that refresh their idle timer on parsed JSON messages receive activity
|
|
86
|
+
throughout otherwise idle task streams. Legacy streams receive:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
data: {"jsonrpc":"2.0","id":"<request-id>","result":{"kind":"artifact-update","taskId":"<task-id>","contextId":"<context-id>","artifact":{"artifactId":"heartbeat","name":"heartbeat","parts":[]},"append":false,"lastChunk":false}}
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Native A2A 1.0 streams use `result.artifactUpdate` with the same fields (without
|
|
94
|
+
`kind`). Identifiers come from the current request and streamed task, including
|
|
95
|
+
resubscription requests. Clients should refresh their idle timer and discard
|
|
96
|
+
these empty heartbeat artifacts without displaying content. Heartbeats are sent
|
|
97
|
+
directly on the transport, never persisted as task artifacts or history, and do
|
|
98
|
+
not change task status, completion, or event IDs. Reverse proxies must forward
|
|
99
|
+
streaming bytes without buffering for keepalives to reach the client.
|
|
90
100
|
|
|
91
101
|
Heartbeats are skipped while the response requires drain, with no heartbeat
|
|
92
102
|
queue or catch-up burst. Timers stop on completion, disconnect, response errors,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Extensions } from '@a2a-js/sdk';
|
|
2
|
+
import { LegacyJsonRpcTransportHandler } from '@a2a-js/sdk/compat/v0_3/server';
|
|
3
|
+
import { defaultServerCallContextBuilder, validateVersion, } from '@a2a-js/sdk/server';
|
|
4
|
+
import { UserBuilder } from '@a2a-js/sdk/server/express';
|
|
5
|
+
/** SDK 1.1.0 derives legacy final from task terminality, losing interaction endings. */
|
|
6
|
+
export function legacyStreamHandler(requestHandler) {
|
|
7
|
+
const transport = new LegacyJsonRpcTransportHandler(requestHandler);
|
|
8
|
+
return async (req, res, next) => {
|
|
9
|
+
if (req.method !== 'POST' ||
|
|
10
|
+
req.path !== '/' ||
|
|
11
|
+
!req.is('application/json') ||
|
|
12
|
+
!['message/stream', 'tasks/resubscribe'].includes(req.body?.method)) {
|
|
13
|
+
next();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const errorResponse = (error) => ({
|
|
17
|
+
jsonrpc: '2.0',
|
|
18
|
+
id: req.body?.id ?? null,
|
|
19
|
+
error: LegacyJsonRpcTransportHandler.mapToLegacyJSONRPCError(error),
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
const context = defaultServerCallContextBuilder({
|
|
23
|
+
extensions: Extensions.parseServiceParameter(req.header('X-A2A-Extensions') ?? req.header('A2A-Extensions')),
|
|
24
|
+
user: await UserBuilder.noAuthentication(),
|
|
25
|
+
headers: req.headers,
|
|
26
|
+
requestedVersion: req.header('A2A-Version'),
|
|
27
|
+
});
|
|
28
|
+
validateVersion(context.requestedVersion, await requestHandler.getAgentCard(), 'JSONRPC');
|
|
29
|
+
const response = await transport.handle(req.body, context);
|
|
30
|
+
if (!(Symbol.asyncIterator in response)) {
|
|
31
|
+
res.json(response);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const stream = response;
|
|
35
|
+
try {
|
|
36
|
+
const startStream = () => {
|
|
37
|
+
if (context.activatedExtensions)
|
|
38
|
+
res.setHeader('X-A2A-Extensions', Array.from(context.activatedExtensions));
|
|
39
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
40
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
41
|
+
res.setHeader('Connection', 'keep-alive');
|
|
42
|
+
res.flushHeaders();
|
|
43
|
+
};
|
|
44
|
+
// The SDK always streams resubscribe errors; sends validate before SSE headers.
|
|
45
|
+
if (req.body.method === 'tasks/resubscribe')
|
|
46
|
+
startStream();
|
|
47
|
+
const first = await stream.next();
|
|
48
|
+
if (!res.headersSent)
|
|
49
|
+
startStream();
|
|
50
|
+
let last;
|
|
51
|
+
const write = (event) => {
|
|
52
|
+
last = event;
|
|
53
|
+
const result = event.result;
|
|
54
|
+
// INPUT_REQUIRED closes the SDK queue, including real approval waits.
|
|
55
|
+
// A task snapshot on resubscribe does not close it and is left intact.
|
|
56
|
+
if (result?.kind === 'status-update' && result.status?.state === 'input-required')
|
|
57
|
+
result.final = true;
|
|
58
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
59
|
+
return result?.kind === 'status-update' && result.final;
|
|
60
|
+
};
|
|
61
|
+
if (!first.done && write(first.value))
|
|
62
|
+
return;
|
|
63
|
+
for await (const event of stream) {
|
|
64
|
+
if (res.destroyed)
|
|
65
|
+
break;
|
|
66
|
+
if (write(event))
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// A cold subscription has no live bus: the SDK ends after the snapshot.
|
|
70
|
+
const snapshot = last?.result;
|
|
71
|
+
if (!res.destroyed && snapshot?.kind === 'task')
|
|
72
|
+
write({
|
|
73
|
+
...last,
|
|
74
|
+
result: {
|
|
75
|
+
kind: 'status-update',
|
|
76
|
+
taskId: snapshot.id,
|
|
77
|
+
contextId: snapshot.contextId,
|
|
78
|
+
status: snapshot.status,
|
|
79
|
+
metadata: snapshot.metadata,
|
|
80
|
+
final: true,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await stream.return(undefined);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error('[dsh-a2a] legacy stream failed:', error);
|
|
90
|
+
if (!res.headersSent)
|
|
91
|
+
res.json(errorResponse(error));
|
|
92
|
+
else if (!res.destroyed)
|
|
93
|
+
res.write(`event: error\ndata: ${JSON.stringify(errorResponse(error))}\n\n`);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (!res.writableEnded)
|
|
97
|
+
res.end();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
package/lib/server.js
CHANGED
|
@@ -19,12 +19,25 @@ import { RequestMalformedError } from '@a2a-js/sdk/errors';
|
|
|
19
19
|
import { DefaultExecutionEventBus, DefaultRequestHandler, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/server';
|
|
20
20
|
import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
|
|
21
21
|
import express from 'express';
|
|
22
|
+
import { legacyStreamHandler } from './legacy-stream.js';
|
|
22
23
|
export async function startA2aServer(options) {
|
|
23
24
|
const intervalMs = options.heartbeat?.intervalMs ?? 15_000;
|
|
24
25
|
if (!Number.isInteger(intervalMs) || intervalMs < 1 || intervalMs > 2_147_483_647) {
|
|
25
26
|
throw new Error('[dsh-a2a] heartbeat.intervalMs must be an integer from 1 to 2147483647');
|
|
26
27
|
}
|
|
27
28
|
const stopHeartbeats = new Set();
|
|
29
|
+
const heartbeatTask = new AsyncLocalStorage();
|
|
30
|
+
async function* observeTask(stream) {
|
|
31
|
+
for await (const event of stream) {
|
|
32
|
+
const state = heartbeatTask.getStore();
|
|
33
|
+
const payload = event.payload;
|
|
34
|
+
if (state && payload && payload.$case !== 'message') {
|
|
35
|
+
state.taskId = payload.$case === 'task' ? payload.value.id : payload.value.taskId;
|
|
36
|
+
state.contextId = payload.value.contextId;
|
|
37
|
+
}
|
|
38
|
+
yield event;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
28
41
|
const base = options.basePath.replace(/\/$/, '');
|
|
29
42
|
const publicUrl = (options.card.publicUrl ?? `http://${options.host}:${options.port}`).replace(/\/$/, '');
|
|
30
43
|
const interfaces = [
|
|
@@ -192,7 +205,7 @@ export async function startA2aServer(options) {
|
|
|
192
205
|
try {
|
|
193
206
|
if (!release)
|
|
194
207
|
await beforeWorkingTaskMessage(args[0].message, args[1]);
|
|
195
|
-
const stream = super.sendMessageStream(...args);
|
|
208
|
+
const stream = observeTask(super.sendMessageStream(...args));
|
|
196
209
|
try {
|
|
197
210
|
if (release) {
|
|
198
211
|
const first = await approvalReply.run(true, () => stream.next());
|
|
@@ -213,10 +226,10 @@ export async function startA2aServer(options) {
|
|
|
213
226
|
async *resubscribe(...args) {
|
|
214
227
|
const task = taskBuses.get(args[0].id);
|
|
215
228
|
if (!task) {
|
|
216
|
-
yield* super.resubscribe(...args);
|
|
229
|
+
yield* observeTask(super.resubscribe(...args));
|
|
217
230
|
return;
|
|
218
231
|
}
|
|
219
|
-
const stream = super.resubscribe(...args);
|
|
232
|
+
const stream = observeTask(super.resubscribe(...args));
|
|
220
233
|
try {
|
|
221
234
|
const replay = task.replay.slice();
|
|
222
235
|
const last = replay.at(-1);
|
|
@@ -309,7 +322,9 @@ export async function startA2aServer(options) {
|
|
|
309
322
|
// raise the ceiling here — body-parser skips re-parsing an already-read body.
|
|
310
323
|
app.use(express.json({ limit: '16mb' }));
|
|
311
324
|
if (options.heartbeat?.enabled) {
|
|
312
|
-
app.use((
|
|
325
|
+
app.use((req, res, next) => {
|
|
326
|
+
const task = {};
|
|
327
|
+
const legacy = req.body?.method === 'message/stream' || req.body?.method === 'tasks/resubscribe';
|
|
313
328
|
const flushHeaders = res.flushHeaders;
|
|
314
329
|
// Wait for the SDK to commit SSE headers: validation errors stay JSON.
|
|
315
330
|
res.flushHeaders = function () {
|
|
@@ -323,7 +338,22 @@ export async function startA2aServer(options) {
|
|
|
323
338
|
if (res.writableNeedDrain)
|
|
324
339
|
return;
|
|
325
340
|
try {
|
|
326
|
-
|
|
341
|
+
if (!task.taskId || !task.contextId)
|
|
342
|
+
return;
|
|
343
|
+
const update = {
|
|
344
|
+
taskId: task.taskId,
|
|
345
|
+
contextId: task.contextId,
|
|
346
|
+
artifact: { artifactId: 'heartbeat', name: 'heartbeat', parts: [] },
|
|
347
|
+
append: false,
|
|
348
|
+
lastChunk: false,
|
|
349
|
+
};
|
|
350
|
+
res.write(`data: ${JSON.stringify({
|
|
351
|
+
jsonrpc: '2.0',
|
|
352
|
+
id: req.body.id ?? null,
|
|
353
|
+
result: legacy
|
|
354
|
+
? { kind: 'artifact-update', ...update }
|
|
355
|
+
: { artifactUpdate: update },
|
|
356
|
+
})}\n\n`);
|
|
327
357
|
}
|
|
328
358
|
catch (error) {
|
|
329
359
|
stop();
|
|
@@ -344,7 +374,7 @@ export async function startA2aServer(options) {
|
|
|
344
374
|
res.off('close', stop);
|
|
345
375
|
res.off('error', stop);
|
|
346
376
|
};
|
|
347
|
-
next
|
|
377
|
+
heartbeatTask.run(task, next);
|
|
348
378
|
});
|
|
349
379
|
}
|
|
350
380
|
const cardHandler = agentCardHandler({
|
|
@@ -408,7 +438,7 @@ export async function startA2aServer(options) {
|
|
|
408
438
|
res.sendStatus(404);
|
|
409
439
|
}, contextLookup, rpcHandler);
|
|
410
440
|
}
|
|
411
|
-
app.use(base, contextLookup, rpcHandler);
|
|
441
|
+
app.use(base, contextLookup, legacyStreamHandler(requestHandler), rpcHandler);
|
|
412
442
|
const server = createServer(app);
|
|
413
443
|
await new Promise((resolve, reject) => {
|
|
414
444
|
server.once('error', reject);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type A2ARequestHandler } from '@a2a-js/sdk/server';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
|
+
/** SDK 1.1.0 derives legacy final from task terminality, losing interaction endings. */
|
|
4
|
+
export declare function legacyStreamHandler(requestHandler: A2ARequestHandler): RequestHandler;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amaster.ai/dsh-a2a",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "A2A protocol (JSON-RPC + SSE) server plugin for DeepSeek Harness: expose dsh agents as A2A agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
+
"@a2a-js/sdk-v03": "npm:@a2a-js/sdk@0.3.14",
|
|
64
65
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
65
66
|
"@deepseek-ai/dsh-agent": "0.2.0-rc.2",
|
|
66
67
|
"@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
|