@amaster.ai/dsh-a2a 0.1.17 → 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.
package/README.md CHANGED
@@ -25,6 +25,9 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
25
25
  host: 127.0.0.1 # no auth built in — keep loopback or front with a proxy
26
26
  port: 41241
27
27
  basePath: /a2a
28
+ heartbeat:
29
+ enabled: false # opt in to SSE heartbeats
30
+ intervalMs: 15000 # positive integer, at most 2147483647
28
31
  cwd: /srv/agent-workspaces
29
32
  uploadsDir: '' # file-part upload root; empty = <OS temp>/dsh-a2a-uploads/<date>
30
33
  agent:
@@ -68,6 +71,38 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
68
71
  - **Restart**: persisted task shells survive in Redis/GCS. With `sessionPersistence` composed, a `contextId` already present on disk resumes its dsh session after restart; a new id creates a new session.
69
72
  - **Clear**: the same-process gateway can call `ctx.get('a2aTasks').clearContext(contextId)` before reporting `messages/clear` success. It cancels and drains the live turn, removes the binding and every TaskStore shell for that context, and returns the removed task IDs. The gateway owns the separate session-surface replacement and client-visible history-ID update.
70
73
 
74
+ ## SSE heartbeats
75
+
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
+ `message/stream` / `tasks/resubscribe` equivalents. The timer starts after the
81
+ SDK commits SSE headers; pre-stream validation and failures retain their JSON
82
+ responses. There is no heartbeat while waiting for the SDK's first stream event.
83
+
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.
100
+
101
+ Heartbeats are skipped while the response requires drain, with no heartbeat
102
+ queue or catch-up burst. Timers stop on completion, disconnect, response errors,
103
+ and server disposal. Heartbeats require a responsive event loop; they cannot
104
+ repair event-loop starvation or guarantee delivery during backpressure.
105
+
71
106
  ## Task stores
72
107
 
73
108
  A2A task snapshots retain protocol history and artifacts; [dsh-storage](../dsh-storage) separately records the full conversation. Backends save on task-state transitions, including the complete reply at `input-required`, so token-rate stream events never reach Redis or GCS. While a task is working, in-process history accumulates text deltas for `GetTask` and resubscription; a restart before the next state change can lose those transient deltas.
@@ -84,7 +119,7 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
84
119
 
85
120
  ## Compatibility
86
121
 
