mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
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:
parent
3d27a01879
commit
6cfaff06f6
20 changed files with 1626 additions and 101 deletions
44
tests/test_media_redact.py
Normal file
44
tests/test_media_redact.py
Normal 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"]
|
||||
46
tests/test_model_path_audit.py
Normal file
46
tests/test_model_path_audit.py
Normal 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 "")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
30
tests/test_tool_runtime_attachments.py
Normal file
30
tests/test_tool_runtime_attachments.py
Normal 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"
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue