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: 0 additions & 2 deletions docs/config-management-detail-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@

- `workdir`
- `shell`
- `max_loops`
- `tool_timeout_sec`
- `context`
- `tools`
Expand Down Expand Up @@ -100,7 +99,6 @@ custom provider 来自:
- `selected_provider`
- `current_model`
- `shell`
- `max_loops`
- `tool_timeout_sec`
- `context`
- `tools`
Expand Down
13 changes: 4 additions & 9 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

## 概览

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

Expand Down Expand Up @@ -76,15 +76,10 @@ context:
当 provider 返回“上下文过长”错误时,runtime 会:

1. 识别 provider 归一化后的 typed error,必要时回退到错误文本匹配。
2. 触发一次 `compact.Run(mode=reactive)`。
2. 触发 `compact.Run(mode=reactive)`,并在仍然命中“上下文过长”时继续做逐步降级恢复
3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。
4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环
4. 每次 `Run()` 最多执行 3 次 reactive compact 降级尝试;每次尝试都会进一步收缩 `manual_keep_recent_messages`,超过上限后返回最后一次 provider 错误

当 runtime 命中 `max_loops` 停止条件时,还会在返回最终错误前 best-effort 触发一次 `compact.Run(mode=loop_limit)` 作为 continuation checkpoint:

