@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.
- package/connection/message-router.js +5 -1
- package/index.js +1 -1
- package/local-runtime/server/handlers/agent-output.js +18 -0
- package/local-runtime/server/handlers/client-conversation.js +18 -0
- package/local-runtime/server/ws-agent.js +1 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +137 -115
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +124 -18
- package/yeaft/engine.js +6 -2
- package/yeaft/tools/bash.js +6 -5
- package/yeaft/tools/file-read.js +83 -15
- package/yeaft/tools/glob.js +26 -25
- package/yeaft/tools/grep.js +178 -89
- package/yeaft/tools/mcp-tools.js +11 -2
- package/yeaft/tools/read-task-log.js +4 -3
- package/yeaft/tools/registry.js +38 -13
- package/yeaft/tools/types.js +4 -0
- package/yeaft/web-bridge.js +42 -10
package/yeaft/tools/grep.js
CHANGED
|
@@ -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
|
|
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
|
|
147
|
-
let
|
|
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
|
|
151
|
-
if (
|
|
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 =
|
|
204
|
+
const remaining = MAX_OUTPUT_BYTES - stderrBytes;
|
|
154
205
|
if (buffer.length > remaining) {
|
|
155
|
-
if (remaining > 0)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
212
|
+
stderrChunks.push(buffer);
|
|
213
|
+
stderrBytes += buffer.length;
|
|
163
214
|
}
|
|
164
215
|
|
|
165
|
-
function decodeCaptured(chunks, wasTruncated) {
|
|
166
|
-
|
|
167
|
-
|
|
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) +
|
|
220
|
+
return truncateUtf8(decoded, maxTextBytes) + boundedMarker;
|
|
173
221
|
}
|
|
174
222
|
|
|
175
|
-
proc.stdout.on('data',
|
|
176
|
-
proc.stderr.on('data',
|
|
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,
|
|
181
|
-
const stderr = decodeCaptured(stderrChunks,
|
|
182
|
-
if (code === 0 || code === 1 ||
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
} else
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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: '
|
|
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:
|
|
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 >
|
|
515
|
+
if (lines.length > headLimit) {
|
|
427
516
|
return boundToolOutput(
|
|
428
|
-
lines.slice(0,
|
|
517
|
+
lines.slice(0, headLimit).join('\n') + `\n\n... (${lines.length - headLimit} more results)`,
|
|
429
518
|
);
|
|
430
519
|
}
|
|
431
520
|
|
package/yeaft/tools/mcp-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
11
|
-
zh: '按 taskId
|
|
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
|
|
35
|
+
tail: typeof input.tail === 'boolean' ? input.tail : !hasOffset,
|
|
35
36
|
});
|
|
36
37
|
return JSON.stringify(result, null, 2);
|
|
37
38
|
},
|
package/yeaft/tools/registry.js
CHANGED
|
@@ -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 =
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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
|
/**
|
package/yeaft/tools/types.js
CHANGED
|
@@ -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,
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -6537,18 +6537,50 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
6537
6537
|
}
|
|
6538
6538
|
|
|
6539
6539
|
/**
|
|
6540
|
-
*
|
|
6541
|
-
*
|
|
6542
|
-
*
|
|
6543
|
-
* `
|
|
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
|
-
*
|
|
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) : '';
|