@amaster.ai/dsh-a2a 0.1.26 → 0.1.28

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/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,65 @@ 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('goal/changed', ({ agent, change }) => {
28
+ const run = this.contexts.get(agent.session.id);
29
+ if (!run)
30
+ return;
31
+ const previous = run.goalOwner;
32
+ if (change.goal?.phase === 'active' && change.goal.activation === 'armed') {
33
+ const task = previous?.goalId === change.ref.id ? previous.task : run.active;
34
+ if (task && !task.settled)
35
+ run.goalOwner = { goalId: change.ref.id, task };
36
+ }
37
+ else
38
+ run.goalOwner = undefined;
39
+ if (previous)
40
+ this.settle(run, previous.task);
41
+ });
42
+ ctx.on('agent/status', ({ agent, status }) => {
43
+ const run = this.contexts.get(agent.session.id);
44
+ if (status !== 'idle' || !run?.goalOwner)
45
+ return;
46
+ const { task } = run.goalOwner;
47
+ try {
48
+ const goal = ctx.get('goals')?.get(agent);
49
+ if (goal?.phase === 'active' && goal.activation === 'armed')
50
+ return;
51
+ run.goalOwner = undefined;
52
+ this.settle(run, task);
53
+ }
54
+ catch (error) {
55
+ run.goalOwner = undefined;
56
+ this.fail(task, agent.session.id, error);
57
+ }
58
+ });
59
+ ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
60
+ const run = this.contexts.get(agent.session.id);
61
+ if (!run)
62
+ return;
63
+ const task = this.owner(run, message);
64
+ if (task && !task.settled) {
65
+ run.claimed.add(task);
66
+ if (!run.active)
67
+ this.select(run, task);
68
+ if (run.active === task) {
69
+ const selectedAt = Date.now();
70
+ task.selectedAt.set(message.id, selectedAt);
71
+ this.observeInput('selected', run, message, { selectedAt, turn });
72
+ }
73
+ }
74
+ });
69
75
  }
70
76
  content(message, contextId) {
71
77
  return this.bridge.buildContent(message, contextId);
@@ -85,9 +91,7 @@ export class NativeInbox {
85
91
  const admitted = { ...message, taskId, contextId: run.entry.sessionId };
86
92
  task.pendingRequests++;
87
93
  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 });
94
+ await this.admit(run, task, admitted, task.context, options.target ?? 'next-turn', options.wakeup !== false);
91
95
  }
92
96
  finally {
93
97
  task.pendingRequests--;
@@ -97,7 +101,182 @@ export class NativeInbox {
97
101
  }
98
102
  throw new Error('Host input must address a live A2A task.');
99
103
  }
