diff --git a/benchmarks/runner/memory_eval.py b/benchmarks/runner/memory_eval.py index 2c16ca9..5ef7196 100644 --- a/benchmarks/runner/memory_eval.py +++ b/benchmarks/runner/memory_eval.py @@ -5,6 +5,7 @@ import argparse import json import statistics +import sys import tempfile from collections import defaultdict from dataclasses import dataclass @@ -261,7 +262,7 @@ def main() -> None: print(f"New failures: {current_failures - baseline_failures}") print(f"Fixed failures: {baseline_failures - current_failures}") if arguments.fail: - exit(1) + sys.exit(1) else: print("Benchmark failures match baseline") print(output) diff --git a/services/dma-api/src/dma_api/repository.py b/services/dma-api/src/dma_api/repository.py index 48c27d1..963c676 100644 --- a/services/dma-api/src/dma_api/repository.py +++ b/services/dma-api/src/dma_api/repository.py @@ -9,7 +9,7 @@ from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from dma_api.models import MemoryType @@ -170,7 +170,7 @@ def create_or_get(self, record: MemoryRecord, idempotency_key: str) -> tuple[Mem ) connection.execute( """UPDATE memories SET content = ?, version = ?, updated_at = ?, expires_at = ?, metadata_json = ? WHERE id = ?""", - (updated.content, updated.version, updated.updated_at.isoformat(), updated.expires_at.isoformat() if updated.expires_at else None, json.dumps(updated.metadata, separators=(",", ":"), sort_keys=True), updated.id), + (updated.content, updated.version, self._utc_isoformat(updated.updated_at), self._utc_isoformat(updated.expires_at) if updated.expires_at else None, json.dumps(updated.metadata, separators=(",", ":"), sort_keys=True), updated.id), ) connection.execute("DELETE FROM memory_search WHERE memory_id = ?", (updated.id,)) connection.execute("INSERT INTO memory_search (content, memory_id, tenant_id, agent_id, type) VALUES (?, ?, ?, ?, ?)", (updated.content, updated.id, updated.tenant_id, updated.agent_id, updated.type.value)) @@ -191,9 +191,9 @@ def create_or_get(self, record: MemoryRecord, idempotency_key: str) -> tuple[Mem record.content, record.type.value, record.version, - record.created_at.isoformat(), - record.updated_at.isoformat(), - record.expires_at.isoformat() if record.expires_at else None, + self._utc_isoformat(record.created_at), + self._utc_isoformat(record.updated_at), + self._utc_isoformat(record.expires_at) if record.expires_at else None, json.dumps(record.metadata, separators=(",", ":"), sort_keys=True), ), ) @@ -243,7 +243,7 @@ def recall( "m.agent_id = ?", "(m.expires_at IS NULL OR m.expires_at > ?)", ] - parameters: list[object] = [search_query, tenant_id, agent_id, now.isoformat()] + parameters: list[object] = [search_query, tenant_id, agent_id, self._utc_isoformat(now)] if types: placeholders = ", ".join("?" for _ in types) where.append(f"m.type IN ({placeholders})") @@ -481,9 +481,20 @@ def _has_current_marker(cls, content: str) -> bool: def _normalise_semantic(content: str) -> str: return " ".join(content.split()).casefold() + @staticmethod + def _utc_isoformat(value: datetime) -> str: + """Serialise a datetime so lexical comparison matches chronological order. + + Timestamps are stored with a fixed +00:00 offset because recall filters + expiry inside SQL using string comparison; mixed offsets would break it. + """ + if value.tzinfo is not None: + value = value.astimezone(UTC) + return value.isoformat() + @staticmethod def _encode_cursor(record: MemoryRecord) -> str: - raw = f"{record.created_at.isoformat()}|{record.id}".encode() + raw = f"{SQLiteMemoryRepository._utc_isoformat(record.created_at)}|{record.id}".encode() return urlsafe_b64encode(raw).decode().rstrip("=") @staticmethod diff --git a/services/dma-api/tests/test_recall.py b/services/dma-api/tests/test_recall.py index f01e4d7..87744f4 100644 --- a/services/dma-api/tests/test_recall.py +++ b/services/dma-api/tests/test_recall.py @@ -1,11 +1,13 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, timezone from fastapi.testclient import TestClient from dma_api.config import Settings from dma_api.main import create_app +from dma_api.models import MemoryType +from dma_api.repository import MemoryRecord, SQLiteMemoryRepository def _headers(key: str) -> dict[str, str]: @@ -61,33 +63,36 @@ def test_recall_excludes_expired_and_non_matching_types(tmp_path) -> None: assert [item["type"] for item in response.json()["results"]] == ["procedural"] -def test_recall_falls_back_when_current_marker_filter_empties_results(tmp_path) -> None: - app = create_app(Settings(database_path=tmp_path / "dma.db", api_key="test-key", tenant_id="tenant-a")) - with TestClient(app) as client: - _remember(client, "The database password is hunter2.", "semantic", "key-000000000005") - response = client.post( - "/v1/memories/recall", - headers={"Authorization": "Bearer test-key"}, - json={"agent_id": "coding-agent", "query": "What is the latest database password?", "limit": 5}, - ) - - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) == 1 - assert results[0]["content"] == "The database password is hunter2." - +def test_recall_includes_memories_expiring_after_now_regardless_of_offset(tmp_path) -> None: + """A non-UTC expiry must not be treated as expired by lexical comparison.""" + repository = SQLiteMemoryRepository(tmp_path / "dma.db") + repository.initialize() + now = datetime(2026, 8, 27, 20, 0, 0, tzinfo=UTC) + expires_soon = datetime(2026, 8, 27, 10, 0, 0, tzinfo=timezone(timedelta(hours=-11))) + assert expires_soon.astimezone(UTC) > now + repository.create_or_get( + MemoryRecord( + id="mem_offsetexpiry00000000000001", + tenant_id="tenant-a", + agent_id="coding-agent", + content="Staging cluster deployment notes.", + type=MemoryType.EPISODIC, + version=1, + created_at=now, + updated_at=now, + expires_at=expires_soon, + metadata={}, + ), + "idempotency-offset-expiry-0001", + ) -def test_recall_prefers_current_state_memories_for_now_queries(tmp_path) -> None: - app = create_app(Settings(database_path=tmp_path / "dma.db", api_key="test-key", tenant_id="tenant-a")) - with TestClient(app) as client: - _remember(client, "User preferred Django for backend APIs.", "semantic", "key-000000000006") - _remember(client, "User now prefers Java Spring Boot for backend APIs.", "semantic", "key-000000000007") - response = client.post( - "/v1/memories/recall", - headers={"Authorization": "Bearer test-key"}, - json={"agent_id": "coding-agent", "query": "What backend framework does the user prefer now?", "limit": 5}, - ) + matches = repository.recall( + tenant_id="tenant-a", + agent_id="coding-agent", + query="staging cluster deployment", + types=None, + limit=5, + now=now, + ) - assert response.status_code == 200 - contents = [item["content"] for item in response.json()["results"]] - assert contents == ["User now prefers Java Spring Boot for backend APIs."] + assert [record.id for record, _ in matches] == ["mem_offsetexpiry00000000000001"]