-
Notifications
You must be signed in to change notification settings - Fork 41
fix: escalating circuit breaker for doom loops in headless mode #658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7671cf9
7d9b09d
558b9fa
ac01755
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -27,6 +27,15 @@ export namespace SessionProcessor { | |||||
| // 30 catches pathological patterns while avoiding false positives for power users. | ||||||
| const TOOL_REPEAT_THRESHOLD = 30 | ||||||
| // altimate_change end | ||||||
| // altimate_change start — escalating circuit breaker for doom loops | ||||||
| // When the repeat threshold is hit and auto-accepted (headless, config allow), the | ||||||
| // counter resets and the loop continues indefinitely. Escalation levels: | ||||||
| // 1st hit (30 calls): ask permission (existing behavior) | ||||||
| // 2nd hit (60 calls): ask + inject synthetic warning telling model to change approach | ||||||
| // 3rd hit (90 calls): force-stop the session — the model is stuck | ||||||
| const DOOM_LOOP_WARN_ESCALATION = 2 // hits before injecting warning | ||||||
| const DOOM_LOOP_STOP_ESCALATION = 3 // hits before force-stopping | ||||||
| // altimate_change end | ||||||
| const log = Log.create({ service: "session.processor" }) | ||||||
|
|
||||||
| export type Info = Awaited<ReturnType<typeof create>> | ||||||
|
|
@@ -42,6 +51,9 @@ export namespace SessionProcessor { | |||||
| // altimate_change start — per-tool call counter for varied-input loop detection | ||||||
| const toolCallCounts: Record<string, number> = {} | ||||||
| // altimate_change end | ||||||
| // altimate_change start — escalation counter: how many times each tool has hit TOOL_REPEAT_THRESHOLD | ||||||
| const toolLoopHits: Record<string, number> = {} | ||||||
| // altimate_change end | ||||||
| let snapshot: string | undefined | ||||||
| let blocked = false | ||||||
| let attempt = 0 | ||||||
|
|
@@ -201,20 +213,77 @@ export namespace SessionProcessor { | |||||
| }) | ||||||
| } | ||||||
|
|
||||||
| // altimate_change start — per-tool repeat counter (catches varied-input loops like todowrite 2,080x) | ||||||
| // altimate_change start — per-tool repeat counter with escalating circuit breaker | ||||||
| // Counter is scoped to the processor lifetime (create() call), so it accumulates | ||||||
| // across multiple process() invocations within a session. This is intentional: | ||||||
| // cross-turn accumulation catches slow-burn loops that stay under the threshold | ||||||
| // per-turn but add up over the session. | ||||||
| toolCallCounts[value.toolName] = (toolCallCounts[value.toolName] ?? 0) + 1 | ||||||
| if (toolCallCounts[value.toolName] >= TOOL_REPEAT_THRESHOLD) { | ||||||
| toolLoopHits[value.toolName] = (toolLoopHits[value.toolName] ?? 0) + 1 | ||||||
| const hits = toolLoopHits[value.toolName] | ||||||
| const totalCalls = hits * TOOL_REPEAT_THRESHOLD | ||||||
|
|
||||||
| Telemetry.track({ | ||||||
| type: "doom_loop_detected", | ||||||
| timestamp: Date.now(), | ||||||
| session_id: input.sessionID, | ||||||
| tool_name: value.toolName, | ||||||
| repeat_count: toolCallCounts[value.toolName], | ||||||
| repeat_count: totalCalls, | ||||||
| escalation_level: hits, | ||||||
| }) | ||||||
|
|
||||||
| // Escalation level 3+: force-stop — the model is irretrievably stuck | ||||||
| if (hits >= DOOM_LOOP_STOP_ESCALATION) { | ||||||
| log.warn("doom loop circuit breaker: force-stopping session", { | ||||||
| tool: value.toolName, | ||||||
| totalCalls, | ||||||
| hits, | ||||||
| sessionID: input.sessionID, | ||||||
| }) | ||||||
| await Session.updatePart({ | ||||||
| id: PartID.ascending(), | ||||||
| messageID: input.assistantMessage.id, | ||||||
| sessionID: input.assistantMessage.sessionID, | ||||||
| type: "text", | ||||||
| synthetic: true, | ||||||
| text: | ||||||
| `⚠️ altimate-code: session stopped — \`${value.toolName}\` was called ${totalCalls} times, ` + | ||||||
| `indicating the agent is stuck in a loop. Please start a new session with a revised prompt.`, | ||||||
| time: { start: Date.now(), end: Date.now() }, | ||||||
| }) | ||||||
| blocked = true | ||||||
| toolCallCounts[value.toolName] = 0 | ||||||
| toolLoopHits[value.toolName] = 0 | ||||||
| break | ||||||
| } | ||||||
|
|
||||||
| // Escalation level 2: warn the model via synthetic message | ||||||
| if (hits >= DOOM_LOOP_WARN_ESCALATION) { | ||||||
| log.warn("doom loop escalation: injecting warning", { | ||||||
| tool: value.toolName, | ||||||
| totalCalls, | ||||||
| hits, | ||||||
| sessionID: input.sessionID, | ||||||
| }) | ||||||
| await Session.updatePart({ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 finding (warning) At escalation level 2, a warning text part is written to |
||||||
| id: PartID.ascending(), | ||||||
| messageID: input.assistantMessage.id, | ||||||
| sessionID: input.assistantMessage.sessionID, | ||||||
| type: "text", | ||||||
| synthetic: true, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The level 2 escalation warning should use Prompt for AI agents
Suggested change
|
||||||
| text: | ||||||
| `⚠️ altimate-code: \`${value.toolName}\` has been called ${totalCalls} times this session. ` + | ||||||
| `You appear to be stuck in a loop. Stop repeating the same approach. ` + | ||||||
| `Either try a fundamentally different strategy or explain to the user what is blocking you. ` + | ||||||
| `The session will be force-stopped if this continues.`, | ||||||
| time: { start: Date.now(), end: Date.now() }, | ||||||
|
Comment on lines
+269
to
+280
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the 60-call warning model-visible. Line 274 marks this warning as 🤖 Prompt for AI Agents |
||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| // Escalation level 1: ask permission (existing behavior) | ||||||
| // Reset before ask so denial/exception doesn't leave count at threshold | ||||||
| toolCallCounts[value.toolName] = 0 | ||||||
| const agent = await Agent.get(input.assistantMessage.agent) | ||||||
| await PermissionNext.ask({ | ||||||
| permission: "doom_loop", | ||||||
|
|
@@ -223,12 +292,11 @@ export namespace SessionProcessor { | |||||
| metadata: { | ||||||
| tool: value.toolName, | ||||||
| input: value.input, | ||||||
| repeat_count: toolCallCounts[value.toolName], | ||||||
| repeat_count: totalCalls, | ||||||
| }, | ||||||
| always: [value.toolName], | ||||||
| ruleset: agent.permission, | ||||||
| }) | ||||||
| toolCallCounts[value.toolName] = 0 | ||||||
| } | ||||||
| // altimate_change end | ||||||
| } | ||||||
|
|
@@ -478,6 +546,9 @@ export namespace SessionProcessor { | |||||
| continue | ||||||
| } | ||||||
| if (needsCompaction) break | ||||||
| // altimate_change start — exit stream loop immediately on doom loop force-stop | ||||||
| if (blocked) break | ||||||
|
Comment on lines
+549
to
+550
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope the early stream break to the new hard-stop path only.
💡 Suggested change- let blocked = false
+ let blocked = false
+ let forceStopped = false
...
- blocked = true
+ blocked = true
+ forceStopped = true
...
- if (blocked) break
+ if (forceStopped) break🤖 Prompt for AI Agents |
||||||
| // altimate_change end | ||||||
| } | ||||||
| } catch (e: any) { | ||||||
| log.error("process", { | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 finding (warning)
At escalation level 3 (force-stop),
toolCallCounts[value.toolName]is correctly reset to 0 at line 256, buttoolLoopHits[value.toolName]is left at 3. In current code this is harmless becauseblocked = truecauses the session to return "stop" and the processor is not reused. However, the two maps are now permanently out of sync: if any future code path ever callsprocess()again on the same processor instance after a force-stop (e.g., an error-recovery refactor), the very next threshold hit would read hits=4, immediately triggering another force-stop instead of cycling through the warn phase first. The force-stop branch should also includetoolLoopHits[value.toolName] = 0to keep both maps consistent.