headroom/tests/test_memory_query_policy.py
Abhay Singh 4e5a67a342
fix(memory): skip <system-reminder> blocks when building the retrieval query (#2195) (#2541)
## Description

Addresses #2195 Finding 1. `extract_memory_query_sources` (the memory
retrieval query builder) was extended to harvest text blocks from
Anthropic list-shaped user turns — the standard Claude Code shape — but
it joins **every** text block in the turn. Claude Code appends
`<system-reminder>` harness blocks to essentially every user turn, so
those get concatenated into the embedding input alongside the real
question.

Per the reporter's measurements (`all-MiniLM-L6-v2`): the clean question
scored top cosine **0.748** against a stored memory; the same question
wrapped in harness boilerplate scored **0.232**. The default
`memory_min_similarity` floor is **0.3**, so the diluted query falls
under the floor and **nothing is retrieved** — memory silently no-ops
for Claude Code clients. The reporter explicitly warned that a naive
"concatenate all text blocks" harvest would still retrieve nothing,
which is exactly the current behavior.

## Fix

Filter out text blocks whose text starts with `<system-reminder` when
building `user_text`, so the retrieval query keys on the substantive
question and the embedding isn't diluted by harness boilerplate. A turn
that is only a system-reminder yields no `user_text` (as before). All
other harvesting (tool_result blocks, OpenAI string content,
assistant/tool context) is unchanged.

Note: the reporter also asked to expose `memory_min_similarity` as an
env var / CLI flag (it lives on `ProxyConfig` with no surface today).
That is a sensible companion but is a separate config-plumbing change; I
kept this PR focused on the retrieval-query bug so it stays easy to
review, and I'm happy to follow up with the env/CLI surface.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/memory_query_policy.py`: in
`extract_memory_query_sources`, skip `<system-reminder>` text blocks
when assembling the user query from a list-shaped Anthropic user turn.
- `tests/test_memory_query_policy.py`: regressions that a
system-reminder block is excluded (real question kept) and that a
reminder-only turn yields no user text.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_memory_query_policy.py -q
7 passed

# with the fix reverted, the two new tests fail: the system-reminder text is
# concatenated into user_text (the diluted-query behavior)

$ uvx ruff@0.15.17 check headroom/proxy/memory_query_policy.py tests/test_memory_query_policy.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/memory_query_policy.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: called `extract_memory_query_sources` with a
Claude Code-shaped user turn (real question text block + an appended
`<system-reminder>` text block), and with a reminder-only turn; then
reverted the source and re-ran.
- Observed result: with the fix `user_text` is exactly `"how do I add
caching to the auth handler?"` (no `system-reminder` substring), and a
reminder-only turn yields `""`; with the fix reverted `user_text`
includes the full `<system-reminder>...</system-reminder>` text (the
diluted embedding input). Ran against the actual module.
- Not tested: an end-to-end embedding + backend retrieval against a live
memory DB measuring the cosine recovery (the dilution figures are the
reporter's; this change removes the boilerplate from the query text that
produces them).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
2026-08-12 00:22:08 -05:00

124 lines
4.1 KiB
Python

"""Tests for pure memory query construction policy."""
from __future__ import annotations
from headroom.proxy.memory_query_policy import (
extract_memory_query_sources,
render_embedding_input,
)
def test_render_embedding_input_orders_sources_for_embedding() -> None:
rendered = render_embedding_input(
user_text="latest user",
recent_tool_outputs=("tool output",),
recent_assistant_turns=("assistant context",),
)
assert rendered.index("assistant context") < rendered.index("tool output")
assert rendered.index("tool output") < rendered.index("latest user")
def test_extract_sources_uses_latest_user_and_recent_context_in_order() -> None:
messages = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "a1"},
{"role": "tool", "content": "t1"},
{"role": "assistant", "content": "a2"},
{"role": "tool", "content": "t2"},
{"role": "user", "content": "second"},
]
user_text, tool_outputs, assistant_turns = extract_memory_query_sources(
messages,
lookback_assistant=2,
lookback_tools=2,
)
assert user_text == "second"
assert tool_outputs == ("t1", "t2")
assert assistant_turns == ("a1", "a2")
def test_extract_sources_handles_anthropic_tool_result_without_user_text() -> None:
messages = [
{"role": "user", "content": "real user"},
{
"role": "user",
"content": [{"type": "tool_result", "content": [{"type": "text", "text": "nested"}]}],
},
]
user_text, tool_outputs, assistant_turns = extract_memory_query_sources(messages)
assert user_text == "real user"
assert tool_outputs == ("nested",)
assert assistant_turns == ()
def test_extract_sources_captures_anthropic_user_text_blocks() -> None:
"""Anthropic user turns carry the prompt as text blocks (the standard Claude
Code shape). The user's question must be captured — not dropped — so memory
retrieval keys on it."""
messages = [
{"role": "user", "content": [{"type": "text", "text": "help me refactor auth"}]},
]
user_text, _tool_outputs, _assistant_turns = extract_memory_query_sources(messages)
assert user_text == "help me refactor auth"
def test_extract_sources_skips_system_reminder_blocks() -> None:
"""Claude Code appends <system-reminder> harness blocks to the user turn.
Concatenated into the embedding input they dilute the real question below the
similarity floor so nothing is retrieved (#2195); they must be filtered out."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "how do I add caching to the auth handler?"},
{
"type": "text",
"text": "<system-reminder>\nThe user opened file x.\n</system-reminder>",
},
],
},
]
user_text, _tool_outputs, _assistant_turns = extract_memory_query_sources(messages)
assert user_text == "how do I add caching to the auth handler?"
assert "system-reminder" not in user_text
def test_extract_sources_reminder_only_turn_yields_no_user_text() -> None:
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "<system-reminder>x</system-reminder>"}],
},
]
user_text, _tool_outputs, _assistant_turns = extract_memory_query_sources(messages)
assert user_text == ""
def test_extract_sources_captures_user_text_alongside_tool_result() -> None:
"""A user turn mixing a tool_result and a text block yields both: the text as
the user query and the tool output as context."""
messages = [
{
"role": "user",
"content": [
{"type": "tool_result", "content": "exit 0"},
{"type": "text", "text": "did the tests pass?"},
],
},
]
user_text, tool_outputs, _assistant_turns = extract_memory_query_sources(messages)
assert user_text == "did the tests pass?"
assert tool_outputs == ("exit 0",)