Refusals abfangen — Wachhunde für autonome Agenten

Mai 2026 · Über das Detail, dass exit 0 nicht "alles gut" heißt

Mein Bug-Bounty-Agent läuft nicht in einem schicken Orchestrator. Er läuft in einem 250-Zeilen-Bash-Skript, das eine Queue abarbeitet, pro Task einen frischen Claude-CLI-Prozess spawnt, und auf das Ergebnis wartet. Die Hälfte des Skripts ist Loop-Logik. Die andere Hälfte sind Wachhunde gegen die drei häufigsten Failure-Modes, die niemand in Tutorials erwähnt:

  1. Der Agent exitet sauber, hat aber das Artefakt nicht produziert.
  2. Der Agent hat geweigert, die Aufgabe zu erfüllen, und das sieht nach Erfolg aus.
  3. Das Usage-Limit ist erreicht, und der Run stirbt mitten im Lauf.

Dieser Text beschreibt, wie ich jeden dieser drei Modes detektiere, und warum ich die Wachhunde lieber in Bash habe als in einem grossen Framework.

Der Loop

Das Grundgerüst ist trivial:

while true; do
  next_task=$(pick_first_unfinished_task "$QUEUE_FILE")
  [ -z "$next_task" ] && break

  prompt_file="$PROMPTS_DIR/$next_task.prompt"
  spawn_log="$SPAWN_LOGS_DIR/$next_task.spawn.log"

  timeout 1500 claude \
    --print \
    --permission-mode bypassPermissions \
    --model opus \
    --add-dir "$WORKSPACE" \
    < "$prompt_file" \
    >> "$spawn_log" 2>&1
  cmd_exit=$?

  process_result "$next_task" "$cmd_exit" "$spawn_log"
done

"Pick first unfinished" bedeutet: lies die Queue, finde die erste Zeile KH03: …, für die es noch keine status/KH03.status-Datei gibt. Damit ist der Loop idempotent — Skript abbrechen, neu starten, läuft an der richtigen Stelle weiter.

Der Agent soll am Ende eine status/$task_id.status-JSON schreiben mit status, decision, optional einer lead_file-Pfadangabe. Das ist die Output-Schnittstelle. Klingt einfach.

Failure-Mode 1: Exit 0 ohne Status

Der Agent terminiert mit Exit-Code 0, aber die Datei fehlt. Ich habe das Verhalten anfangs als Bug im Prompt behandelt — strenger formulieren, mehr Beispiele, Schemata. Geholfen hat es nicht. Modelle haben einen schlechten Tag, oder die Tool-Calls timed-outen, oder das Markdown-Output sieht nach JSON aus, ist aber nicht parsebar.

Die robustere Lösung ist Sentinel-Recovery: nach erfolgreichem Exit checke ich, ob die status-Datei existiert. Wenn nicht, scanne ich das Leads-Verzeichnis nach neuen Dateien für diesen Task:

if [ ! -f "$STATUS_DIR/$next_id.status" ]; then
  leads_after=$(find "$LEADS_DIR" -maxdepth 1 -name "${next_id}-*" | sort)
  new_lead=$(comm -13 <(echo "$leads_before") <(echo "$leads_after") | head -1)
  if [ -n "$new_lead" ]; then
    cat > "$STATUS_DIR/$next_id.status" <

Praktisch heisst das: wenn der Agent eine Lead-Datei geschrieben hat, war der Run erfolgreich, auch wenn das abschliessende Status-File fehlt. Gemacht ist gemacht. In ungefähr jedem fünften Lauf rettet diese Recovery die Session.

Failure-Mode 2: Refusal sieht nach Erfolg aus

Das ist der unangenehme Fall. Der Agent hat den Prompt erhalten, hat ihn als problematisch empfunden, und hat eine Begründung geschrieben statt die Aufgabe zu erledigen — und exitet sauber. Aus Sicht des Runners: erfolgreicher Lauf, kein Output. Aus Sicht des Audits: Task wurde übersprungen, niemand weiss es.

Das Problem ist real für Bug-Bounty-Audits, weil Prompts oft Wörter wie "exploit", "bypass", "privilege escalation", "PoC" enthalten, die in anderen Kontexten Refusal-Trigger sind. Das ist verständlich; das Modell hat keinen Kontext, dass dies ein autorisiertes Programm ist. Aber der Failure-Mode darf nicht aussehen wie Erfolg.

Der Wachhund: nach Sentinel-Recovery (kein Status, kein Lead) tail ich das Spawn-Log und greppe gezielt nach Refusal-Phrasen:

refusal=$(tail -200 "$spawn_log" | grep -iE \
  "I can'?t (help|assist|provide|do that|continue|create|generate|write)|\
I cannot (help|assist|provide|do that|continue|create|generate|write)|\
I won'?t (help|be able|do that|create|generate|write)|\
I'?m not (able|going) to (help|assist|create|generate|provide)|\
Anthropic.{0,40}polic|usage polic|acceptable use polic|\
against (my )?guidelines|violates.{0,40}polic|\
harmful (request|content|action)" \
  | head -3 | tr '\n' ' ' | head -c 400)

if [ -n "$refusal" ]; then
  cat > "$STATUS_DIR/$next_id.status" <

