feat(chat): add run_snippet, an execution track beside the render track

render_preview validates markup and hands it to the browser, which renders it
in an opaque-origin sandboxed iframe. Nothing executes server-side. That model
fits HTML/SVG/JSX and cannot fit C, Rust or Erlang, which need a real
toolchain - so those get a second tool instead of a widened first one.

The split is the feature: the model picks a track by picking a tool, rather
than picking a `lang` value from an enum where half the entries run
server-side and half do not.

synapse/code_run.py compiles and runs one file in a throwaway directory and
returns a ```nexus-run fence carrying the source and its captured output
together, so a model cannot paste output without the code that produced it.
Backticks in the source are re-encoded as ` - still valid JSON, and it
cannot close the fence early.

It is not a sandbox, and the module docstring says so up front. What it gives
is containment by layers: consent (an action tool, gated by
action_tool_policy, per-call Approve/Deny on "ask"), static screening, a
scrubbed environment in a temp dir, wall-clock and POSIX rlimits, and a
network namespace on Linux where unprivileged userns are available. Screening
is a tripwire against a model reaching for `requests` out of habit, not a
boundary against an adversary; layers 1 and 3-5 are the load-bearing ones.

Backend RUN_LANGS and frontend run-langs.js are separate registries because
the two sides need different things - one executes, one labels - and neither
should depend on the other at runtime. tests/test_tools.py asserts the key
sets and the fence tag stay equal, so drift fails the gate instead of
rendering a run result under the wrong language.

tests/snippet_probes/ is a data catalog rather than inlined cases, so adding a
language is a data change and the meta-tests can assert every RUN_LANGS key
has both a smoke probe and a screening probe. Probes skip cleanly on hosts
without the toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Athena Kaminsky
2026-08-20 14:30:03 -05:00
co-authored by Claude Opus 5
parent 425184a30b
commit affba1805c
14 changed files with 2016 additions and 14 deletions
+18 -5
View File
@@ -139,6 +139,10 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
pending_approvals: Dict[str, Dict[str, Any]] = {}
_APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all"
# Tools whose success is a fenced block the model must paste unchanged, and whose
# rejections are worth retrying (with `_attempt`) rather than abandoning.
_FENCE_TOOLS = frozenset({"render_preview", "run_snippet"})
def _as_tool_calls(obj) -> list:
"""Normalize a parsed JSON value into Ollama-style tool_calls entries."""
@@ -385,13 +389,16 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
# complete, styled demo handed to a struggling model gets pasted
# rather than adapted, and then persists into the conversation as a
# template for later requests.
if name == "render_preview" and isinstance(call_args, dict):
if name in _FENCE_TOOLS and isinstance(call_args, dict):
call_args = {**call_args, "_attempt": render_rejects}
result = await _tools.dispatch(name, call_args)
messages.append({"role": "tool", "content": result})
# Cap render_preview reject loops — each retry is another full
# non-stream generation and looks like the UI is "stuck thinking".
if name == "render_preview":
# Cap reject loops — each retry is another full non-stream
# generation and looks like the UI is "stuck thinking". The counter
# is shared across both fence tools on purpose: two failed attempts
# in a turn is two too many whether they were previews, runs, or one
# of each.
if name in _FENCE_TOOLS:
try:
body = _json.loads(result)
except Exception:
@@ -408,7 +415,13 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
def _last_ok_render_fence(messages: list) -> tuple[str | None, dict]:
"""Return (fence, tool_payload) from the latest successful render_preview."""
"""Return (fence, tool_payload) from the latest successful fence tool.
Covers render_preview and run_snippet alike — both return {ok, fence, title}
and both are pasted verbatim rather than reconstructed. Small models rewrite
a fence they were told to copy, which for a preview means a demo that no
longer runs and for a run means output the program never actually produced.
"""
for m in reversed(messages or []):
if m.get("role") != "tool":
continue