fix(preview): hung-frame watchdog + require approval for guessed action calls

A runaway preview script (sync infinite loop, or a re-render loop
outpacing the bootstrap's own coalescing) had nothing detecting it -
the frame just spun. The bootstrap now heartbeats every second, and the
parent tears the iframe down if it goes _WATCHDOG_MS silent, whatever
the cause.

_coerce_tool_calls recovers a tool call guessed from `content` for
models with no native tool_calls field. That guess is weaker evidence
than the API's own structured field - a model can land on JSON shaped
like a call while only meaning to describe one - so an action tool
recovered this way now always requires approval, even under the
"allow" policy that lets a native tool_calls field run unattended.
This commit is contained in:
Jon Wingender
2026-08-26 13:08:58 -05:00
parent 952ef8a0c4
commit 0d26f630e6
3 changed files with 91 additions and 5 deletions
+7 -2
View File
@@ -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,