v0.17.1: 退出释放显存修正 + 备份完整性 + 核心逻辑测试补课 + 上下文逻辑收敛 + 记忆日志可读性
CI / verify (push) Successful in 1m2s

修复:
- main.ts 退出释放模型显存改用 getSetting(serverUrl),不再硬编码 127.0.0.1:11434(避免非默认地址时释放请求打到错误端口)
- 备份导出/导入并入 localStorage 持久化状态(会话摘要、度量历史、轨迹降级缓存、主题),版本升级到 v2,实现完整备份
- 工具数量改为 getEnabledToolDefinitions().length 动态计算,删除写死"32 个"的硬编码
- 记忆日志区分操作来源:memory:write 透传 reason,标注"新增记忆/替换/删除/清空/TTL 衰减清理/访问统计写回(无新条目)",避免"写了但看不到新记忆"的困惑

可维护性:
- 上下文压力逻辑收敛到统一 calculateContextStats,删除 getContextPressureLevel / getTrendAwareCompressThreshold 的重复实现
- 消除 validateToolArgs 同名碰撞(agent-engine 本地版改名 validateToolArgsQuick)
- 子代理工具集改用 getEnabledToolDefinitions() 基线,跟随全局启用开关与 Plan 模式
- 抽取 html-utils.ts 纯函数模块(实体解码/HTML→文本/HTML→Markdown/拦截页检测/相关性评分),tool-handlers-system 净减约 190 行重复代码
- 统一静态导入(savePlanTracker/setPlanModeActive/collectDiagnostics/addWrittenFile)
- console.* 使用处补充豁免说明(启动/退出/刷盘阶段无渲染进程可推送日志)
- run_command 工具描述改为反映可配置执行模式

