feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* v0.8.2 P0-2: 根 MEMORY.md 保护闸门(工具无关的路径形参数匹配)
|
||||
*
|
||||
* 锁定契约:
|
||||
* - delete_file / file_move(source_path / destination_path)等旧名单遗漏的工具
|
||||
* 对根 MEMORY.md 的操作被拦截(读/写/删/移动/改名任一方向)
|
||||
* - 子目录 MEMORY.md 不受保护(H-5 语义保持)
|
||||
* - 相对路径以 workspacePath 为基解析
|
||||
* - MCP 工具(任意带路径形参数的工具)同样纳入
|
||||
* - 非根 MEMORY.md 的路径不误伤
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AgentLoopEngine } from '../engine';
|
||||
|
||||
const WORKSPACE = join(tmpdir(), 'metona-memory-gate-test');
|
||||
const ROOT_MEMORY = join(WORKSPACE, 'MEMORY.md');
|
||||
|
||||
function makeEngine(): AgentLoopEngine {
|
||||
const engine = new AgentLoopEngine({}, {
|
||||
providerId: 'fake',
|
||||
supportedModels: [],
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
send: vi.fn(),
|
||||
sendStream: vi.fn(),
|
||||
} as never);
|
||||
engine.setWorkspacePath(WORKSPACE);
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** 白盒调用(私有方法契约测试) */
|
||||
function gate(engine: AgentLoopEngine, args: Record<string, unknown>): boolean {
|
||||
return (
|
||||
engine as unknown as {
|
||||
isTargetingRootMemoryMd: (tc: { args: Record<string, unknown> }) => boolean;
|
||||
}
|
||||
).isTargetingRootMemoryMd({ args });
|
||||
}
|
||||
|
||||
describe('根 MEMORY.md 保护闸门(P0-2)', () => {
|
||||
it('delete_file / file_move(旧名单遗漏工具)→ 拦截', () => {
|
||||
const engine = makeEngine();
|
||||
expect(gate(engine, { file_path: ROOT_MEMORY })).toBe(true);
|
||||
expect(gate(engine, { source_path: ROOT_MEMORY, destination_path: join(WORKSPACE, 'x') })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
gate(engine, { source_path: join(WORKSPACE, 'note.md'), destination_path: ROOT_MEMORY }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('路径参数别名矩阵:path / filePath / destination / dir_path 均命中', () => {
|
||||
const engine = makeEngine();
|
||||
expect(gate(engine, { path: ROOT_MEMORY })).toBe(true);
|
||||
expect(gate(engine, { filePath: ROOT_MEMORY })).toBe(true);
|
||||
expect(gate(engine, { destination: ROOT_MEMORY })).toBe(true);
|
||||
expect(gate(engine, { dir_path: ROOT_MEMORY })).toBe(true);
|
||||
});
|
||||
|
||||
it('相对路径以工作空间为基解析 → 拦截', () => {
|
||||
const engine = makeEngine();
|
||||
expect(gate(engine, { file_path: 'MEMORY.md' })).toBe(true);
|
||||
expect(gate(engine, { file_path: './MEMORY.md' })).toBe(true);
|
||||
});
|
||||
|
||||
it('子目录 MEMORY.md 不受保护(H-5 语义)', () => {
|
||||
const engine = makeEngine();
|
||||
expect(gate(engine, { file_path: join(WORKSPACE, 'notes', 'MEMORY.md') })).toBe(false);
|
||||
});
|
||||
|
||||
it('MCP 工具的路径形参数同样纳入(任意工具生效)', () => {
|
||||
const engine = makeEngine();
|
||||
expect(gate(engine, { target_path: ROOT_MEMORY, options: { recursive: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it('其他文件路径不误伤', () => {
|
||||
const engine = makeEngine();
|
||||
expect(gate(engine, { file_path: join(WORKSPACE, 'src', 'main.ts') })).toBe(false);
|
||||
expect(gate(engine, { file_path: ROOT_MEMORY + '.bak' })).toBe(false);
|
||||
expect(gate(engine, { command: 'echo hello' })).toBe(false);
|
||||
});
|
||||
|
||||
it('未设置工作空间时闸门放行(无根可保护)', () => {
|
||||
const engine = new AgentLoopEngine({}, {
|
||||
providerId: 'fake',
|
||||
supportedModels: [],
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
send: vi.fn(),
|
||||
sendStream: vi.fn(),
|
||||
} as never);
|
||||
expect(gate(engine, { file_path: ROOT_MEMORY })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,12 @@ import type {
|
||||
MetonaToolCall,
|
||||
MetonaToolResult,
|
||||
MetonaStreamEvent,
|
||||
MetonaThinkingBlock,
|
||||
IMetonaProviderAdapter,
|
||||
MetonaToolDef,
|
||||
} from '../types';
|
||||
import { MetonaStreamEventType, MetonaErrorCode } from '../types';
|
||||
import { estimateMessagesTokens } from '../utils/token-estimator';
|
||||
import { estimateMessagesTokens, estimateStringTokens } from '../utils/token-estimator';
|
||||
import { ContentFilterError } from '../adapters/base-adapter';
|
||||
import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream';
|
||||
import log from 'electron-log';
|
||||
@@ -378,6 +379,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
role: 'assistant',
|
||||
content: step.thought?.content ?? null,
|
||||
reasoningContent: step.thought?.reasoningContent,
|
||||
// v0.8.2 P1-1: 透传原始思考块(Anthropic extended thinking 工具循环
|
||||
// 多轮请求必须回传带签名的 thinking 块,否则 400 或丢失推理上下文)
|
||||
thinkingBlocks: step.thinkingBlocks,
|
||||
toolCalls: step.toolCalls,
|
||||
timestamp: Date.now(),
|
||||
iteration: this.currentIteration,
|
||||
@@ -411,6 +415,43 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === v0.8.2 P1-2: 上下文压缩判定(移至本轮消息入列之后、下一轮请求构建前) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。
|
||||
// v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死
|
||||
// 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。
|
||||
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413。
|
||||
// v0.8.2 P1-2: 估算纳入 system prompt(SOUL + MEMORY.md 注入可达数千 token,
|
||||
// 旧估算只算 messages,system 大时系统性低估 → 压缩迟迟不触发 → API 413)。
|
||||
if (!this.aborted) {
|
||||
const effectiveContextWindow =
|
||||
this.config.contextLength ?? this.config.contextWindow ?? 0;
|
||||
const estimatedTokens = this.estimateContextTokens(messages, systemPrompt);
|
||||
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
|
||||
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
|
||||
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
|
||||
if (
|
||||
effectiveContextWindow > 0 &&
|
||||
actualTokens > compressionThreshold &&
|
||||
messages.length >= 4
|
||||
) {
|
||||
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||||
const compressed = await this.compressMessages(messages);
|
||||
if (compressed) {
|
||||
// 原地替换数组内容,确保下一轮请求构建引用同步更新
|
||||
messages.splice(0, messages.length, ...compressed);
|
||||
// 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
|
||||
this.lastRealInputTokens = 0;
|
||||
this.emit('compressed', {
|
||||
iteration: this.currentIteration,
|
||||
originalTokens: actualTokens,
|
||||
compressedTokens: this.estimateContextTokens(compressed, systemPrompt),
|
||||
});
|
||||
}
|
||||
// 压缩后回到 OBSERVING(下一轮迭代从 THINKING 重新开始)
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 循环退出判断
|
||||
@@ -550,6 +591,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
let tokenUsage: TokenUsage | undefined;
|
||||
// v0.8.0 P0-1: 本轮流的 Provider 原生停止原因(adapter DONE 携带)
|
||||
let iterationFinishReason: string | undefined;
|
||||
// v0.8.2 P1-1: 本轮流的原始思考块(adapter DONE 携带,含 Provider 签名)
|
||||
let iterationThinkingBlocks: MetonaThinkingBlock[] | undefined;
|
||||
|
||||
// 流式接收响应
|
||||
for await (const event of this.chatStreamWithRetry(request)) {
|
||||
@@ -560,6 +603,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 引擎与 TRACE 据此区分自然完成与输出上限截断(length 此前完全不可见)
|
||||
if (event.type === MetonaStreamEventType.DONE) {
|
||||
if (event.finishReason) iterationFinishReason = event.finishReason;
|
||||
// v0.8.2 P1-1: 捕获原始思考块(Anthropic extended thinking 协议回传的数据源)
|
||||
if (event.thinkingBlocks?.length) iterationThinkingBlocks = event.thinkingBlocks;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -662,6 +707,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (iterationFinishReason) {
|
||||
step.finishReason = iterationFinishReason;
|
||||
}
|
||||
// v0.8.2 P1-1: 记录本轮原始思考块(主循环写入 assistant 消息供协议回传)
|
||||
if (iterationThinkingBlocks?.length) {
|
||||
step.thinkingBlocks = iterationThinkingBlocks;
|
||||
}
|
||||
|
||||
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段)
|
||||
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
|
||||
@@ -791,41 +840,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。
|
||||
// v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死
|
||||
// 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0;
|
||||
const estimatedTokens = this.estimateMessagesTokens(request.messages);
|
||||
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
|
||||
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
|
||||
// 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确
|
||||
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
|
||||
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
|
||||
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
|
||||
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
|
||||
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
|
||||
if (
|
||||
effectiveContextWindow > 0 &&
|
||||
actualTokens > compressionThreshold &&
|
||||
request.messages.length >= 4
|
||||
) {
|
||||
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||||
const compressed = await this.compressMessages(request.messages);
|
||||
if (compressed) {
|
||||
// 原地替换数组内容,确保外层 messages 引用同步更新
|
||||
request.messages.splice(0, request.messages.length, ...compressed);
|
||||
// v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
|
||||
this.lastRealInputTokens = 0;
|
||||
this.emit('compressed', {
|
||||
iteration: this.currentIteration,
|
||||
originalTokens: actualTokens,
|
||||
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||
});
|
||||
}
|
||||
// 压缩后回到 OBSERVING
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
}
|
||||
// === v0.8.2 P1-2: 上下文压缩判定已移至主循环(本轮 assistant/tool 消息
|
||||
// 入列之后)—— 旧位置(本方法内、工具执行后)在本轮消息 push 之前,本轮
|
||||
// 刚产生的大体积工具结果不在压缩输入里,最坏情况"压缩完又立刻装回同等
|
||||
// 体量"。压缩输入/估算/触发的完整实现见 executeRunStream。
|
||||
|
||||
step.completedAt = Date.now();
|
||||
step.state = AgentLoopState.OBSERVING;
|
||||
@@ -856,6 +874,15 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||
step.toolCalls = [];
|
||||
for (const [, buf] of toolCallsBuffer) {
|
||||
// v0.8.2 P3-2: 空 name 守卫 —— 纯 DELTA 流丢失 name(上游异常)时,
|
||||
// name='' 的调用会让 registry 查询 Unknown tool '',产生误导性错误结果。
|
||||
// 跳过并留痕,避免无效调用进入执行管道。
|
||||
if (!buf.name) {
|
||||
log.warn(
|
||||
`[AgentLoop] Dropping buffered tool call with empty name (args tail: ...${buf.argsBuffer.slice(-80)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
|
||||
@@ -975,18 +1002,21 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
|
||||
// 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
|
||||
// run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。
|
||||
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
|
||||
if (this.isTargetingRootMemoryMd(toolCall)) {
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'Access to workspace root MEMORY.md is protected by security policy',
|
||||
durationMs: Date.now() - startTs,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
//
|
||||
// v0.8.2 P0-2 根治:不再按工具名单枚举(名单制曾遗漏 delete_file / file_move,
|
||||
// 且对 MCP 文件类工具完全不设防),改为对**任意工具调用**的路径形参数做统一
|
||||
// 精确匹配 —— 只要某个路径形参数指向工作空间根 MEMORY.md(读/写/删/移动/
|
||||
// 改名任一方向),一律拦截。子目录 MEMORY.md 不受影响(保持 H-5 语义)。
|
||||
if (this.isTargetingRootMemoryMd(toolCall)) {
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'Access to workspace root MEMORY.md is protected by security policy',
|
||||
durationMs: Date.now() - startTs,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 执行工具(带超时)
|
||||
@@ -998,6 +1028,18 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
|
||||
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
|
||||
let engineTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// v0.8.2 P3-2 根治: 工具级 AbortController —— 超时/引擎中断时真正取消工具执行体。
|
||||
// 旧实现超时只是引擎侧放弃(Promise.race reject),registry.execute 的 Promise
|
||||
// 悬挂,不检查 signal 的工具(子进程/网络类)继续完成副作用且结果无人消费。
|
||||
// 现将引擎信号镜像到独立的 toolAbort:超时触发 toolAbort.abort(),工具内部
|
||||
// (run_command 子进程、fetch、浏览器操作等)据此真正终止。
|
||||
const toolAbort = new AbortController();
|
||||
const engineSignal = this.abortController?.signal;
|
||||
const onEngineAbort = () => toolAbort.abort();
|
||||
if (engineSignal) {
|
||||
if (engineSignal.aborted) toolAbort.abort();
|
||||
else engineSignal.addEventListener('abort', onEngineAbort, { once: true });
|
||||
}
|
||||
try {
|
||||
toolResult = await Promise.race([
|
||||
this.toolRegistry.execute(toolCall, {
|
||||
@@ -1005,14 +1047,15 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
workspacePath: this.workspacePath,
|
||||
iteration: this.currentIteration,
|
||||
requestId: this.currentRequestId,
|
||||
// P0-4: 引擎级 abort 信号透传——用户中断时工具内部(如 run_command 子进程)可自行终止
|
||||
signal: this.abortController?.signal,
|
||||
// 工具级信号:引擎中断镜像 + 超时联动 abort(P0-4 / v0.8.2 P3-2)
|
||||
signal: toolAbort.signal,
|
||||
}),
|
||||
new Promise<MetonaToolResult>((_, reject) => {
|
||||
engineTimer = setTimeout(
|
||||
() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)),
|
||||
toolTimeout,
|
||||
);
|
||||
engineTimer = setTimeout(() => {
|
||||
// 超时即取消执行体(不只是放弃等待)
|
||||
toolAbort.abort();
|
||||
reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`));
|
||||
}, toolTimeout);
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
@@ -1035,6 +1078,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
} finally {
|
||||
// M-16 修复: 清理未触发的 timeout timer
|
||||
if (engineTimer) clearTimeout(engineTimer);
|
||||
// v0.8.2 P3-2: 清理引擎信号镜像监听器
|
||||
if (engineSignal) engineSignal.removeEventListener('abort', onEngineAbort);
|
||||
}
|
||||
|
||||
// 后置 Hook 管道(P0-2: 钩子可返回修改后的结果——如 SecurityScanHook 对网页内容脱敏)
|
||||
@@ -1048,39 +1093,52 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md
|
||||
* v0.8.2 P0-2: 路径形参数提取(工具无关)。
|
||||
*
|
||||
* @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
|
||||
* subdirectory MEMORY.md files are unrestricted
|
||||
*
|
||||
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
|
||||
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
|
||||
*
|
||||
* @param toolCall 工具调用
|
||||
* @returns 是否指向工作空间根目录的 MEMORY.md
|
||||
* 命中两类键名:① 精确集合 { path, file, target, destination, source, dir,
|
||||
* workdir };② 任意以 path/dir 结尾的键(file_path / dir_path / source_path /
|
||||
* destination_path / filePath / dirpath 等含下划线与驼峰形态)。`target` 在
|
||||
* search_files 中是枚举值("content"/"files"),resolve 后不可能与根 MEMORY.md
|
||||
* 绝对路径精确相等,误报风险为零;反之名单制枚举工具对未来新增工具 / MCP
|
||||
* 文件类工具存在结构性遗漏。
|
||||
*/
|
||||
private static readonly PATH_ARG_KEY_EXACT = new Set([
|
||||
'path',
|
||||
'file',
|
||||
'target',
|
||||
'destination',
|
||||
'source',
|
||||
'dir',
|
||||
'workdir',
|
||||
]);
|
||||
|
||||
private extractPathArgs(args: Record<string, unknown>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (typeof value !== 'string' || value.length === 0) continue;
|
||||
const normalized = key.toLowerCase();
|
||||
const isPathKey =
|
||||
AgentLoopEngine.PATH_ARG_KEY_EXACT.has(normalized) ||
|
||||
// 'filepath'/'source_path'/'dirpath' 等任意以 path/dir 结尾的键均为路径形参
|
||||
normalized.endsWith('path') ||
|
||||
normalized.endsWith('dir');
|
||||
if (isPathKey) out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
|
||||
if (!this.workspacePath) return false;
|
||||
|
||||
// 提取工具参数中的路径(不同工具使用不同的参数名)
|
||||
const args = toolCall.args;
|
||||
const pathStr =
|
||||
(args.path as string) ||
|
||||
(args.file_path as string) ||
|
||||
(args.filePath as string) ||
|
||||
(args.file as string) ||
|
||||
(args.target as string) ||
|
||||
(args.destination as string);
|
||||
|
||||
if (!pathStr || typeof pathStr !== 'string') return false;
|
||||
|
||||
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
|
||||
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
|
||||
const resolved = resolve(pathStr).toLowerCase();
|
||||
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
|
||||
|
||||
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md
|
||||
return resolved === rootMemoryPath;
|
||||
// 精确匹配:任一路径形参数等于 {workspacePath}/MEMORY.md
|
||||
// 相对路径以 workspacePath 为基解析(与 file-guard/各文件工具语义一致)
|
||||
return this.extractPathArgs(toolCall.args).some(
|
||||
(p) => resolve(this.workspacePath, p).toLowerCase() === rootMemoryPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1221,6 +1279,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.adapter = this.fallbackAdapter; // 本 run 内后续迭代均使用 fallback
|
||||
currentAdapter = this.fallbackAdapter;
|
||||
this.syncContextWindow();
|
||||
// v0.8.2 P1-2: 故障转移后重置真实输入 token 校正值 —— 旧 Provider 的
|
||||
// 真实值参与了新 Provider(窗口可能不同)的压缩判定,会造成预算失真
|
||||
this.lastRealInputTokens = 0;
|
||||
// 故障转移后重新注入 abort 信号(新 adapter 实例需要关联引擎的中断控制器)
|
||||
if (this.abortController && currentAdapter.setAbortSignal) {
|
||||
currentAdapter.setAbortSignal(this.abortController.signal);
|
||||
@@ -1300,7 +1361,18 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 5xx 服务器错误 — 可重试
|
||||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||||
// 网络超时/连接错误 — 可重试
|
||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND')
|
||||
// v0.8.2 P3-2: 补充 ECONNABORTED / EPIPE / ECONNREFUSED / EAI_AGAIN ——
|
||||
// 分别对应请求中止(undici 超时变体)、流写管道断裂、目标瞬时不可达、
|
||||
// DNS 临时故障,均属可重试的瞬时网络故障
|
||||
if (
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
err.code === 'ENOTFOUND' ||
|
||||
err.code === 'ECONNABORTED' ||
|
||||
err.code === 'EPIPE' ||
|
||||
err.code === 'ECONNREFUSED' ||
|
||||
err.code === 'EAI_AGAIN'
|
||||
)
|
||||
return true;
|
||||
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
|
||||
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
|
||||
@@ -1341,6 +1413,36 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return estimateMessagesTokens(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.2 P1-2: 估算"下一轮请求"的总输入 token —— messages + system prompt。
|
||||
*
|
||||
* 旧压缩判定只估算 messages:system prompt(SOUL.md + 注入的 MEMORY.md 正文 +
|
||||
* 安全准则,可达数千 token)被完全排除,system 大时系统性低估 → 压缩迟迟不
|
||||
* 触发 → 首轮流式返回前完全靠估算的场景下直接 413。已知的估算边界:工具定义
|
||||
* (tools JSON Schema)体积不在此估算内(与 Provider 的序列化形态差异大,
|
||||
* 由 lastRealInputTokens 真实值校正兜底)。
|
||||
*/
|
||||
private estimateContextTokens(
|
||||
messages: MetonaMessage[],
|
||||
systemPrompt?: MetonaSystemPrompt,
|
||||
): number {
|
||||
let total = this.estimateMessagesTokens(messages);
|
||||
if (systemPrompt) {
|
||||
const systemText = [
|
||||
systemPrompt.roleDefinition,
|
||||
systemPrompt.outputConstraints,
|
||||
systemPrompt.safetyGuidelines,
|
||||
systemPrompt.dynamicReminders,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
total += estimateStringTokens(systemText);
|
||||
// system 消息的结构开销
|
||||
total += 8;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。
|
||||
*
|
||||
@@ -1466,7 +1568,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
* 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组
|
||||
* 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断
|
||||
*
|
||||
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
|
||||
* v0.8.2 P1-2: 摘要调用失败重试一次;最终失败时降级为**纯截断压缩**(仅保留
|
||||
* 近期消息,无 LLM 摘要)—— token 确定下降优于"放弃压缩 → 下一轮 413"。
|
||||
*
|
||||
* @returns 压缩后的消息数组;仅当无法构造任何有效压缩(无可压缩消息)时返回 null
|
||||
*/
|
||||
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
|
||||
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
|
||||
@@ -1531,101 +1636,115 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const summaryRequest: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
requestId: `r_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a conversation summarizer.',
|
||||
outputConstraints:
|
||||
'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.',
|
||||
safetyGuidelines:
|
||||
'Do not include sensitive data like passwords or API keys in the summary.',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Please summarize the following conversation history:\n\n${conversationText}`,
|
||||
// v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断
|
||||
// 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文
|
||||
// v0.8.2 P1-2: 该截断提前到摘要调用之前完成 —— 纯截断兜底路径与摘要路径共用
|
||||
const finalKeep = toKeep.map((msg) => {
|
||||
const msgTokens = this.estimateMessagesTokens([msg]);
|
||||
if (msgTokens > keepBudget && msg.content) {
|
||||
// 截断内容,保留头部和尾部,中间用省略标记
|
||||
const halfBudget = Math.floor(keepBudget / 2);
|
||||
// v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致)
|
||||
// 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token,
|
||||
// 导致截断后保留字符过多,实际 token 仍超 keepBudget
|
||||
const charsPerToken = 1.0;
|
||||
const keepChars = Math.floor(halfBudget * charsPerToken);
|
||||
if (msg.content.length > keepChars * 2) {
|
||||
const head = msg.content.slice(0, keepChars);
|
||||
const tail = msg.content.slice(-keepChars);
|
||||
return {
|
||||
...msg,
|
||||
content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
// v0.8.2 P1-2: 摘要调用带一次重试 + 最终失败降级为纯截断压缩。
|
||||
// 旧实现共用主 adapter 且一次失败即放弃压缩(返回 null)—— Provider 抖动时
|
||||
// 压缩形同虚设,下一轮 LLM 大概率 413。现:① 摘要失败重试一次;② 仍失败则
|
||||
// 返回"仅保留近期消息"的纯截断结果(无摘要但 token 确定下降),宁可丢历史
|
||||
// 也不让会话进入 413 死锁。
|
||||
let summary: string | null = null;
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 2 && !summary; attempt++) {
|
||||
const summaryRequest: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
requestId: `r_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
],
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a conversation summarizer.',
|
||||
outputConstraints:
|
||||
'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.',
|
||||
safetyGuidelines:
|
||||
'Do not include sensitive data like passwords or API keys in the summary.',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Please summarize the following conversation history:\n\n${conversationText}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
try {
|
||||
const response = await this.adapter.send(summaryRequest);
|
||||
const text = response.content.trim();
|
||||
if (text) summary = text;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
log.warn(
|
||||
`[AgentLoop] Context summary attempt ${attempt + 1}/2 failed: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
log.warn(
|
||||
`[AgentLoop] Context summary unavailable (${(lastError as Error)?.message ?? 'empty summary'}) — falling back to pure truncation compression`,
|
||||
);
|
||||
return finalKeep;
|
||||
}
|
||||
|
||||
const summaryMessage: MetonaMessage = {
|
||||
// #30 修复: 改用 assistant 角色注入摘要,避免语义混淆
|
||||
// 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令,
|
||||
// 可能基于"Summary of previous conversation"字面意思执行奇怪操作。
|
||||
// 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会
|
||||
// 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息),
|
||||
// 用 system 角色摘要会被丢弃,压缩无效。
|
||||
// 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结),
|
||||
// LLM 不会将其视为新的用户指令。
|
||||
role: 'assistant',
|
||||
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.adapter.send(summaryRequest);
|
||||
const summary = response.content.trim();
|
||||
log.info(
|
||||
`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`,
|
||||
);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const summaryMessage: MetonaMessage = {
|
||||
// #30 修复: 改用 assistant 角色注入摘要,避免语义混淆
|
||||
// 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令,
|
||||
// 可能基于"Summary of previous conversation"字面意思执行奇怪操作。
|
||||
// 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会
|
||||
// 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息),
|
||||
// 用 system 角色摘要会被丢弃,压缩无效。
|
||||
// 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结),
|
||||
// LLM 不会将其视为新的用户指令。
|
||||
role: 'assistant',
|
||||
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断
|
||||
// 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文
|
||||
const finalKeep = toKeep.map((msg) => {
|
||||
const msgTokens = this.estimateMessagesTokens([msg]);
|
||||
if (msgTokens > keepBudget && msg.content) {
|
||||
// 截断内容,保留头部和尾部,中间用省略标记
|
||||
const halfBudget = Math.floor(keepBudget / 2);
|
||||
// v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致)
|
||||
// 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token,
|
||||
// 导致截断后保留字符过多,实际 token 仍超 keepBudget
|
||||
const charsPerToken = 1.0;
|
||||
const keepChars = Math.floor(halfBudget * charsPerToken);
|
||||
if (msg.content.length > keepChars * 2) {
|
||||
const head = msg.content.slice(0, keepChars);
|
||||
const tail = msg.content.slice(-keepChars);
|
||||
return {
|
||||
...msg,
|
||||
content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
log.info(
|
||||
`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`,
|
||||
);
|
||||
|
||||
// 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
|
||||
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
|
||||
// 插入占位 user 消息保证对话流清晰。
|
||||
return [
|
||||
summaryMessage,
|
||||
// 审查修复: 插入占位 user 消息避免连续 assistant 消息
|
||||
{ role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
|
||||
...finalKeep,
|
||||
];
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'[AgentLoop] Context compression failed, keeping original messages:',
|
||||
(error as Error).message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
|
||||
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
|
||||
// 插入占位 user 消息保证对话流清晰。
|
||||
return [
|
||||
summaryMessage,
|
||||
// 审查修复: 插入占位 user 消息避免连续 assistant 消息
|
||||
{ role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
|
||||
...finalKeep,
|
||||
];
|
||||
}
|
||||
|
||||
private accumulateTokens(usage: TokenUsage): void {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 用于 Agent Loop 引擎内部的状态管理和迭代记录。
|
||||
*/
|
||||
|
||||
import type { MetonaToolCall, MetonaToolResult } from '../types';
|
||||
import type { MetonaToolCall, MetonaToolResult, MetonaThinkingBlock } from '../types';
|
||||
|
||||
// ===== Agent Loop 状态机 =====
|
||||
|
||||
@@ -55,6 +55,12 @@ export interface IterationStep {
|
||||
* 引擎据此区分"自然完成"与"输出上限截断"(P0-2 空响应守卫的输入)。
|
||||
*/
|
||||
finishReason?: string;
|
||||
/**
|
||||
* v0.8.2 P1-1: 本轮 LLM 流的原始思考块(含 Provider 签名)。
|
||||
* 由 adapter DONE 事件携带、引擎捕获;主循环据此写入 push 的 assistant 消息
|
||||
* (MetonaMessage.thinkingBlocks),AnthropicAdapter 在下一轮请求按协议回传。
|
||||
*/
|
||||
thinkingBlocks?: MetonaThinkingBlock[];
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
|
||||
Reference in New Issue
Block a user