From 2d9fc61c9406f8789d546dfecce73d7537f498c4 Mon Sep 17 00:00:00 2001 From: Karina Qian Date: Sat, 30 May 2026 05:53:49 -0700 Subject: [PATCH] [verified] Preserve user focus after todo compression --- agent/context_compressor.py | 40 +++++ agent/conversation_compression.py | 3 +- .../agent/test_compression_todo_injection.py | 143 ++++++++++++++++++ tools/todo_tool.py | 7 +- 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_compression_todo_injection.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 49907e2c33162..9c3cebbf6f358 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -150,6 +150,46 @@ def _append_text_to_content(content: Any, text: str, *, prepend: bool = False) - return text + rendered if prepend else rendered + text +def _inject_todo_snapshot_as_context(messages: List[Dict[str, Any]], todo_snapshot: str) -> List[Dict[str, Any]]: + """Inject preserved todo state without creating a synthetic latest request. + + The todo snapshot is internal continuity state created by Hermes after + compression. Appending it as a standalone ``role='user'`` turn makes weak + models treat the synthetic snapshot as fresh user input and can cause them + to answer older adjacent turns instead of the actual current request. + + Do not mutate assistant turns here: some providers attach signed thinking / + reasoning metadata to assistant messages, and editing assistant content after + signature generation can make replay invalid. Instead, prepend the snapshot + inside the latest real user message with a clear delimiter that keeps that + latest real user instruction mechanically last. + """ + if not todo_snapshot: + return messages + + out = [msg.copy() for msg in messages] + latest_user_idx = None + for idx in range(len(out) - 1, -1, -1): + if out[idx].get("role") == "user": + latest_user_idx = idx + break + + note = ( + f"{todo_snapshot}\n\n" + "--- END INTERNAL STATE NOTE; answer the latest real user message below ---\n\n" + ) + if latest_user_idx is None: + out.append({"role": "user", "content": note + "[No current user message was present after compression.]"}) + return out + + out[latest_user_idx]["content"] = _append_text_to_content( + out[latest_user_idx].get("content"), + note, + prepend=True, + ) + return out + + def _strip_image_parts_from_parts(parts: Any) -> Any: """Strip image parts from an OpenAI-style content-parts list. diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index a620f343e99e6..f5976cee80a90 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -37,6 +37,7 @@ from typing import Any, List, Optional, Tuple from agent.model_metadata import estimate_request_tokens_rough +from agent.context_compressor import _inject_todo_snapshot_as_context logger = logging.getLogger(__name__) @@ -366,7 +367,7 @@ def compress_context( todo_snapshot = agent._todo_store.format_for_injection() if todo_snapshot: - compressed.append({"role": "user", "content": todo_snapshot}) + compressed = _inject_todo_snapshot_as_context(compressed, todo_snapshot) agent._invalidate_system_prompt() new_system_prompt = agent._build_system_prompt(system_message) diff --git a/tests/agent/test_compression_todo_injection.py b/tests/agent/test_compression_todo_injection.py new file mode 100644 index 0000000000000..b7c88791fcc4e --- /dev/null +++ b/tests/agent/test_compression_todo_injection.py @@ -0,0 +1,143 @@ +"""Regression tests for post-compression todo-state injection. + +The todo snapshot is synthetic Hermes state. It must never become the latest +``role='user'`` turn, because that makes models treat it as the fresh request and +can cause them to answer stale adjacent context instead of the actual user task. +""" + +from agent.context_compressor import _inject_todo_snapshot_as_context +from agent.conversation_compression import compress_context +from tools.todo_tool import TodoStore + + +def test_todo_injection_keeps_latest_real_user_message_last(): + messages = [ + {"role": "user", "content": "old daily refresh status question"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "implement the fixes, review, commit and push"}, + ] + todo_snapshot = "[Internal state note — active task list preserved across context compression]\n- [>] implement. Implement fixes" + + injected = _inject_todo_snapshot_as_context(messages, todo_snapshot) + + assert injected[-1]["role"] == messages[-1]["role"] + assert injected[-1]["role"] == "user" + assert injected[-1]["content"].endswith("implement the fixes, review, commit and push") + assert injected[-1]["content"].startswith("[Internal state note") + assert "END INTERNAL STATE NOTE" in injected[-1]["content"] + assert not any( + msg["role"] == "user" and msg.get("content") == todo_snapshot + for msg in injected + ) + + +def test_todo_injection_preserves_signed_assistant_turns_by_only_editing_latest_user(): + messages = [ + {"role": "user", "content": "old question"}, + { + "role": "assistant", + "content": "previous assistant context", + "reasoning_details": [{"signature": "signed-thinking"}], + }, + {"role": "user", "content": "current task"}, + ] + + injected = _inject_todo_snapshot_as_context(messages, "TODO SNAPSHOT") + + assert [m["role"] for m in injected] == ["user", "assistant", "user"] + assert injected[1] == messages[1] + assert "TODO SNAPSHOT" not in injected[1]["content"] + assert injected[-1]["content"].endswith("current task") + assert injected[-1]["content"].startswith("TODO SNAPSHOT") + + +def test_todo_injection_with_single_user_message_does_not_replace_latest_user(): + messages = [{"role": "user", "content": "only real request"}] + + injected = _inject_todo_snapshot_as_context(messages, "TODO SNAPSHOT") + + assert [m["role"] for m in injected] == ["user"] + assert injected[0]["content"].startswith("TODO SNAPSHOT") + assert injected[-1]["content"].endswith("only real request") + + +def test_compress_context_does_not_emit_todo_snapshot_as_latest_user_turn(): + class FakeCompressor: + _last_compress_aborted = False + _last_summary_error = None + _last_aux_model_failure_model = None + _last_aux_model_failure_error = None + compression_count = 0 + last_prompt_tokens = 0 + last_completion_tokens = 0 + last_total_tokens = 0 + + def compress(self, messages, current_tokens=None, focus_topic=None, force=False): + return list(messages) + + class FakeAgent: + session_id = "test-session" + model = "test-model" + tools = [] + _memory_manager = None + _session_db = None + _cached_system_prompt = None + _last_compression_summary_warning = None + _last_aux_fallback_warning_key = None + _compression_feasibility_checked = True + context_compressor = FakeCompressor() + + def __init__(self): + self._todo_store = TodoStore() + self._todo_store.write([ + {"id": "implement", "content": "Implement fix", "status": "in_progress"}, + ]) + + def _emit_status(self, message): + self.status = message + + def _emit_warning(self, message): + self.warning = message + + def _invalidate_system_prompt(self): + self.invalidated = True + + def _build_system_prompt(self, system_message): + return system_message or "system" + + def _vprint(self, *args, **kwargs): + pass + + messages = [ + {"role": "user", "content": "old daily refresh status question"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "implement the fixes, review, commit and push"}, + ] + + compressed, _ = compress_context(FakeAgent(), messages, "system", approx_tokens=100) + + assert compressed[-1]["role"] == messages[-1]["role"] + assert compressed[-1]["role"] == "user" + assert "active task list preserved" in compressed[-1]["content"] + assert compressed[-1]["content"].endswith("implement the fixes, review, commit and push") + assert "END INTERNAL STATE NOTE" in compressed[-1]["content"] + assert not any( + msg["role"] == "user" and msg.get("content", "").strip().startswith("[Internal state note") and "implement the fixes" not in msg.get("content", "") + for msg in compressed + ) + + +def test_todo_snapshot_text_explicitly_says_it_is_not_a_user_request(): + store = TodoStore() + store.write([ + {"id": "implement", "content": "Implement fix", "status": "in_progress"}, + {"id": "review", "content": "Code review", "status": "pending"}, + ]) + + text = store.format_for_injection() + + assert text is not None + assert "NOT a user request" in text + assert "Do not answer or act on older user requests" in text + assert "Continue the latest real user instruction" in text + assert "Implement fix" in text diff --git a/tools/todo_tool.py b/tools/todo_tool.py index 99d9ffe8515c9..200cdb5071f55 100644 --- a/tools/todo_tool.py +++ b/tools/todo_tool.py @@ -114,7 +114,12 @@ def format_for_injection(self) -> Optional[str]: if not active_items: return None - lines = ["[Your active task list was preserved across context compression]"] + lines = [ + "[Internal state note — active task list preserved across context compression]", + "This is NOT a user request. Use it only as task-continuity context.", + "Do not answer or act on older user requests because they appear before this note.", + "Continue the latest real user instruction in the conversation.", + ] for item in active_items: marker = markers.get(item["status"], "[?]") lines.append(f"- {marker} {item['id']}. {item['content']} ({item['status']})")