detached 63d78c8

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 UnverifiedB5690EEEBB952194 The 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
249249 | `--no-session` | Don't read or write session files. |
250250 | `--cwd <path>` | Use `<path>` as the working directory. |
251251 | `--no-tools` | Disable all tools. |
252+| `--no-repetition-guard` | Disable repeated-content loop detection for intentional polling workflows. |
252253 | `--no-lsp` | Disable the built-in LSP/linter tool for this run. |
253254 | `--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. |
254255 | `--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 {
7171 // at Inherit; host runtimes and SDK callers set it explicitly.
7272 WebSearchPolicy subagents.WebSearchPolicy
7373 MaxSteps int
74+ // DisableRepetitionGuard permits deliberate workflows that repeat
75+ // identical calls or assistant messages, such as scheduled polling.
76+ DisableRepetitionGuard bool
7477
7578 // Exts is a list of directory paths the user passed via --ext.
7679 // Each must contain an extension.json. Loaded for one session
@@ -189,6 +192,8 @@ func ParseArgs(in []string) (Args, error) {
189192 a.NoSess = true
190193 case "--no-tools":
191194 a.NoTools = true
195+ case "--no-repetition-guard":
196+ a.DisableRepetitionGuard = true
192197 case "--no-lsp":
193198 a.NoLSP = true
194199 case "--list-models":
@@ -468,6 +473,7 @@ func PrintHelp(version string) {
468473 section("workspace, tools, skills",
469474 row{"--cwd PATH", "treat PATH as the working directory"},
470475 row{"--no-tools", "disable all tools"},
476+ row{"--no-repetition-guard", "disable repeated-content loop detection for intentional polling"},
471477 row{"--no-lsp", "disable the built-in LSP/linter tool"},
472478 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>)"},
473479 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.WebSearchPolicy72 WebSearchPolicy subagents.WebSearchPolicy
73 MaxSteps int73 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 session79 // Each must contain an extension.json. Loaded for one session
@@ -189,6 +192,8 @@ func ParseArgs(in []string) (Args, error) {
189 a.NoSess = true192 a.NoSess = true
190 case "--no-tools":193 case "--no-tools":
191 a.NoTools = true194 a.NoTools = true
195+ case "--no-repetition-guard":
196+ a.DisableRepetitionGuard = true
192 case "--no-lsp":197 case "--no-lsp":
193 a.NoLSP = true198 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 {
3939 FastMode bool
4040 WebSearchPolicy subagents.WebSearchPolicy
4141
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
4748
4849 // ContextWindow and MaxOutput retain the effective model metadata,
4950 // including metadata synthesized for valid open-catalog local and routed
@@ -1013,6 +1014,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
10131014 ToolSummary: summaries,
10141015 SystemPrompt: sys,
10151016 MaxSteps: max,
1017+ DisableRepetitionGuard: args.DisableRepetitionGuard,
10161018 ContextWindow: resolvedModel.ContextWindow,
10171019 MaxOutput: resolvedModel.MaxOutput,
10181020 modelCatalog: modelCatalog,
@@ -1355,6 +1357,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
13551357 func (r Resolved) NewAgent() *core.Agent {
13561358 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
13571359 a.MaxSteps = r.MaxSteps
1360+ a.DisableRepetitionGuard = r.DisableRepetitionGuard
13581361 a.ContextWindow = r.ContextWindow
13591362 a.MaxTokens = r.MaxOutput
13601363 a.Reasoning = r.Reasoning
@@ -39,11 +39,12 @@ type Resolved struct {
39 FastMode bool39 FastMode bool
40 WebSearchPolicy subagents.WebSearchPolicy40 WebSearchPolicy subagents.WebSearchPolicy
41 41
42- ToolRegistry core.Registry42+ ToolRegistry core.Registry
43- ToolSummary []ToolSummary43+ ToolSummary []ToolSummary
44- SystemPrompt string44+ SystemPrompt string
45- MaxSteps int45+ MaxSteps int
46- Sandbox *tools.Sandbox46+ 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 routed50 // 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.MaxSteps1359 a.MaxSteps = r.MaxSteps
1360+ a.DisableRepetitionGuard = r.DisableRepetitionGuard
1358 a.ContextWindow = r.ContextWindow1361 a.ContextWindow = r.ContextWindow
1359 a.MaxTokens = r.MaxOutput1362 a.MaxTokens = r.MaxOutput
1360 a.Reasoning = r.Reasoning1363 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
23702370 ag = candidate.agent
23712371 } else {
23722372 currentAg.SetMessages(candidate.messages)
2373+ currentAg.ResetRepetitionGuard()
23732374 currentAg.SeedCost(candidate.cumulative)
23742375 currentAg.SeedLastTurnUsage(candidate.lastTurn)
23752376 ag = currentAg
@@ -2370,6 +2370,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2370 ag = candidate.agent2370 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 = currentAg2376 ag = currentAg
M packages/agent/modes/btw_dialog.go +1 -0
@@ -336,6 +336,7 @@ func (d *btwDialog) submit(invalidate func()) {
336336 func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
337337 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
338338 agent.MaxSteps = main.MaxSteps
339+ agent.DisableRepetitionGuard = main.DisableRepetitionGuard
339340 agent.Reasoning = main.Reasoning
340341 agent.Temperature = main.Temperature
341342 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.MaxSteps338 agent.MaxSteps = main.MaxSteps
339+ agent.DisableRepetitionGuard = main.DisableRepetitionGuard
339 agent.Reasoning = main.Reasoning340 agent.Reasoning = main.Reasoning
340 agent.Temperature = main.Temperature341 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
131131
132132 compacted := ag.Messages()
133133 if err := ctx.Err(); err != nil {
134- ag.SetMessages(messages)
134+ ag.RestoreMessages(messages)
135135 return nil, err
136136 }
137137 if persist != nil {
138138 if err := ctx.Err(); err != nil {
139- ag.SetMessages(messages)
139+ ag.RestoreMessages(messages)
140140 return nil, err
141141 }
142142 if err := persist(compacted); err != nil {
143143 // The session did not receive the compaction checkpoint. Restore the
144144 // exact pre-compaction transcript so a later suffix flush cannot mix
145145 // the old durable generation with the new in-memory generation.
146- ag.SetMessages(messages)
146+ ag.RestoreMessages(messages)
147147 return nil, fmt.Errorf("persist compacted transcript: %w", err)
148148 }
149149 }
@@ -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, err135 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, err140 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 the143 // The session did not receive the compaction checkpoint. Restore the
144 // exact pre-compaction transcript so a later suffix flush cannot mix144 // 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
476476 // the command owns the next wake, not the pending ordinary prompt.
477477 if startIfIdle {
478478 i.clearInterruptedGoalReturn()
479+ if i.agent != nil {
480+ i.agent.ResetRepetitionGuard()
481+ }
479482 }
480483 i.setGoalStatus(current)
481484 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 {
134134 return i.finishGoalRunCancelled(contextLimited, false)
135135 }
136136
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+
137155 // finishGoalRunCancelled is finishGoalRun with an explicit cancellation
138156 // signal from the turn owner. Esc-armed reassessment relies on this so an
139157 // 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 cancellation155 // finishGoalRunCancelled is finishGoalRun with an explicit cancellation
138 // signal from the turn owner. Esc-armed reassessment relies on this so an156 // 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) {
230230 clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0
231231 if i.agent != nil {
232232 i.agent.SetMessages(nil)
233+ i.agent.ResetRepetitionGuard()
233234 i.agent.SetPlan(nil)
234235 }
235236 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()) > 0230 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
22
33 import (
44 "context"
5+ "fmt"
56 "strings"
67 "time"
78
@@ -222,6 +223,14 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
222223 // does not read as a stall. The inner continuation is owned by
223224 // core, so no goal or scheduler follow-up is started here.
224225 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+ }
225234 case core.EvTurnEnd:
226235 if e.Stop == provider.StopAborted {
227236 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 by223 // 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
324324 i.startQueuedTurn(i.runCtx, message)
325325 }
326326
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 {
331328 var pending []core.QueuedMessage
332329 if i.agent != nil {
333330 pending = i.agent.DrainQueuedMessages()
334331 }
335332 pending = append(pending, i.queued...)
336333 i.queued = nil
334+ return pending
335+}
336+
337+func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) {
338+ pending := i.takeQueuedMessagesLocked()
337339 if !preserveHostEvents {
338340 return
339341 }
@@ -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.QueuedMessage328 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 = nil333 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 return340 return
339 }341 }
M packages/agent/modes/interactive_rendering.go +1 -1
@@ -1148,7 +1148,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
11481148 return out
11491149 }
11501150 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" {
11521152 return true
11531153 }
11541154 if m.Role != provider.RoleUser || len(m.Content) == 0 {
@@ -1148,7 +1148,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
1148 return out1148 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 true1152 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
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "strings"
78 "unicode"
89 "unicode/utf8"
@@ -142,6 +143,9 @@ func (i *Interactive) startQueuedTurn(parent context.Context, message core.Queue
142143 i.startTurnWithImages(parent, message.Text, message.Images)
143144 }
144145 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+ }
145149 if i.agent == nil {
146150 // Text startup pre cannot run without credentials; continue so
147151 // deferred InitialInput (pre-fill or auto-submit) still applies.
@@ -332,14 +336,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
332336 // conversation. FlushSession is idempotent (it advances the
333337 // baseline so subsequent flushes only write new rows).
334338 flush := i.cfg.FlushSession
339+ goalRun := i.goalRun
335340 i.mu.Unlock()
336341 runPendingIdleWork(pendingIdleWork)
337342 if flush != nil {
338343 flush()
339344 }
345+ stalledGoal := false
346+ if errors.Is(err, core.ErrRepetitiveLoop) {
347+ stalledGoal = i.stallGoalRun(goalRun, err.Error())
348+ }
340349 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)
343356 }
344357 // A cancelled or failed turn unwinds without scheduling autonomous
345358 // 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
355368 // Cancellation is user action, not model inaction: unwind the lease
356369 // without consuming the no-progress allowance or stalling the goal.
357370 continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil)
371+ if errors.Is(err, core.ErrRepetitiveLoop) {
372+ continueGoal = false
373+ }
358374 i.mu.Lock()
359375 awaitingPre := i.awaitingStartupPre
360376 // A newer explicit prompt may have cleared the handoff while the
361377 // completed turn was being flushed. Re-read it under the mutex before
362378 // deciding whether this continuation may spend another rescue attempt.
363379 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+ }
364396 // Pop the next queued message, if any, and relaunch.
365397 var next core.QueuedMessage
366398 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)) {
368400 next, i.queued = i.queued[0], i.queued[1:]
369401 hasNext = true
370402 }
@@ -372,12 +404,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
372404 // user isn't bombarded with stale messages after an interrupt.
373405 // Scheduler follow-ups deliberately remain: they are distinct turns
374406 // 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)) {
376408 i.discardQueuedMessagesLocked(ctx.Err() == nil)
377409 }
378410 var scheduled scheduledFollowUp
379411 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 {
381418 scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
382419 hasScheduled = true
383420 }
@@ -471,6 +508,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
471508 return
472509 }
473510 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+ }
474517 case hasNext:
475518 i.startQueuedTurn(parent, next)
476519 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 so150 // 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 the336 // 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.FlushSession338 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 autonomous357 // A cancelled or failed turn unwinds without scheduling autonomous
345 // work below. Preserve a freshly armed Esc return for the next358 // 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 lease368 // 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.awaitingStartupPre375 awaitingPre := i.awaitingStartupPre
360 // A newer explicit prompt may have cleared the handoff while the376 // A newer explicit prompt may have cleared the handoff while the
361 // completed turn was being flushed. Re-read it under the mutex before377 // 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 == compactContinuationStatusRescue379 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.QueuedMessage397 var next core.QueuedMessage
366 var hasNext bool398 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 = true401 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 turns405 // 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 scheduledFollowUp410 var scheduled scheduledFollowUp
379 var hasScheduled bool411 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 = true419 hasScheduled = true
383 }420 }
@@ -471,6 +508,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
471 return508 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 {
147147 case core.EvTurnRecovery:
148148 m["reason"] = e.Reason
149149 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()
150155 case core.EvTurnEnd:
151156 m["stop"] = string(e.Stop)
152157 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.Reason148 m["reason"] = e.Reason
149 m["attempt"] = e.Attempt149 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
5959 // exhausted allowance returns ErrIncompleteTurn from Prompt
6060 // and never reaches here as success.
6161 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+ }
6269 case core.EvTurnEnd:
6370 if e.Err != nil {
6471 runErr = e.Err
@@ -59,6 +59,13 @@ func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt str
59 // exhausted allowance returns ErrIncompleteTurn from Prompt59 // 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.Err71 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
103103 // Assistant text stays on stdout; the recovery notice is a
104104 // host diagnostic and belongs on the diag channel.
105105 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+ }
106113 case core.EvTurnEnd:
107114 if e.Err != nil {
108115 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 a103 // 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.Err115 runErr = e.Err
M packages/agent/plan_modes_persist_test.go +45 -0
@@ -56,6 +56,51 @@ func TestBotSessionPersistsPlanMutation(t *testing.T) {
5656 // TestScheduledSessionPersistsPlanMutation guards GO-002 for the scheduled mode:
5757 // a scheduled turn that calls plan must persist the mutation to the session it
5858 // 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+
59104 func TestScheduledSessionPersistsPlanMutation(t *testing.T) {
60105 home := t.TempDir()
61106 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 it57 // 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) {
349349
350350 case "clear":
351351 s.agent.SetMessages(nil)
352+ s.agent.ResetRepetitionGuard()
352353 s.agent.SetPlan(nil)
353354 s.writeResponse(id, cmd, nil)
354355
@@ -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
7474 appendErr = err
7575 }
7676 }
77+ ag.DisableRepetitionGuard = true
7778 ag.OnMessageAppended = func(message provider.Message) {
7879 recordAppendError(sess.AppendMessage(message))
7980 }
@@ -74,6 +74,7 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba
74 appendErr = err74 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 {
7171 // 0 leaves the loop unlimited.
7272 MaxSteps int
7373
74+ // DisableRepetitionGuard opts out of repeated-content detection for
75+ // intentional polling workflows.
76+ DisableRepetitionGuard bool
77+
7478 // APIKey overrides the credential lookup chain.
7579 APIKey string
7680
@@ -132,21 +136,22 @@ func NewContext(ctx context.Context, cfg Config) (*Runtime, error) {
132136 }
133137 }
134138 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
150155 }
151156 r, err := agent.ResolveSDK(ctx, args)
152157 if err != nil {
@@ -71,6 +71,10 @@ type Config struct {
71 // 0 leaves the loop unlimited.71 // 0 leaves the loop unlimited.
72 MaxSteps int72 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 string79 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, // headless139+ 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 themselves153+ 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 {
5656 // user_message / assistant_message
5757 Message *Message `json:"message,omitempty"`
5858
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+
5965 // turn_end
6066 Stop string `json:"stop,omitempty"`
6167
@@ -239,6 +245,11 @@ func toEvent(ev core.AgentEvent) Event {
239245 CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens,
240246 CostUSD: e.Cumulative.CostUSD,
241247 }
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()
242253 case core.EvTurnEnd:
243254 out.Stop = string(e.Stop)
244255 if e.Err != nil {
@@ -56,6 +56,12 @@ type Event struct {
56 // user_message / assistant_message56 // 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_end65 // 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
4242 // A resident child inherits the parent's resolved step limit, including the
4343 // unlimited default. Its ceiling is the provider context window, with one
4444 // 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.
4647 agent.MaxSteps = resolved.MaxSteps
48+ agent.DisableRepetitionGuard = resolved.DisableRepetitionGuard
4749 agent.ContextWindow = resolved.ContextWindow
4850 agent.MaxTokens = resolved.MaxOutput
4951 agent.Reasoning = resolved.Reasoning
@@ -151,13 +153,13 @@ func recoverResidentContextOverflow(ctx context.Context, agent *core.Agent, jour
151153 return overflowErr
152154 }
153155 if _, err := agent.CompactWithEvents(ctx, residentContextRecoveryKeepTail, sink); err != nil {
154- agent.SetMessages(before)
156+ agent.RestoreMessages(before)
155157 return errors.Join(overflowErr, fmt.Errorf("compact resident child transcript: %w", err))
156158 }
157159 if err := journal.RecordCompacted(residentCheckpointMessages(agent.Messages())); err != nil {
158160 // Memory must not run ahead of durable history: a later resume would
159161 // otherwise replay the pre-compaction transcript.
160- agent.SetMessages(before)
162+ agent.RestoreMessages(before)
161163 return errors.Join(overflowErr, fmt.Errorf("persist resident child compaction: %w", err))
162164 }
163165 // 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 the42 // A resident child inherits the parent's resolved step limit, including the
43 // unlimited default. Its ceiling is the provider context window, with one43 // unlimited default. Its ceiling is the provider context window, with one
44 // compaction recovery per accepted turn, plus any explicit parent44 // 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.MaxSteps47 agent.MaxSteps = resolved.MaxSteps
48+ agent.DisableRepetitionGuard = resolved.DisableRepetitionGuard
47 agent.ContextWindow = resolved.ContextWindow49 agent.ContextWindow = resolved.ContextWindow
48 agent.MaxTokens = resolved.MaxOutput50 agent.MaxTokens = resolved.MaxOutput
49 agent.Reasoning = resolved.Reasoning51 agent.Reasoning = resolved.Reasoning
@@ -151,13 +153,13 @@ func recoverResidentContextOverflow(ctx context.Context, agent *core.Agent, jour
151 return overflowErr153 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 would160 // 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-compaction165 // 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) {
2525 server, requests := residentToolLoopServer(t, 51)
2626 runner, _, _ := newResidentChildTestRunner(t, "unlimited-child", Args{
2727 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,
2929 })
3030 if err := runner(t.Context(), "review"); err != nil {
3131 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 {
3636 // transcript of a child that recovered from a context overflow. It is
3737 // produced only for residentRecordCompacted items, which the UI pager does
3838 // 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"`
4448 }
4549
4650 // ResidentHistoryPage is a recent-first, bounded page of finalized history.
@@ -277,6 +281,12 @@ func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, erro
277281 return ResidentHistoryItem{}, false, errors.New("resident history: malformed tool result record")
278282 }
279283 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
280290 default:
281291 // Lifecycle records and compaction checkpoints are not history items:
282292 // the pager keeps showing the append-only log, while resume replay
@@ -289,7 +299,7 @@ func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, erro
289299 func residentHistoryPageStart(entries []residentHistoryEntry, limit int) int {
290300 first, fallback := -1, -1
291301 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 {
293303 continue
294304 }
295305 fallback = index
@@ -441,6 +451,16 @@ func ReadResidentTranscriptMessages(dir string) ([]provider.Message, error) {
441451 }
442452 items := make([]ResidentHistoryItem, 0, len(records))
443453 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+ }
444464 if record.Type == residentRecordCompacted {
445465 // The pager hides checkpoints, but resume replay starts from one.
446466 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 is36 // transcript of a child that recovered from a context overflow. It is
37 // produced only for residentRecordCompacted items, which the UI pager does37 // 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 replay292 // 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, -1300 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 continue303 continue
294 }304 }
295 fallback = index305 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 (
1212 "github.com/bnema/zut/packages/provider"
1313 )
1414
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+
1543 func TestReadResidentHistoryReturnsCompleteFinalizedItems(t *testing.T) {
1644 root := t.TempDir()
1745 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 (
3838 residentRecordToolResult = "tool.result"
3939 residentRecordCompacted = "child.compacted"
4040 residentRecordUsage = "usage"
41+ residentRecordGuard = "repetition.guard"
4142 residentJournalVersion = 3
4243 residentJournalVersionV2 = 2
4344 residentInterruptedText = "tool interrupted by resident host restart"
@@ -119,6 +120,9 @@ type residentRecord struct {
119120 TurnID string `json:"turn_id,omitempty"`
120121 Prompt string `json:"prompt,omitempty"`
121122 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"`
122126 PatchRef string `json:"patch_ref,omitempty"`
123127 ChangedFiles []string `json:"changed_files,omitempty"`
124128 Message json.RawMessage `json:"message,omitempty"`
@@ -167,6 +171,11 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
167171 return fmt.Errorf("resident journal encode tool result: %w", err)
168172 }
169173 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+ }
170179 case core.EvUsage:
171180 j.usageMu.RLock()
172181 contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription
@@ -890,6 +899,11 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
890899 }
891900 seenTurns[record.TurnID] = residentRecordTurnAccepted
892901 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
893907 case residentRecordTurnStarted:
894908 if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) {
895909 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 = 342 residentJournalVersion = 3
42 residentJournalVersionV2 = 243 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.subscription181 contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription
@@ -890,6 +899,11 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
890 }899 }
891 seenTurns[record.TurnID] = residentRecordTurnAccepted900 seenTurns[record.TurnID] = residentRecordTurnAccepted
892 lastStateAt = record.Time901 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 {
5757 System string
5858 // Tools is mutex-protected; use SetTools to write it and PromptConfig
5959 // 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
6467 // FastMode requests the OpenAI fast service tier. The provider boundary
6568 // rejects it for providers that do not support that contract.
6669 FastMode bool
@@ -186,7 +189,10 @@ type Agent struct {
186189 // execution boundary (guard or confirmation denial) during the current
187190 // top-level runLoop invocation. A denied invocation must not gain a
188191 // 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
190196 timeContext agentTimeContext
191197 hasSessionTimeContext bool
192198 // sessionID identifies this conversation thread. cacheSessionID identifies
@@ -404,6 +410,9 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven
404410 Time: accepted,
405411 }
406412 a.mu.Lock()
413+ if !queued.message.HostEvent {
414+ a.repetition = repetitionGuardState{}
415+ }
407416 a.messages = append(a.messages, msg)
408417 a.rev++
409418 a.mu.Unlock()
@@ -506,7 +515,8 @@ func (a *Agent) ToolsSnapshot() Registry {
506515 return tools
507516 }
508517
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.
510520 func (a *Agent) SetMessages(msgs []provider.Message) {
511521 a.mu.Lock()
512522 defer a.mu.Unlock()
@@ -514,6 +524,23 @@ func (a *Agent) SetMessages(msgs []provider.Message) {
514524 a.rev++
515525 }
516526
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+
517544 // AppendUserContext adds a user-role message to the transcript without
518545 // starting a model turn. Hosts use it for context gathered outside the agent
519546 // loop, such as the output of an explicitly invoked shell command.
@@ -581,9 +608,10 @@ func (a *Agent) fireMessageAppended(m provider.Message) {
581608 }
582609 }
583610
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.
587615 func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error {
588616 if text == "" && len(images) == 0 {
589617 return errors.New("prompt is empty")
@@ -604,6 +632,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image
604632 a.mu.Lock()
605633 a.messages = append(a.messages, user)
606634 a.rev++
635+ a.repetition = repetitionGuardState{}
607636 a.mu.Unlock()
608637 a.fireMessageAppended(user)
609638 sink(EvUserMessage{Message: user})
@@ -647,6 +676,10 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
647676 if pending := a.drainQueuedMessages(); len(pending) > 0 {
648677 a.appendQueuedAsUser(pending, sink)
649678 }
679+ if a.repetitionStopped() {
680+ sink(EvDone{})
681+ return a.repetitiveLoopError()
682+ }
650683
651684 sink(EvTurnStart{Step: step, TurnID: requestContext.TurnID})
652685 turnContext := ""
@@ -748,6 +781,13 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
748781 sink(EvDone{})
749782 return err
750783 }
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+ }
751791 continue
752792 }
753793 if stop == provider.StopToolUse {
@@ -788,6 +828,12 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
788828 return err
789829 }
790830 _ = 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+ }
791837 continue
792838 }
793839
@@ -798,6 +844,14 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
798844 if ctx.Err() == nil && a.QueuedMessageCount() > 0 {
799845 continue
800846 }
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+ }
801855
802856 // Bounded normal-turn recovery: a successful normal stop whose
803857 // current terminal assistant message carries no final answer gets
@@ -57,10 +57,13 @@ type Agent struct {
57 System string57 System string
58 // Tools is mutex-protected; use SetTools to write it and PromptConfig58 // 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 Registry60+ Tools Registry
61- MaxSteps int61+ MaxSteps int
62- Reasoning string62+ // DisableRepetitionGuard opts out of repeated-content detection for
63- Temperature *float3263+ // intentional polling workflows.
64+ DisableRepetitionGuard bool
65+ Reasoning string
66+ Temperature *float32
64 // FastMode requests the OpenAI fast service tier. The provider boundary67 // 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 bool69 FastMode bool
@@ -186,7 +189,10 @@ type Agent struct {
186 // execution boundary (guard or confirmation denial) during the current189 // execution boundary (guard or confirmation denial) during the current
187 // top-level runLoop invocation. A denied invocation must not gain a190 // 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 bool192+ 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 agentTimeContext196 timeContext agentTimeContext
191 hasSessionTimeContext bool197 hasSessionTimeContext bool
192 // sessionID identifies this conversation thread. cacheSessionID identifies198 // 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 tools515 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 without544 // AppendUserContext adds a user-role message to the transcript without
518 // starting a model turn. Hosts use it for context gathered outside the agent545 // 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 model611+// 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 err782 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 continue791 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 err828 return err
789 }829 }
790 _ = hadError830 _ = 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 continue837 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 continue845 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 whose856 // Bounded normal-turn recovery: a successful normal stop whose
803 // current terminal assistant message carries no final answer gets857 // current terminal assistant message carries no final answer gets
M packages/core/events.go +10 -0
@@ -238,6 +238,16 @@ type EvTurnRecovery struct {
238238
239239 func (EvTurnRecovery) Type() string { return "turn_recovery" }
240240
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+
241251 type EvDone struct{}
242252
243253 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close