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
This commit is contained in:
Abhay Singh
2026-08-12 10:52:08 +05:30
committed by GitHub
parent 0805e8e410
commit 4e5a67a342
2 changed files with 45 additions and 2 deletions
+9 -2
View File
@@ -63,11 +63,18 @@ def extract_memory_query_sources(
# Anthropic user turns carry the actual prompt as text blocks
# ({"type":"text","text":...}), not a plain string. Capture it
# so the memory retrieval query keys on the user's question and
# not just any tool_result blocks in the same turn.
# not just any tool_result blocks in the same turn. Skip Claude
# Code's <system-reminder> harness blocks: they are appended to
# the same turn and, concatenated into the embedding input,
# dilute the user's question below the similarity floor so
# nothing is retrieved (#2195). Filtering them keeps the query
# on the substantive question.
user_text = "\n".join(
b.get("text", "")
text
for b in content
if isinstance(b, dict) and b.get("type") == "text"
for text in (str(b.get("text", "")).strip(),)
if text and not text.startswith("<system-reminder")
).strip()
if user_text:
latest_user = user_text
+36
View File
@@ -69,6 +69,42 @@ def test_extract_sources_captures_anthropic_user_text_blocks() -> None:
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."""