@amaster.ai/dsh-a2a 0.1.11 → 0.1.13

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
@@ -54,7 +54,8 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
54
54
 
55
55
  ## Behavior notes
56
56
 
57
- - **One task = one dsh session.** The A2A `contextId` IS the dsh session id. A completed turn ends `input-required`, not `completed` — the task is a conversation and stays continuable; `CancelTask` and turn errors are terminal (`canceled` / `failed`), and the SDK rejects follow-ups addressed at a terminal `taskId` (send with only the `contextId` to continue the session under a fresh task id).
57
+ - **One task = one dsh session.** The A2A `contextId` IS the dsh session id. By default, a completed turn ends `input-required`, not `completed` — the task is a conversation and stays continuable; `CancelTask` and turn errors are terminal (`canceled` / `failed`), and the SDK rejects follow-ups addressed at a terminal `taskId` (send with only the `contextId` to continue the session under a fresh task id).
58
+ - **Host admission and settlement.** `ctx.on('a2a/before-followup', async (detail) => { ... })` runs before the user message enters the agent inbox. Its detail contains `agent`, `contextId`, `taskId`, the original `a2aMessage`, `dshMessageId`, `requestHeaders`, and an abort `signal`; rejection fails the A2A task without enqueueing the message. A host may inspect its own A2A message metadata here; the plugin does not interpret it. The existing `a2a/message-admitted` event remains an observational tap after insertion. A host can register `ctx.on('a2a/task-settlement', async ({ agent, contextId, taskId, reason, signal }, next) => { await hostWork; return next(); })`. The hook starts after the first completed turn and may wait for any number of autonomous follow-ups, including none. The plugin streams their events under the same task ID and publishes the latest turn's final status when the hook resolves. Cancel, clear, and disposal abort the signal and release the waiter. Without this hook, the first completed turn settles as before. The host must end its wait when its work is done; the plugin does not infer that from product metadata.
58
59
  - **Agents are full preset citizens.** Each task's agent is created with the deployment's default model selection (`agentDefaultModel`) and mounts its agent preset (the web profile keeps all tools inside presets — without one the agent would see an empty tool catalog). `agent.preset` pins a specific preset.
59
60
  - **Streaming aggregation.** Text deltas of a turn share one `messageId`, so clients accumulate them into a single message; reasoning deltas ride a separate `messageId` and are marked `metadata.dshAgent.kind: 'thought'`. The turn-final event's message carries the full assembled text, so blocking `SendMessage` clients read the answer from `result.task.status.message`. Tool calls/results are data parts marked `tool-call` / `tool-result`; token usage lands in `metadata.usage` of the final event. A2A 1.0 has no `final` flag — terminal and interrupted states close the stream.
