Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ config.local.yaml
# Local data
data/
.cache/
.neocode/projects/**/.transcripts/

# Editor/IDE
.idea/
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ go run ./cmd/neocode

- `/provider` — 切换模型提供商
- `/model` — 切换模型
- `/compact` — 手动压缩当前会话上下文(保留可追溯 transcript)

上下文压缩默认开启每轮 `micro_compact`,手动 `/compact` 支持 `keep_recent` 与 `full_replace` 两种策略。

## 架构概览

Expand All @@ -89,7 +92,7 @@ go run ./cmd/neocode
核心模块职责:

- **`internal/config`** — 配置管理、环境变量、YAML 加载
- **`internal/context`** — system prompt、消息裁剪与上下文构建
- **`internal/context`** — 多 section system prompt 组装、消息裁剪与上下文构建
- **`internal/provider`** — LLM 提供商抽象,抹平厂商差异
- **`internal/runtime`** — ReAct 主循环、事件流、会话管理
- **`internal/tools`** — 工具注册表与具体工具实现
Expand All @@ -107,7 +110,7 @@ go run ./cmd/neocode
├── internal
│ ├── app # 应用装配
│ ├── config # 配置管理
│ ├── context # 上下文构建
│ ├── context # system prompt 组装与上下文构建
│ ├── provider # Provider 抽象与实现
│ ├── runtime # ReAct 循环与事件流
│ ├── tools # 工具系统
Expand Down
11 changes: 11 additions & 0 deletions docs/config-management-detail-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@

## 核心类型
- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息
- `Config.Context.Compact`:上下文压缩配置,包含:
- `micro_enabled`
- `tool_result_keep_recent`
- `tool_result_placeholder_min_chars`
- `manual_strategy`(M1 支持 `keep_recent` / `full_replace`)
- `manual_keep_recent_spans`
- `max_summary_chars`

默认策略说明:
- `micro_enabled` 默认 `true`,可通过配置显式关闭
- `manual_strategy` 默认 `keep_recent`
- `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名
- `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器
- `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装
Expand Down
36 changes: 28 additions & 8 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@
- `tool_start`
- `tool_result`
- `error`
- `compact_start`
- `compact_done`
- `compact_error`
- `micro_compact_applied`

## ReAct 主循环

1. 加载目标会话或创建新会话。
2. 追加最新的用户消息。
3. 读取最新配置快照。
4. 解析当前 provider 配置并构建 provider 实例。
5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。
6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。
7. 保存 assistant 完整回复。
8. 执行返回的工具调用,并保存每一个工具结果。
9. 如果仍需继续推理,则进入下一轮;否则结束。
4. 每轮请求前先尝试执行 `micro_compact`(失败时降级跳过,不阻断主链路)。
5. 解析当前 provider 配置并构建 provider 实例。
6. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。
7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。
8. 保存 assistant 完整回复。
9. 执行返回的工具调用,并保存每一个工具结果。
10. 如果仍需继续推理,则进入下一轮;否则结束。

### Context Builder 输入与职责

Expand All @@ -31,7 +36,7 @@
- 当前 `provider`
- 当前 `model`
- `context.Builder` 负责统一组装:
- 固定核心 system prompt
- 固定核心 system prompt sections
- 从 `workdir` 向上发现的 `AGENTS.md`
- 系统状态摘要(`workdir` / `shell` / `provider` / `model` / git branch / git dirty)
- 裁剪后的历史消息
Expand All @@ -42,7 +47,7 @@

当前 `system prompt` 按以下顺序拼装:

1. 固定核心指令
1. 固定核心 sections
2. `Project Rules` section
3. `System State` section

Expand All @@ -51,13 +56,28 @@
- 规则文件只支持大写文件名 `AGENTS.md`
- 多份命中结果按“从全局到局部”的顺序注入
- git 只注入摘要,不注入完整 `git status`
- 各 section 统一由 `internal/context` 内部的 `renderPromptSection` 和 `composeSystemPrompt` 渲染,`runtime` 仍只消费最终字符串

## 流式桥接

- Provider 发出 `StreamEvent`
- runtime 将其转换成 `RuntimeEvent`
- TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅

### Compact 事件

- `compact_start`:开始执行 compact(payload 为触发模式,`micro` 或 `manual`)
- `compact_done`:compact 完成,payload 包含:
- `applied`
- `before_chars`
- `after_chars`
- `saved_ratio`
- `trigger_mode`
- `transcript_id`
- `transcript_path`
- `compact_error`:compact 失败,payload 包含触发模式和错误信息
- `micro_compact_applied`:仅当 `micro_compact` 实际改写了消息时触发

## 持久化时机

- 用户消息提交后保存
Expand Down
6 changes: 6 additions & 0 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
## 存储策略
NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。

除会话主存储外,compact 会在压缩前额外写入 transcript 留痕,确保“移出活跃上下文”而非“不可恢复删除”。

## 数据模型
- `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息
- `SessionSummary`:用于侧边栏的轻量摘要结构
- `Transcript(.jsonl)`:compact 前完整消息快照,每行一条消息,包含 `role/content/tool_calls/tool_call_id/is_error/index/timestamp`