100
- context(contextId) {
104
+ // A non-canceled removal keeps an interrupted move distinguishable from explicit cancellation.
105
+ moveInput(agent, message, target) {
106
+ const from = target === 'next-step' ? 'next-turn' : 'next-step';
107
+ const items = from === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
108
+ const index = items.findIndex((item) => item.id === message.id);
109
+ if (index < 0)
110
+ throw new Error('Queued input has already been claimed.');
111
+ agent.session.append('agent/inbox/spliced', {
112
+ target: from,
113
+ start: index,
114
+ removedCount: 1,
115
+ inserted: [],
116
+ });
117
+ agent.inbox.append(target, message);
118
+ }
119
+ async promote(contextId, messageId, taskId) {
120
+ const run = await this.context(contextId);
121
+ const operation = run.admissionTail.then(() => this.promoteInput(run, messageId, taskId));
122
+ run.admissionTail = operation.catch(() => { });
123
+ run.promotions.set(messageId, operation);
124
+ const clear = () => {
125
+ if (run.promotions.get(messageId) === operation)
126
+ run.promotions.delete(messageId);
127
+ };
128
+ void operation.then(clear, clear);
129
+ return operation;
130
+ }
131
+ async promoteInput(run, messageId, taskId) {
132
+ const agent = run.entry.handle.agent;
133
+ const target = run.tasks.get(taskId);
134
+ const message = agent.inbox.nextTurn.find((item) => item.id === messageId);
135
+ if (message?.source.kind !== 'a2a')
136
+ throw new Error('Queued input has already been claimed.');
137
+ const source = run.tasks.get(message.source.taskId);
138
+ if (!target || !source)
139
+ throw new Error('Promotion requires live source and target tasks.');
140
+ const valid = () => !this.stopping &&
141
+ !this.bridge.isClearing(run.entry.sessionId) &&
142
+ !target.settled &&
143
+ !target.controller.signal.aborted &&
144
+ run.active === target &&
145
+ source !== target &&
146
+ !source.settled &&
147
+ !source.controller.signal.aborted &&
148
+ source.task.history.length === 1 &&
149
+ !source.pendingRequests &&
150
+ !run.claimed.has(source) &&
151
+ agent.inbox.nextTurn.some((item) => item === message);
152
+ if (!valid())
153
+ throw new Error('Promotion requires an unclaimed independent input and an active target.');
154
+ const original = Message.fromJSON(JSON.parse(message.source.message));
155
+ original.taskId = taskId;
156
+ original.referenceTaskIds = [...new Set([...original.referenceTaskIds, source.task.id])];
157
+ const promotionId = randomUUID();
158
+ const promoted = {
159
+ ...message,
160
+ source: {
161
+ ...message.source,
162
+ taskId,
163
+ target: 'next-step',
164
+ promotedFrom: source.task.id,
165
+ promotionId,
166
+ message: JSON.stringify(Message.toJSON(original)),
167
+ },
168
+ };
169
+ target.pendingRequests++;
170
+ try {
171
+ await this.checkpoint(target.task, target.context);
172
+ if (!valid())
173
+ throw new Error('Promotion raced input consumption or task settlement.');
174
+ target.task.history = [...target.task.history, original];
175
+ target.headers.set(message.id, source.headers.get(message.id));
176
+ if (!agent.inbox.replace(message.id, promoted))
177
+ throw new Error('Queued input has already been claimed.');
178
+ this.moveInput(agent, promoted, 'next-step');
179
+ try {
180
+ await this.flush(agent.session);
181
+ }
182
+ catch (error) {
183
+ // Until the session checkpoint succeeds, the independent task still owns this input.
184
+ const rollback = {
185
+ ...message,
186
+ source: { ...message.source, promotedFrom: source.task.id },
187
+ };
188
+ run.rolledBack.add(promotionId);
189
+ const live = this.contexts.get(run.entry.sessionId) === run &&
190
+ !this.stopping &&
191
+ !this.bridge.isClearing(run.entry.sessionId);
192
+ if (live && !source.settled && !source.controller.signal.aborted) {
193
+ if (this.pending(run).some((item) => item.id === message.id)) {
194
+ agent.inbox.replace(message.id, rollback);
195
+ this.moveInput(agent, rollback, 'next-turn');
196
+ }
197
+ else
198
+ agent.inbox.append('next-turn', rollback);
199
+ }
200
+ else if (live)
201
+ agent.inbox.remove(message.id);
202
+ target.task.history = target.task.history.filter((item) => item.messageId !== original.messageId);
203
+ target.headers.delete(message.id);
204
+ try {
205
+ if (live)
206
+ await this.flush(agent.session);
207
+ }
208
+ catch (rollbackError) {
209
+ console.error('[dsh-a2a] promotion rollback checkpoint failed:', rollbackError);
210
+ }
211
+ throw error;
212
+ }
213
+ if (this.stopping ||
214
+ this.contexts.get(run.entry.sessionId) !== run ||
215
+ this.bridge.isClearing(run.entry.sessionId))
216
+ throw new Error('Promotion was interrupted by context disposal.');
217
+ source.controller.abort();
218
+ try {
219
+ await this.checkpoint(target.task, target.context);
220
+ }
221
+ finally {
222
+ await this.cancel(source.task.id);
223
+ }
224
+ this.observeInput('promoted', run, promoted);
225
+ }
226
+ finally {
227
+ target.pendingRequests--;
228
+ this.settle(run, target);
229
+ }
230
+ }
231
+ /** Repair only an incomplete ownership move, never an ordinary DSH claim. */
232
+ async recoverPromotions(run, context) {
233
+ const agent = run.entry.handle.agent;
234
+ const transfers = new Map();
235
+ for (const event of agent.session.snapshotEvents()) {
236
+ if (event.type !== 'agent/inbox/spliced')
237
+ continue;
238
+ for (const message of event.data.inserted) {
239
+ if (message.source.kind !== 'a2a' || !message.source.promotedFrom)
240
+ continue;
241
+ transfers.set(message.id, {
242
+ message: message,
243
+ placed: event.data.target === message.source.target,
244
+ });
245
+ }
246
+ }
247
+ for (const { message, placed } of transfers.values()) {
248
+ if (!placed) {
249
+ const queued = this.pending(run).find((item) => item.id === message.id);
250
+ if (queued)
251
+ this.moveInput(agent, message, message.source.target);
252
+ else
253
+ agent.inbox.append(message.source.target, message);
254
+ }
255
+ if (this.pending(run).some((item) => item.id === message.id)) {
256
+ const recipient = await this.store.load(message.source.taskId, context);
257
+ if (!recipient || recipient.contextId !== agent.session.id)
258
+ throw new Error('Promoted A2A task is not accessible.');
259
+ const original = Message.fromJSON(JSON.parse(message.source.message));
260
+ if (!recipient.history.some((item) => item.messageId === original.messageId)) {
261
+ recipient.history.push(original);
262
+ await this.checkpoint(recipient, context);
263
+ }
264
+ }
265
+ if (message.source.promotedFrom === message.source.taskId)
266
+ continue;
267
+ const former = await this.store.load(message.source.promotedFrom, context);
268
+ if (!former || this.terminal(former))
269
+ continue;
270
+ former.status = {
271
+ state: TaskState.TASK_STATE_CANCELED,
272
+ message: undefined,
273
+ timestamp: new Date().toISOString(),
274
+ };
275
+ await this.flush(agent.session);
276
+ await this.checkpoint(former, context);
277
+ }
278
+ }
279
+ context(contextId, context = new ServerCallContext()) {
101
280
  if (this.stopping)
102
281
  return Promise.reject(new Error('Native inbox is unloading.'));
103
282
  const existing = this.contexts.get(contextId);
@@ -107,25 +286,32 @@ export class NativeInbox {
107
286
  if (!creating) {
108
287
  creating = (async () => {
109
288
  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
289
  const run = {
116
290
  entry,
117
- paused: recovered.paused,
118
291
  tasks: new Map(),
292
+ claimed: new Set(),
119
293
  admissions: new Set(),
120
294
  admissionTail: Promise.resolve(),
295
+ promotions: new Map(),
296
+ rolledBack: new Set(),
121
297
  };
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);
298
+ await this.recoverPromotions(run, context);
299
+ const snapshots = new Map();
300
+ for (const message of this.pending(run)) {
301
+ if (message.source.kind !== 'a2a' || snapshots.has(message.source.taskId))
302
+ continue;
303
+ const snapshot = await this.store.load(message.source.taskId, context);
304
+ if (!snapshot || snapshot.contextId !== contextId)
305
+ throw new Error('Queued A2A task is not accessible.');
306
+ if (this.terminal(snapshot))
307
+ entry.handle.agent.inbox.remove(message.id);
308
+ else
309
+ snapshots.set(snapshot.id, snapshot);
127
310
  }
128
- await this.flush(agent.session);
311
+ await this.flush(entry.handle.agent.session);
312
+ for (const snapshot of snapshots.values())
313
+ this.restoreTask(run, snapshot, context);
314
+ this.contexts.set(contextId, run);
129
315
  this.observe('resolved', run);
130
316
  for (const message of this.pending(run))
131
317
  this.observeInput('restored', run, message);
@@ -144,7 +330,6 @@ export class NativeInbox {
144
330
  contextId: run.entry.sessionId,
145
331
  at: Date.now(),
146
332
  pending: pending.length,
147
- paused: run.paused,
148
333
  agentStatus: run.entry.handle.agent.status,
149
334
  taskIds: [
150
335
  ...new Set([...run.tasks.values()]
@@ -162,21 +347,22 @@ export class NativeInbox {
162
347
  }
163
348
  observeInput(phase, run, message, fields = {}) {
164
349
  try {
165
- if (message.source.kind !== 'a2a')
350
+ const task = this.owner(run, message);
351
+ if (!task)
166
352
  return;
167
- const original = Message.fromJSON(JSON.parse(message.source.message));
353
+ const original = this.protocolMessage(run, task, message);
168
354
  const position = this.pending(run).findIndex((input) => input.id === message.id);
169
355
  this.observe(phase, run, {
170
- target: message.source.target,
356
+ target: message.source.kind === 'a2a' ? message.source.target : 'next-turn',
171
357
  input: original.parts
172
358
  .flatMap((part) => (part.content?.$case === 'text' ? [part.content.value] : []))
173
359
  .join('\n'),
174
360
  position: position < 0 ? undefined : position + 1,
175
- taskId: message.source.taskId,
361
+ taskId: task.task.id,
176
362
  messageId: original.messageId,
177
363
  referenceTaskIds: original.referenceTaskIds,
178
364
  nativeMessageId: message.id,
179
- queuedAt: message.source.queuedAt,
365
+ queuedAt: message.source.kind === 'a2a' ? message.source.queuedAt : undefined,
180
366
  ...fields,
181
367
  });
182
368
  }
@@ -198,6 +384,7 @@ export class NativeInbox {
198
384
  task(task, bus, context) {
199
385
  let complete;
200
386
  let finished = false;
387
+ const selectedAt = new Map();
201
388
  const done = new Promise((resolve) => {
202
389
  complete = resolve;
203
390
  });
@@ -209,6 +396,7 @@ export class NativeInbox {
209
396
  const run = this.contexts.get(task.contextId);
210
397
  if (phase && run)
211
398
  this.observe(phase, run, { taskId: task.id });
399
+ selectedAt.clear();
212
400
  complete();
213
401
  };
214
402
  return {
@@ -219,6 +407,7 @@ export class NativeInbox {
219
407
  finish,
220
408
  settled: false,
221
409
  headers: new Map(),
410
+ selectedAt,
222
411
  controller: new AbortController(),
223
412
  pendingRequests: 0,
224
413
  context: new ServerCallContext({ tenant: context.tenant, user: context.user }),
@@ -260,13 +449,13 @@ export class NativeInbox {
260
449
  (message.contextId && snapshot.contextId !== message.contextId) ||
261
450
  this.terminal(snapshot))
262
451
  return;
263
- const run = await this.context(snapshot.contextId);
452
+ const run = await this.context(snapshot.contextId, context);
264
453
  let task = run.tasks.get(snapshot.id);
265
454
  if (!task)
266
455
  task = this.restoreTask(run, snapshot, context);
267
456
  task.pendingRequests++;
268
457
  try {
269
- await this.admit(run, task, message, context, controls.target);
458
+ await this.admit(run, task, message, context, controls.target, controls.wakeup);
270
459
  }
271
460
  catch (error) {
272
461
  task.pendingRequests--;
@@ -291,9 +480,12 @@ export class NativeInbox {
291
480
  const snapshot = await this.store.load(taskId, context);
292
481
  if (!snapshot || this.terminal(snapshot))
293
482
  return;
294
- const run = await this.context(snapshot.contextId);
295
- if (!run.tasks.has(taskId))
296
- this.restoreTask(run, snapshot, context);
483
+ const run = await this.context(snapshot.contextId, context);
484
+ const task = run.tasks.get(taskId) ?? this.restoreTask(run, snapshot, context);
485
+ if (task.settled) {
486
+ await task.done;
487
+ await this.checkpoint(structuredClone(task.task), task.context);
488
+ }
297
489
  }
298
490
  outcome(task) {
299
491
  if (task.status?.state === TaskState.TASK_STATE_COMPLETED)
@@ -313,7 +505,7 @@ export class NativeInbox {
313
505
  TaskState.TASK_STATE_REJECTED,
314
506
  ].includes(task.status.state));
315
507
  }
316
- async admit(run, task, a2aMessage, context, target) {
508
+ async admit(run, task, a2aMessage, context, target, wakeup, accepted) {
317
509
  if (target === 'next-step' && run.active !== task)
318
510
  throw new Error('Steering must address an active task.');
319
511
  const duplicate = () => [...run.tasks.values()].some((owned) => owned.task.history.some((message) => message.messageId === a2aMessage.messageId));
@@ -364,9 +556,10 @@ export class NativeInbox {
364
556
  abort.signal.throwIfAborted();
365
557
  task.task.history = acceptedHistory;
366
558
  task.headers.set(message.id, headers);
367
- run.entry.handle.agent.send(message, target, false);
368
- await this.flush(run.entry.handle.agent.session);
559
+ accepted?.();
560
+ run.entry.handle.agent.send(message, target, wakeup);
369
561
  this.observeInput('admitted', run, message);
562
+ await this.flush(run.entry.handle.agent.session);
370
563
  void this.ctx
371
564
  .parallel('a2a/message-admitted', {
372
565
  contextId: run.entry.sessionId,
@@ -403,7 +596,7 @@ export class NativeInbox {
403
596
  let ownsTask = false;
404
597
  try {
405
598
  const controls = this.controls(userMessage, context);
406
- const run = await this.context(contextId);
599
+ const run = await this.context(contextId, context);
407
600
  task = run.tasks.get(taskId);
408
601
  if (!task) {
409
602
  task = this.task({
@@ -423,13 +616,12 @@ export class NativeInbox {
423
616
  }
424
617
  const preadmitted = this.preadmitted.get(context)?.delete(userMessage.messageId);
425
618
  if (!preadmitted)
426
- await this.admit(run, task, userMessage, context, controls.target);
427
- else
619
+ await this.admit(run, task, userMessage, context, controls.target, controls.wakeup, () => bus.publish(AgentEvent.task(structuredClone(task.task))));
620
+ else {
428
621
  task.pendingRequests--;
429
- bus.publish(AgentEvent.task(structuredClone(task.task)));
622
+ bus.publish(AgentEvent.task(structuredClone(task.task)));
623
+ }
430
624
  this.settle(run, task);
431
- if (controls.wakeup && !run.paused && !task.settled)
432
- await this.wake(contextId, { context });
433
625
  await task.done;
434
626
  if (task.bus !== bus && task.final)
435
627
  bus.publish(task.final);
@@ -455,13 +647,12 @@ export class NativeInbox {
455
647
  metadata: undefined,
456
648
  };
457
649
  bus.publish(AgentEvent.task(snapshot));
458
- bus.publish(terminalStatusUpdate(taskId, contextId, 'failed', text));
459
650
  if (task) {
460
- task.task.status = snapshot.status;
461
- task.settled = true;
462
- task.controller.abort();
463
- task.finish();
651
+ this.fail(task, contextId, error);
652
+ await task.done;
464
653
  }
654
+ else
655
+ bus.publish(terminalStatusUpdate(taskId, contextId, 'failed', text));
465
656
  }
466
657
  finally {
467
658
  bus.finished();
@@ -475,18 +666,80 @@ export class NativeInbox {
475
666
  run.tasks.set(snapshot.id, restored);
476
667
  void restored.done.finally(() => bus.finished());
477
668
  bus.publish(AgentEvent.task(snapshot));
669
+ if (snapshot.status?.state !== TaskState.TASK_STATE_INPUT_REQUIRED &&
670
+ !this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === snapshot.id))
671
+ this.fail(restored, snapshot.contextId, new Error('Task execution was interrupted; DSH has no pending input to resume.'));
478
672
  return restored;
479
673
  }
480
- drive(run) {
481
- run.entry.handle.agent.wake();
674
+ select(run, task) {
675
+ run.active = task;
676
+ this.bridge.bindInboxTask(run.entry, task.task.id, task.translator, task.bus, task.done, task.controller);
677
+ if (run.turnStart) {
678
+ this.publish(task, run.turnStart);
679
+ run.turnStart = undefined;
680
+ }
681
+ }
682
+ protocolMessage(run, task, message) {
683
+ if (message.source.kind === 'a2a')
684
+ return Message.fromJSON(JSON.parse(message.source.message));
685
+ return Message.fromJSON({
686
+ messageId: message.id,
687
+ taskId: task.task.id,
688
+ contextId: run.entry.sessionId,
689
+ role: 'ROLE_USER',
690
+ parts: message.content
691
+ .filter((block) => block.type === 'text')
692
+ .map((block) => ({ text: block.text })),
693
+ });
694
+ }
695
+ owner(run, message) {
696
+ if (message.source.kind === 'a2a')
697
+ return run.tasks.get(message.source.taskId);
698
+ if (message.source.kind !== 'goal' || message.source.round <= 0)
699
+ return;
700
+ const goal = this.ctx.get('goals')?.get(run.entry.handle.agent);
701
+ if (run.goalOwner &&
702
+ goal &&
703
+ run.goalOwner.goalId === message.source.goalId &&
704
+ goal.id === message.source.goalId &&
705
+ goal.revision === message.source.revision &&
706
+ goal.phase === 'active' &&
707
+ goal.activation === 'armed')
708
+ return run.goalOwner.task;
482
709
  }
483
710
  async prepare(detail) {
484
711
  const run = this.contexts.get(detail.agent.session.id);
485
712
  if (!run)
486
713
  return;
487
- if (this.stopping || run.paused)
488
- throw new Error('Inbox is paused.');
489
- if (detail.messages.some((message) => message.source.kind !== 'a2a'))
714
+ if (this.stopping)
715
+ throw new Error('Inbox is unloading.');
716
+ for (let index = detail.messages.length - 1; index >= 0; index--) {
717
+ const message = detail.messages[index];
718
+ if (message.source.kind !== 'a2a' || !message.source.promotionId)
719
+ continue;
720
+ const promotion = run.promotions.get(message.id);
721
+ if (promotion) {
722
+ let onAbort;
723
+ try {
724
+ await Promise.race([
725
+ promotion.catch(() => { }),
726
+ new Promise((_resolve, reject) => {
727
+ onAbort = () => reject(detail.signal.reason);
728
+ detail.signal.addEventListener('abort', onAbort, { once: true });
729
+ if (detail.signal.aborted)
730
+ onAbort();
731
+ }),
732
+ ]);
733
+ detail.signal.throwIfAborted();
734
+ }
735
+ finally {
736
+ detail.signal.removeEventListener('abort', onAbort);
737
+ }
738
+ }
739
+ if (run.rolledBack.delete(message.source.promotionId))
740
+ detail.messages.splice(index, 1);
741
+ }
742
+ if (!run.active && detail.messages.some((message) => !this.owner(run, message)))
490
743
  throw new Error('Native host input requires task-attributed a2aInbox.enqueue().');
491
744
  // Restarted steering and an ordinary queued turn may be claimed together by the core.
492
745
  // Requeue other owners before committing any user message, keeping task lifecycles separate.
@@ -494,36 +747,36 @@ export class NativeInbox {
494
747
  if (owner?.source.kind === 'a2a') {
495
748
  const taskId = owner.source.taskId;
496
749
  const deferred = detail.messages.filter((message) => message.source.kind === 'a2a' && message.source.taskId !== taskId);
497
- for (const message of deferred.slice().reverse())
750
+ for (const message of deferred.slice().reverse()) {
498
751
  detail.agent.inbox.prepend('next-turn', message);
752
+ if (message.source.kind === 'a2a') {
753
+ const task = run.tasks.get(message.source.taskId);
754
+ if (task)
755
+ run.claimed.delete(task);
756
+ }
757
+ }
499
758
  for (let index = detail.messages.length - 1; index >= 0; index--)
500
759
  if (deferred.includes(detail.messages[index]))
501
760
  detail.messages.splice(index, 1);
502
761
  }
503
762
  for (const message of detail.messages) {
504
- if (message.source.kind !== 'a2a')
763
+ const task = this.owner(run, message);
764
+ if (!task && message.source.kind !== 'a2a')
505
765
  continue;
506
- const task = run.tasks.get(message.source.taskId);
507
766
  if (!task || task.settled)
508
767
  throw new Error('Queued input has no live A2A task; restore it before waking.');
509
768
  if (run.active && run.active !== task)
510
769
  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();
770
+ this.select(run, task);
771
+ const selectedAt = task.selectedAt.get(message.id) ?? Date.now();
518
772
  const timing = { selectedAt, turn: detail.turn, step: detail.step };
519
- this.observeInput('selected', run, message, timing);
520
773
  try {
521
774
  await this.flush(detail.agent.session);
522
- await this.ctx.serial('a2a/before-assembly', {
775
+ await this.ctx.serial('a2a/before-step', {
523
776
  agent: detail.agent,
524
777
  contextId: detail.agent.session.id,
525
778
  taskId: task.task.id,
526
- a2aMessage: Message.fromJSON(JSON.parse(message.source.message)),
779
+ a2aMessage: this.protocolMessage(run, task, message),
527
780
  dshMessageId: message.id,
528
781
  requestHeaders: task.headers.get(message.id),
529
782
  signal: detail.signal,
@@ -548,15 +801,19 @@ export class NativeInbox {
548
801
  const run = this.contexts.get(session.id);
549
802
  if (!run)
550
803
  return false;
551
- if (event.type === 'agent/inbox/paused') {
552
- run.paused = event.data.paused;
553
- return true;
554
- }
555
804
  if (event.type === 'turn/start') {
805
+ run.claimed.clear();
556
806
  run.turnStart = event;
557
807
  return true;
558
808
  }
559
809
  const task = run.active;
810
+ if (event.type === 'turn/end') {
811
+ if (!this.stopping && ['error', 'blocked', 'aborted'].includes(event.data.reason.kind))
812
+ for (const owner of run.claimed)
813
+ if (owner !== task && !owner.settled)
814
+ this.fail(owner, session.id, new Error('Selected input was interrupted before its task step.'));
815
+ run.claimed.clear();
816
+ }
560
817
  if (!task)
561
818
  return true;
562
819
  if (task.settled) {
@@ -573,19 +830,14 @@ export class NativeInbox {
573
830
  if (this.stopping)
574
831
  return true;
575
832
  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
833
  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));
834
+ if (task.final?.kind === 'statusUpdate' &&
835
+ [TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED].includes(task.final.data.status.state)) {
836
+ // Official inbox edits append session events; wait until turn/end publication exits.
837
+ void Promise.resolve()
838
+ .then(() => this.settle(run, task))
839
+ .catch((error) => this.fail(task, session.id, error));
840
+ return true;
589
841
  }
590
842
  if (!task.settlementStarted) {
591
843
  task.settlementStarted = true;
@@ -611,11 +863,16 @@ export class NativeInbox {
611
863
  return true;
612
864
  }
613
865
  settle(run, task) {
614
- if (task.settled ||
615
- this.stopping ||
616
- run.paused ||
617
- !task.settlementReady ||
866
+ const terminal = task.final?.kind === 'statusUpdate' &&
867
+ [TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED].includes(task.final.data.status.state);
868
+ if (task.settled || this.stopping)
869
+ return;
870
+ if (terminal) {
871
+ this.discard(run, task.task.id);
872
+ }
873
+ else if (!task.settlementReady ||
618
874
  task.pendingRequests ||
875
+ run.goalOwner?.task === task ||
619
876
  run.active === task ||
620
877
  this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === task.task.id))
621
878
  return;
@@ -650,146 +907,44 @@ export class NativeInbox {
650
907
  }
651
908
  fail(task, contextId, error) {
652
909
  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
- }
910
+ if (task.settled)
911
+ return;
659
912
  task.settled = true;
660
913
  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);
914
+ task.final = terminalStatusUpdate(task.task.id, contextId, 'failed', error instanceof Error ? error.message : String(error));
915
+ if (task.final.kind === 'statusUpdate')
916
+ task.task.status = task.final.data.status;
917
+ // Cleanup may append inbox events, so it must run outside session publication.
918
+ queueMicrotask(() => {
919
+ try {
920
+ const run = this.contexts.get(contextId);
921
+ if (run) {
922
+ this.discard(run, task.task.id);
923
+ if (run.active === task)
924
+ run.entry.handle.agent.cancel({ kind: 'user' }, { keepInbox: true });
737
925
  }
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
926
  }
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];
927
+ catch (cleanupError) {
928
+ console.error('[dsh-a2a] failed task cleanup failed:', cleanupError);
929
+ }
930
+ finally {
931
+ task.headers.clear();
932
+ task.bus.publish(task.final);
933
+ task.finish();
934
+ }
935
+ });
785
936
  }
786
- async cancel(taskId, options = {}) {
937
+ discard(run, taskId) {
938
+ for (const message of this.pending(run))
939
+ if (message.source.kind === 'a2a' && message.source.taskId === taskId)
940
+ run.entry.handle.agent.inbox.remove(message.id);
941
+ }
942
+ async cancel(taskId) {
787
943
  if (this.stopping)
788
944
  throw new Error('Native inbox is unloading.');
789
945
  for (const [contextId, run] of this.contexts) {
790
946
  if (!run.tasks.has(taskId))
791
947
  continue;
792
- await this.waking.get(contextId);
793
948
  const task = run.tasks.get(taskId);
794
949
  if (!task)
795
950
  continue;
@@ -799,7 +954,7 @@ export class NativeInbox {
799
954
  return contextId;
800
955
  let operation = task.cancellation;
801
956
  if (!operation) {
802
- operation = this.cancelTask(run, task, options);
957
+ operation = this.cancelTask(run, task);
803
958
  task.cancellation = operation;
804
959
  void operation.catch(() => {
805
960
  if (task.cancellation === operation)
@@ -811,19 +966,10 @@ export class NativeInbox {
811
966
  }
812
967
  return undefined;
813
968
  }
814
- async cancelTask(run, task, options) {
969
+ async cancelTask(run, task) {
815
970
  const contextId = run.entry.sessionId;
816
971
  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
- }
972
+ this.discard(run, taskId);
827
973
  task.controller.abort();
828
974
  task.settled = true;
829
975
  task.final = terminalStatusUpdate(taskId, contextId, 'canceled', 'Task canceled by request.');
@@ -831,13 +977,6 @@ export class NativeInbox {
831
977
  task.task.status = task.final.data.status;
832
978
  if (run.active === task) {
833
979
  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
980
  }
842
981
  try {
843
982
  try {
@@ -853,7 +992,6 @@ export class NativeInbox {
853
992
  }
854
993
  }
855
994
  async clear(contextId) {
856
- await this.waking.get(contextId);
857
995
  await this.resolving.get(contextId);
858
996
  const run = this.contexts.get(contextId);
859
997
  if (!run)
@@ -864,7 +1002,6 @@ export class NativeInbox {
864
1002
  .map((task) => task.task.id)
865
1003
  .concat(this.pending(run).flatMap((message) => message.source.kind === 'a2a' ? [message.source.taskId] : []))),
866
1004
  ];
867
- run.paused = true;
868
1005
  for (const admission of run.admissions)
869
1006
  admission.abort();
870
1007
  run.entry.handle.agent.inbox.clear();
@@ -885,15 +1022,20 @@ export class NativeInbox {
885
1022
  const run = this.contexts.get(session.id);
886
1023
  if (!run)
887
1024
  return;
888
- this.observe('disposed', run);
889
1025
  for (const admission of run.admissions)
890
1026
  admission.abort();
891
1027
  for (const task of run.tasks.values()) {
892
- // Disposal interrupts transport ownership; retained durable input keeps its task state.
1028
+ if (!task.settled) {
1029
+ const final = terminalStatusUpdate(task.task.id, session.id, 'canceled', 'Agent disposed.');
1030
+ if (final.kind === 'statusUpdate')
1031
+ task.task.status = final.data.status;
1032
+ task.bus.publish(final);
1033
+ }
893
1034
  task.settled = true;
894
1035
  task.controller.abort();
895
1036
  task.finish();
896
1037
  }
1038
+ this.observe('disposed', run);
897
1039
  this.contexts.delete(session.id);
898
1040
  }
899
1041
  shutdown() {
@@ -902,7 +1044,7 @@ export class NativeInbox {
902
1044
  this.stopping = true;
903
1045
  this.shutdownPromise = (async () => {
904
1046
  const failures = [];
905
- await Promise.allSettled([...this.waking.values(), ...this.resolving.values()]);
1047
+ await Promise.allSettled([...this.resolving.values()]);
906
1048
  const cancellations = [...this.contexts.values()].flatMap((run) => [...run.tasks.values()].flatMap((task) => (task.cancellation ? [task.cancellation] : [])));
907
1049
  for (const outcome of await Promise.allSettled(cancellations))
908
1050
  if (outcome.status === 'rejected')
@@ -911,7 +1053,7 @@ export class NativeInbox {
911
1053
  for (const admission of run.admissions)
912
1054
  admission.abort();
913
1055
  try {
914
- run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: true });
1056
+ run.entry.handle.agent.cancel({ kind: 'disposed' });
915
1057
  }
916
1058
  catch (error) {
917
1059
  failures.push(error);
@@ -929,11 +1071,17 @@ export class NativeInbox {
929
1071
  failures.push(error);
930
1072
  }
931
1073
  finally {
932
- this.observe('disposed', run);
933
1074
  for (const task of run.tasks.values()) {
934
- task.controller.abort();
1075
+ try {
1076
+ if (!task.settled)
1077
+ await this.cancelTask(run, task);
1078
+ }
1079
+ catch (error) {
1080
+ failures.push(error);
1081
+ }
935
1082
  task.finish();
936
1083
  }
1084
+ this.observe('disposed', run);
937
1085
  }
938
1086
  }
939
1087
  if (failures.length)