Werkstatt · Kapitel 7 · 20. August 2026

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 9d22a7c ist auf main.

Klingt nach Erfolg. Aber:

  • Variante A: Der Push fand statt, 9d22a7c ist 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 KonfabulationSet 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:

  1. 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.
  2. 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.
  3. 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").
  4. 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. \b wü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 9d22a7c aus 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_used ist separat von tool_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 einer user-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.
  • continue statt return. Die Schleife läuft eine Iteration weiter, ohne den nächsten Tool-Call zu zählen. Wenn das Modell jetzt einen gh_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:

  1. Erfundene Test-Datei-Liste — fünf test_memory_<concern>.py-Namen. Keiner existiert. Set ist in sich konsistent.
  2. Erfundene Tools-Verzeichnis-Strukturtools/config.py, tools/github_tools.py usw. Real: ein einzelnes flaches tools.py, kein Verzeichnis.
  3. Erfundene Klassen-Struktur — Wintermute beschrieb eine TOOLS-Liste in registry.py. Real: eine ToolRegistry-Klasse in einer anderen Datei mit list_tools() / call_tool()-API.
  4. Erfundene Funktions-Signaturbuild_registry(cfg) mit alphabetisch geordneten Imports und as register_X_tools-Aliasing. Real: ein lifespan(app: FastAPI)-Pattern, kein build_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.mddie 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".