@amaster.ai/dsh-a2a 0.1.13 → 0.1.15

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
@@ -55,7 +55,8 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
55
55
  ## Behavior notes
56
56
 
57
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
+ - **Same-task messages.** `ctx.on('a2a/working-task-message', async (detail) => { ... })` runs when another message targets an executing task, before waiting for that task's prior request. Its detail contains `agent`, `contextId`, `taskId`, the original `a2aMessage` (including its `messageId`), `requestHeaders`, and an abort `signal`; the host can use it to end or revise pending continuation. Failure rejects the new request without enqueueing it. The plugin still serializes agent follow-ups.
59
+ - **Host admission and settlement.** `ctx.on('a2a/before-followup', async (detail) => { ... })` runs before every 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.
59
60
  - **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.
60
61
  - **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.
61
62
  - **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.
package/lib/bridge.js CHANGED
@@ -96,6 +96,7 @@ export class A2aBridge {
96
96
  // A turn still open from the previous binding (e.g. a cancel whose abort
97
97
  // is still propagating) must not emit events under the new task id.
98
98
  forContext.interrupted = false;
99
+ forContext.terminalState = undefined;
99
100
  this.tasks.set(taskId, forContext);
100
101
  return { entry: forContext, freshTask: true };
101
102
  }
@@ -155,6 +156,7 @@ export class A2aBridge {
155
156
  staleTurn: false,
156
157
  settled: [],
157
158
  running: new Set(),
159
+ pendingMessageAborts: new Set(),
158
160
  settlementAbort: new AbortController(),
159
161
  settling: false,
160
162
  interrupted: false,
@@ -243,6 +245,38 @@ export class A2aBridge {
243
245
  async waitForTurn(entry) {
244
246
  await Promise.allSettled([...entry.running]);
245
247
  }
248
+ /** Inspect a message for a live task before the SDK starts its next request. */
249
+ async beforeWorkingTaskMessage(a2aMessage, requestHeaders) {
250
+ const entry = this.tasks.get(a2aMessage.taskId);
251
+ if (!entry ||
252
+ (a2aMessage.contextId && entry.sessionId !== a2aMessage.contextId) ||
253
+ entry.running.size === 0)
254
+ return;
255
+ const pending = new AbortController();
256
+ entry.pendingMessageAborts.add(pending);
257
+ try {
258
+ await Promise.race([
259
+ this.ctx.serial('a2a/working-task-message', {
260
+ agent: entry.handle.agent,
261
+ contextId: entry.sessionId,
262
+ taskId: entry.taskId,
263
+ a2aMessage,
264
+ requestHeaders,
265
+ signal: pending.signal,
266
+ }),
267
+ new Promise((_, reject) => {
268
+ const abort = () => reject(new Error('A2A task was interrupted before message admission.'));
269
+ if (pending.signal.aborted)
270
+ abort();
271
+ else
272
+ pending.signal.addEventListener('abort', abort, { once: true });
273
+ }),
274
+ ]);
275
+ }
276
+ finally {
277
+ entry.pendingMessageAborts.delete(pending);
278
+ }
279
+ }
246
280
  /** Include content preparation in the work a context clear must drain. */
247
281
  async trackExecution(contextId, run) {
248
282
  const execution = run();
@@ -265,10 +299,18 @@ export class A2aBridge {
265
299
  async runTurn(entry, content, bus, taskId, a2aMessage, requestHeaders) {
266
300
  const previous = [...entry.running];
267
301
  const running = (async () => {
302
+ if (entry.interrupted || entry.taskId !== taskId || this.tasks.get(taskId) !== entry) {
303
+ bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'canceled', 'Task interrupted.'));
304
+ return;
305
+ }
268
306
  await Promise.allSettled(previous);
269
307
  if (this.clearing.has(entry.sessionId))
270
308
  throw new Error('Context is being cleared.');
271
- if (entry.interrupted || entry.taskId !== taskId) {
309
+ if (entry.terminalState) {
310
+ bus.publish(terminalStatusUpdate(taskId, entry.sessionId, entry.terminalState, 'Task ended.'));
311
+ return;
312
+ }
313
+ if (entry.interrupted || entry.taskId !== taskId || this.tasks.get(taskId) !== entry) {
272
314
  bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'canceled', 'Task interrupted.'));
273
315
  return;
274
316
  }
@@ -278,8 +320,14 @@ export class A2aBridge {
278
320
  try {
279
321
  await running;
280
322
  }
323
+ catch (error) {
324
+ entry.terminalState = 'failed';
325
+ throw error;
326
+ }
281
327
  finally {
282
328
  entry.running.delete(running);
329
+ if (entry.running.size === 0)
330
+ entry.terminalState = undefined;
283
331
  }
284
332
  }
285
333
  async runTurnInner(entry, content, bus, taskId, a2aMessage, requestHeaders) {
@@ -350,6 +398,7 @@ export class A2aBridge {
350
398
  // unhandled rejection would crash the host process, and no turn/end
351
399
  // would ever settle this waiter.
352
400
  void Promise.resolve(result).catch((error) => {
401
+ entry.terminalState = 'failed';
353
402
  const message = error instanceof Error ? error.message : String(error);
354
403
  console.error('[dsh-a2a] followup failed:', error);
355
404
  const index = entry.settled.indexOf(resolveSettled);
@@ -511,6 +560,7 @@ export class A2aBridge {
511
560
  .catch((error) => {
512
561
  if (entry.bus !== bus || entry.taskId !== taskId)
513
562
  return;
563
+ entry.terminalState = 'failed';
514
564
  const message = error instanceof Error ? error.message : String(error);
515
565
  console.error('[dsh-a2a] task settlement failed:', error);
516
566
  bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'failed', message));
@@ -529,6 +579,7 @@ export class A2aBridge {
529
579
  console.error('[dsh-a2a] failed to translate session event:', error);
530
580
  if (event.type === 'turn/end' &&
531
581
  (event.data.reason.kind === 'completed' || event.data.reason.kind === 'max-tokens')) {
582
+ entry.terminalState = 'failed';
532
583
  entry.bus?.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', 'Failed to translate agent turn.'));
533
584
  entry.bus = null;
534
585
  entry.settlementAbort.abort();
@@ -536,6 +587,10 @@ export class A2aBridge {
536
587
  }
537
588
  }
538
589
  if (event.type === 'turn/end') {
590
+ if (event.data.reason.kind === 'blocked' || event.data.reason.kind === 'error')
591
+ entry.terminalState = 'failed';
592
+ if (event.data.reason.kind === 'aborted')
593
+ entry.terminalState = 'canceled';
539
594
  this.withdrawApprovals(entry);
540
595
  this.asked.delete(session.id);
541
596
  entry.turnActive = false;
@@ -576,6 +631,8 @@ export class A2aBridge {
576
631
  /** Close an in-flight stream honestly and release every turn waiter. */
577
632
  settleEntry(entry, message) {
578
633
  entry.interrupted = true;
634
+ for (const pending of entry.pendingMessageAborts)
635
+ pending.abort();
579
636
  entry.admissionAbort?.abort();
580
637
  entry.settlementAbort.abort();
581
638
  this.withdrawApprovals(entry);
package/lib/index.js CHANGED
@@ -140,6 +140,7 @@ export function apply(ctx, config) {
140
140
  executor,
141
141
  taskStore: store,
142
142
  approvalGuard: (message) => bridge.reserveApprovalMessage(message),
143
+ beforeWorkingTaskMessage: (message, headers) => bridge.beforeWorkingTaskMessage(message, headers),
143
144
  });
144
145
  serverPort = server.port;
145
146
  console.log(`[dsh-a2a] A2A endpoint: http://${config.host}:${server.port}${config.basePath}/ ` +
package/lib/server.js CHANGED
@@ -11,11 +11,12 @@
11
11
  * The SSE framing and error envelopes are the SDK's; this layer only binds
12
12
  * the server and builds the card.
13
13
  */
14
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
15
  import { createServer } from 'node:http';
15
16
  import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, TaskState, } from '@a2a-js/sdk';
16
17
  import { duplicateInterfacesForLegacy } from '@a2a-js/sdk/compat/v0_3';
17
18
  import { RequestMalformedError } from '@a2a-js/sdk/errors';
18
- import { DefaultRequestHandler, ServerCallContext, } from '@a2a-js/sdk/server';
19
+ import { DefaultExecutionEventBus, DefaultRequestHandler, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/server';
19
20
  import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
20
21
  import express from 'express';
21
22
  export async function startA2aServer(options) {
@@ -60,11 +61,71 @@ export async function startA2aServer(options) {
60
61
  throw new RequestMalformedError(error instanceof Error ? error.message : String(error));
61
62
  }
62
63
  };
64
+ // SDK's default manager gives concurrent sends one bus. Give each send its
65
+ // own bus while forwarding events to a stable task bus for resubscribe/cancel.
66
+ const approvalReply = new AsyncLocalStorage();
67
+ const taskBuses = new Map();
68
+ const busManager = {
69
+ createOrGetByTaskId(taskId) {
70
+ let task = taskBuses.get(taskId);
71
+ if (!task) {
72
+ const broadcast = new DefaultExecutionEventBus();
73
+ task = { broadcast, requests: new Set() };
74
+ taskBuses.set(taskId, task);
75
+ broadcast.on('event', (event) => {
76
+ const state = event.kind === 'statusUpdate' ? event.data.status?.state : undefined;
77
+ if (state !== undefined &&
78
+ [
79
+ TaskState.TASK_STATE_COMPLETED,
80
+ TaskState.TASK_STATE_FAILED,
81
+ TaskState.TASK_STATE_CANCELED,
82
+ TaskState.TASK_STATE_REJECTED,
83
+ ].includes(state))
84
+ queueMicrotask(() => broadcast.finished());
85
+ });
86
+ }
87
+ if (approvalReply.getStore()) {
88
+ const active = task.requests.values().next().value;
89
+ if (active)
90
+ return active;
91
+ }
92
+ const requestBus = new DefaultExecutionEventBus();
93
+ task.requests.add(requestBus);
94
+ requestBus.on('event', (event) => task.broadcast.publish(event));
95
+ requestBus.on('finished', () => task.requests.delete(requestBus));
96
+ return requestBus;
97
+ },
98
+ getByTaskId: (taskId) => taskBuses.get(taskId)?.broadcast,
99
+ cleanupByTaskId(taskId) {
100
+ const task = taskBuses.get(taskId);
101
+ task?.broadcast.finished();
102
+ task?.broadcast.removeAllListeners();
103
+ taskBuses.delete(taskId);
104
+ },
105
+ };
106
+ const beforeWorkingTaskMessage = async (message, context) => {
107
+ if (!message?.taskId)
108
+ return;
109
+ // Validate through the scoped store before a host hook can affect a live task.
110
+ const task = await options.taskStore.load(message.taskId, context);
111
+ if (!task ||
112
+ task.status?.state !== TaskState.TASK_STATE_WORKING ||
113
+ (message.contextId && message.contextId !== task.contextId))
114
+ return;
115
+ try {
116
+ await options.beforeWorkingTaskMessage?.(message, context.state.get(STATE_HEADERS_KEY));
117
+ }
118
+ catch (error) {
119
+ throw new RequestMalformedError(error instanceof Error ? error.message : String(error));
120
+ }
121
+ };
63
122
  const requestHandler = new (class extends DefaultRequestHandler {
64
123
  async sendMessage(...args) {
65
124
  const release = guardApproval(args[0].message);
66
125
  try {
67
- return await super.sendMessage(...args);
126
+ if (!release)
127
+ await beforeWorkingTaskMessage(args[0].message, args[1]);
128
+ return await approvalReply.run(Boolean(release), () => super.sendMessage(...args));
68
129
  }
69
130
  finally {
70
131
  release?.();
@@ -73,13 +134,27 @@ export async function startA2aServer(options) {
73
134
  async *sendMessageStream(...args) {
74
135
  const release = guardApproval(args[0].message);
75
136
  try {
76
- yield* super.sendMessageStream(...args);
137
+ if (!release)
138
+ await beforeWorkingTaskMessage(args[0].message, args[1]);
139
+ const stream = super.sendMessageStream(...args);
140
+ try {
141
+ if (release) {
142
+ const first = await approvalReply.run(true, () => stream.next());
143
+ if (!first.done)
144
+ yield first.value;
145
+ }
146
+ yield* stream;
147
+ }
148
+ finally {
149
+ if (release)
150
+ await stream.return(undefined);
151
+ }
77
152
  }
78
153
  finally {
79
154
  release?.();
80
155
  }
81
156
  }
82
- })(card, options.taskStore, options.executor);
157
+ })(card, options.taskStore, options.executor, busManager);
83
158
  const app = express();
84
159
  // The SDK's jsonRpcHandler parses bodies with express.json()'s 100kb default;
85
160
  // raise the ceiling here — body-parser skips re-parsing an already-read body.
@@ -48,11 +48,13 @@ export interface TaskEntry {
48
48
  settled: Array<() => void>;
49
49
  /** Active execute() calls that must finish before task shells are deleted. */
50
50
  running: Set<Promise<void>>;
51
+ pendingMessageAborts: Set<AbortController>;
51
52
  admissionAbort?: AbortController;
52
53
  settlementAbort: AbortController;
53
54
  settling: boolean;
54
55
  lastFinal?: AgentExecutionEvent;
55
56
  interrupted: boolean;
57
+ terminalState?: 'failed' | 'canceled';
56
58
  }
57
59
  export declare class A2aBridge {
58
60
  private readonly ctx;
@@ -92,6 +94,8 @@ export declare class A2aBridge {
92
94
  };
93
95
  private findApproval;
94
96
  waitForTurn(entry: TaskEntry): Promise<void>;
97
+ /** Inspect a message for a live task before the SDK starts its next request. */
98
+ beforeWorkingTaskMessage(a2aMessage: Message, requestHeaders: unknown): Promise<void>;
95
99
  /** Include content preparation in the work a context clear must drain. */
96
100
  trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
97
101
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
@@ -30,8 +30,18 @@ export interface A2aTurnContext {
30
30
  requestHeaders: unknown;
31
31
  signal: AbortSignal;
32
32
  }
33
+ export interface A2aWorkingTaskMessageContext {
34
+ agent: Agent;
35
+ contextId: string;
36
+ taskId: string;
37
+ a2aMessage: Message;
38
+ requestHeaders: unknown;
39
+ signal: AbortSignal;
40
+ }
33
41
  declare module '@deepseek-ai/cordis' {
34
42
  interface Events {
43
+ /** Awaited for a message targeting a running task, before waiting for its prior execution. */
44
+ 'a2a/working-task-message'(detail: A2aWorkingTaskMessageContext): void;
35
45
  /** Awaited before inbox insertion; failure rejects the A2A request. */
36
46
  'a2a/before-followup'(detail: A2aTurnContext): void;
37
47
  /** Await host-owned continuation work before closing this task. */
@@ -27,6 +27,7 @@ export interface A2aServerOptions {
27
27
  executor: AgentExecutor;
28
28
  taskStore: TaskStore;
29
29
  approvalGuard?: (message: Message) => undefined | (() => void);
30
+ beforeWorkingTaskMessage?: (message: Message, requestHeaders: unknown) => Promise<void>;
30
31
  }
31
32
  export interface A2aServer {
32
33
  /** The bound port (differs from options.port when 0). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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",