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
4 changes: 2 additions & 2 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ BuildRequest -> FreezeSnapshot -> EstimateInput -> DecideBudget -> (allow | comp
- `context.Builder` 只构建 provider-facing request,不再返回旧的 builder 压缩建议布尔值。
- provider 发送前一定先做输入 token estimate。
- estimate 首次超预算时,runtime 执行一次 `proactive` compact,然后重建 request 并重新估算。
- compact 后仍超预算且估算高置信(`accurate=true`)时,runtime 停止本次 run,并返回 `STOP_BUDGET_EXCEEDED`。
- compact 后仍超预算但估算低置信(`accurate=false`)时,runtime 继续发送请求,不因低置信估算直接硬停
- compact 后仍超预算且 `gate_policy=gateable` 时,runtime 停止本次 run,并返回 `STOP_BUDGET_EXCEEDED`。
- compact 后仍超预算但 `gate_policy=advisory` 时,runtime 继续发送请求,不直接硬停
- provider 返回 `context_too_long` 时,runtime 触发 `reactive` compact,并重新进入同一预算闭环。

## compact 如何压缩
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ BuildRequest -> FreezeSnapshot -> EstimateInput -> DecideBudget -> (allow | comp
- provider 发送前一定先做输入 token estimate。
- 如果 estimate 没超过 `prompt_budget`,本轮允许发送。
- 如果 estimate 首次超预算,先执行一次 `proactive` compact,然后重建请求并重新估算。
- 如果 compact 后仍超预算且估算为高置信(`accurate=true`),停止当前 run,并产出 `STOP_BUDGET_EXCEEDED`。
- 如果 compact 后仍超预算但估算为低置信(`accurate=false`),不直接硬停,继续发送请求。
- 如果 compact 后仍超预算且 `gate_policy=gateable`,停止当前 run,并产出 `STOP_BUDGET_EXCEEDED`。
- 如果 compact 后仍超预算但 `gate_policy=advisory`,不直接硬停,继续发送请求。
- 如果 provider 返回 `context_too_long`,runtime 会进入 `reactive` compact 恢复链路,并重新进入同一预算闭环。

## provider 策略
Expand Down
8 changes: 4 additions & 4 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
- `compact_applied`
- `compact_error`

当前事件 envelope 的唯一有效 `payload_version` 为 `3`。
当前事件 envelope 的唯一有效 `payload_version` 为 `4`。

## ReAct 主循环

Expand Down Expand Up @@ -63,14 +63,14 @@ runtime 不再消费旧的 builder 压缩建议,而是使用冻结快照上的
- `estimated_input_tokens`
- `prompt_budget`
- `estimate_source`
- `estimate_accurate`
- `estimate_gate_policy`

语义:

- `allow`:本轮请求在预算内
- `compact`:首次超预算,需要先压缩
- `stop`:压缩后仍超预算且估算高置信,停止当前 run
- `allow` + `reason=exceeds_budget_inaccurate_after_compact_allow`:压缩后仍超预算但估算低置信,继续放行
- `stop` + `reason=exceeds_budget_after_compact_stop`:压缩后仍超预算且估算可门禁(`gateable`),停止当前 run
- `allow` + `reason=exceeds_budget_after_compact_allow_advisory`:压缩后仍超预算但估算仅 advisory,继续放行

## Context Builder 职责

Expand Down
2 changes: 1 addition & 1 deletion internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1913,7 +1913,7 @@ func (s *stubMemoProvider) EstimateInputTokens(
return providertypes.BudgetEstimate{
EstimatedInputTokens: provider.EstimateTextTokens(req.SystemPrompt),
EstimateSource: provider.EstimateSourceLocal,
Accurate: false,
GatePolicy: provider.EstimateGateGateable,
}, nil
}

Expand Down
78 changes: 78 additions & 0 deletions internal/config/atomic_write.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package config

import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
)

var (
atomicCreateTemp = os.CreateTemp
atomicReadFile = os.ReadFile
atomicRename = os.Rename
)

// writeFileAtomically 通过同目录临时文件与原子替换写入目标文件,并在写后做回读校验。
func writeFileAtomically(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
pattern := "." + filepath.Base(path) + ".tmp-*"
tempFile, err := atomicCreateTemp(dir, pattern)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}

tempPath := tempFile.Name()
cleanupTemp := true
defer func() {
if cleanupTemp {
_ = os.Remove(tempPath)
}
}()

if _, err := tempFile.Write(data); err != nil {
_ = tempFile.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tempFile.Sync(); err != nil {
_ = tempFile.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tempPath, perm); err != nil {
return fmt.Errorf("chmod temp file: %w", err)
}
if err := atomicRename(tempPath, path); err != nil {
return fmt.Errorf("rename temp file: %w", err)
}
cleanupTemp = false

written, err := atomicReadFile(path)
if err != nil {
return fmt.Errorf("read back written file: %w", err)
}
if !bytes.Equal(written, data) {
return errors.New("read back mismatch")
}
if err := fsyncDirectory(dir); err != nil {
return fmt.Errorf("sync target directory: %w", err)
}
return nil
}

// fsyncDirectory 尝试同步目录元数据,确保 rename 后的目录项在支持的平台尽快落盘。
func fsyncDirectory(dir string) error {
handle, err := os.Open(dir)
if err != nil {
return err
}
defer handle.Close()
if err := handle.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) && !errors.Is(err, os.ErrInvalid) {
return err
}
return nil
}
4 changes: 2 additions & 2 deletions internal/config/context_budget_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ func MigrateContextBudgetConfigFile(path string, dryRun bool) (ContextBudgetMigr
}

