diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 5ff794acd..e16778cd3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1664,7 +1664,21 @@ class ContextCompressor(ContextEngine): parts = [] for msg in turns: role = msg.get("role", "unknown") - content = redact_sensitive_text(msg.get("content") or "") + content = msg.get("content") + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + text_parts.append(part.get("text", "")) + elif part.get("type") in { + "image", "image_url", "input_image", + }: + text_parts.append("[image]") + elif isinstance(part, str): + text_parts.append(part) + content = "\n".join(text_parts) + content = redact_sensitive_text(content or "") content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) # Strip inline reasoning blocks (, , etc.) from # assistant content before it reaches the summarizer. Reasoning diff --git a/tests/agent/test_compressor_media_stripping.py b/tests/agent/test_compressor_media_stripping.py index f995ac98a..29ca272a4 100644 --- a/tests/agent/test_compressor_media_stripping.py +++ b/tests/agent/test_compressor_media_stripping.py @@ -54,3 +54,49 @@ class TestMediaDirectiveStripping: result = compressor._serialize_for_summary(turns) assert "MEDIA:" not in result assert result.count("[media attachment]") == 2 + + def test_multimodal_list_content_does_not_crash(self, compressor): + """content as a list (multimodal) must not crash redact_sensitive_text. + + Regression: msg.get('content') can return a list of parts for + multimodal messages (images + text). The old code passed this + list directly to redact_sensitive_text(str), which raised + AttributeError on list.replace(). + """ + turns = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}, + ], + }, + ] + # Must not raise + result = compressor._serialize_for_summary(turns) + assert "What is in this image?" in result + assert "[image]" in result + + def test_multimodal_list_text_parts_extracted(self, compressor): + """Text parts from multimodal list content are preserved in output.""" + turns = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "first part"}, + {"type": "text", "text": "second part"}, + ], + }, + ] + result = compressor._serialize_for_summary(turns) + assert "first part" in result + assert "second part" in result + + def test_multimodal_list_bare_strings_handled(self, compressor): + """Bare strings inside a content list are joined.""" + turns = [ + {"role": "user", "content": ["hello", "world"]}, + ] + result = compressor._serialize_for_summary(turns) + assert "hello" in result + assert "world" in result