Studio: render image content returned by MCP tools (#7081)
* MCP image handling * clean upg * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: return MCP error results so image content is not dropped FastMCP client.call_tool raises ToolError by default on an is_error result, so it never reaches _flatten_result and any returned image is dropped. Pass raise_on_error=False so error results flow through _flatten_result and keep their images. Transport failures still raise and hit the existing handler. Add a regression test for the real path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: accept raise_on_error kwarg in MCP test fake clients The call_tool_sync fix passes raise_on_error=False to client.call_tool. Update the fake MCP clients patched into mcp_client._client so their call_tool signatures accept the keyword, keeping the stdio/servers MCP test suites green. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten MCP raise_on_error rationale comments * Studio: only strip MCP image sentinel when suffix is a valid image envelope * Studio: validate MCP image envelope in chat adapter and keep base64 out of exports * Studio: sanitize MCP images in all export formats and fall through to sandbox parser on invalid marker --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
This commit is contained in:
parent
ed42702730
commit
fea7d9ba34
9 changed files with 400 additions and 12 deletions
|
|
@ -298,20 +298,48 @@ def invalidate_tool_cache(server_id: Optional[str] = None) -> None:
|
|||
_probe_cooloff_until.pop(server_id, None)
|
||||
|
||||
|
||||
MCP_IMAGES_SENTINEL = "__MCP_IMAGES__:"
|
||||
MAX_IMAGE_PAYLOAD_CHARS = 12_000_000
|
||||
|
||||
|
||||
def _flatten_result(result: Any) -> str:
|
||||
parts = []
|
||||
images = []
|
||||
omitted = 0
|
||||
budget = MAX_IMAGE_PAYLOAD_CHARS
|
||||
for block in getattr(result, "content", None) or []:
|
||||
text = getattr(block, "text", None)
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
continue
|
||||
data = getattr(block, "data", None)
|
||||
mime = getattr(block, "mimeType", None)
|
||||
if data and isinstance(mime, str) and mime.startswith("image/"):
|
||||
data = str(data)
|
||||
if len(data) > budget:
|
||||
omitted += 1
|
||||
continue
|
||||
budget -= len(data)
|
||||
images.append({"data": data, "mimeType": mime})
|
||||
body = "\n".join(parts)
|
||||
if not body:
|
||||
structured = getattr(result, "structured_content", None)
|
||||
body = str(structured) if structured is not None else ""
|
||||
if images or omitted:
|
||||
notes = []
|
||||
if images:
|
||||
n = len(images)
|
||||
notes.append(f"{n} image{'s' if n > 1 else ''} attached; displayed to the user")
|
||||
if omitted:
|
||||
notes.append(f"{omitted} image{'s' if omitted > 1 else ''} omitted (too large)")
|
||||
note = f"[{'; '.join(notes)}]"
|
||||
body = f"{body}\n{note}" if body else note
|
||||
|
||||
if getattr(result, "is_error", False):
|
||||
# "Error: " prefix triggers tool_call_parser's TOOL_ERROR_PREFIXES nudge.
|
||||
return f"Error: {body}" if body else "Error: tool returned no content"
|
||||
body = f"Error: {body}" if body else "Error: tool returned no content"
|
||||
if images:
|
||||
body += "\n" + MCP_IMAGES_SENTINEL + json.dumps(images)
|
||||
return body
|
||||
|
||||
|
||||
|
|
@ -333,7 +361,10 @@ def call_tool_sync(
|
|||
|
||||
async def _call() -> Any:
|
||||
async with _client(url, headers, use_oauth) as client:
|
||||
return await client.call_tool(name, args)
|
||||
# raise_on_error=False lets an is_error result (which may still carry
|
||||
# image content) reach _flatten_result instead of FastMCP raising ToolError
|
||||
# and dropping the images. Transport failures still raise (handled below).
|
||||
return await client.call_tool(name, args, raise_on_error = False)
|
||||
|
||||
async def _watch_cancel() -> None:
|
||||
# 50 ms cadence keeps cancellation responsive without busy-looping;
|
||||
|
|
|
|||
|
|
@ -233,9 +233,33 @@ def is_tool_error(result: str) -> bool:
|
|||
return isinstance(result, str) and result.lstrip().startswith(TOOL_ERROR_PREFIXES)
|
||||
|
||||
|
||||
def _strip_mcp_image_suffix(result: str) -> str:
|
||||
"""Drop a trailing __MCP_IMAGES__ envelope only when it is the valid JSON
|
||||
image array appended by _flatten_result, so legit tool text that merely
|
||||
mentions the marker is not truncated."""
|
||||
head, sep, payload = result.rpartition("\n__MCP_IMAGES__:")
|
||||
if not sep:
|
||||
return result
|
||||
try:
|
||||
images = json.loads(payload)
|
||||
except (ValueError, RecursionError):
|
||||
return result
|
||||
if not isinstance(images, list) or not images:
|
||||
return result
|
||||
if not all(
|
||||
isinstance(img, dict)
|
||||
and isinstance(img.get("data"), str)
|
||||
and isinstance(img.get("mimeType"), str)
|
||||
for img in images
|
||||
):
|
||||
return result
|
||||
return head.rstrip()
|
||||
|
||||
|
||||
def strip_result_for_model(result: str) -> str:
|
||||
"""Remove frontend-only sentinels (image paths, RAG source map) before
|
||||
feeding the result back to the model."""
|
||||
result = _strip_mcp_image_suffix(result)
|
||||
for sentinel in ("__IMAGES__:", "__RAG_SOURCES__:"):
|
||||
if sentinel in result:
|
||||
result = result.split(sentinel, 1)[0].rstrip()
|
||||
|
|
|
|||
177
studio/backend/tests/test_mcp_flatten_result.py
Normal file
177
studio/backend/tests/test_mcp_flatten_result.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
from core.inference import mcp_client
|
||||
from core.inference.mcp_client import (
|
||||
MAX_IMAGE_PAYLOAD_CHARS,
|
||||
MCP_IMAGES_SENTINEL,
|
||||
_flatten_result,
|
||||
call_tool_sync,
|
||||
)
|
||||
from core.inference.tool_loop_controller import is_tool_error, strip_result_for_model
|
||||
|
||||
PNG_B64 = "iVBORw0KGgoAAAANSUhEUg=="
|
||||
|
||||
|
||||
def _text(value: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(type = "text", text = value)
|
||||
|
||||
|
||||
def _image(data: str = PNG_B64, mime: str = "image/png") -> SimpleNamespace:
|
||||
return SimpleNamespace(type = "image", data = data, mimeType = mime)
|
||||
|
||||
|
||||
def _result(
|
||||
*blocks,
|
||||
is_error = False,
|
||||
structured = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
content = list(blocks),
|
||||
is_error = is_error,
|
||||
structured_content = structured,
|
||||
)
|
||||
|
||||
|
||||
def test_text_only_result_unchanged():
|
||||
assert _flatten_result(_result(_text("hello"))) == "hello"
|
||||
|
||||
|
||||
def test_image_only_result_keeps_image_and_notes_model():
|
||||
flat = _flatten_result(_result(_image()))
|
||||
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
||||
assert body == "[1 image attached; displayed to the user]"
|
||||
assert json.loads(payload) == [{"data": PNG_B64, "mimeType": "image/png"}]
|
||||
|
||||
|
||||
def test_text_plus_image_keeps_both():
|
||||
flat = _flatten_result(_result(_text("Took a screenshot"), _image()))
|
||||
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
||||
assert body == "Took a screenshot\n[1 image attached; displayed to the user]"
|
||||
assert json.loads(payload)[0]["mimeType"] == "image/png"
|
||||
|
||||
|
||||
def test_multiple_images_pluralized():
|
||||
flat = _flatten_result(_result(_image(), _image(mime = "image/jpeg")))
|
||||
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
||||
assert "[2 images attached; displayed to the user]" in body
|
||||
assert [img["mimeType"] for img in json.loads(payload)] == ["image/png", "image/jpeg"]
|
||||
|
||||
|
||||
def test_strip_result_for_model_drops_image_payload():
|
||||
flat = _flatten_result(_result(_text("Took a screenshot"), _image()))
|
||||
stripped = strip_result_for_model(flat)
|
||||
assert stripped == "Took a screenshot\n[1 image attached; displayed to the user]"
|
||||
assert PNG_B64 not in stripped
|
||||
|
||||
|
||||
def test_strip_preserves_literal_mcp_sentinel_in_text():
|
||||
# A tool that legitimately returns text containing the marker (e.g. reading
|
||||
# source/docs that quote it) must not be truncated: the suffix is not a
|
||||
# valid JSON image array.
|
||||
text = "before\n__MCP_IMAGES__: literal from source\nafter"
|
||||
assert strip_result_for_model(text) == text
|
||||
|
||||
|
||||
def test_strip_preserves_non_image_json_after_marker():
|
||||
text = 'log line\n__MCP_IMAGES__:["not", "image", "dicts"]'
|
||||
assert strip_result_for_model(text) == text
|
||||
|
||||
|
||||
def test_strip_removes_only_valid_terminal_envelope():
|
||||
text = (
|
||||
"Earlier mention: __MCP_IMAGES__: is documented here"
|
||||
"\n[1 image attached; displayed to the user]"
|
||||
'\n__MCP_IMAGES__:[{"data": "AAAA", "mimeType": "image/png"}]'
|
||||
)
|
||||
assert strip_result_for_model(text) == (
|
||||
"Earlier mention: __MCP_IMAGES__: is documented here"
|
||||
"\n[1 image attached; displayed to the user]"
|
||||
)
|
||||
|
||||
|
||||
def test_strip_still_handles_images_and_rag_sentinels():
|
||||
assert strip_result_for_model("output\n__IMAGES__:['a.png']") == "output"
|
||||
assert strip_result_for_model("answer\n__RAG_SOURCES__:[{}]") == "answer"
|
||||
|
||||
|
||||
def test_error_result_keeps_error_prefix_and_images():
|
||||
flat = _flatten_result(_result(_text("boom"), _image(), is_error = True))
|
||||
assert flat.startswith("Error: boom")
|
||||
assert is_tool_error(flat)
|
||||
assert MCP_IMAGES_SENTINEL in flat
|
||||
|
||||
|
||||
def test_image_only_error_no_longer_reports_no_content():
|
||||
flat = _flatten_result(_result(_image(), is_error = True))
|
||||
assert flat.startswith("Error: [1 image attached")
|
||||
assert "tool returned no content" not in flat
|
||||
|
||||
|
||||
def test_oversized_image_omitted_with_note():
|
||||
huge = "A" * (MAX_IMAGE_PAYLOAD_CHARS + 1)
|
||||
flat = _flatten_result(_result(_image(data = huge)))
|
||||
assert flat == "[1 image omitted (too large)]"
|
||||
assert MCP_IMAGES_SENTINEL not in flat
|
||||
|
||||
|
||||
def test_oversized_budget_shared_across_images():
|
||||
big = "A" * (MAX_IMAGE_PAYLOAD_CHARS - 10)
|
||||
flat = _flatten_result(_result(_image(data = big), _image()))
|
||||
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
||||
assert "1 image attached" in body
|
||||
assert "1 image omitted (too large)" in body
|
||||
images = json.loads(payload)
|
||||
assert len(images) == 1 and images[0]["data"] == big
|
||||
|
||||
|
||||
def test_non_image_binary_block_still_ignored():
|
||||
flat = _flatten_result(
|
||||
_result(SimpleNamespace(type = "audio", data = PNG_B64, mimeType = "audio/wav"))
|
||||
)
|
||||
assert flat == ""
|
||||
|
||||
|
||||
def test_structured_content_fallback_still_used():
|
||||
flat = _flatten_result(_result(structured = {"ok": True}))
|
||||
assert flat == "{'ok': True}"
|
||||
|
||||
|
||||
def test_call_tool_sync_passes_raise_on_error_false_and_keeps_error_images(monkeypatch):
|
||||
# Guards that call_tool_sync passes raise_on_error=False, so an is_error result
|
||||
# with image content reaches _flatten_result instead of FastMCP raising ToolError.
|
||||
seen = {}
|
||||
|
||||
class _FakeClient:
|
||||
async def call_tool(
|
||||
self,
|
||||
name,
|
||||
args,
|
||||
raise_on_error = True,
|
||||
):
|
||||
seen["raise_on_error"] = raise_on_error
|
||||
return _result(_text("boom"), _image(), is_error = True)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _fake_client(url, headers, use_oauth):
|
||||
yield _FakeClient()
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_client", _fake_client)
|
||||
out = call_tool_sync("http://x", None, "take_screenshot", {})
|
||||
|
||||
assert seen["raise_on_error"] is False
|
||||
assert out.startswith("Error: boom")
|
||||
assert MCP_IMAGES_SENTINEL in out
|
||||
assert is_tool_error(out)
|
||||
|
|
@ -198,7 +198,12 @@ def test_call_tool_sync_respects_pre_set_cancel_event(monkeypatch):
|
|||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def call_tool(self, name, args):
|
||||
async def call_tool(
|
||||
self,
|
||||
name,
|
||||
args,
|
||||
raise_on_error = True,
|
||||
):
|
||||
import asyncio as _asyncio
|
||||
await _asyncio.sleep(30) # never finishes during the test
|
||||
|
||||
|
|
@ -520,7 +525,12 @@ def test_call_tool_sync_short_circuits_on_pre_set_cancel(monkeypatch):
|
|||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def call_tool(self, name, args):
|
||||
async def call_tool(
|
||||
self,
|
||||
name,
|
||||
args,
|
||||
raise_on_error = True,
|
||||
):
|
||||
return "ran"
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_client", lambda *a, **kw: _StubClient())
|
||||
|
|
|
|||
|
|
@ -93,7 +93,12 @@ class _RecordingClient:
|
|||
async def list_tools(self):
|
||||
return [_FakeTool("list_directory"), _FakeTool("write_file")]
|
||||
|
||||
async def call_tool(self, name, args):
|
||||
async def call_tool(
|
||||
self,
|
||||
name,
|
||||
args,
|
||||
raise_on_error = True,
|
||||
):
|
||||
return _FakeResult(f"called {name}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,30 @@ function ToolFallbackArgs({
|
|||
);
|
||||
}
|
||||
|
||||
interface McpImageResult {
|
||||
text: string;
|
||||
images: { data: string; mimeType: string }[];
|
||||
}
|
||||
|
||||
function isMcpImageResult(val: unknown): val is McpImageResult {
|
||||
if (typeof val !== "object" || val === null) {
|
||||
return false;
|
||||
}
|
||||
const v = val as { text?: unknown; images?: unknown };
|
||||
return (
|
||||
typeof v.text === "string" &&
|
||||
Array.isArray(v.images) &&
|
||||
v.images.length > 0 &&
|
||||
v.images.every(
|
||||
(img: unknown) =>
|
||||
typeof img === "object" &&
|
||||
img !== null &&
|
||||
typeof (img as { data?: unknown }).data === "string" &&
|
||||
typeof (img as { mimeType?: unknown }).mimeType === "string",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackResult({
|
||||
result,
|
||||
className,
|
||||
|
|
@ -271,6 +295,8 @@ function ToolFallbackResult({
|
|||
return null;
|
||||
}
|
||||
|
||||
const imageResult = isMcpImageResult(result) ? result : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-result"
|
||||
|
|
@ -278,9 +304,30 @@ function ToolFallbackResult({
|
|||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
|
||||
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
{imageResult ? (
|
||||
<>
|
||||
{imageResult.text && (
|
||||
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
|
||||
{imageResult.text}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{imageResult.images.map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt={`Tool result ${i + 1}`}
|
||||
loading="lazy"
|
||||
className="max-w-full rounded border border-border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -901,6 +901,33 @@ function serializeAssistantToolCallPart(
|
|||
return entry;
|
||||
}
|
||||
|
||||
export interface McpImageToolResult {
|
||||
text: string;
|
||||
images: { data: string; mimeType: string }[];
|
||||
}
|
||||
|
||||
export function isMcpImageToolResult(
|
||||
val: unknown,
|
||||
): val is McpImageToolResult {
|
||||
if (typeof val !== "object" || val === null) {
|
||||
return false;
|
||||
}
|
||||
const v = val as { text?: unknown; images?: unknown; sessionId?: unknown };
|
||||
return (
|
||||
typeof v.text === "string" &&
|
||||
v.sessionId === undefined &&
|
||||
Array.isArray(v.images) &&
|
||||
v.images.length > 0 &&
|
||||
v.images.every(
|
||||
(img: unknown) =>
|
||||
typeof img === "object" &&
|
||||
img !== null &&
|
||||
typeof (img as { data?: unknown }).data === "string" &&
|
||||
typeof (img as { mimeType?: unknown }).mimeType === "string",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function serializeToolResultPart(
|
||||
part: ToolCallMessagePart,
|
||||
): SerializedToolResult | null {
|
||||
|
|
@ -920,6 +947,8 @@ function serializeToolResultPart(
|
|||
// content; serialise a sentinel JSON so legitimately empty tool
|
||||
// outputs still round-trip the follow-up turn to the provider.
|
||||
content = result.length > 0 ? result : JSON.stringify({ result: "" });
|
||||
} else if (isMcpImageToolResult(result)) {
|
||||
content = result.text.length > 0 ? result.text : JSON.stringify({ result: "" });
|
||||
} else {
|
||||
try {
|
||||
content = JSON.stringify(result);
|
||||
|
|
@ -3196,9 +3225,12 @@ export function createOpenAIStreamAdapter(
|
|||
const rawResult = (toolEvent.result as string) ?? "";
|
||||
const imgMarker = "\n__IMAGES__:";
|
||||
const imgIdx = rawResult.lastIndexOf(imgMarker);
|
||||
const mcpImgMarker = "\n__MCP_IMAGES__:";
|
||||
const mcpImgIdx = rawResult.lastIndexOf(mcpImgMarker);
|
||||
let parsedResult:
|
||||
| string
|
||||
| { text: string; images: string[]; sessionId: string }
|
||||
| McpImageToolResult
|
||||
| {
|
||||
image_b64: string;
|
||||
image_mime: string;
|
||||
|
|
@ -3208,6 +3240,24 @@ export function createOpenAIStreamAdapter(
|
|||
prompt?: string;
|
||||
};
|
||||
const imageB64 = toolEvent.image_b64 as string | undefined;
|
||||
// A valid MCP image envelope wins; an invalid marker falls
|
||||
// through so a sandbox __IMAGES__ suffix still renders and
|
||||
// legit text round-trips unchanged.
|
||||
let mcpImages: McpImageToolResult | null = null;
|
||||
if (mcpImgIdx !== -1) {
|
||||
try {
|
||||
const images = JSON.parse(
|
||||
rawResult.slice(mcpImgIdx + mcpImgMarker.length),
|
||||
);
|
||||
const candidate = {
|
||||
text: rawResult.slice(0, mcpImgIdx),
|
||||
images,
|
||||
};
|
||||
if (isMcpImageToolResult(candidate)) mcpImages = candidate;
|
||||
} catch {
|
||||
// Not a valid envelope; fall through below.
|
||||
}
|
||||
}
|
||||
if (
|
||||
toolCallParts[idx].toolName === "image_generation" &&
|
||||
typeof imageB64 === "string" &&
|
||||
|
|
@ -3225,6 +3275,8 @@ export function createOpenAIStreamAdapter(
|
|||
background: toolEvent.background as string | undefined,
|
||||
prompt: toolEvent.prompt as string | undefined,
|
||||
};
|
||||
} else if (mcpImages !== null) {
|
||||
parsedResult = mcpImages;
|
||||
} else if (imgIdx !== -1) {
|
||||
const text = rawResult.slice(0, imgIdx);
|
||||
// Fall back to "_default" to match the backend sandbox
|
||||
|
|
|
|||
|
|
@ -28,13 +28,43 @@ const SEARCH_REBUILD_DEBOUNCE_MS = 300;
|
|||
// Keys whose values are base64 image/audio payloads, not searchable text.
|
||||
const BINARY_KEY = /b64|base64|^(images?|audio|video)$/i;
|
||||
|
||||
// Drop a trailing __MCP_IMAGES__ envelope only when it is the valid JSON image
|
||||
// array appended by the backend, so legit tool text that merely mentions the
|
||||
// marker stays searchable. (base64 runs below are scrubbed regardless.)
|
||||
function stripMcpImageSuffix(value: string): string {
|
||||
const marker = "\n__MCP_IMAGES__:";
|
||||
const idx = value.lastIndexOf(marker);
|
||||
if (idx === -1) return value;
|
||||
try {
|
||||
const images: unknown = JSON.parse(value.slice(idx + marker.length));
|
||||
if (
|
||||
Array.isArray(images) &&
|
||||
images.length > 0 &&
|
||||
images.every(
|
||||
(img) =>
|
||||
typeof img === "object" &&
|
||||
img !== null &&
|
||||
typeof (img as Record<string, unknown>).data === "string" &&
|
||||
typeof (img as Record<string, unknown>).mimeType === "string",
|
||||
)
|
||||
) {
|
||||
return value.slice(0, idx);
|
||||
}
|
||||
} catch {
|
||||
// Not a valid envelope; leave the text intact.
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Readable text from tool args/results, dropping base64 image/audio blobs so
|
||||
// they never bloat the index (object fields by key, plus data URLs / long
|
||||
// base64 runs and the "__IMAGES__" suffix inside strings).
|
||||
function searchableText(value: unknown, depth = 0): string {
|
||||
if (typeof value === "string") {
|
||||
const cut = value.indexOf("\n__IMAGES__:");
|
||||
return (cut === -1 ? value : value.slice(0, cut))
|
||||
let text = stripMcpImageSuffix(value);
|
||||
const cut = text.indexOf("\n__IMAGES__:");
|
||||
if (cut !== -1) text = text.slice(0, cut);
|
||||
return text
|
||||
.replace(/data:[^;,\s]+;base64,[A-Za-z0-9+/=]+/g, " ")
|
||||
.replace(/[A-Za-z0-9+/]{120,}={0,2}/g, " ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
syncStoredChatMessages,
|
||||
} from "../utils/chat-history-storage";
|
||||
import { notifyChatHistoryUpdated } from "../api/chat-api";
|
||||
import { isMcpImageToolResult } from "../api/chat-adapter";
|
||||
import { usePlusMenuPrefsStore } from "../stores/plus-menu-prefs-store";
|
||||
import type { ThreadRecord, MessageRecord } from "../types";
|
||||
|
||||
|
|
@ -170,11 +171,14 @@ function contentBlocksToText(content: unknown): string {
|
|||
parts.push("[thinking]\n" + thinkText + "\n[/thinking]");
|
||||
}
|
||||
} else if (p.type === "tool-call") {
|
||||
// Keep base64 image payloads out of every export format: use the
|
||||
// model-visible text for MCP image results (matches chat replay).
|
||||
const result = isMcpImageToolResult(p.result) ? p.result.text : p.result;
|
||||
parts.push(
|
||||
JSON.stringify({
|
||||
tool_call: p.toolName,
|
||||
args: p.args,
|
||||
result: p.result,
|
||||
result,
|
||||
}),
|
||||
);
|
||||
} else if (p.type === "image") {
|
||||
|
|
@ -299,7 +303,15 @@ function messageToOpenAI(msg: { role: unknown; content: unknown; attachments?: u
|
|||
const argsStr = p.args != null ? JSON.stringify(p.args) : (typeof p.argsText === "string" ? p.argsText : "{}");
|
||||
toolCalls.push({ id, type: "function", function: { name, arguments: argsStr } });
|
||||
if (p.result !== undefined && p.result !== null) {
|
||||
const resultStr = typeof p.result === "string" ? p.result : JSON.stringify(p.result);
|
||||
// Keep base64 image payloads out of exports: MCP image results carry
|
||||
// their model-visible text alongside the data, so serialize the text
|
||||
// (matching chat replay) instead of the full object.
|
||||
const resultStr =
|
||||
typeof p.result === "string"
|
||||
? p.result
|
||||
: isMcpImageToolResult(p.result)
|
||||
? p.result.text
|
||||
: JSON.stringify(p.result);
|
||||
toolResults.push({ role: "tool", tool_call_id: id, name, content: resultStr });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue