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:
@@ -950,11 +950,12 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
|
||||
expect(adapter.getContextWindow()).toBe(50_000);
|
||||
});
|
||||
|
||||
it('未知模型 → 兜底 200K', () => {
|
||||
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(200_000);
|
||||
// v0.8.1: 窗口唯一来源是设置面板 llm.contextWindow,未配置返回 0(无写死兜底)
|
||||
it('未知模型且未配置 → 返回 0(无写死兜底窗口)', () => {
|
||||
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(0);
|
||||
});
|
||||
|
||||
it('listModels 返回本地模型元信息(无网络请求)', async () => {
|
||||
it('listModels 返回本地模型元信息(无网络请求,不含窗口/上限数值)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const models = await adapter.listModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
@@ -962,7 +963,10 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
|
||||
'claude-opus-4-1',
|
||||
'claude-haiku-4-5',
|
||||
]);
|
||||
expect(models[0]).toMatchObject({ contextWindow: 200_000, maxOutputTokens: 64_000 });
|
||||
// v0.8.1 硬性契约: 元信息不再承载 contextWindow / maxOutputTokens
|
||||
expect(models[0]).toMatchObject({ supportsThinking: true });
|
||||
expect(models[0].contextWindow).toBeUndefined();
|
||||
expect(models[0].maxOutputTokens).toBeUndefined();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,10 +130,11 @@ describe('BaseAdapter — getContextWindow', () => {
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
});
|
||||
|
||||
// v0.7.4 P4-5: 兜底从 1M 降至 128K(未知模型按最保守主流窗口预算,防 413)
|
||||
it('未配置时返回兜底默认值 128K(子类应覆盖真实窗口)', () => {
|
||||
// v0.8.1 硬性契约: 上下文窗口唯一来源是设置面板 llm.contextWindow,
|
||||
// 未配置返回 0(引擎据此跳过压缩预算)—— 任何写死兜底值均已删除
|
||||
it('未配置时返回 0(无任何写死兜底窗口,引擎跳过压缩判定)', () => {
|
||||
const adapter = makeAdapter();
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
expect(adapter.getContextWindow()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,15 +444,15 @@ describe('BaseAdapter — throwHttpError 错误体解析', () => {
|
||||
|
||||
// ===== 追加:getContextWindow / listModels / healthCheck =====
|
||||
|
||||
describe('BaseAdapter — getContextWindow 回退链', () => {
|
||||
it('contextWindow=0 视为未配置(需 >0)', () => {
|
||||
describe('BaseAdapter — getContextWindow 非法值契约', () => {
|
||||
it('contextWindow=0 视为未配置(返回 0,引擎跳过压缩判定)', () => {
|
||||
const adapter = makeAdapter({ contextWindow: 0 });
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
expect(adapter.getContextWindow()).toBe(0);
|
||||
});
|
||||
|
||||
it('contextWindow 为负数视为未配置', () => {
|
||||
it('contextWindow 为负数视为未配置(返回 0)', () => {
|
||||
const adapter = makeAdapter({ contextWindow: -1 });
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
expect(adapter.getContextWindow()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => {
|
||||
expect(userMsg.content).toBe('这张图片里有什么?');
|
||||
});
|
||||
|
||||
it('vision 模型 max_tokens 钳制到 8192(MODEL_INFO 上限)', async () => {
|
||||
it('vision 模型 max_tokens 原样透传(v0.8.1:8192 钳制已废除)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => {
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string);
|
||||
expect(body.max_tokens).toBe(8_192);
|
||||
expect(body.max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('vision 模型无图片时不转换(content 保持纯文本)', async () => {
|
||||
@@ -215,7 +215,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('vision 模型 max_tokens 未配置 → 默认 8192(MODEL_INFO 上限)', async () => {
|
||||
it('vision 模型 max_tokens 未配置 → 不下发该字段(v0.8.1:无写死兜底)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => {
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
expect(body.max_tokens).toBe(8_192);
|
||||
expect(body.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Provider maxTokens 上限钳制契约测试(v0.5.3)
|
||||
* maxTokens 透传契约测试(v0.8.1 硬性契约重写)
|
||||
*
|
||||
* 背景:引擎默认 maxTokens=63488(engine.ts DEFAULT_CONFIG),超过部分模型
|
||||
* 上限时 API 直接 400 —— OpenAI gpt-4o(16384)/gpt-4.1(32768)、Anthropic
|
||||
* opus/haiku(32000)、MiMo standard(32768) 曾不可用。v0.5.3 各 adapter 按
|
||||
* MODEL_INFO.maxOutputTokens 钳制。
|
||||
* 背景:v0.5.3 曾引入"按 MODEL_INFO.maxOutputTokens 钳制"逻辑。v0.8.1 按硬性
|
||||
* 契约废除 —— 设置面板「最大输出上限」(llm.maxTokens)是唯一合法的输出上限
|
||||
* 配置,adapter 对一切 Provider/模型**原样透传** `params.maxTokens`,代码中
|
||||
* 不存在任何写死的输出上限或按模型元信息的钳制行为。
|
||||
*
|
||||
* 测试策略(v0.5.2 教训):mock fetch 记录真实请求体并断言契约 ——
|
||||
* 不 mock adapter 内部方法,验证"发出的 HTTP 请求体"这个最终事实。
|
||||
@@ -33,7 +33,7 @@ afterEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
/** 引擎默认形态的请求(maxTokens=63488,与 engine DEFAULT_CONFIG 一致) */
|
||||
/** 设置面板「最大输出上限」配置生效时的请求形态(llm.maxTokens → params.maxTokens) */
|
||||
function makeRequest(overrides: Partial<MetonaRequest> = {}): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
@@ -79,8 +79,8 @@ function requestBody(): Record<string, unknown> {
|
||||
return JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
it('DeepSeek v4 pro(上限 384K):63488 未超限,原样传递', async () => {
|
||||
describe('maxTokens 原样透传(v0.8.1:设置面板是唯一合法上限配置)', () => {
|
||||
it('DeepSeek:max_tokens 原样透传(不按模型钳制)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
@@ -92,7 +92,19 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('Agnes flash(上限 65536):63488 未超限,原样传递', async () => {
|
||||
it('DeepSeek:超出旧模型元信息上限的值同样原样透传(钳制已废除)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'sk',
|
||||
defaultModel: 'deepseek-v4-flash-vision-exp',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('Agnes:max_tokens 原样透传', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new AgnesAdapter({
|
||||
provider: 'agnes',
|
||||
@@ -104,7 +116,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('MiMo standard(上限 32768):钳制到 32768(原为 400 错误场景)', async () => {
|
||||
it('MiMo standard:max_completion_tokens 原样透传(旧 32768 钳制已废除)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
@@ -113,10 +125,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
defaultModel: 'mimo-v2.5',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(32_768);
|
||||
expect(requestBody().max_completion_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('MiMo pro(上限 131072):63488 未超限,原样传递', async () => {
|
||||
it('MiMo pro:max_completion_tokens 原样透传', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
@@ -128,7 +140,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
expect(requestBody().max_completion_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('OpenAI gpt-4o(上限 16384):钳制到 16384(原为 400 错误场景)', async () => {
|
||||
it('OpenAI gpt-4o(非推理模型):max_tokens 原样透传(旧 16384 钳制已废除)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
@@ -137,10 +149,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
defaultModel: 'gpt-4o',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(16_384);
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('OpenAI o3-mini(上限 100K):63488 未超限,推理模型字段名正确', async () => {
|
||||
it('OpenAI o3-mini(推理模型):max_completion_tokens 原样透传,字段名路由正确', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
@@ -153,7 +165,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
expect(requestBody().max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Anthropic opus(上限 32000):钳制到 32000(原为 400 错误场景)', async () => {
|
||||
it('Anthropic opus:max_tokens 原样透传(旧 32000 钳制已废除)', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
@@ -162,10 +174,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
defaultModel: 'claude-opus-4-1',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(32_000);
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('Anthropic sonnet(上限 64000):63488 未超限', async () => {
|
||||
it('Anthropic sonnet:max_tokens 原样透传', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
@@ -177,13 +189,12 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('未配置 maxTokens 时各 adapter 使用安全默认值(不超过模型上限)', async () => {
|
||||
it('未配置 maxTokens:不下发任何输出上限字段(由 Provider 服务端默认值决定)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const request = makeRequest({
|
||||
params: { temperature: 0, stream: false } as MetonaRequest['params'],
|
||||
});
|
||||
|
||||
// MiMo standard + thinking 默认 → 兜底 32768(= 上限,安全)
|
||||
const mimo = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
@@ -191,6 +202,33 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
defaultModel: 'mimo-v2.5',
|
||||
});
|
||||
await mimo.send(request);
|
||||
expect(requestBody().max_completion_tokens).toBe(32_768);
|
||||
expect(requestBody().max_completion_tokens).toBeUndefined();
|
||||
|
||||
const deepseek = new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'sk',
|
||||
defaultModel: 'deepseek-v4-pro',
|
||||
});
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
await deepseek.send(request);
|
||||
expect(requestBody().max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Anthropic 未配置 maxTokens + thinking 开启:仅满足协议下限 2048(协议不变量,非上限)', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-opus-4-1',
|
||||
});
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { temperature: 0, stream: false, thinkingEnabled: true } as MetonaRequest['params'],
|
||||
}),
|
||||
);
|
||||
expect(requestBody().max_tokens).toBe(2048);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -452,13 +452,25 @@ describe('OllamaAdapter — 能力探测', () => {
|
||||
expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true });
|
||||
});
|
||||
|
||||
it('capabilities 为空数组(showModel 缺省 [])→ 三能力全 false(非 null)', async () => {
|
||||
// v0.8.1: showModel 保留 undefined 语义 —— 响应缺 capabilities 字段 = 未知 → null
|
||||
//(fail-open),不再把"字段缺失"与"权威空"混同(探测竞态下曾误判不支持思考)
|
||||
it('capabilities 字段缺失 → 返回 null(未知,fail-open)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: '', template: '' }),
|
||||
} as unknown as Response);
|
||||
expect(await adapter.probeCapabilities('qwen3')).toBeNull();
|
||||
});
|
||||
|
||||
it('capabilities 为显式空数组(服务端权威无能力)→ 三能力全 false', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: '', template: '', capabilities: [] }),
|
||||
} as unknown as Response);
|
||||
expect(await adapter.probeCapabilities('qwen3')).toEqual({
|
||||
supportsTools: false,
|
||||
supportsVision: false,
|
||||
@@ -688,28 +700,33 @@ describe('OllamaAdapter — 图片归一化', () => {
|
||||
|
||||
// ===== getContextWindow =====
|
||||
|
||||
describe('OllamaAdapter — getContextWindow', () => {
|
||||
it('未探测到时返回默认 4096', () => {
|
||||
describe('OllamaAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => {
|
||||
it('未配置 contextWindow → 返回 0(无 4096 写死兜底,引擎跳过压缩判定)', () => {
|
||||
const adapter = makeAdapter();
|
||||
expect(adapter.getContextWindow()).toBe(4096);
|
||||
expect(adapter.getContextWindow()).toBe(0);
|
||||
});
|
||||
|
||||
it('探测到 num_ctx 后返回实测值(机会主义缓存收敛)', async () => {
|
||||
// 需在构造前就位:构造时的 fire-and-forget 探测消费首个 fetch
|
||||
it('config.contextWindow(llm.contextWindow 注入)→ 返回配置值', () => {
|
||||
const adapter = makeAdapter('qwen3', { contextWindow: 32_768 });
|
||||
expect(adapter.getContextWindow()).toBe(32_768);
|
||||
});
|
||||
|
||||
it('/api/show 探测不再缓存窗口数值(num_ctx 由引擎 contextLength 下发)', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }),
|
||||
} as unknown as Response);
|
||||
const adapter = makeAdapter();
|
||||
// 等待构造时的探测完成并写入缓存
|
||||
await vi.waitFor(() => expect(adapter.getContextWindow()).toBe(32768));
|
||||
// 探测完成(含失败路径)后窗口仍为 0 —— 探测只服务于能力门控
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(adapter.getContextWindow()).toBe(0);
|
||||
});
|
||||
|
||||
it('探测失败(网络错误)→ 保持默认 4096', async () => {
|
||||
it('探测失败(网络错误)→ 仍返回配置值/0,不抛错不阻塞', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('down'));
|
||||
const adapter = makeAdapter();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(adapter.getContextWindow()).toBe(4096);
|
||||
expect(adapter.getContextWindow()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,12 +205,12 @@ describe('OpenAIAdapter — 推理模型拒图(ModelCapabilityError)', () =>
|
||||
|
||||
// ===== max_completion_tokens / max_tokens 路由 =====
|
||||
|
||||
describe('OpenAIAdapter — token 参数路由', () => {
|
||||
describe('OpenAIAdapter — token 参数路由(v0.8.1:原样透传,无模型钳制)', () => {
|
||||
it.each([
|
||||
['o3-mini', 63_488, 63_488, 'max_completion_tokens'],
|
||||
['o3-mini', 200_000, 100_000, 'max_completion_tokens'], // 上限 100000
|
||||
['gpt-4o', 63_488, 16_384, 'max_tokens'],
|
||||
['gpt-4.1', 63_488, 32_768, 'max_tokens'],
|
||||
['o3-mini', 200_000, 200_000, 'max_completion_tokens'], // 超过任何旧元信息上限 → 原样
|
||||
['gpt-4o', 63_488, 63_488, 'max_tokens'],
|
||||
['gpt-4.1', 63_488, 63_488, 'max_tokens'],
|
||||
] as const)('%s maxTokens=%d → %s=%d', async (model, requested, expected, field) => {
|
||||
const adapter = makeAdapter(model);
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
@@ -224,18 +224,18 @@ describe('OpenAIAdapter — token 参数路由', () => {
|
||||
expect(body[other]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('o3-mini 未配置 maxTokens → 默认 32768(thinking 场景安全值)', async () => {
|
||||
it('o3-mini 未配置 maxTokens → 不下发 max_completion_tokens(无写死兜底)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest({ params: { temperature: 0, stream: false } }));
|
||||
expect(lastBody().max_completion_tokens).toBe(32_768);
|
||||
expect(lastBody().max_completion_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('非推理模型未配置 maxTokens → 默认模型上限', async () => {
|
||||
it('非推理模型未配置 maxTokens → 不下发 max_tokens(无写死兜底)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest({ params: { temperature: 0, stream: false } }));
|
||||
expect(lastBody().max_tokens).toBe(16_384);
|
||||
expect(lastBody().max_tokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,23 +277,17 @@ describe('OpenAIAdapter — temperature 路由', () => {
|
||||
|
||||
// ===== getContextWindow 回退链 =====
|
||||
|
||||
describe('OpenAIAdapter — getContextWindow 回退链', () => {
|
||||
it('gpt-4.1 返回 1M 上下文', () => {
|
||||
expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it('o3-mini 返回 200K', () => {
|
||||
expect(makeAdapter('o3-mini').getContextWindow()).toBe(200_000);
|
||||
});
|
||||
|
||||
it('未知模型 → 兜底 128K(v0.7.4 P4-5 从 1M 降级)', () => {
|
||||
expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(128_000);
|
||||
});
|
||||
|
||||
it('config.contextWindow 显式配置优先', () => {
|
||||
describe('OpenAIAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => {
|
||||
it('config.contextWindow 显式配置(llm.contextWindow 注入)返回配置值', () => {
|
||||
const adapter = makeAdapter('gpt-4o', { contextWindow: 64_000 });
|
||||
expect(adapter.getContextWindow()).toBe(64_000);
|
||||
});
|
||||
|
||||
it('未配置(任意模型,含已知/未知)→ 返回 0(引擎跳过压缩判定,无写死兜底)', () => {
|
||||
expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(0);
|
||||
expect(makeAdapter('o3-mini').getContextWindow()).toBe(0);
|
||||
expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== listModels =====
|
||||
@@ -303,7 +297,9 @@ describe('OpenAIAdapter — listModels 动态发现与降级', () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ data: [{ id: 'gpt-4o' }, { id: 'custom-model' }] }));
|
||||
const models = await makeAdapter('gpt-4o').listModels();
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0]).toMatchObject({ id: 'gpt-4o', contextWindow: 128_000 });
|
||||
// v0.8.1: 元信息不再承载窗口/上限数值
|
||||
expect(models[0]).toMatchObject({ id: 'gpt-4o', name: 'GPT-4o' });
|
||||
expect(models[0].contextWindow).toBeUndefined();
|
||||
expect(models[1]).toEqual({ id: 'custom-model' });
|
||||
// /models 请求头携带 Bearer
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('AnthropicAdapter — 请求体契约', () => {
|
||||
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
|
||||
});
|
||||
|
||||
it('A2: max_tokens 按模型上限钳制(63488 → sonnet 64000 / opus 32000)', async () => {
|
||||
it('A2: max_tokens 原样透传(v0.8.1:模型钳制已废除,设置面板是唯一上限来源)', async () => {
|
||||
const sonnet = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
@@ -194,9 +194,9 @@ describe('AnthropicAdapter — 请求体契约', () => {
|
||||
const { bodies } = captureFetch();
|
||||
await sonnet.send(makeRequest());
|
||||
await opus.send(makeRequest());
|
||||
// 引擎默认 63488 低于 sonnet 上限 64000 → 原样保留;opus 上限 32000 → 钳制生效
|
||||
// v0.8.1: 设置面板「最大输出上限」对一切模型原样透传,无任何按模型钳制
|
||||
expect(bodies[0].max_tokens).toBe(63_488);
|
||||
expect(bodies[1].max_tokens).toBe(32_000);
|
||||
expect(bodies[1].max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024(v0.6.4 边界加固)', async () => {
|
||||
@@ -516,8 +516,8 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
|
||||
['low', 1024],
|
||||
['medium', 4096],
|
||||
['high', 16384],
|
||||
// max=32768 但 sonnet 的 max_tokens 先钳到 64000 → budget 二次钳到 floor(64000/2)=32000
|
||||
['max', 32000],
|
||||
// v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768
|
||||
['max', 32768],
|
||||
] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
@@ -570,13 +570,13 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnthropicAdapter — max_tokens 钳制矩阵', () => {
|
||||
describe('AnthropicAdapter — max_tokens 透传矩阵(v0.8.1 无钳制)', () => {
|
||||
it.each([
|
||||
['claude-sonnet-4-5', 63_488, 63_488], // 引擎默认低于上限 → 原样
|
||||
['claude-sonnet-4-5', 70_000, 64_000], // 超上限 → 钳到 sonnet 64000
|
||||
['claude-opus-4-1', 63_488, 32_000], // opus 上限 32000
|
||||
['claude-haiku-4-5', 63_488, 32_000], // haiku 上限 32000
|
||||
['claude-sonnet-4-5', 500, 500], // 低于上限 → 原样
|
||||
['claude-sonnet-4-5', 63_488, 63_488],
|
||||
['claude-sonnet-4-5', 70_000, 70_000], // 超过任何旧元信息上限 → 原样透传
|
||||
['claude-opus-4-1', 63_488, 63_488],
|
||||
['claude-haiku-4-5', 63_488, 63_488],
|
||||
['claude-sonnet-4-5', 500, 500],
|
||||
])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
@@ -795,7 +795,7 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
|
||||
expect(bodies[0].stop).toEqual(['<END>']);
|
||||
});
|
||||
|
||||
it('max_tokens 按模型钳制(pro 384000 / vision 8192)', async () => {
|
||||
it('max_tokens 原样透传(v0.8.1:pro/vision 均不钳制)', async () => {
|
||||
const pro = makeAdapter('deepseek-v4-pro');
|
||||
const vision = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
const { bodies } = captureFetch();
|
||||
@@ -803,8 +803,8 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
|
||||
await vision.send(
|
||||
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(384_000);
|
||||
expect(bodies[1].max_tokens).toBe(8_192);
|
||||
expect(bodies[0].max_tokens).toBe(500_000);
|
||||
expect(bodies[1].max_tokens).toBe(63_488);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -864,8 +864,8 @@ describe('AgnesAdapter — enable_thinking 对称性矩阵', () => {
|
||||
makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].temperature).toBe(0.9);
|
||||
// 65536 上限钳制
|
||||
expect(bodies[0].max_tokens).toBe(65_536);
|
||||
// v0.8.1: 原样透传,无 65536 钳制
|
||||
expect(bodies[0].max_tokens).toBe(70_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -912,21 +912,21 @@ describe('MimoAdapter — thinking 显式开关', () => {
|
||||
expect(bodies[0].top_p).toBeUndefined();
|
||||
});
|
||||
|
||||
it('max_completion_tokens 钳制矩阵(pro 131072 / standard 32768)', async () => {
|
||||
it('max_completion_tokens 原样透传(v0.8.1:pro/standard 均不钳制)', async () => {
|
||||
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
|
||||
const std = makeAdapter({ defaultModel: 'mimo-v2.5' });
|
||||
const { bodies } = captureFetch();
|
||||
await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }));
|
||||
await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }));
|
||||
expect(bodies[0].max_completion_tokens).toBe(131_072);
|
||||
expect(bodies[1].max_completion_tokens).toBe(32_768);
|
||||
expect(bodies[0].max_completion_tokens).toBe(200_000);
|
||||
expect(bodies[1].max_completion_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('thinking 未关闭时未配置 maxTokens → 兜底 32768(思考占配额,防截断)', async () => {
|
||||
it('thinking 未关闭时未配置 maxTokens → 不下发该字段(v0.8.1:无写死兜底值)', async () => {
|
||||
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
|
||||
const { bodies } = captureFetch();
|
||||
await pro.send(makeRequest({ params: { temperature: 0, stream: false } }));
|
||||
expect(bodies[0].max_completion_tokens).toBe(32_768);
|
||||
expect(bodies[0].max_completion_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => {
|
||||
@@ -1029,27 +1029,36 @@ describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1)', () => {
|
||||
expect(bodies[0].reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gpt-4.1 长上下文 1M → getContextWindow 返回 1M(模型元信息表)', () => {
|
||||
const adapter = makeAdapter('gpt-4.1');
|
||||
it('gpt-4.1 → getContextWindow 返回设置面板配置值(v0.8.1:元信息不再承载窗口)', () => {
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'http://o.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'gpt-4.1',
|
||||
contextWindow: 1_000_000,
|
||||
});
|
||||
expect(adapter.getContextWindow()).toBe(1_000_000);
|
||||
// 未配置 → 0(引擎跳过压缩判定),无任何写死兜底
|
||||
const noCfg = makeAdapter('gpt-4.1');
|
||||
expect(noCfg.getContextWindow()).toBe(0);
|
||||
});
|
||||
|
||||
it('o3-mini max_completion_tokens 钳制到 100000', async () => {
|
||||
it('o3-mini max_completion_tokens 原样透传(v0.8.1:无 100000 钳制)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_completion_tokens).toBe(100_000);
|
||||
expect(bodies[0].max_completion_tokens).toBe(200_000);
|
||||
});
|
||||
|
||||
it('gpt-4o max_tokens 钳制到 16384', async () => {
|
||||
it('gpt-4o max_tokens 原样透传(v0.8.1:无 16384 钳制)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(16_384);
|
||||
expect(bodies[0].max_tokens).toBe(63_488);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1255,16 +1264,16 @@ describe('OllamaAdapter — options 缺省与工具定义', () => {
|
||||
|
||||
// ===== 跨 Provider maxTokens 钳制矩阵 =====
|
||||
|
||||
describe('跨 Provider — maxTokens 钳制矩阵汇总', () => {
|
||||
describe('跨 Provider — maxTokens 透传矩阵汇总(v0.8.1 无钳制)', () => {
|
||||
it.each([
|
||||
['anthropic', 'claude-opus-4-1', 100_000, 32_000],
|
||||
['anthropic', 'claude-sonnet-4-5', 100_000, 64_000],
|
||||
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 8_192],
|
||||
['agnes', 'agnes-2.0-flash', 100_000, 65_536],
|
||||
['mimo', 'mimo-v2.5', 100_000, 32_768],
|
||||
['openai', 'gpt-4o', 100_000, 16_384],
|
||||
['anthropic', 'claude-opus-4-1', 100_000, 100_000],
|
||||
['anthropic', 'claude-sonnet-4-5', 100_000, 100_000],
|
||||
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 100_000],
|
||||
['agnes', 'agnes-2.0-flash', 100_000, 100_000],
|
||||
['mimo', 'mimo-v2.5', 100_000, 100_000],
|
||||
['openai', 'gpt-4o', 100_000, 100_000],
|
||||
] as const)(
|
||||
'%s %s maxTokens=100000 → 钳制为 %d',
|
||||
'%s %s maxTokens=100000 → 原样透传 %d',
|
||||
async (provider, model, requested, expected) => {
|
||||
const adapterMap: Record<string, unknown> = {
|
||||
anthropic: new AnthropicAdapter({
|
||||
|
||||
@@ -56,7 +56,7 @@ function asNative(
|
||||
}
|
||||
|
||||
describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
|
||||
it('DeepSeek: vision-exp(元信息 false)+ 用户开启思考 → 照发 enabled + reasoning_effort,max_tokens 仍按模型钳制 8192', async () => {
|
||||
it('DeepSeek: vision-exp(元信息 false)+ 用户开启思考 → 照发 enabled + reasoning_effort,max_tokens 原样透传(v0.8.1 无钳制)', async () => {
|
||||
const adapter = new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
@@ -66,7 +66,7 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
|
||||
const body = asNative(adapter)(makeRequest(), false);
|
||||
expect(body.thinking).toEqual({ type: 'enabled' });
|
||||
expect(body.reasoning_effort).toBe('max');
|
||||
expect(body.max_tokens).toBe(8192);
|
||||
expect(body.max_tokens).toBe(63488);
|
||||
});
|
||||
|
||||
it('DeepSeek: 用户关闭思考 → 显式 disabled', async () => {
|
||||
|
||||
@@ -23,16 +23,15 @@ export class AgnesAdapter extends OpenAICompatibleAdapter {
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
// H-2 修复: Agnes 模型元信息(1M 上下文,65.5K 最大输出)
|
||||
// H-2 修复: Agnes 模型元信息(v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限
|
||||
// 数值已按硬性契约删除,唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens)
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'agnes-2.0-flash': {
|
||||
id: 'agnes-2.0-flash',
|
||||
name: 'Agnes 2.0 Flash',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 65_536,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片(URL + Base64)与思考模式',
|
||||
description: 'Agnes AI 快速版,支持多模态图片(URL + Base64)与思考模式',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,16 +71,13 @@ export class AgnesAdapter extends OpenAICompatibleAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制(agnes-2.0-flash 上限 65536)
|
||||
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
const maxOutput = modelInfo?.maxOutputTokens ?? 65_536;
|
||||
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
|
||||
|
||||
// v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens),
|
||||
// 删除了旧的按模型元信息钳制逻辑
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
temperature: request.params.temperature,
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: request.params.maxTokens,
|
||||
stream,
|
||||
};
|
||||
|
||||
|
||||
@@ -52,21 +52,19 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
// v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
|
||||
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'claude-sonnet-4-5': {
|
||||
id: 'claude-sonnet-4-5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 64_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用',
|
||||
description: 'Anthropic 旗舰模型,支持扩展思考与工具调用',
|
||||
},
|
||||
'claude-opus-4-1': {
|
||||
id: 'claude-opus-4-1',
|
||||
name: 'Claude Opus 4.1',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 32_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Anthropic 深度推理模型',
|
||||
@@ -74,8 +72,6 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
'claude-haiku-4-5': {
|
||||
id: 'claude-haiku-4-5',
|
||||
name: 'Claude Haiku 4.5',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 32_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'Anthropic 低延迟模型',
|
||||
@@ -416,13 +412,9 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id });
|
||||
}
|
||||
|
||||
override getContextWindow(): number {
|
||||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||
return this.config.contextWindow;
|
||||
}
|
||||
const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
return modelInfo?.contextWindow ?? 200_000;
|
||||
}
|
||||
// getContextWindow 使用基类实现 —— v0.8.1 硬性契约:唯一来源是
|
||||
// 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow),
|
||||
// 未配置返回 0,引擎据此跳过压缩预算计算。
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
@@ -531,22 +523,21 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)—
|
||||
// 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400;thinking budget 已在此值内二分
|
||||
const anthropicMaxOutput =
|
||||
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000;
|
||||
|
||||
// v0.6.4 边界加固: thinking 开启时保证 max_tokens ≥ 2048 —— 协议要求
|
||||
// budget_tokens >= 1024 且 < max_tokens。原实现当用户配置极小 maxTokens
|
||||
// (如 1500)时 Math.floor(1500/2)=750 < 1024 直接 API 400。
|
||||
const requestedMaxTokens = request.params.maxTokens ?? 8192;
|
||||
// v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens),
|
||||
// 删除了旧的按模型元信息钳制逻辑(MODEL_INFO.maxOutputTokens 已删除)。
|
||||
// 唯一保留的协议不变量:thinking 开启时 max_tokens ≥ 2048 —— Anthropic 协议
|
||||
// 要求 budget_tokens >= 1024 且 < max_tokens,用户配置低于该下限时 API 必然
|
||||
// 400,此为协议正确性下限而非输出上限(不修改用户配置的持久化值,仅在
|
||||
// 本次请求体上满足协议约束)。
|
||||
const requestedMaxTokens = request.params.maxTokens;
|
||||
const maxTokensForRequest = thinkingRequested
|
||||
? Math.max(2048, Math.min(requestedMaxTokens, anthropicMaxOutput))
|
||||
: Math.min(requestedMaxTokens, anthropicMaxOutput);
|
||||
? Math.max(2048, requestedMaxTokens ?? 2048)
|
||||
: requestedMaxTokens;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
max_tokens: maxTokensForRequest,
|
||||
// v0.8.1: 未配置「最大输出上限」时不下发 max_tokens(服务端默认值生效)
|
||||
...(maxTokensForRequest != null ? { max_tokens: maxTokensForRequest } : {}),
|
||||
messages: merged,
|
||||
stream,
|
||||
};
|
||||
@@ -581,7 +572,8 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
max: 32768,
|
||||
};
|
||||
const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384;
|
||||
const budget = Math.min(effortBudget, Math.floor(maxTokensForRequest / 2));
|
||||
// thinking 路径 maxTokensForRequest 恒为数字(Math.max(2048, …) 兜底)
|
||||
const budget = Math.min(effortBudget, Math.floor((maxTokensForRequest ?? 2048) / 2));
|
||||
body.thinking = { type: 'enabled', budget_tokens: budget };
|
||||
} else {
|
||||
body.temperature = request.params.temperature;
|
||||
|
||||
@@ -69,19 +69,15 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
/**
|
||||
* H-2 修复: 获取上下文窗口大小(规范要求)
|
||||
*
|
||||
* 默认实现从 config 读取 contextWindow,子类可覆盖以支持动态查询。
|
||||
* Engine 用此值估算上下文使用率,决定是否触发压缩。
|
||||
*
|
||||
* @returns 上下文窗口大小(token 数)
|
||||
* v0.8.1 硬性契约:上下文窗口的唯一合法来源是设置面板 LLM 配置的「上下文长度」
|
||||
* (llm.contextWindow,经 main.ts 注入 AdapterConfig.contextWindow)。
|
||||
* 本基类与所有子类禁止携带任何写死的默认窗口值 —— 未配置时返回 0,
|
||||
* 引擎据此跳过压缩预算计算(syncContextWindow 仅在 >0 时采纳)。
|
||||
*/
|
||||
getContextWindow(): number {
|
||||
// 优先使用 AdapterConfig.contextWindow(如果存在)
|
||||
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
|
||||
if (typeof ctx === 'number' && ctx > 0) return ctx;
|
||||
// v0.7.4 P4-5: 兜底从 1M 降至 128K —— 旧默认值 1M 在 config 与模型元信息均缺失时
|
||||
// (如 DeepSeek 未知模型),压缩阈值按 1M 算,实际 64K/128K 模型会先 413 再压缩。
|
||||
// 128K 是当前最保守的主流窗口,未知模型按最小值预算更安全。
|
||||
return 128_000;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,32 +28,27 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
// H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出)
|
||||
// H-2 修复: DeepSeek 模型元信息(v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限
|
||||
// 数值已按硬性契约删除,唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens)
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'deepseek-v4-pro': {
|
||||
id: 'deepseek-v4-pro',
|
||||
name: 'DeepSeek V4 Pro',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 384_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用',
|
||||
description: 'DeepSeek 旗舰模型,支持深度推理与工具调用',
|
||||
},
|
||||
'deepseek-v4-flash': {
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 384_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'DeepSeek 快速版,1M 上下文,低延迟推理',
|
||||
description: 'DeepSeek 快速版,低延迟推理',
|
||||
},
|
||||
// v0.5.4: DeepSeek 多模态实验模型(OpenAI image_url content parts 格式)
|
||||
'deepseek-v4-flash-vision-exp': {
|
||||
id: 'deepseek-v4-flash-vision-exp',
|
||||
name: 'DeepSeek V4 Flash Vision (Exp)',
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 8_192,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'DeepSeek 多模态实验模型,支持图片输入(image_url content parts)',
|
||||
@@ -173,16 +168,13 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
|
||||
const messages = buildOpenAICompatibleMessages(request, this.isVisionModel());
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400
|
||||
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
|
||||
const maxOutput = modelInfo?.maxOutputTokens ?? 384_000;
|
||||
const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput);
|
||||
|
||||
// v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens),
|
||||
// 删除了旧的按模型元信息钳制逻辑 —— 代码中不存在任何写死的输出上限。
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
temperature: request.params.temperature,
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: request.params.maxTokens,
|
||||
stream,
|
||||
};
|
||||
|
||||
@@ -228,11 +220,11 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
|
||||
`[DeepSeek] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`,
|
||||
);
|
||||
}
|
||||
// v0.8.0 P0-3: 思考会占用输出预算 —— 钳制后预算过小时显式告警
|
||||
// v0.8.0 P0-3: 思考会占用输出预算 —— 用户配置的输出预算过小时显式告警
|
||||
//(思考 token 计入 max_tokens,预算过小会出现"思考耗尽正文为零"截断)
|
||||
if (maxTokens < 8192) {
|
||||
if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) {
|
||||
log.warn(
|
||||
`[DeepSeek] thinking enabled with small output budget (${maxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`,
|
||||
`[DeepSeek] thinking enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,12 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
|
||||
readonly supportsThinking = true;
|
||||
|
||||
// MiMo 模型元信息
|
||||
// mimo-v2.5-pro: 1M 上下文 / 131072 max_tokens;mimo-v2.5: 1M 上下文 / 32768 max_tokens
|
||||
// v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
|
||||
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'mimo-v2.5-pro': {
|
||||
id: 'mimo-v2.5-pro',
|
||||
name: 'MiMo V2.5 Pro',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 131_072,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: '小米 MiMo 旗舰模型,支持深度思考与工具调用',
|
||||
@@ -38,8 +37,6 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
|
||||
'mimo-v2.5': {
|
||||
id: 'mimo-v2.5',
|
||||
name: 'MiMo V2.5',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 32_768,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: '小米 MiMo 标准模型,低延迟推理',
|
||||
@@ -82,17 +79,13 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// MiMo 使用 max_completion_tokens(非 max_tokens)
|
||||
// #41 修复: thinking 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出)
|
||||
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)—
|
||||
// 引擎默认 63488 超过 standard 上限时 API 直接 400
|
||||
const mimoMaxOutput =
|
||||
MimoAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 131_072;
|
||||
const mimoDefault = request.params.thinkingEnabled !== false ? 32_768 : mimoMaxOutput;
|
||||
|
||||
// v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了
|
||||
// 旧的"#41 thinking 兜底 32768"与"按模型上限钳制"逻辑 —— 代码中不存在任何
|
||||
// 写死的输出上限;未配置时该字段不下发,由服务端默认值决定。
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
max_completion_tokens: Math.min(request.params.maxTokens ?? mimoDefault, mimoMaxOutput),
|
||||
max_completion_tokens: request.params.maxTokens,
|
||||
stream,
|
||||
};
|
||||
|
||||
@@ -145,11 +138,11 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
|
||||
`[MiMo] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`,
|
||||
);
|
||||
}
|
||||
// v0.8.0 P0-3: 思考占用输出预算 —— 钳制后预算过小时显式告警
|
||||
const effectiveMax = body.max_completion_tokens as number;
|
||||
// v0.8.0 P0-3: 思考占用输出预算 —— 用户配置的输出预算过小时显式告警
|
||||
const effectiveMax = body.max_completion_tokens as number | undefined;
|
||||
if (typeof effectiveMax === 'number' && effectiveMax < 8192) {
|
||||
log.warn(
|
||||
`[MiMo] thinking enabled with small output budget (${effectiveMax} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`,
|
||||
`[MiMo] thinking enabled with small output budget (${effectiveMax} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,18 +37,48 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
|
||||
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
|
||||
|
||||
private baseURL: string;
|
||||
|
||||
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
|
||||
super(config);
|
||||
this.baseURL = config.baseURL || 'http://localhost:11434';
|
||||
// v0.6.4 P4-1: 每个适配器实例(= 每会话独立引擎)启动时做一次 /api/show 探测,
|
||||
// 把 num_ctx 实测值填充进 getContextWindow 缓存。fire-and-forget:失败静默,
|
||||
// 不阻塞/不影响首个请求;此后压缩预算基于实测窗口而非保守默认 4096。
|
||||
this.refreshContextWindow();
|
||||
// v0.8.1: 上下文窗口不再从 /api/show 探测或写死默认值获取 —— 唯一合法来源是
|
||||
// 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow,
|
||||
// 引擎侧经 contextLength=num_ctx 下发)。构造时仅探测能力(thinking/tools/vision,
|
||||
// 属协议正确性门控),不再缓存窗口数值。
|
||||
this.probeCapabilitiesOnce();
|
||||
}
|
||||
|
||||
/** /api/show 能力探测只发一次(构造函数发起),失败不重试(fail-open) */
|
||||
private probeAttempted = false;
|
||||
private probeInProgress = false;
|
||||
/**
|
||||
* /api/show capabilities 探测缓存 —— 模型是否支持思考(协议正确性门控,
|
||||
* 非窗口/输出上限语义)。null = 未探测/探测失败(fail-open 放行,与
|
||||
* listModels 能力回退策略一致);false = 服务端明确不支持 → 不发 think 参数。
|
||||
*/
|
||||
private cachedThinkingSupport: boolean | null = null;
|
||||
|
||||
/**
|
||||
* v0.8.1: 构造时 fire-and-forget 探测一次默认模型能力(仅 thinking 门控消费)。
|
||||
* 旧实现同时缓存 num_ctx 窗口数值 —— 已按"窗口唯一来源是设置面板"契约删除。
|
||||
*/
|
||||
private probeCapabilitiesOnce(): void {
|
||||
if (this.probeAttempted) return;
|
||||
this.probeAttempted = true;
|
||||
this.probeInProgress = true;
|
||||
void this.showModel(this.config.defaultModel)
|
||||
.then((info) => {
|
||||
if (Array.isArray(info?.capabilities)) {
|
||||
this.cachedThinkingSupport = info!.capabilities.map(String).includes('thinking');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 模型探测失败不阻塞对话(fail-open) */
|
||||
})
|
||||
.finally(() => {
|
||||
this.probeInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== POST /api/chat =====
|
||||
@@ -356,14 +386,13 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
|
||||
// v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行),
|
||||
// 供上传入口拒绝不支持图片的本地语言模型
|
||||
// v0.8.1: 不再填充 contextWindow —— 窗口唯一来源是设置面板「上下文长度」
|
||||
const enriched = await Promise.all(
|
||||
data.models.map(async (m) => {
|
||||
const caps = await this.probeCapabilities(m.name);
|
||||
return {
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
|
||||
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
|
||||
supportsToolCalling: caps ? caps.supportsTools : true,
|
||||
supportsThinking: caps ? caps.supportsThinking : true,
|
||||
supportsVision: caps ? caps.supportsVision : undefined,
|
||||
@@ -386,59 +415,15 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
/**
|
||||
* H-2 修复: 获取上下文窗口大小(规范要求)
|
||||
*
|
||||
* Ollama 上下文窗口由 options.num_ctx 决定(默认 4096),
|
||||
* Engine 应通过 MetonaRequest.params.contextLength 显式设置。
|
||||
* 此处返回默认值,供 Engine 在未指定时参考。
|
||||
* v0.8.1 硬性契约: 唯一来源是设置面板「上下文长度」(llm.contextWindow),
|
||||
* 未配置返回 0 —— 删除了旧的 4096 写死默认值与 /api/show num_ctx 探测缓存。
|
||||
* Ollama 的 num_ctx 由引擎经 params.contextLength(同源配置)下发给服务端。
|
||||
*/
|
||||
override getContextWindow(): number {
|
||||
return this.cachedContextWindow ?? OllamaAdapter.DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.4 P4-1: 从 /api/show 的 parameters 区解析 num_ctx 真值。
|
||||
*
|
||||
* 契约约束:IMetonaProviderAdapter.getContextWindow 是同步接口(引擎压缩判定
|
||||
* 依赖同步取值),无法在内部 await。因此采用"机会主义缓存"策略:
|
||||
* send/sendStream 启动时 fire-and-forget 刷新缓存;首次请求前返回默认 4096,
|
||||
* 之后永远返回实测值。压缩预算的准确性随使用逐渐收敛到真值。
|
||||
*/
|
||||
private cachedContextWindow: number | null = null;
|
||||
private refreshingContextWindow = false;
|
||||
/** v0.8.0 P0-3: /api/show 探测只发一次(构造函数发起),失败不重试(fail-open) */
|
||||
private probeAttempted = false;
|
||||
/**
|
||||
* v0.8.0 P0-3: /api/show capabilities 探测缓存 —— 模型是否支持思考。
|
||||
* null = 未探测/探测失败(fail-open 放行,与 listModels 能力回退策略一致);
|
||||
* false = 服务端明确不支持 → toNativeRequest 不发 think 参数。
|
||||
*/
|
||||
private cachedThinkingSupport: boolean | null = null;
|
||||
|
||||
private refreshContextWindow(): void {
|
||||
if (this.refreshingContextWindow || this.probeAttempted) return;
|
||||
this.probeAttempted = true;
|
||||
this.refreshingContextWindow = true;
|
||||
void this.showModel(this.config.defaultModel)
|
||||
.then((info) => {
|
||||
if (!info?.parameters) return;
|
||||
const match = /^num_ctx\s+(\d+)\s*$/m.exec(info.parameters);
|
||||
if (match) {
|
||||
const value = Number(match[1]);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
this.cachedContextWindow = value;
|
||||
log.info(`[Ollama] Context window (num_ctx) detected: ${value}`);
|
||||
}
|
||||
}
|
||||
// v0.8.0 P0-3: 同一次探测顺带缓存思考能力(供 toNativeRequest 同步门控)
|
||||
if (Array.isArray(info.capabilities)) {
|
||||
this.cachedThinkingSupport = info.capabilities.map(String).includes('thinking');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 模型探测失败不阻塞对话 */
|
||||
})
|
||||
.finally(() => {
|
||||
this.refreshingContextWindow = false;
|
||||
});
|
||||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||
return this.config.contextWindow;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,7 +451,7 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
|
||||
async showModel(
|
||||
model: string,
|
||||
): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
|
||||
): Promise<{ parameters: string; template: string; capabilities?: string[] } | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/api/show`, {
|
||||
method: 'POST',
|
||||
@@ -483,7 +468,10 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
return {
|
||||
parameters: data.parameters ?? '',
|
||||
template: data.template ?? '',
|
||||
capabilities: data.capabilities ?? [],
|
||||
// v0.8.1: 保留 undefined 语义 —— 响应未携带 capabilities 字段 = 未知
|
||||
//(fail-open),显式数组(含空数组 = 服务端权威"无任何能力")才参与门控。
|
||||
// 旧实现 `?? []` 把"字段缺失"与"权威空"混同,探测竞态下会误判不支持思考。
|
||||
capabilities: data.capabilities,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -707,7 +695,7 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
// think 会导致每次请求 400 "does not support thinking",而非静默忽略),
|
||||
// 故此处门控属协议正确性而非用户意图覆盖;探测为服务端实时真值(非静态
|
||||
// 元信息)。未探测/探测失败(null)fail-open 放行,与 listModels 能力回退
|
||||
// 策略一致。探测在适配器实例创建时 fire-and-forget 发起(refreshContextWindow)。
|
||||
// 策略一致。探测在适配器实例创建时 fire-and-forget 发起(probeCapabilitiesOnce)。
|
||||
if (request.params.thinkingEnabled) {
|
||||
const modelThinkingSupported = this.cachedThinkingSupport !== false;
|
||||
if (modelThinkingSupported) {
|
||||
|
||||
@@ -24,39 +24,33 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
|
||||
readonly supportsToolCalling = true;
|
||||
readonly supportsThinking = true;
|
||||
|
||||
// v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除,
|
||||
// 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens
|
||||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||||
'gpt-4o': {
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 16_384,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'OpenAI 旗舰多模态模型,128K 上下文',
|
||||
description: 'OpenAI 旗舰多模态模型',
|
||||
},
|
||||
'gpt-4o-mini': {
|
||||
id: 'gpt-4o-mini',
|
||||
name: 'GPT-4o mini',
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 16_384,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'OpenAI 高性价比模型,128K 上下文',
|
||||
description: 'OpenAI 高性价比模型',
|
||||
},
|
||||
'gpt-4.1': {
|
||||
id: 'gpt-4.1',
|
||||
name: 'GPT-4.1',
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 32_768,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
description: 'OpenAI 长上下文模型,1M 上下文',
|
||||
description: 'OpenAI 长上下文模型',
|
||||
},
|
||||
'o3-mini': {
|
||||
id: 'o3-mini',
|
||||
name: 'o3-mini',
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 100_000,
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: true,
|
||||
description: 'OpenAI 推理模型,支持 reasoning_effort',
|
||||
@@ -81,11 +75,6 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
// v0.6.4: OpenAI 家族兜底窗口为 128K(其余 OpenAI 兼容 Provider 为 1M)
|
||||
protected override defaultContextWindowFallback(): number {
|
||||
return 128_000;
|
||||
}
|
||||
|
||||
// ===== GET /v1/models =====
|
||||
|
||||
override async listModels(): Promise<MetonaModelInfo[]> {
|
||||
@@ -137,18 +126,13 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
|
||||
};
|
||||
|
||||
// Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens
|
||||
// v0.5.3: 按模型上限钳制(gpt-4o 16384 / gpt-4.1 32768 / o3-mini 100000)—
|
||||
// 引擎默认 63488 超过 gpt-4o/gpt-4.1 上限时 API 直接 400
|
||||
const oaMaxOutput = OpenAIAdapter.MODEL_INFO[model]?.maxOutputTokens ?? 128_000;
|
||||
const oaMaxTokens = Math.min(
|
||||
request.params.maxTokens ?? (isReasoningModel ? 32_768 : oaMaxOutput),
|
||||
oaMaxOutput,
|
||||
);
|
||||
if (oaMaxTokens) {
|
||||
// v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了
|
||||
// 旧的按模型元信息钳制逻辑与未配置时的 32_768 兜底 —— 未配置时不下发该字段。
|
||||
if (request.params.maxTokens != null) {
|
||||
if (isReasoningModel) {
|
||||
body.max_completion_tokens = oaMaxTokens;
|
||||
body.max_completion_tokens = request.params.maxTokens;
|
||||
} else {
|
||||
body.max_tokens = oaMaxTokens;
|
||||
body.max_tokens = request.params.maxTokens;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +153,10 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
|
||||
max: 'high',
|
||||
};
|
||||
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
|
||||
// v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 钳制后预算过小时告警
|
||||
if (oaMaxTokens < 8192) {
|
||||
// v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 用户配置的预算过小时告警
|
||||
if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) {
|
||||
log.warn(
|
||||
`[OpenAI] reasoning enabled with small output budget (${oaMaxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`,
|
||||
`[OpenAI] reasoning enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`,
|
||||
);
|
||||
}
|
||||
} else if (!isReasoningModel) {
|
||||
|
||||
@@ -41,16 +41,9 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
|
||||
/** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120s;Agnes 历史 300s,保留其值。 */
|
||||
protected abstract sendTimeoutMs(): number;
|
||||
|
||||
/** 模型元信息表(子类持有;用于 getContextWindow 回退链与钳制) */
|
||||
/** 模型元信息表(子类持有;仅承载展示与能力声明,不含任何窗口/输出上限数值) */
|
||||
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
|
||||
|
||||
/** getContextWindow 的最终兜底窗口(未配置且模型未知时使用) */
|
||||
// v0.7.4 P4-5: 1M → 128K(与 base-adapter 兜底对齐)——未知模型按最保守主流窗口预算,
|
||||
// 防止压缩阈值按 1M 计算导致实际小窗口模型先 413 再压缩
|
||||
protected defaultContextWindowFallback(): number {
|
||||
return 128_000;
|
||||
}
|
||||
|
||||
// ===== 认证头 =====
|
||||
|
||||
protected buildHeaders(): Record<string, string> {
|
||||
@@ -161,15 +154,16 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文窗口回退链(v0.6.3 一致化后的统一实现):
|
||||
* config.contextWindow(用户显式配置)→ 模型元信息 → Provider 兜底。
|
||||
* 上下文窗口(v0.8.1 硬性契约单一化):
|
||||
* 唯一合法来源是设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow)。
|
||||
* 删除了旧的 config → 模型元信息 → 兜底 三级回退链 —— 模型元信息不再承载窗口数值,
|
||||
* 未配置时返回 0(引擎据此跳过压缩预算,行为与"用户未声明窗口"语义一致)。
|
||||
*/
|
||||
override getContextWindow(): number {
|
||||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||
return this.config.contextWindow;
|
||||
}
|
||||
const modelInfo = this.modelInfoTable()[this.config.defaultModel];
|
||||
return modelInfo?.contextWindow ?? this.defaultContextWindowFallback();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7)', () => {
|
||||
}
|
||||
|
||||
it('确认请求广播到所有存活窗口(而非仅 mainWindow)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const a = makeTrackedWindow();
|
||||
const b = makeTrackedWindow();
|
||||
const destroyed = makeTrackedWindow(true);
|
||||
@@ -96,6 +97,8 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7)', () => {
|
||||
const hook = new ConfirmationHook(null, null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
const p = hook.beforeExecute(makeToolCall(), 'sess');
|
||||
// v0.8.1 P2-2: 聚合窗口(800ms)结束才广播 —— 推进 fake timers
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// 所有存活窗口均收到确认请求(携带 expiresAt 倒计时契约)
|
||||
expect(a.send).toHaveBeenCalledWith(
|
||||
@@ -111,20 +114,25 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7)', () => {
|
||||
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
|
||||
expect((await p).blocked).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('getAllWindows 为空时回退到注入的 mainWindow(向后兼容)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const mainWin = makeMockWindow();
|
||||
getAllWindowsMock.mockReturnValue([]);
|
||||
const hook = new ConfirmationHook(mainWin, null);
|
||||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||||
const p = hook.beforeExecute(makeToolCall(), 'sess');
|
||||
// v0.8.1 P2-2: 推进聚合窗口后断言广播
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((mainWin.webContents as unknown as { send: Mock }).send).toHaveBeenCalledWith(
|
||||
'tool:confirmationRequest',
|
||||
expect.objectContaining({ toolName: 'run_command' }),
|
||||
);
|
||||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
|
||||
expect((await p).blocked).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('全部窗口不可达时 fail-closed 阻断(no main window available)', async () => {
|
||||
@@ -524,3 +532,92 @@ describe('ConfirmationHook — 跨会话隔离(v0.5.0)', () => {
|
||||
expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 =====
|
||||
|
||||
describe('ConfirmationHook — 同类工具批量确认聚合(v0.8.1 P2-2)', () => {
|
||||
/** 单次 prompt(beforeExecute 阻塞等待确认,不消费 promise) */
|
||||
function startPrompt(hook: ConfirmationHook, id: string): Promise<unknown> {
|
||||
return hook.beforeExecute(
|
||||
{ id, name: 'run_command', args: {}, iteration: 1, timestamp: Date.now() },
|
||||
'sess',
|
||||
);
|
||||
}
|
||||
|
||||
/** 本 describe 专用的 send 追踪窗口(makeTrackedWindow 定义在上一 describe 作用域) */
|
||||
function makeWindow(): { win: BrowserWindow; send: Mock } {
|
||||
const send = vi.fn();
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send },
|
||||
} as unknown as BrowserWindow;
|
||||
return { win, send };
|
||||
}
|
||||
|
||||
function makeRunCommandDef(): MetonaToolDef {
|
||||
return {
|
||||
...HIGH_RISK_DEF,
|
||||
name: 'run_command',
|
||||
requiresPermission: true,
|
||||
};
|
||||
}
|
||||
|
||||
it('3 个同类并行请求 → 聚合为单条 batch 事件(无逐条事件)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const w = makeWindow();
|
||||
getAllWindowsMock.mockReturnValue([w.win]);
|
||||
const hook = new ConfirmationHook(null, null);
|
||||
hook.setToolDefs([makeRunCommandDef()]);
|
||||
|
||||
const prompts = [
|
||||
startPrompt(hook, 'tc_1'),
|
||||
startPrompt(hook, 'tc_2'),
|
||||
startPrompt(hook, 'tc_3'),
|
||||
];
|
||||
// 达到阈值立即 flush
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const batchCalls = (w.send as Mock).mock.calls.filter(
|
||||
(c) => c[0] === 'tool:confirmationRequestBatch',
|
||||
);
|
||||
expect(batchCalls).toHaveLength(1);
|
||||
expect(
|
||||
(batchCalls[0][1] as unknown[]).map((r) => (r as { toolCallId: string }).toolCallId),
|
||||
).toEqual(['tc_1', 'tc_2', 'tc_3']);
|
||||
// 不应再发送逐条事件
|
||||
const individual = (w.send as Mock).mock.calls.filter(
|
||||
(c) => c[0] === 'tool:confirmationRequest',
|
||||
);
|
||||
expect(individual).toHaveLength(0);
|
||||
|
||||
for (const id of ['tc_1', 'tc_2', 'tc_3']) {
|
||||
hook.resolveConfirmation(id, true, false, false);
|
||||
}
|
||||
await Promise.all(prompts);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('2 个同类请求(低于阈值)→ 窗口结束逐条广播(原行为)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const w = makeWindow();
|
||||
getAllWindowsMock.mockReturnValue([w.win]);
|
||||
const hook = new ConfirmationHook(null, null);
|
||||
hook.setToolDefs([makeRunCommandDef()]);
|
||||
|
||||
const prompts = [startPrompt(hook, 'tc_a'), startPrompt(hook, 'tc_b')];
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(
|
||||
(w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequestBatch'),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
(w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequest'),
|
||||
).toHaveLength(2);
|
||||
|
||||
for (const id of ['tc_a', 'tc_b']) {
|
||||
hook.resolveConfirmation(id, true, false, false);
|
||||
}
|
||||
await Promise.all(prompts);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -467,6 +467,39 @@ export class ConfirmationHook implements PreToolHook {
|
||||
*/
|
||||
private lastTimeoutToastAt = 0;
|
||||
|
||||
// ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 =====
|
||||
/**
|
||||
* 同 (sessionId, toolName) 的请求在 AGGREGATION_WINDOW_MS 内聚合:窗口结束时
|
||||
* 若积压 >= AGGREGATION_THRESHOLD 条则只广播一条 `tool:confirmationRequestBatch`
|
||||
* (前端一次性拉取/渲染全部 pending),否则逐条广播(原行为)。
|
||||
* 目的:批量重构等场景(并行工具连续触发 3+ 同类确认)不再弹出 N 个连续弹框。
|
||||
*/
|
||||
private static readonly AGGREGATION_WINDOW_MS = 800;
|
||||
private static readonly AGGREGATION_THRESHOLD = 3;
|
||||
private aggregationBuffers = new Map<
|
||||
string,
|
||||
{ requests: ConfirmationRequest[]; timer: NodeJS.Timeout | null }
|
||||
>();
|
||||
|
||||
private flushAggregationBuffer(key: string): void {
|
||||
const buf = this.aggregationBuffers.get(key);
|
||||
if (!buf) return;
|
||||
this.aggregationBuffers.delete(key);
|
||||
if (buf.timer) {
|
||||
clearTimeout(buf.timer);
|
||||
buf.timer = null;
|
||||
}
|
||||
if (buf.requests.length >= ConfirmationHook.AGGREGATION_THRESHOLD) {
|
||||
// 批量事件:携带完整请求列表(expiresAt 已含)
|
||||
this.broadcastToAllWindows('tool:confirmationRequestBatch', buf.requests);
|
||||
} else {
|
||||
// 逐条广播(原行为)
|
||||
for (const req of buf.requests) {
|
||||
this.broadcastToAllWindows('tool:confirmationRequest', req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const expiresAt = Date.now() + this.confirmationTimeoutMs;
|
||||
@@ -518,10 +551,24 @@ export class ConfirmationHook implements PreToolHook {
|
||||
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
|
||||
// v0.7.2 P2-7: 广播到所有窗口 —— 多窗口场景下任意窗口发起的会话
|
||||
// 触发的确认请求都可达(ConfirmationDialog 按会话过滤展示)
|
||||
this.broadcastToAllWindows('tool:confirmationRequest', {
|
||||
...request,
|
||||
expiresAt,
|
||||
});
|
||||
// v0.8.1 P2-2: 聚合窗口 —— 同会话同类工具的并行请求进入缓冲;
|
||||
// 窗口结束达到阈值时合并为单条批量事件,否则逐条广播(原行为)
|
||||
const aggKey = `${sessionId}:${request.toolName}`;
|
||||
let buf = this.aggregationBuffers.get(aggKey);
|
||||
if (!buf) {
|
||||
buf = { requests: [], timer: null };
|
||||
this.aggregationBuffers.set(aggKey, buf);
|
||||
buf.timer = setTimeout(
|
||||
() => this.flushAggregationBuffer(aggKey),
|
||||
ConfirmationHook.AGGREGATION_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
buf.requests.push({ ...request, expiresAt });
|
||||
|
||||
// 窗口内积压已达阈值 → 立即 flush(不等满窗口)
|
||||
if (buf.requests.length >= ConfirmationHook.AGGREGATION_THRESHOLD) {
|
||||
this.flushAggregationBuffer(aggKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,6 +587,9 @@ export class ConfirmationHook implements PreToolHook {
|
||||
pending.resolve(false);
|
||||
}
|
||||
this.pendingConfirmations.clear();
|
||||
// v0.8.1 P2-2: 清空聚合缓冲(resolve(false) 已由逐条 timer 覆盖……缓冲内的
|
||||
// 请求本身尚未注册 pending,需丢弃防止稍后广播已失效请求)
|
||||
this.aggregationBuffers.clear();
|
||||
return;
|
||||
}
|
||||
for (const [id, pending] of this.pendingConfirmations) {
|
||||
@@ -548,6 +598,20 @@ export class ConfirmationHook implements PreToolHook {
|
||||
pending.resolve(false);
|
||||
this.pendingConfirmations.delete(id);
|
||||
}
|
||||
// v0.8.1 P2-2: 丢弃该会话尚未广播的聚合缓冲
|
||||
for (const [key, buf] of this.aggregationBuffers) {
|
||||
if (!key.startsWith(`${sessionId}:`)) continue;
|
||||
if (buf.timer) clearTimeout(buf.timer);
|
||||
for (const req of buf.requests) {
|
||||
const pending = this.pendingConfirmations.get(req.toolCallId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(false);
|
||||
this.pendingConfirmations.delete(req.toolCallId);
|
||||
}
|
||||
}
|
||||
this.aggregationBuffers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,11 @@ export interface PostToolHook {
|
||||
export class AuditLogHook implements PostToolHook {
|
||||
constructor(private auditService: AuditService) {}
|
||||
|
||||
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> {
|
||||
async afterExecute(
|
||||
toolCall: MetonaToolCall,
|
||||
result: MetonaToolResult,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
// #17 修复: AuditHook 应为 "fire and forget",hook 失败不应影响工具执行链
|
||||
// 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御,
|
||||
// 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry
|
||||
@@ -59,11 +63,24 @@ export class MemoryTriggerHook implements PostToolHook {
|
||||
/** 单次工具结果存储上限(字符),防止过大内容淹没记忆系统 */
|
||||
private readonly MAX_MEMORY_CONTENT = 500;
|
||||
|
||||
/**
|
||||
* v0.8.1 P0-2: 工具结果类情节记忆的 TTL(90 天)。
|
||||
* 此前 episodic_memories.expires_at 全链路无写入方 —— cleanupExpired(健康检查
|
||||
* 周期调用)空转,情节记忆只增不减。现为此类低价值记忆写入过期时间,90 天后
|
||||
* 由周期清理回收;用户偏好等高价值记忆(consolidator 写入 semantic 表)不受影响。
|
||||
*/
|
||||
private readonly EPISODIC_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor(private memoryManager: MemoryManager) {}
|
||||
|
||||
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> {
|
||||
async afterExecute(
|
||||
toolCall: MetonaToolCall,
|
||||
result: MetonaToolResult,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (this.memorableTools.includes(toolCall.name) && result.success) {
|
||||
const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
||||
const content =
|
||||
typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
||||
try {
|
||||
this.memoryManager.store({
|
||||
type: 'episodic',
|
||||
@@ -71,6 +88,7 @@ export class MemoryTriggerHook implements PostToolHook {
|
||||
source: 'tool_result',
|
||||
sessionId,
|
||||
importance: 0.6,
|
||||
expiresAt: Date.now() + this.EPISODIC_TTL_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
// 记忆存储失败不应影响工具执行结果
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* MemoryMaintainer 测试(v0.8.1 P1-2 MEMORY.md 维护闭环)
|
||||
*
|
||||
* 锁定契约:
|
||||
* 1. parseMemoryEntries / buildMemoryEntriesDigest —— 分区条目摘要(纯条目行,
|
||||
* 消除 Consolidator 旧全文截断的去重盲区)
|
||||
* 2. apply 的精确匹配防线 —— LLM 建议的 entry 必须原样存在,防幻觉改写无关内容
|
||||
* 3. delete/update 动作重写 MEMORY.md + 同步 semantic_memories 双轨一致
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { MemoryMaintainer, parseMemoryEntries, buildMemoryEntriesDigest } from '../maintainer';
|
||||
import type { MemoryMaintenanceAction } from '../maintainer';
|
||||
|
||||
const SAMPLE = `# MEMORY.md — AI 持久记忆
|
||||
> 最后更新: 2026-09-07
|
||||
|
||||
## 用户偏好
|
||||
- [沟通风格] 用户喜欢简洁的回答
|
||||
- [工具偏好] 项目使用 pnpm
|
||||
|
||||
## 项目上下文
|
||||
- [Metona] 技术栈: Electron + React
|
||||
|
||||
## 待办事项
|
||||
- [done] 旧待办已完成
|
||||
`;
|
||||
|
||||
describe('parseMemoryEntries / buildMemoryEntriesDigest(v0.8.1 P1-2)', () => {
|
||||
it('解析分区与条目(跳过元数据头)', () => {
|
||||
const sections = parseMemoryEntries(SAMPLE);
|
||||
expect(sections).toHaveLength(3);
|
||||
expect(sections[0].section).toBe('用户偏好');
|
||||
expect(sections[0].entries).toEqual([
|
||||
'[沟通风格] 用户喜欢简洁的回答',
|
||||
'[工具偏好] 项目使用 pnpm',
|
||||
]);
|
||||
});
|
||||
|
||||
it('digest 为纯条目行形态且条目全文可见(无 3000 字符截断盲区)', () => {
|
||||
const digest = buildMemoryEntriesDigest(parseMemoryEntries(SAMPLE));
|
||||
expect(digest).toContain('## 用户偏好');
|
||||
expect(digest).toContain('- [沟通风格] 用户喜欢简洁的回答');
|
||||
// 旧全文形态的头部元数据不进入 digest
|
||||
expect(digest).not.toContain('最后更新');
|
||||
// 超过旧 3000 字符预算的记忆尾部条目同样完整进入 digest
|
||||
const manyEntries = Array.from({ length: 200 }, (_, i) => `- 条目 ${i} ${'x'.repeat(20)}`);
|
||||
const bigMemory = `## 项目上下文\n${manyEntries.join('\n')}`;
|
||||
const bigDigest = buildMemoryEntriesDigest(parseMemoryEntries(bigMemory));
|
||||
expect(bigDigest).toContain('条目 199');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryMaintainer.apply — 精确匹配与双轨同步', () => {
|
||||
function makeMaintainer(memory: string): {
|
||||
maintainer: MemoryMaintainer;
|
||||
getMemory: () => string;
|
||||
db: { prepare(sql: string): { run(...args: unknown[]): { changes: number } } };
|
||||
} {
|
||||
let current = memory;
|
||||
const semanticRows: Array<{ content: string }> = [{ content: '[沟通风格] 用户喜欢简洁的回答' }];
|
||||
const db = {
|
||||
prepare: (sql: string) => ({
|
||||
run: (...args: unknown[]) => {
|
||||
if (sql.startsWith('DELETE')) {
|
||||
const before = semanticRows.length;
|
||||
const target = semanticRows.find((r) => r.content === args[0]);
|
||||
if (target) semanticRows.splice(semanticRows.indexOf(target), 1);
|
||||
return { changes: before - semanticRows.length };
|
||||
}
|
||||
if (sql.startsWith('UPDATE')) {
|
||||
const row = semanticRows.find((r) => r.content === args[2]);
|
||||
if (row) {
|
||||
row.content = args[0] as string;
|
||||
return { changes: 1 };
|
||||
}
|
||||
return { changes: 0 };
|
||||
}
|
||||
return { changes: 0 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
const maintainer = new MemoryMaintainer(
|
||||
() => {
|
||||
throw new Error('not used in apply');
|
||||
},
|
||||
{
|
||||
getFiles: () => ({ soul: '', memory: current }),
|
||||
rewriteMemory: (content: string) => {
|
||||
current = content;
|
||||
},
|
||||
} as never,
|
||||
() => db as never,
|
||||
);
|
||||
return { maintainer, getMemory: () => current, db };
|
||||
}
|
||||
|
||||
it('delete 精确命中 → 行被移除;未命中条目被跳过(防幻觉改写)', () => {
|
||||
const { maintainer, getMemory } = makeMaintainer(SAMPLE);
|
||||
const actions: MemoryMaintenanceAction[] = [
|
||||
{ action: 'delete', section: '待办事项', entry: '[done] 旧待办已完成' },
|
||||
// 幻觉条目:文件中不存在 → 必须跳过
|
||||
{ action: 'delete', section: '用户偏好', entry: '不存在的条目' },
|
||||
];
|
||||
const result = maintainer.apply(actions);
|
||||
expect(result.applied).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
const after = getMemory();
|
||||
expect(after).not.toContain('[done] 旧待办已完成');
|
||||
expect(after).toContain('用户喜欢简洁的回答');
|
||||
expect(after).toContain('## 待办事项'); // 分区头保留(空分区仍保留结构)
|
||||
});
|
||||
|
||||
it('update(合并)→ 替换条目并同步 semantic_memories', () => {
|
||||
const { maintainer, getMemory, db } = makeMaintainer(SAMPLE);
|
||||
const actions: MemoryMaintenanceAction[] = [
|
||||
{
|
||||
action: 'update',
|
||||
section: '用户偏好',
|
||||
entry: '[沟通风格] 用户喜欢简洁的回答',
|
||||
newEntry: '[沟通风格] 用户喜欢简洁的回答,不需要过度解释',
|
||||
},
|
||||
];
|
||||
const result = maintainer.apply(actions);
|
||||
expect(result.applied).toBe(1);
|
||||
expect(getMemory()).toContain('不需要过度解释');
|
||||
const row = db
|
||||
.prepare('SELECT * FROM semantic_memories WHERE content = ?')
|
||||
.run('[沟通风格] 用户喜欢简洁的回答,不需要过度解释');
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('动作数上限 30(防 LLM 过度建议)', () => {
|
||||
const { maintainer } = makeMaintainer(SAMPLE);
|
||||
const actions: MemoryMaintenanceAction[] = Array.from({ length: 40 }, () => ({
|
||||
action: 'delete' as const,
|
||||
section: '待办事项',
|
||||
entry: '不存在的条目',
|
||||
}));
|
||||
const result = maintainer.apply(actions);
|
||||
expect(result.skipped).toBe(40);
|
||||
expect(result.applied).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryMaintainer.analyze — sectionEntryCounts(v0.8.1 review O2)', () => {
|
||||
function makeAnalyzer(
|
||||
memory: string,
|
||||
llmReply: string,
|
||||
): {
|
||||
maintainer: MemoryMaintainer;
|
||||
} {
|
||||
const adapter = {
|
||||
send: vi.fn().mockResolvedValue({ content: llmReply }),
|
||||
} as never;
|
||||
return {
|
||||
maintainer: new MemoryMaintainer(
|
||||
() => adapter,
|
||||
{
|
||||
getFiles: () => ({ soul: '', memory }),
|
||||
rewriteMemory: () => {},
|
||||
} as never,
|
||||
(() => ({})) as never,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
it('proposal 携带各分区条目数(空分区提示的数据源)', async () => {
|
||||
const { maintainer } = makeAnalyzer(
|
||||
SAMPLE,
|
||||
'[{"action":"delete","section":"待办事项","entry":"[done] 旧待办已完成","reason":"已完成"}]',
|
||||
);
|
||||
const proposal = await maintainer.analyze();
|
||||
expect(proposal.sectionEntryCounts['待办事项']).toBe(1);
|
||||
expect(proposal.sectionEntryCounts['用户偏好']).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,8 @@ function createMemorySchema(db: any): void {
|
||||
importance REAL DEFAULT 0.5,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER,
|
||||
tf_cache TEXT
|
||||
tf_cache TEXT,
|
||||
embedding BLOB
|
||||
);
|
||||
CREATE TABLE semantic_memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -55,7 +56,8 @@ function createMemorySchema(db: any): void {
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
tf_cache TEXT
|
||||
tf_cache TEXT,
|
||||
embedding BLOB
|
||||
);
|
||||
CREATE TABLE working_memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -349,10 +351,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
|
||||
).toThrow(/Unknown memory type/);
|
||||
});
|
||||
|
||||
it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', () => {
|
||||
it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', async () => {
|
||||
// v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。
|
||||
mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 });
|
||||
mgr.search('hello'); // 建立 IDF 缓存
|
||||
await mgr.search('hello'); // 建立 IDF 缓存
|
||||
// 再 store 一条 → 缓存应失效,新内容可被检索
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
@@ -360,10 +362,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
|
||||
source: 'user_input',
|
||||
importance: 0.7,
|
||||
});
|
||||
const results = mgr.search('another');
|
||||
const results = await mgr.search('another');
|
||||
expect(results.some((r) => r.content === 'another content')).toBe(true);
|
||||
// 双向验证:缓存重建后旧内容仍可检索(不因重建丢失)
|
||||
const oldResults = mgr.search('hello');
|
||||
const oldResults = await mgr.search('hello');
|
||||
expect(oldResults.some((r) => r.content === 'hello world')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -401,7 +403,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
}
|
||||
});
|
||||
|
||||
it('相同关键词:得分高者排前(内容重复度越高得分越高)', () => {
|
||||
it('相同关键词:得分高者排前(内容重复度越高得分越高)', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: 'memory hello world test',
|
||||
@@ -416,7 +418,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
importance: 0.7,
|
||||
});
|
||||
|
||||
const results = mgr.search('hello world');
|
||||
const results = await mgr.search('hello world');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((r) => r.score > 0)).toBe(true);
|
||||
// 两条命中的按分数降序
|
||||
@@ -424,7 +426,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
|
||||
});
|
||||
|
||||
it('时间衰减:同内容越新得分越高(30 天半衰期)', () => {
|
||||
it('时间衰减:同内容越新得分越高(30 天半衰期)', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: '关键 bug 修复方案',
|
||||
@@ -455,7 +457,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
newId,
|
||||
);
|
||||
|
||||
const results = mgr.search('关键 bug');
|
||||
const results = await mgr.search('关键 bug');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const newResult = results.find((r) => r.id === newId);
|
||||
const oldResult = results.find((r) => r.id === oldId);
|
||||
@@ -464,7 +466,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
expect(newResult!.score).toBeGreaterThan(oldResult!.score);
|
||||
});
|
||||
|
||||
it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5)', () => {
|
||||
it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5)', async () => {
|
||||
// 新鲜记录(0 天)
|
||||
const freshId = mgr.store({
|
||||
type: 'episodic',
|
||||
@@ -484,7 +486,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
agedId,
|
||||
);
|
||||
|
||||
const results = mgr.search('衰减数学验证内容');
|
||||
const results = await mgr.search('衰减数学验证内容');
|
||||
const fresh = results.find((r) => r.id === freshId)!;
|
||||
const aged = results.find((r) => r.id === agedId)!;
|
||||
// score = cosine * decay * importanceFactor;两记录余弦与 importance 相同
|
||||
@@ -492,7 +494,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
expect(aged.score / fresh.score).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', () => {
|
||||
it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', async () => {
|
||||
const lowId = mgr.store({
|
||||
type: 'episodic',
|
||||
content: '重要性权重验证',
|
||||
@@ -506,24 +508,24 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
importance: 1,
|
||||
});
|
||||
// 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5
|
||||
const results = mgr.search('重要性权重验证');
|
||||
const results = await mgr.search('重要性权重验证');
|
||||
const low = results.find((r) => r.id === lowId)!;
|
||||
const high = results.find((r) => r.id === highId)!;
|
||||
expect(high.score / low.score).toBeCloseTo(2.0, 1);
|
||||
});
|
||||
|
||||
it('semantic 记忆可被检索(key+value 参与分词)', () => {
|
||||
it('semantic 记忆可被检索(key+value 参与分词)', async () => {
|
||||
mgr.store({
|
||||
type: 'semantic',
|
||||
content: '用户偏好深色主题',
|
||||
source: 'imported',
|
||||
importance: 0.5,
|
||||
});
|
||||
const results = mgr.search('偏好');
|
||||
const results = await mgr.search('偏好');
|
||||
expect(results.some((r) => r.type === 'semantic')).toBe(true);
|
||||
});
|
||||
|
||||
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5)', () => {
|
||||
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5)', async () => {
|
||||
mgr.store({
|
||||
type: 'working',
|
||||
content: '当前任务文件',
|
||||
@@ -531,11 +533,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
importance: 0.5,
|
||||
source: 'agent_thought',
|
||||
});
|
||||
const results = mgr.search('当前任务');
|
||||
const results = await mgr.search('当前任务');
|
||||
expect(results.some((r) => r.type === 'working')).toBe(true);
|
||||
});
|
||||
|
||||
it('type 过滤:仅返回指定类型', () => {
|
||||
it('type 过滤:仅返回指定类型', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: 'typefilter 内容',
|
||||
@@ -549,13 +551,13 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
importance: 0.5,
|
||||
});
|
||||
|
||||
const episodic = mgr.search('typefilter', { type: 'episodic' });
|
||||
const episodic = await mgr.search('typefilter', { type: 'episodic' });
|
||||
expect(episodic.every((r) => r.type === 'episodic')).toBe(true);
|
||||
const semantic = mgr.search('typefilter', { type: 'semantic' });
|
||||
const semantic = await mgr.search('typefilter', { type: 'semantic' });
|
||||
expect(semantic.every((r) => r.type === 'semantic')).toBe(true);
|
||||
});
|
||||
|
||||
it('topK 限制返回条数', () => {
|
||||
it('topK 限制返回条数', async () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
@@ -564,22 +566,22 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
|
||||
importance: 0.7,
|
||||
});
|
||||
}
|
||||
const results = mgr.search('topk 内容');
|
||||
const results = await mgr.search('topk 内容');
|
||||
expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5
|
||||
const results2 = mgr.search('topk 内容', { topK: 2 });
|
||||
const results2 = await mgr.search('topk 内容', { topK: 2 });
|
||||
expect(results2.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('minImportance 过滤低重要性记忆', () => {
|
||||
it('minImportance 过滤低重要性记忆', async () => {
|
||||
mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 });
|
||||
mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 });
|
||||
const results = mgr.search('重要', { minImportance: 0.5 });
|
||||
const results = await mgr.search('重要', { minImportance: 0.5 });
|
||||
expect(results.every((r) => r.importance >= 0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)(>0 才返回)', () => {
|
||||
it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)(>0 才返回)', async () => {
|
||||
mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 });
|
||||
const results = mgr.search('score 数学');
|
||||
const results = await mgr.search('score 数学');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (const r of results) {
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
@@ -604,19 +606,19 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
|
||||
}
|
||||
});
|
||||
|
||||
it('空查询与纯空白查询返回空数组', () => {
|
||||
expect(mgr.search('')).toEqual([]);
|
||||
expect(mgr.search(' ')).toEqual([]);
|
||||
expect(mgr.search('', { topK: 3 })).toEqual([]);
|
||||
it('空查询与纯空白查询返回空数组', async () => {
|
||||
expect(await mgr.search('')).toEqual([]);
|
||||
expect(await mgr.search(' ')).toEqual([]);
|
||||
expect(await mgr.search('', { topK: 3 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('无匹配关键词返回空数组(不抛错)', () => {
|
||||
it('无匹配关键词返回空数组(不抛错)', async () => {
|
||||
mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 });
|
||||
// "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → []
|
||||
expect(mgr.search('完全无关联')).toEqual([]);
|
||||
expect(await mgr.search('完全无关联')).toEqual([]);
|
||||
});
|
||||
|
||||
it('英文无命中时回退 LIKE 子串搜索', () => {
|
||||
it('英文无命中时回退 LIKE 子串搜索', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: 'hello world network',
|
||||
@@ -625,11 +627,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
|
||||
});
|
||||
// query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中
|
||||
// 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中
|
||||
const results = mgr.search('lo wo');
|
||||
const results = await mgr.search('lo wo');
|
||||
expect(results.some((r) => r.content.includes('hello world'))).toBe(true);
|
||||
});
|
||||
|
||||
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', () => {
|
||||
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: '使用 50% 折扣 与 under_score',
|
||||
@@ -643,15 +645,15 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
|
||||
importance: 0.7,
|
||||
});
|
||||
// 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录
|
||||
const pct = mgr.search('%');
|
||||
const pct = await mgr.search('%');
|
||||
expect(pct.some((r) => r.content.includes('50%'))).toBe(true);
|
||||
expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false);
|
||||
// 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录
|
||||
const underscore = mgr.search('_');
|
||||
const underscore = await mgr.search('_');
|
||||
expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false);
|
||||
});
|
||||
|
||||
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', () => {
|
||||
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: '路径 C:\\Users\\test',
|
||||
@@ -659,10 +661,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
|
||||
importance: 0.7,
|
||||
});
|
||||
// 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错
|
||||
expect(() => mgr.search('\\')).not.toThrow();
|
||||
await expect(mgr.search('\\')).resolves.toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it('search 的 topK 同时作用于回退路径', () => {
|
||||
it('search 的 topK 同时作用于回退路径', async () => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
@@ -671,11 +673,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
|
||||
importance: 0.7,
|
||||
});
|
||||
}
|
||||
const results = mgr.search('backup', { topK: 3 });
|
||||
const results = await mgr.search('backup', { topK: 3 });
|
||||
expect(results.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', () => {
|
||||
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', async () => {
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: 'fallbackscore 内容',
|
||||
@@ -683,16 +685,16 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
|
||||
importance: 0.8,
|
||||
});
|
||||
// "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中
|
||||
const results = mgr.search('allbackscor');
|
||||
const results = await mgr.search('allbackscor');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// 新记录 timeDecay≈1 → score≈importance=0.8
|
||||
expect(results[0].score).toBeCloseTo(0.8, 1);
|
||||
});
|
||||
|
||||
it('LIKE 回退:episodic 按 importance 降序返回', () => {
|
||||
it('LIKE 回退:episodic 按 importance 降序返回', async () => {
|
||||
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 });
|
||||
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 });
|
||||
const results = mgr.search('排序验证');
|
||||
const results = await mgr.search('排序验证');
|
||||
expect(results[0].importance).toBe(0.9);
|
||||
});
|
||||
});
|
||||
@@ -844,3 +846,141 @@ describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => {
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM episodic_memories').get().c).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== v0.8.1: P0-2 生命周期 + P1-1 向量混合检索 =====
|
||||
|
||||
describe.skipIf(!dbAvailable)('MemoryManager — v0.8.1 生命周期与混合检索', () => {
|
||||
let db: any;
|
||||
let mgr: MemoryManager;
|
||||
|
||||
beforeAll(() => {
|
||||
if (!dbAvailable) return;
|
||||
db = new Database(':memory:');
|
||||
createMemorySchema(db);
|
||||
mgr = new MemoryManager(() => db);
|
||||
mgr.initialize();
|
||||
});
|
||||
afterAll(() => {
|
||||
if (db) db.close();
|
||||
});
|
||||
afterEach(() => {
|
||||
db.exec('DELETE FROM episodic_memories');
|
||||
db.exec('DELETE FROM semantic_memories');
|
||||
db.exec('DELETE FROM working_memories');
|
||||
mgr.setEmbedder(null);
|
||||
});
|
||||
|
||||
it('P0-2: store 接受 expiresAt 并写入 episodic_memories.expires_at', () => {
|
||||
const ttl = Date.now() + 1000;
|
||||
mgr.store({
|
||||
type: 'episodic',
|
||||
content: 'TTL 验证内容',
|
||||
source: 'tool_result',
|
||||
importance: 0.6,
|
||||
expiresAt: ttl,
|
||||
});
|
||||
const row = db
|
||||
.prepare('SELECT expires_at FROM episodic_memories WHERE content = ?')
|
||||
.get('TTL 验证内容') as {
|
||||
expires_at: number | null;
|
||||
};
|
||||
expect(row.expires_at).toBe(ttl);
|
||||
});
|
||||
|
||||
it('P0-2: semantic 检索命中后 access_count 递增', async () => {
|
||||
mgr.store({
|
||||
type: 'semantic',
|
||||
content: '用户偏好简洁回答',
|
||||
summary: 'pref-brief',
|
||||
source: 'agent_thought',
|
||||
importance: 0.9,
|
||||
});
|
||||
const before = (
|
||||
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
|
||||
access_count: number;
|
||||
}
|
||||
).access_count;
|
||||
await mgr.search('偏好简洁');
|
||||
const after = (
|
||||
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
|
||||
access_count: number;
|
||||
}
|
||||
).access_count;
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('P1-1: 注入 embedder 后混合检索命中同义改写(TF-IDF 单路召回不到的查询)', async () => {
|
||||
// 文档:"回复要短" — 查询"我喜欢简洁回答"(同义改写,无字面重叠)
|
||||
mgr.store({
|
||||
type: 'semantic',
|
||||
content: '回复要短',
|
||||
summary: 'style-rule',
|
||||
source: 'agent_thought',
|
||||
importance: 0.9,
|
||||
});
|
||||
// 词表不重叠 → TF-IDF 嵌入向量正交 → 纯 TF-IDF 0 分
|
||||
const tfidfOnly = await mgr.search('我喜欢简洁回答');
|
||||
expect(tfidfOnly).toHaveLength(0);
|
||||
|
||||
// 注入固定向量的 embedder:同义改写在向量空间中余弦 > 0
|
||||
const VECTORS: Record<string, number[]> = {
|
||||
'回复要短 style-rule': [1, 0.9, 0],
|
||||
我喜欢简洁回答: [0.95, 1, 0.1],
|
||||
无关内容xyz: [0, 0.1, 1],
|
||||
};
|
||||
mgr.setEmbedder({
|
||||
embed: async (text) => {
|
||||
for (const [k, v] of Object.entries(VECTORS)) {
|
||||
if (text.includes(k)) return v;
|
||||
}
|
||||
return [0, 0, 1];
|
||||
},
|
||||
});
|
||||
// 首次检索触发存量记忆的惰性向量回填(本轮仍走 TF-IDF → 0 命中)
|
||||
await mgr.search('我喜欢简洁回答');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// 回填完成后,向量路径生效 → 同义改写命中
|
||||
const hybrid = await mgr.search('我喜欢简洁回答');
|
||||
expect(hybrid.length).toBeGreaterThan(0);
|
||||
expect(hybrid[0].content).toBe('回复要短');
|
||||
expect(hybrid[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('P1-1: embedder 抛错/返回 null → 回退纯 TF-IDF(行为兼容)', async () => {
|
||||
mgr.store({
|
||||
type: 'semantic',
|
||||
content: 'TF-IDF 兜底验证',
|
||||
summary: 'fallback-vec',
|
||||
source: 'agent_thought',
|
||||
importance: 0.9,
|
||||
});
|
||||
mgr.setEmbedder({
|
||||
embed: async () => {
|
||||
throw new Error('embed down');
|
||||
},
|
||||
});
|
||||
const results = await mgr.search('TF-IDF 兜底验证');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
mgr.setEmbedder({ embed: async () => null });
|
||||
const results2 = await mgr.search('TF-IDF 兜底验证');
|
||||
expect(results2.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('P1-1: store 写入异步回填 embedding BLOB', async () => {
|
||||
mgr.setEmbedder({ embed: async () => [0.5, 0.5, 0.5] });
|
||||
mgr.store({
|
||||
type: 'semantic',
|
||||
content: '向量化回填验证',
|
||||
summary: 'vec-backfill',
|
||||
source: 'agent_thought',
|
||||
importance: 0.8,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const row = db
|
||||
.prepare('SELECT embedding FROM semantic_memories WHERE key = ?')
|
||||
.get('vec-backfill') as { embedding: Buffer | null };
|
||||
expect(row.embedding).not.toBeNull();
|
||||
expect(row.embedding!.length % 4).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { MetonaRequest } from '../types';
|
||||
import type { WorkspaceService } from '../../services/workspace.service';
|
||||
import type { IterationStep } from '../agent-loop/types';
|
||||
import type { MemoryManager } from './manager';
|
||||
// v0.8.1 P1-2: 分区条目摘要(与 Maintainer 共用,消除全文截断去重盲区)
|
||||
import { parseMemoryEntries, buildMemoryEntriesDigest } from './maintainer';
|
||||
|
||||
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
|
||||
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
|
||||
@@ -91,10 +93,7 @@ export class MemoryConsolidator {
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
this.runningPromise.catch(() => {}),
|
||||
timer,
|
||||
]);
|
||||
await Promise.race([this.runningPromise.catch(() => {}), timer]);
|
||||
return !timedOut;
|
||||
} finally {
|
||||
if (timerHandle) clearTimeout(timerHandle);
|
||||
@@ -142,14 +141,21 @@ export class MemoryConsolidator {
|
||||
): Promise<ConsolidationResult> {
|
||||
try {
|
||||
// 1. 构建对话摘要
|
||||
const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations);
|
||||
const conversationDigest = this.buildConversationDigest(
|
||||
userMessage,
|
||||
assistantAnswer,
|
||||
iterations,
|
||||
);
|
||||
if (!conversationDigest) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 2. 读取当前 MEMORY.md 内容(供 LLM 去重)
|
||||
// 2. 读取当前 MEMORY.md 条目摘要(供 LLM 去重)
|
||||
// v0.8.1 P1-2 根治: 旧实现全文截 3000 字符,尾部条目对 LLM 不可见 → 去重
|
||||
// 失效、重复写入。现用纯条目摘要(8000 字符预算),完整覆盖全部条目。
|
||||
const currentMemory = this.workspaceService.getFiles().memory;
|
||||
const memoryDigest = this.truncateMemoryForPrompt(currentMemory);
|
||||
const memoryDigest =
|
||||
buildMemoryEntriesDigest(parseMemoryEntries(currentMemory ?? '')) || '(empty)';
|
||||
|
||||
// 3. 调用 LLM 提取需要持久化的记忆
|
||||
const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest);
|
||||
@@ -205,7 +211,9 @@ export class MemoryConsolidator {
|
||||
}
|
||||
|
||||
if (validEntries.length > 0) {
|
||||
log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`);
|
||||
log.info(
|
||||
`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { appended: validEntries.length, entries: validEntries, skipped };
|
||||
@@ -238,8 +246,10 @@ export class MemoryConsolidator {
|
||||
const status = result?.success ? 'ok' : 'error';
|
||||
const resultPreview = result?.result
|
||||
? this.truncate(JSON.stringify(result.result), 200)
|
||||
: result?.error ?? '';
|
||||
toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`);
|
||||
: (result?.error ?? '');
|
||||
toolSummaries.push(
|
||||
` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (toolSummaries.length > 0) {
|
||||
@@ -252,16 +262,6 @@ export class MemoryConsolidator {
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断 MEMORY.md 内容用于 prompt(避免过长)
|
||||
*/
|
||||
private truncateMemoryForPrompt(memory: string): string {
|
||||
if (!memory) return '(empty)';
|
||||
// 截取前 3000 字符,保留分区结构概览
|
||||
if (memory.length <= 3000) return memory;
|
||||
return memory.slice(0, 3000) + '\n... (truncated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LLM 提取需要持久化的记忆
|
||||
*/
|
||||
@@ -280,7 +280,8 @@ export class MemoryConsolidator {
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.',
|
||||
roleDefinition:
|
||||
"You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent's long-term memory file (MEMORY.md) for future sessions.",
|
||||
outputConstraints: [
|
||||
'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:',
|
||||
'1. Long-term value: will be useful in future conversations (not transient task state)',
|
||||
@@ -294,13 +295,16 @@ export class MemoryConsolidator {
|
||||
'If nothing is worth persisting, output an empty array: []',
|
||||
'Output ONLY the JSON array, no markdown fences, no explanation.',
|
||||
].join('\n'),
|
||||
safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
|
||||
safetyGuidelines:
|
||||
'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
|
||||
},
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
params: {
|
||||
maxTokens: 1024,
|
||||
temperature: 0.0,
|
||||
@@ -341,7 +345,10 @@ export class MemoryConsolidator {
|
||||
|
||||
// 移除可能的 markdown 代码围栏
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
||||
cleaned = cleaned
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -349,9 +356,12 @@ export class MemoryConsolidator {
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed
|
||||
.filter((item): item is { section: string; entry: string } =>
|
||||
typeof item === 'object' && item !== null &&
|
||||
typeof item.section === 'string' && typeof item.entry === 'string',
|
||||
.filter(
|
||||
(item): item is { section: string; entry: string } =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof item.section === 'string' &&
|
||||
typeof item.entry === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
section: item.section.trim(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Memory Embedder — 本地向量记忆嵌入接口(v0.8.1 P1-1)
|
||||
*
|
||||
* 职责边界:本模块只定义记忆系统消费的嵌入契约,不绑定任何 Provider 实现。
|
||||
* main.ts 按用户配置装配:仅在 Provider 为 Ollama(本地推理,零成本、数据不出设备)
|
||||
* 且设置面板配置了 `memory.embeddingModel` 时注入真实实现;否则保持 null,
|
||||
* MemoryManager 自动回退纯 TF-IDF 检索(行为与历史版本完全兼容)。
|
||||
*
|
||||
* 根治背景:OllamaAdapter.embed() 自实现以来全项目零调用 —— 本地向量检索能力
|
||||
* 一直躺在代码里,TF-IDF bigram 对同义改写("我喜欢简洁回答" vs "回复要短")
|
||||
* 零召回。本契约激活该能力,检索升级为 混合评分(向量余弦 × TF-IDF)。
|
||||
*/
|
||||
|
||||
/** 嵌入失败/不可用的统一返回:null = 本次无法向量化(调用方回退 TF-IDF 路径) */
|
||||
export type MemoryEmbedFn = (text: string) => Promise<number[] | null>;
|
||||
|
||||
export interface MemoryEmbedder {
|
||||
embed: MemoryEmbedFn;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Memory Maintainer — MEMORY.md 维护闭环(v0.8.1 P1-2)
|
||||
*
|
||||
* 根治背景:MemoryConsolidator 纯 append-only —— ① 去重盲区:固化 prompt 只带
|
||||
* 全文前 3000 字符,超出部分对 LLM 不可见,重复写入无法避免;② 只增不减:
|
||||
* 过期/被推翻的条目无任何回收路径,MEMORY.md 随使用无限膨胀(>50KB 后固化
|
||||
* prompt 与 system 注入双双劣化)。
|
||||
*
|
||||
* 本模块实现两阶段维护闭环(分析与应用分离,应用前必须经用户确认):
|
||||
* 1. analyze():LLM 读取"分区条目摘要"(纯条目行,无全文截断盲区)→ 产出
|
||||
* 结构化建议 {deletes[], updates[]}(去重 / 合并 / 清理过期);
|
||||
* 2. apply():按用户勾选的动作改写 MEMORY.md(WorkspaceService.rewriteMemory,
|
||||
* 唯一合法写入口)并同步删除/更新 semantic_memories 对应行(双轨一致)。
|
||||
*
|
||||
* 安全边界:仅追加白名单分区、单次动作数上限、条目精确匹配(防 LLM 幻觉改写
|
||||
* 无关内容)、全部动作写入 audit_logs。
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import log from 'electron-log';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { IMetonaProviderAdapter } from '../types/metona-adapter';
|
||||
import type { MetonaRequest } from '../types';
|
||||
import type { WorkspaceService } from '../../services/workspace.service';
|
||||
|
||||
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE / Consolidator 对齐) */
|
||||
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
|
||||
|
||||
/** 动作数上限(防 LLM 过度建议) */
|
||||
const MAX_ACTIONS = 30;
|
||||
/** 单条目在 prompt 中的截断长度 */
|
||||
const ENTRY_PROMPT_CHARS = 160;
|
||||
/** 条目摘要总预算(字符)—— 纯条目行远小于全文,同预算下覆盖完整文件 */
|
||||
const DIGEST_BUDGET_CHARS = 8000;
|
||||
|
||||
/** 一条维护动作(用户确认的输入/输出单元) */
|
||||
export interface MemoryMaintenanceAction {
|
||||
/** 动作类型:delete = 删除整行;merge = 用 newEntry 替换该行(合并多条时产生多条 update 指向同一 newEntry) */
|
||||
action: 'delete' | 'update';
|
||||
section: string;
|
||||
/** MEMORY.md 中该条目的当前完整文本(不含 "- " 前缀;精确匹配锚点) */
|
||||
entry: string;
|
||||
/** action=update 时的替换文本(合并后的新条目) */
|
||||
newEntry?: string;
|
||||
/** LLM 给出的理由(UI 展示) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MemoryMaintenanceProposal {
|
||||
actions: MemoryMaintenanceAction[];
|
||||
/** 当前文件条目总数(UI 展示上下文) */
|
||||
totalEntries: number;
|
||||
/**
|
||||
* v0.8.1 review (O2): 各分区当前条目数 —— 供维护弹框计算"应用后变空的分区"
|
||||
* 并向用户提示(空分区保留分区头,条目区将显示为空)。
|
||||
*/
|
||||
sectionEntryCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 解析后的分区结构(模块级类型 —— parseEntries/apply 共用) */
|
||||
interface ParsedSection {
|
||||
section: string;
|
||||
entries: string[];
|
||||
}
|
||||
|
||||
/** 解析 MEMORY.md 的分区与条目(模块级工具 —— Maintainer 与 Consolidator 共用) */
|
||||
export function parseMemoryEntries(memory: string): ParsedSection[] {
|
||||
const sections: ParsedSection[] = [];
|
||||
let current: ParsedSection | null = null;
|
||||
let inHead = true;
|
||||
for (const line of memory.split('\n')) {
|
||||
if (inHead) {
|
||||
if (line.startsWith('## ')) inHead = false;
|
||||
else continue;
|
||||
}
|
||||
const m = line.match(/^## (.+)$/);
|
||||
if (m) {
|
||||
current = { section: m[1].trim(), entries: [] };
|
||||
sections.push(current);
|
||||
continue;
|
||||
}
|
||||
const em = line.match(/^- (.+)$/);
|
||||
if (em && current) {
|
||||
current.entries.push(em[1].trim());
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建"分区条目摘要"(纯条目行,消除全文截断盲区)。
|
||||
* Consolidator 固化去重与 Maintainer 分析共用:同预算(8000 字符)下纯条目
|
||||
* 形态可覆盖完整文件,而旧的全文截断(3000 字符)会让 LLM 看不到尾部条目、
|
||||
* 去重失效 → 重复写入。
|
||||
*/
|
||||
export function buildMemoryEntriesDigest(sections: ParsedSection[]): string {
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
for (const s of sections) {
|
||||
if (s.entries.length === 0) continue;
|
||||
const lines: string[] = [`## ${s.section}`];
|
||||
for (const e of s.entries) {
|
||||
const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e;
|
||||
lines.push(`- ${clipped}`);
|
||||
}
|
||||
const block = lines.join('\n');
|
||||
if (used + block.length > DIGEST_BUDGET_CHARS) break;
|
||||
parts.push(block);
|
||||
used += block.length;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export class MemoryMaintainer {
|
||||
constructor(
|
||||
private getAdapter: () => IMetonaProviderAdapter,
|
||||
private workspaceService: WorkspaceService,
|
||||
private getDB: () => Database.Database,
|
||||
) {}
|
||||
|
||||
/** 分析当前 MEMORY.md,产出维护建议(不改任何文件/DB) */
|
||||
async analyze(): Promise<MemoryMaintenanceProposal> {
|
||||
const memory = this.workspaceService.getFiles().memory ?? '';
|
||||
const sections = this.parseEntries(memory);
|
||||
const totalEntries = sections.reduce((n, s) => n + s.entries.length, 0);
|
||||
|
||||
const sectionEntryCounts: Record<string, number> = {};
|
||||
for (const s of sections) {
|
||||
sectionEntryCounts[s.section] = s.entries.length;
|
||||
}
|
||||
|
||||
if (totalEntries === 0) {
|
||||
return { actions: [], totalEntries: 0, sectionEntryCounts };
|
||||
}
|
||||
|
||||
const digest = this.buildDigest(sections);
|
||||
const raw = await this.callLLM(digest);
|
||||
const actions = this.parseActions(raw, sections);
|
||||
return { actions, totalEntries, sectionEntryCounts };
|
||||
}
|
||||
|
||||
/** 应用用户确认的动作(只处理精确命中当前文件内容的动作,防幻觉改写) */
|
||||
apply(actions: MemoryMaintenanceAction[]): { applied: number; skipped: number } {
|
||||
const memory = this.workspaceService.getFiles().memory ?? '';
|
||||
const sections = this.parseEntries(memory);
|
||||
|
||||
// 精确匹配校验:entry 必须原样存在于对应分区(LLM 响应与文件状态之间的一致性锚点)
|
||||
const valid: MemoryMaintenanceAction[] = [];
|
||||
for (const a of actions.slice(0, MAX_ACTIONS)) {
|
||||
const section = sections.find((s) => s.section === a.section);
|
||||
const exists = section?.entries.includes(a.entry) ?? false;
|
||||
if (!exists) continue;
|
||||
if (a.action === 'update' && (!a.newEntry || !a.newEntry.trim())) continue;
|
||||
valid.push(a);
|
||||
}
|
||||
|
||||
if (valid.length === 0) return { applied: 0, skipped: actions.length };
|
||||
|
||||
// 应用到内存结构:delete 直接删;update 替换文本
|
||||
for (const a of valid) {
|
||||
const section = sections.find((s) => s.section === a.section);
|
||||
if (!section) continue;
|
||||
if (a.action === 'delete') {
|
||||
section.entries = section.entries.filter((e) => e !== a.entry);
|
||||
} else {
|
||||
section.entries = section.entries.map((e) => (e === a.entry ? a.newEntry!.trim() : e));
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化回 Markdown(保留原文件头;分区结构重建)
|
||||
const head = this.extractHead(memory);
|
||||
const body = sections
|
||||
.map((s) => `## ${s.section}\n${s.entries.map((e) => `- ${e}`).join('\n')}`)
|
||||
.filter((s) => !s.endsWith('## ') && s.split('\n').length > 1)
|
||||
.join('\n\n');
|
||||
this.workspaceService.rewriteMemory(`${head}${body}\n`);
|
||||
|
||||
// 双轨一致:同步 semantic_memories(content 以 entry 写入 —— Consolidator 同口径)
|
||||
const db = this.getDB();
|
||||
const delStmt = db.prepare('DELETE FROM semantic_memories WHERE content = ?');
|
||||
const updStmt = db.prepare(
|
||||
'UPDATE semantic_memories SET content = ?, summary = ? WHERE content = ?',
|
||||
);
|
||||
let dbOps = 0;
|
||||
for (const a of valid) {
|
||||
try {
|
||||
if (a.action === 'delete') {
|
||||
dbOps += delStmt.run(a.entry).changes;
|
||||
} else {
|
||||
dbOps += updStmt.run(
|
||||
a.newEntry!.trim(),
|
||||
`[${a.section}] ${a.newEntry!.trim().slice(0, 60)}`,
|
||||
a.entry,
|
||||
).changes;
|
||||
}
|
||||
} catch (err) {
|
||||
// DB 同步失败不影响 MEMORY.md 已写入结果(与 Consolidator 同语义)
|
||||
log.warn('[MemoryMaintainer] semantic_memories sync failed:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`[MemoryMaintainer] applied ${valid.length} action(s) (db rows touched: ${dbOps}, skipped: ${actions.length - valid.length})`,
|
||||
);
|
||||
return { applied: valid.length, skipped: actions.length - valid.length };
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/** 解析 MEMORY.md 为 {section, entries[]} 结构(跳过元数据头;复用模块级工具) */
|
||||
private parseEntries(memory: string): ParsedSection[] {
|
||||
return parseMemoryEntries(memory);
|
||||
}
|
||||
|
||||
/** 提取文件头(H1 + > 元数据区),供重建时保留 */
|
||||
private extractHead(memory: string): string {
|
||||
const lines = memory.split('\n');
|
||||
let headEnd = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('## ')) {
|
||||
headEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const headLines = lines.slice(0, headEnd).join('\n').trimEnd();
|
||||
return headLines.length > 0 ? `${headLines}\n\n` : '';
|
||||
}
|
||||
|
||||
/** 构建"分区条目摘要"(纯条目行,消除全文截断盲区) */
|
||||
private buildDigest(sections: ParsedSection[]): string {
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
for (const s of sections) {
|
||||
if (s.entries.length === 0) continue;
|
||||
const lines: string[] = [`## ${s.section}`];
|
||||
for (const e of s.entries) {
|
||||
const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e;
|
||||
lines.push(`- ${clipped}`);
|
||||
}
|
||||
const block = lines.join('\n');
|
||||
if (used + block.length > DIGEST_BUDGET_CHARS) break;
|
||||
parts.push(block);
|
||||
used += block.length;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/** LLM 分析(结构化 JSON 输出,30s 超时与 Consolidator 同口径) */
|
||||
private async callLLM(digest: string): Promise<string | null> {
|
||||
const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', ');
|
||||
const request: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: 'memory-maintenance',
|
||||
iteration: 0,
|
||||
requestId: `mm_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition:
|
||||
"You are a memory curator maintaining the agent's long-term memory file (MEMORY.md).",
|
||||
outputConstraints: [
|
||||
'Analyze the memory entries below and propose maintenance actions:',
|
||||
'- "delete": remove stale, superseded, duplicated, or completed entries',
|
||||
'- "update": merge two or more duplicate/similar entries into ONE consolidated entry',
|
||||
'Keep valuable, still-valid information — do NOT delete aggressively.',
|
||||
`Every action must reference an existing entry EXACTLY as written (section must be one of ${sectionsList}).`,
|
||||
'',
|
||||
'Output ONLY a JSON array, no markdown fences:',
|
||||
'[{"action":"delete","section":"...","entry":"...","reason":"..."},',
|
||||
' {"action":"update","section":"...","entry":"old entry","newEntry":"merged entry","reason":"..."}]',
|
||||
'If nothing needs maintenance, output []',
|
||||
].join('\n'),
|
||||
safetyGuidelines: 'Never propose deleting user preference facts without a clear reason.',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `## Current MEMORY.md entries:\n\n${digest}\n\n## Task:\nPropose maintenance actions. Output JSON array only.`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('maintenance analysis timeout')), 30_000);
|
||||
});
|
||||
const response = await Promise.race([this.getAdapter().send(request), timeoutPromise]);
|
||||
return response.content.trim();
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('[MemoryMaintainer] LLM call failed:', (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 LLM 建议(丢弃非法 section / 空条目 / 超限动作) */
|
||||
private parseActions(raw: string | null, sections: ParsedSection[]): MemoryMaintenanceAction[] {
|
||||
if (!raw) return [];
|
||||
let cleaned = raw.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```$/, '')
|
||||
.trim();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch {
|
||||
log.warn('[MemoryMaintainer] failed to parse LLM response as JSON:', cleaned.slice(0, 200));
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const validSections = new Set<string>(ALLOWED_SECTIONS);
|
||||
// 仅允许引用当前文件中真实存在的条目(先过滤一轮,双保险在 apply 中再做精确校验)
|
||||
const existing = new Set<string>();
|
||||
for (const s of sections) {
|
||||
for (const e of s.entries) existing.add(e);
|
||||
}
|
||||
|
||||
const out: MemoryMaintenanceAction[] = [];
|
||||
for (const item of parsed.slice(0, MAX_ACTIONS)) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as Record<string, unknown>;
|
||||
const action = a.action;
|
||||
const section = typeof a.section === 'string' ? a.section.trim() : '';
|
||||
const entry = typeof a.entry === 'string' ? a.entry.trim() : '';
|
||||
if (action !== 'delete' && action !== 'update') continue;
|
||||
if (!validSections.has(section) || !entry || !existing.has(entry)) continue;
|
||||
if (action === 'update' && (typeof a.newEntry !== 'string' || !a.newEntry.trim())) continue;
|
||||
out.push({
|
||||
action,
|
||||
section,
|
||||
entry,
|
||||
newEntry: action === 'update' ? (a.newEntry as string).trim() : undefined,
|
||||
reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+430
-104
@@ -17,6 +17,7 @@ import { nanoid } from 'nanoid';
|
||||
import { createHash } from 'crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
import type { MemoryEmbedder } from './embedder';
|
||||
|
||||
export type MemoryType = 'episodic' | 'semantic' | 'working';
|
||||
export type MemorySource = 'user_input' | 'tool_result' | 'agent_thought' | 'imported';
|
||||
@@ -94,7 +95,11 @@ function computeTF(tokens: string[]): Map<string, number> {
|
||||
}
|
||||
|
||||
/** 计算余弦相似度的点积部分 */
|
||||
function dotProduct(tf1: Map<string, number>, tf2: Map<string, number>, idf: Map<string, number>): number {
|
||||
function dotProduct(
|
||||
tf1: Map<string, number>,
|
||||
tf2: Map<string, number>,
|
||||
idf: Map<string, number>,
|
||||
): number {
|
||||
let sum = 0;
|
||||
for (const [term, freq1] of tf1) {
|
||||
const freq2 = tf2.get(term);
|
||||
@@ -123,6 +128,37 @@ function timeDecayWeight(createdAt: number, now: number = Date.now()): number {
|
||||
return Math.pow(0.5, ageDays / halfLifeDays);
|
||||
}
|
||||
|
||||
// ===== 向量工具(v0.8.1 P1-1 本地向量混合检索) =====
|
||||
|
||||
/** Float32Array → SQLite BLOB(little-endian 原生布局,Node/SQLite 同机读写安全) */
|
||||
function float32ToBlob(vec: number[]): Buffer {
|
||||
const f32 = Float32Array.from(vec);
|
||||
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength);
|
||||
}
|
||||
|
||||
/** SQLite BLOB → number[](维度/字节损坏时返回 null,调用方回退 TF-IDF 路径) */
|
||||
function blobToFloat32(blob: unknown): number[] | null {
|
||||
if (!Buffer.isBuffer(blob)) return null;
|
||||
if (blob.length === 0 || blob.length % 4 !== 0) return null;
|
||||
const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.length / 4);
|
||||
return Array.from(f32);
|
||||
}
|
||||
|
||||
/** 余弦相似度(零向量/维度不匹配返回 0) */
|
||||
function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length === 0 || a.length !== b.length) return 0;
|
||||
let dot = 0;
|
||||
let na = 0;
|
||||
let nb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
if (na === 0 || nb === 0) return 0;
|
||||
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆管理器
|
||||
*/
|
||||
@@ -136,8 +172,20 @@ export class MemoryManager {
|
||||
/** 缓存有效期(5 分钟) */
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v0.8.1 P1-1: 本地向量嵌入器(可选注入)。
|
||||
* main.ts 仅在 Provider=Ollama 且用户配置了 memory.embeddingModel 时注入;
|
||||
* null = 向量检索禁用,search/store 全部走纯 TF-IDF 路径(历史行为)。
|
||||
*/
|
||||
private embedder: MemoryEmbedder | null = null;
|
||||
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
/** 注入向量嵌入器(传 null 关闭向量路径;热切换由 main.ts 在 adapter 重载时联动) */
|
||||
setEmbedder(embedder: MemoryEmbedder | null): void {
|
||||
this.embedder = embedder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化(表结构由 DatabaseService 创建)
|
||||
*/
|
||||
@@ -165,9 +213,15 @@ export class MemoryManager {
|
||||
const newIdfCache = new Map<string, number>();
|
||||
|
||||
// 获取所有记忆内容(episodic + semantic + working)
|
||||
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>;
|
||||
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>;
|
||||
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>;
|
||||
const episodicRows = db
|
||||
.prepare('SELECT content, summary FROM episodic_memories')
|
||||
.all() as Array<{ content: string; summary: string | null }>;
|
||||
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{
|
||||
value: string;
|
||||
}>;
|
||||
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
const allDocs = [
|
||||
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
|
||||
@@ -244,6 +298,10 @@ export class MemoryManager {
|
||||
source: MemorySource;
|
||||
sessionId?: string;
|
||||
expiresAt?: number;
|
||||
/** v0.8.1 P1-1: 文档向量(embedding 列缺失/损坏时为 null → 单路 TF-IDF 评分) */
|
||||
docVec?: number[] | null;
|
||||
/** v0.8.1 P1-1: 查询向量(null → 单路 TF-IDF 评分) */
|
||||
queryVec?: number[] | null;
|
||||
},
|
||||
queryTF: Map<string, number>,
|
||||
queryNorm: number,
|
||||
@@ -253,31 +311,59 @@ export class MemoryManager {
|
||||
const docTF = computeTF(params.docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) return;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
if (docNorm === 0 && !(params.docVec && params.queryVec)) return;
|
||||
|
||||
// 时间衰减
|
||||
const decayWeight = timeDecayWeight(params.createdAt, now);
|
||||
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
|
||||
const finalScore = cosineSim * decayWeight * (0.5 + params.importance * 0.5);
|
||||
const importanceWeight = 0.5 + params.importance * 0.5;
|
||||
|
||||
// TF-IDF 路(docNorm=0 时得 0 分,交由向量路兜底)
|
||||
const tfidfScore =
|
||||
docNorm > 0
|
||||
? (dotProduct(queryTF, docTF, this.idfCache) / (queryNorm * docNorm)) *
|
||||
decayWeight *
|
||||
importanceWeight
|
||||
: 0;
|
||||
// 向量路(双侧齐备时计算余弦)
|
||||
const vectorScore =
|
||||
params.docVec && params.queryVec
|
||||
? cosineSimilarity(params.queryVec, params.docVec) * decayWeight * importanceWeight
|
||||
: 0;
|
||||
|
||||
// v0.8.1 P1-1 混合评分:双侧齐备 0.6 向量 + 0.4 TF-IDF;否则取可用单路
|
||||
let finalScore: number;
|
||||
if (params.docVec && params.queryVec) {
|
||||
finalScore = 0.6 * vectorScore + 0.4 * tfidfScore;
|
||||
} else {
|
||||
finalScore = tfidfScore > 0 ? tfidfScore : vectorScore;
|
||||
}
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: params.id, type: params.type, content: params.content,
|
||||
summary: params.summary, source: params.source,
|
||||
importance: params.importance, sessionId: params.sessionId,
|
||||
createdAt: params.createdAt, expiresAt: params.expiresAt,
|
||||
id: params.id,
|
||||
type: params.type,
|
||||
content: params.content,
|
||||
summary: params.summary,
|
||||
source: params.source,
|
||||
importance: params.importance,
|
||||
sessionId: params.sessionId,
|
||||
createdAt: params.createdAt,
|
||||
expiresAt: params.expiresAt,
|
||||
score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TF-IDF 相似度搜索
|
||||
* TF-IDF 相似度搜索(v0.8.1 P1-1: 可选向量混合评分)
|
||||
*
|
||||
* @param queryVec 查询向量(embedder 未注入/失败时为 null → 纯 TF-IDF)
|
||||
*/
|
||||
private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] {
|
||||
private tfidfSearch(
|
||||
query: string,
|
||||
options: MemorySearchOptions,
|
||||
queryVec: number[] | null,
|
||||
): SearchResult[] {
|
||||
const db = this.getDB();
|
||||
this.updateIdfCache();
|
||||
|
||||
@@ -296,71 +382,142 @@ export class MemoryManager {
|
||||
|
||||
// 搜索 episodic 记忆
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM episodic_memories WHERE importance >= ?
|
||||
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||
`).all(minImportance, topK * 3) as Array<{
|
||||
id: string; session_id: string | null; content: string; summary: string | null;
|
||||
source: string; importance: number; created_at: number; expires_at: number | null;
|
||||
`,
|
||||
)
|
||||
.all(minImportance, topK * 3) as Array<{
|
||||
id: string;
|
||||
session_id: string | null;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
source: string;
|
||||
importance: number;
|
||||
created_at: number;
|
||||
expires_at: number | null;
|
||||
tf_cache: string | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
this.scoreAndPushMemory({
|
||||
docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')),
|
||||
createdAt: row.created_at,
|
||||
importance: row.importance,
|
||||
id: row.id, type: 'episodic', content: row.content,
|
||||
summary: row.summary ?? undefined,
|
||||
source: row.source as MemorySource,
|
||||
sessionId: row.session_id ?? undefined,
|
||||
expiresAt: row.expires_at ?? undefined,
|
||||
}, queryTF, queryNorm, now, results);
|
||||
this.scoreAndPushMemory(
|
||||
{
|
||||
docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')),
|
||||
createdAt: row.created_at,
|
||||
importance: row.importance,
|
||||
id: row.id,
|
||||
type: 'episodic',
|
||||
content: row.content,
|
||||
summary: row.summary ?? undefined,
|
||||
source: row.source as MemorySource,
|
||||
sessionId: row.session_id ?? undefined,
|
||||
expiresAt: row.expires_at ?? undefined,
|
||||
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
|
||||
queryVec,
|
||||
},
|
||||
queryTF,
|
||||
queryNorm,
|
||||
now,
|
||||
results,
|
||||
);
|
||||
}
|
||||
this.backfillMissingEmbeddings(
|
||||
'episodic',
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
embedding: (r as { embedding?: unknown }).embedding,
|
||||
text: r.content + ' ' + (r.summary ?? ''),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索 semantic 记忆
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM semantic_memories WHERE confidence >= ?
|
||||
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||
`).all(minImportance, Math.ceil(topK * 1.5)) as Array<{
|
||||
id: string; key: string; value: string; category: string | null;
|
||||
confidence: number; source_session: string | null; created_at: number;
|
||||
`,
|
||||
)
|
||||
.all(minImportance, Math.ceil(topK * 1.5)) as Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
category: string | null;
|
||||
confidence: number;
|
||||
source_session: string | null;
|
||||
created_at: number;
|
||||
tf_cache: string | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
this.scoreAndPushMemory({
|
||||
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
|
||||
createdAt: row.created_at,
|
||||
importance: row.confidence,
|
||||
id: row.id, type: 'semantic', content: row.value,
|
||||
source: 'imported',
|
||||
sessionId: row.source_session ?? undefined,
|
||||
}, queryTF, queryNorm, now, results);
|
||||
this.scoreAndPushMemory(
|
||||
{
|
||||
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
|
||||
createdAt: row.created_at,
|
||||
importance: row.confidence,
|
||||
id: row.id,
|
||||
type: 'semantic',
|
||||
content: row.value,
|
||||
source: 'imported',
|
||||
sessionId: row.source_session ?? undefined,
|
||||
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
|
||||
queryVec,
|
||||
},
|
||||
queryTF,
|
||||
queryNorm,
|
||||
now,
|
||||
results,
|
||||
);
|
||||
}
|
||||
this.backfillMissingEmbeddings(
|
||||
'semantic',
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
embedding: (r as { embedding?: unknown }).embedding,
|
||||
text: r.key + ' ' + r.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索 working 记忆
|
||||
if (!type || type === 'working') {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?
|
||||
`).all(topK * 3) as Array<{
|
||||
id: string; session_id: string; task_id: string;
|
||||
key: string; value: string; updated_at: number;
|
||||
`,
|
||||
)
|
||||
.all(topK * 3) as Array<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: number;
|
||||
tf_cache: string | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
this.scoreAndPushMemory({
|
||||
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
|
||||
createdAt: row.updated_at,
|
||||
importance: 0.5,
|
||||
id: row.id, type: 'working', content: row.value,
|
||||
source: 'agent_thought',
|
||||
sessionId: row.session_id,
|
||||
}, queryTF, queryNorm, now, results);
|
||||
this.scoreAndPushMemory(
|
||||
{
|
||||
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
|
||||
createdAt: row.updated_at,
|
||||
importance: 0.5,
|
||||
id: row.id,
|
||||
type: 'working',
|
||||
content: row.value,
|
||||
source: 'agent_thought',
|
||||
sessionId: row.session_id,
|
||||
},
|
||||
queryTF,
|
||||
queryNorm,
|
||||
now,
|
||||
results,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,11 +540,22 @@ export class MemoryManager {
|
||||
|
||||
switch (item.type) {
|
||||
case 'episodic':
|
||||
db.prepare(`
|
||||
INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, tf_cache)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, item.sessionId ?? null, item.content, item.summary ?? null, item.source, importance, now,
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, expires_at, tf_cache)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
item.sessionId ?? null,
|
||||
item.content,
|
||||
item.summary ?? null,
|
||||
item.source,
|
||||
importance,
|
||||
now,
|
||||
// v0.8.1 P0-2: expires_at 真实写入方 —— 调用方(MemoryTriggerHook 等)可携带
|
||||
// TTL;此前该列全链路无写入方,cleanupExpired 空转,情节记忆只增不减
|
||||
item.expiresAt ?? null,
|
||||
// P2-12: 写入时预计算分词缓存,加速后续检索
|
||||
JSON.stringify(tokenize(item.content + ' ' + (item.summary ?? ''))),
|
||||
);
|
||||
@@ -397,22 +565,38 @@ export class MemoryManager {
|
||||
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
|
||||
// v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE,
|
||||
// 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
|
||||
`).run(
|
||||
id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now,
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
item.summary ?? this.contentHash(item.content),
|
||||
item.content,
|
||||
'general',
|
||||
importance,
|
||||
item.sessionId ?? null,
|
||||
now,
|
||||
now,
|
||||
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
|
||||
);
|
||||
break;
|
||||
case 'working':
|
||||
// v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖
|
||||
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at, tf_cache)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now,
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
item.sessionId ?? 'default',
|
||||
'default',
|
||||
item.summary ?? this.contentHash(item.content),
|
||||
item.content,
|
||||
now,
|
||||
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
|
||||
);
|
||||
break;
|
||||
@@ -424,28 +608,117 @@ export class MemoryManager {
|
||||
// 使 IDF 缓存失效
|
||||
this.cacheUpdatedAt = 0;
|
||||
|
||||
// v0.8.1 P1-1: 异步向量化回填(fire-and-forget)—— 嵌入不可用时静默跳过,
|
||||
// 该记忆保留 NULL embedding,检索时自动回退 TF-IDF 路径
|
||||
this.enrichEmbedding(item.type, id, (item.summary ?? '') + ' ' + item.content);
|
||||
|
||||
log.debug(`Memory stored: ${id} (${item.type})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减)
|
||||
*
|
||||
* v0.2.0 变更:
|
||||
* - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索
|
||||
* - 支持中英文分词(英文按词,中文按 bigram)
|
||||
* - 时间衰减:30 天半衰期,老旧记忆权重降低
|
||||
* - IDF 缓存:5 分钟有效期,避免重复计算
|
||||
* v0.8.1 P1-1: 异步生成并回填 embedding BLOB。
|
||||
* 失败静默(降级 TF-IDF),不阻塞写入方(工具执行/记忆固化均不等待)。
|
||||
*/
|
||||
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
|
||||
private embeddingBackfillInFlight = new Set<string>();
|
||||
|
||||
private enrichEmbedding(type: MemoryType, id: string, text: string): void {
|
||||
const embedder = this.embedder;
|
||||
if (!embedder) return;
|
||||
const table =
|
||||
type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : null;
|
||||
if (!table) return; // working 记忆会话级生命周期短,不参与向量检索
|
||||
const key = `${table}:${id}`;
|
||||
if (this.embeddingBackfillInFlight.has(key)) return;
|
||||
this.embeddingBackfillInFlight.add(key);
|
||||
void embedder
|
||||
.embed(text.slice(0, 8000))
|
||||
.then((vec) => {
|
||||
if (!vec || vec.length === 0) return;
|
||||
this.getDB()
|
||||
.prepare(`UPDATE ${table} SET embedding = ? WHERE id = ?`)
|
||||
.run(float32ToBlob(vec), id);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.embeddingBackfillInFlight.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.1 P1-1: 存量记忆向量惰性回填 —— 嵌入功能开启前写入的记忆(embedding
|
||||
* IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量
|
||||
* 路径。无阻塞、无独立迁移任务,收敛速度随检索频次自然提升;嵌入器不可用
|
||||
* 时零开销(直接返回)。
|
||||
*/
|
||||
private backfillMissingEmbeddings(
|
||||
type: MemoryType,
|
||||
rows: Array<{ id: string; embedding?: unknown; text: string }>,
|
||||
): void {
|
||||
if (!this.embedder) return;
|
||||
for (const row of rows) {
|
||||
if (row.embedding != null) continue;
|
||||
this.enrichEmbedding(type, row.id, row.text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.1 P0-2: 检索命中后回写 semantic_memories.access_count(LRU 淘汰语义激活)。
|
||||
* 此前该列只在 ORDER BY 中被读取、从无更新方,LRU 淘汰是死语义。
|
||||
*/
|
||||
private bumpAccessCounts(results: SearchResult[]): void {
|
||||
const semanticIds = results.filter((r) => r.type === 'semantic').map((r) => r.id);
|
||||
if (semanticIds.length === 0) return;
|
||||
try {
|
||||
const placeholders = semanticIds.map(() => '?').join(', ');
|
||||
this.getDB()
|
||||
.prepare(
|
||||
`UPDATE semantic_memories SET access_count = access_count + 1 WHERE id IN (${placeholders})`,
|
||||
)
|
||||
.run(...semanticIds);
|
||||
} catch (err) {
|
||||
// 计数回写失败不影响检索结果
|
||||
log.debug('MemoryManager: access_count bump failed:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减;v0.8.1 P1-1: 本地向量混合检索)
|
||||
*
|
||||
* v0.8.1 变更:
|
||||
* - 方法改为 async(查询向量需经 MemoryEmbedder 异步生成;Ollama 本地嵌入)。
|
||||
* - 混合评分:查询向量与文档向量齐备时 score = 0.6×向量余弦 + 0.4×TF-IDF
|
||||
* (两者各自叠加时间衰减与重要度权重);任一缺失时回退单路评分 ——
|
||||
* 未注入 embedder(或嵌入失败)时行为与历史版本完全一致。
|
||||
* - 检索命中的 semantic 记忆回写 access_count(LRU 语义激活,P0-2)。
|
||||
*/
|
||||
async search(query: string, options: MemorySearchOptions = {}): Promise<SearchResult[]> {
|
||||
const db = this.getDB();
|
||||
const { topK = 5, type, minImportance = 0 } = options;
|
||||
// v0.3.0 修复:拦截空 query 和纯空格 query
|
||||
if (!query || !query.trim()) return [];
|
||||
|
||||
// v0.2.0: 优先使用 TF-IDF 语义搜索
|
||||
const tfidfResults = this.tfidfSearch(query, options);
|
||||
// v0.8.1: 查询向量生成一次(嵌入器缺失/失败 → null,全量回退 TF-IDF)
|
||||
let queryVec: number[] | null = null;
|
||||
if (this.embedder) {
|
||||
try {
|
||||
queryVec = await this.embedder.embed(query.slice(0, 8000));
|
||||
if (queryVec && queryVec.length === 0) queryVec = null;
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
'MemoryManager: query embedding failed, falling back to TF-IDF:',
|
||||
(err as Error).message,
|
||||
);
|
||||
queryVec = null;
|
||||
}
|
||||
}
|
||||
|
||||
// v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合)
|
||||
const tfidfResults = this.tfidfSearch(query, options, queryVec);
|
||||
if (tfidfResults.length > 0) {
|
||||
this.bumpAccessCounts(tfidfResults);
|
||||
return tfidfResults;
|
||||
}
|
||||
|
||||
@@ -458,20 +731,35 @@ export class MemoryManager {
|
||||
|
||||
// 搜索情节记忆
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM episodic_memories
|
||||
WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ?
|
||||
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||
`).all(pattern, pattern, minImportance, topK) as Array<{
|
||||
id: string; session_id: string | null; content: string; summary: string | null;
|
||||
source: string; importance: number; created_at: number; expires_at: number | null;
|
||||
`,
|
||||
)
|
||||
.all(pattern, pattern, minImportance, topK) as Array<{
|
||||
id: string;
|
||||
session_id: string | null;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
source: string;
|
||||
importance: number;
|
||||
created_at: number;
|
||||
expires_at: number | null;
|
||||
}>;
|
||||
for (const row of rows) {
|
||||
results.push({
|
||||
id: row.id, type: 'episodic', content: row.content,
|
||||
summary: row.summary ?? undefined, source: row.source as MemorySource,
|
||||
importance: row.importance, sessionId: row.session_id ?? undefined,
|
||||
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
|
||||
id: row.id,
|
||||
type: 'episodic',
|
||||
content: row.content,
|
||||
summary: row.summary ?? undefined,
|
||||
source: row.source as MemorySource,
|
||||
importance: row.importance,
|
||||
sessionId: row.session_id ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at ?? undefined,
|
||||
score: row.importance * timeDecayWeight(row.created_at),
|
||||
});
|
||||
}
|
||||
@@ -479,20 +767,33 @@ export class MemoryManager {
|
||||
|
||||
// 搜索语义记忆
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM semantic_memories
|
||||
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ?
|
||||
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
|
||||
id: string; key: string; value: string; category: string | null;
|
||||
confidence: number; source_session: string | null; created_at: number;
|
||||
`,
|
||||
)
|
||||
.all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
category: string | null;
|
||||
confidence: number;
|
||||
source_session: string | null;
|
||||
created_at: number;
|
||||
}>;
|
||||
for (const row of rows) {
|
||||
results.push({
|
||||
id: row.id, type: 'semantic', content: row.value,
|
||||
source: 'imported', importance: row.confidence,
|
||||
id: row.id,
|
||||
type: 'semantic',
|
||||
content: row.value,
|
||||
source: 'imported',
|
||||
importance: row.confidence,
|
||||
sessionId: row.source_session ?? undefined,
|
||||
createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at),
|
||||
createdAt: row.created_at,
|
||||
score: row.confidence * timeDecayWeight(row.created_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -500,25 +801,39 @@ export class MemoryManager {
|
||||
// 搜索工作记忆
|
||||
// v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致)
|
||||
if (!type || type === 'working') {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM working_memories
|
||||
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
|
||||
ORDER BY updated_at DESC LIMIT ?
|
||||
`).all(pattern, pattern, topK) as Array<{
|
||||
id: string; session_id: string; task_id: string;
|
||||
key: string; value: string; updated_at: number;
|
||||
`,
|
||||
)
|
||||
.all(pattern, pattern, topK) as Array<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: number;
|
||||
}>;
|
||||
for (const row of rows) {
|
||||
results.push({
|
||||
id: row.id, type: 'working', content: row.value,
|
||||
source: 'agent_thought', importance: 0.5,
|
||||
sessionId: row.session_id, createdAt: row.updated_at,
|
||||
id: row.id,
|
||||
type: 'working',
|
||||
content: row.value,
|
||||
source: 'agent_thought',
|
||||
importance: 0.5,
|
||||
sessionId: row.session_id,
|
||||
createdAt: row.updated_at,
|
||||
score: 0.3 * timeDecayWeight(row.updated_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, topK);
|
||||
const finalResults = results.sort((a, b) => b.score - a.score).slice(0, topK);
|
||||
this.bumpAccessCounts(finalResults);
|
||||
return finalResults;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -526,9 +841,13 @@ export class MemoryManager {
|
||||
*/
|
||||
getWorkingMemory(sessionId: string, taskId: string = 'default'): Map<string, string> {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT key, value FROM working_memories WHERE session_id = ? AND task_id = ?
|
||||
`).all(sessionId, taskId) as Array<{ key: string; value: string }>;
|
||||
`,
|
||||
)
|
||||
.all(sessionId, taskId) as Array<{ key: string; value: string }>;
|
||||
return new Map(rows.map((r) => [r.key, r.value]));
|
||||
}
|
||||
|
||||
@@ -537,10 +856,12 @@ export class MemoryManager {
|
||||
*/
|
||||
setWorkingMemory(sessionId: string, taskId: string, key: string, value: string): void {
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now());
|
||||
`,
|
||||
).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,7 +870,10 @@ export class MemoryManager {
|
||||
clearWorkingMemory(sessionId: string, taskId?: string): void {
|
||||
const db = this.getDB();
|
||||
if (taskId) {
|
||||
db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(sessionId, taskId);
|
||||
db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(
|
||||
sessionId,
|
||||
taskId,
|
||||
);
|
||||
} else {
|
||||
db.prepare('DELETE FROM working_memories WHERE session_id = ?').run(sessionId);
|
||||
}
|
||||
@@ -560,7 +884,9 @@ export class MemoryManager {
|
||||
*/
|
||||
cleanupExpired(): number {
|
||||
const db = this.getDB();
|
||||
const result = db.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?').run(Date.now());
|
||||
const result = db
|
||||
.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?')
|
||||
.run(Date.now());
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,14 +172,14 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
thinkingEnabled: this.defaultConfig?.thinkingEnabled ?? true,
|
||||
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
|
||||
contextLength: this.defaultConfig?.contextLength,
|
||||
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
|
||||
contextWindow: this.defaultConfig?.contextWindow,
|
||||
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection(REFLECTING 状态开关)
|
||||
enableReflection: this.defaultConfig?.enableReflection ?? false,
|
||||
// v0.7.4 P3-2 修正: SubAgent 继承主引擎的 temperature/maxTokens ——
|
||||
// 旧实现不读这两个键,新 SubAgent 恒用引擎 DEFAULT_CONFIG(0.0/63488),
|
||||
// 导致"热生效"对子任务不完整
|
||||
// 旧实现不读这两个键导致"热生效"对子任务不完整。
|
||||
// v0.8.1 硬性契约: 不携带任何写死兜底值 —— 与主引擎同源继承设置面板配置
|
||||
temperature: this.defaultConfig?.temperature ?? 0.0,
|
||||
maxTokens: this.defaultConfig?.maxTokens ?? 63488,
|
||||
maxTokens: this.defaultConfig?.maxTokens,
|
||||
},
|
||||
this.engines.createAdapter(),
|
||||
this.toolRegistry,
|
||||
|
||||
@@ -172,9 +172,101 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
||||
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
|
||||
];
|
||||
|
||||
/**
|
||||
* v0.8.1 P2-1: 用户自定义策略解析(设置面板 ToolsSettings 存储)
|
||||
*
|
||||
* 存储契约:配置键 `tools.{toolName}.policy`(JSON 字符串),字段:
|
||||
* - deniedPatterns / allowedPatterns: string[](正则源;加载时编译,非法正则跳过)
|
||||
* - maxFrequency: number(次/分钟)
|
||||
* - requireConfirmation: boolean
|
||||
* 解析失败整体返回 null(回退默认策略),单条非法正则仅跳过该条 —— 配置错误
|
||||
* 不放大执行面(fail-closed),也不让一条坏配置瘫痪整个策略引擎。
|
||||
*/
|
||||
export interface ParsedToolPolicy {
|
||||
deniedPatterns?: RegExp[];
|
||||
allowedPatterns?: RegExp[];
|
||||
maxFrequency?: number;
|
||||
requireConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export function parseToolPolicy(raw: unknown): ParsedToolPolicy | null {
|
||||
let obj: unknown = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
|
||||
const o = obj as Record<string, unknown>;
|
||||
const compileList = (value: unknown): RegExp[] | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const compiled: RegExp[] = [];
|
||||
for (const item of value.slice(0, 50)) {
|
||||
if (typeof item !== 'string' || item.length === 0 || item.length > 500) continue;
|
||||
try {
|
||||
compiled.push(new RegExp(item));
|
||||
} catch {
|
||||
/* 非法正则跳过 */
|
||||
}
|
||||
}
|
||||
return compiled;
|
||||
};
|
||||
const out: ParsedToolPolicy = {};
|
||||
const denied = compileList(o.deniedPatterns);
|
||||
if (denied) out.deniedPatterns = denied;
|
||||
const allowed = compileList(o.allowedPatterns);
|
||||
if (allowed) out.allowedPatterns = allowed;
|
||||
if (typeof o.maxFrequency === 'number' && Number.isFinite(o.maxFrequency) && o.maxFrequency > 0) {
|
||||
out.maxFrequency = Math.floor(o.maxFrequency);
|
||||
}
|
||||
if (typeof o.requireConfirmation === 'boolean') {
|
||||
out.requireConfirmation = o.requireConfirmation;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
export class PolicyEngine {
|
||||
private policies: Map<string, PermissionPolicy> = new Map();
|
||||
|
||||
/**
|
||||
* v0.8.1 P2-1: 用户自定义策略覆盖层(settings → 热加载)。
|
||||
* resolvePolicy 的最高优先级 —— 覆盖层与默认策略按字段合并
|
||||
* (未指定的安全字段如 deniedPatterns 保留默认值,与构造函数合并语义一致)。
|
||||
*/
|
||||
private policyOverrides: Map<string, PermissionPolicy> = new Map();
|
||||
|
||||
/** 设置/清除某工具的用户策略覆盖(null = 清除,回退默认策略) */
|
||||
setPolicyOverride(toolName: string, override: ParsedToolPolicy | null): void {
|
||||
if (!override) {
|
||||
this.policyOverrides.delete(toolName);
|
||||
return;
|
||||
}
|
||||
const base = this.policies.get(toolName);
|
||||
this.policyOverrides.set(toolName, {
|
||||
...(base ?? {
|
||||
toolName,
|
||||
requiredLevel: PermissionLevel.WRITE,
|
||||
}),
|
||||
toolName,
|
||||
...override,
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取某工具当前的覆盖策略(UI 回显用;无覆盖返回 null) */
|
||||
getPolicyOverride(toolName: string): ParsedToolPolicy | null {
|
||||
const o = this.policyOverrides.get(toolName);
|
||||
if (!o) return null;
|
||||
return {
|
||||
deniedPatterns: o.deniedPatterns?.map((r) => r.source),
|
||||
allowedPatterns: o.allowedPatterns?.map((r) => r.source),
|
||||
maxFrequency: o.maxFrequency,
|
||||
requireConfirmation: o.requireConfirmation,
|
||||
} as unknown as ParsedToolPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1: 工具调用频率追踪 — 频率 key -> 调用时间戳列表
|
||||
* key 格式: `${sessionId}:${toolName}`(会话隔离)
|
||||
@@ -217,6 +309,9 @@ export class PolicyEngine {
|
||||
* 供 checkAuthorization 与 requiresConfirmation 共用匹配逻辑,消除双份漂移。
|
||||
*/
|
||||
private resolvePolicy(toolName: string): PermissionPolicy | undefined {
|
||||
// v0.8.1 P2-1: 用户覆盖层最高优先级(settings 面板 → setPolicyOverride)
|
||||
const override = this.policyOverrides.get(toolName);
|
||||
if (override) return override;
|
||||
const exact = this.policies.get(toolName);
|
||||
if (exact) return exact;
|
||||
// C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { resolve, sep } from 'path';
|
||||
import { existsSync, realpathSync } from 'fs';
|
||||
import { lstatSync, realpathSync } from 'fs';
|
||||
|
||||
// v0.6.4 死代码清理:networkPolicy / resourceLimits 配置壳已删除。
|
||||
// 原字段被赋值后无任何方法消费(SandboxManager 没有进程沙箱执行器),
|
||||
@@ -43,7 +43,11 @@ export class SandboxManager {
|
||||
const resolved = resolve(requestedPath);
|
||||
|
||||
if (this.allowedPaths.size === 0) {
|
||||
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' };
|
||||
return {
|
||||
allowed: false,
|
||||
resolvedPath: resolved,
|
||||
reason: 'No allowed paths configured (fail-closed)',
|
||||
};
|
||||
}
|
||||
|
||||
// 先做字符串级白名单校验
|
||||
@@ -54,9 +58,40 @@ export class SandboxManager {
|
||||
return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' };
|
||||
}
|
||||
|
||||
// 解析符号链接(如果路径存在)
|
||||
if (existsSync(resolved)) {
|
||||
// 解析符号链接(v0.8.1 根治:lstat 判定 —— 旧实现用 existsSync 前置判定,
|
||||
// 而 existsSync 跟随链接目标:悬空 symlink(目标不存在)会跳过 realpath
|
||||
// 校验整体放行,构成白名单逃逸 —— 写操作可在白名单外创建目标文件)。
|
||||
// 现契约:lstat 判定条目存在性(不跟随目标);符号链接一律 realpath 解析,
|
||||
// 悬空链接(realpath ENOENT)fail-closed 拒绝;普通条目维持既有 realpath 复核。
|
||||
let st: ReturnType<typeof lstatSync> | null = null;
|
||||
try {
|
||||
st = lstatSync(resolved);
|
||||
} catch {
|
||||
st = null; // 条目不存在 → 允许(新建文件场景,行为不变)
|
||||
}
|
||||
if (st) {
|
||||
try {
|
||||
if (st.isSymbolicLink()) {
|
||||
// 悬空 symlink:realpathSync 抛 ENOENT → 显式拒绝(非偶然 catch)
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = realpathSync(resolved);
|
||||
} catch {
|
||||
return {
|
||||
allowed: false,
|
||||
resolvedPath: resolved,
|
||||
reason: 'Dangling symlink target outside workspace',
|
||||
};
|
||||
}
|
||||
const realAllowed = Array.from(this.allowedPaths).some(
|
||||
(allowed) => realPath === allowed || realPath.startsWith(allowed + sep),
|
||||
);
|
||||
if (!realAllowed) {
|
||||
return { allowed: false, resolvedPath: realPath, reason: 'Symlink escape detected' };
|
||||
}
|
||||
return { allowed: true, resolvedPath: realPath };
|
||||
}
|
||||
// 普通条目:realpath 复核父级链接逃逸(既有行为)
|
||||
const realPath = realpathSync(resolved);
|
||||
const realAllowed = Array.from(this.allowedPaths).some(
|
||||
(allowed) => realPath === allowed || realPath.startsWith(allowed + sep),
|
||||
|
||||
@@ -16,14 +16,24 @@ import type { MemoryManager } from '../../memory/manager';
|
||||
export class MemoryStoreTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'memory_store',
|
||||
description: 'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.',
|
||||
description:
|
||||
'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: { type: 'string', description: 'The memory content to store' },
|
||||
type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] },
|
||||
type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)',
|
||||
enum: ['episodic', 'semantic', 'working'],
|
||||
},
|
||||
importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' },
|
||||
source: { type: 'string', description: 'Source of the memory', enum: ['user_input', 'tool_result', 'agent_thought', 'imported'] },
|
||||
source: {
|
||||
type: 'string',
|
||||
description: 'Source of the memory',
|
||||
enum: ['user_input', 'tool_result', 'agent_thought', 'imported'],
|
||||
},
|
||||
},
|
||||
required: ['content', 'type'],
|
||||
},
|
||||
@@ -39,7 +49,9 @@ export class MemoryStoreTool implements IMetonaTool {
|
||||
const content = args.content as string;
|
||||
const type = args.type as 'episodic' | 'semantic' | 'working';
|
||||
const importance = (args.importance as number) ?? 0.5;
|
||||
const source = (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? 'agent_thought';
|
||||
const source =
|
||||
(args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ??
|
||||
'agent_thought';
|
||||
|
||||
// v0.3.0 修复: store() 是同步方法,移除多余的 await 避免误导维护者
|
||||
const id = this.memoryManager.store({
|
||||
@@ -59,14 +71,23 @@ export class MemoryStoreTool implements IMetonaTool {
|
||||
export class MemorySearchTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'memory_search',
|
||||
description: 'Search persistent memory for relevant information. Returns memories sorted by relevance.',
|
||||
description:
|
||||
'Search persistent memory for relevant information. Returns memories sorted by relevance.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query or keywords' },
|
||||
type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] },
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Filter by memory type',
|
||||
enum: ['episodic', 'semantic', 'working'],
|
||||
},
|
||||
topK: { type: 'number', description: 'Number of results (default 5)' },
|
||||
threshold: { type: 'number', description: 'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.' },
|
||||
threshold: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
@@ -84,8 +105,8 @@ export class MemorySearchTool implements IMetonaTool {
|
||||
const topK = (args.topK as number) ?? 5;
|
||||
const threshold = (args.threshold as number) ?? 0.7;
|
||||
|
||||
// v0.3.0 修复: search() 是同步方法,移除多余的 await 避免误导维护者
|
||||
const results = this.memoryManager.search(query, {
|
||||
// v0.8.1: search() 升级为 async(向量混合检索),查询向量异步生成
|
||||
const results = await this.memoryManager.search(query, {
|
||||
topK,
|
||||
type,
|
||||
minImportance: threshold,
|
||||
|
||||
Reference in New Issue
Block a user