mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(compression): temporal anchoring in compaction summaries (#41102)
Compaction summaries now receive the current date and instruct the summarizer to rewrite completed actions as absolute, dated, past-tense facts (e.g. "email John about the proposal" -> "Sent the proposal email to John on 2026-06-07"). A resumed conversation no longer re-issues work that already happened or treats a finished action as still pending. The date is resolved via hermes_time.now() (date-only, user-configured timezone) inside _generate_summary. The compaction summary is a mid-conversation message that is never part of the cached prefix, so the date does not affect prompt-cache stability. Date resolution is best-effort: a clock failure omits the rule rather than blocking compaction. The rule rides the shared template, so both first-compaction and iterative-update prompts carry it. Inspired by Poke's summarization (temporal anchoring + semantic preservation).
This commit is contained in:
parent
9dbad1990b
commit
d87f293972
2 changed files with 146 additions and 1 deletions
|
|
@ -1247,6 +1247,19 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
|||
summary_budget = self._compute_summary_budget(turns_to_summarize)
|
||||
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
|
||||
|
||||
# Current date for temporal anchoring (see ## Temporal Anchoring below).
|
||||
# Date-only granularity matches system_prompt.py:337 (PR #20451) and the
|
||||
# user's configured timezone via hermes_time.now(). The compaction summary
|
||||
# is a mid-conversation message that is NOT part of the cached prefix, so a
|
||||
# date here never affects prompt-cache stability. Resolved defensively —
|
||||
# a clock failure must never block compaction.
|
||||
try:
|
||||
from hermes_time import now as _hermes_now
|
||||
|
||||
_today_str = _hermes_now().strftime("%Y-%m-%d")
|
||||
except Exception: # pragma: no cover - clock resolution is best-effort
|
||||
_today_str = ""
|
||||
|
||||
# Preamble shared by both first-compaction and iterative-update prompts.
|
||||
# Keep the wording deliberately plain: Azure/OpenAI-compatible content
|
||||
# filters have flagged stronger "injection" / "do not respond" framing.
|
||||
|
|
@ -1264,6 +1277,24 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
|||
"do not preserve their values."
|
||||
)
|
||||
|
||||
# Temporal anchoring directive. Rewrites relative / still-pending-sounding
|
||||
# references into absolute, dated, past-tense facts so a resumed
|
||||
# conversation does not re-issue completed actions. Only emitted when the
|
||||
# current date resolved successfully; otherwise the rule is omitted so the
|
||||
# summarizer is never handed an empty date placeholder.
|
||||
if _today_str:
|
||||
_temporal_anchoring_rule = (
|
||||
f"\nTEMPORAL ANCHORING: The current date is {_today_str}. When an "
|
||||
"action has already been carried out, phrase it as a completed, "
|
||||
"dated, past-tense fact rather than an open instruction. For "
|
||||
'example, rewrite "email John about the proposal" as "Sent the '
|
||||
f'proposal email to John on {_today_str}." Never leave a finished '
|
||||
"action worded as if it still needs doing, and never invent a date "
|
||||
"for work that has not happened yet.\n"
|
||||
)
|
||||
else:
|
||||
_temporal_anchoring_rule = ""
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
|
|
@ -1337,7 +1368,7 @@ Be specific with file paths, commands, line numbers, and results.]
|
|||
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
|
||||
|
||||
Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed.
|
||||
|
||||
{_temporal_anchoring_rule}
|
||||
Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
if self._previous_summary:
|
||||
|
|
|
|||
114
tests/agent/test_context_compressor_temporal_anchoring.py
Normal file
114
tests/agent/test_context_compressor_temporal_anchoring.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for temporal anchoring in context-compaction summaries.
|
||||
|
||||
The summarizer is handed the current date and instructed to rewrite completed
|
||||
actions as absolute, dated, past-tense facts (e.g. "email John" ->
|
||||
"Sent the proposal email to John on 2026-06-07"). This keeps a resumed
|
||||
conversation from re-issuing work that already happened. Date resolution is
|
||||
best-effort: a clock failure must omit the rule, never block compaction.
|
||||
|
||||
These exercise ``_generate_summary`` directly -- the function that builds the
|
||||
summarizer prompt. ``test_context_compressor_summary_continuity`` already
|
||||
proves ``compress()`` routes into ``_generate_summary``.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import hermes_time
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
|
||||
def _compressor() -> ContextCompressor:
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
return ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.85,
|
||||
protect_first_n=1,
|
||||
protect_last_n=1,
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
|
||||
def _response(content: str):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = content
|
||||
return mock_response
|
||||
|
||||
|
||||
def _turns():
|
||||
return [
|
||||
{"role": "user", "content": "do the first thing"},
|
||||
{"role": "assistant", "content": "did the first thing"},
|
||||
{"role": "user", "content": "do the second thing"},
|
||||
{"role": "assistant", "content": "did the second thing"},
|
||||
]
|
||||
|
||||
|
||||
def _fixed_now():
|
||||
return datetime(2026, 6, 7, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_first_compaction_prompt_contains_dated_anchoring_rule():
|
||||
compressor = _compressor()
|
||||
assert compressor._previous_summary is None
|
||||
with patch.object(hermes_time, "now", _fixed_now), patch(
|
||||
"agent.context_compressor.call_llm", return_value=_response("summary")
|
||||
) as mock_call:
|
||||
compressor._generate_summary(_turns())
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "TEMPORAL ANCHORING" in prompt
|
||||
assert "2026-06-07" in prompt
|
||||
# The worked example must carry the resolved date, proving interpolation.
|
||||
assert "Sent the proposal email to John on 2026-06-07" in prompt
|
||||
# First-compaction path marker still present.
|
||||
assert "TURNS TO SUMMARIZE:" in prompt
|
||||
|
||||
|
||||
def test_iterative_update_prompt_also_contains_anchoring_rule():
|
||||
compressor = _compressor()
|
||||
compressor._previous_summary = "OLD summary body with continuity facts"
|
||||
|
||||
with patch.object(hermes_time, "now", _fixed_now), patch(
|
||||
"agent.context_compressor.call_llm", return_value=_response("updated summary")
|
||||
) as mock_call:
|
||||
compressor._generate_summary(_turns())
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "TEMPORAL ANCHORING" in prompt
|
||||
assert "2026-06-07" in prompt
|
||||
|
||||
|
||||
def test_clock_failure_omits_rule_but_compaction_still_runs():
|
||||
compressor = _compressor()
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("clock unavailable")
|
||||
|
||||
with patch.object(hermes_time, "now", _boom), patch(
|
||||
"agent.context_compressor.call_llm", return_value=_response("summary")
|
||||
) as mock_call:
|
||||
result = compressor._generate_summary(_turns())
|
||||
|
||||
# call_llm was still invoked -> compaction was not blocked by the clock error.
|
||||
assert mock_call.called
|
||||
assert result is not None
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "TEMPORAL ANCHORING" not in prompt
|
||||
# Structured template still intact.
|
||||
assert "## Active Task" in prompt
|
||||
|
||||
|
||||
def test_anchoring_rule_uses_date_from_hermes_time_now():
|
||||
"""The date is taken from hermes_time.now(), which respects the user's TZ."""
|
||||
compressor = _compressor()
|
||||
fixed = datetime(2025, 12, 31, 23, 30, tzinfo=timezone.utc)
|
||||
with patch.object(hermes_time, "now", lambda: fixed), patch(
|
||||
"agent.context_compressor.call_llm", return_value=_response("summary")
|
||||
) as mock_call:
|
||||
compressor._generate_summary(_turns())
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "2025-12-31" in prompt
|
||||
Loading…
Add table
Add a link
Reference in a new issue