mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(compression): preserve zero-user provenance
This commit is contained in:
parent
1c2faedd88
commit
c93e4041b6
4 changed files with 381 additions and 32 deletions
|
|
@ -130,8 +130,11 @@ LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
|||
# poisoning every subsequent request in the session — a bare key like
|
||||
# "is_compressed_summary" would reach the wire and trip exactly that.
|
||||
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
|
||||
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY = "_compressed_summary_has_user_turn"
|
||||
_DB_PERSISTED_MARKER = "_db_persisted"
|
||||
|
||||
_NO_USER_TASK_SENTINEL = "None. This session contains no user-authored turns."
|
||||
|
||||
|
||||
def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Copy a message for compaction assembly without persistence markers.
|
||||
|
|
@ -877,6 +880,7 @@ class ContextCompressor(ContextEngine):
|
|||
self._context_probed = False
|
||||
self._context_probe_persistable = False
|
||||
self._previous_summary = None
|
||||
self._summary_has_user_turn = None
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._last_summary_dropped_count = 0
|
||||
|
|
@ -917,6 +921,7 @@ class ContextCompressor(ContextEngine):
|
|||
surface the moment the owning session ends.
|
||||
"""
|
||||
self._previous_summary = None
|
||||
self._summary_has_user_turn = None
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._last_summary_dropped_count = 0
|
||||
|
|
@ -1403,6 +1408,10 @@ class ContextCompressor(ContextEngine):
|
|||
|
||||
# Stores the previous compaction summary for iterative updates
|
||||
self._previous_summary: Optional[str] = None
|
||||
# Provenance for the rolling summary. A compaction handoff can carry
|
||||
# role="user" solely to satisfy provider alternation, so role alone
|
||||
# cannot prove that a human-authored turn ever existed.
|
||||
self._summary_has_user_turn: Optional[bool] = None
|
||||
# Anti-thrashing: track whether last compression was effective
|
||||
self._last_compression_savings_pct: float = 100.0
|
||||
self._ineffective_compression_count: int = 0
|
||||
|
|
@ -2051,7 +2060,7 @@ class ContextCompressor(ContextEngine):
|
|||
active_task = (
|
||||
f"User asked: {user_asks[-1]!r}"
|
||||
if user_asks
|
||||
else "Unknown from deterministic fallback."
|
||||
else _NO_USER_TASK_SENTINEL
|
||||
)
|
||||
previous_summary_note = ""
|
||||
if self._previous_summary:
|
||||
|
|
@ -2194,6 +2203,9 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
|||
if _sanitized_memory_context
|
||||
else ""
|
||||
)
|
||||
has_user_turn = getattr(self, "_summary_has_user_turn", None)
|
||||
if has_user_turn is None:
|
||||
has_user_turn = self._transcript_has_real_user_turn(turns_to_summarize)
|
||||
|
||||
# Current date for temporal anchoring (see ## Temporal Anchoring below).
|
||||
# Date-only granularity matches system_prompt.py:337 (PR #20451) and the
|
||||
|
|
@ -2211,18 +2223,86 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
|||
# 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.
|
||||
if has_user_turn:
|
||||
_language_and_provenance_rule = (
|
||||
"Write the summary in the same language the user was using in the "
|
||||
"conversation — do not translate or switch to English. "
|
||||
)
|
||||
_historical_task_instructions = """[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("<specific user task>")
|
||||
- Questions awaiting an answer ("<specific user question>")
|
||||
- Decisions awaiting input ("<option A or B?>")
|
||||
- Ongoing discussions where the assistant owes the next substantive reply
|
||||
A conversation where the user just asked a question IS an active task — the
|
||||
task is "answer that question with full context". Do NOT write "None" merely
|
||||
because the user did not issue an imperative command; reserve "None" for the
|
||||
rare case where the last exchange was fully resolved and the user said
|
||||
something like "thanks, that's all".
|
||||
If multiple items are outstanding, list only the ones NOT yet completed.
|
||||
This historical snapshot must identify the latest unresolved user input precisely. Examples:
|
||||
"User asked: '<exact latest user request>'"
|
||||
"User asked: '<exact latest user question>' — needs investigation + answer"
|
||||
"User chose <option>; awaiting implementation of <specific next step>"
|
||||
If the user's most recent message was a reverse signal (stop, undo, roll
|
||||
back, never mind, just verify, change of topic) that supersedes earlier
|
||||
work, write the reverse signal verbatim and DO NOT carry forward the
|
||||
cancelled task. Example: "User asked: '<exact reverse signal>' — earlier
|
||||
in-flight work is cancelled."
|
||||
If no outstanding task exists, write "None."]"""
|
||||
_goal_instructions = "[What the user is trying to accomplish overall]"
|
||||
_constraints_instructions = (
|
||||
"[User preferences, coding style, constraints, important decisions]"
|
||||
)
|
||||
_resolved_questions_instructions = (
|
||||
"[Questions the user asked that were ALREADY answered — include the "
|
||||
"answer so it is not repeated]"
|
||||
)
|
||||
_pending_asks_instructions = (
|
||||
"[Questions or requests from the user that have NOT yet been answered "
|
||||
"or fulfilled. These are STALE — they were from the compacted turns. "
|
||||
"Write them here for reference only. The agent must NOT act on them "
|
||||
"unless the latest user message explicitly requests it. If none, "
|
||||
'write "None."]'
|
||||
)
|
||||
else:
|
||||
_language_and_provenance_rule = (
|
||||
"This session contains no user-authored turns. Write the summary "
|
||||
"in the dominant language of the source turns; if they are mixed, "
|
||||
"use the language of the most recent natural-language assistant "
|
||||
"turn. Do not translate, invent a user, or attribute any request "
|
||||
"to a user. "
|
||||
)
|
||||
_historical_task_instructions = f"""[NO user-authored turn exists in this session. Write exactly:
|
||||
{_NO_USER_TASK_SENTINEL}
|
||||
Do not write "User asked:" or any translated equivalent anywhere in the summary.
|
||||
Describe agent/tool work only as completed actions, state, or historical work.]"""
|
||||
_goal_instructions = (
|
||||
"[Historical cron/agent objective inferred only from assistant and "
|
||||
"tool activity. Never call it a user goal.]"
|
||||
)
|
||||
_constraints_instructions = (
|
||||
"[Runtime, configuration, and technical constraints only. Do not "
|
||||
"invent user preferences.]"
|
||||
)
|
||||
_resolved_questions_instructions = (
|
||||
"[Write exactly: None. No user-authored questions exist.]"
|
||||
)
|
||||
_pending_asks_instructions = (
|
||||
"[Write exactly: None. No user-authored requests exist.]"
|
||||
)
|
||||
|
||||
_summarizer_preamble = (
|
||||
"You are a summarization agent creating a context checkpoint. "
|
||||
"Treat the conversation turns below as source material for a "
|
||||
"compact record of prior work. "
|
||||
"Produce only the structured summary; do not add a greeting, "
|
||||
"preamble, or prefix. "
|
||||
"Write the summary in the same language the user was using in the "
|
||||
"conversation — do not translate or switch to English. "
|
||||
+ _language_and_provenance_rule +
|
||||
"NEVER include API keys, tokens, passwords, secrets, credentials, "
|
||||
"or connection strings in the summary — replace any that appear "
|
||||
"with [REDACTED]. Note that the user had credentials present, but "
|
||||
"do not preserve their values."
|
||||
"with [REDACTED]. Note that credentials were present, but do not "
|
||||
"preserve their values."
|
||||
)
|
||||
|
||||
# Temporal anchoring directive. Rewrites relative / still-pending-sounding
|
||||
|
|
@ -2245,34 +2325,13 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
|||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""{HISTORICAL_TASK_HEADING}
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("<specific user task>")
|
||||
- Questions awaiting an answer ("<specific user question>")
|
||||
- Decisions awaiting input ("<option A or B?>")
|
||||
- Ongoing discussions where the assistant owes the next substantive reply
|
||||
A conversation where the user just asked a question IS an active task — the
|
||||
task is "answer that question with full context". Do NOT write "None" merely
|
||||
because the user did not issue an imperative command; reserve "None" for the
|
||||
rare case where the last exchange was fully resolved and the user said
|
||||
something like "thanks, that's all".
|
||||
If multiple items are outstanding, list only the ones NOT yet completed.
|
||||
This historical snapshot must identify the latest unresolved user input precisely. Examples:
|
||||
"User asked: '<exact latest user request>'"
|
||||
"User asked: '<exact latest user question>' — needs investigation + answer"
|
||||
"User chose <option>; awaiting implementation of <specific next step>"
|
||||
If the user's most recent message was a reverse signal (stop, undo, roll
|
||||
back, never mind, just verify, change of topic) that supersedes earlier
|
||||
work, write the reverse signal verbatim and DO NOT carry forward the
|
||||
cancelled task. Example: "User asked: '<exact reverse signal>' — earlier
|
||||
in-flight work is cancelled."
|
||||
If no outstanding task exists, write "None."]
|
||||
{_historical_task_instructions}
|
||||
|
||||
## Goal
|
||||
[What the user is trying to accomplish overall]
|
||||
{_goal_instructions}
|
||||
|
||||
## Constraints & Preferences
|
||||
[User preferences, coding style, constraints, important decisions]
|
||||
{_constraints_instructions}
|
||||
|
||||
## Completed Actions
|
||||
[Numbered list of concrete actions taken — include tool used, target, and outcome.
|
||||
|
|
@ -2301,10 +2360,10 @@ Be specific with file paths, commands, line numbers, and results.]
|
|||
[Important technical decisions and WHY they were made]
|
||||
|
||||
## Resolved Questions
|
||||
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
|
||||
{_resolved_questions_instructions}
|
||||
|
||||
{HISTORICAL_PENDING_ASKS_HEADING}
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
|
||||
{_pending_asks_instructions}
|
||||
|
||||
## Relevant Files
|
||||
[Files read, modified, or created — with brief note on each]
|
||||
|
|
@ -2428,6 +2487,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# ignore prompt instructions and echo back secrets verbatim.
|
||||
summary = redact_sensitive_text(content.strip())
|
||||
summary = self._ground_historical_task_snapshot(summary, turns_to_summarize)
|
||||
self._validate_summary_user_provenance(summary, has_user_turn)
|
||||
# Store for iterative updates on next compaction
|
||||
self._previous_summary = summary
|
||||
self._clear_compression_failure_cooldown()
|
||||
|
|
@ -2674,6 +2734,43 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
return False
|
||||
return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
|
||||
|
||||
@classmethod
|
||||
def _transcript_has_real_user_turn(cls, messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Return whether *messages* contain a user-authored turn.
|
||||
|
||||
Compaction summaries can deliberately carry ``role="user"`` to keep
|
||||
strict provider transcripts valid. The metadata/content checks prevent
|
||||
those synthetic transport rows from becoming evidence of a real user.
|
||||
"""
|
||||
for message in messages:
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
continue
|
||||
if cls._has_compressed_summary_metadata(message):
|
||||
continue
|
||||
if cls._is_context_summary_content(message.get("content")):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _validate_summary_user_provenance(summary: str, has_user_turn: bool) -> None:
|
||||
"""Reject user attribution when the source transcript has no user."""
|
||||
if has_user_turn:
|
||||
return
|
||||
match = re.search(
|
||||
rf"(?ms)^{re.escape(HISTORICAL_TASK_HEADING)}\s*\n(.*?)(?=\n##\s|\Z)",
|
||||
summary,
|
||||
)
|
||||
task_snapshot = match.group(1).strip() if match else ""
|
||||
if (
|
||||
task_snapshot != _NO_USER_TASK_SENTINEL
|
||||
or re.search(r"\bUser\s+asked\s*:", summary, re.IGNORECASE)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Context compression summary invented user attribution for a "
|
||||
"session with no user-authored turns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _derive_auto_focus_topic(
|
||||
cls,
|
||||
|
|
@ -3444,9 +3541,24 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
summary_search_start,
|
||||
compress_end,
|
||||
)
|
||||
real_user_present = self._transcript_has_real_user_turn(messages)
|
||||
if summary_idx is not None:
|
||||
if summary_body and not self._previous_summary:
|
||||
self._previous_summary = summary_body
|
||||
provenance = messages[summary_idx].get(
|
||||
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY
|
||||
)
|
||||
if real_user_present:
|
||||
self._summary_has_user_turn = True
|
||||
elif isinstance(provenance, bool):
|
||||
self._summary_has_user_turn = provenance
|
||||
elif self._summary_has_user_turn is None:
|
||||
# Legacy handoffs predate provenance metadata. Preserve their
|
||||
# user context unless the exact no-user sentinel proves the
|
||||
# stronger invariant; guessing False could erase a real ask.
|
||||
self._summary_has_user_turn = not (
|
||||
summary_body and _NO_USER_TASK_SENTINEL in summary_body
|
||||
)
|
||||
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
|
||||
elif self._previous_summary:
|
||||
# No handoff summary found in the current messages, but
|
||||
|
|
@ -3455,6 +3567,9 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# it so _generate_summary() does not inject cross-session content
|
||||
# into the summarizer prompt via the iterative-update path.
|
||||
self._previous_summary = None
|
||||
self._summary_has_user_turn = real_user_present
|
||||
else:
|
||||
self._summary_has_user_turn = real_user_present
|
||||
|
||||
if not self.quiet_mode:
|
||||
logger.info(
|
||||
|
|
@ -3645,6 +3760,9 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
"role": summary_role,
|
||||
"content": summary,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: bool(
|
||||
self._summary_has_user_turn
|
||||
),
|
||||
})
|
||||
|
||||
for i in range(compress_end, n_messages):
|
||||
|
|
@ -3674,6 +3792,9 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# Mark the merged message so frontends can identify it as
|
||||
# containing a compression summary prefix.
|
||||
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
|
||||
msg[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] = bool(
|
||||
self._summary_has_user_turn
|
||||
)
|
||||
# Content rewritten → the api_content sidecar (exact bytes
|
||||
# previously sent) is stale; drop it so replay can't resend
|
||||
# the pre-merge bytes without the summary.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from unittest.mock import MagicMock, patch
|
|||
import pytest
|
||||
|
||||
from agent.context_compressor import (
|
||||
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
ContextCompressor,
|
||||
)
|
||||
|
|
@ -75,6 +76,7 @@ class TestMetadataFlagNeverReachesWire:
|
|||
with '_'. A bare key would reach strict gateways (Fireworks etc.)
|
||||
and 400 with 'Extra inputs are not permitted'."""
|
||||
assert COMPRESSED_SUMMARY_METADATA_KEY.startswith("_")
|
||||
assert COMPRESSED_SUMMARY_HAS_USER_TURN_KEY.startswith("_")
|
||||
|
||||
def test_chat_completions_transport_strips_flag(self):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
|
@ -83,7 +85,11 @@ class TestMetadataFlagNeverReachesWire:
|
|||
out = _compress(cc, _make_messages())
|
||||
wire = ChatCompletionsTransport().convert_messages(out, model="some-model")
|
||||
assert not any(
|
||||
isinstance(m, dict) and COMPRESSED_SUMMARY_METADATA_KEY in m
|
||||
isinstance(m, dict)
|
||||
and (
|
||||
COMPRESSED_SUMMARY_METADATA_KEY in m
|
||||
or COMPRESSED_SUMMARY_HAS_USER_TURN_KEY in m
|
||||
)
|
||||
for m in wire
|
||||
)
|
||||
# Sanitization must not destroy the in-process flag on the originals.
|
||||
|
|
|
|||
|
|
@ -70,12 +70,14 @@ def _make_compressor():
|
|||
c.last_rough_tokens_when_real_prompt_fit = 0
|
||||
c.awaiting_real_usage_after_compression = False
|
||||
c._previous_summary = None
|
||||
c._summary_has_user_turn = None
|
||||
return c
|
||||
|
||||
|
||||
def _simulate_cron_session_state(c):
|
||||
"""Simulate per-session state that a cron compaction would leave behind."""
|
||||
c._previous_summary = "Cron session summary that must not leak"
|
||||
c._summary_has_user_turn = False
|
||||
c._last_summary_error = "Cron session summary error"
|
||||
c._last_summary_dropped_count = 5
|
||||
c._last_summary_fallback_used = True
|
||||
|
|
@ -166,6 +168,7 @@ def test_on_session_end_matches_on_session_reset_surface():
|
|||
|
||||
per_session_attrs = [
|
||||
"_previous_summary",
|
||||
"_summary_has_user_turn",
|
||||
"_last_summary_error",
|
||||
"_last_summary_dropped_count",
|
||||
"_last_summary_fallback_used",
|
||||
|
|
|
|||
219
tests/agent/test_context_compressor_zero_user_provenance.py
Normal file
219
tests/agent/test_context_compressor_zero_user_provenance.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""Regression coverage for zero-user compaction integrity (#64539)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.context_compressor import (
|
||||
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
HISTORICAL_TASK_HEADING,
|
||||
SUMMARY_PREFIX,
|
||||
ContextCompressor,
|
||||
_NO_USER_TASK_SENTINEL,
|
||||
)
|
||||
|
||||
|
||||
def _response(content: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=content))]
|
||||
)
|
||||
|
||||
|
||||
def _valid_zero_user_summary(label: str = "Checked artifacts.") -> str:
|
||||
return f"""{HISTORICAL_TASK_HEADING}
|
||||
{_NO_USER_TASK_SENTINEL}
|
||||
|
||||
## Goal
|
||||
Historical cron work only.
|
||||
|
||||
## Completed Actions
|
||||
1. {label}
|
||||
|
||||
## Resolved Questions
|
||||
None. No user-authored questions exist.
|
||||
|
||||
## Historical Pending User Asks
|
||||
None. No user-authored requests exist.
|
||||
"""
|
||||
|
||||
|
||||
def _assistant_tool_turns(start: int, count: int) -> list[dict]:
|
||||
turns: list[dict] = []
|
||||
for idx in range(start, start + count):
|
||||
turns.extend(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Continuing scheduled work in English.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"call-{idx}",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": '{"command":"pwd"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": f"call-{idx}",
|
||||
"content": "/workspace/project\n" + ("x" * 300),
|
||||
},
|
||||
]
|
||||
)
|
||||
return turns
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def compressor() -> ContextCompressor:
|
||||
with patch(
|
||||
"agent.context_compressor.get_model_context_length",
|
||||
return_value=100_000,
|
||||
):
|
||||
instance = ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.50,
|
||||
protect_first_n=0,
|
||||
protect_last_n=2,
|
||||
quiet_mode=True,
|
||||
)
|
||||
instance.tail_token_budget = 80
|
||||
return instance
|
||||
|
||||
|
||||
def test_generate_summary_rejects_fabricated_user_ask(compressor):
|
||||
fabricated = f"""{HISTORICAL_TASK_HEADING}
|
||||
User asked: 'Waar zijn de bestanden gedownload?'
|
||||
|
||||
## Goal
|
||||
Vind de bestanden.
|
||||
"""
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
return_value=_response(fabricated),
|
||||
):
|
||||
result = compressor._generate_summary(_assistant_tool_turns(0, 2))
|
||||
|
||||
assert result is None
|
||||
assert compressor._previous_summary is None
|
||||
assert "invented user attribution" in compressor._last_summary_error
|
||||
|
||||
|
||||
def test_zero_user_prompt_anchors_source_language_and_exact_sentinel(compressor):
|
||||
captured_prompt = ""
|
||||
|
||||
def fake_call_llm(**kwargs):
|
||||
nonlocal captured_prompt
|
||||
captured_prompt = kwargs["messages"][0]["content"]
|
||||
return _response(_valid_zero_user_summary())
|
||||
|
||||
with patch("agent.context_compressor.call_llm", side_effect=fake_call_llm):
|
||||
result = compressor._generate_summary(_assistant_tool_turns(0, 2))
|
||||
|
||||
assert result == f"{SUMMARY_PREFIX}\n{_valid_zero_user_summary().strip()}"
|
||||
assert "dominant language of the source turns" in captured_prompt
|
||||
assert _NO_USER_TASK_SENTINEL in captured_prompt
|
||||
assert "Do not write \"User asked:\"" in captured_prompt
|
||||
|
||||
|
||||
def test_zero_user_provenance_survives_iterative_compaction(compressor):
|
||||
messages = _assistant_tool_turns(0, 12)
|
||||
first_summary = f"{SUMMARY_PREFIX}\n{_valid_zero_user_summary('First pass').strip()}"
|
||||
|
||||
with patch.object(compressor, "_generate_summary", return_value=first_summary):
|
||||
first = compressor.compress(messages, current_tokens=90_000)
|
||||
|
||||
first_handoffs = [
|
||||
message
|
||||
for message in first
|
||||
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
]
|
||||
assert len(first_handoffs) == 1
|
||||
assert first_handoffs[0][COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.get_model_context_length",
|
||||
return_value=100_000,
|
||||
):
|
||||
resumed = ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.50,
|
||||
protect_first_n=0,
|
||||
protect_last_n=2,
|
||||
quiet_mode=True,
|
||||
)
|
||||
resumed.tail_token_budget = 80
|
||||
# SessionDB persists the summary content/role but not arbitrary internal
|
||||
# message keys. Simulate that round trip: the exact sentinel must recover
|
||||
# false provenance even when both in-process metadata keys are absent.
|
||||
persisted_handoff = dict(first_handoffs[0])
|
||||
persisted_handoff.pop(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
persisted_handoff.pop(COMPRESSED_SUMMARY_HAS_USER_TURN_KEY)
|
||||
second_input = [persisted_handoff, *_assistant_tool_turns(20, 12)]
|
||||
|
||||
def assert_provenance_then_summarize(*_args, **_kwargs):
|
||||
assert resumed._summary_has_user_turn is False
|
||||
return f"{SUMMARY_PREFIX}\n{_valid_zero_user_summary('Second pass').strip()}"
|
||||
|
||||
with patch.object(
|
||||
resumed,
|
||||
"_generate_summary",
|
||||
side_effect=assert_provenance_then_summarize,
|
||||
):
|
||||
second = resumed.compress(second_input, current_tokens=90_000)
|
||||
|
||||
second_handoffs = [
|
||||
message
|
||||
for message in second
|
||||
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
]
|
||||
assert len(second_handoffs) == 1
|
||||
assert second_handoffs[0][COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
||||
|
||||
|
||||
def test_zero_user_deterministic_fallback_uses_same_provenance(compressor):
|
||||
messages = _assistant_tool_turns(0, 12)
|
||||
|
||||
with patch.object(compressor, "_generate_summary", return_value=None):
|
||||
result = compressor.compress(messages, current_tokens=90_000)
|
||||
|
||||
handoff = next(
|
||||
message
|
||||
for message in result
|
||||
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
)
|
||||
assert _NO_USER_TASK_SENTINEL in handoff["content"]
|
||||
assert "User asked:" not in handoff["content"]
|
||||
assert handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
|
||||
|
||||
|
||||
def test_real_user_turn_sets_provenance_true(compressor):
|
||||
messages = [
|
||||
{"role": "user", "content": "Please inspect the build artifacts."},
|
||||
*_assistant_tool_turns(0, 12),
|
||||
]
|
||||
summary = f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'Please inspect the build artifacts.'"
|
||||
|
||||
with patch.object(compressor, "_generate_summary", return_value=summary):
|
||||
result = compressor.compress(messages, current_tokens=90_000)
|
||||
|
||||
handoff = next(
|
||||
message
|
||||
for message in result
|
||||
if message.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
)
|
||||
assert handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is True
|
||||
|
||||
|
||||
def test_session_boundaries_clear_summary_provenance(compressor):
|
||||
compressor._summary_has_user_turn = False
|
||||
compressor.on_session_reset()
|
||||
assert compressor._summary_has_user_turn is None
|
||||
|
||||
compressor._summary_has_user_turn = True
|
||||
compressor.on_session_end("cron-session", [])
|
||||
assert compressor._summary_has_user_turn is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue