Werkstatt · Kapitel 4 · 10. August 2026

Memory Handling

Dev-Fassung mit Code-Walks — geprüft gegen wintermute@47ec498 (Stand 2026-07-03).

Kapitel 04 — Memory Handling

TL;DR: Ein Agent ohne Memory ist ein Goldfisch. Wintermute kombiniert drei Memory-Schichten: tägliche Markdown-Logs (für Menschen und als Kurzzeit-Historie des Agenten), eine kuratierte Langzeit-Datei (für jeden Turn mitgeladen) und eine Vektor-Datenbank für semantische Suche über tausende vergangener Turns. Dieses Kapitel zeigt, wie die drei zusammenspielen und wo die Stolperfallen liegen.

Das Problem

Standard-LLMs haben kein eigenes Gedächtnis. Was im aktuellen Gespräch nicht im Kontextfenster steht, existiert für das Modell nicht. Damit kollabieren genau die Eigenschaften, die einen persönlichen Agenten interessant machen:

  • Du erzählst eine Vorliebe ein Mal — und musst sie nächste Woche wieder erklären
  • Der Agent kann nicht über Wochen an einem Projekt mitarbeiten
  • Jede neue Konversation ist kalter Start
  • Lessons aus früheren Fehlern gehen verloren

Die einfachste „Lösung" der kommerziellen Anbieter: ein Konversations-Verlauf wird abgespeichert und beim nächsten Mal mitgeladen. Das ist Goldfisch-mit-Notizblock. Es funktioniert für einzelne, kurze Konversationen — aber nicht für „der Agent kennt mich seit drei Monaten".

Das eigentliche Problem ist nicht „Wie speichere ich Text?", sondern „Wie finde ich relevante alte Erinnerungen in Sekundenbruchteilen wieder, ohne den ganzen Verlauf in jeden Prompt zu packen?". Antwort: semantische Suche über Vektoren.

Unsere Lösung

Wintermute hat drei Memory-Schichten, die unterschiedliche Zwecke erfüllen:

flowchart TD
    U["User-Nachricht kommt rein"]
    A["<b>Agent-Core</b><br/>baut den System Prompt"]

    L1["<b>Schicht 1: Daily Logs</b><br/>memory/conversation/YYYY-MM-DD.md<br/><i>Markdown-Logs: Audit-Spur + Kurzzeit-Historie</i>"]
    L2["<b>Schicht 2: Long-term Markdown</b><br/>memory/longterm.md<br/><i>handgepflegt, immer im System Prompt</i>"]
    L3["<b>Schicht 3: Vektor-Store</b><br/>Qdrant + Ollama-Embeddings<br/><i>semantische Suche pro Turn</i>"]

    P["System Prompt mit injiziertem Recall-Kontext"]
    R["Antwort"]
    SAVE["fire-and-forget: speichere user + assistant<br/>als zwei Memory-Punkte"]

    U --> A
    A -.->|"liest"| L2
    A -.->|"lädt letzte N Turns"| L1
    A -->|"memory_search(query)"| L3
    L3 -->|"top-K relevante Erinnerungen"| P
    L2 --> P
    P --> R
    R --> SAVE
    SAVE -.->|"upsert"| L3
    R -.->|"git-bar, lesbar"| L1

    classDef store fill:#dbeafe,stroke:#2563eb,stroke-width:2px;
    class L1,L2,L3 store;

