feat: implement brain user snapshot handling; enhance context management and message projection for improved session continuity
This commit is contained in:
1 parent
d080018106
commit
acefc5fe15
13 files changed
+255
-99
No files matched your search
@@ -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
@@ -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` 是当前有效索引版本。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user