forked from enderofwings/NexusOS
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:
co-authored by
Claude Opus 5
parent
425184a30b
commit
affba1805c
+257
-4
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from . import code_run
|
||||
from .memory.store import store, MemoryItem
|
||||
from .ollama_manager import get_ollama_manager
|
||||
|
||||
@@ -742,6 +743,152 @@ async def _render_preview(
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_snippet — the execution track
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# render_preview and run_snippet are deliberately separate tools over separate
|
||||
# registries. A preview is validated here and *rendered* by the browser inside a
|
||||
# sandboxed frame; a snippet is *executed* on the host by synapse/code_run.py and
|
||||
# comes back as terminal output. Stretching one tool across both would have meant
|
||||
# a `lang` enum where half the values run server-side and half don't, and a
|
||||
# single description that could only be vague about which. The split is the
|
||||
# feature: the model picks a track by picking a tool.
|
||||
#
|
||||
# Result fence: one ```nexus-run block whose body is JSON (source + streams +
|
||||
# exit code), so the code and the output it produced cannot be separated by a
|
||||
# model pasting only half of it. Backticks in the source are re-encoded as \u0060
|
||||
# — still valid JSON, and it cannot terminate the fence early.
|
||||
_RUN_FENCE_LANG = "nexus-run"
|
||||
|
||||
|
||||
def _run_fence(payload: dict) -> str:
|
||||
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
|
||||
return f"```{_RUN_FENCE_LANG}\n{body}\n```"
|
||||
|
||||
|
||||
def _run_lang_prose() -> str:
|
||||
return code_run.lang_prose()
|
||||
|
||||
|
||||
def _run_scaffold_for(lang: str) -> str:
|
||||
"""Minimal entry-point patterns for a struggling model. Shown from the
|
||||
second rejection on — same discipline as render_preview's scaffold."""
|
||||
return {
|
||||
"python": "print(sum(range(10)))\n",
|
||||
"c": (
|
||||
"#include <stdio.h>\n"
|
||||
"int main(void) {\n"
|
||||
' printf("%d\\n", 42);\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
"cpp": (
|
||||
"#include <iostream>\n"
|
||||
"int main() {\n"
|
||||
' std::cout << 42 << "\\n";\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
"rust": 'fn main() { println!("{}", 42); }\n',
|
||||
"erlang": 'main(_) -> io:format("~p~n", [42]).\n',
|
||||
}.get(lang, f"(a short self-contained {lang} program that prints to stdout)\n")
|
||||
|
||||
|
||||
def _with_run_scaffold(payload: dict, lang: str, attempt: int) -> dict:
|
||||
"""Attach a starting pattern from the second rejection on. First rejection
|
||||
stays issues-only so a pasteable scaffold does not become the answer."""
|
||||
if attempt < 1:
|
||||
return payload
|
||||
return {
|
||||
**payload,
|
||||
"scaffold": _run_scaffold_for(lang),
|
||||
"scaffold_note": (
|
||||
"A pattern to adapt, not an answer to paste. Keep the entry point, "
|
||||
"print results to stdout, and drop anything you do not use."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _run_snippet(
|
||||
lang: str = "",
|
||||
source: str = "",
|
||||
stdin: str = "",
|
||||
title: str = "",
|
||||
_attempt: int = 0,
|
||||
**_,
|
||||
) -> str:
|
||||
"""Compile and run a snippet, returning a fence that carries both the source
|
||||
and what it printed.
|
||||
|
||||
ACTION tool: it executes code on this machine. `action_tool_policy` gates it
|
||||
(withheld on "off", per-call Approve/Deny on "ask"), and synapse/code_run.py
|
||||
documents exactly how much containment the run itself gets — which is less
|
||||
than the word "sandbox" would imply.
|
||||
|
||||
`_attempt` is supplied by the tool loop, not by the model — it is how many
|
||||
times this call has already been rejected in the current turn."""
|
||||
import asyncio as _a
|
||||
|
||||
key = code_run.resolve_lang(lang)
|
||||
source = (source or "").strip()
|
||||
title = (title or "").strip()
|
||||
|
||||
if key not in code_run.RUN_LANGS:
|
||||
return json.dumps({
|
||||
"ok": False,
|
||||
"error": f"lang must be {_run_lang_prose()} (got {lang!r}). "
|
||||
"For HTML, SVG or JSX use render_preview instead — those are "
|
||||
"rendered in the browser, not executed here.",
|
||||
})
|
||||
if not source:
|
||||
return json.dumps(_with_run_scaffold({
|
||||
"ok": False,
|
||||
"error": f"source is required — send the complete {key} program, "
|
||||
"entry point included.",
|
||||
}, key, _attempt))
|
||||
|
||||
issues = code_run.critique(key, source)
|
||||
if issues:
|
||||
return json.dumps(_with_run_scaffold({
|
||||
"ok": False,
|
||||
"issues": issues,
|
||||
"hint": (
|
||||
"Fix these and call run_snippet again. The program runs in a throwaway "
|
||||
"directory with no network and a few seconds of CPU: no downloads, no "
|
||||
"absolute paths, no unbounded loops. Print your results to stdout."
|
||||
),
|
||||
}, key, _attempt))
|
||||
|
||||
result = await _a.to_thread(code_run.run, key, source, stdin or "")
|
||||
if not result.get("ok"):
|
||||
# A failed compile hands back the compiler's own diagnostics: they name
|
||||
# the line and the fix, and paraphrasing them here would only lose that.
|
||||
return json.dumps({k: v for k, v in result.items() if v not in ("", None)})
|
||||
|
||||
return json.dumps({
|
||||
"ok": True,
|
||||
"lang": key,
|
||||
"exit_code": result["exit_code"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
"title": title or f"{key} output",
|
||||
"instruction": (
|
||||
"This ran for real — the output below is what it printed. Write a short "
|
||||
"intro, then paste the fenced block exactly as it is. Do not wrap it in a "
|
||||
"second fence, retype the output, or claim results it does not show."
|
||||
),
|
||||
"fence": _run_fence({
|
||||
"lang": key,
|
||||
"source": source,
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
"exit_code": result["exit_code"],
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
|
||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
"search_memory": (
|
||||
@@ -870,6 +1017,62 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_render_preview,
|
||||
),
|
||||
"run_snippet": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_snippet",
|
||||
# Same house style as render_preview: imperative, second person,
|
||||
# nothing the model can recite back in place of acting.
|
||||
"description": (
|
||||
f"Compile and run a short {code_run.lang_prose()} program on this "
|
||||
"machine and get its real output back. Use this when the answer "
|
||||
"depends on what the code actually does — output, a computed "
|
||||
"result, whether it compiles. Write one self-contained file with "
|
||||
"its entry point; there is no package manager, no network, a "
|
||||
f"throwaway working directory and {code_run.RUN_TIMEOUT:g}s of "
|
||||
"runtime, so bound your loops and print results to stdout. "
|
||||
"For HTML, SVG or JSX use render_preview instead. "
|
||||
"Rejected: fix what `issues` or `stderr` says and send it again. "
|
||||
"Accepted: paste the returned `fence` into your reply unchanged, "
|
||||
"and describe only the output it actually contains."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"enum": list(code_run.RUN_LANGS),
|
||||
"description": (
|
||||
"Language to run: "
|
||||
+ "; ".join(
|
||||
f"{name} ({spec['summary']})"
|
||||
for name, spec in code_run.RUN_LANGS.items()
|
||||
)
|
||||
),
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short label for what this program does.",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The complete single-file program, entry point included. "
|
||||
"Standard library only."
|
||||
),
|
||||
},
|
||||
"stdin": {
|
||||
"type": "string",
|
||||
"description": "Optional text piped to the program's stdin.",
|
||||
},
|
||||
},
|
||||
"required": ["lang", "source"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_run_snippet,
|
||||
),
|
||||
"web_search": (
|
||||
{
|
||||
"type": "function",
|
||||
@@ -924,7 +1127,13 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
# Tools that act (write local state or reach the network). These require an
|
||||
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
||||
# allowlist — a playbook granting one isn't enough on its own.
|
||||
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
|
||||
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember", "run_snippet"})
|
||||
|
||||
# Action tools offered on a *cue* rather than only via a playbook allowlist —
|
||||
# the run track is a standing UI capability like the render window, but unlike
|
||||
# render_preview it executes code, so it stays behind the action gate. Listed
|
||||
# here so main.py can advertise it on a "run this" without a playbook edit.
|
||||
CUED_ACTION_TOOLS = frozenset({"run_snippet"})
|
||||
|
||||
# Always advertised when the user asks for a visual (see wants_render_preview).
|
||||
# Not playbook-gated — the render window is a standing UI capability.
|
||||
@@ -949,16 +1158,51 @@ _RENDER_HINTS = (
|
||||
) + tuple(PREVIEW_LANGS)
|
||||
|
||||
|
||||
def wants_render_preview(message: str) -> bool:
|
||||
"""True when this turn should advertise render_preview / enter the tool loop."""
|
||||
# Cues for run_snippet. Unlike _RENDER_HINTS these are verb phrases, not topic
|
||||
# words, and deliberately do not include the language names: "write me a python
|
||||
# function" is not a request to execute anything, and putting `python` in here
|
||||
# would drag every mention of the language into a slow non-stream tool round.
|
||||
# Asking for the *output* is the signal, so that is what these match.
|
||||
_RUN_HINTS = (
|
||||
"run this", "run it", "run that", "run the code", "run this code",
|
||||
"run my code", "run the program", "run and show", "actually run",
|
||||
"run snippet", "run_snippet", "execute this", "execute it", "execute the code",
|
||||
"compile", "compiles", "does it compile", "does this compile",
|
||||
"show the output", "show me the output", "what does it print",
|
||||
"what does this print", "what's the output", "whats the output",
|
||||
"what is the output", "actual output", "real output",
|
||||
)
|
||||
|
||||
|
||||
def _mentions(message: str, hints: tuple[str, ...]) -> bool:
|
||||
"""True if any hint appears in `message` as a whole word/phrase.
|
||||
|
||||
The lookarounds rather than \\b: several hints end in a non-word character
|
||||
("what's the output"), where \\b would anchor to the apostrophe instead of
|
||||
the phrase and match inside longer words.
|
||||
"""
|
||||
import re
|
||||
lower = (message or "").lower()
|
||||
return any(
|
||||
re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower)
|
||||
for hint in _RENDER_HINTS
|
||||
for hint in hints
|
||||
)
|
||||
|
||||
|
||||
def wants_render_preview(message: str) -> bool:
|
||||
"""True when this turn should advertise render_preview / enter the tool loop."""
|
||||
return _mentions(message, _RENDER_HINTS)
|
||||
|
||||
|
||||
def wants_code_run(message: str) -> bool:
|
||||
"""True when this turn is asking for code to actually be executed.
|
||||
|
||||
Only a hint: run_snippet is an action tool, so this can never be what makes
|
||||
it available — `action_tool_policy` still has to be off "off" first.
|
||||
"""
|
||||
return _mentions(message, _RUN_HINTS)
|
||||
|
||||
|
||||
def is_action(name: str) -> bool:
|
||||
return name in ACTION_TOOLS
|
||||
|
||||
@@ -978,6 +1222,15 @@ def standing_schemas() -> list[dict]:
|
||||
return schemas_for(sorted(STANDING_TOOLS), allow_actions=True)
|
||||
|
||||
|
||||
def run_schemas() -> list[dict]:
|
||||
"""Schemas that ship with "run this" turns (currently just run_snippet).
|
||||
|
||||
Callers must have already established that actions are permitted — these are
|
||||
action tools and schemas_for would happily hand them over regardless.
|
||||
"""
|
||||
return schemas_for(sorted(CUED_ACTION_TOOLS), allow_actions=True)
|
||||
|
||||
|
||||
async def dispatch(name: str, args: dict | None) -> str:
|
||||
"""Run a tool by name. Never raises — returns an error string on failure."""
|
||||
entry = REGISTRY.get(name)
|
||||
|
||||
Reference in New Issue
Block a user