Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。
- `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。
- 用户通过 `/compact` 对当前会话执行一次上下文压缩。
- compact 前会先写入完整 transcript,随后生成并校验 compact summary,再回写会话消息。
- compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。

## 配置

Expand Down Expand Up @@ -49,25 +49,26 @@ context:

新增工具时,micro compact 策略不再由 `context` 层静态白名单维护,而是由 `internal/tools` 中的工具实现声明。
默认情况下,已注册工具都会参与 micro compact;只有显式声明保留历史结果的工具才会跳过旧结果清理。
但 micro compact 只有在当前会话已经建立非空 `TaskState` 时才会生效;没有 durable task state 时,context 仅做 trim,不清理旧 tool result。

## 执行链路

1. TUI 识别 `/compact` 并调用 `runtime.Compact(...)`。
2. runtime 发出 `compact_start` 事件。
3. compact runner 将原始消息写入 transcript(JSONL)。
4. compact runner 根据策略构造归档消息与保留消息。
4. compact runner 根据策略构造归档消息与保留消息,并过滤旧的 `[compact_summary]` 展示摘要,避免“摘要的摘要”
5. runtime 选择用于生成 summary 的 provider 和 model:
优先复用会话记录的 `provider` / `model`,缺失时回退到当前配置。
6. summary generator 调用模型生成语义摘要
7. runner 校验摘要结构与长度,必要时截断。
8. compact 成功时回写会话消息并发出 `compact_done`;失败时发出 `compact_error`。
6. summary generator 调用模型生成完整 `task_state` 与 display summary
7. runner 校验 display summary 结构与长度,必要时截断,并写入 `task_state.last_updated_at`
8. compact 成功时回写 `session.TaskState` 与会话消息并发出 `compact_done`;失败时发出 `compact_error`。

其中 `reactive` mode 在 context 包内与 `manual` 复用同一条压缩管线:

1. 先写 transcript。
2. 默认按 `keep_recent` 裁剪可归档历史。
3. 生成并校验 `[compact_summary]`。
4. 返回压缩后的消息与 transcript 元信息。
3. 生成并校验 display summary,同时更新 durable `TaskState`。
4. 返回压缩后的消息、`TaskState` 与 transcript 元信息。

当 provider 返回“上下文过长”错误时,runtime 会:

Expand All @@ -76,9 +77,31 @@ context:
3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。
4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。

## 摘要协议
## 生成协议

compact generator 必须只返回一个 JSON 对象,顶层固定包含:

```json
{
"task_state": {
"goal": "",
"progress": [],
"open_items": [],
"next_step": "",
"blockers": [],
"key_artifacts": [],
"decisions": [],
"user_constraints": []
},
"display_summary": "[compact_summary]\n..."
}
```

- `task_state` 表示 compact 之后的完整 durable task state,而不是增量 patch。
- `task_state` 只允许包含固定字段,不允许混入模型自定义键。
- `display_summary` 仍然必须使用 `[compact_summary]` 协议,供人类阅读和后续轮次参考。

compact summary 必须以如下结构返回:
`display_summary` 必须以如下结构返回:

```text
[compact_summary]
Expand All @@ -105,8 +128,9 @@ constraints:

## 保留原则

- 优先保留已完成事项及结果。
- 保留仍在进行中的状态、关键决策及原因、关键代码改动、用户约束。
- durable truth 优先进入 `TaskState`,而不是散落在聊天消息里。
- `TaskState` 重点保留目标、已完成进展、未完成事项、下一步、阻塞点、关键工件、决策、用户约束。
- `display_summary` 只保留继续工作最少需要的人类可读信息。
- 默认忽略工具详细输出、重复背景、已解决错误的排查细节。

## 事件
Expand Down
30 changes: 27 additions & 3 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,43 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、

`internal/session.Session` 持久化以下核心字段:

- `schema_version`
- `id`、`title`
- `provider`、`model`
- `created_at`、`updated_at`
- `workdir`
- `task_state`
- `messages`
- `token_input_total`
- `token_output_total`

其中:

- `schema_version` 为开发期强校验字段;当前实现只接受当前版本,不兼容旧 session 文件
- `provider` / `model` 记录最近一次成功运行会话时使用的配置,供 compact 等流程优先复用
- `task_state` 是会话级 durable task state,由 runtime 维护、session 持久化、context 只读投影
- `token_input_total` / `token_output_total` 分别表示会话累计输入与输出 token
- token 字段使用 `omitempty`,以兼容旧版 session JSON 文件
- token 字段仍使用 `omitempty`,但不再承担旧版 session JSON 兼容职责

`internal/session.Summary` 只保留会话列表渲染所需的轻量字段,不加载完整消息历史。

`task_state` 固定包含以下字段:

- `goal`
- `progress`
- `open_items`
- `next_step`
- `blockers`
- `key_artifacts`
- `decisions`
- `user_constraints`
- `last_updated_at`

## 读写行为

