diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2884e057ba75..e5d96c594522 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -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 ("") +- Questions awaiting an answer ("") +- Decisions awaiting input ("