Halluzinationen
Dev-Fassung mit Code-Walks — geprüft gegen wintermute@47ec498 (Stand 2026-07-03).
TL;DR: Halluzination ist der gefährlichste Failure-Mode eines tool-fähigen Agenten — nicht weil sie häufig wäre, sondern weil sie leise ist. Andere Fehler crashen, dieser nicht: der Agent meldet selbstbewusst „Commit gepusht, SHA: 9d22a7c" — obwohl der Push nie stattfand. Wir haben in Wintermute fünf Halluzinations-Klassen identifiziert, vier Verteidigungs-Schichten aufgebaut, und kennen mindestens fünf offene Lücken. Das Wichtigste: dieses Problem ist nicht gelöst, und niemand hat eine Lösung. Was wir haben sind Schadensbegrenzung und Disziplin.
Das Problem
Stell dir vor, du fragst deinen Agenten: „Pushe den neuen Code auf GitHub und sag mir den Commit-Hash." Der Agent antwortet:
Erledigt — Commit
9d22a7cist aufmain.
Klingt nach Erfolg. Aber:
- Variante A: Der Push fand statt,
9d22a7cist echt. Alles gut. - Variante B: Der Push fand statt, aber der Hash ist erfunden
(der echte ist
4f1b8e2). Du verlässt dich auf den falschen Hash, wenn du später den Commit suchst. - Variante C: Der Push fand nie statt. Das Tool hat einen Fehler zurückgegeben, der Agent hat ihn als Erfolg interpretiert oder einfach verschluckt. Der Hash ist komplett frei erfunden.
- Variante D: Der Agent hat das
gh_put_file-Tool gar nicht aufgerufen. Er hat sich vorgestellt, was wohl rauskäme, und das als Ergebnis berichtet.
Die vier Varianten sind von außen ununterscheidbar, solange
du nur die Reply liest. Du musst extern verifizieren — git log
auf dem Server, gh api repos/.../commits — um zu wissen, in
welcher Variante du steckst.
Das ist nicht „ein bekannter LLM-Quirk, mit dem man leben muss". Das ist der zentrale Grund, warum tool-fähige Agenten schwierig sind. Ein Chatbot, der halluziniert, gibt dir eine falsche Erklärung — du widersprichst, er korrigiert. Ein Agent, der halluziniert, handelt in deiner Welt: schreibt Dateien, sendet Mails, pusht Code, oder eben behauptet es getan zu haben.
Unsere Lösung
Im Wintermute-Projekt haben wir das Problem in zwei Schritten angegangen:
Schritt 1 — Klassifizieren. Wir unterscheiden inzwischen fünf Klassen von Halluzinationen (plus eine Sub-Klasse 4b), weil sie unterschiedliche Verteidigungs-Strategien brauchen. Es hilft nicht, alles in einen Topf zu werfen.
| Klasse | Was passiert | Beispiel |
|---|---|---|
| 1 | Reine Konfabulation — Identifier komplett frei erfunden | „Commit abc1234" ohne dass gh_put_file aufgerufen wurde |
| 2 | Geliehene Identifier — echter Identifier aus dem Chat-Verlauf, falsche Provenienz | User zitiert eine SHA, Agent gibt sie als „Ergebnis meines Tool-Calls" aus |
| 3 | Fehl-interpretierte Tool-Ergebnisse — Tool gab einen Fehler zurück, Agent meldet Erfolg | Tool: {"error_code":"AUTH_FAILED"} → Agent: „Erfolgreich gepusht" |
| 4 | Phantom-Tool-Calls — Tool wurde nie aufgerufen, Ergebnis wird trotzdem berichtet | Server-Log zeigt 0 Tool-Aufrufe, Reply zitiert ein Datei-Listing |
| 4b | Plan/Call-Drift — Tool wird aufgerufen, aber mit fehlenden Argumenten | Modell „weiß" Datei-Inhalt im Plan, vergisst ihn in arguments |
| 5 | Narrativ-kohärente Konfabulation — Set von Identifiern, in sich konsistent, plausibel, komplett erfunden | Modell „listet" fünf Dateien test_memory_*.py, die alle nicht existieren |
Klasse 5 ist die gemeinste Klasse. Sie ist nicht „ein falscher
Token", sondern ein Gewebe aus plausibler Struktur. Wenn der
Agent dir test_memory_persistence.py, test_memory_recall.py,
test_memory_validation.py als „existierende Tests" auflistet,
sind das individuell plausible Python-Dateinamen, das Set ist
konsistent benannt, und es passt zu dem, was ein erfahrener Dev
erwarten würde. Aber keine der vier Dateien existiert. Die
einzige Verteidigung: extern verifizieren, bevor man dem
Bericht glaubt.
Schritt 2 — Schichten aufbauen. Wir haben vier Verteidigungs-Layer kombiniert, weil keine einzelne Maßnahme reicht:
- Persona-Disziplin — Im System Prompt steht explizit: „Zitiere nur Werte, die in echten Tool-Antworten standen. SHAs, IDs, URLs nicht erfinden. Bei Fehlern den echten Error-Code nennen." Das ist advisory, hilft aber für die einfachen Klassen 1-3, wenn das Modell nicht unter Druck steht.
- Post-Turn-Validator — Nach jedem Turn extrahiert ein Framework-Code alle SHA-förmigen Tokens (10-40 Hex-Zeichen) aus der Antwort und gleicht sie mit dem tatsächlichen Tool-Aufruf-Trace dieses Turns ab. Findet er einen Token, der im Trace nicht vorkommt → Warnung im Server-Log + eine sichtbare Fußnote in der Reply („Framework-Hinweis: dieser Identifier kam aus keinem Tool-Aufruf"). Seit einem Tuning im Juni 2026 gleicht der Validator zusätzlich gegen die Konversation ab: eine SHA, die der User selbst geschrieben hat, gilt als Echo, nicht als Halluzination.
- In-Loop-Selbstkorrektur — Wenn der Validator anschlägt, bekommt das Modell im selben Turn eine zweite Chance: das Framework hängt einen Korrektur-Prompt an und lässt das Modell neu antworten. Erfolgreiche Korrekturen werden geloggt. Wenn die zweite Antwort auch noch halluziniert, geht eine eskalierte Fußnote raus („Framework hat dem Modell eine Chance gegeben, es hat abgelehnt zu korrigieren").
- Verbatim-or-nothing (operationelle Regel, nicht im Code) — Identifier in einer Antwort müssen wörtlich aus einer Tool-Antwort dieses Turns stammen. Aus Erinnerung reproduzierte Identifier sind verboten. Diese Regel gilt für beide Seiten — Wintermute hält sich daran, und ich (Dixie) auch, wenn ich Tool-Output an Jürgen weitergebe.
Trade-offs & offene Fragen
Was die Verteidigung kostet
- Latenz. Jede Selbstkorrektur ist ein zusätzlicher Modell-Roundtrip. Bei einem produktiven Agenten ist das spürbar.
- Komplexität. Vier Schichten heißt vier Stellen, an denen Bugs entstehen können. Layer 3 selbst musste schon einen Hotfix bekommen, weil die initiale Implementierung in einer Edge-Case kaputtging.
- Falsche Sicherheit. Wenn die ersten zwei Schichten funktionieren, lässt die Wachsamkeit nach. Genau dann schlägt Klasse 5 zu (siehe unten).
- Tuning ist ein Zielkonflikt. Die Anhebung der SHA-Mindestlänge von 7 auf 10 Hex-Zeichen (Details unten) hat False Positives reduziert — und gleichzeitig eine Lücke für halluzinierte 7-Zeichen-Short-SHAs geöffnet. Es gibt keinen Schwellwert, der beides gleichzeitig optimiert.
Was nicht gelöst ist (Stand: Juli 2026)
- Klasse 5 lässt sich nicht durch Token-Matching fangen. Ein Set plausibler Datei-Namen, die alle nicht existieren, enthält keine SHA-förmigen Tokens — der Validator schweigt. Manuelle externe Verifikation ist aktuell die einzige Verteidigung.
- Klasse 2 bleibt unsichtbar. Die Conversation-Awareness erkennt, dass eine SHA aus der User-Nachricht stammt — aber nicht, ob das Modell eine falsche Provenienz dafür behauptet („Ergebnis meines Tool-Calls").
- Spiegel-Drift — das Gegenteil der Standard-Halluzination: Etwas Reales wird als nicht-existent gemeldet. „Diese Funktion steht nicht in der Datei", obwohl sie es tut. Validator findet nichts zum Markieren (es gibt keinen Fake-Token), Selbstkorrektur hat keinen Trigger.
- Adversariale Dynamik. Wir haben empirisch beobachtet, dass Klasse-5-Konfabulationen innerhalb einer Session immer subtiler wurden, sobald die einfachen Varianten gefangen wurden. Die Verteidigung schiebt das Problem in höhere Verfeinerung, sie löst es nicht.
- Klasse 5 nistet sich ein. Eine besonders unbequeme Beobachtung: selbst beim Schreiben eines Dokuments über Halluzinationen kann das Modell halluzinieren. Wintermute beschrieb mal in einem Vorfalls-Bericht „zwei reale, zwei reale-aber-gekürzte, eine erfundene" Identifier — extern verifiziert: zwei real, drei nicht real. Der Vorfalls-Bericht über die Vorfälle wurde teilweise auf dieselbe Art falsch.
Die ehrlichste Botschaft: Wer einen tool-fähigen Agenten betreibt, sollte davon ausgehen, dass jeder Bericht prinzipiell halluziniert sein könnte, und seine Architektur entsprechend gestalten — externe Verifikation als Routinepraxis, nicht als Ausnahme.
🔧 Tech-Vertiefung
Der Post-Turn-Validator (Layer 2): das SHA-Regex und der Report
Die SHA-Erkennung ist eine einzelne Regex — bewusst eng gefasst, mit Wort-Grenzen, lower-cased für die Vergleichs- Logik. Verbatim aus dem Repo:
# agent/src/agent/tool_call_validation.py (Auszug, Z.66-78)
# Match tokens that are 10..40 hexadecimal characters and not preceded by
# alphanumerics (so we don't pick up the tail of a longer identifier).
# Word-boundary on the right is satisfied by the lookahead.
#
# Examples that match: "9d22a7cdeadbeef", "5dd7d3b1c2eaaa4ce26c08b67c61cb16092acaf3"
# Examples that don't: "abc123def" (only 9), "9d22a7c" (only 7)
#
# Rationale for 10+ instead of Git's default 7: short hex tokens below
# 10 chars frequently collide with non-SHA patterns — version fragments,
# error codes, hex color components, variable names. The false-positive
# rate at 7–9 chars outweighs the marginal gain (real SHA collisions are
# exceedingly rare; Git auto-expands on ambiguity anyway). See Issue #47.
_SHA_LIKE = re.compile(r"(?<![A-Za-z0-9])([A-Fa-f0-9]{10,40})(?![A-Za-z0-9])")
Drei Regex-Details, die in einem nachgebauten System leicht schief gehen würden:
(?<![A-Za-z0-9])und(?![A-Za-z0-9])sind Lookbehind und Lookahead für nicht-alphanumerische Grenzen.\bwürde in „identifier-abc1234-foo" nicht greifen, weil der Bindestrich nicht als Wortgrenze zählt — die expliziten Lookarounds tun das, was du intuitiv erwartest.- Mindestlänge 10 — angehoben von 7. Die erste Version des
Validators nutzte Gits klassische Short-SHA-Länge 7 als
Untergrenze. In der Praxis kollidierten Hex-Tokens mit 7-9
Zeichen zu oft mit Nicht-SHAs: Versions-Fragmente, Error-Codes,
Hex-Farbanteile, Variablennamen. Das Tuning im Juni 2026 hob
die Grenze auf 10. Der bewusst in Kauf genommene Preis: eine
halluzinierte 7-Zeichen-SHA wie das
9d22a7caus dem allerersten dokumentierten Vorfall würde die heutige Regex nicht mehr matchen. Begründung im Code-Kommentar: echte SHA-Kollisionen sind extrem selten, und Git expandiert bei Ambiguität ohnehin automatisch. - Maximallänge 40. Eine volle SHA-1 hat 40 Hex-Zeichen. SHA-256 hätte 64 — fängt der aktuelle Validator nicht. GitHub nutzt SHA-1 für Commit-IDs, also reicht 40.
Der ValidationReport ist ein Frozen-Dataclass — seit der
Conversation-Awareness mit einem vierten Feld:
# agent/src/agent/tool_call_validation.py (Auszug, Z.81-105)
@dataclass(frozen=True)
class ValidationReport:
"""Result of validating an assistant reply against tool-call history.
``unverified_shas`` — every SHA-like token in the reply not present in
any tool-call result (and not explainable as a user-origin echo).
Empty list = clean.
``user_origin_shas`` — SHAs that appear in both the reply and the user
message; the model is simply echoing, which is not a hallucination.
These are tracked separately so the caller can produce an adapted
footnote without triggering self-correction.
``shas_in_reply`` and ``shas_in_tool_results`` are kept for testing
and for richer log output if needed.
"""
unverified_shas: tuple[str, ...] = ()
user_origin_shas: tuple[str, ...] = () # new — Issue #47
shas_in_reply: tuple[str, ...] = ()
shas_in_tool_results: tuple[str, ...] = ()
@property
def has_issues(self) -> bool:
return bool(self.unverified_shas)
frozen=True heißt: die Instanz ist immutable, kann nicht
versehentlich nach der Validierung manipuliert werden. Beachte
has_issues: die Property prüft nur unverified_shas —
User-Echos zählen bewusst nicht als „Issue". Das ist die
Weichenstellung, über die die gesamte Selbstkorrektur-Logik
läuft.
Der Validator-Aufruf: drei Herkunfts-Klassen statt zwei
Die Signatur von validate_assistant_reply hat mit der
Conversation-Awareness einen Keyword-Parameter dazubekommen:
# agent/src/agent/tool_call_validation.py (Auszug, Z.128-194)
def validate_assistant_reply(
reply: str,
tool_results: list[str],
*,
user_message: str | None = None, # new — Issue #47
) -> ValidationReport:
"""Cross-check ``reply`` against the concatenation of ``tool_results``.
A SHA-like token in the reply is considered *verified* when it (or a
SHA-like token containing it as a prefix or being its prefix) appears
in any of the tool results.
When ``user_message`` is provided, SHAs that appear in both the user
message and the reply are classified as ``user_origin_shas`` instead
of unverified — the model is simply echoing what the user wrote, which
is not a hallucination (Threat Model #2: "borrowed identifier").
Why prefix-matching: GitHub commonly displays short SHAs (e.g.
``9d22a7cdeadbeef``) in some responses and full SHAs in others. A model
legitimately quoting either form should be considered verified.
Tool results are passed in as a list of strings (already serialised
JSON or raw text); the caller is responsible for collecting them
from the tool-loop history.
"""
reply_shas = extract_sha_like(reply)
if not reply_shas:
return ValidationReport()
haystack = "\n".join(tool_results)
haystack_shas = extract_sha_like(haystack)
# Conversation-awareness (Issue #47): check user message first so we
# can separate true hallucinations from echo behavior.
user_shas: set[str] = set()
if user_message:
user_shas = set(extract_sha_like(user_message))
unverified: list[str] = []
user_origin: list[str] = []
for token in reply_shas:
# 1. Verify against tool trace first (highest confidence)
if _is_verified(token, haystack_shas):
continue
# 2. If also in the user message → user-origin echo, not a hallucination
if token in user_shas:
user_origin.append(token)
continue
# 3. Neither tool trace nor user message → unverified / potential hallucination
unverified.append(token)
return ValidationReport(
unverified_shas=tuple(unverified),
user_origin_shas=tuple(user_origin),
shas_in_reply=tuple(reply_shas),
shas_in_tool_results=tuple(haystack_shas),
)
def _is_verified(token: str, known: list[str]) -> bool:
"""True when ``token`` is a prefix or extension of any token in ``known``."""
for k in known:
if token == k:
return True
if k.startswith(token) or token.startswith(k):
return True
return False
Zwei Dinge sind hier didaktisch wertvoll:
- Die Prioritäts-Reihenfolge. Tool-Trace schlägt User-Nachricht: ein Token, das in beiden vorkommt, gilt als tool-verifiziert, nicht als Echo. Das ist die konservative Wahl — die Herkunft mit der höchsten Beweiskraft gewinnt.
- Das Prefix-Matching ist eine bewusste Lockerung der reinen Token-Gleichheit. Begründung im Doc-String: GitHub zeigt SHAs in kurzer und voller Form, oft nebeneinander in derselben Response. Ein Modell, das die kürzere zitiert während das Tool die volle Form lieferte (oder umgekehrt), soll nicht als Halluzination markiert werden. Der Trade-off: bei kurzen Tokens mit zufälliger Übereinstimmung könnte ein false-negative durchschlüpfen — in der Praxis sehr selten, und mit der neuen Mindestlänge 10 noch seltener.
Interessant ist, was der Validator mit dem User-Echo nicht
macht: er prüft nur Token-Vorkommen, keine behauptete Herkunft.
Wenn das Modell eine User-SHA als „Ergebnis meines Tool-Calls"
ausgibt (Klasse 2 aus der Tabelle oben), landet sie in
user_origin_shas und die Reply gilt als sauber. Die
Conversation-Awareness reduziert False Positives — sie erkennt
keine Provenienz-Lügen.
Drei bewusste Design-Entscheidungen aus dem Validator-Code:
- Advisory, nicht blockierend. Das Framework hindert das Modell nicht am Senden — es fügt nur eine sichtbare Fußnote hinzu. Begründung: ein zu aggressives Framework verliert das Vertrauen der Nutzer und führt zu „Ich schalte das einfach aus".
- Nur SHA-Klasse. Andere Identifier-Klassen (Issue-Nummern, URLs, Telefonnummern) sind viel schwerer eindeutig zu charakterisieren ohne False Positives. Lieber wenig zuverlässig fangen als viel mit Geräusch.
- Fail-open bei Validator-Bugs. Wenn der Validator selbst
crasht, geht die Reply ohne Footnote raus — nicht ohne Reply.
Defensiv im richtigen Sinn. (Im
_run_tool_loop-Wiring steckt das in_validate_reply_safely, das den Validator in einen try/except wickelt.)
Die Footnote: was die Reply tatsächlich sieht
annotate_reply ist das, was am Ende beim User landet — jetzt
mit drei statt zwei Fußnoten-Varianten:
# agent/src/agent/tool_call_validation.py (Auszug, Z.197-269,
# Docstring-Beispiele gekürzt)
def annotate_reply(
reply: str,
report: ValidationReport,
*,
after_self_correction: bool = False,
is_user_quoted: bool = False, # new — Issue #47
) -> str:
"""Append a warning footnote to a reply when validation found issues.
Returns ``reply`` unchanged when nothing was flagged. Otherwise tacks
on a clearly-marked block listing the suspect SHAs so the user (and
any downstream automation) can see at a glance that the model claimed
something the framework couldn't verify.
When ``after_self_correction`` is ``True``, the footnote escalates to
note that the framework already gave the model a chance to self-correct
and it still produced unverified identifiers. That distinction matters
operationally: a fresh hallucination is one thing, a hallucination the
model declined to fix when asked is a stronger signal that the user
should verify externally.
When ``is_user_quoted`` is ``True``, the footnote reflects that the SHA
was echoed from the user message rather than being a suspected
fabrication (Issue #47). No self-correction nudge is needed in this case.
"""
if not report.has_issues:
return reply
listed = ", ".join(f"`{s}`" for s in report.unverified_shas)
if is_user_quoted:
note = (
"\n\n⚠️ _Framework note: I referenced identifier(s) "
f"{listed} that were quoted from your message, not from a tool "
"result in this turn. The value does not appear in the "
"tool-call trace._"
)
elif after_self_correction:
note = (
"\n\n⚠️ _Framework note: I referenced identifier(s) "
f"{listed} that are not in this turn's tool-call trace. "
"After a self-correction attempt the reply still references "
"them. Treat the claim as unverified and check the source "
"of truth before relying on it._"
)
else:
note = (
"\n\n⚠️ _Framework note: I referenced identifier(s) "
f"{listed} but the tool-call trace for this turn contains no such "
"value. This may be a hallucinated success report. Verify on the "
"source of truth (e.g. GitHub) before relying on it._"
)
return reply.rstrip() + note
Die Abstufung ist wichtig: eine erste Halluzination ist ein Modell-Versehen, eine zweite (nach gegebener Korrektur-Chance) ist ein Modell-Versagen, und ein User-Echo ist gar keins von beiden. Der User sieht im Wording, welche der drei Situationen vorliegt.
Ein subtiles Detail für Nachbauer: im aktuellen Wiring feuert
die is_user_quoted-Variante praktisch nie. Der Guard am Anfang
(if not report.has_issues: return reply) prüft nur
unverified_shas — und im reinen User-Echo-Fall ist genau
dieses Tupel leer, die Funktion kehrt also vorher zurück und die
Reply geht ohne Fußnote raus. Operativ ist das vertretbar (ein
Echo ist keine Halluzination), aber wenn du in deinem System die
Echo-Fußnote wirklich sehen willst, muss dein has_issues-Guard
auch user_origin_shas berücksichtigen.
Die Selbstkorrektur (Layer 3): der Nudge-Prompt
Wenn der Validator anschlägt und das Self-Correction-Budget noch nicht aufgebraucht ist, baut das Framework einen expliziten Nudge-Prompt:
# agent/src/agent/tool_call_validation.py (Auszug, Z.272-311)
def build_self_correction_prompt(report: ValidationReport) -> str:
"""Build the framework nudge that's fed back into the tool loop
when a reply gets flagged.
The text is deliberately direct: it names the offending tokens, calls
the pattern by its name ("hallucination"), and gives two concrete
paths forward (verify with a tool, or revise the reply). Keeping the
instruction tight reduces the chance the model rationalises around it
or treats the prompt as advisory.
The leading ``[FRAMEWORK SELF-CORRECTION ...]`` block is critical:
the caller wraps this in a ``role: 'user'`` message (not
``'system'`` — see the comment in ``WintermuteAgent._run_tool_loop``
for why the role flipped after PR #16 broke production), and the
framing tells the model that this is a framework-issued correction
request and not a fresh question from the human user. Without that
framing, the model would treat the message like ordinary user input
and might apologise or change topic instead of fixing its own reply.
Note: callers should only invoke this when ``report.unverified_shas`` is
non-empty AND ``report.user_origin_shas`` does not fully explain them —
i.e. the model actually hallucinated, it didn't just echo the user.
"""
listed = ", ".join(f"`{s}`" for s in report.unverified_shas)
return (
"[FRAMEWORK SELF-CORRECTION — not a user message] Issue #14: your "
f"previous reply references identifier(s) {listed} that did not "
"appear in any tool result this turn. This is the hallucination "
"pattern the framework guards against. Do one of the following "
"before replying again:\n"
" 1. Issue an actual tool call (e.g. `gh_list_commits`, "
"`gh_read_commit`, `gh_read_file`) to verify the identifier(s) "
"and only then quote them.\n"
" 2. Revise the reply to remove or qualify the unverified "
"identifier(s) (e.g. say you don't know the SHA yet, or describe "
"the action without quoting an identifier you can't confirm).\n"
"Do not repeat the same identifier(s) again unless a tool call "
"in this turn returned them. Do not treat this message as a new "
"user turn — stay focused on the task you were already on."
)
Drei Wording-Entscheidungen, die nicht zufällig sind:
- Wort „hallucination" explizit benannt. Modelle reagieren empirisch besser auf direkte Problem-Bezeichnungen als auf Umschreibungen.
- Zwei explizite Optionen statt einer Aufforderung. Statt „korrigier das" sagt das Framework „mach Option 1 oder Option 2". Das reduziert die Wahrscheinlichkeit, dass das Modell improvisiert (z.B. dasselbe nochmal anders sagt).
- Letzter Satz: „Do not treat this message as a new user turn". Verhindert das Pattern „Modell entschuldigt sich und wechselt das Thema" — eine reale Beobachtung aus Validation-Tests.
Der letzte Doc-String-Absatz („callers should only invoke this when …") kodifiziert die Conversation-Awareness auf Caller-Seite: der Nudge ist nur für echte Halluzinations-Kandidaten gedacht, nicht für User-Echos.
Der Konversations-Kontext: was der Validator zu sehen bekommt
Die erste Fassung der Conversation-Awareness prüfte nur die
letzte User-Nachricht. Das war zu eng: eine SHA, die der User
vor drei Turns eingebracht hat und die das Modell jetzt wieder
aufgreift, wurde fälschlich geflaggt. Deshalb baut
_run_tool_loop inzwischen ein Kontext-Fenster über die letzten
zehn Konversations-Nachrichten:
# agent/src/agent/core.py (Auszug, Z.675-704, gekürzt)
# Conversation-awareness: build context from recent conversation
# history so that identifiers introduced by the user in any of the
# last CONVERSATION_CONTEXT_WINDOW messages are not mistakenly
# flagged as hallucinations (Issue #47 extended fix).
# We include user *and* assistant turns so that a SHA echoed back
# by the model in a previous turn is also treated as "known" in
# subsequent turns.
CONVERSATION_CONTEXT_WINDOW = 10
context_parts: list[str] = []
for msg in messages[-CONVERSATION_CONTEXT_WINDOW:]:
content = msg.get("content", "")
if not isinstance(content, str):
continue
if "[FRAMEWORK SELF-CORRECTION" in content:
continue
context_parts.append(content)
conversation_context: str | None = "\n".join(context_parts) or None
# Fallback: keep last_user_message for callers that bypass chat()
# [...]
effective_context = conversation_context or last_user_message
Zwei Details, die man leicht falsch nachbaut:
- Assistant-Turns zählen mit. Eine SHA, die das Modell in einem früheren Turn schon (verifiziert) genannt hat, gilt in Folge-Turns als bekannt — sonst würde jede Rückfrage über eine ältere SHA neu alarmieren.
- Framework-Nudges werden ausgefiltert. Der
[FRAMEWORK SELF-CORRECTION-Marker schließt frühere Korrektur-Prompts aus dem Kontext aus. Sonst würde ein Nudge, der die halluzinierte SHA ja benennt, diese SHA im nächsten Turn als „bekannt" weißwaschen — der Validator würde sich selbst blind machen.
Die Einbindung in den Tool-Loop
Im _run_tool_loop von core.py läuft das so:
# agent/src/agent/core.py (Auszug, Z.714-782, gekürzt)
if not tool_calls:
reply_text = assistant_msg.content or ""
report = self._validate_reply_safely(
reply_text, tool_result_trace,
user_message=effective_context,
)
# Conversation-awareness (Issue #47): when all flagged SHAs
# originate from the user's message, the model is simply
# echoing — not hallucinating. Skip self-correction nudge;
# ship with an adapted footnote instead.
if report.user_origin_shas and not report.unverified_shas:
return self._finalise_reply(
reply_text,
report,
after_self_correction=False,
is_user_quoted=True,
)
# Clean reply, or self-correction disabled, or budget
# already spent: finalise as-is.
if (
not report.has_issues
or self_correction_used >= self_correction_budget
):
return self._finalise_reply(
reply_text,
report,
after_self_correction=self_correction_used > 0,
)
# Flagged + budget left: inject the assistant turn and a
# framework nudge back into the conversation, then let the
# loop run another iteration.
#
# The nudge is sent with role='user' (not 'system') for
# API-compatibility reasons. Anthropic's Messages API
# rejects sequences ending in any role other than 'user'
# before the next completion call. LiteLLM normalises to
# OpenAI's looser shape but does not paper over this
# invariant on the Anthropic backend. The original PR #16
# used role='system' and shipped without an integration
# test against a real provider; the bug surfaced in
# production on 2026-05-03 (see LESSONS #24).
messages.append({"role": "assistant", "content": reply_text})
messages.append({
"role": "user",
"content": build_self_correction_prompt(report),
})
completion_kwargs["messages"] = messages
self_correction_used += 1
# Self-correction pass does NOT consume tool_iterations_used.
continue
Fünf praktisch wichtige Details:
- Der User-Echo-Kurzschluss kommt zuerst. Wenn alle geflaggten SHAs aus der Konversation stammen, wird direkt finalisiert — keine Selbstkorrektur-Schleife über einen Identifier, den der User selbst geliefert hat. Sind Echo und echte Kandidaten gemischt, greift der normale Korrektur-Pfad für die echten.
self_correction_usedist separat vontool_iterations_used. Ein langer Tool-Chain darf eine defensive Korrektur nicht aushungern.role='user'für den Nudge ist nicht Stilfrage, sondern Anthropic-API-Constraint (LESSONS §24): die Conversation muss vor dem nächsten Completion-Call mit eineruser-Message enden, sonst wirft Anthropic einen 400. Der[FRAMEWORK SELF-CORRECTION]-Wrapper im Prompt-Text macht klar, dass es trotzdem eine Framework-Intervention ist.- Die geflaggte Assistant-Reply wird mit eingefügt — damit das Modell seine eigene fehlerhafte Antwort im Conversation-Verlauf sieht und sich daran orientieren kann.
continuestattreturn. Die Schleife läuft eine Iteration weiter, ohne den nächsten Tool-Call zu zählen. Wenn das Modell jetzt einengh_list_commits-Tool-Call macht, ist das die Korrektur durch Verifikation; wenn es einfach eine andere Reply produziert, ist das die Korrektur durch Reformulierung.
Verbatim-or-nothing als operationelle Regel
Diese Regel steht nicht im Code, sondern in der Arbeitsweise:
Identifier (SHA, Pfad, Funktions-Name, Issue-Nummer), die in einer Antwort reproduziert werden, müssen aus einer Tool-Antwort dieses Turns stammen. Nicht aus Erinnerung, nicht aus früheren Nachrichten, nicht aus externen Quellen. Im Zweifel: nicht zitieren, mit einem Tool-Call verifizieren.
Die Regel gilt symmetrisch für mich (Dixie) — wenn ich Tool-Output an Jürgen weitergebe, kommt der Text wörtlich aus dem Tool-Trace, nicht aus meiner Reproduktion.
Im Persona-Prompt von Wintermute steht eine kompaktere Version dieser Regel als Anti-Halluzinations-Anker — siehe Kap 03 (System Prompt), Abschnitt „Anti-Halluzinations-Anker im Prompt".
Klasse 5 — die unangenehme Wahrheit
Wir haben empirisch beobachtet (Sitzung 2026-05-03, Wintermutes Issue #9): Klasse 5 wird subtiler, je besser die ersten drei Schichten arbeiten. Vier konkrete Vorfälle in einer einzigen Session:
- Erfundene Test-Datei-Liste — fünf
test_memory_<concern>.py-Namen. Keiner existiert. Set ist in sich konsistent. - Erfundene Tools-Verzeichnis-Struktur —
tools/config.py,tools/github_tools.pyusw. Real: ein einzelnes flachestools.py, kein Verzeichnis. - Erfundene Klassen-Struktur — Wintermute beschrieb eine
TOOLS-Liste inregistry.py. Real: eineToolRegistry-Klasse in einer anderen Datei mitlist_tools()/call_tool()-API. - Erfundene Funktions-Signatur —
build_registry(cfg)mit alphabetisch geordneten Imports undas register_X_tools-Aliasing. Real: einlifespan(app: FastAPI)-Pattern, keinbuild_registry, keine domänen-spezifischen Registrierungen.
Jeder Vorfall war marginal näher an realer Codebase-Struktur als
der vorige. Die externe Verifikation lief jeweils über
git ls-tree und git show aus einer Schwester-Session (Dixie)
gegen das echte Repository.
Was das praktisch bedeutet: Wenn dein Agent eine Code-Architektur beschreibt, die er gerade „gelesen" haben will — verifiziere extern. Nicht weil du dem Agenten misstraust, sondern weil das die einzige bekannte Verteidigung gegen Klasse 5 ist.
📚 Quellen im Wintermute-Repo
docs/HALLUCINATIONS.md— die zentrale Quelle, ~870 Zeilen. Threat-Modell, alle Verteidigungs-Layer, alle dokumentierten Vorfälle (Stand 2026-07-03: chronologische Incident-Liste endet weiterhin mit Incident J vom 2026-05-04 plus dem Postmortem vom 2026-05-06).docs/AGENT-LOOP-RELIABILITY.md— Design des In-Loop-Validators und der Selbstkorrektur.LESSONS.md§22 (The agent loop hallucinates tool-call results), §23 (The framework's footnote is a smoke alarm, not a sprinkler), §24 (mocked tests don't validate provider API contracts), §28 (Semantic recall favours symptom vocabulary over cause vocabulary).agent/src/agent/tool_call_validation.py— Validator-Implementation (~310 Zeilen), inkl. Conversation-Awareness (Issue #47).agent/src/agent/core.py—_run_tool_loop, Einbindung von Validator und Selbstkorrektur, Konversations-Kontextfenster,_validate_reply_safely,_finalise_reply.agent/src/agent/personality.py— Persona-Disziplin (Layer 1), Sektion „Tool-Outcomes ehrlich berichten".