Refactor media payload handling to persist base64 blobs as attachment refs and keep non-turn model paths safe by degrading risky payloads.

This preserves multimodal fidelity for the latest user turn while preventing historical/tool replay bloat, and adds admin UI support for referenced attachment preview/download flows.

Made-with: Cursor
This commit is contained in:
oliver 2026-04-28 10:30:27 +08:00
parent 3d27a01879
commit 6cfaff06f6
20 changed files with 1626 additions and 101 deletions

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import base64
from oclaw.runtime.chat.media_redact import ingest_embedded_image_blobs_as_refs, redact_embedded_image_blobs
def test_redact_nested_mcp_image_block() -> None:
big = "/9j/" + "a" * 800
obj = {
"ok": True,
"result": {"content": [{"type": "image", "mime": "image/jpeg", "data": big}]},
}
out = redact_embedded_image_blobs(obj)
assert out["result"]["content"][0].get("_image_payload_redacted") is True
assert "data" not in out["result"]["content"][0]
assert isinstance(out["result"]["content"][0].get("_redacted_payload_chars"), int)
def test_redact_keeps_small_data_field() -> None:
obj = {"type": "image", "mime": "image/png", "data": "abc"}
assert redact_embedded_image_blobs(obj) == obj
def test_ingest_embedded_image_blob_as_ref(tmp_path) -> None:
raw = base64.b64encode(b"png-bytes").decode("ascii")
obj = {"result": {"content": [{"type": "image", "mime": "image/png", "data": raw, "name": "x.png"}]}}
out, refs = ingest_embedded_image_blobs_as_refs(obj, root_dir=str(tmp_path), filename_prefix="unit")
block = out["result"]["content"][0]
assert block["type"] == "image_ref"
assert str(block.get("attachment_id") or "")
assert "data" not in block
assert refs and refs[0]["attachment_id"] == block["attachment_id"]
def test_ingest_embedded_binary_blob_as_binary_ref(tmp_path) -> None:
raw = base64.b64encode(b"%PDF-1.4-fake").decode("ascii")
obj = {"result": {"content": [{"type": "file", "mime": "application/pdf", "base64": raw, "name": "a.pdf"}]}}
out, refs = ingest_embedded_image_blobs_as_refs(obj, root_dir=str(tmp_path), filename_prefix="unit")
block = out["result"]["content"][0]
assert block["type"] == "binary_ref"
assert str(block.get("attachment_id") or "")
assert "base64" not in block
assert refs and refs[0]["attachment_id"] == block["attachment_id"]

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload
def test_audit_accepts_plain_system_user_messages() -> None:
ensure_no_tool_or_embedded_image_payload(
path="unit.ok",
messages=[
{"role": "system", "content": "sys"},
{"role": "user", "content": "hello"},
],
)
def test_audit_degrades_tool_role() -> None:
msgs = [
{"role": "assistant", "content": "a"},
{"role": "tool", "content": '{"ok":true}'},
]
ensure_no_tool_or_embedded_image_payload(path="unit.tool", messages=msgs)
assert msgs[1]["role"] == "assistant"
assert "omitted" in str(msgs[1]["content"] or "")
def test_audit_degrades_embedded_image_payload() -> None:
msgs = [
{
"role": "user",
"content": [
{"type": "text", "text": "x"},
{"type": "input_image", "image_base64": "a" * 600, "mime": "image/png"},
],
}
]
ensure_no_tool_or_embedded_image_payload(path="unit.image", messages=msgs)
c = msgs[0]["content"]
assert isinstance(c, list)
assert any(isinstance(x, dict) and str(x.get("type") or "") == "text" and "omitted" in str(x.get("text") or "") for x in c)
def test_audit_degrades_large_base64_like_plain_text() -> None:
payload = "A" * 700
msgs = [{"role": "user", "content": payload}]
ensure_no_tool_or_embedded_image_payload(path="unit.b64_text", messages=msgs)
assert "omitted" in str(msgs[0]["content"] or "")

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
from pathlib import Path
from oclaw.runtime.direct_loop import _OCLAW_TOOL_RESULT_HARD_CAP_CHARS, _build_model_context
@ -78,3 +79,42 @@ def test_oclaw_tool_result_context_guard_skips_active_turn_tool_messages(tmp_pat
raw = str(tool_msgs[-1].get("content") or "")
assert "_tool_result_guarded" not in raw
def test_guard_redacts_mcp_nested_image_for_non_active_turn(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
sess = store.create_session("t")
past_turn = "turn-old"
blob = "/9j/" + "a" * 1200
body = {"ok": True, "result": {"content": [{"type": "image", "mime": "image/jpeg", "data": blob}]}}
store.add_message(
session_id=sess.id,
role="assistant",
content="",
tool_calls=[{"id": "c_hist", "name": "mcp", "arguments": {}}],
turn_uuid=past_turn,
)
store.add_message(
session_id=sess.id,
role="tool",
content=json.dumps(body, ensure_ascii=False),
tool_calls={"tool_call_id": "c_hist", "name": "mcp", "assistant_message_id": 1},
turn_uuid=past_turn,
)
msgs = _build_model_context(
store=store,
session_id=sess.id,
max_messages=50,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="zh",
memory_context=None,
trace_id="t1",
parent_span_id=None,
active_turn_uuid="different-active-turn",
)
tm = next(m for m in msgs if m.get("role") == "tool")
inner = json.loads(str(tm.get("content") or ""))
block = inner["result"]["content"][0]
assert block.get("_image_payload_redacted") is True
assert "data" not in block

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import base64
import json
from pathlib import Path
@ -89,6 +90,94 @@ def test_repeated_tool_results_are_compacted_in_history(tmp_path: Path) -> None:
assert "audit_note" in payloads[2]
def test_tool_result_image_blob_persisted_as_attachment_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "img.sqlite"))
sess = store.create_session("t")
def _handler(_args):
raw = base64.b64encode(b"\x89PNGtest-image").decode("ascii")
return {
"ok": True,
"result": {"content": [{"type": "image", "mime": "image/png", "data": raw, "name": "mcp.png"}]},
}
reg = ToolRegistry(
[
ToolSpec(
name="mcp_image_tool",
description="returns image payload",
parameters={"type": "object", "properties": {}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="mcp_image_tool", arguments={})]
ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id, turn_uuid="turn-img"),
assistant_msg_id=1,
tool_uses=tool_uses,
)
rows = store.get_messages(session_id=sess.id, limit=20)
tool_rows = [m for m in rows if str(getattr(m, "role", "") or "") == "tool"]
assert len(tool_rows) == 1
payload = json.loads(str(getattr(tool_rows[0], "content", "") or "{}"))
blk = (((payload.get("result") or {}).get("content") or [{}])[0]) if isinstance(payload, dict) else {}
assert isinstance(blk, dict)
assert str(blk.get("type") or "") == "image_ref"
assert str(blk.get("attachment_id") or "")
assert "data" not in blk
atts = json.loads(str(getattr(tool_rows[0], "attachments", "") or "[]"))
assert isinstance(atts, list) and atts
assert str((atts[0] or {}).get("type") or "") == "image_ref"
assert str((atts[0] or {}).get("attachment_id") or "")
logs = store.get_tool_logs(sess.id, limit=10)
assert logs and isinstance(logs[0], dict)
body = logs[0].get("result") or {}
blk2 = (((body.get("result") or {}).get("content") or [{}])[0]) if isinstance(body, dict) else {}
assert isinstance(blk2, dict)
assert str(blk2.get("type") or "") == "image_ref"
assert "data" not in blk2
def test_tool_result_non_image_base64_persisted_as_binary_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "bin.sqlite"))
sess = store.create_session("t")
def _handler(_args):
raw = base64.b64encode(b"PK\x03\x04fake-zip-bytes").decode("ascii")
return {"ok": True, "result": {"content": [{"type": "file", "mime": "application/zip", "base64": raw, "name": "a.zip"}]}}
reg = ToolRegistry(
[
ToolSpec(
name="mcp_file_tool",
description="returns file payload",
parameters={"type": "object", "properties": {}},
handler=_handler,
read_only=True,
)
]
)
ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id, turn_uuid="turn-bin"),
assistant_msg_id=1,
tool_uses=[LLMToolCall(id="c1", name="mcp_file_tool", arguments={})],
)
rows = store.get_messages(session_id=sess.id, limit=20)
tool_rows = [m for m in rows if str(getattr(m, "role", "") or "") == "tool"]
assert len(tool_rows) == 1
payload = json.loads(str(getattr(tool_rows[0], "content", "") or "{}"))
blk = (((payload.get("result") or {}).get("content") or [{}])[0]) if isinstance(payload, dict) else {}
assert isinstance(blk, dict)
assert str(blk.get("type") or "") == "binary_ref"
assert str(blk.get("attachment_id") or "")
assert "base64" not in blk
atts = json.loads(str(getattr(tool_rows[0], "attachments", "") or "[]"))
assert isinstance(atts, list) and atts
assert str((atts[0] or {}).get("type") or "") == "binary_ref"
def test_repeated_non_sql_tools_are_not_compacted(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g3.sqlite"))
sess = store.create_session("t")
@ -429,6 +518,11 @@ def test_tool_result_image_payload_persisted_as_attachments(tmp_path: Path) -> N
tool_rows = [m for m in rows if str(getattr(m, "role", "") or "") == "tool"]
assert len(tool_rows) == 1
attachments = json.loads(str(getattr(tool_rows[0], "attachments", "") or "[]"))
assert any(str(a.get("type") or "") == "image" and str(a.get("data") or "") == "YWJj" for a in attachments)
assert any(str(a.get("type") or "") == "image_ref" and str(a.get("attachment_id") or "") for a in attachments)
assert any(str(a.get("type") or "") == "image_url" and str(a.get("url") or "").endswith("/a.png") for a in attachments)
body = json.loads(str(getattr(tool_rows[0], "content", "") or "{}"))
content_items = ((body.get("result") or {}).get("content") or []) if isinstance(body, dict) else []
image_block = next((x for x in content_items if isinstance(x, dict) and str(x.get("type") or "") == "image_ref"), {})
assert str(image_block.get("attachment_id") or "")
assert "data" not in image_block

View file

@ -138,7 +138,13 @@ def test_signature_metadata_not_replayed_by_default_for_non_whitelist_model() ->
ensure_ascii=False,
),
event_type="tool_call",
)
),
_Msg(
"tool",
json.dumps({"ok": True}),
tool_calls=json.dumps({"tool_call_id": "call_1", "name": "t"}, ensure_ascii=False),
event_type="tool_result",
),
]
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
assistant = [m for m in msgs if m.get("role") == "assistant"][0]
@ -158,7 +164,13 @@ def test_signature_metadata_can_be_forced_on_via_env(monkeypatch) -> None:
ensure_ascii=False,
),
event_type="tool_call",
)
),
_Msg(
"tool",
json.dumps({"ok": True}),
tool_calls=json.dumps({"tool_call_id": "call_1", "name": "t"}, ensure_ascii=False),
event_type="tool_result",
),
]
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
assistant = [m for m in msgs if m.get("role") == "assistant"][0]

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from oclaw.runtime.chat.tool_runtime import _attachments_from_tool_result
def test_attachments_from_tool_result_preserves_non_image_ref_types() -> None:
result = {
"attachments": [
{
"type": "text_ref",
"attachment_id": "att-text-1",
"mime": "text/plain",
"name": "a.txt",
"bytes": 12,
},
{
"attachment_id": "att-video-1",
"mime": "video/mp4",
"name": "a.mp4",
"bytes": 1024,
},
]
}
out = _attachments_from_tool_result(result)
by_id = {str(x.get("attachment_id")): x for x in out}
assert by_id["att-text-1"]["type"] == "text_ref"
assert by_id["att-video-1"]["type"] == "video_ref"