@amaster.ai/dsh-a2a 0.1.25 → 0.1.26
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 +39 -0
- package/lib/bridge.js +48 -3
- package/lib/executor.js +5 -1
- package/lib/inbox.js +944 -0
- package/lib/index.js +38 -3
- package/lib/legacy-stream.js +1 -1
- package/lib/server.js +43 -2
- package/lib/task-store.js +46 -12
- package/lib/types/bridge.d.ts +4 -0
- package/lib/types/inbox.d.ts +149 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/server.d.ts +7 -2
- package/lib/types/task-store.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,45 @@ A2A protocol server plugin for [DeepSeek Harness (dsh)](https://github.com/deeps
|
|
|
12
12
|
dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Native inbox execution
|
|
16
|
+
|
|
17
|
+
`inbox.enabled: true` opts into durable native DSH input instead of request-serialized execution. **This requires the unreleased upstream native inbox capability (`ctx.agents.nativeInboxVersion === 1`); the tested `0.2.0-rc.2` runtime does not provide it.** Startup rejects unsupported factories before opening a backend. Legacy execution remains the default.
|
|
18
|
+
|
|
19
|
+
In this mode, ordinary `SendMessage` / `SendStreamingMessage` calls enter the DSH inbox before their initial task response is published. Nonblocking sends return a submitted task after durable admission. A context owns one agent, while tasks retain separate message identities, streams, and terminal states. A completed unit of work settles `completed`; use the same context with a new task for later work. Disconnecting an HTTP client does not cancel execution. Task snapshots, polling, and subscriptions use the standard A2A operations.
|
|
20
|
+
|
|
21
|
+
The optional [inbox extension](../../docs/protocol/inbox-v1.md) selects parked admission (`wakeup: false`) or next-step steering (`target: next-step`). Steering must address the currently active task. It cannot pull an unrelated task's input into the active model step.
|
|
22
|
+
|
|
23
|
+
The same-process `a2aInbox` service implements the exported `A2aInbox` interface:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const inbox = ctx.get('a2aInbox') as A2aInbox;
|
|
27
|
+
const agent = await inbox.resolve(contextId);
|
|
28
|
+
const blocks = await inbox.content(message, contextId);
|
|
29
|
+
await inbox.enqueue(taskId, message); // Host follow-up with explicit task ownership.
|
|
30
|
+
await inbox.pause(contextId);
|
|
31
|
+
await inbox.wake(contextId); // Leaves a persisted pause intact.
|
|
32
|
+
await inbox.wake(contextId, { resume: true }); // Explicitly unpauses existing work.
|
|
33
|
+
await inbox.cancel(taskId, { retainInbox: true }); // Terminal task; pending input stays parked.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`pause` cancels active tasks while retaining their unconsumed input and other queued tasks; resumed input from a canceled task gets a fresh task identity. `CancelTask` remains terminal and removes that task's pending input, retaining other tasks' input. The service's explicit retention option parks the context; resuming retained input creates a new task when its former owner is terminal. `a2aTasks.clearContext` discards pending work even when idle. Plugin unload preserves the native inbox and drains its session writes. For restart recovery, configure durable DSH session persistence and a Redis or GCS TaskStore, then call `wake` for the contexts the host chooses to restore. `SubscribeToTask` observes recovered work without submitting a wake message.
|
|
37
|
+
|
|
38
|
+
In native mode, host settlement hooks submit follow-ups through `a2aInbox.enqueue(taskId, message)`; it binds and persists the task identity before enqueueing. Raw `Agent.send` / `followup` inputs without A2A attribution are rejected rather than assigned to the most recent hook.
|
|
39
|
+
|
|
40
|
+
`a2a/before-followup` validates admission; use `a2a/before-assembly` for per-input runtime state. The latter runs after the model-step batch is selected, before prompt assembly, and receives the same `A2aTurnContext` fields. Request headers are process-local: restored inputs receive `requestHeaders: undefined`. Persist replay-safe metadata in the message, never credentials in the queue. Admission observers remain non-vetoing.
|
|
41
|
+
|
|
42
|
+
Recovery only restores an interrupted claim without a committed `user/message`; it never replays committed model input or promises exactly-once tool effects. A task with committed input, no remaining pending input, and no durable terminal A2A checkpoint becomes failed on recovery, including when the turn ended before the process stopped. The host can then decide what to do next. Keep file parts on persistent storage with `uploadsDir` or `a2aFileMaterializer` when queued inputs must survive host or machine restarts. The memory TaskStore and temporary upload default do not provide that durability. In-process task handles remain until context clear or plugin unload; long-lived hosts should clear finished contexts.
|
|
43
|
+
|
|
44
|
+
Run the real loop / A2A HTTP / JSONL recovery checks against a prepared upstream checkout:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
DSH_SOURCE_ROOT=/absolute/path/to/deepseek-harness node .github/scripts/check-native-inbox.mjs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The Integration workflow runs this suite without secrets, including on fork PRs, against a pinned upstream source checkout plus the checked-in native inbox source companion. It verifies insertion during an active turn, FIFO execution, independent task completion, and exactly one user-message commit per input.
|
|
51
|
+
|
|
52
|
+
The checkout needs its installed dependencies and `pnpm run build:native-system`; model responses are scripted, so this check needs no gateway or model credentials. The upstream source companion is required before native mode can be released; no dependency artifact patch is installed by this plugin.
|
|
53
|
+
|
|
15
54
|
## Configuration
|
|
16
55
|
|
|
17
56
|
Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
package/lib/bridge.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* autonomous turns through `a2a/task-settlement`.
|
|
15
15
|
*/
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
17
18
|
import { TaskState } from '@a2a-js/sdk';
|
|
18
19
|
import { AgentEvent } from '@a2a-js/sdk/server';
|
|
19
20
|
// installModelSelection is a root-module runtime import: the dsh-agent index
|
|
@@ -30,6 +31,7 @@ import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './tra
|
|
|
30
31
|
export class A2aBridge {
|
|
31
32
|
ctx;
|
|
32
33
|
options;
|
|
34
|
+
inbox;
|
|
33
35
|
tasks = new Map(); // taskId → entry
|
|
34
36
|
bySession = new Map(); // sessionId (== contextId) → entry
|
|
35
37
|
clearing = new Set();
|
|
@@ -117,8 +119,8 @@ export class A2aBridge {
|
|
|
117
119
|
this.creating.delete(contextId);
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
|
-
async ensureTaskInner(taskId, contextId) {
|
|
121
|
-
if (this.clearing.has(contextId))
|
|
122
|
+
async ensureTaskInner(taskId, contextId, clearing = false) {
|
|
123
|
+
if (this.clearing.has(contextId) && !clearing)
|
|
122
124
|
throw new Error('Context is being cleared.');
|
|
123
125
|
const existing = this.tasks.get(taskId);
|
|
124
126
|
if (existing)
|
|
@@ -185,12 +187,18 @@ export class A2aBridge {
|
|
|
185
187
|
const persistence = this.ctx.get('sessionPersistence');
|
|
186
188
|
const persisted = await persistence?.stat(sessionId);
|
|
187
189
|
const handle = persisted
|
|
188
|
-
? await this.ctx.agents.resume({
|
|
190
|
+
? await this.ctx.agents.resume({
|
|
191
|
+
resumeSessionId: sessionId,
|
|
192
|
+
agentOptions,
|
|
193
|
+
setup,
|
|
194
|
+
...(this.inbox ? { keepInboxOnDispose: true } : {}),
|
|
195
|
+
})
|
|
189
196
|
: await this.ctx.agents.create({
|
|
190
197
|
sessionId,
|
|
191
198
|
meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
|
|
192
199
|
agentOptions,
|
|
193
200
|
setup,
|
|
201
|
+
...(this.inbox ? { keepInboxOnDispose: true } : {}),
|
|
194
202
|
});
|
|
195
203
|
const entry = {
|
|
196
204
|
taskId,
|
|
@@ -291,6 +299,19 @@ export class A2aBridge {
|
|
|
291
299
|
async waitForTurn(entry) {
|
|
292
300
|
await Promise.allSettled([...entry.running]);
|
|
293
301
|
}
|
|
302
|
+
/** The native inbox selects task ownership before assembling its first prompt. */
|
|
303
|
+
bindInboxTask(entry, taskId, translator, bus, completion, controller) {
|
|
304
|
+
this.tasks.delete(entry.taskId);
|
|
305
|
+
entry.taskId = taskId;
|
|
306
|
+
entry.translator = translator;
|
|
307
|
+
entry.bus = bus;
|
|
308
|
+
entry.interrupted = false;
|
|
309
|
+
entry.settlementAbort = controller;
|
|
310
|
+
entry.running.clear();
|
|
311
|
+
entry.running.add(completion);
|
|
312
|
+
void completion.then(() => entry.running.delete(completion));
|
|
313
|
+
this.tasks.set(taskId, entry);
|
|
314
|
+
}
|
|
294
315
|
/** Inspect a message for a live task before the SDK starts its next request. */
|
|
295
316
|
async beforeWorkingTaskMessage(a2aMessage, requestHeaders) {
|
|
296
317
|
const entry = this.tasks.get(a2aMessage.taskId);
|
|
@@ -484,9 +505,16 @@ export class A2aBridge {
|
|
|
484
505
|
throw new Error('Context is already being cleared.');
|
|
485
506
|
this.clearing.add(contextId);
|
|
486
507
|
try {
|
|
508
|
+
await this.inbox?.clear(contextId);
|
|
487
509
|
await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
|
|
510
|
+
if (this.inbox && !this.bySession.has(contextId)) {
|
|
511
|
+
const persistence = this.ctx.get('sessionPersistence');
|
|
512
|
+
if (await persistence?.stat(SessionId(contextId)))
|
|
513
|
+
await this.ensureTaskInner(randomUUID(), contextId, true);
|
|
514
|
+
}
|
|
488
515
|
const entry = this.bySession.get(contextId);
|
|
489
516
|
if (entry) {
|
|
517
|
+
entry.handle.agent.inbox?.clear();
|
|
490
518
|
const active = entry.turnActive || entry.running.size > 0;
|
|
491
519
|
this.settleEntry(entry, 'Context cleared.');
|
|
492
520
|
if (active) {
|
|
@@ -530,6 +558,12 @@ export class A2aBridge {
|
|
|
530
558
|
}
|
|
531
559
|
/** Dispose every owned agent (plugin unload / server shutdown). */
|
|
532
560
|
async dispose() {
|
|
561
|
+
try {
|
|
562
|
+
await this.inbox?.shutdown();
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
console.error('[dsh-a2a] native inbox shutdown failed:', error);
|
|
566
|
+
}
|
|
533
567
|
const entries = [...this.tasks.values()];
|
|
534
568
|
this.tasks.clear();
|
|
535
569
|
this.bySession.clear();
|
|
@@ -575,6 +609,14 @@ export class A2aBridge {
|
|
|
575
609
|
}
|
|
576
610
|
if (event.type === 'turn/start')
|
|
577
611
|
entry.turnActive = true;
|
|
612
|
+
if (this.inbox?.onSessionEvent(session, event)) {
|
|
613
|
+
if (event.type === 'turn/end') {
|
|
614
|
+
this.withdrawApprovals(entry);
|
|
615
|
+
this.asked.delete(session.id);
|
|
616
|
+
entry.turnActive = false;
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
578
620
|
// No-throw seam: translation errors must never reach the agent loop. The
|
|
579
621
|
// turn/end waiter still resolves below, so a poisoned event cannot hang
|
|
580
622
|
// an in-flight execute() either.
|
|
@@ -667,6 +709,8 @@ export class A2aBridge {
|
|
|
667
709
|
* `assistant/message` events). Same no-throw seam as the session feed.
|
|
668
710
|
*/
|
|
669
711
|
onAssistantStream(agent, frame) {
|
|
712
|
+
if (this.inbox?.onAssistantStream(agent, frame))
|
|
713
|
+
return;
|
|
670
714
|
const entry = this.bySession.get(agent.session.id);
|
|
671
715
|
if (!entry || entry.staleTurn)
|
|
672
716
|
return;
|
|
@@ -679,6 +723,7 @@ export class A2aBridge {
|
|
|
679
723
|
}
|
|
680
724
|
}
|
|
681
725
|
onSessionDisposed(session) {
|
|
726
|
+
this.inbox?.onDisposed(session);
|
|
682
727
|
this.children.delete(session);
|
|
683
728
|
const entry = this.bySession.get(session.id);
|
|
684
729
|
if (!entry)
|
package/lib/executor.js
CHANGED
|
@@ -29,6 +29,9 @@ export class DshAgentExecutor {
|
|
|
29
29
|
this.suppressTask = suppressTask;
|
|
30
30
|
}
|
|
31
31
|
async execute(requestContext, eventBus) {
|
|
32
|
+
if (this.bridge.inbox && !this.bridge.validateApprovalMessage(requestContext.userMessage)) {
|
|
33
|
+
return this.bridge.trackExecution(requestContext.contextId, () => this.bridge.inbox.execute(requestContext, eventBus));
|
|
34
|
+
}
|
|
32
35
|
if (this.bridge.isClearing(requestContext.contextId)) {
|
|
33
36
|
this.suppressTask?.(requestContext.taskId);
|
|
34
37
|
}
|
|
@@ -87,7 +90,8 @@ export class DshAgentExecutor {
|
|
|
87
90
|
// canceled statusUpdate via the translator — a harmless duplicate); the
|
|
88
91
|
// published event moves the persisted shell to canceled through the
|
|
89
92
|
// handler's ResultManager either way.
|
|
90
|
-
const contextId = this.bridge.cancel(taskId) ??
|
|
93
|
+
const contextId = (this.bridge.inbox ? await this.bridge.inbox.cancel(taskId) : this.bridge.cancel(taskId)) ??
|
|
94
|
+
'';
|
|
91
95
|
eventBus.publish(terminalStatusUpdate(taskId, contextId, 'canceled', 'Task canceled by request.'));
|
|
92
96
|
};
|
|
93
97
|
}
|