loop: per-step budgets — replan gate (8 rounds) and stall detection (5 rounds)

This commit is contained in:
Levi Neely 2026-05-17 12:13:26 +02:00
parent 8be8b9c42d
commit 011843a59d
1 changed files with 44 additions and 0 deletions

View File

@ -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