backup := path + ".bak"
if err := os.WriteFile(backup, raw, 0o644); err != nil {
if err := writeFileAtomically(backup, raw, 0o644); err != nil {
return result, fmt.Errorf("config: write migration backup %s: %w", backup, err)
}
if err := os.WriteFile(path, migrated, 0o644); err != nil {
if err := writeFileAtomically(path, migrated, 0o644); err != nil {
return result, fmt.Errorf("config: write migrated config %s: %w", path, err)
}
result.Backup = backup
Expand Down
128 changes: 128 additions & 0 deletions internal/config/context_budget_migration_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -179,3 +180,130 @@ context:
t.Fatalf("expected note %q, got %v", ContextBudgetMigrationNoteEnabledDeprecated, result.Notes)
}
}

func TestMigrateContextBudgetConfigFileKeepsOriginalWhenBackupWriteFails(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, configName)
original := strings.TrimSpace(`
context:
auto_compact:
input_token_threshold: 120000
`) + "\n"
if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}

restore := stubAtomicWriteOps(t)
defer restore()
atomicCreateTemp = func(dir string, pattern string) (*os.File, error) {
return nil, errors.New("create temp failed")
}

_, err := MigrateContextBudgetConfigFile(target, false)
if err == nil || !strings.Contains(err.Error(), "write migration backup") {
t.Fatalf("expected backup write error, got %v", err)
}
raw, readErr := os.ReadFile(target)
if readErr != nil {
t.Fatalf("read target: %v", readErr)
}
if string(raw) != original {
t.Fatalf("expected original config to stay unchanged, got:\n%s", raw)
}
}

func TestMigrateContextBudgetConfigFileKeepsOriginalWhenTargetReplaceFails(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, configName)
original := strings.TrimSpace(`
context:
auto_compact:
input_token_threshold: 120000
`) + "\n"
if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}

restore := stubAtomicWriteOps(t)
defer restore()
renameCount := 0
atomicRename = func(oldpath string, newpath string) error {
renameCount++
if renameCount == 2 {
return errors.New("rename target failed")
}
return os.Rename(oldpath, newpath)
}

_, err := MigrateContextBudgetConfigFile(target, false)
if err == nil || !strings.Contains(err.Error(), "write migrated config") {
t.Fatalf("expected migrated config write error, got %v", err)
}
if renameCount < 2 {
t.Fatalf("expected second rename to fail, got renameCount=%d", renameCount)
}

raw, readErr := os.ReadFile(target)
if readErr != nil {
t.Fatalf("read target: %v", readErr)
}
if string(raw) != original {
t.Fatalf("expected original config to stay unchanged, got:\n%s", raw)
}

backupRaw, backupErr := os.ReadFile(target + ".bak")
if backupErr != nil {
t.Fatalf("read backup: %v", backupErr)
}
if string(backupRaw) != original {
t.Fatalf("expected backup to keep original content, got:\n%s", backupRaw)
}
}

