2026-04-23 07:49:27 -05:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from headroom.proxy import memory_handler as memory_handler_module
|
|
|
|
|
from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def handler(tmp_path: Path) -> MemoryHandler:
|
|
|
|
|
return MemoryHandler(
|
|
|
|
|
MemoryConfig(
|
|
|
|
|
enabled=False,
|
|
|
|
|
use_native_tool=True,
|
|
|
|
|
native_memory_dir=str(tmp_path / "native"),
|
|
|
|
|
),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeBackend:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.search_results: list[object] = []
|
|
|
|
|
self.saved: list[dict[str, object]] = []
|
|
|
|
|
self.updated: list[dict[str, object]] = []
|
|
|
|
|
self.deleted: list[str] = []
|
fix(memory): audit passive context injection (#2212)
## Description
Close the passive-memory observability loop by recording access for
context rows that survive the final injection budget and tagging
requests where context is actually appended.
Closes #2211
## 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
- Track only memory IDs retained after ranking, similarity filtering,
entry limits, and final text truncation.
- Call optional backend `record_access` with stable de-duplication and
fail-open error handling.
- Extend structured injection logging to stamp `memory_injected=true`
when injected bytes are positive.
- Thread request tags through successful Anthropic, OpenAI Chat, OpenAI
Responses, Gemini, and Codex WebSocket injection sites.
- Add a static contract test that all current successful handler
injection logs pass tags.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \
tests/test_memory_handler_native_ops.py \
tests/test_memory_auto_tail.py \
tests/test_memory_handler_project_isolation.py \
tests/test_memory_injection_logging.py -q
51 passed
$ uv run --with ruff ruff check <touched Python files>
All checks passed!
$ git diff --check
(no output)
```
## Real Behavior Proof
- Environment: Python 3.13 with synthetic backend and handler fixtures
- Exact command / steps: run the focused test set above
- Observed result: only IDs present after the final text budget are
access-recorded; access-write failures remain fail-open; positive
injection logs stamp `memory_injected=true`; all six current successful
injection call sites pass tags
- Not tested: live provider requests, full repository suite, third-party
backends without `record_access`
## Review Readiness
- [x] I have performed a self-review
- [ ] 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
## Screenshots (if applicable)
N/A
## Additional Notes
Access accounting is intentionally best-effort: unsupported backends and
write failures do not delay or fail the upstream model request.
Documentation and changelog changes are not needed for this internal
observability fix.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-16 02:17:31 +08:00
|
|
|
self.accessed: list[list[str]] = []
|
2026-04-23 07:49:27 -05:00
|
|
|
self.raise_on: str | None = None
|
|
|
|
|
|
|
|
|
|
async def search_memories(self, **kwargs): # noqa: ANN003
|
|
|
|
|
if self.raise_on == "search":
|
|
|
|
|
raise RuntimeError("search failed")
|
|
|
|
|
return self.search_results
|
|
|
|
|
|
|
|
|
|
async def save_memory(self, **kwargs): # noqa: ANN003
|
|
|
|
|
if self.raise_on == "save":
|
|
|
|
|
raise RuntimeError("save failed")
|
|
|
|
|
self.saved.append(kwargs)
|
|
|
|
|
return SimpleNamespace(id=f"mem-{len(self.saved)}", content=kwargs["content"])
|
|
|
|
|
|
|
|
|
|
async def update_memory(self, **kwargs): # noqa: ANN003
|
|
|
|
|
if self.raise_on == "update":
|
|
|
|
|
raise RuntimeError("update failed")
|
|
|
|
|
self.updated.append(kwargs)
|
|
|
|
|
return SimpleNamespace(id=kwargs["memory_id"])
|
|
|
|
|
|
|
|
|
|
async def delete_memory(self, memory_id: str): # noqa: ANN201
|
|
|
|
|
if self.raise_on == "delete":
|
|
|
|
|
raise RuntimeError("delete failed")
|
|
|
|
|
self.deleted.append(memory_id)
|
|
|
|
|
return True
|
|
|
|
|
|
fix(memory): audit passive context injection (#2212)
## Description
Close the passive-memory observability loop by recording access for
context rows that survive the final injection budget and tagging
requests where context is actually appended.
Closes #2211
## 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
- Track only memory IDs retained after ranking, similarity filtering,
entry limits, and final text truncation.
- Call optional backend `record_access` with stable de-duplication and
fail-open error handling.
- Extend structured injection logging to stamp `memory_injected=true`
when injected bytes are positive.
- Thread request tags through successful Anthropic, OpenAI Chat, OpenAI
Responses, Gemini, and Codex WebSocket injection sites.
- Add a static contract test that all current successful handler
injection logs pass tags.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \
tests/test_memory_handler_native_ops.py \
tests/test_memory_auto_tail.py \
tests/test_memory_handler_project_isolation.py \
tests/test_memory_injection_logging.py -q
51 passed
$ uv run --with ruff ruff check <touched Python files>
All checks passed!
$ git diff --check
(no output)
```
## Real Behavior Proof
- Environment: Python 3.13 with synthetic backend and handler fixtures
- Exact command / steps: run the focused test set above
- Observed result: only IDs present after the final text budget are
access-recorded; access-write failures remain fail-open; positive
injection logs stamp `memory_injected=true`; all six current successful
injection call sites pass tags
- Not tested: live provider requests, full repository suite, third-party
backends without `record_access`
## Review Readiness
- [x] I have performed a self-review
- [ ] 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
## Screenshots (if applicable)
N/A
## Additional Notes
Access accounting is intentionally best-effort: unsupported backends and
write failures do not delay or fail the upstream model request.
Documentation and changelog changes are not needed for this internal
observability fix.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-16 02:17:31 +08:00
|
|
|
async def record_access(self, memory_ids: list[str]) -> int:
|
|
|
|
|
if self.raise_on == "record_access":
|
|
|
|
|
raise RuntimeError("access tracking failed")
|
|
|
|
|
self.accessed.append(memory_ids)
|
|
|
|
|
return len(memory_ids)
|
|
|
|
|
|
2026-04-23 07:49:27 -05:00
|
|
|
|
|
|
|
|
def make_result(
|
|
|
|
|
memory_id: str,
|
|
|
|
|
content: str,
|
|
|
|
|
*,
|
|
|
|
|
score: float = 0.9,
|
|
|
|
|
metadata: dict[str, object] | None = None,
|
|
|
|
|
related_entities: list[str] | None = None,
|
|
|
|
|
created_at: str | None = None,
|
|
|
|
|
importance: float = 0.5,
|
|
|
|
|
) -> object:
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
memory=SimpleNamespace(
|
|
|
|
|
id=memory_id,
|
|
|
|
|
content=content,
|
|
|
|
|
metadata=metadata or {},
|
|
|
|
|
created_at=created_at,
|
|
|
|
|
importance=importance,
|
|
|
|
|
),
|
|
|
|
|
score=score,
|
|
|
|
|
related_entities=related_entities or [],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_native_path_blocks_traversal(handler: MemoryHandler) -> None:
|
|
|
|
|
resolved = handler._resolve_native_path("/memories/topic.txt", "u1")
|
|
|
|
|
assert resolved.name == "topic.txt"
|
|
|
|
|
assert "u1" in str(resolved)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="Path traversal detected"):
|
|
|
|
|
handler._resolve_native_path("/memories/../escape.txt", "u1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_view_lists_directory_and_reads_files(handler: MemoryHandler) -> None:
|
|
|
|
|
root = handler._resolve_native_path("/memories", "u1")
|
|
|
|
|
(root / "alpha.txt").write_text("line1\nline2\nline3", encoding="utf-8")
|
|
|
|
|
(root / "nested").mkdir()
|
|
|
|
|
(root / "nested" / "beta.txt").write_text("nested", encoding="utf-8")
|
|
|
|
|
(root / ".hidden").write_text("skip", encoding="utf-8")
|
|
|
|
|
(root / "node_modules").mkdir()
|
|
|
|
|
|
|
|
|
|
listing = handler._native_view({"path": "/memories"}, "u1")
|
|
|
|
|
assert "/memories/alpha.txt" in listing
|
|
|
|
|
assert "/memories/nested/beta.txt" in listing
|
|
|
|
|
assert ".hidden" not in listing
|
|
|
|
|
assert "/memories/node_modules" not in listing
|
|
|
|
|
|
|
|
|
|
file_view = handler._native_view({"path": "/memories/alpha.txt", "view_range": [2, 3]}, "u1")
|
|
|
|
|
assert "2\tline2" in file_view
|
|
|
|
|
assert "3\tline3" in file_view
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_view_handles_missing_paths_and_latin1(handler: MemoryHandler) -> None:
|
|
|
|
|
missing = handler._native_view({"path": "/memories/missing.txt"}, "u1")
|
|
|
|
|
assert "does not exist" in missing
|
|
|
|
|
|
|
|
|
|
latin_path = handler._resolve_native_path("/memories/latin.txt", "u1")
|
|
|
|
|
latin_path.write_bytes("caf\xe9".encode("latin-1"))
|
|
|
|
|
viewed = handler._native_view({"path": "/memories/latin.txt"}, "u1")
|
|
|
|
|
assert "cafe" not in viewed
|
|
|
|
|
assert "café" in viewed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_create_insert_delete_and_rename(handler: MemoryHandler) -> None:
|
|
|
|
|
assert handler._native_create(
|
|
|
|
|
{"path": "/memories/note.txt", "file_text": "a\nb"}, "u1"
|
|
|
|
|
).startswith("File created successfully")
|
|
|
|
|
assert handler._native_create(
|
|
|
|
|
{"path": "/memories/note.txt", "file_text": "dup"}, "u1"
|
|
|
|
|
).startswith("Error: File /memories/note.txt already exists")
|
|
|
|
|
|
|
|
|
|
inserted = handler._native_insert(
|
|
|
|
|
{"path": "/memories/note.txt", "insert_line": 1, "insert_text": "middle"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert inserted == "The file /memories/note.txt has been edited."
|
|
|
|
|
assert "middle" in handler._resolve_native_path("/memories/note.txt", "u1").read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
renamed = handler._native_rename(
|
|
|
|
|
{"old_path": "/memories/note.txt", "new_path": "/memories/archive/renamed.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert renamed == "Successfully renamed /memories/note.txt to /memories/archive/renamed.txt"
|
|
|
|
|
|
|
|
|
|
deleted = handler._native_delete_file({"path": "/memories/archive"}, "u1")
|
|
|
|
|
assert deleted == "Successfully deleted /memories/archive"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_insert_validates_range_and_path(handler: MemoryHandler) -> None:
|
|
|
|
|
assert (
|
|
|
|
|
handler._native_insert({"insert_line": 0, "insert_text": "x"}, "u1")
|
|
|
|
|
== "Error: path is required"
|
|
|
|
|
)
|
|
|
|
|
assert "does not exist" in handler._native_insert(
|
|
|
|
|
{"path": "/memories/missing.txt", "insert_line": 0, "insert_text": "x"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
note = handler._resolve_native_path("/memories/note.txt", "u1")
|
|
|
|
|
note.write_text("a\nb", encoding="utf-8")
|
|
|
|
|
invalid = handler._native_insert(
|
|
|
|
|
{"path": "/memories/note.txt", "insert_line": 4, "insert_text": "x"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "Invalid `insert_line` parameter: 4" in invalid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_str_replace_covers_missing_multiple_and_success(handler: MemoryHandler) -> None:
|
|
|
|
|
note = handler._resolve_native_path("/memories/note.txt", "u1")
|
|
|
|
|
note.write_text("hello\nhello\nworld", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
handler._native_str_replace({"old_str": "hello", "new_str": "bye"}, "u1")
|
|
|
|
|
== "Error: path is required"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
handler._native_str_replace({"path": "/memories/note.txt", "new_str": "bye"}, "u1")
|
|
|
|
|
== "Error: old_str is required"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
multiple = handler._native_str_replace(
|
|
|
|
|
{"path": "/memories/note.txt", "old_str": "hello", "new_str": "bye"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "Multiple occurrences of old_str `hello` in lines: 1, 2" in multiple
|
|
|
|
|
|
|
|
|
|
note.write_text("hello\nworld", encoding="utf-8")
|
|
|
|
|
missing = handler._native_str_replace(
|
|
|
|
|
{"path": "/memories/note.txt", "old_str": "nope", "new_str": "bye"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "did not appear verbatim" in missing
|
|
|
|
|
|
|
|
|
|
success = handler._native_str_replace(
|
|
|
|
|
{"path": "/memories/note.txt", "old_str": "hello", "new_str": "bye"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "The memory file has been edited." in success
|
|
|
|
|
assert "bye" in note.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_delete_and_rename_validate_inputs(handler: MemoryHandler) -> None:
|
|
|
|
|
assert handler._native_delete_file({}, "u1") == "Error: path is required"
|
|
|
|
|
assert "does not exist" in handler._native_delete_file({"path": "/memories/missing.txt"}, "u1")
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
handler._native_rename({"new_path": "/memories/new.txt"}, "u1")
|
|
|
|
|
== "Error: old_path is required"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
handler._native_rename({"old_path": "/memories/old.txt"}, "u1")
|
|
|
|
|
== "Error: new_path is required"
|
|
|
|
|
)
|
|
|
|
|
assert "does not exist" in handler._native_rename(
|
|
|
|
|
{"old_path": "/memories/old.txt", "new_path": "/memories/new.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
old = handler._resolve_native_path("/memories/old.txt", "u1")
|
|
|
|
|
new = handler._resolve_native_path("/memories/new.txt", "u1")
|
|
|
|
|
old.write_text("x", encoding="utf-8")
|
|
|
|
|
new.write_text("y", encoding="utf-8")
|
|
|
|
|
assert (
|
|
|
|
|
handler._native_rename(
|
|
|
|
|
{"old_path": "/memories/old.txt", "new_path": "/memories/new.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
== "Error: The destination /memories/new.txt already exists"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_native_memory_tool_dispatches_and_wraps_errors(
|
|
|
|
|
handler: MemoryHandler, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
handler._backend = object()
|
|
|
|
|
called: list[tuple[str, dict[str, object], str]] = []
|
|
|
|
|
|
|
|
|
|
async def fake_ensure_initialized() -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def fake_view(input_data, user_id): # noqa: ANN001
|
|
|
|
|
called.append(("view", input_data, user_id))
|
|
|
|
|
return "viewed"
|
|
|
|
|
|
|
|
|
|
async def fake_create(input_data, user_id): # noqa: ANN001
|
|
|
|
|
called.append(("create", input_data, user_id))
|
|
|
|
|
return "created"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_ensure_initialized", fake_ensure_initialized)
|
|
|
|
|
monkeypatch.setattr(handler, "_native_view_semantic", fake_view)
|
|
|
|
|
monkeypatch.setattr(handler, "_native_create_semantic", fake_create)
|
|
|
|
|
|
|
|
|
|
assert await handler._execute_native_memory_tool({"command": "view"}, "u1") == "viewed"
|
|
|
|
|
assert await handler._execute_native_memory_tool({"command": "create"}, "u1") == "created"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._execute_native_memory_tool({"command": "bad"}, "u1")
|
|
|
|
|
== "Error: Unknown command 'bad'"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def boom(input_data, user_id): # noqa: ANN001
|
|
|
|
|
raise RuntimeError("oops")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_native_view_semantic", boom)
|
|
|
|
|
assert await handler._execute_native_memory_tool({"command": "view"}, "u1") == "Error: oops"
|
|
|
|
|
assert [entry[0] for entry in called] == ["view", "create"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_semantic_search_recent_all_and_overview(handler: MemoryHandler) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result(
|
|
|
|
|
"m1",
|
|
|
|
|
"Alice likes pizza and pasta",
|
|
|
|
|
score=0.91,
|
|
|
|
|
related_entities=["Alice", "pizza"],
|
|
|
|
|
created_at="2026-04-22",
|
|
|
|
|
),
|
|
|
|
|
make_result("m2", "Bob prefers ramen", score=0.83),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
search_text = await handler._semantic_search("pizza", "u1")
|
|
|
|
|
assert "Found 2 memories matching 'pizza'" in search_text
|
|
|
|
|
assert "[91% match] Alice likes pizza and pasta" in search_text
|
|
|
|
|
assert "Related: Alice, pizza" in search_text
|
|
|
|
|
|
|
|
|
|
recent_text = await handler._get_recent_memories("u1", limit=2)
|
|
|
|
|
assert "Recent memories:" in recent_text
|
|
|
|
|
assert "(2026-04-22)" in recent_text
|
|
|
|
|
|
|
|
|
|
all_text = await handler._list_all_memories("u1", limit=2)
|
|
|
|
|
assert "Showing up to 2 memories:" in all_text
|
|
|
|
|
assert "Showing first 2" in all_text
|
|
|
|
|
|
|
|
|
|
overview = await handler._get_memory_overview("u1")
|
|
|
|
|
assert "Memory System (2 memories stored)" in overview
|
|
|
|
|
assert "view /memories/search/<your query>" in overview
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_semantic_helpers_handle_empty_backend_and_errors(handler: MemoryHandler) -> None:
|
|
|
|
|
assert await handler._semantic_search("x", "u1") == "Error: Memory backend not initialized"
|
|
|
|
|
assert await handler._get_recent_memories("u1") == "Error: Memory backend not initialized"
|
|
|
|
|
assert await handler._list_all_memories("u1") == "Error: Memory backend not initialized"
|
|
|
|
|
assert await handler._get_memory_overview("u1") == "Error: Memory backend not initialized"
|
|
|
|
|
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
assert "No memories found matching 'x'" in await handler._semantic_search("x", "u1")
|
|
|
|
|
assert "No memories stored yet." in await handler._list_all_memories("u1")
|
|
|
|
|
assert "No memories stored yet." in await handler._get_recent_memories("u1")
|
|
|
|
|
|
|
|
|
|
backend.raise_on = "search"
|
|
|
|
|
assert "Error searching memories: search failed" == await handler._semantic_search("x", "u1")
|
|
|
|
|
assert "Error getting recent memories: search failed" == await handler._get_recent_memories(
|
|
|
|
|
"u1"
|
|
|
|
|
)
|
|
|
|
|
assert "Error listing memories: search failed" == await handler._list_all_memories("u1")
|
|
|
|
|
overview = await handler._get_memory_overview("u1")
|
|
|
|
|
assert "📁 Memory System" in overview
|
|
|
|
|
assert "To SEARCH memories" in overview
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_native_view_semantic_routes_paths(
|
|
|
|
|
handler: MemoryHandler, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
seen: list[tuple[str, object]] = []
|
|
|
|
|
|
|
|
|
|
async def fake_search(query, user_id, top_k=5): # noqa: ANN001
|
|
|
|
|
seen.append(("search", query))
|
|
|
|
|
return "search-result"
|
|
|
|
|
|
|
|
|
|
async def fake_recent(user_id, limit=10): # noqa: ANN001
|
|
|
|
|
seen.append(("recent", limit))
|
|
|
|
|
return "recent-result"
|
|
|
|
|
|
|
|
|
|
async def fake_all(user_id, limit=20): # noqa: ANN001
|
|
|
|
|
seen.append(("all", limit))
|
|
|
|
|
return "all-result"
|
|
|
|
|
|
|
|
|
|
async def fake_overview(user_id): # noqa: ANN001
|
|
|
|
|
seen.append(("overview", user_id))
|
|
|
|
|
return "overview-result"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_semantic_search", fake_search)
|
|
|
|
|
monkeypatch.setattr(handler, "_get_recent_memories", fake_recent)
|
|
|
|
|
monkeypatch.setattr(handler, "_list_all_memories", fake_all)
|
|
|
|
|
monkeypatch.setattr(handler, "_get_memory_overview", fake_overview)
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_view_semantic({"path": "/memories/search/pizza"}, "u1")
|
|
|
|
|
== "search-result"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_view_semantic({"path": "/memories/recent"}, "u1") == "recent-result"
|
|
|
|
|
)
|
|
|
|
|
assert await handler._native_view_semantic({"path": "/memories/all"}, "u1") == "all-result"
|
|
|
|
|
assert await handler._native_view_semantic({"path": "/memories"}, "u1") == "overview-result"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_view_semantic({"path": "/memories/work/projects"}, "u1")
|
|
|
|
|
== "search-result"
|
|
|
|
|
)
|
|
|
|
|
assert (await handler._native_view_semantic({"path": "/memories/search/"}, "u1")).startswith(
|
|
|
|
|
"Error: Please provide a search query"
|
|
|
|
|
)
|
|
|
|
|
assert seen == [
|
|
|
|
|
("search", "pizza"),
|
|
|
|
|
("recent", 10),
|
|
|
|
|
("all", 20),
|
|
|
|
|
("overview", "u1"),
|
|
|
|
|
("search", "work projects"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_native_semantic_create_append_delete_and_rename(handler: MemoryHandler) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
|
|
|
|
|
assert await handler._native_create_semantic({}, "u1") == "Error: path is required"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_create_semantic({"path": "/memories/topic.txt"}, "u1")
|
|
|
|
|
== "Error: file_text is required (the memory content)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
created = await handler._native_create_semantic(
|
|
|
|
|
{"path": "/memories/topic.txt", "file_text": "prefers pizza"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert created == "File created successfully at: /memories/topic.txt"
|
|
|
|
|
assert backend.saved[-1]["metadata"] == {
|
|
|
|
|
"virtual_path": "/memories/topic.txt",
|
|
|
|
|
"topic": "topic",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert await handler._native_append_semantic({}, "u1") == "Error: path is required"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_append_semantic({"path": "/memories/topic.txt"}, "u1")
|
|
|
|
|
== "Error: insert_text is required"
|
|
|
|
|
)
|
|
|
|
|
appended = await handler._native_append_semantic(
|
|
|
|
|
{"path": "/memories/topic.txt", "insert_text": "and pasta"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert appended == "The file /memories/topic.txt has been edited."
|
|
|
|
|
assert backend.saved[-1]["metadata"]["appended"] is True
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result(
|
|
|
|
|
"m1", "prefers pizza", metadata={"virtual_path": "/memories/topic.txt"}, score=0.6
|
|
|
|
|
),
|
|
|
|
|
make_result("m2", "prefers pasta", metadata={}, score=0.91),
|
|
|
|
|
]
|
|
|
|
|
deleted = await handler._native_delete_semantic({"path": "/memories/topic.txt"}, "u1")
|
|
|
|
|
assert deleted == "Successfully deleted /memories/topic.txt"
|
|
|
|
|
assert backend.deleted == ["m1", "m2"]
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result(
|
|
|
|
|
"m3", "old content", metadata={"virtual_path": "/memories/old.txt"}, importance=0.7
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
renamed = await handler._native_rename_semantic(
|
|
|
|
|
{"old_path": "/memories/old.txt", "new_path": "/memories/new/topic.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert renamed == "Successfully renamed /memories/old.txt to /memories/new/topic.txt"
|
|
|
|
|
assert backend.deleted[-1] == "m3"
|
|
|
|
|
assert backend.saved[-1]["metadata"] == {
|
|
|
|
|
"virtual_path": "/memories/new/topic.txt",
|
|
|
|
|
"topic": "new_topic",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_native_semantic_update_delete_rename_and_backend_errors(
|
|
|
|
|
handler: MemoryHandler,
|
|
|
|
|
) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
|
|
|
|
|
assert await handler._native_update_semantic({}, "u1") == "Error: path is required"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_update_semantic({"path": "/memories/t.txt"}, "u1")
|
|
|
|
|
== "Error: old_str is required"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result("m1", "hello hello world", metadata={"virtual_path": "/memories/t.txt"})
|
|
|
|
|
]
|
|
|
|
|
multi = await handler._native_update_semantic(
|
|
|
|
|
{"path": "/memories/t.txt", "old_str": "hello", "new_str": "bye"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "Multiple occurrences of old_str `hello`" in multi
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result("m1", "hello world", metadata={"virtual_path": "/memories/t.txt"})
|
|
|
|
|
]
|
|
|
|
|
edited = await handler._native_update_semantic(
|
|
|
|
|
{"path": "/memories/t.txt", "old_str": "hello", "new_str": "bye"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "The memory file has been edited." in edited
|
|
|
|
|
assert backend.updated[-1]["new_content"] == "bye world"
|
|
|
|
|
|
|
|
|
|
class NoUpdateBackend:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.search_results: list[object] = []
|
|
|
|
|
self.saved: list[dict[str, object]] = []
|
|
|
|
|
self.deleted: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def search_memories(self, **kwargs): # noqa: ANN003
|
|
|
|
|
return self.search_results
|
|
|
|
|
|
|
|
|
|
async def delete_memory(self, memory_id: str): # noqa: ANN201
|
|
|
|
|
self.deleted.append(memory_id)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def save_memory(self, **kwargs): # noqa: ANN003
|
|
|
|
|
self.saved.append(kwargs)
|
|
|
|
|
return SimpleNamespace(id=f"mem-{len(self.saved)}")
|
|
|
|
|
|
|
|
|
|
no_update_backend = NoUpdateBackend()
|
|
|
|
|
no_update_backend.search_results = [
|
|
|
|
|
make_result("m2", "alpha beta", metadata={"virtual_path": "/memories/t.txt"})
|
|
|
|
|
]
|
|
|
|
|
handler._backend = no_update_backend
|
|
|
|
|
fallback = await handler._native_update_semantic(
|
|
|
|
|
{"path": "/memories/t.txt", "old_str": "alpha", "new_str": "omega"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
assert "The memory file has been edited." in fallback
|
|
|
|
|
assert no_update_backend.deleted[-1] == "m2"
|
|
|
|
|
assert no_update_backend.saved[-1]["content"] == "omega beta"
|
|
|
|
|
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
assert await handler._native_delete_semantic({}, "u1") == "Error: path is required"
|
|
|
|
|
assert await handler._native_rename_semantic({}, "u1") == "Error: old_path is required"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_rename_semantic({"old_path": "/memories/a.txt"}, "u1")
|
|
|
|
|
== "Error: new_path is required"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_delete_semantic({"path": "/memories/x.txt"}, "u1")
|
|
|
|
|
== "Error: The path /memories/x.txt does not exist"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_rename_semantic(
|
|
|
|
|
{"old_path": "/memories/x.txt", "new_path": "/memories/y.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
== "Error: The path /memories/x.txt does not exist"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result("m9", "content", metadata={"virtual_path": "/memories/other.txt"}, score=0.1)
|
|
|
|
|
]
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_delete_semantic({"path": "/memories/x.txt"}, "u1")
|
|
|
|
|
== "Error: The path /memories/x.txt does not exist"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_rename_semantic(
|
|
|
|
|
{"old_path": "/memories/x.txt", "new_path": "/memories/y.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
== "Error: The path /memories/x.txt does not exist"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
backend.raise_on = "search"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_create_semantic(
|
|
|
|
|
{"path": "/memories/topic.txt", "file_text": "content"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
== "File created successfully at: /memories/topic.txt"
|
|
|
|
|
)
|
|
|
|
|
backend.raise_on = "save"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_create_semantic(
|
|
|
|
|
{"path": "/memories/topic.txt", "file_text": "content"}, "u1"
|
|
|
|
|
)
|
|
|
|
|
).startswith("Error: ")
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_append_semantic(
|
|
|
|
|
{"path": "/memories/topic.txt", "insert_text": "content"}, "u1"
|
|
|
|
|
)
|
|
|
|
|
).startswith("Error: ")
|
|
|
|
|
backend.raise_on = "search"
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_update_semantic(
|
|
|
|
|
{"path": "/memories/t.txt", "old_str": "a", "new_str": "b"}, "u1"
|
|
|
|
|
)
|
|
|
|
|
).startswith("Error: ")
|
|
|
|
|
assert (await handler._native_delete_semantic({"path": "/memories/x.txt"}, "u1")).startswith(
|
|
|
|
|
"Error: "
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await handler._native_rename_semantic(
|
|
|
|
|
{"old_path": "/memories/x.txt", "new_path": "/memories/y.txt"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
).startswith("Error: ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_search_update_delete_and_handler_status(
|
|
|
|
|
handler: MemoryHandler, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
|
|
|
|
|
assert json.loads(await handler._execute_search({}, "u1")) == {
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": "query is required",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result("m1", "pizza", score=0.9123, related_entities=["food", "italy"])
|
|
|
|
|
]
|
|
|
|
|
search_payload = json.loads(
|
|
|
|
|
await handler._execute_search(
|
|
|
|
|
{"query": "pizza", "top_k": 3, "include_related": False, "entities": ["food"]},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert search_payload["status"] == "found"
|
|
|
|
|
assert search_payload["count"] == 1
|
|
|
|
|
assert search_payload["memories"][0] == {
|
|
|
|
|
"id": "m1",
|
|
|
|
|
"content": "pizza",
|
|
|
|
|
"score": 0.912,
|
|
|
|
|
"entities": ["food", "italy"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert json.loads(await handler._execute_update({}, "u1")) == {
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": "memory_id is required",
|
|
|
|
|
}
|
|
|
|
|
assert json.loads(await handler._execute_update({"memory_id": "m1"}, "u1")) == {
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": "new_content is required",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
backend.search_results = [make_result("m1", "old content")]
|
|
|
|
|
update_payload = json.loads(
|
|
|
|
|
await handler._execute_update(
|
|
|
|
|
{"memory_id": "m1", "new_content": "new content", "reason": "cleanup"},
|
|
|
|
|
"u1",
|
|
|
|
|
provider="openai",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert update_payload == {"status": "updated", "memory_id": "m1"}
|
|
|
|
|
assert backend.updated[-1]["new_content"] == "new content"
|
|
|
|
|
|
|
|
|
|
class NoUpdateBackend:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.deleted: list[str] = []
|
|
|
|
|
self.saved: list[dict[str, object]] = []
|
|
|
|
|
|
|
|
|
|
async def delete_memory(self, memory_id: str): # noqa: ANN201
|
|
|
|
|
self.deleted.append(memory_id)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def save_memory(self, **kwargs): # noqa: ANN003
|
|
|
|
|
self.saved.append(kwargs)
|
|
|
|
|
return SimpleNamespace(id="m2")
|
|
|
|
|
|
|
|
|
|
no_update_backend = NoUpdateBackend()
|
|
|
|
|
handler._backend = no_update_backend
|
|
|
|
|
update_fallback = json.loads(
|
|
|
|
|
await handler._execute_update({"memory_id": "m1", "new_content": "replacement"}, "u1")
|
|
|
|
|
)
|
|
|
|
|
assert update_fallback == {
|
|
|
|
|
"status": "updated",
|
|
|
|
|
"memory_id": "m2",
|
|
|
|
|
"note": "Replaced via delete+save",
|
|
|
|
|
}
|
|
|
|
|
assert no_update_backend.deleted == ["m1"]
|
|
|
|
|
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
assert json.loads(await handler._execute_delete({}, "u1")) == {
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": "memory_id is required",
|
|
|
|
|
}
|
|
|
|
|
delete_payload = json.loads(await handler._execute_delete({"memory_id": "m1"}, "u1"))
|
|
|
|
|
assert delete_payload == {"status": "deleted", "memory_id": "m1"}
|
|
|
|
|
|
|
|
|
|
assert handler.health_status() == {
|
|
|
|
|
"enabled": False,
|
|
|
|
|
"backend": "local",
|
|
|
|
|
"initialized": False,
|
|
|
|
|
"native_tool": True,
|
|
|
|
|
"bridge_enabled": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seen = {"count": 0}
|
|
|
|
|
|
|
|
|
|
async def fake_ensure_initialized() -> None:
|
|
|
|
|
seen["count"] += 1
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_ensure_initialized", fake_ensure_initialized)
|
|
|
|
|
await handler.ensure_initialized()
|
|
|
|
|
assert seen["count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_warmup_embedder_and_close(handler: MemoryHandler) -> None:
|
|
|
|
|
assert await handler.warmup_embedder() is False
|
|
|
|
|
|
|
|
|
|
class FakeEmbedder:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def embed(self, value: str) -> None:
|
|
|
|
|
self.calls.append(value)
|
|
|
|
|
|
|
|
|
|
embedder = FakeEmbedder()
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
handler._backend = SimpleNamespace(_hierarchical_memory=SimpleNamespace(_embedder=embedder))
|
|
|
|
|
assert await handler.warmup_embedder() is True
|
|
|
|
|
assert embedder.calls == ["warmup"]
|
|
|
|
|
|
|
|
|
|
handler._backend = SimpleNamespace(
|
|
|
|
|
_hierarchical_memory=SimpleNamespace(_embedder=SimpleNamespace())
|
|
|
|
|
)
|
|
|
|
|
assert await handler.warmup_embedder() is False
|
|
|
|
|
|
|
|
|
|
handler._backend = SimpleNamespace(close=lambda: None)
|
|
|
|
|
await handler.close()
|
|
|
|
|
assert handler.backend is None
|
|
|
|
|
assert handler.initialized is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
fix(memory): preserve semantically similar memories (#2303)
## Description
Prevent memory_save from automatically deleting semantically similar but
distinct memories. The previous fire-and-forget deduplication path
deleted existing memories at cosine similarity scores of 0.92 or higher
after the save had already returned success. Similarity remains
available as a consolidation hint, while supersession now requires an
explicit memory_update or memory_delete operation.
## 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
- Removed the automatic background deletion scheduled by memory_save.
- Removed the automatic-dedup threshold and background coroutine that
were no longer needed.
- Preserved the existing similarity search and consolidation hint.
- Kept explicit memory_update and memory_delete behavior unchanged.
- Added a regression test proving that distinct memories survive even at
0.99 simulated similarity.
- Added an Unreleased changelog entry.
## Testing
- [x] Unit tests pass (pytest)
- [x] Linting passes (ruff check .)
- [x] Type checking passes (mypy headroom)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
$ uv run --extra dev --frozen pytest
tests/test_memory_handler_native_ops.py
33 passed
$ uv run --extra dev --frozen ruff check .
All checks passed!
$ uv run --extra dev --frozen ruff format --check
headroom/proxy/memory_handler.py tests/test_memory_handler_native_ops.py
2 files already formatted
$ uv run --extra dev --frozen mypy headroom --ignore-missing-imports
Success: no issues found in 504 source files
$ uv run --extra dev --frozen pytest
9361 passed, 565 skipped, 4 failed
The four full-suite failures are unrelated to this diff: the Anthropic
compaction test passed in isolation; the Codex recovery test exceeded
the macOS AF_UNIX path limit; the dashboard test expects text absent
from the existing implementation; and the content-router test expects a
fallback absent from the existing strategy chain.
The repository-wide format check also flags pre-existing formatting in
the untouched headroom/proxy/handlers/anthropic.py.
## Real Behavior Proof
- Environment: macOS on Apple Silicon, CPython 3.12.13, real
LocalBackend, temporary SQLite database, and the local
sentence-transformers
embedding backend; no external provider or model API.
- Exact command / steps: Ran uv run --extra dev --frozen python with a
temporary database, saved User's primary backend framework at work is
FastAPI., queried its similarity to User's primary backend framework at
home is FastAPI., saved the second fact through
MemoryHandler._execute_save, and listed the user's memories.
- Observed result: The real embedding similarity was 0.9387, above the
former 0.92 deletion threshold. The second save returned saved,
included the consolidation hint, retained the original memory, and left
both distinct facts in the database (memory_count: 2).
- Not tested: Live OpenAI or Anthropic provider calls, a deployed proxy
or MCP client session, and Qdrant or Neo4j memory backends. These
paths share the handler policy changed here; backend-specific explicit
update and delete behavior is unchanged.
## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The documentation and code-comment checklist items are not applicable
because this change removes unsafe behavior without introducing a new
public interface or complex implementation. The full-suite checkbox
remains unchecked because four unrelated tests failed locally, as
documented above.
2026-07-17 00:02:38 +05:30
|
|
|
async def test_execute_memory_tool_save_returns_dedup_hint(handler: MemoryHandler) -> None:
|
2026-04-23 07:49:27 -05:00
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
|
|
|
|
|
assert json.loads(await handler._execute_memory_tool("unknown", {}, "u1")) == {
|
|
|
|
|
"error": "Unknown tool: unknown"
|
|
|
|
|
}
|
|
|
|
|
assert json.loads(await handler._execute_memory_tool("memory_save", {}, "u1")) == {
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": "content is required",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result(
|
|
|
|
|
"other",
|
|
|
|
|
"Very similar memory content " * 5,
|
|
|
|
|
score=0.8,
|
|
|
|
|
metadata={"source_agent": "claude"},
|
|
|
|
|
),
|
|
|
|
|
make_result("mem-1", "self result", score=0.99),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
saved = json.loads(
|
|
|
|
|
await handler._execute_memory_tool(
|
|
|
|
|
"memory_save",
|
|
|
|
|
{
|
|
|
|
|
"content": "Useful fact",
|
|
|
|
|
"importance": 0.7,
|
|
|
|
|
"facts": ["fact"],
|
|
|
|
|
"entities": ["entity"],
|
|
|
|
|
"extracted_entities": ["entity"],
|
|
|
|
|
"relationships": ["rel"],
|
|
|
|
|
"extracted_relationships": ["rel"],
|
|
|
|
|
},
|
|
|
|
|
"u1",
|
|
|
|
|
provider="openai",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert saved["status"] == "saved"
|
|
|
|
|
assert saved["memory_id"] == "mem-1"
|
|
|
|
|
assert "Similar memory exists" in saved["note"]
|
|
|
|
|
assert "saved by claude" in saved["note"]
|
|
|
|
|
assert backend.saved[-1]["metadata"]["source_provider"] == "openai"
|
fix(memory): preserve semantically similar memories (#2303)
## Description
Prevent memory_save from automatically deleting semantically similar but
distinct memories. The previous fire-and-forget deduplication path
deleted existing memories at cosine similarity scores of 0.92 or higher
after the save had already returned success. Similarity remains
available as a consolidation hint, while supersession now requires an
explicit memory_update or memory_delete operation.
## 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
- Removed the automatic background deletion scheduled by memory_save.
- Removed the automatic-dedup threshold and background coroutine that
were no longer needed.
- Preserved the existing similarity search and consolidation hint.
- Kept explicit memory_update and memory_delete behavior unchanged.
- Added a regression test proving that distinct memories survive even at
0.99 simulated similarity.
- Added an Unreleased changelog entry.
## Testing
- [x] Unit tests pass (pytest)
- [x] Linting passes (ruff check .)
- [x] Type checking passes (mypy headroom)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
$ uv run --extra dev --frozen pytest
tests/test_memory_handler_native_ops.py
33 passed
$ uv run --extra dev --frozen ruff check .
All checks passed!
$ uv run --extra dev --frozen ruff format --check
headroom/proxy/memory_handler.py tests/test_memory_handler_native_ops.py
2 files already formatted
$ uv run --extra dev --frozen mypy headroom --ignore-missing-imports
Success: no issues found in 504 source files
$ uv run --extra dev --frozen pytest
9361 passed, 565 skipped, 4 failed
The four full-suite failures are unrelated to this diff: the Anthropic
compaction test passed in isolation; the Codex recovery test exceeded
the macOS AF_UNIX path limit; the dashboard test expects text absent
from the existing implementation; and the content-router test expects a
fallback absent from the existing strategy chain.
The repository-wide format check also flags pre-existing formatting in
the untouched headroom/proxy/handlers/anthropic.py.
## Real Behavior Proof
- Environment: macOS on Apple Silicon, CPython 3.12.13, real
LocalBackend, temporary SQLite database, and the local
sentence-transformers
embedding backend; no external provider or model API.
- Exact command / steps: Ran uv run --extra dev --frozen python with a
temporary database, saved User's primary backend framework at work is
FastAPI., queried its similarity to User's primary backend framework at
home is FastAPI., saved the second fact through
MemoryHandler._execute_save, and listed the user's memories.
- Observed result: The real embedding similarity was 0.9387, above the
former 0.92 deletion threshold. The second save returned saved,
included the consolidation hint, retained the original memory, and left
both distinct facts in the database (memory_count: 2).
- Not tested: Live OpenAI or Anthropic provider calls, a deployed proxy
or MCP client session, and Qdrant or Neo4j memory backends. These
paths share the handler policy changed here; backend-specific explicit
update and delete behavior is unchanged.
## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The documentation and code-comment checklist items are not applicable
because this change removes unsafe behavior without introducing a new
public interface or complex implementation. The full-suite checkbox
remains unchecked because four unrelated tests failed locally, as
documented above.
2026-07-17 00:02:38 +05:30
|
|
|
assert backend.deleted == []
|
2026-04-23 07:49:27 -05:00
|
|
|
|
|
|
|
|
backend.raise_on = "save"
|
|
|
|
|
errored = json.loads(await handler._execute_memory_tool("memory_save", {"content": "x"}, "u1"))
|
|
|
|
|
assert errored == {"status": "error", "error": "save failed"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
fix(memory): preserve semantically similar memories (#2303)
## Description
Prevent memory_save from automatically deleting semantically similar but
distinct memories. The previous fire-and-forget deduplication path
deleted existing memories at cosine similarity scores of 0.92 or higher
after the save had already returned success. Similarity remains
available as a consolidation hint, while supersession now requires an
explicit memory_update or memory_delete operation.
## 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
- Removed the automatic background deletion scheduled by memory_save.
- Removed the automatic-dedup threshold and background coroutine that
were no longer needed.
- Preserved the existing similarity search and consolidation hint.
- Kept explicit memory_update and memory_delete behavior unchanged.
- Added a regression test proving that distinct memories survive even at
0.99 simulated similarity.
- Added an Unreleased changelog entry.
## Testing
- [x] Unit tests pass (pytest)
- [x] Linting passes (ruff check .)
- [x] Type checking passes (mypy headroom)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
$ uv run --extra dev --frozen pytest
tests/test_memory_handler_native_ops.py
33 passed
$ uv run --extra dev --frozen ruff check .
All checks passed!
$ uv run --extra dev --frozen ruff format --check
headroom/proxy/memory_handler.py tests/test_memory_handler_native_ops.py
2 files already formatted
$ uv run --extra dev --frozen mypy headroom --ignore-missing-imports
Success: no issues found in 504 source files
$ uv run --extra dev --frozen pytest
9361 passed, 565 skipped, 4 failed
The four full-suite failures are unrelated to this diff: the Anthropic
compaction test passed in isolation; the Codex recovery test exceeded
the macOS AF_UNIX path limit; the dashboard test expects text absent
from the existing implementation; and the content-router test expects a
fallback absent from the existing strategy chain.
The repository-wide format check also flags pre-existing formatting in
the untouched headroom/proxy/handlers/anthropic.py.
## Real Behavior Proof
- Environment: macOS on Apple Silicon, CPython 3.12.13, real
LocalBackend, temporary SQLite database, and the local
sentence-transformers
embedding backend; no external provider or model API.
- Exact command / steps: Ran uv run --extra dev --frozen python with a
temporary database, saved User's primary backend framework at work is
FastAPI., queried its similarity to User's primary backend framework at
home is FastAPI., saved the second fact through
MemoryHandler._execute_save, and listed the user's memories.
- Observed result: The real embedding similarity was 0.9387, above the
former 0.92 deletion threshold. The second save returned saved,
included the consolidation hint, retained the original memory, and left
both distinct facts in the database (memory_count: 2).
- Not tested: Live OpenAI or Anthropic provider calls, a deployed proxy
or MCP client session, and Qdrant or Neo4j memory backends. These
paths share the handler policy changed here; backend-specific explicit
update and delete behavior is unchanged.
## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The documentation and code-comment checklist items are not applicable
because this change removes unsafe behavior without introducing a new
public interface or complex implementation. The full-suite checkbox
remains unchecked because four unrelated tests failed locally, as
documented above.
2026-07-17 00:02:38 +05:30
|
|
|
async def test_execute_save_handles_search_failure(handler: MemoryHandler) -> None:
|
2026-04-23 07:49:27 -05:00
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
|
|
|
|
|
backend.raise_on = "search"
|
|
|
|
|
saved = json.loads(await handler._execute_save({"content": "Useful fact"}, "u1"))
|
|
|
|
|
assert saved == {"status": "saved", "memory_id": "mem-1", "content": "Useful fact"}
|
|
|
|
|
|
fix(memory): preserve semantically similar memories (#2303)
## Description
Prevent memory_save from automatically deleting semantically similar but
distinct memories. The previous fire-and-forget deduplication path
deleted existing memories at cosine similarity scores of 0.92 or higher
after the save had already returned success. Similarity remains
available as a consolidation hint, while supersession now requires an
explicit memory_update or memory_delete operation.
## 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
- Removed the automatic background deletion scheduled by memory_save.
- Removed the automatic-dedup threshold and background coroutine that
were no longer needed.
- Preserved the existing similarity search and consolidation hint.
- Kept explicit memory_update and memory_delete behavior unchanged.
- Added a regression test proving that distinct memories survive even at
0.99 simulated similarity.
- Added an Unreleased changelog entry.
## Testing
- [x] Unit tests pass (pytest)
- [x] Linting passes (ruff check .)
- [x] Type checking passes (mypy headroom)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
$ uv run --extra dev --frozen pytest
tests/test_memory_handler_native_ops.py
33 passed
$ uv run --extra dev --frozen ruff check .
All checks passed!
$ uv run --extra dev --frozen ruff format --check
headroom/proxy/memory_handler.py tests/test_memory_handler_native_ops.py
2 files already formatted
$ uv run --extra dev --frozen mypy headroom --ignore-missing-imports
Success: no issues found in 504 source files
$ uv run --extra dev --frozen pytest
9361 passed, 565 skipped, 4 failed
The four full-suite failures are unrelated to this diff: the Anthropic
compaction test passed in isolation; the Codex recovery test exceeded
the macOS AF_UNIX path limit; the dashboard test expects text absent
from the existing implementation; and the content-router test expects a
fallback absent from the existing strategy chain.
The repository-wide format check also flags pre-existing formatting in
the untouched headroom/proxy/handlers/anthropic.py.
## Real Behavior Proof
- Environment: macOS on Apple Silicon, CPython 3.12.13, real
LocalBackend, temporary SQLite database, and the local
sentence-transformers
embedding backend; no external provider or model API.
- Exact command / steps: Ran uv run --extra dev --frozen python with a
temporary database, saved User's primary backend framework at work is
FastAPI., queried its similarity to User's primary backend framework at
home is FastAPI., saved the second fact through
MemoryHandler._execute_save, and listed the user's memories.
- Observed result: The real embedding similarity was 0.9387, above the
former 0.92 deletion threshold. The second save returned saved,
included the consolidation hint, retained the original memory, and left
both distinct facts in the database (memory_count: 2).
- Not tested: Live OpenAI or Anthropic provider calls, a deployed proxy
or MCP client session, and Qdrant or Neo4j memory backends. These
paths share the handler policy changed here; backend-specific explicit
update and delete behavior is unchanged.
## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The documentation and code-comment checklist items are not applicable
because this change removes unsafe behavior without introducing a new
public interface or complex implementation. The full-suite checkbox
remains unchecked because four unrelated tests failed locally, as
documented above.
2026-07-17 00:02:38 +05:30
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_save_preserves_high_similarity_distinct_memory(
|
|
|
|
|
handler: MemoryHandler,
|
|
|
|
|
) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result(
|
|
|
|
|
"existing-memory",
|
|
|
|
|
"User uses Python at work",
|
|
|
|
|
score=0.99,
|
|
|
|
|
metadata={"source_agent": "claude"},
|
|
|
|
|
)
|
2026-04-23 07:49:27 -05:00
|
|
|
]
|
|
|
|
|
|
fix(memory): preserve semantically similar memories (#2303)
## Description
Prevent memory_save from automatically deleting semantically similar but
distinct memories. The previous fire-and-forget deduplication path
deleted existing memories at cosine similarity scores of 0.92 or higher
after the save had already returned success. Similarity remains
available as a consolidation hint, while supersession now requires an
explicit memory_update or memory_delete operation.
## 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
- Removed the automatic background deletion scheduled by memory_save.
- Removed the automatic-dedup threshold and background coroutine that
were no longer needed.
- Preserved the existing similarity search and consolidation hint.
- Kept explicit memory_update and memory_delete behavior unchanged.
- Added a regression test proving that distinct memories survive even at
0.99 simulated similarity.
- Added an Unreleased changelog entry.
## Testing
- [x] Unit tests pass (pytest)
- [x] Linting passes (ruff check .)
- [x] Type checking passes (mypy headroom)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
$ uv run --extra dev --frozen pytest
tests/test_memory_handler_native_ops.py
33 passed
$ uv run --extra dev --frozen ruff check .
All checks passed!
$ uv run --extra dev --frozen ruff format --check
headroom/proxy/memory_handler.py tests/test_memory_handler_native_ops.py
2 files already formatted
$ uv run --extra dev --frozen mypy headroom --ignore-missing-imports
Success: no issues found in 504 source files
$ uv run --extra dev --frozen pytest
9361 passed, 565 skipped, 4 failed
The four full-suite failures are unrelated to this diff: the Anthropic
compaction test passed in isolation; the Codex recovery test exceeded
the macOS AF_UNIX path limit; the dashboard test expects text absent
from the existing implementation; and the content-router test expects a
fallback absent from the existing strategy chain.
The repository-wide format check also flags pre-existing formatting in
the untouched headroom/proxy/handlers/anthropic.py.
## Real Behavior Proof
- Environment: macOS on Apple Silicon, CPython 3.12.13, real
LocalBackend, temporary SQLite database, and the local
sentence-transformers
embedding backend; no external provider or model API.
- Exact command / steps: Ran uv run --extra dev --frozen python with a
temporary database, saved User's primary backend framework at work is
FastAPI., queried its similarity to User's primary backend framework at
home is FastAPI., saved the second fact through
MemoryHandler._execute_save, and listed the user's memories.
- Observed result: The real embedding similarity was 0.9387, above the
former 0.92 deletion threshold. The second save returned saved,
included the consolidation hint, retained the original memory, and left
both distinct facts in the database (memory_count: 2).
- Not tested: Live OpenAI or Anthropic provider calls, a deployed proxy
or MCP client session, and Qdrant or Neo4j memory backends. These
paths share the handler policy changed here; backend-specific explicit
update and delete behavior is unchanged.
## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The documentation and code-comment checklist items are not applicable
because this change removes unsafe behavior without introducing a new
public interface or complex implementation. The full-suite checkbox
remains unchecked because four unrelated tests failed locally, as
documented above.
2026-07-17 00:02:38 +05:30
|
|
|
saved = json.loads(
|
|
|
|
|
await handler._execute_save(
|
|
|
|
|
{"content": "User prefers Python for side projects"},
|
|
|
|
|
"u1",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
# Give any accidentally scheduled background work a chance to run.
|
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
|
|
|
|
assert saved["status"] == "saved"
|
|
|
|
|
assert "Similar memory exists" in saved["note"]
|
|
|
|
|
assert "ignore if these are distinct facts" in saved["note"]
|
|
|
|
|
assert backend.deleted == []
|
2026-04-23 07:49:27 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_tools_extract_query_and_has_tool_calls(
|
|
|
|
|
handler: MemoryHandler, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
handler,
|
|
|
|
|
"_get_memory_tools",
|
|
|
|
|
lambda: [
|
|
|
|
|
{
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "memory_save",
|
|
|
|
|
"description": "save memory",
|
|
|
|
|
"parameters": {"type": "object"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
anthropic_tools, injected = handler.inject_tools([], "anthropic")
|
|
|
|
|
assert injected is True
|
|
|
|
|
assert anthropic_tools == [{"type": "memory_20250818", "name": "memory"}]
|
|
|
|
|
|
|
|
|
|
custom_handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(enabled=False, inject_tools=True), agent_type="codex"
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
custom_handler,
|
|
|
|
|
"_get_memory_tools",
|
|
|
|
|
lambda: [
|
|
|
|
|
{
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "memory_save",
|
|
|
|
|
"description": "save memory",
|
|
|
|
|
"parameters": {"type": "object"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
anthropic_custom, injected_custom = custom_handler.inject_tools([], "anthropic")
|
|
|
|
|
assert injected_custom is True
|
|
|
|
|
assert anthropic_custom == [
|
|
|
|
|
{"name": "memory_save", "description": "save memory", "input_schema": {"type": "object"}}
|
|
|
|
|
]
|
|
|
|
|
openai_custom, _ = custom_handler.inject_tools([], "openai")
|
|
|
|
|
assert openai_custom == [
|
|
|
|
|
{
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "memory_save",
|
|
|
|
|
"description": "save memory",
|
|
|
|
|
"parameters": {"type": "object"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
existing, was_injected = custom_handler.inject_tools(
|
|
|
|
|
[{"function": {"name": "memory_save"}}],
|
|
|
|
|
"openai",
|
|
|
|
|
)
|
|
|
|
|
assert was_injected is False
|
|
|
|
|
assert existing == [{"function": {"name": "memory_save"}}]
|
|
|
|
|
|
|
|
|
|
assert handler._extract_user_query([{"role": "assistant", "content": "skip"}]) == ""
|
2026-05-19 11:26:21 -05:00
|
|
|
# Pre-PR-this method truncated at 500 chars; that was a real bug
|
|
|
|
|
# (none of Letta / Mem0 / Cognee / Supermemory truncate the
|
|
|
|
|
# retrieval query). Now returns the full message — embedder
|
|
|
|
|
# handles its own context window. See ``MemoryQuery``.
|
|
|
|
|
assert handler._extract_user_query([{"role": "user", "content": "x" * 600}]) == "x" * 600
|
2026-04-23 07:49:27 -05:00
|
|
|
assert (
|
|
|
|
|
handler._extract_user_query(
|
|
|
|
|
[{"role": "user", "content": [{"type": "text", "text": "hello"}, {"type": "image"}]}]
|
|
|
|
|
)
|
|
|
|
|
== "hello"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
anthropic_response = {
|
|
|
|
|
"content": [{"type": "tool_use", "name": "memory_save", "id": "1", "input": {}}]
|
|
|
|
|
}
|
|
|
|
|
openai_response = {
|
|
|
|
|
"choices": [{"message": {"tool_calls": [{"id": "1", "function": {"name": "memory_save"}}]}}]
|
|
|
|
|
}
|
|
|
|
|
responses_api = {"output": [{"type": "function_call", "call_id": "2", "name": "memory"}]}
|
|
|
|
|
assert handler.has_memory_tool_calls(anthropic_response, "anthropic") is True
|
|
|
|
|
assert handler.has_memory_tool_calls(openai_response, "openai") is True
|
|
|
|
|
assert handler.has_memory_tool_calls(responses_api, "openai") is True
|
|
|
|
|
assert handler.has_memory_tool_calls({"content": []}, "anthropic") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_memory_handler_misc_helpers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
|
|
|
handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(
|
|
|
|
|
enabled=False,
|
|
|
|
|
use_native_tool=True,
|
|
|
|
|
inject_tools=True,
|
|
|
|
|
native_memory_dir=str(tmp_path / "native"),
|
|
|
|
|
),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
first_lock = handler._get_init_lock()
|
|
|
|
|
assert handler._get_init_lock() is first_lock
|
|
|
|
|
assert handler.get_beta_headers() == {"anthropic-beta": "context-management-2025-06-27"}
|
|
|
|
|
|
|
|
|
|
disabled_headers = MemoryHandler(
|
|
|
|
|
MemoryConfig(enabled=False, use_native_tool=False), agent_type="codex"
|
|
|
|
|
)
|
|
|
|
|
assert disabled_headers.get_beta_headers() == {}
|
|
|
|
|
|
|
|
|
|
tools, injected = handler._inject_native_tool([])
|
|
|
|
|
assert injected is True
|
|
|
|
|
assert tools == [{"type": "memory_20250818", "name": "memory"}]
|
|
|
|
|
same_tools, same_injected = handler._inject_native_tool([{"name": "memory"}])
|
|
|
|
|
assert same_injected is False
|
|
|
|
|
assert same_tools == [{"name": "memory"}]
|
|
|
|
|
|
|
|
|
|
calls = {"count": 0}
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
__import__("sys").modules,
|
|
|
|
|
"headroom.memory.tools",
|
|
|
|
|
SimpleNamespace(
|
2026-04-23 08:49:50 -05:00
|
|
|
get_memory_tools_optimized=lambda: (
|
|
|
|
|
calls.__setitem__("count", calls["count"] + 1) or [{"name": "tool"}]
|
|
|
|
|
)
|
2026-04-23 07:49:27 -05:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
cache_handler = MemoryHandler(MemoryConfig(enabled=False), agent_type="codex")
|
|
|
|
|
assert cache_handler._get_memory_tools() == [{"name": "tool"}]
|
|
|
|
|
assert cache_handler._get_memory_tools() == [{"name": "tool"}]
|
|
|
|
|
assert calls["count"] == 1
|
|
|
|
|
|
|
|
|
|
assert cache_handler._extract_tool_calls(
|
|
|
|
|
{"content": [{"type": "tool_use", "id": "1"}]}, "anthropic"
|
|
|
|
|
) == [{"type": "tool_use", "id": "1"}]
|
|
|
|
|
assert cache_handler._extract_tool_calls(
|
|
|
|
|
{"choices": [{"message": {"tool_calls": [{"id": "2"}]}}]},
|
|
|
|
|
"openai",
|
|
|
|
|
) == [{"id": "2"}]
|
|
|
|
|
assert cache_handler._extract_tool_calls(
|
|
|
|
|
{"output": [{"type": "function_call", "call_id": "3"}]},
|
|
|
|
|
"openai",
|
|
|
|
|
) == [{"type": "function_call", "call_id": "3"}]
|
|
|
|
|
assert cache_handler._extract_tool_calls({}, "other") == []
|
|
|
|
|
|
|
|
|
|
closed: list[str] = []
|
|
|
|
|
|
|
|
|
|
class Closable:
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
closed.append("closed")
|
|
|
|
|
|
|
|
|
|
await cache_handler._close_backend_instance(Closable(), reason="test")
|
|
|
|
|
assert closed == ["closed"]
|
|
|
|
|
await cache_handler._close_backend_instance(SimpleNamespace(), reason="test")
|
|
|
|
|
|
|
|
|
|
class BrokenCloser:
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
|
|
|
|
await cache_handler._close_backend_instance(BrokenCloser(), reason="test")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_and_format_context_and_handle_memory_tool_calls(
|
|
|
|
|
handler: MemoryHandler, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result("m1", "Alice likes pizza", score=0.8, related_entities=["Alice", "pizza"]),
|
|
|
|
|
make_result("m2", "below threshold", score=0.2),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
inject_none = await handler.search_and_format_context(
|
|
|
|
|
"u1",
|
|
|
|
|
[{"role": "assistant", "content": "skip"}],
|
|
|
|
|
)
|
|
|
|
|
assert inject_none is None
|
|
|
|
|
|
|
|
|
|
context = await handler.search_and_format_context(
|
|
|
|
|
"u1",
|
|
|
|
|
[{"role": "user", "content": "What food does Alice like?"}],
|
|
|
|
|
)
|
|
|
|
|
assert "## Relevant Memories for This User" in context
|
2026-05-19 19:54:24 -05:00
|
|
|
# Format now includes memory ID in brackets so the model can address
|
|
|
|
|
# rows directly via memory_update / memory_delete without round-
|
|
|
|
|
# tripping through memory_search.
|
|
|
|
|
assert "1. [m1] Alice likes pizza" in context
|
2026-04-23 07:49:27 -05:00
|
|
|
assert "(Related: Alice, pizza)" in context
|
fix(memory): audit passive context injection (#2212)
## Description
Close the passive-memory observability loop by recording access for
context rows that survive the final injection budget and tagging
requests where context is actually appended.
Closes #2211
## 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
- Track only memory IDs retained after ranking, similarity filtering,
entry limits, and final text truncation.
- Call optional backend `record_access` with stable de-duplication and
fail-open error handling.
- Extend structured injection logging to stamp `memory_injected=true`
when injected bytes are positive.
- Thread request tags through successful Anthropic, OpenAI Chat, OpenAI
Responses, Gemini, and Codex WebSocket injection sites.
- Add a static contract test that all current successful handler
injection logs pass tags.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \
tests/test_memory_handler_native_ops.py \
tests/test_memory_auto_tail.py \
tests/test_memory_handler_project_isolation.py \
tests/test_memory_injection_logging.py -q
51 passed
$ uv run --with ruff ruff check <touched Python files>
All checks passed!
$ git diff --check
(no output)
```
## Real Behavior Proof
- Environment: Python 3.13 with synthetic backend and handler fixtures
- Exact command / steps: run the focused test set above
- Observed result: only IDs present after the final text budget are
access-recorded; access-write failures remain fail-open; positive
injection logs stamp `memory_injected=true`; all six current successful
injection call sites pass tags
- Not tested: live provider requests, full repository suite, third-party
backends without `record_access`
## Review Readiness
- [x] I have performed a self-review
- [ ] 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
## Screenshots (if applicable)
N/A
## Additional Notes
Access accounting is intentionally best-effort: unsupported backends and
write failures do not delay or fail the upstream model request.
Documentation and changelog changes are not needed for this internal
observability fix.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-16 02:17:31 +08:00
|
|
|
assert backend.accessed == [["m1"]]
|
2026-04-23 07:49:27 -05:00
|
|
|
|
|
|
|
|
backend.raise_on = "search"
|
|
|
|
|
assert (
|
|
|
|
|
await handler.search_and_format_context("u1", [{"role": "user", "content": "Question"}])
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
backend.raise_on = None
|
|
|
|
|
|
fix(memory): audit passive context injection (#2212)
## Description
Close the passive-memory observability loop by recording access for
context rows that survive the final injection budget and tagging
requests where context is actually appended.
Closes #2211
## 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
- Track only memory IDs retained after ranking, similarity filtering,
entry limits, and final text truncation.
- Call optional backend `record_access` with stable de-duplication and
fail-open error handling.
- Extend structured injection logging to stamp `memory_injected=true`
when injected bytes are positive.
- Thread request tags through successful Anthropic, OpenAI Chat, OpenAI
Responses, Gemini, and Codex WebSocket injection sites.
- Add a static contract test that all current successful handler
injection logs pass tags.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \
tests/test_memory_handler_native_ops.py \
tests/test_memory_auto_tail.py \
tests/test_memory_handler_project_isolation.py \
tests/test_memory_injection_logging.py -q
51 passed
$ uv run --with ruff ruff check <touched Python files>
All checks passed!
$ git diff --check
(no output)
```
## Real Behavior Proof
- Environment: Python 3.13 with synthetic backend and handler fixtures
- Exact command / steps: run the focused test set above
- Observed result: only IDs present after the final text budget are
access-recorded; access-write failures remain fail-open; positive
injection logs stamp `memory_injected=true`; all six current successful
injection call sites pass tags
- Not tested: live provider requests, full repository suite, third-party
backends without `record_access`
## Review Readiness
- [x] I have performed a self-review
- [ ] 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
## Screenshots (if applicable)
N/A
## Additional Notes
Access accounting is intentionally best-effort: unsupported backends and
write failures do not delay or fail the upstream model request.
Documentation and changelog changes are not needed for this internal
observability fix.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-16 02:17:31 +08:00
|
|
|
backend.raise_on = "record_access"
|
|
|
|
|
context = await handler.search_and_format_context(
|
|
|
|
|
"u1",
|
|
|
|
|
[{"role": "user", "content": "What food does Alice like?"}],
|
|
|
|
|
)
|
|
|
|
|
assert "1. [m1] Alice likes pizza" in context
|
|
|
|
|
backend.raise_on = None
|
|
|
|
|
|
2026-04-23 07:49:27 -05:00
|
|
|
async def fake_ensure_initialized() -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
fix: per-project memory storage so projects can no longer bleed memories (GH #462)
Memory retrieval was partitioned only by `x-headroom-user-id`. Claude
Code never sets that header, so every project a user worked on landed
in one global `default` bucket; the proxy then injected semantically
similar memories from that mixed bucket into every `/v1/messages`
request, regardless of which repo the session was actually about. The
injected `## Relevant Memories` block reads like a prompt-injection
payload and Claude has been seen to refuse to act on it, defeating the
feature.
This change makes leakage structurally impossible by giving each
resolved workspace its own SQLite database file. The wrong DB is
simply not open during a request.
- `headroom/memory/storage_router.py` (new) — `MemoryStorageMode`
(project/user/global), `ProjectResolver` (x-headroom-project-id →
x-headroom-cwd → --memory-project-root CLI override → env-block
parse: `Primary working directory:` / `Working directory:` / `cwd:`,
no regex), and `BackendRouter` with an LRU of open `LocalBackend`s
keyed by db_path.
- `proxy/memory_handler.py` — `MemoryConfig.storage_mode` defaults to
`PROJECT`. Provider handlers build a `RequestContext` once and pass
it through; `search_and_format_context`, `handle_memory_tool_calls`,
and the `_execute_*` methods route save/search/update/delete on the
per-project backend. Qdrant-neo4j gets a composite
`user::project_key` partition so external Mem0-style deployments
also isolate per project without a parallel collection.
- Fix C — injected block carries provenance:
`## Relevant Memories (workspace: <basename>, scope: project)`.
CCR proactive-expansion block gets a matching workspace tag.
- `memory/factory.py` — process-wide embedder cache so opening N
project DBs doesn't load the embedder N times. OpenAI key
validation runs ahead of the cache.
- CLI — `--memory-storage={project,user,global}` (default `project`),
`--memory-project-root` override, rewritten `--memory` help text,
banner reports storage mode.
- Migration UX — if the legacy single-file DB has content while
project mode is active, an INFO log points users at
`--memory-storage=global`. Bridge currently only syncs the legacy
DB; a WARN fires when bridge + project mode are combined.
Backward-compatible: legacy `~/.headroom/memory.db` untouched and
reachable via `--memory-storage=global`. `request_context` is
keyword-only on entry points so existing tests/mocks keep working.
Tests: 24 new (resolver tiers, LRU eviction, two-cwd isolation,
user-mode partition, legacy fallback, provenance headers); full
suite 5260 passing, ci-precheck green.
2026-05-13 15:27:41 -07:00
|
|
|
async def fake_execute_memory_tool(
|
|
|
|
|
tool_name, input_data, user_id, provider="anthropic", *, request_context=None
|
|
|
|
|
): # noqa: ANN001
|
2026-04-23 07:49:27 -05:00
|
|
|
return f"ran:{tool_name}:{user_id}:{provider}:{input_data}"
|
|
|
|
|
|
|
|
|
|
async def fake_execute_native(input_data, user_id): # noqa: ANN001
|
|
|
|
|
return f"native:{user_id}:{input_data}"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_ensure_initialized", fake_ensure_initialized)
|
|
|
|
|
monkeypatch.setattr(handler, "_execute_memory_tool", fake_execute_memory_tool)
|
|
|
|
|
monkeypatch.setattr(handler, "_execute_native_memory_tool", fake_execute_native)
|
|
|
|
|
|
|
|
|
|
anthropic_results = await handler.handle_memory_tool_calls(
|
|
|
|
|
{
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_use", "name": "memory_save", "id": "a1", "input": {"content": "x"}},
|
|
|
|
|
{"type": "tool_use", "name": "memory", "id": "a2", "input": {"command": "view"}},
|
|
|
|
|
{"type": "tool_use", "name": "other", "id": "a3", "input": {}},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"u1",
|
|
|
|
|
"anthropic",
|
|
|
|
|
)
|
|
|
|
|
assert anthropic_results == [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "a1",
|
|
|
|
|
"content": "ran:memory_save:u1:anthropic:{'content': 'x'}",
|
|
|
|
|
},
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "a2", "content": "native:u1:{'command': 'view'}"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
openai_results = await handler.handle_memory_tool_calls(
|
|
|
|
|
{
|
|
|
|
|
"choices": [
|
|
|
|
|
{
|
|
|
|
|
"message": {
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": "o1",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "memory_search",
|
|
|
|
|
"arguments": '{"query":"pizza"}',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{"id": "o2", "function": {"name": "other", "arguments": "{}"}},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"u1",
|
|
|
|
|
"openai",
|
|
|
|
|
)
|
|
|
|
|
assert openai_results == [
|
|
|
|
|
{
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": "o1",
|
|
|
|
|
"content": "ran:memory_search:u1:openai:{'query': 'pizza'}",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
handler._backend = None
|
|
|
|
|
skipped = await handler.handle_memory_tool_calls(
|
|
|
|
|
{
|
|
|
|
|
"choices": [
|
|
|
|
|
{
|
|
|
|
|
"message": {
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": "o3",
|
|
|
|
|
"function": {"name": "memory_delete", "arguments": "{}"},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"u1",
|
|
|
|
|
"openai",
|
|
|
|
|
)
|
|
|
|
|
assert skipped == []
|
|
|
|
|
|
|
|
|
|
|
fix(memory): audit passive context injection (#2212)
## Description
Close the passive-memory observability loop by recording access for
context rows that survive the final injection budget and tagging
requests where context is actually appended.
Closes #2211
## 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
- Track only memory IDs retained after ranking, similarity filtering,
entry limits, and final text truncation.
- Call optional backend `record_access` with stable de-duplication and
fail-open error handling.
- Extend structured injection logging to stamp `memory_injected=true`
when injected bytes are positive.
- Thread request tags through successful Anthropic, OpenAI Chat, OpenAI
Responses, Gemini, and Codex WebSocket injection sites.
- Add a static contract test that all current successful handler
injection logs pass tags.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \
tests/test_memory_handler_native_ops.py \
tests/test_memory_auto_tail.py \
tests/test_memory_handler_project_isolation.py \
tests/test_memory_injection_logging.py -q
51 passed
$ uv run --with ruff ruff check <touched Python files>
All checks passed!
$ git diff --check
(no output)
```
## Real Behavior Proof
- Environment: Python 3.13 with synthetic backend and handler fixtures
- Exact command / steps: run the focused test set above
- Observed result: only IDs present after the final text budget are
access-recorded; access-write failures remain fail-open; positive
injection logs stamp `memory_injected=true`; all six current successful
injection call sites pass tags
- Not tested: live provider requests, full repository suite, third-party
backends without `record_access`
## Review Readiness
- [x] I have performed a self-review
- [ ] 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
## Screenshots (if applicable)
N/A
## Additional Notes
Access accounting is intentionally best-effort: unsupported backends and
write failures do not delay or fail the upstream model request.
Documentation and changelog changes are not needed for this internal
observability fix.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-16 02:17:31 +08:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_records_only_memories_left_by_final_text_budget(
|
|
|
|
|
handler: MemoryHandler,
|
|
|
|
|
) -> None:
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
backend.search_results = [
|
|
|
|
|
make_result("m1", "First preference"),
|
|
|
|
|
make_result("m2", "Second preference"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
class FirstEntryBudget:
|
|
|
|
|
max_entries = 2
|
|
|
|
|
min_similarity = 0.3
|
|
|
|
|
max_tokens = 1024
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def apply_to_text(text: str) -> str:
|
|
|
|
|
return text.split("2. [m2]", maxsplit=1)[0]
|
|
|
|
|
|
|
|
|
|
context = await handler.search_and_format_context(
|
|
|
|
|
"u1",
|
|
|
|
|
[{"role": "user", "content": "What are my preferences?"}],
|
|
|
|
|
budget=FirstEntryBudget(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "[m1]" in context
|
|
|
|
|
assert "[m2]" not in context
|
|
|
|
|
assert backend.accessed == [["m1"]]
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 07:49:27 -05:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_ensure_initialized_timeout_and_cancellation(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(
|
|
|
|
|
enabled=True, use_native_tool=True, native_memory_dir=str(tmp_path / "native")
|
|
|
|
|
),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
closed: list[str] = []
|
|
|
|
|
|
|
|
|
|
class ClosableBackend:
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
closed.append("closed")
|
|
|
|
|
|
|
|
|
|
async def fake_init_backend_locked() -> None:
|
|
|
|
|
handler._backend = ClosableBackend()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_init_backend_locked", fake_init_backend_locked)
|
|
|
|
|
|
|
|
|
|
async def fake_wait_for_timeout(coro, timeout): # noqa: ANN001
|
|
|
|
|
await coro
|
|
|
|
|
raise asyncio.TimeoutError
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(memory_handler_module.asyncio, "wait_for", fake_wait_for_timeout)
|
|
|
|
|
await handler._ensure_initialized()
|
|
|
|
|
assert handler.backend is None
|
|
|
|
|
assert handler.initialized is False
|
|
|
|
|
assert closed == ["closed"]
|
|
|
|
|
|
|
|
|
|
async def fake_wait_for_cancel(coro, timeout): # noqa: ANN001
|
|
|
|
|
await coro
|
|
|
|
|
raise asyncio.CancelledError
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(memory_handler_module.asyncio, "wait_for", fake_wait_for_cancel)
|
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
|
|
|
await handler._ensure_initialized()
|
|
|
|
|
assert handler.backend is None
|
|
|
|
|
assert handler.initialized is False
|
|
|
|
|
assert closed == ["closed", "closed"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_init_backend_locked_local_and_bridge_import(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(
|
|
|
|
|
enabled=True,
|
|
|
|
|
backend="local",
|
|
|
|
|
db_path=str(tmp_path / "memory.db"),
|
|
|
|
|
bridge_enabled=True,
|
|
|
|
|
bridge_auto_import=True,
|
|
|
|
|
bridge_md_paths=[str(tmp_path / "notes.md")],
|
|
|
|
|
bridge_md_format="auto",
|
|
|
|
|
bridge_export_path=str(tmp_path / "export"),
|
|
|
|
|
),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
seen: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
class FakeLocalBackendConfig:
|
|
|
|
|
def __init__(self, **kwargs): # noqa: ANN003
|
|
|
|
|
seen["config"] = kwargs
|
|
|
|
|
for key, value in kwargs.items():
|
|
|
|
|
setattr(self, key, value)
|
|
|
|
|
|
|
|
|
|
class FakeLocalBackend:
|
|
|
|
|
def __init__(self, config) -> None: # noqa: ANN001
|
|
|
|
|
seen["backend_config"] = config
|
|
|
|
|
|
|
|
|
|
async def _ensure_initialized(self) -> None:
|
|
|
|
|
seen["backend_initialized"] = True
|
|
|
|
|
|
|
|
|
|
async def fake_init_and_import_bridge() -> None:
|
|
|
|
|
seen["bridge_called"] = True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.memory.backends.local",
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
LocalBackend=FakeLocalBackend,
|
|
|
|
|
LocalBackendConfig=FakeLocalBackendConfig,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setitem(sys.modules, "onnxruntime", SimpleNamespace())
|
|
|
|
|
monkeypatch.setattr(handler, "_init_and_import_bridge", fake_init_and_import_bridge)
|
|
|
|
|
|
|
|
|
|
await handler._init_backend_locked()
|
|
|
|
|
|
|
|
|
|
assert handler.initialized is True
|
|
|
|
|
assert seen["backend_initialized"] is True
|
|
|
|
|
assert seen["bridge_called"] is True
|
|
|
|
|
assert seen["config"] == {
|
|
|
|
|
"db_path": str(tmp_path / "memory.db"),
|
|
|
|
|
"embedder_backend": "onnx",
|
|
|
|
|
"embedder_model": "all-MiniLM-L6-v2",
|
|
|
|
|
"vector_dimension": 384,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_init_and_import_bridge_success_and_failure(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(
|
|
|
|
|
enabled=True,
|
|
|
|
|
bridge_enabled=True,
|
|
|
|
|
bridge_md_paths=[str(tmp_path / "notes.md")],
|
|
|
|
|
bridge_md_format="auto",
|
|
|
|
|
bridge_export_path=str(tmp_path / "export"),
|
|
|
|
|
),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
handler._backend = object()
|
|
|
|
|
seen: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
class FakeMarkdownFormat(str):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
class FakeBridgeConfig:
|
|
|
|
|
def __init__(self, **kwargs): # noqa: ANN003
|
|
|
|
|
seen["bridge_config"] = kwargs
|
|
|
|
|
|
|
|
|
|
class FakeMemoryBridge:
|
|
|
|
|
def __init__(self, config, backend) -> None: # noqa: ANN001
|
|
|
|
|
seen["bridge_backend"] = backend
|
|
|
|
|
self.config = config
|
|
|
|
|
|
|
|
|
|
async def import_from_markdown(self):
|
|
|
|
|
return SimpleNamespace(sections_imported=2, sections_skipped_duplicate=1)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.memory.bridge",
|
|
|
|
|
SimpleNamespace(MemoryBridge=FakeMemoryBridge),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.memory.bridge_config",
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
BridgeConfig=FakeBridgeConfig,
|
|
|
|
|
MarkdownFormat=FakeMarkdownFormat,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await handler._init_and_import_bridge()
|
|
|
|
|
assert isinstance(handler._bridge, FakeMemoryBridge)
|
|
|
|
|
assert seen["bridge_backend"] is handler._backend
|
|
|
|
|
assert seen["bridge_config"] == {
|
|
|
|
|
"md_paths": [tmp_path / "notes.md"],
|
|
|
|
|
"md_format": "auto",
|
|
|
|
|
"auto_import_on_startup": True,
|
|
|
|
|
"export_path": tmp_path / "export",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class BrokenMemoryBridge(FakeMemoryBridge):
|
|
|
|
|
async def import_from_markdown(self):
|
|
|
|
|
raise RuntimeError("bridge failed")
|
|
|
|
|
|
|
|
|
|
handler._bridge = None
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.memory.bridge",
|
|
|
|
|
SimpleNamespace(MemoryBridge=BrokenMemoryBridge),
|
|
|
|
|
)
|
|
|
|
|
await handler._init_and_import_bridge()
|
|
|
|
|
assert isinstance(handler._bridge, BrokenMemoryBridge)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_memory_handler_init_defaults_and_tool_injection_edges(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
native_dir = tmp_path / "workspace-memory"
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.paths.native_memory_dir",
|
|
|
|
|
lambda: native_dir,
|
|
|
|
|
)
|
|
|
|
|
handler = MemoryHandler(MemoryConfig(enabled=False, use_native_tool=True), agent_type="codex")
|
|
|
|
|
assert handler._native_memory_dir == native_dir
|
|
|
|
|
assert native_dir.exists()
|
|
|
|
|
|
|
|
|
|
disabled_injection = MemoryHandler(
|
|
|
|
|
MemoryConfig(enabled=False, inject_tools=False, use_native_tool=False),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
assert disabled_injection.inject_tools(None, "openai") == ([], False)
|
|
|
|
|
|
|
|
|
|
same_type_tools, same_type_injected = handler._inject_native_tool(
|
|
|
|
|
[{"type": "memory_20250818", "name": "other"}]
|
|
|
|
|
)
|
|
|
|
|
assert same_type_injected is False
|
|
|
|
|
assert same_type_tools == [{"type": "memory_20250818", "name": "other"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_ensure_initialized_fast_paths_and_qdrant_variants(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
disabled = MemoryHandler(MemoryConfig(enabled=False), agent_type="codex")
|
|
|
|
|
await disabled._ensure_initialized()
|
|
|
|
|
assert disabled.initialized is False
|
|
|
|
|
|
|
|
|
|
initialized = MemoryHandler(MemoryConfig(enabled=True), agent_type="codex")
|
|
|
|
|
initialized._initialized = True
|
|
|
|
|
await initialized._ensure_initialized()
|
|
|
|
|
assert initialized.initialized is True
|
|
|
|
|
|
|
|
|
|
qdrant_handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(enabled=True, backend="qdrant-neo4j"),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
seen: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
class FakeMem0Config:
|
|
|
|
|
def __init__(self, **kwargs): # noqa: ANN003
|
|
|
|
|
seen["config"] = kwargs
|
|
|
|
|
|
|
|
|
|
class FakeAdapter:
|
|
|
|
|
def __init__(self, config) -> None: # noqa: ANN001
|
|
|
|
|
seen["adapter_config"] = config
|
|
|
|
|
|
|
|
|
|
async def ensure_initialized(self) -> None:
|
|
|
|
|
seen["initialized"] = True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.memory.backends.direct_mem0",
|
|
|
|
|
SimpleNamespace(DirectMem0Adapter=FakeAdapter, Mem0Config=FakeMem0Config),
|
|
|
|
|
)
|
|
|
|
|
await qdrant_handler._init_backend_locked()
|
|
|
|
|
assert qdrant_handler.initialized is True
|
|
|
|
|
assert seen["initialized"] is True
|
|
|
|
|
assert seen["config"] == {
|
2026-04-25 14:56:55 -07:00
|
|
|
"qdrant_url": None,
|
2026-04-23 07:49:27 -05:00
|
|
|
"qdrant_host": "localhost",
|
|
|
|
|
"qdrant_port": 6333,
|
2026-04-25 14:56:55 -07:00
|
|
|
"qdrant_api_key": None,
|
2026-04-23 07:49:27 -05:00
|
|
|
"neo4j_uri": "neo4j://localhost:7687",
|
|
|
|
|
"neo4j_user": "neo4j",
|
|
|
|
|
"neo4j_password": "password",
|
|
|
|
|
"enable_graph": True,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "headroom.memory.backends.direct_mem0", None)
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
real_import = builtins.__import__
|
|
|
|
|
|
|
|
|
|
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
|
|
|
|
|
if name == "headroom.memory.backends.direct_mem0":
|
|
|
|
|
raise ImportError("missing mem0")
|
|
|
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
|
|
|
broken_qdrant = MemoryHandler(
|
|
|
|
|
MemoryConfig(enabled=True, backend="qdrant-neo4j"),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(ImportError, match="missing mem0"):
|
|
|
|
|
await broken_qdrant._init_backend_locked()
|
|
|
|
|
monkeypatch.setattr(builtins, "__import__", real_import)
|
|
|
|
|
|
|
|
|
|
unknown = MemoryHandler(MemoryConfig(enabled=True, backend="local"), agent_type="codex")
|
|
|
|
|
unknown.config.backend = "mystery" # type: ignore[assignment]
|
|
|
|
|
with pytest.raises(ValueError, match="Unknown memory backend"):
|
|
|
|
|
await unknown._init_backend_locked()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_init_and_import_bridge_early_return_and_context_formatting_edges(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = MemoryHandler(
|
|
|
|
|
MemoryConfig(
|
|
|
|
|
enabled=True, bridge_enabled=True, bridge_md_paths=[str(tmp_path / "notes.md")]
|
|
|
|
|
),
|
|
|
|
|
agent_type="codex",
|
|
|
|
|
)
|
|
|
|
|
handler._bridge = object()
|
|
|
|
|
await handler._init_and_import_bridge()
|
|
|
|
|
assert handler._bridge is not None
|
|
|
|
|
|
|
|
|
|
handler.config.inject_context = False
|
|
|
|
|
assert (
|
|
|
|
|
await handler.search_and_format_context("u1", [{"role": "user", "content": "hello"}])
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
handler.config.inject_context = True
|
|
|
|
|
handler._backend = FakeBackend()
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
handler._backend.search_results = [make_result("m1", "too low", score=0.1)]
|
|
|
|
|
assert (
|
|
|
|
|
await handler.search_and_format_context(
|
|
|
|
|
"u1", [{"role": "user", "content": [{"type": "image"}]}]
|
|
|
|
|
)
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await handler.search_and_format_context("u1", [{"role": "user", "content": "hello"}])
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_extract_tool_calls_and_handle_tool_calls_parse_edges(
|
|
|
|
|
handler: MemoryHandler, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
assert handler._extract_tool_calls({"content": "bad"}, "anthropic") == []
|
|
|
|
|
assert handler._extract_tool_calls({"choices": []}, "openai") == []
|
|
|
|
|
assert handler._extract_tool_calls({"output": "bad"}, "openai") == []
|
|
|
|
|
|
|
|
|
|
backend = FakeBackend()
|
|
|
|
|
handler._backend = backend
|
|
|
|
|
|
|
|
|
|
async def fake_ensure_initialized() -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
fix: per-project memory storage so projects can no longer bleed memories (GH #462)
Memory retrieval was partitioned only by `x-headroom-user-id`. Claude
Code never sets that header, so every project a user worked on landed
in one global `default` bucket; the proxy then injected semantically
similar memories from that mixed bucket into every `/v1/messages`
request, regardless of which repo the session was actually about. The
injected `## Relevant Memories` block reads like a prompt-injection
payload and Claude has been seen to refuse to act on it, defeating the
feature.
This change makes leakage structurally impossible by giving each
resolved workspace its own SQLite database file. The wrong DB is
simply not open during a request.
- `headroom/memory/storage_router.py` (new) — `MemoryStorageMode`
(project/user/global), `ProjectResolver` (x-headroom-project-id →
x-headroom-cwd → --memory-project-root CLI override → env-block
parse: `Primary working directory:` / `Working directory:` / `cwd:`,
no regex), and `BackendRouter` with an LRU of open `LocalBackend`s
keyed by db_path.
- `proxy/memory_handler.py` — `MemoryConfig.storage_mode` defaults to
`PROJECT`. Provider handlers build a `RequestContext` once and pass
it through; `search_and_format_context`, `handle_memory_tool_calls`,
and the `_execute_*` methods route save/search/update/delete on the
per-project backend. Qdrant-neo4j gets a composite
`user::project_key` partition so external Mem0-style deployments
also isolate per project without a parallel collection.
- Fix C — injected block carries provenance:
`## Relevant Memories (workspace: <basename>, scope: project)`.
CCR proactive-expansion block gets a matching workspace tag.
- `memory/factory.py` — process-wide embedder cache so opening N
project DBs doesn't load the embedder N times. OpenAI key
validation runs ahead of the cache.
- CLI — `--memory-storage={project,user,global}` (default `project`),
`--memory-project-root` override, rewritten `--memory` help text,
banner reports storage mode.
- Migration UX — if the legacy single-file DB has content while
project mode is active, an INFO log points users at
`--memory-storage=global`. Bridge currently only syncs the legacy
DB; a WARN fires when bridge + project mode are combined.
Backward-compatible: legacy `~/.headroom/memory.db` untouched and
reachable via `--memory-storage=global`. `request_context` is
keyword-only on entry points so existing tests/mocks keep working.
Tests: 24 new (resolver tiers, LRU eviction, two-cwd isolation,
user-mode partition, legacy fallback, provenance headers); full
suite 5260 passing, ci-precheck green.
2026-05-13 15:27:41 -07:00
|
|
|
async def fake_execute(
|
|
|
|
|
tool_name, input_data, user_id, provider="anthropic", *, request_context=None
|
|
|
|
|
): # noqa: ANN001
|
2026-04-23 07:49:27 -05:00
|
|
|
return f"ok:{tool_name}:{input_data}"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_ensure_initialized", fake_ensure_initialized)
|
|
|
|
|
monkeypatch.setattr(handler, "_execute_memory_tool", fake_execute)
|
|
|
|
|
|
|
|
|
|
results = await handler.handle_memory_tool_calls(
|
|
|
|
|
{
|
|
|
|
|
"output": [
|
|
|
|
|
{
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
"call_id": "fc1",
|
|
|
|
|
"name": "memory_search",
|
|
|
|
|
"arguments": "{bad",
|
|
|
|
|
},
|
|
|
|
|
{"type": "function_call", "call_id": "fc2", "name": "other", "arguments": "{}"},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"u1",
|
|
|
|
|
"openai",
|
|
|
|
|
)
|
|
|
|
|
assert results == [{"role": "tool", "tool_call_id": "fc1", "content": "ok:memory_search:{}"}]
|
2026-05-19 19:54:24 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── memory_list (browse without semantic query) ──────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_list_returns_recent_memories_with_ids(handler: MemoryHandler) -> None:
|
|
|
|
|
"""memory_list returns memories in reverse-chronological order with
|
|
|
|
|
IDs, content, and timestamps. Distinct from memory_search — no
|
|
|
|
|
semantic query required; model can browse to discover IDs."""
|
|
|
|
|
|
|
|
|
|
class ListBackend:
|
|
|
|
|
async def search_memories(self, **kwargs): # noqa: ANN003
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
async def list_memories(self, *, user_id, limit): # noqa: ANN001, ANN201
|
|
|
|
|
assert user_id == "u1"
|
|
|
|
|
assert limit == 5
|
|
|
|
|
return [
|
|
|
|
|
make_result("mem_002", "newest fact", created_at="2026-05-19T12:00:00+00:00"),
|
|
|
|
|
make_result("mem_001", "older fact", created_at="2026-05-18T10:00:00+00:00"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
handler._backend = ListBackend() # type: ignore[assignment]
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
out = await handler._execute_list({"limit": 5}, "u1")
|
|
|
|
|
payload = json.loads(out)
|
|
|
|
|
assert payload["status"] == "ok"
|
|
|
|
|
assert payload["count"] == 2
|
|
|
|
|
assert payload["memories"][0]["id"] == "mem_002"
|
|
|
|
|
assert payload["memories"][0]["content"] == "newest fact"
|
|
|
|
|
assert payload["memories"][0]["created_at"] == "2026-05-19T12:00:00+00:00"
|
|
|
|
|
assert payload["memories"][1]["id"] == "mem_001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_list_falls_back_to_search_when_list_unavailable(
|
|
|
|
|
handler: MemoryHandler,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Backends without list_memories fall back to an empty-query
|
|
|
|
|
search — most backends treat that as "return recent." Locks the
|
|
|
|
|
fallback path so a future backend without list_memories still works."""
|
|
|
|
|
|
|
|
|
|
class SearchOnlyBackend:
|
|
|
|
|
async def search_memories(self, *, query, user_id, top_k, **kwargs): # noqa: ANN001, ANN003
|
|
|
|
|
assert query == "" # fallback uses empty query
|
|
|
|
|
assert top_k == 3
|
|
|
|
|
return [make_result("mem_x", "anything", created_at=None)]
|
|
|
|
|
|
|
|
|
|
handler._backend = SearchOnlyBackend() # type: ignore[assignment]
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
out = await handler._execute_list({"limit": 3}, "u1")
|
|
|
|
|
payload = json.loads(out)
|
|
|
|
|
assert payload["status"] == "ok"
|
|
|
|
|
assert payload["count"] == 1
|
|
|
|
|
assert payload["memories"][0]["id"] == "mem_x"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_list_caps_limit_to_1_100(handler: MemoryHandler) -> None:
|
|
|
|
|
"""Defensive: input limit is clamped to [1, 100]. Protects the
|
|
|
|
|
backend from a runaway value the model might invent."""
|
|
|
|
|
|
|
|
|
|
received_limits: list[int] = []
|
|
|
|
|
|
|
|
|
|
class LimitWatcher:
|
|
|
|
|
async def search_memories(self, **kwargs): # noqa: ANN003
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
async def list_memories(self, *, user_id, limit): # noqa: ANN001, ANN201
|
|
|
|
|
received_limits.append(limit)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
handler._backend = LimitWatcher() # type: ignore[assignment]
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
|
|
|
|
|
await handler._execute_list({"limit": 99999}, "u1")
|
|
|
|
|
await handler._execute_list({"limit": 0}, "u1")
|
|
|
|
|
await handler._execute_list({"limit": "bogus"}, "u1") # type: ignore[dict-item]
|
|
|
|
|
assert received_limits == [100, 1, 10] # clamped to 100, 1, default 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_memory_list_dispatched_via_execute_memory_tool(handler: MemoryHandler) -> None:
|
|
|
|
|
"""End-to-end: memory_list in MEMORY_TOOL_NAMES means the
|
|
|
|
|
handler.handle_memory_tool_calls dispatcher routes it correctly."""
|
|
|
|
|
|
|
|
|
|
class StubBackend:
|
|
|
|
|
async def search_memories(self, **kwargs): # noqa: ANN003
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
async def list_memories(self, *, user_id, limit): # noqa: ANN001, ANN201
|
|
|
|
|
return [make_result("m1", "fact", created_at=None)]
|
|
|
|
|
|
|
|
|
|
handler._backend = StubBackend() # type: ignore[assignment]
|
|
|
|
|
handler._initialized = True
|
|
|
|
|
out = await handler._execute_memory_tool("memory_list", {"limit": 5}, "u1", "anthropic")
|
|
|
|
|
payload = json.loads(out)
|
|
|
|
|
assert payload["status"] == "ok"
|
|
|
|
|
assert payload["memories"][0]["id"] == "m1"
|