@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.
- package/CHANGELOG.md +2 -2
- package/dist/agent/agentToolRegistrar.d.ts +30 -0
- package/dist/agent/agentToolRegistrar.js +72 -18
- package/dist/agent/backgroundCommands.d.ts +110 -0
- package/dist/agent/backgroundCommands.js +914 -0
- package/dist/agent/backgroundDelegation.d.ts +87 -0
- package/dist/agent/backgroundDelegation.js +753 -0
- package/dist/agent/gitTools.d.ts +43 -0
- package/dist/agent/gitTools.js +618 -0
- package/dist/agent/taskChecklist.d.ts +58 -0
- package/dist/agent/taskChecklist.js +322 -0
- package/dist/artifacts/artifactBanking.d.ts +57 -0
- package/dist/artifacts/artifactBanking.js +123 -0
- package/dist/artifacts/artifactStore.d.ts +36 -8
- package/dist/artifacts/artifactStore.js +164 -13
- package/dist/browser/neurolink.min.js +442 -414
- package/dist/neurolink.d.ts +294 -3
- package/dist/neurolink.js +447 -4
- package/dist/types/artifact.d.ts +54 -0
- package/dist/types/backgroundCommand.d.ts +174 -0
- package/dist/types/backgroundCommand.js +22 -0
- package/dist/types/delegation.d.ts +178 -0
- package/dist/types/delegation.js +18 -0
- package/dist/types/gitTools.d.ts +69 -0
- package/dist/types/gitTools.js +22 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +8 -0
- package/dist/types/pathSandbox.d.ts +23 -0
- package/dist/types/pathSandbox.js +12 -0
- package/dist/types/tasks.d.ts +85 -0
- package/dist/types/tasks.js +14 -0
- package/dist/types/tools.d.ts +11 -0
- package/dist/utils/pathSandbox.d.ts +49 -0
- package/dist/utils/pathSandbox.js +127 -0
- 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
|
+
}
|