@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 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, 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.
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; throws only when no part is usable. */
234
- async buildContent(message, contextId) {
235
- const attachments = this.ctx.get('attachments');
236
- const materializer = this.ctx.get('a2aFileMaterializer');
237
- return buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir), materializer ? { contextId, materializer } : undefined, {
238
- allowPrivate: this.options.downloadAllowPrivate,
239
- allowedHosts: this.options.downloadAllowedHosts,
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 undefined;
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
- export async function buildMessageContent(parts, attachments, dir, materialization, downloads = {}) {
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
- const { name: stem, ext } = path.parse(name);
149
- for (let attempt = 0;; attempt++) {
150
- const candidate = path.join(dir, attempt === 0 ? name : `${stem}-${attempt}${ext}`);
151
- try {
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
- let current = new URL(uri);
293
- for (let redirect = 0;; redirect++) {
294
- if (current.protocol !== 'http:' && current.protocol !== 'https:') {
295
- throw new Error(`unsupported scheme "${current.protocol}"`);
296
- }
297
- await safeAddresses(current.hostname, allowedHosts, options.allowPrivate === true);
298
- const response = await new Promise((resolve, reject) => {
299
- const request = (current.protocol === 'http:' ? httpRequest : httpsRequest)(current, {
300
- signal: controller.signal,
301
- lookup: (hostname, lookupOptions, callback) => {
302
- const allowed = options.allowPrivate === true ||
303
- allowedHosts.has(hostname.replace(/^\[|\]$/g, '').toLowerCase());
304
- const resolving = allowed
305
- ? dnsLookup(hostname, { all: lookupOptions.all ?? false }).then((entry) => Array.isArray(entry) ? entry : [entry])
306
- : safeAddresses(hostname, allowedHosts, false);
307
- void resolving.then((addresses) => {
308
- if (lookupOptions.all)
309
- return callback(null, addresses);
310
- const { address, family } = addresses[0];
311
- callback(null, address, family);
312
- return addresses;
313
- }, (error) => callback(error, '', 0));
314
- },
315
- }, resolve);
316
- request.on('socket', (socket) => {
317
- socket.once('connect', () => {
318
- if (options.allowPrivate === true)
319
- return;
320
- if (allowedHosts.has(current.hostname.replace(/^\[|\]$/g, '').toLowerCase()))
321
- return;
322
- try {
323
- assertPublicAddress(socket.remoteAddress ?? '');
324
- }
325
- catch (error) {
326
- request.destroy(error);
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
- request.on('error', reject);
331
- request.end();
332
- });
333
- const status = response.statusCode ?? 0;
334
- const location = response.headers.location;
335
- if ([301, 302, 303, 307, 308].includes(status) && location) {
336
- response.resume();
337
- if (redirect >= 5)
338
- throw new Error('too many download redirects');
339
- current = new URL(location, current);
340
- continue;
341
- }
342
- if (status < 200 || status >= 300) {
343
- response.resume();
344
- throw new Error(`HTTP ${status}`);
345
- }
346
- const declared = Number(response.headers['content-length']);
347
- if (declared > MAX_DOWNLOAD_BYTES) {
348
- response.destroy();
349
- throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
350
- }
351
- const chunks = [];
352
- let total = 0;
353
- try {
354
- for await (const chunk of response) {
355
- total += chunk.byteLength;
356
- if (total > MAX_DOWNLOAD_BYTES) {
357
- throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
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
- catch (error) {
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();