@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 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 transport keepalive comments
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 the SSE comment `: heartbeat\n\n` every
77
- `heartbeat.intervalMs` (default 15000 ms). The default is disabled. Intervals
78
- must be positive integers no greater than 2147483647 ms (Node's timer limit).
79
- This covers `SendStreamingMessage` / `SubscribeToTask` and their legacy
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
- Comments contain no JSON payload and are ignored by SSE event parsers. They
85
- keep an otherwise idle transport active without changing task state, history,
86
- results, or event IDs. A client whose idle timeout resets only on parsed JSON
87
- messages will still time out: reset that transport timer on received bytes, or
88
- configure a longer timeout. Reverse proxies must forward streaming bytes without
89
- buffering for keepalives to reach the client.
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((_req, res, next) => {
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
- res.write(': heartbeat\n\n');
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.18",
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",