Was die Schichten leisten

  • Schicht 1 — Daily Logs (Markdown). Pro Tag eine Datei memory/conversation/YYYY-MM-DD.md. Jeder Gesprächsturn wird als lesbarer Markdown-Block angehängt. Das ist menschen- lesbar, git diff-bar, mit Standardwerkzeugen durchsuchbar (grep, cat, Editor). Sie ist aber nicht nur Rohdaten-Backup und Audit-Spur: der Agent liest diese Schicht auch selbst — beim Prompt-Bau lädt er die letzten N Nachrichten aus genau diesen Dateien als Kurzzeit-Historie zurück. Das macht das Datei-Format zur Schnittstelle, nicht nur zur Ablage — mit einem echten Fallstrick, siehe Tech-Vertiefung.
  • Schicht 2 — Long-term Markdown. Eine einzige Datei, memory/longterm.md. Hand-kuratiert. Sie wird bei jedem Turn komplett in den System Prompt geladen. Hier stehen Dinge, die immer gelten sollen: „Der User heißt Jürgen", „Lieblingssprache: Deutsch", „bevorzugte Anrede: du". Die Datei muss kompakt bleiben (Token-Budget!) — typisch 2-5 KB.
  • Schicht 3 — Vektor-Store (Qdrant). Jeder Turn wird automatisch gespeichert: User-Nachricht und Assistant-Antwort jeweils als separater Punkt. Vor jedem neuen Turn macht der Agent eine semantische Suche nach der aktuellen User- Nachricht, holt die Top-K relevanten Punkte zurück, und injiziert sie unter ## Recalled context in den System Prompt.

Was bewusst nicht gespeichert wird

Der Auto-Save schreibt pro Turn genau zwei Dinge: die User-Nachricht und die finale Assistant-Antwort. Die Tool-Aufrufe dazwischen — Datei-Dumps, Status-JSON, komplette E-Mail-Bodies aus den IMAP-Tools — erreichen den Vektor-Store nie; sie leben nur im Tool-Loop des jeweiligen Turns. Das ist eine Privacy-Entscheidung mit klarer Nebenwirkung: eine Mail kann später nur dann per Recall auftauchen, wenn der Agent sie in seiner Antwort zitiert hat. Wer einen Inhalt bewusst behalten will, pinnt die Antwort per /remember.

Warum diese Trennung?

Die drei Schichten optimieren auf verschiedene Zugriffsmuster:

  • Schicht 1 ist optimiert auf chronologischer Verlauf — für Menschen zum Nachlesen, für den Agenten als letzte N Turns
  • Schicht 2 ist optimiert auf jeder Turn braucht diesen Kontext → eine kleine Datei, immer im Prompt
  • Schicht 3 ist optimiert auf Agent sucht passende Erinnerungen zu dieser konkreten Frage → Vektorsuche, skaliert auf zehntausende Punkte ohne dass der Prompt explodiert

Du brauchst alle drei. Wer nur Schicht 3 hat, kann den Verlauf nicht inspizieren. Wer nur Schicht 1 hat, kann nicht semantisch suchen. Wer nur Schicht 2 hat, hat ein Token-Budget-Problem sobald „Langzeit-Kontext" auf 50 KB anwächst.

