@amaster.ai/dsh-a2a 0.1.12 → 0.1.14
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 +4 -2
- package/lib/bridge.js +229 -34
- package/lib/executor.js +2 -2
- package/lib/index.js +1 -0
- package/lib/server.js +79 -4
- package/lib/types/bridge.d.ts +15 -8
- package/lib/types/executor.d.ts +1 -1
- package/lib/types/index.d.ts +32 -0
- package/lib/types/server.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,7 +54,9 @@ 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.
|
|
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
|
+
- **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.
|
|
58
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.
|
|
59
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.
|
|
60
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.
|
|
@@ -62,7 +64,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
62
64
|
- **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
65
|
- **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
66
|
- **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
|
-
- **
|
|
67
|
+
- **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
68
|
- **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
69
|
- **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
70
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,24 @@ export class A2aBridge {
|
|
|
81
79
|
return { entry: existing, freshTask: false };
|
|
82
80
|
const forContext = this.bySession.get(contextId);
|
|
83
81
|
if (forContext) {
|
|
84
|
-
|
|
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.
|
|
98
|
+
forContext.interrupted = false;
|
|
99
|
+
forContext.terminalState = undefined;
|
|
91
100
|
this.tasks.set(taskId, forContext);
|
|
92
101
|
return { entry: forContext, freshTask: true };
|
|
93
102
|
}
|
|
@@ -147,6 +156,10 @@ export class A2aBridge {
|
|
|
147
156
|
staleTurn: false,
|
|
148
157
|
settled: [],
|
|
149
158
|
running: new Set(),
|
|
159
|
+
pendingMessageAborts: new Set(),
|
|
160
|
+
settlementAbort: new AbortController(),
|
|
161
|
+
settling: false,
|
|
162
|
+
interrupted: false,
|
|
150
163
|
};
|
|
151
164
|
this.tasks.set(taskId, entry);
|
|
152
165
|
this.bySession.set(contextId, entry);
|
|
@@ -232,6 +245,38 @@ export class A2aBridge {
|
|
|
232
245
|
async waitForTurn(entry) {
|
|
233
246
|
await Promise.allSettled([...entry.running]);
|
|
234
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
|
+
}
|
|
235
280
|
/** Include content preparation in the work a context clear must drain. */
|
|
236
281
|
async trackExecution(contextId, run) {
|
|
237
282
|
const execution = run();
|
|
@@ -251,27 +296,84 @@ export class A2aBridge {
|
|
|
251
296
|
}
|
|
252
297
|
}
|
|
253
298
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
254
|
-
async runTurn(entry, content, bus,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
299
|
+
async runTurn(entry, content, bus, taskId, a2aMessage, requestHeaders) {
|
|
300
|
+
const previous = [...entry.running];
|
|
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
|
+
}
|
|
306
|
+
await Promise.allSettled(previous);
|
|
307
|
+
if (this.clearing.has(entry.sessionId))
|
|
308
|
+
throw new Error('Context is being cleared.');
|
|
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) {
|
|
314
|
+
bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'canceled', 'Task interrupted.'));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await this.runTurnInner(entry, content, bus, taskId, a2aMessage, requestHeaders);
|
|
318
|
+
})();
|
|
258
319
|
entry.running.add(running);
|
|
259
320
|
try {
|
|
260
321
|
await running;
|
|
261
322
|
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
entry.terminalState = 'failed';
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
262
327
|
finally {
|
|
263
328
|
entry.running.delete(running);
|
|
329
|
+
if (entry.running.size === 0)
|
|
330
|
+
entry.terminalState = undefined;
|
|
264
331
|
}
|
|
265
332
|
}
|
|
266
|
-
async runTurnInner(entry, content, bus,
|
|
333
|
+
async runTurnInner(entry, content, bus, taskId, a2aMessage, requestHeaders) {
|
|
267
334
|
let resolveSettled;
|
|
268
335
|
const settled = new Promise((resolve) => {
|
|
269
336
|
resolveSettled = resolve;
|
|
270
337
|
});
|
|
271
338
|
entry.settled.push(resolveSettled);
|
|
272
339
|
entry.bus = bus;
|
|
340
|
+
entry.settlementAbort = new AbortController();
|
|
341
|
+
entry.settling = false;
|
|
342
|
+
entry.lastFinal = undefined;
|
|
273
343
|
try {
|
|
274
344
|
const message = createUserMessage({ content, source: { kind: 'user' } });
|
|
345
|
+
const admission = new AbortController();
|
|
346
|
+
entry.admissionAbort = admission;
|
|
347
|
+
try {
|
|
348
|
+
await Promise.race([
|
|
349
|
+
this.ctx.serial('a2a/before-followup', {
|
|
350
|
+
agent: entry.handle.agent,
|
|
351
|
+
contextId: entry.sessionId,
|
|
352
|
+
taskId,
|
|
353
|
+
a2aMessage,
|
|
354
|
+
dshMessageId: message.id,
|
|
355
|
+
requestHeaders,
|
|
356
|
+
signal: admission.signal,
|
|
357
|
+
}),
|
|
358
|
+
new Promise((_, reject) => {
|
|
359
|
+
const abort = () => reject(new Error('A2A task was interrupted before message admission.'));
|
|
360
|
+
if (admission.signal.aborted)
|
|
361
|
+
abort();
|
|
362
|
+
else
|
|
363
|
+
admission.signal.addEventListener('abort', abort, { once: true });
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
if (entry.admissionAbort === admission)
|
|
369
|
+
entry.admissionAbort = undefined;
|
|
370
|
+
}
|
|
371
|
+
if (!entry.settled.includes(resolveSettled) ||
|
|
372
|
+
entry.interrupted ||
|
|
373
|
+
entry.taskId !== taskId ||
|
|
374
|
+
this.tasks.get(taskId) !== entry) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
275
377
|
// followup() inserts synchronously, then may claim the message before
|
|
276
378
|
// returning. Observe that insertion so the mapping is both real and early.
|
|
277
379
|
const stopListening = this.ctx.on('agent/inbox/inserted', ({ agent, message: inserted }) => {
|
|
@@ -281,8 +383,8 @@ export class A2aBridge {
|
|
|
281
383
|
void this.ctx
|
|
282
384
|
.parallel('a2a/message-admitted', {
|
|
283
385
|
contextId: entry.sessionId,
|
|
284
|
-
taskId
|
|
285
|
-
a2aMessageId,
|
|
386
|
+
taskId,
|
|
387
|
+
a2aMessageId: a2aMessage.messageId,
|
|
286
388
|
dshMessageId: message.id,
|
|
287
389
|
requestHeaders,
|
|
288
390
|
})
|
|
@@ -296,10 +398,14 @@ export class A2aBridge {
|
|
|
296
398
|
// unhandled rejection would crash the host process, and no turn/end
|
|
297
399
|
// would ever settle this waiter.
|
|
298
400
|
void Promise.resolve(result).catch((error) => {
|
|
401
|
+
entry.terminalState = 'failed';
|
|
299
402
|
const message = error instanceof Error ? error.message : String(error);
|
|
300
403
|
console.error('[dsh-a2a] followup failed:', error);
|
|
404
|
+
const index = entry.settled.indexOf(resolveSettled);
|
|
405
|
+
if (index >= 0)
|
|
406
|
+
entry.settled.splice(index, 1);
|
|
301
407
|
if (entry.bus === bus) {
|
|
302
|
-
entry.bus.publish(terminalStatusUpdate(
|
|
408
|
+
entry.bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'failed', message));
|
|
303
409
|
}
|
|
304
410
|
resolveSettled();
|
|
305
411
|
});
|
|
@@ -315,11 +421,15 @@ export class A2aBridge {
|
|
|
315
421
|
const index = entry.settled.indexOf(resolveSettled);
|
|
316
422
|
if (index >= 0)
|
|
317
423
|
entry.settled.splice(index, 1);
|
|
424
|
+
else
|
|
425
|
+
return; // cancellation/clear already published the terminal state
|
|
318
426
|
throw error;
|
|
319
427
|
}
|
|
320
428
|
finally {
|
|
321
|
-
if (entry.bus === bus)
|
|
429
|
+
if (entry.bus === bus) {
|
|
430
|
+
entry.settlementAbort.abort();
|
|
322
431
|
entry.bus = null;
|
|
432
|
+
}
|
|
323
433
|
}
|
|
324
434
|
}
|
|
325
435
|
/** Cancel and release the live binding before the caller deletes stored shells. */
|
|
@@ -331,12 +441,17 @@ export class A2aBridge {
|
|
|
331
441
|
await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
|
|
332
442
|
const entry = this.bySession.get(contextId);
|
|
333
443
|
if (entry) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
444
|
+
const active = entry.turnActive || entry.running.size > 0;
|
|
445
|
+
this.settleEntry(entry, 'Context cleared.');
|
|
446
|
+
if (active) {
|
|
447
|
+
try {
|
|
448
|
+
await entry.handle.agent.cancel({ kind: 'user' });
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
console.error('[dsh-a2a] clear cancel failed:', error);
|
|
452
|
+
}
|
|
337
453
|
}
|
|
338
454
|
await entry.handle.agent.whenIdle();
|
|
339
|
-
this.settleEntry(entry, 'Context cleared.');
|
|
340
455
|
await Promise.allSettled([...entry.running]);
|
|
341
456
|
this.tasks.delete(entry.taskId);
|
|
342
457
|
this.bySession.delete(contextId);
|
|
@@ -354,10 +469,17 @@ export class A2aBridge {
|
|
|
354
469
|
const entry = this.tasks.get(taskId);
|
|
355
470
|
if (!entry)
|
|
356
471
|
return undefined;
|
|
357
|
-
|
|
472
|
+
const staleTurn = entry.turnActive;
|
|
473
|
+
this.settleEntry(entry, 'Task canceled by request.');
|
|
474
|
+
entry.staleTurn ||= staleTurn;
|
|
358
475
|
// cancel() is typed void; the guard keeps a mistyped async impl from
|
|
359
476
|
// crashing the host (the caller already published its canceled final).
|
|
360
|
-
|
|
477
|
+
try {
|
|
478
|
+
void Promise.resolve(entry.handle.agent.cancel({ kind: 'user' })).catch((error) => console.error('[dsh-a2a] cancel failed:', error));
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
console.error('[dsh-a2a] cancel failed:', error);
|
|
482
|
+
}
|
|
361
483
|
return entry.sessionId;
|
|
362
484
|
}
|
|
363
485
|
/** Dispose every owned agent (plugin unload / server shutdown). */
|
|
@@ -380,6 +502,11 @@ export class A2aBridge {
|
|
|
380
502
|
const entry = this.bySession.get(session.id);
|
|
381
503
|
if (!entry)
|
|
382
504
|
return;
|
|
505
|
+
if (entry.staleTurn) {
|
|
506
|
+
if (event.type === 'turn/end')
|
|
507
|
+
entry.staleTurn = false;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
383
510
|
if (event.type === 'approval/asked') {
|
|
384
511
|
const queue = this.asked.get(session.id) ?? [];
|
|
385
512
|
queue.push({ id: event.data.id, toolName: event.data.toolName, callId: event.data.callId });
|
|
@@ -387,29 +514,91 @@ export class A2aBridge {
|
|
|
387
514
|
}
|
|
388
515
|
if (event.type === 'turn/start')
|
|
389
516
|
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
517
|
// No-throw seam: translation errors must never reach the agent loop. The
|
|
398
518
|
// turn/end waiter still resolves below, so a poisoned event cannot hang
|
|
399
519
|
// an in-flight execute() either.
|
|
400
520
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
521
|
+
const outputs = entry.translator.handle(event);
|
|
522
|
+
if (event.type === 'turn/end' &&
|
|
523
|
+
(event.data.reason.kind === 'completed' || event.data.reason.kind === 'max-tokens') &&
|
|
524
|
+
entry.bus) {
|
|
525
|
+
const bus = entry.bus;
|
|
526
|
+
const taskId = entry.taskId;
|
|
527
|
+
entry.lastFinal = outputs[0];
|
|
528
|
+
if (!entry.settling) {
|
|
529
|
+
entry.settling = true;
|
|
530
|
+
const signal = entry.settlementAbort.signal;
|
|
531
|
+
const complete = () => {
|
|
532
|
+
if (signal.aborted || entry.bus !== bus || entry.taskId !== taskId)
|
|
533
|
+
return;
|
|
534
|
+
if (entry.lastFinal)
|
|
535
|
+
bus.publish(entry.lastFinal);
|
|
536
|
+
entry.bus = null;
|
|
537
|
+
entry.settlementAbort.abort();
|
|
538
|
+
entry.settled.shift()?.();
|
|
539
|
+
};
|
|
540
|
+
const wait = this.ctx.waterfall('a2a/task-settlement', {
|
|
541
|
+
agent: entry.handle.agent,
|
|
542
|
+
contextId: entry.sessionId,
|
|
543
|
+
taskId,
|
|
544
|
+
reason: event.data.reason,
|
|
545
|
+
signal,
|
|
546
|
+
}, () => { });
|
|
547
|
+
if (!wait)
|
|
548
|
+
complete();
|
|
549
|
+
else
|
|
550
|
+
void Promise.race([
|
|
551
|
+
wait,
|
|
552
|
+
new Promise((resolve) => {
|
|
553
|
+
if (signal.aborted)
|
|
554
|
+
resolve();
|
|
555
|
+
else
|
|
556
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
557
|
+
}),
|
|
558
|
+
])
|
|
559
|
+
.then(complete)
|
|
560
|
+
.catch((error) => {
|
|
561
|
+
if (entry.bus !== bus || entry.taskId !== taskId)
|
|
562
|
+
return;
|
|
563
|
+
entry.terminalState = 'failed';
|
|
564
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
565
|
+
console.error('[dsh-a2a] task settlement failed:', error);
|
|
566
|
+
bus.publish(terminalStatusUpdate(taskId, entry.sessionId, 'failed', message));
|
|
567
|
+
entry.bus = null;
|
|
568
|
+
entry.settlementAbort.abort();
|
|
569
|
+
entry.settled.shift()?.();
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
for (const out of outputs)
|
|
403
575
|
entry.bus?.publish(out);
|
|
576
|
+
}
|
|
404
577
|
}
|
|
405
578
|
catch (error) {
|
|
406
579
|
console.error('[dsh-a2a] failed to translate session event:', error);
|
|
580
|
+
if (event.type === 'turn/end' &&
|
|
581
|
+
(event.data.reason.kind === 'completed' || event.data.reason.kind === 'max-tokens')) {
|
|
582
|
+
entry.terminalState = 'failed';
|
|
583
|
+
entry.bus?.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', 'Failed to translate agent turn.'));
|
|
584
|
+
entry.bus = null;
|
|
585
|
+
entry.settlementAbort.abort();
|
|
586
|
+
entry.settled.shift()?.();
|
|
587
|
+
}
|
|
407
588
|
}
|
|
408
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';
|
|
409
594
|
this.withdrawApprovals(entry);
|
|
410
595
|
this.asked.delete(session.id);
|
|
411
596
|
entry.turnActive = false;
|
|
412
|
-
|
|
597
|
+
if (event.data.reason.kind !== 'completed' && event.data.reason.kind !== 'max-tokens') {
|
|
598
|
+
entry.settlementAbort.abort();
|
|
599
|
+
entry.bus = null;
|
|
600
|
+
entry.settled.shift()?.();
|
|
601
|
+
}
|
|
413
602
|
}
|
|
414
603
|
}
|
|
415
604
|
/**
|
|
@@ -418,7 +607,7 @@ export class A2aBridge {
|
|
|
418
607
|
*/
|
|
419
608
|
onAssistantStream(agent, frame) {
|
|
420
609
|
const entry = this.bySession.get(agent.session.id);
|
|
421
|
-
if (!entry)
|
|
610
|
+
if (!entry || entry.staleTurn)
|
|
422
611
|
return;
|
|
423
612
|
try {
|
|
424
613
|
for (const out of entry.translator.handleStreamFrame(frame))
|
|
@@ -441,10 +630,16 @@ export class A2aBridge {
|
|
|
441
630
|
}
|
|
442
631
|
/** Close an in-flight stream honestly and release every turn waiter. */
|
|
443
632
|
settleEntry(entry, message) {
|
|
633
|
+
entry.interrupted = true;
|
|
634
|
+
for (const pending of entry.pendingMessageAborts)
|
|
635
|
+
pending.abort();
|
|
636
|
+
entry.admissionAbort?.abort();
|
|
637
|
+
entry.settlementAbort.abort();
|
|
444
638
|
this.withdrawApprovals(entry);
|
|
445
|
-
if (entry.
|
|
639
|
+
if (entry.bus && (entry.turnActive || entry.settled.length > 0)) {
|
|
446
640
|
entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'canceled', message));
|
|
447
641
|
}
|
|
642
|
+
entry.bus = null;
|
|
448
643
|
entry.turnActive = false;
|
|
449
644
|
for (const resolve of entry.settled.splice(0))
|
|
450
645
|
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()`
|
|
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
|
|
65
|
+
await this.bridge.runTurn(entry, content, eventBus, taskId, userMessage, requestHeaders);
|
|
66
66
|
}
|
|
67
67
|
catch (error) {
|
|
68
68
|
if (error instanceof ApprovalReplyError)
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/types/bridge.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,17 @@ 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
|
|
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
|
+
pendingMessageAborts: Set<AbortController>;
|
|
52
|
+
admissionAbort?: AbortController;
|
|
53
|
+
settlementAbort: AbortController;
|
|
54
|
+
settling: boolean;
|
|
55
|
+
lastFinal?: AgentExecutionEvent;
|
|
56
|
+
interrupted: boolean;
|
|
57
|
+
terminalState?: 'failed' | 'canceled';
|
|
53
58
|
}
|
|
54
59
|
export declare class A2aBridge {
|
|
55
60
|
private readonly ctx;
|
|
@@ -89,10 +94,12 @@ export declare class A2aBridge {
|
|
|
89
94
|
};
|
|
90
95
|
private findApproval;
|
|
91
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>;
|
|
92
99
|
/** Include content preparation in the work a context clear must drain. */
|
|
93
100
|
trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
|
|
94
101
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
95
|
-
runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus,
|
|
102
|
+
runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus, taskId: string, a2aMessage: Message, requestHeaders: unknown): Promise<void>;
|
|
96
103
|
private runTurnInner;
|
|
97
104
|
/** Cancel and release the live binding before the caller deletes stored shells. */
|
|
98
105
|
clearContext(contextId: string, clearStored?: (liveIds: string[]) => Promise<string[]>): Promise<string[]>;
|
package/lib/types/executor.d.ts
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()`
|
|
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)
|
package/lib/types/index.d.ts
CHANGED
|
@@ -11,15 +11,47 @@
|
|
|
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
|
+
}
|
|
33
|
+
export interface A2aWorkingTaskMessageContext {
|
|
34
|
+
agent: Agent;
|
|
35
|
+
contextId: string;
|
|
36
|
+
taskId: string;
|
|
37
|
+
a2aMessage: Message;
|
|
38
|
+
requestHeaders: unknown;
|
|
39
|
+
signal: AbortSignal;
|
|
40
|
+
}
|
|
21
41
|
declare module '@deepseek-ai/cordis' {
|
|
22
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;
|
|
45
|
+
/** Awaited before inbox insertion; failure rejects the A2A request. */
|
|
46
|
+
'a2a/before-followup'(detail: A2aTurnContext): void;
|
|
47
|
+
/** Await host-owned continuation work before closing this task. */
|
|
48
|
+
'a2a/task-settlement'(detail: {
|
|
49
|
+
agent: Agent;
|
|
50
|
+
contextId: string;
|
|
51
|
+
taskId: string;
|
|
52
|
+
reason: TurnEndReason;
|
|
53
|
+
signal: AbortSignal;
|
|
54
|
+
}, next: () => void | Promise<void>): Promise<void> | void;
|
|
23
55
|
/** Link a protocol message to the dsh inbox message created for its turn. */
|
|
24
56
|
'a2a/message-admitted'(detail: {
|
|
25
57
|
contextId: string;
|
package/lib/types/server.d.ts
CHANGED
|
@@ -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