feat(core): stop repetitive agent loops (#223)
* feat(core): stop repetitive agent loops * fix(core): preserve loop state on compaction rollback * test(core): verify compaction rollback keeps loop state * fix(modes): satisfy repetition stage lint
- commit
- 63d78c805ebc4e60692afd88a1211fae71781a08
- author
- bnema · · 2026-09-23 11:55 UTC
- committer
- GitHub · · 2026-09-23 11:55 UTC
- parent
- 82578de
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.43.0
32 files · +1200 -52
M README.md +1 -0
| @@ -249,6 +249,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 249 | 249 | | `--no-session` | Don't read or write session files. | | |
| 250 | 250 | | `--cwd <path>` | Use `<path>` as the working directory. | | |
| 251 | 251 | | `--no-tools` | Disable all tools. | | |
| 252 | + | | `--no-repetition-guard` | Disable repeated-content loop detection for intentional polling workflows. | | |
| 252 | 253 | | `--no-lsp` | Disable the built-in LSP/linter tool for this run. | | |
| 253 | 254 | | `--tools <csv>` | Only enable the listed tools. Include `lsp` to select it explicitly. An explicit list must include `web_search` to retain or opt into the complete public-web capability (`web_search`, `web_open`, `web_find`, and `web_click`) for that invocation; explicitly including it overrides the persisted web-search setting. | | |
| 254 | 255 | | `--max-steps <n>` | Cap agent loop iterations (default: unlimited). | | |
| @@ -249,6 +249,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 249 | | `--no-session` | Don't read or write session files. | | 249 | | `--no-session` | Don't read or write session files. | | ||
| 250 | | `--cwd <path>` | Use `<path>` as the working directory. | | 250 | | `--cwd <path>` | Use `<path>` as the working directory. | | ||
| 251 | | `--no-tools` | Disable all tools. | | 251 | | `--no-tools` | Disable all tools. | | ||
| 252 | + | | `--no-repetition-guard` | Disable repeated-content loop detection for intentional polling workflows. | | |||
| 252 | | `--no-lsp` | Disable the built-in LSP/linter tool for this run. | | 253 | | `--no-lsp` | Disable the built-in LSP/linter tool for this run. | | ||
| 253 | | `--tools <csv>` | Only enable the listed tools. Include `lsp` to select it explicitly. An explicit list must include `web_search` to retain or opt into the complete public-web capability (`web_search`, `web_open`, `web_find`, and `web_click`) for that invocation; explicitly including it overrides the persisted web-search setting. | | 254 | | `--tools <csv>` | Only enable the listed tools. Include `lsp` to select it explicitly. An explicit list must include `web_search` to retain or opt into the complete public-web capability (`web_search`, `web_open`, `web_find`, and `web_click`) for that invocation; explicitly including it overrides the persisted web-search setting. | | ||
| 254 | | `--max-steps <n>` | Cap agent loop iterations (default: unlimited). | | 255 | | `--max-steps <n>` | Cap agent loop iterations (default: unlimited). | | ||
M packages/agent/args.go +6 -0
| @@ -71,6 +71,9 @@ type Args struct { | |||
| 71 | 71 | // at Inherit; host runtimes and SDK callers set it explicitly. | |
| 72 | 72 | WebSearchPolicy subagents.WebSearchPolicy | |
| 73 | 73 | MaxSteps int | |
| 74 | + | // DisableRepetitionGuard permits deliberate workflows that repeat | |
| 75 | + | // identical calls or assistant messages, such as scheduled polling. | |
| 76 | + | DisableRepetitionGuard bool | |
| 74 | 77 | ||
| 75 | 78 | // Exts is a list of directory paths the user passed via --ext. | |
| 76 | 79 | // Each must contain an extension.json. Loaded for one session | |
| @@ -189,6 +192,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 189 | 192 | a.NoSess = true | |
| 190 | 193 | case "--no-tools": | |
| 191 | 194 | a.NoTools = true | |
| 195 | + | case "--no-repetition-guard": | |
| 196 | + | a.DisableRepetitionGuard = true | |
| 192 | 197 | case "--no-lsp": | |
| 193 | 198 | a.NoLSP = true | |
| 194 | 199 | case "--list-models": | |
| @@ -468,6 +473,7 @@ func PrintHelp(version string) { | |||
| 468 | 473 | section("workspace, tools, skills", | |
| 469 | 474 | row{"--cwd PATH", "treat PATH as the working directory"}, | |
| 470 | 475 | row{"--no-tools", "disable all tools"}, | |
| 476 | + | row{"--no-repetition-guard", "disable repeated-content loop detection for intentional polling"}, | |
| 471 | 477 | row{"--no-lsp", "disable the built-in LSP/linter tool"}, | |
| 472 | 478 | row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation; the subagent tool accepts subagent:<action>)"}, | |
| 473 | 479 | row{"--no-yolo", "ask before running every tool call"}, | |
| @@ -71,6 +71,9 @@ type Args struct { | |||||
| 71 | // at Inherit; host runtimes and SDK callers set it explicitly. | 71 | // at Inherit; host runtimes and SDK callers set it explicitly. | ||
| 72 | WebSearchPolicy subagents.WebSearchPolicy | 72 | WebSearchPolicy subagents.WebSearchPolicy | ||
| 73 | MaxSteps int | 73 | MaxSteps int | ||
| 74 | + | // DisableRepetitionGuard permits deliberate workflows that repeat | |||
| 75 | + | // identical calls or assistant messages, such as scheduled polling. | |||
| 76 | + | DisableRepetitionGuard bool | |||
| 74 | 77 | ||||
| 75 | // Exts is a list of directory paths the user passed via --ext. | 78 | // Exts is a list of directory paths the user passed via --ext. | ||
| 76 | // Each must contain an extension.json. Loaded for one session | 79 | // Each must contain an extension.json. Loaded for one session | ||
| @@ -189,6 +192,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 189 | a.NoSess = true | 192 | a.NoSess = true | ||
| 190 | case "--no-tools": | 193 | case "--no-tools": | ||
| 191 | a.NoTools = true | 194 | a.NoTools = true | ||
| 195 | + | case "--no-repetition-guard": | |||
| 196 | + | a.DisableRepetitionGuard = true | |||
| 192 | case "--no-lsp": | 197 | case "--no-lsp": | ||
| 193 | a.NoLSP = true | 198 | a.NoLSP = true | ||
| 194 | case "--list-models": | 199 | case "--list-models": | ||
| @@ -468,6 +473,7 @@ func PrintHelp(version string) { | |||||
| 468 | section("workspace, tools, skills", | 473 | section("workspace, tools, skills", | ||
| 469 | row{"--cwd PATH", "treat PATH as the working directory"}, | 474 | row{"--cwd PATH", "treat PATH as the working directory"}, | ||
| 470 | row{"--no-tools", "disable all tools"}, | 475 | row{"--no-tools", "disable all tools"}, | ||
| 476 | + | row{"--no-repetition-guard", "disable repeated-content loop detection for intentional polling"}, | |||
| 471 | row{"--no-lsp", "disable the built-in LSP/linter tool"}, | 477 | row{"--no-lsp", "disable the built-in LSP/linter tool"}, | ||
| 472 | row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation; the subagent tool accepts subagent:<action>)"}, | 478 | row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation; the subagent tool accepts subagent:<action>)"}, | ||
| 473 | row{"--no-yolo", "ask before running every tool call"}, | 479 | row{"--no-yolo", "ask before running every tool call"}, | ||
M packages/agent/build.go +8 -5
| @@ -39,11 +39,12 @@ type Resolved struct { | |||
| 39 | 39 | FastMode bool | |
| 40 | 40 | WebSearchPolicy subagents.WebSearchPolicy | |
| 41 | 41 | ||
| 42 | - | ToolRegistry core.Registry | |
| 43 | - | ToolSummary []ToolSummary | |
| 44 | - | SystemPrompt string | |
| 45 | - | MaxSteps int | |
| 46 | - | Sandbox *tools.Sandbox | |
| 42 | + | ToolRegistry core.Registry | |
| 43 | + | ToolSummary []ToolSummary | |
| 44 | + | SystemPrompt string | |
| 45 | + | MaxSteps int | |
| 46 | + | DisableRepetitionGuard bool | |
| 47 | + | Sandbox *tools.Sandbox | |
| 47 | 48 | ||
| 48 | 49 | // ContextWindow and MaxOutput retain the effective model metadata, | |
| 49 | 50 | // including metadata synthesized for valid open-catalog local and routed | |
| @@ -1013,6 +1014,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 1013 | 1014 | ToolSummary: summaries, | |
| 1014 | 1015 | SystemPrompt: sys, | |
| 1015 | 1016 | MaxSteps: max, | |
| 1017 | + | DisableRepetitionGuard: args.DisableRepetitionGuard, | |
| 1016 | 1018 | ContextWindow: resolvedModel.ContextWindow, | |
| 1017 | 1019 | MaxOutput: resolvedModel.MaxOutput, | |
| 1018 | 1020 | modelCatalog: modelCatalog, | |
| @@ -1355,6 +1357,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1355 | 1357 | func (r Resolved) NewAgent() *core.Agent { | |
| 1356 | 1358 | a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry) | |
| 1357 | 1359 | a.MaxSteps = r.MaxSteps | |
| 1360 | + | a.DisableRepetitionGuard = r.DisableRepetitionGuard | |
| 1358 | 1361 | a.ContextWindow = r.ContextWindow | |
| 1359 | 1362 | a.MaxTokens = r.MaxOutput | |
| 1360 | 1363 | a.Reasoning = r.Reasoning | |
| @@ -39,11 +39,12 @@ type Resolved struct { | |||||
| 39 | FastMode bool | 39 | FastMode bool | ||
| 40 | WebSearchPolicy subagents.WebSearchPolicy | 40 | WebSearchPolicy subagents.WebSearchPolicy | ||
| 41 | 41 | ||||
| 42 | - | ToolRegistry core.Registry | 42 | + | ToolRegistry core.Registry |
| 43 | - | ToolSummary []ToolSummary | 43 | + | ToolSummary []ToolSummary |
| 44 | - | SystemPrompt string | 44 | + | SystemPrompt string |
| 45 | - | MaxSteps int | 45 | + | MaxSteps int |
| 46 | - | Sandbox *tools.Sandbox | 46 | + | DisableRepetitionGuard bool |
| 47 | + | Sandbox *tools.Sandbox | |||
| 47 | 48 | ||||
| 48 | // ContextWindow and MaxOutput retain the effective model metadata, | 49 | // ContextWindow and MaxOutput retain the effective model metadata, | ||
| 49 | // including metadata synthesized for valid open-catalog local and routed | 50 | // including metadata synthesized for valid open-catalog local and routed | ||
| @@ -1013,6 +1014,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 1013 | ToolSummary: summaries, | 1014 | ToolSummary: summaries, | ||
| 1014 | SystemPrompt: sys, | 1015 | SystemPrompt: sys, | ||
| 1015 | MaxSteps: max, | 1016 | MaxSteps: max, | ||
| 1017 | + | DisableRepetitionGuard: args.DisableRepetitionGuard, | |||
| 1016 | ContextWindow: resolvedModel.ContextWindow, | 1018 | ContextWindow: resolvedModel.ContextWindow, | ||
| 1017 | MaxOutput: resolvedModel.MaxOutput, | 1019 | MaxOutput: resolvedModel.MaxOutput, | ||
| 1018 | modelCatalog: modelCatalog, | 1020 | modelCatalog: modelCatalog, | ||
| @@ -1355,6 +1357,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 1355 | func (r Resolved) NewAgent() *core.Agent { | 1357 | func (r Resolved) NewAgent() *core.Agent { | ||
| 1356 | a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry) | 1358 | a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry) | ||
| 1357 | a.MaxSteps = r.MaxSteps | 1359 | a.MaxSteps = r.MaxSteps | ||
| 1360 | + | a.DisableRepetitionGuard = r.DisableRepetitionGuard | |||
| 1358 | a.ContextWindow = r.ContextWindow | 1361 | a.ContextWindow = r.ContextWindow | ||
| 1359 | a.MaxTokens = r.MaxOutput | 1362 | a.MaxTokens = r.MaxOutput | ||
| 1360 | a.Reasoning = r.Reasoning | 1363 | a.Reasoning = r.Reasoning | ||
M packages/agent/cli.go +1 -0
| @@ -2370,6 +2370,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2370 | 2370 | ag = candidate.agent | |
| 2371 | 2371 | } else { | |
| 2372 | 2372 | currentAg.SetMessages(candidate.messages) | |
| 2373 | + | currentAg.ResetRepetitionGuard() | |
| 2373 | 2374 | currentAg.SeedCost(candidate.cumulative) | |
| 2374 | 2375 | currentAg.SeedLastTurnUsage(candidate.lastTurn) | |
| 2375 | 2376 | ag = currentAg | |
| @@ -2370,6 +2370,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2370 | ag = candidate.agent | 2370 | ag = candidate.agent | ||
| 2371 | } else { | 2371 | } else { | ||
| 2372 | currentAg.SetMessages(candidate.messages) | 2372 | currentAg.SetMessages(candidate.messages) | ||
| 2373 | + | currentAg.ResetRepetitionGuard() | |||
| 2373 | currentAg.SeedCost(candidate.cumulative) | 2374 | currentAg.SeedCost(candidate.cumulative) | ||
| 2374 | currentAg.SeedLastTurnUsage(candidate.lastTurn) | 2375 | currentAg.SeedLastTurnUsage(candidate.lastTurn) | ||
| 2375 | ag = currentAg | 2376 | ag = currentAg | ||
M packages/agent/modes/btw_dialog.go +1 -0
| @@ -336,6 +336,7 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 336 | 336 | func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent { | |
| 337 | 337 | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | |
| 338 | 338 | agent.MaxSteps = main.MaxSteps | |
| 339 | + | agent.DisableRepetitionGuard = main.DisableRepetitionGuard | |
| 339 | 340 | agent.Reasoning = main.Reasoning | |
| 340 | 341 | agent.Temperature = main.Temperature | |
| 341 | 342 | agent.FastMode = main.FastModeEnabled() | |
| @@ -336,6 +336,7 @@ func (d *btwDialog) submit(invalidate func()) { | |||||
| 336 | func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent { | 336 | func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent { | ||
| 337 | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | 337 | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | ||
| 338 | agent.MaxSteps = main.MaxSteps | 338 | agent.MaxSteps = main.MaxSteps | ||
| 339 | + | agent.DisableRepetitionGuard = main.DisableRepetitionGuard | |||
| 339 | agent.Reasoning = main.Reasoning | 340 | agent.Reasoning = main.Reasoning | ||
| 340 | agent.Temperature = main.Temperature | 341 | agent.Temperature = main.Temperature | ||
| 341 | agent.FastMode = main.FastModeEnabled() | 342 | agent.FastMode = main.FastModeEnabled() | ||
M packages/agent/modes/context_recovery.go +3 -3
| @@ -131,19 +131,19 @@ func compactContext(ctx context.Context, ag *core.Agent, messages []provider.Mes | |||
| 131 | 131 | ||
| 132 | 132 | compacted := ag.Messages() | |
| 133 | 133 | if err := ctx.Err(); err != nil { | |
| 134 | - | ag.SetMessages(messages) | |
| 134 | + | ag.RestoreMessages(messages) | |
| 135 | 135 | return nil, err | |
| 136 | 136 | } | |
| 137 | 137 | if persist != nil { | |
| 138 | 138 | if err := ctx.Err(); err != nil { | |
| 139 | - | ag.SetMessages(messages) | |
| 139 | + | ag.RestoreMessages(messages) | |
| 140 | 140 | return nil, err | |
| 141 | 141 | } | |
| 142 | 142 | if err := persist(compacted); err != nil { | |
| 143 | 143 | // The session did not receive the compaction checkpoint. Restore the | |
| 144 | 144 | // exact pre-compaction transcript so a later suffix flush cannot mix | |
| 145 | 145 | // the old durable generation with the new in-memory generation. | |
| 146 | - | ag.SetMessages(messages) | |
| 146 | + | ag.RestoreMessages(messages) | |
| 147 | 147 | return nil, fmt.Errorf("persist compacted transcript: %w", err) | |
| 148 | 148 | } | |
| 149 | 149 | } | |
| @@ -131,19 +131,19 @@ func compactContext(ctx context.Context, ag *core.Agent, messages []provider.Mes | |||||
| 131 | 131 | ||||
| 132 | compacted := ag.Messages() | 132 | compacted := ag.Messages() | ||
| 133 | if err := ctx.Err(); err != nil { | 133 | if err := ctx.Err(); err != nil { | ||
| 134 | - | ag.SetMessages(messages) | 134 | + | ag.RestoreMessages(messages) |
| 135 | return nil, err | 135 | return nil, err | ||
| 136 | } | 136 | } | ||
| 137 | if persist != nil { | 137 | if persist != nil { | ||
| 138 | if err := ctx.Err(); err != nil { | 138 | if err := ctx.Err(); err != nil { | ||
| 139 | - | ag.SetMessages(messages) | 139 | + | ag.RestoreMessages(messages) |
| 140 | return nil, err | 140 | return nil, err | ||
| 141 | } | 141 | } | ||
| 142 | if err := persist(compacted); err != nil { | 142 | if err := persist(compacted); err != nil { | ||
| 143 | // The session did not receive the compaction checkpoint. Restore the | 143 | // The session did not receive the compaction checkpoint. Restore the | ||
| 144 | // exact pre-compaction transcript so a later suffix flush cannot mix | 144 | // exact pre-compaction transcript so a later suffix flush cannot mix | ||
| 145 | // the old durable generation with the new in-memory generation. | 145 | // the old durable generation with the new in-memory generation. | ||
| 146 | - | ag.SetMessages(messages) | 146 | + | ag.RestoreMessages(messages) |
| 147 | return nil, fmt.Errorf("persist compacted transcript: %w", err) | 147 | return nil, fmt.Errorf("persist compacted transcript: %w", err) | ||
| 148 | } | 148 | } | ||
| 149 | } | 149 | } | ||
M packages/agent/modes/goal.go +3 -0
| @@ -476,6 +476,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 476 | 476 | // the command owns the next wake, not the pending ordinary prompt. | |
| 477 | 477 | if startIfIdle { | |
| 478 | 478 | i.clearInterruptedGoalReturn() | |
| 479 | + | if i.agent != nil { | |
| 480 | + | i.agent.ResetRepetitionGuard() | |
| 481 | + | } | |
| 479 | 482 | } | |
| 480 | 483 | i.setGoalStatus(current) | |
| 481 | 484 | i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) | |
| @@ -476,6 +476,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||||
| 476 | // the command owns the next wake, not the pending ordinary prompt. | 476 | // the command owns the next wake, not the pending ordinary prompt. | ||
| 477 | if startIfIdle { | 477 | if startIfIdle { | ||
| 478 | i.clearInterruptedGoalReturn() | 478 | i.clearInterruptedGoalReturn() | ||
| 479 | + | if i.agent != nil { | |||
| 480 | + | i.agent.ResetRepetitionGuard() | |||
| 481 | + | } | |||
| 479 | } | 482 | } | ||
| 480 | i.setGoalStatus(current) | 483 | i.setGoalStatus(current) | ||
| 481 | i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) | 484 | i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) | ||
M packages/agent/modes/goal_runtime.go +18 -0
| @@ -134,6 +134,24 @@ func (i *Interactive) finishGoalRun(contextLimited bool) bool { | |||
| 134 | 134 | return i.finishGoalRunCancelled(contextLimited, false) | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | + | func (i *Interactive) stallGoalRun(run *goalContinuationRun, reason string) bool { | |
| 138 | + | if run == nil || i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 142 | + | if goal == nil || goal.Status != core.GoalActive || run.goalID != "" && goal.ID != run.goalID { | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | goal.Status = core.GoalStalled | |
| 146 | + | goal.Reason = reason | |
| 147 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |
| 148 | + | i.ReportError(fmt.Errorf("persist stalled goal: %w", err)) | |
| 149 | + | return false | |
| 150 | + | } | |
| 151 | + | i.setGoalStatus(goal) | |
| 152 | + | return true | |
| 153 | + | } | |
| 154 | + | ||
| 137 | 155 | // finishGoalRunCancelled is finishGoalRun with an explicit cancellation | |
| 138 | 156 | // signal from the turn owner. Esc-armed reassessment relies on this so an | |
| 139 | 157 | // interrupted corrective continuation cannot stall the still-active goal. | |
| @@ -134,6 +134,24 @@ func (i *Interactive) finishGoalRun(contextLimited bool) bool { | |||||
| 134 | return i.finishGoalRunCancelled(contextLimited, false) | 134 | return i.finishGoalRunCancelled(contextLimited, false) | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | + | func (i *Interactive) stallGoalRun(run *goalContinuationRun, reason string) bool { | |||
| 138 | + | if run == nil || i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 142 | + | if goal == nil || goal.Status != core.GoalActive || run.goalID != "" && goal.ID != run.goalID { | |||
| 143 | + | return false | |||
| 144 | + | } | |||
| 145 | + | goal.Status = core.GoalStalled | |||
| 146 | + | goal.Reason = reason | |||
| 147 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |||
| 148 | + | i.ReportError(fmt.Errorf("persist stalled goal: %w", err)) | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | i.setGoalStatus(goal) | |||
| 152 | + | return true | |||
| 153 | + | } | |||
| 154 | + | ||||
| 137 | // finishGoalRunCancelled is finishGoalRun with an explicit cancellation | 155 | // finishGoalRunCancelled is finishGoalRun with an explicit cancellation | ||
| 138 | // signal from the turn owner. Esc-armed reassessment relies on this so an | 156 | // signal from the turn owner. Esc-armed reassessment relies on this so an | ||
| 139 | // interrupted corrective continuation cannot stall the still-active goal. | 157 | // interrupted corrective continuation cannot stall the still-active goal. | ||
M packages/agent/modes/interactive_commands.go +1 -0
| @@ -230,6 +230,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 230 | 230 | clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0 | |
| 231 | 231 | if i.agent != nil { | |
| 232 | 232 | i.agent.SetMessages(nil) | |
| 233 | + | i.agent.ResetRepetitionGuard() | |
| 233 | 234 | i.agent.SetPlan(nil) | |
| 234 | 235 | } | |
| 235 | 236 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| @@ -230,6 +230,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 230 | clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0 | 230 | clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0 | ||
| 231 | if i.agent != nil { | 231 | if i.agent != nil { | ||
| 232 | i.agent.SetMessages(nil) | 232 | i.agent.SetMessages(nil) | ||
| 233 | + | i.agent.ResetRepetitionGuard() | |||
| 233 | i.agent.SetPlan(nil) | 234 | i.agent.SetPlan(nil) | ||
| 234 | } | 235 | } | ||
| 235 | i.toolCalls = map[string]*tui.ToolCallView{} | 236 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
M packages/agent/modes/interactive_events.go +9 -0
| @@ -2,6 +2,7 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -222,6 +223,14 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 222 | 223 | // does not read as a stall. The inner continuation is owned by | |
| 223 | 224 | // core, so no goal or scheduler follow-up is started here. | |
| 224 | 225 | i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received.")) | |
| 226 | + | case core.EvRepetitionGuard: | |
| 227 | + | switch e.Stage { | |
| 228 | + | case core.RepetitionGuardWarning: | |
| 229 | + | i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Repeated content detected; trying a different approach.")) | |
| 230 | + | case core.RepetitionGuardStopped: | |
| 231 | + | i.statusErr = fmt.Sprintf("stopped a repetitive loop after %d identical attempts", e.Count) | |
| 232 | + | i.statusOK = "" | |
| 233 | + | } | |
| 225 | 234 | case core.EvTurnEnd: | |
| 226 | 235 | if e.Stop == provider.StopAborted { | |
| 227 | 236 | i.resetStreamingStateLocked() | |
| @@ -2,6 +2,7 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -222,6 +223,14 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 222 | // does not read as a stall. The inner continuation is owned by | 223 | // does not read as a stall. The inner continuation is owned by | ||
| 223 | // core, so no goal or scheduler follow-up is started here. | 224 | // core, so no goal or scheduler follow-up is started here. | ||
| 224 | i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received.")) | 225 | i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received.")) | ||
| 226 | + | case core.EvRepetitionGuard: | |||
| 227 | + | switch e.Stage { | |||
| 228 | + | case core.RepetitionGuardWarning: | |||
| 229 | + | i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Repeated content detected; trying a different approach.")) | |||
| 230 | + | case core.RepetitionGuardStopped: | |||
| 231 | + | i.statusErr = fmt.Sprintf("stopped a repetitive loop after %d identical attempts", e.Count) | |||
| 232 | + | i.statusOK = "" | |||
| 233 | + | } | |||
| 225 | case core.EvTurnEnd: | 234 | case core.EvTurnEnd: | ||
| 226 | if e.Stop == provider.StopAborted { | 235 | if e.Stop == provider.StopAborted { | ||
| 227 | i.resetStreamingStateLocked() | 236 | i.resetStreamingStateLocked() | ||
M packages/agent/modes/interactive_host.go +6 -4
| @@ -324,16 +324,18 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput | |||
| 324 | 324 | i.startQueuedTurn(i.runCtx, message) | |
| 325 | 325 | } | |
| 326 | 326 | ||
| 327 | - | // discardQueuedMessagesLocked drops stale input after an error or cancellation. | |
| 328 | - | // Host evidence survives errors in the agent queue for the next explicit turn; | |
| 329 | - | // it must not automatically retry the failed provider or compaction request. | |
| 330 | - | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | |
| 327 | + | func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage { | |
| 331 | 328 | var pending []core.QueuedMessage | |
| 332 | 329 | if i.agent != nil { | |
| 333 | 330 | pending = i.agent.DrainQueuedMessages() | |
| 334 | 331 | } | |
| 335 | 332 | pending = append(pending, i.queued...) | |
| 336 | 333 | i.queued = nil | |
| 334 | + | return pending | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | |
| 338 | + | pending := i.takeQueuedMessagesLocked() | |
| 337 | 339 | if !preserveHostEvents { | |
| 338 | 340 | return | |
| 339 | 341 | } | |
| @@ -324,16 +324,18 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput | |||||
| 324 | i.startQueuedTurn(i.runCtx, message) | 324 | i.startQueuedTurn(i.runCtx, message) | ||
| 325 | } | 325 | } | ||
| 326 | 326 | ||||
| 327 | - | // discardQueuedMessagesLocked drops stale input after an error or cancellation. | 327 | + | func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage { |
| 328 | - | // Host evidence survives errors in the agent queue for the next explicit turn; | |||
| 329 | - | // it must not automatically retry the failed provider or compaction request. | |||
| 330 | - | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | |||
| 331 | var pending []core.QueuedMessage | 328 | var pending []core.QueuedMessage | ||
| 332 | if i.agent != nil { | 329 | if i.agent != nil { | ||
| 333 | pending = i.agent.DrainQueuedMessages() | 330 | pending = i.agent.DrainQueuedMessages() | ||
| 334 | } | 331 | } | ||
| 335 | pending = append(pending, i.queued...) | 332 | pending = append(pending, i.queued...) | ||
| 336 | i.queued = nil | 333 | i.queued = nil | ||
| 334 | + | return pending | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | |||
| 338 | + | pending := i.takeQueuedMessagesLocked() | |||
| 337 | if !preserveHostEvents { | 339 | if !preserveHostEvents { | ||
| 338 | return | 340 | return | ||
| 339 | } | 341 | } | ||
M packages/agent/modes/interactive_rendering.go +1 -1
| @@ -1148,7 +1148,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message | |||
| 1148 | 1148 | return out | |
| 1149 | 1149 | } | |
| 1150 | 1150 | func isHiddenTranscriptMessage(m provider.Message) bool { | |
| 1151 | - | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" || m.Meta[goalInterruptReassessMetaKey] == "true" { | |
| 1151 | + | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" || m.Meta[goalInterruptReassessMetaKey] == "true" || m.Meta[core.RepetitionGuardMetaKey] == "true" { | |
| 1152 | 1152 | return true | |
| 1153 | 1153 | } | |
| 1154 | 1154 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | |
| @@ -1148,7 +1148,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message | |||||
| 1148 | return out | 1148 | return out | ||
| 1149 | } | 1149 | } | ||
| 1150 | func isHiddenTranscriptMessage(m provider.Message) bool { | 1150 | func isHiddenTranscriptMessage(m provider.Message) bool { | ||
| 1151 | - | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" || m.Meta[goalInterruptReassessMetaKey] == "true" { | 1151 | + | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" || m.Meta[goalInterruptReassessMetaKey] == "true" || m.Meta[core.RepetitionGuardMetaKey] == "true" { |
| 1152 | return true | 1152 | return true | ||
| 1153 | } | 1153 | } | ||
| 1154 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | 1154 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | ||
M packages/agent/modes/interactive_turn.go +48 -5
| @@ -3,6 +3,7 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "unicode" | |
| 8 | 9 | "unicode/utf8" | |
| @@ -142,6 +143,9 @@ func (i *Interactive) startQueuedTurn(parent context.Context, message core.Queue | |||
| 142 | 143 | i.startTurnWithImages(parent, message.Text, message.Images) | |
| 143 | 144 | } | |
| 144 | 145 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) { | |
| 146 | + | if !continueExisting && i.agent != nil { | |
| 147 | + | i.agent.ResetRepetitionGuard() | |
| 148 | + | } | |
| 145 | 149 | if i.agent == nil { | |
| 146 | 150 | // Text startup pre cannot run without credentials; continue so | |
| 147 | 151 | // deferred InitialInput (pre-fill or auto-submit) still applies. | |
| @@ -332,14 +336,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 332 | 336 | // conversation. FlushSession is idempotent (it advances the | |
| 333 | 337 | // baseline so subsequent flushes only write new rows). | |
| 334 | 338 | flush := i.cfg.FlushSession | |
| 339 | + | goalRun := i.goalRun | |
| 335 | 340 | i.mu.Unlock() | |
| 336 | 341 | runPendingIdleWork(pendingIdleWork) | |
| 337 | 342 | if flush != nil { | |
| 338 | 343 | flush() | |
| 339 | 344 | } | |
| 345 | + | stalledGoal := false | |
| 346 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |
| 347 | + | stalledGoal = i.stallGoalRun(goalRun, err.Error()) | |
| 348 | + | } | |
| 340 | 349 | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | |
| 341 | - | if terminalGoalError { | |
| 342 | - | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | |
| 350 | + | if terminalGoalError && !stalledGoal { | |
| 351 | + | status, reason := core.GoalBlocked, "turn ended with an error" | |
| 352 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |
| 353 | + | status, reason = core.GoalStalled, err.Error() | |
| 354 | + | } | |
| 355 | + | i.updateActiveGoal(status, reason) | |
| 343 | 356 | } | |
| 344 | 357 | // A cancelled or failed turn unwinds without scheduling autonomous | |
| 345 | 358 | // work below. Preserve a freshly armed Esc return for the next | |
| @@ -355,16 +368,35 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 355 | 368 | // Cancellation is user action, not model inaction: unwind the lease | |
| 356 | 369 | // without consuming the no-progress allowance or stalling the goal. | |
| 357 | 370 | continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil) | |
| 371 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |
| 372 | + | continueGoal = false | |
| 373 | + | } | |
| 358 | 374 | i.mu.Lock() | |
| 359 | 375 | awaitingPre := i.awaitingStartupPre | |
| 360 | 376 | // A newer explicit prompt may have cleared the handoff while the | |
| 361 | 377 | // completed turn was being flushed. Re-read it under the mutex before | |
| 362 | 378 | // deciding whether this continuation may spend another rescue attempt. | |
| 363 | 379 | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | |
| 380 | + | // A fresh user prompt typed during a repetitive loop is an explicit | |
| 381 | + | // request, not an automatic retry. Preserve it and start a new Prompt. | |
| 382 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |
| 383 | + | pending := i.takeQueuedMessagesLocked() | |
| 384 | + | for _, message := range pending { | |
| 385 | + | if message.HostEvent { | |
| 386 | + | if i.agent != nil { | |
| 387 | + | i.agent.QueuePrompt(message) | |
| 388 | + | } else { | |
| 389 | + | i.queued = append(i.queued, message) | |
| 390 | + | } | |
| 391 | + | continue | |
| 392 | + | } | |
| 393 | + | i.queued = append(i.queued, message) | |
| 394 | + | } | |
| 395 | + | } | |
| 364 | 396 | // Pop the next queued message, if any, and relaunch. | |
| 365 | 397 | var next core.QueuedMessage | |
| 366 | 398 | var hasNext bool | |
| 367 | - | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 399 | + | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && (err == nil || errors.Is(err, core.ErrRepetitiveLoop)) { | |
| 368 | 400 | next, i.queued = i.queued[0], i.queued[1:] | |
| 369 | 401 | hasNext = true | |
| 370 | 402 | } | |
| @@ -372,12 +404,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 372 | 404 | // user isn't bombarded with stale messages after an interrupt. | |
| 373 | 405 | // Scheduler follow-ups deliberately remain: they are distinct turns | |
| 374 | 406 | // whose due time must not let them steer this failed turn. | |
| 375 | - | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | |
| 407 | + | if ctx.Err() != nil || (err != nil && !recoverContextOverflow && !errors.Is(err, core.ErrRepetitiveLoop)) { | |
| 376 | 408 | i.discardQueuedMessagesLocked(ctx.Err() == nil) | |
| 377 | 409 | } | |
| 378 | 410 | var scheduled scheduledFollowUp | |
| 379 | 411 | var hasScheduled bool | |
| 380 | - | if !awaitingPre && !hasNext && len(i.scheduled) > 0 { | |
| 412 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |
| 413 | + | for _, followUp := range i.scheduled { | |
| 414 | + | followUp.accepted <- err | |
| 415 | + | } | |
| 416 | + | i.scheduled = nil | |
| 417 | + | } else if !awaitingPre && !hasNext && len(i.scheduled) > 0 { | |
| 381 | 418 | scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:] | |
| 382 | 419 | hasScheduled = true | |
| 383 | 420 | } | |
| @@ -471,6 +508,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 471 | 508 | return | |
| 472 | 509 | } | |
| 473 | 510 | switch { | |
| 511 | + | case errors.Is(err, core.ErrRepetitiveLoop): | |
| 512 | + | // Keep the interactive session open and honor a user prompt queued | |
| 513 | + | // during the stopped run, but do not resume autonomous work. | |
| 514 | + | if hasNext { | |
| 515 | + | i.startQueuedTurn(parent, next) | |
| 516 | + | } | |
| 474 | 517 | case hasNext: | |
| 475 | 518 | i.startQueuedTurn(parent, next) | |
| 476 | 519 | case hasScheduled: | |
| @@ -3,6 +3,7 @@ package modes | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "unicode" | 8 | "unicode" | ||
| 8 | "unicode/utf8" | 9 | "unicode/utf8" | ||
| @@ -142,6 +143,9 @@ func (i *Interactive) startQueuedTurn(parent context.Context, message core.Queue | |||||
| 142 | i.startTurnWithImages(parent, message.Text, message.Images) | 143 | i.startTurnWithImages(parent, message.Text, message.Images) | ||
| 143 | } | 144 | } | ||
| 144 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) { | 145 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) { | ||
| 146 | + | if !continueExisting && i.agent != nil { | |||
| 147 | + | i.agent.ResetRepetitionGuard() | |||
| 148 | + | } | |||
| 145 | if i.agent == nil { | 149 | if i.agent == nil { | ||
| 146 | // Text startup pre cannot run without credentials; continue so | 150 | // Text startup pre cannot run without credentials; continue so | ||
| 147 | // deferred InitialInput (pre-fill or auto-submit) still applies. | 151 | // deferred InitialInput (pre-fill or auto-submit) still applies. | ||
| @@ -332,14 +336,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 332 | // conversation. FlushSession is idempotent (it advances the | 336 | // conversation. FlushSession is idempotent (it advances the | ||
| 333 | // baseline so subsequent flushes only write new rows). | 337 | // baseline so subsequent flushes only write new rows). | ||
| 334 | flush := i.cfg.FlushSession | 338 | flush := i.cfg.FlushSession | ||
| 339 | + | goalRun := i.goalRun | |||
| 335 | i.mu.Unlock() | 340 | i.mu.Unlock() | ||
| 336 | runPendingIdleWork(pendingIdleWork) | 341 | runPendingIdleWork(pendingIdleWork) | ||
| 337 | if flush != nil { | 342 | if flush != nil { | ||
| 338 | flush() | 343 | flush() | ||
| 339 | } | 344 | } | ||
| 345 | + | stalledGoal := false | |||
| 346 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |||
| 347 | + | stalledGoal = i.stallGoalRun(goalRun, err.Error()) | |||
| 348 | + | } | |||
| 340 | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | 349 | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | ||
| 341 | - | if terminalGoalError { | 350 | + | if terminalGoalError && !stalledGoal { |
| 342 | - | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | 351 | + | status, reason := core.GoalBlocked, "turn ended with an error" |
| 352 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |||
| 353 | + | status, reason = core.GoalStalled, err.Error() | |||
| 354 | + | } | |||
| 355 | + | i.updateActiveGoal(status, reason) | |||
| 343 | } | 356 | } | ||
| 344 | // A cancelled or failed turn unwinds without scheduling autonomous | 357 | // A cancelled or failed turn unwinds without scheduling autonomous | ||
| 345 | // work below. Preserve a freshly armed Esc return for the next | 358 | // work below. Preserve a freshly armed Esc return for the next | ||
| @@ -355,16 +368,35 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 355 | // Cancellation is user action, not model inaction: unwind the lease | 368 | // Cancellation is user action, not model inaction: unwind the lease | ||
| 356 | // without consuming the no-progress allowance or stalling the goal. | 369 | // without consuming the no-progress allowance or stalling the goal. | ||
| 357 | continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil) | 370 | continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil) | ||
| 371 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |||
| 372 | + | continueGoal = false | |||
| 373 | + | } | |||
| 358 | i.mu.Lock() | 374 | i.mu.Lock() | ||
| 359 | awaitingPre := i.awaitingStartupPre | 375 | awaitingPre := i.awaitingStartupPre | ||
| 360 | // A newer explicit prompt may have cleared the handoff while the | 376 | // A newer explicit prompt may have cleared the handoff while the | ||
| 361 | // completed turn was being flushed. Re-read it under the mutex before | 377 | // completed turn was being flushed. Re-read it under the mutex before | ||
| 362 | // deciding whether this continuation may spend another rescue attempt. | 378 | // deciding whether this continuation may spend another rescue attempt. | ||
| 363 | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | 379 | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | ||
| 380 | + | // A fresh user prompt typed during a repetitive loop is an explicit | |||
| 381 | + | // request, not an automatic retry. Preserve it and start a new Prompt. | |||
| 382 | + | if errors.Is(err, core.ErrRepetitiveLoop) { | |||
| 383 | + | pending := i.takeQueuedMessagesLocked() | |||
| 384 | + | for _, message := range pending { | |||
| 385 | + | if message.HostEvent { | |||
| 386 | + | if i.agent != nil { | |||
| 387 | + | i.agent.QueuePrompt(message) | |||
| 388 | + | } else { | |||
| 389 | + | i.queued = append(i.queued, message) | |||
| 390 | + | } | |||
| 391 | + | continue | |||
| 392 | + | } | |||
| 393 | + | i.queued = append(i.queued, message) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 364 | // Pop the next queued message, if any, and relaunch. | 396 | // Pop the next queued message, if any, and relaunch. | ||
| 365 | var next core.QueuedMessage | 397 | var next core.QueuedMessage | ||
| 366 | var hasNext bool | 398 | var hasNext bool | ||
| 367 | - | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | 399 | + | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && (err == nil || errors.Is(err, core.ErrRepetitiveLoop)) { |
| 368 | next, i.queued = i.queued[0], i.queued[1:] | 400 | next, i.queued = i.queued[0], i.queued[1:] | ||
| 369 | hasNext = true | 401 | hasNext = true | ||
| 370 | } | 402 | } | ||
| @@ -372,12 +404,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 372 | // user isn't bombarded with stale messages after an interrupt. | 404 | // user isn't bombarded with stale messages after an interrupt. | ||
| 373 | // Scheduler follow-ups deliberately remain: they are distinct turns | 405 | // Scheduler follow-ups deliberately remain: they are distinct turns | ||
| 374 | // whose due time must not let them steer this failed turn. | 406 | // whose due time must not let them steer this failed turn. | ||
| 375 | - | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | 407 | + | if ctx.Err() != nil || (err != nil && !recoverContextOverflow && !errors.Is(err, core.ErrRepetitiveLoop)) { |
| 376 | i.discardQueuedMessagesLocked(ctx.Err() == nil) | 408 | i.discardQueuedMessagesLocked(ctx.Err() == nil) | ||
| 377 | } | 409 | } | ||
| 378 | var scheduled scheduledFollowUp | 410 | var scheduled scheduledFollowUp | ||
| 379 | var hasScheduled bool | 411 | var hasScheduled bool | ||
| 380 | - | if !awaitingPre && !hasNext && len(i.scheduled) > 0 { | 412 | + | if errors.Is(err, core.ErrRepetitiveLoop) { |
| 413 | + | for _, followUp := range i.scheduled { | |||
| 414 | + | followUp.accepted <- err | |||
| 415 | + | } | |||
| 416 | + | i.scheduled = nil | |||
| 417 | + | } else if !awaitingPre && !hasNext && len(i.scheduled) > 0 { | |||
| 381 | scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:] | 418 | scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:] | ||
| 382 | hasScheduled = true | 419 | hasScheduled = true | ||
| 383 | } | 420 | } | ||
| @@ -471,6 +508,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 471 | return | 508 | return | ||
| 472 | } | 509 | } | ||
| 473 | switch { | 510 | switch { | ||
| 511 | + | case errors.Is(err, core.ErrRepetitiveLoop): | |||
| 512 | + | // Keep the interactive session open and honor a user prompt queued | |||
| 513 | + | // during the stopped run, but do not resume autonomous work. | |||
| 514 | + | if hasNext { | |||
| 515 | + | i.startQueuedTurn(parent, next) | |||
| 516 | + | } | |||
| 474 | case hasNext: | 517 | case hasNext: | ||
| 475 | i.startQueuedTurn(parent, next) | 518 | i.startQueuedTurn(parent, next) | ||
| 476 | case hasScheduled: | 519 | case hasScheduled: | ||
M packages/agent/modes/json.go +5 -0
| @@ -147,6 +147,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||
| 147 | 147 | case core.EvTurnRecovery: | |
| 148 | 148 | m["reason"] = e.Reason | |
| 149 | 149 | m["attempt"] = e.Attempt | |
| 150 | + | case core.EvRepetitionGuard: | |
| 151 | + | m["kind"] = e.Kind | |
| 152 | + | m["stage"] = e.Stage | |
| 153 | + | m["count"] = e.Count | |
| 154 | + | m["elapsed_ms"] = e.Elapsed.Milliseconds() | |
| 150 | 155 | case core.EvTurnEnd: | |
| 151 | 156 | m["stop"] = string(e.Stop) | |
| 152 | 157 | if e.Err != nil { | |
| @@ -147,6 +147,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 147 | case core.EvTurnRecovery: | 147 | case core.EvTurnRecovery: | ||
| 148 | m["reason"] = e.Reason | 148 | m["reason"] = e.Reason | ||
| 149 | m["attempt"] = e.Attempt | 149 | m["attempt"] = e.Attempt | ||
| 150 | + | case core.EvRepetitionGuard: | |||
| 151 | + | m["kind"] = e.Kind | |||
| 152 | + | m["stage"] = e.Stage | |||
| 153 | + | m["count"] = e.Count | |||
| 154 | + | m["elapsed_ms"] = e.Elapsed.Milliseconds() | |||
| 150 | case core.EvTurnEnd: | 155 | case core.EvTurnEnd: | ||
| 151 | m["stop"] = string(e.Stop) | 156 | m["stop"] = string(e.Stop) | ||
| 152 | if e.Err != nil { | 157 | if e.Err != nil { | ||
M packages/agent/modes/print.go +7 -0
| @@ -59,6 +59,13 @@ func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt str | |||
| 59 | 59 | // exhausted allowance returns ErrIncompleteTurn from Prompt | |
| 60 | 60 | // and never reaches here as success. | |
| 61 | 61 | fmt.Fprintln(os.Stderr, "Continuing: no final answer received.") | |
| 62 | + | case core.EvRepetitionGuard: | |
| 63 | + | switch e.Stage { | |
| 64 | + | case core.RepetitionGuardWarning: | |
| 65 | + | fmt.Fprintf(os.Stderr, "Repeated content detected (%d occurrences); asking the agent to try another approach.\n", e.Count) | |
| 66 | + | case core.RepetitionGuardStopped: | |
| 67 | + | fmt.Fprintf(os.Stderr, "Stopped a repetitive loop after %d occurrences.\n", e.Count) | |
| 68 | + | } | |
| 62 | 69 | case core.EvTurnEnd: | |
| 63 | 70 | if e.Err != nil { | |
| 64 | 71 | runErr = e.Err | |
| @@ -59,6 +59,13 @@ func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt str | |||||
| 59 | // exhausted allowance returns ErrIncompleteTurn from Prompt | 59 | // exhausted allowance returns ErrIncompleteTurn from Prompt | ||
| 60 | // and never reaches here as success. | 60 | // and never reaches here as success. | ||
| 61 | fmt.Fprintln(os.Stderr, "Continuing: no final answer received.") | 61 | fmt.Fprintln(os.Stderr, "Continuing: no final answer received.") | ||
| 62 | + | case core.EvRepetitionGuard: | |||
| 63 | + | switch e.Stage { | |||
| 64 | + | case core.RepetitionGuardWarning: | |||
| 65 | + | fmt.Fprintf(os.Stderr, "Repeated content detected (%d occurrences); asking the agent to try another approach.\n", e.Count) | |||
| 66 | + | case core.RepetitionGuardStopped: | |||
| 67 | + | fmt.Fprintf(os.Stderr, "Stopped a repetitive loop after %d occurrences.\n", e.Count) | |||
| 68 | + | } | |||
| 62 | case core.EvTurnEnd: | 69 | case core.EvTurnEnd: | ||
| 63 | if e.Err != nil { | 70 | if e.Err != nil { | ||
| 64 | runErr = e.Err | 71 | runErr = e.Err | ||
A packages/agent/modes/repetition_guard_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type repetitiveInteractiveRequest struct { | |
| 17 | + | call int32 | |
| 18 | + | hasQueuedPrompt bool | |
| 19 | + | userText []string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type repetitiveInteractiveClient struct { | |
| 23 | + | calls atomic.Int32 | |
| 24 | + | entered chan struct{} | |
| 25 | + | resume chan struct{} | |
| 26 | + | requests chan repetitiveInteractiveRequest | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (*repetitiveInteractiveClient) Name() string { return "repetitive-interactive-test" } | |
| 30 | + | ||
| 31 | + | func (c *repetitiveInteractiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 32 | + | call := c.calls.Add(1) | |
| 33 | + | hasQueuedPrompt := false | |
| 34 | + | var userText []string | |
| 35 | + | for _, message := range req.Messages { | |
| 36 | + | if message.Role != provider.RoleUser { | |
| 37 | + | continue | |
| 38 | + | } | |
| 39 | + | for _, content := range message.Content { | |
| 40 | + | if text, ok := content.(provider.TextBlock); ok { | |
| 41 | + | userText = append(userText, text.Text) | |
| 42 | + | if strings.Contains(text.Text, "new request") { | |
| 43 | + | hasQueuedPrompt = true | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if c.requests != nil { | |
| 49 | + | c.requests <- repetitiveInteractiveRequest{call: call, hasQueuedPrompt: hasQueuedPrompt, userText: userText} | |
| 50 | + | } | |
| 51 | + | if call == 8 && c.entered != nil { | |
| 52 | + | close(c.entered) | |
| 53 | + | <-c.resume | |
| 54 | + | } | |
| 55 | + | if call > 8 { | |
| 56 | + | out := make(chan provider.Event, 1) | |
| 57 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 58 | + | Role: provider.RoleAssistant, | |
| 59 | + | Content: []provider.Content{provider.TextBlock{Text: "answered"}}, | |
| 60 | + | }} | |
| 61 | + | close(out) | |
| 62 | + | return out, nil | |
| 63 | + | } | |
| 64 | + | out := make(chan provider.Event, 4) | |
| 65 | + | out <- provider.EventStart{Provider: c.Name(), Model: req.Model} | |
| 66 | + | out <- provider.EventToolStart{ID: "call", Name: "repeat"} | |
| 67 | + | out <- provider.EventToolEnd{ID: "call"} | |
| 68 | + | out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{ | |
| 69 | + | Role: provider.RoleAssistant, | |
| 70 | + | Content: []provider.Content{provider.ToolCallBlock{ | |
| 71 | + | ID: "call", Name: "repeat", Arguments: json.RawMessage(`{}`), | |
| 72 | + | }}, | |
| 73 | + | }} | |
| 74 | + | close(out) | |
| 75 | + | return out, nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | type repetitiveInteractiveTool struct{} | |
| 79 | + | ||
| 80 | + | func (*repetitiveInteractiveTool) Name() string { return "repeat" } | |
| 81 | + | func (*repetitiveInteractiveTool) Description() string { return "repeats" } | |
| 82 | + | func (*repetitiveInteractiveTool) Schema() json.RawMessage { | |
| 83 | + | return json.RawMessage(`{"type":"object"}`) | |
| 84 | + | } | |
| 85 | + | func (*repetitiveInteractiveTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) { | |
| 86 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "same"}}}, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestInteractiveRepetitionGuardPreservesQueuedUserPrompt(t *testing.T) { | |
| 90 | + | client := &repetitiveInteractiveClient{ | |
| 91 | + | entered: make(chan struct{}), | |
| 92 | + | resume: make(chan struct{}), | |
| 93 | + | requests: make(chan repetitiveInteractiveRequest, 9), | |
| 94 | + | } | |
| 95 | + | defer func() { | |
| 96 | + | select { | |
| 97 | + | case <-client.resume: | |
| 98 | + | default: | |
| 99 | + | close(client.resume) | |
| 100 | + | } | |
| 101 | + | }() | |
| 102 | + | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | |
| 103 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 104 | + | ||
| 105 | + | interactive.startTurn(context.Background(), "start") | |
| 106 | + | select { | |
| 107 | + | case <-client.entered: | |
| 108 | + | case <-time.After(3 * time.Second): | |
| 109 | + | t.Fatal("eighth provider request did not start") | |
| 110 | + | } | |
| 111 | + | interactive.submitOrQueue("new request", nil, true) | |
| 112 | + | for call := 1; call <= 8; call++ { | |
| 113 | + | select { | |
| 114 | + | case request := <-client.requests: | |
| 115 | + | if request.call != int32(call) || request.hasQueuedPrompt { | |
| 116 | + | t.Fatalf("provider request before loop stop = %#v; want call %d without queued prompt", request, call) | |
| 117 | + | } | |
| 118 | + | if call == 8 && len(request.userText) == 0 { | |
| 119 | + | t.Fatal("eighth provider request was not observed; queueing raced with loop teardown") | |
| 120 | + | } | |
| 121 | + | case <-time.After(3 * time.Second): | |
| 122 | + | t.Fatalf("provider request %d was not observed", call) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | close(client.resume) | |
| 126 | + | ||
| 127 | + | select { | |
| 128 | + | case request := <-client.requests: | |
| 129 | + | if request.call != 9 || !request.hasQueuedPrompt { | |
| 130 | + | t.Fatalf("provider request after loop stop = %#v, want call 9 with queued user prompt", request) | |
| 131 | + | } | |
| 132 | + | case <-time.After(3 * time.Second): | |
| 133 | + | t.Fatal("queued user prompt was not sent after loop stop") | |
| 134 | + | } | |
| 135 | + | waitInteractiveIdle(t, interactive) | |
| 136 | + | if got := client.calls.Load(); got != 9 { | |
| 137 | + | t.Fatalf("provider calls = %d, want 9 including the user prompt", got) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) { | |
| 142 | + | client := &repetitiveInteractiveClient{} | |
| 143 | + | store := &interruptGoalStore{} | |
| 144 | + | registry := goalToolRegistry() | |
| 145 | + | registry["repeat"] = &repetitiveInteractiveTool{} | |
| 146 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 147 | + | Agent: core.NewAgent(client, "model", "system", registry), | |
| 148 | + | CurrentGoal: store.current, | |
| 149 | + | PersistGoal: store.persist, | |
| 150 | + | PersistGoalRuntime: store.persist, | |
| 151 | + | }) | |
| 152 | + | interactive.runCtx = context.Background() | |
| 153 | + | ||
| 154 | + | interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"}) | |
| 155 | + | waitInteractiveIdle(t, interactive) | |
| 156 | + | if got := store.current(); got == nil || got.Status != core.GoalStalled { | |
| 157 | + | t.Fatalf("goal after repetitive loop = %#v, want stalled", got) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | interactive.runGoalCommand(context.Background(), "/goal resume", []string{"/goal", "resume"}) | |
| 161 | + | waitInteractiveIdle(t, interactive) | |
| 162 | + | if got := client.calls.Load(); got < 9 { | |
| 163 | + | t.Fatalf("provider requests after resume = %d, want at least 9", got) | |
| 164 | + | } | |
| 165 | + | if got := store.current(); got == nil || (got.Status != core.GoalActive && got.Status != core.GoalStalled) { | |
| 166 | + | t.Fatalf("goal after resume = %#v, want an answered goal run", got) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | |
| 171 | + | client := &repetitiveInteractiveClient{} | |
| 172 | + | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | |
| 173 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 174 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 175 | + | defer cancel() | |
| 176 | + | ||
| 177 | + | interactive.startTurn(ctx, "start") | |
| 178 | + | followUp := make(chan error, 1) | |
| 179 | + | go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }() | |
| 180 | + | ||
| 181 | + | select { | |
| 182 | + | case err := <-followUp: | |
| 183 | + | if !errors.Is(err, core.ErrRepetitiveLoop) { | |
| 184 | + | t.Fatalf("scheduled follow-up error = %v, want ErrRepetitiveLoop", err) | |
| 185 | + | } | |
| 186 | + | case <-time.After(3 * time.Second): | |
| 187 | + | t.Fatal("scheduled follow-up remained blocked after repetitive loop stop") | |
| 188 | + | } | |
| 189 | + | if got := client.calls.Load(); got != 8 { | |
| 190 | + | t.Fatalf("provider calls = %d, want 8", got) | |
| 191 | + | } | |
| 192 | + | } | |
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zut/packages/core" | |||
| 13 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type repetitiveInteractiveRequest struct { | |||
| 17 | + | call int32 | |||
| 18 | + | hasQueuedPrompt bool | |||
| 19 | + | userText []string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type repetitiveInteractiveClient struct { | |||
| 23 | + | calls atomic.Int32 | |||
| 24 | + | entered chan struct{} | |||
| 25 | + | resume chan struct{} | |||
| 26 | + | requests chan repetitiveInteractiveRequest | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (*repetitiveInteractiveClient) Name() string { return "repetitive-interactive-test" } | |||
| 30 | + | ||||
| 31 | + | func (c *repetitiveInteractiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 32 | + | call := c.calls.Add(1) | |||
| 33 | + | hasQueuedPrompt := false | |||
| 34 | + | var userText []string | |||
| 35 | + | for _, message := range req.Messages { | |||
| 36 | + | if message.Role != provider.RoleUser { | |||
| 37 | + | continue | |||
| 38 | + | } | |||
| 39 | + | for _, content := range message.Content { | |||
| 40 | + | if text, ok := content.(provider.TextBlock); ok { | |||
| 41 | + | userText = append(userText, text.Text) | |||
| 42 | + | if strings.Contains(text.Text, "new request") { | |||
| 43 | + | hasQueuedPrompt = true | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | if c.requests != nil { | |||
| 49 | + | c.requests <- repetitiveInteractiveRequest{call: call, hasQueuedPrompt: hasQueuedPrompt, userText: userText} | |||
| 50 | + | } | |||
| 51 | + | if call == 8 && c.entered != nil { | |||
| 52 | + | close(c.entered) | |||
| 53 | + | <-c.resume | |||
| 54 | + | } | |||
| 55 | + | if call > 8 { | |||
| 56 | + | out := make(chan provider.Event, 1) | |||
| 57 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |||
| 58 | + | Role: provider.RoleAssistant, | |||
| 59 | + | Content: []provider.Content{provider.TextBlock{Text: "answered"}}, | |||
| 60 | + | }} | |||
| 61 | + | close(out) | |||
| 62 | + | return out, nil | |||
| 63 | + | } | |||
| 64 | + | out := make(chan provider.Event, 4) | |||
| 65 | + | out <- provider.EventStart{Provider: c.Name(), Model: req.Model} | |||
| 66 | + | out <- provider.EventToolStart{ID: "call", Name: "repeat"} | |||
| 67 | + | out <- provider.EventToolEnd{ID: "call"} | |||
| 68 | + | out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{ | |||
| 69 | + | Role: provider.RoleAssistant, | |||
| 70 | + | Content: []provider.Content{provider.ToolCallBlock{ | |||
| 71 | + | ID: "call", Name: "repeat", Arguments: json.RawMessage(`{}`), | |||
| 72 | + | }}, | |||
| 73 | + | }} | |||
| 74 | + | close(out) | |||
| 75 | + | return out, nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | type repetitiveInteractiveTool struct{} | |||
| 79 | + | ||||
| 80 | + | func (*repetitiveInteractiveTool) Name() string { return "repeat" } | |||
| 81 | + | func (*repetitiveInteractiveTool) Description() string { return "repeats" } | |||
| 82 | + | func (*repetitiveInteractiveTool) Schema() json.RawMessage { | |||
| 83 | + | return json.RawMessage(`{"type":"object"}`) | |||
| 84 | + | } | |||
| 85 | + | func (*repetitiveInteractiveTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) { | |||
| 86 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "same"}}}, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestInteractiveRepetitionGuardPreservesQueuedUserPrompt(t *testing.T) { | |||
| 90 | + | client := &repetitiveInteractiveClient{ | |||
| 91 | + | entered: make(chan struct{}), | |||
| 92 | + | resume: make(chan struct{}), | |||
| 93 | + | requests: make(chan repetitiveInteractiveRequest, 9), | |||
| 94 | + | } | |||
| 95 | + | defer func() { | |||
| 96 | + | select { | |||
| 97 | + | case <-client.resume: | |||
| 98 | + | default: | |||
| 99 | + | close(client.resume) | |||
| 100 | + | } | |||
| 101 | + | }() | |||
| 102 | + | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | |||
| 103 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 104 | + | ||||
| 105 | + | interactive.startTurn(context.Background(), "start") | |||
| 106 | + | select { | |||
| 107 | + | case <-client.entered: | |||
| 108 | + | case <-time.After(3 * time.Second): | |||
| 109 | + | t.Fatal("eighth provider request did not start") | |||
| 110 | + | } | |||
| 111 | + | interactive.submitOrQueue("new request", nil, true) | |||
| 112 | + | for call := 1; call <= 8; call++ { | |||
| 113 | + | select { | |||
| 114 | + | case request := <-client.requests: | |||
| 115 | + | if request.call != int32(call) || request.hasQueuedPrompt { | |||
| 116 | + | t.Fatalf("provider request before loop stop = %#v; want call %d without queued prompt", request, call) | |||
| 117 | + | } | |||
| 118 | + | if call == 8 && len(request.userText) == 0 { | |||
| 119 | + | t.Fatal("eighth provider request was not observed; queueing raced with loop teardown") | |||
| 120 | + | } | |||
| 121 | + | case <-time.After(3 * time.Second): | |||
| 122 | + | t.Fatalf("provider request %d was not observed", call) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | close(client.resume) | |||
| 126 | + | ||||
| 127 | + | select { | |||
| 128 | + | case request := <-client.requests: | |||
| 129 | + | if request.call != 9 || !request.hasQueuedPrompt { | |||
| 130 | + | t.Fatalf("provider request after loop stop = %#v, want call 9 with queued user prompt", request) | |||
| 131 | + | } | |||
| 132 | + | case <-time.After(3 * time.Second): | |||
| 133 | + | t.Fatal("queued user prompt was not sent after loop stop") | |||
| 134 | + | } | |||
| 135 | + | waitInteractiveIdle(t, interactive) | |||
| 136 | + | if got := client.calls.Load(); got != 9 { | |||
| 137 | + | t.Fatalf("provider calls = %d, want 9 including the user prompt", got) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) { | |||
| 142 | + | client := &repetitiveInteractiveClient{} | |||
| 143 | + | store := &interruptGoalStore{} | |||
| 144 | + | registry := goalToolRegistry() | |||
| 145 | + | registry["repeat"] = &repetitiveInteractiveTool{} | |||
| 146 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 147 | + | Agent: core.NewAgent(client, "model", "system", registry), | |||
| 148 | + | CurrentGoal: store.current, | |||
| 149 | + | PersistGoal: store.persist, | |||
| 150 | + | PersistGoalRuntime: store.persist, | |||
| 151 | + | }) | |||
| 152 | + | interactive.runCtx = context.Background() | |||
| 153 | + | ||||
| 154 | + | interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"}) | |||
| 155 | + | waitInteractiveIdle(t, interactive) | |||
| 156 | + | if got := store.current(); got == nil || got.Status != core.GoalStalled { | |||
| 157 | + | t.Fatalf("goal after repetitive loop = %#v, want stalled", got) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | interactive.runGoalCommand(context.Background(), "/goal resume", []string{"/goal", "resume"}) | |||
| 161 | + | waitInteractiveIdle(t, interactive) | |||
| 162 | + | if got := client.calls.Load(); got < 9 { | |||
| 163 | + | t.Fatalf("provider requests after resume = %d, want at least 9", got) | |||
| 164 | + | } | |||
| 165 | + | if got := store.current(); got == nil || (got.Status != core.GoalActive && got.Status != core.GoalStalled) { | |||
| 166 | + | t.Fatalf("goal after resume = %#v, want an answered goal run", got) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | |||
| 171 | + | client := &repetitiveInteractiveClient{} | |||
| 172 | + | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | |||
| 173 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 174 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 175 | + | defer cancel() | |||
| 176 | + | ||||
| 177 | + | interactive.startTurn(ctx, "start") | |||
| 178 | + | followUp := make(chan error, 1) | |||
| 179 | + | go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }() | |||
| 180 | + | ||||
| 181 | + | select { | |||
| 182 | + | case err := <-followUp: | |||
| 183 | + | if !errors.Is(err, core.ErrRepetitiveLoop) { | |||
| 184 | + | t.Fatalf("scheduled follow-up error = %v, want ErrRepetitiveLoop", err) | |||
| 185 | + | } | |||
| 186 | + | case <-time.After(3 * time.Second): | |||
| 187 | + | t.Fatal("scheduled follow-up remained blocked after repetitive loop stop") | |||
| 188 | + | } | |||
| 189 | + | if got := client.calls.Load(); got != 8 { | |||
| 190 | + | t.Fatalf("provider calls = %d, want 8", got) | |||
| 191 | + | } | |||
| 192 | + | } | |||
M packages/agent/modes/stream.go +7 -0
| @@ -103,6 +103,13 @@ func RunStreamWithContextRecovery(ctx context.Context, ag *core.Agent, prompt st | |||
| 103 | 103 | // Assistant text stays on stdout; the recovery notice is a | |
| 104 | 104 | // host diagnostic and belongs on the diag channel. | |
| 105 | 105 | fmt.Fprintln(diag, "Continuing: no final answer received.") | |
| 106 | + | case core.EvRepetitionGuard: | |
| 107 | + | switch e.Stage { | |
| 108 | + | case core.RepetitionGuardWarning: | |
| 109 | + | fmt.Fprintf(diag, "Repeated content detected (%d occurrences); asking the agent to try another approach.\n", e.Count) | |
| 110 | + | case core.RepetitionGuardStopped: | |
| 111 | + | fmt.Fprintf(diag, "Stopped a repetitive loop after %d occurrences.\n", e.Count) | |
| 112 | + | } | |
| 106 | 113 | case core.EvTurnEnd: | |
| 107 | 114 | if e.Err != nil { | |
| 108 | 115 | runErr = e.Err | |
| @@ -103,6 +103,13 @@ func RunStreamWithContextRecovery(ctx context.Context, ag *core.Agent, prompt st | |||||
| 103 | // Assistant text stays on stdout; the recovery notice is a | 103 | // Assistant text stays on stdout; the recovery notice is a | ||
| 104 | // host diagnostic and belongs on the diag channel. | 104 | // host diagnostic and belongs on the diag channel. | ||
| 105 | fmt.Fprintln(diag, "Continuing: no final answer received.") | 105 | fmt.Fprintln(diag, "Continuing: no final answer received.") | ||
| 106 | + | case core.EvRepetitionGuard: | |||
| 107 | + | switch e.Stage { | |||
| 108 | + | case core.RepetitionGuardWarning: | |||
| 109 | + | fmt.Fprintf(diag, "Repeated content detected (%d occurrences); asking the agent to try another approach.\n", e.Count) | |||
| 110 | + | case core.RepetitionGuardStopped: | |||
| 111 | + | fmt.Fprintf(diag, "Stopped a repetitive loop after %d occurrences.\n", e.Count) | |||
| 112 | + | } | |||
| 106 | case core.EvTurnEnd: | 113 | case core.EvTurnEnd: | ||
| 107 | if e.Err != nil { | 114 | if e.Err != nil { | ||
| 108 | runErr = e.Err | 115 | runErr = e.Err | ||
M packages/agent/plan_modes_persist_test.go +45 -0
| @@ -56,6 +56,51 @@ func TestBotSessionPersistsPlanMutation(t *testing.T) { | |||
| 56 | 56 | // TestScheduledSessionPersistsPlanMutation guards GO-002 for the scheduled mode: | |
| 57 | 57 | // a scheduled turn that calls plan must persist the mutation to the session it | |
| 58 | 58 | // reconstructed. | |
| 59 | + | func TestScheduledSessionDisablesRepetitionGuard(t *testing.T) { | |
| 60 | + | home := t.TempDir() | |
| 61 | + | cwd := t.TempDir() | |
| 62 | + | t.Setenv("ZUT_HOME", home) | |
| 63 | + | sess, err := core.NewSession(home, cwd, "openai", "gpt-5", "test") | |
| 64 | + | if err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | id, path := sess.Meta.ID, sess.Path | |
| 68 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 69 | + | _, _ = io.ReadAll(r.Body) | |
| 70 | + | w.Header().Set("content-type", "text/event-stream") | |
| 71 | + | chunk := map[string]any{ | |
| 72 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "done"}, "finish_reason": "stop"}}, | |
| 73 | + | "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2}, | |
| 74 | + | } | |
| 75 | + | data, _ := json.Marshal(chunk) | |
| 76 | + | _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data) | |
| 77 | + | })) | |
| 78 | + | defer server.Close() | |
| 79 | + | ||
| 80 | + | args := Args{Provider: "openai", Model: "gpt-5", BaseURL: server.URL, APIKey: "test-key"} | |
| 81 | + | base, err := Resolve(args, true) | |
| 82 | + | if err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | managedPath, err := core.ImportSession(path, home, cwd, "test") | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | if err := runScheduledSession(context.Background(), scheduler.Task{ID: "task-guard", SessionID: id, Message: "scheduled task"}, args, base, nil, nil); err != nil { | |
| 90 | + | t.Fatal(err) | |
| 91 | + | } | |
| 92 | + | reopened, messages, err := core.OpenSession(managedPath) | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | defer reopened.Close() | |
| 97 | + | for _, message := range messages { | |
| 98 | + | if message.Meta[core.RepetitionGuardMetaKey] == "true" { | |
| 99 | + | t.Fatalf("scheduled run unexpectedly injected repetition warning: %#v", message) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 59 | 104 | func TestScheduledSessionPersistsPlanMutation(t *testing.T) { | |
| 60 | 105 | home := t.TempDir() | |
| 61 | 106 | t.Setenv("ZUT_HOME", home) | |
| @@ -56,6 +56,51 @@ func TestBotSessionPersistsPlanMutation(t *testing.T) { | |||||
| 56 | // TestScheduledSessionPersistsPlanMutation guards GO-002 for the scheduled mode: | 56 | // TestScheduledSessionPersistsPlanMutation guards GO-002 for the scheduled mode: | ||
| 57 | // a scheduled turn that calls plan must persist the mutation to the session it | 57 | // a scheduled turn that calls plan must persist the mutation to the session it | ||
| 58 | // reconstructed. | 58 | // reconstructed. | ||
| 59 | + | func TestScheduledSessionDisablesRepetitionGuard(t *testing.T) { | |||
| 60 | + | home := t.TempDir() | |||
| 61 | + | cwd := t.TempDir() | |||
| 62 | + | t.Setenv("ZUT_HOME", home) | |||
| 63 | + | sess, err := core.NewSession(home, cwd, "openai", "gpt-5", "test") | |||
| 64 | + | if err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | id, path := sess.Meta.ID, sess.Path | |||
| 68 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 69 | + | _, _ = io.ReadAll(r.Body) | |||
| 70 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 71 | + | chunk := map[string]any{ | |||
| 72 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "done"}, "finish_reason": "stop"}}, | |||
| 73 | + | "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2}, | |||
| 74 | + | } | |||
| 75 | + | data, _ := json.Marshal(chunk) | |||
| 76 | + | _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data) | |||
| 77 | + | })) | |||
| 78 | + | defer server.Close() | |||
| 79 | + | ||||
| 80 | + | args := Args{Provider: "openai", Model: "gpt-5", BaseURL: server.URL, APIKey: "test-key"} | |||
| 81 | + | base, err := Resolve(args, true) | |||
| 82 | + | if err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | managedPath, err := core.ImportSession(path, home, cwd, "test") | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | if err := runScheduledSession(context.Background(), scheduler.Task{ID: "task-guard", SessionID: id, Message: "scheduled task"}, args, base, nil, nil); err != nil { | |||
| 90 | + | t.Fatal(err) | |||
| 91 | + | } | |||
| 92 | + | reopened, messages, err := core.OpenSession(managedPath) | |||
| 93 | + | if err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | defer reopened.Close() | |||
| 97 | + | for _, message := range messages { | |||
| 98 | + | if message.Meta[core.RepetitionGuardMetaKey] == "true" { | |||
| 99 | + | t.Fatalf("scheduled run unexpectedly injected repetition warning: %#v", message) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 59 | func TestScheduledSessionPersistsPlanMutation(t *testing.T) { | 104 | func TestScheduledSessionPersistsPlanMutation(t *testing.T) { | ||
| 60 | home := t.TempDir() | 105 | home := t.TempDir() | ||
| 61 | t.Setenv("ZUT_HOME", home) | 106 | t.Setenv("ZUT_HOME", home) | ||
A packages/agent/repetition_guard_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestParseArgsDisablesRepetitionGuard(t *testing.T) { | |
| 6 | + | args, err := ParseArgs([]string{"--no-repetition-guard", "inspect this"}) | |
| 7 | + | if err != nil { | |
| 8 | + | t.Fatalf("ParseArgs failed: %v", err) | |
| 9 | + | } | |
| 10 | + | if !args.DisableRepetitionGuard { | |
| 11 | + | t.Fatal("DisableRepetitionGuard = false, want true") | |
| 12 | + | } | |
| 13 | + | if args.Prompt != "inspect this" { | |
| 14 | + | t.Fatalf("prompt = %q, want %q", args.Prompt, "inspect this") | |
| 15 | + | } | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestParseArgsDisablesRepetitionGuard(t *testing.T) { | |||
| 6 | + | args, err := ParseArgs([]string{"--no-repetition-guard", "inspect this"}) | |||
| 7 | + | if err != nil { | |||
| 8 | + | t.Fatalf("ParseArgs failed: %v", err) | |||
| 9 | + | } | |||
| 10 | + | if !args.DisableRepetitionGuard { | |||
| 11 | + | t.Fatal("DisableRepetitionGuard = false, want true") | |||
| 12 | + | } | |||
| 13 | + | if args.Prompt != "inspect this" { | |||
| 14 | + | t.Fatalf("prompt = %q, want %q", args.Prompt, "inspect this") | |||
| 15 | + | } | |||
| 16 | + | } | |||
M packages/agent/rpc.go +1 -0
| @@ -349,6 +349,7 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||
| 349 | 349 | ||
| 350 | 350 | case "clear": | |
| 351 | 351 | s.agent.SetMessages(nil) | |
| 352 | + | s.agent.ResetRepetitionGuard() | |
| 352 | 353 | s.agent.SetPlan(nil) | |
| 353 | 354 | s.writeResponse(id, cmd, nil) | |
| 354 | 355 | ||
| @@ -349,6 +349,7 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||||
| 349 | 349 | ||||
| 350 | case "clear": | 350 | case "clear": | ||
| 351 | s.agent.SetMessages(nil) | 351 | s.agent.SetMessages(nil) | ||
| 352 | + | s.agent.ResetRepetitionGuard() | |||
| 352 | s.agent.SetPlan(nil) | 353 | s.agent.SetPlan(nil) | ||
| 353 | s.writeResponse(id, cmd, nil) | 354 | s.writeResponse(id, cmd, nil) | ||
| 354 | 355 | ||||
M packages/agent/scheduler_runtime.go +1 -0
| @@ -74,6 +74,7 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba | |||
| 74 | 74 | appendErr = err | |
| 75 | 75 | } | |
| 76 | 76 | } | |
| 77 | + | ag.DisableRepetitionGuard = true | |
| 77 | 78 | ag.OnMessageAppended = func(message provider.Message) { | |
| 78 | 79 | recordAppendError(sess.AppendMessage(message)) | |
| 79 | 80 | } | |
| @@ -74,6 +74,7 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba | |||||
| 74 | appendErr = err | 74 | appendErr = err | ||
| 75 | } | 75 | } | ||
| 76 | } | 76 | } | ||
| 77 | + | ag.DisableRepetitionGuard = true | |||
| 77 | ag.OnMessageAppended = func(message provider.Message) { | 78 | ag.OnMessageAppended = func(message provider.Message) { | ||
| 78 | recordAppendError(sess.AppendMessage(message)) | 79 | recordAppendError(sess.AppendMessage(message)) | ||
| 79 | } | 80 | } | ||
M packages/agent/sdk/sdk.go +20 -15
| @@ -71,6 +71,10 @@ type Config struct { | |||
| 71 | 71 | // 0 leaves the loop unlimited. | |
| 72 | 72 | MaxSteps int | |
| 73 | 73 | ||
| 74 | + | // DisableRepetitionGuard opts out of repeated-content detection for | |
| 75 | + | // intentional polling workflows. | |
| 76 | + | DisableRepetitionGuard bool | |
| 77 | + | ||
| 74 | 78 | // APIKey overrides the credential lookup chain. | |
| 75 | 79 | APIKey string | |
| 76 | 80 | ||
| @@ -132,21 +136,22 @@ func NewContext(ctx context.Context, cfg Config) (*Runtime, error) { | |||
| 132 | 136 | } | |
| 133 | 137 | } | |
| 134 | 138 | args := agent.Args{ | |
| 135 | - | Mode: agent.ModeJSON, // headless | |
| 136 | - | Provider: cfg.Provider, | |
| 137 | - | Model: cfg.Model, | |
| 138 | - | CWD: cfg.CWD, | |
| 139 | - | APIKey: cfg.APIKey, | |
| 140 | - | BaseURL: cfg.BaseURL, | |
| 141 | - | SystemPrompt: cfg.SystemPrompt, | |
| 142 | - | AppendSystemPrompt: cfg.AppendSystemPrompt, | |
| 143 | - | Reasoning: cfg.Reasoning, | |
| 144 | - | Temperature: cfg.Temperature, | |
| 145 | - | MaxSteps: cfg.MaxSteps, | |
| 146 | - | Tools: cfg.Tools, | |
| 147 | - | NoTools: cfg.NoTools, | |
| 148 | - | WebSearchPolicy: webSearchPolicy, | |
| 149 | - | NoSess: true, // SDK callers manage persistence themselves | |
| 139 | + | Mode: agent.ModeJSON, // headless | |
| 140 | + | Provider: cfg.Provider, | |
| 141 | + | Model: cfg.Model, | |
| 142 | + | CWD: cfg.CWD, | |
| 143 | + | APIKey: cfg.APIKey, | |
| 144 | + | BaseURL: cfg.BaseURL, | |
| 145 | + | SystemPrompt: cfg.SystemPrompt, | |
| 146 | + | AppendSystemPrompt: cfg.AppendSystemPrompt, | |
| 147 | + | Reasoning: cfg.Reasoning, | |
| 148 | + | Temperature: cfg.Temperature, | |
| 149 | + | MaxSteps: cfg.MaxSteps, | |
| 150 | + | DisableRepetitionGuard: cfg.DisableRepetitionGuard, | |
| 151 | + | Tools: cfg.Tools, | |
| 152 | + | NoTools: cfg.NoTools, | |
| 153 | + | WebSearchPolicy: webSearchPolicy, | |
| 154 | + | NoSess: true, // SDK callers manage persistence themselves | |
| 150 | 155 | } | |
| 151 | 156 | r, err := agent.ResolveSDK(ctx, args) | |
| 152 | 157 | if err != nil { | |
| @@ -71,6 +71,10 @@ type Config struct { | |||||
| 71 | // 0 leaves the loop unlimited. | 71 | // 0 leaves the loop unlimited. | ||
| 72 | MaxSteps int | 72 | MaxSteps int | ||
| 73 | 73 | ||||
| 74 | + | // DisableRepetitionGuard opts out of repeated-content detection for | |||
| 75 | + | // intentional polling workflows. | |||
| 76 | + | DisableRepetitionGuard bool | |||
| 77 | + | ||||
| 74 | // APIKey overrides the credential lookup chain. | 78 | // APIKey overrides the credential lookup chain. | ||
| 75 | APIKey string | 79 | APIKey string | ||
| 76 | 80 | ||||
| @@ -132,21 +136,22 @@ func NewContext(ctx context.Context, cfg Config) (*Runtime, error) { | |||||
| 132 | } | 136 | } | ||
| 133 | } | 137 | } | ||
| 134 | args := agent.Args{ | 138 | args := agent.Args{ | ||
| 135 | - | Mode: agent.ModeJSON, // headless | 139 | + | Mode: agent.ModeJSON, // headless |
| 136 | - | Provider: cfg.Provider, | 140 | + | Provider: cfg.Provider, |
| 137 | - | Model: cfg.Model, | 141 | + | Model: cfg.Model, |
| 138 | - | CWD: cfg.CWD, | 142 | + | CWD: cfg.CWD, |
| 139 | - | APIKey: cfg.APIKey, | 143 | + | APIKey: cfg.APIKey, |
| 140 | - | BaseURL: cfg.BaseURL, | 144 | + | BaseURL: cfg.BaseURL, |
| 141 | - | SystemPrompt: cfg.SystemPrompt, | 145 | + | SystemPrompt: cfg.SystemPrompt, |
| 142 | - | AppendSystemPrompt: cfg.AppendSystemPrompt, | 146 | + | AppendSystemPrompt: cfg.AppendSystemPrompt, |
| 143 | - | Reasoning: cfg.Reasoning, | 147 | + | Reasoning: cfg.Reasoning, |
| 144 | - | Temperature: cfg.Temperature, | 148 | + | Temperature: cfg.Temperature, |
| 145 | - | MaxSteps: cfg.MaxSteps, | 149 | + | MaxSteps: cfg.MaxSteps, |
| 146 | - | Tools: cfg.Tools, | 150 | + | DisableRepetitionGuard: cfg.DisableRepetitionGuard, |
| 147 | - | NoTools: cfg.NoTools, | 151 | + | Tools: cfg.Tools, |
| 148 | - | WebSearchPolicy: webSearchPolicy, | 152 | + | NoTools: cfg.NoTools, |
| 149 | - | NoSess: true, // SDK callers manage persistence themselves | 153 | + | WebSearchPolicy: webSearchPolicy, |
| 154 | + | NoSess: true, // SDK callers manage persistence themselves | |||
| 150 | } | 155 | } | ||
| 151 | r, err := agent.ResolveSDK(ctx, args) | 156 | r, err := agent.ResolveSDK(ctx, args) | ||
| 152 | if err != nil { | 157 | if err != nil { | ||
M packages/agent/sdk/types.go +11 -0
| @@ -56,6 +56,12 @@ type Event struct { | |||
| 56 | 56 | // user_message / assistant_message | |
| 57 | 57 | Message *Message `json:"message,omitempty"` | |
| 58 | 58 | ||
| 59 | + | // repetition_guard | |
| 60 | + | RepetitionKind string `json:"kind,omitempty"` | |
| 61 | + | RepetitionStage string `json:"stage,omitempty"` | |
| 62 | + | RepetitionCount int `json:"count,omitempty"` | |
| 63 | + | RepetitionElapsed int64 `json:"elapsed_ms,omitempty"` | |
| 64 | + | ||
| 59 | 65 | // turn_end | |
| 60 | 66 | Stop string `json:"stop,omitempty"` | |
| 61 | 67 | ||
| @@ -239,6 +245,11 @@ func toEvent(ev core.AgentEvent) Event { | |||
| 239 | 245 | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | |
| 240 | 246 | CostUSD: e.Cumulative.CostUSD, | |
| 241 | 247 | } | |
| 248 | + | case core.EvRepetitionGuard: | |
| 249 | + | out.RepetitionKind = string(e.Kind) | |
| 250 | + | out.RepetitionStage = string(e.Stage) | |
| 251 | + | out.RepetitionCount = e.Count | |
| 252 | + | out.RepetitionElapsed = e.Elapsed.Milliseconds() | |
| 242 | 253 | case core.EvTurnEnd: | |
| 243 | 254 | out.Stop = string(e.Stop) | |
| 244 | 255 | if e.Err != nil { | |
| @@ -56,6 +56,12 @@ type Event struct { | |||||
| 56 | // user_message / assistant_message | 56 | // user_message / assistant_message | ||
| 57 | Message *Message `json:"message,omitempty"` | 57 | Message *Message `json:"message,omitempty"` | ||
| 58 | 58 | ||||
| 59 | + | // repetition_guard | |||
| 60 | + | RepetitionKind string `json:"kind,omitempty"` | |||
| 61 | + | RepetitionStage string `json:"stage,omitempty"` | |||
| 62 | + | RepetitionCount int `json:"count,omitempty"` | |||
| 63 | + | RepetitionElapsed int64 `json:"elapsed_ms,omitempty"` | |||
| 64 | + | ||||
| 59 | // turn_end | 65 | // turn_end | ||
| 60 | Stop string `json:"stop,omitempty"` | 66 | Stop string `json:"stop,omitempty"` | ||
| 61 | 67 | ||||
| @@ -239,6 +245,11 @@ func toEvent(ev core.AgentEvent) Event { | |||||
| 239 | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | 245 | CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens, | ||
| 240 | CostUSD: e.Cumulative.CostUSD, | 246 | CostUSD: e.Cumulative.CostUSD, | ||
| 241 | } | 247 | } | ||
| 248 | + | case core.EvRepetitionGuard: | |||
| 249 | + | out.RepetitionKind = string(e.Kind) | |||
| 250 | + | out.RepetitionStage = string(e.Stage) | |||
| 251 | + | out.RepetitionCount = e.Count | |||
| 252 | + | out.RepetitionElapsed = e.Elapsed.Milliseconds() | |||
| 242 | case core.EvTurnEnd: | 253 | case core.EvTurnEnd: | ||
| 243 | out.Stop = string(e.Stop) | 254 | out.Stop = string(e.Stop) | ||
| 244 | if e.Err != nil { | 255 | if e.Err != nil { | ||
M packages/agent/subagent_factory.go +5 -3
| @@ -42,8 +42,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 42 | 42 | // A resident child inherits the parent's resolved step limit, including the | |
| 43 | 43 | // unlimited default. Its ceiling is the provider context window, with one | |
| 44 | 44 | // compaction recovery per accepted turn, plus any explicit parent | |
| 45 | - | // --max-steps. There is no child-only step budget. | |
| 45 | + | // --max-steps. There is no child-only step budget. The explicit | |
| 46 | + | // repetition-guard opt-out applies to child turns as well. | |
| 46 | 47 | agent.MaxSteps = resolved.MaxSteps | |
| 48 | + | agent.DisableRepetitionGuard = resolved.DisableRepetitionGuard | |
| 47 | 49 | agent.ContextWindow = resolved.ContextWindow | |
| 48 | 50 | agent.MaxTokens = resolved.MaxOutput | |
| 49 | 51 | agent.Reasoning = resolved.Reasoning | |
| @@ -151,13 +153,13 @@ func recoverResidentContextOverflow(ctx context.Context, agent *core.Agent, jour | |||
| 151 | 153 | return overflowErr | |
| 152 | 154 | } | |
| 153 | 155 | if _, err := agent.CompactWithEvents(ctx, residentContextRecoveryKeepTail, sink); err != nil { | |
| 154 | - | agent.SetMessages(before) | |
| 156 | + | agent.RestoreMessages(before) | |
| 155 | 157 | return errors.Join(overflowErr, fmt.Errorf("compact resident child transcript: %w", err)) | |
| 156 | 158 | } | |
| 157 | 159 | if err := journal.RecordCompacted(residentCheckpointMessages(agent.Messages())); err != nil { | |
| 158 | 160 | // Memory must not run ahead of durable history: a later resume would | |
| 159 | 161 | // otherwise replay the pre-compaction transcript. | |
| 160 | - | agent.SetMessages(before) | |
| 162 | + | agent.RestoreMessages(before) | |
| 161 | 163 | return errors.Join(overflowErr, fmt.Errorf("persist resident child compaction: %w", err)) | |
| 162 | 164 | } | |
| 163 | 165 | // The summarization request's usage describes the pre-compaction | |
| @@ -42,8 +42,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 42 | // A resident child inherits the parent's resolved step limit, including the | 42 | // A resident child inherits the parent's resolved step limit, including the | ||
| 43 | // unlimited default. Its ceiling is the provider context window, with one | 43 | // unlimited default. Its ceiling is the provider context window, with one | ||
| 44 | // compaction recovery per accepted turn, plus any explicit parent | 44 | // compaction recovery per accepted turn, plus any explicit parent | ||
| 45 | - | // --max-steps. There is no child-only step budget. | 45 | + | // --max-steps. There is no child-only step budget. The explicit |
| 46 | + | // repetition-guard opt-out applies to child turns as well. | |||
| 46 | agent.MaxSteps = resolved.MaxSteps | 47 | agent.MaxSteps = resolved.MaxSteps | ||
| 48 | + | agent.DisableRepetitionGuard = resolved.DisableRepetitionGuard | |||
| 47 | agent.ContextWindow = resolved.ContextWindow | 49 | agent.ContextWindow = resolved.ContextWindow | ||
| 48 | agent.MaxTokens = resolved.MaxOutput | 50 | agent.MaxTokens = resolved.MaxOutput | ||
| 49 | agent.Reasoning = resolved.Reasoning | 51 | agent.Reasoning = resolved.Reasoning | ||
| @@ -151,13 +153,13 @@ func recoverResidentContextOverflow(ctx context.Context, agent *core.Agent, jour | |||||
| 151 | return overflowErr | 153 | return overflowErr | ||
| 152 | } | 154 | } | ||
| 153 | if _, err := agent.CompactWithEvents(ctx, residentContextRecoveryKeepTail, sink); err != nil { | 155 | if _, err := agent.CompactWithEvents(ctx, residentContextRecoveryKeepTail, sink); err != nil { | ||
| 154 | - | agent.SetMessages(before) | 156 | + | agent.RestoreMessages(before) |
| 155 | return errors.Join(overflowErr, fmt.Errorf("compact resident child transcript: %w", err)) | 157 | return errors.Join(overflowErr, fmt.Errorf("compact resident child transcript: %w", err)) | ||
| 156 | } | 158 | } | ||
| 157 | if err := journal.RecordCompacted(residentCheckpointMessages(agent.Messages())); err != nil { | 159 | if err := journal.RecordCompacted(residentCheckpointMessages(agent.Messages())); err != nil { | ||
| 158 | // Memory must not run ahead of durable history: a later resume would | 160 | // Memory must not run ahead of durable history: a later resume would | ||
| 159 | // otherwise replay the pre-compaction transcript. | 161 | // otherwise replay the pre-compaction transcript. | ||
| 160 | - | agent.SetMessages(before) | 162 | + | agent.RestoreMessages(before) |
| 161 | return errors.Join(overflowErr, fmt.Errorf("persist resident child compaction: %w", err)) | 163 | return errors.Join(overflowErr, fmt.Errorf("persist resident child compaction: %w", err)) | ||
| 162 | } | 164 | } | ||
| 163 | // The summarization request's usage describes the pre-compaction | 165 | // The summarization request's usage describes the pre-compaction | ||
M packages/agent/subagent_factory_test.go +1 -1
| @@ -25,7 +25,7 @@ func TestResidentChildRunnerWithoutStepLimitRunsPastFormerCap(t *testing.T) { | |||
| 25 | 25 | server, requests := residentToolLoopServer(t, 51) | |
| 26 | 26 | runner, _, _ := newResidentChildTestRunner(t, "unlimited-child", Args{ | |
| 27 | 27 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | |
| 28 | - | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | |
| 28 | + | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true, | |
| 29 | 29 | }) | |
| 30 | 30 | if err := runner(t.Context(), "review"); err != nil { | |
| 31 | 31 | t.Fatalf("runner error = %v, want success past the former 50-step cap", err) | |
| @@ -25,7 +25,7 @@ func TestResidentChildRunnerWithoutStepLimitRunsPastFormerCap(t *testing.T) { | |||||
| 25 | server, requests := residentToolLoopServer(t, 51) | 25 | server, requests := residentToolLoopServer(t, 51) | ||
| 26 | runner, _, _ := newResidentChildTestRunner(t, "unlimited-child", Args{ | 26 | runner, _, _ := newResidentChildTestRunner(t, "unlimited-child", Args{ | ||
| 27 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | 27 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | ||
| 28 | - | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | 28 | + | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true, |
| 29 | }) | 29 | }) | ||
| 30 | if err := runner(t.Context(), "review"); err != nil { | 30 | if err := runner(t.Context(), "review"); err != nil { | ||
| 31 | t.Fatalf("runner error = %v, want success past the former 50-step cap", err) | 31 | t.Fatalf("runner error = %v, want success past the former 50-step cap", err) | ||
M packages/agent/subagents/resident_history.go +26 -6
| @@ -36,11 +36,15 @@ type ResidentHistoryItem struct { | |||
| 36 | 36 | // transcript of a child that recovered from a context overflow. It is | |
| 37 | 37 | // produced only for residentRecordCompacted items, which the UI pager does | |
| 38 | 38 | // not expose. | |
| 39 | - | Messages []json.RawMessage `json:"messages,omitempty"` | |
| 40 | - | ToolID string `json:"tool_id,omitempty"` | |
| 41 | - | ToolName string `json:"tool_name,omitempty"` | |
| 42 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 43 | - | ToolResult json.RawMessage `json:"tool_result,omitempty"` | |
| 39 | + | Messages []json.RawMessage `json:"messages,omitempty"` | |
| 40 | + | ToolID string `json:"tool_id,omitempty"` | |
| 41 | + | ToolName string `json:"tool_name,omitempty"` | |
| 42 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 43 | + | ToolResult json.RawMessage `json:"tool_result,omitempty"` | |
| 44 | + | Outcome string `json:"outcome,omitempty"` | |
| 45 | + | GuardKind string `json:"guard_kind,omitempty"` | |
| 46 | + | GuardCount int `json:"guard_count,omitempty"` | |
| 47 | + | GuardElapsed string `json:"guard_elapsed,omitempty"` | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | // ResidentHistoryPage is a recent-first, bounded page of finalized history. | |
| @@ -277,6 +281,12 @@ func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, erro | |||
| 277 | 281 | return ResidentHistoryItem{}, false, errors.New("resident history: malformed tool result record") | |
| 278 | 282 | } | |
| 279 | 283 | item.ToolID, item.ToolResult = record.ToolID, append(json.RawMessage(nil), record.ToolResult...) | |
| 284 | + | case residentRecordGuard: | |
| 285 | + | if record.Outcome != string(core.RepetitionGuardWarning) && record.Outcome != string(core.RepetitionGuardStopped) { | |
| 286 | + | return ResidentHistoryItem{}, false, errors.New("resident history: malformed repetition guard record") | |
| 287 | + | } | |
| 288 | + | item.Outcome, item.GuardKind = record.Outcome, record.GuardKind | |
| 289 | + | item.GuardCount, item.GuardElapsed = record.GuardCount, record.GuardElapsed | |
| 280 | 290 | default: | |
| 281 | 291 | // Lifecycle records and compaction checkpoints are not history items: | |
| 282 | 292 | // the pager keeps showing the append-only log, while resume replay | |
| @@ -289,7 +299,7 @@ func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, erro | |||
| 289 | 299 | func residentHistoryPageStart(entries []residentHistoryEntry, limit int) int { | |
| 290 | 300 | first, fallback := -1, -1 | |
| 291 | 301 | for index := range entries { | |
| 292 | - | if entries[index].item.Type != residentRecordUser && entries[index].item.Type != residentRecordAssistant { | |
| 302 | + | if entries[index].item.Type != residentRecordUser && entries[index].item.Type != residentRecordAssistant && entries[index].item.Type != residentRecordGuard { | |
| 293 | 303 | continue | |
| 294 | 304 | } | |
| 295 | 305 | fallback = index | |
| @@ -441,6 +451,16 @@ func ReadResidentTranscriptMessages(dir string) ([]provider.Message, error) { | |||
| 441 | 451 | } | |
| 442 | 452 | items := make([]ResidentHistoryItem, 0, len(records)) | |
| 443 | 453 | for _, record := range records { | |
| 454 | + | if record.Type == residentRecordGuard { | |
| 455 | + | item, include, err := residentHistoryItem(record) | |
| 456 | + | if err != nil { | |
| 457 | + | return nil, err | |
| 458 | + | } | |
| 459 | + | if include { | |
| 460 | + | items = append(items, item) | |
| 461 | + | } | |
| 462 | + | continue | |
| 463 | + | } | |
| 444 | 464 | if record.Type == residentRecordCompacted { | |
| 445 | 465 | // The pager hides checkpoints, but resume replay starts from one. | |
| 446 | 466 | items = append(items, ResidentHistoryItem{Type: record.Type, Time: record.Time, Messages: cloneRawMessages(record.Messages)}) | |
| @@ -36,11 +36,15 @@ type ResidentHistoryItem struct { | |||||
| 36 | // transcript of a child that recovered from a context overflow. It is | 36 | // transcript of a child that recovered from a context overflow. It is | ||
| 37 | // produced only for residentRecordCompacted items, which the UI pager does | 37 | // produced only for residentRecordCompacted items, which the UI pager does | ||
| 38 | // not expose. | 38 | // not expose. | ||
| 39 | - | Messages []json.RawMessage `json:"messages,omitempty"` | 39 | + | Messages []json.RawMessage `json:"messages,omitempty"` |
| 40 | - | ToolID string `json:"tool_id,omitempty"` | 40 | + | ToolID string `json:"tool_id,omitempty"` |
| 41 | - | ToolName string `json:"tool_name,omitempty"` | 41 | + | ToolName string `json:"tool_name,omitempty"` |
| 42 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 42 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 43 | - | ToolResult json.RawMessage `json:"tool_result,omitempty"` | 43 | + | ToolResult json.RawMessage `json:"tool_result,omitempty"` |
| 44 | + | Outcome string `json:"outcome,omitempty"` | |||
| 45 | + | GuardKind string `json:"guard_kind,omitempty"` | |||
| 46 | + | GuardCount int `json:"guard_count,omitempty"` | |||
| 47 | + | GuardElapsed string `json:"guard_elapsed,omitempty"` | |||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | // ResidentHistoryPage is a recent-first, bounded page of finalized history. | 50 | // ResidentHistoryPage is a recent-first, bounded page of finalized history. | ||
| @@ -277,6 +281,12 @@ func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, erro | |||||
| 277 | return ResidentHistoryItem{}, false, errors.New("resident history: malformed tool result record") | 281 | return ResidentHistoryItem{}, false, errors.New("resident history: malformed tool result record") | ||
| 278 | } | 282 | } | ||
| 279 | item.ToolID, item.ToolResult = record.ToolID, append(json.RawMessage(nil), record.ToolResult...) | 283 | item.ToolID, item.ToolResult = record.ToolID, append(json.RawMessage(nil), record.ToolResult...) | ||
| 284 | + | case residentRecordGuard: | |||
| 285 | + | if record.Outcome != string(core.RepetitionGuardWarning) && record.Outcome != string(core.RepetitionGuardStopped) { | |||
| 286 | + | return ResidentHistoryItem{}, false, errors.New("resident history: malformed repetition guard record") | |||
| 287 | + | } | |||
| 288 | + | item.Outcome, item.GuardKind = record.Outcome, record.GuardKind | |||
| 289 | + | item.GuardCount, item.GuardElapsed = record.GuardCount, record.GuardElapsed | |||
| 280 | default: | 290 | default: | ||
| 281 | // Lifecycle records and compaction checkpoints are not history items: | 291 | // Lifecycle records and compaction checkpoints are not history items: | ||
| 282 | // the pager keeps showing the append-only log, while resume replay | 292 | // the pager keeps showing the append-only log, while resume replay | ||
| @@ -289,7 +299,7 @@ func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, erro | |||||
| 289 | func residentHistoryPageStart(entries []residentHistoryEntry, limit int) int { | 299 | func residentHistoryPageStart(entries []residentHistoryEntry, limit int) int { | ||
| 290 | first, fallback := -1, -1 | 300 | first, fallback := -1, -1 | ||
| 291 | for index := range entries { | 301 | for index := range entries { | ||
| 292 | - | if entries[index].item.Type != residentRecordUser && entries[index].item.Type != residentRecordAssistant { | 302 | + | if entries[index].item.Type != residentRecordUser && entries[index].item.Type != residentRecordAssistant && entries[index].item.Type != residentRecordGuard { |
| 293 | continue | 303 | continue | ||
| 294 | } | 304 | } | ||
| 295 | fallback = index | 305 | fallback = index | ||
| @@ -441,6 +451,16 @@ func ReadResidentTranscriptMessages(dir string) ([]provider.Message, error) { | |||||
| 441 | } | 451 | } | ||
| 442 | items := make([]ResidentHistoryItem, 0, len(records)) | 452 | items := make([]ResidentHistoryItem, 0, len(records)) | ||
| 443 | for _, record := range records { | 453 | for _, record := range records { | ||
| 454 | + | if record.Type == residentRecordGuard { | |||
| 455 | + | item, include, err := residentHistoryItem(record) | |||
| 456 | + | if err != nil { | |||
| 457 | + | return nil, err | |||
| 458 | + | } | |||
| 459 | + | if include { | |||
| 460 | + | items = append(items, item) | |||
| 461 | + | } | |||
| 462 | + | continue | |||
| 463 | + | } | |||
| 444 | if record.Type == residentRecordCompacted { | 464 | if record.Type == residentRecordCompacted { | ||
| 445 | // The pager hides checkpoints, but resume replay starts from one. | 465 | // The pager hides checkpoints, but resume replay starts from one. | ||
| 446 | items = append(items, ResidentHistoryItem{Type: record.Type, Time: record.Time, Messages: cloneRawMessages(record.Messages)}) | 466 | items = append(items, ResidentHistoryItem{Type: record.Type, Time: record.Time, Messages: cloneRawMessages(record.Messages)}) | ||
M packages/agent/subagents/resident_history_test.go +28 -0
| @@ -12,6 +12,34 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zut/packages/provider" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | func TestReadResidentHistoryPageIncludesRepetitionGuardNotice(t *testing.T) { | |
| 16 | + | root := t.TempDir() | |
| 17 | + | journal, err := OpenResidentJournal(root, "guard-child") | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | spec := ResidentChildSpec{ID: "guard-child", SessionID: "guard-session", Provider: "openai", Model: "test"} | |
| 22 | + | if err := journal.Accept(spec, "task"); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | if err := journal.RecordAgentEvent(core.EvRepetitionGuard{ | |
| 26 | + | Kind: core.RepetitionKindToolCall, Stage: core.RepetitionGuardWarning, Count: 5, | |
| 27 | + | }); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if err := journal.Close(); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 10) | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if len(page.Items) != 1 || page.Items[0].Type != residentRecordGuard || page.Items[0].Outcome != string(core.RepetitionGuardWarning) || page.Items[0].GuardCount != 5 || page.Items[0].GuardKind != string(core.RepetitionKindToolCall) { | |
| 39 | + | t.Fatalf("history items = %#v, want repetition guard warning", page.Items) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 15 | 43 | func TestReadResidentHistoryReturnsCompleteFinalizedItems(t *testing.T) { | |
| 16 | 44 | root := t.TempDir() | |
| 17 | 45 | journal, err := OpenResidentJournal(root, "history-child") | |
| @@ -12,6 +12,34 @@ import ( | |||||
| 12 | "github.com/bnema/zut/packages/provider" | 12 | "github.com/bnema/zut/packages/provider" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | + | func TestReadResidentHistoryPageIncludesRepetitionGuardNotice(t *testing.T) { | |||
| 16 | + | root := t.TempDir() | |||
| 17 | + | journal, err := OpenResidentJournal(root, "guard-child") | |||
| 18 | + | if err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | spec := ResidentChildSpec{ID: "guard-child", SessionID: "guard-session", Provider: "openai", Model: "test"} | |||
| 22 | + | if err := journal.Accept(spec, "task"); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | if err := journal.RecordAgentEvent(core.EvRepetitionGuard{ | |||
| 26 | + | Kind: core.RepetitionKindToolCall, Stage: core.RepetitionGuardWarning, Count: 5, | |||
| 27 | + | }); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if err := journal.Close(); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 10) | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | if len(page.Items) != 1 || page.Items[0].Type != residentRecordGuard || page.Items[0].Outcome != string(core.RepetitionGuardWarning) || page.Items[0].GuardCount != 5 || page.Items[0].GuardKind != string(core.RepetitionKindToolCall) { | |||
| 39 | + | t.Fatalf("history items = %#v, want repetition guard warning", page.Items) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 15 | func TestReadResidentHistoryReturnsCompleteFinalizedItems(t *testing.T) { | 43 | func TestReadResidentHistoryReturnsCompleteFinalizedItems(t *testing.T) { | ||
| 16 | root := t.TempDir() | 44 | root := t.TempDir() | ||
| 17 | journal, err := OpenResidentJournal(root, "history-child") | 45 | journal, err := OpenResidentJournal(root, "history-child") | ||
M packages/agent/subagents/resident_journal.go +14 -0
| @@ -38,6 +38,7 @@ const ( | |||
| 38 | 38 | residentRecordToolResult = "tool.result" | |
| 39 | 39 | residentRecordCompacted = "child.compacted" | |
| 40 | 40 | residentRecordUsage = "usage" | |
| 41 | + | residentRecordGuard = "repetition.guard" | |
| 41 | 42 | residentJournalVersion = 3 | |
| 42 | 43 | residentJournalVersionV2 = 2 | |
| 43 | 44 | residentInterruptedText = "tool interrupted by resident host restart" | |
| @@ -119,6 +120,9 @@ type residentRecord struct { | |||
| 119 | 120 | TurnID string `json:"turn_id,omitempty"` | |
| 120 | 121 | Prompt string `json:"prompt,omitempty"` | |
| 121 | 122 | Outcome string `json:"outcome,omitempty"` | |
| 123 | + | GuardKind string `json:"guard_kind,omitempty"` | |
| 124 | + | GuardCount int `json:"guard_count,omitempty"` | |
| 125 | + | GuardElapsed string `json:"guard_elapsed,omitempty"` | |
| 122 | 126 | PatchRef string `json:"patch_ref,omitempty"` | |
| 123 | 127 | ChangedFiles []string `json:"changed_files,omitempty"` | |
| 124 | 128 | Message json.RawMessage `json:"message,omitempty"` | |
| @@ -167,6 +171,11 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||
| 167 | 171 | return fmt.Errorf("resident journal encode tool result: %w", err) | |
| 168 | 172 | } | |
| 169 | 173 | record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result} | |
| 174 | + | case core.EvRepetitionGuard: | |
| 175 | + | record = residentRecord{ | |
| 176 | + | Type: residentRecordGuard, Outcome: string(value.Stage), GuardKind: string(value.Kind), | |
| 177 | + | GuardCount: value.Count, GuardElapsed: value.Elapsed.Round(time.Second).String(), | |
| 178 | + | } | |
| 170 | 179 | case core.EvUsage: | |
| 171 | 180 | j.usageMu.RLock() | |
| 172 | 181 | contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription | |
| @@ -890,6 +899,11 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 890 | 899 | } | |
| 891 | 900 | seenTurns[record.TurnID] = residentRecordTurnAccepted | |
| 892 | 901 | lastStateAt = record.Time | |
| 902 | + | case residentRecordGuard: | |
| 903 | + | if record.Outcome != string(core.RepetitionGuardWarning) && record.Outcome != string(core.RepetitionGuardStopped) { | |
| 904 | + | return ResidentMetadata{}, errors.New("resident journal: invalid repetition guard stage") | |
| 905 | + | } | |
| 906 | + | lastStateAt = record.Time | |
| 893 | 907 | case residentRecordTurnStarted: | |
| 894 | 908 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | |
| 895 | 909 | return ResidentMetadata{}, errors.New("resident journal: turn started without acceptance") | |
| @@ -38,6 +38,7 @@ const ( | |||||
| 38 | residentRecordToolResult = "tool.result" | 38 | residentRecordToolResult = "tool.result" | ||
| 39 | residentRecordCompacted = "child.compacted" | 39 | residentRecordCompacted = "child.compacted" | ||
| 40 | residentRecordUsage = "usage" | 40 | residentRecordUsage = "usage" | ||
| 41 | + | residentRecordGuard = "repetition.guard" | |||
| 41 | residentJournalVersion = 3 | 42 | residentJournalVersion = 3 | ||
| 42 | residentJournalVersionV2 = 2 | 43 | residentJournalVersionV2 = 2 | ||
| 43 | residentInterruptedText = "tool interrupted by resident host restart" | 44 | residentInterruptedText = "tool interrupted by resident host restart" | ||
| @@ -119,6 +120,9 @@ type residentRecord struct { | |||||
| 119 | TurnID string `json:"turn_id,omitempty"` | 120 | TurnID string `json:"turn_id,omitempty"` | ||
| 120 | Prompt string `json:"prompt,omitempty"` | 121 | Prompt string `json:"prompt,omitempty"` | ||
| 121 | Outcome string `json:"outcome,omitempty"` | 122 | Outcome string `json:"outcome,omitempty"` | ||
| 123 | + | GuardKind string `json:"guard_kind,omitempty"` | |||
| 124 | + | GuardCount int `json:"guard_count,omitempty"` | |||
| 125 | + | GuardElapsed string `json:"guard_elapsed,omitempty"` | |||
| 122 | PatchRef string `json:"patch_ref,omitempty"` | 126 | PatchRef string `json:"patch_ref,omitempty"` | ||
| 123 | ChangedFiles []string `json:"changed_files,omitempty"` | 127 | ChangedFiles []string `json:"changed_files,omitempty"` | ||
| 124 | Message json.RawMessage `json:"message,omitempty"` | 128 | Message json.RawMessage `json:"message,omitempty"` | ||
| @@ -167,6 +171,11 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||||
| 167 | return fmt.Errorf("resident journal encode tool result: %w", err) | 171 | return fmt.Errorf("resident journal encode tool result: %w", err) | ||
| 168 | } | 172 | } | ||
| 169 | record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result} | 173 | record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result} | ||
| 174 | + | case core.EvRepetitionGuard: | |||
| 175 | + | record = residentRecord{ | |||
| 176 | + | Type: residentRecordGuard, Outcome: string(value.Stage), GuardKind: string(value.Kind), | |||
| 177 | + | GuardCount: value.Count, GuardElapsed: value.Elapsed.Round(time.Second).String(), | |||
| 178 | + | } | |||
| 170 | case core.EvUsage: | 179 | case core.EvUsage: | ||
| 171 | j.usageMu.RLock() | 180 | j.usageMu.RLock() | ||
| 172 | contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription | 181 | contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription | ||
| @@ -890,6 +899,11 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 890 | } | 899 | } | ||
| 891 | seenTurns[record.TurnID] = residentRecordTurnAccepted | 900 | seenTurns[record.TurnID] = residentRecordTurnAccepted | ||
| 892 | lastStateAt = record.Time | 901 | lastStateAt = record.Time | ||
| 902 | + | case residentRecordGuard: | |||
| 903 | + | if record.Outcome != string(core.RepetitionGuardWarning) && record.Outcome != string(core.RepetitionGuardStopped) { | |||
| 904 | + | return ResidentMetadata{}, errors.New("resident journal: invalid repetition guard stage") | |||
| 905 | + | } | |||
| 906 | + | lastStateAt = record.Time | |||
| 893 | case residentRecordTurnStarted: | 907 | case residentRecordTurnStarted: | ||
| 894 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | 908 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | ||
| 895 | return ResidentMetadata{}, errors.New("resident journal: turn started without acceptance") | 909 | return ResidentMetadata{}, errors.New("resident journal: turn started without acceptance") | ||
M packages/core/agent.go +63 -9
| @@ -57,10 +57,13 @@ type Agent struct { | |||
| 57 | 57 | System string | |
| 58 | 58 | // Tools is mutex-protected; use SetTools to write it and PromptConfig | |
| 59 | 59 | // or ToolsSnapshot to read it. | |
| 60 | - | Tools Registry | |
| 61 | - | MaxSteps int | |
| 62 | - | Reasoning string | |
| 63 | - | Temperature *float32 | |
| 60 | + | Tools Registry | |
| 61 | + | MaxSteps int | |
| 62 | + | // DisableRepetitionGuard opts out of repeated-content detection for | |
| 63 | + | // intentional polling workflows. | |
| 64 | + | DisableRepetitionGuard bool | |
| 65 | + | Reasoning string | |
| 66 | + | Temperature *float32 | |
| 64 | 67 | // FastMode requests the OpenAI fast service tier. The provider boundary | |
| 65 | 68 | // rejects it for providers that do not support that contract. | |
| 66 | 69 | FastMode bool | |
| @@ -186,7 +189,10 @@ type Agent struct { | |||
| 186 | 189 | // execution boundary (guard or confirmation denial) during the current | |
| 187 | 190 | // top-level runLoop invocation. A denied invocation must not gain a | |
| 188 | 191 | // recovery prompt urging more actions. | |
| 189 | - | deniedToolCall bool | |
| 192 | + | deniedToolCall bool | |
| 193 | + | // repetition state is agent-owned rather than transcript-derived so it | |
| 194 | + | // survives compaction and automated Prompt/Continue boundaries. | |
| 195 | + | repetition repetitionGuardState | |
| 190 | 196 | timeContext agentTimeContext | |
| 191 | 197 | hasSessionTimeContext bool | |
| 192 | 198 | // sessionID identifies this conversation thread. cacheSessionID identifies | |
| @@ -404,6 +410,9 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven | |||
| 404 | 410 | Time: accepted, | |
| 405 | 411 | } | |
| 406 | 412 | a.mu.Lock() | |
| 413 | + | if !queued.message.HostEvent { | |
| 414 | + | a.repetition = repetitionGuardState{} | |
| 415 | + | } | |
| 407 | 416 | a.messages = append(a.messages, msg) | |
| 408 | 417 | a.rev++ | |
| 409 | 418 | a.mu.Unlock() | |
| @@ -506,7 +515,8 @@ func (a *Agent) ToolsSnapshot() Registry { | |||
| 506 | 515 | return tools | |
| 507 | 516 | } | |
| 508 | 517 | ||
| 509 | - | // SetMessages replaces the transcript (used when resuming a session). | |
| 518 | + | // SetMessages replaces the transcript without changing repetition history. | |
| 519 | + | // Call ResetRepetitionGuard when replacing it for a new conversation. | |
| 510 | 520 | func (a *Agent) SetMessages(msgs []provider.Message) { | |
| 511 | 521 | a.mu.Lock() | |
| 512 | 522 | defer a.mu.Unlock() | |
| @@ -514,6 +524,23 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||
| 514 | 524 | a.rev++ | |
| 515 | 525 | } | |
| 516 | 526 | ||
| 527 | + | // RestoreMessages replaces a transcript without resetting loop detection. | |
| 528 | + | // Use it when rolling back a failed compaction/transcript replacement. | |
| 529 | + | func (a *Agent) RestoreMessages(msgs []provider.Message) { | |
| 530 | + | a.mu.Lock() | |
| 531 | + | defer a.mu.Unlock() | |
| 532 | + | a.messages = append(a.messages[:0], msgs...) | |
| 533 | + | a.rev++ | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // ResetRepetitionGuard clears repeated-content history for an explicit | |
| 537 | + | // user action without replacing the transcript. | |
| 538 | + | func (a *Agent) ResetRepetitionGuard() { | |
| 539 | + | a.mu.Lock() | |
| 540 | + | a.repetition = repetitionGuardState{} | |
| 541 | + | a.mu.Unlock() | |
| 542 | + | } | |
| 543 | + | ||
| 517 | 544 | // AppendUserContext adds a user-role message to the transcript without | |
| 518 | 545 | // starting a model turn. Hosts use it for context gathered outside the agent | |
| 519 | 546 | // loop, such as the output of an explicitly invoked shell command. | |
| @@ -581,9 +608,10 @@ func (a *Agent) fireMessageAppended(m provider.Message) { | |||
| 581 | 608 | } | |
| 582 | 609 | } | |
| 583 | 610 | ||
| 584 | - | // Prompt sends a user message and runs the agent loop until the model | |
| 585 | - | // stops or an error occurs. Events are delivered via sink in order. | |
| 586 | - | // sink must not block the caller for long; buffer as needed. | |
| 611 | + | // Prompt sends a new user message and runs the agent loop until the model | |
| 612 | + | // stops or an error occurs. It resets repeated-content tracking for the new | |
| 613 | + | // request. Events are delivered via sink in order. sink must not block the | |
| 614 | + | // caller for long; buffer as needed. | |
| 587 | 615 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | |
| 588 | 616 | if text == "" && len(images) == 0 { | |
| 589 | 617 | return errors.New("prompt is empty") | |
| @@ -604,6 +632,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||
| 604 | 632 | a.mu.Lock() | |
| 605 | 633 | a.messages = append(a.messages, user) | |
| 606 | 634 | a.rev++ | |
| 635 | + | a.repetition = repetitionGuardState{} | |
| 607 | 636 | a.mu.Unlock() | |
| 608 | 637 | a.fireMessageAppended(user) | |
| 609 | 638 | sink(EvUserMessage{Message: user}) | |
| @@ -647,6 +676,10 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 647 | 676 | if pending := a.drainQueuedMessages(); len(pending) > 0 { | |
| 648 | 677 | a.appendQueuedAsUser(pending, sink) | |
| 649 | 678 | } | |
| 679 | + | if a.repetitionStopped() { | |
| 680 | + | sink(EvDone{}) | |
| 681 | + | return a.repetitiveLoopError() | |
| 682 | + | } | |
| 650 | 683 | ||
| 651 | 684 | sink(EvTurnStart{Step: step, TurnID: requestContext.TurnID}) | |
| 652 | 685 | turnContext := "" | |
| @@ -748,6 +781,13 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 748 | 781 | sink(EvDone{}) | |
| 749 | 782 | return err | |
| 750 | 783 | } | |
| 784 | + | if a.QueuedMessageCount() == 0 { | |
| 785 | + | if decision := a.observeRepetition(assistantMessageFingerprint(assistantMsg), RepetitionKindAssistantMessage, ""); decision.stage != "" { | |
| 786 | + | if a.handleRepetitionDecision(decision, sink) { | |
| 787 | + | return a.repetitiveLoopError() | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | } | |
| 751 | 791 | continue | |
| 752 | 792 | } | |
| 753 | 793 | if stop == provider.StopToolUse { | |
| @@ -788,6 +828,12 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 788 | 828 | return err | |
| 789 | 829 | } | |
| 790 | 830 | _ = hadError | |
| 831 | + | for _, observation := range repetitionCallObservations(assistantMsg, toolMsg) { | |
| 832 | + | decision := a.observeRepetition(observation.fingerprint, observation.kind, observation.toolName) | |
| 833 | + | if decision.stage != "" && a.handleRepetitionDecision(decision, sink) { | |
| 834 | + | return a.repetitiveLoopError() | |
| 835 | + | } | |
| 836 | + | } | |
| 791 | 837 | continue | |
| 792 | 838 | } | |
| 793 | 839 | ||
| @@ -798,6 +844,14 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 798 | 844 | if ctx.Err() == nil && a.QueuedMessageCount() > 0 { | |
| 799 | 845 | continue | |
| 800 | 846 | } | |
| 847 | + | if stop == provider.StopEnd && !a.sawDeniedToolCall() { | |
| 848 | + | if decision := a.observeRepetition(assistantMessageFingerprint(assistantMsg), RepetitionKindAssistantMessage, ""); decision.stage != "" { | |
| 849 | + | if a.handleRepetitionDecision(decision, sink) { | |
| 850 | + | return a.repetitiveLoopError() | |
| 851 | + | } | |
| 852 | + | continue | |
| 853 | + | } | |
| 854 | + | } | |
| 801 | 855 | ||
| 802 | 856 | // Bounded normal-turn recovery: a successful normal stop whose | |
| 803 | 857 | // current terminal assistant message carries no final answer gets | |
| @@ -57,10 +57,13 @@ type Agent struct { | |||||
| 57 | System string | 57 | System string | ||
| 58 | // Tools is mutex-protected; use SetTools to write it and PromptConfig | 58 | // Tools is mutex-protected; use SetTools to write it and PromptConfig | ||
| 59 | // or ToolsSnapshot to read it. | 59 | // or ToolsSnapshot to read it. | ||
| 60 | - | Tools Registry | 60 | + | Tools Registry |
| 61 | - | MaxSteps int | 61 | + | MaxSteps int |
| 62 | - | Reasoning string | 62 | + | // DisableRepetitionGuard opts out of repeated-content detection for |
| 63 | - | Temperature *float32 | 63 | + | // intentional polling workflows. |
| 64 | + | DisableRepetitionGuard bool | |||
| 65 | + | Reasoning string | |||
| 66 | + | Temperature *float32 | |||
| 64 | // FastMode requests the OpenAI fast service tier. The provider boundary | 67 | // FastMode requests the OpenAI fast service tier. The provider boundary | ||
| 65 | // rejects it for providers that do not support that contract. | 68 | // rejects it for providers that do not support that contract. | ||
| 66 | FastMode bool | 69 | FastMode bool | ||
| @@ -186,7 +189,10 @@ type Agent struct { | |||||
| 186 | // execution boundary (guard or confirmation denial) during the current | 189 | // execution boundary (guard or confirmation denial) during the current | ||
| 187 | // top-level runLoop invocation. A denied invocation must not gain a | 190 | // top-level runLoop invocation. A denied invocation must not gain a | ||
| 188 | // recovery prompt urging more actions. | 191 | // recovery prompt urging more actions. | ||
| 189 | - | deniedToolCall bool | 192 | + | deniedToolCall bool |
| 193 | + | // repetition state is agent-owned rather than transcript-derived so it | |||
| 194 | + | // survives compaction and automated Prompt/Continue boundaries. | |||
| 195 | + | repetition repetitionGuardState | |||
| 190 | timeContext agentTimeContext | 196 | timeContext agentTimeContext | ||
| 191 | hasSessionTimeContext bool | 197 | hasSessionTimeContext bool | ||
| 192 | // sessionID identifies this conversation thread. cacheSessionID identifies | 198 | // sessionID identifies this conversation thread. cacheSessionID identifies | ||
| @@ -404,6 +410,9 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven | |||||
| 404 | Time: accepted, | 410 | Time: accepted, | ||
| 405 | } | 411 | } | ||
| 406 | a.mu.Lock() | 412 | a.mu.Lock() | ||
| 413 | + | if !queued.message.HostEvent { | |||
| 414 | + | a.repetition = repetitionGuardState{} | |||
| 415 | + | } | |||
| 407 | a.messages = append(a.messages, msg) | 416 | a.messages = append(a.messages, msg) | ||
| 408 | a.rev++ | 417 | a.rev++ | ||
| 409 | a.mu.Unlock() | 418 | a.mu.Unlock() | ||
| @@ -506,7 +515,8 @@ func (a *Agent) ToolsSnapshot() Registry { | |||||
| 506 | return tools | 515 | return tools | ||
| 507 | } | 516 | } | ||
| 508 | 517 | ||||
| 509 | - | // SetMessages replaces the transcript (used when resuming a session). | 518 | + | // SetMessages replaces the transcript without changing repetition history. |
| 519 | + | // Call ResetRepetitionGuard when replacing it for a new conversation. | |||
| 510 | func (a *Agent) SetMessages(msgs []provider.Message) { | 520 | func (a *Agent) SetMessages(msgs []provider.Message) { | ||
| 511 | a.mu.Lock() | 521 | a.mu.Lock() | ||
| 512 | defer a.mu.Unlock() | 522 | defer a.mu.Unlock() | ||
| @@ -514,6 +524,23 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||||
| 514 | a.rev++ | 524 | a.rev++ | ||
| 515 | } | 525 | } | ||
| 516 | 526 | ||||
| 527 | + | // RestoreMessages replaces a transcript without resetting loop detection. | |||
| 528 | + | // Use it when rolling back a failed compaction/transcript replacement. | |||
| 529 | + | func (a *Agent) RestoreMessages(msgs []provider.Message) { | |||
| 530 | + | a.mu.Lock() | |||
| 531 | + | defer a.mu.Unlock() | |||
| 532 | + | a.messages = append(a.messages[:0], msgs...) | |||
| 533 | + | a.rev++ | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | // ResetRepetitionGuard clears repeated-content history for an explicit | |||
| 537 | + | // user action without replacing the transcript. | |||
| 538 | + | func (a *Agent) ResetRepetitionGuard() { | |||
| 539 | + | a.mu.Lock() | |||
| 540 | + | a.repetition = repetitionGuardState{} | |||
| 541 | + | a.mu.Unlock() | |||
| 542 | + | } | |||
| 543 | + | ||||
| 517 | // AppendUserContext adds a user-role message to the transcript without | 544 | // AppendUserContext adds a user-role message to the transcript without | ||
| 518 | // starting a model turn. Hosts use it for context gathered outside the agent | 545 | // starting a model turn. Hosts use it for context gathered outside the agent | ||
| 519 | // loop, such as the output of an explicitly invoked shell command. | 546 | // loop, such as the output of an explicitly invoked shell command. | ||
| @@ -581,9 +608,10 @@ func (a *Agent) fireMessageAppended(m provider.Message) { | |||||
| 581 | } | 608 | } | ||
| 582 | } | 609 | } | ||
| 583 | 610 | ||||
| 584 | - | // Prompt sends a user message and runs the agent loop until the model | 611 | + | // Prompt sends a new user message and runs the agent loop until the model |
| 585 | - | // stops or an error occurs. Events are delivered via sink in order. | 612 | + | // stops or an error occurs. It resets repeated-content tracking for the new |
| 586 | - | // sink must not block the caller for long; buffer as needed. | 613 | + | // request. Events are delivered via sink in order. sink must not block the |
| 614 | + | // caller for long; buffer as needed. | |||
| 587 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | 615 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | ||
| 588 | if text == "" && len(images) == 0 { | 616 | if text == "" && len(images) == 0 { | ||
| 589 | return errors.New("prompt is empty") | 617 | return errors.New("prompt is empty") | ||
| @@ -604,6 +632,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||||
| 604 | a.mu.Lock() | 632 | a.mu.Lock() | ||
| 605 | a.messages = append(a.messages, user) | 633 | a.messages = append(a.messages, user) | ||
| 606 | a.rev++ | 634 | a.rev++ | ||
| 635 | + | a.repetition = repetitionGuardState{} | |||
| 607 | a.mu.Unlock() | 636 | a.mu.Unlock() | ||
| 608 | a.fireMessageAppended(user) | 637 | a.fireMessageAppended(user) | ||
| 609 | sink(EvUserMessage{Message: user}) | 638 | sink(EvUserMessage{Message: user}) | ||
| @@ -647,6 +676,10 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 647 | if pending := a.drainQueuedMessages(); len(pending) > 0 { | 676 | if pending := a.drainQueuedMessages(); len(pending) > 0 { | ||
| 648 | a.appendQueuedAsUser(pending, sink) | 677 | a.appendQueuedAsUser(pending, sink) | ||
| 649 | } | 678 | } | ||
| 679 | + | if a.repetitionStopped() { | |||
| 680 | + | sink(EvDone{}) | |||
| 681 | + | return a.repetitiveLoopError() | |||
| 682 | + | } | |||
| 650 | 683 | ||||
| 651 | sink(EvTurnStart{Step: step, TurnID: requestContext.TurnID}) | 684 | sink(EvTurnStart{Step: step, TurnID: requestContext.TurnID}) | ||
| 652 | turnContext := "" | 685 | turnContext := "" | ||
| @@ -748,6 +781,13 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 748 | sink(EvDone{}) | 781 | sink(EvDone{}) | ||
| 749 | return err | 782 | return err | ||
| 750 | } | 783 | } | ||
| 784 | + | if a.QueuedMessageCount() == 0 { | |||
| 785 | + | if decision := a.observeRepetition(assistantMessageFingerprint(assistantMsg), RepetitionKindAssistantMessage, ""); decision.stage != "" { | |||
| 786 | + | if a.handleRepetitionDecision(decision, sink) { | |||
| 787 | + | return a.repetitiveLoopError() | |||
| 788 | + | } | |||
| 789 | + | } | |||
| 790 | + | } | |||
| 751 | continue | 791 | continue | ||
| 752 | } | 792 | } | ||
| 753 | if stop == provider.StopToolUse { | 793 | if stop == provider.StopToolUse { | ||
| @@ -788,6 +828,12 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 788 | return err | 828 | return err | ||
| 789 | } | 829 | } | ||
| 790 | _ = hadError | 830 | _ = hadError | ||
| 831 | + | for _, observation := range repetitionCallObservations(assistantMsg, toolMsg) { | |||
| 832 | + | decision := a.observeRepetition(observation.fingerprint, observation.kind, observation.toolName) | |||
| 833 | + | if decision.stage != "" && a.handleRepetitionDecision(decision, sink) { | |||
| 834 | + | return a.repetitiveLoopError() | |||
| 835 | + | } | |||
| 836 | + | } | |||
| 791 | continue | 837 | continue | ||
| 792 | } | 838 | } | ||
| 793 | 839 | ||||
| @@ -798,6 +844,14 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 798 | if ctx.Err() == nil && a.QueuedMessageCount() > 0 { | 844 | if ctx.Err() == nil && a.QueuedMessageCount() > 0 { | ||
| 799 | continue | 845 | continue | ||
| 800 | } | 846 | } | ||
| 847 | + | if stop == provider.StopEnd && !a.sawDeniedToolCall() { | |||
| 848 | + | if decision := a.observeRepetition(assistantMessageFingerprint(assistantMsg), RepetitionKindAssistantMessage, ""); decision.stage != "" { | |||
| 849 | + | if a.handleRepetitionDecision(decision, sink) { | |||
| 850 | + | return a.repetitiveLoopError() | |||
| 851 | + | } | |||
| 852 | + | continue | |||
| 853 | + | } | |||
| 854 | + | } | |||
| 801 | 855 | ||||
| 802 | // Bounded normal-turn recovery: a successful normal stop whose | 856 | // Bounded normal-turn recovery: a successful normal stop whose | ||
| 803 | // current terminal assistant message carries no final answer gets | 857 | // current terminal assistant message carries no final answer gets | ||
M packages/core/events.go +10 -0
| @@ -238,6 +238,16 @@ type EvTurnRecovery struct { | |||
| 238 | 238 | ||
| 239 | 239 | func (EvTurnRecovery) Type() string { return "turn_recovery" } | |
| 240 | 240 | ||
| 241 | + | // EvRepetitionGuard reports repeated content detection to hosts. | |
| 242 | + | type EvRepetitionGuard struct { | |
| 243 | + | Kind RepetitionKind `json:"kind"` | |
| 244 | + | Stage RepetitionGuardStage `json:"stage"` | |
| 245 | + | Count int `json:"count"` | |
| 246 | + | Elapsed time.Duration `json:"elapsed"` | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func (EvRepetitionGuard) Type() string { return "repetition_guard" } | |
| 250 | + | ||
| 241 | 251 | type EvDone struct{} | |
| 242 | 252 | ||
| 243 | 253 | func (EvDone) Type() string { return "done" } | |
| @@ -238,6 +238,16 @@ type EvTurnRecovery struct { | |||||
| 238 | 238 | ||||
| 239 | func (EvTurnRecovery) Type() string { return "turn_recovery" } | 239 | func (EvTurnRecovery) Type() string { return "turn_recovery" } | ||
| 240 | 240 | ||||
| 241 | + | // EvRepetitionGuard reports repeated content detection to hosts. | |||
| 242 | + | type EvRepetitionGuard struct { | |||
| 243 | + | Kind RepetitionKind `json:"kind"` | |||
| 244 | + | Stage RepetitionGuardStage `json:"stage"` | |||
| 245 | + | Count int `json:"count"` | |||
| 246 | + | Elapsed time.Duration `json:"elapsed"` | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func (EvRepetitionGuard) Type() string { return "repetition_guard" } | |||
| 250 | + | ||||
| 241 | type EvDone struct{} | 251 | type EvDone struct{} | ||
| 242 | 252 | ||||
| 243 | func (EvDone) Type() string { return "done" } | 253 | func (EvDone) Type() string { return "done" } | ||
A packages/core/repetition.go +317 -0
| @@ -0,0 +1,317 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "encoding/hex" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "hash" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ErrRepetitiveLoop reports that an agent repeated identical work after being | |
| 18 | + | // warned to try a different approach. It matches with errors.Is. | |
| 19 | + | var ErrRepetitiveLoop = errors.New("agent stopped after detecting a repetitive loop") | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | repetitionWarnThreshold = 5 | |
| 23 | + | repetitionStopThreshold = 8 | |
| 24 | + | repetitionWindowSize = 64 | |
| 25 | + | ||
| 26 | + | // RepetitionGuardMetaKey marks the hidden prompt added when the agent | |
| 27 | + | // detects repeated content. | |
| 28 | + | RepetitionGuardMetaKey = "zut_repetition_guard" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // RepetitionKind identifies the exact content pattern that repeated. | |
| 32 | + | type RepetitionKind string | |
| 33 | + | ||
| 34 | + | const ( | |
| 35 | + | RepetitionKindToolCall RepetitionKind = "tool_call_result" | |
| 36 | + | RepetitionKindAssistantMessage RepetitionKind = "assistant_message" | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // RepetitionGuardStage identifies a warning or a terminal stop. | |
| 40 | + | type RepetitionGuardStage string | |
| 41 | + | ||
| 42 | + | const ( | |
| 43 | + | RepetitionGuardWarning RepetitionGuardStage = "warning" | |
| 44 | + | RepetitionGuardStopped RepetitionGuardStage = "stopped" | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | type repetitionObservation struct { | |
| 48 | + | fingerprint string | |
| 49 | + | kind RepetitionKind | |
| 50 | + | toolName string | |
| 51 | + | at time.Time | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | type repetitionPattern struct { | |
| 55 | + | count int | |
| 56 | + | firstSeen time.Time | |
| 57 | + | warned bool | |
| 58 | + | kind RepetitionKind | |
| 59 | + | toolName string | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type repetitionGuardState struct { | |
| 63 | + | patterns map[string]repetitionPattern | |
| 64 | + | history []repetitionObservation | |
| 65 | + | stopped bool | |
| 66 | + | stoppedDecision repetitionDecision | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | type repetitionDecision struct { | |
| 70 | + | stage RepetitionGuardStage | |
| 71 | + | kind RepetitionKind | |
| 72 | + | toolName string | |
| 73 | + | count int | |
| 74 | + | elapsed time.Duration | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // observeRepetition counts one exact content fingerprint in a bounded sliding | |
| 78 | + | // window. Fingerprints contain no call ID, timing, or raw content, so repeated | |
| 79 | + | // executions can match without retaining private tool arguments or output. | |
| 80 | + | func (a *Agent) observeRepetition(fingerprint string, kind RepetitionKind, toolName string) repetitionDecision { | |
| 81 | + | if fingerprint == "" || a.DisableRepetitionGuard { | |
| 82 | + | return repetitionDecision{} | |
| 83 | + | } | |
| 84 | + | now := time.Now() | |
| 85 | + | a.mu.Lock() | |
| 86 | + | defer a.mu.Unlock() | |
| 87 | + | if a.repetition.stopped { | |
| 88 | + | decision := a.repetition.stoppedDecision | |
| 89 | + | decision.stage = RepetitionGuardStopped | |
| 90 | + | return decision | |
| 91 | + | } | |
| 92 | + | if a.repetition.patterns == nil { | |
| 93 | + | a.repetition.patterns = make(map[string]repetitionPattern) | |
| 94 | + | } | |
| 95 | + | a.repetition.history = append(a.repetition.history, repetitionObservation{ | |
| 96 | + | fingerprint: fingerprint, | |
| 97 | + | kind: kind, | |
| 98 | + | toolName: toolName, | |
| 99 | + | at: now, | |
| 100 | + | }) | |
| 101 | + | if len(a.repetition.history) > repetitionWindowSize { | |
| 102 | + | oldest := a.repetition.history[0] | |
| 103 | + | a.repetition.history[0] = repetitionObservation{} | |
| 104 | + | a.repetition.history = a.repetition.history[1:] | |
| 105 | + | oldPattern := a.repetition.patterns[oldest.fingerprint] | |
| 106 | + | oldPattern.count-- | |
| 107 | + | if oldPattern.count <= 0 { | |
| 108 | + | delete(a.repetition.patterns, oldest.fingerprint) | |
| 109 | + | } else { | |
| 110 | + | if oldPattern.count < repetitionWarnThreshold { | |
| 111 | + | oldPattern.warned = false | |
| 112 | + | } | |
| 113 | + | oldPattern.firstSeen = firstRepetitionSeen(a.repetition.history, oldest.fingerprint) | |
| 114 | + | a.repetition.patterns[oldest.fingerprint] = oldPattern | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | pattern := a.repetition.patterns[fingerprint] | |
| 118 | + | if pattern.count == 0 { | |
| 119 | + | pattern.firstSeen = now | |
| 120 | + | } | |
| 121 | + | pattern.count++ | |
| 122 | + | pattern.kind = kind | |
| 123 | + | if toolName != "" { | |
| 124 | + | pattern.toolName = toolName | |
| 125 | + | } | |
| 126 | + | a.repetition.patterns[fingerprint] = pattern | |
| 127 | + | decision := repetitionDecision{ | |
| 128 | + | kind: pattern.kind, | |
| 129 | + | toolName: pattern.toolName, | |
| 130 | + | count: pattern.count, | |
| 131 | + | elapsed: now.Sub(pattern.firstSeen), | |
| 132 | + | } | |
| 133 | + | if pattern.count >= repetitionStopThreshold && pattern.warned { | |
| 134 | + | a.repetition.stopped = true | |
| 135 | + | decision.stage = RepetitionGuardStopped | |
| 136 | + | a.repetition.stoppedDecision = decision | |
| 137 | + | return decision | |
| 138 | + | } | |
| 139 | + | if pattern.count >= repetitionWarnThreshold && !pattern.warned { | |
| 140 | + | pattern.warned = true | |
| 141 | + | a.repetition.patterns[fingerprint] = pattern | |
| 142 | + | decision.stage = RepetitionGuardWarning | |
| 143 | + | } | |
| 144 | + | return decision | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func firstRepetitionSeen(history []repetitionObservation, fingerprint string) time.Time { | |
| 148 | + | for _, observation := range history { | |
| 149 | + | if observation.fingerprint == fingerprint { | |
| 150 | + | return observation.at | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | return time.Now() | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (a *Agent) repetitionStopped() bool { | |
| 157 | + | a.mu.Lock() | |
| 158 | + | defer a.mu.Unlock() | |
| 159 | + | return a.repetition.stopped && !a.DisableRepetitionGuard | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (a *Agent) repetitiveLoopError() error { | |
| 163 | + | a.mu.Lock() | |
| 164 | + | decision := a.repetition.stoppedDecision | |
| 165 | + | a.mu.Unlock() | |
| 166 | + | if decision.toolName != "" { | |
| 167 | + | return fmt.Errorf("%w: tool %q repeated %d times with identical results over %s", ErrRepetitiveLoop, decision.toolName, decision.count, decision.elapsed.Round(time.Second)) | |
| 168 | + | } | |
| 169 | + | return fmt.Errorf("%w: identical assistant message repeated %d times over %s", ErrRepetitiveLoop, decision.count, decision.elapsed.Round(time.Second)) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (a *Agent) handleRepetitionDecision(decision repetitionDecision, sink func(AgentEvent)) bool { | |
| 173 | + | if decision.stage == RepetitionGuardWarning { | |
| 174 | + | prompt := "You appear to be stuck in a loop: the same assistant message has repeated. Try a substantively different action or workaround. If you are blocked, explain the blocker and ask the user for help instead of repeating the same work." | |
| 175 | + | if decision.toolName != "" { | |
| 176 | + | prompt = fmt.Sprintf("You appear to be stuck repeating tool %q with identical arguments and results (%d repetitions). Try a different action or workaround; if blocked, ask the user for help instead of repeating the tool.", decision.toolName, decision.count) | |
| 177 | + | } | |
| 178 | + | a.AppendUserContext(prompt, map[string]string{RepetitionGuardMetaKey: "true"}) | |
| 179 | + | } | |
| 180 | + | sink(EvRepetitionGuard{ | |
| 181 | + | Kind: decision.kind, | |
| 182 | + | Stage: decision.stage, | |
| 183 | + | Count: decision.count, | |
| 184 | + | Elapsed: decision.elapsed, | |
| 185 | + | }) | |
| 186 | + | if decision.stage != RepetitionGuardStopped { | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | sink(EvDone{}) | |
| 190 | + | return true | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func assistantMessageFingerprint(message provider.Message) string { | |
| 194 | + | h := sha256.New() | |
| 195 | + | writeRepetitionField(h, []byte("assistant-message")) | |
| 196 | + | var hasText bool | |
| 197 | + | for _, content := range message.Content { | |
| 198 | + | text, ok := content.(provider.TextBlock) | |
| 199 | + | if !ok || strings.TrimSpace(text.Text) == "" { | |
| 200 | + | continue | |
| 201 | + | } | |
| 202 | + | hasText = true | |
| 203 | + | writeRepetitionField(h, []byte(text.Text)) | |
| 204 | + | } | |
| 205 | + | if !hasText { | |
| 206 | + | return "" | |
| 207 | + | } | |
| 208 | + | return hex.EncodeToString(h.Sum(nil)) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func toolCallResultFingerprint(call provider.ToolCallBlock, result provider.ToolResultBlock) string { | |
| 212 | + | h := sha256.New() | |
| 213 | + | writeRepetitionField(h, []byte("tool-call-result")) | |
| 214 | + | writeRepetitionField(h, []byte(call.Name)) | |
| 215 | + | writeRepetitionField(h, canonicalRepetitionJSON(call.Arguments)) | |
| 216 | + | if result.IsError { | |
| 217 | + | writeRepetitionField(h, []byte{1}) | |
| 218 | + | } else { | |
| 219 | + | writeRepetitionField(h, []byte{0}) | |
| 220 | + | } | |
| 221 | + | for _, content := range result.Content { | |
| 222 | + | switch block := content.(type) { | |
| 223 | + | case provider.TextBlock: | |
| 224 | + | writeRepetitionField(h, []byte("text")) | |
| 225 | + | writeRepetitionField(h, []byte(block.Text)) | |
| 226 | + | writeRepetitionField(h, []byte(block.ThoughtSignature)) | |
| 227 | + | writeRepetitionField(h, []byte(block.Phase)) | |
| 228 | + | case provider.ImageBlock: | |
| 229 | + | writeRepetitionField(h, []byte("image")) | |
| 230 | + | writeRepetitionField(h, []byte(block.MimeType)) | |
| 231 | + | writeRepetitionField(h, block.Data) | |
| 232 | + | writeRepetitionField(h, []byte(block.ThoughtSignature)) | |
| 233 | + | case provider.ToolCallBlock: | |
| 234 | + | writeRepetitionField(h, []byte("tool_call")) | |
| 235 | + | writeRepetitionField(h, []byte(block.Name)) | |
| 236 | + | writeRepetitionField(h, canonicalRepetitionJSON(block.Arguments)) | |
| 237 | + | case provider.ToolResultBlock: | |
| 238 | + | writeRepetitionField(h, []byte("tool_result")) | |
| 239 | + | if block.IsError { | |
| 240 | + | writeRepetitionField(h, []byte{1}) | |
| 241 | + | } else { | |
| 242 | + | writeRepetitionField(h, []byte{0}) | |
| 243 | + | } | |
| 244 | + | for _, nested := range block.Content { | |
| 245 | + | if encoded, err := json.Marshal(nested); err == nil { | |
| 246 | + | writeRepetitionField(h, encoded) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | case provider.ReasoningBlock: | |
| 250 | + | writeRepetitionField(h, []byte("reasoning")) | |
| 251 | + | writeRepetitionField(h, []byte(block.ID)) | |
| 252 | + | writeRepetitionField(h, []byte(block.Summary)) | |
| 253 | + | writeRepetitionField(h, []byte(block.Encrypted)) | |
| 254 | + | default: | |
| 255 | + | encoded, err := json.Marshal(content) | |
| 256 | + | if err != nil { | |
| 257 | + | writeRepetitionField(h, []byte(fmt.Sprintf("%T", content))) | |
| 258 | + | } else { | |
| 259 | + | writeRepetitionField(h, encoded) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return hex.EncodeToString(h.Sum(nil)) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func writeRepetitionField(h hash.Hash, value []byte) { | |
| 267 | + | var size [8]byte | |
| 268 | + | binary.BigEndian.PutUint64(size[:], uint64(len(value))) | |
| 269 | + | _, _ = h.Write(size[:]) | |
| 270 | + | _, _ = h.Write(value) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func repetitionCallObservations(assistant, toolMessage provider.Message) []repetitionObservation { | |
| 274 | + | results := make(map[string]provider.ToolResultBlock, len(toolMessage.Content)) | |
| 275 | + | for _, content := range toolMessage.Content { | |
| 276 | + | if result, ok := content.(provider.ToolResultBlock); ok { | |
| 277 | + | results[result.CallID] = result | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | observations := make([]repetitionObservation, 0, len(results)) | |
| 281 | + | seen := make(map[string]bool, len(results)) | |
| 282 | + | for _, content := range assistant.Content { | |
| 283 | + | call, ok := content.(provider.ToolCallBlock) | |
| 284 | + | if !ok { | |
| 285 | + | continue | |
| 286 | + | } | |
| 287 | + | result, ok := results[call.ID] | |
| 288 | + | if !ok { | |
| 289 | + | continue | |
| 290 | + | } | |
| 291 | + | fingerprint := toolCallResultFingerprint(call, result) | |
| 292 | + | if seen[fingerprint] { | |
| 293 | + | continue | |
| 294 | + | } | |
| 295 | + | seen[fingerprint] = true | |
| 296 | + | observations = append(observations, repetitionObservation{ | |
| 297 | + | fingerprint: fingerprint, | |
| 298 | + | kind: RepetitionKindToolCall, | |
| 299 | + | toolName: call.Name, | |
| 300 | + | }) | |
| 301 | + | } | |
| 302 | + | return observations | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func canonicalRepetitionJSON(value json.RawMessage) []byte { | |
| 306 | + | decoder := json.NewDecoder(strings.NewReader(string(value))) | |
| 307 | + | decoder.UseNumber() | |
| 308 | + | var decoded any | |
| 309 | + | if err := decoder.Decode(&decoded); err != nil { | |
| 310 | + | return value | |
| 311 | + | } | |
| 312 | + | canonical, err := json.Marshal(decoded) | |
| 313 | + | if err != nil { | |
| 314 | + | return value | |
| 315 | + | } | |
| 316 | + | return canonical | |
| 317 | + | } | |
| @@ -0,0 +1,317 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "encoding/hex" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "hash" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/zut/packages/provider" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // ErrRepetitiveLoop reports that an agent repeated identical work after being | |||
| 18 | + | // warned to try a different approach. It matches with errors.Is. | |||
| 19 | + | var ErrRepetitiveLoop = errors.New("agent stopped after detecting a repetitive loop") | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | repetitionWarnThreshold = 5 | |||
| 23 | + | repetitionStopThreshold = 8 | |||
| 24 | + | repetitionWindowSize = 64 | |||
| 25 | + | ||||
| 26 | + | // RepetitionGuardMetaKey marks the hidden prompt added when the agent | |||
| 27 | + | // detects repeated content. | |||
| 28 | + | RepetitionGuardMetaKey = "zut_repetition_guard" | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // RepetitionKind identifies the exact content pattern that repeated. | |||
| 32 | + | type RepetitionKind string | |||
| 33 | + | ||||
| 34 | + | const ( | |||
| 35 | + | RepetitionKindToolCall RepetitionKind = "tool_call_result" | |||
| 36 | + | RepetitionKindAssistantMessage RepetitionKind = "assistant_message" | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // RepetitionGuardStage identifies a warning or a terminal stop. | |||
| 40 | + | type RepetitionGuardStage string | |||
| 41 | + | ||||
| 42 | + | const ( | |||
| 43 | + | RepetitionGuardWarning RepetitionGuardStage = "warning" | |||
| 44 | + | RepetitionGuardStopped RepetitionGuardStage = "stopped" | |||
| 45 | + | ) | |||
| 46 | + | ||||
| 47 | + | type repetitionObservation struct { | |||
| 48 | + | fingerprint string | |||
| 49 | + | kind RepetitionKind | |||
| 50 | + | toolName string | |||
| 51 | + | at time.Time | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | type repetitionPattern struct { | |||
| 55 | + | count int | |||
| 56 | + | firstSeen time.Time | |||
| 57 | + | warned bool | |||
| 58 | + | kind RepetitionKind | |||
| 59 | + | toolName string | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type repetitionGuardState struct { | |||
| 63 | + | patterns map[string]repetitionPattern | |||
| 64 | + | history []repetitionObservation | |||
| 65 | + | stopped bool | |||
| 66 | + | stoppedDecision repetitionDecision | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | type repetitionDecision struct { | |||
| 70 | + | stage RepetitionGuardStage | |||
| 71 | + | kind RepetitionKind | |||
| 72 | + | toolName string | |||
| 73 | + | count int | |||
| 74 | + | elapsed time.Duration | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // observeRepetition counts one exact content fingerprint in a bounded sliding | |||
| 78 | + | // window. Fingerprints contain no call ID, timing, or raw content, so repeated | |||
| 79 | + | // executions can match without retaining private tool arguments or output. | |||
| 80 | + | func (a *Agent) observeRepetition(fingerprint string, kind RepetitionKind, toolName string) repetitionDecision { | |||
| 81 | + | if fingerprint == "" || a.DisableRepetitionGuard { | |||
| 82 | + | return repetitionDecision{} | |||
| 83 | + | } | |||
| 84 | + | now := time.Now() | |||
| 85 | + | a.mu.Lock() | |||
| 86 | + | defer a.mu.Unlock() | |||
| 87 | + | if a.repetition.stopped { | |||
| 88 | + | decision := a.repetition.stoppedDecision | |||
| 89 | + | decision.stage = RepetitionGuardStopped | |||
| 90 | + | return decision | |||
| 91 | + | } | |||
| 92 | + | if a.repetition.patterns == nil { | |||
| 93 | + | a.repetition.patterns = make(map[string]repetitionPattern) | |||
| 94 | + | } | |||
| 95 | + | a.repetition.history = append(a.repetition.history, repetitionObservation{ | |||
| 96 | + | fingerprint: fingerprint, | |||
| 97 | + | kind: kind, | |||
| 98 | + | toolName: toolName, | |||
| 99 | + | at: now, | |||
| 100 | + | }) | |||
| 101 | + | if len(a.repetition.history) > repetitionWindowSize { | |||
| 102 | + | oldest := a.repetition.history[0] | |||
| 103 | + | a.repetition.history[0] = repetitionObservation{} | |||
| 104 | + | a.repetition.history = a.repetition.history[1:] | |||
| 105 | + | oldPattern := a.repetition.patterns[oldest.fingerprint] | |||
| 106 | + | oldPattern.count-- | |||
| 107 | + | if oldPattern.count <= 0 { | |||
| 108 | + | delete(a.repetition.patterns, oldest.fingerprint) | |||
| 109 | + | } else { | |||
| 110 | + | if oldPattern.count < repetitionWarnThreshold { | |||
| 111 | + | oldPattern.warned = false | |||
| 112 | + | } | |||
| 113 | + | oldPattern.firstSeen = firstRepetitionSeen(a.repetition.history, oldest.fingerprint) | |||
| 114 | + | a.repetition.patterns[oldest.fingerprint] = oldPattern | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | pattern := a.repetition.patterns[fingerprint] | |||
| 118 | + | if pattern.count == 0 { | |||
| 119 | + | pattern.firstSeen = now | |||
| 120 | + | } | |||
| 121 | + | pattern.count++ | |||
| 122 | + | pattern.kind = kind | |||
| 123 | + | if toolName != "" { | |||
| 124 | + | pattern.toolName = toolName | |||
| 125 | + | } | |||
| 126 | + | a.repetition.patterns[fingerprint] = pattern | |||
| 127 | + | decision := repetitionDecision{ | |||
| 128 | + | kind: pattern.kind, | |||
| 129 | + | toolName: pattern.toolName, | |||
| 130 | + | count: pattern.count, | |||
| 131 | + | elapsed: now.Sub(pattern.firstSeen), | |||
| 132 | + | } | |||
| 133 | + | if pattern.count >= repetitionStopThreshold && pattern.warned { | |||
| 134 | + | a.repetition.stopped = true | |||
| 135 | + | decision.stage = RepetitionGuardStopped | |||
| 136 | + | a.repetition.stoppedDecision = decision | |||
| 137 | + | return decision | |||
| 138 | + | } | |||
| 139 | + | if pattern.count >= repetitionWarnThreshold && !pattern.warned { | |||
| 140 | + | pattern.warned = true | |||
| 141 | + | a.repetition.patterns[fingerprint] = pattern | |||
| 142 | + | decision.stage = RepetitionGuardWarning | |||
| 143 | + | } | |||
| 144 | + | return decision | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func firstRepetitionSeen(history []repetitionObservation, fingerprint string) time.Time { | |||
| 148 | + | for _, observation := range history { | |||
| 149 | + | if observation.fingerprint == fingerprint { | |||
| 150 | + | return observation.at | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | return time.Now() | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (a *Agent) repetitionStopped() bool { | |||
| 157 | + | a.mu.Lock() | |||
| 158 | + | defer a.mu.Unlock() | |||
| 159 | + | return a.repetition.stopped && !a.DisableRepetitionGuard | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (a *Agent) repetitiveLoopError() error { | |||
| 163 | + | a.mu.Lock() | |||
| 164 | + | decision := a.repetition.stoppedDecision | |||
| 165 | + | a.mu.Unlock() | |||
| 166 | + | if decision.toolName != "" { | |||
| 167 | + | return fmt.Errorf("%w: tool %q repeated %d times with identical results over %s", ErrRepetitiveLoop, decision.toolName, decision.count, decision.elapsed.Round(time.Second)) | |||
| 168 | + | } | |||
| 169 | + | return fmt.Errorf("%w: identical assistant message repeated %d times over %s", ErrRepetitiveLoop, decision.count, decision.elapsed.Round(time.Second)) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (a *Agent) handleRepetitionDecision(decision repetitionDecision, sink func(AgentEvent)) bool { | |||
| 173 | + | if decision.stage == RepetitionGuardWarning { | |||
| 174 | + | prompt := "You appear to be stuck in a loop: the same assistant message has repeated. Try a substantively different action or workaround. If you are blocked, explain the blocker and ask the user for help instead of repeating the same work." | |||
| 175 | + | if decision.toolName != "" { | |||
| 176 | + | prompt = fmt.Sprintf("You appear to be stuck repeating tool %q with identical arguments and results (%d repetitions). Try a different action or workaround; if blocked, ask the user for help instead of repeating the tool.", decision.toolName, decision.count) | |||
| 177 | + | } | |||
| 178 | + | a.AppendUserContext(prompt, map[string]string{RepetitionGuardMetaKey: "true"}) | |||
| 179 | + | } | |||
| 180 | + | sink(EvRepetitionGuard{ | |||
| 181 | + | Kind: decision.kind, | |||
| 182 | + | Stage: decision.stage, | |||
| 183 | + | Count: decision.count, | |||
| 184 | + | Elapsed: decision.elapsed, | |||
| 185 | + | }) | |||
| 186 | + | if decision.stage != RepetitionGuardStopped { | |||
| 187 | + | return false | |||
| 188 | + | } | |||
| 189 | + | sink(EvDone{}) | |||
| 190 | + | return true | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func assistantMessageFingerprint(message provider.Message) string { | |||
| 194 | + | h := sha256.New() | |||
| 195 | + | writeRepetitionField(h, []byte("assistant-message")) | |||
| 196 | + | var hasText bool | |||
| 197 | + | for _, content := range message.Content { | |||
| 198 | + | text, ok := content.(provider.TextBlock) | |||
| 199 | + | if !ok || strings.TrimSpace(text.Text) == "" { | |||
| 200 | + | continue | |||
| 201 | + | } | |||
| 202 | + | hasText = true | |||
| 203 | + | writeRepetitionField(h, []byte(text.Text)) | |||
| 204 | + | } | |||
| 205 | + | if !hasText { | |||
| 206 | + | return "" | |||
| 207 | + | } | |||
| 208 | + | return hex.EncodeToString(h.Sum(nil)) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func toolCallResultFingerprint(call provider.ToolCallBlock, result provider.ToolResultBlock) string { | |||
| 212 | + | h := sha256.New() | |||
| 213 | + | writeRepetitionField(h, []byte("tool-call-result")) | |||
| 214 | + | writeRepetitionField(h, []byte(call.Name)) | |||
| 215 | + | writeRepetitionField(h, canonicalRepetitionJSON(call.Arguments)) | |||
| 216 | + | if result.IsError { | |||
| 217 | + | writeRepetitionField(h, []byte{1}) | |||
| 218 | + | } else { | |||
| 219 | + | writeRepetitionField(h, []byte{0}) | |||
| 220 | + | } | |||
| 221 | + | for _, content := range result.Content { | |||
| 222 | + | switch block := content.(type) { | |||
| 223 | + | case provider.TextBlock: | |||
| 224 | + | writeRepetitionField(h, []byte("text")) | |||
| 225 | + | writeRepetitionField(h, []byte(block.Text)) | |||
| 226 | + | writeRepetitionField(h, []byte(block.ThoughtSignature)) | |||
| 227 | + | writeRepetitionField(h, []byte(block.Phase)) | |||
| 228 | + | case provider.ImageBlock: | |||
| 229 | + | writeRepetitionField(h, []byte("image")) | |||
| 230 | + | writeRepetitionField(h, []byte(block.MimeType)) | |||
| 231 | + | writeRepetitionField(h, block.Data) | |||
| 232 | + | writeRepetitionField(h, []byte(block.ThoughtSignature)) | |||
| 233 | + | case provider.ToolCallBlock: | |||
| 234 | + | writeRepetitionField(h, []byte("tool_call")) | |||
| 235 | + | writeRepetitionField(h, []byte(block.Name)) | |||
| 236 | + | writeRepetitionField(h, canonicalRepetitionJSON(block.Arguments)) | |||
| 237 | + | case provider.ToolResultBlock: | |||
| 238 | + | writeRepetitionField(h, []byte("tool_result")) | |||
| 239 | + | if block.IsError { | |||
| 240 | + | writeRepetitionField(h, []byte{1}) | |||
| 241 | + | } else { | |||
| 242 | + | writeRepetitionField(h, []byte{0}) | |||
| 243 | + | } | |||
| 244 | + | for _, nested := range block.Content { | |||
| 245 | + | if encoded, err := json.Marshal(nested); err == nil { | |||
| 246 | + | writeRepetitionField(h, encoded) | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | case provider.ReasoningBlock: | |||
| 250 | + | writeRepetitionField(h, []byte("reasoning")) | |||
| 251 | + | writeRepetitionField(h, []byte(block.ID)) | |||
| 252 | + | writeRepetitionField(h, []byte(block.Summary)) | |||
| 253 | + | writeRepetitionField(h, []byte(block.Encrypted)) | |||
| 254 | + | default: | |||
| 255 | + | encoded, err := json.Marshal(content) | |||
| 256 | + | if err != nil { | |||
| 257 | + | writeRepetitionField(h, []byte(fmt.Sprintf("%T", content))) | |||
| 258 | + | } else { | |||
| 259 | + | writeRepetitionField(h, encoded) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return hex.EncodeToString(h.Sum(nil)) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func writeRepetitionField(h hash.Hash, value []byte) { | |||
| 267 | + | var size [8]byte | |||
| 268 | + | binary.BigEndian.PutUint64(size[:], uint64(len(value))) | |||
| 269 | + | _, _ = h.Write(size[:]) | |||
| 270 | + | _, _ = h.Write(value) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func repetitionCallObservations(assistant, toolMessage provider.Message) []repetitionObservation { | |||
| 274 | + | results := make(map[string]provider.ToolResultBlock, len(toolMessage.Content)) | |||
| 275 | + | for _, content := range toolMessage.Content { | |||
| 276 | + | if result, ok := content.(provider.ToolResultBlock); ok { | |||
| 277 | + | results[result.CallID] = result | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | observations := make([]repetitionObservation, 0, len(results)) | |||
| 281 | + | seen := make(map[string]bool, len(results)) | |||
| 282 | + | for _, content := range assistant.Content { | |||
| 283 | + | call, ok := content.(provider.ToolCallBlock) | |||
| 284 | + | if !ok { | |||
| 285 | + | continue | |||
| 286 | + | } | |||
| 287 | + | result, ok := results[call.ID] | |||
| 288 | + | if !ok { | |||
| 289 | + | continue | |||
| 290 | + | } | |||
| 291 | + | fingerprint := toolCallResultFingerprint(call, result) | |||
| 292 | + | if seen[fingerprint] { | |||
| 293 | + | continue | |||
| 294 | + | } | |||
| 295 | + | seen[fingerprint] = true | |||
| 296 | + | observations = append(observations, repetitionObservation{ | |||
| 297 | + | fingerprint: fingerprint, | |||
| 298 | + | kind: RepetitionKindToolCall, | |||
| 299 | + | toolName: call.Name, | |||
| 300 | + | }) | |||
| 301 | + | } | |||
| 302 | + | return observations | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func canonicalRepetitionJSON(value json.RawMessage) []byte { | |||
| 306 | + | decoder := json.NewDecoder(strings.NewReader(string(value))) | |||
| 307 | + | decoder.UseNumber() | |||
| 308 | + | var decoded any | |||
| 309 | + | if err := decoder.Decode(&decoded); err != nil { | |||
| 310 | + | return value | |||
| 311 | + | } | |||
| 312 | + | canonical, err := json.Marshal(decoded) | |||
| 313 | + | if err != nil { | |||
| 314 | + | return value | |||
| 315 | + | } | |||
| 316 | + | return canonical | |||
| 317 | + | } | |||
A packages/core/repetition_guard_test.go +325 -0
| @@ -0,0 +1,325 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type repetitionTestClient struct { | |
| 16 | + | calls atomic.Int32 | |
| 17 | + | assistantOnly bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (c *repetitionTestClient) Name() string { return "repetition-test" } | |
| 21 | + | ||
| 22 | + | func (c *repetitionTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 23 | + | call := c.calls.Add(1) | |
| 24 | + | id := fmt.Sprintf("call-%d", call) | |
| 25 | + | out := make(chan provider.Event, 4) | |
| 26 | + | out <- provider.EventStart{Provider: c.Name(), Model: req.Model} | |
| 27 | + | if c.assistantOnly { | |
| 28 | + | out <- provider.EventTextDelta{Delta: "I am still checking."} | |
| 29 | + | out <- provider.EventDone{Stop: provider.StopContinue, Message: provider.Message{ | |
| 30 | + | Role: provider.RoleAssistant, | |
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: "I am still checking."}}, | |
| 32 | + | }} | |
| 33 | + | } else { | |
| 34 | + | out <- provider.EventToolStart{ID: id, Name: "repeat"} | |
| 35 | + | out <- provider.EventToolEnd{ID: id} | |
| 36 | + | out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{ | |
| 37 | + | Role: provider.RoleAssistant, | |
| 38 | + | Content: []provider.Content{provider.ToolCallBlock{ | |
| 39 | + | ID: id, Name: "repeat", Arguments: json.RawMessage(`{"b":2,"a":1}`), | |
| 40 | + | }}, | |
| 41 | + | }} | |
| 42 | + | } | |
| 43 | + | close(out) | |
| 44 | + | return out, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type repetitionTestTool struct { | |
| 48 | + | calls atomic.Int32 | |
| 49 | + | differ bool | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (*repetitionTestTool) Name() string { return "repeat" } | |
| 53 | + | func (*repetitionTestTool) Description() string { return "repeats a test operation" } | |
| 54 | + | func (*repetitionTestTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | |
| 55 | + | func (t *repetitionTestTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) { | |
| 56 | + | call := t.calls.Add(1) | |
| 57 | + | result := "unchanged" | |
| 58 | + | if t.differ { | |
| 59 | + | result = fmt.Sprintf("result %d", call) | |
| 60 | + | } | |
| 61 | + | return ToolResult{Content: []provider.Content{provider.TextBlock{Text: result}}}, nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestAgentStopsAfterWarningForRepeatedToolCallAndResult(t *testing.T) { | |
| 65 | + | client := &repetitionTestClient{} | |
| 66 | + | tool := &repetitionTestTool{} | |
| 67 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |
| 68 | + | var warnings, stops int | |
| 69 | + | if err := agent.Prompt(context.Background(), "inspect the project", nil, func(event AgentEvent) { | |
| 70 | + | guard, ok := event.(EvRepetitionGuard) | |
| 71 | + | if !ok { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | switch guard.Stage { | |
| 75 | + | case RepetitionGuardWarning: | |
| 76 | + | warnings++ | |
| 77 | + | if guard.Count != repetitionWarnThreshold { | |
| 78 | + | t.Errorf("warning count = %d, want %d", guard.Count, repetitionWarnThreshold) | |
| 79 | + | } | |
| 80 | + | case RepetitionGuardStopped: | |
| 81 | + | stops++ | |
| 82 | + | if guard.Count != repetitionStopThreshold { | |
| 83 | + | t.Errorf("stop count = %d, want %d", guard.Count, repetitionStopThreshold) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | }); !errors.Is(err, ErrRepetitiveLoop) { | |
| 87 | + | t.Fatalf("Prompt error = %v, want ErrRepetitiveLoop", err) | |
| 88 | + | } | |
| 89 | + | if got := client.calls.Load(); got != int32(repetitionStopThreshold) { | |
| 90 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold) | |
| 91 | + | } | |
| 92 | + | if got := warnings; got != 1 { | |
| 93 | + | t.Fatalf("warning events = %d, want 1", got) | |
| 94 | + | } | |
| 95 | + | if got := stops; got != 1 { | |
| 96 | + | t.Fatalf("stop events = %d, want 1", got) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | messages := agent.Messages() | |
| 100 | + | var warningFound bool | |
| 101 | + | for _, message := range messages { | |
| 102 | + | if message.Meta[RepetitionGuardMetaKey] == "true" { | |
| 103 | + | warningFound = true | |
| 104 | + | if message.Role != provider.RoleUser || extractText(message) == "" { | |
| 105 | + | t.Fatalf("malformed hidden warning message: %#v", message) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if !warningFound { | |
| 110 | + | t.Fatal("warning was not added to the transcript") | |
| 111 | + | } | |
| 112 | + | if len(messages) < 3 { | |
| 113 | + | t.Fatalf("transcript has %d messages, want tool-call history", len(messages)) | |
| 114 | + | } | |
| 115 | + | if got := messages[len(messages)-1].Role; got != provider.RoleTool { | |
| 116 | + | t.Fatalf("last transcript role = %q, want paired tool result", got) | |
| 117 | + | } | |
| 118 | + | if got := messages[len(messages)-2].Content[0].(provider.ToolCallBlock).Name; got != "repeat" { | |
| 119 | + | t.Fatalf("repeated tool name = %q, want repeat", got) | |
| 120 | + | } | |
| 121 | + | if !strings.Contains(repetitionGuardPromptFromTranscript(messages), "tool \"repeat\"") { | |
| 122 | + | t.Fatal("warning prompt does not identify the repeated tool") | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func repetitionGuardPromptFromTranscript(messages []provider.Message) string { | |
| 127 | + | for _, message := range messages { | |
| 128 | + | if message.Meta[RepetitionGuardMetaKey] == "true" { | |
| 129 | + | return extractText(message) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return "" | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestToolCallResultFingerprintCanonicalizesJSONArgumentKeyOrder(t *testing.T) { | |
| 136 | + | first := provider.ToolCallBlock{Name: "repeat", Arguments: json.RawMessage(`{"b":2,"a":1}`)} | |
| 137 | + | second := provider.ToolCallBlock{Name: "repeat", Arguments: json.RawMessage(`{"a":1,"b":2}`)} | |
| 138 | + | result := provider.ToolResultBlock{Content: []provider.Content{provider.TextBlock{Text: "same"}}} | |
| 139 | + | if firstFingerprint, secondFingerprint := toolCallResultFingerprint(first, result), toolCallResultFingerprint(second, result); firstFingerprint != secondFingerprint { | |
| 140 | + | t.Fatalf("fingerprints differ for equivalent JSON args: %q != %q", firstFingerprint, secondFingerprint) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestAgentDoesNotStopWhenRepeatedToolOutputChanges(t *testing.T) { | |
| 145 | + | client := &repetitionTestClient{} | |
| 146 | + | tool := &repetitionTestTool{differ: true} | |
| 147 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |
| 148 | + | agent.MaxSteps = repetitionStopThreshold + 1 | |
| 149 | + | var guardEvents int | |
| 150 | + | err := agent.Prompt(context.Background(), "inspect the project", nil, func(event AgentEvent) { | |
| 151 | + | if _, ok := event.(EvRepetitionGuard); ok { | |
| 152 | + | guardEvents++ | |
| 153 | + | } | |
| 154 | + | }) | |
| 155 | + | if errors.Is(err, ErrRepetitiveLoop) { | |
| 156 | + | t.Fatalf("Prompt error = %v, unexpected loop detection for changing results", err) | |
| 157 | + | } | |
| 158 | + | if guardEvents != 0 { | |
| 159 | + | t.Fatalf("repetition guard events = %d, want 0", guardEvents) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestAgentCompactionRollbackPreservesRepetitionCount(t *testing.T) { | |
| 164 | + | client := &repetitionTestClient{} | |
| 165 | + | agent := NewAgent(client, "model", "system", Registry{}) | |
| 166 | + | for i := 0; i < repetitionWarnThreshold-1; i++ { | |
| 167 | + | agent.observeRepetition("repeated", RepetitionKindAssistantMessage, "") | |
| 168 | + | } | |
| 169 | + | before := agent.repetition.patterns["repeated"].count | |
| 170 | + | if before != repetitionWarnThreshold-1 { | |
| 171 | + | t.Fatalf("repetition count before rollback = %d, want %d", before, repetitionWarnThreshold-1) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser}}) | |
| 175 | + | agent.RestoreMessages(nil) | |
| 176 | + | if got := agent.repetition.patterns["repeated"].count; got != before { | |
| 177 | + | t.Fatalf("repetition count after transcript rollback = %d, want %d", got, before) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestAgentTranscriptReplacementResetsRepetitionGuard(t *testing.T) { | |
| 182 | + | client := &repetitionTestClient{} | |
| 183 | + | agent := NewAgent(client, "model", "system", Registry{}) | |
| 184 | + | agent.DisableRepetitionGuard = true | |
| 185 | + | for i := 0; i < repetitionStopThreshold; i++ { | |
| 186 | + | agent.observeRepetition("stopped", RepetitionKindAssistantMessage, "") | |
| 187 | + | } | |
| 188 | + | agent.repetition.stopped = true | |
| 189 | + | agent.repetition.stoppedDecision = repetitionDecision{count: repetitionStopThreshold} | |
| 190 | + | ||
| 191 | + | agent.SetMessages(nil) | |
| 192 | + | agent.ResetRepetitionGuard() | |
| 193 | + | agent.DisableRepetitionGuard = false | |
| 194 | + | agent.MaxSteps = 1 | |
| 195 | + | if err := agent.Continue(context.Background(), nil); !errors.Is(err, ErrMaxSteps) { | |
| 196 | + | t.Fatalf("Continue after transcript replacement error = %v, want ErrMaxSteps after one request", err) | |
| 197 | + | } | |
| 198 | + | if got := client.calls.Load(); got != 1 { | |
| 199 | + | t.Fatalf("provider calls = %d, want 1", got) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestAgentResetRepetitionGuardAllowsContinueAfterStop(t *testing.T) { | |
| 204 | + | client := &repetitionTestClient{} | |
| 205 | + | agent := NewAgent(client, "model", "system", Registry{}) | |
| 206 | + | agent.DisableRepetitionGuard = true | |
| 207 | + | agent.MaxSteps = 1 | |
| 208 | + | for i := 0; i < repetitionStopThreshold; i++ { | |
| 209 | + | agent.observeRepetition("stopped", RepetitionKindAssistantMessage, "") | |
| 210 | + | } | |
| 211 | + | agent.repetition.stopped = true | |
| 212 | + | agent.repetition.stoppedDecision = repetitionDecision{toolName: "repeat", count: repetitionStopThreshold} | |
| 213 | + | if agent.repetitionStopped() { | |
| 214 | + | t.Fatal("repetition guard should be disabled") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | agent.ResetRepetitionGuard() | |
| 218 | + | agent.DisableRepetitionGuard = false | |
| 219 | + | if err := agent.Continue(context.Background(), nil); !errors.Is(err, ErrMaxSteps) { | |
| 220 | + | t.Fatalf("Continue after reset error = %v, want ErrMaxSteps after one request", err) | |
| 221 | + | } | |
| 222 | + | if got := client.calls.Load(); got != 1 { | |
| 223 | + | t.Fatalf("provider calls = %d, want 1", got) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestAgentRepetitionCountSurvivesContinue(t *testing.T) { | |
| 228 | + | client := &repetitionTestClient{} | |
| 229 | + | tool := &repetitionTestTool{} | |
| 230 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |
| 231 | + | agent.MaxSteps = 4 | |
| 232 | + | if err := agent.Prompt(context.Background(), "inspect the project", nil, nil); !errors.Is(err, ErrMaxSteps) { | |
| 233 | + | t.Fatalf("Prompt error = %v, want ErrMaxSteps after four calls", err) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | var warningCount, stoppedCount int | |
| 237 | + | err := agent.Continue(context.Background(), func(event AgentEvent) { | |
| 238 | + | guard, ok := event.(EvRepetitionGuard) | |
| 239 | + | if !ok { | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | if guard.Stage == RepetitionGuardWarning { | |
| 243 | + | warningCount = guard.Count | |
| 244 | + | } | |
| 245 | + | if guard.Stage == RepetitionGuardStopped { | |
| 246 | + | stoppedCount = guard.Count | |
| 247 | + | } | |
| 248 | + | }) | |
| 249 | + | if !errors.Is(err, ErrRepetitiveLoop) { | |
| 250 | + | t.Fatalf("Continue error = %v, want ErrRepetitiveLoop", err) | |
| 251 | + | } | |
| 252 | + | if warningCount != repetitionWarnThreshold || stoppedCount != repetitionStopThreshold { | |
| 253 | + | t.Fatalf("warning/stop counts = %d/%d, want %d/%d", warningCount, stoppedCount, repetitionWarnThreshold, repetitionStopThreshold) | |
| 254 | + | } | |
| 255 | + | if got := client.calls.Load(); got != int32(repetitionStopThreshold) { | |
| 256 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestRepetitionGuardWindowEvictsOldPatterns(t *testing.T) { | |
| 261 | + | agent := NewAgent(nil, "model", "system", nil) | |
| 262 | + | fingerprint := strings.Repeat("a", 64) | |
| 263 | + | for range repetitionWarnThreshold { | |
| 264 | + | decision := agent.observeRepetition(fingerprint, RepetitionKindToolCall, "read") | |
| 265 | + | if decision.stage == RepetitionGuardWarning { | |
| 266 | + | break | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | for index := 0; index < repetitionWindowSize; index++ { | |
| 270 | + | agent.observeRepetition(fmt.Sprintf("other-%d", index), RepetitionKindAssistantMessage, "") | |
| 271 | + | } | |
| 272 | + | for index := 0; index < repetitionWarnThreshold-1; index++ { | |
| 273 | + | if decision := agent.observeRepetition(fingerprint, RepetitionKindToolCall, "read"); decision.stage != "" { | |
| 274 | + | t.Fatalf("repetition %d after eviction reported stage %q", index+1, decision.stage) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | decision := agent.observeRepetition(fingerprint, RepetitionKindToolCall, "read") | |
| 278 | + | if decision.stage != RepetitionGuardWarning || decision.count != repetitionWarnThreshold { | |
| 279 | + | t.Fatalf("post-eviction decision = %#v, want warning at %d", decision, repetitionWarnThreshold) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestAgentDisableRepetitionGuardAllowsPolling(t *testing.T) { | |
| 284 | + | client := &repetitionTestClient{} | |
| 285 | + | tool := &repetitionTestTool{} | |
| 286 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |
| 287 | + | agent.DisableRepetitionGuard = true | |
| 288 | + | agent.MaxSteps = repetitionStopThreshold + 1 | |
| 289 | + | if err := agent.Prompt(context.Background(), "poll", nil, nil); !errors.Is(err, ErrMaxSteps) { | |
| 290 | + | t.Fatalf("Prompt error = %v, want MaxSteps with the guard disabled", err) | |
| 291 | + | } | |
| 292 | + | if got := client.calls.Load(); got != repetitionStopThreshold+1 { | |
| 293 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold+1) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestAgentStopsAfterWarningForRepeatedAssistantMessage(t *testing.T) { | |
| 298 | + | client := &repetitionTestClient{assistantOnly: true} | |
| 299 | + | agent := NewAgent(client, "model", "system", Registry{}) | |
| 300 | + | var warnings, stops int | |
| 301 | + | err := agent.Prompt(context.Background(), "continue the goal", nil, func(event AgentEvent) { | |
| 302 | + | guard, ok := event.(EvRepetitionGuard) | |
| 303 | + | if !ok { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | if guard.Kind != RepetitionKindAssistantMessage { | |
| 307 | + | t.Errorf("guard kind = %q, want repeated assistant message", guard.Kind) | |
| 308 | + | } | |
| 309 | + | if guard.Stage == RepetitionGuardWarning { | |
| 310 | + | warnings++ | |
| 311 | + | } | |
| 312 | + | if guard.Stage == RepetitionGuardStopped { | |
| 313 | + | stops++ | |
| 314 | + | } | |
| 315 | + | }) | |
| 316 | + | if !errors.Is(err, ErrRepetitiveLoop) { | |
| 317 | + | t.Fatalf("Prompt error = %v, want ErrRepetitiveLoop", err) | |
| 318 | + | } | |
| 319 | + | if got := client.calls.Load(); got != int32(repetitionStopThreshold) { | |
| 320 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold) | |
| 321 | + | } | |
| 322 | + | if warnings != 1 || stops != 1 { | |
| 323 | + | t.Fatalf("warning/stop events = %d/%d, want 1/1", warnings, stops) | |
| 324 | + | } | |
| 325 | + | } | |
| @@ -0,0 +1,325 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zut/packages/provider" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type repetitionTestClient struct { | |||
| 16 | + | calls atomic.Int32 | |||
| 17 | + | assistantOnly bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (c *repetitionTestClient) Name() string { return "repetition-test" } | |||
| 21 | + | ||||
| 22 | + | func (c *repetitionTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 23 | + | call := c.calls.Add(1) | |||
| 24 | + | id := fmt.Sprintf("call-%d", call) | |||
| 25 | + | out := make(chan provider.Event, 4) | |||
| 26 | + | out <- provider.EventStart{Provider: c.Name(), Model: req.Model} | |||
| 27 | + | if c.assistantOnly { | |||
| 28 | + | out <- provider.EventTextDelta{Delta: "I am still checking."} | |||
| 29 | + | out <- provider.EventDone{Stop: provider.StopContinue, Message: provider.Message{ | |||
| 30 | + | Role: provider.RoleAssistant, | |||
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: "I am still checking."}}, | |||
| 32 | + | }} | |||
| 33 | + | } else { | |||
| 34 | + | out <- provider.EventToolStart{ID: id, Name: "repeat"} | |||
| 35 | + | out <- provider.EventToolEnd{ID: id} | |||
| 36 | + | out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{ | |||
| 37 | + | Role: provider.RoleAssistant, | |||
| 38 | + | Content: []provider.Content{provider.ToolCallBlock{ | |||
| 39 | + | ID: id, Name: "repeat", Arguments: json.RawMessage(`{"b":2,"a":1}`), | |||
| 40 | + | }}, | |||
| 41 | + | }} | |||
| 42 | + | } | |||
| 43 | + | close(out) | |||
| 44 | + | return out, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | type repetitionTestTool struct { | |||
| 48 | + | calls atomic.Int32 | |||
| 49 | + | differ bool | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (*repetitionTestTool) Name() string { return "repeat" } | |||
| 53 | + | func (*repetitionTestTool) Description() string { return "repeats a test operation" } | |||
| 54 | + | func (*repetitionTestTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | |||
| 55 | + | func (t *repetitionTestTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) { | |||
| 56 | + | call := t.calls.Add(1) | |||
| 57 | + | result := "unchanged" | |||
| 58 | + | if t.differ { | |||
| 59 | + | result = fmt.Sprintf("result %d", call) | |||
| 60 | + | } | |||
| 61 | + | return ToolResult{Content: []provider.Content{provider.TextBlock{Text: result}}}, nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestAgentStopsAfterWarningForRepeatedToolCallAndResult(t *testing.T) { | |||
| 65 | + | client := &repetitionTestClient{} | |||
| 66 | + | tool := &repetitionTestTool{} | |||
| 67 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |||
| 68 | + | var warnings, stops int | |||
| 69 | + | if err := agent.Prompt(context.Background(), "inspect the project", nil, func(event AgentEvent) { | |||
| 70 | + | guard, ok := event.(EvRepetitionGuard) | |||
| 71 | + | if !ok { | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | switch guard.Stage { | |||
| 75 | + | case RepetitionGuardWarning: | |||
| 76 | + | warnings++ | |||
| 77 | + | if guard.Count != repetitionWarnThreshold { | |||
| 78 | + | t.Errorf("warning count = %d, want %d", guard.Count, repetitionWarnThreshold) | |||
| 79 | + | } | |||
| 80 | + | case RepetitionGuardStopped: | |||
| 81 | + | stops++ | |||
| 82 | + | if guard.Count != repetitionStopThreshold { | |||
| 83 | + | t.Errorf("stop count = %d, want %d", guard.Count, repetitionStopThreshold) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | }); !errors.Is(err, ErrRepetitiveLoop) { | |||
| 87 | + | t.Fatalf("Prompt error = %v, want ErrRepetitiveLoop", err) | |||
| 88 | + | } | |||
| 89 | + | if got := client.calls.Load(); got != int32(repetitionStopThreshold) { | |||
| 90 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold) | |||
| 91 | + | } | |||
| 92 | + | if got := warnings; got != 1 { | |||
| 93 | + | t.Fatalf("warning events = %d, want 1", got) | |||
| 94 | + | } | |||
| 95 | + | if got := stops; got != 1 { | |||
| 96 | + | t.Fatalf("stop events = %d, want 1", got) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | messages := agent.Messages() | |||
| 100 | + | var warningFound bool | |||
| 101 | + | for _, message := range messages { | |||
| 102 | + | if message.Meta[RepetitionGuardMetaKey] == "true" { | |||
| 103 | + | warningFound = true | |||
| 104 | + | if message.Role != provider.RoleUser || extractText(message) == "" { | |||
| 105 | + | t.Fatalf("malformed hidden warning message: %#v", message) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | if !warningFound { | |||
| 110 | + | t.Fatal("warning was not added to the transcript") | |||
| 111 | + | } | |||
| 112 | + | if len(messages) < 3 { | |||
| 113 | + | t.Fatalf("transcript has %d messages, want tool-call history", len(messages)) | |||
| 114 | + | } | |||
| 115 | + | if got := messages[len(messages)-1].Role; got != provider.RoleTool { | |||
| 116 | + | t.Fatalf("last transcript role = %q, want paired tool result", got) | |||
| 117 | + | } | |||
| 118 | + | if got := messages[len(messages)-2].Content[0].(provider.ToolCallBlock).Name; got != "repeat" { | |||
| 119 | + | t.Fatalf("repeated tool name = %q, want repeat", got) | |||
| 120 | + | } | |||
| 121 | + | if !strings.Contains(repetitionGuardPromptFromTranscript(messages), "tool \"repeat\"") { | |||
| 122 | + | t.Fatal("warning prompt does not identify the repeated tool") | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func repetitionGuardPromptFromTranscript(messages []provider.Message) string { | |||
| 127 | + | for _, message := range messages { | |||
| 128 | + | if message.Meta[RepetitionGuardMetaKey] == "true" { | |||
| 129 | + | return extractText(message) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return "" | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestToolCallResultFingerprintCanonicalizesJSONArgumentKeyOrder(t *testing.T) { | |||
| 136 | + | first := provider.ToolCallBlock{Name: "repeat", Arguments: json.RawMessage(`{"b":2,"a":1}`)} | |||
| 137 | + | second := provider.ToolCallBlock{Name: "repeat", Arguments: json.RawMessage(`{"a":1,"b":2}`)} | |||
| 138 | + | result := provider.ToolResultBlock{Content: []provider.Content{provider.TextBlock{Text: "same"}}} | |||
| 139 | + | if firstFingerprint, secondFingerprint := toolCallResultFingerprint(first, result), toolCallResultFingerprint(second, result); firstFingerprint != secondFingerprint { | |||
| 140 | + | t.Fatalf("fingerprints differ for equivalent JSON args: %q != %q", firstFingerprint, secondFingerprint) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestAgentDoesNotStopWhenRepeatedToolOutputChanges(t *testing.T) { | |||
| 145 | + | client := &repetitionTestClient{} | |||
| 146 | + | tool := &repetitionTestTool{differ: true} | |||
| 147 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |||
| 148 | + | agent.MaxSteps = repetitionStopThreshold + 1 | |||
| 149 | + | var guardEvents int | |||
| 150 | + | err := agent.Prompt(context.Background(), "inspect the project", nil, func(event AgentEvent) { | |||
| 151 | + | if _, ok := event.(EvRepetitionGuard); ok { | |||
| 152 | + | guardEvents++ | |||
| 153 | + | } | |||
| 154 | + | }) | |||
| 155 | + | if errors.Is(err, ErrRepetitiveLoop) { | |||
| 156 | + | t.Fatalf("Prompt error = %v, unexpected loop detection for changing results", err) | |||
| 157 | + | } | |||
| 158 | + | if guardEvents != 0 { | |||
| 159 | + | t.Fatalf("repetition guard events = %d, want 0", guardEvents) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestAgentCompactionRollbackPreservesRepetitionCount(t *testing.T) { | |||
| 164 | + | client := &repetitionTestClient{} | |||
| 165 | + | agent := NewAgent(client, "model", "system", Registry{}) | |||
| 166 | + | for i := 0; i < repetitionWarnThreshold-1; i++ { | |||
| 167 | + | agent.observeRepetition("repeated", RepetitionKindAssistantMessage, "") | |||
| 168 | + | } | |||
| 169 | + | before := agent.repetition.patterns["repeated"].count | |||
| 170 | + | if before != repetitionWarnThreshold-1 { | |||
| 171 | + | t.Fatalf("repetition count before rollback = %d, want %d", before, repetitionWarnThreshold-1) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser}}) | |||
| 175 | + | agent.RestoreMessages(nil) | |||
| 176 | + | if got := agent.repetition.patterns["repeated"].count; got != before { | |||
| 177 | + | t.Fatalf("repetition count after transcript rollback = %d, want %d", got, before) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestAgentTranscriptReplacementResetsRepetitionGuard(t *testing.T) { | |||
| 182 | + | client := &repetitionTestClient{} | |||
| 183 | + | agent := NewAgent(client, "model", "system", Registry{}) | |||
| 184 | + | agent.DisableRepetitionGuard = true | |||
| 185 | + | for i := 0; i < repetitionStopThreshold; i++ { | |||
| 186 | + | agent.observeRepetition("stopped", RepetitionKindAssistantMessage, "") | |||
| 187 | + | } | |||
| 188 | + | agent.repetition.stopped = true | |||
| 189 | + | agent.repetition.stoppedDecision = repetitionDecision{count: repetitionStopThreshold} | |||
| 190 | + | ||||
| 191 | + | agent.SetMessages(nil) | |||
| 192 | + | agent.ResetRepetitionGuard() | |||
| 193 | + | agent.DisableRepetitionGuard = false | |||
| 194 | + | agent.MaxSteps = 1 | |||
| 195 | + | if err := agent.Continue(context.Background(), nil); !errors.Is(err, ErrMaxSteps) { | |||
| 196 | + | t.Fatalf("Continue after transcript replacement error = %v, want ErrMaxSteps after one request", err) | |||
| 197 | + | } | |||
| 198 | + | if got := client.calls.Load(); got != 1 { | |||
| 199 | + | t.Fatalf("provider calls = %d, want 1", got) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestAgentResetRepetitionGuardAllowsContinueAfterStop(t *testing.T) { | |||
| 204 | + | client := &repetitionTestClient{} | |||
| 205 | + | agent := NewAgent(client, "model", "system", Registry{}) | |||
| 206 | + | agent.DisableRepetitionGuard = true | |||
| 207 | + | agent.MaxSteps = 1 | |||
| 208 | + | for i := 0; i < repetitionStopThreshold; i++ { | |||
| 209 | + | agent.observeRepetition("stopped", RepetitionKindAssistantMessage, "") | |||
| 210 | + | } | |||
| 211 | + | agent.repetition.stopped = true | |||
| 212 | + | agent.repetition.stoppedDecision = repetitionDecision{toolName: "repeat", count: repetitionStopThreshold} | |||
| 213 | + | if agent.repetitionStopped() { | |||
| 214 | + | t.Fatal("repetition guard should be disabled") | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | agent.ResetRepetitionGuard() | |||
| 218 | + | agent.DisableRepetitionGuard = false | |||
| 219 | + | if err := agent.Continue(context.Background(), nil); !errors.Is(err, ErrMaxSteps) { | |||
| 220 | + | t.Fatalf("Continue after reset error = %v, want ErrMaxSteps after one request", err) | |||
| 221 | + | } | |||
| 222 | + | if got := client.calls.Load(); got != 1 { | |||
| 223 | + | t.Fatalf("provider calls = %d, want 1", got) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func TestAgentRepetitionCountSurvivesContinue(t *testing.T) { | |||
| 228 | + | client := &repetitionTestClient{} | |||
| 229 | + | tool := &repetitionTestTool{} | |||
| 230 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |||
| 231 | + | agent.MaxSteps = 4 | |||
| 232 | + | if err := agent.Prompt(context.Background(), "inspect the project", nil, nil); !errors.Is(err, ErrMaxSteps) { | |||
| 233 | + | t.Fatalf("Prompt error = %v, want ErrMaxSteps after four calls", err) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | var warningCount, stoppedCount int | |||
| 237 | + | err := agent.Continue(context.Background(), func(event AgentEvent) { | |||
| 238 | + | guard, ok := event.(EvRepetitionGuard) | |||
| 239 | + | if !ok { | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | if guard.Stage == RepetitionGuardWarning { | |||
| 243 | + | warningCount = guard.Count | |||
| 244 | + | } | |||
| 245 | + | if guard.Stage == RepetitionGuardStopped { | |||
| 246 | + | stoppedCount = guard.Count | |||
| 247 | + | } | |||
| 248 | + | }) | |||
| 249 | + | if !errors.Is(err, ErrRepetitiveLoop) { | |||
| 250 | + | t.Fatalf("Continue error = %v, want ErrRepetitiveLoop", err) | |||
| 251 | + | } | |||
| 252 | + | if warningCount != repetitionWarnThreshold || stoppedCount != repetitionStopThreshold { | |||
| 253 | + | t.Fatalf("warning/stop counts = %d/%d, want %d/%d", warningCount, stoppedCount, repetitionWarnThreshold, repetitionStopThreshold) | |||
| 254 | + | } | |||
| 255 | + | if got := client.calls.Load(); got != int32(repetitionStopThreshold) { | |||
| 256 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestRepetitionGuardWindowEvictsOldPatterns(t *testing.T) { | |||
| 261 | + | agent := NewAgent(nil, "model", "system", nil) | |||
| 262 | + | fingerprint := strings.Repeat("a", 64) | |||
| 263 | + | for range repetitionWarnThreshold { | |||
| 264 | + | decision := agent.observeRepetition(fingerprint, RepetitionKindToolCall, "read") | |||
| 265 | + | if decision.stage == RepetitionGuardWarning { | |||
| 266 | + | break | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | for index := 0; index < repetitionWindowSize; index++ { | |||
| 270 | + | agent.observeRepetition(fmt.Sprintf("other-%d", index), RepetitionKindAssistantMessage, "") | |||
| 271 | + | } | |||
| 272 | + | for index := 0; index < repetitionWarnThreshold-1; index++ { | |||
| 273 | + | if decision := agent.observeRepetition(fingerprint, RepetitionKindToolCall, "read"); decision.stage != "" { | |||
| 274 | + | t.Fatalf("repetition %d after eviction reported stage %q", index+1, decision.stage) | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | decision := agent.observeRepetition(fingerprint, RepetitionKindToolCall, "read") | |||
| 278 | + | if decision.stage != RepetitionGuardWarning || decision.count != repetitionWarnThreshold { | |||
| 279 | + | t.Fatalf("post-eviction decision = %#v, want warning at %d", decision, repetitionWarnThreshold) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestAgentDisableRepetitionGuardAllowsPolling(t *testing.T) { | |||
| 284 | + | client := &repetitionTestClient{} | |||
| 285 | + | tool := &repetitionTestTool{} | |||
| 286 | + | agent := NewAgent(client, "model", "system", Registry{"repeat": tool}) | |||
| 287 | + | agent.DisableRepetitionGuard = true | |||
| 288 | + | agent.MaxSteps = repetitionStopThreshold + 1 | |||
| 289 | + | if err := agent.Prompt(context.Background(), "poll", nil, nil); !errors.Is(err, ErrMaxSteps) { | |||
| 290 | + | t.Fatalf("Prompt error = %v, want MaxSteps with the guard disabled", err) | |||
| 291 | + | } | |||
| 292 | + | if got := client.calls.Load(); got != repetitionStopThreshold+1 { | |||
| 293 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold+1) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestAgentStopsAfterWarningForRepeatedAssistantMessage(t *testing.T) { | |||
| 298 | + | client := &repetitionTestClient{assistantOnly: true} | |||
| 299 | + | agent := NewAgent(client, "model", "system", Registry{}) | |||
| 300 | + | var warnings, stops int | |||
| 301 | + | err := agent.Prompt(context.Background(), "continue the goal", nil, func(event AgentEvent) { | |||
| 302 | + | guard, ok := event.(EvRepetitionGuard) | |||
| 303 | + | if !ok { | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | if guard.Kind != RepetitionKindAssistantMessage { | |||
| 307 | + | t.Errorf("guard kind = %q, want repeated assistant message", guard.Kind) | |||
| 308 | + | } | |||
| 309 | + | if guard.Stage == RepetitionGuardWarning { | |||
| 310 | + | warnings++ | |||
| 311 | + | } | |||
| 312 | + | if guard.Stage == RepetitionGuardStopped { | |||
| 313 | + | stops++ | |||
| 314 | + | } | |||
| 315 | + | }) | |||
| 316 | + | if !errors.Is(err, ErrRepetitiveLoop) { | |||
| 317 | + | t.Fatalf("Prompt error = %v, want ErrRepetitiveLoop", err) | |||
| 318 | + | } | |||
| 319 | + | if got := client.calls.Load(); got != int32(repetitionStopThreshold) { | |||
| 320 | + | t.Fatalf("provider calls = %d, want %d", got, repetitionStopThreshold) | |||
| 321 | + | } | |||
| 322 | + | if warnings != 1 || stops != 1 { | |||
| 323 | + | t.Fatalf("warning/stop events = %d/%d, want 1/1", warnings, stops) | |||
| 324 | + | } | |||
| 325 | + | } | |||