@yeaft/webchat-agent 1.0.209 → 1.0.210

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.
@@ -1 +1 @@
1
- {"version":"1.0.209"}
1
+ {"version":"1.0.210"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.209",
3
+ "version": "1.0.210",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -46,7 +46,7 @@ import { countTurns } from './turn-utils.js';
46
46
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
47
47
  import { resolveThinking } from './router/thinking.js';
48
48
  import { approxTokens } from './memory/budget.js';
49
- import { COLLAB_TOOL_POLICY, normalizeToolOutput, truncateToolResultIfNeeded } from './tools/registry.js';
49
+ import { COLLAB_TOOL_POLICY, isToolErrorOutput, normalizeToolOutput, truncateToolResultIfNeeded } from './tools/registry.js';
50
50
  import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
51
51
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
52
52
  import {
@@ -3344,6 +3344,7 @@ export class Engine {
3344
3344
  let output;
3345
3345
  let displayImages = [];
3346
3346
  let isError = false;
3347
+ let toolErrorOutput = null;
3347
3348
  currentToolCallForAsyncTask = {
3348
3349
  id: tc.id,
3349
3350
  name: tc.name,
@@ -3363,9 +3364,11 @@ export class Engine {
3363
3364
  try {
3364
3365
  yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
3365
3366
  if (this.#toolRegistry) {
3367
+ toolErrorOutput = this.#toolRegistry.get(tc.name)?.errorOutput || null;
3366
3368
  output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
3367
3369
  } else {
3368
3370
  const tool = this.#tools.get(tc.name);
3371
+ toolErrorOutput = tool.errorOutput || null;
3369
3372
  // Pass the full toolCtx (cwd, workDir, signal, …) — not just
3370
3373
  // `{ signal }`. Legacy registerTool() callers historically got
3371
3374
  // a 1-field ctx, but that means tools like bash/file-read run
@@ -3380,7 +3383,8 @@ export class Engine {
3380
3383
  if (displayImages.length > 0) {
3381
3384
  output = stripDisplayImageData(output, displayImages);
3382
3385
  }
3383
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError: false, threadId: this.currentThreadId };
3386
+ isError = toolErrorOutput === 'json-error-envelope' && isToolErrorOutput(output);
3387
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError, threadId: this.currentThreadId };
3384
3388
  if (displayImages.some(image => image.deliveryQueued === true)) hasDisplayImageAnchor = true;
3385
3389
  } catch (err) {
3386
3390
  output = `Error: ${err.message}`;
@@ -210,6 +210,7 @@ Guidelines:
210
210
  },
211
211
  required: ['command'],
212
212
  },
213
+ errorOutput: null,
213
214
  isConcurrencySafe: () => false,
214
215
  isReadOnly: () => false,
215
216
  isDestructive: (input) => {
@@ -223,7 +224,7 @@ Guidelines:
223
224
  },
224
225
  async execute(input, ctx) {
225
226
  const { command, cwd: inputCwd, timeout_ms, background = false, taskTitle } = input;
226
- if (!command) return JSON.stringify({ error: 'command is required' });
227
+ if (!command) throw new Error('command is required');
227
228
 
228
229
  // Resolve working directory
229
230
  const cwd = inputCwd
@@ -231,7 +232,7 @@ Guidelines:
231
232
  : (ctx?.cwd || process.cwd());
232
233
 
233
234
  if (!existsSync(cwd)) {
234
- return JSON.stringify({ error: `Working directory does not exist: ${cwd}` });
235
+ throw new Error(`Working directory does not exist: ${cwd}`);
235
236
  }
236
237
 
237
238
  // Clamp timeout
@@ -240,7 +241,7 @@ Guidelines:
240
241
 
241
242
  if (background) {
242
243
  if (!ctx?.taskManager) {
243
- return JSON.stringify({ error: 'background tasks are unavailable in this runtime' });
244
+ throw new Error('background tasks are unavailable in this runtime');
244
245
  }
245
246
  try {
246
247
  const task = ctx.taskManager.startShellTask({
@@ -263,7 +264,7 @@ Guidelines:
263
264
  try { ctx.registerAsyncTask?.(task.id, currentToolCall || {}); } catch { /* never block tool return on coord errors */ }
264
265
  return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
265
266
  } catch (err) {
266
- return JSON.stringify({ error: err?.message || String(err) });
267
+ throw new Error(err?.message || String(err));
267
268
  }
268
269
  }
269
270
 
@@ -287,7 +288,7 @@ Guidelines:
287
288
  }
288
289
  return output || '(no output)';
289
290
  } catch (err) {
290
- return JSON.stringify({ error: err.message });
291
+ throw new Error(err.message);
291
292
  }
292
293
  },
293
294
  });
@@ -34,6 +34,50 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
34
34
  * round-trip this tool's prompt guidance promises to avoid). */
35
35
  const DEFAULT_LIMIT = 3000;
36
36
 
37
+ /** Keep raw output close to the model-facing 32 KiB budget while leaving
38
+ * room for line metadata and the Registry's localized truncation marker. */
39
+ const DEFAULT_OUTPUT_BYTES = 30 * 1024;
40
+
41
+ function takeUtf8(text, maxBytes) {
42
+ const chars = [];
43
+ let bytes = 0;
44
+ for (const char of text) {
45
+ const size = Buffer.byteLength(char, 'utf8');
46
+ if (bytes + size > maxBytes) break;
47
+ chars.push(char);
48
+ bytes += size;
49
+ }
50
+ return { text: chars.join(''), charCount: chars.length, bytes };
51
+ }
52
+
53
+ function formatLinesWithinBudget(allLines, startLine, endLine, startColumn = 0, maxBytes = DEFAULT_OUTPUT_BYTES) {
54
+ const parts = [];
55
+ let usedBytes = 0;
56
+ let nextLine = startLine;
57
+ let nextColumn = startColumn;
58
+ for (let i = startLine; i < endLine; i += 1) {
59
+ const prefix = parts.length > 0 ? '\n' : '';
60
+ const lineChars = Array.from(allLines[i]);
61
+ const column = i === startLine ? Math.min(startColumn, lineChars.length) : 0;
62
+ const linePrefix = `${i + 1}\t${column > 0 ? `[column ${column}] ` : ''}`;
63
+ const formatted = linePrefix + lineChars.slice(column).join('');
64
+ const remaining = maxBytes - usedBytes - Buffer.byteLength(prefix, 'utf8');
65
+ if (remaining <= 0) break;
66
+ const bounded = takeUtf8(formatted, remaining);
67
+ if (!bounded.text) break;
68
+ parts.push(prefix + bounded.text);
69
+ usedBytes += Buffer.byteLength(prefix, 'utf8') + bounded.bytes;
70
+ if (bounded.text !== formatted) {
71
+ nextLine = i;
72
+ nextColumn = column + Math.max(0, bounded.charCount - Array.from(linePrefix).length);
73
+ break;
74
+ }
75
+ nextLine = i + 1;
76
+ nextColumn = 0;
77
+ }
78
+ return { text: parts.join(''), nextLine, nextColumn };
79
+ }
80
+
37
81
  export default defineTool({
38
82
  name: 'FileRead',
39
83
  description: {
@@ -70,14 +114,21 @@ Guidelines:
70
114
  },
71
115
  },
72
116
  offset: {
73
- type: 'number',
117
+ type: 'integer',
118
+ minimum: 0,
74
119
  description: {
75
120
  en: 'Line number to start reading from (0-based, default: 0)',
76
121
  zh: '起始行号(从 0 开始计数,默认 0)',
77
122
  },
78
123
  },
124
+ column_offset: {
125
+ type: 'integer',
126
+ minimum: 0,
127
+ description: { en: 'Unicode character offset within the first requested line (0-based, default: 0)', zh: '首个待读行内的 Unicode 字符偏移量(从 0 开始,默认 0)' },
128
+ },
79
129
  limit: {
80
- type: 'number',
130
+ type: 'integer',
131
+ minimum: 1,
81
132
  description: {
82
133
  en: `Maximum number of lines to read (default: ${DEFAULT_LIMIT})`,
83
134
  zh: `最多读取行数(默认 ${DEFAULT_LIMIT} 行)`,
@@ -89,8 +140,17 @@ Guidelines:
89
140
  isConcurrencySafe: () => true,
90
141
  isReadOnly: () => true,
91
142
  async execute(input, ctx) {
92
- const { file_path, offset = 0, limit = DEFAULT_LIMIT } = input;
143
+ const { file_path, offset = 0, column_offset = 0, limit = DEFAULT_LIMIT } = input;
93
144
  if (!file_path) return JSON.stringify({ error: 'file_path is required' });
145
+ if (!Number.isInteger(offset) || offset < 0) {
146
+ return JSON.stringify({ error: 'offset must be a non-negative integer' });
147
+ }
148
+ if (!Number.isInteger(column_offset) || column_offset < 0) {
149
+ return JSON.stringify({ error: 'column_offset must be a non-negative integer' });
150
+ }
151
+ if (!Number.isInteger(limit) || limit < 1) {
152
+ return JSON.stringify({ error: 'limit must be a positive integer' });
153
+ }
94
154
 
95
155
  const cwd = ctx?.cwd || process.cwd();
96
156
  const absPath = resolve(cwd, file_path);
@@ -126,20 +186,28 @@ Guidelines:
126
186
  const allLines = content.split('\n');
127
187
  const totalLines = allLines.length;
128
188
 
189
+ if (offset > totalLines) {
190
+ return JSON.stringify({ error: `offset ${offset} exceeds file length (${totalLines} lines)` });
191
+ }
192
+ if (offset === totalLines) {
193
+ return `[Offset ${offset} is at end of file (${totalLines} lines total).]`;
194
+ }
195
+
129
196
  // Apply offset and limit
130
- const startLine = Math.max(0, Math.min(offset, totalLines));
197
+ const startLine = offset;
131
198
  const endLine = Math.min(startLine + limit, totalLines);
132
- const lines = allLines.slice(startLine, endLine);
133
-
134
- // Format with line numbers (1-based like cat -n)
135
- const numbered = lines.map((line, i) => {
136
- const lineNum = startLine + i + 1;
137
- return `${lineNum}\t${line}`;
138
- }).join('\n');
139
-
140
- // Add metadata if partial
141
- if (startLine > 0 || endLine < totalLines) {
142
- return `${numbered}\n\n[Showing lines ${startLine + 1}-${endLine} of ${totalLines} total]`;
199
+ const startColumn = column_offset;
200
+ const { text: numbered, nextLine, nextColumn } = formatLinesWithinBudget(allLines, startLine, endLine, startColumn);
201
+
202
+ const hasMoreContent = nextColumn > 0 || nextLine < totalLines;
203
+ if (startLine > 0 || startColumn > 0 || hasMoreContent) {
204
+ const continuation = hasMoreContent
205
+ ? nextColumn > 0
206
+ ? ` Continue with offset=${nextLine}, column_offset=${nextColumn}.`
207
+ : ` Continue with offset=${nextLine}.`
208
+ : '';
209
+ const shownEnd = nextColumn > 0 ? nextLine + 1 : nextLine;
210
+ return `${numbered}\n\n[Showing lines ${startLine + 1}-${shownEnd} of ${totalLines} total.${continuation}]`;
143
211
  }
144
212
 
145
213
  return numbered;
@@ -10,6 +10,13 @@ import { readdir, stat } from 'fs/promises';
10
10
  import { existsSync } from 'fs';
11
11
  import { resolve, join, relative } from 'path';
12
12
 
13
+ const STAT_CONCURRENCY = 32;
14
+ const SKIP_DIRS = new Set([
15
+ 'node_modules', '.git', '__pycache__', '.next', '.nuxt',
16
+ 'dist', 'build', '.cache', '.venv', 'venv', '.tox',
17
+ 'vendor', 'target', '.gradle', '.idea', '.vscode',
18
+ ]);
19
+
13
20
  /**
14
21
  * Simple glob pattern matcher (supports * and **).
15
22
  * @param {string} pattern
@@ -44,19 +51,13 @@ async function* walkDir(dir, baseDir, maxDepth = 10, depth = 0) {
44
51
  return;
45
52
  }
46
53
 
47
- // Skip common large/irrelevant directories
48
- const SKIP = new Set([
49
- 'node_modules', '.git', '__pycache__', '.next', '.nuxt',
50
- 'dist', 'build', '.cache', '.venv', 'venv', '.tox',
51
- 'vendor', 'target', '.gradle', '.idea', '.vscode',
52
- ]);
53
-
54
54
  for (const entry of entries) {
55
55
  const fullPath = join(dir, entry.name);
56
56
  const relPath = relative(baseDir, fullPath);
57
57
 
58
58
  if (entry.isDirectory()) {
59
- if (SKIP.has(entry.name)) continue;
59
+ const normalized = relPath.replace(/\\/g, '/');
60
+ if (SKIP_DIRS.has(entry.name) || normalized === '.yeaft/worktrees' || normalized.startsWith('.yeaft/worktrees/')) continue;
60
61
  yield { path: relPath, isDir: true };
61
62
  yield* walkDir(fullPath, baseDir, maxDepth, depth + 1);
62
63
  } else {
@@ -106,7 +107,8 @@ Guidelines:
106
107
  },
107
108
  },
108
109
  limit: {
109
- type: 'number',
110
+ type: 'integer',
111
+ minimum: 1,
110
112
  description: {
111
113
  en: 'Maximum number of results (default: 500)',
112
114
  zh: '最多返回结果数(默认 500)',
@@ -120,6 +122,9 @@ Guidelines:
120
122
  async execute(input, ctx) {
121
123
  const { pattern, path: searchPath, limit = 500 } = input;
122
124
  if (!pattern) return JSON.stringify({ error: 'pattern is required' });
125
+ if (!Number.isInteger(limit) || limit < 1) {
126
+ return JSON.stringify({ error: 'limit must be a positive integer' });
127
+ }
123
128
 
124
129
  const cwd = ctx?.cwd || process.cwd();
125
130
  const baseDir = searchPath ? resolve(cwd, searchPath) : cwd;
@@ -129,29 +134,25 @@ Guidelines:
129
134
  }
130
135
 
131
136
  try {
132
- const matches = [];
133
-
137
+ const paths = [];
134
138
  for await (const entry of walkDir(baseDir, baseDir)) {
135
- if (matches.length >= limit * 2) break; // over-fetch for sorting
139
+ if (!entry.isDir && matchGlob(pattern, entry.path)) paths.push(entry.path);
140
+ }
136
141
 
137
- if (!entry.isDir && matchGlob(pattern, entry.path)) {
138
- // Get mtime for sorting
142
+ // Exact newest-first semantics require every matching mtime. Batch the
143
+ // metadata reads instead of serializing one syscall per path.
144
+ const matches = [];
145
+ for (let i = 0; i < paths.length; i += STAT_CONCURRENCY) {
146
+ matches.push(...await Promise.all(paths.slice(i, i + STAT_CONCURRENCY).map(async (path) => {
139
147
  try {
140
- const fileStat = await stat(join(baseDir, entry.path));
141
- matches.push({
142
- path: entry.path,
143
- mtime: fileStat.mtimeMs,
144
- });
148
+ const fileStat = await stat(join(baseDir, path));
149
+ return { path, mtime: fileStat.mtimeMs };
145
150
  } catch {
146
- matches.push({ path: entry.path, mtime: 0 });
151
+ return { path, mtime: 0 };
147
152
  }
148
- }
153
+ })));
149
154
  }
150
-
151
- // Sort by mtime (newest first)
152
155
  matches.sort((a, b) => b.mtime - a.mtime);
153
-
154
- // Trim to limit
155
156
  const trimmed = matches.slice(0, limit);
156
157
 
157
158
  return trimmed.map(m => m.path).join('\n') || '(no matches)';
@@ -17,11 +17,15 @@ const MAX_LINES = 250;
17
17
 
18
18
  /** Hard cap before Grep output reaches history, debug events, or WebSocket. */
19
19
  const MAX_OUTPUT_BYTES = 512 * 1024;
20
+ const SEARCH_RESULT_BYTES = 32 * 1024;
20
21
  const OUTPUT_TRUNCATED_MARKER = '\n\n[Output truncated]';
21
22
  const MAX_CAPTURE_BYTES = MAX_OUTPUT_BYTES - Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
22
23
 
23
24
  /** Keep one pathological source line from consuming the whole output budget. */
24
25
  const MAX_LINE_BYTES = 16 * 1024;
26
+ const FALLBACK_CONCURRENCY = 8;
27
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.next', 'dist', 'build', '.cache']);
28
+ let ripgrepAvailability;
25
29
 
26
30
  /** Binary extensions to skip. */
27
31
  const BINARY_EXTS = new Set([
@@ -108,12 +112,22 @@ function createOutputCollector(maxBytes = MAX_OUTPUT_BYTES) {
108
112
  /**
109
113
  * Check if ripgrep is available.
110
114
  */
115
+ export function setRipgrepAvailabilityForTests(value) {
116
+ ripgrepAvailability = value;
117
+ }
118
+
111
119
  function hasRipgrep() {
112
- return new Promise((resolve) => {
120
+ if (typeof ripgrepAvailability === 'boolean') return Promise.resolve(ripgrepAvailability);
121
+ if (ripgrepAvailability) return ripgrepAvailability;
122
+ ripgrepAvailability = new Promise((resolve) => {
113
123
  const proc = spawn('rg', ['--version'], { stdio: 'pipe', windowsHide: true });
114
124
  proc.on('close', (code) => resolve(code === 0));
115
125
  proc.on('error', () => resolve(false));
126
+ }).then((available) => {
127
+ ripgrepAvailability = available;
128
+ return available;
116
129
  });
130
+ return ripgrepAvailability;
117
131
  }
118
132
 
119
133
  /**
@@ -121,15 +135,9 @@ function hasRipgrep() {
121
135
  */
122
136
  export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
123
137
  return new Promise((resolve, reject) => {
124
- const args = [
125
- pattern,
126
- searchPath,
127
- '--no-heading',
128
- '--line-number',
129
- '--color', 'never',
130
- ];
131
-
138
+ const args = [pattern, searchPath, '--no-heading', '--line-number', '--color', 'never'];
132
139
  if (options.caseInsensitive) args.push('-i');
140
+ if (options.fixedStrings) args.push('-F');
133
141
  if (options.glob) args.push('--glob', options.glob);
134
142
  if (options.type) args.push('--type', options.type);
135
143
  if (options.filesOnly) args.push('-l');
@@ -138,52 +146,92 @@ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
138
146
  if (options.before) args.push('-B', String(options.before));
139
147
  if (options.after) args.push('-A', String(options.after));
140
148
  if (options.multiline) args.push('-U', '--multiline-dotall');
141
- args.push('--max-count', String(options.maxResults || 500));
142
149
 
143
150
  const proc = spawnProcess('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
151
+ const requestedBudget = Number(options.byteBudget);
152
+ const stdoutBudget = Number.isFinite(requestedBudget) && requestedBudget >= 0
153
+ ? Math.min(requestedBudget, MAX_OUTPUT_BYTES)
154
+ : MAX_OUTPUT_BYTES;
155
+ const stdoutMarker = truncateUtf8(OUTPUT_TRUNCATED_MARKER, stdoutBudget);
144
156
  const stdoutChunks = [];
145
157
  const stderrChunks = [];
146
- let capturedBytes = 0;
147
- let truncatedStream = null;
158
+ let stdoutBytes = 0;
159
+ let stderrBytes = 0;
160
+ let stdoutTruncated = false;
161
+ let stderrTruncated = false;
162
+ let stdoutLines = 0;
163
+ let stoppedForLimit = false;
164
+ let stopRequested = false;
148
165
  let settled = false;
149
166
 
150
- function capture(streamName, chunk, chunks) {
151
- if (truncatedStream) return;
167
+ function stop() {
168
+ if (stopRequested) return;
169
+ stopRequested = true;
170
+ try { proc.kill(); } catch {}
171
+ }
172
+
173
+ function captureStdout(chunk) {
174
+ if (stdoutTruncated || stoppedForLimit) return;
175
+ let buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
176
+ const maxResults = Math.max(1, options.maxResults || 500);
177
+ let cursor = 0;
178
+ while (stdoutLines < maxResults) {
179
+ const newline = buffer.indexOf(0x0a, cursor);
180
+ if (newline === -1) break;
181
+ stdoutLines += 1;
182
+ cursor = newline + 1;
183
+ }
184
+ if (stdoutLines >= maxResults) {
185
+ buffer = buffer.subarray(0, cursor);
186
+ stoppedForLimit = true;
187
+ }
188
+
189
+ const remaining = stdoutBudget - stdoutBytes;
190
+ if (buffer.length > remaining) {
191
+ if (remaining > 0) stdoutChunks.push(buffer.subarray(0, remaining));
192
+ stdoutBytes = stdoutBudget;
193
+ stdoutTruncated = true;
194
+ } else {
195
+ stdoutChunks.push(buffer);
196
+ stdoutBytes += buffer.length;
197
+ }
198
+ if (stdoutTruncated || stoppedForLimit) stop();
199
+ }
200
+
201
+ function captureStderr(chunk) {
202
+ if (stderrTruncated) return;
152
203
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
153
- const remaining = MAX_CAPTURE_BYTES - capturedBytes;
204
+ const remaining = MAX_OUTPUT_BYTES - stderrBytes;
154
205
  if (buffer.length > remaining) {
155
- if (remaining > 0) chunks.push(buffer.subarray(0, remaining));
156
- capturedBytes = MAX_CAPTURE_BYTES;
157
- truncatedStream = streamName;
158
- try { proc.kill(); } catch {}
206
+ if (remaining > 0) stderrChunks.push(buffer.subarray(0, remaining));
207
+ stderrBytes = MAX_OUTPUT_BYTES;
208
+ stderrTruncated = true;
209
+ stop();
159
210
  return;
160
211
  }
161
- chunks.push(buffer);
162
- capturedBytes += buffer.length;
212
+ stderrChunks.push(buffer);
213
+ stderrBytes += buffer.length;
163
214
  }
164
215
 
165
- function decodeCaptured(chunks, wasTruncated) {
166
- // Buffer decoding normally expands each invalid byte to the three-byte
167
- // U+FFFD replacement character. Use a one-byte replacement, then enforce
168
- // the final encoded-byte boundary as a last line of defense.
169
- const marker = wasTruncated ? OUTPUT_TRUNCATED_MARKER : '';
170
- const maxTextBytes = MAX_OUTPUT_BYTES - Buffer.byteLength(marker, 'utf8');
216
+ function decodeCaptured(chunks, maxBytes, wasTruncated, marker = OUTPUT_TRUNCATED_MARKER) {
217
+ const boundedMarker = wasTruncated ? truncateUtf8(marker, maxBytes) : '';
218
+ const maxTextBytes = Math.max(0, maxBytes - Buffer.byteLength(boundedMarker, 'utf8'));
171
219
  const decoded = Buffer.concat(chunks).toString('utf8').replaceAll('\ufffd', '?').replace(/\r/g, '');
172
- return truncateUtf8(decoded, maxTextBytes) + marker;
220
+ return truncateUtf8(decoded, maxTextBytes) + boundedMarker;
173
221
  }
174
222
 
175
- proc.stdout.on('data', (chunk) => capture('stdout', chunk, stdoutChunks));
176
- proc.stderr.on('data', (chunk) => capture('stderr', chunk, stderrChunks));
223
+ proc.stdout.on('data', captureStdout);
224
+ proc.stderr.on('data', captureStderr);
177
225
  proc.on('close', (code) => {
178
226
  if (settled) return;
179
227
  settled = true;
180
- const stdout = decodeCaptured(stdoutChunks, truncatedStream === 'stdout');
181
- const stderr = decodeCaptured(stderrChunks, truncatedStream === 'stderr');
182
- if (code === 0 || code === 1 || truncatedStream === 'stdout') resolve(stdout);
228
+ const stdout = decodeCaptured(stdoutChunks, stdoutBudget, stdoutTruncated, stdoutMarker);
229
+ const stderr = decodeCaptured(stderrChunks, MAX_OUTPUT_BYTES, stderrTruncated);
230
+ if (code === 0 || code === 1 || stoppedForLimit || stdoutTruncated) resolve(stdout);
183
231
  else reject(new Error(stderr || `rg exited with code ${code}`));
184
232
  });
185
233
  proc.on('error', (err) => {
186
- if (settled) return;
234
+ if (settled || stopRequested) return;
187
235
  settled = true;
188
236
  reject(err);
189
237
  });
@@ -194,67 +242,94 @@ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
194
242
  * Fallback: Node.js grep implementation.
195
243
  */
196
244
  export async function nodeGrep(pattern, searchPath, options) {
197
- const regex = new RegExp(pattern, options.caseInsensitive ? 'gi' : 'g');
198
- const output = createOutputCollector();
245
+ const regexSource = options.fixedStrings
246
+ ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
247
+ : pattern;
248
+ const regex = new RegExp(regexSource, options.caseInsensitive ? 'gi' : 'g');
249
+ const output = createOutputCollector(options.byteBudget || SEARCH_RESULT_BYTES);
250
+ const maxResults = Math.max(1, options.maxResults || 500);
199
251
  let resultCount = 0;
200
- const SKIP = new Set(['node_modules', '.git', '__pycache__', '.next', 'dist', 'build', '.cache']);
252
+ let stopped = false;
253
+
254
+ function compileGlob(glob) {
255
+ const escaped = glob.replace(/\\/g, '/')
256
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
257
+ .replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')
258
+ .replace(/\0/g, '.*');
259
+ return new RegExp(`^${escaped}$`);
260
+ }
261
+ const globMatcher = options.glob ? compileGlob(options.glob) : null;
262
+ const typeExtensions = {
263
+ js: ['.js', '.jsx', '.mjs', '.cjs'], ts: ['.ts', '.tsx', '.mts', '.cts'],
264
+ py: ['.py'], rust: ['.rs'], go: ['.go'], java: ['.java'],
265
+ json: ['.json'], yaml: ['.yaml', '.yml'], markdown: ['.md', '.markdown'],
266
+ html: ['.html', '.htm'], css: ['.css'], shell: ['.sh', '.bash', '.zsh'],
267
+ };
268
+
269
+ function matchesFilters(fullPath) {
270
+ const relPath = relative(searchPath, fullPath).replace(/\\/g, '/');
271
+ if (globMatcher && !globMatcher.test(relPath) && !globMatcher.test(relPath.split('/').pop())) return false;
272
+ if (!options.type) return true;
273
+ const extensions = typeExtensions[options.type];
274
+ return Boolean(extensions?.includes(extname(fullPath).toLowerCase()));
275
+ }
276
+
277
+ function addResult(value) {
278
+ resultCount += 1;
279
+ if (!output.add(value) || resultCount >= maxResults) stopped = true;
280
+ }
281
+
282
+ async function searchFile(fullPath) {
283
+ if (stopped || !matchesFilters(fullPath) || BINARY_EXTS.has(extname(fullPath).toLowerCase())) return;
284
+ try {
285
+ const fileStat = await stat(fullPath);
286
+ if (fileStat.size > 1024 * 1024 || stopped) return;
287
+ const content = decodeTextFile(await readFile(fullPath));
288
+ if (content == null) return;
289
+ const relPath = relative(searchPath, fullPath);
290
+ regex.lastIndex = 0;
291
+ if (options.filesOnly) {
292
+ if (regex.test(content)) addResult(relPath);
293
+ } else if (options.count) {
294
+ const matches = content.match(regex);
295
+ if (matches) addResult(`${relPath}:${matches.length}`);
296
+ } else {
297
+ const lines = content.split('\n');
298
+ for (let i = 0; i < lines.length && !stopped; i += 1) {
299
+ regex.lastIndex = 0;
300
+ if (regex.test(lines[i])) addResult(`${relPath}:${i + 1}:${lines[i]}`);
301
+ }
302
+ }
303
+ } catch {
304
+ // Skip unreadable files.
305
+ }
306
+ }
201
307
 
202
308
  async function searchDir(dir) {
203
- if (resultCount >= (options.maxResults || 500)) return;
309
+ if (stopped) return;
204
310
  let entries;
205
311
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
206
-
312
+ const files = [];
313
+ const directories = [];
207
314
  for (const entry of entries) {
208
- if (resultCount >= (options.maxResults || 500)) return;
209
315
  const fullPath = join(dir, entry.name);
210
-
211
316
  if (entry.isDirectory()) {
212
- if (SKIP.has(entry.name)) continue;
213
- await searchDir(fullPath);
214
- } else {
215
- const ext = extname(entry.name).toLowerCase();
216
- if (BINARY_EXTS.has(ext)) continue;
217
-
218
- try {
219
- const fileStat = await stat(fullPath);
220
- if (fileStat.size > 1024 * 1024) continue; // skip files > 1MB
221
-
222
- const buffer = await readFile(fullPath);
223
- const content = decodeTextFile(buffer);
224
- if (content == null) continue;
225
- const relPath = relative(searchPath, fullPath);
226
-
227
- if (options.filesOnly) {
228
- if (regex.test(content)) {
229
- resultCount += 1;
230
- if (!output.add(relPath)) return;
231
- }
232
- regex.lastIndex = 0;
233
- } else if (options.count) {
234
- const matches = content.match(regex);
235
- if (matches) {
236
- resultCount += 1;
237
- if (!output.add(`${relPath}:${matches.length}`)) return;
238
- }
239
- } else {
240
- const lines = content.split('\n');
241
- for (let i = 0; i < lines.length; i++) {
242
- if (regex.test(lines[i])) {
243
- resultCount += 1;
244
- if (!output.add(`${relPath}:${i + 1}:${lines[i]}`)) return;
245
- }
246
- regex.lastIndex = 0;
247
- if (resultCount >= (options.maxResults || 500)) return;
248
- }
249
- }
250
- } catch {
251
- // Skip unreadable files
252
- }
253
- }
317
+ const relPath = relative(searchPath, fullPath).replace(/\\/g, '/');
318
+ if (!SKIP_DIRS.has(entry.name) && relPath !== '.yeaft/worktrees' && !relPath.startsWith('.yeaft/worktrees/')) directories.push(fullPath);
319
+ } else files.push(fullPath);
320
+ }
321
+ for (let i = 0; i < files.length && !stopped; i += FALLBACK_CONCURRENCY) {
322
+ await Promise.all(files.slice(i, i + FALLBACK_CONCURRENCY).map(searchFile));
323
+ }
324
+ for (const child of directories) {
325
+ if (stopped) break;
326
+ await searchDir(child);
254
327
  }
255
328
  }
256
329
 
257
- await searchDir(searchPath);
330
+ const rootStat = await stat(searchPath);
331
+ if (rootStat.isDirectory()) await searchDir(searchPath);
332
+ else await searchFile(searchPath);
258
333
  return output.toString();
259
334
  }
260
335
 
@@ -336,6 +411,13 @@ Guidelines:
336
411
  zh: '不区分大小写搜索(默认 false)',
337
412
  },
338
413
  },
414
+ fixed_strings: {
415
+ type: 'boolean',
416
+ description: {
417
+ en: 'Treat the pattern as a literal string (default: false)',
418
+ zh: '将模式视为普通字符串而非正则表达式(默认 false)',
419
+ },
420
+ },
339
421
  context: {
340
422
  type: 'number',
341
423
  description: {
@@ -365,7 +447,8 @@ Guidelines:
365
447
  },
366
448
  },
367
449
  head_limit: {
368
- type: 'number',
450
+ type: 'integer',
451
+ minimum: 1,
369
452
  description: {
370
453
  en: 'Limit output to first N results (default: 250)',
371
454
  zh: '限制输出前 N 条结果(默认 250)',
@@ -379,12 +462,16 @@ Guidelines:
379
462
  async execute(input, ctx) {
380
463
  const {
381
464
  pattern, path: searchPath, output_mode = 'files_with_matches',
382
- glob: globFilter, type, case_insensitive = false,
465
+ glob: globFilter, type, case_insensitive = false, fixed_strings = false,
383
466
  context, before, after, multiline = false,
384
467
  head_limit = MAX_LINES,
385
468
  } = input;
386
469
 
387
470
  if (!pattern) return JSON.stringify({ error: 'pattern is required' });
471
+ if (!Number.isInteger(head_limit) || head_limit < 1) {
472
+ return JSON.stringify({ error: 'head_limit must be a positive integer' });
473
+ }
474
+ const headLimit = Math.min(head_limit, 10000);
388
475
 
389
476
  const cwd = ctx?.cwd || process.cwd();
390
477
  const absPath = searchPath ? resolve(cwd, searchPath) : cwd;
@@ -397,13 +484,15 @@ Guidelines:
397
484
  caseInsensitive: case_insensitive,
398
485
  glob: globFilter,
399
486
  type,
487
+ fixedStrings: fixed_strings,
400
488
  filesOnly: output_mode === 'files_with_matches',
401
489
  count: output_mode === 'count',
402
490
  context,
403
491
  before,
404
492
  after,
405
493
  multiline,
406
- maxResults: head_limit * 2,
494
+ maxResults: headLimit,
495
+ byteBudget: SEARCH_RESULT_BYTES,
407
496
  };
408
497
 
409
498
  try {
@@ -423,9 +512,9 @@ Guidelines:
423
512
  // Limit output lines, then enforce the byte budget at the actual tool
424
513
  // boundary so prefixes, JSON escaping, and result markers are included.
425
514
  const lines = result.trim().split('\n');
426
- if (lines.length > head_limit) {
515
+ if (lines.length > headLimit) {
427
516
  return boundToolOutput(
428
- lines.slice(0, head_limit).join('\n') + `\n\n... (${lines.length - head_limit} more results)`,
517
+ lines.slice(0, headLimit).join('\n') + `\n\n... (${lines.length - headLimit} more results)`,
429
518
  );
430
519
  }
431
520
 
@@ -111,6 +111,7 @@ export function buildMcpFlattenedTools(mcpManager) {
111
111
  t.description || `MCP tool ${fullName.split('__').slice(1).join('__')} from server ${t.server}`
112
112
  ),
113
113
  parameters: t.inputSchema || { type: 'object', properties: {} },
114
+ errorOutput: null,
114
115
  async execute(input = {}, _ctx) {
115
116
  // Look up the manager fresh on each call. We deliberately don't
116
117
  // close over a server reference — hot-reload may have replaced
@@ -125,7 +126,11 @@ export function buildMcpFlattenedTools(mcpManager) {
125
126
  throw new Error(`MCP manager not available for ${flattenedName}`);
126
127
  }
127
128
  const result = await mcpManager.callTool(fullName, input || {});
128
- return formatMcpResult(result);
129
+ const output = formatMcpResult(result);
130
+ if (result && typeof result === 'object' && result.isError === true) {
131
+ throw new Error(output || `MCP tool ${fullName} failed`);
132
+ }
133
+ return output;
129
134
  },
130
135
  });
131
136
  });
@@ -262,7 +267,11 @@ Usage guidelines:
262
267
 
263
268
  try {
264
269
  const result = await mcpManager.callTool(tool_name, args, timeout_ms || 30000);
265
- return formatMcpResult(result);
270
+ const output = formatMcpResult(result);
271
+ if (result && typeof result === 'object' && result.isError === true) {
272
+ throw new Error(output || `MCP tool ${tool_name} failed`);
273
+ }
274
+ return output;
266
275
  } catch (err) {
267
276
  return JSON.stringify({
268
277
  error: err.message,
@@ -7,8 +7,8 @@ import { defineTool } from './types.js';
7
7
  export default defineTool({
8
8
  name: 'ReadTaskLog',
9
9
  description: {
10
- en: 'Read a background task log by taskId. Supports tail reads and byte offsets.',
11
- zh: '按 taskId 读取后台任务日志。支持 tail 和字节 offset。',
10
+ en: 'Read a background task log by taskId. The first read defaults to the tail. For later reads, pass the previous nextOffset as offset to receive only new bytes; an explicit offset defaults tail to false.',
11
+ zh: '按 taskId 读取后台任务日志。首次读取默认返回末尾;后续把上次返回的 nextOffset 作为 offset 传入即可只读取新增字节,显式传 offset 时 tail 默认 false。',
12
12
  },
13
13
  parameters: {
14
14
  type: 'object',
@@ -28,10 +28,11 @@ export default defineTool({
28
28
  const taskId = input.taskId;
29
29
  if (!taskId) return JSON.stringify({ error: 'taskId is required' });
30
30
  const sessionId = input.sessionId || ctx.sessionId || 'default';
31
+ const hasOffset = Number.isFinite(input.offset);
31
32
  const result = ctx.taskManager.readTaskLog(sessionId, taskId, {
32
33
  offset: input.offset,
33
34
  maxBytes: input.maxBytes,
34
- tail: input.tail !== false,
35
+ tail: typeof input.tail === 'boolean' ? input.tail : !hasOffset,
35
36
  });
36
37
  return JSON.stringify(result, null, 2);
37
38
  },
@@ -37,7 +37,7 @@ export const FORWARD_TOOL_NAMES = Object.freeze(['RouteForward']);
37
37
  * persisted transcripts need the raw result. The engine/history replay path
38
38
  * applies this only when building messages for the model.
39
39
  */
40
- export const TOOL_RESULT_MAX_BYTES = 10 * 1024;
40
+ export const TOOL_RESULT_MAX_BYTES = 32 * 1024;
41
41
 
42
42
  function normalizeLanguage(language) {
43
43
  return String(language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
@@ -194,24 +194,49 @@ export function normalizeToolOutput(output) {
194
194
  return text;
195
195
  }
196
196
 
197
+ export function isToolErrorOutput(output) {
198
+ const text = normalizeToolOutput(output).trim();
199
+ if (!text.startsWith('{')) return false;
200
+ try {
201
+ const parsed = JSON.parse(text);
202
+ return Boolean(
203
+ parsed
204
+ && typeof parsed === 'object'
205
+ && !Array.isArray(parsed)
206
+ && typeof parsed.error === 'string'
207
+ && parsed.error.trim(),
208
+ );
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
214
+ function truncateUtf8(text, maxBytes) {
215
+ if (maxBytes <= 0) return '';
216
+ const buffer = Buffer.from(String(text), 'utf8');
217
+ if (buffer.length <= maxBytes) return String(text);
218
+ let end = maxBytes;
219
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
220
+ return buffer.subarray(0, end).toString('utf8');
221
+ }
222
+
197
223
  export function truncateToolResultIfNeeded(output, { toolName, language } = {}) {
198
224
  const text = normalizeToolOutput(output);
199
225
  const originalBytes = Buffer.byteLength(text, 'utf8');
200
226
  if (originalBytes <= TOOL_RESULT_MAX_BYTES) return text;
201
227
 
202
- const chunks = [];
203
- let used = 0;
204
- for (const ch of text) {
205
- const n = Buffer.byteLength(ch, 'utf8');
206
- if (used + n > TOOL_RESULT_MAX_BYTES) break;
207
- chunks.push(ch);
208
- used += n;
228
+ const markerFor = name => normalizeLanguage(language) === 'zh'
229
+ ? `\n\n[已截断:${name} 返回 ${formatSize(originalBytes)},上限为 ${formatSize(TOOL_RESULT_MAX_BYTES)};原因:单个 tool result 超过 ${formatSize(TOOL_RESULT_MAX_BYTES)},模型消息历史不会看到剩余内容]`
230
+ : `\n\n[truncated: ${name} returned ${formatSize(originalBytes)}, capped at ${formatSize(TOOL_RESULT_MAX_BYTES)}; reason: single tool result exceeded ${formatSize(TOOL_RESULT_MAX_BYTES)}, the model message history will not see the rest]`;
231
+ let marker = markerFor(String(toolName || 'tool'));
232
+ if (Buffer.byteLength(marker, 'utf8') > TOOL_RESULT_MAX_BYTES) {
233
+ const fixedMarker = markerFor('');
234
+ const nameBudget = Math.max(0, TOOL_RESULT_MAX_BYTES - Buffer.byteLength(fixedMarker, 'utf8'));
235
+ marker = markerFor(truncateUtf8(String(toolName || 'tool'), nameBudget));
209
236
  }
210
- const head = chunks.join('');
211
- const marker = normalizeLanguage(language) === 'zh'
212
- ? `\n\n[已截断:${toolName} 返回 ${formatSize(originalBytes)},上限为 ${formatSize(TOOL_RESULT_MAX_BYTES)};原因:单个 tool result 超过 ${formatSize(TOOL_RESULT_MAX_BYTES)},模型消息历史不会看到剩余内容]`
213
- : `\n\n[truncated: ${toolName} returned ${formatSize(originalBytes)}, capped at ${formatSize(TOOL_RESULT_MAX_BYTES)}; reason: single tool result exceeded ${formatSize(TOOL_RESULT_MAX_BYTES)}, the model message history will not see the rest]`;
214
- return head + marker;
237
+ marker = truncateUtf8(marker, TOOL_RESULT_MAX_BYTES);
238
+ const contentBudget = Math.max(0, TOOL_RESULT_MAX_BYTES - Buffer.byteLength(marker, 'utf8'));
239
+ return truncateUtf8(text, contentBudget) + marker;
215
240
  }
216
241
 
217
242
  /**
@@ -62,6 +62,7 @@
62
62
  * @property {(input?: object) => boolean} [isConcurrencySafe] — can run in parallel?
63
63
  * @property {(input?: object) => boolean} [isReadOnly] — read-only operation?
64
64
  * @property {(input?: object) => boolean} [isDestructive] — destructive operation?
65
+ * @property {'json-error-envelope' | null} [errorOutput] — explicit returned-output error contract; null means only thrown errors fail
65
66
  */
66
67
 
67
68
  /**
@@ -75,6 +76,7 @@
75
76
  * isConcurrencySafe?: (input?: object) => boolean,
76
77
  * isReadOnly?: (input?: object) => boolean,
77
78
  * isDestructive?: (input?: object) => boolean,
79
+ * errorOutput?: 'json-error-envelope' | null,
78
80
  * timeoutMs?: number,
79
81
  * }} def
80
82
  * @returns {ToolDef}
@@ -88,6 +90,7 @@ export function defineTool({
88
90
  isConcurrencySafe = () => false,
89
91
  isReadOnly = () => false,
90
92
  isDestructive = () => false,
93
+ errorOutput = 'json-error-envelope',
91
94
  timeoutMs,
92
95
  }) {
93
96
  if (!name) throw new Error('Tool must have a name');
@@ -101,6 +104,7 @@ export function defineTool({
101
104
  isConcurrencySafe,
102
105
  isReadOnly,
103
106
  isDestructive,
107
+ errorOutput,
104
108
  };
105
109
  // Legacy tool-name aliases. Registered as extra lookup keys so old
106
110
  // jsonl tool_calls (e.g. `SendMessage` → `PromptAgent`) keep resolving,