From acefc5fe1599ea9f34ba0b656d58f7b4b99a5178 Mon Sep 17 00:00:00 2001 From: hy Date: Thu, 3 Sep 2026 09:55:14 +0800 Subject: [PATCH] feat: implement brain user snapshot handling; enhance context management and message projection for improved session continuity --- doc/ai-harness/human-harness.md | 61 +++++---- doc/database.md | 2 +- doc/test-plan/ai-test.md | 6 +- .../ai/human/memory/brainMessageProjection.ts | 18 +-- .../ai/human/memory/brainUserSnapshot.ts | 51 ++++++++ .../services/ai/human/memory/transcript.ts | 5 +- .../services/ai/human/node/brainNode/index.ts | 12 ++ .../human/node/contextCompactionNode/index.ts | 11 ++ src/main/services/ai/human/prompts/brain.ts | 49 ++----- src/main/services/ai/human/types.ts | 2 + src/shared/types.ts | 5 + .../ai/brain-prompt-cache-stability.test.ts | 121 +++++++++++++++--- .../unit/ai/brain-prompt-formatting.test.ts | 11 +- 13 files changed, 255 insertions(+), 99 deletions(-) create mode 100644 src/main/services/ai/human/memory/brainUserSnapshot.ts diff --git a/doc/ai-harness/human-harness.md b/doc/ai-harness/human-harness.md index 7209c17d..0e22c546 100644 --- a/doc/ai-harness/human-harness.md +++ b/doc/ai-harness/human-harness.md @@ -118,6 +118,7 @@ append-only 会话转录;模型上下文、UI、resume 均由此投影。Compa | `learning_started` / `learning_completed` | `{ skillId }` | UI 过程层 | | `skill_state_updated` | `{ learnedSkillIds: string[] }` | brain `` 技能(**取最新一条**) | | `plan_updated` | `{ plan: [{ title, status }] }` | brain `` 计划 + UI 计划条 | +| `brain_user_snapshot` | `{ text: string }` | 当前 build user **最后一次发给模型的原文**;resume 还原历史 build user(取该 `user` 之后、下一条 `user` 之前的**最后一条**) | | `compaction_checkpoint` | 见示例 | brain `` + UI 压缩层 | | `finalizing_started` | `{}` | 投影时**清空** plan | @@ -188,6 +189,10 @@ append-only 会话转录;模型上下文、UI、resume 均由此投影。Compa { "kind": "annotation", "eventType": "skill_state_updated", "payload": { "learnedSkillIds": ["docx-read", "docx-write"] } } ``` +```json +{ "kind": "annotation", "eventType": "brain_user_snapshot", "payload": { "text": "…" } } +``` + ```json { "kind": "annotation", @@ -364,15 +369,16 @@ flowchart TD … … … + … ← 仅当前 build user;System 不含时间 … ``` **稳态行为**(同 run、未触发 §5–§6): -- 首次 brain:`[System, User built]`,tail 为空。 -- brain → 工具 → 再 brain:tail 末尾 append;brain 前 **replace** 首条 build user 的 ``(及 plan),**`` 不变**。 -- 前缀 `[System, User built, …已发送 tail]` 字节序不变 → §4 缓存命中。 +- 首次 brain:`[System, User built]`,tail 为空;写入 `brain_user_snapshot`。 +- brain → 工具 → 再 brain:tail 末尾 append;brain 前 **replace** 当前 build user 的 ``(及 plan),**`` 不变**;若原文变化再写 `brain_user_snapshot`。 +- 前缀 `[System, …已发送历史 build user / tail]` 字节序不变 → §4 缓存命中。System **跨 run 稳定**(无 `current_time`)。 ### 3.3 稳态投影规则 @@ -397,16 +403,18 @@ flowchart TD | `` | 当前 run Composer | | `` | 当前 run Composer(§2.5) | | `` | 最新 `compaction_checkpoint.summary`(无则省略) | +| `` | 当前 run `runStartedAt`(**只写当前 build user**;历史冻结文本不再改) | | 已学习技能 | 最新 `skill_state_updated.learnedSkillIds` | | 计划进度 | 全 session log 顺序扫描:最后 `plan_updated`;`finalizing_started` 清空 | -annotation 扫描用**完整** log;tail 用 checkpoint **截断后**的行。 +annotation 扫描用**完整** log;tail 用 checkpoint **截断后**的行。 +历史 build user:**禁止**从 log `user` 重算;必须用该 user 之后最后一条 `brain_user_snapshot.text`。 **组装** - **同 run 首次 brain**:`[System] + [User built] + tail`(tail 可为空)。 -- **同 run 后续 brain**:replace 首条 build user 的 ``;tail append。 -- **resume 新 run**:`[System] + [User built₁] + …history tail… + [User built₂]`(见 §6);**不**改写 built₁ 与 history。 +- **同 run 后续 brain**:replace 当前 build user 的 ``;tail append。 +- **resume 新 run**:`[System] + [冻结 User built₁] + …history tail… + [新 User built₂]`(见 §6);**不**改写 built₁ 与 history。 **同 run 刷新**:固定首条 `currentUserMessageIndex`;compaction 整段替换见 §5。 @@ -487,7 +495,7 @@ log 投影 tail 时:`tool.images[]` 在对应 ToolMessage **之后** flush 为 ## 4. 前缀缓存 主模型 provider 的前缀缓存要求:**多次请求中,已发送的前缀字节序一致**。 -human harness 通过 **tail 只 append、resume 只 append 新 build user(不改写历史)** 满足这一点。 +human harness 通过 **tail 只 append、resume 只 append 新 build user(历史 build user 取 `brain_user_snapshot` 冻结原文)、System 不含易变时间** 满足这一点。 ### 4.1 什么算「前缀」 @@ -497,17 +505,18 @@ human harness 通过 **tail 只 append、resume 只 append 新 build user(不 [System] + [User built₁] + … + tail 已发送段 + (resume 时已有的 User built₂ 之前) ``` +- System 跨 run **字节稳定**(platform / paths / 技能目录;**不含** `current_time`)。 - tail 只许 **末尾 append** assistant/tool。 -- 同 run 内:仅 **replace 首条** build user 的 `` / plan;**`` 不动**。 -- resume:**append** 新 build user 在整段末尾;**前面的 user 与 tail 一字不改**。 +- 同 run 内:仅 **replace 当前** build user 的 `` / plan;`` 取本 run `runStartedAt`(同 run 内不变);**`` 不动**。 +- resume:**append** 新 build user 在整段末尾;历史 build user / tail **一字不改**(来自冻结 snapshot,禁止重算)。 ### 4.2 稳态下如何保持命中 | 操作 | 对前缀的影响 | |------|--------------| -| brain → hand → 再 brain | 已发送段不变;tail 末尾 append | -| `update_plan` | replace 首条 build user 的 plan 行;tail 不变 | -| **resume** | 历史段 **不变**;末尾 **append** 新 build user(§6) | +| brain → hand → 再 brain | 已发送段不变;tail 末尾 append;当前 build user 可变块 refresh 后写 snapshot | +| `update_plan` | replace 当前 build user 的 plan 行;tail 不变 | +| **resume** | 历史段 **不变**(冻结 snapshot);末尾 **append** 新 build user(§6) | ### 4.3 打破前缀缓存的操作 @@ -519,7 +528,7 @@ human harness 通过 **tail 只 append、resume 只 append 新 build user(不 | **learn_skill**(§7) | 当前 run 的 build user 的 `` 技能列表变化 | **resume 不属于打破**:历史保持原样,只在末尾追加新 build user。 -设计约束:除 §4.3 外,不得 rewrite 已发送的 tail 段;resume 不得替换首条 build user 的 ``。 +设计约束:除 §4.3 外,不得 rewrite 已发送的 tail 段;resume 不得替换历史 build user,也不得从 log `user` 重拼可变块。 --- @@ -587,17 +596,20 @@ log 行 append-only 保留;被覆盖段不再进入 tail。 ### 6.2 核心规则 1. **log**:append 新 `user` 行在转录末尾(含 Composer `context` 快照)。 -2. **brain**:在**已有** `[System, User built₁, …tail…]` **末尾 append** `User built₂`(新 run 的 input + Composer);**不替换** `User built₁`,**不改写**中间 tail。 -3. 新诉求只出现在 **末尾** 那条 build user 的 ``;仍走 `buildBrainUserMessage`,不是裸 HumanMessage。 -4. 符合 §4:历史前缀不变 → 缓存可命中;打破仍仅 compaction / learn_skill。 +2. **brain**:在**已有** `[System, 冻结 User built₁, …tail…]` **末尾 append** `User built₂`(新 run 的 input + Composer + 最新 active_state + 本 run `current_time`);**不替换** `User built₁`,**不改写**中间 tail。 +3. 历史 `User built₁` = 该 `user` 之后最后一条 `brain_user_snapshot.text`(**禁止** `FromLogEntry` 式重算)。 +4. 新诉求只出现在 **末尾** 那条 build user 的 ``;仍走 `buildBrainUserMessage`,不是裸 HumanMessage。 +5. 符合 §4:历史前缀不变 → 缓存可命中;打破仍仅 compaction / learn_skill。 +6. **tools**:`bindTools` 按全 session 最新 `learnedSkillIds` 重绑(与 `User built₂` 的 `` 技能对齐),不是冻住进程内 tool 对象。 ### 6.3 示例 **续跑前**(run-1 中断后,session 已有 log;brain 侧最后一轮发给模型的形态): ```text -[System] +[System] ← 无 current_time [User built] task_goal=帮我把上周周报模板复制一份,填好本周内容 + (含当时的 current_time / active_state;已写入 brain_user_snapshot) [Assistant] …(含未闭合 tool_calls 时由 repair 补 ToolMessage) [Tool] … [Tool] Error: The run was interrupted… @@ -606,19 +618,20 @@ log 行 append-only 保留;被覆盖段不再进入 tail。 **续跑后**(用户发送「继续把周报写完」;log 末尾多一条 `user`;brain **append** 新 build user): ```text -[System] -[User built] task_goal=帮我把上周周报模板复制一份… ← 与续跑前一致,不替换 +[System] ← 与续跑前一致 +[User built] … ← 冻结 snapshot 原文,不替换、不重算 [Assistant] … [Tool] … [Tool] Error: The run was interrupted… [User built] task_goal=继续把周报写完 ← append;reference_scope=当前 Composer - compacted_history=…(若有) active_state=… + current_time=本 run;compacted_history=…(若有);active_state=最新技能/计划 ``` log 侧(示意): ```text seq … user content=帮我把上周周报… +seq … brain_user_snapshot text=… ← 最后发送原文 seq … assistant / tool / … seq N user content=继续把周报写完 context=当前 Composer ← 新 append ``` @@ -626,9 +639,10 @@ seq N user content=继续把周报写完 context=当前 Composer ← ### 6.4 构建步骤 1. 从 log 投影已有 tail(checkpoint 截断 + repair)。 -2. 还原/保留续跑前的 `[System, User built₁, …tail]`(字节级不变)。 -3. append `User built₂` = build(新 `input`、当前 Composer、annotation 投影的 compacted/active_state)。 -4. `context_budget` 将本轮 `user` 写入 log(若尚未写入)→ brain。 +2. 还原续跑前的历史 build user:每条可见 `user` → 对应最后一条 `brain_user_snapshot`(缺则失败);System 重渲染但内容跨 run 稳定。 +3. append `User built₂` = build(新 `input`、当前 Composer、annotation 投影的 compacted/active_state、本 run `current_time`)。 +4. `context_budget` 将本轮 `user` 写入 log(若尚未写入)→ brain;brain 发送前写入/更新本轮 `brain_user_snapshot`。 +5. `buildAvailableTools(learnedSkillIds)`:从最新 `skill_state_updated` 恢复技能后绑定工具。 ### 6.5 边界 @@ -780,6 +794,7 @@ applyDeliverySideEffects | Log 投影 | `src/main/services/ai/human/memory/transcript.ts` | | 多模态 | `src/main/services/ai/human/memory/multimodal.ts` | | Build user | `src/main/services/ai/human/prompts/brain.ts` | +| Build user 冻结 | `src/main/services/ai/human/memory/brainUserSnapshot.ts` | | Compaction | `src/main/services/ai/human/node/contextCompactionNode/` | | Log 存储 | `src/main/services/storage/domains/ChatMessageLogStore.ts` | | Resume | `src/main/services/ai/ChatRunService.ts` | diff --git a/doc/database.md b/doc/database.md index 442a670d..70ec2ed7 100644 --- a/doc/database.md +++ b/doc/database.md @@ -35,7 +35,7 @@ SQLite 保存以下业务数据: 关键规则: - `chat_search_turn` 使用会话内 `seq` 保证搜索轮次稳定顺序。 -- `chat_session_message_log` 使用 `session_id + seq` 保证会话内消息转录的稳定顺序(`user`/`assistant`/`tool`/`annotation` 条目);`plan_updated` 和 `compaction_checkpoint` 是强类型 annotation。恢复投影读取记录自身的 `seq/run_id`,不会修改历史行。 +- `chat_session_message_log` 使用 `session_id + seq` 保证会话内消息转录的稳定顺序(`user`/`assistant`/`tool`/`annotation` 条目);`plan_updated`、`brain_user_snapshot` 和 `compaction_checkpoint` 是强类型 annotation。恢复投影读取记录自身的 `seq/run_id`,不会修改历史行;历史 build user 必须来自 `brain_user_snapshot` 冻结原文。 - `ai_run.partial_answer` 与 `partial_answer_role` 只保存当前流式 UI 草稿;它们不参与下一次模型上下文恢复。`failure_code` 保存 `process_interrupted`、`output_truncated`、`input_too_long` 等终止原因。 - 待审批状态以 SQLite 中的 run 记录为准。 - `document.active_generation_id` 是当前有效索引版本。 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 7e8faeb7..7e843025 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -791,10 +791,10 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm ##### `human-interruption-message-repair` - 测试类型:`backend / mock-llm` -- What:续接(跨会话 / 中断恢复)重建消息数组时,顺序为 `system + 历史 build user + interleaved assistant/tool + 当前 run buildBrainUserMessage user`;每条 log `user` 投影为一条 build user;末条 assistant 有悬空 tool_calls 时补 `interrupted` ToolMessage。 -- Why:模型上下文唯一真相源是会话消息记录,续接必须忠实回放历史并把新诉求追加到末尾;若上一条 assistant 声明了 tool_call 却没有对应结果,接口会直接报错,必须补一条意外中断反馈。 +- What:续接(跨会话 / 中断恢复)重建消息数组时,顺序为 `system + 冻结历史 build user(brain_user_snapshot)+ interleaved assistant/tool + 当前 run buildBrainUserMessage user`;每条可见 log `user` 对应一条冻结 build user;末条 assistant 有悬空 tool_calls 时补 `interrupted` ToolMessage。 +- Why:模型上下文唯一真相源是会话消息记录,续接必须忠实回放**上次发给模型的**历史并把新诉求追加到末尾;若上一条 assistant 声明了 tool_call 却没有对应结果,接口会直接报错,必须补一条意外中断反馈。 - How:mock-llm 下跑两个子场景:其一 run-1 正常完成(含 learn_skill 与最终交付),run-2 捕获首次 brain 的 `request.messages`;其二 run-1 的 brain 返回 learn_skill tool_call 后在 brain 调用返回时 abort 控制器,run-2 恢复并捕获消息数组。 -- Oracle:子场景一:消息数组首条为 system,历史含 run-1 的 build user、assistant/tool 与 run-2 末尾 build user(共 2 条 human);子场景二:run-1 为 `user_cancelled`,run-2 在悬空 learn_skill tool_call 后补相同 ID 的失败 ToolMessage,且含 run-1 与 run-2 两条 build user。 +- Oracle:子场景一:消息数组首条为 system(无 `current_time`),历史含 run-1 的冻结 build user、assistant/tool 与 run-2 末尾 build user(共 2 条 human,末条含本 run `current_time` / 最新 active_state);子场景二:run-1 为 `user_cancelled`,run-2 在悬空 learn_skill tool_call 后补相同 ID 的失败 ToolMessage,且含 run-1 与 run-2 两条 build user。 - Not:不验证 live provider 真实网络抖动;不恢复 docx 临时运行态。 ##### `session-skill-memory-multi-run` diff --git a/src/main/services/ai/human/memory/brainMessageProjection.ts b/src/main/services/ai/human/memory/brainMessageProjection.ts index 1f94bb5e..802b5e4a 100644 --- a/src/main/services/ai/human/memory/brainMessageProjection.ts +++ b/src/main/services/ai/human/memory/brainMessageProjection.ts @@ -6,13 +6,14 @@ import type { } from '../../../../../shared/types' import type { AiRunContext } from '../../types' import type { HumanState } from '../types' -import { buildBrainUserMessage, buildBrainUserMessageFromLogEntry } from '../prompts/brain' +import { buildBrainUserMessage } from '../prompts/brain' import { createImageReferenceMessage } from './multimodal' import { createCurrentBrainUserMessage, isCurrentBrainUserMessage } from '../node/utils/messageTail' import { repairInterruptedToolCalls } from './transcript' +import { resolveHistoricalBrainUserTextsByUserSeq } from './brainUserSnapshot' const toToolCall = ( call: ChatMessageLogToolCall @@ -58,6 +59,7 @@ export const projectBrainMessagesFromLog = (input: { ? ordered.filter((record) => record.seq > checkpoint.coveredThroughSeq) : ordered + const frozenBrainUserByUserSeq = resolveHistoricalBrainUserTextsByUserSeq(ordered) const messages: BaseMessage[] = [] let pendingImages: ChatMessageImageRef[] = [] const flushImages = (): void => { @@ -70,15 +72,13 @@ export const projectBrainMessagesFromLog = (input: { const entry = record.entry if (entry.kind === 'user') { flushImages() - messages.push( - new HumanMessage( - buildBrainUserMessageFromLogEntry({ - content: entry.content, - context: entry.context, - ctx: input.ctx - }) + const frozenText = frozenBrainUserByUserSeq.get(record.seq) + if (!frozenText) { + throw new Error( + `历史 build user 缺少 brain_user_snapshot(user seq=${record.seq})。resume 必须还原上次发给模型的原文,禁止重算。` ) - ) + } + messages.push(new HumanMessage(frozenText)) continue } if (entry.kind === 'assistant') { diff --git a/src/main/services/ai/human/memory/brainUserSnapshot.ts b/src/main/services/ai/human/memory/brainUserSnapshot.ts new file mode 100644 index 00000000..743d1959 --- /dev/null +++ b/src/main/services/ai/human/memory/brainUserSnapshot.ts @@ -0,0 +1,51 @@ +import type { ChatMessageLogRecord } from '../../../../../shared/types' +import type { AiRunContext } from '../../types' +import type { HumanRuntimeCache } from '../types' + +/** 每个 user 行之后、下一条 user 之前,最后一次发给模型的 build user 原文。 */ +export const resolveHistoricalBrainUserTextsByUserSeq = ( + records: ChatMessageLogRecord[] +): Map => { + const ordered = [...records].sort((a, b) => a.seq - b.seq) + const byUserSeq = new Map() + let currentUserSeq: number | null = null + for (const record of ordered) { + const entry = record.entry + if (entry.kind === 'user') { + currentUserSeq = record.seq + continue + } + if ( + entry.kind === 'annotation' && + entry.eventType === 'brain_user_snapshot' && + currentUserSeq !== null + ) { + const text = entry.payload.text + if (!text.trim()) { + throw new Error(`brain_user_snapshot(seq=${record.seq}) 的 text 不能为空。`) + } + byUserSeq.set(currentUserSeq, text) + } + } + return byUserSeq +} + +export const persistCurrentBrainUserSnapshot = async (input: { + ctx: AiRunContext + cache: HumanRuntimeCache + text: string +}): Promise => { + const text = input.text + if (!text.trim()) { + throw new Error('当前 build user 文本为空,无法写入 brain_user_snapshot。') + } + if (input.cache.lastPersistedBrainUserText === text) return + await input.ctx.appendMessageLog([ + { + kind: 'annotation', + eventType: 'brain_user_snapshot', + payload: { text } + } + ]) + input.cache.lastPersistedBrainUserText = text +} diff --git a/src/main/services/ai/human/memory/transcript.ts b/src/main/services/ai/human/memory/transcript.ts index 59bd96af..5937ff21 100644 --- a/src/main/services/ai/human/memory/transcript.ts +++ b/src/main/services/ai/human/memory/transcript.ts @@ -160,9 +160,8 @@ export const projectMessageLogRecords = ( const projectedRecords = checkpoint ? ordered.filter((record) => record.seq > checkpoint.coveredThroughSeq) : ordered - // 历史 user 只属于不可变日志和聊天 UI,不直接作为裸 HumanMessage - // 发给 Brain。每次新 run 的当前 user 统一由 buildBrainUserMessage 构造, - // 这样恢复时不会把旧诉求误当成一条新的用户输入。 + // 历史 user 只属于不可变日志和聊天 UI;brain 侧历史 build user 必须来自 + // brain_user_snapshot 冻结原文。每次新 run 的当前 user 统一由 buildBrainUserMessage 构造。 const messages = deriveMessageLogMessages({ entries: projectedRecords .map((record) => record.entry) diff --git a/src/main/services/ai/human/node/brainNode/index.ts b/src/main/services/ai/human/node/brainNode/index.ts index a912f6ff..dd0b2b04 100644 --- a/src/main/services/ai/human/node/brainNode/index.ts +++ b/src/main/services/ai/human/node/brainNode/index.ts @@ -33,6 +33,7 @@ import { finalizeDelivery } from './finalizeDelivery' import { createCurrentBrainUserMessage } from '../utils/messageTail' +import { persistCurrentBrainUserSnapshot } from '../../memory/brainUserSnapshot' import { LlmOutputTruncatedError } from '../../../errors' const getErrorMessage = (error: unknown): string => @@ -86,6 +87,17 @@ export const createBrainNode = (input: { } } + const currentUserIndex = input.cache.currentUserMessageIndex + const currentUserText = + currentUserIndex !== undefined && currentUserIndex >= 0 && currentUserIndex < messages.length + ? String(messages[currentUserIndex]?.content || '') + : '' + await persistCurrentBrainUserSnapshot({ + ctx: input.ctx, + cache: input.cache, + text: currentUserText + }) + const tools = buildAvailableTools({ cache: input.cache, ctx: input.ctx, diff --git a/src/main/services/ai/human/node/contextCompactionNode/index.ts b/src/main/services/ai/human/node/contextCompactionNode/index.ts index 42904cb4..d2b3e07a 100644 --- a/src/main/services/ai/human/node/contextCompactionNode/index.ts +++ b/src/main/services/ai/human/node/contextCompactionNode/index.ts @@ -8,6 +8,7 @@ import type { HumanRuntimeCache, HumanState } from '../../types' import { estimateTextTokens } from '../../utils/tokenEstimate' import { emitCompactionCompleted, emitCompactionStarted } from '../../../events/processEvents' import { listHistoricalBuildUserMessages } from '../../memory/brainMessageProjection' +import { persistCurrentBrainUserSnapshot } from '../../memory/brainUserSnapshot' import { createCurrentBrainUserMessage, renderMessagesForCompaction, @@ -154,6 +155,16 @@ export const createContextCompactionNode = (input: { input.cache.currentUserMessageIndex = rebuilt.findIndex( (message) => message.additional_kwargs?.ncurator_current_user === true ) + const compactedUserIndex = input.cache.currentUserMessageIndex + const compactedUserText = + compactedUserIndex !== undefined && compactedUserIndex >= 0 + ? String(rebuilt[compactedUserIndex]?.content || '') + : '' + await persistCurrentBrainUserSnapshot({ + ctx: input.ctx, + cache: input.cache, + text: compactedUserText + }) return { ...state, diff --git a/src/main/services/ai/human/prompts/brain.ts b/src/main/services/ai/human/prompts/brain.ts index f2b6c4c7..d1fe006f 100644 --- a/src/main/services/ai/human/prompts/brain.ts +++ b/src/main/services/ai/human/prompts/brain.ts @@ -1,7 +1,5 @@ -import type { ChatMessageLogUserContext } from '../../../../../shared/types' import type { AiRunContext } from '../../types' import { renderReferenceScopeText } from './referenceScope' -import { buildReferenceScope } from './referenceScope' import { renderPlanItemStatus } from '../shared/memoryRenderers' import { getWorkspaceAssetsRootDir, @@ -14,6 +12,14 @@ import { listBuiltinSkillDefinitions, listExternalSkillDefinitions } from '../sk export const renderLocalEnvironmentText = (ctx: AiRunContext): string => { const workspace = ctx.workspace + return [ + `- platform:${ctx.platform}`, + `- runtime_paths.scripts:${workspace ? getWorkspaceScriptsRootDir(workspace.rootPath) : 'none'}(用户工作区内的一次性临时脚本目录)`, + `- runtime_paths.assets:${workspace ? getWorkspaceAssetsRootDir(workspace.rootPath) : 'none'}(运行期生成的临时产物目录,如图片、渲染中间结果等)` + ].join('\n') +} + +export const renderCurrentTimeText = (ctx: AiRunContext): string => { const startedAt = new Date(ctx.runStartedAt) const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown' const offsetMinutes = -startedAt.getTimezoneOffset() @@ -25,13 +31,7 @@ export const renderLocalEnvironmentText = (ctx: AiRunContext): string => { .replace(/\.\d{3}/, '') .replace('Z', `${offsetSign}${offsetHours}:${offsetRemainderMinutes}`) - return [ - `- platform:${ctx.platform}`, - `- runtime_paths.scripts:${workspace ? getWorkspaceScriptsRootDir(workspace.rootPath) : 'none'}(用户工作区内的一次性临时脚本目录)`, - `- runtime_paths.assets:${workspace ? getWorkspaceAssetsRootDir(workspace.rootPath) : 'none'}(运行期生成的临时产物目录,如图片、渲染中间结果等)`, - `- current_time.timezone:${timezone}`, - `- current_time.local_iso:${localIso}` - ].join('\n') + return [`- current_time.timezone:${timezone}`, `- current_time.local_iso:${localIso}`].join('\n') } const renderPreferenceText = (preference: AiRunContext['preference']): string => { @@ -150,37 +150,12 @@ ${renderPreferenceText(input.ctx.preference)} ${renderReferenceScopeText(input.ctx.referenceScope)} ${compactedHistory} + +${renderCurrentTimeText(input.ctx)} + ${renderActiveState()} `.trim() } - -export const buildBrainUserMessageFromLogEntry = (input: { - content: string - context: ChatMessageLogUserContext - ctx: AiRunContext -}): string => { - const referenceScope = buildReferenceScope({ - workspace: input.context.workspace, - selectedReferences: input.context.selectedReferences, - attachments: input.context.attachments - }) - - return ` - - -${input.content} - - - -${renderPreferenceText(input.ctx.preference)} - - - -${renderReferenceScopeText(referenceScope)} - - -`.trim() -} diff --git a/src/main/services/ai/human/types.ts b/src/main/services/ai/human/types.ts index 64b2fce9..f671de23 100644 --- a/src/main/services/ai/human/types.ts +++ b/src/main/services/ai/human/types.ts @@ -96,6 +96,8 @@ export type HumanRuntimeCache = { currentUserLogAppended?: boolean messageLogRecords?: import('../../../../shared/types').ChatMessageLogRecord[] currentState?: HumanState + /** 本 run 已落盘的最后一次当前 build user 原文;相同则跳过重复 snapshot。 */ + lastPersistedBrainUserText?: string lastBrainInputTokens?: number lastBrainMessageTokenEstimate?: number postCompactionNeedsFreshBrainUsage?: boolean diff --git a/src/shared/types.ts b/src/shared/types.ts index 434a57dd..9ee63ea4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1765,6 +1765,11 @@ export type ChatMessageLogAnnotationEntry = sourceMessageCount: number } } + | { + kind: 'annotation' + eventType: 'brain_user_snapshot' + payload: { text: string } + } export type ChatMessageLogEntry = | { diff --git a/tests/case/unit/ai/brain-prompt-cache-stability.test.ts b/tests/case/unit/ai/brain-prompt-cache-stability.test.ts index 4d7e85f6..25e44f9d 100644 --- a/tests/case/unit/ai/brain-prompt-cache-stability.test.ts +++ b/tests/case/unit/ai/brain-prompt-cache-stability.test.ts @@ -10,12 +10,12 @@ import { createInitialHumanState } from '../../../../src/main/services/ai/human/ import type { AiRunContext } from '../../../../src/main/services/ai/types' import type { ChatMessageLogRecord } from '../../../../src/shared/types' -const createFakeCtx = (): AiRunContext => +const createFakeCtx = (runStartedAt = 0): AiRunContext => ({ platform: 'macos', runId: 'run-cache-stability', sessionId: 'session-cache-stability', - runStartedAt: 0, + runStartedAt, preference: 'default', workspace: { workspaceId: 'ws-1', @@ -57,11 +57,33 @@ const prefixBeforeCurrentUser = (messages: BaseMessage[]): string => { return messageSignature(messages.slice(0, currentIndex)) } +const FROZEN_HISTORICAL_USER = ` + +请整理过拟合概念。 + + + +无 + + + +无 + + +- current_time.timezone:UTC +- current_time.local_iso:1970-01-01T00:00:00+00:00 + + +已学习技能:docx-read + +` + const buildResumeBrainMessages = (input: { records: ChatMessageLogRecord[] state: ReturnType + runStartedAt?: number }): BaseMessage[] => { - const ctx = createFakeCtx() + const ctx = createFakeCtx(input.runStartedAt) const brainProjection = projectBrainMessagesFromLog({ records: input.records, ctx, @@ -93,11 +115,23 @@ const resumeRecords: ChatMessageLogRecord[] = [ } }, { - id: 'log-2', + id: 'log-1-snapshot', sessionId: 'session-cache-stability', runId: 'run-1', seq: 2, createdAt: 2, + entry: { + kind: 'annotation', + eventType: 'brain_user_snapshot', + payload: { text: FROZEN_HISTORICAL_USER } + } + }, + { + id: 'log-2', + sessionId: 'session-cache-stability', + runId: 'run-1', + seq: 3, + createdAt: 3, entry: { kind: 'assistant', content: '先检索资料', @@ -108,11 +142,13 @@ const resumeRecords: ChatMessageLogRecord[] = [ id: 'log-3', sessionId: 'session-cache-stability', runId: 'run-1', - seq: 3, - createdAt: 3, + seq: 4, + createdAt: 4, entry: { kind: 'tool', toolCallId: 'call-1', + toolName: 'grep_search', + ok: true, content: '命中 2 条结果' } }, @@ -120,8 +156,8 @@ const resumeRecords: ChatMessageLogRecord[] = [ id: 'log-4', sessionId: 'session-cache-stability', runId: 'run-1', - seq: 4, - createdAt: 4, + seq: 5, + createdAt: 5, entry: { kind: 'assistant', content: '已整理过拟合:概念、原因、处理方式。', @@ -144,21 +180,25 @@ describe('ai unit brain prompt cache stability', () => { expect(secondUser).toBe(firstUser) }) - it('keeps the system prompt static across tasks', () => { + it('keeps the system prompt static across tasks and run start times', () => { const stateA = createInitialHumanState({ taskId: 'task-a', userInput: '任务 A' }) const stateB = createInitialHumanState({ taskId: 'task-b', userInput: '任务 B' }) - const ctx = createFakeCtx() + const ctxA = createFakeCtx(1_700_000_000_000) + const ctxB = createFakeCtx(1_700_000_100_000) - const aSystem = buildBrainSystemPrompt({ ctx }) - const bSystem = buildBrainSystemPrompt({ ctx }) + const aSystem = buildBrainSystemPrompt({ ctx: ctxA }) + const bSystem = buildBrainSystemPrompt({ ctx: ctxB }) expect(aSystem).toBe(bSystem) + expect(aSystem).not.toContain('current_time.local_iso') - const aUser = buildBrainUserMessage({ state: stateA, ctx }) - const bUser = buildBrainUserMessage({ state: stateB, ctx }) + const aUser = buildBrainUserMessage({ state: stateA, ctx: ctxA }) + const bUser = buildBrainUserMessage({ state: stateB, ctx: ctxB }) expect(aUser).not.toBe(bUser) expect(aUser).toContain('任务 A') expect(bUser).toContain('任务 B') + expect(aUser).toContain('') + expect(bUser).toContain('') }) it('keeps system static across tasks and embeds platform and runtime paths', () => { @@ -169,9 +209,10 @@ describe('ai unit brain prompt cache stability', () => { expect(system).toContain('platform:macos') expect(system).toContain('runtime_paths.assets:') expect(system).toContain('runtime_paths.scripts:') + expect(system).not.toContain('current_time') }) - it('rebuilds resume messages with historical and current build users', () => { + it('rebuilds resume messages from frozen historical build user and current build user', () => { const state = createInitialHumanState({ taskId: 'run-2', userInput: '保留刚才的过拟合信息,再补充反走样。' @@ -182,12 +223,13 @@ describe('ai unit brain prompt cache stability', () => { expect(messages[0]?.getType()).toBe('system') expect(humanMessages).toHaveLength(2) - expect(String(humanMessages[0]?.content)).toContain('请整理过拟合概念。') + expect(String(humanMessages[0]?.content)).toBe(FROZEN_HISTORICAL_USER) + expect(String(humanMessages[0]?.content)).toContain('已学习技能:docx-read') expect(String(humanMessages[1]?.content)).toContain('保留刚才的过拟合信息,再补充反走样。') expect(isCurrentBrainUserMessage(humanMessages[1] as BaseMessage)).toBe(true) }) - it('keeps resume prefix stable while only the current build user changes', () => { + it('keeps resume prefix stable across run clocks while only the current build user changes', () => { const baseState = createInitialHumanState({ taskId: 'run-2', userInput: '保留刚才的过拟合信息,再补充反走样。' @@ -200,12 +242,51 @@ describe('ai unit brain prompt cache stability', () => { } } - const firstPass = buildResumeBrainMessages({ records: resumeRecords, state: baseState }) - const secondPass = buildResumeBrainMessages({ records: resumeRecords, state: baseState }) - const withPlan = buildResumeBrainMessages({ records: resumeRecords, state: stateWithPlan }) + const firstPass = buildResumeBrainMessages({ + records: resumeRecords, + state: baseState, + runStartedAt: 1_700_000_000_000 + }) + const secondPass = buildResumeBrainMessages({ + records: resumeRecords, + state: baseState, + runStartedAt: 1_700_000_000_000 + }) + const laterClock = buildResumeBrainMessages({ + records: resumeRecords, + state: baseState, + runStartedAt: 1_700_000_180_000 + }) + const withPlan = buildResumeBrainMessages({ + records: resumeRecords, + state: stateWithPlan, + runStartedAt: 1_700_000_000_000 + }) expect(messageSignature(secondPass)).toBe(messageSignature(firstPass)) + expect(prefixBeforeCurrentUser(laterClock)).toBe(prefixBeforeCurrentUser(firstPass)) expect(prefixBeforeCurrentUser(withPlan)).toBe(prefixBeforeCurrentUser(firstPass)) expect(messageSignature(withPlan)).not.toBe(messageSignature(firstPass)) + expect(String(laterClock.find((message) => isCurrentBrainUserMessage(message))?.content)).not.toBe( + String(firstPass.find((message) => isCurrentBrainUserMessage(message))?.content) + ) + }) + + it('rejects historical users without a frozen brain_user_snapshot', () => { + const state = createInitialHumanState({ + taskId: 'run-2', + userInput: '继续' + }) + const recordsWithoutSnapshot = resumeRecords.filter( + (record) => + !( + record.entry.kind === 'annotation' && + record.entry.eventType === 'brain_user_snapshot' + ) + ) + + expect(() => + buildResumeBrainMessages({ records: recordsWithoutSnapshot, state }) + ).toThrow(/brain_user_snapshot/) }) }) diff --git a/tests/case/unit/ai/brain-prompt-formatting.test.ts b/tests/case/unit/ai/brain-prompt-formatting.test.ts index eebc564a..6e63836c 100644 --- a/tests/case/unit/ai/brain-prompt-formatting.test.ts +++ b/tests/case/unit/ai/brain-prompt-formatting.test.ts @@ -44,7 +44,7 @@ describe('ai unit brain prompt formatting', () => { expect(skillCatalog).not.toMatch(/^- [^\n]+\n\n- /m) }) - it('renders the local environment inside the system prompt', () => { + it('renders the local environment inside the system prompt without current time', () => { const system = buildBrainSystemPrompt({ ctx: createFakeCtx() }) const environment = system.match(/([\s\S]*?)<\/local_environment>/)?.[1] || '' @@ -52,6 +52,8 @@ describe('ai unit brain prompt formatting', () => { expect(environment).toContain('platform:windows') expect(environment).toContain('runtime_paths.scripts:') expect(environment).toContain('runtime_paths.assets:') + expect(environment).not.toContain('current_time') + expect(system).not.toContain('current_time.local_iso') }) it('keeps context efficiency and planning rules concise', () => { @@ -66,7 +68,7 @@ describe('ai unit brain prompt formatting', () => { expect(system).not.toContain('') }) - it('renders the default preference and reference scope in the user message', () => { + it('renders the default preference, reference scope and current time in the user message', () => { const state = createInitialHumanState({ taskId: 'task-prompt-formatting', userInput: '继续处理任务' @@ -77,6 +79,8 @@ describe('ai unit brain prompt formatting', () => { expect(user).toContain('继续处理任务') expect(user.match(/([\s\S]*?)<\/preference>/)?.[1].trim()).toBe('无') expect(user).toContain('') + expect(user).toContain('') + expect(user).toContain('current_time.local_iso') }) it('renders plan and learned skills inside current_state when injectPlan is set', () => { @@ -124,7 +128,8 @@ describe('ai unit brain prompt formatting', () => { }) expect(user).toContain('\n任务锚点:继续处理已确认事实\n ') expect(user.indexOf('')).toBeLessThan(user.indexOf('')) - expect(user.indexOf('')).toBeLessThan(user.indexOf('')) + expect(user.indexOf('')).toBeLessThan(user.indexOf('')) + expect(user.indexOf('')).toBeLessThan(user.indexOf('')) expect(user.match(/[\s\S]*/)).toBeNull() }) })