Trade-offs & offene Fragen

  • Speicher-Bedarf wächst linear mit Nutzung. Pro Turn ein bis zwei Vektoren à 768 Dimensionen Float32 = ~3 KB plus Payload-Text. Bei intensiver Nutzung sind das ~50-100 MB Qdrant-Daten pro Jahr — handhabbar, aber nicht null.
  • Embedding-Kosten kommen on top. Jeder Turn = mindestens drei Embedding-Aufrufe (Suche + 2× Save). Mit lokalem Ollama ist das umsonst, aber langsam (~200-500ms). Mit OpenAI ist es schnell, aber pro-Token-bepreist. Und: batch-artige Embedding-Läufe laufen sequenziell in die Minuten — der HTTP-Timeout zwischen Agent und Gateway muss das aushalten (Details unten, „Lange Embedding-Läufe").
  • Semantische Suche ist nicht perfekt. Sie findet was ähnlich klingt, nicht zwingend was die Frage beantwortet. Konkret: Wintermute lernte (LESSONS §28), dass eine Suche nach „httpx ReadTimeout leerer Error Envelope" nichts zurückgab, obwohl die passende Lesson 8 das Mechanismus- Detail enthielt — aber unter Symptom-Vokabular gespeichert war. Konsequenz für Authoring: wer Lessons oder Erinnerungen schreibt, sollte sowohl Symptom als auch Mechanismus erwähnen, damit beide Such-Wege das Dokument finden.
  • Auto-Save erzeugt Rauschen. Wenn der User „kannst du das bitte nochmal in fett?" schreibt, landet das als Memory-Punkt im Store. Wintermutes Antwort: ein Save-Filter vor dem Speichern (Mindest-Länge, Footnote- Anteil, Code-Anteil, Dedup-Lookback). Pinned Saves via /remember umgehen den Filter — manuelle Kuration schlägt Heuristik.
  • Ein menschenlesbares Format ist trotzdem ein Parser. Wintermutes Daily Logs trennen Nachrichten mit ### user / ### assistant-Zeilen. Der erste Parser splittete auf dem Substring ### — und zerschnitt damit jede Assistant- Antwort mit eigenen ###-Überschriften. Details und Fix in der Tech-Vertiefung (LESSONS §29).
  • Was fehlt: Vergessen. Wintermute kann gezielt einzelne Punkte löschen (memory_forget), aber es gibt keine systematische „Memory-Komprimierung" oder „alte-irrelevante-Erinnerungen-zusammenfassen"-Routine. Das ist offen.
  • Multi-User-Memory ist nicht gelöst. Aktuell ein globaler Store. Wer mehrere User mit getrennten Memories braucht, muss conversation_id zur Pflicht-Filter-Bedingung machen und ein Berechtigungsmodell draufsetzen.

🔧 Tech-Vertiefung

Konkrete Tools im MCP-Gateway

Die Memory-Schicht 3 ist vollständig über das MCP-Gateway gewrappt. Fünf Tools (in mcp-gateway/src/gateway/tools/memory.py):

Tool Zweck Aufrufer
memory_search Semantische Suche, Top-K Punkte zurück Agent (pro Turn)
memory_save Neuen Punkt speichern Agent (auto), User (/remember)
memory_status Vektorzahl, RAM-Schätzung, Headroom Agent (alle N Turns), User (/memstatus)
memory_forget Punkt nach ID löschen Admin / User (/forget)
memory_load_doc Markdown-Doc als gepinnte Doc-Points ingestieren, idempotent Admin (on demand)

Read-Flow pro Turn: die echte Recall-Funktion

In agent/src/agent/memory_recall.py lebt die ganze Recall-Logik in einer Funktion. Verbatim:

# agent/src/agent/memory_recall.py (Auszug, Z.92-148)
async def recall_for_turn(
    tools: ToolRegistry,
    *,
    query: str,
    conversation_id: str,
    enabled: bool,
    top_k: int,
    min_score: float,
    max_chars: int,
) -> str | None:
    """Fetch top-K recall hits and return a formatted markdown block.

    Returns ``None`` when:

    * recall is disabled,
    * the query is empty or whitespace,
    * the gateway call fails or returns no usable hits.

    The caller treats ``None`` as "no recall this turn" and proceeds with
    the existing prompt build.
    """
    if not enabled:
        return None
    if not query or not query.strip():
        return None

    arguments = {
        "query": query.strip(),
        "k": int(top_k),
        "conversation_id": conversation_id,
        "min_score": float(min_score),
        "include_pinned": True,
    }

    try:
        envelope = await tools.call_tool("memory_search", arguments)
    except Exception as e:  # noqa: BLE001
        logger.warning("memory recall: gateway call failed: %s", e)
        return None

    inner = _unwrap(envelope)
    if inner is None:
        # Either a transport error or a tool-level error. Either way, skip.
        logger.info(
            "memory recall: skipping turn — gateway returned %r",
            (envelope.get("error") if isinstance(envelope, dict) else envelope),
        )
        return None

    hits = inner.get("hits") or []
    if not isinstance(hits, list) or not hits:
        return None

    block = _format_hits(hits, max_chars=max_chars)
    if not block.strip():
        return None
    return block

Drei Design-Entscheidungen, die hier sichtbar werden:

  • Fail-soft auf jeder Stufe. enabled=False, leere Query, Gateway-Crash, leeres hits-Array — alles führt zu return None. Der Caller behandelt None als „kein Recall in diesem Turn" und macht normal weiter. Ein gescheiterter Recall darf den Agent nicht blockieren.
  • include_pinned: True als impliziter Default. Pinned Saves sind die User-kuratierten Erinnerungen (siehe /remember). Die wollen wir immer sehen, auch wenn der Score-Match niedrig ist. Der Gateway-Tool injiziert sie zusätzlich zu den Top-K-Vector-Hits.
  • max_chars als Sicherheits-Cap. _format_hits schneidet den Block ab, wenn er zu lang würde. Wintermutes Default ist 4000 Zeichen (MEMORY_MAX_CHARS) — ein vernünftiger Kompromiss zwischen „genug Kontext für relevante Anfragen" und „nicht den ganzen System Prompt mit altem Material zumüllen".

Write-Flow pro Turn: Schedule + Filter + Save

Der Write-Path ist ein Detached-Background-Task pro Turn. Die Schedule-Funktion:

# agent/src/agent/memory_save.py (Auszug, Z.314-360)
def schedule_turn_saves(
    tools: ToolRegistry,
    *,
    enabled: bool,
    conversation_id: str,
    channel: str | None,
    user_message: str,
    assistant_reply: str,
    tags: list[str] | None = None,
) -> list[asyncio.Task]:
    """Schedule background saves for a completed turn. Returns the task handles.

    Tasks are detached — the caller does not need to await them. They are
    returned only so tests (and the optional shutdown path) can wait on
    them deliberately.

    The caller is responsible for ensuring this runs inside an event loop
    (it does, because the chat handler is already async).
    """
    if not enabled:
        return []

    pairs: list[tuple[str, str]] = []
    if user_message and user_message.strip():
        pairs.append(("user", user_message.strip()))
    if assistant_reply and assistant_reply.strip():
        pairs.append(("assistant", assistant_reply.strip()))

    tasks: list[asyncio.Task] = []
    for role, content in pairs:
        coro = _save_one(
            tools,
            content=content,
            role=role,
            conversation_id=conversation_id,
            channel=channel,
            pinned=False,
            tags=tags,
        )
        try:
            task = asyncio.create_task(coro, name=f"memory_save:{role}")
        except RuntimeError as e:
            # No running loop (shouldn't happen from the chat handler, but be safe).
            logger.warning("memory_save: cannot schedule (%s): %s", role, e)
            continue
        tasks.append(task)
    return tasks

Der eigentliche Filter sitzt eine Schicht tiefer in _filter_skip_reason:

# agent/src/agent/memory_save.py (Auszug, Z.146-198)
def _filter_skip_reason(
    *,
    role: str,
    content: str,
    conversation_id: str,
    min_chars: int,
    footnote_ratio: float,
    code_ratio: float,
    dedup_lookback: int,
    dedup_threshold: float,
) -> str | None:
    """Return the name of the rule that fires, or ``None`` to allow the save.

    Rules are checked in cheapest-first order; the first match wins.
    """
    stripped = content.strip()
    total = len(stripped)
    if total == 0:
        # The caller's empty-check should have already handled this; guard
        # anyway so the ratio math below doesn't divide by zero.
        return "empty"

    # Rule 1: length floor. Applies to both roles.
    if total < min_chars:
        return "length_floor"

    # Rules 2 & 3 only make sense for assistant output. User messages can
    # legitimately be short questions or pasted snippets and we want to
    # keep the user side of the conversation in the index.
    if role == "assistant":
        # Rule 2: framework-footnote dominant.
        fn_len = _footnote_block_length(stripped)
        if fn_len > 0 and fn_len / total > footnote_ratio:
            return "footnote_dominant"

        # Rule 3: tool-output / fenced-code dominant.
        code_len = _code_block_length(stripped)
        if code_len > 0 and code_len / total > code_ratio:
            return "code_dominant"

    # Rule 4: near-duplicate of a recent same-role save in this conversation.
    if dedup_lookback > 0:
        buf = _dedup_buffer(conversation_id, role, dedup_lookback)
        # Snapshot under the lock: a concurrent _record_dedup append would
        # otherwise mutate the deque mid-iteration (RuntimeError).
        with _dedup_lock:
            recent = list(buf)
        for prev in recent:
            ratio = SequenceMatcher(a=prev, b=stripped, autojunk=False).ratio()
            if ratio > dedup_threshold:
                return "dedup"

    return None

Fünf wichtige Implementierungs-Details:

  • „Cheapest-first" als Such-Reihenfolge. Rule 1 (Längen-Floor) ist O(1), Rule 4 (SequenceMatcher gegen N vorherige Saves) ist O(N×M) — die billige Regel fliegt zuerst. Bei kurzen Antworten („ja", „ok", „danke") überlebt nichts den Filter, und die teure Vergleichs-Logik läuft nie.
  • Asymmetrische Behandlung von user vs. assistant. User-Nachrichten können legitimerweise kurz und sich wiederholend sein (Folge-Fragen, Klärungen). Sie müssen nur den Längen-Floor passieren. Assistant-Antworten haben drei zusätzliche Hürden (Footnote-Dominanz, Code-Dominanz, Dedup), weil sie sonst den Store mit Framework-Footnotes und mehrfach-zitierten Tool-Outputs verstopfen würden.
  • SequenceMatcher mit autojunk=False. Standard- difflib.SequenceMatcher hat eine „Junk-Optimierung", die für Code-/Text-ähnliche Strings nichts beschleunigt und manchmal Ratio-Werte verzerrt. autojunk=False macht die Vergleichs-Logik vorhersehbarer.
  • Dedup vergleicht gegen einen Snapshot, nicht die lebende Deque. Der Ring-Buffer wird von den detached Background- Save-Tasks konkurrierend beschrieben. Die Vergleichs-Schleife zieht deshalb unter Lock eine Kopie (recent = list(buf)) — ein konkurrierendes Append während der Iteration wäre sonst ein RuntimeError. Klassischer Bug, der erst unter Last auftaucht.
  • Rückgabewert ist der Rule-Name als String (oder None). Damit lässt sich die Skip-Statistik trivial loggen: „diese Antwort wurde wegen dedup nicht gespeichert" ist mehr Info als „nicht gespeichert". Hilft beim Tuning der Filter- Schwellen.