func TestMigrateContextBudgetConfigFileKeepsOriginalWhenBackupVerificationFails(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, configName)
original := strings.TrimSpace(`
context:
auto_compact:
input_token_threshold: 120000
`) + "\n"
if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}

restore := stubAtomicWriteOps(t)
defer restore()
readCount := 0
atomicReadFile = func(path string) ([]byte, error) {
readCount++
if readCount == 1 {
return []byte("corrupted"), nil
}
return os.ReadFile(path)
}

_, err := MigrateContextBudgetConfigFile(target, false)
if err == nil || !strings.Contains(err.Error(), "read back mismatch") {
t.Fatalf("expected read back mismatch error, got %v", err)
}
raw, readErr := os.ReadFile(target)
if readErr != nil {
t.Fatalf("read target: %v", readErr)
}
if string(raw) != original {
t.Fatalf("expected original config to stay unchanged, got:\n%s", raw)
}
}

func stubAtomicWriteOps(t *testing.T) func() {
t.Helper()
prevCreateTemp := atomicCreateTemp
prevReadFile := atomicReadFile
prevRename := atomicRename
return func() {
atomicCreateTemp = prevCreateTemp
atomicReadFile = prevReadFile
atomicRename = prevRename
}
}
2 changes: 1 addition & 1 deletion internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error {
return err
}

if err := os.WriteFile(l.ConfigPath(), data, 0o644); err != nil {
if err := writeFileAtomically(l.ConfigPath(), data, 0o644); err != nil {
return fmt.Errorf("config: write config file: %w", err)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/provider/anthropic/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func (p *Provider) EstimateInputTokens(
return providertypes.BudgetEstimate{
EstimatedInputTokens: tokens,
EstimateSource: provider.EstimateSourceLocal,
Accurate: false,
GatePolicy: provider.EstimateGateGateable,
}, nil
}

Expand Down
34 changes: 34 additions & 0 deletions internal/provider/anthropic/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,40 @@ func TestBuildRequestRejectsSessionAssetWithoutReader(t *testing.T) {
}
}

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

p, err := New(provider.RuntimeConfig{
Driver: provider.DriverAnthropic,
BaseURL: "https://api.anthropic.com/v1",
DefaultModel: "claude-3-7-sonnet",
APIKeyEnv: "ANTHROPIC_TEST_KEY",
APIKeyResolver: provider.StaticAPIKeyResolver("test-key"),
})
if err != nil {
t.Fatalf("New() error = %v", err)
}

estimate, err := p.EstimateInputTokens(context.Background(), providertypes.GenerateRequest{
Messages: []providertypes.Message{{
Role: providertypes.RoleUser,
Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")},
}},
})
if err != nil {
t.Fatalf("EstimateInputTokens() error = %v", err)
}
if estimate.EstimateSource != provider.EstimateSourceLocal {
t.Fatalf("estimate source = %q, want %q", estimate.EstimateSource, provider.EstimateSourceLocal)
}
if estimate.GatePolicy != provider.EstimateGateGateable {
t.Fatalf("gate policy = %q, want %q", estimate.GatePolicy, provider.EstimateGateGateable)
}
if estimate.EstimatedInputTokens <= 0 {
t.Fatalf("expected positive estimate tokens, got %d", estimate.EstimatedInputTokens)
}
}

func drainEvents(events <-chan providertypes.StreamEvent) []providertypes.StreamEvent {
var drained []providertypes.StreamEvent
for {
Expand Down
2 changes: 2 additions & 0 deletions internal/provider/estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
const (
EstimateSourceNative = "native"
EstimateSourceLocal = "local"
EstimateGateAdvisory = "advisory"
EstimateGateGateable = "gateable"
localEstimateSlack = 1.15
)

Expand Down
2 changes: 1 addition & 1 deletion internal/provider/gemini/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (p *Provider) EstimateInputTokens(
return providertypes.BudgetEstimate{
EstimatedInputTokens: tokens,
EstimateSource: provider.EstimateSourceLocal,
Accurate: false,
GatePolicy: provider.EstimateGateGateable,
}, nil
}

Expand Down
Loading
Loading