- `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON
- `Load` 在用户真正进入某个会话时读取完整历史
- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回
- `Load` 在用户真正进入某个会话时读取完整历史,并严格要求 `schema_version` 与 `task_state` 字段存在
- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回;不合法的旧 session 文件会被直接跳过

## Token 计数持久化

Expand All @@ -48,6 +64,14 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、
- 会话重新加载时,runtime 从 session 恢复累计 token
- 自动 compact 成功后,runtime 会重置累计 token,并将重置后的值持久化

## TaskState 与 compact

- `TaskState` 是继续执行多轮任务时的唯一 durable truth,不依赖聊天消息本身长期保存
- compact 成功后,runtime 会同时回写 `session.TaskState` 和压缩后的 `session.Messages`
- `messages` 中的 `[compact_summary]` 只是展示层,不再是唯一续航载体
- context 构建时会优先注入 `TaskState`,再注入 memo、最近消息和必要工具结果
- 只有当 `TaskState` 已建立后,读时 micro compact 才允许清理旧的可重建 tool payload

## 保存时机

- 用户消息提交后保存
Expand Down
16 changes: 12 additions & 4 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

providertypes "neo-code/internal/provider/types"
agentsession "neo-code/internal/session"
"neo-code/internal/tools"
)

Expand All @@ -27,6 +28,7 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder {
promptSources: []promptSectionSource{
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
systemSource,
},
trimPolicy: spanMessageTrimPolicy{},
Expand All @@ -41,11 +43,12 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou
sources := []promptSectionSource{
corePromptSource{},
&projectRulesSource{},
systemSource,
taskStateSource{},
}
if memoSource != nil {
sources = append(sources, memoSource)
}
sources = append(sources, systemSource)
return &DefaultBuilder{
promptSources: sources,
trimPolicy: spanMessageTrimPolicy{},
Expand Down Expand Up @@ -78,15 +81,20 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu

return BuildResult{
SystemPrompt: composeSystemPrompt(sections...),
Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies),
Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.TaskState, input.Compact, b.microCompactPolicies),
AutoCompactSuggested: shouldAutoCompact,
}, nil
}

// applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。
func applyReadTimeContextProjection(messages []providertypes.Message, options CompactOptions, policies MicroCompactPolicySource) []providertypes.Message {
func applyReadTimeContextProjection(
messages []providertypes.Message,
taskState agentsession.TaskState,
options CompactOptions,
policies MicroCompactPolicySource,
) []providertypes.Message {
var projected []providertypes.Message
if options.DisableMicroCompact {
if options.DisableMicroCompact || !taskState.Established() {
projected = cloneContextMessages(messages)
} else {
projected = microCompactMessagesWithPolicies(messages, policies)
Expand Down
77 changes: 75 additions & 2 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"neo-code/internal/context/internalcompact"
providertypes "neo-code/internal/provider/types"
agentsession "neo-code/internal/session"
"neo-code/internal/tools"
)

Expand Down Expand Up @@ -108,6 +109,36 @@ func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) {
}
}

func TestDefaultBuilderBuildIncludesTaskStateBeforeSystemState(t *testing.T) {
t.Parallel()

builder := NewBuilder()
got, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: []providertypes.Message{{Role: "user", Content: "hello"}},
TaskState: agentsession.TaskState{
Goal: "Finish task state refactor",
OpenItems: []string{"Update tests"},
NextStep: "Run go test ./...",
},
Metadata: testMetadata(t.TempDir()),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}

taskStateIndex := strings.Index(got.SystemPrompt, "## Task State")
systemStateIndex := strings.Index(got.SystemPrompt, "## System State")
if taskStateIndex < 0 || systemStateIndex < 0 {
t.Fatalf("expected task state and system state sections, got %q", got.SystemPrompt)
}
if taskStateIndex > systemStateIndex {
t.Fatalf("expected task state before system state, got %q", got.SystemPrompt)
}
if !strings.Contains(got.SystemPrompt, "- goal: Finish task state refactor") {
t.Fatalf("expected task state content in system prompt, got %q", got.SystemPrompt)
}
}

func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T) {
t.Parallel()

Expand All @@ -125,7 +156,10 @@ func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T
},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
got, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: messages,
TaskState: agentsession.TaskState{Goal: "keep implementing task"},
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
Expand Down Expand Up @@ -188,7 +222,10 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) {
{Role: providertypes.RoleAssistant, Content: "current reply"},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
got, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: messages,
TaskState: agentsession.TaskState{Goal: "keep implementing task"},
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
Expand All @@ -206,6 +243,42 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) {
}
}

func TestDefaultBuilderBuildSkipsMicroCompactWithoutEstablishedTaskState(t *testing.T) {
t.Parallel()

builder := &DefaultBuilder{
promptSources: []promptSectionSource{
stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}},
},
}

messages := []providertypes.Message{
{Role: providertypes.RoleUser, Content: "older user"},
{
Role: providertypes.RoleAssistant,
ToolCalls: []providertypes.ToolCall{
{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"},
},
},
{Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"},
{
Role: providertypes.RoleAssistant,
ToolCalls: []providertypes.ToolCall{
{ID: "call-2", Name: "bash", Arguments: "{}"},
},
},
{Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if got.Messages[2].Content != "old read result" {
t.Fatalf("expected old tool result to remain visible without task state, got %q", got.Messages[2].Content)
}
}

func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading