feat: 移除所有工具结果截断,确保内容完整传给AI
删除TOOL_MAX_RESULT_SIZE配置表和formatDefaultToolResult截断; read_file移除2000行/100KB截断; read_multiple_files移除10000字符截断; list_directory移除2000条目上限; search_files移除10条匹配和50结果限制; run_command移除512KB输出截断; browser_extract移除15000字符截断; session_read移除50条消息限制; session_list移除20条限制; memory search移除8条限制; web_search移除10条结果限制; SearXNG HTML移除内容截断; 版本号升级至0.14.11; 实现工具三档执行模式(auto/confirm/disabled)
This commit is contained in:
@@ -109,21 +109,6 @@ function getOSEnvironment() {
|
||||
};
|
||||
}
|
||||
|
||||
/** 每个工具返回给模型的最大字符数 */
|
||||
const TOOL_MAX_RESULT_SIZE: Record<string, number> = {
|
||||
web_fetch: 20000,
|
||||
web_search: 3000,
|
||||
read_file: 15000,
|
||||
read_multiple_files: 10000,
|
||||
list_directory: 5000,
|
||||
search_files: 5000,
|
||||
run_command: 10000,
|
||||
git: 5000,
|
||||
session_read: 15000,
|
||||
browser_extract: 10000,
|
||||
browser_evaluate: 8000,
|
||||
};
|
||||
|
||||
/** 始终可并行的只读/独立工具 */
|
||||
const ALWAYS_PARALLEL = new Set([
|
||||
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
|
||||
@@ -466,10 +451,7 @@ function formatDefaultToolResult(toolName: string, result: ToolResult): string {
|
||||
k === 'status' || k === 'length' || k === 'isDirectory') continue;
|
||||
clean[k] = v;
|
||||
}
|
||||
let json = JSON.stringify(clean);
|
||||
const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000;
|
||||
if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)';
|
||||
return json;
|
||||
return JSON.stringify(clean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,13 +466,12 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
|
||||
case 'web_search': {
|
||||
const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined;
|
||||
if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' });
|
||||
const visible = Math.min(10, raw.length);
|
||||
const top = raw.slice(0, visible).map((r, i) =>
|
||||
const top = raw.map((r, i) =>
|
||||
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
|
||||
).join('\n\n');
|
||||
const fetched = (result as any)._fetched as Array<{ url: string; title: string; content: string }> | undefined;
|
||||
const body = JSON.stringify({
|
||||
success: true, query: result.query, total: result.total, shown: visible, results: top,
|
||||
success: true, query: result.query, total: result.total, shown: raw.length, results: top,
|
||||
});
|
||||
if (fetched && fetched.length > 0) {
|
||||
return body + '\n\n' + fetched.map((f, i) =>
|
||||
@@ -881,18 +862,14 @@ async function handleInit(
|
||||
if (ctx.mode === 'plan') {
|
||||
const resumeData = state.get<{ steps: Array<{index:number;label:string;done:boolean}>; total: number; done: number; loopCount: number } | null>('_planResumeData', null);
|
||||
if (resumeData && resumeData.steps.length > 0 && resumeData.done < resumeData.total) {
|
||||
// 恢复追踪器状态
|
||||
const { initPlanTracker } = await import('./tool-registry.js');
|
||||
const steps = resumeData.steps.map(s => s.label);
|
||||
const tracker = initPlanTracker(steps);
|
||||
// 标记已完成的步骤
|
||||
for (const s of resumeData.steps) {
|
||||
if (s.done) {
|
||||
tracker.steps[s.index - 1].done = true;
|
||||
tracker.done++;
|
||||
}
|
||||
}
|
||||
// 直接从恢复数据构建追踪器,一次保存(避免 initPlanTracker 的冗余中间保存)
|
||||
const { savePlanTracker } = await import('./tool-registry.js');
|
||||
const tracker = {
|
||||
steps: resumeData.steps.map(s => ({ index: s.index, label: s.label, done: s.done })),
|
||||
total: resumeData.total,
|
||||
done: resumeData.done,
|
||||
active: true as const,
|
||||
};
|
||||
savePlanTracker(tracker);
|
||||
// 清空恢复数据,避免重复恢复
|
||||
state.set('_planResumeData', null);
|
||||
@@ -990,6 +967,7 @@ async function handleInit(
|
||||
role: 'user' as const,
|
||||
content: `[Plan Mode] 请按照 Plan Mode 执行规则中指定的格式输出执行计划。不要直接执行工具调用,先输出计划等待用户批准。`,
|
||||
ephemeral: true,
|
||||
planConstraint: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1018,7 +996,7 @@ function extractPlanSteps(content: string): string[] {
|
||||
// 匹配: 1. **步骤名** — 工具: xxx — 描述
|
||||
const match = line.match(/^\d+[\.\)、]\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*[—\-]\s*)/);
|
||||
if (match) {
|
||||
const step = match[0].trim();
|
||||
const step = match[1].trim();
|
||||
if (step.length >= 5 && step.length <= 200) {
|
||||
steps.push(step);
|
||||
continue;
|
||||
@@ -1048,7 +1026,7 @@ function extractPlanSteps(content: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
return steps.length > 0 ? steps.slice(0, 8) : ['执行任务计划(详见上方描述)'];
|
||||
return steps.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1283,7 +1261,8 @@ async function handleThinking(
|
||||
}
|
||||
|
||||
// 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的
|
||||
if (ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
|
||||
// Plan Mode 下由 Plan Tracker 统一追踪进度,跳过避免信息冗余
|
||||
if (ctx.mode !== 'plan' && ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
|
||||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
||||
const userText = firstUserMsg?.content || '';
|
||||
const pending = detectPendingActions(userText, ctx.allToolRecords);
|
||||
@@ -1888,8 +1867,12 @@ async function handleReflecting(
|
||||
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
|
||||
// P1 修复:移除 ctx.toolCalls.length === 0 条件 — 模型可能忽略 Plan Mode 指令直接调用工具,
|
||||
// 此时应取消工具执行,转为等待用户确认计划
|
||||
if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.content.length > 50) {
|
||||
const isPlanLike = /计划|步骤|step|plan|思路|方案|流程/.test(ctx.content.slice(0, 200));
|
||||
if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.content.length > 20) {
|
||||
// 收紧检测:需要同时包含计划关键词和结构化标记(编号列表或 Markdown 标题)
|
||||
const head = ctx.content.slice(0, 300);
|
||||
const hasPlanKeyword = /执行计划|步骤[一二三四五六七八九十\d]|Step\s*\d|Plan:|行动计划|实施方案/.test(head);
|
||||
const hasStructure = /^\s*(\d+[\.\)、]|[-*]\s)/m.test(head) || /^#{1,3}\s/m.test(head);
|
||||
const isPlanLike = hasPlanKeyword && hasStructure;
|
||||
if (isPlanLike && callbacks.onPlanReady) {
|
||||
// 如果模型在第一轮就调用了工具,取消这些工具调用(Plan Mode 要求先规划后执行)
|
||||
if (ctx.toolCalls.length > 0) {
|
||||
@@ -1928,10 +1911,9 @@ async function handleReflecting(
|
||||
initPlanTracker(steps);
|
||||
logInfo('Plan Tracker 已启动', `${steps.length} 个步骤`);
|
||||
}
|
||||
// 移除 Plan Mode 的"不要执行工具"约束消息,替换为执行指令
|
||||
// 从后往前找到并移除 Plan Mode 提示(ephemeral 标记)
|
||||
// 移除 Plan Mode 的约束消息(通过 planConstraint 标记精确匹配,不依赖字符串内容)
|
||||
for (let i = ctx.messages.length - 1; i >= 0; i--) {
|
||||
if (ctx.messages[i].ephemeral && ctx.messages[i].content?.includes('不要直接执行工具调用')) {
|
||||
if (ctx.messages[i].planConstraint) {
|
||||
ctx.messages.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
@@ -1944,9 +1926,40 @@ async function handleReflecting(
|
||||
transition(ctx, S.THINKING);
|
||||
return;
|
||||
}
|
||||
// 非计划回复(如简单问答)→ 跳过 Plan Mode,直接完成
|
||||
// 非计划回复 → 检查是否需要保护
|
||||
if (!isPlanLike) {
|
||||
logInfo('Plan Mode: 回复非计划内容,直接完成');
|
||||
// 模型未输出计划但调用了工具 → 取消工具,要求重新规划
|
||||
if (ctx.toolCalls.length > 0) {
|
||||
ctx.planRetries++;
|
||||
if (ctx.planRetries >= 3) {
|
||||
logWarn('Plan Mode: 非计划回复+工具调用达到最大重试次数,强制执行');
|
||||
for (const call of ctx.toolCalls) {
|
||||
callbacks.onToolCallError(call.function.name, 'Plan Mode: 超过最大重试次数', call);
|
||||
}
|
||||
ctx.toolCalls.length = 0;
|
||||
ctx.messages.push({
|
||||
role: 'user',
|
||||
content: '已达到最大计划重试次数。请基于当前回复直接开始执行任务。',
|
||||
ephemeral: true,
|
||||
});
|
||||
transition(ctx, S.THINKING);
|
||||
return;
|
||||
}
|
||||
logWarn(`Plan Mode: 回复非计划内容但调用了 ${ctx.toolCalls.length} 个工具,取消并要求重新规划 (重试 ${ctx.planRetries}/3)`);
|
||||
for (const call of ctx.toolCalls) {
|
||||
callbacks.onToolCallError(call.function.name, 'Plan Mode: 首轮应输出计划,请先规划再执行', call);
|
||||
}
|
||||
ctx.toolCalls.length = 0;
|
||||
ctx.messages.push({
|
||||
role: 'user',
|
||||
content: '请按照 Plan Mode 执行规则输出结构化执行计划(包含编号步骤),不要直接回答或调用工具。',
|
||||
ephemeral: true,
|
||||
});
|
||||
transition(ctx, S.THINKING);
|
||||
return;
|
||||
}
|
||||
// 无工具调用的简单回复 → 跳过 Plan Mode,直接完成
|
||||
logInfo('Plan Mode: 回复非计划内容(简单问答),直接完成');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1977,13 +1990,13 @@ async function handleReflecting(
|
||||
|
||||
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
|
||||
// ── 保存 Plan Mode 最终进度到状态,供下一轮 Loop 注入历史 ──
|
||||
// 注意:不在此处 clearPlanTracker,由 finally 块统一处理(保存 resumeData 后清理)
|
||||
if (ctx.mode === 'plan') {
|
||||
const tracker = getPlanTracker();
|
||||
if (tracker.active && tracker.steps.length > 0) {
|
||||
state.set('_lastPlanStatus', formatPlanStatus());
|
||||
}
|
||||
}
|
||||
clearPlanTracker();
|
||||
|
||||
// ── 自动记忆提取(异步,不阻塞用户看到回复)──
|
||||
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
|
||||
|
||||
@@ -286,7 +286,7 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
|
||||
/**
|
||||
* 关键词搜索记忆
|
||||
*/
|
||||
export function searchMemory(entries: MemoryEntry[], query: string, limit = 8): MemorySearchResult[] {
|
||||
export function searchMemory(entries: MemoryEntry[], query: string, limit = 0): MemorySearchResult[] {
|
||||
if (!query || entries.length === 0) return [];
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
@@ -331,7 +331,7 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 8):
|
||||
}
|
||||
}
|
||||
|
||||
return merged.slice(0, limit);
|
||||
return limit > 0 ? merged.slice(0, limit) : merged;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -437,7 +437,7 @@ export async function loadAllEntries(): Promise<MemoryEntry[]> {
|
||||
}
|
||||
|
||||
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
|
||||
export async function search(query: string, limit = 8): Promise<MemorySearchResult[]> {
|
||||
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
|
||||
try {
|
||||
const entries = await loadAllEntries();
|
||||
return searchMemory(entries, query, limit);
|
||||
|
||||
@@ -446,7 +446,7 @@ CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) an
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['status', 'mark_done', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_all_done=全部完成' },
|
||||
action: { type: 'string', enum: ['status', 'mark_done', 'mark_undone', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_undone=撤销步骤完成标记, mark_all_done=全部完成' },
|
||||
step_index: { type: 'integer', description: 'Step number (1-indexed) to mark as done. Required for mark_done.' },
|
||||
step_label: { type: 'string', description: 'Optional: description of what was completed, for logging.' }
|
||||
}
|
||||
@@ -675,34 +675,45 @@ CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) an
|
||||
}
|
||||
];
|
||||
|
||||
// 需要用户确认的工具:写操作、删除、命令执行、压缩、浏览器操作
|
||||
const CONFIRM_TOOLS = [
|
||||
// 支持三档开关的工具列表(auto/confirm/disabled)
|
||||
// 浏览器工具不需要确认,永远自动执行
|
||||
const MODE_TOOLS = [
|
||||
'run_command',
|
||||
'write_file', 'create_directory', 'delete_file',
|
||||
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
|
||||
'download_file', 'compress',
|
||||
'browser_open', 'browser_click', 'browser_type', 'browser_evaluate',
|
||||
];
|
||||
|
||||
export type ToolMode = 'auto' | 'confirm' | 'disabled';
|
||||
|
||||
// 工具模式缓存:key=工具名, value=模式
|
||||
const _toolModes = new Map<string, ToolMode>();
|
||||
|
||||
export function needsConfirmation(toolName: string): boolean {
|
||||
if (toolName === 'run_command') {
|
||||
// run_command 确认模式由 runCommandMode 状态控制
|
||||
const runCommandMode = (window as any).__runCommandMode || 'confirm';
|
||||
return runCommandMode === 'confirm';
|
||||
}
|
||||
return CONFIRM_TOOLS.includes(toolName);
|
||||
if (!MODE_TOOLS.includes(toolName)) return false;
|
||||
return getToolMode(toolName) === 'confirm';
|
||||
}
|
||||
|
||||
/** 设置 run_command 执行模式 */
|
||||
export function setRunCommandMode(mode: 'auto' | 'confirm' | 'disabled'): void {
|
||||
(window as any).__runCommandMode = mode;
|
||||
/** 获取工具模式,默认 confirm */
|
||||
export function getToolMode(toolName: string): ToolMode {
|
||||
return _toolModes.get(toolName) ?? 'confirm';
|
||||
}
|
||||
|
||||
/** 设置工具执行模式 */
|
||||
export function setToolMode(toolName: string, mode: ToolMode): void {
|
||||
_toolModes.set(toolName, mode);
|
||||
if (mode === 'disabled') {
|
||||
setToolEnabled('run_command', false);
|
||||
setToolEnabled(toolName, false);
|
||||
} else {
|
||||
setToolEnabled('run_command', true);
|
||||
setToolEnabled(toolName, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 兼容旧接口:设置 run_command 执行模式 */
|
||||
export function setRunCommandMode(mode: ToolMode): void {
|
||||
setToolMode('run_command', mode);
|
||||
}
|
||||
|
||||
let enabledTools: Set<string> = new Set([
|
||||
'read_file', 'list_directory', 'search_files',
|
||||
'write_file', 'create_directory', 'delete_file',
|
||||
@@ -729,8 +740,8 @@ export function setPlanModeActive(active: boolean): void {
|
||||
enabledTools.add('plan_track');
|
||||
} else {
|
||||
enabledTools.delete('plan_track');
|
||||
// 清除追踪器状态
|
||||
state.set('_planTracker', null);
|
||||
// 清除追踪器状态(同时清模块变量和 state)
|
||||
clearPlanTracker();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,9 +804,9 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
switch (action) {
|
||||
case 'search': {
|
||||
const query = args.query as string;
|
||||
const limit = (args.limit as number) || 8;
|
||||
if (!query) return { success: false, error: '缺少 query 参数' };
|
||||
const results = await search(query, limit);
|
||||
const limit = (args.limit as number) || 0; // 0 = 不限制
|
||||
if (!query) return { success: false, error: '缺少 query 参数' };
|
||||
const results = await search(query, limit);
|
||||
logToolResult('memory', true, `${results.length} 条结果`);
|
||||
return { success: true, action, results, total: results.length };
|
||||
}
|
||||
@@ -843,7 +854,7 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
if (toolName === 'session_list') {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
|
||||
const limit = (args.limit as number) || 20;
|
||||
const limit = (args.limit as number) || 0; // 0 = 不限制
|
||||
const search = (args.search as string) || '';
|
||||
const sessions = await bridge.db.getAllSessions();
|
||||
let filtered = sessions.map((s: any) => ({
|
||||
@@ -858,7 +869,7 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
filtered = filtered.filter((s: any) => s.title.toLowerCase().includes(search.toLowerCase()));
|
||||
}
|
||||
filtered.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
|
||||
filtered = filtered.slice(0, limit);
|
||||
if (limit > 0) filtered = filtered.slice(0, limit);
|
||||
logToolResult('session_list', true, `${filtered.length} 个会话`);
|
||||
return { success: true, sessions: filtered, total: filtered.length };
|
||||
}
|
||||
@@ -866,12 +877,12 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
|
||||
const sessionId = args.session_id as string;
|
||||
const maxMessages = (args.max_messages as number) || 50;
|
||||
const maxMessages = (args.max_messages as number) || 0; // 0 = 不限制
|
||||
if (!sessionId) return { success: false, error: '缺少 session_id 参数' };
|
||||
const session = await bridge.db.getSession(sessionId);
|
||||
if (!session) return { success: false, error: `会话 ${sessionId} 不存在` };
|
||||
const messages = await bridge.db.getMessages(sessionId);
|
||||
const msgs = messages.slice(0, maxMessages).map((m: any) => ({
|
||||
const msgs = (maxMessages > 0 ? messages.slice(0, maxMessages) : messages).map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
timestamp: m.created_at
|
||||
@@ -909,6 +920,9 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
return { success: true, action: 'status', steps: tracker.steps, total: tracker.total, done: tracker.done };
|
||||
}
|
||||
if (action === 'mark_done' && typeof stepIndex === 'number' && stepIndex > 0) {
|
||||
if (!Number.isInteger(stepIndex)) {
|
||||
return { success: false, error: `step_index 必须是整数,收到 ${stepIndex}` };
|
||||
}
|
||||
const idx = stepIndex - 1;
|
||||
if (idx >= 0 && idx < tracker.steps.length) {
|
||||
tracker.steps[idx].done = true;
|
||||
@@ -921,6 +935,21 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
||||
}
|
||||
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
|
||||
}
|
||||
if (action === 'mark_undone' && typeof stepIndex === 'number' && stepIndex > 0) {
|
||||
if (!Number.isInteger(stepIndex)) {
|
||||
return { success: false, error: `step_index 必须是整数,收到 ${stepIndex}` };
|
||||
}
|
||||
const idx = stepIndex - 1;
|
||||
if (idx >= 0 && idx < tracker.steps.length) {
|
||||
tracker.steps[idx].done = false;
|
||||
tracker.done = tracker.steps.filter(s => s.done).length;
|
||||
savePlanTracker(tracker);
|
||||
const remaining = tracker.total - tracker.done;
|
||||
logToolResult('plan_track', true, `步骤 ${stepIndex} 已撤销完成标记 (${tracker.done}/${tracker.total}, 剩余 ${remaining})`);
|
||||
return { success: true, action: 'mark_undone', step: stepIndex, done: tracker.done, total: tracker.total, remaining };
|
||||
}
|
||||
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
|
||||
}
|
||||
if (action === 'mark_all_done') {
|
||||
for (const s of tracker.steps) s.done = true;
|
||||
tracker.done = tracker.total;
|
||||
@@ -996,10 +1025,12 @@ export interface PlanTracker {
|
||||
|
||||
let _planTracker: PlanTracker = { steps: [], total: 0, done: 0, active: false };
|
||||
|
||||
const EMPTY_TRACKER: PlanTracker = { steps: [], total: 0, done: 0, active: false };
|
||||
|
||||
export function getPlanTracker(): PlanTracker {
|
||||
// 每次读取时同步 state 中的最新状态
|
||||
// 始终从 state 同步最新状态;state 为 null 时返回空追踪器
|
||||
const saved = state.get<PlanTracker | null>('_planTracker', null);
|
||||
if (saved) _planTracker = saved;
|
||||
_planTracker = saved ?? EMPTY_TRACKER;
|
||||
return _planTracker;
|
||||
}
|
||||
|
||||
@@ -1021,7 +1052,7 @@ export function initPlanTracker(steps: string[]): PlanTracker {
|
||||
}
|
||||
|
||||
export function clearPlanTracker(): void {
|
||||
_planTracker = { steps: [], total: 0, done: 0, active: false };
|
||||
_planTracker = EMPTY_TRACKER;
|
||||
state.set('_planTracker', null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user