@amaster.ai/dsh-a2a 0.1.18 → 0.1.19

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.
Files changed (3) hide show
  1. package/README.md +21 -11
  2. package/lib/server.js +35 -6
  3. package/package.json +1 -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 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>
@@ -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,
package/lib/server.js CHANGED
@@ -25,6 +25,18 @@ export async function startA2aServer(options) {
25
25
  throw new Error('[dsh-a2a] heartbeat.intervalMs must be an integer from 1 to 2147483647');
26
26
  }
27
27
  const stopHeartbeats = new Set();
28
+ const heartbeatTask = new AsyncLocalStorage();
29
+ async function* observeTask(stream) {
30
+ for await (const event of stream) {
31
+ const state = heartbeatTask.getStore();
32
+ const payload = event.payload;
33
+ if (state && payload && payload.$case !== 'message') {
34
+ state.taskId = payload.$case === 'task' ? payload.value.id : payload.value.taskId;
35
+ state.contextId = payload.value.contextId;
36
+ }
37
+ yield event;
38
+ }
39
+ }
28
40
  const base = options.basePath.replace(/\/$/, '');
29
41
  const publicUrl = (options.card.publicUrl ?? `http://${options.host}:${options.port}`).replace(/\/$/, '');
30
42
  const interfaces = [
@@ -192,7 +204,7 @@ export async function startA2aServer(options) {
192
204
  try {
193
205
  if (!release)
194
206
  await beforeWorkingTaskMessage(args[0].message, args[1]);
195
- const stream = super.sendMessageStream(...args);
207
+ const stream = observeTask(super.sendMessageStream(...args));
196
208
  try {
197
209
  if (release) {
198
210
  const first = await approvalReply.run(true, () => stream.next());
@@ -213,10 +225,10 @@ export async function startA2aServer(options) {
213
225
  async *resubscribe(...args) {
214
226
  const task = taskBuses.get(args[0].id);
215
227
  if (!task) {
216
- yield* super.resubscribe(...args);
228
+ yield* observeTask(super.resubscribe(...args));
217
229
  return;
218
230
  }
219
- const stream = super.resubscribe(...args);
231
+ const stream = observeTask(super.resubscribe(...args));
220
232
  try {
221
233
  const replay = task.replay.slice();
222
234
  const last = replay.at(-1);
@@ -309,7 +321,9 @@ export async function startA2aServer(options) {
309
321
  // raise the ceiling here — body-parser skips re-parsing an already-read body.
310
322
  app.use(express.json({ limit: '16mb' }));
311
323
  if (options.heartbeat?.enabled) {
312
- app.use((_req, res, next) => {
324
+ app.use((req, res, next) => {
325
+ const task = {};
326
+ const legacy = req.body?.method === 'message/stream' || req.body?.method === 'tasks/resubscribe';
313
327
  const flushHeaders = res.flushHeaders;
314
328
  // Wait for the SDK to commit SSE headers: validation errors stay JSON.
315
329
  res.flushHeaders = function () {
@@ -323,7 +337,22 @@ export async function startA2aServer(options) {
323
337
  if (res.writableNeedDrain)
324
338
  return;
325
339
  try {
326
- res.write(': heartbeat\n\n');
340
+ if (!task.taskId || !task.contextId)
341
+ return;
342
+ const update = {
343
+ taskId: task.taskId,
344
+ contextId: task.contextId,
345
+ artifact: { artifactId: 'heartbeat', name: 'heartbeat', parts: [] },
346
+ append: false,
347
+ lastChunk: false,
348
+ };
349
+ res.write(`data: ${JSON.stringify({
350
+ jsonrpc: '2.0',
351
+ id: req.body.id ?? null,
352
+ result: legacy
353
+ ? { kind: 'artifact-update', ...update }
354
+ : { artifactUpdate: update },
355
+ })}\n\n`);
327
356
  }
328
357
  catch (error) {
329
358
  stop();
@@ -344,7 +373,7 @@ export async function startA2aServer(options) {
344
373
  res.off('close', stop);
345
374
  res.off('error', stop);
346
375
  };
347
- next();
376
+ heartbeatTask.run(task, next);
348
377
  });
349
378
  }
350
379
  const cardHandler = agentCardHandler({
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.19",
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",