60
61
  - **Message parts beyond text.** File parts can carry inline bytes or a `url`; the plugin downloads URLs over http/https, bounded to 64 MiB and 30 s. Supported images use the composed attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`) to reach vision-capable models. Non-image files use a configured materializer or persist under `uploadsDir` (default `<OS temp>/dsh-a2a-uploads/<date>/`, names sanitized cross-platform, collisions suffixed), even when an attachment store exists. The prompt references the resulting readable path inside a `<document>` tag. Ensure the local directory is readable by the agent's tools. `data` parts become `<data>` JSON text. A turn never fails because a part kind is unsupported.
@@ -62,7 +63,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
62
63
  - **Local execution files.** Without a remote materializer, inline and downloaded non-image FileParts persist under `uploadsDir`; the `<document>` envelope carries the path, source URL, media type, size, and original filename when sanitization or a collision changes it. Images still use native attachment handling.
63
64
  - **Tool approvals.** When the profile composes `@deepseek-ai/dsh-user-approval` with policy `ask`, A2A-owned agents answer `approval/request` by publishing `input-required` with a DataPart `{ requestId, toolName, reason?, callId? }`. Reply on the same `taskId`/`contextId` with one DataPart `{ requestId, callId?, outcome: "allowed-once" | "rejected" }`. The reply settles the waiting tool call inside the original dsh turn; it does not create another user message or billing turn. Invalid, duplicate, and withdrawn replies are rejected. `never` policy rejects inside dsh before the A2A answerer is called. The approval service is optional; without it, `ask` fails closed as usual.
64
65
  - **Custom approval payloads.** A deployment may register `ctx.provide('a2aApprovalCodec', codec)` before this plugin starts. The exported `A2aApprovalCodec` has `encode(request)` and `decode(message)` methods; `decode` returns `{ requestId?, callId?, outcome }` only for a supported one-shot decision. A unique `callId` can replace `requestId` for legacy clients. Reject unsupported outcomes in the decoder; never translate `proceed_always` into `allowed-once`. Other message types, including question, plan, and UI markup, remain outside this plugin.
65
- - **One in-flight user turn per task** is the supported flow (send the next user message after the turn-final event). Approval confirmations are the exception: they settle a pending decision in that same turn. dsh serializes queued follow-ups into successive turns, but concurrent unrelated requests share one event bus — a second in-flight request may resolve with the first turn's final event.
66
+ - **Requests for one task execute in order.** Autonomous turns covered by a settlement hook finish before the next user request starts. Approval confirmations settle a pending decision inside its current turn.
66
67
  - **Restart**: persisted task shells survive in Redis/GCS. With `sessionPersistence` composed, a `contextId` already present on disk resumes its dsh session after restart; a new id creates a new session.
67
68
  - **Clear**: the same-process gateway can call `ctx.get('a2aTasks').clearContext(contextId)` before reporting `messages/clear` success. It cancels and drains the live turn, removes the binding and every TaskStore shell for that context, and returns the removed task IDs. The gateway owns the separate session-surface replacement and client-visible history-ID update.
68
69
 
package/lib/bridge.js CHANGED
@@ -9,11 +9,9 @@
9
9
  * {@link SessionTranslator} onto the executing request's
10
10
  * {@link ExecutionEventBus}.
11
11
  *
12
- * Turn settlement: `agent.followup()` is fire-and-forget, so callers await a
13
- * FIFO waiter resolved by the session's next `turn/end` event (dsh's inbox
14
- * serializes queued follow-ups into successive turns, so waiter order matches
15
- * turn order). The final status-update is published before the waiter
16
- * resolves, so an execute() that returns always landed its events first.
12
+ * Turn settlement: `agent.followup()` is fire-and-forget. The default policy
13
+ * settles at the next `turn/end`; a host may keep the same task open for
14
+ * autonomous turns through `a2a/task-settlement`.
17
15
  */
18
16
  import { TaskState } from '@a2a-js/sdk';
19
17
  import { AgentEvent } from '@a2a-js/sdk/server';
@@ -81,13 +79,23 @@ export class A2aBridge {
81
79
  return { entry: existing, freshTask: false };
82
80
  const forContext = this.bySession.get(contextId);
83
81
  if (forContext) {
84
- this.withdrawApprovals(forContext);
82
+ const staleTurn = forContext.staleTurn || forContext.turnActive;
83
+ this.settleEntry(forContext, 'Task superseded by a new request.');
84
+ forContext.staleTurn = staleTurn;
85
+ // The previous task may have queued autonomous work that has not started
86
+ // a turn yet. dsh-agent cancel clears that inbox work synchronously.
87
+ try {
88
+ forContext.handle.agent.cancel({ kind: 'user' });
89
+ }
90
+ catch (error) {
91
+ console.error('[dsh-a2a] rebind cancel failed:', error);
92
+ }
85
93
  this.tasks.delete(forContext.taskId);
86
94
  forContext.taskId = taskId;
87
95
  forContext.translator = new SessionTranslator(taskId, contextId, this.options.agentOptions?.model);
88
96
  // A turn still open from the previous binding (e.g. a cancel whose abort
89
97
  // is still propagating) must not emit events under the new task id.
90
- forContext.staleTurn = forContext.turnActive;
98
+ forContext.interrupted = false;
91
99
  this.tasks.set(taskId, forContext);
92
100
  return { entry: forContext, freshTask: true };
93
101
  }
@@ -147,6 +155,9 @@ export class A2aBridge {
147
155
  staleTurn: false,
148
156
  settled: [],
149
157
  running: new Set(),
158
+ settlementAbort: new AbortController(),
159
+ settling: false,
160
+ interrupted: false,
150
161
  };
151
162
  this.tasks.set(taskId, entry);
152
163
  this.bySession.set(contextId, entry);
@@ -251,10 +262,18 @@ export class A2aBridge {
251
262
  }
252
263
  }
253
264
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
254
- async runTurn(entry, content, bus, a2aMessageId, requestHeaders) {
255
- if (this.clearing.has(entry.sessionId))
256
- throw new Error('Context is being cleared.');
257
- const running = this.runTurnInner(entry, content, bus, a2aMessageId, requestHeaders);
265
+ async runTurn(entry, content, bus, taskId, a2aMessage, requestHeaders) {
266
+ const previous = [...entry.running];
267
+ const running = (async () => {
268
+ await Promise.allSettled(previous);
269
+ if (this.clearing.has(entry.sessionId))
270
+ throw new Error('Context is being cleared.');
271
+ if (entry.interrupted || entry.taskId !== taskId) {
272
+ bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'canceled', 'Task interrupted.'));
273
+ return;
274
+ }
275
+ await this.runTurnInner(entry, content, bus, taskId, a2aMessage, requestHeaders);
276
+ })();
258
277
  entry.running.add(running);
259
278
  try {
260
279
  await running;
@@ -263,15 +282,50 @@ export class A2aBridge {
263
282
  entry.running.delete(running);
264
283
  }
265
284
  }
266
- async runTurnInner(entry, content, bus, a2aMessageId, requestHeaders) {
285
+ async runTurnInner(entry, content, bus, taskId, a2aMessage, requestHeaders) {
267
286
  let resolveSettled;
268
287
  const settled = new Promise((resolve) => {
269
288
  resolveSettled = resolve;
270
289
  });
271
290
  entry.settled.push(resolveSettled);
272
291
  entry.bus = bus;
292
+ entry.settlementAbort = new AbortController();
293
+ entry.settling = false;
294
+ entry.lastFinal = undefined;
273
295
  try {
274
296
  const message = createUserMessage({ content, source: { kind: 'user' } });
297
+ const admission = new AbortController();
298
+ entry.admissionAbort = admission;
299
+ try {
300
+ await Promise.race([
301
+ this.ctx.serial('a2a/before-followup', {
302
+ agent: entry.handle.agent,
303
+ contextId: entry.sessionId,
304
+ taskId,
305
+ a2aMessage,
306
+ dshMessageId: message.id,
307
+ requestHeaders,
308
+ signal: admission.signal,
309
+ }),
310
+ new Promise((_, reject) => {
311
+ const abort = () => reject(new Error('A2A task was interrupted before message admission.'));
312
+ if (admission.signal.aborted)
313
+ abort();
314
+ else
315
+ admission.signal.addEventListener('abort', abort, { once: true });
316
+ }),
317
+ ]);
318
+ }
319
+ finally {
320
+ if (entry.admissionAbort === admission)
321
+ entry.admissionAbort = undefined;
322
+ }
323
+ if (!entry.settled.includes(resolveSettled) ||
324
+ entry.interrupted ||
325
+ entry.taskId !== taskId ||
326
+ this.tasks.get(taskId) !== entry) {
327
+ return;
328
+ }
275
329
  // followup() inserts synchronously, then may claim the message before
276
330
  // returning. Observe that insertion so the mapping is both real and early.
277
331
  const stopListening = this.ctx.on('agent/inbox/inserted', ({ agent, message: inserted }) => {
@@ -281,8 +335,8 @@ export class A2aBridge {
281
335
  void this.ctx
282
336
  .parallel('a2a/message-admitted', {
283
337
  contextId: entry.sessionId,
284
- taskId: entry.taskId,
285
- a2aMessageId,
338
+ taskId,
339
+ a2aMessageId: a2aMessage.messageId,
286
340
  dshMessageId: message.id,
287
341
  requestHeaders,
288
342
  })
@@ -298,8 +352,11 @@ export class A2aBridge {
298
352
  void Promise.resolve(result).catch((error) => {
299
353
  const message = error instanceof Error ? error.message : String(error);
300
354
  console.error('[dsh-a2a] followup failed:', error);
355
+ const index = entry.settled.indexOf(resolveSettled);
356
+ if (index >= 0)
357
+ entry.settled.splice(index, 1);
301
358
  if (entry.bus === bus) {
302
- entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', message));
359
+ entry.bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'failed', message));
303
360
  }
304
361
  resolveSettled();
305
362
  });
@@ -315,11 +372,15 @@ export class A2aBridge {
315
372
  const index = entry.settled.indexOf(resolveSettled);
316
373
  if (index >= 0)
317
374
  entry.settled.splice(index, 1);
375
+ else
376
+ return; // cancellation/clear already published the terminal state
318
377
  throw error;
319
378
  }
320
379
  finally {
321
- if (entry.bus === bus)
380
+ if (entry.bus === bus) {
381
+ entry.settlementAbort.abort();
322
382
  entry.bus = null;
383
+ }
323
384
  }
324
385
  }
325
386
  /** Cancel and release the live binding before the caller deletes stored shells. */
@@ -331,12 +392,17 @@ export class A2aBridge {
331
392
  await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
332
393
  const entry = this.bySession.get(contextId);
333
394
  if (entry) {
334
- this.withdrawApprovals(entry);
335
- if (entry.turnActive || entry.running.size > 0) {
336
- await entry.handle.agent.cancel({ kind: 'user' });
395
+ const active = entry.turnActive || entry.running.size > 0;
396
+ this.settleEntry(entry, 'Context cleared.');
397
+ if (active) {
398
+ try {
399
+ await entry.handle.agent.cancel({ kind: 'user' });
400
+ }
401
+ catch (error) {
402
+ console.error('[dsh-a2a] clear cancel failed:', error);
403
+ }
337
404
  }
338
405
  await entry.handle.agent.whenIdle();
339
- this.settleEntry(entry, 'Context cleared.');
340
406
  await Promise.allSettled([...entry.running]);
341
407
  this.tasks.delete(entry.taskId);
342
408
  this.bySession.delete(contextId);
@@ -354,10 +420,17 @@ export class A2aBridge {
354
420
  const entry = this.tasks.get(taskId);
355
421
  if (!entry)
356
422
  return undefined;
357
- this.withdrawApprovals(entry);
423
+ const staleTurn = entry.turnActive;
424
+ this.settleEntry(entry, 'Task canceled by request.');
425
+ entry.staleTurn ||= staleTurn;
358
426
  // cancel() is typed void; the guard keeps a mistyped async impl from
359
427
  // crashing the host (the caller already published its canceled final).
360
- void Promise.resolve(entry.handle.agent.cancel({ kind: 'user' })).catch((error) => console.error('[dsh-a2a] cancel failed:', error));
428
+ try {
429
+ void Promise.resolve(entry.handle.agent.cancel({ kind: 'user' })).catch((error) => console.error('[dsh-a2a] cancel failed:', error));
430
+ }
431
+ catch (error) {
432
+ console.error('[dsh-a2a] cancel failed:', error);
433
+ }
361
434
  return entry.sessionId;
362
435
  }
363
436
  /** Dispose every owned agent (plugin unload / server shutdown). */
@@ -380,6 +453,11 @@ export class A2aBridge {
380
453
  const entry = this.bySession.get(session.id);
381
454
  if (!entry)
382
455
  return;
456
+ if (entry.staleTurn) {
457
+ if (event.type === 'turn/end')
458
+ entry.staleTurn = false;
459
+ return;
460
+ }
383
461
  if (event.type === 'approval/asked') {
384
462
  const queue = this.asked.get(session.id) ?? [];
385
463
  queue.push({ id: event.data.id, toolName: event.data.toolName, callId: event.data.callId });
@@ -387,29 +465,85 @@ export class A2aBridge {
387
465
  }
388
466
  if (event.type === 'turn/start')
389
467
  entry.turnActive = true;
390
- // A turn carried over from a previous task binding (e.g. a canceled turn
391
- // whose abort was still in flight when the context rebound): its turn/end
392
- // belongs to the old task — settle waiters, but publish nothing under the
393
- // new task id.
394
- const stale = event.type === 'turn/end' && entry.staleTurn;
395
- if (stale)
396
- entry.staleTurn = false;
397
468
  // No-throw seam: translation errors must never reach the agent loop. The
398
469
  // turn/end waiter still resolves below, so a poisoned event cannot hang
399
470
  // an in-flight execute() either.
400
471
  try {
401
- if (!stale)
402
- for (const out of entry.translator.handle(event))
472
+ const outputs = entry.translator.handle(event);
473
+ if (event.type === 'turn/end' &&
474
+ (event.data.reason.kind === 'completed' || event.data.reason.kind === 'max-tokens') &&
475
+ entry.bus) {
476
+ const bus = entry.bus;
477
+ const taskId = entry.taskId;
478
+ entry.lastFinal = outputs[0];
479
+ if (!entry.settling) {
480
+ entry.settling = true;
481
+ const signal = entry.settlementAbort.signal;
482
+ const complete = () => {
483
+ if (signal.aborted || entry.bus !== bus || entry.taskId !== taskId)
484
+ return;
485
+ if (entry.lastFinal)
486
+ bus.publish(entry.lastFinal);
487
+ entry.bus = null;
488
+ entry.settlementAbort.abort();
489
+ entry.settled.shift()?.();
490
+ };
491
+ const wait = this.ctx.waterfall('a2a/task-settlement', {
492
+ agent: entry.handle.agent,
493
+ contextId: entry.sessionId,
494
+ taskId,
495
+ reason: event.data.reason,
496
+ signal,
497
+ }, () => { });
498
+ if (!wait)
499
+ complete();
500
+ else
501
+ void Promise.race([
502
+ wait,
503
+ new Promise((resolve) => {
504
+ if (signal.aborted)
505
+ resolve();
506
+ else
507
+ signal.addEventListener('abort', () => resolve(), { once: true });
508
+ }),
509
+ ])
510
+ .then(complete)
511
+ .catch((error) => {
512
+ if (entry.bus !== bus || entry.taskId !== taskId)
513
+ return;
514
+ const message = error instanceof Error ? error.message : String(error);
515
+ console.error('[dsh-a2a] task settlement failed:', error);
516
+ bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'failed', message));
517
+ entry.bus = null;
518
+ entry.settlementAbort.abort();
519
+ entry.settled.shift()?.();
520
+ });
521
+ }
522
+ }
523
+ else {
524
+ for (const out of outputs)
403
525
  entry.bus?.publish(out);
526
+ }
404
527
  }
405
528
  catch (error) {
406
529
  console.error('[dsh-a2a] failed to translate session event:', error);
530
+ if (event.type === 'turn/end' &&
531
+ (event.data.reason.kind === 'completed' || event.data.reason.kind === 'max-tokens')) {
532
+ entry.bus?.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', 'Failed to translate agent turn.'));
533
+ entry.bus = null;
534
+ entry.settlementAbort.abort();
535
+ entry.settled.shift()?.();
536
+ }
407
537
  }
408
538
  if (event.type === 'turn/end') {
409
539
  this.withdrawApprovals(entry);
410
540
  this.asked.delete(session.id);
411
541
  entry.turnActive = false;
412
- entry.settled.shift()?.();
542
+ if (event.data.reason.kind !== 'completed' && event.data.reason.kind !== 'max-tokens') {
543
+ entry.settlementAbort.abort();
544
+ entry.bus = null;
545
+ entry.settled.shift()?.();
546
+ }
413
547
  }
414
548
  }
415
549
  /**
@@ -418,7 +552,7 @@ export class A2aBridge {
418
552
  */
419
553
  onAssistantStream(agent, frame) {
420
554
  const entry = this.bySession.get(agent.session.id);
421
- if (!entry)
555
+ if (!entry || entry.staleTurn)
422
556
  return;
423
557
  try {
424
558
  for (const out of entry.translator.handleStreamFrame(frame))
@@ -441,10 +575,14 @@ export class A2aBridge {
441
575
  }
442
576
  /** Close an in-flight stream honestly and release every turn waiter. */
443
577
  settleEntry(entry, message) {
578
+ entry.interrupted = true;
579
+ entry.admissionAbort?.abort();
580
+ entry.settlementAbort.abort();
444
581
  this.withdrawApprovals(entry);
445
- if (entry.turnActive && entry.bus) {
582
+ if (entry.bus && (entry.turnActive || entry.settled.length > 0)) {
446
583
  entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'canceled', message));
447
584
  }
585
+ entry.bus = null;
448
586
  entry.turnActive = false;
449
587
  for (const resolve of entry.settled.splice(0))
450
588
  resolve();
package/lib/executor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
3
- * bridge. One `execute()` = one user-message turn:
3
+ * bridge. One `execute()` starts one user message and may include host-owned follow-up turns:
4
4
  *
5
5
  * 1. map the message's parts onto dsh content blocks (file parts become
6
6
  * execution-world paths with a materializer, local paths otherwise)
@@ -62,7 +62,7 @@ export class DshAgentExecutor {
62
62
  taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
63
63
  anchored = true;
64
64
  const requestHeaders = requestContext.context.state.get(STATE_HEADERS_KEY);
65
- await this.bridge.runTurn(entry, content, eventBus, userMessage.messageId, requestHeaders);
65
+ await this.bridge.runTurn(entry, content, eventBus, taskId, userMessage, requestHeaders);
66
66
  }
67
67
  catch (error) {
68
68
  if (error instanceof ApprovalReplyError)
@@ -9,14 +9,12 @@
9
9
  * {@link SessionTranslator} onto the executing request's
10
10
  * {@link ExecutionEventBus}.
11
11
  *
12
- * Turn settlement: `agent.followup()` is fire-and-forget, so callers await a
13
- * FIFO waiter resolved by the session's next `turn/end` event (dsh's inbox
14
- * serializes queued follow-ups into successive turns, so waiter order matches
15
- * turn order). The final status-update is published before the waiter
16
- * resolves, so an execute() that returns always landed its events first.
12
+ * Turn settlement: `agent.followup()` is fire-and-forget. The default policy
13
+ * settles at the next `turn/end`; a host may keep the same task open for
14
+ * autonomous turns through `a2a/task-settlement`.
17
15
  */
18
16
  import { type Message } from '@a2a-js/sdk';
19
- import { type ExecutionEventBus } from '@a2a-js/sdk/server';
17
+ import { type AgentExecutionEvent, type ExecutionEventBus } from '@a2a-js/sdk/server';
20
18
  import type { Context } from '@deepseek-ai/cordis';
21
19
  import type { AgentHandle } from '@deepseek-ai/dsh-agent';
22
20
  import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
@@ -46,10 +44,15 @@ export interface TaskEntry {
46
44
  turnActive: boolean;
47
45
  /** A turn still open from a previous task binding; its turn/end is stale. */
48
46
  staleTurn: boolean;
49
- /** FIFO turn-end waiters, one per queued/running execute(). */
47
+ /** FIFO settlement waiters, one per queued/running execute(). */
50
48
  settled: Array<() => void>;
51
49
  /** Active execute() calls that must finish before task shells are deleted. */
52
50
  running: Set<Promise<void>>;
51
+ admissionAbort?: AbortController;
52
+ settlementAbort: AbortController;
53
+ settling: boolean;
54
+ lastFinal?: AgentExecutionEvent;
55
+ interrupted: boolean;
53
56
  }
54
57
  export declare class A2aBridge {
55
58
  private readonly ctx;
@@ -92,7 +95,7 @@ export declare class A2aBridge {
92
95
  /** Include content preparation in the work a context clear must drain. */
93
96
  trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
94
97
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
95
- runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus, a2aMessageId: string, requestHeaders: unknown): Promise<void>;
98
+ runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus, taskId: string, a2aMessage: Message, requestHeaders: unknown): Promise<void>;
96
99
  private runTurnInner;
97
100
  /** Cancel and release the live binding before the caller deletes stored shells. */
98
101
  clearContext(contextId: string, clearStored?: (liveIds: string[]) => Promise<string[]>): Promise<string[]>;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
3
- * bridge. One `execute()` = one user-message turn:
3
+ * bridge. One `execute()` starts one user message and may include host-owned follow-up turns:
4
4
  *
5
5
  * 1. map the message's parts onto dsh content blocks (file parts become
6
6
  * execution-world paths with a materializer, local paths otherwise)
@@ -11,15 +11,37 @@
11
11
  *
12
12
  * @module dsh-a2a
13
13
  */
14
+ import { type Message } from '@a2a-js/sdk';
14
15
  import type { Context } from '@deepseek-ai/cordis';
16
+ import type { Agent } from '@deepseek-ai/dsh-agent';
17
+ import type { TurnEndReason } from '@deepseek-ai/dsh-session';
15
18
  import Schema from '@deepseek-ai/schemastery';
16
19
  export type { A2aApproval, A2aApprovalCodec, A2aApprovalReply } from './approval.js';
17
20
  export { defaultApprovalCodec } from './approval.js';
18
21
  export type { A2aFileMaterializer } from './content.js';
19
22
  /** Event emitted when one A2A user message enters the dsh inbox. */
20
23
  export declare const A2A_MESSAGE_ADMITTED_EVENT: "a2a/message-admitted";
24
+ export interface A2aTurnContext {
25
+ agent: Agent;
26
+ contextId: string;
27
+ taskId: string;
28
+ a2aMessage: Message;
29
+ dshMessageId: string;
30
+ requestHeaders: unknown;
31
+ signal: AbortSignal;
32
+ }
21
33
  declare module '@deepseek-ai/cordis' {
22
34
  interface Events {
35
+ /** Awaited before inbox insertion; failure rejects the A2A request. */
36
+ 'a2a/before-followup'(detail: A2aTurnContext): void;
37
+ /** Await host-owned continuation work before closing this task. */
38
+ 'a2a/task-settlement'(detail: {
39
+ agent: Agent;
40
+ contextId: string;
41
+ taskId: string;
42
+ reason: TurnEndReason;
43
+ signal: AbortSignal;
44
+ }, next: () => void | Promise<void>): Promise<void> | void;
23
45
  /** Link a protocol message to the dsh inbox message created for its turn. */
24
46
  'a2a/message-admitted'(detail: {
25
47
  contextId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",