Pinned Saves via /remember <text> laufen über eine eigene Funktion save_pinned — sie umgeht den Filter komplett und wird, anders als die Auto-Saves, awaited, weil der Caller die Bestätigung sehen will, bevor er dem User antwortet. Explizite Kuration durch den User schlägt jede Heuristik.

Die Filter-Schwellen kommen aus der Umgebung; der Env-Block im docker-compose.yml (Agent-Service) verbatim:

# docker-compose.yml (Agent-Service, Auszug)
- MEMORY_ENABLED=${MEMORY_ENABLED:-true}
- MEMORY_TOP_K=${MEMORY_TOP_K:-5}
- MEMORY_MIN_SCORE=${MEMORY_MIN_SCORE:-0.6}
- MEMORY_MAX_CHARS=${MEMORY_MAX_CHARS:-4000}
- MEMORY_STATUS_EVERY_N=${MEMORY_STATUS_EVERY_N:-50}
- MEMORY_SAVE_MIN_CHARS=${MEMORY_SAVE_MIN_CHARS:-50}
- MEMORY_SAVE_FOOTNOTE_RATIO=${MEMORY_SAVE_FOOTNOTE_RATIO:-0.7}
- MEMORY_SAVE_CODE_RATIO=${MEMORY_SAVE_CODE_RATIO:-0.8}
- MEMORY_SAVE_DEDUP_LOOKBACK=${MEMORY_SAVE_DEDUP_LOOKBACK:-3}
- MEMORY_SAVE_DEDUP_THRESHOLD=${MEMORY_SAVE_DEDUP_THRESHOLD:-0.85}

