@amaster.ai/dsh-a2a 0.1.26 → 0.1.27

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
@@ -14,42 +14,13 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
14
14
 
15
15
  ## Native inbox execution
16
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.
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
- 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.
19
+ Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and subscriptions remain the wire interface. The negotiated [inbox extension](../../docs/protocol/inbox-v1.md) 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
- 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.
21
+ The same-process `a2aInbox` service exposes `resolve(contextId)`, `content(message, contextId)`, `enqueue(taskId, message, { target?, wakeup? })`, 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
- 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.
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 follows the official runtime's cancellation behavior. Durable A2A task snapshots still require a Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
53
24
 
54
25
  ## Configuration
55
26
 
package/lib/bridge.js CHANGED
@@ -191,14 +191,12 @@ export class A2aBridge {
191
191
  resumeSessionId: sessionId,
192
192
  agentOptions,
193
193
  setup,
194
- ...(this.inbox ? { keepInboxOnDispose: true } : {}),
195
194
  })
196
195
  : await this.ctx.agents.create({
197
196
  sessionId,
198
197
  meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
199
198
  agentOptions,
200
199
  setup,
201
- ...(this.inbox ? { keepInboxOnDispose: true } : {}),
202
200
  });
203
201
  const entry = {
204
202
  taskId,
@@ -299,7 +297,7 @@ export class A2aBridge {
299
297
  async waitForTurn(entry) {
300
298
  await Promise.allSettled([...entry.running]);
301
299
  }
302
- /** The native inbox selects task ownership before assembling its first prompt. */
300
+ /** The official inbox claim selects the task that owns the next model step. */
303
301
  bindInboxTask(entry, taskId, translator, bus, completion, controller) {
304
302
  this.tasks.delete(entry.taskId);
305
303
  entry.taskId = taskId;
package/lib/inbox.js CHANGED
@@ -5,48 +5,6 @@ import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/s
5
5
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
6
6
  import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
7
7
  export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md';
8
- /** Fold native input mutations; a claimed input is recoverable only before user/message. */
9
- export function recoverInbox(events) {
10
- const pending = new Map();
11
- const lists = { 'next-step': [], 'next-turn': [] };
12
- let paused = false;
13
- let claimed = new Set();
14
- const committed = new Set();
15
- for (const event of events) {
16
- if (event.type === 'agent/inbox/paused')
17
- paused = event.data.paused;
18
- else if (event.type === 'turn/start') {
19
- claimed = new Set();
20
- }
21
- else if (event.type === 'turn/end') {
22
- if (!paused &&
23
- (event.data.reason.kind === 'error' ||
24
- event.data.reason.kind === 'blocked' ||
25
- (event.data.reason.kind === 'aborted' && event.data.reason.reason.kind !== 'disposed')))
26
- for (const id of claimed)
27
- pending.delete(id);
28
- }
29
- else if (event.type === 'agent/inbox/spliced') {
30
- const { target, start, removedCount = 0, inserted, outcome } = event.data;
31
- const removed = lists[target].splice(start, removedCount, ...inserted);
32
- for (const message of removed) {
33
- if (outcome === 'canceled')
34
- pending.delete(message.id);
35
- else
36
- claimed.add(message.id);
37
- }
38
- for (const message of inserted)
39
- if (message.source.kind === 'a2a')
40
- pending.set(message.id, message);
41
- }
42
- else if (event.type === 'user/message') {
43
- pending.delete(event.data.id);
44
- if (event.data.source.kind === 'a2a')
45
- committed.add(event.data.source.taskId);
46
- }
47
- }
48
- return { paused, pending: [...pending.values()], committed };
49
- }
50
8
  export class NativeInbox {
51
9
  ctx;
52
10
  bridge;
@@ -55,17 +13,33 @@ export class NativeInbox {
55
13
  resolving = new Map();
56
14
  stopping = false;
57
15
  shutdownPromise;
58
- waking = new Map();
59
16
  preadmitted = new WeakMap();
60
17
  server;
61
18
  constructor(ctx, bridge, store) {
62
19
  this.ctx = ctx;
63
20
  this.bridge = bridge;
64
21
  this.store = store;
65
- if (ctx.agents.nativeInboxVersion !== 1)
66
- throw new Error('Native inbox requires a DSH factory with nativeInboxVersion 1. Upgrade DSH before enabling inbox.enabled.');
67
22
  bridge.inbox = this;
68
- ctx.on('agent/before-assembly', (detail) => this.prepare(detail));
23
+ ctx.on('agent/pre-step', async (detail, next) => {
24
+ await this.prepare(detail);
25
+ return next();
26
+ });
27
+ ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
28
+ const run = this.contexts.get(agent.session.id);
29
+ if (!run || message.source.kind !== 'a2a')
30
+ return;
31
+ const task = run.tasks.get(message.source.taskId);
32
+ if (task && !task.settled) {
33
+ run.claimed.add(task);
34
+ if (!run.active)
35
+ this.select(run, task);
36
+ if (run.active === task) {
37
+ const selectedAt = Date.now();
38
+ task.selectedAt.set(message.id, selectedAt);
39
+ this.observeInput('selected', run, message, { selectedAt, turn });
40
+ }
41
+ }
42
+ });
69
43
  }
70
44
  content(message, contextId) {
71
45
  return this.bridge.buildContent(message, contextId);
@@ -85,9 +59,7 @@ export class NativeInbox {
85
59
  const admitted = { ...message, taskId, contextId: run.entry.sessionId };
86
60
  task.pendingRequests++;
87
61
  try {
88
- await this.admit(run, task, admitted, task.context, options.target ?? 'next-turn');
89
- if (options.wakeup !== false && !run.paused)
90
- await this.wake(run.entry.sessionId, { context: task.context });
62
+ await this.admit(run, task, admitted, task.context, options.target ?? 'next-turn', options.wakeup !== false);
91
63
  }
92
64
  finally {
93
65
  task.pendingRequests--;
@@ -97,7 +69,7 @@ export class NativeInbox {
97
69
  }
98
70
  throw new Error('Host input must address a live A2A task.');
99
71
  }
100
- context(contextId) {
72
+ context(contextId, context = new ServerCallContext()) {
101
73
  if (this.stopping)
102
74
  return Promise.reject(new Error('Native inbox is unloading.'));
103
75
  const existing = this.contexts.get(contextId);
@@ -107,25 +79,29 @@ export class NativeInbox {
107
79
  if (!creating) {
108
80
  creating = (async () => {
109
81
  const { entry } = await this.bridge.ensureTask(randomUUID(), contextId);
110
- const agent = entry.handle.agent;
111
- if (typeof agent.wake !== 'function' ||
112
- typeof agent.inbox.setPaused !== 'function')
113
- throw new Error('Native inbox requires DSH Agent.wake(), agent/before-assembly, and inbox-preserving disposal. See the native inbox compatibility notes.');
114
- const recovered = recoverInbox(agent.session.snapshotEvents());
115
82
  const run = {
116
83
  entry,
117
- paused: recovered.paused,
118
84
  tasks: new Map(),
85
+ claimed: new Set(),
119
86
  admissions: new Set(),
120
87
  admissionTail: Promise.resolve(),
121
88
  };
122
- this.contexts.set(contextId, run);
123
- const queued = new Set([...agent.inbox.nextStep, ...agent.inbox.nextTurn].map((message) => message.id));
124
- for (const message of recovered.pending.slice().reverse()) {
125
- if (!queued.has(message.id) && message.source.kind === 'a2a')
126
- agent.inbox.prepend(message.source.target, message);
89
+ const snapshots = new Map();
90
+ for (const message of this.pending(run)) {
91
+ if (message.source.kind !== 'a2a' || snapshots.has(message.source.taskId))
92
+ continue;
93
+ const snapshot = await this.store.load(message.source.taskId, context);
94
+ if (!snapshot || snapshot.contextId !== contextId)
95
+ throw new Error('Queued A2A task is not accessible.');
96
+ if (this.terminal(snapshot))
97
+ entry.handle.agent.inbox.remove(message.id);
98
+ else
99
+ snapshots.set(snapshot.id, snapshot);
127
100
  }
128
- await this.flush(agent.session);
101
+ await this.flush(entry.handle.agent.session);
102
+ for (const snapshot of snapshots.values())
103
+ this.restoreTask(run, snapshot, context);
104
+ this.contexts.set(contextId, run);
129
105
  this.observe('resolved', run);
130
106
  for (const message of this.pending(run))
131
107
  this.observeInput('restored', run, message);
@@ -144,7 +120,6 @@ export class NativeInbox {
144
120
  contextId: run.entry.sessionId,
145
121
  at: Date.now(),
146
122
  pending: pending.length,
147
- paused: run.paused,
148
123
  agentStatus: run.entry.handle.agent.status,
149
124
  taskIds: [
150
125
  ...new Set([...run.tasks.values()]
@@ -198,6 +173,7 @@ export class NativeInbox {
198
173
  task(task, bus, context) {
199
174
  let complete;
200
175
  let finished = false;
176
+ const selectedAt = new Map();
201
177
  const done = new Promise((resolve) => {
202
178
  complete = resolve;
203
179
  });
@@ -209,6 +185,7 @@ export class NativeInbox {
209
185
  const run = this.contexts.get(task.contextId);
210
186
  if (phase && run)
211
187
  this.observe(phase, run, { taskId: task.id });
188
+ selectedAt.clear();
212
189
  complete();
213
190
  };
214
191
  return {
@@ -219,6 +196,7 @@ export class NativeInbox {
219
196
  finish,
220
197
  settled: false,
221
198
  headers: new Map(),
199
+ selectedAt,
222
200
  controller: new AbortController(),
223
201
  pendingRequests: 0,
224
202
  context: new ServerCallContext({ tenant: context.tenant, user: context.user }),
@@ -260,13 +238,13 @@ export class NativeInbox {
260
238
  (message.contextId && snapshot.contextId !== message.contextId) ||
261
239
  this.terminal(snapshot))
262
240
  return;
263
- const run = await this.context(snapshot.contextId);
241
+ const run = await this.context(snapshot.contextId, context);
264
242
  let task = run.tasks.get(snapshot.id);
265
243
  if (!task)
266
244
  task = this.restoreTask(run, snapshot, context);
267
245
  task.pendingRequests++;
268
246
  try {
269
- await this.admit(run, task, message, context, controls.target);
247
+ await this.admit(run, task, message, context, controls.target, controls.wakeup);
270
248
  }
271
249
  catch (error) {
272
250
  task.pendingRequests--;
@@ -291,9 +269,12 @@ export class NativeInbox {
291
269
  const snapshot = await this.store.load(taskId, context);
292
270
  if (!snapshot || this.terminal(snapshot))
293
271
  return;
294
- const run = await this.context(snapshot.contextId);
295
- if (!run.tasks.has(taskId))
296
- this.restoreTask(run, snapshot, context);
272
+ const run = await this.context(snapshot.contextId, context);
273
+ const task = run.tasks.get(taskId) ?? this.restoreTask(run, snapshot, context);
274
+ if (task.settled) {
275
+ await task.done;
276
+ await this.checkpoint(structuredClone(task.task), task.context);
277
+ }
297
278
  }
298
279
  outcome(task) {
299
280
  if (task.status?.state === TaskState.TASK_STATE_COMPLETED)
@@ -313,7 +294,7 @@ export class NativeInbox {
313
294
  TaskState.TASK_STATE_REJECTED,
314
295
  ].includes(task.status.state));
315
296
  }
316
- async admit(run, task, a2aMessage, context, target) {
297
+ async admit(run, task, a2aMessage, context, target, wakeup, accepted) {
317
298
  if (target === 'next-step' && run.active !== task)
318
299
  throw new Error('Steering must address an active task.');
319
300
  const duplicate = () => [...run.tasks.values()].some((owned) => owned.task.history.some((message) => message.messageId === a2aMessage.messageId));
@@ -364,9 +345,10 @@ export class NativeInbox {
364
345
  abort.signal.throwIfAborted();
365
346
  task.task.history = acceptedHistory;
366
347
  task.headers.set(message.id, headers);
367
- run.entry.handle.agent.send(message, target, false);
368
- await this.flush(run.entry.handle.agent.session);
348
+ accepted?.();
349
+ run.entry.handle.agent.send(message, target, wakeup);
369
350
  this.observeInput('admitted', run, message);
351
+ await this.flush(run.entry.handle.agent.session);
370
352
  void this.ctx
371
353
  .parallel('a2a/message-admitted', {
372
354
  contextId: run.entry.sessionId,
@@ -403,7 +385,7 @@ export class NativeInbox {
403
385
  let ownsTask = false;
404
386
  try {
405
387
  const controls = this.controls(userMessage, context);
406
- const run = await this.context(contextId);
388
+ const run = await this.context(contextId, context);
407
389
  task = run.tasks.get(taskId);
408
390
  if (!task) {
409
391
  task = this.task({
@@ -423,13 +405,12 @@ export class NativeInbox {
423
405
  }
424
406
  const preadmitted = this.preadmitted.get(context)?.delete(userMessage.messageId);
425
407
  if (!preadmitted)
426
- await this.admit(run, task, userMessage, context, controls.target);
427
- else
408
+ await this.admit(run, task, userMessage, context, controls.target, controls.wakeup, () => bus.publish(AgentEvent.task(structuredClone(task.task))));
409
+ else {
428
410
  task.pendingRequests--;
429
- bus.publish(AgentEvent.task(structuredClone(task.task)));
411
+ bus.publish(AgentEvent.task(structuredClone(task.task)));
412
+ }
430
413
  this.settle(run, task);
431
- if (controls.wakeup && !run.paused && !task.settled)
432
- await this.wake(contextId, { context });
433
414
  await task.done;
434
415
  if (task.bus !== bus && task.final)
435
416
  bus.publish(task.final);
@@ -455,13 +436,12 @@ export class NativeInbox {
455
436
  metadata: undefined,
456
437
  };
457
438
  bus.publish(AgentEvent.task(snapshot));
458
- bus.publish(terminalStatusUpdate(taskId, contextId, 'failed', text));
459
439
  if (task) {
460
- task.task.status = snapshot.status;
461
- task.settled = true;
462
- task.controller.abort();
463
- task.finish();
440
+ this.fail(task, contextId, error);
441
+ await task.done;
464
442
  }
443
+ else
444
+ bus.publish(terminalStatusUpdate(taskId, contextId, 'failed', text));
465
445
  }
466
446
  finally {
467
447
  bus.finished();
@@ -475,18 +455,26 @@ export class NativeInbox {
475
455
  run.tasks.set(snapshot.id, restored);
476
456
  void restored.done.finally(() => bus.finished());
477
457
  bus.publish(AgentEvent.task(snapshot));
458
+ if (snapshot.status?.state !== TaskState.TASK_STATE_INPUT_REQUIRED &&
459
+ !this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === snapshot.id))
460
+ this.fail(restored, snapshot.contextId, new Error('Task execution was interrupted; DSH has no pending input to resume.'));
478
461
  return restored;
479
462
  }
480
- drive(run) {
481
- run.entry.handle.agent.wake();
463
+ select(run, task) {
464
+ run.active = task;
465
+ this.bridge.bindInboxTask(run.entry, task.task.id, task.translator, task.bus, task.done, task.controller);
466
+ if (run.turnStart) {
467
+ this.publish(task, run.turnStart);
468
+ run.turnStart = undefined;
469
+ }
482
470
  }
483
471
  async prepare(detail) {
484
472
  const run = this.contexts.get(detail.agent.session.id);
485
473
  if (!run)
486
474
  return;
487
- if (this.stopping || run.paused)
488
- throw new Error('Inbox is paused.');
489
- if (detail.messages.some((message) => message.source.kind !== 'a2a'))
475
+ if (this.stopping)
476
+ throw new Error('Inbox is unloading.');
477
+ if (!run.active && detail.messages.some((message) => message.source.kind !== 'a2a'))
490
478
  throw new Error('Native host input requires task-attributed a2aInbox.enqueue().');
491
479
  // Restarted steering and an ordinary queued turn may be claimed together by the core.
492
480
  // Requeue other owners before committing any user message, keeping task lifecycles separate.
@@ -494,8 +482,14 @@ export class NativeInbox {
494
482
  if (owner?.source.kind === 'a2a') {
495
483
  const taskId = owner.source.taskId;
496
484
  const deferred = detail.messages.filter((message) => message.source.kind === 'a2a' && message.source.taskId !== taskId);
497
- for (const message of deferred.slice().reverse())
485
+ for (const message of deferred.slice().reverse()) {
498
486
  detail.agent.inbox.prepend('next-turn', message);
487
+ if (message.source.kind === 'a2a') {
488
+ const task = run.tasks.get(message.source.taskId);
489
+ if (task)
490
+ run.claimed.delete(task);
491
+ }
492
+ }
499
493
  for (let index = detail.messages.length - 1; index >= 0; index--)
500
494
  if (deferred.includes(detail.messages[index]))
501
495
  detail.messages.splice(index, 1);
@@ -508,18 +502,12 @@ export class NativeInbox {
508
502
  throw new Error('Queued input has no live A2A task; restore it before waking.');
509
503
  if (run.active && run.active !== task)
510
504
  throw new Error('A model step cannot consume inputs from different A2A tasks.');
511
- run.active = task;
512
- this.bridge.bindInboxTask(run.entry, task.task.id, task.translator, task.bus, task.done, task.controller);
513
- if (run.turnStart) {
514
- this.publish(task, run.turnStart);
515
- run.turnStart = undefined;
516
- }
517
- const selectedAt = Date.now();
505
+ this.select(run, task);
506
+ const selectedAt = task.selectedAt.get(message.id) ?? Date.now();
518
507
  const timing = { selectedAt, turn: detail.turn, step: detail.step };
519
- this.observeInput('selected', run, message, timing);
520
508
  try {
521
509
  await this.flush(detail.agent.session);
522
- await this.ctx.serial('a2a/before-assembly', {
510
+ await this.ctx.serial('a2a/before-step', {
523
511
  agent: detail.agent,
524
512
  contextId: detail.agent.session.id,
525
513
  taskId: task.task.id,
@@ -548,15 +536,19 @@ export class NativeInbox {
548
536
  const run = this.contexts.get(session.id);
549
537
  if (!run)
550
538
  return false;
551
- if (event.type === 'agent/inbox/paused') {
552
- run.paused = event.data.paused;
553
- return true;
554
- }
555
539
  if (event.type === 'turn/start') {
540
+ run.claimed.clear();
556
541
  run.turnStart = event;
557
542
  return true;
558
543
  }
559
544
  const task = run.active;
545
+ if (event.type === 'turn/end') {
546
+ if (!this.stopping && ['error', 'blocked', 'aborted'].includes(event.data.reason.kind))
547
+ for (const owner of run.claimed)
548
+ if (owner !== task && !owner.settled)
549
+ this.fail(owner, session.id, new Error('Selected input was interrupted before its task step.'));
550
+ run.claimed.clear();
551
+ }
560
552
  if (!task)
561
553
  return true;
562
554
  if (task.settled) {
@@ -573,19 +565,14 @@ export class NativeInbox {
573
565
  if (this.stopping)
574
566
  return true;
575
567
  const outputs = task.translator.handle(event);
576
- if (run.paused) {
577
- void this.cancel(task.task.id, { retainInbox: true }).catch((error) => console.error('[dsh-a2a] pause cancellation failed:', error));
578
- return true;
579
- }
580
568
  task.final = outputs.at(-1);
581
- if (event.data.reason.kind === 'error' || event.data.reason.kind === 'blocked') {
582
- void run.entry.handle.agent
583
- .whenIdle()
584
- .then(() => {
585
- if (!this.stopping && !run.paused && !this.bridge.isClearing(session.id))
586
- this.drive(run);
587
- })
588
- .catch((error) => console.error('[dsh-a2a] queue continuation failed:', error));
569
+ if (task.final?.kind === 'statusUpdate' &&
570
+ [TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED].includes(task.final.data.status.state)) {
571
+ // Official inbox edits append session events; wait until turn/end publication exits.
572
+ void Promise.resolve()
573
+ .then(() => this.settle(run, task))
574
+ .catch((error) => this.fail(task, session.id, error));
575
+ return true;
589
576
  }
590
577
  if (!task.settlementStarted) {
591
578
  task.settlementStarted = true;
@@ -611,10 +598,14 @@ export class NativeInbox {
611
598
  return true;
612
599
  }
613
600
  settle(run, task) {
614
- if (task.settled ||
615
- this.stopping ||
616
- run.paused ||
617
- !task.settlementReady ||
601
+ const terminal = task.final?.kind === 'statusUpdate' &&
602
+ [TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED].includes(task.final.data.status.state);
603
+ if (task.settled || this.stopping)
604
+ return;
605
+ if (terminal) {
606
+ this.discard(run, task.task.id);
607
+ }
608
+ else if (!task.settlementReady ||
618
609
  task.pendingRequests ||
619
610
  run.active === task ||
620
611
  this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === task.task.id))
@@ -650,146 +641,44 @@ export class NativeInbox {
650
641
  }
651
642
  fail(task, contextId, error) {
652
643
  console.error('[dsh-a2a] native inbox failed:', error);
653
- if (!task.settled) {
654
- task.final = terminalStatusUpdate(task.task.id, contextId, 'failed', error instanceof Error ? error.message : String(error));
655
- if (task.final.kind === 'statusUpdate')
656
- task.task.status = task.final.data.status;
657
- task.bus.publish(task.final);
658
- }
644
+ if (task.settled)
645
+ return;
659
646
  task.settled = true;
660
647
  task.controller.abort();
661
- task.headers.clear();
662
- task.finish();
663
- }
664
- async pause(contextId) {
665
- await this.waking.get(contextId);
666
- const run = await this.context(contextId);
667
- run.paused = true;
668
- for (const admission of run.admissions)
669
- admission.abort();
670
- run.entry.handle.agent.inbox.setPaused(true);
671
- this.observe('paused', run);
672
- for (const task of run.tasks.values())
673
- if (!task.settled && task.task.status?.state === TaskState.TASK_STATE_WORKING)
674
- await this.cancel(task.task.id, { retainInbox: true });
675
- run.entry.handle.agent.cancel({ kind: 'user' }, { keepInbox: true });
676
- await run.entry.handle.agent.whenIdle();
677
- await this.flush(run.entry.handle.agent.session);
678
- }
679
- async wake(contextId, options = {}) {
680
- const operation = (this.waking.get(contextId) ?? Promise.resolve())
681
- .catch(() => { })
682
- .then(() => this.wakeInner(contextId, options));
683
- this.waking.set(contextId, operation);
684
- const release = () => {
685
- if (this.waking.get(contextId) === operation)
686
- this.waking.delete(contextId);
687
- };
688
- void operation.then(release, release);
689
- return operation;
690
- }
691
- async wakeInner(contextId, options) {
692
- const run = await this.context(contextId);
693
- if (run.paused && !options.resume)
694
- return [];
695
- if (options.resume && run.entry.handle.agent.status === 'idle') {
696
- const recovered = recoverInbox(run.entry.handle.agent.session.snapshotEvents());
697
- const queued = new Set(this.pending(run).map((message) => message.id));
698
- for (const message of recovered.pending.slice().reverse())
699
- if (!queued.has(message.id) && message.source.kind === 'a2a')
700
- run.entry.handle.agent.inbox.prepend(message.source.target, message);
701
- }
702
- const context = options.context ?? new ServerCallContext();
703
- const tasks = new Set();
704
- const reassigned = new Map();
705
- for (const message of this.pending(run)) {
706
- if (message.source.kind !== 'a2a')
707
- throw new Error('Cannot wake input without A2A task attribution.');
708
- const oldId = message.source.taskId;
709
- let id = oldId;
710
- if (run.tasks.has(id) && !run.tasks.get(id).settled) {
711
- tasks.add(id);
712
- continue;
713
- }
714
- if (!this.server)
715
- throw new Error('A2A task recovery requires the running server.');
716
- let task = run.tasks.get(id)?.task ?? (await this.store.load(id, context));
717
- if (!task || task.contextId !== contextId)
718
- throw new Error('Queued A2A task is not accessible.');
719
- if (this.terminal(task)) {
720
- if (!options.resume)
721
- throw new Error('Retained input needs explicit resume under a new task.');
722
- let replacement = reassigned.get(oldId);
723
- const original = Message.fromJSON(JSON.parse(message.source.message));
724
- if (!replacement) {
725
- replacement = {
726
- ...task,
727
- id: randomUUID(),
728
- history: [],
729
- artifacts: [],
730
- status: {
731
- state: TaskState.TASK_STATE_SUBMITTED,
732
- timestamp: new Date().toISOString(),
733
- message: undefined,
734
- },
735
- };
736
- reassigned.set(oldId, replacement);
648
+ task.final = terminalStatusUpdate(task.task.id, contextId, 'failed', error instanceof Error ? error.message : String(error));
649
+ if (task.final.kind === 'statusUpdate')
650
+ task.task.status = task.final.data.status;
651
+ // Cleanup may append inbox events, so it must run outside session publication.
652
+ queueMicrotask(() => {
653
+ try {
654
+ const run = this.contexts.get(contextId);
655
+ if (run) {
656
+ this.discard(run, task.task.id);
657
+ if (run.active === task)
658
+ run.entry.handle.agent.cancel({ kind: 'user' }, { keepInbox: true });
737
659
  }
738
- id = replacement.id;
739
- original.taskId = id;
740
- original.referenceTaskIds = [...new Set([...original.referenceTaskIds, oldId])];
741
- replacement.history.push(original);
742
- await this.checkpoint(replacement, context);
743
- this.observe(this.outcome(task), run, { taskId: oldId });
744
- const replacementInput = {
745
- ...message,
746
- source: {
747
- ...message.source,
748
- taskId: id,
749
- message: JSON.stringify(Message.toJSON(original)),
750
- },
751
- };
752
- run.entry.handle.agent.inbox.replace(message.id, replacementInput);
753
- this.observeInput('reassigned', run, replacementInput);
754
- task = replacement;
755
660
  }
756
- tasks.add(id);
757
- if (run.tasks.has(id))
758
- continue;
759
- this.restoreTask(run, task, context);
760
- }
761
- const recovered = recoverInbox(run.entry.handle.agent.session.snapshotEvents());
762
- for (const id of recovered.committed) {
763
- const owned = run.tasks.get(id);
764
- if (tasks.has(id) ||
765
- (owned && (owned.settled || owned.settlementStarted || run.active === owned)))
766
- continue;
767
- const interrupted = owned?.task ?? (await this.store.load(id, context));
768
- if (!interrupted || interrupted.contextId !== contextId || this.terminal(interrupted))
769
- continue;
770
- const failed = owned ?? this.restoreTask(run, interrupted, context);
771
- this.fail(failed, contextId, new Error('Committed input has no durable terminal task checkpoint; automatic replay is disabled.'));
772
- await this.checkpoint(structuredClone(failed.task), failed.context);
773
- }
774
- if (this.stopping || this.bridge.isClearing(contextId))
775
- return [];
776
- if (options.resume) {
777
- run.paused = false;
778
- run.entry.handle.agent.inbox.setPaused(false);
779
- }
780
- await this.flush(run.entry.handle.agent.session);
781
- this.observe(options.resume ? 'resume' : 'wake', run);
782
- if (tasks.size)
783
- this.drive(run);
784
- return [...tasks];
661
+ catch (cleanupError) {
662
+ console.error('[dsh-a2a] failed task cleanup failed:', cleanupError);
663
+ }
664
+ finally {
665
+ task.headers.clear();
666
+ task.bus.publish(task.final);
667
+ task.finish();
668
+ }
669
+ });
670
+ }
671
+ discard(run, taskId) {
672
+ for (const message of this.pending(run))
673
+ if (message.source.kind === 'a2a' && message.source.taskId === taskId)
674
+ run.entry.handle.agent.inbox.remove(message.id);
785
675
  }
786
- async cancel(taskId, options = {}) {
676
+ async cancel(taskId) {
787
677
  if (this.stopping)
788
678
  throw new Error('Native inbox is unloading.');
789
679
  for (const [contextId, run] of this.contexts) {
790
680
  if (!run.tasks.has(taskId))
791
681
  continue;
792
- await this.waking.get(contextId);
793
682
  const task = run.tasks.get(taskId);
794
683
  if (!task)
795
684
  continue;
@@ -799,7 +688,7 @@ export class NativeInbox {
799
688
  return contextId;
800
689
  let operation = task.cancellation;
801
690
  if (!operation) {
802
- operation = this.cancelTask(run, task, options);
691
+ operation = this.cancelTask(run, task);
803
692
  task.cancellation = operation;
804
693
  void operation.catch(() => {
805
694
  if (task.cancellation === operation)
@@ -811,19 +700,10 @@ export class NativeInbox {
811
700
  }
812
701
  return undefined;
813
702
  }
814
- async cancelTask(run, task, options) {
703
+ async cancelTask(run, task) {
815
704
  const contextId = run.entry.sessionId;
816
705
  const taskId = task.task.id;
817
- if (options.retainInbox) {
818
- run.paused = true;
819
- run.entry.handle.agent.inbox.setPaused(true);
820
- this.observe('paused', run);
821
- }
822
- else {
823
- for (const message of this.pending(run))
824
- if (message.source.kind === 'a2a' && message.source.taskId === taskId)
825
- run.entry.handle.agent.inbox.remove(message.id);
826
- }
706
+ this.discard(run, taskId);
827
707
  task.controller.abort();
828
708
  task.settled = true;
829
709
  task.final = terminalStatusUpdate(taskId, contextId, 'canceled', 'Task canceled by request.');
@@ -831,13 +711,6 @@ export class NativeInbox {
831
711
  task.task.status = task.final.data.status;
832
712
  if (run.active === task) {
833
713
  run.entry.handle.agent.cancel({ kind: 'user' }, { keepInbox: true });
834
- void run.entry.handle.agent
835
- .whenIdle()
836
- .then(() => {
837
- if (!run.paused && !this.stopping)
838
- this.drive(run);
839
- })
840
- .catch((error) => console.error('[dsh-a2a] cancellation failed:', error));
841
714
  }
842
715
  try {
843
716
  try {
@@ -853,7 +726,6 @@ export class NativeInbox {
853
726
  }
854
727
  }
855
728
  async clear(contextId) {
856
- await this.waking.get(contextId);
857
729
  await this.resolving.get(contextId);
858
730
  const run = this.contexts.get(contextId);
859
731
  if (!run)
@@ -864,7 +736,6 @@ export class NativeInbox {
864
736
  .map((task) => task.task.id)
865
737
  .concat(this.pending(run).flatMap((message) => message.source.kind === 'a2a' ? [message.source.taskId] : []))),
866
738
  ];
867
- run.paused = true;
868
739
  for (const admission of run.admissions)
869
740
  admission.abort();
870
741
  run.entry.handle.agent.inbox.clear();
@@ -885,15 +756,20 @@ export class NativeInbox {
885
756
  const run = this.contexts.get(session.id);
886
757
  if (!run)
887
758
  return;
888
- this.observe('disposed', run);
889
759
  for (const admission of run.admissions)
890
760
  admission.abort();
891
761
  for (const task of run.tasks.values()) {
892
- // Disposal interrupts transport ownership; retained durable input keeps its task state.
762
+ if (!task.settled) {
763
+ const final = terminalStatusUpdate(task.task.id, session.id, 'canceled', 'Agent disposed.');
764
+ if (final.kind === 'statusUpdate')
765
+ task.task.status = final.data.status;
766
+ task.bus.publish(final);
767
+ }
893
768
  task.settled = true;
894
769
  task.controller.abort();
895
770
  task.finish();
896
771
  }
772
+ this.observe('disposed', run);
897
773
  this.contexts.delete(session.id);
898
774
  }
899
775
  shutdown() {
@@ -902,7 +778,7 @@ export class NativeInbox {
902
778
  this.stopping = true;
903
779
  this.shutdownPromise = (async () => {
904
780
  const failures = [];
905
- await Promise.allSettled([...this.waking.values(), ...this.resolving.values()]);
781
+ await Promise.allSettled([...this.resolving.values()]);
906
782
  const cancellations = [...this.contexts.values()].flatMap((run) => [...run.tasks.values()].flatMap((task) => (task.cancellation ? [task.cancellation] : [])));
907
783
  for (const outcome of await Promise.allSettled(cancellations))
908
784
  if (outcome.status === 'rejected')
@@ -911,7 +787,7 @@ export class NativeInbox {
911
787
  for (const admission of run.admissions)
912
788
  admission.abort();
913
789
  try {
914
- run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: true });
790
+ run.entry.handle.agent.cancel({ kind: 'disposed' });
915
791
  }
916
792
  catch (error) {
917
793
  failures.push(error);
@@ -929,11 +805,17 @@ export class NativeInbox {
929
805
  failures.push(error);
930
806
  }
931
807
  finally {
932
- this.observe('disposed', run);
933
808
  for (const task of run.tasks.values()) {
934
- task.controller.abort();
809
+ try {
810
+ if (!task.settled)
811
+ await this.cancelTask(run, task);
812
+ }
813
+ catch (error) {
814
+ failures.push(error);
815
+ }
935
816
  task.finish();
936
817
  }
818
+ this.observe('disposed', run);
937
819
  }
938
820
  }
939
821
  if (failures.length)
package/lib/index.js CHANGED
@@ -29,7 +29,7 @@ export const name = 'dsh-a2a';
29
29
  /** The bridge creates and owns agents through the registry service. */
30
30
  export const inject = ['agents'];
31
31
  export const Config = Schema.object({
32
- inbox: Schema.object({ enabled: Schema.boolean().default(false) }).description('Use durable native inbox execution (requires the upstream native inbox capability).'),
32
+ inbox: Schema.object({ enabled: Schema.boolean().default(false) }).description('Use the official DSH inbox for queued turns and steering.'),
33
33
  enabled: Schema.boolean().default(false),
34
34
  host: Schema.string().default('127.0.0.1'),
35
35
  port: Schema.natural().default(41241),
@@ -102,7 +102,7 @@ export declare class A2aBridge {
102
102
  };
103
103
  private findApproval;
104
104
  waitForTurn(entry: TaskEntry): Promise<void>;
105
- /** The native inbox selects task ownership before assembling its first prompt. */
105
+ /** The official inbox claim selects the task that owns the next model step. */
106
106
  bindInboxTask(entry: TaskEntry, taskId: string, translator: SessionTranslator, bus: ExecutionEventBus, completion: Promise<void>, controller: AbortController): void;
107
107
  /** Inspect a message for a live task before the SDK starts its next request. */
108
108
  beforeWorkingTaskMessage(a2aMessage: Message, requestHeaders: unknown): Promise<void>;
@@ -2,7 +2,7 @@ import { Message } from '@a2a-js/sdk';
2
2
  import { type ExecutionEventBus, type RequestContext, ServerCallContext } from '@a2a-js/sdk/server';
3
3
  import type { Context } from '@deepseek-ai/cordis';
4
4
  import type { Agent, AssistantStreamFrame, InboxTarget } from '@deepseek-ai/dsh-agent';
5
- import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types';
5
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
6
6
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
7
7
  import type { A2aBridge } from './bridge.js';
8
8
  import type { A2aTurnContext } from './index.js';
@@ -17,7 +17,6 @@ export interface A2aInboxEvent {
17
17
  target?: 'next-turn' | 'next-step';
18
18
  input?: string;
19
19
  position?: number;
20
- paused?: boolean;
21
20
  agentStatus?: string;
22
21
  taskId?: string;
23
22
  taskIds?: string[];
@@ -42,26 +41,12 @@ declare module '@deepseek-ai/dsh-llm/types' {
42
41
  a2a: InboxSource;
43
42
  }
44
43
  }
45
- declare module '@deepseek-ai/dsh-session/types' {
46
- interface SessionEventMap {
47
- 'agent/inbox/paused': {
48
- paused: boolean;
49
- };
50
- }
51
- }
52
44
  declare module '@deepseek-ai/cordis' {
53
45
  interface Events {
54
46
  /** Native inbox lifecycle; content consumers must honor their capture settings. */
55
47
  'a2a/inbox'(detail: A2aInboxEvent): void;
56
- 'agent/before-assembly'(detail: {
57
- agent: Agent;
58
- messages: UserMessage[];
59
- turn: number;
60
- step: number;
61
- signal: AbortSignal;
62
- }): void | Promise<void>;
63
- /** Selected input, before system-prompt assembly; restored input has no original headers. */
64
- 'a2a/before-assembly'(detail: A2aTurnContext): void | Promise<void>;
48
+ /** Selected input at the official pre-step boundary, after system-prompt assembly. */
49
+ 'a2a/before-step'(detail: A2aTurnContext): void | Promise<void>;
65
50
  }
66
51
  }
67
52
  /** Same-process queue control; wire messages and task subscriptions remain standard A2A. */
@@ -73,23 +58,9 @@ export interface A2aInbox {
73
58
  target?: InboxTarget;
74
59
  wakeup?: boolean;
75
60
  }): Promise<void>;
76
- pause(contextId: string): Promise<void>;
77
- /** Cancellation is terminal; retained pending input stays paused until explicit resume. */
78
- cancel(taskId: string, options?: {
79
- retainInbox?: boolean;
80
- }): Promise<string | undefined>;
81
- /** Recovery does not override a persisted pause unless resume is explicitly true. */
82
- wake(contextId: string, options?: {
83
- resume?: boolean;
84
- context?: ServerCallContext;
85
- }): Promise<string[]>;
61
+ /** Standard cancellation is terminal and removes only this task's pending input. */
62
+ cancel(taskId: string): Promise<string | undefined>;
86
63
  }
87
- /** Fold native input mutations; a claimed input is recoverable only before user/message. */
88
- export declare function recoverInbox(events: readonly SessionEvent[]): {
89
- paused: boolean;
90
- pending: UserMessage[];
91
- committed: Set<string>;
92
- };
93
64
  export declare class NativeInbox implements A2aInbox {
94
65
  private readonly ctx;
95
66
  private readonly bridge;
@@ -98,7 +69,6 @@ export declare class NativeInbox implements A2aInbox {
98
69
  private readonly resolving;
99
70
  private stopping;
100
71
  private shutdownPromise?;
101
- private readonly waking;
102
72
  private readonly preadmitted;
103
73
  server?: A2aServer;
104
74
  constructor(ctx: Context, bridge: A2aBridge, store: ManagedTaskStore);
@@ -125,22 +95,15 @@ export declare class NativeInbox implements A2aInbox {
125
95
  private admit;
126
96
  execute(request: RequestContext, bus: ExecutionEventBus): Promise<void>;
127
97
  private restoreTask;
128
- private drive;
98
+ private select;
129
99
  private prepare;
130
100
  private publish;
131
101
  onSessionEvent(session: Session, event: SessionEvent): boolean;
132
102
  private settle;
133
103
  onAssistantStream(agent: Agent, frame: AssistantStreamFrame): boolean;
134
104
  private fail;
135
- pause(contextId: string): Promise<void>;
136
- wake(contextId: string, options?: {
137
- resume?: boolean;
138
- context?: ServerCallContext;
139
- }): Promise<string[]>;
140
- private wakeInner;
141
- cancel(taskId: string, options?: {
142
- retainInbox?: boolean;
143
- }): Promise<string | undefined>;
105
+ private discard;
106
+ cancel(taskId: string): Promise<string | undefined>;
144
107
  private cancelTask;
145
108
  clear(contextId: string): Promise<void>;
146
109
  onDisposed(session: Session): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
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",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.1",
41
- "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
41
+ "@deepseek-ai/dsh-agent": ">=0.2.0-rc.2 || >=0.2.1-alpha.1",
42
42
  "@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
43
43
  "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
44
44
  "@deepseek-ai/dsh-session": ">=0.2.0-rc.1",
@@ -63,14 +63,14 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@a2a-js/sdk-v03": "npm:@a2a-js/sdk@0.3.14",
66
- "@deepseek-ai/cordis": "^4.0.4",
67
- "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
68
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
69
- "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
70
- "@deepseek-ai/dsh-session": "0.2.0-rc.2",
71
- "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
72
- "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
73
- "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
66
+ "@deepseek-ai/cordis": "4.0.5",
67
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
68
+ "@deepseek-ai/dsh-attachment": "0.2.1-rc.2",
69
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
70
+ "@deepseek-ai/dsh-session": "0.2.1-rc.2",
71
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.2",
72
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
73
+ "@deepseek-ai/dsh-user-approval": "0.2.1-rc.2",
74
74
  "@google-cloud/storage": "^7.0.0",
75
75
  "@types/express": "^4.17.21",
76
76
  "@types/node": "^22.0.0",