@yeaft/webchat-agent 1.0.208 → 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.
@@ -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,
@@ -6537,18 +6537,50 @@ export async function handleYeaftLoadHistory(msg) {
6537
6537
  }
6538
6538
 
6539
6539
  /**
6540
- * Handle a "load older messages" pagination request. Reads `turns` more
6541
- * turns of history strictly older than `beforeSeq` for `sessionId`, and
6542
- * emits them in a single `yeaft_history_chunk` envelope (NOT a
6543
- * `yeaft_output` that pipeline appends, but the frontend needs to
6544
- * PREPEND these older messages above what it already has).
6540
+ * Load one lightweight Conversation Outline page. The response contains only
6541
+ * visible user/assistant metadata and bounded snippets; full message bodies and
6542
+ * tool payloads stay on the Agent. `beforeSeq` is an exclusive older-page
6543
+ * cursor, and `includeTotal` avoids recounting after the first page.
6545
6544
  *
6546
- * Tool replay is NOT included in this PR same projection as
6547
- * `handleYeaftLoadHistory` (user / assistant text only). On any internal
6548
- * failure we still emit an empty chunk so the spinner clears.
6549
- *
6550
- * @param {object} msg — { sessionId, beforeSeq, turns }
6545
+ * @param {object} msg { sessionId, beforeSeq, limit, includeTotal }
6551
6546
  */
6547
+ export async function handleYeaftLoadHistoryOutline(msg) {
6548
+ const sessionId = typeof msg?.sessionId === 'string' ? msg.sessionId.trim() : '';
6549
+ const requestId = typeof msg?.requestId === 'string' ? msg.requestId : null;
6550
+ const beforeSeq = Number.isFinite(msg?.beforeSeq) ? msg.beforeSeq : null;
6551
+ const limit = Math.min(100, Math.max(1, Number.isFinite(msg?.limit) ? Math.floor(msg.limit) : 50));
6552
+ const response = {
6553
+ type: 'yeaft_history_outline',
6554
+ requestId,
6555
+ sessionId: sessionId || null,
6556
+ results: [],
6557
+ hasMore: false,
6558
+ nextBeforeSeq: null,
6559
+ totalCount: null,
6560
+ _requestClientId: msg?._requestClientId || null,
6561
+ };
6562
+
6563
+ if (!sessionId) {
6564
+ sendToServer({ ...response, error: 'invalid_session' });
6565
+ return;
6566
+ }
6567
+
6568
+ try {
6569
+ const defaultYeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
6570
+ const storeDir = resolveSessionYeaftDir(defaultYeaftDir, sessionId);
6571
+ const store = new ConversationStore(storeDir);
6572
+ const result = store.loadVisibleOutlineBySession(sessionId, {
6573
+ limit,
6574
+ beforeSeq,
6575
+ includeTotal: msg?.includeTotal !== false,
6576
+ });
6577
+ sendToServer({ ...response, ...result });
6578
+ } catch (err) {
6579
+ console.error('[Yeaft] Session history outline failed:', err?.message || err);
6580
+ sendToServer({ ...response, error: 'outline_failed' });
6581
+ }
6582
+ }
6583
+
6552
6584
  export async function handleYeaftSearchHistory(msg) {
6553
6585
  const sessionId = typeof msg?.sessionId === 'string' ? msg.sessionId.trim() : '';
6554
6586
  const query = typeof msg?.query === 'string' ? msg.query.trim().slice(0, 500) : '';