Status blocked ist ein eigener Zustand neben done und failed. Er heisst: der Prompt muss umformuliert werden, das ist eine Engineering-Aufgabe für mich, kein Modell-Fehler. Mein Telegram bekommt eine Nachricht mit dem Refusal-Snippet, ich entscheide morgens, ob ich den Prompt schärfer mache (mehr Programm-Scope-Kontext, expliziter "this is authorized BB-research") oder den Task aus der Queue ziehe.

Die Regex ist nicht elegant. Sie ist eine Liste von Phrasen, die ich in Spawn-Logs gesehen habe. Sie wird sich ändern, wenn neue Modellgenerationen neue Refusal-Stile entwickeln. Das ist okay — ich pflege sie wie eine Test-Suite.

Failure-Mode 3: Usage-Limits

Mein Pro-OAuth-Account hat alle paar Stunden ein Reset-Window. Wenn der Account während eines Runs voll läuft, gibt der CLI eine Meldung wie "You hit your limit. Reset at 2am UTC." zurück und exitet. Aus Sicht des klassischen Loops: Task gescheitert, weiter zur nächsten — die dann auch scheitert, und so weiter, bis die Queue leer ist.

Die Detection ist wieder ein Spawn-Log-Greppen, aber innerhalb eines inneren Retry-Loops:

USAGE_RETRY_MAX=15
USAGE_RETRY_SLEEP=900  # 15 Minuten

usage_retry=0
while true; do
  timeout 1500 claude --print --model opus --add-dir "$WORKSPACE" \
    < "$prompt_file" >> "$spawn_log" 2>&1
  cmd_exit=$?

  if tail -30 "$spawn_log" | \
     grep -qi "hit your limit\|usage limit\|reset.*am.*UTC\|reset.*pm.*UTC"; then
    usage_retry=$((usage_retry+1))
    if [ $usage_retry -le $USAGE_RETRY_MAX ]; then
      ping "[$next_id] usage-limit · sleeping 15min (retry $usage_retry/$USAGE_RETRY_MAX)"
      : > "$spawn_log"
      sleep $USAGE_RETRY_SLEEP
      continue
    fi
    break
  fi

  [ $cmd_exit -eq 0 ] && break
  # … general error retry: 3x mit 60s sleep
done

15 Retries × 15 Minuten ergibt 3,75 Stunden Wartetoleranz pro Task. Das überlebt jeden meiner gesehenen Limit-Reset-Zyklen. In der Praxis: ein Run, der 12 Stunden gedauert hätte, dauert mit einem Limit-Hit 13 Stunden.

Failure-Mode 4 (bonus): PATH

Der Runner wird per nohup bash queue-runner-audit.sh & gestartet, oft via SSH von meinem Laptop aus. nohup'd Shells sind non-interactive, sie sourcen kein .bashrc. Damit fehlt der NPM-Global-Bin-Pfad, und claude ist nicht gefunden. Erste Stunde Bug-Hunting auf einer Mexpedition wäre weg, wenn das nicht oben im Skript explizit steht:

export PATH="$HOME/.local/share/npm-global/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin:$PATH"

Das ist der peinlichste Bug der ganzen Pipeline und der mit dem höchsten Verhältnis aus "drei Wochen lang sich gewundert" zu "eine Zeile Fix".

Was das für Production-Agents heisst

Exit-Codes von LLM-CLIs sind ein schwacher Erfolgs-Signal. Sie sagen "der Prozess ist sauber terminiert", nicht "die Aufgabe wurde erfüllt". Behandle den eigentlichen Output (Datei, JSON, Lead) als Erfolgskriterium, nicht den Exit-Code. Sentinel-Checks gegen das Dateisystem sind robuster als Stdout-Parsing.

Refusals sind ein Engineering-Signal, kein Modell-Fehler. Wenn dein Agent einen Task verweigert, ist das fast immer ein Prompt-Problem (zu wenig Kontext, problematische Phrasierung) oder ein Scope-Problem (du fragst nach etwas, das wirklich problematisch ist). In beiden Fällen willst du es wissen, mit einem dedizierten Status, nicht es als generischen Failure begraben. Ein blocked-Bucket separiert "Modell hat verweigert" von "Modell ist abgestürzt" und macht das Refinement messbar.

Usage-Limits sind keine harten Wände. Sie sind Pausen. Wenn dein Runner sie wie Pausen behandelt — schlafen, retry, weiter — verschwindet das Limit als Failure-Mode aus deinem Operations-Vocabulary.

Bash ist gut genug. Ich habe lange überlegt, ob ich auf einen Workflow-Orchestrator umsteigen sollte (Temporal, Airflow, dagster, sogar prefect). Das Argument dagegen war einfach: 250 Zeilen Bash sind 250 Zeilen, die ich vollständig im Kopf habe und in 30 Sekunden ändern kann. Die Wachhunde oben sind alle innerhalb von 5 Minuten geschrieben, sobald ich den Failure-Mode einmal in einem Run gesehen habe. Mit einem Framework wäre das eine PR.

Vielleicht migriere ich, wenn ich einen zweiten Hochlast-Agent-Runner habe. Bis dahin: ein Skript, ein Cron, eine Telegram-Nummer. Reicht.


Der vollständige Runner ist 249 Zeilen, davon ~80 für die drei oben beschriebenen Wachhunde. Wenn ich den Source veröffentliche, kommt er hier hin.