feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
@@ -0,0 +1,102 @@
/**
* v0.8.2 P0-2: 根 MEMORY.md 保护闸门(工具无关的路径形参数匹配)
*
* 锁定契约:
* - delete_file / file_movesource_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);
});
});
+289 -170
View File
@@ -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 promptSOUL + 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,
// 工具级信号:引擎中断镜像 + 超时联动 abortP0-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。
*
* 旧压缩判定只估算 messagessystem promptSOUL.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 {
+7 -1
View File
@@ -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 {