@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 +36 -1
- package/lib/index.js +5 -0
- package/lib/server.js +79 -4
- package/lib/types/index.d.ts +18 -0
- package/lib/types/server.d.ts +5 -1
- package/package.json +7 -7
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.
|
|
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
|
|
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
|
}),
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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: {
|
package/lib/types/server.d.ts
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
|
|
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.
|
|
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.
|
|
66
|
-
"@deepseek-ai/dsh-attachment": "0.2.0-rc.
|
|
67
|
-
"@deepseek-ai/dsh-llm": "0.2.0-rc.
|
|
68
|
-
"@deepseek-ai/dsh-session": "0.2.0-rc.
|
|
69
|
-
"@deepseek-ai/dsh-session-projection": "0.2.0-rc.
|
|
70
|
-
"@deepseek-ai/dsh-user-approval": "0.2.0-rc.
|
|
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",
|