@yeaft/webchat-agent 1.0.209 → 1.0.211
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/engine.js +6 -2
- package/yeaft/mcp.js +36 -24
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.211"}
|
package/package.json
CHANGED
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
|
-
|
|
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}`;
|
package/yeaft/mcp.js
CHANGED
|
@@ -141,8 +141,17 @@ class MCPServerConnection extends EventEmitter {
|
|
|
141
141
|
*/
|
|
142
142
|
async start() {
|
|
143
143
|
return new Promise((resolve, reject) => {
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
let settled = false;
|
|
145
|
+
let timer = null;
|
|
146
|
+
const failStart = async (err) => {
|
|
147
|
+
if (settled) return;
|
|
148
|
+
settled = true;
|
|
149
|
+
if (timer) clearTimeout(timer);
|
|
150
|
+
await this.stop();
|
|
151
|
+
reject(err);
|
|
152
|
+
};
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
void failStart(new Error(`MCP server "${this.#name}" startup timeout (${STARTUP_TIMEOUT_MS}ms)`));
|
|
146
155
|
}, STARTUP_TIMEOUT_MS);
|
|
147
156
|
|
|
148
157
|
try {
|
|
@@ -181,23 +190,22 @@ class MCPServerConnection extends EventEmitter {
|
|
|
181
190
|
});
|
|
182
191
|
|
|
183
192
|
this.#process.on('error', (err) => {
|
|
184
|
-
|
|
185
|
-
reject(err);
|
|
193
|
+
void failStart(err);
|
|
186
194
|
});
|
|
187
195
|
|
|
188
196
|
// Initialize MCP protocol
|
|
189
197
|
this.#initialize().then(() => {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
settled = true;
|
|
190
200
|
clearTimeout(timer);
|
|
191
201
|
this.#ready = true;
|
|
192
202
|
resolve();
|
|
193
203
|
}).catch((err) => {
|
|
194
|
-
|
|
195
|
-
reject(err);
|
|
204
|
+
void failStart(err);
|
|
196
205
|
});
|
|
197
206
|
|
|
198
207
|
} catch (err) {
|
|
199
|
-
|
|
200
|
-
reject(err);
|
|
208
|
+
void failStart(err);
|
|
201
209
|
}
|
|
202
210
|
});
|
|
203
211
|
}
|
|
@@ -319,23 +327,27 @@ class MCPServerConnection extends EventEmitter {
|
|
|
319
327
|
* Stop the MCP server process.
|
|
320
328
|
*/
|
|
321
329
|
async stop() {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
330
|
+
const child = this.#process;
|
|
331
|
+
if (!child) return;
|
|
332
|
+
|
|
333
|
+
this.#ready = false;
|
|
334
|
+
let closed = this.#closed;
|
|
335
|
+
if (!closed) {
|
|
336
|
+
child.kill('SIGTERM');
|
|
337
|
+
closed = await new Promise(resolve => {
|
|
338
|
+
let timer = null;
|
|
339
|
+
const finish = (didClose) => {
|
|
340
|
+
if (timer) clearTimeout(timer);
|
|
341
|
+
child.removeListener('close', onClose);
|
|
342
|
+
resolve(didClose);
|
|
343
|
+
};
|
|
344
|
+
const onClose = () => finish(true);
|
|
345
|
+
child.once('close', onClose);
|
|
346
|
+
timer = setTimeout(() => finish(false), 2000);
|
|
347
|
+
});
|
|
348
|
+
if (!closed) child.kill('SIGKILL');
|
|
338
349
|
}
|
|
350
|
+
if (this.#process === child) this.#process = null;
|
|
339
351
|
}
|
|
340
352
|
}
|
|
341
353
|
|
package/yeaft/tools/bash.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
+
throw new Error(err.message);
|
|
291
292
|
}
|
|
292
293
|
},
|
|
293
294
|
});
|
package/yeaft/tools/file-read.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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 =
|
|
197
|
+
const startLine = offset;
|
|
131
198
|
const endLine = Math.min(startLine + limit, totalLines);
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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;
|
package/yeaft/tools/glob.js
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
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
|
|
133
|
-
|
|
137
|
+
const paths = [];
|
|
134
138
|
for await (const entry of walkDir(baseDir, baseDir)) {
|
|
135
|
-
if (
|
|
139
|
+
if (!entry.isDir && matchGlob(pattern, entry.path)) paths.push(entry.path);
|
|
140
|
+
}
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
|
|
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,
|
|
141
|
-
|
|
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
|
-
|
|
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)';
|
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,
|