@nexrall/code-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/agent/agentTypes.d.ts +17 -0
- package/dist/agent/agentTypes.d.ts.map +1 -0
- package/dist/agent/agentTypes.js +156 -0
- package/dist/agent/loop.d.ts +3 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +775 -0
- package/dist/api/client.d.ts +27 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +414 -0
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +95 -0
- package/dist/checkpoint/manager.d.ts +86 -0
- package/dist/checkpoint/manager.d.ts.map +1 -0
- package/dist/checkpoint/manager.js +409 -0
- package/dist/commands/loader.d.ts +18 -0
- package/dist/commands/loader.d.ts.map +1 -0
- package/dist/commands/loader.js +183 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/mcp/client.d.ts +46 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +128 -0
- package/dist/mcp/httpClient.d.ts +25 -0
- package/dist/mcp/httpClient.d.ts.map +1 -0
- package/dist/mcp/httpClient.js +143 -0
- package/dist/mcp/manager.d.ts +56 -0
- package/dist/mcp/manager.d.ts.map +1 -0
- package/dist/mcp/manager.js +234 -0
- package/dist/mcp/sseClient.d.ts +30 -0
- package/dist/mcp/sseClient.d.ts.map +1 -0
- package/dist/mcp/sseClient.js +224 -0
- package/dist/permissions/rules.d.ts +20 -0
- package/dist/permissions/rules.d.ts.map +1 -0
- package/dist/permissions/rules.js +218 -0
- package/dist/plugins/index.d.ts +18 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +139 -0
- package/dist/tools/executor.d.ts +6 -0
- package/dist/tools/executor.d.ts.map +1 -0
- package/dist/tools/executor.js +1458 -0
- package/dist/tools/sandbox.d.ts +13 -0
- package/dist/tools/sandbox.d.ts.map +1 -0
- package/dist/tools/sandbox.js +140 -0
- package/dist/tools/symbols.d.ts +13 -0
- package/dist/tools/symbols.d.ts.map +1 -0
- package/dist/tools/symbols.js +279 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +92 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runAgentLoop = runAgentLoop;
|
|
37
|
+
const client_1 = require("../api/client");
|
|
38
|
+
const executor_1 = require("../tools/executor");
|
|
39
|
+
const agentTypes_1 = require("./agentTypes");
|
|
40
|
+
const rules_1 = require("../permissions/rules");
|
|
41
|
+
const sandbox_1 = require("../tools/sandbox");
|
|
42
|
+
const index_1 = require("../plugins/index");
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const child_process_1 = require("child_process");
|
|
46
|
+
function loadHooks(workDir) {
|
|
47
|
+
let fromSettings = {};
|
|
48
|
+
try {
|
|
49
|
+
const p = path.join(workDir, '.nexrall', 'settings.json');
|
|
50
|
+
if (fs.existsSync(p))
|
|
51
|
+
fromSettings = JSON.parse(fs.readFileSync(p, 'utf-8')).hooks ?? {};
|
|
52
|
+
}
|
|
53
|
+
catch { /* ignore */ }
|
|
54
|
+
// Merge plugin-provided hooks AFTER the project's own (project hooks run first).
|
|
55
|
+
const fromPlugins = (0, index_1.pluginHooks)(workDir);
|
|
56
|
+
const merged = { ...fromSettings };
|
|
57
|
+
for (const phase of Object.keys(fromPlugins)) {
|
|
58
|
+
const extra = fromPlugins[phase];
|
|
59
|
+
if (!Array.isArray(extra) || !extra.length)
|
|
60
|
+
continue;
|
|
61
|
+
merged[phase] = [
|
|
62
|
+
...((merged[phase]) ?? []),
|
|
63
|
+
...extra,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
// Run PreToolUse / PostToolUse hooks with a Claude-Code-style control protocol.
|
|
69
|
+
//
|
|
70
|
+
// Each hook command receives a JSON payload on stdin and NEXRALL_TOOL_* env vars.
|
|
71
|
+
// It controls the agent via:
|
|
72
|
+
// β’ exit code 2 β BLOCK the tool; stderr becomes the reason
|
|
73
|
+
// β’ stdout JSON object β { "decision": "block"|"allow", "reason": "...",
|
|
74
|
+
// "additionalContext": "text to feed the model" }
|
|
75
|
+
// β’ any other exit code β non-blocking (stderr logged, tool proceeds)
|
|
76
|
+
function runToolHooks(entries, phase, toolName, input, workDir, result) {
|
|
77
|
+
const outcome = { block: false };
|
|
78
|
+
if (!entries?.length)
|
|
79
|
+
return outcome;
|
|
80
|
+
const payload = JSON.stringify({
|
|
81
|
+
phase,
|
|
82
|
+
tool: toolName,
|
|
83
|
+
input,
|
|
84
|
+
...(result ? { result: { output: result.output, error: result.error } } : {}),
|
|
85
|
+
});
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.matcher && entry.matcher !== '*' && !toolName.includes(entry.matcher))
|
|
88
|
+
continue;
|
|
89
|
+
for (const hook of entry.hooks ?? []) {
|
|
90
|
+
if (hook.type !== 'command' || !hook.command)
|
|
91
|
+
continue;
|
|
92
|
+
let r;
|
|
93
|
+
try {
|
|
94
|
+
r = (0, child_process_1.spawnSync)(hook.command, {
|
|
95
|
+
shell: true,
|
|
96
|
+
cwd: workDir,
|
|
97
|
+
timeout: 10000,
|
|
98
|
+
encoding: 'utf-8',
|
|
99
|
+
input: payload,
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
NEXRALL_TOOL_NAME: toolName,
|
|
103
|
+
NEXRALL_TOOL_INPUT: JSON.stringify(input),
|
|
104
|
+
NEXRALL_HOOK_PHASE: phase,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue; // hook itself failed to spawn β non-fatal
|
|
110
|
+
}
|
|
111
|
+
// Optional JSON directive on stdout
|
|
112
|
+
const out = (r.stdout ?? '').toString().trim();
|
|
113
|
+
if (out.startsWith('{')) {
|
|
114
|
+
try {
|
|
115
|
+
const j = JSON.parse(out);
|
|
116
|
+
if (j.decision === 'block') {
|
|
117
|
+
outcome.block = true;
|
|
118
|
+
outcome.reason = j.reason ?? outcome.reason ?? 'Blocked by hook';
|
|
119
|
+
}
|
|
120
|
+
if (typeof j.additionalContext === 'string' && j.additionalContext) {
|
|
121
|
+
outcome.context = (outcome.context ? outcome.context + '\n' : '') + j.additionalContext;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { /* not a directive β ignore */ }
|
|
125
|
+
}
|
|
126
|
+
// Exit code 2 β hard block; stderr is the reason fed back to the model
|
|
127
|
+
if (r.status === 2) {
|
|
128
|
+
outcome.block = true;
|
|
129
|
+
const err = (r.stderr ?? '').toString().trim();
|
|
130
|
+
outcome.reason = err || outcome.reason || `Blocked by ${phase} hook`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return outcome;
|
|
135
|
+
}
|
|
136
|
+
function runSimpleHooks(defs, workDir) {
|
|
137
|
+
if (!defs?.length)
|
|
138
|
+
return;
|
|
139
|
+
for (const hook of defs) {
|
|
140
|
+
if (hook.type === 'command' && hook.command) {
|
|
141
|
+
try {
|
|
142
|
+
(0, child_process_1.spawnSync)(hook.command, { shell: true, cwd: workDir, timeout: 10000 });
|
|
143
|
+
}
|
|
144
|
+
catch { /* non-fatal */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// βββ Iteration cap ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
149
|
+
// Each iteration is one model response + one round of tool execution. The cap is
|
|
150
|
+
// a runaway-loop backstop, NOT a task-size limit β on a large project a single
|
|
151
|
+
// task can legitimately need well over 50 rounds (read β search β edit β test β
|
|
152
|
+
// fix β β¦). A too-low cap makes the agent appear to "freeze" mid-task. Keep the
|
|
153
|
+
// default high and let projects raise it further via settings / env.
|
|
154
|
+
const DEFAULT_MAX_ITERATIONS = 500;
|
|
155
|
+
const MAX_ITERATIONS_CEILING = 2000; // absolute backstop β auto-continue never goes past this
|
|
156
|
+
const STALL_LIMIT = 8; // consecutive all-failed tool rounds β give up (runaway guard)
|
|
157
|
+
// Resolve the soft iteration budget. Precedence:
|
|
158
|
+
// options.maxIterations β env NEXRALL_MAX_ITERATIONS β settings.maxIterations β default
|
|
159
|
+
function resolveMaxIterations(optionValue, settingsRaw) {
|
|
160
|
+
const fromEnv = Number(process.env.NEXRALL_MAX_ITERATIONS);
|
|
161
|
+
const fromSettings = Number(settingsRaw.maxIterations);
|
|
162
|
+
const candidate = (typeof optionValue === 'number' && optionValue > 0) ? optionValue
|
|
163
|
+
: Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv
|
|
164
|
+
: Number.isFinite(fromSettings) && fromSettings > 0 ? fromSettings
|
|
165
|
+
: DEFAULT_MAX_ITERATIONS;
|
|
166
|
+
return Math.min(Math.floor(candidate), MAX_ITERATIONS_CEILING);
|
|
167
|
+
}
|
|
168
|
+
// When the soft budget is exhausted with work still pending, keep going instead
|
|
169
|
+
// of stopping. Precedence: options.autoContinue β env NEXRALL_AUTO_CONTINUE β
|
|
170
|
+
// settings.autoContinue β default (on). Bounded by STALL_LIMIT and the ceiling.
|
|
171
|
+
function resolveAutoContinue(optionValue, settingsRaw) {
|
|
172
|
+
if (typeof optionValue === 'boolean')
|
|
173
|
+
return optionValue;
|
|
174
|
+
const env = process.env.NEXRALL_AUTO_CONTINUE;
|
|
175
|
+
if (env === '0' || env === 'false')
|
|
176
|
+
return false;
|
|
177
|
+
if (env === '1' || env === 'true')
|
|
178
|
+
return true;
|
|
179
|
+
const s = settingsRaw.autoContinue;
|
|
180
|
+
if (typeof s === 'boolean')
|
|
181
|
+
return s;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
// βββ Per-file write lock ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
185
|
+
// When the model emits multiple tool_use blocks in one turn (executed via
|
|
186
|
+
// Promise.all), two edits to the same file race: both read the original, both
|
|
187
|
+
// write their version, and the second write silently discards the first edit.
|
|
188
|
+
// This lock serialises writes per absolute path to prevent that.
|
|
189
|
+
const _fileLocks = new Map();
|
|
190
|
+
async function withFileLock(absPath, fn) {
|
|
191
|
+
const prev = _fileLocks.get(absPath) ?? Promise.resolve();
|
|
192
|
+
let releaseLock;
|
|
193
|
+
const next = new Promise((res) => { releaseLock = res; });
|
|
194
|
+
_fileLocks.set(absPath, prev.then(() => next));
|
|
195
|
+
try {
|
|
196
|
+
await prev; // wait for any in-flight operation on this file
|
|
197
|
+
return await fn();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
releaseLock();
|
|
201
|
+
// Cleanup: remove the entry once the chain is idle to avoid unbounded growth
|
|
202
|
+
if (_fileLocks.get(absPath) === next)
|
|
203
|
+
_fileLocks.delete(absPath);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// βββ Human-readable tool descriptions ββββββββββββββββββββββββββββββββββββββββ
|
|
207
|
+
function humanDescription(name, input) {
|
|
208
|
+
switch (name) {
|
|
209
|
+
case 'read_file':
|
|
210
|
+
return `Read file: ${input.path ?? '(unknown)'}`;
|
|
211
|
+
case 'write_file': {
|
|
212
|
+
const content = typeof input.content === 'string' ? input.content : '';
|
|
213
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
214
|
+
return `Write file: ${input.path ?? '(unknown)'} (${bytes} bytes)`;
|
|
215
|
+
}
|
|
216
|
+
case 'list_directory':
|
|
217
|
+
return `List directory: ${input.path ?? '.'}`;
|
|
218
|
+
case 'bash':
|
|
219
|
+
return `Run: ${input.command ?? '(unknown)'}`;
|
|
220
|
+
case 'search_files': {
|
|
221
|
+
const searchType = input.type === 'filename' ? 'filename' : 'content';
|
|
222
|
+
const inPath = input.path ? ` in ${input.path}` : '';
|
|
223
|
+
return `Search ${searchType}: "${input.pattern ?? ''}"${inPath}`;
|
|
224
|
+
}
|
|
225
|
+
case 'create_directory':
|
|
226
|
+
return `Create directory: ${input.path ?? '(unknown)'}`;
|
|
227
|
+
case 'move_file':
|
|
228
|
+
return `Move file: ${input.source ?? '(unknown)'} β ${input.dest ?? '(unknown)'}`;
|
|
229
|
+
case 'copy_file':
|
|
230
|
+
return `Copy file: ${input.source ?? '(unknown)'} β ${input.destination ?? '(unknown)'}`;
|
|
231
|
+
case 'edit_file':
|
|
232
|
+
return `Edit file: ${input.path ?? '(unknown)'}`;
|
|
233
|
+
case 'multi_edit': {
|
|
234
|
+
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
235
|
+
return `Multi-edit file: ${input.path ?? '(unknown)'} (${edits.length} change${edits.length !== 1 ? 's' : ''})`;
|
|
236
|
+
}
|
|
237
|
+
case 'glob':
|
|
238
|
+
return `Glob: ${input.pattern ?? '(unknown)'}${input.path ? ` in ${input.path}` : ''}`;
|
|
239
|
+
case 'todo_write': {
|
|
240
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
241
|
+
return `Update task list (${todos.length} item${todos.length !== 1 ? 's' : ''})`;
|
|
242
|
+
}
|
|
243
|
+
case 'todo_read':
|
|
244
|
+
return 'Read task list';
|
|
245
|
+
case 'notebook_read':
|
|
246
|
+
return `Read notebook: ${input.path ?? '(unknown)'}`;
|
|
247
|
+
case 'notebook_edit': {
|
|
248
|
+
const t = input.edit_type ?? 'edit';
|
|
249
|
+
return `Notebook ${t}: ${input.path ?? '(unknown)'} cell ${input.cell_index ?? '?'}`;
|
|
250
|
+
}
|
|
251
|
+
case 'delete_file':
|
|
252
|
+
return `Delete file: ${input.path ?? '(unknown)'}`;
|
|
253
|
+
case 'bash_output':
|
|
254
|
+
return `Read background shell: ${input.shell_id ?? '(unknown)'}`;
|
|
255
|
+
case 'kill_shell':
|
|
256
|
+
return `Kill background shell: ${input.shell_id ?? '(unknown)'}`;
|
|
257
|
+
case 'fetch_url':
|
|
258
|
+
return `Fetch URL: ${input.url ?? '(unknown)'}`;
|
|
259
|
+
case 'generate_image':
|
|
260
|
+
return `Generate image β ${input.path ?? '(unknown)'}`;
|
|
261
|
+
case 'stock_photo':
|
|
262
|
+
return input.path
|
|
263
|
+
? `Stock photo "${input.query ?? ''}" β ${input.path}`
|
|
264
|
+
: `Search stock photos: "${input.query ?? ''}"`;
|
|
265
|
+
case 'task': {
|
|
266
|
+
const desc = typeof input.description === 'string' ? input.description : '';
|
|
267
|
+
const preview = typeof input.prompt === 'string' ? input.prompt.slice(0, 60) : '';
|
|
268
|
+
return `Sub-task: ${desc || preview}${!desc && preview.length === 60 ? 'β¦' : ''}`;
|
|
269
|
+
}
|
|
270
|
+
case 'get_diagnostics':
|
|
271
|
+
return input.path ? `Get diagnostics: ${input.path}` : 'Get workspace diagnostics';
|
|
272
|
+
case 'go_to_definition':
|
|
273
|
+
return `Go to definition: ${input.path ?? ''}:${input.line ?? ''}:${input.character ?? ''}`;
|
|
274
|
+
case 'find_references':
|
|
275
|
+
return `Find references: ${input.path ?? ''}:${input.line ?? ''}:${input.character ?? ''}`;
|
|
276
|
+
case 'get_symbols':
|
|
277
|
+
return `Get symbols: ${input.path ?? '(unknown)'}`;
|
|
278
|
+
case 'get_workspace_symbols':
|
|
279
|
+
return `Search symbols: "${input.query ?? ''}"`;
|
|
280
|
+
case 'get_hover':
|
|
281
|
+
return `Get hover info: ${input.path ?? ''}:${input.line ?? ''}:${input.character ?? ''}`;
|
|
282
|
+
case 'open_in_browser':
|
|
283
|
+
return `Open in browser: ${input.url ?? '(unknown)'}`;
|
|
284
|
+
default:
|
|
285
|
+
return `Use tool: ${name}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// βββ Sub-task runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
289
|
+
const MAX_TASK_DEPTH = 2; // main agent (0) can spawn sub-agents (1) β sub-agents cannot spawn further
|
|
290
|
+
let _subTaskCounter = 0; // unique per-process id β per-sub-agent todo scope
|
|
291
|
+
async function runSubTask(input, options, agentTypes) {
|
|
292
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
293
|
+
if (!prompt)
|
|
294
|
+
return { error: 'task tool requires a non-empty prompt' };
|
|
295
|
+
const depth = options._depth ?? 0;
|
|
296
|
+
if (depth >= MAX_TASK_DEPTH) {
|
|
297
|
+
return { error: `Sub-task depth limit (${MAX_TASK_DEPTH}) reached β sub-agents cannot spawn further sub-agents.` };
|
|
298
|
+
}
|
|
299
|
+
// Resolve an optional custom agent type (subagent_type).
|
|
300
|
+
const requestedType = typeof input.subagent_type === 'string' ? input.subagent_type : '';
|
|
301
|
+
const agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
|
|
302
|
+
if (requestedType && !agent) {
|
|
303
|
+
const known = agentTypes.map((a) => a.name).join(', ') || '(none defined)';
|
|
304
|
+
return { error: `Unknown subagent_type "${requestedType}". Available types: ${known}.` };
|
|
305
|
+
}
|
|
306
|
+
// A custom agent's persona is delivered through the project-instructions
|
|
307
|
+
// channel (authoritative in the system prompt), layered above the project's
|
|
308
|
+
// own nexrall.md so it keeps project conventions.
|
|
309
|
+
const subNexrallMd = agent
|
|
310
|
+
? `# Sub-agent role: ${agent.name}\n${agent.prompt}` +
|
|
311
|
+
(options.nexrallMd ? `\n\n---\n\n${options.nexrallMd}` : '')
|
|
312
|
+
: options.nexrallMd;
|
|
313
|
+
// Optional tool allowlist β deny anything outside it for this sub-agent.
|
|
314
|
+
const allowed = agent?.tools ? new Set(agent.tools) : null;
|
|
315
|
+
const gatedPermission = async (req) => {
|
|
316
|
+
if (allowed && !allowed.has(req.tool))
|
|
317
|
+
return false;
|
|
318
|
+
return options.requestPermission(req);
|
|
319
|
+
};
|
|
320
|
+
const subMessages = [
|
|
321
|
+
{ role: 'user', content: [{ type: 'text', text: prompt }] },
|
|
322
|
+
];
|
|
323
|
+
try {
|
|
324
|
+
const result = await runAgentLoop(subMessages, {
|
|
325
|
+
...options,
|
|
326
|
+
_depth: depth + 1,
|
|
327
|
+
_agentScope: `sub_${++_subTaskCounter}`, // isolated todo store per sub-agent
|
|
328
|
+
editorContext: null, // fresh isolated context for sub-agent
|
|
329
|
+
model: agent?.model ?? options.model,
|
|
330
|
+
nexrallMd: subNexrallMd,
|
|
331
|
+
requestPermission: gatedPermission,
|
|
332
|
+
onText: () => { }, // sub-agent text is returned as the tool result, not streamed live
|
|
333
|
+
// Forward tool events with isSubTask=true so the UI can render a badge
|
|
334
|
+
// instead of prepending "[sub-task]" to the tool name (which caused double-prefix
|
|
335
|
+
// when the name was already labelled, and mixed display concerns into the data layer).
|
|
336
|
+
onToolUse: (n, i) => options.onToolUse(n, i, true),
|
|
337
|
+
onToolResult: (n, r) => options.onToolResult(n, r, true),
|
|
338
|
+
// Forward thinking so the UI shows the indicator while sub-agent reasons
|
|
339
|
+
onThinking: (text) => options.onThinking?.(text),
|
|
340
|
+
onThinkingDelta: (text) => options.onThinkingDelta?.(text),
|
|
341
|
+
onThinkingProgress: (tok) => options.onThinkingProgress?.(tok),
|
|
342
|
+
});
|
|
343
|
+
// Extract final assistant text as the task output
|
|
344
|
+
const lastAssistant = [...result].reverse().find((m) => m.role === 'assistant');
|
|
345
|
+
const text = (lastAssistant?.content ?? [])
|
|
346
|
+
.filter((b) => b.type === 'text')
|
|
347
|
+
.map((b) => b.text)
|
|
348
|
+
.join('')
|
|
349
|
+
.trim();
|
|
350
|
+
return { output: text || '(sub-task completed with no text output)' };
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
return { error: `Sub-task failed: ${err.message}` };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// βββ Auto-compact βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
357
|
+
//
|
|
358
|
+
// When the conversation's prompt size approaches the model's context window,
|
|
359
|
+
// summarise the older portion automatically (Claude-Code style) instead of
|
|
360
|
+
// letting the request fail or forcing the user to run /compact by hand.
|
|
361
|
+
//
|
|
362
|
+
// Compaction only happens at a turn boundary (top of the loop, before the next
|
|
363
|
+
// streamChat) and only cuts at a "safe" user message β one with no tool_result
|
|
364
|
+
// blocks β so tool_use/tool_result pairing is never broken.
|
|
365
|
+
const MODEL_CONTEXT_TOKENS = {
|
|
366
|
+
turbo: 200000,
|
|
367
|
+
pro: 200000,
|
|
368
|
+
ultra: 1000000,
|
|
369
|
+
};
|
|
370
|
+
const AUTO_COMPACT_THRESHOLD = 0.8; // compact when prompt > 80% of the window
|
|
371
|
+
const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
|
|
372
|
+
function resolveAutoCompact(fromOptions, rawSettings) {
|
|
373
|
+
if (typeof fromOptions === 'boolean')
|
|
374
|
+
return fromOptions;
|
|
375
|
+
const env = (process.env.NEXRALL_AUTO_COMPACT ?? '').toLowerCase();
|
|
376
|
+
if (env === '0' || env === 'false' || env === 'off')
|
|
377
|
+
return false;
|
|
378
|
+
if (env === '1' || env === 'true' || env === 'on')
|
|
379
|
+
return true;
|
|
380
|
+
const s = rawSettings?.autoCompact;
|
|
381
|
+
if (typeof s === 'boolean')
|
|
382
|
+
return s;
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/** Find the latest index β€ maxIdx where history can be cut safely (user msg, no tool_results). */
|
|
386
|
+
function findSafeCutIndex(messages, maxIdx) {
|
|
387
|
+
for (let i = Math.min(maxIdx, messages.length - 1); i > 0; i--) {
|
|
388
|
+
const m = messages[i];
|
|
389
|
+
if (m.role !== 'user')
|
|
390
|
+
continue;
|
|
391
|
+
const hasToolResult = m.content.some((b) => b.type === 'tool_result');
|
|
392
|
+
if (!hasToolResult)
|
|
393
|
+
return i;
|
|
394
|
+
}
|
|
395
|
+
return -1;
|
|
396
|
+
}
|
|
397
|
+
/** Render messages to a plain-text transcript for the summariser (tool noise truncated). */
|
|
398
|
+
function transcriptOf(messages) {
|
|
399
|
+
const parts = [];
|
|
400
|
+
for (const m of messages) {
|
|
401
|
+
for (const b of m.content) {
|
|
402
|
+
if (b.type === 'text' && b.text) {
|
|
403
|
+
parts.push(`${m.role.toUpperCase()}: ${b.text.slice(0, 2000)}`);
|
|
404
|
+
}
|
|
405
|
+
else if (b.type === 'tool_use') {
|
|
406
|
+
parts.push(`${m.role.toUpperCase()} [tool: ${b.name}]: ${JSON.stringify(b.input ?? {}).slice(0, 400)}`);
|
|
407
|
+
}
|
|
408
|
+
else if (b.type === 'tool_result') {
|
|
409
|
+
parts.push(`TOOL RESULT: ${String(b.content ?? '').slice(0, 600)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return parts.join('\n');
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Compact `messages` in place: summarise everything before a safe cut point and
|
|
417
|
+
* replace it with a summary preamble. Returns true if compaction happened.
|
|
418
|
+
*/
|
|
419
|
+
async function autoCompactMessages(messages, options) {
|
|
420
|
+
const cut = findSafeCutIndex(messages, messages.length - COMPACT_KEEP_MIN);
|
|
421
|
+
if (cut < 2)
|
|
422
|
+
return false; // nothing meaningful to fold
|
|
423
|
+
const toSummarize = messages.slice(0, cut);
|
|
424
|
+
const kept = messages.slice(cut);
|
|
425
|
+
const summaryPrompt = `Summarize this coding-session transcript into concise bullet points the assistant needs to continue the work: ` +
|
|
426
|
+
`key decisions, files changed (and how), commands run, unresolved problems, and user preferences. Max 400 words.\n\n` +
|
|
427
|
+
transcriptOf(toSummarize);
|
|
428
|
+
let summary = '';
|
|
429
|
+
try {
|
|
430
|
+
const reply = await (0, client_1.streamChat)([{ role: 'user', content: [{ type: 'text', text: summaryPrompt }] }], {
|
|
431
|
+
model: options.model ?? 'turbo',
|
|
432
|
+
mode: 'ask', // summariser must not call tools; ask-mode discourages action
|
|
433
|
+
env: options.env,
|
|
434
|
+
clientType: options.clientType,
|
|
435
|
+
abortSignal: options.abortSignal,
|
|
436
|
+
}, () => { });
|
|
437
|
+
summary = reply.content
|
|
438
|
+
.filter((b) => b.type === 'text')
|
|
439
|
+
.map((b) => b.text ?? '')
|
|
440
|
+
.join('')
|
|
441
|
+
.trim();
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return false; // summarisation failed β leave history as is, the turn may still fit
|
|
445
|
+
}
|
|
446
|
+
if (!summary)
|
|
447
|
+
return false;
|
|
448
|
+
messages.splice(0, cut, { role: 'user', content: [{ type: 'text', text: `[Auto-compacted ${toSummarize.length} earlier messages]\n\nSummary of the earlier conversation:\n${summary}` }] }, { role: 'assistant', content: [{ type: 'text', text: 'Understood β I have the summary of our earlier work and will continue from there.' }] });
|
|
449
|
+
// `kept` follows automatically since splice only replaced the head.
|
|
450
|
+
void kept;
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
// βββ Agent Loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
454
|
+
async function runAgentLoop(initialMessages, options) {
|
|
455
|
+
const messages = [...initialMessages];
|
|
456
|
+
const model = options.model ?? 'turbo';
|
|
457
|
+
const hooks = loadHooks(options.workDir);
|
|
458
|
+
const depth = options._depth ?? 0;
|
|
459
|
+
const agentScope = options._agentScope ?? 'root';
|
|
460
|
+
// Discover custom sub-agent types. Only the top-level agent is told the
|
|
461
|
+
// catalogue (sub-agents can't spawn further), but every level resolves types.
|
|
462
|
+
const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir);
|
|
463
|
+
const agentsCatalogue = depth === 0 ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
|
|
464
|
+
// Optional OS-level bash sandbox (opt-in via settings.json "sandbox").
|
|
465
|
+
const settings = (0, rules_1.loadSettings)(options.workDir);
|
|
466
|
+
const sandboxCfg = (0, sandbox_1.parseSandboxConfig)(settings.raw.sandbox) ?? undefined;
|
|
467
|
+
// Soft iteration budget + optional auto-continue past it (see resolvers above).
|
|
468
|
+
const maxIterations = resolveMaxIterations(options.maxIterations, settings.raw);
|
|
469
|
+
const autoContinue = resolveAutoContinue(options.autoContinue, settings.raw);
|
|
470
|
+
const autoCompact = resolveAutoCompact(options.autoCompact, settings.raw);
|
|
471
|
+
const contextWindow = MODEL_CONTEXT_TOKENS[model] ?? 200000;
|
|
472
|
+
// Live prompt-size estimate, updated from usage events after every stream.
|
|
473
|
+
let lastPromptTokens = 0;
|
|
474
|
+
let compacting = false; // re-entrancy guard β compaction itself calls streamChat
|
|
475
|
+
// Absolute hard stop: auto-continue extends the budget in maxIterations-sized
|
|
476
|
+
// segments up to this ceiling; without auto-continue, the soft budget IS the cap.
|
|
477
|
+
const hardCap = autoContinue ? Math.max(maxIterations, MAX_ITERATIONS_CEILING) : maxIterations;
|
|
478
|
+
// NOTE: we deliberately do NOT call process.chdir(options.workDir) here.
|
|
479
|
+
// process.cwd() is global process state β mutating it from concurrent sub-agent
|
|
480
|
+
// coroutines (task tool runs multiple sub-agents via Promise.all) causes a race
|
|
481
|
+
// condition where one sub-agent's chdir overwrites another's, making every tool
|
|
482
|
+
// that falls back to process.cwd() resolve paths against the wrong directory.
|
|
483
|
+
// workDir is now threaded explicitly through executeTool β resolvePath instead.
|
|
484
|
+
// Distinguish a clean finish (model produced a final answer) from hitting the
|
|
485
|
+
// iteration backstop, so we can tell the user what happened rather than
|
|
486
|
+
// silently returning a half-done task.
|
|
487
|
+
let completedCleanly = false;
|
|
488
|
+
let stalledOut = false; // tripped the runaway guard (all-failed rounds)
|
|
489
|
+
let consecutiveErrorRounds = 0; // rounds where every tool call errored
|
|
490
|
+
let budget = maxIterations; // extended by auto-continue, capped at hardCap
|
|
491
|
+
let iteration = 0;
|
|
492
|
+
try {
|
|
493
|
+
for (; iteration < budget; iteration++) {
|
|
494
|
+
if (options.abortSignal?.aborted)
|
|
495
|
+
break;
|
|
496
|
+
// Auto-compact: if the last request's prompt crossed the threshold,
|
|
497
|
+
// summarise older history before the next stream so we never hit the
|
|
498
|
+
// context-window wall mid-task. Runs at a turn boundary only.
|
|
499
|
+
if (autoCompact && !compacting && lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD && messages.length > COMPACT_KEEP_MIN + 2) {
|
|
500
|
+
compacting = true;
|
|
501
|
+
try {
|
|
502
|
+
const did = await autoCompactMessages(messages, options);
|
|
503
|
+
if (did) {
|
|
504
|
+
lastPromptTokens = 0; // stale β next usage event refreshes it
|
|
505
|
+
options.onText(`\n\u267b\ufe0f Auto-compacted earlier conversation to stay within the context window.\n`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
compacting = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// When auto-continue is off, nudge the model to wrap up just before the hard
|
|
513
|
+
// cap so it isn't cut off mid-task. (With auto-continue on we WANT it to keep
|
|
514
|
+
// going, so no wrap-up nudge.)
|
|
515
|
+
if (!autoContinue && iteration === budget - 5 && budget > 5) {
|
|
516
|
+
options.onText(`\nβ οΈ Approaching the ${budget}-step limit (step ${iteration + 1}). Please wrap up and summarise what has been done.\n`);
|
|
517
|
+
}
|
|
518
|
+
// Collect tool_use blocks emitted during this stream
|
|
519
|
+
const pendingToolUse = [];
|
|
520
|
+
// Build SSE event handler
|
|
521
|
+
const onEvent = (event) => {
|
|
522
|
+
switch (event.type) {
|
|
523
|
+
case 'text':
|
|
524
|
+
options.onText(event.text);
|
|
525
|
+
break;
|
|
526
|
+
case 'thinking':
|
|
527
|
+
options.onThinking?.(event.text);
|
|
528
|
+
break;
|
|
529
|
+
case 'thinking_delta':
|
|
530
|
+
options.onThinkingDelta?.(event.text);
|
|
531
|
+
break;
|
|
532
|
+
case 'thinking_progress':
|
|
533
|
+
options.onThinkingProgress?.(event.tokens);
|
|
534
|
+
break;
|
|
535
|
+
case 'tool_use':
|
|
536
|
+
// Collect tool use blocks β they may arrive incrementally
|
|
537
|
+
// We use message_complete to finalize them, so just track here
|
|
538
|
+
pendingToolUse.push({
|
|
539
|
+
type: 'tool_use',
|
|
540
|
+
id: event.id,
|
|
541
|
+
name: event.name,
|
|
542
|
+
input: event.input,
|
|
543
|
+
});
|
|
544
|
+
break;
|
|
545
|
+
case 'usage':
|
|
546
|
+
lastPromptTokens =
|
|
547
|
+
(event.usage.input_tokens ?? 0) +
|
|
548
|
+
(event.usage.cache_creation_input_tokens ?? 0) +
|
|
549
|
+
(event.usage.cache_read_input_tokens ?? 0);
|
|
550
|
+
options.onUsage(event.usage);
|
|
551
|
+
break;
|
|
552
|
+
case 'message_complete':
|
|
553
|
+
// The complete message is returned from streamChat, we handle it below
|
|
554
|
+
break;
|
|
555
|
+
case 'done':
|
|
556
|
+
case 'error':
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
// 1. Stream from the API
|
|
561
|
+
let assistantMessage;
|
|
562
|
+
try {
|
|
563
|
+
assistantMessage = await (0, client_1.streamChat)(messages, {
|
|
564
|
+
model,
|
|
565
|
+
mode: options.mode,
|
|
566
|
+
effort: options.effort,
|
|
567
|
+
env: options.env,
|
|
568
|
+
editorContext: options.editorContext,
|
|
569
|
+
nexrallMd: options.nexrallMd,
|
|
570
|
+
clientType: options.clientType,
|
|
571
|
+
abortSignal: options.abortSignal,
|
|
572
|
+
extraTools: options.mcpManager?.getAnthropicTools(),
|
|
573
|
+
agents: agentsCatalogue || undefined,
|
|
574
|
+
}, onEvent);
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
if (options.abortSignal?.aborted || err.name === 'AbortError')
|
|
578
|
+
break;
|
|
579
|
+
runSimpleHooks(hooks.OnError, options.workDir);
|
|
580
|
+
throw new Error(`Stream failed: ${err.message}`);
|
|
581
|
+
}
|
|
582
|
+
if (options.abortSignal?.aborted)
|
|
583
|
+
break;
|
|
584
|
+
// 2. Strip thinking blocks β API rejects them in message history
|
|
585
|
+
assistantMessage.content = assistantMessage.content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking');
|
|
586
|
+
// If the model returned nothing usable (e.g. thinking-only, then stopped), the
|
|
587
|
+
// stripped content is empty. Do NOT push an empty assistant turn into history β it
|
|
588
|
+
// would be sent back on the next request and either error or force a placeholder,
|
|
589
|
+
// and combined with a folded follow-up it leaks an empty turn to the model. Treat
|
|
590
|
+
// it as a no-op step: fold any queued input and continue, otherwise end cleanly.
|
|
591
|
+
if (assistantMessage.content.length === 0) {
|
|
592
|
+
const queued = options.takePendingInput?.() ?? [];
|
|
593
|
+
if (queued.length) {
|
|
594
|
+
const text = queued.join('\n\n');
|
|
595
|
+
queued.forEach((q) => options.onInjectedInput?.(q));
|
|
596
|
+
messages.push({ role: 'user', content: [{ type: 'text', text }] });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
runSimpleHooks(hooks.PostMessageComplete, options.workDir);
|
|
600
|
+
completedCleanly = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
messages.push(assistantMessage);
|
|
604
|
+
// 3. Collect tool_use blocks from the completed message.
|
|
605
|
+
// Server-side tools (e.g. web_search_20260209) are executed by Anthropic and their
|
|
606
|
+
// tool_result blocks appear in the SAME assistant message. We must skip those β
|
|
607
|
+
// sending a second tool_result for them would confuse the API.
|
|
608
|
+
const serverSideResultIds = new Set(assistantMessage.content
|
|
609
|
+
.filter(b => b.type === 'tool_result')
|
|
610
|
+
.map(b => b.tool_use_id)
|
|
611
|
+
.filter((id) => !!id));
|
|
612
|
+
const toolUseBlocks = assistantMessage.content.filter((block) => block.type === 'tool_use' && !serverSideResultIds.has(block.id));
|
|
613
|
+
// 4. If no tool use β agent produced a final text response.
|
|
614
|
+
// But if the user queued a follow-up while we were working, fold it in as the
|
|
615
|
+
// next user turn and keep going instead of ending (Claude-Code style).
|
|
616
|
+
if (toolUseBlocks.length === 0) {
|
|
617
|
+
const queued = options.takePendingInput?.() ?? [];
|
|
618
|
+
if (queued.length) {
|
|
619
|
+
const text = queued.join('\n\n');
|
|
620
|
+
queued.forEach((q) => options.onInjectedInput?.(q));
|
|
621
|
+
messages.push({ role: 'user', content: [{ type: 'text', text }] });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
runSimpleHooks(hooks.PostMessageComplete, options.workDir);
|
|
625
|
+
completedCleanly = true;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
// 5. Execute all tool uses in parallel
|
|
629
|
+
const toolResults = await Promise.all(toolUseBlocks.map(async (block) => {
|
|
630
|
+
const { id, name, input } = block;
|
|
631
|
+
// Notify caller about pending tool use
|
|
632
|
+
options.onToolUse(name, input);
|
|
633
|
+
// Request permission
|
|
634
|
+
const description = humanDescription(name, input);
|
|
635
|
+
let result;
|
|
636
|
+
let permitted;
|
|
637
|
+
try {
|
|
638
|
+
permitted = await options.requestPermission({ tool: name, input, description });
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
permitted = false;
|
|
642
|
+
}
|
|
643
|
+
if (!permitted) {
|
|
644
|
+
result = { error: 'Permission denied by user' };
|
|
645
|
+
}
|
|
646
|
+
else if (name === 'task') {
|
|
647
|
+
result = await runSubTask(input, options, agentTypes);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);
|
|
651
|
+
if (pre.block) {
|
|
652
|
+
result = { error: `Blocked by PreToolUse hook: ${pre.reason}` };
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
try {
|
|
656
|
+
// 1. Try platform-specific tools (e.g. VS Code semantic tools)
|
|
657
|
+
const external = options.executeExternalTool
|
|
658
|
+
? await options.executeExternalTool(name, input)
|
|
659
|
+
: null;
|
|
660
|
+
if (external !== null && external !== undefined) {
|
|
661
|
+
result = external;
|
|
662
|
+
// 2. Try MCP tools (serverName__toolName)
|
|
663
|
+
}
|
|
664
|
+
else if (options.mcpManager?.isMcpTool(name)) {
|
|
665
|
+
const mcpOutput = await options.mcpManager.callTool(name, input);
|
|
666
|
+
result = { output: mcpOutput ?? '' };
|
|
667
|
+
// 3. Fall through to built-in executor
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
// Snapshot pre-mutation state so the user can /rewind this turn.
|
|
671
|
+
options.checkpointManager?.recordBeforeMutation(name, input);
|
|
672
|
+
// Serialise writes to the same file: if the model emits two
|
|
673
|
+
// edit_file/write_file calls for the same path in one turn
|
|
674
|
+
// (executed via Promise.all), the second would overwrite the
|
|
675
|
+
// first. withFileLock queues them so they apply sequentially.
|
|
676
|
+
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
|
|
677
|
+
if (WRITE_TOOLS.has(name)) {
|
|
678
|
+
const targetPath = (typeof input.path === 'string' ? input.path : typeof input.source === 'string' ? input.source : '');
|
|
679
|
+
const absTarget = targetPath && options.workDir ? path.resolve(options.workDir, targetPath) : targetPath;
|
|
680
|
+
result = await withFileLock(absTarget || name, () => (0, executor_1.executeTool)(name, input, options.abortSignal, sandboxCfg, options.workDir, agentScope));
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
result = await (0, executor_1.executeTool)(name, input, options.abortSignal, sandboxCfg, options.workDir, agentScope);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
result = { error: `Tool execution failed: ${err.message}` };
|
|
689
|
+
}
|
|
690
|
+
// PostToolUse can inject context for the model or flag a problem.
|
|
691
|
+
const post = runToolHooks(hooks.PostToolUse, 'PostToolUse', name, input, options.workDir, result);
|
|
692
|
+
const injected = [pre.context, post.context].filter(Boolean).join('\n');
|
|
693
|
+
if (injected) {
|
|
694
|
+
if (result.error !== undefined)
|
|
695
|
+
result.error = `${result.error}\n\n[hook] ${injected}`;
|
|
696
|
+
else
|
|
697
|
+
result.output = `${result.output ?? ''}\n\n[hook] ${injected}`;
|
|
698
|
+
}
|
|
699
|
+
if (post.block) {
|
|
700
|
+
// e.g. a lint/test hook failed β surface it as an error so the model fixes it.
|
|
701
|
+
result.error = `${result.error ? result.error + '\n' : ''}PostToolUse hook flagged: ${post.reason}`;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Notify caller about result
|
|
706
|
+
options.onToolResult(name, result);
|
|
707
|
+
return { block: { ...block, id }, result };
|
|
708
|
+
}));
|
|
709
|
+
// 6. Build tool_result message and append to history
|
|
710
|
+
const toolResultBlocks = toolResults.map(({ block, result }) => ({
|
|
711
|
+
type: 'tool_result',
|
|
712
|
+
tool_use_id: block.id,
|
|
713
|
+
content: result.error
|
|
714
|
+
? `Error: ${result.error}`
|
|
715
|
+
: result.output ?? '',
|
|
716
|
+
is_error: result.error !== undefined,
|
|
717
|
+
}));
|
|
718
|
+
// Fold any follow-up the user queued mid-task into THIS user turn (alongside the
|
|
719
|
+
// tool results) so the agent sees it on the very next step β not after the whole
|
|
720
|
+
// task finishes. Appending a text block to the tool_result user message keeps the
|
|
721
|
+
// required user/assistant alternation intact.
|
|
722
|
+
const toolResultContent = [...toolResultBlocks];
|
|
723
|
+
const queuedMid = options.takePendingInput?.() ?? [];
|
|
724
|
+
if (queuedMid.length) {
|
|
725
|
+
const text = queuedMid.join('\n\n');
|
|
726
|
+
queuedMid.forEach((q) => options.onInjectedInput?.(q));
|
|
727
|
+
toolResultContent.push({ type: 'text', text });
|
|
728
|
+
}
|
|
729
|
+
const toolResultMessage = {
|
|
730
|
+
role: 'user',
|
|
731
|
+
content: toolResultContent,
|
|
732
|
+
};
|
|
733
|
+
messages.push(toolResultMessage);
|
|
734
|
+
// Runaway guard: if every tool call in this round failed, count it. Enough
|
|
735
|
+
// consecutive all-failed rounds (e.g. a command that always errors, or the
|
|
736
|
+
// user denying every permission) means we're stuck β stop instead of
|
|
737
|
+
// burning the whole budget spinning.
|
|
738
|
+
const allErrored = toolResults.length > 0 && toolResults.every(({ result }) => result.error !== undefined);
|
|
739
|
+
consecutiveErrorRounds = allErrored ? consecutiveErrorRounds + 1 : 0;
|
|
740
|
+
if (consecutiveErrorRounds >= STALL_LIMIT) {
|
|
741
|
+
stalledOut = true;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
// Auto-continue: about to exhaust the current budget but the model is still
|
|
745
|
+
// calling tools (task unfinished) and we're under the ceiling β extend the
|
|
746
|
+
// budget by another segment and keep going, so the agent finishes on its own
|
|
747
|
+
// without the user having to type "continue". History stays valid (ends on a
|
|
748
|
+
// tool_result turn), so no API error on the next step.
|
|
749
|
+
if (autoContinue && iteration + 1 >= budget && budget < hardCap) {
|
|
750
|
+
budget = Math.min(budget + maxIterations, hardCap);
|
|
751
|
+
options.onText(`\nπ Auto-continuing (extended to ${budget} steps) β the task isn't finished yet.\n`);
|
|
752
|
+
}
|
|
753
|
+
// Loop back to step 1
|
|
754
|
+
}
|
|
755
|
+
// Explain why we stopped when it wasn't a clean finish or a user abort, so a
|
|
756
|
+
// long run never just goes silent. History ends on a tool_result turn, so
|
|
757
|
+
// "continue" resumes exactly where it left off.
|
|
758
|
+
if (!options.abortSignal?.aborted && !completedCleanly) {
|
|
759
|
+
if (stalledOut) {
|
|
760
|
+
options.onText(`\nπ Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
|
|
761
|
+
`Fix the underlying error (or grant the needed permission) and send "continue".\n`);
|
|
762
|
+
}
|
|
763
|
+
else if (iteration >= budget) {
|
|
764
|
+
options.onText(`\nβΈοΈ Stopped at the ${budget}-step safety limit β the task may be incomplete. ` +
|
|
765
|
+
`Send "continue" to resume, or raise the limit via "maxIterations" in .nexrall/settings.json ` +
|
|
766
|
+
`(or the NEXRALL_MAX_ITERATIONS env var). Auto-continue can be disabled with "autoContinue": false.\n`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
runSimpleHooks(hooks.OnStop, options.workDir);
|
|
772
|
+
}
|
|
773
|
+
return messages;
|
|
774
|
+
}
|
|
775
|
+
//# sourceMappingURL=loop.js.map
|