@amaster.ai/dsh-a2a 0.1.8 → 0.1.10
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 +3 -2
- package/lib/approval.js +19 -0
- package/lib/bridge.js +217 -23
- package/lib/executor.js +18 -2
- package/lib/index.js +4 -0
- package/lib/server.js +31 -1
- package/lib/translator.js +2 -6
- package/lib/types/approval.d.ts +21 -0
- package/lib/types/bridge.d.ts +20 -3
- package/lib/types/index.d.ts +16 -0
- package/lib/types/server.d.ts +2 -0
- package/lib/types/translator.d.ts +2 -6
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -60,8 +60,9 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
60
60
|
- **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.
|
|
61
61
|
- **Remote execution files.** A deployment can register `ctx.provide('a2aFileMaterializer', { materializeFile })`, using the exported `A2aFileMaterializer` type. The callback receives `{ contextId, bytes, filename, mediaType }` (with a sanitized basename) and returns `{ readablePath }`, a path readable by that context's file/shell tools. When configured, non-image FileParts use the materializer instead of `ctx.attachments.saveFile()`; the model receives the returned `<document path="...">`. Images keep the existing attachment handling. A failed callback yields a delivery note, never a host-path fallback. The deployment owns sandbox selection, upload, and lifetime; no E2B SDK is required by this plugin.
|
|
62
62
|
- **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
|
-
- **
|
|
63
|
+
- **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
|
+
- **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.
|
|
65
66
|
- **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.
|
|
66
67
|
- **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.
|
|
67
68
|
|
package/lib/approval.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class ApprovalReplyError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export const defaultApprovalCodec = {
|
|
4
|
+
encode: ({ requestId, toolName, reason, callId }) => ({ requestId, toolName, reason, callId }),
|
|
5
|
+
decode(message) {
|
|
6
|
+
const data = message.parts.length === 1 && message.parts[0].content?.$case === 'data'
|
|
7
|
+
? message.parts[0].content.value
|
|
8
|
+
: undefined;
|
|
9
|
+
if (!data || !Object.hasOwn(data, 'requestId'))
|
|
10
|
+
return;
|
|
11
|
+
if (typeof data.requestId !== 'string' ||
|
|
12
|
+
!data.requestId ||
|
|
13
|
+
(data.outcome !== 'allowed-once' && data.outcome !== 'rejected') ||
|
|
14
|
+
(data.callId !== undefined && typeof data.callId !== 'string')) {
|
|
15
|
+
throw new Error('Unsupported approval reply.');
|
|
16
|
+
}
|
|
17
|
+
return { requestId: data.requestId, outcome: data.outcome, callId: data.callId };
|
|
18
|
+
},
|
|
19
|
+
};
|
package/lib/bridge.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* turn order). The final status-update is published before the waiter
|
|
16
16
|
* resolves, so an execute() that returns always landed its events first.
|
|
17
17
|
*/
|
|
18
|
+
import { TaskState } from '@a2a-js/sdk';
|
|
19
|
+
import { AgentEvent } from '@a2a-js/sdk/server';
|
|
18
20
|
// installModelSelection is a root-module runtime import: the dsh-agent index
|
|
19
21
|
// pulls workspace-internal packages (dsh-scope) that are absent from the
|
|
20
22
|
// plugin's dev install, so unit tests vi.mock this module.
|
|
@@ -23,8 +25,9 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
|
23
25
|
// modules import packages that are not on npm yet (dsh-timeout, dsh-scope).
|
|
24
26
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
25
27
|
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
28
|
+
import { ApprovalReplyError, defaultApprovalCodec, } from './approval.js';
|
|
26
29
|
import { buildMessageContent, uploadsDir } from './content.js';
|
|
27
|
-
import { SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
30
|
+
import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
28
31
|
export class A2aBridge {
|
|
29
32
|
ctx;
|
|
30
33
|
options;
|
|
@@ -33,12 +36,18 @@ export class A2aBridge {
|
|
|
33
36
|
clearing = new Set();
|
|
34
37
|
creating = new Map();
|
|
35
38
|
executing = new Map();
|
|
39
|
+
asked = new Map();
|
|
40
|
+
approvals = new Map();
|
|
41
|
+
codec;
|
|
36
42
|
constructor(ctx, options) {
|
|
37
43
|
this.ctx = ctx;
|
|
38
44
|
this.options = options;
|
|
45
|
+
this.codec =
|
|
46
|
+
ctx.get('a2aApprovalCodec') ?? defaultApprovalCodec;
|
|
39
47
|
ctx.on('session/event', (session, event) => this.onSessionEvent(session, event));
|
|
40
48
|
ctx.on('session/disposed', (session) => this.onSessionDisposed(session));
|
|
41
49
|
ctx.on('agent/assistant-stream', ({ agent, frame }) => this.onAssistantStream(agent, frame));
|
|
50
|
+
ctx.on('approval/request', (req, next) => this.onApprovalRequest(req, next));
|
|
42
51
|
}
|
|
43
52
|
/**
|
|
44
53
|
* Resolve the live task entry, creating the session/agent when needed.
|
|
@@ -72,6 +81,7 @@ export class A2aBridge {
|
|
|
72
81
|
return { entry: existing, freshTask: false };
|
|
73
82
|
const forContext = this.bySession.get(contextId);
|
|
74
83
|
if (forContext) {
|
|
84
|
+
this.withdrawApprovals(forContext);
|
|
75
85
|
this.tasks.delete(forContext.taskId);
|
|
76
86
|
forContext.taskId = taskId;
|
|
77
87
|
forContext.translator = new SessionTranslator(taskId, contextId, this.options.agentOptions?.model);
|
|
@@ -151,6 +161,77 @@ export class A2aBridge {
|
|
|
151
161
|
isClearing(contextId) {
|
|
152
162
|
return this.clearing.has(contextId);
|
|
153
163
|
}
|
|
164
|
+
/** Called before the SDK starts an executor, so a bad reply cannot fail the live task. */
|
|
165
|
+
validateApprovalMessage(message) {
|
|
166
|
+
const entry = message.contextId
|
|
167
|
+
? this.bySession.get(message.contextId)
|
|
168
|
+
: this.tasks.get(message.taskId);
|
|
169
|
+
const pending = [...this.approvals].filter(([, value]) => value.entry === entry);
|
|
170
|
+
const reply = this.codec.decode(message);
|
|
171
|
+
if (!pending.length && !reply)
|
|
172
|
+
return false;
|
|
173
|
+
if (!entry || !message.taskId || entry.taskId !== message.taskId || !reply) {
|
|
174
|
+
throw new ApprovalReplyError('Approval reply requires the current task and a supported outcome.');
|
|
175
|
+
}
|
|
176
|
+
const found = this.findApproval(entry, reply);
|
|
177
|
+
if (!found || (found[1].reservedBy && found[1].reservedBy !== message.messageId)) {
|
|
178
|
+
throw new ApprovalReplyError('Approval request ID or call ID does not match a pending request.');
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
/** Reserve one reply before the SDK asynchronously loads and saves the task. */
|
|
183
|
+
reserveApprovalMessage(message) {
|
|
184
|
+
if (!this.validateApprovalMessage(message))
|
|
185
|
+
return;
|
|
186
|
+
const entry = this.tasks.get(message.taskId);
|
|
187
|
+
const [id, approval] = this.findApproval(entry, this.codec.decode(message));
|
|
188
|
+
if (approval.reservedBy)
|
|
189
|
+
throw new ApprovalReplyError('Approval reply is already in progress.');
|
|
190
|
+
approval.reservedBy = message.messageId;
|
|
191
|
+
return () => {
|
|
192
|
+
if (this.approvals.get(id) === approval)
|
|
193
|
+
approval.reservedBy = undefined;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Claim the decision before publishing the reply's task anchor. */
|
|
197
|
+
claimApproval(message) {
|
|
198
|
+
const reply = this.codec.decode(message);
|
|
199
|
+
if (!reply)
|
|
200
|
+
throw new ApprovalReplyError('Unsupported approval reply.');
|
|
201
|
+
const entry = this.tasks.get(message.taskId);
|
|
202
|
+
const found = entry && this.findApproval(entry, reply);
|
|
203
|
+
if (!found ||
|
|
204
|
+
entry.sessionId !== message.contextId ||
|
|
205
|
+
(found[1].reservedBy && found[1].reservedBy !== message.messageId)) {
|
|
206
|
+
throw new ApprovalReplyError('Approval is no longer pending.');
|
|
207
|
+
}
|
|
208
|
+
const [id, approval] = found;
|
|
209
|
+
this.approvals.delete(id);
|
|
210
|
+
approval.removeAbort();
|
|
211
|
+
return {
|
|
212
|
+
entry,
|
|
213
|
+
resume: () => {
|
|
214
|
+
approval.resolve(reply.outcome);
|
|
215
|
+
this.publishNextApproval(entry);
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
findApproval(entry, reply) {
|
|
220
|
+
if (reply.requestId) {
|
|
221
|
+
const approval = this.approvals.get(reply.requestId);
|
|
222
|
+
return approval?.entry === entry &&
|
|
223
|
+
(reply.callId === undefined || approval.request.callId === reply.callId)
|
|
224
|
+
? [reply.requestId, approval]
|
|
225
|
+
: undefined;
|
|
226
|
+
}
|
|
227
|
+
if (!reply.callId)
|
|
228
|
+
return;
|
|
229
|
+
const matches = [...this.approvals].filter(([, approval]) => approval.entry === entry && approval.request.callId === reply.callId);
|
|
230
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
231
|
+
}
|
|
232
|
+
async waitForTurn(entry) {
|
|
233
|
+
await Promise.allSettled([...entry.running]);
|
|
234
|
+
}
|
|
154
235
|
/** Include content preparation in the work a context clear must drain. */
|
|
155
236
|
async trackExecution(contextId, run) {
|
|
156
237
|
const execution = run();
|
|
@@ -170,10 +251,10 @@ export class A2aBridge {
|
|
|
170
251
|
}
|
|
171
252
|
}
|
|
172
253
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
173
|
-
async runTurn(entry, content, bus) {
|
|
254
|
+
async runTurn(entry, content, bus, a2aMessageId, requestHeaders) {
|
|
174
255
|
if (this.clearing.has(entry.sessionId))
|
|
175
256
|
throw new Error('Context is being cleared.');
|
|
176
|
-
const running = this.runTurnInner(entry, content, bus);
|
|
257
|
+
const running = this.runTurnInner(entry, content, bus, a2aMessageId, requestHeaders);
|
|
177
258
|
entry.running.add(running);
|
|
178
259
|
try {
|
|
179
260
|
await running;
|
|
@@ -182,7 +263,7 @@ export class A2aBridge {
|
|
|
182
263
|
entry.running.delete(running);
|
|
183
264
|
}
|
|
184
265
|
}
|
|
185
|
-
async runTurnInner(entry, content, bus) {
|
|
266
|
+
async runTurnInner(entry, content, bus, a2aMessageId, requestHeaders) {
|
|
186
267
|
let resolveSettled;
|
|
187
268
|
const settled = new Promise((resolve) => {
|
|
188
269
|
resolveSettled = resolve;
|
|
@@ -190,26 +271,45 @@ export class A2aBridge {
|
|
|
190
271
|
entry.settled.push(resolveSettled);
|
|
191
272
|
entry.bus = bus;
|
|
192
273
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
274
|
+
const message = createUserMessage({ content, source: { kind: 'user' } });
|
|
275
|
+
// followup() inserts synchronously, then may claim the message before
|
|
276
|
+
// returning. Observe that insertion so the mapping is both real and early.
|
|
277
|
+
const stopListening = this.ctx.on('agent/inbox/inserted', ({ agent, message: inserted }) => {
|
|
278
|
+
if (agent !== entry.handle.agent || inserted.id !== message.id)
|
|
279
|
+
return;
|
|
280
|
+
stopListening();
|
|
281
|
+
void this.ctx
|
|
282
|
+
.parallel('a2a/message-admitted', {
|
|
283
|
+
contextId: entry.sessionId,
|
|
284
|
+
taskId: entry.taskId,
|
|
285
|
+
a2aMessageId,
|
|
286
|
+
dshMessageId: message.id,
|
|
287
|
+
requestHeaders,
|
|
288
|
+
})
|
|
289
|
+
.catch((error) => {
|
|
290
|
+
console.error('[dsh-a2a] message-admitted listener failed:', error);
|
|
291
|
+
});
|
|
207
292
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
293
|
+
try {
|
|
294
|
+
const result = entry.handle.agent.followup(message);
|
|
295
|
+
// followup() is typed void; guard a mistyped async impl anyway — an
|
|
296
|
+
// unhandled rejection would crash the host process, and no turn/end
|
|
297
|
+
// would ever settle this waiter.
|
|
298
|
+
void Promise.resolve(result).catch((error) => {
|
|
299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
300
|
+
console.error('[dsh-a2a] followup failed:', error);
|
|
301
|
+
if (entry.bus === bus) {
|
|
302
|
+
entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', message));
|
|
303
|
+
}
|
|
304
|
+
resolveSettled();
|
|
305
|
+
});
|
|
306
|
+
// Never rejects: waiters only resolve. A synchronous followup() throw
|
|
307
|
+
// means no turn started, so pull its waiter from the FIFO below.
|
|
308
|
+
await settled;
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
stopListening();
|
|
312
|
+
}
|
|
213
313
|
}
|
|
214
314
|
catch (error) {
|
|
215
315
|
const index = entry.settled.indexOf(resolveSettled);
|
|
@@ -231,6 +331,7 @@ export class A2aBridge {
|
|
|
231
331
|
await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
|
|
232
332
|
const entry = this.bySession.get(contextId);
|
|
233
333
|
if (entry) {
|
|
334
|
+
this.withdrawApprovals(entry);
|
|
234
335
|
if (entry.turnActive || entry.running.size > 0) {
|
|
235
336
|
await entry.handle.agent.cancel({ kind: 'user' });
|
|
236
337
|
}
|
|
@@ -253,6 +354,7 @@ export class A2aBridge {
|
|
|
253
354
|
const entry = this.tasks.get(taskId);
|
|
254
355
|
if (!entry)
|
|
255
356
|
return undefined;
|
|
357
|
+
this.withdrawApprovals(entry);
|
|
256
358
|
// cancel() is typed void; the guard keeps a mistyped async impl from
|
|
257
359
|
// crashing the host (the caller already published its canceled final).
|
|
258
360
|
void Promise.resolve(entry.handle.agent.cancel({ kind: 'user' })).catch((error) => console.error('[dsh-a2a] cancel failed:', error));
|
|
@@ -264,6 +366,7 @@ export class A2aBridge {
|
|
|
264
366
|
this.tasks.clear();
|
|
265
367
|
this.bySession.clear();
|
|
266
368
|
for (const entry of entries) {
|
|
369
|
+
this.withdrawApprovals(entry);
|
|
267
370
|
this.settleEntry(entry, 'Plugin unloaded.');
|
|
268
371
|
try {
|
|
269
372
|
await entry.handle.dispose();
|
|
@@ -277,6 +380,11 @@ export class A2aBridge {
|
|
|
277
380
|
const entry = this.bySession.get(session.id);
|
|
278
381
|
if (!entry)
|
|
279
382
|
return;
|
|
383
|
+
if (event.type === 'approval/asked') {
|
|
384
|
+
const queue = this.asked.get(session.id) ?? [];
|
|
385
|
+
queue.push({ id: event.data.id, toolName: event.data.toolName, callId: event.data.callId });
|
|
386
|
+
this.asked.set(session.id, queue);
|
|
387
|
+
}
|
|
280
388
|
if (event.type === 'turn/start')
|
|
281
389
|
entry.turnActive = true;
|
|
282
390
|
// A turn carried over from a previous task binding (e.g. a canceled turn
|
|
@@ -298,6 +406,8 @@ export class A2aBridge {
|
|
|
298
406
|
console.error('[dsh-a2a] failed to translate session event:', error);
|
|
299
407
|
}
|
|
300
408
|
if (event.type === 'turn/end') {
|
|
409
|
+
this.withdrawApprovals(entry);
|
|
410
|
+
this.asked.delete(session.id);
|
|
301
411
|
entry.turnActive = false;
|
|
302
412
|
entry.settled.shift()?.();
|
|
303
413
|
}
|
|
@@ -322,6 +432,7 @@ export class A2aBridge {
|
|
|
322
432
|
const entry = this.bySession.get(session.id);
|
|
323
433
|
if (!entry)
|
|
324
434
|
return;
|
|
435
|
+
this.withdrawApprovals(entry);
|
|
325
436
|
this.tasks.delete(entry.taskId);
|
|
326
437
|
this.bySession.delete(session.id);
|
|
327
438
|
// A mid-turn disposal never emits turn/end — settle instead of hanging
|
|
@@ -330,6 +441,7 @@ export class A2aBridge {
|
|
|
330
441
|
}
|
|
331
442
|
/** Close an in-flight stream honestly and release every turn waiter. */
|
|
332
443
|
settleEntry(entry, message) {
|
|
444
|
+
this.withdrawApprovals(entry);
|
|
333
445
|
if (entry.turnActive && entry.bus) {
|
|
334
446
|
entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'canceled', message));
|
|
335
447
|
}
|
|
@@ -337,6 +449,88 @@ export class A2aBridge {
|
|
|
337
449
|
for (const resolve of entry.settled.splice(0))
|
|
338
450
|
resolve();
|
|
339
451
|
}
|
|
452
|
+
onApprovalRequest(req, next) {
|
|
453
|
+
const entry = this.bySession.get(req.agent.session.id);
|
|
454
|
+
if (!entry || entry.handle.agent !== req.agent)
|
|
455
|
+
return next();
|
|
456
|
+
const queue = this.asked.get(entry.sessionId) ?? [];
|
|
457
|
+
const matching = queue.filter((ask) => ask.toolName === req.toolName && ask.callId === req.callId);
|
|
458
|
+
// The answerer request has no request ID; duplicate unclaimed audit asks
|
|
459
|
+
// cannot be paired safely without a distinct call ID.
|
|
460
|
+
const asked = matching.length === 1 ? queue.splice(queue.indexOf(matching[0]), 1)[0] : undefined;
|
|
461
|
+
if (!asked || !entry.bus || !entry.turnActive || req.signal?.aborted)
|
|
462
|
+
return Promise.resolve('unavailable');
|
|
463
|
+
return new Promise((resolve) => {
|
|
464
|
+
const onAbort = () => {
|
|
465
|
+
this.approvals.delete(asked.id);
|
|
466
|
+
resolve('cancelled');
|
|
467
|
+
this.publishNextApproval(entry);
|
|
468
|
+
};
|
|
469
|
+
req.signal?.addEventListener('abort', onAbort, { once: true });
|
|
470
|
+
this.approvals.set(asked.id, {
|
|
471
|
+
entry,
|
|
472
|
+
request: {
|
|
473
|
+
requestId: asked.id,
|
|
474
|
+
toolName: req.toolName,
|
|
475
|
+
reason: req.reason,
|
|
476
|
+
callId: asked.callId,
|
|
477
|
+
},
|
|
478
|
+
prompted: false,
|
|
479
|
+
resolve,
|
|
480
|
+
removeAbort: () => req.signal?.removeEventListener('abort', onAbort),
|
|
481
|
+
});
|
|
482
|
+
this.publishNextApproval(entry);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
publishNextApproval(entry) {
|
|
486
|
+
const waiting = [...this.approvals].filter(([, approval]) => approval.entry === entry);
|
|
487
|
+
if (waiting.some(([, approval]) => approval.prompted))
|
|
488
|
+
return;
|
|
489
|
+
const next = waiting[0];
|
|
490
|
+
if (!next || !entry.bus)
|
|
491
|
+
return;
|
|
492
|
+
const [id, approval] = next;
|
|
493
|
+
approval.prompted = true;
|
|
494
|
+
try {
|
|
495
|
+
const data = this.codec.encode(approval.request);
|
|
496
|
+
const message = agentTextMessage(entry.taskId, entry.sessionId, 'Tool approval required.');
|
|
497
|
+
message.parts = [
|
|
498
|
+
{
|
|
499
|
+
content: { $case: 'data', value: data },
|
|
500
|
+
metadata: undefined,
|
|
501
|
+
filename: '',
|
|
502
|
+
mediaType: 'application/json',
|
|
503
|
+
},
|
|
504
|
+
];
|
|
505
|
+
entry.bus.publish(AgentEvent.statusUpdate({
|
|
506
|
+
taskId: entry.taskId,
|
|
507
|
+
contextId: entry.sessionId,
|
|
508
|
+
status: {
|
|
509
|
+
state: TaskState.TASK_STATE_INPUT_REQUIRED,
|
|
510
|
+
message,
|
|
511
|
+
timestamp: new Date().toISOString(),
|
|
512
|
+
},
|
|
513
|
+
metadata: { dshAgent: { kind: 'state-change', reason: 'approval' } },
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
console.error('[dsh-a2a] failed to publish approval:', error);
|
|
518
|
+
this.approvals.delete(id);
|
|
519
|
+
approval.removeAbort();
|
|
520
|
+
approval.resolve('unavailable');
|
|
521
|
+
this.publishNextApproval(entry);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
withdrawApprovals(entry) {
|
|
525
|
+
for (const [id, approval] of this.approvals) {
|
|
526
|
+
if (approval.entry !== entry)
|
|
527
|
+
continue;
|
|
528
|
+
this.approvals.delete(id);
|
|
529
|
+
approval.removeAbort();
|
|
530
|
+
approval.resolve('cancelled');
|
|
531
|
+
}
|
|
532
|
+
this.asked.delete(entry.sessionId);
|
|
533
|
+
}
|
|
340
534
|
/**
|
|
341
535
|
* Config override wins per field; otherwise the deployment default read from
|
|
342
536
|
* the `agentDefaultModel` service (the entry-point contract — the loop
|
package/lib/executor.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* `cancelTask` is an arrow property because the SDK destructures it.
|
|
19
19
|
*/
|
|
20
20
|
import { TaskState } from '@a2a-js/sdk';
|
|
21
|
-
import { AgentEvent } from '@a2a-js/sdk/server';
|
|
21
|
+
import { AgentEvent, STATE_HEADERS_KEY } from '@a2a-js/sdk/server';
|
|
22
|
+
import { ApprovalReplyError } from './approval.js';
|
|
22
23
|
import { agentTextMessage, terminalStatusUpdate } from './translator.js';
|
|
23
24
|
export class DshAgentExecutor {
|
|
24
25
|
bridge;
|
|
@@ -37,6 +38,18 @@ export class DshAgentExecutor {
|
|
|
37
38
|
const { userMessage, taskId, contextId } = requestContext;
|
|
38
39
|
let anchored = false;
|
|
39
40
|
try {
|
|
41
|
+
if (this.bridge.validateApprovalMessage(userMessage)) {
|
|
42
|
+
const approval = this.bridge.claimApproval(userMessage);
|
|
43
|
+
try {
|
|
44
|
+
eventBus.publish(taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
|
|
45
|
+
anchored = true;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
approval.resume();
|
|
49
|
+
}
|
|
50
|
+
await this.bridge.waitForTurn(approval.entry);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
40
53
|
const content = await this.bridge.buildContent(userMessage, contextId);
|
|
41
54
|
const { entry, freshTask } = await this.bridge.ensureTask(taskId, contextId);
|
|
42
55
|
eventBus.publish(freshTask
|
|
@@ -48,9 +61,12 @@ export class DshAgentExecutor {
|
|
|
48
61
|
// stored task (history/artifacts preserved).
|
|
49
62
|
taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
|
|
50
63
|
anchored = true;
|
|
51
|
-
|
|
64
|
+
const requestHeaders = requestContext.context.state.get(STATE_HEADERS_KEY);
|
|
65
|
+
await this.bridge.runTurn(entry, content, eventBus, userMessage.messageId, requestHeaders);
|
|
52
66
|
}
|
|
53
67
|
catch (error) {
|
|
68
|
+
if (error instanceof ApprovalReplyError)
|
|
69
|
+
return;
|
|
54
70
|
if (this.bridge.isClearing(contextId))
|
|
55
71
|
this.suppressTask?.(taskId);
|
|
56
72
|
const message = error instanceof Error ? error.message : String(error);
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,9 @@ import { startA2aServer } from './server.js';
|
|
|
20
20
|
import { GcsTaskStore } from './stores/gcs.js';
|
|
21
21
|
import { RedisTaskStore } from './stores/redis.js';
|
|
22
22
|
import { MemoryTaskStore, SanitizedTaskStore } from './task-store.js';
|
|
23
|
+
export { defaultApprovalCodec } from './approval.js';
|
|
24
|
+
/** Event emitted when one A2A user message enters the dsh inbox. */
|
|
25
|
+
export const A2A_MESSAGE_ADMITTED_EVENT = 'a2a/message-admitted';
|
|
23
26
|
export const name = 'dsh-a2a';
|
|
24
27
|
/** The bridge creates and owns agents through the registry service. */
|
|
25
28
|
export const inject = ['agents'];
|
|
@@ -136,6 +139,7 @@ export function apply(ctx, config) {
|
|
|
136
139
|
},
|
|
137
140
|
executor,
|
|
138
141
|
taskStore: store,
|
|
142
|
+
approvalGuard: (message) => bridge.reserveApprovalMessage(message),
|
|
139
143
|
});
|
|
140
144
|
serverPort = server.port;
|
|
141
145
|
console.log(`[dsh-a2a] A2A endpoint: http://${config.host}:${server.port}${config.basePath}/ ` +
|
package/lib/server.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, TaskState, } from '@a2a-js/sdk';
|
|
16
16
|
import { duplicateInterfacesForLegacy } from '@a2a-js/sdk/compat/v0_3';
|
|
17
|
+
import { RequestMalformedError } from '@a2a-js/sdk/errors';
|
|
17
18
|
import { DefaultRequestHandler, ServerCallContext, } from '@a2a-js/sdk/server';
|
|
18
19
|
import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
|
|
19
20
|
import express from 'express';
|
|
@@ -49,7 +50,36 @@ export async function startA2aServer(options) {
|
|
|
49
50
|
skills: [],
|
|
50
51
|
signatures: [],
|
|
51
52
|
};
|
|
52
|
-
const
|
|
53
|
+
const guardApproval = (message) => {
|
|
54
|
+
if (!message)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
return options.approvalGuard?.(message);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new RequestMalformedError(error instanceof Error ? error.message : String(error));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const requestHandler = new (class extends DefaultRequestHandler {
|
|
64
|
+
async sendMessage(...args) {
|
|
65
|
+
const release = guardApproval(args[0].message);
|
|
66
|
+
try {
|
|
67
|
+
return await super.sendMessage(...args);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
release?.();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async *sendMessageStream(...args) {
|
|
74
|
+
const release = guardApproval(args[0].message);
|
|
75
|
+
try {
|
|
76
|
+
yield* super.sendMessageStream(...args);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
release?.();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
})(card, options.taskStore, options.executor);
|
|
53
83
|
const app = express();
|
|
54
84
|
// The SDK's jsonRpcHandler parses bodies with express.json()'s 100kb default;
|
|
55
85
|
// raise the ceiling here — body-parser skips re-parsing an already-read body.
|
package/lib/translator.js
CHANGED
|
@@ -33,12 +33,8 @@
|
|
|
33
33
|
* A2A 1.0 removed the `final` flag: the SDK's event queue stops on terminal
|
|
34
34
|
* and interrupted (input-required) states, which is exactly the set above.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* headless profiles do not compose it and `tools/pre-execute`'s `ask` fails
|
|
39
|
-
* closed without one, so no approval bridge exists here. TODO(verify):
|
|
40
|
-
* bridge approval asks to A2A input-required once a deployment composes the
|
|
41
|
-
* service.
|
|
36
|
+
* Mid-turn approvals are mapped separately by A2aBridge: they interrupt the
|
|
37
|
+
* A2A task while this translator keeps the original dsh turn open.
|
|
42
38
|
*/
|
|
43
39
|
import { randomUUID } from 'node:crypto';
|
|
44
40
|
import { Role, TaskState } from '@a2a-js/sdk';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Message } from '@a2a-js/sdk';
|
|
2
|
+
export declare class ApprovalReplyError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface A2aApproval {
|
|
5
|
+
requestId: string;
|
|
6
|
+
toolName: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
callId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface A2aApprovalReply {
|
|
11
|
+
/** A legacy codec may correlate by a unique callId instead. */
|
|
12
|
+
requestId?: string;
|
|
13
|
+
callId?: string;
|
|
14
|
+
outcome: 'allowed-once' | 'rejected';
|
|
15
|
+
}
|
|
16
|
+
/** A2A has no standard approval DataPart schema. Deployments may replace this codec. */
|
|
17
|
+
export interface A2aApprovalCodec {
|
|
18
|
+
encode(request: A2aApproval): Record<string, unknown>;
|
|
19
|
+
decode(message: Message): A2aApprovalReply | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare const defaultApprovalCodec: A2aApprovalCodec;
|
package/lib/types/bridge.d.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* turn order). The final status-update is published before the waiter
|
|
16
16
|
* resolves, so an execute() that returns always landed its events first.
|
|
17
17
|
*/
|
|
18
|
-
import type
|
|
19
|
-
import type
|
|
18
|
+
import { type Message } from '@a2a-js/sdk';
|
|
19
|
+
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
20
20
|
import type { Context } from '@deepseek-ai/cordis';
|
|
21
21
|
import type { AgentHandle } from '@deepseek-ai/dsh-agent';
|
|
22
22
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
|
|
@@ -59,6 +59,9 @@ export declare class A2aBridge {
|
|
|
59
59
|
private readonly clearing;
|
|
60
60
|
private readonly creating;
|
|
61
61
|
private readonly executing;
|
|
62
|
+
private readonly asked;
|
|
63
|
+
private readonly approvals;
|
|
64
|
+
private readonly codec;
|
|
62
65
|
constructor(ctx: Context, options: BridgeOptions);
|
|
63
66
|
/**
|
|
64
67
|
* Resolve the live task entry, creating the session/agent when needed.
|
|
@@ -75,10 +78,21 @@ export declare class A2aBridge {
|
|
|
75
78
|
/** A2A parts → dsh content blocks; throws only when no part is usable. */
|
|
76
79
|
buildContent(message: Message, contextId: string): Promise<ContentBlock[]>;
|
|
77
80
|
isClearing(contextId: string): boolean;
|
|
81
|
+
/** Called before the SDK starts an executor, so a bad reply cannot fail the live task. */
|
|
82
|
+
validateApprovalMessage(message: Message): boolean;
|
|
83
|
+
/** Reserve one reply before the SDK asynchronously loads and saves the task. */
|
|
84
|
+
reserveApprovalMessage(message: Message): (() => void) | undefined;
|
|
85
|
+
/** Claim the decision before publishing the reply's task anchor. */
|
|
86
|
+
claimApproval(message: Message): {
|
|
87
|
+
entry: TaskEntry;
|
|
88
|
+
resume: () => void;
|
|
89
|
+
};
|
|
90
|
+
private findApproval;
|
|
91
|
+
waitForTurn(entry: TaskEntry): Promise<void>;
|
|
78
92
|
/** Include content preparation in the work a context clear must drain. */
|
|
79
93
|
trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
|
|
80
94
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
81
|
-
runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus): Promise<void>;
|
|
95
|
+
runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus, a2aMessageId: string, requestHeaders: unknown): Promise<void>;
|
|
82
96
|
private runTurnInner;
|
|
83
97
|
/** Cancel and release the live binding before the caller deletes stored shells. */
|
|
84
98
|
clearContext(contextId: string, clearStored?: (liveIds: string[]) => Promise<string[]>): Promise<string[]>;
|
|
@@ -95,6 +109,9 @@ export declare class A2aBridge {
|
|
|
95
109
|
private onSessionDisposed;
|
|
96
110
|
/** Close an in-flight stream honestly and release every turn waiter. */
|
|
97
111
|
private settleEntry;
|
|
112
|
+
private onApprovalRequest;
|
|
113
|
+
private publishNextApproval;
|
|
114
|
+
private withdrawApprovals;
|
|
98
115
|
/**
|
|
99
116
|
* Config override wins per field; otherwise the deployment default read from
|
|
100
117
|
* the `agentDefaultModel` service (the entry-point contract — the loop
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,7 +13,23 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { Context } from '@deepseek-ai/cordis';
|
|
15
15
|
import Schema from '@deepseek-ai/schemastery';
|
|
16
|
+
export type { A2aApproval, A2aApprovalCodec, A2aApprovalReply } from './approval.js';
|
|
17
|
+
export { defaultApprovalCodec } from './approval.js';
|
|
16
18
|
export type { A2aFileMaterializer } from './content.js';
|
|
19
|
+
/** Event emitted when one A2A user message enters the dsh inbox. */
|
|
20
|
+
export declare const A2A_MESSAGE_ADMITTED_EVENT: "a2a/message-admitted";
|
|
21
|
+
declare module '@deepseek-ai/cordis' {
|
|
22
|
+
interface Events {
|
|
23
|
+
/** Link a protocol message to the dsh inbox message created for its turn. */
|
|
24
|
+
'a2a/message-admitted'(detail: {
|
|
25
|
+
contextId: string;
|
|
26
|
+
taskId: string;
|
|
27
|
+
a2aMessageId: string;
|
|
28
|
+
dshMessageId: string;
|
|
29
|
+
requestHeaders: unknown;
|
|
30
|
+
}): void;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
17
33
|
export declare const name = "dsh-a2a";
|
|
18
34
|
/** The bridge creates and owns agents through the registry service. */
|
|
19
35
|
export declare const inject: string[];
|
package/lib/types/server.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
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 { type Message } from '@a2a-js/sdk';
|
|
14
15
|
import { type AgentExecutor, type TaskStore } from '@a2a-js/sdk/server';
|
|
15
16
|
export interface A2aServerOptions {
|
|
16
17
|
host: string;
|
|
@@ -25,6 +26,7 @@ export interface A2aServerOptions {
|
|
|
25
26
|
};
|
|
26
27
|
executor: AgentExecutor;
|
|
27
28
|
taskStore: TaskStore;
|
|
29
|
+
approvalGuard?: (message: Message) => undefined | (() => void);
|
|
28
30
|
}
|
|
29
31
|
export interface A2aServer {
|
|
30
32
|
/** The bound port (differs from options.port when 0). */
|
|
@@ -33,12 +33,8 @@
|
|
|
33
33
|
* A2A 1.0 removed the `final` flag: the SDK's event queue stops on terminal
|
|
34
34
|
* and interrupted (input-required) states, which is exactly the set above.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* headless profiles do not compose it and `tools/pre-execute`'s `ask` fails
|
|
39
|
-
* closed without one, so no approval bridge exists here. TODO(verify):
|
|
40
|
-
* bridge approval asks to A2A input-required once a deployment composes the
|
|
41
|
-
* service.
|
|
36
|
+
* Mid-turn approvals are mapped separately by A2aBridge: they interrupt the
|
|
37
|
+
* A2A task while this translator keeps the original dsh turn open.
|
|
42
38
|
*/
|
|
43
39
|
import { type Message } from '@a2a-js/sdk';
|
|
44
40
|
import { type AgentExecutionEvent } from '@a2a-js/sdk/server';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amaster.ai/dsh-a2a",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
43
43
|
"@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
44
44
|
"@deepseek-ai/dsh-session": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
45
|
+
"@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2",
|
|
45
46
|
"@google-cloud/storage": "^7.0.0",
|
|
46
47
|
"ioredis": "^5.0.0"
|
|
47
48
|
},
|
|
@@ -49,6 +50,9 @@
|
|
|
49
50
|
"@deepseek-ai/dsh-attachment": {
|
|
50
51
|
"optional": true
|
|
51
52
|
},
|
|
53
|
+
"@deepseek-ai/dsh-user-approval": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
52
56
|
"@google-cloud/storage": {
|
|
53
57
|
"optional": true
|
|
54
58
|
},
|
|
@@ -62,6 +66,7 @@
|
|
|
62
66
|
"@deepseek-ai/dsh-attachment": "0.1.6-alpha.2",
|
|
63
67
|
"@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
|
|
64
68
|
"@deepseek-ai/dsh-session": "0.1.6-alpha.2",
|
|
69
|
+
"@deepseek-ai/dsh-user-approval": "0.1.6-alpha.2",
|
|
65
70
|
"@google-cloud/storage": "^7.0.0",
|
|
66
71
|
"@types/express": "^4.17.21",
|
|
67
72
|
"@types/node": "^22.0.0",
|