87
- Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs (`@deepseek-ai/dsh-{agent,session,llm,attachment}@0.2.0-rc.1` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
122
+ Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs (`@deepseek-ai/dsh-{agent,session,llm,attachment}@0.2.0-rc.2` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
88
123
 
89
124
  ## License
90
125
 
package/lib/index.js CHANGED
@@ -31,6 +31,10 @@ export const Config = Schema.object({
31
31
  host: Schema.string().default('127.0.0.1'),
32
32
  port: Schema.natural().default(41241),
33
33
  basePath: Schema.string().default('/a2a'),
34
+ heartbeat: Schema.object({
35
+ enabled: Schema.boolean().default(false),
36
+ intervalMs: Schema.natural().min(1).max(2_147_483_647).default(15_000),
37
+ }),
34
38
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
35
39
  cwd: Schema.string().default(process.cwd()),
36
40
  /**
@@ -131,6 +135,7 @@ export function apply(ctx, config) {
131
135
  host: config.host,
132
136
  port: config.port,
133
137
  basePath: config.basePath,
138
+ heartbeat: config.heartbeat,
134
139
  card: {
135
140
  name: config.card.name,
136
141
  description: config.card.description,
package/lib/server.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * through the SDK's opt-in legacyCompat layer
10
10
  *
11
11
  * The SSE framing and error envelopes are the SDK's; this layer only binds
12
- * the server and builds the card.
12
+ * the server, builds the card, and optionally adds transport heartbeats.
13
13
  */
14
14
  import { AsyncLocalStorage } from 'node:async_hooks';
15
15
  import { createServer } from 'node:http';
@@ -20,6 +20,23 @@ import { DefaultExecutionEventBus, DefaultRequestHandler, ServerCallContext, STA
20
20
  import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
21
21
  import express from 'express';
22
22
  export async function startA2aServer(options) {
23
+ const intervalMs = options.heartbeat?.intervalMs ?? 15_000;
24
+ if (!Number.isInteger(intervalMs) || intervalMs < 1 || intervalMs > 2_147_483_647) {
25
+ throw new Error('[dsh-a2a] heartbeat.intervalMs must be an integer from 1 to 2147483647');
26
+ }
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
+ }
23
40
  const base = options.basePath.replace(/\/$/, '');
24
41
  const publicUrl = (options.card.publicUrl ?? `http://${options.host}:${options.port}`).replace(/\/$/, '');
25
42
  const interfaces = [
@@ -187,7 +204,7 @@ export async function startA2aServer(options) {
187
204
  try {
188
205
  if (!release)
189
206
  await beforeWorkingTaskMessage(args[0].message, args[1]);
190
- const stream = super.sendMessageStream(...args);
207
+ const stream = observeTask(super.sendMessageStream(...args));
191
208
  try {
192
209
  if (release) {
193
210
  const first = await approvalReply.run(true, () => stream.next());
@@ -208,10 +225,10 @@ export async function startA2aServer(options) {
208
225
  async *resubscribe(...args) {
209
226
  const task = taskBuses.get(args[0].id);
210
227
  if (!task) {
211
- yield* super.resubscribe(...args);
228
+ yield* observeTask(super.resubscribe(...args));
212
229
  return;
213
230
  }
214
- const stream = super.resubscribe(...args);
231
+ const stream = observeTask(super.resubscribe(...args));
215
232
  try {
216
233
  const replay = task.replay.slice();
217
234
  const last = replay.at(-1);
@@ -303,6 +320,62 @@ export async function startA2aServer(options) {
303
320
  // The SDK's jsonRpcHandler parses bodies with express.json()'s 100kb default;
304
321
  // raise the ceiling here — body-parser skips re-parsing an already-read body.
305
322
  app.use(express.json({ limit: '16mb' }));
323
+ if (options.heartbeat?.enabled) {
324
+ app.use((req, res, next) => {
325
+ const task = {};
326
+ const legacy = req.body?.method === 'message/stream' || req.body?.method === 'tasks/resubscribe';
327
+ const flushHeaders = res.flushHeaders;
328
+ // Wait for the SDK to commit SSE headers: validation errors stay JSON.
329
+ res.flushHeaders = function () {
330
+ flushHeaders.call(this);
331
+ if (!String(res.getHeader('content-type')).startsWith('text/event-stream') || timer)
332
+ return;
333
+ timer = setInterval(() => {
334
+ if (res.destroyed || res.writableEnded)
335
+ return stop();
336
+ // A slow client gets no additional heartbeat backlog; retry next tick.
337
+ if (res.writableNeedDrain)
338
+ return;
339
+ try {
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`);
356
+ }
357
+ catch (error) {
358
+ stop();
359
+ console.error('[dsh-a2a] heartbeat write failed:', error);
360
+ }
361
+ }, intervalMs);
362
+ timer.unref();
363
+ stopHeartbeats.add(stop);
364
+ res.once('finish', stop);
365
+ res.once('close', stop);
366
+ res.once('error', stop);
367
+ };
368
+ let timer;
369
+ const stop = () => {
370
+ clearInterval(timer);
371
+ stopHeartbeats.delete(stop);
372
+ res.off('finish', stop);
373
+ res.off('close', stop);
374
+ res.off('error', stop);
375
+ };
376
+ heartbeatTask.run(task, next);
377
+ });
378
+ }
306
379
  const cardHandler = agentCardHandler({
307
380
  agentCardProvider: requestHandler,
308
381
  legacyCompat: { enabled: true },
@@ -373,6 +446,8 @@ export async function startA2aServer(options) {
373
446
  return {
374
447
  port: server.address().port,
375
448
  close: () => new Promise((resolve) => {
449
+ for (const stop of stopHeartbeats)
450
+ stop();
376
451
  server.closeAllConnections?.();
377
452
  server.close(() => resolve());
378
453
  }),
@@ -70,6 +70,13 @@ export declare const Config: Schema<Schemastery.ObjectS<{
70
70
  host: Schema<string, string>;
71
71
  port: Schema<number, number>;
72
72
  basePath: Schema<string, string>;
73
+ heartbeat: Schema<Schemastery.ObjectS<{
74
+ enabled: Schema<boolean, boolean>;
75
+ intervalMs: Schema<number, number>;
76
+ }>, Schemastery.ObjectT<{
77
+ enabled: Schema<boolean, boolean>;
78
+ intervalMs: Schema<number, number>;
79
+ }>>;
73
80
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
74
81
  cwd: Schema<string, string>;
75
82
  /**
@@ -135,6 +142,13 @@ export declare const Config: Schema<Schemastery.ObjectS<{
135
142
  host: Schema<string, string>;
136
143
  port: Schema<number, number>;
137
144
  basePath: Schema<string, string>;
145
+ heartbeat: Schema<Schemastery.ObjectS<{
146
+ enabled: Schema<boolean, boolean>;
147
+ intervalMs: Schema<number, number>;
148
+ }>, Schemastery.ObjectT<{
149
+ enabled: Schema<boolean, boolean>;
150
+ intervalMs: Schema<number, number>;
151
+ }>>;
138
152
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
139
153
  cwd: Schema<string, string>;
140
154
  /**
@@ -201,6 +215,10 @@ export interface A2aPluginConfig {
201
215
  host: string;
202
216
  port: number;
203
217
  basePath: string;
218
+ heartbeat?: {
219
+ enabled: boolean;
220
+ intervalMs: number;
221
+ };
204
222
  cwd: string;
205
223
  uploadsDir: string;
206
224
  agent: {
@@ -9,7 +9,7 @@
9
9
  * through the SDK's opt-in legacyCompat layer
10
10
  *
11
11
  * The SSE framing and error envelopes are the SDK's; this layer only binds
12
- * the server and builds the card.
12
+ * the server, builds the card, and optionally adds transport heartbeats.
13
13
  */
14
14
  import { type Message } from '@a2a-js/sdk';
15
15
  import { type AgentExecutor, type TaskStore } from '@a2a-js/sdk/server';
@@ -26,6 +26,10 @@ export interface A2aServerOptions {
26
26
  };
27
27
  executor: AgentExecutor;
28
28
  taskStore: TaskStore;
29
+ heartbeat?: {
30
+ enabled: boolean;
31
+ intervalMs: number;
32
+ };
29
33
  approvalGuard?: (message: Message) => undefined | (() => void);
30
34
  beforeWorkingTaskMessage?: (message: Message, requestHeaders: unknown) => Promise<void>;
31
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.17",
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",
@@ -62,12 +62,12 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@deepseek-ai/cordis": "^4.0.4",
65
- "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
66
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
67
- "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
68
- "@deepseek-ai/dsh-session": "0.2.0-rc.1",
69
- "@deepseek-ai/dsh-session-projection": "0.2.0-rc.1",
70
- "@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
65
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
66
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
68
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
69
+ "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
71
71
  "@google-cloud/storage": "^7.0.0",
72
72
  "@types/express": "^4.17.21",
73
73
  "@types/node": "^22.0.0",