mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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
124 lines
4.1 KiB
Python
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",)
|