@amaster.ai/dsh-a2a 0.1.16 → 0.1.18

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 transport keepalive comments
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,28 @@ 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 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
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
+ 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.
90
+
91
+ Heartbeats are skipped while the response requires drain, with no heartbeat
92
+ queue or catch-up burst. Timers stop on completion, disconnect, response errors,
93
+ and server disposal. Heartbeats require a responsive event loop; they cannot
94
+ repair event-loop starvation or guarantee delivery during backpressure.
95
+
71
96
  ## Task stores
72
97
 
73
98
  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 +109,7 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
84
109
 
85
110
  ## Compatibility
86
111
 
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.
112
+ 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
113
 
89
114
  ## License
90
115
 
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,11 @@ 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();
23
28
  const base = options.basePath.replace(/\/$/, '');
24
29
  const publicUrl = (options.card.publicUrl ?? `http://${options.host}:${options.port}`).replace(/\/$/, '');
25
30
  const interfaces = [
@@ -303,6 +308,45 @@ export async function startA2aServer(options) {
303
308
  // The SDK's jsonRpcHandler parses bodies with express.json()'s 100kb default;
304
309
  // raise the ceiling here — body-parser skips re-parsing an already-read body.
305
310
  app.use(express.json({ limit: '16mb' }));
311
+ if (options.heartbeat?.enabled) {
312
+ app.use((_req, res, next) => {
313
+ const flushHeaders = res.flushHeaders;
314
+ // Wait for the SDK to commit SSE headers: validation errors stay JSON.
315
+ res.flushHeaders = function () {
316
+ flushHeaders.call(this);
317
+ if (!String(res.getHeader('content-type')).startsWith('text/event-stream') || timer)
318
+ return;
319
+ timer = setInterval(() => {
320
+ if (res.destroyed || res.writableEnded)
321
+ return stop();
322
+ // A slow client gets no additional heartbeat backlog; retry next tick.
323
+ if (res.writableNeedDrain)
324
+ return;
325
+ try {
326
+ res.write(': heartbeat\n\n');
327
+ }
328
+ catch (error) {
329
+ stop();
330
+ console.error('[dsh-a2a] heartbeat write failed:', error);
331
+ }
332
+ }, intervalMs);
333
+ timer.unref();
334
+ stopHeartbeats.add(stop);
335
+ res.once('finish', stop);
336
+ res.once('close', stop);
337
+ res.once('error', stop);
338
+ };
339
+ let timer;
340
+ const stop = () => {
341
+ clearInterval(timer);
342
+ stopHeartbeats.delete(stop);
343
+ res.off('finish', stop);
344
+ res.off('close', stop);
345
+ res.off('error', stop);
346
+ };
347
+ next();
348
+ });
349
+ }
306
350
  const cardHandler = agentCardHandler({
307
351
  agentCardProvider: requestHandler,
308
352
  legacyCompat: { enabled: true },
@@ -373,6 +417,8 @@ export async function startA2aServer(options) {
373
417
  return {
374
418
  port: server.address().port,
375
419
  close: () => new Promise((resolve) => {
420
+ for (const stop of stopHeartbeats)
421
+ stop();
376
422
  server.closeAllConnections?.();
377
423
  server.close(() => resolve());
378
424
  }),
@@ -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.16",
3
+ "version": "0.1.18",
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",