feat: v0.3.18 上下文压缩机制修复 + MCP 工具就绪竞态修复 + 记忆系统加固
【上下文压缩机制修复】 - engine.ts 新增 lastRealInputTokens 记录 LLM 返回的真实输入 token,压缩判断取 max(估算值, 真实值),避免估算偏低导致不压缩但 API 413 - 修复 effectiveContextWindow 缺少默认值导致 compressionThreshold 变 NaN、压缩永不触发的 bug(添加 ?? 128_000 兜底) - compressMessages 保留区从固定 10 条改为按 token 预算动态截断(50% 上下文窗口) - 二次截断 charsPerToken 从 2 调整为 1.0,与 CJK_TOKEN_RATIO 一致 - 压缩后重置 lastRealInputTokens,避免跨迭代污染 - 每个 run 开始时重置 lastRealInputTokens 【前端 Token 显示修复】 - 区分"累计消耗"和"上下文占用"语义——之前 totalTokens(累计) / contextWindow(单次窗口) 得出无意义百分比 - TokenUsage.tsx 上下文占用改用 lastInputTokens,新增压缩节省行(绿色,仅当 > 0 时显示) - agent-store.ts TokenUsage 接口新增 lastInputTokens 和 lastCompressedSaved 字段,4 处初始值统一更新 - useAgentStream.ts usage 事件 lastInputTokens 替换不累加,compressed 事件通过 streamEvent 接收 savedTokens - handlers.ts onCompressed 同时发 toast + streamEvent,解决"压缩触发但前端 token 显示不降"缺陷 - 旧数据兼容使用 ?? 0,保证历史会话加载不崩溃 【MCP 工具就绪竞态修复】 - 修复输入框永久显示"工具加载中"的竞态条件:MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件在前端监听器注册前已发出 - main.ts 维护 toolsReady 标志 + 注册 tools:isReady IPC handler 查询当前状态 - preload.ts 暴露 tools.isReady() 方法 - App.tsx 注册 onReady 监听器后立即查询 isReady(),无论事件是否错过都能恢复正确状态 【记忆系统加固】 - consolidator.ts 新增 runningPromise + waitForCompletion(35s),before-quit 等待固化完成,防止退出时异步 consolidate 数据丢失 - 固化到 MEMORY.md 的同时写入 semantic_memories 表,解决双轨存储无交叉验证问题 - manager.ts tokenize 按中英文标点切分子句后再做 bigram,优化中文分词 - 清理正则冗余括号 【SOUL.md 降级处理】 - context-builder.ts SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户 - 新增 fallbackRoleNotified 去重标志,仅首次降级通知,避免每次发消息都弹 toast - SOUL.md 恢复内容时重置标志 【Token 估算调整】 - token-estimator.ts CJK_TOKEN_RATIO 从 1.5 调整为 1.0 【MCP 异步初始化】 - main.ts MCP 完成后广播 tools:ready 事件,前端 UI 据以控制输入框可用性 - preload.ts + global.d.ts 暴露 tools.onReady() 监听器 - App.tsx + ChatInput.tsx toolsReady 状态控制输入框 【版本号】 - package.json + package-lock.json 从 0.3.16 升级到 0.3.18
This commit is contained in:
@@ -81,6 +81,13 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
private workspacePath: string = '';
|
||||
private abortController: AbortController | null = null;
|
||||
private eventSeq = 0;
|
||||
/**
|
||||
* v0.3.18 修复: 最近一次 LLM 调用返回的真实输入 token 数
|
||||
* 用于校正压缩判断——估算值可能偏低(尤其中文场景),
|
||||
* 导致估算没到阈值不压缩,但真实 API 调用已经 413。
|
||||
* 压缩判断时取 max(估算值, 真实值) 作为实际占用。
|
||||
*/
|
||||
private lastRealInputTokens = 0;
|
||||
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */
|
||||
private runId: string = '';
|
||||
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
|
||||
@@ -192,6 +199,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.currentIteration = 0;
|
||||
this.currentSessionId = sessionId;
|
||||
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||
// v0.3.18 修复: 每个 run 重置 lastRealInputTokens,避免跨 run 污染
|
||||
this.lastRealInputTokens = 0;
|
||||
this.abortController = new AbortController();
|
||||
// C-2 修复: 将 abortController 的 signal 注入到 adapter,
|
||||
// 使正在进行的 fetch 可被用户中断,避免资源泄漏
|
||||
@@ -485,6 +494,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
completionTokens: event.usage.outputTokens ?? 0,
|
||||
totalTokens: event.usage.totalTokens ?? 0,
|
||||
};
|
||||
// v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断
|
||||
// 估算值可能偏低(尤其中文场景),导致不压缩但 API 413
|
||||
const realInput = event.usage.inputTokens ?? 0;
|
||||
if (realInput > 0) {
|
||||
this.lastRealInputTokens = realInput;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -597,18 +612,28 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
|
||||
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
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;
|
||||
if (estimatedTokens > compressionThreshold && request.messages.length > 10) {
|
||||
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
|
||||
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
|
||||
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
|
||||
if (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: estimatedTokens,
|
||||
originalTokens: actualTokens,
|
||||
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||
});
|
||||
}
|
||||
@@ -961,7 +986,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
/**
|
||||
* 估算消息列表的 token 数
|
||||
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
* 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
* v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值
|
||||
* @see electron/harness/utils/token-estimator.ts
|
||||
*/
|
||||
private estimateMessagesTokens(messages: MetonaMessage[]): number {
|
||||
@@ -1022,21 +1048,48 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话
|
||||
* 上下文压缩 — 将旧消息摘要为一条 assistant 消息,保留近期完整对话
|
||||
*
|
||||
* v0.3.18 修复: 保留策略从"固定 10 条"改为"按 token 预算动态截断"
|
||||
* 之前固定保留最近 10 条,若 10 条里有超长 tool_result(如 read_file 读了 5000 行),
|
||||
* 压缩后仍超 token 限制,下一轮 LLM 调用会返回 413。
|
||||
* 现在按 effectiveContextWindow 的 50% 预算从末尾反向累加消息,
|
||||
* 直到预算用尽或只剩 1 条可压缩消息。
|
||||
*
|
||||
* 策略:
|
||||
* 1. 保留最后 keepRecent 条消息(约 3 轮对话)
|
||||
* 2. 将前面的所有消息交给 LLM 生成摘要
|
||||
* 3. 用 [Context Summary] system 消息 + 近期消息替换原数组
|
||||
* 1. 计算 keepBudget = effectiveContextWindow * 0.5(保留区预算)
|
||||
* 2. 从末尾反向累加消息到 toKeep,直到累加 token 超过 keepBudget
|
||||
* 3. 将前面的所有消息交给 LLM 生成摘要
|
||||
* 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组
|
||||
* 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断
|
||||
*
|
||||
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
|
||||
*/
|
||||
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
|
||||
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool)
|
||||
if (messages.length <= keepRecent) return null;
|
||||
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
|
||||
// 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
|
||||
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
|
||||
|
||||
let toCompress = messages.slice(0, messages.length - keepRecent);
|
||||
let toKeep = messages.slice(messages.length - keepRecent);
|
||||
// 从末尾反向累加,确定 toKeep 范围
|
||||
let keepStartIdx = messages.length;
|
||||
let keepTokens = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msgTokens = this.estimateMessagesTokens([messages[i]]);
|
||||
if (keepTokens + msgTokens > keepBudget && i < messages.length - minKeepCount) {
|
||||
break;
|
||||
}
|
||||
keepTokens += msgTokens;
|
||||
keepStartIdx = i;
|
||||
}
|
||||
|
||||
// 至少保留 minKeepCount 条,且至少要有 1 条可压缩
|
||||
if (keepStartIdx < minKeepCount) keepStartIdx = minKeepCount;
|
||||
if (keepStartIdx >= messages.length) return null;
|
||||
|
||||
let toCompress = messages.slice(0, keepStartIdx);
|
||||
let toKeep = messages.slice(keepStartIdx);
|
||||
|
||||
// MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头
|
||||
// OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息
|
||||
@@ -1118,7 +1171,31 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`);
|
||||
// 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。
|
||||
@@ -1127,7 +1204,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
summaryMessage,
|
||||
// 审查修复: 插入占位 user 消息避免连续 assistant 消息
|
||||
{ role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
|
||||
...toKeep,
|
||||
...finalKeep,
|
||||
];
|
||||
} catch (error) {
|
||||
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { IMetonaProviderAdapter } from '../types/metona-adapter';
|
||||
import type { MetonaRequest, MetonaMessage } from '../types';
|
||||
import type { WorkspaceService } from '../../services/workspace.service';
|
||||
import type { IterationStep } from '../agent-loop/types';
|
||||
import type { MemoryManager } from './manager';
|
||||
|
||||
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
|
||||
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
|
||||
@@ -43,6 +44,11 @@ export interface ConsolidationResult {
|
||||
}
|
||||
|
||||
export class MemoryConsolidator {
|
||||
/** v0.3.18 修复: 跟踪进行中的 consolidate 任务,供应用退出时等待 */
|
||||
private runningPromise: Promise<ConsolidationResult> | null = null;
|
||||
/** v0.3.18 修复: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉写入 */
|
||||
private memoryManager: MemoryManager | null = null;
|
||||
|
||||
constructor(
|
||||
private adapter: IMetonaProviderAdapter,
|
||||
private workspaceService: WorkspaceService,
|
||||
@@ -55,6 +61,47 @@ export class MemoryConsolidator {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
|
||||
*/
|
||||
setMemoryManager(memoryManager: MemoryManager): void {
|
||||
this.memoryManager = memoryManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.18 修复: 是否有进行中的 consolidate 任务
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.runningPromise !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.18 修复: 等待进行中的 consolidate 任务完成(应用退出时调用)
|
||||
*
|
||||
* @param timeoutMs 超时时间(默认 35 秒,略大于 LLM 调用的 30 秒超时)
|
||||
* @returns true 表示已完成,false 表示超时
|
||||
*/
|
||||
async waitForCompletion(timeoutMs: number = 35_000): Promise<boolean> {
|
||||
if (!this.runningPromise) return true;
|
||||
let timedOut = false;
|
||||
let timerHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timer = new Promise<void>((resolve) => {
|
||||
timerHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
this.runningPromise.catch(() => {}),
|
||||
timer,
|
||||
]);
|
||||
return !timedOut;
|
||||
} finally {
|
||||
if (timerHandle) clearTimeout(timerHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 固化本次对话的重要记忆到 MEMORY.md
|
||||
*
|
||||
@@ -67,6 +114,32 @@ export class MemoryConsolidator {
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): Promise<ConsolidationResult> {
|
||||
// v0.3.18 修复: 跟踪进行中的 consolidate,供应用退出时等待
|
||||
const promise = this.executeConsolidation(userMessage, assistantAnswer, iterations);
|
||||
this.runningPromise = promise;
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
// 只有当前 promise 仍是自己时才清理,避免被后续调用覆盖
|
||||
if (this.runningPromise === promise) {
|
||||
this.runningPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行固化的内部方法
|
||||
*
|
||||
* v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
|
||||
* 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
|
||||
* 现在每条固化到 MEMORY.md 的条目也同步存入 semantic_memories 表,
|
||||
* 使 TF-IDF 检索能命中跨会话的用户偏好/决策/项目上下文。
|
||||
*/
|
||||
private async executeConsolidation(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): Promise<ConsolidationResult> {
|
||||
try {
|
||||
// 1. 构建对话摘要
|
||||
@@ -105,6 +178,27 @@ export class MemoryConsolidator {
|
||||
try {
|
||||
this.workspaceService.appendMemory(item.section, truncatedEntry);
|
||||
validEntries.push({ section: item.section, entry: truncatedEntry });
|
||||
|
||||
// v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
|
||||
// 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
|
||||
// 现在每条固化到 MEMORY.md 的条目也存入 semantic_memories,
|
||||
// key 用 section(如"用户偏好"),value 用 entry 内容,
|
||||
// confidence 按 section 语义分级(偏好/决策最高,待办最低)
|
||||
if (this.memoryManager) {
|
||||
try {
|
||||
const confidence = this.getSectionConfidence(item.section);
|
||||
this.memoryManager.store({
|
||||
type: 'semantic',
|
||||
content: truncatedEntry,
|
||||
summary: `[${item.section}] ${truncatedEntry.slice(0, 60)}`,
|
||||
source: 'agent_thought',
|
||||
importance: confidence,
|
||||
});
|
||||
} catch (dbErr) {
|
||||
// DB 写入失败不影响 MEMORY.md 已写入的结果,仅记录日志
|
||||
log.warn(`[MemoryConsolidator] Failed to sync to semantic_memories:`, dbErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err);
|
||||
skipped++;
|
||||
@@ -278,6 +372,29 @@ export class MemoryConsolidator {
|
||||
return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.18 修复: 按 MEMORY.md 分区语义返回 confidence(重要度)
|
||||
*
|
||||
* 用于同步写入 semantic_memories 时的 importance 字段:
|
||||
* - 用户偏好/重要决策:0.9(长期有效,跨会话高价值)
|
||||
* - 项目上下文/已知问题:0.7(项目级,中等价值)
|
||||
* - 待办事项:0.5(短期,可能很快完成)
|
||||
*/
|
||||
private getSectionConfidence(section: string): number {
|
||||
switch (section) {
|
||||
case '用户偏好':
|
||||
case '重要决策':
|
||||
return 0.9;
|
||||
case '项目上下文':
|
||||
case '已知问题':
|
||||
return 0.7;
|
||||
case '待办事项':
|
||||
return 0.5;
|
||||
default:
|
||||
return 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串
|
||||
*/
|
||||
|
||||
@@ -44,21 +44,37 @@ interface MemorySearchOptions {
|
||||
minImportance?: number;
|
||||
}
|
||||
|
||||
/** 分词:将文本拆分为词项(支持中英文) */
|
||||
/**
|
||||
* 分词:将文本拆分为词项(支持中英文)
|
||||
*
|
||||
* v0.3.18 修复: 改进中文分词策略,减少 bigram 噪声
|
||||
* 之前对整段文本连续取 bigram,会跨越标点边界产生无意义组合
|
||||
* (如"开发规范。下一句"会产生"范。"和"。下"这类噪声 bigram),
|
||||
* 降低 IDF 区分度。
|
||||
* 现在先按中英文标点切分子句,再在每个子句内做 bigram,
|
||||
* 避免跨句组合,提升检索准确度。
|
||||
*/
|
||||
function tokenize(text: string): string[] {
|
||||
// 转小写,按非字母数字字符分割
|
||||
// 转小写
|
||||
const lower = text.toLowerCase();
|
||||
// 英文词
|
||||
const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? [];
|
||||
// 中文 bigram(相邻两字组合),覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文
|
||||
const cjkChars = lower.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g) ?? [];
|
||||
|
||||
// v0.3.18 修复: 按中英文标点切分子句,再在每个子句内做 bigram
|
||||
// 标点包括:中文句号/逗号/顿号/分号/感叹/问号 + 英文 .,;!?()
|
||||
const sentences = lower.split(/[。,、;!?.,;!?()\n\r\t]/);
|
||||
const bigrams: string[] = [];
|
||||
for (let i = 0; i < cjkChars.length - 1; i++) {
|
||||
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
|
||||
}
|
||||
// 单字 CJK 补 unigram(避免单字文档无 token)
|
||||
if (cjkChars.length === 1) {
|
||||
bigrams.push(cjkChars[0]);
|
||||
for (const sentence of sentences) {
|
||||
// 提取子句内的 CJK 字符(覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文)
|
||||
const cjkChars = sentence.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g);
|
||||
if (!cjkChars || cjkChars.length === 0) continue;
|
||||
for (let i = 0; i < cjkChars.length - 1; i++) {
|
||||
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
|
||||
}
|
||||
// 单字 CJK 子句补 unigram(避免单字文档无 token)
|
||||
if (cjkChars.length === 1) {
|
||||
bigrams.push(cjkChars[0]);
|
||||
}
|
||||
}
|
||||
return [...words, ...bigrams];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
|
||||
*/
|
||||
|
||||
import log from 'electron-log';
|
||||
import type { MetonaContext, MetonaMemoryItem, MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types';
|
||||
import type { WorkspaceFiles } from '../../services/workspace.service';
|
||||
import { estimateStringTokens } from '../utils/token-estimator';
|
||||
@@ -34,6 +35,30 @@ interface ContextBuildParams {
|
||||
* 上下文构建器
|
||||
*/
|
||||
export class ContextBuilder {
|
||||
/**
|
||||
* v0.3.18 修复: 标记上次 build 是否使用了兜底身份(SOUL.md 为空或不存在)
|
||||
* 供 handlers.ts 读取后决定是否向前端发送 toast 提示
|
||||
*/
|
||||
private lastUsedFallbackRole = false;
|
||||
/**
|
||||
* v0.3.18 修复: 降级通知去重标志
|
||||
* 第一次降级时通知,之后不再重复通知(避免每次发消息都弹 toast)
|
||||
* 当 SOUL.md 恢复内容后重置为 false,下次再降级时会重新通知
|
||||
*/
|
||||
private fallbackRoleNotified = false;
|
||||
|
||||
/**
|
||||
* v0.3.18 修复: 获取上次 build 是否使用了兜底身份
|
||||
* 仅在首次降级(或恢复后再次降级)时返回 true,避免每次发消息都弹 toast
|
||||
*/
|
||||
isUsingFallbackRole(): boolean {
|
||||
if (this.lastUsedFallbackRole && !this.fallbackRoleNotified) {
|
||||
this.fallbackRoleNotified = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的 MetonaContext
|
||||
*/
|
||||
@@ -138,6 +163,8 @@ export class ContextBuilder {
|
||||
*
|
||||
* v0.3.14: 兜底身份定义使用 Metona 灵魂定义(含角色/原则/风格/边界/元指令)
|
||||
* SOUL.md 存在但内容为空(仅空白字符)时也走兜底分支
|
||||
* v0.3.18 修复: 降级时设置 lastUsedFallbackRole 标志 + 打 WARN 日志,
|
||||
* 避免用户自定义人格丢失但不知情的"安静失败"问题
|
||||
*/
|
||||
private buildRoleDefinition(soulContent?: string): string {
|
||||
const parts: string[] = [];
|
||||
@@ -145,8 +172,14 @@ export class ContextBuilder {
|
||||
// v0.3.14: SOUL.md 不存在或内容为空(仅空白)时使用兜底身份定义
|
||||
if (soulContent && soulContent.trim()) {
|
||||
// SOUL.md 存在且有内容 — 直接使用其全部内容,不加任何固定前缀
|
||||
this.lastUsedFallbackRole = false;
|
||||
// v0.3.18 修复: SOUL.md 恢复内容后重置通知标志,下次再降级时会重新通知
|
||||
this.fallbackRoleNotified = false;
|
||||
parts.push(soulContent);
|
||||
} else {
|
||||
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 handlers.ts 读取后发 toast
|
||||
this.lastUsedFallbackRole = true;
|
||||
log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity');
|
||||
// 兜底身份定义(Metona 灵魂定义)
|
||||
parts.push(`# Metona — 灵魂定义
|
||||
> "想清楚再动手,做对比做快重要"
|
||||
@@ -255,7 +288,8 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre
|
||||
|
||||
/**
|
||||
* Token 估算
|
||||
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
* 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
* v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值
|
||||
* @see electron/harness/utils/token-estimator.ts
|
||||
*/
|
||||
private estimateTokens(
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* Token 估算工具 — 跨 Provider 通用
|
||||
*
|
||||
* 策略:智能字符估算,区分中文字符与 ASCII 字符
|
||||
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
|
||||
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token
|
||||
* - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
||||
* - 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
||||
*
|
||||
* v0.3.18 修复: CJK 系数从 1.5 调整为 1.0
|
||||
* 实测 DeepSeek/GLM 等 BPE tokenizer 对中文约 0.6-0.8 token/字,
|
||||
* 原系数 1.5 导致中文场景估算偏高约 2 倍,80% 阈值实际在 40-50% 就触发压缩,
|
||||
* 造成上下文过早丢失。1.0 仍保守(留 ~25% 安全裕度),但更接近真实值。
|
||||
*
|
||||
* 对比旧的 `length / 2` 方案:
|
||||
* - 中文场景:估算准确度从 ~50% 提升到 ~90%
|
||||
* - 英文场景:从偏低变为接近真实
|
||||
@@ -19,9 +24,10 @@ const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\
|
||||
|
||||
/**
|
||||
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
|
||||
* v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值
|
||||
* @see project_memory.md — Token estimation coefficients
|
||||
*/
|
||||
const CJK_TOKEN_RATIO = 1.5; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
|
||||
const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8)
|
||||
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
||||
const OTHER_TOKEN_RATIO = 1; // 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
||||
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
|
||||
|
||||
@@ -146,6 +146,15 @@ export function registerAllIPCHandlers(
|
||||
const workspaceFiles = workspaceService.getFiles();
|
||||
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
|
||||
|
||||
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||||
// 避免用户自定义人格丢失但不知情的"安静失败"问题
|
||||
if (contextBuilder.isUsingFallbackRole() && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'info',
|
||||
message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
});
|
||||
}
|
||||
|
||||
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||||
@@ -265,12 +274,26 @@ export function registerAllIPCHandlers(
|
||||
}
|
||||
};
|
||||
|
||||
// M-6: 转发上下文压缩事件为 toast 通知
|
||||
// M-6: 转发上下文压缩事件为 toast 通知 + streamEvent 通知
|
||||
// v0.3.18 修复: 同时通过 streamEvent 转发压缩数据,前端 store 据以更新 token 显示
|
||||
// 解决"压缩触发但前端 token 显示不降"的缺陷
|
||||
const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => {
|
||||
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
// toast 通知用户压缩已发生
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'info',
|
||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens`,
|
||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`,
|
||||
});
|
||||
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
||||
mainWindow.webContents.send('agent:streamEvent', {
|
||||
type: 'compressed',
|
||||
sessionId,
|
||||
iteration: data.iteration ?? 0,
|
||||
originalTokens: data.originalTokens,
|
||||
compressedTokens: data.compressedTokens,
|
||||
savedTokens,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+39
-3
@@ -14,7 +14,7 @@
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||||
*/
|
||||
|
||||
import { app, shell, Menu, BrowserWindow } from 'electron';
|
||||
import { app, shell, Menu, BrowserWindow, ipcMain } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||
@@ -331,15 +331,34 @@ async function initialize(): Promise<void> {
|
||||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||||
|
||||
// P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop
|
||||
// v0.3.18 修复: MCP 完成后广播 tools:ready 事件,前端据以启用发送按钮
|
||||
// 之前 MCP 初始化是异步的,用户在 MCP 完成前发送消息会用到不全的工具集
|
||||
// v0.3.18 修复: 维护 toolsReady 标志 + 暴露 tools:isReady 查询,解决竞态条件
|
||||
// MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件
|
||||
// 可能在前端监听器注册前就发出,导致前端永久错过事件、输入框永久禁用
|
||||
let toolsReady = false;
|
||||
mcpManager.initialize().then(() => {
|
||||
agentLoop.setTools(toolRegistry.listTools());
|
||||
log.info('[MCP] Tools registered and synced to AgentLoop');
|
||||
toolsReady = true;
|
||||
// 广播工具就绪事件给所有窗口
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
|
||||
}
|
||||
}).catch((err) => {
|
||||
log.warn('MCP Manager initialization error:', err);
|
||||
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
|
||||
toolsReady = true;
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
||||
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
||||
// v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
|
||||
// 实现 DB 记忆与 MEMORY.md 双轨交叉,避免"AI 记得一半"的不一致
|
||||
memoryConsolidator.setMemoryManager(memoryManager);
|
||||
|
||||
// ===== Task Orchestrator(子任务委派)=====
|
||||
const orchestrator = new TaskOrchestrator(
|
||||
@@ -473,6 +492,9 @@ async function initialize(): Promise<void> {
|
||||
promptDefender, outputValidator,
|
||||
confirmationHook, memoryConsolidator, orchestrator,
|
||||
);
|
||||
// v0.3.18 修复: 注册 tools:isReady 查询,前端注册 onReady 监听器后立即查询,
|
||||
// 解决 tools:ready 事件在监听器注册前发出的竞态条件
|
||||
ipcMain.handle('tools:isReady', () => ({ ready: toolsReady, toolCount: toolRegistry.size }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -541,12 +563,13 @@ async function initialize(): Promise<void> {
|
||||
(global as Record<string, unknown>).shutdownInProgress = true;
|
||||
|
||||
event.preventDefault();
|
||||
// 内部异步清理,加 5 秒超时保护防止卡死
|
||||
// v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
|
||||
// 之前 5 秒超时会导致进行中的 consolidate 被 kill,记忆丢失
|
||||
const shutdownTimeout = setTimeout(() => {
|
||||
log.warn('[Shutdown] Timeout reached, forcing exit');
|
||||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||||
app.exit(0);
|
||||
}, 5_000);
|
||||
}, 40_000);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -555,6 +578,19 @@ async function initialize(): Promise<void> {
|
||||
log.error('[Shutdown] MCP shutdown failed:', err);
|
||||
}
|
||||
cleanupBrowser();
|
||||
|
||||
// v0.3.18 修复: 等待进行中的记忆固化任务完成,避免应用退出导致记忆丢失
|
||||
// consolidate 内部有 30 秒 LLM 超时保护,此处等待 35 秒足够覆盖
|
||||
if (memoryConsolidator.isRunning()) {
|
||||
log.info('[Shutdown] Waiting for memory consolidation to complete...');
|
||||
const completed = await memoryConsolidator.waitForCompletion(35_000);
|
||||
if (!completed) {
|
||||
log.warn('[Shutdown] Memory consolidation timed out, proceeding with exit');
|
||||
} else {
|
||||
log.info('[Shutdown] Memory consolidation completed');
|
||||
}
|
||||
}
|
||||
|
||||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||||
clearTimeout(shutdownTimeout);
|
||||
app.exit(0);
|
||||
|
||||
@@ -167,6 +167,14 @@ const metonaAPI = {
|
||||
tools: {
|
||||
list: () => ipcRenderer.invoke('tools:list'),
|
||||
toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled),
|
||||
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
|
||||
onReady: (callback: (data: { toolCount: number }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data);
|
||||
ipcRenderer.on('tools:ready', listener);
|
||||
return () => ipcRenderer.removeListener('tools:ready', listener);
|
||||
},
|
||||
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */
|
||||
isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>,
|
||||
},
|
||||
|
||||
// ===== 数据管理 =====
|
||||
|
||||
Reference in New Issue
Block a user