diff --git a/interface/web/src/Markdown.jsx b/interface/web/src/Markdown.jsx index 296b69d..e95550f 100644 --- a/interface/web/src/Markdown.jsx +++ b/interface/web/src/Markdown.jsx @@ -243,6 +243,9 @@ const _PREVIEW_BOOTSTRAP = ``; @@ -304,6 +307,14 @@ const _MIN_PREVIEW_H = 160; const _MAX_PREVIEW_H = 720; const _MAX_H_STEPS = 60; +// A frame that never posts again — a synchronous `while(true)` in the user's +// own script, or a runaway re-render loop the bootstrap's own coalescing +// can't outpace — has nothing else to signal it. Silence past this long since +// mount (or since the last message) is treated as hung and the frame is torn +// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never +// gets close to this. +const _WATCHDOG_MS = 6000; + // Live preview for a renderable fenced block: a Preview/Code toggle rendered // via a sandboxed iframe whose document is an encoded data: URL. // @@ -407,9 +418,11 @@ function PreviewFrame({ lang, value, expanded }) { const [doc, setDoc] = useState(""); const [buildError, setBuildError] = useState(""); const [height, setHeight] = useState(240); + const [hung, setHung] = useState(false); const frameRef = useRef(null); const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe const stepsRef = useRef(0); + const lastMsgRef = useRef(0); // set for real by the watchdog effect below // Receive the bootstrap's reports. The frame is on an opaque origin, so // e.origin is the string "null" and proves nothing - identify the sender by @@ -419,6 +432,7 @@ function PreviewFrame({ lang, value, expanded }) { if (!frameRef.current || e.source !== frameRef.current.contentWindow) return; const data = e.data; if (!data || data.__nexusPreview !== 1) return; + lastMsgRef.current = Date.now(); if (typeof data.err === "string" && data.err) setError(data.err); @@ -435,6 +449,23 @@ function PreviewFrame({ lang, value, expanded }) { return () => window.removeEventListener("message", onMessage); }, []); + // Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a + // synchronous infinite loop in the model's own script, which blocks even + // the bootstrap's heartbeat from ever running — gets torn down rather than + // left spinning. Checked on an interval rather than a single timeout so a + // message arriving late (slow compile, heavy first paint) keeps resetting + // the clock instead of tripping early. + useEffect(() => { + lastMsgRef.current = Date.now(); + const id = setInterval(() => { + if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) { + setHung(true); + clearInterval(id); + } + }, 1000); + return () => clearInterval(id); + }, [lang, value]); + useEffect(() => { let current = true; setDoc(""); @@ -448,9 +479,13 @@ function PreviewFrame({ lang, value, expanded }) { }, [lang, value]); // A build failure (JSX that doesn't parse) has no document to show at all, so - // the message stands in for the frame rather than sitting under it. - const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : ""; - const shown = buildError || error; + // the message stands in for the frame rather than sitting under it. A hung + // frame tears down the same way: dropping frameUrl unmounts the iframe, + // which is what actually stops a runaway script from holding the tab. + const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : ""; + const shown = hung + ? "Preview stopped responding (likely an infinite loop) and was stopped." + : buildError || error; return ( <> diff --git a/synapse/chat.py b/synapse/chat.py index 7ebb23f..5156f95 100644 --- a/synapse/chat.py +++ b/synapse/chat.py @@ -276,18 +276,23 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu ) if not isinstance(msg, dict): break # None/error or no tool support -> fall back to plain stream + native = bool(msg.get("tool_calls")) calls = _coerce_tool_calls(msg, allowed_names) if not calls: break # Normalize content-JSON tool calls into the shape later turns expect. - if not msg.get("tool_calls"): + if not native: msg = {"role": "assistant", "content": "", "tool_calls": calls} messages.append(msg) # If any action tool needs per-call approval, pause and wait for the user. + # A call recovered by guessing at `content` (no native tool_calls field) + # is a weaker signal than the API's own structured field — a model can + # land on JSON shaped like a call while only meaning to describe one, so + # it always goes through approval regardless of policy, even "allow". decisions = None action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))] - if policy == "ask" and action_calls: + if (policy == "ask" or not native) and action_calls: event = asyncio.Event() # Single-use capability token, delivered only to the client that owns # this stream. /chat/approve requires it, so knowing the (guessable, diff --git a/tests/test_tools.py b/tests/test_tools.py index 9d2c7a1..50218fb 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -93,6 +93,52 @@ def test_ask_policy_skips_on_deny(monkeypatch): assert any(m["role"] == "tool" and "declined" in m["content"] for m in messages) +class _ContentJsonActionManager: + """Small-model shape: dumps the action call into `content`, no native + `tool_calls` field — the lower-confidence path the "allow" bypass must + not trust.""" + def __init__(self): + self.n = 0 + + async def chat(self, **_): + self.n += 1 + if self.n == 1: + return {"role": "assistant", + "content": json.dumps({"name": "remember", "arguments": {"text": "x"}})} + return {"role": "assistant", "content": "done"} + + +def test_content_json_action_call_asks_even_under_allow_policy(monkeypatch): + """A call recovered by guessing at `content` is weaker evidence than the + API's own structured tool_calls field — a model can land on JSON shaped + like a call while only meaning to describe one. It must still go through + approval even when action_tool_policy is "allow", the default that lets a + *native* tool_calls field run unattended.""" + from synapse import chat as chatmod + + async def fake_dispatch(name, args): + return "saved-ok" + monkeypatch.setattr(tools, "dispatch", fake_dispatch) + + async def run(): + messages = [{"role": "user", "content": "remember x"}] + schemas = tools.schemas_for(["remember"]) + gen = chatmod._run_tool_loop(_ContentJsonActionManager(), messages, "m", schemas, None, None, + conversation_id="conv", policy="allow") + statuses = [] + async for s in gen: + statuses.append(s) + if s.startswith("__approve__"): + w = chatmod.pending_approvals["conv"] + w["decisions"] = {"remember": True} + w["event"].set() + return statuses + + statuses = asyncio.run(run()) + assert any(s.startswith("__approve__") for s in statuses) + assert "__status__remember" in statuses + + def test_action_tools_gated_by_consent(): allow = ["search_memory", "web_search", "remember", "fetch_url"] on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]