@amaster.ai/dsh-a2a 0.1.38 → 0.1.39
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 +41 -4
- package/lib/bridge.js +32 -9
- package/lib/content.js +139 -96
- package/lib/executor.js +4 -3
- package/lib/inbox.js +48 -34
- package/lib/stores/filesystem.js +40 -15
- package/lib/stores/gcs.js +61 -15
- package/lib/stores/listing.js +63 -0
- package/lib/stores/redis.js +55 -12
- package/lib/task-store.js +41 -7
- package/lib/types/bridge.d.ts +3 -2
- package/lib/types/content.d.ts +2 -1
- package/lib/types/inbox.d.ts +8 -5
- package/lib/types/stores/filesystem.d.ts +2 -0
- package/lib/types/stores/gcs.d.ts +2 -0
- package/lib/types/stores/listing.d.ts +10 -0
- package/lib/types/stores/redis.d.ts +2 -0
- package/lib/types/task-store.d.ts +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,11 +14,11 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
|
14
14
|
|
|
15
15
|
## Native inbox execution
|
|
16
16
|
|
|
17
|
-
`inbox.enabled: true` uses the official DSH inbox (`Agent.send`) for ordinary `next-turn` input and `next-step` steering. Tested with the unmodified npm runtime `0.2.1-rc.5`; no core patch is required. The queue and its durable projection belong to DSH. The plugin only materializes messages, attaches A2A task identities, and translates execution events.
|
|
17
|
+
`inbox.enabled: true` uses the official DSH inbox (`Agent.send`) for ordinary `next-turn` input and `next-step` steering. Tested with the unmodified npm runtime `0.2.1-rc.5`; no core patch is required. Cold task recovery scans committed input once per batch and groups it by task, preserving input order. The queue and its durable projection belong to DSH. The plugin only materializes messages, attaches A2A task identities, and translates execution events.
|
|
18
18
|
|
|
19
19
|
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and task subscriptions keep their official meanings. Optional inbox metadata forwards `target` and `wakeup` to DSH. `wakeup: false` leaves an idle driver parked; it does not prevent an active driver from consuming pending input. A later real waking message releases pending input.
|
|
20
20
|
|
|
21
|
-
The same-process `a2aInbox` service exposes `resolve`, `content`, `enqueue`, `promote`, `cancel`, `get`, `update`, `move`, `remove`, `clearPending`, and `executePending`, plus the existing pause/wake/resume controls. Pending-only clearing preserves active work; context clearing discards all work.
|
|
21
|
+
The same-process `a2aInbox` service exposes `recoveryCandidates`, `resolve`, `content`, `enqueue`, `promote`, `cancel`, `get`, `update`, `move`, `remove`, `clearPending`, and `executePending`, plus the existing pause/wake/resume controls. Pending-only clearing preserves active work; context clearing discards all work.
|
|
22
22
|
|
|
23
23
|
Pause is process-local and writes no custom required session event. After restart, restored input stays parked until a waking admission or explicit execution. Retention recovery accepts snapshots under the historical extension URI as well as the current URI, writes only the current metadata key, and clears both keys after consumption. Explicit resume durably transfers input to a fresh task before waking it; retry after an interrupted cleanup reuses the existing replacement instead of replaying its input.
|
|
24
24
|
|
|
@@ -54,6 +54,41 @@ Select `taskStore: filesystem` with `filesystem.root` pointing at a retained dir
|
|
|
54
54
|
|
|
55
55
|
After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or replay of the interrupted active model call.
|
|
56
56
|
|
|
57
|
+
#### Discovering recovery candidates
|
|
58
|
+
|
|
59
|
+
`await a2aInbox.recoveryCandidates(call?)` returns sorted, distinct context IDs
|
|
60
|
+
from nonterminal task checkpoints in the caller's SDK tenant/user scope (default:
|
|
61
|
+
the anonymous default scope). It does not open session logs, create agents,
|
|
62
|
+
resolve contexts or wake input. Pass the same `call` to
|
|
63
|
+
`a2aInbox.resolve(contextId, call)` to restore tasks from that scope. Prepare host
|
|
64
|
+
identity before resolving a context;
|
|
65
|
+
the host still decides whether restored work should run. Paused, input-required
|
|
66
|
+
and interrupted tasks can all be candidates, including tasks with no remaining
|
|
67
|
+
unclaimed input. Completed, canceled, failed and rejected tasks are excluded;
|
|
68
|
+
retained input on a terminal task still requires explicit `resume`.
|
|
69
|
+
|
|
70
|
+
Context IDs identify agents globally within one plugin instance; the TaskStore
|
|
71
|
+
scope is not a separate agent namespace. Resolving an already loading or loaded
|
|
72
|
+
context with a different explicit tenant/user scope rejects. Existing host
|
|
73
|
+
controls without a call context continue to address that loaded agent.
|
|
74
|
+
|
|
75
|
+
No separate index or storage configuration is needed. Discovery uses the same
|
|
76
|
+
atomic summaries as task listing: bounded snapshot prefixes under
|
|
77
|
+
`filesystem.root`, Redis keys under the configured prefix, or object metadata in
|
|
78
|
+
the configured GCS bucket. Each call enumerates the scope once, without sorting
|
|
79
|
+
or hydrating full task pages. Old, malformed or oversized summaries fall back to
|
|
80
|
+
reading their snapshots; unreadable snapshots and backend errors reject the
|
|
81
|
+
call. Custom TaskStores without the optional discovery method use exhaustive
|
|
82
|
+
task pagination. Task listing counts, filters and cursor semantics are unchanged.
|
|
83
|
+
|
|
84
|
+
Discovery remains O(task count) in lightweight summaries; it is not a
|
|
85
|
+
pending-only index. It covers surviving task snapshots, not orphaned session
|
|
86
|
+
logs. Keep the host's session-scan fallback for deployments whose historical
|
|
87
|
+
tasks were not durably stored or have expired. The memory backend has no restart
|
|
88
|
+
durability. Redis persistence must be configured separately, and its task TTL
|
|
89
|
+
(default 24 hours) still applies. Durable recovery also requires retained DSH
|
|
90
|
+
session logs and materialized files; a TaskStore alone does not preserve them.
|
|
91
|
+
|
|
57
92
|
### Graceful host shutdown
|
|
58
93
|
|
|
59
94
|
Set `inbox: { enabled: true, shutdown: retain-pending }` on both sides of a restart to retain unclaimed A2A `next-step` and `next-turn` input. Keep durable session persistence, the TaskStore and materialized files on retained storage. This is a host configuration policy, not wire metadata. The default `shutdown: cancel` keeps the existing terminal-on-unload behavior.
|
|
@@ -308,8 +343,8 @@ slow readers independently of the LLM/Langfuse integration gate.
|
|
|
308
343
|
- **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.
|
|
309
344
|
- **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.
|
|
310
345
|
- **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.
|
|
311
|
-
- **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. Downloads default to public IP addresses and revalidate every redirect. In a trusted intranet, set `urlDownloads.allowPrivate: true` to allow every private or loopback host without an allowlist; `urlDownloads.allowedHosts` remains available to allow only named hosts. 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,
|
|
312
|
-
- **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.
|
|
346
|
+
- **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, including the DNS-validation wait. Downloads default to public IP addresses and revalidate every redirect. In a trusted intranet, set `urlDownloads.allowPrivate: true` to allow every private or loopback host without an allowlist; `urlDownloads.allowedHosts` remains available to allow only named hosts. 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, duplicate names stored in unique `upload-*` subdirectories), 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.
|
|
347
|
+
- **Remote execution files.** A deployment can register `ctx.provide('a2aFileMaterializer', { materializeFile })`, using the exported `A2aFileMaterializer` type. The callback receives `{ contextId, bytes, filename, mediaType, signal? }` (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. Task cancellation, context clearing, and plugin unload stop waiting for file preparation. Materializers should honor the optional abort signal to stop their underlying work; a backend that ignores it may still finish independently. The deployment owns sandbox selection, upload, and lifetime; no E2B SDK is required by this plugin.
|
|
313
348
|
- **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.
|
|
314
349
|
- **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.
|
|
315
350
|
- **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.
|
|
@@ -366,6 +401,8 @@ A2A task snapshots retain protocol history and artifacts; [dsh-storage](../dsh-s
|
|
|
366
401
|
- `redis` — task JSON under `<keyPrefix>:tasks:<scope>:<taskId>` with a TTL; requires the `ioredis` peer
|
|
367
402
|
- `gcs` — gzipped task JSON at `<prefix>/<scope>/<taskId>/metadata.json.gz`; requires the `@google-cloud/storage` peer. `archiveWorkspace()` (tar of the workspace) exists but is not wired to the lifecycle yet.
|
|
368
403
|
|
|
404
|
+
Task listing scans lightweight summaries and loads full snapshots only for the selected page. Filesystem and Redis snapshots carry the summary in the same JSON write; GCS stores it as object metadata in the same upload. Scoped legacy snapshots and oversized summaries fall back to full reads, so no migration is required. Listing still enumerates and sorts the summaries in the caller's scope; this is not a database query index. Filtering, totals, cursor ordering, history, and optional artifacts keep the same API semantics.
|
|
405
|
+
|
|
369
406
|
`<scope>` is a hash of the A2A tenant and user. Older unscoped Redis/GCS task records are not read after this upgrade; migrate known records into their scope or let them expire before rollout.
|
|
370
407
|
|
|
371
408
|
## Security
|
package/lib/bridge.js
CHANGED
|
@@ -41,6 +41,7 @@ export class A2aBridge {
|
|
|
41
41
|
executing = new Map();
|
|
42
42
|
asked = new Map();
|
|
43
43
|
approvals = new Map();
|
|
44
|
+
preparing = new Map();
|
|
44
45
|
codec;
|
|
45
46
|
delegation = new AsyncLocalStorage();
|
|
46
47
|
children = new WeakMap();
|
|
@@ -230,14 +231,24 @@ export class A2aBridge {
|
|
|
230
231
|
...(this.inbox ? { keepInboxOnDispose: true } : {}),
|
|
231
232
|
});
|
|
232
233
|
}
|
|
233
|
-
/** A2A parts → dsh content blocks;
|
|
234
|
-
async buildContent(message, contextId) {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
/** A2A parts → dsh content blocks; rejects unusable messages and cancellation. */
|
|
235
|
+
async buildContent(message, contextId, signal, taskId = message.taskId) {
|
|
236
|
+
const abort = new AbortController();
|
|
237
|
+
this.preparing.set(abort, { contextId, taskId });
|
|
238
|
+
const readSignal = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
|
|
239
|
+
try {
|
|
240
|
+
const attachments = this.ctx.get('attachments');
|
|
241
|
+
const materializer = this.ctx.get('a2aFileMaterializer');
|
|
242
|
+
const content = await buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir), materializer ? { contextId, materializer } : undefined, {
|
|
243
|
+
allowPrivate: this.options.downloadAllowPrivate,
|
|
244
|
+
allowedHosts: this.options.downloadAllowedHosts,
|
|
245
|
+
}, readSignal);
|
|
246
|
+
readSignal.throwIfAborted();
|
|
247
|
+
return content;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.preparing.delete(abort);
|
|
251
|
+
}
|
|
241
252
|
}
|
|
242
253
|
isClearing(contextId) {
|
|
243
254
|
return this.clearing.has(contextId);
|
|
@@ -524,6 +535,9 @@ export class A2aBridge {
|
|
|
524
535
|
if (this.clearing.has(contextId))
|
|
525
536
|
throw new Error('Context is already being cleared.');
|
|
526
537
|
this.clearing.add(contextId);
|
|
538
|
+
for (const [abort, pending] of this.preparing)
|
|
539
|
+
if (pending.contextId === contextId)
|
|
540
|
+
abort.abort();
|
|
527
541
|
try {
|
|
528
542
|
await this.inbox?.clear(contextId);
|
|
529
543
|
await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
|
|
@@ -560,9 +574,16 @@ export class A2aBridge {
|
|
|
560
574
|
}
|
|
561
575
|
/** Abort the task's active turn; returns the contextId when the task is live. */
|
|
562
576
|
cancel(taskId) {
|
|
577
|
+
let preparingContext;
|
|
578
|
+
for (const [abort, pending] of this.preparing) {
|
|
579
|
+
if (pending.taskId !== taskId)
|
|
580
|
+
continue;
|
|
581
|
+
preparingContext = pending.contextId;
|
|
582
|
+
abort.abort();
|
|
583
|
+
}
|
|
563
584
|
const entry = this.tasks.get(taskId);
|
|
564
585
|
if (!entry)
|
|
565
|
-
return
|
|
586
|
+
return preparingContext;
|
|
566
587
|
const staleTurn = entry.turnActive;
|
|
567
588
|
this.settleEntry(entry, 'Task canceled by request.');
|
|
568
589
|
entry.staleTurn ||= staleTurn;
|
|
@@ -578,6 +599,8 @@ export class A2aBridge {
|
|
|
578
599
|
}
|
|
579
600
|
/** Dispose every owned agent (plugin unload / server shutdown). */
|
|
580
601
|
async dispose() {
|
|
602
|
+
for (const abort of this.preparing.keys())
|
|
603
|
+
abort.abort();
|
|
581
604
|
try {
|
|
582
605
|
await this.inbox?.shutdown();
|
|
583
606
|
}
|
package/lib/content.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* raw bytes and must degrade, never fail.
|
|
18
18
|
*/
|
|
19
19
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
20
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
|
21
21
|
import { request as httpRequest } from 'node:http';
|
|
22
22
|
import { request as httpsRequest } from 'node:https';
|
|
23
23
|
import { BlockList, isIP } from 'node:net';
|
|
@@ -26,9 +26,32 @@ import path from 'node:path';
|
|
|
26
26
|
/** Cap on one url part download. */
|
|
27
27
|
const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
|
28
28
|
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
29
|
-
|
|
29
|
+
/** Stop waiting even when a backend cannot cancel its underlying work. */
|
|
30
|
+
async function abortable(work, signal) {
|
|
31
|
+
if (!signal)
|
|
32
|
+
return work;
|
|
33
|
+
let onAbort;
|
|
34
|
+
try {
|
|
35
|
+
return await Promise.race([
|
|
36
|
+
work,
|
|
37
|
+
new Promise((_, reject) => {
|
|
38
|
+
onAbort = () => reject(signal.reason);
|
|
39
|
+
if (signal.aborted)
|
|
40
|
+
onAbort();
|
|
41
|
+
else
|
|
42
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
signal.removeEventListener('abort', onAbort);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function buildMessageContent(parts, attachments, dir, materialization, downloads = {}, signal) {
|
|
30
51
|
const content = [];
|
|
52
|
+
signal?.throwIfAborted();
|
|
31
53
|
for (const part of parts) {
|
|
54
|
+
signal?.throwIfAborted();
|
|
32
55
|
const c = part.content;
|
|
33
56
|
if (!c)
|
|
34
57
|
continue;
|
|
@@ -42,7 +65,7 @@ export async function buildMessageContent(parts, attachments, dir, materializati
|
|
|
42
65
|
break;
|
|
43
66
|
case 'raw':
|
|
44
67
|
case 'url':
|
|
45
|
-
content.push(await fileContent(c, part, attachments, dir, materialization, downloads));
|
|
68
|
+
content.push(await abortable(fileContent(c, part, attachments, dir, materialization, downloads, signal), signal));
|
|
46
69
|
break;
|
|
47
70
|
default:
|
|
48
71
|
// A part kind this SDK version predates: keep it visible, never drop.
|
|
@@ -67,14 +90,14 @@ function failNote(part, mediaType, uri, label, error) {
|
|
|
67
90
|
return note(part, mediaType, uri, uri ? text.replaceAll(uri, redactedUri(uri) ?? '[redacted URL]') : text);
|
|
68
91
|
}
|
|
69
92
|
/** Resolve one file part to an attachment block or a workspace file. */
|
|
70
|
-
async function fileContent(c, part, attachments, dir, materialization, downloads) {
|
|
93
|
+
async function fileContent(c, part, attachments, dir, materialization, downloads, signal) {
|
|
71
94
|
let bytes;
|
|
72
95
|
let mediaType = part.mediaType;
|
|
73
96
|
let uri;
|
|
74
97
|
try {
|
|
75
98
|
if (c.$case === 'url') {
|
|
76
99
|
uri = c.value;
|
|
77
|
-
const got = await download(uri, downloads);
|
|
100
|
+
const got = await download(uri, downloads, signal);
|
|
78
101
|
bytes = got.bytes;
|
|
79
102
|
mediaType ||= got.mediaType ?? '';
|
|
80
103
|
}
|
|
@@ -83,11 +106,14 @@ async function fileContent(c, part, attachments, dir, materialization, downloads
|
|
|
83
106
|
}
|
|
84
107
|
}
|
|
85
108
|
catch (error) {
|
|
109
|
+
signal?.throwIfAborted();
|
|
86
110
|
return failNote(part, mediaType, uri, 'failed to read a file part', error);
|
|
87
111
|
}
|
|
112
|
+
signal?.throwIfAborted();
|
|
88
113
|
const image = attachments?.imageLimits.mediaTypes.includes(mediaType.toLowerCase());
|
|
89
114
|
if (attachments && image) {
|
|
90
|
-
const block = await saveAttachment(attachments, bytes, mediaType, part.filename);
|
|
115
|
+
const block = await saveAttachment(attachments, bytes, mediaType, part.filename, signal);
|
|
116
|
+
signal?.throwIfAborted();
|
|
91
117
|
if (block)
|
|
92
118
|
return block;
|
|
93
119
|
}
|
|
@@ -98,27 +124,31 @@ async function fileContent(c, part, attachments, dir, materialization, downloads
|
|
|
98
124
|
bytes,
|
|
99
125
|
filename: sanitizeUploadFileName(part.filename),
|
|
100
126
|
mediaType,
|
|
127
|
+
signal,
|
|
101
128
|
});
|
|
129
|
+
signal?.throwIfAborted();
|
|
102
130
|
if (!readablePath)
|
|
103
131
|
throw new Error('materializer returned no readable path');
|
|
104
132
|
return documentBlock(uri, mediaType, bytes.byteLength, readablePath, part.filename);
|
|
105
133
|
}
|
|
106
134
|
catch (error) {
|
|
135
|
+
signal?.throwIfAborted();
|
|
107
136
|
console.error('[dsh-a2a] failed to materialize a file part:', error);
|
|
108
137
|
return note(part, mediaType, uri, 'file materialization failed');
|
|
109
138
|
}
|
|
110
139
|
}
|
|
111
140
|
try {
|
|
112
141
|
const name = sanitizeUploadFileName(part.filename);
|
|
113
|
-
const filePath = await persistUpload(dir, name, bytes);
|
|
142
|
+
const filePath = await persistUpload(dir, name, bytes, signal);
|
|
114
143
|
return documentBlock(uri, mediaType, bytes.byteLength, filePath, part.filename);
|
|
115
144
|
}
|
|
116
145
|
catch (error) {
|
|
146
|
+
signal?.throwIfAborted();
|
|
117
147
|
return failNote(part, mediaType, uri, 'failed to persist a file part', error);
|
|
118
148
|
}
|
|
119
149
|
}
|
|
120
150
|
/** Commit part bytes to the store: image path first, verbatim file on refusal. */
|
|
121
|
-
async function saveAttachment(attachments, bytes, mediaType, filename) {
|
|
151
|
+
async function saveAttachment(attachments, bytes, mediaType, filename, signal) {
|
|
122
152
|
const name = filename || undefined;
|
|
123
153
|
if (attachments.imageLimits.mediaTypes.includes(mediaType.toLowerCase())) {
|
|
124
154
|
try {
|
|
@@ -130,33 +160,37 @@ async function saveAttachment(attachments, bytes, mediaType, filename) {
|
|
|
130
160
|
return { type: 'image', attachment };
|
|
131
161
|
}
|
|
132
162
|
catch (error) {
|
|
163
|
+
signal?.throwIfAborted();
|
|
133
164
|
console.error('[dsh-a2a] image admission failed; storing the bytes as a file:', error);
|
|
134
165
|
}
|
|
135
166
|
}
|
|
167
|
+
signal?.throwIfAborted();
|
|
136
168
|
try {
|
|
137
169
|
const attachment = await attachments.saveFile({ data: bytes, name });
|
|
138
170
|
return { type: 'file', attachment };
|
|
139
171
|
}
|
|
140
172
|
catch (error) {
|
|
173
|
+
signal?.throwIfAborted();
|
|
141
174
|
console.error('[dsh-a2a] failed to store an attachment:', error);
|
|
142
175
|
return undefined;
|
|
143
176
|
}
|
|
144
177
|
}
|
|
145
178
|
/** Save one upload under the upload dir; returns the absolute path. */
|
|
146
|
-
async function persistUpload(dir, name, bytes) {
|
|
179
|
+
async function persistUpload(dir, name, bytes, signal) {
|
|
147
180
|
await mkdir(dir, { recursive: true });
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
await writeFile(candidate, bytes, { flag: 'wx' });
|
|
153
|
-
return candidate;
|
|
154
|
-
}
|
|
155
|
-
catch (error) {
|
|
156
|
-
if (error.code !== 'EEXIST')
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
181
|
+
let candidate = path.join(dir, name);
|
|
182
|
+
try {
|
|
183
|
+
await writeFile(candidate, bytes, { flag: 'wx', signal });
|
|
184
|
+
return candidate;
|
|
159
185
|
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error.code !== 'EEXIST')
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
// A unique directory preserves even near-limit UTF-8 filenames without probing old suffixes.
|
|
191
|
+
candidate = path.join(await mkdtemp(path.join(dir, 'upload-')), name);
|
|
192
|
+
await writeFile(candidate, bytes, { flag: 'wx', signal });
|
|
193
|
+
return candidate;
|
|
160
194
|
}
|
|
161
195
|
// Windows refuses these device stems regardless of extension.
|
|
162
196
|
const WINDOWS_RESERVED_STEMS = new Set('CON PRN AUX NUL COM1 COM2 COM3 COM4 COM5 COM6 COM7 COM8 COM9 LPT1 LPT2 LPT3 LPT4 LPT5 LPT6 LPT7 LPT8 LPT9'.split(' '));
|
|
@@ -283,92 +317,101 @@ async function safeAddresses(hostname, allowedHosts, allowPrivate) {
|
|
|
283
317
|
return addresses;
|
|
284
318
|
}
|
|
285
319
|
/** Fetch one url part's bytes over http(s), bounded in size, redirects, address and time. */
|
|
286
|
-
async function download(uri, options) {
|
|
320
|
+
async function download(uri, options, requestedSignal) {
|
|
287
321
|
const allowedHosts = new Set((options.allowedHosts ?? []).map((host) => host.replace(/^\[|\]$/g, '').toLowerCase()));
|
|
288
322
|
const controller = new AbortController();
|
|
323
|
+
const signal = requestedSignal
|
|
324
|
+
? AbortSignal.any([requestedSignal, controller.signal])
|
|
325
|
+
: controller.signal;
|
|
326
|
+
signal.throwIfAborted();
|
|
289
327
|
const timeout = setTimeout(() => controller.abort(new Error('download timed out')), DOWNLOAD_TIMEOUT_MS);
|
|
290
328
|
timeout.unref();
|
|
291
329
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
330
|
+
return await abortable((async () => {
|
|
331
|
+
let current = new URL(uri);
|
|
332
|
+
for (let redirect = 0;; redirect++) {
|
|
333
|
+
if (current.protocol !== 'http:' && current.protocol !== 'https:') {
|
|
334
|
+
throw new Error(`unsupported scheme "${current.protocol}"`);
|
|
335
|
+
}
|
|
336
|
+
await safeAddresses(current.hostname, allowedHosts, options.allowPrivate === true);
|
|
337
|
+
signal.throwIfAborted();
|
|
338
|
+
const response = await new Promise((resolve, reject) => {
|
|
339
|
+
const request = (current.protocol === 'http:' ? httpRequest : httpsRequest)(current, {
|
|
340
|
+
signal,
|
|
341
|
+
lookup: (hostname, lookupOptions, callback) => {
|
|
342
|
+
const allowed = options.allowPrivate === true ||
|
|
343
|
+
allowedHosts.has(hostname.replace(/^\[|\]$/g, '').toLowerCase());
|
|
344
|
+
const resolving = allowed
|
|
345
|
+
? dnsLookup(hostname, { all: lookupOptions.all ?? false }).then((entry) => Array.isArray(entry) ? entry : [entry])
|
|
346
|
+
: safeAddresses(hostname, allowedHosts, false);
|
|
347
|
+
void resolving.then((addresses) => {
|
|
348
|
+
if (lookupOptions.all)
|
|
349
|
+
return callback(null, addresses);
|
|
350
|
+
const { address, family } = addresses[0];
|
|
351
|
+
callback(null, address, family);
|
|
352
|
+
return addresses;
|
|
353
|
+
}, (error) => callback(error, '', 0));
|
|
354
|
+
},
|
|
355
|
+
}, resolve);
|
|
356
|
+
request.on('socket', (socket) => {
|
|
357
|
+
socket.once('connect', () => {
|
|
358
|
+
if (options.allowPrivate === true)
|
|
359
|
+
return;
|
|
360
|
+
if (allowedHosts.has(current.hostname.replace(/^\[|\]$/g, '').toLowerCase()))
|
|
361
|
+
return;
|
|
362
|
+
try {
|
|
363
|
+
assertPublicAddress(socket.remoteAddress ?? '');
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
request.destroy(error);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
328
369
|
});
|
|
370
|
+
request.on('error', reject);
|
|
371
|
+
request.end();
|
|
329
372
|
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
373
|
+
signal.throwIfAborted();
|
|
374
|
+
const status = response.statusCode ?? 0;
|
|
375
|
+
const location = response.headers.location;
|
|
376
|
+
if ([301, 302, 303, 307, 308].includes(status) && location) {
|
|
377
|
+
response.resume();
|
|
378
|
+
if (redirect >= 5)
|
|
379
|
+
throw new Error('too many download redirects');
|
|
380
|
+
current = new URL(location, current);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (status < 200 || status >= 300) {
|
|
384
|
+
response.resume();
|
|
385
|
+
throw new Error(`HTTP ${status}`);
|
|
386
|
+
}
|
|
387
|
+
const declared = Number(response.headers['content-length']);
|
|
388
|
+
if (declared > MAX_DOWNLOAD_BYTES) {
|
|
389
|
+
response.destroy();
|
|
390
|
+
throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
|
|
391
|
+
}
|
|
392
|
+
const chunks = [];
|
|
393
|
+
let total = 0;
|
|
394
|
+
try {
|
|
395
|
+
for await (const chunk of response) {
|
|
396
|
+
signal.throwIfAborted();
|
|
397
|
+
total += chunk.byteLength;
|
|
398
|
+
if (total > MAX_DOWNLOAD_BYTES) {
|
|
399
|
+
throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
|
|
400
|
+
}
|
|
401
|
+
chunks.push(chunk);
|
|
358
402
|
}
|
|
359
|
-
chunks.push(chunk);
|
|
360
403
|
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
response.destroy();
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
const contentType = response.headers['content-type'];
|
|
409
|
+
const mediaType = (Array.isArray(contentType) ? contentType[0] : contentType)
|
|
410
|
+
?.split(';')[0]
|
|
411
|
+
.trim();
|
|
412
|
+
return { bytes: Buffer.concat(chunks, total), mediaType: mediaType || undefined };
|
|
361
413
|
}
|
|
362
|
-
|
|
363
|
-
response.destroy();
|
|
364
|
-
throw error;
|
|
365
|
-
}
|
|
366
|
-
const contentType = response.headers['content-type'];
|
|
367
|
-
const mediaType = (Array.isArray(contentType) ? contentType[0] : contentType)
|
|
368
|
-
?.split(';')[0]
|
|
369
|
-
.trim();
|
|
370
|
-
return { bytes: Buffer.concat(chunks, total), mediaType: mediaType || undefined };
|
|
371
|
-
}
|
|
414
|
+
})(), signal);
|
|
372
415
|
}
|
|
373
416
|
finally {
|
|
374
417
|
clearTimeout(timeout);
|
package/lib/executor.js
CHANGED
|
@@ -83,7 +83,7 @@ export class DshAgentExecutor {
|
|
|
83
83
|
await this.bridge.waitForTurn(approval.entry);
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
|
-
const content = await this.bridge.buildContent(userMessage, contextId);
|
|
86
|
+
const content = await this.bridge.buildContent(userMessage, contextId, undefined, taskId);
|
|
87
87
|
const { entry, freshTask } = await this.bridge.ensureTask(taskId, contextId);
|
|
88
88
|
eventBus.publish(freshTask
|
|
89
89
|
? // First contact: the submitted anchor carries the user message
|
|
@@ -108,12 +108,13 @@ export class DshAgentExecutor {
|
|
|
108
108
|
if (this.bridge.isClearing(contextId))
|
|
109
109
|
this.suppressTask?.(taskId);
|
|
110
110
|
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
const canceled = error instanceof Error && error.name === 'AbortError';
|
|
111
112
|
if (!anchored) {
|
|
112
|
-
eventBus.publish(taskEvent(taskId, contextId, TaskState.TASK_STATE_FAILED, {
|
|
113
|
+
eventBus.publish(taskEvent(taskId, contextId, canceled ? TaskState.TASK_STATE_CANCELED : TaskState.TASK_STATE_FAILED, {
|
|
113
114
|
message: agentTextMessage(taskId, contextId, message),
|
|
114
115
|
}));
|
|
115
116
|
}
|
|
116
|
-
eventBus.publish(terminalStatusUpdate(taskId, contextId, 'failed', message));
|
|
117
|
+
eventBus.publish(terminalStatusUpdate(taskId, contextId, canceled ? 'canceled' : 'failed', message));
|
|
117
118
|
}
|
|
118
119
|
finally {
|
|
119
120
|
eventBus.finished();
|