@nexrall/code-core 1.4.33 → 1.4.34
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/agentRegistry.d.ts +27 -2
- package/dist/agent/agentRegistry.d.ts.map +1 -1
- package/dist/agent/agentRegistry.js +24 -3
- package/dist/agent/agentTypes.d.ts.map +1 -1
- package/dist/agent/agentTypes.js +22 -9
- package/dist/agent/loop.d.ts +69 -5
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +461 -131
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +10 -9
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util/safeSlice.d.ts +20 -0
- package/dist/util/safeSlice.d.ts.map +1 -0
- package/dist/util/safeSlice.js +40 -0
- package/package.json +8 -7
package/dist/agent/loop.js
CHANGED
|
@@ -33,15 +33,21 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = exports.
|
|
36
|
+
exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = exports.AGENT_MEMORY_TOOL_SCHEMA = exports.AGENT_MEMORY_TOOL = exports._stallLimits = exports.bashNeedsRepoLock = void 0;
|
|
37
37
|
exports.errorRoundSignature = errorRoundSignature;
|
|
38
38
|
exports.executeAgentMemoryWrite = executeAgentMemoryWrite;
|
|
39
39
|
exports.stopReasonNotice = stopReasonNotice;
|
|
40
40
|
exports.resolveMaxIterations = resolveMaxIterations;
|
|
41
41
|
exports.lockPathsFor = lockPathsFor;
|
|
42
42
|
exports.resolveMaxConcurrentSubtasks = resolveMaxConcurrentSubtasks;
|
|
43
|
+
exports.resolveMaxSubagentsPerSession = resolveMaxSubagentsPerSession;
|
|
43
44
|
exports.createLimiter = createLimiter;
|
|
44
45
|
exports._resetSubTaskLimiter = _resetSubTaskLimiter;
|
|
46
|
+
exports.resetSessionSubAgentBudget = resetSessionSubAgentBudget;
|
|
47
|
+
exports._sessionSubAgentCount = _sessionSubAgentCount;
|
|
48
|
+
exports.resolveMaxSubagentDepth = resolveMaxSubagentDepth;
|
|
49
|
+
exports.noSpawnReason = noSpawnReason;
|
|
50
|
+
exports.intersectAllowlists = intersectAllowlists;
|
|
45
51
|
exports.canSpawnSubAgents = canSpawnSubAgents;
|
|
46
52
|
exports.resolveSubtaskTimeoutMs = resolveSubtaskTimeoutMs;
|
|
47
53
|
exports.extractSubTaskText = extractSubTaskText;
|
|
@@ -81,6 +87,7 @@ const testIntegrity_1 = require("./testIntegrity");
|
|
|
81
87
|
const flaky_1 = require("./flaky");
|
|
82
88
|
const claimEvidence_1 = require("./claimEvidence");
|
|
83
89
|
const memory_1 = require("./memory");
|
|
90
|
+
const safeSlice_1 = require("../util/safeSlice");
|
|
84
91
|
const fs = __importStar(require("fs"));
|
|
85
92
|
const path = __importStar(require("path"));
|
|
86
93
|
const child_process_1 = require("child_process");
|
|
@@ -478,7 +485,11 @@ const DEFAULT_MAX_CONCURRENT_SUBTASKS = 4;
|
|
|
478
485
|
* degrade to "a lot" rather than fork-bomb the machine.
|
|
479
486
|
*/
|
|
480
487
|
function resolveMaxConcurrentSubtasks(settingsRaw = {}) {
|
|
481
|
-
|
|
488
|
+
// `Math.max(1, …)` matters: a fractional value like 0.5 passes the `> 0` guard, then floors
|
|
489
|
+
// to 0, and createLimiter(0) queues every task with nothing left to ever release them — a
|
|
490
|
+
// silent permanent hang with no timeout and no error. Harmless when only depth 0 used the
|
|
491
|
+
// limiter; now that every level does, it would wedge the whole tree.
|
|
492
|
+
const clamp = (n) => Math.max(1, Math.min(Math.floor(n), 16));
|
|
482
493
|
const fromEnv = Number(process.env.NEXRALL_MAX_CONCURRENT_SUBTASKS);
|
|
483
494
|
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
484
495
|
return clamp(fromEnv);
|
|
@@ -487,6 +498,40 @@ function resolveMaxConcurrentSubtasks(settingsRaw = {}) {
|
|
|
487
498
|
return clamp(fromSettings);
|
|
488
499
|
return DEFAULT_MAX_CONCURRENT_SUBTASKS;
|
|
489
500
|
}
|
|
501
|
+
// ── Session TOTAL, distinct from the per-moment concurrency gate ─────────────
|
|
502
|
+
//
|
|
503
|
+
// maxConcurrentSubtasks bounds how many run AT ONCE; it does not bound how many run
|
|
504
|
+
// IN TOTAL. With a queue rather than a rejection, "4 at a time" and "unbounded" are the
|
|
505
|
+
// same thing given enough turns — the limiter just meters the spend, it never stops it.
|
|
506
|
+
// A model in a retry loop could spawn sub-agents indefinitely and the only signal would
|
|
507
|
+
// be the bill.
|
|
508
|
+
//
|
|
509
|
+
// This gap did not matter much while sub-agents were leaves: only the main agent could
|
|
510
|
+
// spawn, so the count grew linearly with its own turns. With nesting it grows like a
|
|
511
|
+
// tree, which is exactly why Anthropic added a per-session subagent ceiling alongside
|
|
512
|
+
// their concurrency cap rather than relying on concurrency alone.
|
|
513
|
+
//
|
|
514
|
+
// 100 is chosen to be invisible in real work (a heavy orchestration session uses a few
|
|
515
|
+
// dozen) and decisive in a runaway. Unlike the concurrency gate this REJECTS rather than
|
|
516
|
+
// queues: a queue that never drains is a hang, and the point here is to stop.
|
|
517
|
+
const DEFAULT_MAX_SUBAGENTS_PER_SESSION = 100;
|
|
518
|
+
/** Resolve the session-total sub-agent ceiling: env → settings.json → default. */
|
|
519
|
+
function resolveMaxSubagentsPerSession(settingsRaw = {}) {
|
|
520
|
+
// Clamped, unlike the first draft of this function. The argument for leaving it unbounded
|
|
521
|
+
// was that it only moves a counter — but `.nexrall/settings.json` is REPO-CONTROLLED and
|
|
522
|
+
// merged last, so a cloned repo could set 999999 and neutralise the one ceiling that makes
|
|
523
|
+
// a default depth > 1 defensible, turning `depth 5 x 16 wide` into an unbounded spend on a
|
|
524
|
+
// machine whose owner only opened a project. Depth and concurrency were already clamped for
|
|
525
|
+
// exactly this reason; this was the gap between them.
|
|
526
|
+
const floor = (n) => Math.max(1, Math.min(Math.floor(n), 10000));
|
|
527
|
+
const fromEnv = Number(process.env.NEXRALL_MAX_SUBAGENTS_PER_SESSION);
|
|
528
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
529
|
+
return floor(fromEnv);
|
|
530
|
+
const fromSettings = Number(settingsRaw.maxSubagentsPerSession);
|
|
531
|
+
if (Number.isFinite(fromSettings) && fromSettings > 0)
|
|
532
|
+
return floor(fromSettings);
|
|
533
|
+
return DEFAULT_MAX_SUBAGENTS_PER_SESSION;
|
|
534
|
+
}
|
|
490
535
|
/**
|
|
491
536
|
* Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
|
|
492
537
|
* the CLI ships as a single esbuild bundle with no node_modules, and this is a
|
|
@@ -520,21 +565,134 @@ function createLimiter(max) {
|
|
|
520
565
|
// Once created it is reused for the process lifetime — rebuilding it per turn would
|
|
521
566
|
// reset `active` and let the ceiling be exceeded, which is worse than not honouring a
|
|
522
567
|
// mid-session settings change.
|
|
523
|
-
|
|
568
|
+
// ONE LIMITER PER DEPTH, which is what makes nesting safe to gate at all.
|
|
569
|
+
//
|
|
570
|
+
// The old design gated only `depth === 0` and left nested spawns ungated — deliberately,
|
|
571
|
+
// because a single shared limiter deadlocks the moment a slot-holder re-enters it: a
|
|
572
|
+
// parent holding one of N slots waits for a child that can only start when a slot frees,
|
|
573
|
+
// and if all N are held by such parents the run wedges forever. While sub-agents were
|
|
574
|
+
// leaves that could not happen, so "gate the top, leave the rest" cost nothing.
|
|
575
|
+
//
|
|
576
|
+
// With nesting it costs everything: nested fan-out becomes completely unbounded, which is
|
|
577
|
+
// worse than the deadlock it was avoiding.
|
|
578
|
+
//
|
|
579
|
+
// Keying the limiter by depth fixes both at once. A depth-D run only ever waits on the
|
|
580
|
+
// depth-(D+1) limiter, never its own, so the wait-for graph is strictly ordered by depth —
|
|
581
|
+
// a DAG, and a DAG cannot deadlock. Every level is independently bounded, so worst-case
|
|
582
|
+
// concurrency is bounded per level rather than unbounded below level 1.
|
|
583
|
+
const _subTaskLimiters = new Map();
|
|
584
|
+
/** The tapered ceiling actually applied at each depth, so the queue notice can report it. */
|
|
585
|
+
const _subTaskLimitMaxByDepth = new Map();
|
|
524
586
|
let _subTaskLimitMax = 0;
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
function subTaskLimiter(workDir) {
|
|
528
|
-
if (!
|
|
587
|
+
/** In-flight count per depth — used only to detect queueing, so the notice is accurate. */
|
|
588
|
+
const _inFlightByDepth = new Map();
|
|
589
|
+
function subTaskLimiter(depth, workDir) {
|
|
590
|
+
if (!_subTaskLimitMax) {
|
|
529
591
|
_subTaskLimitMax = resolveMaxConcurrentSubtasks(workDir ? (0, rules_1.loadSettings)(workDir).raw : {});
|
|
530
|
-
_subTaskLimitInstance = createLimiter(_subTaskLimitMax);
|
|
531
592
|
}
|
|
532
|
-
|
|
593
|
+
let run = _subTaskLimiters.get(depth);
|
|
594
|
+
let max = _subTaskLimitMaxByDepth.get(depth) ?? 0;
|
|
595
|
+
if (!run) {
|
|
596
|
+
// TAPERED per level, not `max` at every level.
|
|
597
|
+
//
|
|
598
|
+
// Giving each depth the full `max` multiplies total concurrency by the depth limit: 4
|
|
599
|
+
// becomes 12 by default and 16×5 = 80 at the configured maxima. The value is justified by
|
|
600
|
+
// shared resources — CPU, file handles, ONE API rate limit — none of which care which
|
|
601
|
+
// level a loop is running at, so honouring `4` per level silently abandons the limit the
|
|
602
|
+
// user set. Halving per level bounds the total at ~2× `max` (4+2+1 = 7) while keeping the
|
|
603
|
+
// per-depth structure that makes the wait-for graph acyclic.
|
|
604
|
+
//
|
|
605
|
+
// Never below 1: a level with 0 slots is a permanent hang, not a restriction.
|
|
606
|
+
max = Math.max(1, Math.ceil(_subTaskLimitMax / 2 ** Math.max(0, depth - 1)));
|
|
607
|
+
run = createLimiter(max);
|
|
608
|
+
_subTaskLimiters.set(depth, run);
|
|
609
|
+
_subTaskLimitMaxByDepth.set(depth, max);
|
|
610
|
+
}
|
|
611
|
+
return { run, max };
|
|
533
612
|
}
|
|
534
613
|
/** Test-only: forget the memoised limiter so a new limit can take effect. */
|
|
535
614
|
function _resetSubTaskLimiter() {
|
|
536
|
-
|
|
615
|
+
_subTaskLimiters.clear();
|
|
616
|
+
_subTaskLimitMaxByDepth.clear();
|
|
617
|
+
_inFlightByDepth.clear();
|
|
537
618
|
_subTaskLimitMax = 0;
|
|
619
|
+
_subAgentsThisSession = 0;
|
|
620
|
+
_sessionCapMax = 0;
|
|
621
|
+
_sessionCapNotified = false;
|
|
622
|
+
}
|
|
623
|
+
// Counts sub-agents STARTED, never decremented — that is what makes it a budget rather
|
|
624
|
+
// than a concurrency gate.
|
|
625
|
+
//
|
|
626
|
+
// NOT process-wide, unlike the limiter, and the distinction is load-bearing. A concurrency
|
|
627
|
+
// gate is safe to share across a process because it self-drains; a monotonic counter is
|
|
628
|
+
// not. In the CLI one process is one session, but VS Code calls runAgentLoop from a
|
|
629
|
+
// long-lived extension host, so process-scoped state would accumulate across every
|
|
630
|
+
// conversation in the window until delegation died permanently — and the remedy string
|
|
631
|
+
// ("start a new session") would be a lie, since only reloading the window would help.
|
|
632
|
+
let _subAgentsThisSession = 0;
|
|
633
|
+
let _sessionCapMax = 0;
|
|
634
|
+
let _sessionCapNotified = false;
|
|
635
|
+
/**
|
|
636
|
+
* Start a fresh sub-agent budget. Call when a NEW conversation begins.
|
|
637
|
+
*
|
|
638
|
+
* Exported for clients that reuse one process across conversations (the VS Code extension
|
|
639
|
+
* host). A client that never calls it gets process-lifetime semantics, which is correct
|
|
640
|
+
* for a one-shot CLI invocation.
|
|
641
|
+
*/
|
|
642
|
+
function resetSessionSubAgentBudget() {
|
|
643
|
+
_subAgentsThisSession = 0;
|
|
644
|
+
_sessionCapMax = 0;
|
|
645
|
+
_sessionCapNotified = false;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Claim one slot against the session total. Returns an error string when exhausted.
|
|
649
|
+
*
|
|
650
|
+
* `notify` surfaces exhaustion to the HUMAN exactly once. Without it only the model is
|
|
651
|
+
* told, and a model instructed to "do the remaining work directly" complies silently — so
|
|
652
|
+
* the user never learns delegation was capped, which is the one thing a runaway guard has
|
|
653
|
+
* to make visible.
|
|
654
|
+
*/
|
|
655
|
+
function claimSessionSubAgentSlot(workDir, notify) {
|
|
656
|
+
if (!_sessionCapMax) {
|
|
657
|
+
_sessionCapMax = resolveMaxSubagentsPerSession(workDir ? (0, rules_1.loadSettings)(workDir).raw : {});
|
|
658
|
+
}
|
|
659
|
+
if (_subAgentsThisSession >= _sessionCapMax) {
|
|
660
|
+
if (!_sessionCapNotified) {
|
|
661
|
+
_sessionCapNotified = true;
|
|
662
|
+
notify?.(`\u26a0\ufe0f Sub-agent budget reached (${_sessionCapMax} this session) \u2014 further delegation is ` +
|
|
663
|
+
'blocked and the agent will continue without it. Raise "maxSubagentsPerSession" in ' +
|
|
664
|
+
'.nexrall/settings.json if this was legitimate work.');
|
|
665
|
+
}
|
|
666
|
+
return (`Sub-agent budget for this session is exhausted (${_sessionCapMax} started). This is a ` +
|
|
667
|
+
'runaway-delegation guard, not a per-task limit: do the remaining work directly, and say ' +
|
|
668
|
+
'in your final message that you hit the delegation cap.');
|
|
669
|
+
}
|
|
670
|
+
_subAgentsThisSession++;
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Give back a slot claimed for a spawn that never started an agent.
|
|
675
|
+
*
|
|
676
|
+
* The claim happens at the dispatch site, BEFORE the concurrency limiter, so that a spawn
|
|
677
|
+
* which is already over budget is refused immediately instead of queueing behind running
|
|
678
|
+
* siblings. That ordering is what makes the guard useful in a runaway — but it also means
|
|
679
|
+
* the claim precedes runSubTask's own validation, which rejects on several paths without
|
|
680
|
+
* ever starting a loop (empty prompt, a `deny` rule, an unusable resume id).
|
|
681
|
+
*
|
|
682
|
+
* Without a refund those rejections spend budget: a model retrying against a deny rule would
|
|
683
|
+
* silently burn the whole allowance on spawns that never ran, then lose delegation for the
|
|
684
|
+
* session with a notice blaming a runaway that never happened. The counter's contract is
|
|
685
|
+
* "sub-agents STARTED", and this is what keeps that true while preserving fast refusal.
|
|
686
|
+
*
|
|
687
|
+
* Floored at 0 so a double refund can never manufacture budget.
|
|
688
|
+
*/
|
|
689
|
+
function refundSessionSubAgentSlot() {
|
|
690
|
+
if (_subAgentsThisSession > 0)
|
|
691
|
+
_subAgentsThisSession--;
|
|
692
|
+
}
|
|
693
|
+
/** Test-only: observe the session counter without exporting the mutable binding. */
|
|
694
|
+
function _sessionSubAgentCount() {
|
|
695
|
+
return _subAgentsThisSession;
|
|
538
696
|
}
|
|
539
697
|
// ─── Human-readable tool descriptions ────────────────────────────────────────
|
|
540
698
|
function humanDescription(name, input) {
|
|
@@ -621,25 +779,81 @@ function humanDescription(name, input) {
|
|
|
621
779
|
}
|
|
622
780
|
}
|
|
623
781
|
// ─── Sub-task runner ──────────────────────────────────────────────────────────
|
|
624
|
-
//
|
|
625
|
-
// depth 0 and the sub-agents it spawns at depth 1.
|
|
782
|
+
// Nesting depth. The main agent runs at depth 0, the sub-agents it spawns at depth 1.
|
|
626
783
|
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
// sub-agents
|
|
631
|
-
// the
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
784
|
+
// A run at `depth` may spawn when `depth < limit`, so a limit of N means N generations
|
|
785
|
+
// below the main agent. This used to be a hard-coded 1 with the historical note "This is
|
|
786
|
+
// 1, not 2" — that note was about making the CONSTANT agree with a system prompt that
|
|
787
|
+
// promised "sub-agents cannot spawn further sub-agents". Both sides of that agreement have
|
|
788
|
+
// now moved: nesting is a supported, configured capability, and the prompt is generated
|
|
789
|
+
// from the same predicate that enforces it (canSpawnSubAgents), so the two cannot drift
|
|
790
|
+
// again regardless of the value.
|
|
791
|
+
//
|
|
792
|
+
// What made the old value load-bearing was that fan-out had no TOTAL bound — only a
|
|
793
|
+
// per-moment concurrency gate. Depth × fan-out is multiplicative, so raising depth without
|
|
794
|
+
// a session ceiling converts a bounded queue into an unbounded tree. That ceiling
|
|
795
|
+
// (resolveMaxSubagentsPerSession) is what makes a depth > 1 safe to default to.
|
|
796
|
+
const DEFAULT_MAX_TASK_DEPTH = 3;
|
|
797
|
+
/**
|
|
798
|
+
* Hard ceiling on `maxSubagentDepth`, independent of what anyone configures.
|
|
799
|
+
*
|
|
800
|
+
* 5 matches the deepest tier Anthropic shipped for Claude Code. It exists because depth
|
|
801
|
+
* is MULTIPLICATIVE with fan-out: at 4-wide, depth 5 is 4^5 = 1024 possible loops. The
|
|
802
|
+
* per-depth limiter and the session ceiling below are what actually bound that, but a
|
|
803
|
+
* typo'd `maxSubagentDepth: 50` should degrade to "deep" rather than to a fork bomb —
|
|
804
|
+
* same reasoning as the clamp on maxConcurrentSubtasks.
|
|
805
|
+
*/
|
|
806
|
+
const HARD_MAX_TASK_DEPTH = 5;
|
|
807
|
+
/**
|
|
808
|
+
* Deepest nesting level allowed to spawn: env → settings.json → default 3.
|
|
809
|
+
*
|
|
810
|
+
* Was a hard-coded 1, i.e. "sub-agents are leaves". That was the right default while the
|
|
811
|
+
* three capability decisions disagreed with each other (see canSpawnSubAgents), but it is
|
|
812
|
+
* no longer where the ecosystem is: Claude Code lifted the no-nesting rule and, after a
|
|
813
|
+
* brief period with it disabled entirely, settled on a configurable default of 3.
|
|
814
|
+
*
|
|
815
|
+
* 3, not 5, deliberately. Depth is a budget you SPEND, not headroom you fill: every level
|
|
816
|
+
* is a context window that receives only a dispatch prompt on the way down and returns
|
|
817
|
+
* only a summary on the way up, so the deeper frames pay full freight to carry less
|
|
818
|
+
* information. 3 covers orchestrator → worker → helper, which is where the observed value
|
|
819
|
+
* is; beyond that latency and token cost tend to exceed the benefit.
|
|
820
|
+
*
|
|
821
|
+
* Depth 1 remains available (`maxSubagentDepth: 1`) for anyone who wants leaves-only.
|
|
822
|
+
*/
|
|
823
|
+
function resolveMaxSubagentDepth(settingsRaw = {}) {
|
|
824
|
+
const clamp = (n) => Math.max(1, Math.min(Math.floor(n), HARD_MAX_TASK_DEPTH));
|
|
825
|
+
const fromEnv = Number(process.env.NEXRALL_MAX_SUBAGENT_DEPTH);
|
|
826
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
827
|
+
return clamp(fromEnv);
|
|
828
|
+
const fromSettings = Number(settingsRaw.maxSubagentDepth);
|
|
829
|
+
if (Number.isFinite(fromSettings) && fromSettings > 0)
|
|
830
|
+
return clamp(fromSettings);
|
|
831
|
+
return DEFAULT_MAX_TASK_DEPTH;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The ONE explanation for "this run may not spawn a sub-agent", shared by every place
|
|
835
|
+
* that can refuse it, so the same impossibility never gets two different stories.
|
|
836
|
+
*
|
|
837
|
+
* Parameterised on the REASON because the reasons are no longer interchangeable. It used
|
|
838
|
+
* to be a flat constant reading "Sub-agents cannot spawn further sub-agents — this is a
|
|
839
|
+
* structural limit"; with nesting configurable that sentence is now false for most runs,
|
|
840
|
+
* and telling a depth-1 agent its limit is structural when the user could raise it by one
|
|
841
|
+
* line of settings is the same class of misdirection as the "needs a different agent" text
|
|
842
|
+
* this replaced. A refusal has to be accurate about whether it can be lifted, or the model
|
|
843
|
+
* either gives up when it shouldn't or hunts for an escape that doesn't exist.
|
|
844
|
+
*/
|
|
845
|
+
function noSpawnReason(kind, limit) {
|
|
846
|
+
if (kind === 'denied') {
|
|
847
|
+
return ('This sub-agent\'s definition does not grant `task`, so it may not delegate. That is a ' +
|
|
848
|
+
'deliberate restriction on this agent type — do not ask for approval and do not look for ' +
|
|
849
|
+
'a way around it. Do the work with the tools you have, or report back what is missing.');
|
|
850
|
+
}
|
|
851
|
+
return (`Maximum sub-agent nesting depth (${limit ?? DEFAULT_MAX_TASK_DEPTH}) reached, so this run ` +
|
|
852
|
+
'is a leaf and cannot delegate further. Depth is a budget, not a bug: finish this work ' +
|
|
853
|
+
'yourself, or report back so a shallower frame can decide. (The ceiling is ' +
|
|
854
|
+
'"maxSubagentDepth" in .nexrall/settings.json, but raising it mid-task will not help you — ' +
|
|
855
|
+
'it applies from the next session.)');
|
|
856
|
+
}
|
|
643
857
|
/**
|
|
644
858
|
* Whether a run at `depth` may spawn sub-agents.
|
|
645
859
|
*
|
|
@@ -649,12 +863,41 @@ exports.NO_NESTED_SUBAGENTS_MSG = 'Sub-agents cannot spawn further sub-agents
|
|
|
649
863
|
* independently, and the result was a sub-agent that got the tool plus instructions to use
|
|
650
864
|
* it but no catalogue — then a permission-gate refusal telling it not to ask for approval.
|
|
651
865
|
*
|
|
652
|
-
* `depth <
|
|
653
|
-
*
|
|
654
|
-
*
|
|
866
|
+
* `depth < limit` because the children this run would create land at `depth + 1`; the
|
|
867
|
+
* guard inside runSubTask mirrors it as `depth >= limit`.
|
|
868
|
+
*
|
|
869
|
+
* `limit` is injected rather than read from module state so this stays pure and testable.
|
|
870
|
+
* Callers pass the resolved per-workspace value; it defaults to the built-in for the
|
|
871
|
+
* handful of call sites that have no settings in hand.
|
|
872
|
+
*/
|
|
873
|
+
/**
|
|
874
|
+
* A child sub-agent's effective tool allowlist: its own, narrowed by its parent's.
|
|
875
|
+
*
|
|
876
|
+
* Exported and pure because it is a SECURITY boundary and was previously verified only by
|
|
877
|
+
* grepping the source for the intersection expression — which matched happily while the
|
|
878
|
+
* code threw a TypeError on one of its own four cases. A boundary needs behavioural tests.
|
|
879
|
+
*
|
|
880
|
+
* `null` means "no allowlist" (unrestricted), and it is returned only when BOTH sides say
|
|
881
|
+
* so. The four cases:
|
|
882
|
+
* own + parent → intersection (a child can narrow, never widen)
|
|
883
|
+
* own only → own (the main agent, which has no allowlist, spawning a specialist)
|
|
884
|
+
* parent only → a COPY of parent (an unnamed/general-purpose child inherits the
|
|
885
|
+
* restriction instead of resetting to full access — this is the
|
|
886
|
+
* escalation path, since `general-purpose` declares no tools at all)
|
|
887
|
+
* neither → null
|
|
888
|
+
*
|
|
889
|
+
* The parent-only case must COPY: the caller adds AGENT_MEMORY_TOOL to the returned set,
|
|
890
|
+
* which would otherwise mutate the parent's live allowlist.
|
|
655
891
|
*/
|
|
656
|
-
function
|
|
657
|
-
if (
|
|
892
|
+
function intersectAllowlists(own, parent) {
|
|
893
|
+
if (own && parent)
|
|
894
|
+
return new Set([...own].filter((t) => parent.has(t)));
|
|
895
|
+
if (own)
|
|
896
|
+
return own;
|
|
897
|
+
return parent ? new Set(parent) : null;
|
|
898
|
+
}
|
|
899
|
+
function canSpawnSubAgents(depth, allowedTools, limit = DEFAULT_MAX_TASK_DEPTH) {
|
|
900
|
+
if (depth >= limit)
|
|
658
901
|
return false;
|
|
659
902
|
// An allowlist that omits `task` is the other reason a run cannot delegate. No
|
|
660
903
|
// allowlist at all (the main agent, or a general-purpose sub-task) means no
|
|
@@ -712,34 +955,11 @@ class ToolNotAllowedError extends Error {
|
|
|
712
955
|
}
|
|
713
956
|
}
|
|
714
957
|
exports.ToolNotAllowedError = ToolNotAllowedError;
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
* that the Anthropic API rejects outright with "no low surrogate in string". That
|
|
721
|
-
* exact failure has already been shipped and fixed once in this codebase; every
|
|
722
|
-
* new cap on model-facing text has to be surrogate-aware from the start.
|
|
723
|
-
*/
|
|
724
|
-
function sliceSafeEnd(s, max) {
|
|
725
|
-
if (s.length <= max)
|
|
726
|
-
return s;
|
|
727
|
-
let end = max;
|
|
728
|
-
const code = s.charCodeAt(end - 1);
|
|
729
|
-
if (code >= 0xd800 && code <= 0xdbff)
|
|
730
|
-
end--; // trailing high surrogate — drop it
|
|
731
|
-
return s.slice(0, end);
|
|
732
|
-
}
|
|
733
|
-
/** Mirror of sliceSafeEnd for a tail slice: never START on a low surrogate. */
|
|
734
|
-
function sliceSafeStart(s, from) {
|
|
735
|
-
if (from <= 0)
|
|
736
|
-
return s;
|
|
737
|
-
let start = from;
|
|
738
|
-
const code = s.charCodeAt(start);
|
|
739
|
-
if (code >= 0xdc00 && code <= 0xdfff)
|
|
740
|
-
start++; // leading low surrogate — drop it
|
|
741
|
-
return s.slice(start);
|
|
742
|
-
}
|
|
958
|
+
// sliceSafeEnd/sliceSafeStart moved to ../util/safeSlice so every module that
|
|
959
|
+
// truncates model-facing/wire-facing text (loop.ts and tools/executor.ts) shares
|
|
960
|
+
// ONE surrogate-safe implementation instead of drifting copies. See that file's
|
|
961
|
+
// header for why raw `.slice()` on these strings caused a 400
|
|
962
|
+
// "no low surrogate in string" from the Anthropic API.
|
|
743
963
|
/**
|
|
744
964
|
* Reduce a sub-agent's message history to the text its parent should receive.
|
|
745
965
|
*
|
|
@@ -771,8 +991,8 @@ function extractSubTaskText(messages, preferLast = true) {
|
|
|
771
991
|
function capSubTaskText(text, max = SUBTASK_MAX) {
|
|
772
992
|
if (text.length <= max)
|
|
773
993
|
return text;
|
|
774
|
-
const head = sliceSafeEnd(text, Math.floor(max * 0.6));
|
|
775
|
-
const tail = sliceSafeStart(text, text.length - Math.floor(max * 0.4));
|
|
994
|
+
const head = (0, safeSlice_1.sliceSafeEnd)(text, Math.floor(max * 0.6));
|
|
995
|
+
const tail = (0, safeSlice_1.sliceSafeStart)(text, text.length - Math.floor(max * 0.4));
|
|
776
996
|
return `${head}\n\n[… sub-task output truncated (${text.length} chars) — kept the beginning and end …]\n\n${tail}`;
|
|
777
997
|
}
|
|
778
998
|
/**
|
|
@@ -859,7 +1079,7 @@ function lastToolResults(messages, count, maxChars) {
|
|
|
859
1079
|
continue;
|
|
860
1080
|
const name = nameById.get(String(b.tool_use_id ?? '')) ?? 'tool';
|
|
861
1081
|
const body = text.length > maxChars
|
|
862
|
-
? `${sliceSafeEnd(text, maxChars)}\n… [truncated]`
|
|
1082
|
+
? `${(0, safeSlice_1.sliceSafeEnd)(text, maxChars)}\n… [truncated]`
|
|
863
1083
|
: text;
|
|
864
1084
|
out.push(`• ${name}:\n${body}`);
|
|
865
1085
|
}
|
|
@@ -880,14 +1100,29 @@ function toolResultText(block) {
|
|
|
880
1100
|
}
|
|
881
1101
|
return '';
|
|
882
1102
|
}
|
|
883
|
-
async function runSubTask(input, options, agentTypes
|
|
1103
|
+
async function runSubTask(input, options, agentTypes,
|
|
1104
|
+
// Set to true at the moment an agent loop actually STARTS, so the caller can refund the
|
|
1105
|
+
// session slot it claimed for a spawn that turned out never to run.
|
|
1106
|
+
//
|
|
1107
|
+
// An out-param rather than a discriminated return type on purpose: every one of this
|
|
1108
|
+
// function's ~8 early returns is a non-start, and several are far from the top. Enumerating
|
|
1109
|
+
// them in the caller would be a list to forget to update; flipping one flag at the single
|
|
1110
|
+
// point of no return cannot go stale, and a path added later is a non-start by default.
|
|
1111
|
+
started) {
|
|
884
1112
|
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
885
1113
|
if (!prompt)
|
|
886
1114
|
return { error: 'task tool requires a non-empty prompt' };
|
|
1115
|
+
// The scope of the frame DOING the spawning — i.e. the owner of any resumable id this call
|
|
1116
|
+
// produces, and the identity checked when resuming one. 'root' is the main agent.
|
|
1117
|
+
const agentScope = options._agentScope ?? 'root';
|
|
887
1118
|
const depth = options._depth ?? 0;
|
|
888
|
-
|
|
889
|
-
|
|
1119
|
+
const depthLimit = resolveMaxSubagentDepth(options.workDir ? (0, rules_1.loadSettings)(options.workDir).raw : {});
|
|
1120
|
+
if (depth >= depthLimit) {
|
|
1121
|
+
return { error: noSpawnReason('depth', depthLimit) };
|
|
890
1122
|
}
|
|
1123
|
+
// NOTE: the session budget is claimed at the DISPATCH SITE, not here — runSubTask runs
|
|
1124
|
+
// inside the concurrency limiter, so claiming here would make a doomed spawn wait behind
|
|
1125
|
+
// running siblings before being told no. See the `name === 'task'` branch in runAgentLoop.
|
|
891
1126
|
// Resolve an optional custom agent type (subagent_type).
|
|
892
1127
|
//
|
|
893
1128
|
// `agentTypes` is a snapshot taken once at the top of runAgentLoop, before the
|
|
@@ -914,6 +1149,22 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
914
1149
|
// used to launder a denied agent under an allowed name.
|
|
915
1150
|
const resumeId = typeof input.resume_agent_id === 'string' ? input.resume_agent_id.trim() : '';
|
|
916
1151
|
const resumed = resumeId ? (0, agentRegistry_1.getAgent)(resumeId) : undefined;
|
|
1152
|
+
// OWNERSHIP, in addition to the deny-rule re-authorisation below.
|
|
1153
|
+
//
|
|
1154
|
+
// Re-deriving the agent NAME from storage stops an id laundering a denied agent, but it
|
|
1155
|
+
// says nothing about WHO may use the id. The registry is one flat process-global Map, so a
|
|
1156
|
+
// nested sub-agent could name an id it was never given and read another agent's entire
|
|
1157
|
+
// unredacted transcript, or overwrite it. Harmless while sub-agents were leaves (only the
|
|
1158
|
+
// main agent ever held an id); live once they can spawn.
|
|
1159
|
+
//
|
|
1160
|
+
// Reported as "expired" rather than "not yours": a distinct message would confirm the id
|
|
1161
|
+
// exists, turning the error into an oracle for enumerating other frames' agents.
|
|
1162
|
+
if (resumed && !(0, agentRegistry_1.canResume)(resumed, agentScope)) {
|
|
1163
|
+
return {
|
|
1164
|
+
error: `No resumable sub-agent with id "${resumeId}" is available to this run. Start a fresh ` +
|
|
1165
|
+
'sub-task with a self-contained prompt instead.',
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
917
1168
|
if (resumeId && !resumed) {
|
|
918
1169
|
return {
|
|
919
1170
|
error: `No resumable sub-agent with id "${resumeId}". Ids live only for the current session and the ` +
|
|
@@ -934,15 +1185,26 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
934
1185
|
//
|
|
935
1186
|
// Phrased as a policy refusal, not "unknown type": the model must not respond
|
|
936
1187
|
// by trying to create the agent file it thinks is missing.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1188
|
+
// Evaluated UNCONDITIONALLY, with 'general-purpose' standing in for an unnamed dispatch.
|
|
1189
|
+
//
|
|
1190
|
+
// This used to be `if (requestedType)`, which meant an unnamed spawn skipped the rule
|
|
1191
|
+
// entirely: `deny: ["task(general-purpose)"]` matched the named form and returned null for
|
|
1192
|
+
// the unnamed one. Omitting the field was therefore a bypass for the single most
|
|
1193
|
+
// privileged variant — an unnamed sub-task has no allowlist of its own, so before the
|
|
1194
|
+
// parent-intersection it received FULL access, exactly what such a rule is written to stop.
|
|
1195
|
+
//
|
|
1196
|
+
// The substitution is also the honest model rather than a patch: an unnamed sub-task IS
|
|
1197
|
+
// general-purpose behaviourally (that is what naming it accomplished in the first place),
|
|
1198
|
+
// so a rule about that agent should govern both spellings. A bare `deny: ["task"]` already
|
|
1199
|
+
// caught both and is unaffected.
|
|
1200
|
+
const denyKey = requestedType || 'general-purpose';
|
|
1201
|
+
const decision = (0, rules_1.evaluatePermission)((0, rules_1.loadSettings)(options.workDir).permissions, 'task', { subagent_type: denyKey }, options.workDir);
|
|
1202
|
+
if (decision === 'deny') {
|
|
1203
|
+
return {
|
|
1204
|
+
error: `The sub-agent "${denyKey}" is disabled by a permission rule in this project ` +
|
|
1205
|
+
`(permissions.deny in settings.json). This is a deliberate policy choice, not a missing file — ` +
|
|
1206
|
+
'do not create it and do not retry. Do the work yourself, or use a different sub-agent.',
|
|
1207
|
+
};
|
|
946
1208
|
}
|
|
947
1209
|
let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
|
|
948
1210
|
let knownTypes = agentTypes;
|
|
@@ -991,7 +1253,24 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
991
1253
|
// "Permission denied by user", which was actively misleading: the user denied
|
|
992
1254
|
// nothing, and a model told that will re-ask for approval instead of noticing
|
|
993
1255
|
// that the agent's own allowlist (often a typo'd tool name) is what stopped it.
|
|
994
|
-
|
|
1256
|
+
// ── The child's allowlist is INTERSECTED with the parent's ──────────────────
|
|
1257
|
+
//
|
|
1258
|
+
// A child's own definition can only ever NARROW what its parent had, never widen it.
|
|
1259
|
+
// Without this, nesting is a privilege-escalation ladder: `reviewer` is read-only and
|
|
1260
|
+
// has no write_file, but `general-purpose` declares no `tools:` at all (= full access),
|
|
1261
|
+
// so a read-only agent could delegate to an unrestricted one and edit the repo through
|
|
1262
|
+
// it. The user's "this agent cannot write" would silently mean "cannot write directly".
|
|
1263
|
+
//
|
|
1264
|
+
// This was unreachable while sub-agents were leaves — nobody but the (unrestricted) main
|
|
1265
|
+
// agent could spawn. Turning nesting on is what makes it live, so the intersection ships
|
|
1266
|
+
// in the same change rather than as a follow-up.
|
|
1267
|
+
//
|
|
1268
|
+
// `null` still means "no allowlist", but only when BOTH sides say so: an unrestricted
|
|
1269
|
+
// parent spawning general-purpose stays unrestricted (today's behaviour at depth 1),
|
|
1270
|
+
// while a restricted parent yields a restricted child no matter what the child declares.
|
|
1271
|
+
// Sticky for the same reason the allowlist intersects: inherited OR own, never shed.
|
|
1272
|
+
const testFilesOnly = !!agent?.testFilesOnly || !!options._testFilesOnly;
|
|
1273
|
+
const allowed = intersectAllowlists(agent?.tools ? new Set(agent.tools) : null, options._allowedTools);
|
|
995
1274
|
// The ONE capability `memory:` grants. Added to the allowlist rather than bypassing
|
|
996
1275
|
// it, so the allowlist stays the single source of truth for what this agent can do.
|
|
997
1276
|
if (allowed && memoryScope)
|
|
@@ -1005,31 +1284,48 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
1005
1284
|
'`memory:` scope (project, user or local). Report anything worth remembering in your final ' +
|
|
1006
1285
|
'message instead — the main agent decides what to persist.');
|
|
1007
1286
|
}
|
|
1008
|
-
// `task` is
|
|
1009
|
-
//
|
|
1010
|
-
// runSubTask, so every run it governs is already at depth >= 1 and can never spawn.
|
|
1287
|
+
// `task` is answered by the same predicate that decides whether the tool was sent in
|
|
1288
|
+
// the first place, so the gate cannot disagree with the prompt.
|
|
1011
1289
|
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1290
|
+
// This was briefly an UNCONDITIONAL refusal, which was correct only while the depth
|
|
1291
|
+
// ceiling was hard-coded to 1 (every gated run was a leaf by definition). With nesting
|
|
1292
|
+
// configurable that shortcut becomes a real bug: a depth-1 agent under
|
|
1293
|
+
// `maxSubagentDepth: 3` would be handed the tool by the backend and then refused here.
|
|
1294
|
+
// Fail-closed, so it would have looked like a mysterious dead end rather than a crash.
|
|
1017
1295
|
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
|
|
1296
|
+
// Two distinct reasons, two distinct messages — a depth ceiling is raisable, an agent
|
|
1297
|
+
// definition withholding `task` is not, and a refusal that lies about which one applies
|
|
1298
|
+
// makes the model either give up early or hunt for an escape hatch.
|
|
1299
|
+
if (req.tool === 'task' && !canSpawnSubAgents(depth + 1, allowed ?? undefined, depthLimit)) {
|
|
1300
|
+
// `depth + 1`, not `depth`: this closure gates the CHILD's tool calls, and the child
|
|
1301
|
+
// runs one level below the `depth` in scope here (which belongs to its parent). Using
|
|
1302
|
+
// `depth` would evaluate the parent's right to spawn — permitting one level too many.
|
|
1303
|
+
throw new ToolNotAllowedError(allowed && !allowed.has('task')
|
|
1304
|
+
? noSpawnReason('denied')
|
|
1305
|
+
: noSpawnReason('depth', depthLimit));
|
|
1022
1306
|
}
|
|
1023
1307
|
if (allowed && !allowed.has(req.tool)) {
|
|
1024
|
-
throw new ToolNotAllowedError(
|
|
1308
|
+
throw new ToolNotAllowedError(
|
|
1309
|
+
// `agent?.name`, NOT `agent!.name`. The non-null assertion held only while `allowed`
|
|
1310
|
+
// was derived solely from `agent?.tools` (non-null allowlist ⇒ named agent). The
|
|
1311
|
+
// parent-intersection broke that invariant: a RESTRICTED parent dispatching `task`
|
|
1312
|
+
// with no subagent_type yields a non-null inherited allowlist with `agent`
|
|
1313
|
+
// undefined, and this line then threw a TypeError instead of ToolNotAllowedError —
|
|
1314
|
+
// which the dispatch site does not recognise, so it laundered the refusal into the
|
|
1315
|
+
// generic "Permission denied by user" this very message exists to avoid.
|
|
1316
|
+
`The "${agent?.name ?? 'general-purpose'}" sub-agent is not allowed to use \`${req.tool}\` — it is not in that agent's ` +
|
|
1025
1317
|
'tool allowlist. This is a restriction of the agent definition, NOT a user decision: do not ask ' +
|
|
1026
1318
|
'for approval, use one of the tools you do have, or report back that the task needs a different agent.');
|
|
1027
1319
|
}
|
|
1028
|
-
// Path-scoped write restriction
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1320
|
+
// Path-scoped write restriction — see allowsTestOnlyWrite for the reasoning and its
|
|
1321
|
+
// known limit. Applies when THIS agent declares it OR any ancestor did: like the tool
|
|
1322
|
+
// allowlist above, a restriction can only ever be narrowed by nesting, never shed.
|
|
1323
|
+
// Without the inherited half, `test-writer` could delegate to an unrestricted agent and
|
|
1324
|
+
// have production source written on its behalf.
|
|
1325
|
+
if (testFilesOnly && !allowsTestOnlyWrite(req.tool, req.input)) {
|
|
1326
|
+
throw new ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may only write to TEST files, so ` +
|
|
1327
|
+
`\`${req.tool}\` was refused for this path. Do not try to work around it: if production ` +
|
|
1328
|
+
'code must change, say so in your report instead.');
|
|
1033
1329
|
}
|
|
1034
1330
|
return options.requestPermission(req);
|
|
1035
1331
|
};
|
|
@@ -1086,6 +1382,9 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
1086
1382
|
subAbort.aborted = true;
|
|
1087
1383
|
}, 250);
|
|
1088
1384
|
try {
|
|
1385
|
+
// The point of no return: past here a real agent loop exists and the budget slot is spent.
|
|
1386
|
+
if (started)
|
|
1387
|
+
started.value = true;
|
|
1089
1388
|
const result = await runAgentLoop(subMessages, {
|
|
1090
1389
|
...options,
|
|
1091
1390
|
_depth: depth + 1,
|
|
@@ -1102,15 +1401,21 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
1102
1401
|
// `tools:` line inherited the parent's allowlist, making the prompt's capability
|
|
1103
1402
|
// claim disagree with its real one.
|
|
1104
1403
|
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1404
|
+
// This is now LIVE, not latent: nesting is enabled by default, so a grandchild really
|
|
1405
|
+
// can be spawned by an agent that has a memory binding. The explicit `undefined` is
|
|
1406
|
+
// what stops it inheriting that binding and appending to its grandparent's private
|
|
1407
|
+
// notes — a silent cross-agent write rather than a visible error. Note the allowlist
|
|
1408
|
+
// takes the opposite direction on purpose (inherited, because it RESTRICTS); identity
|
|
1409
|
+
// must not be inherited, capability must.
|
|
1110
1410
|
_agentMemory: agent && memoryScope ? { agentName: agent.name, scope: memoryScope } : undefined,
|
|
1111
1411
|
// The same set `gatedPermission` enforces above, so prompt and permission agree
|
|
1112
1412
|
// by construction instead of by two people remembering to update both.
|
|
1113
1413
|
_allowedTools: allowed ?? undefined,
|
|
1414
|
+
// Propagated so a grandchild inherits it too — see _testFilesOnly. Assigned
|
|
1415
|
+
// unconditionally (not by conditional spread) for the same reason as _agentMemory
|
|
1416
|
+
// above: a conditional spread leaves the parent's value in place instead of clearing
|
|
1417
|
+
// it, and here that direction is at least safe, whereas forgetting to propagate is not.
|
|
1418
|
+
_testFilesOnly: testFilesOnly,
|
|
1114
1419
|
editorContext: null, // fresh isolated context for sub-agent
|
|
1115
1420
|
model: agent?.model ?? options.model,
|
|
1116
1421
|
// Plan mode is inherited, never relaxed. If the main agent could spawn a
|
|
@@ -1181,7 +1486,7 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
1181
1486
|
// do: run the whole task again. Resuming is now POSSIBLE but never implied to be
|
|
1182
1487
|
// safe: the text below states plainly that the work is unverified, and resumption
|
|
1183
1488
|
// re-authorises against current permissions exactly as it does for a clean run.
|
|
1184
|
-
const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result);
|
|
1489
|
+
const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope);
|
|
1185
1490
|
const sections = [
|
|
1186
1491
|
`Sub-task STOPPED after ${mins} minutes with NO PROGRESS (it was not making tool calls or ` +
|
|
1187
1492
|
'producing output) — treat everything below as PARTIAL, unverified work, not a finished answer.',
|
|
@@ -1208,8 +1513,8 @@ async function runSubTask(input, options, agentTypes) {
|
|
|
1208
1513
|
// it cannot determine. Those paths already salvage their partial output as
|
|
1209
1514
|
// TEXT, which is the safe way to carry that information forward.
|
|
1210
1515
|
const agentId = resumed
|
|
1211
|
-
? ((0, agentRegistry_1.updateAgent)(resumed.id, result), resumed.id)
|
|
1212
|
-
: (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result);
|
|
1516
|
+
? ((0, agentRegistry_1.updateAgent)(resumed.id, result, agentScope), resumed.id)
|
|
1517
|
+
: (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope);
|
|
1213
1518
|
const body = text || '(sub-task completed with no text output)';
|
|
1214
1519
|
return {
|
|
1215
1520
|
output: `${body}\n\n[resumable: this sub-agent is "${agentId}". To ask IT a follow-up — keeping ` +
|
|
@@ -1467,13 +1772,13 @@ function transcriptOf(messages) {
|
|
|
1467
1772
|
for (const m of messages) {
|
|
1468
1773
|
for (const b of m.content) {
|
|
1469
1774
|
if (b.type === 'text' && b.text) {
|
|
1470
|
-
parts.push(`${m.role.toUpperCase()}: ${b.text
|
|
1775
|
+
parts.push(`${m.role.toUpperCase()}: ${(0, safeSlice_1.sliceSafeEnd)(b.text, 2000)}`);
|
|
1471
1776
|
}
|
|
1472
1777
|
else if (b.type === 'tool_use') {
|
|
1473
|
-
parts.push(`${m.role.toUpperCase()} [tool: ${b.name}]: ${JSON.stringify(b.input ?? {})
|
|
1778
|
+
parts.push(`${m.role.toUpperCase()} [tool: ${b.name}]: ${(0, safeSlice_1.sliceSafeEnd)(JSON.stringify(b.input ?? {}), 400)}`);
|
|
1474
1779
|
}
|
|
1475
1780
|
else if (b.type === 'tool_result') {
|
|
1476
|
-
parts.push(`TOOL RESULT: ${String(b.content ?? '')
|
|
1781
|
+
parts.push(`TOOL RESULT: ${(0, safeSlice_1.sliceSafeEnd)(String(b.content ?? ''), 600)}`);
|
|
1477
1782
|
}
|
|
1478
1783
|
}
|
|
1479
1784
|
}
|
|
@@ -1484,8 +1789,8 @@ function transcriptOf(messages) {
|
|
|
1484
1789
|
// continuation). Slice on line boundaries so we don't cut a line in half.
|
|
1485
1790
|
const headBudget = Math.floor(MAX_TRANSCRIPT_CHARS * 0.4);
|
|
1486
1791
|
const tailBudget = MAX_TRANSCRIPT_CHARS - headBudget;
|
|
1487
|
-
const head =
|
|
1488
|
-
const tail =
|
|
1792
|
+
const head = (0, safeSlice_1.sliceSafeEnd)(full, headBudget);
|
|
1793
|
+
const tail = (0, safeSlice_1.sliceSafeStart)(full, full.length - tailBudget);
|
|
1489
1794
|
const dropped = full.length - head.length - tail.length;
|
|
1490
1795
|
return `${head}\n\n[… ${dropped} chars of mid-session transcript elided to fit the summariser's context window …]\n\n${tail}`;
|
|
1491
1796
|
}
|
|
@@ -1939,8 +2244,8 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
1939
2244
|
// switch a sub-agent off, and an agent that may not run must not be
|
|
1940
2245
|
// advertised (see below).
|
|
1941
2246
|
const settings = (0, rules_1.loadSettings)(options.workDir);
|
|
1942
|
-
// Discover custom sub-agent types.
|
|
1943
|
-
//
|
|
2247
|
+
// Discover custom sub-agent types. Every level resolves types; only a level that may
|
|
2248
|
+
// actually delegate is TOLD the catalogue (see maySpawn below).
|
|
1944
2249
|
//
|
|
1945
2250
|
// Denied agents are filtered OUT of the catalogue rather than left in it to be
|
|
1946
2251
|
// refused on dispatch. Listing an agent you have forbidden trains the model to
|
|
@@ -1950,7 +2255,12 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
1950
2255
|
const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir).filter((t) => (0, rules_1.evaluatePermission)(settings.permissions, 'task', { subagent_type: t.name }, options.workDir) !== 'deny');
|
|
1951
2256
|
// Can THIS run delegate at all? Previously only the catalogue was gated (`depth === 0`)
|
|
1952
2257
|
// while the tool schema and its instructions went to every run — see canSpawnSubAgents.
|
|
1953
|
-
|
|
2258
|
+
//
|
|
2259
|
+
// The depth limit is resolved from the SAME settings object the rest of the run uses, so
|
|
2260
|
+
// a project that sets maxSubagentDepth gets a prompt matching its own configuration
|
|
2261
|
+
// rather than the built-in default.
|
|
2262
|
+
const depthLimit = resolveMaxSubagentDepth(settings.raw);
|
|
2263
|
+
const maySpawn = canSpawnSubAgents(depth, options._allowedTools, depthLimit);
|
|
1954
2264
|
const agentsCatalogue = maySpawn ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
|
|
1955
2265
|
// Skills catalogue — unlike agentsCatalogue, available at every depth: a skill is
|
|
1956
2266
|
// just a reusable prompt template (via use_skill), not another spawn point, so
|
|
@@ -2568,37 +2878,57 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
2568
2878
|
// sub-task queued behind three long-running siblings could then be
|
|
2569
2879
|
// "stopped for stalling" having never executed a single step.
|
|
2570
2880
|
//
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2573
|
-
//
|
|
2574
|
-
//
|
|
2575
|
-
//
|
|
2576
|
-
//
|
|
2577
|
-
//
|
|
2578
|
-
|
|
2579
|
-
|
|
2881
|
+
// EVERY level is gated, each against its own depth's limiter.
|
|
2882
|
+
//
|
|
2883
|
+
// This used to be `depth === 0` only, because one shared limiter deadlocks if a
|
|
2884
|
+
// slot-holder re-enters it — and with sub-agents as leaves, skipping the nested
|
|
2885
|
+
// case was free. Now that nesting is real, skipping it would leave fan-out below
|
|
2886
|
+
// level 1 completely unbounded. Per-depth limiters give bounded concurrency at
|
|
2887
|
+
// every level while keeping the wait-for graph a DAG (a depth-D holder only ever
|
|
2888
|
+
// waits on depth-D+1), so there is no cycle to deadlock on. See _subTaskLimiters.
|
|
2889
|
+
// Session budget FIRST, outside the limiter. runSubTask runs inside limitRun, so
|
|
2890
|
+
// claiming in there meant a spawn that was already over budget queued behind up to
|
|
2891
|
+
// `max` running siblings before hearing no — in a runaway, i.e. the exact case this
|
|
2892
|
+
// guard exists for, refusals trickled out at sibling-completion rate.
|
|
2893
|
+
const overBudget = claimSessionSubAgentSlot(options.workDir, options.onNotice ?? options.onText);
|
|
2894
|
+
if (overBudget) {
|
|
2895
|
+
result = { error: overBudget };
|
|
2896
|
+
}
|
|
2897
|
+
else {
|
|
2898
|
+
const childDepth = depth + 1;
|
|
2899
|
+
const { run: limitRun, max: limitMax } = subTaskLimiter(childDepth, options.workDir);
|
|
2580
2900
|
// Tell the user when a sub-task is WAITING rather than working.
|
|
2581
2901
|
//
|
|
2582
2902
|
// With a burst of 8 and a ceiling of 4, the last four sat silently in the
|
|
2583
2903
|
// queue. From the outside they looked started, so a long wait read as a
|
|
2584
2904
|
// hang — and the fix for a hang (Ctrl+C and retry) is exactly wrong here,
|
|
2585
|
-
// since the work was about to run.
|
|
2586
|
-
//
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2905
|
+
// since the work was about to run. Counted per depth, so a level-2 queue is
|
|
2906
|
+
// not reported using a level-1 count.
|
|
2907
|
+
const inFlight = _inFlightByDepth.get(childDepth) ?? 0;
|
|
2908
|
+
if (inFlight >= limitMax) {
|
|
2909
|
+
(options.onNotice ?? options.onText)(`\u23f3 Queued: ${limitMax} sub-agent(s) already running at this level, so this one ` +
|
|
2910
|
+
`starts when a slot frees up (raise "maxConcurrentSubtasks" in .nexrall/settings.json ` +
|
|
2911
|
+
'to widen it).');
|
|
2590
2912
|
}
|
|
2591
|
-
|
|
2913
|
+
_inFlightByDepth.set(childDepth, inFlight + 1);
|
|
2914
|
+
// runSubTask flips this at its point of no return, so a spawn rejected by its own
|
|
2915
|
+
// validation (empty prompt, a deny rule, an unusable resume id) gives the slot
|
|
2916
|
+
// back. Without it, a model retrying against a deny rule would burn the entire
|
|
2917
|
+
// allowance on spawns that never ran and then lose delegation for the session.
|
|
2918
|
+
const started = { value: false };
|
|
2592
2919
|
try {
|
|
2593
|
-
result = await limitRun(() => runSubTask(input, options, agentTypes));
|
|
2920
|
+
result = await limitRun(() => runSubTask(input, options, agentTypes, started));
|
|
2594
2921
|
}
|
|
2595
2922
|
finally {
|
|
2596
|
-
|
|
2923
|
+
if (!started.value)
|
|
2924
|
+
refundSessionSubAgentSlot();
|
|
2925
|
+
const n = (_inFlightByDepth.get(childDepth) ?? 1) - 1;
|
|
2926
|
+
if (n > 0)
|
|
2927
|
+
_inFlightByDepth.set(childDepth, n);
|
|
2928
|
+
else
|
|
2929
|
+
_inFlightByDepth.delete(childDepth); // don't retain a key per depth forever
|
|
2597
2930
|
}
|
|
2598
2931
|
}
|
|
2599
|
-
else {
|
|
2600
|
-
result = await runSubTask(input, options, agentTypes);
|
|
2601
|
-
}
|
|
2602
2932
|
}
|
|
2603
2933
|
else {
|
|
2604
2934
|
const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);
|