v0.17.2: 上下文一致性根治 + 消息差量持久化 + 压缩摘要修复 + 安全层测试补课
CI / verify (push) Successful in 1m53s

- P0: 发送路径用户消息重复注入根治(history-builder 纯模块 + 单测,连带修复 maxCount 截断保留最旧消息缺陷);/undo、/retry 消息删除差量落库(新增 db:getMessageIds/deleteMessages 四层通道);/compress 摘要 role:user + 可折叠卡片渲染 + 旧 system 行读取归一化
- P1: memory search 默认 limit=8;工具缓存键/去重改稳定序列化;run_command 超时联动主进程杀子进程;记忆访问统计写回纳入写入锁;搜索自动抓取单页限幅 8k;Token 趋势采样移出 calculateContextStats 并记录裁剪后值;空白 assistant 幽灵消息跳过入库(新迭代/中止两路径)
- P2: 新增 tool-security(18 用例,平台自适应)与 history-builder(11 用例)测试;帮助/README/DEVELOPMENT 文案与代码事实对齐;vendor 失效 sourcemap 与 .npmrc 弃用配置清理;备份导入携带 attachments 修复
- 版本号升级 0.17.2(5 文件白名单);typecheck 零错误 / 301 测试通过 / 构建通过
This commit is contained in:
2026-09-08 16:32:28 +08:00
parent b66945c8a7
commit 3fb293c618
26 changed files with 789 additions and 201 deletions
+26 -5
View File
@@ -72,6 +72,7 @@ import {
import { executeHooks, addWrittenFile } from './hooks.js';
import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
import { getEffectiveNumCtx } from '../components/model-bar.js';
import { stableStringify } from '../utils/utils.js';
import type {
OllamaMessage,
OllamaStreamChunk,
@@ -276,10 +277,20 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
return timeout;
}
/** run_command 超时/中止时通过主进程终止子进程(killToolProcess),
* 避免"伪超时"后命令仍在后台继续执行、占用资源或继续产生副作用 */
function killToolSubprocess(toolName: string): void {
if (toolName !== 'run_command') return;
try {
void window.metonaDesktop?.workspace?.cmdKill?.();
} catch { /* 终止失败不阻塞超时返回 */ }
}
/** R3: 带超时的工具执行包装器
* P0-E1 修正:executeTool 不接受 AbortSignal,此处用 Promise + settled 标志实现"伪超时"。
* 注意:超时/中止后底层 executeTool 仍在后台执行(fire-and-forget),对有副作用的工具
* write_file/run_command 等)用户应知晓"中止"只是不再等待结果,副作用可能已发生。
* run_command 例外:超时/中止时同步调用主进程 killToolProcess 终止子进程。
* 注意:其他工具超时/中止后底层 executeTool 仍在后台执行(fire-and-forget),对有副作用的工具
* write_file 等)用户应知晓"中止"只是不再等待结果,副作用可能已发生。
*/
async function executeToolWithTimeout(
toolName: string,
@@ -293,6 +304,7 @@ async function executeToolWithTimeout(
// 外部已中止
if (abortSignal?.aborted) {
killToolSubprocess(toolName);
return { success: false, error: '用户中止' };
}
@@ -318,16 +330,21 @@ async function executeToolWithTimeout(
// 超时
timer = setTimeout(() => {
killToolSubprocess(toolName);
settle({ success: false, error: `工具 ${toolName} 执行超时 (${timeoutMs / 1000}s),请尝试拆分任务或优化参数` });
}, timeoutMs);
// 外部中止
if (abortSignal) {
if (abortSignal.aborted) {
killToolSubprocess(toolName);
settle({ success: false, error: '用户中止' });
return;
}
onExternalAbort = () => settle({ success: false, error: '用户中止' });
onExternalAbort = () => {
killToolSubprocess(toolName);
settle({ success: false, error: '用户中止' });
};
abortSignal.addEventListener('abort', onExternalAbort, { once: true });
}
@@ -548,10 +565,10 @@ function isCacheValid(toolName: string, timestamp: number): boolean {
return Date.now() - timestamp < ttl;
}
/** 生成工具调用缓存 key */
/** 生成工具调用缓存 key(稳定序列化:键排序,键序不同参数相同也命中同一缓存) */
function getToolCacheKey(name: string, args: Record<string, unknown>): string {
try {
return name + '::' + JSON.stringify(args, Object.keys(args).sort());
return name + '::' + stableStringify(args);
} catch {
return name + '::' + String(args);
}
@@ -1847,6 +1864,10 @@ async function handleObserving(
// 统一上下文统计(第二次计算,裁剪后重新统计,复用于后续所有判断)
ctxStats = calculateContextStats(ctx.messages, numCtx);
// Token 趋势采样 — 每轮迭代仅记录一次,且记录裁剪后的值
// (这才是下一轮实际发送的上下文规模;此前在 calculateContextStats 内部采样,
// 一轮内多次调用会产生重复数据点,污染趋势预测的线性回归)
recordTokenUsage(ctxStats.contentTokens, numCtx);
// R53: 主动上下文压缩 — 使用统一计算的趋势预测结果
{
+2 -1
View File
@@ -14,6 +14,7 @@
import type { OllamaMessage } from '../types.js';
import { logInfo } from './log-service.js';
import { stableStringify } from '../utils/utils.js';
// ═══════════════════════════════════════════════════════════════
// R51: 工具结果离线存储
@@ -60,7 +61,7 @@ const _toolCallHistory: string[] = [];
/** 记录工具调用到历史序列(供快照/恢复) */
export function recordToolCallHistory(toolName: string, args: Record<string, unknown>): void {
const key = `${toolName}:${JSON.stringify(args, Object.keys(args).sort()).slice(0, 100)}`;
const key = `${toolName}:${stableStringify(args).slice(0, 100)}`;
_toolCallHistory.push(key);
if (_toolCallHistory.length > 8) _toolCallHistory.shift();
}
+4 -3
View File
@@ -1015,6 +1015,10 @@ function calculateTotalTokens(messages: OllamaMessage[]): number {
/**
* 统一上下文统计 — 单次计算替代多次遍历
*
* 注意:本函数是纯计算(无副作用)。Token 趋势采样(recordTokenUsage
* 由调用方在合适的频率执行——本函数在 OBSERVING 中一轮内会被调用多次,
* 若在此处采样会导致同一轮被记录多个数据点,趋势预测失真。
*/
export function calculateContextStats(
messages: OllamaMessage[],
@@ -1031,9 +1035,6 @@ export function calculateContextStats(
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
const msgCount = messages.length;
// 记录当前迭代的 token 使用量(必须在 predictContextOverflow 之前调用)
recordTokenUsage(contentTokens, numCtx);
// 压力等级计算(内联,避免重复遍历)
let level: ContextPressureLevel;
const actions: string[] = [];
+87
View File
@@ -0,0 +1,87 @@
/**
* History Builder — 从会话消息构建 Ollama 格式的历史消息列表(纯函数)
*
* 发送与 /retry 两条路径共用的唯一实现,保证跨 Loop 上下文一致。
*
* 核心契约(防重复注入):调用方传入完整会话消息,本函数定位末尾的
* 当前用户消息并排除它及其之后的所有内容——当前输入由
* agent-engine.handleInit 以 userContent/images 重新构造注入。
* 若历史中再包含一份当前输入,用户消息(含图片 base64)会被发送两份,
* 视觉模型下图片 token 直接翻倍。
*
* 不注入 system 消息——系统提示词始终由 handleInit 统一构建,
* 保证传给 API 时只有 1 条 system 且排在首位。
*/
import { formatToolResultForModel } from './result-formatter.js';
import type { ChatMessage, OllamaMessage } from '../types.js';
export function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
// 定位最后一条 user 消息(即本轮当前输入),排除它及其后的所有消息
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break; }
}
const historyMsgs = lastUserIdx >= 0 ? msgs.slice(0, lastUserIdx) : msgs;
const result: OllamaMessage[] = [];
for (const msg of historyMsgs) {
if (msg.role !== 'assistant' && msg.role !== 'user') continue;
if (msg.role === 'user') {
// 用 _apiContent(含附件 JSON 结构化数据),没有则回退 content
const content = (msg as { _apiContent?: string })._apiContent || msg.content || '';
result.push({ role: 'user', content, ...(msg.images?.length && { images: msg.images }) });
continue;
}
// assistant:注入内容、thinking、images 与 tool_callsOllama 格式)
const assistantMsg: OllamaMessage = {
role: 'assistant',
content: msg.content || '',
...(msg.think && { thinking: msg.think }),
...(msg.images?.length && { images: msg.images }),
};
if (msg.toolCalls?.length) {
assistantMsg.tool_calls = msg.toolCalls.map(tc => ({
type: 'function' as const,
function: {
name: tc.name,
arguments: tc.arguments,
},
}));
}
result.push(assistantMsg);
// 注入 tool 结果消息(role: 'tool'),复用 formatToolResultForModel
// 保持与当前 Loop 格式一致
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
if (!tc.result) continue;
const formattedResult = formatToolResultForModel(tc.name, tc.result);
// R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
const resultDuration = Date.now() - tc.timestamp;
const resultSize = formattedResult.length;
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
result.push({
role: 'tool',
tool_name: tc.name,
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`,
});
}
}
}
// 超过 maxCount 时从头部裁剪,保留最近的消息(旧实现在循环内 break,
// 长会话会保留最旧的消息、丢失最近上下文)。
// 裁剪点不得落在 tool 消息上——其所属 assistant(tool_calls) 会被切掉,产生孤立 tool 消息
if (result.length > maxCount) {
let cut = result.length - maxCount;
while (cut < result.length && result[cut].role === 'tool') cut++;
return result.slice(cut);
}
return result;
}
+8 -3
View File
@@ -535,9 +535,14 @@ function scheduleHitsFlush(): void {
_hitsFlushTimer = setTimeout(async () => {
_hitsFlushTimer = null;
try {
if (_entriesCache && _entriesCache.length > 0) {
// 仅为访问统计(hits/last)写回,不新增/改动记忆条目
await writeMemoryFile(serializeMemoryMd(_entriesCache), '访问统计写回,无新条目');
const entries = _entriesCache;
if (entries && entries.length > 0) {
// 仅为访问统计(hits/last)写回,不新增/改动记忆条目。
// 必须走写入锁:否则与 add/replace/remove 并发时,
// 旧缓存快照可能覆盖掉正在写入的新条目(丢失记忆)
await withWriteLock(() =>
writeMemoryFile(serializeMemoryMd(entries), '访问统计写回,无新条目')
);
}
} catch {
// 写回失败不影响主流程,下次访问会再次调度
+6 -1
View File
@@ -1348,7 +1348,12 @@ 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) || 0; // 0 = 不限制
// 未指定 limit 时默认 8(与工具 schema 描述一致);
// 旧实现 0=不限,模型省略参数会返回全部记忆撑爆上下文
const rawLimit = args.limit as number | undefined;
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0
? Math.floor(rawLimit)
: 8;
if (!query) return { success: false, error: '缺少 query 参数' };
const results = await search(query, limit);
logToolResult('memory', true, `${results.length} 条结果`);