headroom/tests/test_memory_injection_budget.py
chopratejas 71d5a7b545 fix(proxy): MemoryDecision contract + 3 bypass bugs + drop 500-char query cap
Three bug classes fixed plus three architectural extension points,
together making the memory subsystem uniform across all five sites
and ready for future Mem0/Letta/Cognee backend integration.

## Bug fixes

* **3 sites silently ignored `x-headroom-bypass: true`** —
  ``anthropic.py:1303``, ``openai.py:1620`` (chat), ``gemini.py:382``
  injected memory under bypass, mutating request bytes when the user
  explicitly asked for byte-faithful passthrough. Now gated on
  ``MemoryDecision.decide(...)`` which honours bypass uniformly.

* **500-char query truncation** — ``memory_handler._extract_user_query``
  capped at 500 chars, silently throwing away signal. None of Letta /
  Mem0 / Cognee / Supermemory truncate. Removed; the embedding model
  handles its own window.

* **Gemini had no timeout** on ``search_and_format_context`` — the
  only chat handler without one. A slow backend could stall requests.
  Added ``asyncio.wait_for`` matching Anthropic + OpenAI Chat +
  Responses.

* **WS injected into ``body["instructions"]``** — the system /
  cache-hot-zone field, violating invariant I2 (all other handlers
  inject at user-message tail). Switched to ``ws_response_body["input"]``
  for string-shaped input; list-shaped input deferred to the Rust
  handler with a clear log.

## New value types (extension points)

* ``MemoryDecision`` — frozen dataclass + factory. Five-way skip
  reason enum (``bypass_header`` / ``no_handler`` / ``no_user_id`` /
  ``mode_disabled`` / ``mode_tool``). ``apply_to_tags()`` surfaces
  the skip reason in ``RequestOutcome.tags["memory_skip_reason"]``
  — dashboards can now slice memory-blind traffic by cause.

* ``MemoryQuery`` — multi-source retrieval query. ``from_messages()``
  walks the conversation and extracts latest user text + recent tool
  outputs + recent assistant turns at FULL fidelity (no truncation).
  Handles both OpenAI-shape ``role: tool`` and Anthropic-shape
  ``tool_result`` content blocks. ``to_embedding_input()`` produces
  a delimited concatenation the embedder sees as structured context.

* ``MemoryInjectionBudget`` — uniform token / entry / similarity
  bound on the formatted injection block. Pre-this-PR no cap (~4000
  tokens could land per request). Default 1024 tokens / 10 entries /
  0.3 similarity floor. ``apply_to_text()`` truncates at line
  boundaries so dashboard renders intact bullet points.

## Migration scope — all 5 sites uniform at the GATE level

| Site | Handler | Pre-PR gate | Post-PR gate |
|---|---|---|---|
| 1 | anthropic.py | `memory_handler and memory_user_id` | `memory_decision.inject` |
| 2 | gemini.py | `memory_handler and memory_user_id` | `memory_decision.inject` |
| 3 | openai.py chat | `memory_handler and memory_user_id` | `memory_decision.inject` |
| 4 | openai.py Responses | `memory_handler and memory_user_id and not _bypass` | `responses_memory_decision.inject` |
| 6 | openai.py WS | `memory_handler and body and not _ws_bypass` | `ws_memory_decision.inject` |

Site 5 (Responses bypass-elif log-only branch) is preserved verbatim.

## Deliberately deferred (separate PRs)

* **Memory injection order inversion** — sites 4 and 6 inject
  BEFORE compression; sites 1/2/3 inject AFTER. Moving 4 + 6 to
  post-compression needs its own focused cache-stability testing.
* **Importance scoring** — recency × source × access-count.
* **Per-memory atomize-and-split** — Mem0/Supermemory pattern.
* **AST-aware code chunking for tool outputs** — Supermemory's
  code-chunk approach.

The contracts shipped here (``MemoryQuery`` + ``MemoryInjectionBudget``)
are the extension points those will plug into.

## Test coverage

* 20 new tests on ``MemoryDecision``
* 14 new tests on ``MemoryQuery`` (full-fidelity, multi-source)
* 10 new tests on ``MemoryInjectionBudget``
* 3 new AST contract tests (no raw gate; no system writes; every
  search call passes ``query=``)
* All existing memory + cache-stability tests still pass (222 passed)

## Rust portability

Every new value type ports cleanly to a frozen Rust struct. Pure
functions, no I/O, no global state. Same Python ↔ Rust parity-test
pattern that ``CompressionDecision`` already uses.

## Zero-regression contract

Existing chat/completion harnesses (Claude Code, Codex, Cursor,
Continue, Aider) see ZERO wire-byte changes when bypass is NOT set.
When bypass IS set, the 3 chat handlers now correctly skip memory
injection — that's the bug fix, not a regression.
2026-05-19 11:13:52 -05:00

