@juspay/neurolink 12.1.0 → 12.2.0

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/agent/agentToolRegistrar.d.ts +30 -0
  3. package/dist/agent/agentToolRegistrar.js +72 -18
  4. package/dist/agent/backgroundCommands.d.ts +110 -0
  5. package/dist/agent/backgroundCommands.js +914 -0
  6. package/dist/agent/backgroundDelegation.d.ts +87 -0
  7. package/dist/agent/backgroundDelegation.js +753 -0
  8. package/dist/agent/gitTools.d.ts +43 -0
  9. package/dist/agent/gitTools.js +618 -0
  10. package/dist/agent/taskChecklist.d.ts +58 -0
  11. package/dist/agent/taskChecklist.js +322 -0
  12. package/dist/artifacts/artifactBanking.d.ts +57 -0
  13. package/dist/artifacts/artifactBanking.js +123 -0
  14. package/dist/artifacts/artifactStore.d.ts +36 -8
  15. package/dist/artifacts/artifactStore.js +164 -13
  16. package/dist/browser/neurolink.min.js +442 -414
  17. package/dist/neurolink.d.ts +294 -3
  18. package/dist/neurolink.js +447 -4
  19. package/dist/types/artifact.d.ts +54 -0
  20. package/dist/types/backgroundCommand.d.ts +174 -0
  21. package/dist/types/backgroundCommand.js +22 -0
  22. package/dist/types/delegation.d.ts +178 -0
  23. package/dist/types/delegation.js +18 -0
  24. package/dist/types/gitTools.d.ts +69 -0
  25. package/dist/types/gitTools.js +22 -0
  26. package/dist/types/index.d.ts +5 -0
  27. package/dist/types/index.js +8 -0
  28. package/dist/types/pathSandbox.d.ts +23 -0
  29. package/dist/types/pathSandbox.js +12 -0
  30. package/dist/types/tasks.d.ts +85 -0
  31. package/dist/types/tasks.js +14 -0
  32. package/dist/types/tools.d.ts +11 -0
  33. package/dist/utils/pathSandbox.d.ts +49 -0
  34. package/dist/utils/pathSandbox.js +127 -0
  35. package/package.json +5 -1
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Async delegation (N2) — spawn a background worker, collect it later, in any
3
+ * order.
4
+ *
5
+ * Delegation through `registerAgentTool` is synchronous: the supervising
6
+ * agent's loop blocks on the worker, so four investigations cost four times
7
+ * one investigation and the supervisor sits idle while each runs. This module
8
+ * keeps the same worker machinery and changes only WHEN the caller waits.
9
+ * `spawnDelegate` records the job and returns a `workerId` immediately;
10
+ * `collectDelegates` hands back whichever worker finished FIRST, which has
11
+ * nothing to do with which was spawned first.
12
+ *
13
+ * Everything underneath already existed and is reused, not re-implemented:
14
+ *
15
+ * - `runIsolatedAgent` gives the worker a fresh session on a worker instance
16
+ * that SHARES the host's tool registry (so live MCP connections are reused),
17
+ * plus waste detection, honest stop reasons and continuation handles;
18
+ * - the process-wide delegation pool in `agentToolRegistrar` bounds
19
+ * concurrency — one pool, not a second one competing with the first;
20
+ * - `bankArtifact` (N3) writes each worker's FULL report to a file, so the
21
+ * conversation carries a bounded summary and a read-back call rather than
22
+ * a report that has been truncated into uselessness;
23
+ * - the checklist's `delegatesPending` / `delegatesReady` counters (N1) are
24
+ * fed from here, which is how the model learns "a worker finished" from any
25
+ * `tasks_list` — no polling loop, and no change to the core generate loop.
26
+ *
27
+ * @module agent/backgroundDelegation
28
+ */
29
+ import { z } from "zod";
30
+ import { logger } from "../utils/logger.js";
31
+ import { DELEGATION_RESULT_CONTENT_CHARS, acquireDelegationSlot, raiseDelegationPoolCapacity, resolveDelegationDepth, tryAcquireDelegationSlot, } from "./agentToolRegistrar.js";
32
+ import { buildMechanicalDigest, runIsolatedAgent, } from "./isolatedAgentRunner.js";
33
+ import { resolveChecklistSessionId, setChecklistDelegateCountsSource, } from "./taskChecklist.js";
34
+ /**
35
+ * Every outstanding job in the process, keyed by workerId.
36
+ *
37
+ * Module-level for the same reason the checklist is: a job outlives the tool
38
+ * call that spawned it and must survive compaction, which rewrites messages
39
+ * and cannot touch a module map. Claimed jobs are deleted on the spot, so this
40
+ * holds only work that has not been accounted for.
41
+ */
42
+ const jobs = new Map();
43
+ const hostSettings = new WeakMap();
44
+ /**
45
+ * Default depth ceiling: a background worker does not spawn background
46
+ * workers. Its own delegates would outlive it with nobody left to collect
47
+ * them — an orphan that burns pool slots and is never read.
48
+ */
49
+ const DEFAULT_DELEGATE_MAX_DEPTH = 1;
50
+ /** How long a spawned worker waits for a pool slot before giving up. */
51
+ const DEFAULT_DELEGATE_POOL_QUEUE_TIMEOUT_MS = 120_000;
52
+ /** How long a collect waits when the caller names no bound. */
53
+ const DEFAULT_COLLECT_WAIT_MS = 300_000;
54
+ /** Grace period for cancelled workers to unwind before `cancelDelegates` returns. */
55
+ const CANCEL_SETTLE_GRACE_MS = 15_000;
56
+ /**
57
+ * Per-record evidence kept for a delegated worker (default is ~8 KB).
58
+ *
59
+ * Raised because these records are BANKED, not sent to a model: the banked
60
+ * report is the run's evidence, and evidence that was cut at 8 KB before it
61
+ * ever reached the file is evidence nobody can recover.
62
+ */
63
+ const DELEGATE_CAPTURE_RESULT_CHARS = 100_000;
64
+ /** Preview cut into the conversation from each banked report. */
65
+ const REPORT_PREVIEW_CHARS = 600;
66
+ /** Longest label derived from a task string. */
67
+ const LABEL_MAX_CHARS = 60;
68
+ let workerCounter = 0;
69
+ let settleCounter = 0;
70
+ let checklistCountsInstalled = false;
71
+ // ── Small helpers ──────────────────────────────────────────────────────────
72
+ function asRecord(value) {
73
+ return value && typeof value === "object"
74
+ ? value
75
+ : undefined;
76
+ }
77
+ function errorMessage(error) {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }
80
+ /** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */
81
+ function refusal(message) {
82
+ return { isError: true, error: message };
83
+ }
84
+ function bounded(text, maxChars) {
85
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
86
+ }
87
+ function firstLine(text, maxChars) {
88
+ const line = text.split("\n", 1)[0]?.trim() ?? "";
89
+ return bounded(line, maxChars);
90
+ }
91
+ function jsonBlock(value) {
92
+ try {
93
+ return JSON.stringify(value, null, 2) ?? String(value);
94
+ }
95
+ catch (error) {
96
+ return `[unserializable: ${errorMessage(error)}]`;
97
+ }
98
+ }
99
+ /** A timer that never keeps the process alive for a collect nobody awaits. */
100
+ function afterMs(ms) {
101
+ return new Promise((resolve) => {
102
+ const timer = setTimeout(resolve, Math.max(0, ms));
103
+ timer.unref?.();
104
+ });
105
+ }
106
+ function whenAborted(signal) {
107
+ if (signal.aborted) {
108
+ return Promise.resolve();
109
+ }
110
+ return new Promise((resolve) => {
111
+ signal.addEventListener("abort", () => resolve(), { once: true });
112
+ });
113
+ }
114
+ function deferredOutcome() {
115
+ let resolve = () => undefined;
116
+ const promise = new Promise((settle) => {
117
+ resolve = settle;
118
+ });
119
+ return { promise, resolve };
120
+ }
121
+ // ── Settings ───────────────────────────────────────────────────────────────
122
+ function settingsFor(host) {
123
+ return (hostSettings.get(host) ?? {
124
+ maxDepth: DEFAULT_DELEGATE_MAX_DEPTH,
125
+ poolQueueTimeoutMs: DEFAULT_DELEGATE_POOL_QUEUE_TIMEOUT_MS,
126
+ defaultCollectWaitMs: DEFAULT_COLLECT_WAIT_MS,
127
+ });
128
+ }
129
+ /**
130
+ * Feed the task checklist's delegate counters, once per process.
131
+ *
132
+ * Keyed by session alone because that is the signature the checklist offers —
133
+ * and the right key anyway: a checklist and the workers a run spawned belong
134
+ * to the same session, which is exactly what `delegatesPending` reports.
135
+ */
136
+ function installChecklistCounts() {
137
+ if (checklistCountsInstalled) {
138
+ return;
139
+ }
140
+ setChecklistDelegateCountsSource(countsForSession);
141
+ checklistCountsInstalled = true;
142
+ }
143
+ /**
144
+ * Apply registration options for one host: depth ceiling, queue wait, and a
145
+ * raise (never a lowering) of the shared delegation pool.
146
+ */
147
+ export function configureDelegation(host, options = {}) {
148
+ const settings = {
149
+ maxDepth: options.maxDepth ?? DEFAULT_DELEGATE_MAX_DEPTH,
150
+ poolQueueTimeoutMs: options.poolQueueTimeoutMs ?? DEFAULT_DELEGATE_POOL_QUEUE_TIMEOUT_MS,
151
+ defaultCollectWaitMs: DEFAULT_COLLECT_WAIT_MS,
152
+ ...(options.spawnDefaults !== undefined && {
153
+ spawnDefaults: options.spawnDefaults,
154
+ }),
155
+ };
156
+ hostSettings.set(host, settings);
157
+ if (options.maxConcurrent !== undefined) {
158
+ raiseDelegationPoolCapacity(options.maxConcurrent);
159
+ }
160
+ installChecklistCounts();
161
+ return settings;
162
+ }
163
+ // ── Counting ───────────────────────────────────────────────────────────────
164
+ function isReady(job) {
165
+ return job.phase === "ready" && job.outcome !== undefined;
166
+ }
167
+ function outstandingFor(host, sessionId) {
168
+ return [...jobs.values()].filter((job) => job.host === host &&
169
+ job.sessionId === sessionId &&
170
+ job.phase !== "claimed");
171
+ }
172
+ function tally(candidates) {
173
+ let pending = 0;
174
+ let ready = 0;
175
+ for (const job of candidates) {
176
+ if (isReady(job)) {
177
+ ready += 1;
178
+ }
179
+ else {
180
+ pending += 1;
181
+ }
182
+ }
183
+ return { pending, ready };
184
+ }
185
+ /** Counts across every host for one session — what the checklist reads. */
186
+ function countsForSession(sessionId) {
187
+ return tally([...jobs.values()].filter((job) => job.sessionId === sessionId && job.phase !== "claimed"));
188
+ }
189
+ /**
190
+ * Outstanding workers for a host's session: `pending` are still running or
191
+ * queued, `ready` finished and are waiting to be claimed. This is what feeds
192
+ * every `ChecklistToolResult`, so the model learns a worker landed from any
193
+ * `tasks_list` call.
194
+ */
195
+ export function delegateCounts(host, sessionId) {
196
+ const session = sessionId ?? resolveChecklistSessionId(host);
197
+ return tally(outstandingFor(host, session));
198
+ }
199
+ // ── Report assembly ────────────────────────────────────────────────────────
200
+ function renderRecords(records) {
201
+ if (records.length === 0) {
202
+ return "(no tools were called)";
203
+ }
204
+ return records
205
+ .map((record, index) => `[${index + 1}] ${record.toolName}(${jsonBlock(record.params)}) → ` +
206
+ `${record.isError ? "ERROR" : "ok"} (${record.durationMs}ms)\n` +
207
+ record.resultText)
208
+ .join("\n\n");
209
+ }
210
+ /**
211
+ * The worker's complete report, as it goes to disk.
212
+ *
213
+ * Everything the run produced is here — narrative, structured data, a digest,
214
+ * and every tool execution record in full. The bounded thing is the SUMMARY
215
+ * that goes into the conversation; this is the thing the summary points at, so
216
+ * cutting it would defeat the point of banking it.
217
+ */
218
+ function buildReportBody(job, outcome) {
219
+ const header = [
220
+ `workerId: ${job.workerId}`,
221
+ `label: ${job.label}`,
222
+ `status: ${outcome.status}`,
223
+ outcome.stopReason ? `stopReason: ${outcome.stopReason}` : "",
224
+ `durationMs: ${outcome.durationMs}`,
225
+ `toolCalls: ${outcome.toolExecutions.length}`,
226
+ outcome.handle ? `continuationHandle: ${outcome.handle}` : "",
227
+ ]
228
+ .filter(Boolean)
229
+ .join("\n");
230
+ return [
231
+ "# Delegated worker report",
232
+ header,
233
+ `## Task\n${job.task}`,
234
+ `## Report\n${outcome.content?.trim() || "(the worker produced no narrative)"}`,
235
+ outcome.data !== undefined
236
+ ? `## Structured data\n${jsonBlock(outcome.data)}`
237
+ : "",
238
+ outcome.wasteSignals?.length
239
+ ? `## Waste signals\n${outcome.wasteSignals.join("\n")}`
240
+ : "",
241
+ outcome.extractionError
242
+ ? `## Extraction errors\n${outcome.extractionError}`
243
+ : "",
244
+ `## Tool execution digest\n${jsonBlock(buildMechanicalDigest(outcome.toolExecutions))}`,
245
+ `## Tool executions (${outcome.toolExecutions.length}, complete)\n${renderRecords(outcome.toolExecutions)}`,
246
+ ]
247
+ .filter(Boolean)
248
+ .join("\n\n");
249
+ }
250
+ /**
251
+ * Bank the report and hand back the pointer.
252
+ *
253
+ * A banking failure is reported in the reference rather than thrown: losing
254
+ * the file must not also lose the worker's outcome, and the caller is told in
255
+ * so many words that the read-back is not available and why.
256
+ */
257
+ async function bankReport(job, body) {
258
+ const label = `delegate:${job.label}`;
259
+ try {
260
+ return await job.host.bankArtifact(body, {
261
+ kind: "worker-report",
262
+ label,
263
+ sessionId: job.sessionId,
264
+ previewChars: REPORT_PREVIEW_CHARS,
265
+ });
266
+ }
267
+ catch (error) {
268
+ const message = errorMessage(error);
269
+ logger.warn("[BackgroundDelegation] Banking the worker report failed", {
270
+ workerId: job.workerId,
271
+ error: message,
272
+ });
273
+ return {
274
+ artifactId: "",
275
+ label,
276
+ kind: "worker-report",
277
+ sizeBytes: Buffer.byteLength(body, "utf-8"),
278
+ preview: bounded(body, REPORT_PREVIEW_CHARS),
279
+ readBackHint: `The full report could NOT be banked (${message}), so there is nothing to ` +
280
+ "read back — what survived is this preview. Re-run the task if you need the rest.",
281
+ };
282
+ }
283
+ }
284
+ // ── Settling ───────────────────────────────────────────────────────────────
285
+ function markSettled(job, outcome) {
286
+ job.outcome = outcome;
287
+ job.settledOrder = ++settleCounter;
288
+ job.phase = "ready";
289
+ logger.debug("[BackgroundDelegation] Worker settled", {
290
+ workerId: job.workerId,
291
+ status: outcome.status,
292
+ durationMs: outcome.durationMs,
293
+ artifactId: outcome.report.artifactId,
294
+ });
295
+ return outcome;
296
+ }
297
+ async function settleRun(job, outcome, startedAt) {
298
+ const report = await bankReport(job, buildReportBody(job, outcome));
299
+ const summary = outcome.content?.trim() ||
300
+ `Worker ${job.workerId} finished with status "${outcome.status}" and ` +
301
+ `${outcome.toolExecutions.length} tool calls but wrote no narrative; ` +
302
+ "the banked report holds the evidence.";
303
+ return markSettled(job, {
304
+ workerId: job.workerId,
305
+ label: job.label,
306
+ status: outcome.status,
307
+ ok: outcome.status === "completed" || outcome.status === "partial",
308
+ summary: bounded(summary, DELEGATION_RESULT_CONTENT_CHARS),
309
+ report,
310
+ durationMs: Date.now() - startedAt,
311
+ toolCallsUsed: outcome.toolExecutions.length,
312
+ ...(outcome.wasteSignals?.length && { wasteSignals: outcome.wasteSignals }),
313
+ ...(outcome.handle && { handle: outcome.handle }),
314
+ // A cancelled worker still runs to a real outcome (the runner unwinds and
315
+ // reports what it had). Say WHY it is short, or the supervisor reads a
316
+ // truncated investigation as a completed one.
317
+ ...(job.cancelled && {
318
+ error: `Worker ${job.workerId} was cancelled by the caller.`,
319
+ }),
320
+ });
321
+ }
322
+ /**
323
+ * A worker that never produced an outcome — cancelled, refused a pool slot, or
324
+ * broken. It still settles into a claimable outcome with a banked report: a
325
+ * failure the supervisor cannot see is worse than a failure it can.
326
+ */
327
+ async function settleFailure(job, reason, startedAt) {
328
+ const body = [
329
+ "# Delegated worker report",
330
+ `workerId: ${job.workerId}\nlabel: ${job.label}\nstatus: error`,
331
+ `## Task\n${job.task}`,
332
+ `## Failure\n${reason}`,
333
+ ].join("\n\n");
334
+ const report = await bankReport(job, body);
335
+ return markSettled(job, {
336
+ workerId: job.workerId,
337
+ label: job.label,
338
+ status: "error",
339
+ ok: false,
340
+ summary: bounded(reason, DELEGATION_RESULT_CONTENT_CHARS),
341
+ report,
342
+ durationMs: Date.now() - startedAt,
343
+ toolCallsUsed: 0,
344
+ error: reason,
345
+ });
346
+ }
347
+ // ── Running one job ────────────────────────────────────────────────────────
348
+ function buildDefinition(job, options) {
349
+ const instructions = [
350
+ "You are a background worker. A supervising agent delegated exactly one task to you " +
351
+ "and is doing other work while you run.",
352
+ "Use your tools to gather real evidence, then finish with a SELF-CONTAINED report: " +
353
+ "what you did, what you found, and the evidence for each finding. The supervisor " +
354
+ "cannot see your tool calls — whatever is not in your report did not happen.",
355
+ "Stay inside your task. Do not start adjacent work you were not asked for.",
356
+ options.scope ? `Scope — what you may look at:\n${options.scope}` : "",
357
+ options.context ? `Context from the supervisor:\n${options.context}` : "",
358
+ ]
359
+ .filter(Boolean)
360
+ .join("\n\n");
361
+ return {
362
+ id: `delegate-${job.workerId}`,
363
+ name: job.label,
364
+ description: `Background worker: ${firstLine(job.task, LABEL_MAX_CHARS)}`,
365
+ instructions,
366
+ ...(options.provider && { provider: options.provider }),
367
+ ...(options.model && { model: options.model }),
368
+ ...(options.tools?.length && { tools: options.tools }),
369
+ ...(options.maxSteps !== undefined && { maxSteps: options.maxSteps }),
370
+ };
371
+ }
372
+ function buildRunOptions(job, options) {
373
+ const overrides = {
374
+ ...(options.budgetMs !== undefined && { turnTimeoutMs: options.budgetMs }),
375
+ ...(options.maxSteps !== undefined && { maxSteps: options.maxSteps }),
376
+ };
377
+ return {
378
+ abortSignal: job.controller.signal,
379
+ // Depth travels so a worker's own delegation attempts hit the ceiling.
380
+ // The caller's sessionId deliberately does NOT: the worker gets its own
381
+ // session (runIsolatedAgent stamps the run id), so its checklist and its
382
+ // delegate counters stay separate from the supervisor's.
383
+ toolContext: {
384
+ agentDepth: job.depth + 1,
385
+ delegateWorkerId: job.workerId,
386
+ },
387
+ capture: { maxResultChars: DELEGATE_CAPTURE_RESULT_CHARS },
388
+ ...(Object.keys(overrides).length > 0 && { overrides }),
389
+ };
390
+ }
391
+ /**
392
+ * The detached body of one delegation. Never rejects: every path ends in a
393
+ * claimable outcome, because a job that vanishes is a job the supervisor waits
394
+ * on forever.
395
+ */
396
+ async function runJob(job, options, settings, preAcquired) {
397
+ const startedAt = Date.now();
398
+ let release = preAcquired;
399
+ try {
400
+ if (!release) {
401
+ const queued = acquireDelegationSlot(settings.poolQueueTimeoutMs);
402
+ const raced = await Promise.race([
403
+ queued.then((grant) => ({ kind: "granted", grant }), (error) => ({ kind: "queue-timeout", error })),
404
+ whenAborted(job.controller.signal).then(() => ({
405
+ kind: "aborted",
406
+ })),
407
+ ]);
408
+ if (raced.kind === "granted") {
409
+ release = raced.grant;
410
+ }
411
+ else {
412
+ // Whatever we stopped waiting for may still be granted — hand it
413
+ // straight back rather than leaking a slot nobody will ever use.
414
+ void queued.then((grant) => grant()).catch(() => undefined);
415
+ return raced.kind === "aborted"
416
+ ? await settleFailure(job, `Worker ${job.workerId} was cancelled while waiting for a delegation slot.`, startedAt)
417
+ : await settleFailure(job, `Worker ${job.workerId} never got a delegation slot: ${errorMessage(raced.error)}. The pool was saturated for ${settings.poolQueueTimeoutMs}ms.`, startedAt);
418
+ }
419
+ }
420
+ if (job.controller.signal.aborted) {
421
+ return await settleFailure(job, `Worker ${job.workerId} was cancelled before it started.`, startedAt);
422
+ }
423
+ job.phase = "running";
424
+ const outcome = await runIsolatedAgent(job.host, buildDefinition(job, options), job.task, buildRunOptions(job, options));
425
+ return await settleRun(job, outcome, startedAt);
426
+ }
427
+ catch (error) {
428
+ return await settleFailure(job, job.cancelled
429
+ ? `Worker ${job.workerId} was cancelled: ${errorMessage(error)}`
430
+ : `Worker ${job.workerId} failed: ${errorMessage(error)}`, startedAt);
431
+ }
432
+ finally {
433
+ release?.();
434
+ job.detachParent?.();
435
+ }
436
+ }
437
+ // ── Public API ─────────────────────────────────────────────────────────────
438
+ function depthRefusal(depth, maxDepth) {
439
+ return (`Delegation depth limit reached (${depth}/${maxDepth}). Complete this investigation ` +
440
+ "yourself with your own tools instead of delegating further.");
441
+ }
442
+ /**
443
+ * Start a background worker and return its handle immediately — before the
444
+ * pool is waited on, and long before the worker finishes.
445
+ *
446
+ * The worker runs through `runIsolatedAgent`, so it gets a fresh session on a
447
+ * worker instance sharing this host's tool registry (live MCP connections
448
+ * included), waste detection, and an honest stop reason. Its full report is
449
+ * banked to a file when it settles; `collectDelegates` hands back the bounded
450
+ * summary and the pointer.
451
+ *
452
+ * @throws when the task is empty or the caller is already at the depth ceiling
453
+ */
454
+ export async function spawnDelegate(host, options) {
455
+ const task = options.task?.trim() ?? "";
456
+ if (!task) {
457
+ throw new Error("delegate_task needs a task: state, in one or two sentences, exactly what the " +
458
+ "worker should investigate and what it should report back.");
459
+ }
460
+ const settings = settingsFor(host);
461
+ const depth = options.depth ?? resolveDelegationDepth(host);
462
+ if (depth >= settings.maxDepth) {
463
+ throw new Error(depthRefusal(depth, settings.maxDepth));
464
+ }
465
+ installChecklistCounts();
466
+ workerCounter += 1;
467
+ const workerId = `w${workerCounter}`;
468
+ const controller = new AbortController();
469
+ // Chain the parent, and remember how to unchain: one long-lived run signal
470
+ // with many delegates hung off it would otherwise accumulate a listener per
471
+ // worker for the life of the run.
472
+ let detachParent;
473
+ const parentSignal = options.abortSignal;
474
+ if (parentSignal) {
475
+ if (parentSignal.aborted) {
476
+ controller.abort();
477
+ }
478
+ else {
479
+ const onParentAbort = () => controller.abort();
480
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
481
+ detachParent = () => parentSignal.removeEventListener("abort", onParentAbort);
482
+ }
483
+ }
484
+ // Ask for a slot without queueing, so the handle can say truthfully whether
485
+ // this worker is running or waiting.
486
+ const immediate = tryAcquireDelegationSlot();
487
+ const settled = deferredOutcome();
488
+ const job = {
489
+ workerId,
490
+ host,
491
+ sessionId: options.sessionId ?? resolveChecklistSessionId(host),
492
+ label: options.label?.trim() || firstLine(task, LABEL_MAX_CHARS) || workerId,
493
+ task,
494
+ depth,
495
+ phase: immediate ? "running" : "queued",
496
+ spawnedAt: Date.now(),
497
+ settledOrder: 0,
498
+ controller,
499
+ ...(detachParent && { detachParent }),
500
+ settled: settled.promise,
501
+ cancelled: false,
502
+ };
503
+ jobs.set(workerId, job);
504
+ // Detached on purpose: this is the whole point of the primitive.
505
+ void runJob(job, options, settings, immediate).then(settled.resolve);
506
+ logger.debug("[BackgroundDelegation] Worker spawned", {
507
+ workerId,
508
+ sessionId: job.sessionId,
509
+ queued: immediate === undefined,
510
+ depth,
511
+ });
512
+ return {
513
+ workerId,
514
+ spawnedAt: job.spawnedAt,
515
+ queued: immediate === undefined,
516
+ };
517
+ }
518
+ function claim(job) {
519
+ const outcome = job.outcome;
520
+ if (!outcome) {
521
+ return undefined;
522
+ }
523
+ job.phase = "claimed";
524
+ jobs.delete(job.workerId);
525
+ return outcome;
526
+ }
527
+ function bySettleOrder(left, right) {
528
+ return left.settledOrder - right.settledOrder;
529
+ }
530
+ /**
531
+ * Claim finished workers.
532
+ *
533
+ * `{ mode: "any" }` returns the first worker to FINISH — spawn order is
534
+ * irrelevant. `{ mode: "all" }` returns every outstanding worker in completion
535
+ * order. `{ workerId }` waits for one named worker. Each outcome is claimed
536
+ * exactly once and then dropped, so two collects never report the same work
537
+ * twice.
538
+ *
539
+ * `waitMs: 0` polls (whatever is ready right now); omitting it waits up to the
540
+ * runtime default. `timedOut` says work was still outstanding when the call
541
+ * returned — the signal to come back later, not an error.
542
+ *
543
+ * @throws when a named workerId is unknown to this host and session
544
+ */
545
+ export async function collectDelegates(host, request) {
546
+ const settings = settingsFor(host);
547
+ const sessionId = request.sessionId ?? resolveChecklistSessionId(host);
548
+ const waitMs = request.waitMs ?? settings.defaultCollectWaitMs;
549
+ if ("workerId" in request) {
550
+ const job = jobs.get(request.workerId);
551
+ if (!job || job.host !== host || job.sessionId !== sessionId) {
552
+ const known = outstandingFor(host, sessionId).map((j) => j.workerId);
553
+ throw new Error(known.length > 0
554
+ ? `No outstanding worker "${request.workerId}". Outstanding workers are ${known.join(", ")} — collect one of those, or use { mode: "any" }.`
555
+ : `No outstanding worker "${request.workerId}": every worker has already been ` +
556
+ "collected. Do not collect again; work with the results you have.");
557
+ }
558
+ if (!isReady(job)) {
559
+ await Promise.race([job.settled, afterMs(waitMs)]);
560
+ }
561
+ const outcome = isReady(job) ? claim(job) : undefined;
562
+ const counts = tally(outstandingFor(host, sessionId));
563
+ return {
564
+ completed: outcome ? [outcome] : [],
565
+ ...counts,
566
+ timedOut: outcome === undefined,
567
+ };
568
+ }
569
+ const mode = request.mode;
570
+ let ready = outstandingFor(host, sessionId).filter(isReady);
571
+ if (mode === "any") {
572
+ if (ready.length === 0) {
573
+ const unsettled = outstandingFor(host, sessionId).filter((job) => !isReady(job));
574
+ if (unsettled.length > 0) {
575
+ await Promise.race([
576
+ ...unsettled.map((job) => job.settled),
577
+ afterMs(waitMs),
578
+ ]);
579
+ ready = outstandingFor(host, sessionId).filter(isReady);
580
+ }
581
+ }
582
+ const first = [...ready].sort(bySettleOrder)[0];
583
+ const outcome = first ? claim(first) : undefined;
584
+ const counts = tally(outstandingFor(host, sessionId));
585
+ return {
586
+ completed: outcome ? [outcome] : [],
587
+ ...counts,
588
+ timedOut: outcome === undefined && counts.pending > 0,
589
+ };
590
+ }
591
+ const unsettled = outstandingFor(host, sessionId).filter((job) => !isReady(job));
592
+ if (unsettled.length > 0) {
593
+ await Promise.race([
594
+ Promise.all(unsettled.map((job) => job.settled)),
595
+ afterMs(waitMs),
596
+ ]);
597
+ }
598
+ const completed = outstandingFor(host, sessionId)
599
+ .filter(isReady)
600
+ .sort(bySettleOrder)
601
+ .map(claim)
602
+ .filter((outcome) => outcome !== undefined);
603
+ const counts = tally(outstandingFor(host, sessionId));
604
+ return { completed, ...counts, timedOut: counts.pending > 0 };
605
+ }
606
+ /**
607
+ * Cancel background workers: one by id, or every outstanding worker this host
608
+ * spawned. Cancelled workers still settle into a claimable outcome saying they
609
+ * were cancelled — silence would leave the supervisor waiting on a worker that
610
+ * is never coming back.
611
+ *
612
+ * @returns how many workers were cancelled
613
+ */
614
+ export async function cancelDelegates(host, workerId) {
615
+ const targets = [...jobs.values()].filter((job) => job.host === host &&
616
+ job.phase !== "claimed" &&
617
+ !job.controller.signal.aborted &&
618
+ (workerId === undefined || job.workerId === workerId));
619
+ for (const job of targets) {
620
+ job.cancelled = true;
621
+ job.controller.abort();
622
+ }
623
+ if (targets.length > 0) {
624
+ // Bounded: a worker that ignores its abort must not hang the caller.
625
+ await Promise.race([
626
+ Promise.all(targets.map((job) => job.settled)),
627
+ afterMs(CANCEL_SETTLE_GRACE_MS),
628
+ ]);
629
+ }
630
+ logger.debug("[BackgroundDelegation] Workers cancelled", {
631
+ cancelled: targets.length,
632
+ ...(workerId && { workerId }),
633
+ });
634
+ return targets.length;
635
+ }
636
+ // ── Model-facing tools ─────────────────────────────────────────────────────
637
+ const SPAWN_SCHEMA = z.object({
638
+ task: z
639
+ .string()
640
+ .describe("Exactly what this worker must investigate and report back. Self-contained: " +
641
+ "the worker cannot see your conversation."),
642
+ scope: z
643
+ .string()
644
+ .optional()
645
+ .describe("What the worker may look at — files, directories, systems."),
646
+ context: z
647
+ .string()
648
+ .optional()
649
+ .describe("A brief slice of background the worker needs. Keep it short; do not paste whole documents."),
650
+ tools: z
651
+ .array(z.string())
652
+ .optional()
653
+ .describe("Restrict the worker to these tool names."),
654
+ model: z.string().optional().describe("Model override for this worker."),
655
+ });
656
+ const COLLECT_SCHEMA = z.object({
657
+ mode: z
658
+ .enum(["any", "all"])
659
+ .optional()
660
+ .describe('"any" (default) returns the first worker to finish; "all" waits for every outstanding worker.'),
661
+ workerId: z
662
+ .string()
663
+ .optional()
664
+ .describe("Collect one specific worker instead of using mode."),
665
+ waitMs: z
666
+ .number()
667
+ .optional()
668
+ .describe("How long to wait, in milliseconds. 0 returns only what is already finished."),
669
+ });
670
+ /**
671
+ * `delegate_task` and `collect_results`, bound to `host`. Register them with
672
+ * `host.registerTool()` (see `NeuroLink.registerDelegationTools()`), never on
673
+ * the tool registry directly: only the "user-defined" category reaches the
674
+ * LLM's tool schema.
675
+ */
676
+ export function createDelegationTools(host) {
677
+ return {
678
+ delegate_task: {
679
+ name: "delegate_task",
680
+ description: "Hand one self-contained task to a background worker and get a workerId back " +
681
+ "IMMEDIATELY — the worker runs while you keep working. Delegate the big, " +
682
+ "separable investigations; do the small ones yourself. Collect the results later " +
683
+ "with collect_results; they come back in whatever order the workers finish.",
684
+ inputSchema: SPAWN_SCHEMA,
685
+ execute: async (params, executionContext) => {
686
+ const parsed = SPAWN_SCHEMA.safeParse(params ?? {});
687
+ if (!parsed.success) {
688
+ return refusal("delegate_task expects { task, scope?, context?, tools?, model? } with task a " +
689
+ "non-empty string. Call it again with the task spelled out.");
690
+ }
691
+ const contextRecord = asRecord(executionContext);
692
+ const sessionId = resolveChecklistSessionId(host, executionContext);
693
+ // Registration-time defaults under the model's arguments: the model's own
694
+ // `model` wins, `provider` can only come from here (the schema has none).
695
+ const defaults = settingsFor(host).spawnDefaults;
696
+ try {
697
+ const handle = await spawnDelegate(host, {
698
+ ...(defaults?.provider !== undefined && {
699
+ provider: defaults.provider,
700
+ }),
701
+ ...(defaults?.model !== undefined && { model: defaults.model }),
702
+ ...parsed.data,
703
+ sessionId,
704
+ depth: resolveDelegationDepth(host, contextRecord),
705
+ });
706
+ const counts = delegateCounts(host, sessionId);
707
+ const result = {
708
+ ...handle,
709
+ pending: counts.pending,
710
+ ready: counts.ready,
711
+ };
712
+ return result;
713
+ }
714
+ catch (error) {
715
+ return refusal(errorMessage(error));
716
+ }
717
+ },
718
+ },
719
+ collect_results: {
720
+ name: "collect_results",
721
+ description: "Claim finished background workers. Each result carries a bounded summary and a " +
722
+ "read-back call for the worker's FULL report, which was banked to a file. Results " +
723
+ "are returned in completion order, not the order you spawned them, and each one is " +
724
+ "handed out exactly once. pending/ready tell you what is still outstanding.",
725
+ inputSchema: COLLECT_SCHEMA,
726
+ execute: async (params, executionContext) => {
727
+ const parsed = COLLECT_SCHEMA.safeParse(params ?? {});
728
+ if (!parsed.success) {
729
+ return refusal('collect_results expects { mode?: "any" | "all", workerId?, waitMs? }. ' +
730
+ "Call it again with no arguments to take the next finished worker.");
731
+ }
732
+ const sessionId = resolveChecklistSessionId(host, executionContext);
733
+ const { mode, workerId, waitMs } = parsed.data;
734
+ try {
735
+ return await collectDelegates(host, workerId
736
+ ? {
737
+ workerId,
738
+ sessionId,
739
+ ...(waitMs !== undefined && { waitMs }),
740
+ }
741
+ : {
742
+ mode: mode ?? "any",
743
+ sessionId,
744
+ ...(waitMs !== undefined && { waitMs }),
745
+ });
746
+ }
747
+ catch (error) {
748
+ return refusal(errorMessage(error));
749
+ }
750
+ },
751
+ },
752
+ };
753
+ }