@amaster.ai/dsh-a2a 0.1.13 → 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 +2 -1
- package/lib/bridge.js +58 -1
- package/lib/index.js +1 -0
- package/lib/server.js +79 -4
- package/lib/types/bridge.d.ts +4 -0
- package/lib/types/index.d.ts +10 -0
- package/lib/types/server.d.ts +1 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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`. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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. */
|
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