loom-agent 1.3.2 → 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 +216 -10
- package/README.md +10 -8
- 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 +311 -75
- package/src/core/session.js +262 -35
- 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 +206 -35
- package/src/tui/App.tsx +250 -36
- package/src/tui/components/Modals.tsx +130 -26
- 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,43 +191,71 @@ 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.',
|
|
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) {
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
|
|
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');
|
|
202
253
|
const risk = params._approved ? null : commandRiskLabel(params.command);
|
|
203
254
|
if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
|
|
204
255
|
const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
|
|
205
256
|
const res = await sandbox.runInSandbox(params.command, {
|
|
206
257
|
workdir: params.workdir || cwd,
|
|
258
|
+
timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? Math.min(params.timeoutMs, 600000) : 120000,
|
|
207
259
|
onChunk: stream ? (text, which) => stream(text, which) : undefined,
|
|
208
260
|
});
|
|
209
261
|
if ('error' in res) return { error: res.error };
|
|
@@ -236,13 +288,29 @@ const TOOLS = {
|
|
|
236
288
|
const child = isWin
|
|
237
289
|
? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', '[Console]::OutputEncoding=[Text.Encoding]::UTF8;' + params.command], opts)
|
|
238
290
|
: spawn('/bin/sh', ['-c', params.command], opts);
|
|
239
|
-
|
|
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 = () => {
|
|
240
297
|
if (isWin) {
|
|
241
298
|
try { execSync('taskkill /PID ' + child.pid + ' /T /F', { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
242
299
|
}
|
|
243
300
|
child.kill('SIGKILL');
|
|
244
|
-
}
|
|
301
|
+
};
|
|
302
|
+
const killTimer = setTimeout(() => { timedOut = true; killChild(); }, timeoutMs);
|
|
245
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
|
+
}
|
|
246
314
|
let stdout = '';
|
|
247
315
|
let stderr = '';
|
|
248
316
|
let truncated = false;
|
|
@@ -272,7 +340,9 @@ const TOOLS = {
|
|
|
272
340
|
let result = '';
|
|
273
341
|
if (stdout) result += stdout;
|
|
274
342
|
if (stderr) result += `\n[stderr]\n${stderr}`;
|
|
275
|
-
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}]`;
|
|
276
346
|
if (truncated) result += '\n[output truncated at 10 MB]';
|
|
277
347
|
resolve(result || 'command completed');
|
|
278
348
|
});
|
|
@@ -283,6 +353,32 @@ const TOOLS = {
|
|
|
283
353
|
});
|
|
284
354
|
}
|
|
285
355
|
},
|
|
356
|
+
sandbox: {
|
|
357
|
+
name: 'sandbox',
|
|
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.',
|
|
359
|
+
parameters: {
|
|
360
|
+
command: { type: 'string', required: true, description: 'The Linux shell command to run inside the container' },
|
|
361
|
+
workdir: { type: 'string', required: false, description: 'Working directory inside the container (host paths under the project map to /workspace/...)' },
|
|
362
|
+
timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 120000)' },
|
|
363
|
+
},
|
|
364
|
+
async execute(params, ctx) {
|
|
365
|
+
// Opt-in isolation path: nothing is routed here automatically. The
|
|
366
|
+
// container is revived/re-mounted on demand, then torn back to idle.
|
|
367
|
+
const sandbox = require('../core/sandbox');
|
|
368
|
+
const risk = params._approved ? null : commandRiskLabel(params.command);
|
|
369
|
+
if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
|
|
370
|
+
const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
|
|
371
|
+
const res = await sandbox.runInSandbox(params.command, {
|
|
372
|
+
workdir: params.workdir || cwd,
|
|
373
|
+
timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : undefined,
|
|
374
|
+
onChunk: stream ? (text, which) => stream(text, which) : undefined,
|
|
375
|
+
});
|
|
376
|
+
if ('error' in res) return { error: res.error };
|
|
377
|
+
let out = res.output;
|
|
378
|
+
if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
|
|
379
|
+
return out || 'command completed';
|
|
380
|
+
}
|
|
381
|
+
},
|
|
286
382
|
grep: {
|
|
287
383
|
name: 'grep',
|
|
288
384
|
description: 'Search file contents using regular expressions.',
|
|
@@ -337,7 +433,7 @@ const TOOLS = {
|
|
|
337
433
|
parameters: {
|
|
338
434
|
url: { type: 'string', required: true, description: 'URL to fetch' },
|
|
339
435
|
},
|
|
340
|
-
async execute(params) {
|
|
436
|
+
async execute(params, ctx) {
|
|
341
437
|
let u;
|
|
342
438
|
try {
|
|
343
439
|
u = new URL(String(params.url || ''));
|
|
@@ -351,7 +447,10 @@ const TOOLS = {
|
|
|
351
447
|
// Follow redirects manually so every hop passes the SSRF guard — a
|
|
352
448
|
// public URL may legitimately bounce into an internal address.
|
|
353
449
|
let current = u;
|
|
354
|
-
|
|
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;
|
|
355
454
|
for (let hop = 0; hop < 5; hop++) {
|
|
356
455
|
if (!(await urlIsPublic(current))) {
|
|
357
456
|
return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
|
|
@@ -443,6 +542,59 @@ const TOOLS = {
|
|
|
443
542
|
return lines.join('\n');
|
|
444
543
|
}
|
|
445
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
|
+
},
|
|
446
598
|
ask: {
|
|
447
599
|
name: 'ask',
|
|
448
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.',
|
|
@@ -458,7 +610,7 @@ const TOOLS = {
|
|
|
458
610
|
},
|
|
459
611
|
task: {
|
|
460
612
|
name: 'task',
|
|
461
|
-
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.',
|
|
462
614
|
parameters: {
|
|
463
615
|
prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
|
|
464
616
|
agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
|
|
@@ -498,7 +650,7 @@ const TOOLS = {
|
|
|
498
650
|
},
|
|
499
651
|
mcp: {
|
|
500
652
|
name: 'mcp',
|
|
501
|
-
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.',
|
|
502
654
|
parameters: {
|
|
503
655
|
action: { type: 'string', required: true, description: '"list" | "add" | "remove" | "enable" | "disable"' },
|
|
504
656
|
name: { type: 'string', required: false, description: 'MCP server name, letters/digits/-/_ (e.g. supabase)' },
|
|
@@ -596,6 +748,22 @@ function getToolDefinitions(mode = 'build') {
|
|
|
596
748
|
});
|
|
597
749
|
}
|
|
598
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
|
+
|
|
599
767
|
/** Execute one tool. `ctx` is passed through to tool executors that need
|
|
600
768
|
* session context (parent session, abort signal, subagent progress relay).
|
|
601
769
|
* @param {string} [toolName]
|
|
@@ -621,19 +789,22 @@ async function executeTool(toolName, params, mode = 'build', ctx = null) {
|
|
|
621
789
|
if (idx < 0) return { error: 'Malformed MCP tool name: ' + toolName };
|
|
622
790
|
const server = toolName.slice(5, idx);
|
|
623
791
|
const tool = toolName.slice(idx + 2);
|
|
624
|
-
|
|
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;
|
|
625
796
|
} catch (e) {
|
|
626
|
-
return { error: 'MCP error: ' + e.message };
|
|
797
|
+
return { error: truncateToolText('MCP error: ' + e.message) };
|
|
627
798
|
}
|
|
628
799
|
}
|
|
629
800
|
const tool = TOOLS[toolName];
|
|
630
801
|
if (!tool) return { error: `Unknown tool: ${toolName}` };
|
|
631
802
|
try {
|
|
632
803
|
const result = await tool.execute(params, ctx);
|
|
633
|
-
if (result && typeof result === 'object' && result.error) return { error: result.error };
|
|
634
|
-
return { result };
|
|
804
|
+
if (result && typeof result === 'object' && result.error) return { error: truncateToolText(result.error) };
|
|
805
|
+
return { result: typeof result === 'string' ? truncateToolText(result) : result };
|
|
635
806
|
} catch (e) {
|
|
636
|
-
return { error: e.message };
|
|
807
|
+
return { error: truncateToolText(e.message) };
|
|
637
808
|
}
|
|
638
809
|
}
|
|
639
810
|
|
|
@@ -669,4 +840,4 @@ async function getAllToolDefinitions(mode = 'build') {
|
|
|
669
840
|
}
|
|
670
841
|
}
|
|
671
842
|
|
|
672
|
-
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
|
|
843
|
+
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress, truncateToolText };
|