loom-agent 1.3.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/README.md +9 -7
- package/package.json +2 -2
- package/src/core/agents.js +14 -1
- package/src/core/cli.js +30 -2
- package/src/core/hooks.js +28 -14
- package/src/core/memory.js +54 -6
- package/src/core/permissions.js +17 -4
- package/src/core/plugin-cmd.js +88 -1
- package/src/core/sandbox.js +61 -1
- package/src/core/session.js +262 -36
- package/src/core/transcript-undo.js +74 -0
- package/src/core/update.js +116 -6
- package/src/mcp/connector-presets.js +105 -0
- package/src/providers/anthropic.js +122 -25
- package/src/providers/google.js +19 -11
- package/src/providers/nvidia.js +45 -33
- package/src/providers/openai-compat.js +59 -27
- package/src/providers/openai.js +42 -18
- package/src/providers/openrouter.js +56 -26
- package/src/tools/index.js +193 -30
- package/src/tui/App.tsx +249 -35
- package/src/tui/components/Modals.tsx +64 -19
- package/src/tui/mcp-presets.ts +4 -65
- package/src/tui/store.ts +6 -1
package/src/tools/index.js
CHANGED
|
@@ -89,7 +89,7 @@ const MODES = ['build', 'plan', 'chat'];
|
|
|
89
89
|
// Tools that never mutate the filesystem/state — safe to expose in plan mode.
|
|
90
90
|
// task delegates to a read-only-or-not subagent, so from the CALLER's side it
|
|
91
91
|
// is safe in plan mode (the subagent's own tool list gates what it may do).
|
|
92
|
-
const READ_ONLY_TOOLS = ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite', 'task'];
|
|
92
|
+
const READ_ONLY_TOOLS = ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite', 'todoread', 'task'];
|
|
93
93
|
|
|
94
94
|
// Optional path sandbox: when config.sandbox.paths is set, filesystem tools
|
|
95
95
|
// only operate inside those roots (defense in depth on top of permissions).
|
|
@@ -126,7 +126,30 @@ const TOOLS = {
|
|
|
126
126
|
const filePath = path.resolve(params.filePath);
|
|
127
127
|
if (!pathAllowed(filePath)) return { error: sandboxDenied('read', filePath) };
|
|
128
128
|
if (!fsSync.existsSync(filePath)) return { error: `File not found: ${filePath}` };
|
|
129
|
-
|
|
129
|
+
// Binary detection: a NUL byte in the first 8 KB means this isn't text —
|
|
130
|
+
// reading it as utf8 produces garbage that poisons the context.
|
|
131
|
+
const stat = fsSync.statSync(filePath);
|
|
132
|
+
if (stat.size > 0) {
|
|
133
|
+
const fd = fsSync.openSync(filePath, 'r');
|
|
134
|
+
try {
|
|
135
|
+
const head = Buffer.alloc(Math.min(8192, stat.size));
|
|
136
|
+
fsSync.readSync(fd, head, 0, head.length, 0);
|
|
137
|
+
if (head.includes(0)) {
|
|
138
|
+
return `[binary file: ${filePath} — ${stat.size} bytes, content omitted]`;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Unreadable head (device/fifo?) — fall through and try normally.
|
|
142
|
+
} finally {
|
|
143
|
+
fsSync.closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Size cap: huge files are read up to 2000 lines (or the requested
|
|
147
|
+
// window) instead of slurping megabytes into the context.
|
|
148
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
149
|
+
const content = stat.size > MAX_FILE_BYTES && !params.offset && !params.limit
|
|
150
|
+
? (await fs.readFile(filePath, 'utf8')).split('\n').slice(0, 2000).join('\n') +
|
|
151
|
+
`\n[file truncated at 2000 lines (${stat.size} bytes total) — pass offset/limit to read more]`
|
|
152
|
+
: await fs.readFile(filePath, 'utf8');
|
|
130
153
|
const lines = content.split('\n');
|
|
131
154
|
const start = (params.offset || 1) - 1;
|
|
132
155
|
const end = params.limit ? start + params.limit : lines.length;
|
|
@@ -154,11 +177,12 @@ const TOOLS = {
|
|
|
154
177
|
},
|
|
155
178
|
edit: {
|
|
156
179
|
name: 'edit',
|
|
157
|
-
description: 'Performs exact string replacements in files.',
|
|
180
|
+
description: 'Performs exact string replacements in files. Pass either oldString/newString for one replacement, or an edits array of {oldString, newString, replaceAll?} entries applied in order (multi-edit — prefer this over several edit calls on the same file).',
|
|
158
181
|
parameters: {
|
|
159
182
|
filePath: { type: 'string', required: true, description: 'Absolute path to the file' },
|
|
160
|
-
oldString: { type: 'string', required:
|
|
161
|
-
newString: { type: 'string', required:
|
|
183
|
+
oldString: { type: 'string', required: false, description: 'Text to replace (single edit)' },
|
|
184
|
+
newString: { type: 'string', required: false, description: 'Replacement text (single edit)' },
|
|
185
|
+
edits: { type: 'array', required: false, description: 'Multiple edits applied in order: [{oldString, newString, replaceAll?}, ...]' },
|
|
162
186
|
replaceAll: { type: 'boolean', required: false, description: 'Replace all occurrences' },
|
|
163
187
|
dryRun: { type: 'boolean', required: false, description: 'Preview the edit without writing' },
|
|
164
188
|
},
|
|
@@ -167,32 +191,78 @@ const TOOLS = {
|
|
|
167
191
|
if (!pathAllowed(filePath)) return { error: sandboxDenied('edit', filePath) };
|
|
168
192
|
if (!fsSync.existsSync(filePath)) return { error: `File not found: ${filePath}` };
|
|
169
193
|
let content = await fs.readFile(filePath, 'utf8');
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
194
|
+
// Multi-edit path: apply each {oldString, newString} in order against
|
|
195
|
+
// the in-memory copy, then write once. Sequential application means a
|
|
196
|
+
// later edit can target text an earlier edit inserted. Entries enforce
|
|
197
|
+
// uniqueness (a multi-edit that silently hits 2+ matches is how files
|
|
198
|
+
// get corrupted); the classic single oldString path keeps its
|
|
199
|
+
// replace-first-occurrence semantics for back-compat.
|
|
200
|
+
const multi = Array.isArray(params.edits) && params.edits.length > 0;
|
|
201
|
+
const edits = multi
|
|
202
|
+
? params.edits
|
|
203
|
+
: (params.oldString != null || params.newString != null
|
|
204
|
+
? [{ oldString: params.oldString, newString: params.newString, replaceAll: params.replaceAll }]
|
|
205
|
+
: []);
|
|
206
|
+
if (!edits.length) return { error: 'edit needs oldString/newString or an edits array' };
|
|
207
|
+
const applied = [];
|
|
208
|
+
for (let i = 0; i < edits.length; i++) {
|
|
209
|
+
const e = edits[i] || {};
|
|
210
|
+
const oldStr = e.oldString;
|
|
211
|
+
const newStr = e.newString;
|
|
212
|
+
if (typeof oldStr !== 'string' || typeof newStr !== 'string') {
|
|
213
|
+
return { error: `edits[${i}]: oldString and newString must be strings` };
|
|
214
|
+
}
|
|
215
|
+
const count = content.split(oldStr).length - 1;
|
|
216
|
+
if (count === 0) return { error: multi ? `edits[${i}]: oldString not found in content` : 'oldString not found in content' };
|
|
217
|
+
if (e.replaceAll) {
|
|
218
|
+
content = content.split(oldStr).join(newStr);
|
|
219
|
+
applied.push(`replaced ${count} occurrences`);
|
|
220
|
+
} else {
|
|
221
|
+
if (multi && count > 1) {
|
|
222
|
+
return { error: `edits[${i}]: found ${count} matches for oldString — provide more context to make it unique` };
|
|
223
|
+
}
|
|
224
|
+
content = content.replace(oldStr, newStr.replace(/\$/g, '$$$$'));
|
|
225
|
+
applied.push('replaced 1 occurrence');
|
|
226
|
+
}
|
|
176
227
|
}
|
|
177
228
|
if (params.dryRun) {
|
|
178
|
-
|
|
179
|
-
return `Dry run — would edit ${filePath}: ${changed} (file not modified).\n${content.slice(0, 4000)}`;
|
|
229
|
+
return `Dry run — would edit ${filePath}: ${applied.join('; ')} (file not modified).\n${content.slice(0, 4000)}`;
|
|
180
230
|
}
|
|
181
231
|
await fs.writeFile(filePath, content, 'utf8');
|
|
182
232
|
let extra = '';
|
|
183
233
|
try { extra = await require('../core/format').formatAfterWrite(filePath); } catch {}
|
|
184
|
-
return `Edited ${filePath}: ${
|
|
234
|
+
return `Edited ${filePath}: ${applied.join('; ')}${extra}`;
|
|
185
235
|
}
|
|
186
236
|
},
|
|
187
237
|
bash: {
|
|
188
238
|
name: 'bash',
|
|
189
|
-
description: 'Execute a shell command on the user\'s machine.',
|
|
239
|
+
description: 'Execute a shell command on the user\'s machine. Pass sandbox:true to run it INSIDE the Docker sandbox instead (docker exec into loom-sandbox, project at /workspace) — required for anything the user asks to run "in the sandbox".',
|
|
190
240
|
parameters: {
|
|
191
241
|
command: { type: 'string', required: true, description: 'The command to execute' },
|
|
192
242
|
workdir: { type: 'string', required: false, description: 'Working directory' },
|
|
193
|
-
|
|
243
|
+
timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 60000, max 600000)' },
|
|
244
|
+
background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel or the tasks tool.' },
|
|
245
|
+
sandbox: { type: 'boolean', required: false, description: 'Run this command inside the Docker sandbox container (docker exec) instead of on the host. Use when the user asked for the sandbox or isolation is needed; defaults to the host.' },
|
|
194
246
|
},
|
|
195
247
|
async execute(params, ctx) {
|
|
248
|
+
// Sandbox opt-in: the SAME command goes through docker exec into the
|
|
249
|
+
// loom-sandbox container — never a silent host fallback if the
|
|
250
|
+
// container is unavailable (the error tells the model what to do).
|
|
251
|
+
if (params.sandbox && !params.background) {
|
|
252
|
+
const sandbox = require('../core/sandbox');
|
|
253
|
+
const risk = params._approved ? null : commandRiskLabel(params.command);
|
|
254
|
+
if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
|
|
255
|
+
const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
|
|
256
|
+
const res = await sandbox.runInSandbox(params.command, {
|
|
257
|
+
workdir: params.workdir || cwd,
|
|
258
|
+
timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? Math.min(params.timeoutMs, 600000) : 120000,
|
|
259
|
+
onChunk: stream ? (text, which) => stream(text, which) : undefined,
|
|
260
|
+
});
|
|
261
|
+
if ('error' in res) return { error: res.error };
|
|
262
|
+
let out = res.output;
|
|
263
|
+
if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
|
|
264
|
+
return out || 'command completed';
|
|
265
|
+
}
|
|
196
266
|
// Background mode: fire-and-forget via the task registry.
|
|
197
267
|
if (params.background) {
|
|
198
268
|
const bt = require('../core/background-tasks');
|
|
@@ -218,13 +288,29 @@ const TOOLS = {
|
|
|
218
288
|
const child = isWin
|
|
219
289
|
? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', '[Console]::OutputEncoding=[Text.Encoding]::UTF8;' + params.command], opts)
|
|
220
290
|
: spawn('/bin/sh', ['-c', params.command], opts);
|
|
221
|
-
|
|
291
|
+
// Kill after timeoutMs (default 60s, cap 600s) so a hung command
|
|
292
|
+
// can never wedge the turn forever.
|
|
293
|
+
const timeoutMs = Math.min(Math.max(Number(params.timeoutMs) || 60000, 1000), 600000);
|
|
294
|
+
let timedOut = false;
|
|
295
|
+
let aborted = false;
|
|
296
|
+
const killChild = () => {
|
|
222
297
|
if (isWin) {
|
|
223
298
|
try { execSync('taskkill /PID ' + child.pid + ' /T /F', { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
224
299
|
}
|
|
225
300
|
child.kill('SIGKILL');
|
|
226
|
-
}
|
|
301
|
+
};
|
|
302
|
+
const killTimer = setTimeout(() => { timedOut = true; killChild(); }, timeoutMs);
|
|
227
303
|
child.on('close', () => clearTimeout(killTimer));
|
|
304
|
+
// ESC/interrupt: the session's abort signal kills the running
|
|
305
|
+
// command so the user isn't left waiting on a zombie process.
|
|
306
|
+
const sig = ctx && ctx.signal;
|
|
307
|
+
if (sig) {
|
|
308
|
+
if (sig.aborted) { aborted = true; clearTimeout(killTimer); killChild(); }
|
|
309
|
+
else {
|
|
310
|
+
const onAbort = () => { aborted = true; clearTimeout(killTimer); killChild(); };
|
|
311
|
+
try { sig.addEventListener('abort', onAbort, { once: true }); child.on('close', () => { try { sig.removeEventListener('abort', onAbort); } catch {} }); } catch {}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
228
314
|
let stdout = '';
|
|
229
315
|
let stderr = '';
|
|
230
316
|
let truncated = false;
|
|
@@ -254,7 +340,9 @@ const TOOLS = {
|
|
|
254
340
|
let result = '';
|
|
255
341
|
if (stdout) result += stdout;
|
|
256
342
|
if (stderr) result += `\n[stderr]\n${stderr}`;
|
|
257
|
-
if (
|
|
343
|
+
if (timedOut) result += `\n[timed out after ${Math.round(timeoutMs / 1000)}s — killed]`;
|
|
344
|
+
else if (aborted) result += '\n[aborted by user]';
|
|
345
|
+
else if (code !== 0) result += `\n[exit code: ${code}]`;
|
|
258
346
|
if (truncated) result += '\n[output truncated at 10 MB]';
|
|
259
347
|
resolve(result || 'command completed');
|
|
260
348
|
});
|
|
@@ -267,7 +355,7 @@ const TOOLS = {
|
|
|
267
355
|
},
|
|
268
356
|
sandbox: {
|
|
269
357
|
name: 'sandbox',
|
|
270
|
-
description: 'Run a shell command INSIDE the Docker sandbox (isolated Linux container, project
|
|
358
|
+
description: 'Run a shell command INSIDE the Docker sandbox via `docker exec loom-sandbox sh -lc "<command>"` (isolated Linux container, project at /workspace). Use when the user asked for the sandbox, the command is risky (installs, deletions, untrusted scripts), or a Linux-only environment is needed. Equivalent to bash with sandbox:true. One command per call.',
|
|
271
359
|
parameters: {
|
|
272
360
|
command: { type: 'string', required: true, description: 'The Linux shell command to run inside the container' },
|
|
273
361
|
workdir: { type: 'string', required: false, description: 'Working directory inside the container (host paths under the project map to /workspace/...)' },
|
|
@@ -345,7 +433,7 @@ const TOOLS = {
|
|
|
345
433
|
parameters: {
|
|
346
434
|
url: { type: 'string', required: true, description: 'URL to fetch' },
|
|
347
435
|
},
|
|
348
|
-
async execute(params) {
|
|
436
|
+
async execute(params, ctx) {
|
|
349
437
|
let u;
|
|
350
438
|
try {
|
|
351
439
|
u = new URL(String(params.url || ''));
|
|
@@ -359,7 +447,10 @@ const TOOLS = {
|
|
|
359
447
|
// Follow redirects manually so every hop passes the SSRF guard — a
|
|
360
448
|
// public URL may legitimately bounce into an internal address.
|
|
361
449
|
let current = u;
|
|
362
|
-
|
|
450
|
+
// ESC/interrupt kills the in-flight fetch (ctx.signal) alongside the
|
|
451
|
+
// 15s timeout so the turn never hangs on a stalled connection.
|
|
452
|
+
const timeoutSignal = AbortSignal.timeout(15000);
|
|
453
|
+
const signal = (ctx && ctx.signal) ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
|
|
363
454
|
for (let hop = 0; hop < 5; hop++) {
|
|
364
455
|
if (!(await urlIsPublic(current))) {
|
|
365
456
|
return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
|
|
@@ -451,6 +542,59 @@ const TOOLS = {
|
|
|
451
542
|
return lines.join('\n');
|
|
452
543
|
}
|
|
453
544
|
},
|
|
545
|
+
todoread: {
|
|
546
|
+
name: 'todoread',
|
|
547
|
+
description: 'Read the current session todo list (the same list todowrite maintains). Use to check task progress without overwriting it.',
|
|
548
|
+
parameters: {},
|
|
549
|
+
async execute(params, ctx) {
|
|
550
|
+
const todos = (ctx && ctx.parentSession && ctx.parentSession.todos) || [];
|
|
551
|
+
if (!todos.length) return '(no todos yet)';
|
|
552
|
+
const statuses = { pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
|
|
553
|
+
const lines = ['## Todo List', ''];
|
|
554
|
+
for (const t of todos) {
|
|
555
|
+
const status = t.status || 'pending';
|
|
556
|
+
statuses[status] = (statuses[status] || 0) + 1;
|
|
557
|
+
const icon = status === 'completed' ? '[x]' : status === 'in_progress' ? '[>]' : status === 'cancelled' ? '[-]' : '[ ]';
|
|
558
|
+
const priority = t.priority ? ` [${t.priority}]` : '';
|
|
559
|
+
lines.push(`${icon}${priority} ${t.content || '(unnamed)'}`);
|
|
560
|
+
}
|
|
561
|
+
lines.push('');
|
|
562
|
+
lines.push(`Summary: ${statuses.completed} done, ${statuses.in_progress} in-progress, ${statuses.pending} pending, ${statuses.cancelled} cancelled`);
|
|
563
|
+
return lines.join('\n');
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
tasks: {
|
|
567
|
+
name: 'tasks',
|
|
568
|
+
description: 'Inspect and manage background tasks started with bash background:true. Actions: list (all tasks with status), output (recent output of one task by id), kill (stop a running task). Use when a background command may have finished, or the user asks about dev servers/builds running in the background.',
|
|
569
|
+
parameters: {
|
|
570
|
+
action: { type: 'string', required: true, description: '"list" | "output" | "kill"' },
|
|
571
|
+
id: { type: 'string', required: false, description: 'Task id (for output/kill), e.g. bt-1-abc' },
|
|
572
|
+
tailLines: { type: 'number', required: false, description: 'How many trailing lines of output to return (default 80)' },
|
|
573
|
+
},
|
|
574
|
+
async execute(params) {
|
|
575
|
+
const bt = require('../core/background-tasks');
|
|
576
|
+
const action = String(params.action || 'list').toLowerCase();
|
|
577
|
+
if (action === 'list') {
|
|
578
|
+
const rows = bt.listBackgroundTasks();
|
|
579
|
+
if (!rows.length) return 'No background tasks. Start one with bash {background: true}.';
|
|
580
|
+
return rows.map((t) => `${t.id} [${t.status}]${t.exitCode != null ? ' exit ' + t.exitCode : ''} ${String(t.command).slice(0, 120)}`).join('\n');
|
|
581
|
+
}
|
|
582
|
+
if (action === 'output') {
|
|
583
|
+
const t = bt.getBackgroundTask(String(params.id || ''));
|
|
584
|
+
if (!t) return { error: 'Unknown task id. Use action="list" to see ids.' };
|
|
585
|
+
const lines = String(t.output || '').split('\n');
|
|
586
|
+
const n = Math.min(Math.max(Number(params.tailLines) || 80, 1), 500);
|
|
587
|
+
const tail = lines.slice(-n).join('\n');
|
|
588
|
+
return `[${t.id} — ${t.status}${t.exitCode != null ? ', exit ' + t.exitCode : ''}]\n` + (tail || '(no output yet)');
|
|
589
|
+
}
|
|
590
|
+
if (action === 'kill') {
|
|
591
|
+
const ok = bt.killBackgroundTask(String(params.id || ''));
|
|
592
|
+
if (!ok) return { error: 'Task not found or not running: ' + (params.id || '(none)') };
|
|
593
|
+
return 'Killed ' + params.id + '.';
|
|
594
|
+
}
|
|
595
|
+
return { error: 'Unknown action "' + action + '". Use list, output, or kill.' };
|
|
596
|
+
}
|
|
597
|
+
},
|
|
454
598
|
ask: {
|
|
455
599
|
name: 'ask',
|
|
456
600
|
description: 'Ask the user a question when you genuinely need their input (missing info, a decision between approaches, or confirmation of intent). Provide up to 3 concise options when the answer is likely one of a few choices — the user can pick one OR type their own answer. Use sparingly: prefer deciding and acting yourself; never ask what you can determine from the codebase.',
|
|
@@ -466,7 +610,7 @@ const TOOLS = {
|
|
|
466
610
|
},
|
|
467
611
|
task: {
|
|
468
612
|
name: 'task',
|
|
469
|
-
description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model.
|
|
613
|
+
description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Pick the smallest suitable agent (see the agent param). Pass a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
|
|
470
614
|
parameters: {
|
|
471
615
|
prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
|
|
472
616
|
agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
|
|
@@ -506,7 +650,7 @@ const TOOLS = {
|
|
|
506
650
|
},
|
|
507
651
|
mcp: {
|
|
508
652
|
name: 'mcp',
|
|
509
|
-
description: 'Manage MCP (tool) servers for this workspace. Use when the user asks to add
|
|
653
|
+
description: 'Manage MCP (tool) servers for this workspace: list, add, remove, enable, disable. Use when the user asks to add a connector or when a task needs tools the session lacks yet (a DB, a browser, a ticketing system). Adding a server needs a stdio command (e.g. "npx -y <pkg>"); it stays disabled until enabled. If unsure a package exists, verify it with webfetch on the registry before installing — never run an unverified npm package.',
|
|
510
654
|
parameters: {
|
|
511
655
|
action: { type: 'string', required: true, description: '"list" | "add" | "remove" | "enable" | "disable"' },
|
|
512
656
|
name: { type: 'string', required: false, description: 'MCP server name, letters/digits/-/_ (e.g. supabase)' },
|
|
@@ -604,6 +748,22 @@ function getToolDefinitions(mode = 'build') {
|
|
|
604
748
|
});
|
|
605
749
|
}
|
|
606
750
|
|
|
751
|
+
// Session-level tool-result cap: a tool that returns megabytes (huge bash
|
|
752
|
+
// output, MCP payloads, giant file reads) poisons the context window and can
|
|
753
|
+
// even break the provider request. Cap at 100k chars, head+tail so both the
|
|
754
|
+
// start AND the error tail survive.
|
|
755
|
+
const TOOL_RESULT_CAP = 100 * 1000;
|
|
756
|
+
|
|
757
|
+
function truncateToolText(text) {
|
|
758
|
+
const s = String(text);
|
|
759
|
+
if (s.length <= TOOL_RESULT_CAP) return s;
|
|
760
|
+
const head = Math.floor(TOOL_RESULT_CAP * 0.6);
|
|
761
|
+
const tail = TOOL_RESULT_CAP - head - 100;
|
|
762
|
+
return s.slice(0, head) +
|
|
763
|
+
`\n\n[…truncated ${s.length - head - tail} chars — full output was ${s.length} chars; re-run with narrower scope]\n\n` +
|
|
764
|
+
s.slice(-tail);
|
|
765
|
+
}
|
|
766
|
+
|
|
607
767
|
/** Execute one tool. `ctx` is passed through to tool executors that need
|
|
608
768
|
* session context (parent session, abort signal, subagent progress relay).
|
|
609
769
|
* @param {string} [toolName]
|
|
@@ -629,19 +789,22 @@ async function executeTool(toolName, params, mode = 'build', ctx = null) {
|
|
|
629
789
|
if (idx < 0) return { error: 'Malformed MCP tool name: ' + toolName };
|
|
630
790
|
const server = toolName.slice(5, idx);
|
|
631
791
|
const tool = toolName.slice(idx + 2);
|
|
632
|
-
|
|
792
|
+
const res = await mcp.callTool(server, tool, params || {});
|
|
793
|
+
if (res && typeof res === 'object' && res.error) return { error: truncateToolText(res.error) };
|
|
794
|
+
if (res && typeof res === 'object' && typeof res.result === 'string') return { result: truncateToolText(res.result) };
|
|
795
|
+
return res;
|
|
633
796
|
} catch (e) {
|
|
634
|
-
return { error: 'MCP error: ' + e.message };
|
|
797
|
+
return { error: truncateToolText('MCP error: ' + e.message) };
|
|
635
798
|
}
|
|
636
799
|
}
|
|
637
800
|
const tool = TOOLS[toolName];
|
|
638
801
|
if (!tool) return { error: `Unknown tool: ${toolName}` };
|
|
639
802
|
try {
|
|
640
803
|
const result = await tool.execute(params, ctx);
|
|
641
|
-
if (result && typeof result === 'object' && result.error) return { error: result.error };
|
|
642
|
-
return { result };
|
|
804
|
+
if (result && typeof result === 'object' && result.error) return { error: truncateToolText(result.error) };
|
|
805
|
+
return { result: typeof result === 'string' ? truncateToolText(result) : result };
|
|
643
806
|
} catch (e) {
|
|
644
|
-
return { error: e.message };
|
|
807
|
+
return { error: truncateToolText(e.message) };
|
|
645
808
|
}
|
|
646
809
|
}
|
|
647
810
|
|
|
@@ -677,4 +840,4 @@ async function getAllToolDefinitions(mode = 'build') {
|
|
|
677
840
|
}
|
|
678
841
|
}
|
|
679
842
|
|
|
680
|
-
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
|
|
843
|
+
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress, truncateToolText };
|