@nexrall/code-core 1.4.29 → 1.4.31
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/dist/agent/agentTypes.d.ts +14 -0
- package/dist/agent/agentTypes.d.ts.map +1 -1
- package/dist/agent/agentTypes.js +34 -0
- package/dist/agent/loop.d.ts +56 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +376 -40
- package/package.json +1 -1
|
@@ -35,6 +35,20 @@ export interface AgentType {
|
|
|
35
35
|
* user — ~/.nexrall/agent-memory/<name>.md (follows you everywhere)
|
|
36
36
|
*/
|
|
37
37
|
memory?: AgentMemoryScope;
|
|
38
|
+
/**
|
|
39
|
+
* Skip the project's nexrall.md for this agent, keeping its prompt lean.
|
|
40
|
+
*
|
|
41
|
+
* Claude Code does the same for its two research builtins (Explore and Plan skip
|
|
42
|
+
* CLAUDE.md and git status to stay fast and cheap), and the reasoning transfers: a
|
|
43
|
+
* search-and-report agent re-reads the entire project instruction file on EVERY
|
|
44
|
+
* invocation, on the tier chosen for being cheap, to produce output the main agent
|
|
45
|
+
* then interprets WITH that context anyway. The rules that must reach it belong in the
|
|
46
|
+
* delegation prompt instead.
|
|
47
|
+
*
|
|
48
|
+
* Only for agents that neither write files nor make judgement calls about conventions
|
|
49
|
+
* — a reviewer or test-writer needs the project's rules, an explorer does not.
|
|
50
|
+
*/
|
|
51
|
+
lightPrompt?: boolean;
|
|
38
52
|
}
|
|
39
53
|
/** Where a sub-agent's own persistent notes live. */
|
|
40
54
|
export type AgentMemoryScope = 'project' | 'user' | 'local';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agentTypes.d.ts","sourceRoot":"","sources":["../../src/agent/agentTypes.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IACpD;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"agentTypes.d.ts","sourceRoot":"","sources":["../../src/agent/agentTypes.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IACpD;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,qDAAqD;AACrD,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;AA4U5D;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAyKD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAE3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;CAAE,CAqB5G;AAED,yFAAyF;AACzF,wBAAgB,cAAc,IAAI,MAAM,EAAE,CAEzC;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,IAAI,SAAS,EAAE,CAE3C;AACD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,CAY1D;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAIjG"}
|
package/dist/agent/agentTypes.js
CHANGED
|
@@ -115,6 +115,37 @@ const WRITE_TOOLS = [
|
|
|
115
115
|
'notebook_edit',
|
|
116
116
|
];
|
|
117
117
|
const BUILTIN_AGENTS = [
|
|
118
|
+
{
|
|
119
|
+
// The catch-all, matching Claude Code's `general-purpose`.
|
|
120
|
+
//
|
|
121
|
+
// This capability already existed — omitting `subagent_type` gives an unrestricted
|
|
122
|
+
// sub-agent — but it had no NAME, and that had two consequences worth fixing:
|
|
123
|
+
//
|
|
124
|
+
// 1. `permissions.deny: ["task(...)"]` matches on the agent name, so the ONE
|
|
125
|
+
// sub-agent that can write files and run bash was the one variant a project
|
|
126
|
+
// could not disable individually. Only a blanket `deny: ["task"]` reached it.
|
|
127
|
+
// 2. The model had to infer that leaving the field blank was even an option, so
|
|
128
|
+
// it would sometimes pick a specialist that fitted badly (an unrestricted
|
|
129
|
+
// explorer) rather than the general worker it actually wanted.
|
|
130
|
+
//
|
|
131
|
+
// `tools` is deliberately UNDEFINED, which means "no allowlist" — full access,
|
|
132
|
+
// inheriting whatever the session permits. That is the same power an unnamed
|
|
133
|
+
// sub-task always had; naming it changes only who can see and deny it. The safety
|
|
134
|
+
// properties elsewhere still apply: plan mode is inherited, the user's permission
|
|
135
|
+
// gate still runs on every call, and it cannot spawn further sub-agents.
|
|
136
|
+
name: 'general-purpose',
|
|
137
|
+
description: 'General-purpose worker for a multi-step task that needs BOTH exploration and changes ' +
|
|
138
|
+
'(edit files, run commands) and that no specialist above fits. Inherits the session model ' +
|
|
139
|
+
'and full tool access, so prefer a narrower agent when one matches.',
|
|
140
|
+
prompt: 'You are a general-purpose engineering sub-agent. Work the task end to end: explore what you ' +
|
|
141
|
+
'need, make the changes, and verify them with the project\'s own build/test commands.\n\n' +
|
|
142
|
+
'Rules:\n' +
|
|
143
|
+
'- Mirror existing conventions; make the smallest correct change.\n' +
|
|
144
|
+
'- Verify before you claim success. If you could not verify, say so explicitly.\n' +
|
|
145
|
+
'- Your FINAL MESSAGE is the only thing that reaches the main agent: state what you changed ' +
|
|
146
|
+
'(with file paths), what you ran and its outcome, and anything you deliberately left undone.',
|
|
147
|
+
source: 'builtin',
|
|
148
|
+
},
|
|
118
149
|
{
|
|
119
150
|
name: 'reviewer',
|
|
120
151
|
description: 'Read-only code reviewer — finds correctness bugs, edge cases, and security issues in a diff or file set. Cannot modify files.',
|
|
@@ -177,6 +208,9 @@ const BUILTIN_AGENTS = [
|
|
|
177
208
|
// frontier model, and this is the agent most likely to be spawned in bulk.
|
|
178
209
|
{
|
|
179
210
|
name: 'explorer',
|
|
211
|
+
// Lean prompt: this agent exists to keep bulk searching cheap, and it reports
|
|
212
|
+
// findings for the MAIN agent to interpret with full project context.
|
|
213
|
+
lightPrompt: true,
|
|
180
214
|
description: 'Fast read-only codebase explorer — locates files, symbols, and call sites and reports concise findings. Use to keep bulk searching out of the main context. Cannot modify files.',
|
|
181
215
|
tools: READ_ONLY_TOOLS,
|
|
182
216
|
model: 'turbo',
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -85,12 +85,58 @@ export declare function stopReasonNotice(reason: StopReason, ctx?: {
|
|
|
85
85
|
repeatError?: string | null;
|
|
86
86
|
}): string | null;
|
|
87
87
|
export declare function resolveMaxIterations(optionValue: number | undefined, settingsRaw: Record<string, unknown>): number;
|
|
88
|
+
/**
|
|
89
|
+
* Whether a bash command should take the repo-state lock.
|
|
90
|
+
*
|
|
91
|
+
* Exported for tests: the failure mode (two concurrent `git commit`) is timing-dependent
|
|
92
|
+
* and painful to reproduce through the real loop.
|
|
93
|
+
*
|
|
94
|
+
* Scans EVERY segment, not just the first word, because the mutating verb is routinely
|
|
95
|
+
* not leading: `cd packages/core && npm publish`, `FOO=1 git push`, `ls && git commit`.
|
|
96
|
+
* Checking only the first token would miss most real commands.
|
|
97
|
+
*/
|
|
98
|
+
export declare function bashNeedsRepoLock(command: string): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Every path a tool call will touch, so all of them can be locked.
|
|
101
|
+
*
|
|
102
|
+
* `move_file`/`copy_file` carry `source` + `destination`, the rest carry `path`. Both
|
|
103
|
+
* `dest` and `destination` are accepted because the schema has used both spellings and
|
|
104
|
+
* silently missing the key would mean silently losing the lock — a failure that shows
|
|
105
|
+
* up as corrupted content rather than an error.
|
|
106
|
+
*
|
|
107
|
+
* Exported for tests: getting this wrong is invisible until two writes race.
|
|
108
|
+
*/
|
|
109
|
+
export declare function lockPathsFor(name: string, input: Record<string, unknown>, workDir?: string): string[];
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the fan-out limit: env → settings.json → default.
|
|
112
|
+
*
|
|
113
|
+
* Was env-ONLY, the same gap as the sub-task timeout: a project on a small machine that
|
|
114
|
+
* wanted 2, or a big one that wanted 6, had to set a shell variable, and nobody reading
|
|
115
|
+
* settings.json could see what the limit even was. Capped at 16 because this bounds real
|
|
116
|
+
* shared resources (CPU, the API rate limit, file handles) and a typo like 400 should
|
|
117
|
+
* degrade to "a lot" rather than fork-bomb the machine.
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveMaxConcurrentSubtasks(settingsRaw?: Record<string, unknown>): number;
|
|
88
120
|
/**
|
|
89
121
|
* Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
|
|
90
122
|
* the CLI ships as a single esbuild bundle with no node_modules, and this is a
|
|
91
123
|
* dozen lines.
|
|
92
124
|
*/
|
|
93
125
|
export declare function createLimiter(max: number): <T>(fn: () => Promise<T>) => Promise<T>;
|
|
126
|
+
/** Test-only: forget the memoised limiter so a new limit can take effect. */
|
|
127
|
+
export declare function _resetSubTaskLimiter(): void;
|
|
128
|
+
/**
|
|
129
|
+
* How long a sub-agent may make NO progress before it is stopped.
|
|
130
|
+
*
|
|
131
|
+
* Precedence matches every other tunable in this file
|
|
132
|
+
* (env → settings.json → default) — it used to be env-ONLY, which meant a project that
|
|
133
|
+
* legitimately needed longer sub-tasks had no way to say so in the file where every
|
|
134
|
+
* other such preference lives, and the limit was invisible to anyone reading settings.
|
|
135
|
+
*
|
|
136
|
+
* Exported for tests: the behaviour that matters (a long-but-productive sub-agent is
|
|
137
|
+
* NOT killed) takes minutes of wall clock to exercise through the real loop.
|
|
138
|
+
*/
|
|
139
|
+
export declare function resolveSubtaskTimeoutMs(settingsRaw: Record<string, unknown>): number;
|
|
94
140
|
/**
|
|
95
141
|
* Thrown by a sub-agent's permission gate when the AGENT DEFINITION forbids a
|
|
96
142
|
* tool — as opposed to the user declining it.
|
|
@@ -135,6 +181,16 @@ export declare function capSubTaskText(text: string, max?: number): string;
|
|
|
135
181
|
* had already been billed in full.
|
|
136
182
|
*/
|
|
137
183
|
export declare function summariseSubTaskProgress(messages: Message[]): string;
|
|
184
|
+
/**
|
|
185
|
+
* The last N tool results from a transcript, newest last, each truncated.
|
|
186
|
+
*
|
|
187
|
+
* Pure + exported so the salvage rules are testable without a real sub-agent.
|
|
188
|
+
*
|
|
189
|
+
* Truncation keeps the HEAD of each result: tool output is overwhelmingly
|
|
190
|
+
* front-loaded (a file starts with its imports, a failing command starts with its
|
|
191
|
+
* error), and a head slice is the half that identifies what was found.
|
|
192
|
+
*/
|
|
193
|
+
export declare function lastToolResults(messages: Message[], count: number, maxChars: number): string;
|
|
138
194
|
/** The context window (tokens) for a model alias — the same lookup runAgentLoop uses internally. */
|
|
139
195
|
export declare function contextWindowFor(model?: 'turbo' | 'pro' | 'ultra'): number;
|
|
140
196
|
/** Auto-prune / auto-compact thresholds as fractions of the context window — for UI display (e.g. `/context`). */
|
package/dist/agent/loop.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAKP,UAAU,EACV,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAIlB,OAAO,EAAkE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAmKrH;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAC9C,MAAM,CAKR;AAED,+CAA+C;AAC/C,eAAO,MAAM,YAAY;;;CAAsC,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;CAgB3B,CAAC;AAEX,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,EAAE,kBAAkB,GAAG,SAAS,EACvC,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,UAAU,CAAC,CA8BrB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAClB,OAAO,GACP,SAAS,GACT,oBAAoB,GACpB,gBAAgB,GAChB,YAAY,GACZ,SAAS,GACT,gBAAgB,GAChB,QAAQ,GACR,SAAS,CAAC;AAEd;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,UAAU,EAClB,GAAG,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GACzD,MAAM,GAAG,IAAI,CAgCf;AAWD,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;
|
|
1
|
+
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
|
package/dist/agent/loop.js
CHANGED
|
@@ -38,10 +38,16 @@ exports.errorRoundSignature = errorRoundSignature;
|
|
|
38
38
|
exports.executeAgentMemoryWrite = executeAgentMemoryWrite;
|
|
39
39
|
exports.stopReasonNotice = stopReasonNotice;
|
|
40
40
|
exports.resolveMaxIterations = resolveMaxIterations;
|
|
41
|
+
exports.bashNeedsRepoLock = bashNeedsRepoLock;
|
|
42
|
+
exports.lockPathsFor = lockPathsFor;
|
|
43
|
+
exports.resolveMaxConcurrentSubtasks = resolveMaxConcurrentSubtasks;
|
|
41
44
|
exports.createLimiter = createLimiter;
|
|
45
|
+
exports._resetSubTaskLimiter = _resetSubTaskLimiter;
|
|
46
|
+
exports.resolveSubtaskTimeoutMs = resolveSubtaskTimeoutMs;
|
|
42
47
|
exports.extractSubTaskText = extractSubTaskText;
|
|
43
48
|
exports.capSubTaskText = capSubTaskText;
|
|
44
49
|
exports.summariseSubTaskProgress = summariseSubTaskProgress;
|
|
50
|
+
exports.lastToolResults = lastToolResults;
|
|
45
51
|
exports.contextWindowFor = contextWindowFor;
|
|
46
52
|
exports.compactionThresholds = compactionThresholds;
|
|
47
53
|
exports.estimateBodyBytes = estimateBodyBytes;
|
|
@@ -385,6 +391,113 @@ async function withFileLock(absPath, fn) {
|
|
|
385
391
|
_fileLocks.delete(absPath);
|
|
386
392
|
}
|
|
387
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Take several locks at once, always in a globally consistent order.
|
|
396
|
+
*
|
|
397
|
+
* Needed because move_file/copy_file touch TWO paths. Locking only one of them (the
|
|
398
|
+
* old behaviour locked `source` and left `dest` unprotected) leaves exactly the race
|
|
399
|
+
* the lock exists to prevent: a `move_file{dest:'shared.ts'}` running concurrently with
|
|
400
|
+
* an `edit_file{path:'shared.ts'}` had nothing serialising them.
|
|
401
|
+
*
|
|
402
|
+
* The sort is load-bearing, not tidiness: two callers acquiring {A,B} and {B,A} at the
|
|
403
|
+
* same time would deadlock, each holding what the other waits for. Sorting means every
|
|
404
|
+
* caller in the process takes them in the same order, which makes that impossible.
|
|
405
|
+
*/
|
|
406
|
+
async function withFileLocks(absPaths, fn) {
|
|
407
|
+
const unique = [...new Set(absPaths.filter(Boolean))].sort();
|
|
408
|
+
if (unique.length === 0)
|
|
409
|
+
return fn();
|
|
410
|
+
const [first, ...rest] = unique;
|
|
411
|
+
return withFileLock(first, () => (rest.length ? withFileLocks(rest, fn) : fn()));
|
|
412
|
+
}
|
|
413
|
+
/** Tools that mutate the filesystem and must be serialised per path. */
|
|
414
|
+
const WRITE_TOOLS = new Set([
|
|
415
|
+
'write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit',
|
|
416
|
+
]);
|
|
417
|
+
/**
|
|
418
|
+
* Shared lock key for bash commands that mutate repository-wide state.
|
|
419
|
+
*
|
|
420
|
+
* Not a path, because these commands do not declare one — `git commit` contends over
|
|
421
|
+
* `.git/index.lock`, `npm install` over `node_modules` and the lockfile. One key means
|
|
422
|
+
* they serialise against each other while everything else stays parallel.
|
|
423
|
+
*/
|
|
424
|
+
const REPO_STATE_LOCK = '\u0000repo-state';
|
|
425
|
+
/**
|
|
426
|
+
* Commands that must not run concurrently with another instance of themselves.
|
|
427
|
+
*
|
|
428
|
+
* `bash` was excluded from the write-tool lock entirely, which was the largest hole in
|
|
429
|
+
* the concurrency story: it is the most mutating tool there is, and with 4 sub-agents
|
|
430
|
+
* fanned out two of them could run `git commit` simultaneously (one dies on
|
|
431
|
+
* `.git/index.lock`), or two `npm install` (corrupted node_modules / lockfile), or two
|
|
432
|
+
* `npm test` writing the same coverage and snapshot files (wrong results, not an error).
|
|
433
|
+
* The comment above withFileLock explains carefully why two edit_file calls must be
|
|
434
|
+
* sequenced, then a `bash -c 'echo x > file'` did exactly that with nothing holding it.
|
|
435
|
+
*
|
|
436
|
+
* Deliberately a VERB ALLOWLIST rather than serialising all of bash: an explorer's
|
|
437
|
+
* `grep`/`rg`/`ls`/`cat` are read-only and are precisely the calls that benefit most
|
|
438
|
+
* from running in parallel. Serialising those would trade a real correctness bug for a
|
|
439
|
+
* real performance regression. These verbs are the ones that take a global lock, write
|
|
440
|
+
* a lockfile, or mutate a shared build/dependency directory.
|
|
441
|
+
*/
|
|
442
|
+
const MUTATING_BASH_VERBS = new Set([
|
|
443
|
+
'git', 'npm', 'pnpm', 'yarn', 'bun', 'cargo', 'go', 'pip', 'pip3', 'poetry', 'uv',
|
|
444
|
+
'bundle', 'composer', 'gradle', 'mvn', 'make', 'terraform', 'helm', 'docker',
|
|
445
|
+
'vsce', 'tsc', 'pytest', 'dotnet', 'swift', 'gem',
|
|
446
|
+
]);
|
|
447
|
+
/**
|
|
448
|
+
* Whether a bash command should take the repo-state lock.
|
|
449
|
+
*
|
|
450
|
+
* Exported for tests: the failure mode (two concurrent `git commit`) is timing-dependent
|
|
451
|
+
* and painful to reproduce through the real loop.
|
|
452
|
+
*
|
|
453
|
+
* Scans EVERY segment, not just the first word, because the mutating verb is routinely
|
|
454
|
+
* not leading: `cd packages/core && npm publish`, `FOO=1 git push`, `ls && git commit`.
|
|
455
|
+
* Checking only the first token would miss most real commands.
|
|
456
|
+
*/
|
|
457
|
+
function bashNeedsRepoLock(command) {
|
|
458
|
+
if (!command)
|
|
459
|
+
return false;
|
|
460
|
+
// Split on shell separators, then take the first bare word of each segment, skipping
|
|
461
|
+
// env-var assignments (FOO=bar git push) and `sudo`/`time`-style prefixes.
|
|
462
|
+
const segments = command.split(/\|\||&&|[;|&\n()]|\$\(/);
|
|
463
|
+
for (const seg of segments) {
|
|
464
|
+
for (const tokenRaw of seg.trim().split(/\s+/)) {
|
|
465
|
+
const token = tokenRaw.replace(/^["']|["']$/g, '');
|
|
466
|
+
if (!token)
|
|
467
|
+
continue;
|
|
468
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
|
|
469
|
+
continue; // env assignment
|
|
470
|
+
if (token === 'sudo' || token === 'time' || token === 'nice' || token === 'env')
|
|
471
|
+
continue;
|
|
472
|
+
// Use the basename so `/usr/bin/git` and `./node_modules/.bin/tsc` still match.
|
|
473
|
+
const verb = token.split('/').pop() ?? token;
|
|
474
|
+
if (MUTATING_BASH_VERBS.has(verb))
|
|
475
|
+
return true;
|
|
476
|
+
break; // only the command word of each segment matters
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Every path a tool call will touch, so all of them can be locked.
|
|
483
|
+
*
|
|
484
|
+
* `move_file`/`copy_file` carry `source` + `destination`, the rest carry `path`. Both
|
|
485
|
+
* `dest` and `destination` are accepted because the schema has used both spellings and
|
|
486
|
+
* silently missing the key would mean silently losing the lock — a failure that shows
|
|
487
|
+
* up as corrupted content rather than an error.
|
|
488
|
+
*
|
|
489
|
+
* Exported for tests: getting this wrong is invisible until two writes race.
|
|
490
|
+
*/
|
|
491
|
+
function lockPathsFor(name, input, workDir) {
|
|
492
|
+
if (!WRITE_TOOLS.has(name))
|
|
493
|
+
return [];
|
|
494
|
+
const raw = [input.path, input.source, input.destination, input.dest]
|
|
495
|
+
.filter((p) => typeof p === 'string' && p.length > 0);
|
|
496
|
+
const abs = raw.map((p) => (workDir ? path.resolve(workDir, p) : p));
|
|
497
|
+
// Fall back to the tool name so a malformed call still serialises against itself
|
|
498
|
+
// rather than escaping the lock entirely.
|
|
499
|
+
return abs.length ? abs : [name];
|
|
500
|
+
}
|
|
388
501
|
// ─── Sub-agent fan-out limiter ────────────────────────────────────────────────
|
|
389
502
|
//
|
|
390
503
|
// Tool calls in one turn run via Promise.all with no ceiling. For ordinary tools
|
|
@@ -405,10 +518,26 @@ async function withFileLock(absPath, fn) {
|
|
|
405
518
|
//
|
|
406
519
|
// This is a QUEUE, not a rejection: every sub-task still runs, just at most N at a
|
|
407
520
|
// time. Failing the excess would be worse than serialising it.
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
521
|
+
const DEFAULT_MAX_CONCURRENT_SUBTASKS = 4;
|
|
522
|
+
/**
|
|
523
|
+
* Resolve the fan-out limit: env → settings.json → default.
|
|
524
|
+
*
|
|
525
|
+
* Was env-ONLY, the same gap as the sub-task timeout: a project on a small machine that
|
|
526
|
+
* wanted 2, or a big one that wanted 6, had to set a shell variable, and nobody reading
|
|
527
|
+
* settings.json could see what the limit even was. Capped at 16 because this bounds real
|
|
528
|
+
* shared resources (CPU, the API rate limit, file handles) and a typo like 400 should
|
|
529
|
+
* degrade to "a lot" rather than fork-bomb the machine.
|
|
530
|
+
*/
|
|
531
|
+
function resolveMaxConcurrentSubtasks(settingsRaw = {}) {
|
|
532
|
+
const clamp = (n) => Math.min(Math.floor(n), 16);
|
|
533
|
+
const fromEnv = Number(process.env.NEXRALL_MAX_CONCURRENT_SUBTASKS);
|
|
534
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
535
|
+
return clamp(fromEnv);
|
|
536
|
+
const fromSettings = Number(settingsRaw.maxConcurrentSubtasks);
|
|
537
|
+
if (Number.isFinite(fromSettings) && fromSettings > 0)
|
|
538
|
+
return clamp(fromSettings);
|
|
539
|
+
return DEFAULT_MAX_CONCURRENT_SUBTASKS;
|
|
540
|
+
}
|
|
412
541
|
/**
|
|
413
542
|
* Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
|
|
414
543
|
* the CLI ships as a single esbuild bundle with no node_modules, and this is a
|
|
@@ -436,7 +565,28 @@ function createLimiter(max) {
|
|
|
436
565
|
// Process-wide, deliberately: the limit exists to protect shared resources (CPU,
|
|
437
566
|
// the API rate limit, file handles), and those are shared across every concurrent
|
|
438
567
|
// turn in this process, not just the tool calls of one message.
|
|
439
|
-
|
|
568
|
+
//
|
|
569
|
+
// Built LAZILY on first use rather than at module load, because the limit can now come
|
|
570
|
+
// from settings.json and the workspace is not known when this module is imported.
|
|
571
|
+
// Once created it is reused for the process lifetime — rebuilding it per turn would
|
|
572
|
+
// reset `active` and let the ceiling be exceeded, which is worse than not honouring a
|
|
573
|
+
// mid-session settings change.
|
|
574
|
+
let _subTaskLimitInstance = null;
|
|
575
|
+
let _subTaskLimitMax = 0;
|
|
576
|
+
/** Sub-tasks dispatched but not yet finished — used only to detect queueing. */
|
|
577
|
+
let _inFlightSubTasks = 0;
|
|
578
|
+
function subTaskLimiter(workDir) {
|
|
579
|
+
if (!_subTaskLimitInstance) {
|
|
580
|
+
_subTaskLimitMax = resolveMaxConcurrentSubtasks(workDir ? (0, rules_1.loadSettings)(workDir).raw : {});
|
|
581
|
+
_subTaskLimitInstance = createLimiter(_subTaskLimitMax);
|
|
582
|
+
}
|
|
583
|
+
return { run: _subTaskLimitInstance, max: _subTaskLimitMax };
|
|
584
|
+
}
|
|
585
|
+
/** Test-only: forget the memoised limiter so a new limit can take effect. */
|
|
586
|
+
function _resetSubTaskLimiter() {
|
|
587
|
+
_subTaskLimitInstance = null;
|
|
588
|
+
_subTaskLimitMax = 0;
|
|
589
|
+
}
|
|
440
590
|
// ─── Human-readable tool descriptions ────────────────────────────────────────
|
|
441
591
|
function humanDescription(name, input) {
|
|
442
592
|
switch (name) {
|
|
@@ -539,9 +689,28 @@ let _subTaskCounter = 0; // unique per-process id → per-sub-agent todo scope
|
|
|
539
689
|
// pointing at it specifically. Give every sub-task an explicit wall-clock cap:
|
|
540
690
|
// if it hasn't finished by then, fail it clearly instead of hanging the parent
|
|
541
691
|
// turn indefinitely. Overridable via env for slow CI machines / huge sub-tasks.
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
692
|
+
const DEFAULT_SUBTASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 min of NO PROGRESS (see the watchdog)
|
|
693
|
+
/**
|
|
694
|
+
* How long a sub-agent may make NO progress before it is stopped.
|
|
695
|
+
*
|
|
696
|
+
* Precedence matches every other tunable in this file
|
|
697
|
+
* (env → settings.json → default) — it used to be env-ONLY, which meant a project that
|
|
698
|
+
* legitimately needed longer sub-tasks had no way to say so in the file where every
|
|
699
|
+
* other such preference lives, and the limit was invisible to anyone reading settings.
|
|
700
|
+
*
|
|
701
|
+
* Exported for tests: the behaviour that matters (a long-but-productive sub-agent is
|
|
702
|
+
* NOT killed) takes minutes of wall clock to exercise through the real loop.
|
|
703
|
+
*/
|
|
704
|
+
function resolveSubtaskTimeoutMs(settingsRaw) {
|
|
705
|
+
const fromEnv = Number(process.env.NEXRALL_SUBTASK_TIMEOUT_MS);
|
|
706
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
707
|
+
return Math.floor(fromEnv);
|
|
708
|
+
const raw = settingsRaw.subtaskTimeoutMs;
|
|
709
|
+
const fromSettings = Number(raw);
|
|
710
|
+
if (Number.isFinite(fromSettings) && fromSettings > 0)
|
|
711
|
+
return Math.floor(fromSettings);
|
|
712
|
+
return DEFAULT_SUBTASK_TIMEOUT_MS;
|
|
713
|
+
}
|
|
545
714
|
/** Cap on the text a sub-task hands back, so one verbose sub-agent can't blow up
|
|
546
715
|
* the PARENT's context in a single tool_result. */
|
|
547
716
|
const SUBTASK_MAX = 48000; // chars (~12k tokens)
|
|
@@ -648,6 +817,14 @@ function summariseSubTaskProgress(messages) {
|
|
|
648
817
|
}
|
|
649
818
|
if (toolNames.length === 0)
|
|
650
819
|
return '';
|
|
820
|
+
// The FINDINGS, not just the activity log.
|
|
821
|
+
//
|
|
822
|
+
// An inventory of tool names ("read_file ×9, bash ×14") tells the parent that work
|
|
823
|
+
// happened but nothing about what was learned, so it re-derives everything. A stalled
|
|
824
|
+
// research agent's value is almost entirely in what its last few tool calls RETURNED
|
|
825
|
+
// — the file it had just read, the command output it was about to interpret — because
|
|
826
|
+
// its own prose summary is exactly the thing it never got to write.
|
|
827
|
+
const recentFindings = lastToolResults(messages, SALVAGE_RESULT_COUNT, SALVAGE_RESULT_CHARS);
|
|
651
828
|
// Collapse to "name ×N" so a 40-call run reads as a short inventory rather
|
|
652
829
|
// than forty repeated lines of the same tool name.
|
|
653
830
|
const counts = new Map();
|
|
@@ -657,7 +834,71 @@ function summariseSubTaskProgress(messages) {
|
|
|
657
834
|
.sort((a, b) => b[1] - a[1])
|
|
658
835
|
.map(([name, n]) => (n > 1 ? `${name} ×${n}` : name))
|
|
659
836
|
.join(', ');
|
|
660
|
-
|
|
837
|
+
const header = `Tool calls completed before it was stopped (${toolNames.length} total): ${inventory}.`;
|
|
838
|
+
return recentFindings
|
|
839
|
+
? `${header}\n\nWhat its most recent tool calls actually returned (use this instead of repeating them):\n${recentFindings}`
|
|
840
|
+
: header;
|
|
841
|
+
}
|
|
842
|
+
/** How many trailing tool results to salvage, and how much of each. */
|
|
843
|
+
const SALVAGE_RESULT_COUNT = 4;
|
|
844
|
+
const SALVAGE_RESULT_CHARS = 2000;
|
|
845
|
+
/**
|
|
846
|
+
* The last N tool results from a transcript, newest last, each truncated.
|
|
847
|
+
*
|
|
848
|
+
* Pure + exported so the salvage rules are testable without a real sub-agent.
|
|
849
|
+
*
|
|
850
|
+
* Truncation keeps the HEAD of each result: tool output is overwhelmingly
|
|
851
|
+
* front-loaded (a file starts with its imports, a failing command starts with its
|
|
852
|
+
* error), and a head slice is the half that identifies what was found.
|
|
853
|
+
*/
|
|
854
|
+
function lastToolResults(messages, count, maxChars) {
|
|
855
|
+
// Map tool_use id → tool name, so a salvaged result can say WHICH tool produced it.
|
|
856
|
+
const nameById = new Map();
|
|
857
|
+
for (const m of messages) {
|
|
858
|
+
if (m.role !== 'assistant' || !Array.isArray(m.content))
|
|
859
|
+
continue;
|
|
860
|
+
for (const b of m.content) {
|
|
861
|
+
if (b?.type === 'tool_use' && b.id && typeof b.name === 'string')
|
|
862
|
+
nameById.set(b.id, b.name);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const out = [];
|
|
866
|
+
// Walk backwards and stop early: only the most recent results are worth the tokens,
|
|
867
|
+
// and a long research run may hold hundreds.
|
|
868
|
+
for (let i = messages.length - 1; i >= 0 && out.length < count; i--) {
|
|
869
|
+
const m = messages[i];
|
|
870
|
+
if (m.role !== 'user' || !Array.isArray(m.content))
|
|
871
|
+
continue;
|
|
872
|
+
for (const b of [...m.content].reverse()) {
|
|
873
|
+
if (out.length >= count)
|
|
874
|
+
break;
|
|
875
|
+
if (b?.type !== 'tool_result')
|
|
876
|
+
continue;
|
|
877
|
+
const text = toolResultText(b);
|
|
878
|
+
if (!text)
|
|
879
|
+
continue;
|
|
880
|
+
const name = nameById.get(String(b.tool_use_id ?? '')) ?? 'tool';
|
|
881
|
+
const body = text.length > maxChars
|
|
882
|
+
? `${sliceSafeEnd(text, maxChars)}\n… [truncated]`
|
|
883
|
+
: text;
|
|
884
|
+
out.push(`• ${name}:\n${body}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return out.reverse().join('\n\n');
|
|
888
|
+
}
|
|
889
|
+
/** Extract readable text from a tool_result block, whose content may be string or blocks. */
|
|
890
|
+
function toolResultText(block) {
|
|
891
|
+
const c = block.content;
|
|
892
|
+
if (typeof c === 'string')
|
|
893
|
+
return c.trim();
|
|
894
|
+
if (Array.isArray(c)) {
|
|
895
|
+
return c
|
|
896
|
+
.filter((x) => x?.type === 'text' && typeof x.text === 'string')
|
|
897
|
+
.map((x) => x.text)
|
|
898
|
+
.join('\n')
|
|
899
|
+
.trim();
|
|
900
|
+
}
|
|
901
|
+
return '';
|
|
661
902
|
}
|
|
662
903
|
async function runSubTask(input, options, agentTypes) {
|
|
663
904
|
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
@@ -755,10 +996,14 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
755
996
|
const agentMemoryNotes = agent && memoryScope
|
|
756
997
|
? (0, memory_1.agentMemoryPreamble)(agent.name, memoryScope, options.workDir)
|
|
757
998
|
: '';
|
|
999
|
+
// `lightPrompt` agents skip the project's nexrall.md — see AgentType.lightPrompt for
|
|
1000
|
+
// why. The role prompt and any private notes still apply; only the (potentially very
|
|
1001
|
+
// large) project instruction file is dropped.
|
|
1002
|
+
const inheritedMd = agent?.lightPrompt ? '' : (options.nexrallMd ?? '');
|
|
758
1003
|
const subNexrallMd = agent
|
|
759
1004
|
? `# Sub-agent role: ${agent.name}\n${agent.prompt}` +
|
|
760
1005
|
(agentMemoryNotes ? `\n\n---\n\n${agentMemoryNotes}` : '') +
|
|
761
|
-
(
|
|
1006
|
+
(inheritedMd ? `\n\n---\n\n${inheritedMd}` : '')
|
|
762
1007
|
: options.nexrallMd;
|
|
763
1008
|
// Optional tool allowlist — deny anything outside it for this sub-agent.
|
|
764
1009
|
//
|
|
@@ -809,7 +1054,34 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
809
1054
|
// runAgentLoop already checks abortSignal.aborted at every iteration boundary,
|
|
810
1055
|
// so this is enough to make it stop promptly without a forceful kill.
|
|
811
1056
|
const subAbort = { aborted: false };
|
|
812
|
-
const
|
|
1057
|
+
const subtaskTimeoutMs = resolveSubtaskTimeoutMs((0, rules_1.loadSettings)(options.workDir).raw);
|
|
1058
|
+
// ── Stall watchdog, NOT a total-runtime deadline ─────────────────────────────
|
|
1059
|
+
//
|
|
1060
|
+
// This used to be a single setTimeout armed once and never refreshed. Its own
|
|
1061
|
+
// comment called it a "stalled-agent cutoff", but it measured TOTAL LIFETIME: a
|
|
1062
|
+
// sub-agent working hard and calling a tool every few seconds was killed at ten
|
|
1063
|
+
// minutes exactly like one that had hung. That is not a hypothetical — auditing a
|
|
1064
|
+
// handful of 600-2800 line files legitimately exceeds it, and when it fired the
|
|
1065
|
+
// parent got back a fragment ("I'll start by reading the files…") after paying for
|
|
1066
|
+
// 23 tool calls, then typically re-ran the whole thing.
|
|
1067
|
+
//
|
|
1068
|
+
// The main loop already draws this distinction correctly (client.ts's heartbeat vs
|
|
1069
|
+
// progress watchdogs, where only real events bump lastProgressAt). Sub-agents now do
|
|
1070
|
+
// too: the clock resets on every completed tool round, so the cap means "no progress
|
|
1071
|
+
// for N minutes" — which is what catches a genuine hang — while useful work can run
|
|
1072
|
+
// as long as it keeps being useful.
|
|
1073
|
+
//
|
|
1074
|
+
// `stalled` is tracked separately from `subAbort.aborted` because the parent's Ctrl+C
|
|
1075
|
+
// also sets the latter, and the two must be reported differently.
|
|
1076
|
+
let lastProgressAt = Date.now();
|
|
1077
|
+
let stalled = false;
|
|
1078
|
+
const bumpProgress = () => { lastProgressAt = Date.now(); };
|
|
1079
|
+
const stallWatchdog = setInterval(() => {
|
|
1080
|
+
if (Date.now() - lastProgressAt > subtaskTimeoutMs) {
|
|
1081
|
+
stalled = true;
|
|
1082
|
+
subAbort.aborted = true;
|
|
1083
|
+
}
|
|
1084
|
+
}, 1000);
|
|
813
1085
|
// Mirror the parent's own abort into the sub-agent's signal so Ctrl+C during
|
|
814
1086
|
// a running sub-task still stops it (previously this worked implicitly by
|
|
815
1087
|
// sharing the same object via `...options` — now that we own a distinct
|
|
@@ -823,11 +1095,27 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
823
1095
|
...options,
|
|
824
1096
|
_depth: depth + 1,
|
|
825
1097
|
_agentScope: `sub_${++_subTaskCounter}`, // isolated todo store per sub-agent
|
|
826
|
-
//
|
|
827
|
-
|
|
1098
|
+
// ── Assigned UNCONDITIONALLY, never by conditional spread ────────────────
|
|
1099
|
+
//
|
|
1100
|
+
// These two were previously spread in only when set:
|
|
1101
|
+
//
|
|
1102
|
+
// ...(agent && memoryScope ? { _agentMemory: … } : {}),
|
|
1103
|
+
//
|
|
1104
|
+
// which does NOT clear the key — it leaves whatever `...options` already had.
|
|
1105
|
+
// So a child WITHOUT its own `memory:` inherited its PARENT's binding and would
|
|
1106
|
+
// have appended to another agent's private notes; likewise an agent with no
|
|
1107
|
+
// `tools:` line inherited the parent's allowlist, making the prompt's capability
|
|
1108
|
+
// claim disagree with its real one.
|
|
1109
|
+
//
|
|
1110
|
+
// MAX_TASK_DEPTH === 1 means no nested spawn can reach this today, so it is
|
|
1111
|
+
// latent rather than live — but the limiter comment below explicitly contemplates
|
|
1112
|
+
// raising that depth, and this is exactly the kind of leak that would come back
|
|
1113
|
+
// as a security bug rather than a visible error. Explicit undefined makes the
|
|
1114
|
+
// child's identity independent of the parent's by construction.
|
|
1115
|
+
_agentMemory: agent && memoryScope ? { agentName: agent.name, scope: memoryScope } : undefined,
|
|
828
1116
|
// The same set `gatedPermission` enforces above, so prompt and permission agree
|
|
829
1117
|
// by construction instead of by two people remembering to update both.
|
|
830
|
-
|
|
1118
|
+
_allowedTools: allowed ?? undefined,
|
|
831
1119
|
editorContext: null, // fresh isolated context for sub-agent
|
|
832
1120
|
model: agent?.model ?? options.model,
|
|
833
1121
|
// Plan mode is inherited, never relaxed. If the main agent could spawn a
|
|
@@ -856,13 +1144,20 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
856
1144
|
// Forward tool events with isSubTask=true so the UI can render a badge
|
|
857
1145
|
// instead of prepending "[sub-task]" to the tool name (which caused double-prefix
|
|
858
1146
|
// when the name was already labelled, and mixed display concerns into the data layer).
|
|
859
|
-
|
|
860
|
-
|
|
1147
|
+
// Every tool event is PROGRESS: it proves the sub-agent is still doing work, which
|
|
1148
|
+
// is what the stall watchdog above measures. Bumping on both use and result means a
|
|
1149
|
+
// single very slow tool (a long test run) resets the clock when it starts AND when
|
|
1150
|
+
// it finishes, so it cannot be mistaken for a hang.
|
|
1151
|
+
onToolUse: (n, i) => { bumpProgress(); options.onToolUse(n, i, true); },
|
|
1152
|
+
onToolResult: (n, r) => { bumpProgress(); options.onToolResult(n, r, true); },
|
|
861
1153
|
onToolStreamChunk: (n, c) => options.onToolStreamChunk?.(n, c, true),
|
|
862
1154
|
// Forward thinking so the UI shows the indicator while sub-agent reasons
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1155
|
+
// Thinking is progress too — a model reasoning for minutes on a hard problem is
|
|
1156
|
+
// working, not stalled. Without this, deep reasoning on an expensive tier would
|
|
1157
|
+
// trip the watchdog precisely when the sub-agent was most valuable.
|
|
1158
|
+
onThinking: (text) => { bumpProgress(); options.onThinking?.(text); },
|
|
1159
|
+
onThinkingDelta: (text) => { bumpProgress(); options.onThinkingDelta?.(text); },
|
|
1160
|
+
onThinkingProgress: (tok) => { bumpProgress(); options.onThinkingProgress?.(tok); },
|
|
866
1161
|
});
|
|
867
1162
|
// ── Stall timeout: SALVAGE, don't discard ────────────────────────────────
|
|
868
1163
|
//
|
|
@@ -877,15 +1172,29 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
877
1172
|
// whatever was actually accomplished, not instead of it. `preferLast: false`
|
|
878
1173
|
// because a killed sub-agent rarely has a closing summary — its useful output
|
|
879
1174
|
// is spread across the assistant turns it did manage to produce.
|
|
880
|
-
if (
|
|
881
|
-
const mins = Math.round(
|
|
1175
|
+
if (stalled && !options.abortSignal?.aborted) {
|
|
1176
|
+
const mins = Math.round(subtaskTimeoutMs / 60000);
|
|
882
1177
|
const partial = capSubTaskText(extractSubTaskText(result, false));
|
|
883
1178
|
const progress = summariseSubTaskProgress(result);
|
|
1179
|
+
// Keep the transcript so the parent can CONTINUE this run instead of redoing it.
|
|
1180
|
+
//
|
|
1181
|
+
// Previously only a cleanly-finished sub-agent was remembered, on the reasoning
|
|
1182
|
+
// that a transcript ending mid-thought is unsafe to build on. The reasoning is
|
|
1183
|
+
// sound; the conclusion was too strong. Refusing to store it meant a stalled
|
|
1184
|
+
// sub-agent's entire body of work — dozens of tool calls, already billed — was
|
|
1185
|
+
// unreachable, so the parent's only option was the very thing we tell it not to
|
|
1186
|
+
// do: run the whole task again. Resuming is now POSSIBLE but never implied to be
|
|
1187
|
+
// safe: the text below states plainly that the work is unverified, and resumption
|
|
1188
|
+
// re-authorises against current permissions exactly as it does for a clean run.
|
|
1189
|
+
const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result);
|
|
884
1190
|
const sections = [
|
|
885
|
-
`Sub-task STOPPED after ${mins} minutes
|
|
1191
|
+
`Sub-task STOPPED after ${mins} minutes with NO PROGRESS (it was not making tool calls or ` +
|
|
1192
|
+
'producing output) — treat everything below as PARTIAL, unverified work, not a finished answer.',
|
|
886
1193
|
progress,
|
|
887
1194
|
partial ? `Partial output before it was stopped:\n\n${partial}` : '',
|
|
888
|
-
|
|
1195
|
+
`Do NOT re-run the same sub-task from scratch. Either build on what is above, or continue THIS ` +
|
|
1196
|
+
`run with resume_agent_id="${partialId}" (it still has everything it read), or split the ` +
|
|
1197
|
+
'remaining work into smaller, more focused sub-tasks.',
|
|
889
1198
|
].filter(Boolean);
|
|
890
1199
|
// Returned as `error` (not `output`) on purpose: the loop's ledger counts an
|
|
891
1200
|
// errored call as a non-effect, which is right — nothing here is verified —
|
|
@@ -934,7 +1243,7 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
934
1243
|
return { error: `Sub-task failed: ${err.message}` };
|
|
935
1244
|
}
|
|
936
1245
|
finally {
|
|
937
|
-
|
|
1246
|
+
clearInterval(stallWatchdog);
|
|
938
1247
|
clearInterval(parentAbortPoll);
|
|
939
1248
|
}
|
|
940
1249
|
}
|
|
@@ -2265,9 +2574,30 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
2265
2574
|
// waiting on a child that can never be scheduled, the run would wedge
|
|
2266
2575
|
// permanently. Keeping the limiter acyclic (a holder never re-enters it)
|
|
2267
2576
|
// means raising MAX_TASK_DEPTH later can't silently reintroduce that.
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2577
|
+
if (depth === 0) {
|
|
2578
|
+
const { run: limitRun, max: limitMax } = subTaskLimiter(options.workDir);
|
|
2579
|
+
// Tell the user when a sub-task is WAITING rather than working.
|
|
2580
|
+
//
|
|
2581
|
+
// With a burst of 8 and a ceiling of 4, the last four sat silently in the
|
|
2582
|
+
// queue. From the outside they looked started, so a long wait read as a
|
|
2583
|
+
// hang — and the fix for a hang (Ctrl+C and retry) is exactly wrong here,
|
|
2584
|
+
// since the work was about to run. `_inFlightSubTasks` is only ever
|
|
2585
|
+
// touched from this single dispatch point, so the count is exact.
|
|
2586
|
+
if (_inFlightSubTasks >= limitMax) {
|
|
2587
|
+
(options.onNotice ?? options.onText)(`\u23f3 Queued: ${limitMax} sub-agents are already running, so this one starts when a ` +
|
|
2588
|
+
`slot frees up (raise "maxConcurrentSubtasks" in .nexrall/settings.json to widen it).`);
|
|
2589
|
+
}
|
|
2590
|
+
_inFlightSubTasks++;
|
|
2591
|
+
try {
|
|
2592
|
+
result = await limitRun(() => runSubTask(input, options, agentTypes));
|
|
2593
|
+
}
|
|
2594
|
+
finally {
|
|
2595
|
+
_inFlightSubTasks--;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
else {
|
|
2599
|
+
result = await runSubTask(input, options, agentTypes);
|
|
2600
|
+
}
|
|
2271
2601
|
}
|
|
2272
2602
|
else {
|
|
2273
2603
|
const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);
|
|
@@ -2301,22 +2631,28 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
2301
2631
|
else {
|
|
2302
2632
|
// Snapshot pre-mutation state so the user can /rewind this turn.
|
|
2303
2633
|
options.checkpointManager?.recordBeforeMutation(name, input);
|
|
2304
|
-
// Serialise writes to the same file: if the model emits two
|
|
2305
|
-
// edit_file/write_file calls for the same path in one turn
|
|
2306
|
-
// (executed via Promise.all), the second would overwrite the
|
|
2307
|
-
// first. withFileLock queues them so they apply sequentially.
|
|
2308
|
-
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
|
|
2309
2634
|
const onStream = options.onToolStreamChunk
|
|
2310
2635
|
? (chunk) => options.onToolStreamChunk(name, chunk)
|
|
2311
2636
|
: undefined;
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2637
|
+
const run = () => (0, executor_1.executeTool)(name, input, options.abortSignal, sandboxCfg, options.workDir, agentScope, onStream);
|
|
2638
|
+
// Serialise anything that would otherwise race. Two sources of races:
|
|
2639
|
+
//
|
|
2640
|
+
// • Writes to the SAME PATH — the model emits multiple tool_use blocks
|
|
2641
|
+
// per turn and they run via Promise.all, so two edit_file calls on
|
|
2642
|
+
// one file would both read the original and the second would discard
|
|
2643
|
+
// the first. Now ALL paths a call touches are locked (move_file and
|
|
2644
|
+
// copy_file have two), in a globally consistent order.
|
|
2645
|
+
// • Repo-wide state via bash — `git commit`, `npm install`, `npm test`.
|
|
2646
|
+
// These declare no path, so they take one shared lock. Read-only
|
|
2647
|
+
// commands (grep, ls, cat) are excluded and stay parallel, which is
|
|
2648
|
+
// what an explorer agent spends nearly all its calls on.
|
|
2649
|
+
//
|
|
2650
|
+
// This matters far more with sub-agents than without: up to 4 run
|
|
2651
|
+
// concurrently, each issuing its own tool calls into this same process.
|
|
2652
|
+
const locks = name === 'bash'
|
|
2653
|
+
? (bashNeedsRepoLock(String(input.command ?? '')) ? [REPO_STATE_LOCK] : [])
|
|
2654
|
+
: lockPathsFor(name, input, options.workDir);
|
|
2655
|
+
result = locks.length ? await withFileLocks(locks, run) : await run();
|
|
2320
2656
|
}
|
|
2321
2657
|
}
|
|
2322
2658
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexrall/code-core",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.31",
|
|
4
4
|
"description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nexrall <support@nexrall.com> (https://nexrall.com)",
|