fix(hindsight): let hindsight_retain convey event time via occurred_at
Adds an optional occurred_at (ISO-8601 date/datetime) parameter to the hindsight_retain tool schema, threaded into the retain item's timestamp field. When absent, the item timestamp defaults to the configured event clock (base from PR #82928 by @ragingbulld, authorship preserved) so the Hindsight server can resolve relative time phrases; previously no item timestamp was ever sent and temporal memories landed with null occurred_start/occurred_end. Fixes #93568. Salvages #82928.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user