fix(agent): harden multimodal tool result flow

Keep multimodal tool outputs on the native content-block path while
restoring redirect SSRF checks for web_fetch image responses. Also share
image block construction, simplify persisted history sanitization, and
add regression tests for image reads and blocked private redirects.

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-03-21 05:34:56 +00:00
parent 834f1e3a9f
commit 445a96ab55
8 changed files with 133 additions and 46 deletions
+48 -24
View File
@@ -465,6 +465,52 @@ class AgentLoop:
metadata=msg.metadata or {},
)
@staticmethod
def _image_placeholder(block: dict[str, Any]) -> dict[str, str]:
"""Convert an inline image block into a compact text placeholder."""
path = (block.get("_meta") or {}).get("path", "")
return {"type": "text", "text": f"[image: {path}]" if path else "[image]"}
def _sanitize_persisted_blocks(
self,
content: list[dict[str, Any]],
*,
truncate_text: bool = False,
drop_runtime: bool = False,
) -> list[dict[str, Any]]:
"""Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = []
for block in content:
if not isinstance(block, dict):
filtered.append(block)
continue
if (
drop_runtime
and block.get("type") == "text"
and isinstance(block.get("text"), str)
and block["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG)
):
continue
if (
block.get("type") == "image_url"
and block.get("image_url", {}).get("url", "").startswith("data:image/")
):
filtered.append(self._image_placeholder(block))
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"]
if truncate_text and len(text) > self._TOOL_RESULT_MAX_CHARS:
text = text[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
filtered.append({**block, "text": text})
continue
filtered.append(block)
return filtered
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
@@ -477,19 +523,7 @@ class AgentLoop:
if isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS:
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
elif isinstance(content, list):
filtered = []
for c in content:
if c.get("type") == "image_url" and c.get("image_url", {}).get("url", "").startswith("data:image/"):
path = (c.get("_meta") or {}).get("path", "")
placeholder = f"[image: {path}]" if path else "[image]"
filtered.append({"type": "text", "text": placeholder})
elif c.get("type") == "text" and isinstance(c.get("text"), str):
text = c["text"]
if len(text) > self._TOOL_RESULT_MAX_CHARS:
text = text[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
filtered.append({"type": "text", "text": text})
else:
filtered.append(c)
filtered = self._sanitize_persisted_blocks(content, truncate_text=True)
if not filtered:
continue
entry["content"] = filtered
@@ -502,17 +536,7 @@ class AgentLoop:
else:
continue
if isinstance(content, list):
filtered = []
for c in content:
if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
continue # Strip runtime context from multimodal messages
if (c.get("type") == "image_url"
and c.get("image_url", {}).get("url", "").startswith("data:image/")):
path = (c.get("_meta") or {}).get("path", "")
placeholder = f"[image: {path}]" if path else "[image]"
filtered.append({"type": "text", "text": placeholder})
else:
filtered.append(c)
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
if not filtered:
continue
entry["content"] = filtered