diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 254ac0ac5..f959b92a6 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -64,6 +64,47 @@ _CHARS_PER_TOKEN = 4 _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 +def _content_text_for_contains(content: Any) -> str: + """Return a best-effort text view of message content. + + Used only for substring checks when we need to know whether we've already + appended a note to a message. Keeps multimodal lists intact elsewhere. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(part for part in parts if part) + return str(content) + + +def _append_text_to_content(content: Any, text: str, *, prepend: bool = False) -> Any: + """Append or prepend plain text to message content safely. + + Compression sometimes needs to add a note or merge a summary into an + existing message. Message content may be plain text or a multimodal list of + blocks, so direct string concatenation is not always safe. + """ + if content is None: + return text + if isinstance(content, str): + return text + content if prepend else content + text + if isinstance(content, list): + text_block = {"type": "text", "text": text} + return [text_block, *content] if prepend else [*content, text_block] + rendered = str(content) + return text + rendered if prepend else rendered + text + + def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str: """Shrink long string values inside a tool-call arguments JSON blob while preserving JSON validity. @@ -1144,10 +1185,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio for i in range(compress_start): msg = messages[i].copy() if i == 0 and msg.get("role") == "system": - existing = msg.get("content") or "" + existing = msg.get("content") _compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]" - if _compression_note not in existing: - msg["content"] = existing + "\n\n" + _compression_note + if _compression_note not in _content_text_for_contains(existing): + msg["content"] = _append_text_to_content( + existing, + "\n\n" + _compression_note if isinstance(existing, str) and existing else _compression_note, + ) compressed.append(msg) # If LLM summary failed, insert a static fallback so the model @@ -1191,12 +1235,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio for i in range(compress_end, n_messages): msg = messages[i].copy() if _merge_summary_into_tail and i == compress_end: - original = msg.get("content") or "" - msg["content"] = ( + merged_prefix = ( summary + "\n\n--- END OF CONTEXT SUMMARY — " "respond to the message below, not the summary above ---\n\n" - + original + ) + msg["content"] = _append_text_to_content( + msg.get("content"), + merged_prefix, + prepend=True, ) _merge_summary_into_tail = False compressed.append(msg) diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 0c20dddcd..8072a58d9 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -253,6 +253,35 @@ class TestSummaryPrefixNormalization: class TestCompressWithClient: + def test_system_content_list_gets_compression_note_without_crashing(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "system prompt"}]}, + {"role": "user", "content": "msg 1"}, + {"role": "assistant", "content": "msg 2"}, + {"role": "user", "content": "msg 3"}, + {"role": "assistant", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "assistant", "content": "msg 6"}, + {"role": "user", "content": "msg 7"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + assert isinstance(result[0]["content"], list) + assert any( + isinstance(block, dict) + and "compacted into a handoff summary" in block.get("text", "") + for block in result[0]["content"] + ) + def test_summarization_path(self): mock_client = MagicMock() mock_response = MagicMock() @@ -460,6 +489,41 @@ class TestCompressWithClient: assert len(first_tail) == 1 assert "summary text" in first_tail[0]["content"] + def test_double_collision_merges_summary_into_list_tail_content(self): + """Structured tail content should accept a merged summary without TypeError.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3, protect_last_n=3) + + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "msg 1"}, + {"role": "assistant", "content": "msg 2"}, + {"role": "user", "content": "msg 3"}, + {"role": "assistant", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "user", "content": [{"type": "text", "text": "msg 6"}]}, + {"role": "assistant", "content": "msg 7"}, + {"role": "user", "content": "msg 8"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + merged_tail = next( + m for m in result + if m.get("role") == "user" and isinstance(m.get("content"), list) + ) + assert isinstance(merged_tail["content"], list) + assert "summary text" in merged_tail["content"][0]["text"] + assert any( + isinstance(block, dict) and block.get("text") == "msg 6" + for block in merged_tail["content"] + ) + def test_double_collision_user_head_assistant_tail(self): """Reverse double collision: head ends with 'user', tail starts with 'assistant'. summary='assistant' collides with tail, 'user' collides with head → merge."""