Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down
143 changes: 143 additions & 0 deletions tests/agent/test_compression_todo_injection.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion tools/todo_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']})")
Expand Down