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:
Teknium
2026-08-24 00:42:56 -07:00
parent 497d6d5a66
commit 32fb12a235
2 changed files with 65 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)