Wichtig fürs Privacy-Modell: gespeichert wird pro Turn nur user_message und assistant_reply. Die Tool-Call-/Tool-Result- Paare aus dem Tool-Loop (agent/core.py) — inklusive kompletter Mail-Bodies aus den IMAP-Tools — erreichen memory_save nie. Siehe docs/IMAP-TOOLS.md, Abschnitt „Privacy & memory".

Daily Logs parsen: der Delimiter-Fallstrick (LESSONS §29)

Schicht 1 ist nicht nur Ablage — MemoryManager.get_recent_history in agent/src/agent/memory.py parst die Tagesdateien bei jedem Prompt-Bau zurück in eine Nachrichten-Liste. Geschrieben wird im Format ### {role}\n{content}\n\n, und genau da lag der Bug: der erste Parser splittete mit text.split("### ") — auf einem Substring, den Assistant-Antworten selbst ständig enthalten (### Summary, ### Schritt 1; die Persona liebt Überschriften). Jede eingebettete ### -Stelle wurde als Nachrichtengrenze gelesen, der Assistant-Turn am ersten eigenen Header abgeschnitten und der Rest als Pseudo-Rolle verworfen. Das Modell bekam so jede Runde eine verstümmelte Kopie seines eigenen letzten Turns.

Der Fix: ein verankerter, eindeutiger Delimiter — eine Zeile, die exakt ein bekannter Rollen-Header ist:

