- 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:
@@ -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: 主动上下文压缩 — 使用统一计算的趋势预测结果
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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_calls(Ollama 格式)
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
// 写回失败不影响主流程,下次访问会再次调度
|
||||
|
||||
@@ -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} 条结果`);
|
||||
|
||||
Reference in New Issue
Block a user