feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
@@ -84,10 +84,11 @@ const DEFAULT_CONFIG: AgentLoopConfig = {
|
||||
totalTimeoutMs: 600_000,
|
||||
enableReflection: false,
|
||||
compressionThreshold: 0.8,
|
||||
contextWindow: 128_000,
|
||||
// v0.8.1: contextWindow / maxTokens 不再携带任何写死默认值 —— 唯一合法来源是
|
||||
// 设置面板 LLM 配置(llm.contextWindow / llm.maxTokens),由 main.ts baseConfig
|
||||
// 与 applyEngineConfigKey 注入。未配置时压缩判定跳过、输出上限参数不下发。
|
||||
retryCount: 3,
|
||||
temperature: 0.0,
|
||||
maxTokens: 63488,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'high',
|
||||
toolExecutionTimeoutMs: 120_000,
|
||||
@@ -185,10 +186,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
/**
|
||||
* #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置
|
||||
*
|
||||
* Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的
|
||||
* 上下文窗口差异巨大(DeepSeek 1M / Agnes 1M / MiMo 1M / OpenAI 128K~1M /
|
||||
* Anthropic 200K / Ollama 4096 起,v0.7.4 修正注释——此前误写 64K)。
|
||||
* 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。
|
||||
* v0.8.1 契约: 窗口唯一来源是设置面板「上下文长度」—— 引擎 config 与 adapter
|
||||
* config 同源注入;本同步仅当 adapter 侧返回 >0(用户已配置)时采纳,
|
||||
* 返回 0(未配置)不覆盖,压缩判定按未配置语义跳过。
|
||||
*/
|
||||
private syncContextWindow(): void {
|
||||
const adapterCtx = this.adapter.getContextWindow?.();
|
||||
@@ -628,6 +628,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
promptTokens: event.usage.inputTokens ?? 0,
|
||||
completionTokens: event.usage.outputTokens ?? 0,
|
||||
totalTokens: event.usage.totalTokens ?? 0,
|
||||
// v0.8.1 P1-3: 透传 Prompt Cache 命中字段(前端命中率展示的数据源;
|
||||
// 此前引擎在此处丢弃 adapter 已采集的 cache 字段,观测链路断头)
|
||||
cacheHitTokens: event.usage.cacheHitTokens,
|
||||
cacheMissTokens: event.usage.cacheMissTokens,
|
||||
};
|
||||
// v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断
|
||||
// 估算值可能偏低(尤其中文场景),导致不压缩但 API 413
|
||||
@@ -788,10 +792,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||||
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
|
||||
const effectiveContextWindow =
|
||||
this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
// 有效上下文窗口: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 时的早期判断(首轮或重试场景)
|
||||
@@ -801,7 +805,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
|
||||
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
|
||||
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
|
||||
if (actualTokens > compressionThreshold && request.messages.length >= 4) {
|
||||
if (
|
||||
effectiveContextWindow > 0 &&
|
||||
actualTokens > compressionThreshold &&
|
||||
request.messages.length >= 4
|
||||
) {
|
||||
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||||
const compressed = await this.compressMessages(request.messages);
|
||||
if (compressed) {
|
||||
@@ -1462,9 +1470,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
*/
|
||||
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
|
||||
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
|
||||
// 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN
|
||||
const effectiveContextWindow =
|
||||
this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
// v0.8.1: 窗口唯一来源是设置面板「上下文长度」,无写死兜底 —— 未配置时无法
|
||||
// 计算保留预算,直接放弃压缩(调用方保持原数组,行为安全)
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0;
|
||||
if (!(effectiveContextWindow > 0)) return null;
|
||||
const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
|
||||
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
|
||||
|
||||
@@ -1623,6 +1632,16 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.totalTokens.promptTokens += usage.promptTokens;
|
||||
this.totalTokens.completionTokens += usage.completionTokens;
|
||||
this.totalTokens.totalTokens += usage.totalTokens;
|
||||
// v0.8.1 P1-3: 累计缓存命中/未命中(未上报的 Provider 保持 undefined,
|
||||
// 不把 undefined 污染为数字 0 —— 前端以 undefined 判定"该 Provider 不上报")
|
||||
if (usage.cacheHitTokens != null) {
|
||||
this.totalTokens.cacheHitTokens =
|
||||
(this.totalTokens.cacheHitTokens ?? 0) + usage.cacheHitTokens;
|
||||
}
|
||||
if (usage.cacheMissTokens != null) {
|
||||
this.totalTokens.cacheMissTokens =
|
||||
(this.totalTokens.cacheMissTokens ?? 0) + usage.cacheMissTokens;
|
||||
}
|
||||
}
|
||||
|
||||
private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput {
|
||||
|
||||
@@ -61,6 +61,13 @@ export interface TokenUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
/**
|
||||
* v0.8.1 P1-3: Prompt Cache 命中/未命中 token 数(Provider 上报时透传)。
|
||||
* 供前端 TokenUsage 面板计算缓存命中率 —— 采集与展示全链路闭环,
|
||||
* 未上报的 Provider 为 undefined(UI 隐藏该行)。
|
||||
*/
|
||||
cacheHitTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
}
|
||||
|
||||
// ===== Agent Loop 配置 =====
|
||||
@@ -70,16 +77,26 @@ export interface AgentLoopConfig {
|
||||
totalTimeoutMs: number;
|
||||
enableReflection: boolean;
|
||||
compressionThreshold: number;
|
||||
contextWindow: number;
|
||||
/**
|
||||
* 上下文窗口大小(token 数)—— 唯一合法来源是设置面板 LLM 配置的「上下文长度」
|
||||
* (llm.contextWindow,经 main.ts baseConfig / applyEngineConfigKey 注入)。
|
||||
* 引擎与适配器禁止携带任何写死的默认值;未配置(undefined/0)时压缩判定跳过。
|
||||
*/
|
||||
contextWindow?: number;
|
||||
retryCount: number;
|
||||
temperature: number;
|
||||
/** 最大生成 token 数(默认 63488) */
|
||||
maxTokens: number;
|
||||
/**
|
||||
* 最大生成 token 数 —— 唯一合法来源是设置面板 LLM 配置的「最大输出上限」
|
||||
* (llm.maxTokens,经 main.ts baseConfig / applyEngineConfigKey 注入)。
|
||||
* 引擎与适配器禁止携带任何写死的默认值,也不得按模型元信息钳制;
|
||||
* 未配置时该参数不下发,由 Provider 服务端默认值决定。
|
||||
*/
|
||||
maxTokens?: number;
|
||||
/** 是否启用思考模式(默认 true) */
|
||||
thinkingEnabled: boolean;
|
||||
/** 思考强度(默认 'high') */
|
||||
thinkingEffort: 'low' | 'medium' | 'high' | 'max';
|
||||
/** Ollama 上下文窗口大小(num_ctx),其他 Provider 忽略 */
|
||||
/** Ollama num_ctx(与「上下文长度」同源:llm.contextWindow,仅 Ollama Provider 下发) */
|
||||
contextLength?: number;
|
||||
/** 工具执行兜底超时(ms,默认 120000),实际取 max(此值, tool.timeoutMs) */
|
||||
toolExecutionTimeoutMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user