diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 079c4b0b560..71c7944c772 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -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: diff --git a/tests/agent/test_context_compressor_temporal_anchoring.py b/tests/agent/test_context_compressor_temporal_anchoring.py new file mode 100644 index 00000000000..973bf12909f --- /dev/null +++ b/tests/agent/test_context_compressor_temporal_anchoring.py @@ -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