@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.
- package/README.md +21 -11
- package/lib/server.js +35 -6
- 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
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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((
|
|
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
|
-
|
|
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