## 加载策略
- `ListSummaries` 只读取渲染侧边栏所需的基础信息
- `Load` 仅在用户真正进入某个会话时读取完整消息历史
- `Save` 通过临时文件原子写入完整 Session
- transcript 当前不参与自动加载回放,仅用于追溯与审计

## 命名策略
- 新会话默认展示为 `Draft`
Expand All @@ -18,3 +22,5 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优
## 并发约束
- SessionStore 实现必须自行保护共享访问
- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入
- compact transcript 也通过原子落盘写入,路径为 `~/.neocode/projects/<workdir-hash>/.transcripts/`
- transcript 文件在 Unix 系统默认使用 `0600` 权限,减少敏感内容泄露面
2 changes: 2 additions & 0 deletions docs/tools-and-tui-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

## TUI 集成方式
- 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择
- `/compact` 提供手动上下文压缩入口,触发 runtime 的 manual compact 流程
- TUI 对 compact 仅做轻量可观测展示:显示 `compact_done` / `compact_error` 关键信息,避免事件刷屏
- runtime 事件以内联形式渲染到 transcript 中,而不是单独拆出控制台面板
- 工具开始和结束事件会以轻量提示插入聊天流,使交互更沉浸

Expand Down
178 changes: 178 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,184 @@ func TestConstructorsRejectMissingDependencies(t *testing.T) {
})
}

func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) {
tempDir := t.TempDir()
loader := NewLoader(tempDir, testDefaultConfig())

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}

compactCfg := cfg.Context.Compact
if !compactCfg.MicroEnabled {
t.Fatalf("expected micro_enabled default true")
}
if compactCfg.ToolResultKeepRecent != DefaultCompactToolResultKeepRecent {
t.Fatalf("expected tool_result_keep_recent=%d, got %d", DefaultCompactToolResultKeepRecent, compactCfg.ToolResultKeepRecent)
}
if compactCfg.ToolResultPlaceholderMinChars != DefaultCompactPlaceholderMinChars {
t.Fatalf("expected tool_result_placeholder_min_chars=%d, got %d", DefaultCompactPlaceholderMinChars, compactCfg.ToolResultPlaceholderMinChars)
}
if compactCfg.ManualStrategy != CompactManualStrategyKeepRecent {
t.Fatalf("expected manual strategy %q, got %q", CompactManualStrategyKeepRecent, compactCfg.ManualStrategy)
}
if compactCfg.ManualKeepRecentSpans != DefaultCompactManualKeepRecentSpans {
t.Fatalf("expected manual_keep_recent_spans=%d, got %d", DefaultCompactManualKeepRecentSpans, compactCfg.ManualKeepRecentSpans)
}
if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars {
t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars)
}

