Some checks are pending
release / release (push) Waiting to run
Package layout (zubble/), vendored benchlib.stream_chat, LocalEnv, minimal CLI, 38 pure-logic tests, and a tag-triggered Forgejo Actions release pipeline that builds a single-file PyInstaller executable.
242 lines
No EOL
7.7 KiB
Python
242 lines
No EOL
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""doom — doom-loop escape hatch for the zubble agent loop.
|
|
|
|
Two detectors, an insurance counter, and a reset cap:
|
|
|
|
Detector A (action-level, across turns):
|
|
Tracks consecutive identical actions by canonical JSON (all fields
|
|
except 'reasoning'). 3rd consecutive identical → NOTE. 4th → KILL.
|
|
|
|
Detector B (in-stream, within one generation):
|
|
Watches the combined reasoning + content token stream. Buffers
|
|
50-delta blocks; when the current block equals the previous block,
|
|
a streak increments. Thresholds:
|
|
streak >= 50 → L1: abort, keep prefix, inject cut-note
|
|
streak >= 500 → L2: abort, discard turn, retry once
|
|
streak >= 1500 → L3: kill → fresh conversation
|
|
|
|
Insurance counter:
|
|
10 total aborts (L1 + L2) in a scenario → kill → fresh conversation.
|
|
|
|
Reset cap:
|
|
3 fresh-context resets per scenario; the 4th doom → outcome="doom_loop".
|
|
|
|
Known blind spot (accepted): repetition periods that don't divide 50
|
|
(e.g. 37-delta loops) evade Detector B. Exact-match only — no fuzzy
|
|
fingerprinting, no decay. Review findings in practice before adjusting.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
from enum import IntEnum
|
|
|
|
|
|
# ── Signals ────────────────────────────────────────────────────────
|
|
|
|
class Level(IntEnum):
|
|
"""In-stream repetition severity (Detector B)."""
|
|
NONE = 0
|
|
L1 = 1 # abort, keep prefix, inject cut-note, continue
|
|
L2 = 2 # abort, discard turn, retry once with corrective summary
|
|
L3 = 3 # kill → fresh conversation
|
|
|
|
|
|
class ActionSignal(IntEnum):
|
|
"""Action-level repetition severity (Detector A)."""
|
|
NONE = 0
|
|
NOTE = 1 # corrective note injected
|
|
KILL = 2 # kill → fresh conversation
|
|
|
|
|
|
# ── Intervention texts ─────────────────────────────────────────────
|
|
|
|
NOTE_ACTION_REPEAT = (
|
|
"You have repeated the same action with identical input {n} times "
|
|
"in a row. Use the result from the previous execution or change "
|
|
"your approach."
|
|
)
|
|
|
|
NOTE_STREAM_L1 = (
|
|
"[GENERATION ABORTED: the same 50-token segment repeated {streak} "
|
|
"times. Here is what you produced before the abort. Do not repeat "
|
|
"it — continue with a different approach.]"
|
|
)
|
|
|
|
NOTE_STREAM_L2 = (
|
|
"Your previous generation was discarded because it repeated the "
|
|
"same content extensively ({streak} repeats). Think of a different "
|
|
"approach. Previous session memory follows."
|
|
)
|
|
|
|
NOTE_KILL_RESET = (
|
|
"The previous conversation was reset because the agent entered a "
|
|
"doom loop. Summary of what was already attempted follows. "
|
|
"Do not repeat the same actions."
|
|
)
|
|
|
|
NOTE_DOOM_LOOP = (
|
|
"Scenario abandoned: the agent entered a doom loop that could not "
|
|
"be resolved after {resets} fresh-context resets."
|
|
)
|
|
|
|
|
|
# ── Detector A: action-level consecutive identical ─────────────────
|
|
|
|
def canonical_form(obj):
|
|
"""Canonical JSON of a parsed action, excluding 'reasoning'.
|
|
|
|
Reasoning varies even at temperature 0 and is not an operative
|
|
field — it is the model's internal monologue, not the tool input.
|
|
This mirrors OpenCode's approach of comparing tool name + input,
|
|
not the model's thought process.
|
|
"""
|
|
stripped = {k: v for k, v in obj.items() if k != "reasoning"}
|
|
return json.dumps(stripped, sort_keys=True)
|
|
|
|
|
|
class ActionRepetitionDetector:
|
|
"""Tracks consecutive identical actions across turns.
|
|
|
|
feed(parsed_action) → ActionSignal
|
|
3rd consecutive identical → NOTE
|
|
4th consecutive identical → KILL
|
|
Any different action resets the counter.
|
|
"""
|
|
|
|
NOTE_THRESHOLD = 3
|
|
KILL_THRESHOLD = 4
|
|
|
|
def __init__(self):
|
|
self._last_canonical = None
|
|
self._count = 0
|
|
|
|
def feed(self, parsed_action):
|
|
canonical = canonical_form(parsed_action)
|
|
if canonical == self._last_canonical:
|
|
self._count += 1
|
|
else:
|
|
self._count = 1
|
|
self._last_canonical = canonical
|
|
|
|
if self._count >= self.KILL_THRESHOLD:
|
|
return ActionSignal.KILL
|
|
if self._count == self.NOTE_THRESHOLD:
|
|
return ActionSignal.NOTE
|
|
return ActionSignal.NONE
|
|
|
|
def reset(self):
|
|
"""Clear state (used on fresh-context reset)."""
|
|
self._last_canonical = None
|
|
self._count = 0
|
|
|
|
@property
|
|
def count(self):
|
|
return self._count
|
|
|
|
|
|
# ── Detector B: in-stream exact-segment repetition ─────────────────
|
|
|
|
class StreamRepetitionDetector:
|
|
"""Watches the combined reasoning + content stream for exact
|
|
50-delta block repetition.
|
|
|
|
feed(kind, text_delta) → Level
|
|
A new instance should be created for each generation call.
|
|
The ``level`` attribute holds the highest level triggered.
|
|
"""
|
|
|
|
BLOCK_SIZE = 50
|
|
L1_THRESHOLD = 50
|
|
L2_THRESHOLD = 500
|
|
L3_THRESHOLD = 1500
|
|
|
|
def __init__(self):
|
|
self._buf = []
|
|
self._prev_hash = None
|
|
self._streak = 0
|
|
self.level = Level.NONE
|
|
|
|
def feed(self, kind, text):
|
|
"""Process one delta. kind is 'reasoning' or 'content' (ignored
|
|
— both streams are combined in emission order). Returns the
|
|
current Level, re-evaluated at each 50-delta block boundary."""
|
|
self._buf.append(text)
|
|
if len(self._buf) < self.BLOCK_SIZE:
|
|
return Level.NONE
|
|
|
|
block = "".join(self._buf)
|
|
self._buf = []
|
|
block_hash = hashlib.md5(block.encode()).hexdigest()
|
|
|
|
if block_hash == self._prev_hash:
|
|
self._streak += 1
|
|
else:
|
|
self._streak = 0
|
|
self._prev_hash = block_hash
|
|
|
|
return self._evaluate()
|
|
|
|
def _evaluate(self):
|
|
if self._streak >= self.L3_THRESHOLD:
|
|
self.level = Level.L3
|
|
return Level.L3
|
|
if self._streak >= self.L2_THRESHOLD:
|
|
if self.level < Level.L2:
|
|
self.level = Level.L2
|
|
return Level.L2
|
|
if self._streak >= self.L1_THRESHOLD:
|
|
if self.level < Level.L1:
|
|
self.level = Level.L1
|
|
return Level.L1
|
|
return Level.NONE
|
|
|
|
@property
|
|
def streak(self):
|
|
return self._streak
|
|
|
|
|
|
# ── Insurance counter ──────────────────────────────────────────────
|
|
|
|
class InsuranceCounter:
|
|
"""Global tripwire: 10 total mid-stream aborts (L1 + L2) in a
|
|
scenario → kill → fresh conversation."""
|
|
|
|
MAX_ABORTS = 10
|
|
|
|
def __init__(self):
|
|
self._aborts = 0
|
|
|
|
def register_abort(self):
|
|
"""Call after any L1 or L2 abort. Returns True if the
|
|
insurance tripwire fired (kill → fresh conversation)."""
|
|
self._aborts += 1
|
|
return self._aborts >= self.MAX_ABORTS
|
|
|
|
@property
|
|
def count(self):
|
|
return self._aborts
|
|
|
|
|
|
# ── Reset cap ──────────────────────────────────────────────────────
|
|
|
|
class ResetCap:
|
|
"""Caps fresh-context resets at 3 per scenario. The 4th doom
|
|
→ abandon scenario with outcome='doom_loop'."""
|
|
|
|
MAX_RESETS = 3
|
|
|
|
def __init__(self):
|
|
self._resets = 0
|
|
|
|
def register_reset(self):
|
|
"""Call on each fresh-context reset. Returns True if the
|
|
cap is exceeded (scenario should be abandoned)."""
|
|
self._resets += 1
|
|
return self._resets > self.MAX_RESETS
|
|
|
|
@property
|
|
def count(self):
|
|
return self._resets
|
|
|
|
@property
|
|
def remaining(self):
|
|
return max(0, self.MAX_RESETS - self._resets) |