zubble/zubble/doom.py
pch b005fb1ef6
Some checks are pending
release / release (push) Waiting to run
initial: zubble agentic harness — extracted from llm_benchmark
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.
2026-10-24 09:37:10 -04:00

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)