feat: implement brain user snapshot handling; enhance context management and message projection for improved session continuity

This commit is contained in:
hy committed 2026-09-03 09:55:14 +08:00
1 parent d080018106
commit acefc5fe15
13 files changed
+255 -99

No files matched your search

+38 -23
View File
@@ -118,6 +118,7 @@ append-only 会话转录;模型上下文、UI、resume 均由此投影。Compa
| `learning_started` / `learning_completed` | `{ skillId }` | UI 过程层 |
| `skill_state_updated` | `{ learnedSkillIds: string[] }` | brain `<active_state>` 技能(**取最新一条**) |
| `plan_updated` | `{ plan: [{ title, status }] }` | brain `<active_state>` 计划 + UI 计划条 |
| `brain_user_snapshot` | `{ text: string }` | 当前 build user **最后一次发给模型的原文**;resume 还原历史 build user(取该 `user` 之后、下一条 `user` 之前的**最后一条**) |
| `compaction_checkpoint` | 见示例 | brain `<compacted_history>` + 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": "<user_context>…</user_context>" } }
```
```json
{
"kind": "annotation",
@@ -364,15 +369,16 @@ flowchart TD
<preference>…</preference>
<reference_scope>…</reference_scope>
<compacted_history>…</compacted_history>
<current_time>…</current_time> ← 仅当前 build user;System 不含时间
<active_state>…</active_state>
</user_context>
```
**稳态行为**(同 run、未触发 §5–§6):
- 首次 brain:`[System, User built]`,tail 为空。
- brain → 工具 → 再 brain:tail 末尾 append;brain 前 **replace** 首条 build user 的 `<active_state>`(及 plan),**`<task_goal>` 不变**。
- 前缀 `[System, User built, …已发送 tail]` 字节序不变 → §4 缓存命中。
- 首次 brain:`[System, User built]`,tail 为空;写入 `brain_user_snapshot`。
- brain → 工具 → 再 brain:tail 末尾 append;brain 前 **replace** 当前 build user 的 `<active_state>`(及 plan),**`<task_goal>` 不变**;若原文变化再写 `brain_user_snapshot`。
- 前缀 `[System, …已发送历史 build user / tail]` 字节序不变 → §4 缓存命中。System **跨 run 稳定**(无 `current_time`)。
### 3.3 稳态投影规则
@@ -397,16 +403,18 @@ flowchart TD
| `<preference>` | 当前 run Composer |
| `<reference_scope>` | 当前 run Composer(§2.5) |
| `<compacted_history>` | 最新 `compaction_checkpoint.summary`(无则省略) |
| `<current_time>` | 当前 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 的 `<active_state>`;tail append。
- **resume 新 run**:`[System] + [User built₁] + …history tail… + [User built₂]`(见 §6);**不**改写 built₁ 与 history。
- **同 run 后续 brain**:replace 当前 build user 的 `<active_state>`;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 的 `<active_state>` / plan;**`<task_goal>` 不动**。
- resume:**append** 新 build user 在整段末尾;**前面的 user 与 tail 一字不改**。
- 同 run 内:仅 **replace 当前** build user 的 `<active_state>` / plan;`<current_time>` 取本 run `runStartedAt`(同 run 内不变);**`<task_goal>` 不动**。
- 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 的 `<active_state>` 技能列表变化 |
**resume 不属于打破**:历史保持原样,只在末尾追加新 build user。
设计约束:除 §4.3 外,不得 rewrite 已发送的 tail 段;resume 不得替换首条 build user 的 `<task_goal>`。
设计约束:除 §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 的 `<task_goal>`;仍走 `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 的 `<task_goal>`;仍走 `buildBrainUserMessage`,不是裸 HumanMessage。
5. 符合 §4:历史前缀不变 → 缓存可命中;打破仍仅 compaction / learn_skill。
6. **tools**:`bindTools` 按全 session 最新 `learnedSkillIds` 重绑(与 `User built₂` 的 `<active_state>` 技能对齐),不是冻住进程内 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=<user_context>…</user_context> ← 最后发送原文
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` |
+1 -1
View File
@@ -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` 是当前有效索引版本。
+3 -3
View File
@@ -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`
@@ -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') {
@@ -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<number, string> => {
const ordered = [...records].sort((a, b) => a.seq - b.seq)
const byUserSeq = new Map<number, string>()
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<void> => {
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
}
@@ -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)
@@ -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,
@@ -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,
+12 -37
View File
@@ -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)}
</reference_scope>
${compactedHistory}
<current_time>
${renderCurrentTimeText(input.ctx)}
</current_time>
<active_state>
${renderActiveState()}
</active_state>
</user_context>
`.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 `
<user_context>
<task_goal>
${input.content}
</task_goal>
<preference>
${renderPreferenceText(input.ctx.preference)}
</preference>
<reference_scope>
${renderReferenceScopeText(referenceScope)}
</reference_scope>
</user_context>
`.trim()
}
+2
View File
@@ -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
+5
View File
@@ -1765,6 +1765,11 @@ export type ChatMessageLogAnnotationEntry =
sourceMessageCount: number
}
}
| {
kind: 'annotation'
eventType: 'brain_user_snapshot'
payload: { text: string }
}
export type ChatMessageLogEntry =
| {
@@ -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 = `<user_context>
<task_goal>
请整理过拟合概念。
</task_goal>
<preference>
无
</preference>
<reference_scope>
无
</reference_scope>
<current_time>
- current_time.timezone:UTC
- current_time.local_iso:1970-01-01T00:00:00+00:00
</current_time>
<active_state>
已学习技能:docx-read
</active_state>
</user_context>`
const buildResumeBrainMessages = (input: {
records: ChatMessageLogRecord[]
state: ReturnType<typeof createInitialHumanState>
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('<current_time>')
expect(bUser).toContain('<current_time>')
})
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/)
})
})
@@ -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(/<local_environment>([\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('<context_efficiency>')
})
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(/<preference>([\s\S]*?)<\/preference>/)?.[1].trim()).toBe('无')
expect(user).toContain('<reference_scope>')
expect(user).toContain('<current_time>')
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('<compacted_history>\n任务锚点:继续处理已确认事实\n </compacted_history>')
expect(user.indexOf('</reference_scope>')).toBeLessThan(user.indexOf('<compacted_history>'))
expect(user.indexOf('</compacted_history>')).toBeLessThan(user.indexOf('<active_state>'))
expect(user.indexOf('</compacted_history>')).toBeLessThan(user.indexOf('<current_time>'))
expect(user.indexOf('</current_time>')).toBeLessThan(user.indexOf('<active_state>'))
expect(user.match(/<active_state>[\s\S]*<compacted_history>/)).toBeNull()
})
})