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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
- token 累积记录与事件发射
- 自动压缩触发与重置逻辑
- 修改 `context` 时,重点覆盖:
- Build 输入输出契约(含 Metadata 新字段、ShouldAutoCompact 决策)
- Build 输入输出契约(含 Metadata 新字段、AutoCompactSuggested 决策)
- micro compact 策略
- 消息裁剪边界
- AGENTS.md 加载与截断
Expand Down
4 changes: 2 additions & 2 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## 概览

- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。
- `internal/context/compact` 支持 `manual` 与 `reactive` 两种 mode。
- `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。
- 用户通过 `/compact` 对当前会话执行一次上下文压缩。
- compact 前会先写入完整 transcript,随后生成并校验 compact summary,再回写会话消息。

Expand Down Expand Up @@ -40,7 +40,7 @@ context:

## 自动压缩

当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.ShouldAutoCompact` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩;token 计数的重置与持久化语义统一见 [Session 持久化设计](./session-persistence-design.md)。
当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.AutoCompactSuggested` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩;token 计数的重置与持久化语义统一见 [Session 持久化设计](./session-persistence-design.md)。

设计原则:
- **context 拥有压缩决策权**,runtime 只做编排执行。
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ $env:GEMINI_API_KEY = "AI..."

`config.yaml` 里的 `selected_provider/current_model` 表达的是“用户上次保存的选择状态”。

启动时系统还会基于当前 provider、driver 支持情况和模型目录快照执行选择修正。因此需要区分两件事:
启动时系统还会进行选择校验与必要修正;若 driver 不受支持会报错并中止。因此需要区分两件事:

- 配置快照结构合法
- 当前选择已经可直接运行
Expand Down
29 changes: 23 additions & 6 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@

当前 runtime 对外暴露一组小而稳定的事件:

- `user_message`
- `agent_chunk`
- `agent_done`
- `tool_call_thinking`
- `tool_start`
- `tool_chunk`
- `tool_result`
- `run_canceled`
- `error`
- `provider_retry`
- `permission_request`
- `permission_resolved`
- `token_usage`
- `compact_start`
- `compact_done`
Expand All @@ -19,15 +26,20 @@
1. 加载目标会话或创建新会话。
2. 追加最新的用户消息。
3. 读取最新配置快照。
4. 解析当前 provider 配置并构建 provider 实例
5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文
6. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再继续构造请求
4. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文
5. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再在同一轮内重建请求
6. 冻结当前 turn 的 `provider / model / tools / workdir / request` 快照
7. 调用 `Provider.Generate`,并把流式事件桥接给 TUI。
8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅重试一次当前请求
8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅在同一 turn 内重建一次当前请求
9. 保存 assistant 完整回复。
10. 执行返回的工具调用,并保存每一个工具结果。
11. 如果仍需继续推理,则进入下一轮;否则结束。

补充约束:
- 同一 turn 内的 provider retry 只重放冻结后的 turn 快照,不会重新读取配置。
- `auto compact` 与 `reactive compact` 都不额外消耗 reasoning turn。
- 权限审批等待由 `internal/runtime/approval` 负责 request 生命周期,runtime 自己负责事件发射与 tool 重试编排。

### Context Builder 输入与职责

- `runtime` 只向 `context.Builder` 传递本轮所需元数据:
Expand All @@ -44,7 +56,7 @@
- 从 `workdir` 向上发现的 `AGENTS.md`
- 系统状态摘要(`workdir` / `shell` / `provider` / `model` / git branch / git dirty)
- 裁剪后的历史消息
- 自动压缩决策(`BuildResult.ShouldAutoCompact`)
- 自动压缩决策(`BuildResult.AutoCompactSuggested`)
- `runtime` 不直接读取规则文件,也不直接查询 git 状态。
- `provider` 只消费最终生成的 `SystemPrompt`、消息列表和工具 schema,不感知上下文来源。

Expand All @@ -66,9 +78,14 @@
## 流式桥接

- Provider 发出 `StreamEvent`
- runtime 将其转换成 `RuntimeEvent`
- `internal/provider/streaming` 统一累积文本、tool call 增量和 `message_done`
- runtime 将累积过程映射成 `RuntimeEvent`
- TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅

同一套流式累积逻辑同时复用于:
- 普通 `Run()` 的 assistant 回复收敛
- compact summary 生成阶段的 provider 输出消费

## Token 计量

runtime 在转发 provider 流式事件时,从 `MessageDone` 事件中提取 `Usage`(`InputTokens`、`OutputTokens`),累积到会话级计数器,并发出 `token_usage` 事件供 TUI 消费。
Expand Down
10 changes: 5 additions & 5 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou
sources = append(sources, memoSource)
}
return &DefaultBuilder{
promptSources: sources,
trimPolicy: spanMessageTrimPolicy{},
promptSources: sources,
trimPolicy: spanMessageTrimPolicy{},
microCompactPolicies: policies,
}
}
Expand Down Expand Up @@ -77,9 +77,9 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu
input.Metadata.SessionInputTokens >= input.Compact.AutoCompactThreshold

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

Expand Down
24 changes: 12 additions & 12 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ func TestTrimMessagesBoundaries(t *testing.T) {
}
}

func TestBuildShouldAutoCompactDisabled(t *testing.T) {
func TestBuildAutoCompactSuggestedDisabled(t *testing.T) {
t.Parallel()

builder := NewBuilder()
Expand All @@ -566,12 +566,12 @@ func TestBuildShouldAutoCompactDisabled(t *testing.T) {
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if result.ShouldAutoCompact {
t.Fatalf("expected ShouldAutoCompact false when threshold is 0")
if result.AutoCompactSuggested {
t.Fatalf("expected AutoCompactSuggested false when threshold is 0")
}
}

func TestBuildShouldAutoCompactBelowThreshold(t *testing.T) {
func TestBuildAutoCompactSuggestedBelowThreshold(t *testing.T) {
t.Parallel()

builder := NewBuilder()
Expand All @@ -586,12 +586,12 @@ func TestBuildShouldAutoCompactBelowThreshold(t *testing.T) {
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if result.ShouldAutoCompact {
t.Fatalf("expected ShouldAutoCompact false when tokens below threshold")
if result.AutoCompactSuggested {
t.Fatalf("expected AutoCompactSuggested false when tokens below threshold")
}
}

func TestBuildShouldAutoCompactAtThreshold(t *testing.T) {
func TestBuildAutoCompactSuggestedAtThreshold(t *testing.T) {
t.Parallel()

builder := NewBuilder()
Expand All @@ -606,12 +606,12 @@ func TestBuildShouldAutoCompactAtThreshold(t *testing.T) {
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if !result.ShouldAutoCompact {
t.Fatalf("expected ShouldAutoCompact true when tokens equal threshold")
if !result.AutoCompactSuggested {
t.Fatalf("expected AutoCompactSuggested true when tokens equal threshold")
}
}

func TestBuildShouldAutoCompactAboveThreshold(t *testing.T) {
func TestBuildAutoCompactSuggestedAboveThreshold(t *testing.T) {
t.Parallel()

builder := NewBuilder()
Expand All @@ -626,8 +626,8 @@ func TestBuildShouldAutoCompactAboveThreshold(t *testing.T) {
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if !result.ShouldAutoCompact {
t.Fatalf("expected ShouldAutoCompact true when tokens above threshold")
if !result.AutoCompactSuggested {
t.Fatalf("expected AutoCompactSuggested true when tokens above threshold")
}
}

Expand Down
19 changes: 19 additions & 0 deletions internal/context/source_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package context

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -216,6 +218,9 @@ func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder rul

match, err := finder(dir)
if err != nil {
if isRuleDiscoveryPermissionError(err) {

This comment was marked as outdated.

break
}
return nil, fmt.Errorf("context: discover rule file in %s: %w", dir, err)
}
if match != "" {
Expand All @@ -236,6 +241,20 @@ func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder rul
return paths, nil
}

// isRuleDiscoveryPermissionError 判断规则发现失败是否由权限限制导致。
// 在沙箱或受限目录场景下,遇到无权限读取的父目录时应停止继续向上探测,而不是让整个上下文构建失败。
func isRuleDiscoveryPermissionError(err error) bool {
if err == nil {
return false
}
if os.IsPermission(err) || errors.Is(err, fs.ErrPermission) || errors.Is(err, os.ErrPermission) {
return true
}

lower := strings.ToLower(err.Error())
return strings.Contains(lower, "permission denied") || strings.Contains(lower, "access is denied")
}

// findExactRuleFile 只匹配大小写完全一致的 AGENTS.md,避免误读同名变体。
func findExactRuleFile(dir string) (string, error) {
entries, err := os.ReadDir(dir)
Expand Down
13 changes: 7 additions & 6 deletions internal/context/source_rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package context
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -115,7 +116,7 @@ func TestLoadRuleDocumentsReturnsReadError(t *testing.T) {
}
}

func TestDiscoverRuleFilesReturnsDirectoryReadError(t *testing.T) {
func TestDiscoverRuleFilesStopsTraversalOnPermissionDenied(t *testing.T) {
t.Parallel()

root := t.TempDir()
Expand All @@ -133,7 +134,7 @@ func TestDiscoverRuleFilesReturnsDirectoryReadError(t *testing.T) {
t.Fatalf("write local rules: %v", err)
}

permissionErr := errors.New("permission denied")
permissionErr := fmt.Errorf("wrapped permission: %w", os.ErrPermission)
paths, err := discoverRuleFilesWithFinder(context.Background(), nested, func(dir string) (string, error) {
switch dir {
case nested:
Expand All @@ -146,11 +147,11 @@ func TestDiscoverRuleFilesReturnsDirectoryReadError(t *testing.T) {
return "", nil
}
})
if err == nil || !strings.Contains(err.Error(), permissionErr.Error()) {
t.Fatalf("expected discoverRuleFilesWithFinder() to return permission error, got %v", err)
if err != nil {
t.Fatalf("discoverRuleFilesWithFinder() error = %v", err)
}
if paths != nil {
t.Fatalf("expected no paths on discovery failure, got %+v", paths)
if len(paths) != 1 || paths[0] != localRules {
t.Fatalf("expected discovery to stop after permission denial, got %+v", paths)
}
}

Expand Down
6 changes: 3 additions & 3 deletions internal/context/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ type BuildInput struct {

// BuildResult is the provider-facing context produced for a single round.
type BuildResult struct {
SystemPrompt string
Messages []providertypes.Message
ShouldAutoCompact bool
SystemPrompt string
Messages []providertypes.Message
AutoCompactSuggested bool
}

// MicroCompactPolicySource 定义 context 读取工具 micro compact 策略的最小依赖。
Expand Down
Loading
Loading