1. 成功时回写 compact 后的消息、durable `TaskState` 与重置后的 token 计数。
2. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=loop_limit` 标记来源。
3. 无论 compact 成功、失败或 no-op,本次 run 仍会结束;区别只在于后续“继续”能否优先复用 durable checkpoint。

## 生成协议

Expand Down
2 changes: 0 additions & 2 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ custom provider 目录:
selected_provider: openai
current_model: gpt-4.1
shell: bash
max_loops: 8
tool_timeout_sec: 20

tools:
Expand Down Expand Up @@ -67,7 +66,6 @@ context:
| `selected_provider` | 当前选中的 provider 名称 |
| `current_model` | 当前选中的模型 ID |
| `shell` | 默认 shell,Windows 默认 `powershell`,其他平台默认 `bash` |
| `max_loops` | Agent 主循环最大轮数 |
| `tool_timeout_sec` | 工具执行超时(秒) |

### `context` 字段
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 @@ -21,7 +21,7 @@
- `compact_done`
- `compact_error`

这三类 compact 事件同时用于 `manual`、`auto`、`reactive` 与 `loop_limit` 四种来源,调用方可通过 payload 中的 `trigger_mode` 区分。
这三类 compact 事件同时用于 `manual`、`auto` 与 `reactive` 三种来源,调用方可通过 payload 中的 `trigger_mode` 区分。

## ReAct 主循环

Expand All @@ -32,15 +32,15 @@
5. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再在同一轮内重建请求。
6. 冻结当前 turn 的 `provider / model / tools / workdir / request` 快照。
7. 调用 `Provider.Generate`,并把流式事件桥接给 TUI。
8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅在同一 turn 内重建一次当前请求
8. 如 provider 返回“上下文过长”错误,则触发 `reactive` compact,并在同一 run 内最多做 3 次逐步降级的恢复尝试
9. 保存 assistant 完整回复。
10. 执行返回的工具调用,并保存每一个工具结果。
11. 如果最终 assistant 回复后没有后续工具调用,则在 runtime 收口处安排一次后台 memo 自动提取。
12. 如果仍需继续推理,则进入下一轮;否则结束。

补充说明:
- 当下一轮开始前已命中 `max_loops` 上限时,runtime 会先尝试一次 `loop_limit` compact checkpoint,再发出最终 `error` 事件结束本次 run
- 该 checkpoint 成功时会先发出 `compact_start -> compact_done`,并把 session 消息、`TaskState` 与累计 token 重置后的状态持久化;失败时会发出 `compact_error`,随后仍以最大轮数错误结束
- runtime 不再设置内部 `max_loops` 停止条件;单次 run 仅在拿到最终 assistant 回复、遇到错误或收到外部取消时结束
- 由于 session 锁覆盖整个 run 生命周期,同一会话如果持续陷入工具调用循环,会一直占用该会话直到模型自行收口、报错或被取消

### Memo 自动提取调度

Expand Down
6 changes: 0 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (

const (
DefaultWorkdir = "."
DefaultMaxLoops = 8
DefaultToolTimeoutSec = 20
)

Expand All @@ -21,7 +20,6 @@ type Config struct {
CurrentModel string `yaml:"current_model"`
Workdir string `yaml:"-"`
Shell string `yaml:"shell"`
MaxLoops int `yaml:"max_loops,omitempty"`
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`
Context ContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
Expand All @@ -33,7 +31,6 @@ func StaticDefaults() *Config {
return &Config{
Workdir: DefaultWorkdir,
Shell: defaultShell(),
MaxLoops: DefaultMaxLoops,
ToolTimeoutSec: DefaultToolTimeoutSec,
Context: defaultContextConfig(),
Tools: ToolsConfig{
Expand Down Expand Up @@ -69,9 +66,6 @@ func (c *Config) applyStaticDefaults(defaults Config) {
if strings.TrimSpace(c.Shell) == "" {
c.Shell = defaults.Shell
}
if c.MaxLoops <= 0 {
c.MaxLoops = defaults.MaxLoops
}
if c.ToolTimeoutSec <= 0 {
c.ToolTimeoutSec = defaults.ToolTimeoutSec
}
Expand Down
16 changes: 10 additions & 6 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ shell: powershell
`,
err: "field workdir not found",
},
{
name: "removed max_loops key is rejected",
data: `
selected_provider: openai
current_model: gpt-4.1
shell: powershell
max_loops: 8
`,
err: "field max_loops not found",
},
{
name: "legacy persisted providers list is rejected",
data: `
Expand Down Expand Up @@ -1519,9 +1529,6 @@ func TestStaticReturnsCompleteSkeleton(t *testing.T) {
if cfg.Shell == "" {
t.Fatal("expected shell to be set")
}
if cfg.MaxLoops == 0 {
t.Fatal("expected max_loops to be set")
}
if cfg.ToolTimeoutSec == 0 {
t.Fatal("expected tool_timeout_sec to be set")
}
Expand Down Expand Up @@ -1809,7 +1816,4 @@ func TestConfigCloneNilReceiverReturnsDefaults(t *testing.T) {
if cloned.Shell == "" {
t.Fatal("expected cloned nil config to have default shell")
}
if cloned.MaxLoops == 0 {
t.Fatal("expected cloned nil config to have default max_loops")
}
}
3 changes: 0 additions & 3 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ type persistedConfig struct {
SelectedProvider string `yaml:"selected_provider,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_loops was removed from persisted config while strict YAML known-fields decoding is still enforced, so existing user configs containing max_loops now hard-fail to load. If this breaking change is intentional, consider adding a temporary compatibility shim (accept+ignore legacy key) or an explicit migration path in docs/release notes to avoid upgrade-time boot failures.

CurrentModel string `yaml:"current_model,omitempty"`
Shell string `yaml:"shell"`
MaxLoops int `yaml:"max_loops,omitempty"`
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`
Context persistedContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
Expand Down Expand Up @@ -204,7 +203,6 @@ func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults
SelectedProvider: strings.TrimSpace(file.SelectedProvider),
CurrentModel: strings.TrimSpace(file.CurrentModel),
Shell: strings.TrimSpace(file.Shell),
MaxLoops: file.MaxLoops,
ToolTimeoutSec: file.ToolTimeoutSec,
Context: fromPersistedContextConfig(file.Context, contextDefaults),
Tools: file.Tools,
Expand All @@ -219,7 +217,6 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) {
SelectedProvider: snapshot.SelectedProvider,
CurrentModel: snapshot.CurrentModel,
Shell: snapshot.Shell,
MaxLoops: snapshot.MaxLoops,
ToolTimeoutSec: snapshot.ToolTimeoutSec,
Context: newPersistedContextConfig(snapshot.Context),
Tools: snapshot.Tools,
Expand Down
2 changes: 1 addition & 1 deletion internal/context/compact/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type compactionPlanner struct{}

// Plan 根据 mode 与配置返回摘要前的裁剪规划结果。
func (compactionPlanner) Plan(mode Mode, messages []providertypes.Message, cfg config.CompactConfig) (compactionPlan, error) {
if mode == ModeReactive || mode == ModeLoopLimit {
if mode == ModeReactive {
return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil
}

Expand Down
4 changes: 1 addition & 3 deletions internal/context/compact/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ const (
ModeAuto Mode = "auto"
// ModeReactive runs the provider-error-triggered compact flow.
ModeReactive Mode = "reactive"
// ModeLoopLimit runs the continuation checkpoint flow triggered before loop-budget exit.
ModeLoopLimit Mode = "loop_limit"
)

// ErrorMode classifies compact result errors.
Expand Down Expand Up @@ -129,7 +127,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) {
return Result{}, err
}

if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive && input.Mode != ModeLoopLimit {
if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive {
return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode)
}

Expand Down
33 changes: 3 additions & 30 deletions internal/runtime/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
// Run 执行一次完整的 ReAct 闭环:保存用户输入、驱动模型、执行工具并发出事件。
// 已有会话会先加锁再加载/更新,确保同一会话并发 Run 不会出现状态覆盖;
// 新会话在创建后再绑定会话锁,不同会话可并行执行。
// 当前实现不再设置内部轮数上限,因此 Run 仅在拿到最终 assistant 回复、遇到错误或收到外部取消时结束。
// 这也意味着同一 session 的锁会覆盖整个运行周期,调用方需要依赖模型终止条件或取消机制兜底。
func (s *Service) Run(ctx context.Context, input UserInput) error {
if strings.TrimSpace(input.Content) == "" {
return errors.New("runtime: input content is empty")
Expand Down Expand Up @@ -68,28 +70,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error {
return s.handleRunError(ctx, state.runID, state.session.ID, err)
}

for turn := 0; ; turn++ {
maxLoops := resolveMaxLoops(s.configManager.Get())
if turn >= maxLoops {
errMessage := "runtime: max loop reached"
applied, compactErr := s.applyCompactForState(
ctx,
&state,
s.configManager.Get(),
contextcompact.ModeLoopLimit,
compactErrorBestEffort,
)
if compactErr != nil {
return s.handleRunError(ctx, state.runID, state.session.ID, compactErr)
}
if applied {
errMessage = "runtime: max loop reached after saving continuation checkpoint"
}
err := errors.New(errMessage)
s.emit(ctx, EventError, state.runID, state.session.ID, err.Error())
return err
}

for {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the loop budget turns the run loop into an unbounded execution path. If the model keeps returning tool_calls, this can run indefinitely (unbounded tool invocations/token spend/session growth) until external cancellation. Please keep a hard safety guard (for example a per-run max-turn fail-safe, or a separate safety budget) even if max_loops is removed from user-facing config.

for {
if err := ctx.Err(); err != nil {
return s.handleRunError(ctx, state.runID, state.session.ID, err)
Expand Down Expand Up @@ -298,14 +279,6 @@ func (s *Service) applyCompactForState(
return false, nil
}

// resolveMaxLoops 收敛运行时最大推理轮数的默认值逻辑。
func resolveMaxLoops(cfg config.Config) int {
if cfg.MaxLoops <= 0 {
return defaultMaxLoops
}
return cfg.MaxLoops
}

// autoCompactThreshold 返回当前配置下的自动 compact 触发阈值。
func autoCompactThreshold(cfg config.Config) int {
if cfg.Context.AutoCompact.Enabled && cfg.Context.AutoCompact.InputTokenThreshold > 0 {
Expand Down
1 change: 0 additions & 1 deletion internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const (
defaultProviderRetryMax = 2
providerRetryBaseWait = 1 * time.Second
providerRetryMaxWait = 5 * time.Second
defaultMaxLoops = 8
)

// Runtime 定义 runtime 对外暴露的运行、压缩与审批接口。
Expand Down
15 changes: 0 additions & 15 deletions internal/runtime/runtime_internal_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"testing"
"time"

"neo-code/internal/config"
"neo-code/internal/provider"
providertypes "neo-code/internal/provider/types"
"neo-code/internal/runtime/approval"
Expand Down Expand Up @@ -143,20 +142,6 @@ func TestAppendToolMessageAndSaveSanitizesMetadata(t *testing.T) {
}
}

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

if got := resolveMaxLoops(config.Config{MaxLoops: 0}); got != defaultMaxLoops {
t.Fatalf("expected default max loops for zero, got %d", got)
}
if got := resolveMaxLoops(config.Config{MaxLoops: -3}); got != defaultMaxLoops {
t.Fatalf("expected default max loops for negative, got %d", got)
}
if got := resolveMaxLoops(config.Config{MaxLoops: 12}); got != 12 {
t.Fatalf("expected explicit max loops, got %d", got)
}
}

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

Expand Down
Loading
Loading