测试:
- 新增 7 个测试文件 + 扩展 2 个,共 273 个测试(原 34 → 273)
- 覆盖 agent-engine / agent-safety / context-manager / tool-registry / result-formatter / tool-parsing / memory-service / crypto / build-context / html-utils / utils / tool-handlers-fs
- 全部通过 npm run typecheck && npm test && npm run build
This commit is contained in:
2026-08-26 15:02:47 +08:00
parent 0b172d30c0
commit b66945c8a7
32 changed files with 2082 additions and 326 deletions
+37 -12
View File
@@ -14,6 +14,8 @@ import {
initPlanTracker,
getPlanTracker,
clearPlanTracker,
savePlanTracker,
setPlanModeActive,
setSubAgentConfirmHandler,
} from './tool-registry.js';
import {
@@ -33,6 +35,9 @@ import {
// 错误恢复建议
getErrorRecoverySuggestions,
formatErrorRecovery,
// 诊断报告
collectDiagnostics,
formatDiagnosticsReport,
} from './agent-safety.js';
import { search, formatMemoryContext } from './memory-service.js';
import { formatToolResultForModel, summarizeAuditResult } from './result-formatter.js';
@@ -64,7 +69,7 @@ import {
// R125: Agent 状态检查点
createCheckpoint, clearCheckpoints,
} from './context-manager.js';
import { executeHooks } from './hooks.js';
import { executeHooks, addWrittenFile } from './hooks.js';
import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
import { getEffectiveNumCtx } from '../components/model-bar.js';
import type {
@@ -93,7 +98,7 @@ let _filteredTools: import('../types.js').ToolDefinition[] = [];
/** S1/S6: 清洗不可信文本,移除提示词注入模式
* P3 #10 增强:先标准化 Unicode/零宽字符,再匹配更多变体
*/
function sanitizeUntrustedInput(text: string): string {
export function sanitizeUntrustedInput(text: string): string {
if (!text) return '';
// 先标准化:移除零宽字符、全角→半角,避免同形字符绕过
let normalized = text
@@ -425,7 +430,7 @@ function extractAffectedPath(call: ToolCall): string | null {
}
/** 两个路径是否冲突(相同、父子、重叠) */
function pathsConflict(a: string, b: string): boolean {
export function pathsConflict(a: string, b: string): boolean {
if (!a || !b) return false;
const na = a.replace(/\\/g, '/').replace(/\/+$/, '');
const nb = b.replace(/\\/g, '/').replace(/\/+$/, '');
@@ -433,8 +438,9 @@ function pathsConflict(a: string, b: string): boolean {
return na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/');
}
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代 */
function validateToolArgs(toolName: string, args: Record<string, unknown>): string | null {
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代
* 注:与 tool-registry 的 schema 校验 validateToolArgs 同名不同返回类型,此处重命名避免混淆 */
export function validateToolArgsQuick(toolName: string, args: Record<string, unknown>): string | null {
const getString = (key: string): string | null => {
const v = args[key];
if (typeof v === 'string' && v.trim().length > 0) return v;
@@ -649,6 +655,29 @@ function _fallbackSaveTraces(traces: Array<Record<string, any>>): void {
}
}
/** A2: 备份导出 — 读取轨迹降级缓存(供 .metona 备份携带) */
export function getTraceFallbackBackup(): Array<Record<string, any>> {
try {
const raw = localStorage.getItem(TRACE_FALLBACK_KEY);
if (!raw) return [];
return JSON.parse(raw) as Array<Record<string, any>>;
} catch {
return [];
}
}
/** A2: 备份导入 — 恢复轨迹降级缓存 */
export function restoreTraceFallbackBackup(traces: Array<Record<string, any>>): void {
if (!Array.isArray(traces)) return;
try {
const sliced = traces.slice(-TRACE_FALLBACK_MAX);
localStorage.setItem(TRACE_FALLBACK_KEY, JSON.stringify(sliced));
logInfo(`R9: 从备份恢复 ${sliced.length} 条轨迹降级缓存`);
} catch (err) {
logWarn(`R9: 恢复轨迹降级缓存失败: ${(err as Error).message}`);
}
}
/** 保存执行轨迹到 SQLite(缓冲写入) */
async function saveTrace(trace: Record<string, any>): Promise<void> {
_traceBuffer.push(trace);
@@ -905,7 +934,6 @@ ${formatted}
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) {
// 直接从恢复数据构建追踪器,一次保存(避免 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,
@@ -1019,7 +1047,7 @@ ${formatted}
}
/** 按 token 预算截断文本(约 1.5 中文字/token, 4 英文字符/token */
function truncateByTokenBudget(text: string, maxTokens: number): string {
export function truncateByTokenBudget(text: string, maxTokens: number): string {
const estimated = estimateTokens(text);
if (estimated <= maxTokens) return text;
// 按比例截取
@@ -1029,7 +1057,7 @@ function truncateByTokenBudget(text: string, maxTokens: number): string {
}
/** 从 Plan Mode 输出中提取步骤列表(兼容多种模型格式) */
function extractPlanSteps(content: string): string[] {
export function extractPlanSteps(content: string): string[] {
const steps: string[] = [];
// 策略1: 精确匹配 "## 执行计划" 区块中的编号行
@@ -1485,7 +1513,7 @@ async function handleExecuting(
}
// P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代
const paramError = validateToolArgs(call.function.name, call.function.arguments);
const paramError = validateToolArgsQuick(call.function.name, call.function.arguments);
if (paramError) {
logWarn(`参数校验失败: ${call.function.name}`, paramError);
return [{
@@ -1600,7 +1628,6 @@ async function handleExecuting(
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
const { addWrittenFile } = await import('./hooks.js');
addWrittenFile(String(call.function.arguments.path), String(call.function.arguments.content || ''));
}
return [{
@@ -1849,7 +1876,6 @@ async function handleObserving(
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
try {
const { collectDiagnostics, formatDiagnosticsReport } = await import('./agent-safety.js');
const diag = collectDiagnostics();
logInfo('R112: 诊断报告\n' + formatDiagnosticsReport(diag));
// R100: Token 使用统计报告
@@ -2271,7 +2297,6 @@ export async function runAgentLoop(
snapshotLoopContext(ctx);
startSessionMetrics(sessionId, model);
// Plan Mode 激活时注册 plan_track 工具
const { setPlanModeActive } = await import('./tool-registry.js');
setPlanModeActive(mode === 'plan');
// 子代理确认管线:与主 Agent 共用同一确认回调(finally 中清理)
setSubAgentConfirmHandler(callbacks.onConfirmTool ?? null);
+38 -1
View File
@@ -9,7 +9,7 @@
* - 度量仪表盘数据源
*/
import { logInfo, logDebug } from './log-service.js';
import { logInfo, logDebug, logWarn } from './log-service.js';
import type { AgentMetrics, LoopContext } from '../types.js';
// ═══════════════════════════════════════════════════════════════
@@ -157,6 +157,43 @@ export function getMetricsHistory(): SessionMetrics[] {
return [...sessionMetricsHistory];
}
/** A2: 备份导出 — 读取度量历史持久化数据(供 .metona 备份携带) */
export function getMetricsBackup(): Array<Record<string, unknown>> {
try {
const raw = localStorage.getItem(METRICS_STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as Array<Record<string, unknown>>;
} catch {
return [];
}
}
/** A2: 备份导入 — 恢复度量历史持久化数据(同时写入存储并重建内存历史) */
export function restoreMetricsBackup(data: Array<Record<string, unknown>>): void {
if (!Array.isArray(data)) return;
try {
localStorage.setItem(METRICS_STORAGE_KEY, JSON.stringify(data.slice(-METRICS_HISTORY_MAX)));
// 重建内存历史(供当前会话仪表盘立即生效)
sessionMetricsHistory.length = 0;
for (const item of data) {
sessionMetricsHistory.push({
sessionId: String(item.sessionId || 'unknown'),
model: String(item.model || 'unknown'),
startTime: item.timestamp ? Number(item.timestamp) - Number(item.duration || 0) : 0,
endTime: Number(item.timestamp || 0),
totalIterations: Number(item.iterations || 0),
toolCalls: [],
totalInputTokens: Number(item.inputTokens || 0),
totalOutputTokens: Number(item.outputTokens || 0),
errorPatterns: Array.isArray(item.errorPatterns) ? (item.errorPatterns as string[]) : [],
});
}
logInfo(`R84: 从备份恢复 ${sessionMetricsHistory.length} 条历史度量`);
} catch (err) {
logWarn(`R84: 恢复度量历史失败: ${(err as Error).message}`);
}
}
// ═══════════════════════════════════════════════════════════════
// 度量计算
// ═══════════════════════════════════════════════════════════════
+22 -86
View File
@@ -1096,43 +1096,14 @@ export function calculateContextStats(
* - medium (30-50%): 轻量压缩(归档旧工具结果、清理 ephemeral)
* - high (50-70%): 中等压缩(截断工具结果、合并消息)
* - critical (>70%): LLM 压缩
*
* C1: 委托 unified calculateContextStats,避免与压缩决策重复计算。
*/
export function getContextPressureLevel(
messages: OllamaMessage[],
numCtx: number,
): ContextPressureInfo {
const totalTokens = messages.reduce((sum, m) => {
let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
}
}
if (m.images?.length) t += m.images.length * 100;
return sum + t;
}, 0);
const ratio = numCtx > 0 ? totalTokens / numCtx : 0;
const msgCount = messages.length;
const actions: string[] = [];
let level: ContextPressureLevel;
if (ratio > 0.7) {
level = 'critical';
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
} else if (ratio > 0.5) {
level = 'high';
actions.push('truncate_results', 'compact_old', 'merge_messages');
} else if (ratio > 0.3) {
level = 'medium';
actions.push('compact_old', 'clear_ephemeral');
} else {
level = 'low';
if (msgCount > 60) actions.push('compact_old');
}
return { level, tokenUsageRatio: ratio, messageCount: msgCount, recommendedActions: actions };
return calculateContextStats(messages, numCtx).pressureInfo;
}
// ═══════════════════════════════════════════════════════════════
@@ -1305,65 +1276,14 @@ export function mergeConsecutiveMessages(messages: OllamaMessage[]): OllamaMessa
* R98: 获取结合趋势的压缩触发阈值
* 如果 token 使用趋势在快速增长,提前触发压缩
* 如果趋势稳定或下降,延后压缩
*
* C1: 委托 unified calculateContextStats 的 compressDecision,避免重复计算。
*/
export function getTrendAwareCompressThreshold(
numCtx: number,
messages: OllamaMessage[],
): { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' } {
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
const currentTokens = messages.reduce((sum, m) => {
let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
}
}
if (m.images?.length) t += m.images.length * 100;
return sum + t;
}, 0);
const usageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
const prediction = predictContextOverflow(numCtx);
// 紧急情况:预测即将溢出
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
return {
shouldCompress: true,
reason: `趋势预测触发: ${prediction.message}`,
urgency: 'high',
};
}
// 趋势加速增长 + 使用率超过基础阈值
if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
return {
shouldCompress: true,
reason: `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`,
urgency: 'medium',
};
}
// 标准阈值触发
if (usageRatio > baseThreshold) {
return {
shouldCompress: true,
reason: `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`,
urgency: usageRatio > 0.6 ? 'high' : 'medium',
};
}
// 消息条数硬阈值
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
if (messages.length >= msgThreshold) {
return {
shouldCompress: true,
reason: `消息条数触发: ${messages.length} >= ${msgThreshold}`,
urgency: 'low',
};
}
return { shouldCompress: false, reason: '', urgency: 'low' };
return calculateContextStats(messages, numCtx).compressDecision;
}
/** R98: 消息条数阈值(独立函数,供 engine 复用) */
@@ -1601,6 +1521,22 @@ export function loadSessionSummaries(): SessionSummary[] {
}
}
/** A2: 备份导出 — 读取会话摘要持久化数据(供 .metona 备份携带) */
export function getSessionSummariesBackup(): SessionSummary[] {
return loadSessionSummaries();
}
/** A2: 备份导入 — 恢复会话摘要持久化数据 */
export function restoreSessionSummariesBackup(summaries: SessionSummary[]): void {
if (!Array.isArray(summaries)) return;
try {
localStorage.setItem(SESSION_SUMMARY_KEY, JSON.stringify(summaries.slice(0, MAX_SESSION_SUMMARIES)));
logInfo(`R123: 从备份恢复 ${summaries.length} 条会话摘要`);
} catch (err) {
logWarn(`R123: 恢复会话摘要失败: ${(err as Error).message}`);
}
}
/** R123: 生成当前会话摘要 */
export function generateSessionSummary(
goal: string,
+20 -16
View File
@@ -143,10 +143,12 @@ async function readMemoryFile(): Promise<string> {
return result.content || '';
}
/** 写入 MEMORY.md(通过专用 IPC 通道) */
async function writeMemoryFile(content: string): Promise<void> {
/** 写入 MEMORY.md(通过专用 IPC 通道)
* reason: 日志标注的操作来源,用于区分"新增记忆/替换/删除/清空/访问统计写回",
* 避免出现"文件已写入但看不到新记忆"的困惑 */
async function writeMemoryFile(content: string, reason?: string): Promise<void> {
const bridge = getBridge();
const result = await bridge.memoryAccess!.write(content);
const result = await bridge.memoryAccess!.write(content, reason);
if (!result.success) {
throw new Error(`写入 MEMORY.md 失败: ${result.error}`);
}
@@ -534,7 +536,8 @@ function scheduleHitsFlush(): void {
_hitsFlushTimer = null;
try {
if (_entriesCache && _entriesCache.length > 0) {
await writeMemoryFile(serializeMemoryMd(_entriesCache));
// 仅为访问统计(hits/last)写回,不新增/改动记忆条目
await writeMemoryFile(serializeMemoryMd(_entriesCache), '访问统计写回,无新条目');
}
} catch {
// 写回失败不影响主流程,下次访问会再次调度
@@ -542,11 +545,12 @@ function scheduleHitsFlush(): void {
}, HITS_FLUSH_INTERVAL);
}
/** 写入条目(同步更新缓存) */
async function persistEntries(entries: MemoryEntry[]): Promise<void> {
/** 写入条目(同步更新缓存)
* reason: 操作来源(新增/替换/删除/衰减清理等),透传给主进程日志 */
async function persistEntries(entries: MemoryEntry[], reason?: string): Promise<void> {
_entriesCache = entries;
const fileContent = entries.length > 0 ? serializeMemoryMd(entries) : '';
await writeMemoryFile(fileContent);
await writeMemoryFile(fileContent, reason);
}
// ═══════════════════════════════════════════════════════════════
@@ -666,7 +670,7 @@ export async function addEntry(
throw new Error(`序列化后校验失败: ${validation.error}`);
}
await persistEntries(entries);
await persistEntries(entries, '新增记忆');
logMemory(`新增: ${type}`, content.slice(0, 60));
return entry;
});
@@ -714,7 +718,7 @@ export async function replaceEntry(oldText: string, newContent: string): Promise
return { success: false, message: `序列化后校验失败: ${validation.error}` };
}
await persistEntries(entries);
await persistEntries(entries, '替换记忆');
logMemory('替换记忆', `${target.id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${target.id}` };
});
@@ -742,7 +746,7 @@ export async function removeEntry(oldText: string): Promise<{ success: boolean;
}
const newEntries = entries.filter(e => e.id !== matches[0].id);
await persistEntries(newEntries);
await persistEntries(newEntries, '删除记忆');
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
@@ -805,7 +809,7 @@ export async function removeEntries(oldTexts: string[]): Promise<{
const newEntries = entries.filter(e => !idsToDelete.has(e.id));
await persistEntries(newEntries);
await persistEntries(newEntries, '批量删除记忆');
logMemory('批量删除', `删除 ${deleted} 条, 失败 ${failed}`);
return {
@@ -827,7 +831,7 @@ export async function removeEntryById(id: string): Promise<{ success: boolean; m
return { success: false, message: `记忆 ${id} 不存在` };
}
const newEntries = entries.filter(e => e.id !== id);
await persistEntries(newEntries);
await persistEntries(newEntries, '删除记忆');
logMemory('删除记忆', `${id}: ${target.content.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${id}` };
});
@@ -838,7 +842,7 @@ export async function removeEntryById(id: string): Promise<{ success: boolean; m
export async function clearAll(): Promise<void> {
return withWriteLock(async () => {
_entriesCache = [];
await writeMemoryFile('');
await writeMemoryFile('', '清空全部记忆');
logMemory('清空', '所有记忆已删除');
});
}
@@ -859,7 +863,7 @@ function extractTags(text: string): string[] {
* 词级 Jaccard 相似度
* M5: 中文文本无法用空格分词,使用字符级 bigram 相似度
*/
function simpleSimilarity(a: string, b: string): number {
export function simpleSimilarity(a: string, b: string): number {
// 判断是否包含中文
const hasChinese = /[一-鿿]/.test(a) || /[一-鿿]/.test(b);
@@ -897,7 +901,7 @@ function simpleSimilarity(a: string, b: string): number {
* 规范化文本用于去重比较:全角→半角标点、统一空白
* 消除 AI 输出中常见的全角逗号/冒号/括号等差异
*/
function normalizeForDedup(text: string): string {
export function normalizeForDedup(text: string): string {
const FULLWIDTH_MAP: Record<string, string> = {
'\uff0c': ',', //
'\uff1a': ':', //
@@ -1087,7 +1091,7 @@ async function maybeRunTTLDecay(): Promise<void> {
const { decayed, removed, changed } = applyTTLDecay(entries);
if (changed && removed > 0) {
await persistEntries(decayed);
await persistEntries(decayed, 'TTL 衰减清理');
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length}`);
}
} catch (err) {
+4 -3
View File
@@ -6,7 +6,6 @@
import { state, KEYS } from '../state/state.js';
import { OllamaAPI } from '../api/ollama.js';
import { TOOL_DEFINITIONS } from './tool-registry.js';
import { getEnabledToolDefinitions, needsConfirmation } from './tool-registry.js';
import { logInfo, logWarn, logError } from './log-service.js';
import { validatePathSandbox, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
@@ -57,10 +56,12 @@ function getToolsForPermission(permission: SubAgentPermission): Set<string> {
}
}
/** 根据权限级别获取可用工具定义 */
/** 根据权限级别获取可用工具定义
* C3: 以全局已启用工具为基线(含 MCP 动态工具 + plan_track 注册状态),
* 再叠加权限白名单,确保被用户禁用的工具不会喂给子代理 LLM。 */
function getSubAgentTools(permission: SubAgentPermission = 'readonly'): ToolDefinition[] {
const allowed = getToolsForPermission(permission);
return TOOL_DEFINITIONS.filter(d => allowed.has(d.function.name));
return getEnabledToolDefinitions().filter(d => allowed.has(d.function.name));
}
export interface SubAgentOptions {
+1 -1
View File
@@ -119,7 +119,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'run_command',
description: 'Execute a shell command via workspace. Timeout configurable (default 300s for general, 600s for long-running). cwd defaults to workspace directory. Requires user confirmation. Output is streamed in real-time through the workspace process.',
description: 'Execute a shell command via workspace. cwd defaults to workspace directory. Output is streamed in real-time through the workspace process. Execution mode is configurable (auto / confirm / disabled) — set in the Tools panel; in confirm mode the user must approve each command before it runs.',
parameters: {
type: 'object',
required: ['command'],