loom-agent 1.3.3 → 1.6.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.
@@ -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
- const content = await fs.readFile(filePath, 'utf8');
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: true, description: 'Text to replace' },
161
- newString: { type: 'string', required: true, description: 'Replacement text' },
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
- const count = content.split(params.oldString).length - 1;
171
- if (count === 0) return { error: 'oldString not found in content' };
172
- if (params.replaceAll) {
173
- content = content.split(params.oldString).join(params.newString);
174
- } else {
175
- content = content.replace(params.oldString, params.newString.replace(/\$/g, '$$$$'));
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
- const changed = params.replaceAll ? `replaced ${count} occurrences` : 'replaced 1 occurrence';
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}: ${params.replaceAll ? `replaced ${count} occurrences` : 'replaced 1 occurrence'}${extra}`;
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
- background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
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
- const killTimer = setTimeout(() => {
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
- }, 60000);
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 (code !== 0) result += `\n[exit code: ${code}]`;
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 mounted at /workspace). Use ONLY when isolation is needed: the user asked for the sandbox, the command is risky (installs, deletions, untrusted scripts), or a Linux-only environment is required. For ordinary commands use bash (host). One command per call; the container idles in the background between calls.',
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
- const signal = AbortSignal.timeout(15000);
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. Agents: general (full toolset, default), explore (fast read-only code search), scout (quick docs lookups), research (deep multi-source investigation with a sourced report), coder (isolated implementation packages), reviewer (severity-ranked code review), tester (write/run tests until green), web (headless-browser work on live pages). Pick the smallest suitable agent. 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.',
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/remove/enable/disable an MCP server or connector (e.g. github, filesystem, gmail) or when a task requires tools the session does not have yet. In the TUI, hosting/cloud services (supabase, vercel, nextjs, railway, netlify, cloudflare) live under /connectors; dev-tool MCP servers (playwright, github, sentry, figma, postgres, mongodb, linear, slack, brave-search, exa, context7, filesystem, sqlite, puppeteer) live under /mcp. Common packages: Playwright=@playwright/mcp, Sentry=@sentry/mcp-server (needs --access-token TOKEN), Figma=figma-developer-mcp (env FIGMA_API_KEY), Postgres=@modelcontextprotocol/server-postgres (env DATABASE_URL), MongoDB=mongodb-mcp-server (env MONGODB_CONNECTION_STRING), Linear=linear-mcp-server (env LINEAR_API_KEY), Slack=@modelcontextprotocol/server-slack (env SLACK_BOT_TOKEN + SLACK_TEAM_ID), Brave=@modelcontextprotocol/server-brave-search (env BRAVE_API_KEY), Exa=exa-mcp-server (env EXA_API_KEY), Context7=@upstash/context7-mcp, Vercel=vercel-mcp-server (env VERCEL_TOKEN), Railway=@railway/mcp-server (env RAILWAY_API_TOKEN), Netlify=netlify-mcp-server (env NETLIFY_AUTH_TOKEN), Cloudflare=@cloudflare/mcp-server-cloudflare (env CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID), Supabase=@supabase/mcp-server-supabase (needs --access-token TOKEN), GitHub=ghcr.io/github/github-mcp-server (docker). If unsure about an MCP package, verify it exists first with webfetch (e.g. https://registry.npmjs.org/<pkg>) or web search (fetch or bash curl) — never trust an unverified npm package.',
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
- return await mcp.callTool(server, tool, params || {});
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 };