@amaster.ai/dsh-a2a 0.1.24 → 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 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({ resumeSessionId: sessionId, agentOptions, setup })
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
  }