if err := loader.Save(context.Background(), cfg); err != nil {
t.Fatalf("Save() error = %v", err)
}
reloaded, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Reload() error = %v", err)
}

if reloaded.Context.Compact.ManualStrategy != CompactManualStrategyKeepRecent {
t.Fatalf("expected manual strategy to persist, got %q", reloaded.Context.Compact.ManualStrategy)
}
}

func TestCompactConfigMicroEnabledFalsePersistsAcrossReload(t *testing.T) {
tempDir := t.TempDir()
loader := NewLoader(tempDir, testDefaultConfig())

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
cfg.Context.Compact.MicroEnabled = false

if err := loader.Save(context.Background(), cfg); err != nil {
t.Fatalf("Save() error = %v", err)
}
reloaded, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Reload() error = %v", err)
}
if reloaded.Context.Compact.MicroEnabled {
t.Fatalf("expected micro_enabled to persist as false after reload")
}
}

func TestCompactConfigMissingMicroEnabledFallsBackToDefaultTrue(t *testing.T) {
tempDir := t.TempDir()
loader := NewLoader(tempDir, testDefaultConfig())
if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil {
t.Fatalf("create base dir: %v", err)
}

legacyConfig := []byte(strings.Join([]string{
"selected_provider: openai",
"current_model: gpt-4.1",
"workdir: " + filepath.ToSlash(tempDir),
"shell: powershell",
"",
}, "\n"))
if err := os.WriteFile(loader.ConfigPath(), legacyConfig, 0o644); err != nil {
t.Fatalf("write legacy config: %v", err)
}

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Context.Compact.MicroEnabled {
t.Fatalf("expected missing micro_enabled to fallback to default true")
}
}

func TestCompactConfigValidateFailures(t *testing.T) {
tests := []struct {
name string
compact CompactConfig
expectErr string
}{
{
name: "invalid keep recent",
compact: CompactConfig{
ToolResultKeepRecent: 0,
ToolResultPlaceholderMinChars: 100,
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentSpans: 6,
MaxSummaryChars: 1200,
},
expectErr: "tool_result_keep_recent",
},
{
name: "invalid placeholder min chars",
compact: CompactConfig{
ToolResultKeepRecent: 3,
ToolResultPlaceholderMinChars: 0,
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentSpans: 6,
MaxSummaryChars: 1200,
},
expectErr: "tool_result_placeholder_min_chars",
},
{
name: "invalid manual strategy",
compact: CompactConfig{
ToolResultKeepRecent: 3,
ToolResultPlaceholderMinChars: 100,
ManualStrategy: "unsupported",
ManualKeepRecentSpans: 6,
MaxSummaryChars: 1200,
},
expectErr: "manual_strategy",
},
{
name: "invalid manual keep spans",
compact: CompactConfig{
ToolResultKeepRecent: 3,
ToolResultPlaceholderMinChars: 100,
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentSpans: 0,
MaxSummaryChars: 1200,
},
expectErr: "manual_keep_recent_spans",
},
{
name: "invalid summary chars",
compact: CompactConfig{
ToolResultKeepRecent: 3,
ToolResultPlaceholderMinChars: 100,
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentSpans: 6,
MaxSummaryChars: 0,
},
expectErr: "max_summary_chars",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
err := tt.compact.Validate()
if err == nil || !strings.Contains(err.Error(), tt.expectErr) {
t.Fatalf("expected error containing %q, got %v", tt.expectErr, err)
}
})
}
}

func TestCompactConfigValidateSupportsFullReplace(t *testing.T) {
err := (CompactConfig{
ToolResultKeepRecent: 3,
ToolResultPlaceholderMinChars: 100,
ManualStrategy: CompactManualStrategyFullReplace,
ManualKeepRecentSpans: 6,
MaxSummaryChars: 1200,
}).Validate()
if err != nil {
t.Fatalf("expected full_replace strategy to be valid, got %v", err)
}
}

func restoreEnv(t *testing.T, key string) {
t.Helper()
value, ok := os.LookupEnv(key)
Expand Down
Loading
Loading