130 lines
5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for :class:`headroom.proxy.memory_injection.MemoryInjectionBudget`.
Pre-PR Headroom had **no token cap on injected memory**: top_k=10
candidates × ~400 tokens each = up to ~4000 tokens injected per
request. None of Letta/Mem0/Cognee/Supermemory ship a token-uncapped
injection path on the hot wire.
``MemoryInjectionBudget`` is the single configurable cap applied to
every injection site so all 5 sites are uniformly bounded — set the
budget once, apply at every handler, dashboards see the same shape.
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from headroom.proxy.memory_injection import MemoryInjectionBudget
# ── Value-type contract ───────────────────────────────────────────────
def test_budget_is_frozen() -> None:
b = MemoryInjectionBudget()
try:
b.max_tokens = 99 # type: ignore[misc]
except FrozenInstanceError:
pass
else:
raise AssertionError("MemoryInjectionBudget must be frozen")
def test_budget_defaults() -> None:
"""Default budget is conservative — 1024 tokens, 10 entries, 0.3
similarity floor. Operators can override via config; the default
is hard-set so a misconfiguration can't accidentally unbound
injection."""
b = MemoryInjectionBudget()
assert b.max_tokens == 1024
assert b.max_entries == 10
assert b.min_similarity == 0.3
def test_budget_value_equal() -> None:
a = MemoryInjectionBudget(max_tokens=512, max_entries=5, min_similarity=0.5)
b = MemoryInjectionBudget(max_tokens=512, max_entries=5, min_similarity=0.5)
assert a == b
# ── apply_to_text — bounding the formatted context block ─────────────
def test_apply_to_text_returns_input_when_under_budget() -> None:
"""Short context passes through unchanged — no spurious mutation."""
b = MemoryInjectionBudget(max_tokens=1024)
text = "## Relevant Memories\n1. small fact\n"
out = b.apply_to_text(text)
assert out == text
def test_apply_to_text_truncates_when_over_budget() -> None:
"""Large context is bounded — truncated at the budget. The
truncation here is on the OUTPUT (the formatted injection block),
NOT on the INPUT (which keeps full fidelity per MemoryQuery
contract)."""
# 4 tokens/char heuristic in our cap — make the input clearly
# over even the most generous budget.
b = MemoryInjectionBudget(max_tokens=128) # ~512 chars at 4 char/token
huge = "x" * 100000
out = b.apply_to_text(huge)
# Output should be substantially smaller than input.
assert len(out) < len(huge)
def test_apply_to_text_preserves_full_lines() -> None:
"""When truncating, prefer cutting at line boundaries so the
dashboard renders intact memory entries (no half-truncated bullet
point)."""
b = MemoryInjectionBudget(max_tokens=64) # very tight
text = "## Relevant Memories\n" + "".join(f"{i}. fact {i}\n" for i in range(100))
out = b.apply_to_text(text)
# No partial last line — every retained line ends in newline or is
# the final line.
if out and not out.endswith("\n"):
# The last char is the closing of the final line; it must not
# be in the middle of "fact " — easy heuristic: must not end
# mid-word with a hanging digit-then-period.
assert ". fact" not in out[-15:] or out.rstrip().endswith(("fact 0", "fact 1", "fact 2"))
def test_apply_to_text_handles_empty_input() -> None:
"""Empty input → empty output."""
assert MemoryInjectionBudget().apply_to_text("") == ""
# ── apply_to_entries — bounding the list before formatting ───────────
def test_apply_to_entries_caps_entry_count() -> None:
"""Even if the backend returns 100 candidates, the budget caps
entry count to ``max_entries``."""
b = MemoryInjectionBudget(max_entries=3)
entries = [{"content": f"entry {i}", "score": 0.9 - i * 0.01} for i in range(20)]
out = b.apply_to_entries(entries)
assert len(out) == 3
def test_apply_to_entries_preserves_order_of_input() -> None:
"""Budget doesn't re-rank — the backend's order is preserved. (The
backend should already have ranked by score; budget just caps.)"""
b = MemoryInjectionBudget(max_entries=2)
entries = [
{"content": "alpha", "score": 0.9},
{"content": "beta", "score": 0.8},
{"content": "gamma", "score": 0.7},
]
out = b.apply_to_entries(entries)
assert [e["content"] for e in out] == ["alpha", "beta"]
def test_apply_to_entries_filters_below_min_similarity() -> None:
"""Entries below ``min_similarity`` are dropped, regardless of
entry-count budget remaining."""
b = MemoryInjectionBudget(max_entries=10, min_similarity=0.5)
entries = [
{"content": "kept", "score": 0.9},
{"content": "dropped", "score": 0.3},
{"content": "kept2", "score": 0.55},
]
out = b.apply_to_entries(entries)
assert {e["content"] for e in out} == {"kept", "kept2"}