# agent/src/agent/memory.py (Auszug)
# A message boundary is a line that is EXACTLY a known role header, e.g.
# ``### user`` or ``### assistant``. Anchoring to start-of-line and to the
# known role set means markdown headers inside a message body (``### Summary``,
# ``### Schritt 1`` — which the persona emits constantly) are NOT mistaken for
# message boundaries. The old ``text.split("### ")`` split on every header and
# truncated/dropped the assistant's own turns.
_MESSAGE_HEADER_RE = re.compile(r"^### (user|assistant)[ \t]*{{INHALT}}quot;, re.MULTILINE)


@staticmethod
def _parse_messages(text: str) -> list[dict]:
    """Parse one conversation file into ordered ``{role, content}`` dicts.

    Splits only on exact role headers (see ``_MESSAGE_HEADER_RE``); a
    message body may freely contain ``### ...`` markdown headers.
    """
    out: list[dict] = []
    matches = list(_MESSAGE_HEADER_RE.finditer(text))
    for i, m in enumerate(matches):
        role = m.group(1)
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        body = text[start:end].strip()
        out.append({"role": role, "content": body})
    return out

Die allgemeine Regel aus LESSONS §29: wenn dein serialisiertes Format einen Marker benutzt, dann entweder (a) den Marker in Payloads escapen, oder (b) den Split so verankern, dass Payload- Vorkommen nicht matchen können (Zeilenanfang + geschlossenes Token-Set). Ein nacktes .split(marker) über Freitext ist ein latenter Korruptions-Bug.

Qdrant-API-Stolperfalle (LESSONS §15)

Wenn du qdrant-client ≥ 1.10 verwendest: die alte .search()- Methode ist weg. Du musst .query_points() benutzen:

response = await client.query_points(
    collection_name="wintermute-memory",
    query=vec,                  # nicht "query_vector="!
    limit=5,
    score_threshold=0.6,
    query_filter=qm.Filter(should=[...]),
    with_payload=True,
)
hits = response.points          # nicht direkt iterierbar!

Wer das aus älteren Tutorials kopiert, kriegt einen AttributeError und debuggt sich tot. Wintermutes Memory-Client liegt im Gateway in mcp-gateway/src/gateway/tools/memory.py — wenn du dort hinein-schaust, siehst du das query_points-Pattern als einzigen Code-Pfad (kein Backward-Compatibility-Fallback, weil Wintermute auf qdrant-client>=1.10 festgepinnt ist).

Lange Embedding-Läufe: memory_load_doc und der Timeout (LESSONS §8, Variante 2)

Das fünfte Tool, memory_load_doc, ingestiert ein strukturiertes Markdown-Dokument (z.B. LESSONS.md) als gepinnte, system-role Doc-Points. Idempotent über Edits hinweg: neue Einträge werden embedded und hinzugefügt, geänderte aktualisiert, unveränderte kosten null Embedding-Calls, verwaiste werden entfernt — die Datei ist die Source of Truth, der Store wird abgeglichen.

Der Fallstrick: so ein Lauf embedded Dutzende Einträge sequenziell. Der erste echte Lauf gegen LESSONS.md (27 Ollama-Embeddings, ~50s Wall-Clock) riss den damaligen 30s-HTTP-Timeout des Agent-Clients. Perfides Detail: str(httpx.ReadTimeout) ist der leere String — der Agent sah also nur {"error": ""}, während die Arbeit serverseitig längst erledigt war und alle 27 Punkte in Qdrant standen. Der Lauf war erfolgreich; nur der Antwort-Kanal starb.

Konsequenz: der Agent→Gateway-Timeout ist heute großzügig bemessen und ohne Rebuild tunebar:

# docker-compose.yml (Agent-Service, Auszug)
- AGENT_TOOL_HTTP_TIMEOUT_SECONDS=${AGENT_TOOL_HTTP_TIMEOUT_SECONDS:-300}

Faustregel für den eigenen Nachbau: der HTTP-Timeout für Tool-Calls muss sich am langsamsten legitimen Tool orientieren (Batch-Embedding, Self-Update), nicht am typischen. Ein zu kurzer Timeout produziert die schlimmste Fehlerklasse: die Arbeit passiert, aber das Ergebnis sieht nach Fehler aus.

Memory-Health-Selbstreflexion

In agent/src/agent/memory_health.py lebt eine Eigenheit, die direkt aus der Wintermute-Persönlichkeit kommt: alle MEMORY_STATUS_EVERY_N=50 Turns ruft der Agent memory_status auf. Wenn das Ergebnis headroom != "ok" ist, kommentiert er das selbständig in der nächsten Antwort.

Die Notice-Strings verbatim:

# agent/src/agent/memory_health.py (Auszug, Z.27-41)
# Notice messages keyed by headroom level. Tone: dry, terse,
# in-character. Length kept short so prepending doesn't push the
# actual answer off-screen in compact UIs (Telegram, etc.).
_NOTICES: dict[str, str] = {
    "warn": (
        "⚠️ memory headroom: warn. RAM use is past the soft "
        "threshold; I'm fine but it'd be a good time to think "
        "about housekeeping. Run `/memstatus` for the numbers."
    ),
    "critical": (
        "⚠️ memory headroom: **critical**. I need more RAM. "
        "(No, really. Run `/memstatus` and consider `/forget`-"
        "ting some points or raising `MEMORY_RAM_CRIT_BYTES`.)"
    ),
}

Der Kommentar oben drüber im Original-File ist eine kleine Lore-Notiz, die zeigt wie persona-bewusst Wintermute geschrieben wurde:

„Wintermute, you need more RAM." „I know, Case. I know."

We never warn twice in a row for the same headroom level. Once you've been told it's warn, the next notice only fires if it improves to ok then worsens again, or escalates to critical. This avoids the agent hand-wringing every fifty turns about the same condition.

Das Anti-Re-Announce-Verhalten ist nicht nur Lore — es ist auch Operations-Hygiene: ein Agent, der alle 50 Turns dasselbe Problem meldet, wird ignoriert. Wenn die Warnung nur bei Eskalation oder Wiederkehr feuert, hat sie noch Gewicht.

Konfigurierbar via:

  • MEMORY_RAM_WARN_BYTES (default 256 MB) → headroom: "warn"
  • MEMORY_RAM_CRIT_BYTES (default 512 MB) → headroom: "critical"
  • MEMORY_STATUS_EVERY_N (default 50) → wie oft gepollt wird

Empfehlungen für eigene Implementierungen

  1. Trenne die Schichten. Auch wenn dein erster Wurf nur Vector-Store hat — bau die Markdown-Schichten dazu, weil sie dir später beim Debuggen Gold wert sind.
  2. Auto-Save mit Filter, nicht ohne. Ein ungefilterter Auto-Save erzeugt nach Wochen einen Store, in dem 80% Rauschen ist. Filter sind keine Kür.
  3. Pins als Sicherheitsventil. Eine /remember-artige Funktion, die den Filter umgeht, ist Gold wert. Manche Dinge weiß der User besser als jede Heuristik.
  4. Symptom und Mechanismus speichern. Wenn dein Agent Erinnerungen schreibt, sollten beide Vokabulare drinstehen (siehe LESSONS §28).
  5. Self-Reporting einbauen. memory_status als Tool + periodische Selbstprüfung ist günstig zu implementieren und spart später Stunden Debugging.
  6. Record-Formate nur auf verankerten Delimitern splitten. Wenn dein Agent seine eigene Historie parst, darf der Trenner nichts sein, was in den Nachrichten selbst vorkommen kann (siehe LESSONS §29).

📚 Quellen im Wintermute-Repo