diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index a9a1d57df3..7f9079ee27 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -367,6 +367,16 @@ RETAIN_SCHEMA = { "items": {"type": "string"}, "description": "Optional per-call tags to merge with configured default retain tags.", }, + "occurred_at": { + "type": "string", + "description": ( + "When the remembered event actually happened, as an ISO-8601 date " + "or datetime (e.g. '2026-08-20' or '2026-08-20T14:30:00+02:00'). " + "Pass this whenever the memory references a specific event time " + "('yesterday', 'last Tuesday', 'on March 3rd') so Hindsight can " + "anchor it on the timeline. Omit for timeless facts/preferences." + ), + }, }, "required": ["content"], }, @@ -2039,12 +2049,17 @@ class HindsightMemoryProvider(MemoryProvider): metadata: Dict[str, str] | None = None, tags: List[str] | None = None, retain_async: bool | None = None, + occurred_at: str | None = None, ) -> Dict[str, Any]: + # The item-level timestamp is what the Hindsight server uses to resolve + # occurred_start/occurred_end (including relative phrases in content). + # An explicit occurred_at (from the retain tool) wins; otherwise default + # to the configured event clock so relative times still resolve (#93568). kwargs: Dict[str, Any] = { "bank_id": self._bank_id, "content": content, "metadata": metadata or self._build_metadata(message_count=1, turn_index=self._turn_index), - "timestamp": _event_timestamp(), + "timestamp": occurred_at.strip() if occurred_at and occurred_at.strip() else _event_timestamp(), } if context is not None: kwargs["context"] = context @@ -2197,6 +2212,7 @@ class HindsightMemoryProvider(MemoryProvider): content, context=context, tags=args.get("tags"), + occurred_at=args.get("occurred_at"), ) # aretain_batch takes bank_id/retain_async as call args, not item keys. item.pop("bank_id", None) diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index e0e79bb26b..475e3adb37 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -456,6 +456,50 @@ class TestToolHandlers: assert "bank_id" not in item assert "retain_async" not in item + def test_retain_defaults_item_timestamp_when_no_occurred_at(self, provider, monkeypatch): + event_time = datetime(2026, 8, 24, 9, 30, tzinfo=ZoneInfo("America/Los_Angeles")) + monkeypatch.setattr("plugins.memory.hindsight._hermes_now", lambda: event_time) + result = json.loads(provider.handle_tool_call( + "hindsight_retain", {"content": "user likes dark mode"} + )) + assert result["result"] == "Memory stored successfully." + item = provider._client.aretain_batch.call_args.kwargs["items"][0] + # Non-temporal retains still carry a defaulted event timestamp so the + # server can resolve any relative time phrases (#93568). + assert item["timestamp"] == event_time.isoformat(timespec="seconds") + + def test_retain_threads_explicit_occurred_at_into_item_timestamp(self, provider): + result = json.loads(provider.handle_tool_call( + "hindsight_retain", + {"content": "user visited Paris", "occurred_at": "2026-03-03"}, + )) + assert result["result"] == "Memory stored successfully." + item = provider._client.aretain_batch.call_args.kwargs["items"][0] + assert item["timestamp"] == "2026-03-03" + + def test_retain_ignores_blank_occurred_at(self, provider, monkeypatch): + event_time = datetime(2026, 8, 24, 9, 30, tzinfo=ZoneInfo("America/Los_Angeles")) + monkeypatch.setattr("plugins.memory.hindsight._hermes_now", lambda: event_time) + json.loads(provider.handle_tool_call( + "hindsight_retain", {"content": "hello", "occurred_at": " "} + )) + item = provider._client.aretain_batch.call_args.kwargs["items"][0] + assert item["timestamp"] == event_time.isoformat(timespec="seconds") + + def test_build_retain_kwargs_accepts_explicit_occurred_at(self, provider): + item = provider._build_retain_kwargs("dinner with Sam", occurred_at="2026-08-20T19:00:00+02:00") + assert item["timestamp"] == "2026-08-20T19:00:00+02:00" + + def test_retain_schema_exposes_occurred_at(self): + from plugins.memory.hindsight import RETAIN_SCHEMA + + props = RETAIN_SCHEMA["parameters"]["properties"] + assert "occurred_at" in props + assert props["occurred_at"]["type"] == "string" + # The description must steer the model to pass event times. + assert "event" in props["occurred_at"]["description"].lower() + assert "occurred_at" not in RETAIN_SCHEMA["parameters"]["required"] + def test_recall_success(self, provider): result = json.loads(provider.handle_tool_call( @@ -915,7 +959,10 @@ class TestSyncTurn: @pytest.mark.asyncio async def test_retain_timestamp_is_serialized_by_pinned_client(self, provider): - from hindsight_client import Hindsight + hindsight_client = pytest.importorskip( + "hindsight_client", reason="pinned hindsight-client SDK not installed" + ) + Hindsight = hindsight_client.Hindsight item = provider._build_retain_kwargs("hello") item.pop("bank_id", None)