@amaster.ai/dsh-a2a 0.1.28 → 0.1.30
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 -6
- package/lib/inbox.js +174 -12
- package/lib/index.js +7 -2
- package/lib/task-store.js +32 -3
- package/lib/translator.js +14 -4
- package/lib/types/inbox.d.ts +6 -2
- package/lib/types/index.d.ts +5 -0
- package/lib/types/task-store.d.ts +4 -1
- package/lib/types/translator.d.ts +4 -4
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -16,11 +16,11 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
|
16
16
|
|
|
17
17
|
`inbox.enabled: true` uses the official DSH inbox (`Agent.send`) for ordinary `next-turn` input and `next-step` steering. Tested with the unmodified npm runtime `0.2.1-rc.2`; no core patch is required. The queue and its durable projection belong to DSH. The plugin only materializes messages, attaches A2A task identities, and translates execution events.
|
|
18
18
|
|
|
19
|
-
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and subscriptions remain the wire interface. The negotiated
|
|
19
|
+
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and subscriptions remain the wire interface. The negotiated inbox extension forwards `target` and `wakeup` to DSH. `wakeup: false` leaves an idle driver parked; it does not prevent an active driver from consuming pending input. A later real waking message releases pending input.
|
|
20
20
|
|
|
21
21
|
The same-process `a2aInbox` service exposes `resolve(contextId)`, `content(message, contextId)`, `enqueue(taskId, message, { target?, wakeup? })`, `promote(contextId, nativeMessageId, activeTaskId)`, and `cancel(taskId)`. There is no separate pause, wake-only, resume, or retained-task restart operation. Cancellation is terminal and removes that task's pending input. Other tasks' input is retained according to official DSH cancellation semantics, and may wait for the next waking message. Clearing a context discards all its pending work.
|
|
22
22
|
|
|
23
|
-
`a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal
|
|
23
|
+
`a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal cancels tasks by default; `inbox.shutdown: retain-pending` opts into the retention policy below. Durable A2A task snapshots require a filesystem, Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
|
|
24
24
|
|
|
25
25
|
### Goal rounds
|
|
26
26
|
|
|
@@ -40,14 +40,29 @@ Only an unclaimed, single-message independent task in the same context can be pr
|
|
|
40
40
|
|
|
41
41
|
Select `taskStore: filesystem` with `filesystem.root` pointing at a retained directory, and separately enable durable DSH session persistence. Keep queued file materialization on retained storage as well. Each task snapshot is scoped by tenant and user, written to a private temporary file and atomically renamed; no additional service is needed. Use one host writer per directory. Files have no automatic expiry; context clearing deletes task snapshots.
|
|
42
42
|
|
|
43
|
-
After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or
|
|
43
|
+
After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or replay of the interrupted active model call.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
### Graceful host shutdown
|
|
46
|
+
|
|
47
|
+
Set `inbox: { enabled: true, shutdown: retain-pending }` on both sides of a restart to retain unclaimed A2A `next-step` and `next-turn` input. Keep durable session persistence, the TaskStore and materialized files on retained storage. This is a host configuration policy, not wire metadata. The default `shutdown: cancel` keeps the existing terminal-on-unload behavior.
|
|
48
|
+
|
|
49
|
+
Retention tracks normalized native inbox splices, including public edits, reordering, removals and queue moves, then freezes the final pending state when host/plugin unloading begins. It stops the current execution, checkpoints pending input in task snapshot metadata with its order and original native/A2A identities, then closes live request/subscription streams. The extra checkpoint survives the core's later inbox-clearing disposal. A task with pending input stays nonterminal for recovery; an interrupted task with no pending input becomes failed. Reconnect subscriptions after restart: streams are not replayed or carried across processes.
|
|
50
|
+
|
|
51
|
+
On `a2aInbox.resolve(contextId)`, the plugin restores retained ownership before allowing new input, excludes IDs already committed as `user/message`, and drains the retention checkpoint only after the restored session is durable. Explicit `CancelTask`, `a2aInbox.cancel()` and context clearing remain terminal. Claims already handed to the active execution are not retained, and external tool effects are not exactly-once. The host still owns startup discovery and an attributed waking input; merely resolving a context does not wake it. Persistence/checkpoint errors are reported through the existing shutdown error path; the host must not treat a failed checkpoint as a durable restart barrier.
|
|
52
|
+
|
|
53
|
+
The dedicated `E2E (dsh-a2a graceful retention)` job covers public plugin disposal, a real SIGTERM child process, repeated restarts, promotion/checkpoint races and terminal cancellation. It also reports recovered tasks to Langfuse and verifies their task/turn/generation ancestry through the real Observations API on same-repository CI.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
node .github/scripts/check-inbox.mjs -t 'graceful retention'
|
|
57
|
+
DSH_GRACEFUL_RETENTION_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'graceful retention.*reports gracefully recovered'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The dedicated `E2E (dsh-a2a native ownership)` integration job runs these regressions with unmodified npm DSH and scripted model responses. Its Langfuse scenario verifies Goal rounds and promoted input under the original task trace, canceled source ownership, and execution restored after SIGKILL. Same-repository CI also sends the scenario to the configured real Langfuse project and polls its Observations API; fork PRs use the local collector without credentials.
|
|
46
61
|
|
|
47
62
|
```sh
|
|
48
|
-
node .github/scripts/check-inbox.mjs -t
|
|
63
|
+
node .github/scripts/check-inbox.mjs -t 'native ownership'
|
|
49
64
|
# With LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY configured:
|
|
50
|
-
|
|
65
|
+
DSH_NATIVE_OWNERSHIP_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'native ownership.*reports Goal continuation'
|
|
51
66
|
```
|
|
52
67
|
|
|
53
68
|
## Configuration
|
package/lib/inbox.js
CHANGED
|
@@ -3,23 +3,42 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { Message, TaskState } from '@a2a-js/sdk';
|
|
4
4
|
import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/server';
|
|
5
5
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
6
|
+
import { commitInboxInput, markInboxInputPending } from './task-store.js';
|
|
6
7
|
import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
7
8
|
export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md';
|
|
9
|
+
const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
|
|
8
10
|
export class NativeInbox {
|
|
9
11
|
ctx;
|
|
10
12
|
bridge;
|
|
11
13
|
store;
|
|
14
|
+
retainPending;
|
|
12
15
|
contexts = new Map();
|
|
13
16
|
resolving = new Map();
|
|
14
17
|
stopping = false;
|
|
15
18
|
shutdownPromise;
|
|
16
19
|
preadmitted = new WeakMap();
|
|
17
20
|
server;
|
|
18
|
-
|
|
21
|
+
persistence;
|
|
22
|
+
constructor(ctx, bridge, store, retainPending = false) {
|
|
19
23
|
this.ctx = ctx;
|
|
20
24
|
this.bridge = bridge;
|
|
21
25
|
this.store = store;
|
|
26
|
+
this.retainPending = retainPending;
|
|
22
27
|
bridge.inbox = this;
|
|
28
|
+
this.persistence = ctx.get('sessionPersistence');
|
|
29
|
+
if (retainPending)
|
|
30
|
+
ctx.on('session/event', (session, event) => {
|
|
31
|
+
const run = this.contexts.get(session.id);
|
|
32
|
+
if (!run || event.type !== 'agent/inbox/spliced' || this.stopping || this.unloading())
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
const splice = event.data;
|
|
36
|
+
run.retained[splice.target].splice(splice.start, splice.removedCount ?? 0, ...structuredClone(splice.inserted));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('[dsh-a2a] failed to track pending inbox:', error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
23
42
|
ctx.on('agent/pre-step', async (detail, next) => {
|
|
24
43
|
await this.prepare(detail);
|
|
25
44
|
return next();
|
|
@@ -73,6 +92,18 @@ export class NativeInbox {
|
|
|
73
92
|
}
|
|
74
93
|
});
|
|
75
94
|
}
|
|
95
|
+
unloading() {
|
|
96
|
+
// Cordis publishes FiberState as a const enum, without a runtime export.
|
|
97
|
+
const unloading = 5;
|
|
98
|
+
for (let fiber = this.ctx.fiber;;) {
|
|
99
|
+
if (fiber.uid === null || fiber.state === unloading)
|
|
100
|
+
return true;
|
|
101
|
+
const parent = fiber.parent.fiber;
|
|
102
|
+
if (parent === fiber)
|
|
103
|
+
return false;
|
|
104
|
+
fiber = parent;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
76
107
|
content(message, contextId) {
|
|
77
108
|
return this.bridge.buildContent(message, contextId);
|
|
78
109
|
}
|
|
@@ -171,7 +202,7 @@ export class NativeInbox {
|
|
|
171
202
|
await this.checkpoint(target.task, target.context);
|
|
172
203
|
if (!valid())
|
|
173
204
|
throw new Error('Promotion raced input consumption or task settlement.');
|
|
174
|
-
target.task.history = [...target.task.history, original];
|
|
205
|
+
target.task.history = [...target.task.history, markInboxInputPending(original)];
|
|
175
206
|
target.headers.set(message.id, source.headers.get(message.id));
|
|
176
207
|
if (!agent.inbox.replace(message.id, promoted))
|
|
177
208
|
throw new Error('Queued input has already been claimed.');
|
|
@@ -238,9 +269,14 @@ export class NativeInbox {
|
|
|
238
269
|
for (const message of event.data.inserted) {
|
|
239
270
|
if (message.source.kind !== 'a2a' || !message.source.promotedFrom)
|
|
240
271
|
continue;
|
|
272
|
+
const previous = transfers.get(message.id);
|
|
241
273
|
transfers.set(message.id, {
|
|
242
274
|
message: message,
|
|
243
|
-
|
|
275
|
+
// A completed transfer stays complete after later public queue edits.
|
|
276
|
+
placed: event.data.target === message.source.target ||
|
|
277
|
+
!!(previous?.placed &&
|
|
278
|
+
previous.message.source.taskId === message.source.taskId &&
|
|
279
|
+
previous.message.source.promotionId === message.source.promotionId),
|
|
244
280
|
});
|
|
245
281
|
}
|
|
246
282
|
}
|
|
@@ -258,7 +294,7 @@ export class NativeInbox {
|
|
|
258
294
|
throw new Error('Promoted A2A task is not accessible.');
|
|
259
295
|
const original = Message.fromJSON(JSON.parse(message.source.message));
|
|
260
296
|
if (!recipient.history.some((item) => item.messageId === original.messageId)) {
|
|
261
|
-
recipient.history.push(original);
|
|
297
|
+
recipient.history.push(markInboxInputPending(original));
|
|
262
298
|
await this.checkpoint(recipient, context);
|
|
263
299
|
}
|
|
264
300
|
}
|
|
@@ -276,6 +312,63 @@ export class NativeInbox {
|
|
|
276
312
|
await this.checkpoint(former, context);
|
|
277
313
|
}
|
|
278
314
|
}
|
|
315
|
+
async recoverRetained(run, context) {
|
|
316
|
+
if (!this.retainPending)
|
|
317
|
+
return;
|
|
318
|
+
const agent = run.entry.handle.agent;
|
|
319
|
+
const committed = new Set(agent.session
|
|
320
|
+
.snapshotEvents()
|
|
321
|
+
.flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
|
|
322
|
+
const snapshots = [];
|
|
323
|
+
const inputs = [];
|
|
324
|
+
let pageToken = '';
|
|
325
|
+
do {
|
|
326
|
+
const page = await this.store.list({
|
|
327
|
+
tenant: '',
|
|
328
|
+
contextId: agent.session.id,
|
|
329
|
+
pageSize: 100,
|
|
330
|
+
pageToken,
|
|
331
|
+
status: TaskState.TASK_STATE_UNSPECIFIED,
|
|
332
|
+
statusTimestampAfter: undefined,
|
|
333
|
+
}, context);
|
|
334
|
+
for (const task of page.tasks) {
|
|
335
|
+
const retained = task.metadata?.[RETAINED_INPUTS];
|
|
336
|
+
if (typeof retained !== 'string')
|
|
337
|
+
continue;
|
|
338
|
+
snapshots.push(task);
|
|
339
|
+
if (this.terminal(task))
|
|
340
|
+
continue;
|
|
341
|
+
for (const input of JSON.parse(retained)) {
|
|
342
|
+
const { message } = input;
|
|
343
|
+
if (message.source.kind !== 'a2a' || message.source.taskId !== task.id)
|
|
344
|
+
throw new Error('Retained input has invalid task ownership.');
|
|
345
|
+
if (!committed.has(message.id))
|
|
346
|
+
inputs.push(input);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
pageToken = page.nextPageToken;
|
|
350
|
+
} while (pageToken);
|
|
351
|
+
for (const { message, target } of inputs.sort((a, b) => a.position - b.position)) {
|
|
352
|
+
if (message.source.kind !== 'a2a')
|
|
353
|
+
continue;
|
|
354
|
+
const existing = this.pending(run).find((pending) => pending.id === message.id);
|
|
355
|
+
if (existing) {
|
|
356
|
+
agent.inbox.replace(message.id, message);
|
|
357
|
+
const destination = target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn;
|
|
358
|
+
if (!destination.some((pending) => pending.id === message.id))
|
|
359
|
+
this.moveInput(agent, message, target);
|
|
360
|
+
}
|
|
361
|
+
else
|
|
362
|
+
agent.inbox.append(target, message);
|
|
363
|
+
}
|
|
364
|
+
if (!snapshots.length)
|
|
365
|
+
return;
|
|
366
|
+
await this.flush(agent.session);
|
|
367
|
+
for (const task of snapshots) {
|
|
368
|
+
delete task.metadata[RETAINED_INPUTS];
|
|
369
|
+
await this.checkpoint(task, context);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
279
372
|
context(contextId, context = new ServerCallContext()) {
|
|
280
373
|
if (this.stopping)
|
|
281
374
|
return Promise.reject(new Error('Native inbox is unloading.'));
|
|
@@ -294,7 +387,9 @@ export class NativeInbox {
|
|
|
294
387
|
admissionTail: Promise.resolve(),
|
|
295
388
|
promotions: new Map(),
|
|
296
389
|
rolledBack: new Set(),
|
|
390
|
+
retained: { 'next-step': [], 'next-turn': [] },
|
|
297
391
|
};
|
|
392
|
+
await this.recoverRetained(run, context);
|
|
298
393
|
await this.recoverPromotions(run, context);
|
|
299
394
|
const snapshots = new Map();
|
|
300
395
|
for (const message of this.pending(run)) {
|
|
@@ -311,6 +406,10 @@ export class NativeInbox {
|
|
|
311
406
|
await this.flush(entry.handle.agent.session);
|
|
312
407
|
for (const snapshot of snapshots.values())
|
|
313
408
|
this.restoreTask(run, snapshot, context);
|
|
409
|
+
run.retained = structuredClone({
|
|
410
|
+
'next-step': [...entry.handle.agent.inbox.nextStep],
|
|
411
|
+
'next-turn': [...entry.handle.agent.inbox.nextTurn],
|
|
412
|
+
});
|
|
314
413
|
this.contexts.set(contextId, run);
|
|
315
414
|
this.observe('resolved', run);
|
|
316
415
|
for (const message of this.pending(run))
|
|
@@ -323,7 +422,9 @@ export class NativeInbox {
|
|
|
323
422
|
}
|
|
324
423
|
observe(phase, run, fields = {}) {
|
|
325
424
|
try {
|
|
326
|
-
const pending = this.
|
|
425
|
+
const pending = this.stopping && this.retainPending
|
|
426
|
+
? [...run.retained['next-step'], ...run.retained['next-turn']]
|
|
427
|
+
: this.pending(run);
|
|
327
428
|
void this.ctx
|
|
328
429
|
.parallel('a2a/inbox', {
|
|
329
430
|
phase,
|
|
@@ -551,7 +652,7 @@ export class NativeInbox {
|
|
|
551
652
|
const history = saved?.history ?? task.task.history;
|
|
552
653
|
const acceptedHistory = history.some((item) => item.messageId === a2aMessage.messageId)
|
|
553
654
|
? history
|
|
554
|
-
: [...history, a2aMessage];
|
|
655
|
+
: [...history, markInboxInputPending(a2aMessage)];
|
|
555
656
|
await this.checkpoint({ ...task.task, history: acceptedHistory }, context);
|
|
556
657
|
abort.signal.throwIfAborted();
|
|
557
658
|
task.task.history = acceptedHistory;
|
|
@@ -627,7 +728,7 @@ export class NativeInbox {
|
|
|
627
728
|
bus.publish(task.final);
|
|
628
729
|
}
|
|
629
730
|
catch (error) {
|
|
630
|
-
if (task?.settled)
|
|
731
|
+
if (task?.settled || (this.stopping && this.retainPending))
|
|
631
732
|
return;
|
|
632
733
|
if (task && !ownsTask) {
|
|
633
734
|
bus.publish(AgentEvent.task(structuredClone(task.task)));
|
|
@@ -659,6 +760,12 @@ export class NativeInbox {
|
|
|
659
760
|
}
|
|
660
761
|
}
|
|
661
762
|
restoreTask(run, snapshot, context) {
|
|
763
|
+
for (const event of run.entry.handle.agent.session.snapshotEvents()) {
|
|
764
|
+
if (event.type === 'user/message' &&
|
|
765
|
+
event.data.source.kind === 'a2a' &&
|
|
766
|
+
event.data.source.taskId === snapshot.id)
|
|
767
|
+
commitInboxInput(snapshot.history, Message.fromJSON(JSON.parse(event.data.source.message)));
|
|
768
|
+
}
|
|
662
769
|
if (!this.server)
|
|
663
770
|
throw new Error('A2A task recovery requires the running server.');
|
|
664
771
|
const bus = this.server.restoreTask(snapshot, context);
|
|
@@ -822,6 +929,24 @@ export class NativeInbox {
|
|
|
822
929
|
return true;
|
|
823
930
|
}
|
|
824
931
|
try {
|
|
932
|
+
if (event.type === 'user/message' && event.data.source.kind === 'a2a') {
|
|
933
|
+
// Commit on the task bus before subsequent assistant deltas, not on admission.
|
|
934
|
+
const status = {
|
|
935
|
+
state: TaskState.TASK_STATE_WORKING,
|
|
936
|
+
message: { ...this.protocolMessage(run, task, event.data), taskId: task.task.id },
|
|
937
|
+
timestamp: new Date().toISOString(),
|
|
938
|
+
};
|
|
939
|
+
task.translator.handle(event);
|
|
940
|
+
commitInboxInput(task.task.history, status.message);
|
|
941
|
+
task.task.status = status;
|
|
942
|
+
task.bus.publish(AgentEvent.statusUpdate({
|
|
943
|
+
taskId: task.task.id,
|
|
944
|
+
contextId: session.id,
|
|
945
|
+
status,
|
|
946
|
+
metadata: { dshAgent: { kind: 'user-message' } },
|
|
947
|
+
}));
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
825
950
|
if (event.type !== 'turn/end') {
|
|
826
951
|
this.publish(task, event);
|
|
827
952
|
return true;
|
|
@@ -1004,6 +1129,7 @@ export class NativeInbox {
|
|
|
1004
1129
|
];
|
|
1005
1130
|
for (const admission of run.admissions)
|
|
1006
1131
|
admission.abort();
|
|
1132
|
+
run.retained = { 'next-step': [], 'next-turn': [] };
|
|
1007
1133
|
run.entry.handle.agent.inbox.clear();
|
|
1008
1134
|
run.entry.handle.agent.cancel({ kind: 'user' });
|
|
1009
1135
|
await run.entry.handle.agent.whenIdle();
|
|
@@ -1020,7 +1146,7 @@ export class NativeInbox {
|
|
|
1020
1146
|
}
|
|
1021
1147
|
onDisposed(session) {
|
|
1022
1148
|
const run = this.contexts.get(session.id);
|
|
1023
|
-
if (!run)
|
|
1149
|
+
if (!run || (this.retainPending && this.stopping))
|
|
1024
1150
|
return;
|
|
1025
1151
|
for (const admission of run.admissions)
|
|
1026
1152
|
admission.abort();
|
|
@@ -1053,7 +1179,7 @@ export class NativeInbox {
|
|
|
1053
1179
|
for (const admission of run.admissions)
|
|
1054
1180
|
admission.abort();
|
|
1055
1181
|
try {
|
|
1056
|
-
run.entry.handle.agent.cancel({ kind: 'disposed' });
|
|
1182
|
+
run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: this.retainPending });
|
|
1057
1183
|
}
|
|
1058
1184
|
catch (error) {
|
|
1059
1185
|
failures.push(error);
|
|
@@ -1065,7 +1191,11 @@ export class NativeInbox {
|
|
|
1065
1191
|
failures.push(error);
|
|
1066
1192
|
}
|
|
1067
1193
|
try {
|
|
1068
|
-
|
|
1194
|
+
// The persistence barrier tolerates writers already closed by parallel core teardown.
|
|
1195
|
+
if (this.retainPending && this.persistence)
|
|
1196
|
+
await this.persistence.flush();
|
|
1197
|
+
else
|
|
1198
|
+
await this.flush(run.entry.handle.agent.session);
|
|
1069
1199
|
}
|
|
1070
1200
|
catch (error) {
|
|
1071
1201
|
failures.push(error);
|
|
@@ -1073,8 +1203,40 @@ export class NativeInbox {
|
|
|
1073
1203
|
finally {
|
|
1074
1204
|
for (const task of run.tasks.values()) {
|
|
1075
1205
|
try {
|
|
1076
|
-
if (!task.settled)
|
|
1077
|
-
|
|
1206
|
+
if (!task.settled) {
|
|
1207
|
+
if (this.retainPending) {
|
|
1208
|
+
const committed = new Set(run.entry.handle.agent.session
|
|
1209
|
+
.snapshotEvents()
|
|
1210
|
+
.flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
|
|
1211
|
+
let position = 0;
|
|
1212
|
+
const pending = ['next-step', 'next-turn'].flatMap((target) => run.retained[target].flatMap((message) => {
|
|
1213
|
+
const input = { message, target, position: position++ };
|
|
1214
|
+
return message.source.kind === 'a2a' &&
|
|
1215
|
+
message.source.taskId === task.task.id &&
|
|
1216
|
+
!committed.has(message.id)
|
|
1217
|
+
? [input]
|
|
1218
|
+
: [];
|
|
1219
|
+
}));
|
|
1220
|
+
if (pending.length) {
|
|
1221
|
+
task.task.metadata = {
|
|
1222
|
+
...task.task.metadata,
|
|
1223
|
+
[RETAINED_INPUTS]: JSON.stringify(pending),
|
|
1224
|
+
};
|
|
1225
|
+
await this.checkpoint(structuredClone(task.task), task.context);
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
task.final = terminalStatusUpdate(task.task.id, run.entry.sessionId, 'failed', 'Host stopped; active input will not be replayed.');
|
|
1229
|
+
if (task.final.kind === 'statusUpdate')
|
|
1230
|
+
task.task.status = task.final.data.status;
|
|
1231
|
+
await this.checkpoint(structuredClone(task.task), task.context);
|
|
1232
|
+
task.bus.publish(task.final);
|
|
1233
|
+
}
|
|
1234
|
+
task.settled = true;
|
|
1235
|
+
task.controller.abort();
|
|
1236
|
+
}
|
|
1237
|
+
else
|
|
1238
|
+
await this.cancelTask(run, task);
|
|
1239
|
+
}
|
|
1078
1240
|
}
|
|
1079
1241
|
catch (error) {
|
|
1080
1242
|
failures.push(error);
|
package/lib/index.js
CHANGED
|
@@ -30,7 +30,10 @@ export const name = 'dsh-a2a';
|
|
|
30
30
|
/** The bridge creates and owns agents through the registry service. */
|
|
31
31
|
export const inject = ['agents'];
|
|
32
32
|
export const Config = Schema.object({
|
|
33
|
-
inbox: Schema.object({
|
|
33
|
+
inbox: Schema.object({
|
|
34
|
+
enabled: Schema.boolean().default(false),
|
|
35
|
+
shutdown: Schema.union([Schema.const('cancel'), Schema.const('retain-pending')]).default('cancel'),
|
|
36
|
+
}).description('Use the official DSH inbox for queued turns and steering.'),
|
|
34
37
|
enabled: Schema.boolean().default(false),
|
|
35
38
|
host: Schema.string().default('127.0.0.1'),
|
|
36
39
|
port: Schema.natural().default(41241),
|
|
@@ -114,7 +117,9 @@ export function apply(ctx, config) {
|
|
|
114
117
|
subagentTools: config.subagentTools,
|
|
115
118
|
});
|
|
116
119
|
const store = createTaskStore(config);
|
|
117
|
-
const inbox = config.inbox?.enabled
|
|
120
|
+
const inbox = config.inbox?.enabled
|
|
121
|
+
? new NativeInbox(ctx, bridge, store, config.inbox.shutdown === 'retain-pending')
|
|
122
|
+
: undefined;
|
|
118
123
|
await store.init?.();
|
|
119
124
|
if (inbox)
|
|
120
125
|
ctx.provide('a2aInbox', inbox);
|
package/lib/task-store.js
CHANGED
|
@@ -16,11 +16,38 @@
|
|
|
16
16
|
* the shared filter/sort/paginate logic so the backends only enumerate shells.
|
|
17
17
|
*/
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
|
-
import { TaskState } from '@a2a-js/sdk';
|
|
19
|
+
import { Role, TaskState, } from '@a2a-js/sdk';
|
|
20
20
|
import { resolveUserScope } from '@a2a-js/sdk/server';
|
|
21
|
+
function inboxMetadata(message) {
|
|
22
|
+
const value = message.metadata?.dshAgent;
|
|
23
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
24
|
+
}
|
|
25
|
+
export function markInboxInputPending(message) {
|
|
26
|
+
return {
|
|
27
|
+
...message,
|
|
28
|
+
metadata: { ...message.metadata, dshAgent: { ...inboxMetadata(message), inboxPending: true } },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** SDK status updates do not replace admitted history entries with the same ID. */
|
|
32
|
+
export function commitInboxInput(history, message) {
|
|
33
|
+
const index = history.findIndex((item) => item.messageId === message.messageId && inboxMetadata(item).inboxPending === true);
|
|
34
|
+
if (index < 0)
|
|
35
|
+
return;
|
|
36
|
+
const previous = history[index];
|
|
37
|
+
const dshAgent = { ...inboxMetadata(previous), ...inboxMetadata(message) };
|
|
38
|
+
delete dshAgent.inboxPending;
|
|
39
|
+
history.splice(index, 1);
|
|
40
|
+
history.push({
|
|
41
|
+
...previous,
|
|
42
|
+
...structuredClone(message),
|
|
43
|
+
metadata: { ...previous.metadata, ...message.metadata, dshAgent },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
21
46
|
/** Omit an unfinished current message from a WORKING snapshot. */
|
|
22
47
|
export function sanitizeTask(task) {
|
|
23
|
-
const current = task.status?.state === TaskState.TASK_STATE_WORKING &&
|
|
48
|
+
const current = task.status?.state === TaskState.TASK_STATE_WORKING &&
|
|
49
|
+
task.status.message?.role === Role.ROLE_AGENT &&
|
|
50
|
+
task.status.message.messageId;
|
|
24
51
|
return current
|
|
25
52
|
? { ...task, history: task.history.filter((message) => message.messageId !== current) }
|
|
26
53
|
: task;
|
|
@@ -115,7 +142,9 @@ export class SanitizedTaskStore {
|
|
|
115
142
|
this.terminalStates.set(task.id, state);
|
|
116
143
|
const history = structuredClone(task.history ?? []);
|
|
117
144
|
const current = task.status?.message;
|
|
118
|
-
if (
|
|
145
|
+
if (current?.role === Role.ROLE_USER)
|
|
146
|
+
commitInboxInput(history, current);
|
|
147
|
+
if (task.status?.state === TaskState.TASK_STATE_WORKING && current?.role === Role.ROLE_AGENT) {
|
|
119
148
|
const previous = this.liveHistory
|
|
120
149
|
.get(task.id)
|
|
121
150
|
?.history.find((message) => message.messageId === current.messageId);
|
package/lib/translator.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* turn/start → statusUpdate(WORKING), ids rotate
|
|
15
15
|
* stream chunk (text-delta) → statusUpdate(WORKING, text part),
|
|
16
|
-
* reusing the
|
|
16
|
+
* reusing the reply segment's messageId so
|
|
17
17
|
* clients aggregate deltas into one message
|
|
18
18
|
* stream chunk (reasoning-delta) → statusUpdate(WORKING, thought metadata)
|
|
19
19
|
* assistant/message → no event; text + usage captured for the
|
|
@@ -88,12 +88,12 @@ export class SessionTranslator {
|
|
|
88
88
|
contextId;
|
|
89
89
|
model;
|
|
90
90
|
source;
|
|
91
|
-
/**
|
|
91
|
+
/** Shared by streamed text deltas until the next committed input or turn. */
|
|
92
92
|
textMessageId = randomUUID();
|
|
93
93
|
thoughtMessageId = randomUUID();
|
|
94
|
-
/**
|
|
94
|
+
/** Current reply segment, assembled from `assistant/message` events. */
|
|
95
95
|
turnText = '';
|
|
96
|
-
/** Fallback
|
|
96
|
+
/** Fallback reply segment, accumulated from `text-delta` chunks. */
|
|
97
97
|
deltaText = '';
|
|
98
98
|
sawAssistantMessage = false;
|
|
99
99
|
usage;
|
|
@@ -117,6 +117,16 @@ export class SessionTranslator {
|
|
|
117
117
|
this.usage = undefined;
|
|
118
118
|
return [this.status(TaskState.TASK_STATE_WORKING, { kind: 'state-change' })];
|
|
119
119
|
}
|
|
120
|
+
case 'user/message': {
|
|
121
|
+
if (event.data.source.kind === 'a2a') {
|
|
122
|
+
this.textMessageId = randomUUID();
|
|
123
|
+
this.thoughtMessageId = randomUUID();
|
|
124
|
+
this.turnText = '';
|
|
125
|
+
this.deltaText = '';
|
|
126
|
+
this.sawAssistantMessage = false;
|
|
127
|
+
}
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
120
130
|
case 'assistant/message': {
|
|
121
131
|
this.sawAssistantMessage = true;
|
|
122
132
|
for (const block of event.data.message.content) {
|
package/lib/types/inbox.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
|
8
8
|
import type { A2aBridge } from './bridge.js';
|
|
9
9
|
import type { A2aTurnContext } from './index.js';
|
|
10
10
|
import type { A2aServer } from './server.js';
|
|
11
|
-
import type
|
|
11
|
+
import { type ManagedTaskStore } from './task-store.js';
|
|
12
12
|
export declare const INBOX_EXTENSION = "https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md";
|
|
13
13
|
export interface A2aInboxEvent {
|
|
14
14
|
phase: string;
|
|
@@ -71,13 +71,16 @@ export declare class NativeInbox implements A2aInbox {
|
|
|
71
71
|
private readonly ctx;
|
|
72
72
|
private readonly bridge;
|
|
73
73
|
private readonly store;
|
|
74
|
+
private readonly retainPending;
|
|
74
75
|
private readonly contexts;
|
|
75
76
|
private readonly resolving;
|
|
76
77
|
private stopping;
|
|
77
78
|
private shutdownPromise?;
|
|
78
79
|
private readonly preadmitted;
|
|
79
80
|
server?: A2aServer;
|
|
80
|
-
|
|
81
|
+
private readonly persistence?;
|
|
82
|
+
constructor(ctx: Context, bridge: A2aBridge, store: ManagedTaskStore, retainPending?: boolean);
|
|
83
|
+
private unloading;
|
|
81
84
|
content(message: Message, contextId: string): Promise<ContentBlock[]>;
|
|
82
85
|
resolve(contextId: string): Promise<Agent>;
|
|
83
86
|
enqueue(taskId: string, message: Message, options?: {
|
|
@@ -89,6 +92,7 @@ export declare class NativeInbox implements A2aInbox {
|
|
|
89
92
|
private promoteInput;
|
|
90
93
|
/** Repair only an incomplete ownership move, never an ordinary DSH claim. */
|
|
91
94
|
private recoverPromotions;
|
|
95
|
+
private recoverRetained;
|
|
92
96
|
private context;
|
|
93
97
|
private observe;
|
|
94
98
|
private observeInput;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -71,8 +71,10 @@ export declare const inject: string[];
|
|
|
71
71
|
export declare const Config: Schema<Schemastery.ObjectS<{
|
|
72
72
|
inbox: Schema<Schemastery.ObjectS<{
|
|
73
73
|
enabled: Schema<boolean, boolean>;
|
|
74
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
74
75
|
}>, Schemastery.ObjectT<{
|
|
75
76
|
enabled: Schema<boolean, boolean>;
|
|
77
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
76
78
|
}>>;
|
|
77
79
|
enabled: Schema<boolean, boolean>;
|
|
78
80
|
host: Schema<string, string>;
|
|
@@ -158,8 +160,10 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
158
160
|
}>, Schemastery.ObjectT<{
|
|
159
161
|
inbox: Schema<Schemastery.ObjectS<{
|
|
160
162
|
enabled: Schema<boolean, boolean>;
|
|
163
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
161
164
|
}>, Schemastery.ObjectT<{
|
|
162
165
|
enabled: Schema<boolean, boolean>;
|
|
166
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
163
167
|
}>>;
|
|
164
168
|
enabled: Schema<boolean, boolean>;
|
|
165
169
|
host: Schema<string, string>;
|
|
@@ -246,6 +250,7 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
246
250
|
export interface A2aPluginConfig {
|
|
247
251
|
inbox?: {
|
|
248
252
|
enabled: boolean;
|
|
253
|
+
shutdown?: 'cancel' | 'retain-pending';
|
|
249
254
|
};
|
|
250
255
|
enabled: boolean;
|
|
251
256
|
host: string;
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* A2A 1.0 added `list()` to the TaskStore contract; {@link listShells} holds
|
|
16
16
|
* the shared filter/sort/paginate logic so the backends only enumerate shells.
|
|
17
17
|
*/
|
|
18
|
-
import { type ListTasksRequest, type ListTasksResponse, type Task } from '@a2a-js/sdk';
|
|
18
|
+
import { type ListTasksRequest, type ListTasksResponse, type Message, type Task } from '@a2a-js/sdk';
|
|
19
19
|
import { type ServerCallContext, type TaskStore } from '@a2a-js/sdk/server';
|
|
20
|
+
export declare function markInboxInputPending(message: Message): Message;
|
|
21
|
+
/** SDK status updates do not replace admitted history entries with the same ID. */
|
|
22
|
+
export declare function commitInboxInput(history: Message[], message: Message): void;
|
|
20
23
|
/** Omit an unfinished current message from a WORKING snapshot. */
|
|
21
24
|
export declare function sanitizeTask(task: Task): Task;
|
|
22
25
|
/** TaskStore with the optional lifecycle our backends add. */
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* turn/start → statusUpdate(WORKING), ids rotate
|
|
15
15
|
* stream chunk (text-delta) → statusUpdate(WORKING, text part),
|
|
16
|
-
* reusing the
|
|
16
|
+
* reusing the reply segment's messageId so
|
|
17
17
|
* clients aggregate deltas into one message
|
|
18
18
|
* stream chunk (reasoning-delta) → statusUpdate(WORKING, thought metadata)
|
|
19
19
|
* assistant/message → no event; text + usage captured for the
|
|
@@ -56,12 +56,12 @@ export declare class SessionTranslator {
|
|
|
56
56
|
private readonly contextId;
|
|
57
57
|
private readonly model?;
|
|
58
58
|
private readonly source?;
|
|
59
|
-
/**
|
|
59
|
+
/** Shared by streamed text deltas until the next committed input or turn. */
|
|
60
60
|
private textMessageId;
|
|
61
61
|
private thoughtMessageId;
|
|
62
|
-
/**
|
|
62
|
+
/** Current reply segment, assembled from `assistant/message` events. */
|
|
63
63
|
private turnText;
|
|
64
|
-
/** Fallback
|
|
64
|
+
/** Fallback reply segment, accumulated from `text-delta` chunks. */
|
|
65
65
|
private deltaText;
|
|
66
66
|
private sawAssistantMessage;
|
|
67
67
|
private usage;
|
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.30",
|
|
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",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
47
47
|
"@google-cloud/storage": "^7.0.0",
|
|
48
48
|
"ioredis": "^5.0.0",
|
|
49
|
-
"@deepseek-ai/dsh-goal": ">=0.2.0-rc.2 || >=0.2.1-alpha.1"
|
|
49
|
+
"@deepseek-ai/dsh-goal": ">=0.2.0-rc.2 || >=0.2.1-alpha.1",
|
|
50
|
+
"@deepseek-ai/dsh-session-persistence": ">=0.2.0-rc.2 || >=0.2.1-alpha.1"
|
|
50
51
|
},
|
|
51
52
|
"peerDependenciesMeta": {
|
|
52
53
|
"@deepseek-ai/dsh-attachment": {
|
|
@@ -63,6 +64,9 @@
|
|
|
63
64
|
},
|
|
64
65
|
"@deepseek-ai/dsh-goal": {
|
|
65
66
|
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@deepseek-ai/dsh-session-persistence": {
|
|
69
|
+
"optional": true
|
|
66
70
|
}
|
|
67
71
|
},
|
|
68
72
|
"devDependencies": {
|
|
@@ -81,7 +85,8 @@
|
|
|
81
85
|
"ioredis": "^5.0.0",
|
|
82
86
|
"typescript": "^5.6.3",
|
|
83
87
|
"vitest": "^4.1.11",
|
|
84
|
-
"@deepseek-ai/dsh-goal": "0.2.1-rc.2"
|
|
88
|
+
"@deepseek-ai/dsh-goal": "0.2.1-rc.2",
|
|
89
|
+
"@deepseek-ai/dsh-session-persistence": "0.2.1-rc.2"
|
|
85
90
|
},
|
|
86
91
|
"engines": {
|
|
87
92
|
"node": "^20.19 || ^22.12 || >=24.0"
|