@nexrall/code-core 1.4.66 → 1.4.67
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 +6 -2
- package/dist/agent/agentTypes.d.ts.map +1 -1
- package/dist/agent/agentTypes.js +13 -4
- package/dist/agent/askOnce.d.ts +12 -0
- package/dist/agent/askOnce.d.ts.map +1 -0
- package/dist/agent/askOnce.js +41 -0
- package/dist/agent/compaction.d.ts +244 -0
- package/dist/agent/compaction.d.ts.map +1 -0
- package/dist/agent/compaction.js +976 -0
- package/dist/agent/fileLocks.d.ts +34 -0
- package/dist/agent/fileLocks.d.ts.map +1 -0
- package/dist/agent/fileLocks.js +114 -0
- package/dist/agent/hooks.d.ts +324 -0
- package/dist/agent/hooks.d.ts.map +1 -0
- package/dist/agent/hooks.js +1228 -0
- package/dist/agent/iterationPolicy.d.ts +121 -0
- package/dist/agent/iterationPolicy.d.ts.map +1 -0
- package/dist/agent/iterationPolicy.js +297 -0
- package/dist/agent/lifecycleHost.d.ts +55 -0
- package/dist/agent/lifecycleHost.d.ts.map +1 -0
- package/dist/agent/lifecycleHost.js +294 -0
- package/dist/agent/loop.d.ts +11 -491
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +417 -3031
- package/dist/agent/planMode.d.ts.map +1 -1
- package/dist/agent/planMode.js +1 -0
- package/dist/agent/sharedTasks.d.ts +7 -0
- package/dist/agent/sharedTasks.d.ts.map +1 -1
- package/dist/agent/sharedTasks.js +16 -0
- package/dist/agent/subAgentBudget.d.ts +65 -0
- package/dist/agent/subAgentBudget.d.ts.map +1 -0
- package/dist/agent/subAgentBudget.js +269 -0
- package/dist/agent/subTask.d.ts +6 -0
- package/dist/agent/subTask.d.ts.map +1 -0
- package/dist/agent/subTask.js +713 -0
- package/dist/agent/subTaskSupport.d.ts +156 -0
- package/dist/agent/subTaskSupport.d.ts.map +1 -0
- package/dist/agent/subTaskSupport.js +409 -0
- package/dist/agent/toolDescriptions.d.ts +3 -0
- package/dist/agent/toolDescriptions.d.ts.map +1 -0
- package/dist/agent/toolDescriptions.js +116 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/mcp/client.d.ts +104 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +136 -2
- package/dist/mcp/httpClient.d.ts +17 -1
- package/dist/mcp/httpClient.d.ts.map +1 -1
- package/dist/mcp/httpClient.js +120 -19
- package/dist/mcp/manager.d.ts +77 -2
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +275 -9
- package/dist/mcp/sseClient.d.ts +7 -1
- package/dist/mcp/sseClient.d.ts.map +1 -1
- package/dist/mcp/sseClient.js +45 -1
- package/dist/mcp/stats.d.ts +41 -0
- package/dist/mcp/stats.d.ts.map +1 -0
- package/dist/mcp/stats.js +108 -0
- package/dist/permissions/destructive.d.ts +2 -0
- package/dist/permissions/destructive.d.ts.map +1 -1
- package/dist/permissions/destructive.js +6 -2
- package/dist/permissions/destructiveTokens.d.ts +5 -0
- package/dist/permissions/destructiveTokens.d.ts.map +1 -1
- package/dist/permissions/destructiveTokens.js +9 -3
- package/dist/permissions/modePolicy.d.ts.map +1 -1
- package/dist/permissions/modePolicy.js +5 -2
- package/dist/permissions/rules.d.ts +4 -1
- package/dist/permissions/rules.d.ts.map +1 -1
- package/dist/permissions/rules.js +29 -0
- package/dist/types.d.ts +45 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runSubTask = runSubTask;
|
|
4
|
+
const worktree_1 = require("./worktree");
|
|
5
|
+
const types_1 = require("../types");
|
|
6
|
+
const agentTypes_1 = require("./agentTypes");
|
|
7
|
+
const skills_1 = require("./skills");
|
|
8
|
+
const rules_1 = require("../permissions/rules");
|
|
9
|
+
const agentRegistry_1 = require("./agentRegistry");
|
|
10
|
+
const memory_1 = require("./memory");
|
|
11
|
+
const modelCatalogue_1 = require("./modelCatalogue");
|
|
12
|
+
const hooks_1 = require("./hooks");
|
|
13
|
+
const compaction_1 = require("./compaction");
|
|
14
|
+
const iterationPolicy_1 = require("./iterationPolicy");
|
|
15
|
+
const subTaskSupport_1 = require("./subTaskSupport");
|
|
16
|
+
let _subTaskCounter = 0; // unique per-process id → per-sub-agent todo scope
|
|
17
|
+
async function runSubTask(input, options, agentTypes,
|
|
18
|
+
// Set to true at the moment an agent loop actually STARTS, so the caller can refund the
|
|
19
|
+
// session slot it claimed for a spawn that turned out never to run.
|
|
20
|
+
//
|
|
21
|
+
// An out-param rather than a discriminated return type on purpose: every one of this
|
|
22
|
+
// function's ~8 early returns is a non-start, and several are far from the top. Enumerating
|
|
23
|
+
// them in the caller would be a list to forget to update; flipping one flag at the single
|
|
24
|
+
// point of no return cannot go stale, and a path added later is a non-start by default.
|
|
25
|
+
started,
|
|
26
|
+
// Seam: the recursive entry point (loop.ts's runAgentLoop), injected so this module never imports loop.ts.
|
|
27
|
+
runChild) {
|
|
28
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
29
|
+
if (!prompt)
|
|
30
|
+
return { error: 'task tool requires a non-empty prompt' };
|
|
31
|
+
// The scope of the frame DOING the spawning — i.e. the owner of any resumable id this call
|
|
32
|
+
// produces, and the identity checked when resuming one. 'root' is the main agent.
|
|
33
|
+
const agentScope = options._agentScope ?? 'root';
|
|
34
|
+
const depth = options._depth ?? 0;
|
|
35
|
+
const depthLimit = (0, subTaskSupport_1.resolveMaxSubagentDepth)(options.workDir ? (0, rules_1.loadSettings)(options.workDir).raw : {});
|
|
36
|
+
if (depth >= depthLimit) {
|
|
37
|
+
return { error: (0, subTaskSupport_1.noSpawnReason)('depth', depthLimit) };
|
|
38
|
+
}
|
|
39
|
+
// NOTE: the session budget is claimed at the DISPATCH SITE, not here — runSubTask runs
|
|
40
|
+
// inside the concurrency limiter, so claiming here would make a doomed spawn wait behind
|
|
41
|
+
// running siblings before being told no. See the `name === 'task'` branch in runAgentLoop.
|
|
42
|
+
// Resolve an optional custom agent type (subagent_type).
|
|
43
|
+
//
|
|
44
|
+
// `agentTypes` is a snapshot taken once at the top of runAgentLoop, before the
|
|
45
|
+
// model said anything. That made "write .nexrall/agents/x.md, then use it"
|
|
46
|
+
// impossible within a single turn: the file existed on disk, but this lookup
|
|
47
|
+
// consulted a list captured before it was written, and the model was told the
|
|
48
|
+
// agent did not exist — which reads as "creating it failed".
|
|
49
|
+
//
|
|
50
|
+
// So on a MISS ONLY, re-read from disk before giving up. The hit path (every
|
|
51
|
+
// normal call) still costs zero syscalls, and the miss path costs ~4 mostly-
|
|
52
|
+
// ENOENT stats — against a sub-agent that is about to run for seconds to
|
|
53
|
+
// minutes. Note runAgentLoop re-reads for the sub-agent anyway, so the old
|
|
54
|
+
// behaviour was already inconsistent: fresh for the child, stale for the lookup.
|
|
55
|
+
// ── Resolve a resume target ─────────────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// `resume_agent_id` continues a previous sub-agent. The stored transcript
|
|
58
|
+
// carries the agent's NAME, and that name — not the one the model passed — is
|
|
59
|
+
// what gets authorised below.
|
|
60
|
+
//
|
|
61
|
+
// This matters because an id would otherwise be a permanent capability: deny
|
|
62
|
+
// `task(explorer)` today and a model holding yesterday's explorer id could
|
|
63
|
+
// still resume it, with the deny rule looking like it was applied. Re-deriving
|
|
64
|
+
// the name from storage also stops a mismatched `subagent_type` from being
|
|
65
|
+
// used to launder a denied agent under an allowed name.
|
|
66
|
+
const resumeId = typeof input.resume_agent_id === 'string' ? input.resume_agent_id.trim() : '';
|
|
67
|
+
const resumed = resumeId ? (0, agentRegistry_1.getAgent)(resumeId) : undefined;
|
|
68
|
+
// OWNERSHIP, in addition to the deny-rule re-authorisation below.
|
|
69
|
+
//
|
|
70
|
+
// Re-deriving the agent NAME from storage stops an id laundering a denied agent, but it
|
|
71
|
+
// says nothing about WHO may use the id. The registry is one flat process-global Map, so a
|
|
72
|
+
// nested sub-agent could name an id it was never given and read another agent's entire
|
|
73
|
+
// unredacted transcript, or overwrite it. Harmless while sub-agents were leaves (only the
|
|
74
|
+
// main agent ever held an id); live once they can spawn.
|
|
75
|
+
//
|
|
76
|
+
// Reported as "expired" rather than "not yours": a distinct message would confirm the id
|
|
77
|
+
// exists, turning the error into an oracle for enumerating other frames' agents.
|
|
78
|
+
if (resumed && !(0, agentRegistry_1.canResume)(resumed, agentScope, options.sessionId)) {
|
|
79
|
+
return {
|
|
80
|
+
error: `No resumable sub-agent with id "${resumeId}" is available to this run. Start a fresh ` +
|
|
81
|
+
'sub-task with a self-contained prompt instead.',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (resumeId && !resumed) {
|
|
85
|
+
return {
|
|
86
|
+
error: `No resumable sub-agent with id "${resumeId}". Ids live only for the current session and the ` +
|
|
87
|
+
'oldest are dropped when too many accumulate, so this one has expired or never existed. ' +
|
|
88
|
+
'Start a fresh sub-task with a self-contained prompt instead.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const requestedType = resumed
|
|
92
|
+
? (resumed.agentName ?? '')
|
|
93
|
+
: (typeof input.subagent_type === 'string' ? input.subagent_type : '');
|
|
94
|
+
// ── Enforce `deny: ["task(<name>)"]` ─────────────────────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// This is the load-bearing check; filtering the catalogue in runAgentLoop only
|
|
97
|
+
// stops the agent being SUGGESTED. It must run BEFORE resolution, because the
|
|
98
|
+
// reload-on-miss path below deliberately re-reads from disk UNFILTERED — a
|
|
99
|
+
// denied agent is absent from the snapshot, would therefore "miss", and would
|
|
100
|
+
// then be found by that reload and run. Denying by omission is not denying.
|
|
101
|
+
//
|
|
102
|
+
// Phrased as a policy refusal, not "unknown type": the model must not respond
|
|
103
|
+
// by trying to create the agent file it thinks is missing.
|
|
104
|
+
// Evaluated UNCONDITIONALLY, with 'general-purpose' standing in for an unnamed dispatch.
|
|
105
|
+
//
|
|
106
|
+
// This used to be `if (requestedType)`, which meant an unnamed spawn skipped the rule
|
|
107
|
+
// entirely: `deny: ["task(general-purpose)"]` matched the named form and returned null for
|
|
108
|
+
// the unnamed one. Omitting the field was therefore a bypass for the single most
|
|
109
|
+
// privileged variant — an unnamed sub-task has no allowlist of its own, so before the
|
|
110
|
+
// parent-intersection it received FULL access, exactly what such a rule is written to stop.
|
|
111
|
+
//
|
|
112
|
+
// The substitution is also the honest model rather than a patch: an unnamed sub-task IS
|
|
113
|
+
// general-purpose behaviourally (that is what naming it accomplished in the first place),
|
|
114
|
+
// so a rule about that agent should govern both spellings. A bare `deny: ["task"]` already
|
|
115
|
+
// caught both and is unaffected.
|
|
116
|
+
const denyKey = requestedType || 'general-purpose';
|
|
117
|
+
const decision = (0, rules_1.evaluatePermission)((0, rules_1.loadSettings)(options.workDir).permissions, 'task', { subagent_type: denyKey }, options.workDir);
|
|
118
|
+
if (decision === 'deny') {
|
|
119
|
+
return {
|
|
120
|
+
error: `The sub-agent "${denyKey}" is disabled by a permission rule in this project ` +
|
|
121
|
+
`(permissions.deny in settings.json). This is a deliberate policy choice, not a missing file — ` +
|
|
122
|
+
'do not create it and do not retry. Do the work yourself, or use a different sub-agent.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// An unnamed dispatch IS general-purpose (the tool schema says so): same role prompt,
|
|
126
|
+
// same "your final message is your report" instructions, same deny rule (denyKey above).
|
|
127
|
+
let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType || 'general-purpose');
|
|
128
|
+
let knownTypes = agentTypes;
|
|
129
|
+
if (requestedType && !agent) {
|
|
130
|
+
// Same `extra` list the top-of-run snapshot used (see runAgentLoop) — otherwise a
|
|
131
|
+
// programmatically-registered agent (registerAgentType()) would resolve on the FIRST
|
|
132
|
+
// call in a turn (present in the snapshot) but "miss" on this reload path, since it was
|
|
133
|
+
// never written to disk for loadAgentTypes to rediscover.
|
|
134
|
+
knownTypes = (0, agentTypes_1.loadAgentTypesWithWarnings)(options.workDir, options._extraAgentTypes ?? []).types;
|
|
135
|
+
agent = (0, agentTypes_1.findAgentType)(knownTypes, requestedType);
|
|
136
|
+
}
|
|
137
|
+
if (requestedType && !agent) {
|
|
138
|
+
const known = knownTypes.map((a) => a.name).join(', ') || '(none defined)';
|
|
139
|
+
return {
|
|
140
|
+
error: `Unknown subagent_type "${requestedType}". Available types: ${known}.\n` +
|
|
141
|
+
'If you just created .nexrall/agents/' + requestedType + '.md, make sure the write finished in an ' +
|
|
142
|
+
'EARLIER tool call than this one — a file written in the same batch may not be on disk yet.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// A custom agent's persona is delivered through the project-instructions
|
|
146
|
+
// channel (authoritative in the system prompt), layered above the project's
|
|
147
|
+
// own nexrall.md so it keeps project conventions.
|
|
148
|
+
// ── Per-agent memory (`memory:` frontmatter) ────────────────────────────────
|
|
149
|
+
//
|
|
150
|
+
// When an agent declares a memory scope, its own notes from previous runs are
|
|
151
|
+
// injected ahead of its role prompt, and it gains ONE extra tool to append to them.
|
|
152
|
+
//
|
|
153
|
+
// Claude Code implements the equivalent by auto-enabling Read/Write/Edit "regardless
|
|
154
|
+
// of what the tools allowlist says". We deliberately do NOT copy that: silently
|
|
155
|
+
// widening a declared allowlist is fail-OPEN, and this repo already fixed the
|
|
156
|
+
// mirror-image bug (an unparseable agent file used to receive FULL access). So the
|
|
157
|
+
// grant here is a single purpose-built tool that can only ever touch this agent's
|
|
158
|
+
// own notes file — declaring `memory:` cannot hand anything write access to the repo.
|
|
159
|
+
const memoryScope = agent?.memory;
|
|
160
|
+
const agentMemoryNotes = agent && memoryScope
|
|
161
|
+
? (0, memory_1.agentMemoryPreamble)(agent.name, memoryScope, options.workDir)
|
|
162
|
+
: '';
|
|
163
|
+
// `lightPrompt` agents skip the project's nexrall.md — see AgentType.lightPrompt for
|
|
164
|
+
// why. The role prompt and any private notes still apply; only the (potentially very
|
|
165
|
+
// large) project instruction file is dropped.
|
|
166
|
+
//
|
|
167
|
+
// Composed from the PROJECT's instructions (`_projectNexrallMd`), never from the
|
|
168
|
+
// parent's own composite prompt: a grandchild used to inherit its parent's role
|
|
169
|
+
// ("# Sub-agent role: general-purpose … make the changes") on top of its own
|
|
170
|
+
// ("explorer … you NEVER modify files"), plus the parent's private memory notes.
|
|
171
|
+
const projectMd = options._projectNexrallMd ?? options.nexrallMd ?? '';
|
|
172
|
+
const inheritedMd = agent?.lightPrompt ? '' : projectMd;
|
|
173
|
+
// `skills:` — preload those skills' instructions (raw body: no !`cmd` expansion runs
|
|
174
|
+
// just because an agent was spawned). A missing name is stated, not silently dropped.
|
|
175
|
+
let preloadedSkills = '';
|
|
176
|
+
if (agent?.skills?.length) {
|
|
177
|
+
const known = (0, skills_1.loadSkillsWithWarnings)(options.workDir, options._extraSkills ?? []).skills;
|
|
178
|
+
preloadedSkills = agent.skills.map((n) => {
|
|
179
|
+
const sk = (0, skills_1.findSkill)(known, n);
|
|
180
|
+
return sk ? `# Preloaded skill: ${sk.name}\n${sk.body.trim()}` : `# Preloaded skill: ${n}\n(not found in this project — ignore)`;
|
|
181
|
+
}).join('\n\n');
|
|
182
|
+
}
|
|
183
|
+
// Shared-before-specific order: the project's nexrall.md (usually the largest part, and
|
|
184
|
+
// identical for every non-lightPrompt sibling) goes first; per-agent parts (preamble,
|
|
185
|
+
// role, skills, private notes) follow — last, where they also carry the most weight
|
|
186
|
+
// with the model. With the role first, two agents diverged at the first line of this
|
|
187
|
+
// block. HONEST SCOPE: this only buys cache reuse where everything BEFORE the block
|
|
188
|
+
// already matches — same tool list and same prompt flags (so e.g. two custom agents
|
|
189
|
+
// with equal `tools:`, on prefix-cached providers). Types with different allowlists
|
|
190
|
+
// diverge earlier, at the tools array, whatever the order here. It costs nothing, and
|
|
191
|
+
// tool enforcement never depended on prompt order (see the allowlist below).
|
|
192
|
+
const subNexrallMd = [
|
|
193
|
+
inheritedMd,
|
|
194
|
+
subTaskSupport_1.SUBAGENT_PREAMBLE,
|
|
195
|
+
agent ? `# Sub-agent role: ${agent.name}\n${agent.prompt}` : '',
|
|
196
|
+
preloadedSkills,
|
|
197
|
+
agentMemoryNotes,
|
|
198
|
+
].filter(Boolean).join('\n\n---\n\n');
|
|
199
|
+
// Optional tool allowlist — deny anything outside it for this sub-agent.
|
|
200
|
+
//
|
|
201
|
+
// A refusal here is reported through `deniedReason` rather than the generic
|
|
202
|
+
// "Permission denied by user", which was actively misleading: the user denied
|
|
203
|
+
// nothing, and a model told that will re-ask for approval instead of noticing
|
|
204
|
+
// that the agent's own allowlist (often a typo'd tool name) is what stopped it.
|
|
205
|
+
// ── The child's allowlist is INTERSECTED with the parent's ──────────────────
|
|
206
|
+
//
|
|
207
|
+
// A child's own definition can only ever NARROW what its parent had, never widen it.
|
|
208
|
+
// Without this, nesting is a privilege-escalation ladder: a read-only agent (planner, or any
|
|
209
|
+
// user-defined reviewer) has no write_file, but `general-purpose` declares no `tools:` at all (= full access),
|
|
210
|
+
// so a read-only agent could delegate to an unrestricted one and edit the repo through
|
|
211
|
+
// it. The user's "this agent cannot write" would silently mean "cannot write directly".
|
|
212
|
+
//
|
|
213
|
+
// This was unreachable while sub-agents were leaves — nobody but the (unrestricted) main
|
|
214
|
+
// agent could spawn. Turning nesting on is what makes it live, so the intersection ships
|
|
215
|
+
// in the same change rather than as a follow-up.
|
|
216
|
+
//
|
|
217
|
+
// `null` still means "no allowlist", but only when BOTH sides say so: an unrestricted
|
|
218
|
+
// parent spawning general-purpose stays unrestricted (today's behaviour at depth 1),
|
|
219
|
+
// while a restricted parent yields a restricted child no matter what the child declares.
|
|
220
|
+
// Sticky for the same reason the allowlist intersects: inherited OR own, never shed.
|
|
221
|
+
const testFilesOnly = !!agent?.testFilesOnly || !!options._testFilesOnly;
|
|
222
|
+
const allowed = (0, subTaskSupport_1.intersectAllowlists)(agent?.tools ? new Set(agent.tools) : null, options._allowedTools);
|
|
223
|
+
// The ONE capability `memory:` grants. Added to the allowlist rather than bypassing
|
|
224
|
+
// it, so the allowlist stays the single source of truth for what this agent can do.
|
|
225
|
+
if (allowed && memoryScope)
|
|
226
|
+
allowed.add(iterationPolicy_1.AGENT_MEMORY_TOOL);
|
|
227
|
+
// `disallowedTools` (Claude Code) — inherited like the allowlist: a child can only add
|
|
228
|
+
// to its ancestors' refusals, never shed them.
|
|
229
|
+
const disallowed = new Set([...(options._disallowedTools ?? []), ...(agent?.disallowedTools ?? [])]);
|
|
230
|
+
if (allowed)
|
|
231
|
+
for (const t of disallowed)
|
|
232
|
+
allowed.delete(t);
|
|
233
|
+
// `mcpServers` — narrows, inherited like the tool allowlist.
|
|
234
|
+
let mcpAllow = options._mcpServerAllowlist;
|
|
235
|
+
if (agent?.mcpServers) {
|
|
236
|
+
const own = new Set(agent.mcpServers);
|
|
237
|
+
mcpAllow = mcpAllow ? new Set([...mcpAllow].filter((x) => own.has(x))) : own;
|
|
238
|
+
}
|
|
239
|
+
// Time spent waiting on a human (permission prompt) is not a stall.
|
|
240
|
+
let waitingOnUser = 0;
|
|
241
|
+
const gatedPermission = async (req) => {
|
|
242
|
+
// Guard against the tool being reachable without a declared scope — e.g. an agent
|
|
243
|
+
// that lists it in `tools:` by hand, or a general-purpose sub-task with no
|
|
244
|
+
// allowlist at all (`allowed === null` permits everything).
|
|
245
|
+
if (req.tool === iterationPolicy_1.AGENT_MEMORY_TOOL && !(agent && memoryScope)) {
|
|
246
|
+
throw new subTaskSupport_1.ToolNotAllowedError(`\`${iterationPolicy_1.AGENT_MEMORY_TOOL}\` is only available to a sub-agent whose definition declares a ` +
|
|
247
|
+
'`memory:` scope (project, user or local). Report anything worth remembering in your final ' +
|
|
248
|
+
'message instead — the main agent decides what to persist.');
|
|
249
|
+
}
|
|
250
|
+
// `task` is answered by the same predicate that decides whether the tool was sent in
|
|
251
|
+
// the first place, so the gate cannot disagree with the prompt.
|
|
252
|
+
//
|
|
253
|
+
// This was briefly an UNCONDITIONAL refusal, which was correct only while the depth
|
|
254
|
+
// ceiling was hard-coded to 1 (every gated run was a leaf by definition). With nesting
|
|
255
|
+
// configurable that shortcut becomes a real bug: a depth-1 agent under
|
|
256
|
+
// `maxSubagentDepth: 3` would be handed the tool by the backend and then refused here.
|
|
257
|
+
// Fail-closed, so it would have looked like a mysterious dead end rather than a crash.
|
|
258
|
+
//
|
|
259
|
+
// Two distinct reasons, two distinct messages — a depth ceiling is raisable, an agent
|
|
260
|
+
// definition withholding `task` is not, and a refusal that lies about which one applies
|
|
261
|
+
// makes the model either give up early or hunt for an escape hatch.
|
|
262
|
+
if (req.tool === 'task' && !(0, subTaskSupport_1.canSpawnSubAgents)(depth + 1, allowed ?? undefined, depthLimit)) {
|
|
263
|
+
// `depth + 1`, not `depth`: this closure gates the CHILD's tool calls, and the child
|
|
264
|
+
// runs one level below the `depth` in scope here (which belongs to its parent). Using
|
|
265
|
+
// `depth` would evaluate the parent's right to spawn — permitting one level too many.
|
|
266
|
+
throw new subTaskSupport_1.ToolNotAllowedError(allowed && !allowed.has('task')
|
|
267
|
+
? (0, subTaskSupport_1.noSpawnReason)('denied')
|
|
268
|
+
: (0, subTaskSupport_1.noSpawnReason)('depth', depthLimit));
|
|
269
|
+
}
|
|
270
|
+
if (allowed && !allowed.has(req.tool)) {
|
|
271
|
+
throw new subTaskSupport_1.ToolNotAllowedError(
|
|
272
|
+
// `agent?.name`, NOT `agent!.name`. The non-null assertion held only while `allowed`
|
|
273
|
+
// was derived solely from `agent?.tools` (non-null allowlist ⇒ named agent). The
|
|
274
|
+
// parent-intersection broke that invariant: a RESTRICTED parent dispatching `task`
|
|
275
|
+
// with no subagent_type yields a non-null inherited allowlist with `agent`
|
|
276
|
+
// undefined, and this line then threw a TypeError instead of ToolNotAllowedError —
|
|
277
|
+
// which the dispatch site does not recognise, so it laundered the refusal into the
|
|
278
|
+
// generic "Permission denied by user" this very message exists to avoid.
|
|
279
|
+
`The "${agent?.name ?? 'general-purpose'}" sub-agent is not allowed to use \`${req.tool}\` — it is not in that agent's ` +
|
|
280
|
+
'tool allowlist. This is a restriction of the agent definition, NOT a user decision: do not ask ' +
|
|
281
|
+
'for approval, use one of the tools you do have, or report back that the task needs a different agent.');
|
|
282
|
+
}
|
|
283
|
+
// Path-scoped write restriction — see allowsTestOnlyWrite for the reasoning and its
|
|
284
|
+
// known limit. Applies when THIS agent declares it OR any ancestor did: like the tool
|
|
285
|
+
// allowlist above, a restriction can only ever be narrowed by nesting, never shed.
|
|
286
|
+
// Without the inherited half, a test-only agent could delegate to an unrestricted agent and
|
|
287
|
+
// have production source written on its behalf.
|
|
288
|
+
if (mcpAllow && req.tool.includes('__') && !mcpAllow.has(req.tool.split('__')[0])) {
|
|
289
|
+
throw new subTaskSupport_1.ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may only use tools from these MCP servers: ` +
|
|
290
|
+
`${[...mcpAllow].join(', ') || '(none)'}. \`${req.tool}\` is from another server — use a different tool or report back.`);
|
|
291
|
+
}
|
|
292
|
+
if (disallowed.has(req.tool)) {
|
|
293
|
+
throw new subTaskSupport_1.ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may not use \`${req.tool}\` (disallowedTools in its ` +
|
|
294
|
+
'definition). This is a restriction of the agent definition, NOT a user decision — use another tool or report back.');
|
|
295
|
+
}
|
|
296
|
+
if (req.tool === 'memory_write') {
|
|
297
|
+
throw new subTaskSupport_1.ToolNotAllowedError('Sub-agents cannot write the shared memory store. Put anything worth remembering in your final report — ' +
|
|
298
|
+
'the main agent decides what to persist.');
|
|
299
|
+
}
|
|
300
|
+
if (testFilesOnly && !(0, compaction_1.allowsTestOnlyWrite)(req.tool, req.input)) {
|
|
301
|
+
throw new subTaskSupport_1.ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may only write to TEST files, so ` +
|
|
302
|
+
`\`${req.tool}\` was refused for this path. Do not try to work around it: if production ` +
|
|
303
|
+
'code must change, say so in your report instead.');
|
|
304
|
+
}
|
|
305
|
+
// An agent writing its OWN notes (declared `memory:`) is answered here, not by the
|
|
306
|
+
// ancestors: their gates refuse agent_memory_write unless THEY declared a scope, so a
|
|
307
|
+
// memory agent spawned by general-purpose could never write. The tool can only touch
|
|
308
|
+
// this agent's own notes file, so there is nothing for anyone above to approve.
|
|
309
|
+
if (req.tool === iterationPolicy_1.AGENT_MEMORY_TOOL && agent && memoryScope)
|
|
310
|
+
return true;
|
|
311
|
+
waitingOnUser++;
|
|
312
|
+
try {
|
|
313
|
+
// Say WHICH sub-agent is asking (the innermost one wins as the request bubbles up).
|
|
314
|
+
const description = typeof input.description === 'string' ? input.description : undefined;
|
|
315
|
+
return await options.requestPermission({
|
|
316
|
+
...req,
|
|
317
|
+
agent: req.agent ?? { name: agent?.name ?? 'general-purpose', ...(description ? { description } : {}) },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
waitingOnUser--;
|
|
322
|
+
bumpProgress();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
// ── Resume: continue a previous sub-agent instead of starting cold ──────────
|
|
326
|
+
//
|
|
327
|
+
// The new prompt is appended as another user turn to the stored transcript, so
|
|
328
|
+
// the agent keeps every file it read and every conclusion it reached. Without
|
|
329
|
+
// this, "now also check the auth path" means re-describing the entire job and
|
|
330
|
+
// re-reading everything — the most common and most expensive kind of waste in
|
|
331
|
+
// a delegated workflow.
|
|
332
|
+
const subMessages = resumed
|
|
333
|
+
? [...resumed.messages, { role: 'user', content: [{ type: 'text', text: prompt }] }]
|
|
334
|
+
: [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
|
|
335
|
+
// A dedicated abort signal for this sub-agent, distinct from the parent's own
|
|
336
|
+
// options.abortSignal (user hit Ctrl+C). Set to true either when the parent
|
|
337
|
+
// aborts OR when the stall timeout below fires, whichever happens first —
|
|
338
|
+
// runAgentLoop already checks abortSignal.aborted at every iteration boundary,
|
|
339
|
+
// so this is enough to make it stop promptly without a forceful kill.
|
|
340
|
+
const subAbort = { aborted: false };
|
|
341
|
+
const subtaskTimeoutMs = (0, subTaskSupport_1.resolveSubtaskTimeoutMs)((0, rules_1.loadSettings)(options.workDir).raw);
|
|
342
|
+
// ── Stall watchdog, NOT a total-runtime deadline ─────────────────────────────
|
|
343
|
+
//
|
|
344
|
+
// This used to be a single setTimeout armed once and never refreshed. Its own
|
|
345
|
+
// comment called it a "stalled-agent cutoff", but it measured TOTAL LIFETIME: a
|
|
346
|
+
// sub-agent working hard and calling a tool every few seconds was killed at ten
|
|
347
|
+
// minutes exactly like one that had hung. That is not a hypothetical — auditing a
|
|
348
|
+
// handful of 600-2800 line files legitimately exceeds it, and when it fired the
|
|
349
|
+
// parent got back a fragment ("I'll start by reading the files…") after paying for
|
|
350
|
+
// 23 tool calls, then typically re-ran the whole thing.
|
|
351
|
+
//
|
|
352
|
+
// The main loop already draws this distinction correctly (client.ts's heartbeat vs
|
|
353
|
+
// progress watchdogs, where only real events bump lastProgressAt). Sub-agents now do
|
|
354
|
+
// too: the clock resets on every completed tool round, so the cap means "no progress
|
|
355
|
+
// for N minutes" — which is what catches a genuine hang — while useful work can run
|
|
356
|
+
// as long as it keeps being useful.
|
|
357
|
+
//
|
|
358
|
+
// `stalled` is tracked separately from `subAbort.aborted` because the parent's Ctrl+C
|
|
359
|
+
// also sets the latter, and the two must be reported differently.
|
|
360
|
+
let lastProgressAt = Date.now();
|
|
361
|
+
let stalled = false;
|
|
362
|
+
const bumpProgress = () => { lastProgressAt = Date.now(); };
|
|
363
|
+
const stallWatchdog = setInterval(() => {
|
|
364
|
+
if (waitingOnUser > 0) {
|
|
365
|
+
lastProgressAt = Date.now();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() - lastProgressAt > subtaskTimeoutMs) {
|
|
369
|
+
stalled = true;
|
|
370
|
+
subAbort.aborted = true;
|
|
371
|
+
}
|
|
372
|
+
}, 1000);
|
|
373
|
+
// Mirror the parent's own abort into the sub-agent's signal so Ctrl+C during
|
|
374
|
+
// a running sub-task still stops it (previously this worked implicitly by
|
|
375
|
+
// sharing the same object via `...options` — now that we own a distinct
|
|
376
|
+
// object we must forward it explicitly).
|
|
377
|
+
const parentAbortPoll = setInterval(() => {
|
|
378
|
+
if (options.abortSignal?.aborted)
|
|
379
|
+
subAbort.aborted = true;
|
|
380
|
+
}, 250);
|
|
381
|
+
// ── Worktree isolation (`isolation: "worktree"` on the call or in the definition) ──
|
|
382
|
+
// A fresh worktree per spawn, like Claude Code: the child's writes, bash cwd and git
|
|
383
|
+
// commands are confined to it (worktreeEnforcement), and it is removed afterwards
|
|
384
|
+
// unless the child actually changed something.
|
|
385
|
+
let isoState;
|
|
386
|
+
if (input.isolation === 'worktree' || agent?.isolation === 'worktree') {
|
|
387
|
+
const created = (0, worktree_1.createWorktree)(options.workDir);
|
|
388
|
+
if (!created.ok || !created.state) {
|
|
389
|
+
clearInterval(stallWatchdog);
|
|
390
|
+
clearInterval(parentAbortPoll);
|
|
391
|
+
return {
|
|
392
|
+
error: `Could not create an isolated worktree for this sub-agent: ${created.error ?? 'unknown error'}. ` +
|
|
393
|
+
'Run it without isolation, or fix the repository state first.',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
isoState = created.state;
|
|
397
|
+
}
|
|
398
|
+
const childWorkDir = isoState?.worktreePath ?? options.workDir;
|
|
399
|
+
// Claude Code's Explore/Plan skip git status; so do lightPrompt agents here. An
|
|
400
|
+
// isolated child is told where it actually is.
|
|
401
|
+
let childEnv = options.env;
|
|
402
|
+
if (childEnv && agent?.lightPrompt) {
|
|
403
|
+
const { gitStatus: _s, gitDiff: _d, recentCommits: _c, ...rest } = childEnv;
|
|
404
|
+
childEnv = rest;
|
|
405
|
+
}
|
|
406
|
+
if (childEnv && isoState)
|
|
407
|
+
childEnv = { ...childEnv, cwd: isoState.worktreePath, gitBranch: isoState.branch ?? childEnv.gitBranch };
|
|
408
|
+
// Per-sub-agent accounting, reported in its tool result (Claude Code shows the same).
|
|
409
|
+
const subStartedAt = Date.now();
|
|
410
|
+
let subToolCalls = 0;
|
|
411
|
+
let subTokens = 0;
|
|
412
|
+
let subCost = 0;
|
|
413
|
+
let childStop = null;
|
|
414
|
+
let childNotice = null;
|
|
415
|
+
let childVerifications = [];
|
|
416
|
+
const finalize = (r) => {
|
|
417
|
+
let isoNote = '';
|
|
418
|
+
if (isoState) {
|
|
419
|
+
if ((0, worktree_1.worktreeHasWork)(isoState)) {
|
|
420
|
+
isoNote = `\n[isolated worktree: this sub-agent's changes are in ${isoState.worktreePath}` +
|
|
421
|
+
`${isoState.branch ? ` (branch ${isoState.branch})` : ''} — NOT in the main checkout. Review them, then merge or discard.]`;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
(0, worktree_1.removeWorktree)(isoState, { force: true });
|
|
425
|
+
}
|
|
426
|
+
isoState = undefined;
|
|
427
|
+
}
|
|
428
|
+
const stats = `[sub-agent stats: ${subToolCalls} tool call(s) · ${(0, subTaskSupport_1.formatTokenCount)(subTokens)} tokens · ` +
|
|
429
|
+
`$${subCost.toFixed(3)} · ${(0, subTaskSupport_1.formatElapsed)(Date.now() - subStartedAt)}]`;
|
|
430
|
+
const out = { ...r, ...(childVerifications.length ? { childVerifications } : {}) };
|
|
431
|
+
if (out.error !== undefined)
|
|
432
|
+
out.error = `${out.error}\n\n${stats}${isoNote}`;
|
|
433
|
+
else
|
|
434
|
+
out.output = `${out.output ?? ''}\n\n${stats}${isoNote}`;
|
|
435
|
+
return out;
|
|
436
|
+
};
|
|
437
|
+
// Claimed right before `try` (whose finally releases it). runSubTask has no `await`
|
|
438
|
+
// before this point, so two parallel calls cannot both pass the check.
|
|
439
|
+
if (resumed && !(0, agentRegistry_1.claimAgentForResume)(resumed.id)) {
|
|
440
|
+
clearInterval(stallWatchdog);
|
|
441
|
+
clearInterval(parentAbortPoll);
|
|
442
|
+
if (isoState)
|
|
443
|
+
(0, worktree_1.removeWorktree)(isoState, { force: true });
|
|
444
|
+
return {
|
|
445
|
+
error: `Sub-agent "${resumed.id}" is already being resumed by another task call that is still running. ` +
|
|
446
|
+
'Wait for that result, then resume it again with your follow-up — two parallel resumes of one agent ' +
|
|
447
|
+
'would overwrite each other\'s work.',
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const childMeta = (m) => (m
|
|
451
|
+
? { ...m, parentId: m.parentId ?? options._taskToolUseId, agentName: m.agentName ?? agent?.name ?? 'general-purpose' }
|
|
452
|
+
: undefined);
|
|
453
|
+
try {
|
|
454
|
+
// The point of no return: past here a real agent loop exists and the budget slot is spent.
|
|
455
|
+
if (started)
|
|
456
|
+
started.value = true;
|
|
457
|
+
// SubagentStart: observer (cannot veto — the PreToolUse hook on `task` already can).
|
|
458
|
+
// additionalContext is appended to the brief the child receives.
|
|
459
|
+
{
|
|
460
|
+
const subAgentName = agent?.name ?? (typeof input.subagent_type === 'string' && input.subagent_type ? input.subagent_type : 'general-purpose');
|
|
461
|
+
const startItems = (0, hooks_1.loadHooks)(options.workDir).SubagentStart;
|
|
462
|
+
if (startItems?.length) {
|
|
463
|
+
const o = await (0, hooks_1.runLifecycleHooks)(startItems, 'SubagentStart', options.workDir, { session_id: options.sessionId ?? '', agent_type: subAgentName, description: String(input.description ?? '') }, subAgentName, (0, hooks_1.hookRunOptsFor)(options)).catch(() => ({ block: false }));
|
|
464
|
+
if (o.context) {
|
|
465
|
+
const last = subMessages[subMessages.length - 1];
|
|
466
|
+
if (last && Array.isArray(last.content)) {
|
|
467
|
+
subMessages[subMessages.length - 1] = { ...last, content: [...last.content, { type: 'text', text: `\n\n<hook-context>\n${o.context}\n</hook-context>` }] };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const childRun = runChild(subMessages, {
|
|
473
|
+
...options,
|
|
474
|
+
_depth: depth + 1,
|
|
475
|
+
_agentScope: `sub_${++_subTaskCounter}`, // isolated todo store per sub-agent
|
|
476
|
+
// ── Assigned UNCONDITIONALLY, never by conditional spread ────────────────
|
|
477
|
+
//
|
|
478
|
+
// These two were previously spread in only when set:
|
|
479
|
+
//
|
|
480
|
+
// ...(agent && memoryScope ? { _agentMemory: … } : {}),
|
|
481
|
+
//
|
|
482
|
+
// which does NOT clear the key — it leaves whatever `...options` already had.
|
|
483
|
+
// So a child WITHOUT its own `memory:` inherited its PARENT's binding and would
|
|
484
|
+
// have appended to another agent's private notes; likewise an agent with no
|
|
485
|
+
// `tools:` line inherited the parent's allowlist, making the prompt's capability
|
|
486
|
+
// claim disagree with its real one.
|
|
487
|
+
//
|
|
488
|
+
// This is now LIVE, not latent: nesting is enabled by default, so a grandchild really
|
|
489
|
+
// can be spawned by an agent that has a memory binding. The explicit `undefined` is
|
|
490
|
+
// what stops it inheriting that binding and appending to its grandparent's private
|
|
491
|
+
// notes — a silent cross-agent write rather than a visible error. Note the allowlist
|
|
492
|
+
// takes the opposite direction on purpose (inherited, because it RESTRICTS); identity
|
|
493
|
+
// must not be inherited, capability must.
|
|
494
|
+
_agentMemory: agent && memoryScope ? { agentName: agent.name, scope: memoryScope } : undefined,
|
|
495
|
+
// Assigned unconditionally for the same reason as _agentMemory above: a
|
|
496
|
+
// conditional spread would leave the PARENT's type name in place, so an
|
|
497
|
+
// untyped (general-purpose) child would be recorded in the audit trail
|
|
498
|
+
// under its parent's agent type — a wrong attribution, which is worse in a
|
|
499
|
+
// compliance record than an absent one.
|
|
500
|
+
_agentTypeName: agent?.name,
|
|
501
|
+
// The same set `gatedPermission` enforces above, so prompt and permission agree
|
|
502
|
+
// by construction instead of by two people remembering to update both.
|
|
503
|
+
_allowedTools: allowed ?? undefined,
|
|
504
|
+
// Propagated so a grandchild inherits it too — see _testFilesOnly. Assigned
|
|
505
|
+
// unconditionally (not by conditional spread) for the same reason as _agentMemory
|
|
506
|
+
// above: a conditional spread leaves the parent's value in place instead of clearing
|
|
507
|
+
// it, and here that direction is at least safe, whereas forgetting to propagate is not.
|
|
508
|
+
_testFilesOnly: testFilesOnly,
|
|
509
|
+
editorContext: null, // fresh isolated context for sub-agent
|
|
510
|
+
model: (0, modelCatalogue_1.resolveSubAgentModel)(agent?.model, options.model),
|
|
511
|
+
workDir: childWorkDir,
|
|
512
|
+
env: childEnv,
|
|
513
|
+
effort: agent?.effort ?? options.effort,
|
|
514
|
+
// A bounded run that REPORTS when it hits the limit (see childStop below), instead
|
|
515
|
+
// of inheriting the main agent's 500-step budget plus auto-continue to 2000.
|
|
516
|
+
maxIterations: agent?.maxTurns ?? subTaskSupport_1.DEFAULT_SUBAGENT_MAX_TURNS,
|
|
517
|
+
autoContinue: false,
|
|
518
|
+
// ── Session-level channels a child must NEVER consume ────────────────────
|
|
519
|
+
// Inherited through `...options`, a child drained the user's queued follow-ups
|
|
520
|
+
// and inbound peer messages into ITS history (the main agent never saw them),
|
|
521
|
+
// and its onProgress saved the child's transcript AS the session (CLI/desktop).
|
|
522
|
+
takePendingInput: undefined,
|
|
523
|
+
onInjectedInput: undefined,
|
|
524
|
+
drainPeerMessages: undefined,
|
|
525
|
+
onPeerMessage: undefined,
|
|
526
|
+
onProgress: undefined,
|
|
527
|
+
backgroundAgents: undefined,
|
|
528
|
+
_projectNexrallMd: projectMd,
|
|
529
|
+
_disallowedTools: disallowed.size ? disallowed : undefined,
|
|
530
|
+
_mcpServerAllowlist: mcpAllow,
|
|
531
|
+
_agentHooks: agent?.hooks,
|
|
532
|
+
_onStopReason: (reason, notice) => { childStop = reason; childNotice = notice; },
|
|
533
|
+
_onVerifications: (records) => { childVerifications = records; },
|
|
534
|
+
onUsage: (u, partial, cost, _sub) => {
|
|
535
|
+
if (!partial) {
|
|
536
|
+
subTokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0)
|
|
537
|
+
+ (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
538
|
+
subCost += cost ?? 0;
|
|
539
|
+
}
|
|
540
|
+
options.onUsage(u, partial, cost, true);
|
|
541
|
+
},
|
|
542
|
+
// Plan mode is inherited, never relaxed. If the main agent could spawn a
|
|
543
|
+
// sub-agent that writes, the lock would be one `task` call from useless.
|
|
544
|
+
// `permissionMode: plan` in a definition can only ADD the lock.
|
|
545
|
+
planMode: options.planMode || agent?.permissionMode === 'plan',
|
|
546
|
+
// Same reasoning as planMode directly above: a sub-agent that could reach
|
|
547
|
+
// outside its parent's worktree would defeat the isolation in one `task`
|
|
548
|
+
// call. Already inherited via `...options` above — restated explicitly so
|
|
549
|
+
// it reads the same way as planMode and is never accidentally dropped by
|
|
550
|
+
// a future refactor of this spread.
|
|
551
|
+
worktree: isoState ?? options.worktree,
|
|
552
|
+
// A sub-agent using message_peer_session/list_peer_sessions should
|
|
553
|
+
// present as the SAME peer identity as its parent — there is one
|
|
554
|
+
// registered peer per SESSION, not per sub-agent, so a sub-agent is
|
|
555
|
+
// not a separate discoverable entity of its own.
|
|
556
|
+
selfPeer: options.selfPeer,
|
|
557
|
+
nexrallMd: subNexrallMd,
|
|
558
|
+
abortSignal: subAbort,
|
|
559
|
+
requestPermission: gatedPermission,
|
|
560
|
+
onText: () => { }, // sub-agent text is returned as the tool result, not streamed live
|
|
561
|
+
// A sub-agent renders nothing live (text and thinking are not streamed — see below),
|
|
562
|
+
// so a restart of ITS stream has nothing on screen to roll back: a real no-op
|
|
563
|
+
// handler is exactly right, and it opts the child into post-render restarts.
|
|
564
|
+
onStreamRestart: () => { },
|
|
565
|
+
// Forward tool events with isSubTask=true so the UI can render a badge
|
|
566
|
+
// instead of prepending "[sub-task]" to the tool name (which caused double-prefix
|
|
567
|
+
// when the name was already labelled, and mixed display concerns into the data layer).
|
|
568
|
+
// Every tool event is PROGRESS: it proves the sub-agent is still doing work, which
|
|
569
|
+
// is what the stall watchdog above measures. Bumping on both use and result means a
|
|
570
|
+
// single very slow tool (a long test run) resets the clock when it starts AND when
|
|
571
|
+
// it finishes, so it cannot be mistaken for a hang.
|
|
572
|
+
// `parentId` = the `task` call that spawned THIS child. Set only if not already set,
|
|
573
|
+
// so a grandchild's events keep pointing at their own (nearest) task row.
|
|
574
|
+
onToolUse: (n, i, _s, m) => { bumpProgress(); subToolCalls++; options.onToolUse(n, i, true, childMeta(m)); },
|
|
575
|
+
onToolResult: (n, r, _s, m) => { bumpProgress(); options.onToolResult(n, r, true, childMeta(m)); },
|
|
576
|
+
// A long command streaming output is working, not hung.
|
|
577
|
+
onToolStreamChunk: (n, c, _s, m) => { bumpProgress(); options.onToolStreamChunk?.(n, c, true, childMeta(m)); },
|
|
578
|
+
// Thinking is progress (a model reasoning for minutes is working, not stalled), but
|
|
579
|
+
// it is NOT forwarded to the UI: parallel siblings' deltas interleaved into one live
|
|
580
|
+
// thinking block, and Claude Code does not show sub-agent reasoning either. The
|
|
581
|
+
// sub-agent's tool rows (grouped under its task row) are its visible progress.
|
|
582
|
+
onThinking: () => { bumpProgress(); },
|
|
583
|
+
onThinkingDelta: () => { bumpProgress(); },
|
|
584
|
+
onThinkingProgress: () => { bumpProgress(); },
|
|
585
|
+
});
|
|
586
|
+
const raced = await (0, subTaskSupport_1.raceHardStop)(childRun, subAbort);
|
|
587
|
+
if (raced === subTaskSupport_1.HARD_STOPPED) {
|
|
588
|
+
// The child was told to stop (stall watchdog, parent Stop, background stop) but a
|
|
589
|
+
// tool it is running ignored cancellation — an MCP call, an editor-side tool. The
|
|
590
|
+
// parent must not hang on it forever; the orphaned call is left to finish alone.
|
|
591
|
+
return finalize({
|
|
592
|
+
error: `Sub-task did not stop within ${Math.round((0, subTaskSupport_1.hardStopGraceMs)() / 1000)}s of being stopped: a tool it was ` +
|
|
593
|
+
'running ignored cancellation. Its partial work could not be collected. Do not re-run it as-is — ' +
|
|
594
|
+
'narrow the task, or avoid the tool that hung.',
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const result = raced;
|
|
598
|
+
// ── Stall timeout: SALVAGE, don't discard ────────────────────────────────
|
|
599
|
+
//
|
|
600
|
+
// The sub-agent hit its wall-clock cap (rather than finishing, or the parent
|
|
601
|
+
// aborting). This used to return ONLY an error string — throwing away
|
|
602
|
+
// everything the sub-agent had produced in up to ten minutes of work. The
|
|
603
|
+
// tokens were billed in full either way, and the parent model, told merely
|
|
604
|
+
// that "it stalled", would routinely re-run the identical work from scratch.
|
|
605
|
+
//
|
|
606
|
+
// The timeout still has to be reported unmistakably (the parent must not
|
|
607
|
+
// mistake a truncated run for a complete answer), but it is reported ALONGSIDE
|
|
608
|
+
// whatever was actually accomplished, not instead of it. `preferLast: false`
|
|
609
|
+
// because a killed sub-agent rarely has a closing summary — its useful output
|
|
610
|
+
// is spread across the assistant turns it did manage to produce.
|
|
611
|
+
if (stalled && !options.abortSignal?.aborted) {
|
|
612
|
+
const mins = Math.round(subtaskTimeoutMs / 60000);
|
|
613
|
+
const partial = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(result, false));
|
|
614
|
+
const progress = (0, subTaskSupport_1.summariseSubTaskProgress)(result);
|
|
615
|
+
// Keep the transcript so the parent can CONTINUE this run instead of redoing it.
|
|
616
|
+
//
|
|
617
|
+
// Previously only a cleanly-finished sub-agent was remembered, on the reasoning
|
|
618
|
+
// that a transcript ending mid-thought is unsafe to build on. The reasoning is
|
|
619
|
+
// sound; the conclusion was too strong. Refusing to store it meant a stalled
|
|
620
|
+
// sub-agent's entire body of work — dozens of tool calls, already billed — was
|
|
621
|
+
// unreachable, so the parent's only option was the very thing we tell it not to
|
|
622
|
+
// do: run the whole task again. Resuming is now POSSIBLE but never implied to be
|
|
623
|
+
// safe: the text below states plainly that the work is unverified, and resumption
|
|
624
|
+
// re-authorises against current permissions exactly as it does for a clean run.
|
|
625
|
+
const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope, options.sessionId);
|
|
626
|
+
const sections = [
|
|
627
|
+
`Sub-task STOPPED after ${mins} minutes with NO PROGRESS (it was not making tool calls or ` +
|
|
628
|
+
'producing output) — treat everything below as PARTIAL, unverified work, not a finished answer.',
|
|
629
|
+
progress,
|
|
630
|
+
partial ? `Partial output before it was stopped:\n\n${partial}` : '',
|
|
631
|
+
`Do NOT re-run the same sub-task from scratch. Either build on what is above, or continue THIS ` +
|
|
632
|
+
`run with resume_agent_id="${partialId}" (it still has everything it read), or split the ` +
|
|
633
|
+
'remaining work into smaller, more focused sub-tasks.',
|
|
634
|
+
].filter(Boolean);
|
|
635
|
+
// Returned as `error` (not `output`) on purpose: the loop's ledger counts an
|
|
636
|
+
// errored call as a non-effect, which is right — nothing here is verified —
|
|
637
|
+
// and the STALL_LIMIT runaway guard must still see repeated timeouts as
|
|
638
|
+
// failures so a permanently stuck sub-task can't loop forever.
|
|
639
|
+
return finalize({ error: sections.join('\n\n') });
|
|
640
|
+
}
|
|
641
|
+
// ── Stopped abnormally (turn limit, repeated failures, truncation, no balance) ──
|
|
642
|
+
// runAgentLoop RETURNS normally for these, so this used to fall through to the
|
|
643
|
+
// success path: the parent was handed a fragment as if it were the finished report,
|
|
644
|
+
// while the explanation went to the user's chat as if the main agent had stopped.
|
|
645
|
+
// Assigned inside callbacks, so TS narrows them to `null` here without the casts.
|
|
646
|
+
const stopReasonOfChild = childStop;
|
|
647
|
+
const noticeOfChild = childNotice;
|
|
648
|
+
if (stopReasonOfChild && subTaskSupport_1.ABNORMAL_SUBAGENT_STOPS.has(stopReasonOfChild) && !subAbort.aborted) {
|
|
649
|
+
const partial = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(result, false));
|
|
650
|
+
const progress = (0, subTaskSupport_1.summariseSubTaskProgress)(result);
|
|
651
|
+
const partialId = resumed
|
|
652
|
+
? ((0, agentRegistry_1.updateAgent)(resumed.id, result, agentScope, options.sessionId), resumed.id)
|
|
653
|
+
: (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope, options.sessionId);
|
|
654
|
+
const why = stopReasonOfChild === 'budget'
|
|
655
|
+
? `it reached its turn limit (${agent?.maxTurns ?? subTaskSupport_1.DEFAULT_SUBAGENT_MAX_TURNS} model round-trips)`
|
|
656
|
+
: `it stopped early (${stopReasonOfChild})`;
|
|
657
|
+
const sections = [
|
|
658
|
+
`Sub-task did NOT finish: ${why}. Treat everything below as PARTIAL, unverified work.`,
|
|
659
|
+
noticeOfChild ? noticeOfChild.trim() : '',
|
|
660
|
+
progress,
|
|
661
|
+
partial ? `Partial output:\n\n${partial}` : '',
|
|
662
|
+
`To continue it with everything it already read, call task with resume_agent_id="${partialId}".`,
|
|
663
|
+
].filter(Boolean);
|
|
664
|
+
return finalize({ error: sections.join('\n\n') });
|
|
665
|
+
}
|
|
666
|
+
// Normal completion: the final assistant message is the sub-agent's answer.
|
|
667
|
+
const text = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(result, true));
|
|
668
|
+
// Store the transcript so a follow-up can continue this agent rather than
|
|
669
|
+
// re-running it from scratch, and tell the parent the id.
|
|
670
|
+
//
|
|
671
|
+
// (The stalled and stopped-early paths above register their transcripts too, marked
|
|
672
|
+
// as partial work in the text they return; only a thrown failure is not resumable.)
|
|
673
|
+
const agentId = resumed
|
|
674
|
+
? ((0, agentRegistry_1.updateAgent)(resumed.id, result, agentScope, options.sessionId), resumed.id)
|
|
675
|
+
: (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope, options.sessionId);
|
|
676
|
+
const body = text || '(sub-task completed with no text output)';
|
|
677
|
+
return finalize({
|
|
678
|
+
output: `${body}\n\n[resumable: this sub-agent is "${agentId}". To ask IT a follow-up — keeping ` +
|
|
679
|
+
'everything it already read and concluded — call task again with resume_agent_id="' + agentId +
|
|
680
|
+
'" instead of writing a new prompt from scratch.]',
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
// Same salvage rule as the timeout path above, for the other way a sub-agent
|
|
685
|
+
// dies: runAgentLoop throws AgentTurnError when its stream fails, and that
|
|
686
|
+
// error CARRIES the history completed up to the failure precisely so callers
|
|
687
|
+
// don't lose it (see types.ts). Discarding it here — as this catch used to —
|
|
688
|
+
// reproduced the exact waste that class was written to prevent, one level down.
|
|
689
|
+
const salvaged = (0, types_1.salvageHistory)(err);
|
|
690
|
+
if (salvaged) {
|
|
691
|
+
const partial = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(salvaged, false));
|
|
692
|
+
const progress = (0, subTaskSupport_1.summariseSubTaskProgress)(salvaged);
|
|
693
|
+
const sections = [
|
|
694
|
+
`Sub-task FAILED before completing: ${err.message}`,
|
|
695
|
+
progress,
|
|
696
|
+
partial ? `Partial output before the failure:\n\n${partial}` : '',
|
|
697
|
+
'Treat the above as PARTIAL, unverified work. Build on it rather than re-running the whole sub-task.',
|
|
698
|
+
].filter(Boolean);
|
|
699
|
+
return finalize({ error: sections.join('\n\n') });
|
|
700
|
+
}
|
|
701
|
+
return finalize({ error: `Sub-task failed: ${err.message}` });
|
|
702
|
+
}
|
|
703
|
+
finally {
|
|
704
|
+
clearInterval(stallWatchdog);
|
|
705
|
+
clearInterval(parentAbortPoll);
|
|
706
|
+
// Every return above already ran finalize(); this only catches an unexpected path.
|
|
707
|
+
if (isoState && !(0, worktree_1.worktreeHasWork)(isoState))
|
|
708
|
+
(0, worktree_1.removeWorktree)(isoState, { force: true });
|
|
709
|
+
if (resumed)
|
|
710
|
+
(0, agentRegistry_1.releaseAgentForResume)(resumed.id);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
//# sourceMappingURL=subTask.js.map
|