diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 1d41e13c..e7ef7836 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -44,7 +44,6 @@ - `workdir` - `shell` -- `max_loops` - `tool_timeout_sec` - `context` - `tools` @@ -100,7 +99,6 @@ custom provider 来自: - `selected_provider` - `current_model` - `shell` -- `max_loops` - `tool_timeout_sec` - `context` - `tools` diff --git a/docs/context-compact.md b/docs/context-compact.md index 986aa85a..55d11131 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -4,8 +4,8 @@ ## 概览 -- runtime 已接入手动 compact、基于 token 阈值的自动 compact、provider 上下文过长后的 `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,再回写会话消息。 @@ -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。 ## 生成协议 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 548f0661..edab9d06 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -37,7 +37,6 @@ custom provider 目录: selected_provider: openai current_model: gpt-4.1 shell: bash -max_loops: 8 tool_timeout_sec: 20 tools: @@ -67,7 +66,6 @@ context: | `selected_provider` | 当前选中的 provider 名称 | | `current_model` | 当前选中的模型 ID | | `shell` | 默认 shell,Windows 默认 `powershell`,其他平台默认 `bash` | -| `max_loops` | Agent 主循环最大轮数 | | `tool_timeout_sec` | 工具执行超时(秒) | ### `context` 字段 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index f8253f88..cea85a23 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -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 主循环 @@ -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 自动提取调度 diff --git a/internal/config/config.go b/internal/config/config.go index bc4a882f..7d0d5c07 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,6 @@ import ( const ( DefaultWorkdir = "." - DefaultMaxLoops = 8 DefaultToolTimeoutSec = 20 ) @@ -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"` @@ -33,7 +31,6 @@ func StaticDefaults() *Config { return &Config{ Workdir: DefaultWorkdir, Shell: defaultShell(), - MaxLoops: DefaultMaxLoops, ToolTimeoutSec: DefaultToolTimeoutSec, Context: defaultContextConfig(), Tools: ToolsConfig{ @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 12aa0394..f7b5de4e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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: ` @@ -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") } @@ -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") - } } diff --git a/internal/config/loader.go b/internal/config/loader.go index d0129ab5..c7fba9b4 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -25,7 +25,6 @@ type persistedConfig struct { SelectedProvider string `yaml:"selected_provider,omitempty"` 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"` @@ -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, @@ -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, diff --git a/internal/context/compact/planner.go b/internal/context/compact/planner.go index dc83a6ff..7ecb2204 100644 --- a/internal/context/compact/planner.go +++ b/internal/context/compact/planner.go @@ -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 } diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 0dd39200..b093a266 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -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. @@ -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) } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 9b33fcf1..dac1b069 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -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") @@ -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 { for { if err := ctx.Err(); err != nil { return s.handleRunError(ctx, state.runID, state.session.ID, err) @@ -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 { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index beece19f..b8905fe5 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -21,7 +21,6 @@ const ( defaultProviderRetryMax = 2 providerRetryBaseWait = 1 * time.Second providerRetryMaxWait = 5 * time.Second - defaultMaxLoops = 8 ) // Runtime 定义 runtime 对外暴露的运行、压缩与审批接口。 diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 3b5472f6..49ff165f 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -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" @@ -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() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 38826ce8..ee1999e3 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1637,7 +1637,6 @@ func TestServiceRunErrorPaths(t *testing.T) { tests := []struct { name string input UserInput - maxLoops int provider *scriptedProvider factoryErr error registerTool *stubTool @@ -1658,28 +1657,43 @@ func TestServiceRunErrorPaths(t *testing.T) { }, }, { - name: "max loops reached after repeated tool cycles", - input: UserInput{RunID: "run-max-loops", Content: "loop"}, - maxLoops: 1, - provider: &scriptedProvider{ - streams: [][]providertypes.StreamEvent{ - { - providertypes.NewToolCallStartStreamEvent(0, "loop-call", "filesystem_edit"), - providertypes.NewToolCallDeltaStreamEvent(0, "loop-call", `{"path":"x"}`), - }, - }, - }, + name: "repeated tool cycles continue until assistant completion", + input: UserInput{RunID: "run-many-tool-cycles", Content: "loop"}, + provider: func() *scriptedProvider { + responses := make([]scriptedResponse, 0, 10) + for i := 0; i < 9; i++ { + responses = append(responses, scriptedResponse{ + Message: providertypes.Message{ + ToolCalls: []providertypes.ToolCall{ + { + ID: fmt.Sprintf("loop-call-%d", i), + Name: "filesystem_edit", + Arguments: `{"path":"x"}`, + }, + }, + }, + FinishReason: "tool_calls", + }) + } + responses = append(responses, scriptedResponse{ + Message: providertypes.Message{Content: "done after many cycles"}, + FinishReason: "stop", + }) + return &scriptedProvider{responses: responses} + }(), registerTool: &stubTool{name: "filesystem_edit", content: "loop tool output"}, - expectErr: "max loop reached", - expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventError}, + expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventAgentDone}, assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { t.Helper() - if scripted.callCount != 1 { - t.Fatalf("expected one provider call before loop exit, got %d", scripted.callCount) + if scripted.callCount != 10 { + t.Fatalf("expected 10 provider calls without loop cap, got %d", scripted.callCount) } session := onlySession(t, store) - if len(session.Messages) != 3 { - t.Fatalf("expected user, assistant, tool messages before abort, got %d", len(session.Messages)) + if got := len(session.Messages); got != 20 { + t.Fatalf("expected 20 persisted messages after 9 tool cycles and final answer, got %d", got) + } + if session.Messages[len(session.Messages)-1].Content != "done after many cycles" { + t.Fatalf("expected final assistant reply to be persisted, got %+v", session.Messages[len(session.Messages)-1]) } }, }, @@ -1818,14 +1832,6 @@ func TestServiceRunErrorPaths(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { manager := newRuntimeConfigManager(t) - if tt.maxLoops > 0 { - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = tt.maxLoops - return nil - }); err != nil { - t.Fatalf("update max loops: %v", err) - } - } store := newMemoryStore() if tt.seedSession != nil { @@ -1867,275 +1873,6 @@ func TestServiceRunErrorPaths(t *testing.T) { } } -func TestServiceRunMaxLoopsSavesLoopLimitCheckpoint(t *testing.T) { - t.Parallel() - - manager := newRuntimeConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = 1 - return nil - }); err != nil { - t.Fatalf("update config: %v", err) - } - store := newMemoryStore() - session := agentsession.New("loop-limit-success") - session.ID = "session-loop-limit-success" - session.TokenInputTotal = 33 - session.TokenOutputTotal = 7 - session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - } - store.sessions[session.ID] = cloneSession(session) - - registry := tools.NewRegistry() - tool := &stubTool{name: "filesystem_edit", content: "loop tool output"} - registry.Register(tool) - - scripted := &scriptedProvider{ - responses: []scriptedResponse{ - { - Message: providertypes.Message{ - ToolCalls: []providertypes.ToolCall{ - {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, - }, - }, - FinishReason: "tool_calls", - }, - }, - } - - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - compactRunner := &stubCompactRunner{ - result: contextcompact.Result{ - Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- checkpoint saved\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleUser, Content: "continue from saved checkpoint"}, - }, - TaskState: agentsession.TaskState{ - Goal: "Preserve continuation checkpoint", - Progress: []string{"Saved loop-limit compact checkpoint"}, - NextStep: "Resume analysis from compacted context", - OpenItems: []string{"Continue source inspection"}, - Decisions: []string{"Use loop_limit compact before max loop exit"}, - Blockers: []string{}, - KeyArtifacts: []string{ - "checkpoint-summary", - }, - UserConstraints: []string{"Prefer durable checkpoint over raw message tail"}, - }, - Applied: true, - Metrics: contextcompact.Metrics{ - BeforeChars: 120, - AfterChars: 48, - SavedRatio: 0.6, - TriggerMode: string(contextcompact.ModeLoopLimit), - }, - TranscriptID: "transcript_loop_limit", - TranscriptPath: "/tmp/loop_limit.jsonl", - }, - } - service.compactRunner = compactRunner - - err := service.Run(context.Background(), UserInput{ - SessionID: session.ID, - RunID: "run-loop-limit-success", - Content: "continue", - }) - if err == nil || !strings.Contains(err.Error(), "max loop reached after saving continuation checkpoint") { - t.Fatalf("expected loop-limit checkpoint error, got %v", err) - } - - if len(compactRunner.calls) != 1 { - t.Fatalf("expected loop-limit compact to run once, got %d", len(compactRunner.calls)) - } - if compactRunner.calls[0].Mode != contextcompact.ModeLoopLimit { - t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeLoopLimit, compactRunner.calls[0].Mode) - } - if compactRunner.calls[0].SessionInputTokens != 33 { - t.Fatalf("expected pre-checkpoint input tokens to be preserved, got %d", compactRunner.calls[0].SessionInputTokens) - } - - saved, err := store.Load(context.Background(), session.ID) - if err != nil { - t.Fatalf("load compacted session: %v", err) - } - if len(saved.Messages) != 2 || !strings.Contains(saved.Messages[0].Content, "checkpoint saved") { - t.Fatalf("expected compacted checkpoint messages, got %+v", saved.Messages) - } - if saved.TaskState.Goal != "Preserve continuation checkpoint" { - t.Fatalf("expected persisted task state, got %+v", saved.TaskState) - } - if saved.TokenInputTotal != 0 || saved.TokenOutputTotal != 0 { - t.Fatalf("expected token totals reset after loop-limit checkpoint, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) - } - - events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{ - EventUserMessage, - EventToolStart, - EventToolChunk, - EventToolResult, - EventCompactStart, - EventCompactDone, - EventError, - }) - assertNoEventType(t, events, EventCompactError) - - compactStartIndex := eventIndex(events, EventCompactStart) - compactDoneIndex := eventIndex(events, EventCompactDone) - errorIndex := eventIndex(events, EventError) - if compactStartIndex == -1 || compactDoneIndex == -1 || errorIndex == -1 { - t.Fatalf("expected loop-limit events in %+v", events) - } - if !(compactStartIndex < compactDoneIndex && compactDoneIndex < errorIndex) { - t.Fatalf("expected compact_start -> compact_done -> error ordering, got %+v", events) - } - - foundLoopLimitDone := false - foundLoopLimitError := false - for _, event := range events { - switch event.Type { - case EventCompactDone: - payload, ok := event.Payload.(CompactResult) - if !ok { - t.Fatalf("expected CompactResult payload, got %T", event.Payload) - } - if payload.TriggerMode == string(contextcompact.ModeLoopLimit) { - foundLoopLimitDone = true - } - case EventError: - message, ok := event.Payload.(string) - if !ok { - t.Fatalf("expected string error payload, got %T", event.Payload) - } - if strings.Contains(message, "after saving continuation checkpoint") { - foundLoopLimitError = true - } - } - } - if !foundLoopLimitDone { - t.Fatalf("expected loop_limit compact_done payload in %+v", events) - } - if !foundLoopLimitError { - t.Fatalf("expected saved-checkpoint error payload in %+v", events) - } -} - -func TestServiceRunMaxLoopsLoopLimitCompactFailureFallsBackToOriginalExit(t *testing.T) { - t.Parallel() - - manager := newRuntimeConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = 1 - return nil - }); err != nil { - t.Fatalf("update config: %v", err) - } - store := newMemoryStore() - session := agentsession.New("loop-limit-failure") - session.ID = "session-loop-limit-failure" - session.TokenInputTotal = 12 - session.TokenOutputTotal = 4 - session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - } - store.sessions[session.ID] = cloneSession(session) - - registry := tools.NewRegistry() - registry.Register(&stubTool{name: "filesystem_edit", content: "loop tool output"}) - - scripted := &scriptedProvider{ - responses: []scriptedResponse{ - { - Message: providertypes.Message{ - ToolCalls: []providertypes.ToolCall{ - {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, - }, - }, - FinishReason: "tool_calls", - }, - }, - } - - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - compactRunner := &stubCompactRunner{err: errors.New("loop limit compact failed")} - service.compactRunner = compactRunner - - err := service.Run(context.Background(), UserInput{ - SessionID: session.ID, - RunID: "run-loop-limit-failure", - Content: "continue", - }) - if err == nil || !strings.Contains(err.Error(), "max loop reached") || strings.Contains(err.Error(), "after saving continuation checkpoint") { - t.Fatalf("expected original max loop error, got %v", err) - } - - if len(compactRunner.calls) != 1 { - t.Fatalf("expected loop-limit compact to be attempted once, got %d", len(compactRunner.calls)) - } - if compactRunner.calls[0].Mode != contextcompact.ModeLoopLimit { - t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeLoopLimit, compactRunner.calls[0].Mode) - } - - saved, err := store.Load(context.Background(), session.ID) - if err != nil { - t.Fatalf("load session after failed checkpoint: %v", err) - } - if len(saved.Messages) != 4 { - t.Fatalf("expected original messages to remain after compact failure, got %+v", saved.Messages) - } - if saved.TokenInputTotal != 12 || saved.TokenOutputTotal != 4 { - t.Fatalf("expected token totals to remain unchanged, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) - } - - events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{ - EventUserMessage, - EventToolStart, - EventToolChunk, - EventToolResult, - EventCompactStart, - EventCompactError, - EventError, - }) - assertNoEventType(t, events, EventCompactDone) - - compactStartIndex := eventIndex(events, EventCompactStart) - compactErrorIndex := eventIndex(events, EventCompactError) - errorIndex := eventIndex(events, EventError) - if compactStartIndex == -1 || compactErrorIndex == -1 || errorIndex == -1 { - t.Fatalf("expected loop-limit failure events in %+v", events) - } - if !(compactStartIndex < compactErrorIndex && compactErrorIndex < errorIndex) { - t.Fatalf("expected compact_start -> compact_error -> error ordering, got %+v", events) - } - - foundLoopLimitCompactError := false - for _, event := range events { - switch event.Type { - case EventCompactError: - payload, ok := event.Payload.(CompactErrorPayload) - if !ok { - t.Fatalf("expected CompactErrorPayload, got %T", event.Payload) - } - if payload.TriggerMode == string(contextcompact.ModeLoopLimit) && strings.Contains(payload.Message, "loop limit compact failed") { - foundLoopLimitCompactError = true - } - case EventError: - message, ok := event.Payload.(string) - if !ok { - t.Fatalf("expected string error payload, got %T", event.Payload) - } - if strings.Contains(message, "after saving continuation checkpoint") { - t.Fatalf("did not expect saved-checkpoint message after compact failure: %+v", events) - } - } - } - if !foundLoopLimitCompactError { - t.Fatalf("expected loop_limit compact_error payload in %+v", events) - } -} - func TestServiceCancelActiveRun(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() @@ -3101,7 +2838,6 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.ToolTimeoutSec = 1 - cfg.MaxLoops = 4 cfg.Workdir = defaultWorkdir return nil }); err != nil { @@ -4129,16 +3865,10 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { } } -func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { +func TestServiceRunReactiveCompactRetriesWithinSameRun(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = 1 - return nil - }); err != nil { - t.Fatalf("update config: %v", err) - } store := newMemoryStore() session := agentsession.New("reactive-single-loop") @@ -4198,11 +3928,11 @@ func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { RunID: "run-reactive-single-loop", Content: "continue", }); err != nil { - t.Fatalf("Run() with MaxLoops=1 should recover, got %v", err) + t.Fatalf("Run() should recover after reactive compact, got %v", err) } if scripted.callCount != 2 { - t.Fatalf("expected provider to be called twice within one loop budget, got %d", scripted.callCount) + t.Fatalf("expected provider to be called twice within the same run, got %d", scripted.callCount) } events := collectRuntimeEvents(service.Events())