diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0d03724..3a1153a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -28,6 +28,14 @@ const ( consecutiveErrorHardLimit = 10 ) +// replanGate is the max consecutive tool rounds without a PLAN: block before +// a mandatory replan nudge is injected. +const replanGate = 8 + +// stallThreshold is the number of consecutive rounds where LastAction doesn't +// change before injecting a stall nudge. +const stallThreshold = 5 + // planReinjectInterval is the number of tool-call rounds between periodic // task state re-injection into the conversation. const planReinjectInterval = 10 @@ -65,6 +73,9 @@ type agentConfig struct { func run(ctx context.Context, cfg agentConfig, state state) error { var step int var consecutiveErrors int // rounds where every tool call returned an error + var roundsWithoutPlan int // consecutive tool rounds without a PLAN: block + var lastAction string // previous round's LastAction for stall detection + var stallRounds int // consecutive rounds with unchanged LastAction // resultCache stores outputs of read-safe tool calls keyed by name+args. // Only tools classified as parallel-safe (immutable reads) are cached. // sync.Map is required because execOne may run in concurrent goroutines. @@ -492,6 +503,39 @@ func run(ctx context.Context, cfg agentConfig, state state) error { }, nil) } + // Replan gate: if the model has been calling tools without emitting a + // PLAN: block, force it to replan before continuing. + if strings.Contains(msg.Content, "PLAN:") { + roundsWithoutPlan = 0 + } else { + roundsWithoutPlan++ + } + if roundsWithoutPlan >= replanGate { + state.update(backend.Message{ + Role: "user", + Content: "[system: you have executed " + fmt.Sprintf("%d", replanGate) + " tool rounds without replanning. Stop and write a PLAN: block showing your current checklist before calling any more tools. Update s/$OLLIE_SESSION_ID/plan.]", + }, nil) + roundsWithoutPlan = 0 + } + + // Stall detection: if LastAction hasn't changed across rounds, the + // model is likely stuck in a loop. + if ts := state.taskState(); ts != nil { + if ts.LastAction == lastAction && lastAction != "" { + stallRounds++ + } else { + stallRounds = 0 + } + lastAction = ts.LastAction + if stallRounds >= stallThreshold { + state.update(backend.Message{ + Role: "user", + Content: "[system: stall detected — you have repeated the same action for " + fmt.Sprintf("%d", stallRounds) + " rounds. Step back, reassess your approach, and try something different. Update your plan.]", + }, nil) + stallRounds = 0 + } + } + // Soft step-budget guardrail: when MaxSteps is set and the budget is // exhausted, nudge the model to wrap up and exit cleanly. This is not // a hard abort — the model gets one final turn without tools to emit