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
+18
-5
@@ -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
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Run a short code snippet in an ephemeral working directory.
|
||||
|
||||
This is the *second* track of the code-preview feature and deliberately not the
|
||||
first. `render_preview` (synapse/tools.py) executes nothing server-side: it
|
||||
validates markup and the chat UI renders it inside an opaque-origin iframe.
|
||||
That model fits HTML/SVG/JSX and cannot fit C, Rust or Erlang, which need a real
|
||||
toolchain. So those go through here instead, and the result is shown as terminal
|
||||
output rather than a rendered document.
|
||||
|
||||
WHAT THIS IS NOT
|
||||
----------------
|
||||
Not a security sandbox. Snippets run as the current user on the host. What this
|
||||
module actually provides is *containment by limits*, layered:
|
||||
|
||||
1. Consent run_snippet is an ACTION tool, so it is withheld entirely
|
||||
unless `action_tool_policy` is "ask"/"allow" — and on "ask"
|
||||
every call waits for the user's Approve/Deny in chat.
|
||||
2. Screening `critique` rejects the obvious-abuse shapes (sockets, process
|
||||
spawning, absolute paths) before anything is written to disk.
|
||||
3. Isolation cwd is a fresh temp dir that is deleted afterwards; HOME and
|
||||
TMPDIR point at it; the environment is scrubbed to a small
|
||||
allowlist.
|
||||
4. Limits wall-clock timeout, RLIMIT_CPU/AS/FSIZE/NPROC on POSIX,
|
||||
truncated output.
|
||||
5. Network on Linux, `unshare -rn` when unprivileged user namespaces are
|
||||
available (probed once, see `_net_isolation`). Nowhere else —
|
||||
macOS and Windows get no network isolation at all.
|
||||
|
||||
Layer 2 is a tripwire, not a boundary: arbitrary Python can evade any regex
|
||||
trivially. It exists to catch a model that reaches for `requests` by habit, not
|
||||
an adversary. The load-bearing layers are 1 and 3–5. Anyone wanting a real
|
||||
boundary should run this under a container or a VM; that is a deployment
|
||||
decision this module does not make for them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Wall clock. Compilation gets its own, larger budget: rustc on a cold cache
|
||||
# routinely spends longer than any snippet is allowed to *run*, and killing a
|
||||
# compile at the run timeout would look like the code hung when it never started.
|
||||
RUN_TIMEOUT = 5.0
|
||||
COMPILE_TIMEOUT = 25.0
|
||||
|
||||
MAX_SOURCE = 100_000 # chars of source accepted
|
||||
MAX_OUTPUT = 20_000 # chars of stdout/stderr returned, per stream
|
||||
MAX_STDIN = 10_000
|
||||
|
||||
# Applied to the run step only. A compiler legitimately needs more address space
|
||||
# than a snippet does and forks a linker, so imposing these on the compile step
|
||||
# breaks the toolchain rather than containing the snippet.
|
||||
_MEM_BYTES = 512 * 1024 * 1024
|
||||
_MAX_PROCS = 64
|
||||
_MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
# RLIMIT_NPROC is counted per real UID, not per run. An absolute "512" is
|
||||
# therefore 512 minus whatever the user already has — enough on a quiet host,
|
||||
# zero on a busy macOS desktop. Erlang needs relative headroom for BEAM's
|
||||
# boot process tree; other languages keep the absolute _MAX_PROCS (blocking
|
||||
# fork is the intent there).
|
||||
_ERLANG_PROC_HEADROOM = 256
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static screening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _shared_issues(source: str) -> list[str]:
|
||||
stripped = source.strip()
|
||||
if len(stripped) < 8:
|
||||
return ["source is empty or too short to run."]
|
||||
issues: list[str] = []
|
||||
# Only flag URLs inside string literals. A citation in a comment
|
||||
# (`/* see https://… */`) is not a network reach and bouncing it costs a
|
||||
# useless retry round — this layer is a tripwire, not a parser.
|
||||
if re.search(r"""['"][^'"]*https?://[^'"]*['"]""", source):
|
||||
issues.append(
|
||||
"source contains an http(s) URL string — the runner has no network "
|
||||
"access. Inline the data you need."
|
||||
)
|
||||
if re.search(r"\b(TODO|FIXME|your code here|implement this)\b", source, re.I):
|
||||
issues.append("source still contains a placeholder — send the finished code.")
|
||||
# An absolute path is either reaching outside the ephemeral dir or is a
|
||||
# machine-specific guess that will not exist. Relative paths are fine: cwd
|
||||
# is the temp dir and goes away with it.
|
||||
# Windows: match `C:\…` and `C:\\…` (raw / escaped). Unix: common roots.
|
||||
if re.search(
|
||||
r"""['"](?:/(?:etc|home|root|usr|var|proc|sys)/|[A-Za-z]:[/\\])""",
|
||||
source,
|
||||
):
|
||||
issues.append(
|
||||
"source references an absolute filesystem path. The snippet runs in a "
|
||||
"throwaway directory — use relative paths, or inline the data."
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _deny(source: str, rules: list[tuple[str, str]]) -> list[str]:
|
||||
return [msg for pattern, msg in rules if re.search(pattern, source)]
|
||||
|
||||
|
||||
_NO_NET = "the runner has no network access"
|
||||
_NO_SPAWN = "the runner does not allow spawning other processes"
|
||||
|
||||
_PY_RULES = [
|
||||
(r"\b(?:import|from)\s+(?:socket|ssl|ftplib|smtplib|telnetlib|urllib|http)\b",
|
||||
f"networking module imported — {_NO_NET}."),
|
||||
(r"\bimport\s+(?:requests|httpx|aiohttp|urllib3)\b",
|
||||
f"HTTP client imported — {_NO_NET}."),
|
||||
(r"\b(?:import|from)\s+(?:subprocess|multiprocessing)\b",
|
||||
f"subprocess/multiprocessing imported — {_NO_SPAWN}."),
|
||||
(r"\bos\.(?:system|popen|exec[lv]|fork|spawn|kill)\b",
|
||||
f"os process call — {_NO_SPAWN}."),
|
||||
(r"\b(?:import|from)\s+ctypes\b",
|
||||
"ctypes imported — the runner does not allow native calls."),
|
||||
]
|
||||
|
||||
_C_RULES = [
|
||||
(r"#\s*include\s*<(?:sys/socket|netinet/|arpa/|netdb)",
|
||||
f"socket header included — {_NO_NET}."),
|
||||
(r"\b(?:system|popen|fork|execv?[lpe]*)\s*\(",
|
||||
f"process call — {_NO_SPAWN}."),
|
||||
]
|
||||
|
||||
_RUST_RULES = [
|
||||
(r"\bstd::net\b", f"std::net used — {_NO_NET}."),
|
||||
(r"\bstd::process::(?:Command|abort)\b", f"std::process::Command used — {_NO_SPAWN}."),
|
||||
]
|
||||
|
||||
_ERL_RULES = [
|
||||
(r"\b(?:gen_tcp|gen_udp|httpc|inets|ssl)\b", f"networking module used — {_NO_NET}."),
|
||||
(r"\bos:cmd\b", f"os:cmd/1 used — {_NO_SPAWN}."),
|
||||
]
|
||||
|
||||
|
||||
def _entry_point(pattern: str, message: str):
|
||||
"""Most of these languages fail with a linker/loader error rather than a
|
||||
useful one when the entry point is missing, so name it up front."""
|
||||
def check(source: str) -> list[str]:
|
||||
return [] if re.search(pattern, source) else [message]
|
||||
return check
|
||||
|
||||
|
||||
def _critique(rules: list[tuple[str, str]], entry=None):
|
||||
def check(source: str) -> list[str]:
|
||||
issues = _shared_issues(source)
|
||||
if issues and issues[0].startswith("source is empty"):
|
||||
return issues # nothing else is worth saying about an empty body
|
||||
issues += _deny(source, rules)
|
||||
if entry:
|
||||
issues += entry(source)
|
||||
return issues
|
||||
return check
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drivers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _source_name(default: str):
|
||||
return lambda _source: default
|
||||
|
||||
|
||||
def _erlang_source_name(source: str) -> str:
|
||||
"""escript reads the same file two different ways, chosen by extension: a
|
||||
`.erl` file is compiled as a module (needs -module/-export), anything else
|
||||
is a plain script (needs only main/1). Models write both, so let the source
|
||||
pick its own filename instead of forcing one dialect."""
|
||||
return "main.erl" if re.search(r"^\s*-module\s*\(", source, re.M) else "main.escript"
|
||||
|
||||
|
||||
def _erlang_write_source(source: str) -> str:
|
||||
"""OTP 28+ escript rejects a shebang-less `.escript` with 'Premature end of
|
||||
file'. Module-form `.erl` files do not need one. Inject only when missing so
|
||||
a model that already wrote `#!/usr/bin/env escript` is left alone."""
|
||||
if _erlang_source_name(source).endswith(".escript"):
|
||||
stripped = source.lstrip()
|
||||
if not stripped.startswith("#!"):
|
||||
return "#!/usr/bin/env escript\n" + source
|
||||
return source
|
||||
|
||||
|
||||
# The one place that says which languages can be executed. Each entry owns that
|
||||
# language's screening, toolchain probe and argv. The tool schema's `lang` enum,
|
||||
# the capability line in the system prompt and the dispatch below are all derived
|
||||
# from these keys rather than repeating them.
|
||||
#
|
||||
# The frontend keeps a matching registry (RUN_LANGS in
|
||||
# interface/web/src/preview/run-langs.js) because the two sides need different
|
||||
# things per language — this side executes, that side labels and displays — and
|
||||
# neither should depend on the other at runtime. tests/test_tools.py asserts the
|
||||
# key sets stay equal, so drift fails the check gate instead of silently showing
|
||||
# a run result with the wrong language on it.
|
||||
RUN_LANGS: dict[str, dict] = {
|
||||
"python": {
|
||||
"summary": "Python script (stdlib only)",
|
||||
"tool": lambda: sys.executable,
|
||||
"install": "Python is bundled with NexusOS; this should not happen.",
|
||||
"source_name": _source_name("main.py"),
|
||||
# -I is isolated mode: ignores PYTHON* env vars, the user site-packages
|
||||
# dir and the script's own directory on sys.path.
|
||||
"compile": None,
|
||||
"run": lambda tool, src, _exe: [tool, "-I", src],
|
||||
"critique": _critique(_PY_RULES),
|
||||
},
|
||||
"c": {
|
||||
"summary": "single-file C program (C11, libm linked)",
|
||||
"tool": lambda: shutil.which("cc") or shutil.which("gcc") or shutil.which("clang"),
|
||||
"install": "install a C compiler (clang or gcc)",
|
||||
"source_name": _source_name("main.c"),
|
||||
"compile": lambda cc, src, exe: [cc, "-std=c11", "-O0", "-Wall", "-o", exe, src, "-lm"],
|
||||
"run": lambda _tool, _src, exe: [exe],
|
||||
"critique": _critique(_C_RULES, _entry_point(
|
||||
r"\bmain\s*\(", "no main() — a C program needs `int main(void)`.")),
|
||||
},
|
||||
"cpp": {
|
||||
"summary": "single-file C++ program (C++17)",
|
||||
"tool": lambda: shutil.which("c++") or shutil.which("g++") or shutil.which("clang++"),
|
||||
"install": "install a C++ compiler (clang++ or g++)",
|
||||
"source_name": _source_name("main.cpp"),
|
||||
"compile": lambda cc, src, exe: [cc, "-std=c++17", "-O0", "-Wall", "-o", exe, src],
|
||||
"run": lambda _tool, _src, exe: [exe],
|
||||
"critique": _critique(_C_RULES, _entry_point(
|
||||
r"\bmain\s*\(", "no main() — a C++ program needs `int main()`.")),
|
||||
},
|
||||
"rust": {
|
||||
"summary": "single-file Rust program (2021 edition, std only)",
|
||||
"tool": lambda: shutil.which("rustc"),
|
||||
"install": "install Rust (https://rustup.rs)",
|
||||
"source_name": _source_name("main.rs"),
|
||||
# Debug build: -O roughly triples compile time for snippets that run for
|
||||
# milliseconds either way.
|
||||
"compile": lambda cc, src, exe: [cc, "--edition", "2021", "-o", exe, src],
|
||||
"run": lambda _tool, _src, exe: [exe],
|
||||
"critique": _critique(_RUST_RULES, _entry_point(
|
||||
r"\bfn\s+main\s*\(", "no main() — a Rust program needs `fn main()`.")),
|
||||
},
|
||||
"erlang": {
|
||||
"summary": "escript program with a main/1 entry point",
|
||||
"tool": lambda: shutil.which("escript"),
|
||||
"install": "install Erlang/OTP (provides escript)",
|
||||
"source_name": _erlang_source_name,
|
||||
"compile": None,
|
||||
"run": lambda tool, src, _exe: [tool, src],
|
||||
"critique": _critique(_ERL_RULES, _entry_point(
|
||||
r"\bmain\s*\(", "no main/1 — escript calls `main(Args)`.")),
|
||||
},
|
||||
}
|
||||
|
||||
# Spellings a model reaches for that aren't the registry key. Resolved before
|
||||
# lookup so `c++` and `py` work without doubling the tool schema's enum.
|
||||
ALIASES = {
|
||||
"c++": "cpp", "cc": "c", "py": "python", "python3": "python",
|
||||
"rs": "rust", "erl": "erlang", "escript": "erlang",
|
||||
}
|
||||
|
||||
|
||||
def resolve_lang(lang: str) -> str:
|
||||
key = (lang or "").strip().lower()
|
||||
return ALIASES.get(key, key)
|
||||
|
||||
|
||||
def lang_prose() -> str:
|
||||
"""'python, c or rust' — the runnable languages as a phrase for prompts."""
|
||||
names = list(RUN_LANGS)
|
||||
if len(names) < 2:
|
||||
return names[0] if names else ""
|
||||
return f"{', '.join(names[:-1])} or {names[-1]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Environment variables the child keeps. Everything else is dropped: the snippet
|
||||
# has no business seeing API keys, proxy settings or the user's shell config, and
|
||||
# PYTHON*/LD_* in particular would let ambient config change how it runs.
|
||||
_ENV_KEEP = ("PATH", "LANG", "LC_ALL", "TERM", "SYSTEMROOT", "COMSPEC")
|
||||
|
||||
# rustc is usually a rustup shim, and a shim with HOME rewritten cannot find its
|
||||
# own toolchain. Passing these through is what makes Rust work at all here; they
|
||||
# point at read-only toolchain data, not at anything the snippet should write.
|
||||
_ENV_KEEP_BY_LANG = {"rust": ("RUSTUP_HOME", "CARGO_HOME", "RUSTUP_TOOLCHAIN")}
|
||||
|
||||
|
||||
def _child_env(lang: str, workdir: Path) -> dict:
|
||||
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
|
||||
for k in _ENV_KEEP_BY_LANG.get(lang, ()):
|
||||
if k in os.environ:
|
||||
env[k] = os.environ[k]
|
||||
if lang == "rust" and "RUSTUP_HOME" not in env:
|
||||
# HOME is about to be rewritten, so resolve rustup's default location
|
||||
# against the real home while we still know it.
|
||||
default = Path.home() / ".rustup"
|
||||
if default.is_dir():
|
||||
env["RUSTUP_HOME"] = str(default)
|
||||
env.setdefault("CARGO_HOME", str(Path.home() / ".cargo"))
|
||||
env["HOME"] = str(workdir)
|
||||
env["TMPDIR"] = str(workdir)
|
||||
env.setdefault("LC_ALL", "C.UTF-8")
|
||||
return env
|
||||
|
||||
|
||||
def _user_process_count() -> int | None:
|
||||
"""How many processes this UID already owns. None when we cannot count
|
||||
(Windows, or psutil missing) — callers must not invent an absolute cap."""
|
||||
if os.name == "nt" or not hasattr(os, "getuid"):
|
||||
return None
|
||||
try:
|
||||
import psutil # optional; process extra
|
||||
except ImportError:
|
||||
return None
|
||||
uid = os.getuid()
|
||||
n = 0
|
||||
for proc in psutil.process_iter(["uids"]):
|
||||
try:
|
||||
uids = proc.info.get("uids")
|
||||
if uids is not None and getattr(uids, "real", None) == uid:
|
||||
n += 1
|
||||
except (psutil.Error, TypeError, AttributeError):
|
||||
continue
|
||||
return n
|
||||
|
||||
|
||||
def _max_procs_for(lang: str) -> int | None:
|
||||
"""Soft RLIMIT_NPROC for this run, or None to leave the limit unset.
|
||||
|
||||
Erlang: current per-UID count + headroom (RLIMIT_NPROC is UID-scoped).
|
||||
Everything else: the absolute _MAX_PROCS tripwire against fork bombs.
|
||||
"""
|
||||
if lang == "erlang":
|
||||
current = _user_process_count()
|
||||
if current is None:
|
||||
return None
|
||||
return current + _ERLANG_PROC_HEADROOM
|
||||
return _MAX_PROCS
|
||||
|
||||
|
||||
def _limits(constrain_memory: bool, max_procs: int | None = None):
|
||||
"""preexec_fn applying POSIX rlimits, or None where they don't exist.
|
||||
|
||||
RLIMIT_CPU is a backstop for the wall-clock timeout: a snippet that ignores
|
||||
SIGTERM still loses the CPU. The memory and process caps are skipped for
|
||||
compilation — see _MEM_BYTES.
|
||||
|
||||
`max_procs=None` with constrain_memory means "do not set RLIMIT_NPROC"
|
||||
(used when we cannot compute a relative Erlang ceiling). Passing an int
|
||||
always sets it.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return None
|
||||
try:
|
||||
import resource
|
||||
except ImportError: # pragma: no cover - POSIX only
|
||||
return None
|
||||
|
||||
cpu = int(COMPILE_TIMEOUT if not constrain_memory else RUN_TIMEOUT) + 1
|
||||
wanted = [("RLIMIT_CPU", cpu), ("RLIMIT_FSIZE", _MAX_FILE_BYTES), ("RLIMIT_CORE", 0)]
|
||||
if constrain_memory:
|
||||
wanted.append(("RLIMIT_AS", _MEM_BYTES))
|
||||
# Distinguish "caller omitted" (use default) from "explicitly skip"
|
||||
# by requiring the kw to be passed — see _spawn.
|
||||
if max_procs is not None:
|
||||
wanted.append(("RLIMIT_NPROC", max_procs))
|
||||
|
||||
def apply(): # runs in the forked child, between fork and exec
|
||||
# Every limit is set independently and failure is swallowed. Which of
|
||||
# these exist, and which can be lowered, varies by platform (macOS has
|
||||
# no usable RLIMIT_AS, RLIMIT_NPROC is absent on some POSIX systems) —
|
||||
# and an exception raised here does not "skip a limit", it aborts the
|
||||
# spawn entirely. Partial limits are the right failure mode; no run at
|
||||
# all is not.
|
||||
for name, soft in wanted:
|
||||
which = getattr(resource, name, None)
|
||||
if which is None:
|
||||
continue
|
||||
try:
|
||||
_, hard = resource.getrlimit(which)
|
||||
if hard != resource.RLIM_INFINITY:
|
||||
soft = min(soft, hard)
|
||||
resource.setrlimit(which, (soft, hard))
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
|
||||
return apply
|
||||
|
||||
|
||||
_net_isolation_cache: list | None = None
|
||||
|
||||
|
||||
def _net_isolation() -> list[str]:
|
||||
"""argv prefix that drops the child into an empty network namespace, or [].
|
||||
|
||||
Linux only, and only where unprivileged user namespaces are enabled — which
|
||||
is a kernel/distro setting we can't change and shouldn't fail over. Probed
|
||||
once and cached; an empty list means the run simply has host networking, and
|
||||
callers must not treat this as a guarantee either way.
|
||||
"""
|
||||
global _net_isolation_cache
|
||||
if _net_isolation_cache is not None:
|
||||
return _net_isolation_cache
|
||||
_net_isolation_cache = []
|
||||
if sys.platform.startswith("linux") and shutil.which("unshare"):
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["unshare", "-rn", "true"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
_net_isolation_cache = ["unshare", "-rn"]
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return _net_isolation_cache
|
||||
|
||||
|
||||
def _clip(raw: bytes) -> str:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
if len(text) <= MAX_OUTPUT:
|
||||
return text
|
||||
return text[:MAX_OUTPUT] + f"\n... [truncated at {MAX_OUTPUT} characters]"
|
||||
|
||||
|
||||
def _spawn(argv: list[str], workdir: Path, env: dict, timeout: float,
|
||||
stdin: str = "", constrain_memory: bool = True,
|
||||
max_procs: int | None = _MAX_PROCS):
|
||||
"""Spawn a child. `max_procs` defaults to `_MAX_PROCS`; pass `None` to skip
|
||||
RLIMIT_NPROC entirely (Erlang, when a relative ceiling cannot be computed)."""
|
||||
return subprocess.run(
|
||||
argv,
|
||||
cwd=str(workdir),
|
||||
env=env,
|
||||
input=stdin.encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
preexec_fn=_limits(constrain_memory, max_procs=max_procs),
|
||||
)
|
||||
|
||||
|
||||
def run(lang: str, source: str, stdin: str = "") -> dict:
|
||||
"""Compile (if needed) and run `source`. Blocking — call from a thread.
|
||||
|
||||
Returns {ok, lang, stage, exit_code, stdout, stderr, error}. `ok` is False
|
||||
only when the snippet could not be run at all (missing toolchain, compile
|
||||
error, timeout); a program that runs and exits non-zero is a successful run
|
||||
with a non-zero exit_code, because its stderr is the answer the user wants.
|
||||
"""
|
||||
key = resolve_lang(lang)
|
||||
entry = RUN_LANGS.get(key)
|
||||
if entry is None:
|
||||
return {"ok": False, "lang": lang, "stage": "lang",
|
||||
"error": f"cannot run {lang!r} — use {lang_prose()}."}
|
||||
|
||||
tool = entry["tool"]()
|
||||
if not tool:
|
||||
return {"ok": False, "lang": key, "stage": "toolchain",
|
||||
"error": f"no toolchain for {key} on this machine — {entry['install']}. "
|
||||
"Show the code instead of running it."}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="nexus-run-") as tmp:
|
||||
workdir = Path(tmp)
|
||||
body = _erlang_write_source(source) if key == "erlang" else source
|
||||
src = workdir / entry["source_name"](source)
|
||||
src.write_text(body, encoding="utf-8")
|
||||
exe = str(workdir / ("program.exe" if sys.platform == "win32" else "program"))
|
||||
env = _child_env(key, workdir)
|
||||
max_procs = _max_procs_for(key)
|
||||
|
||||
if entry["compile"]:
|
||||
try:
|
||||
built = _spawn(entry["compile"](tool, str(src), exe), workdir, env,
|
||||
COMPILE_TIMEOUT, constrain_memory=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "lang": key, "stage": "compile",
|
||||
"error": f"compilation timed out after {COMPILE_TIMEOUT:g}s."}
|
||||
except OSError as e:
|
||||
return {"ok": False, "lang": key, "stage": "compile",
|
||||
"error": f"could not start the compiler: {e}"}
|
||||
if built.returncode != 0:
|
||||
return {"ok": False, "lang": key, "stage": "compile",
|
||||
"exit_code": built.returncode,
|
||||
"stdout": _clip(built.stdout), "stderr": _clip(built.stderr),
|
||||
"error": "compilation failed — read stderr, fix the source, "
|
||||
"and call run_snippet again."}
|
||||
|
||||
argv = _net_isolation() + entry["run"](tool, str(src), exe)
|
||||
try:
|
||||
done = _spawn(argv, workdir, env, RUN_TIMEOUT, stdin=stdin[:MAX_STDIN],
|
||||
max_procs=max_procs)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return {"ok": False, "lang": key, "stage": "run",
|
||||
"stdout": _clip(e.stdout or b""), "stderr": _clip(e.stderr or b""),
|
||||
"error": f"the program did not finish within {RUN_TIMEOUT:g}s — "
|
||||
"it is probably looping. Bound the work and try again."}
|
||||
except OSError as e:
|
||||
return {"ok": False, "lang": key, "stage": "run",
|
||||
"error": f"could not start the program: {e}"}
|
||||
|
||||
stdout = _clip(done.stdout)
|
||||
stderr = _clip(done.stderr)
|
||||
# BEAM failing to fork under RLIMIT_NPROC looks like a snippet bug if we
|
||||
# report ok=True. Call it out as a runner limit so the model does not keep
|
||||
# rewriting a correct program.
|
||||
if (
|
||||
key == "erlang"
|
||||
and done.returncode != 0
|
||||
and "Resource temporarily unavailable" in stderr
|
||||
):
|
||||
return {
|
||||
"ok": False,
|
||||
"lang": key,
|
||||
"stage": "run",
|
||||
"exit_code": done.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"error": (
|
||||
"Erlang could not start under the process limit (host already has "
|
||||
"many processes for this user). Free some processes and retry, or "
|
||||
"show the code instead of running it."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"lang": key,
|
||||
"stage": "run",
|
||||
"exit_code": done.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
|
||||
def critique(lang: str, source: str) -> list[str]:
|
||||
"""Static screening for one snippet. See the module docstring on what this
|
||||
is worth: a tripwire against habitual network/process reaches, not a
|
||||
boundary."""
|
||||
key = resolve_lang(lang)
|
||||
entry = RUN_LANGS.get(key)
|
||||
if entry is None:
|
||||
return [f"cannot run {lang!r} — use {lang_prose()}."]
|
||||
if len(source) > MAX_SOURCE:
|
||||
return [f"source is over {MAX_SOURCE} characters — send a single focused snippet."]
|
||||
return entry["critique"](source)
|
||||
+27
-2
@@ -79,6 +79,19 @@ _RENDER_PREAMBLE = (
|
||||
"CSS/JS, no network.\n"
|
||||
)
|
||||
|
||||
# The execution track's hint, on the same terms as the render one: offered only
|
||||
# when the tool behind it is, for the same contamination reason. The distinction
|
||||
# it has to carry is which track a request belongs to — a model that reaches for
|
||||
# run_snippet to "preview" an HTML page gets a compile error, and one that
|
||||
# reaches for render_preview to run a C program gets a plain code block.
|
||||
_RUN_PREAMBLE = (
|
||||
"\n\n---\nCode runner: when the answer depends on what code actually does, call "
|
||||
f"the `run_snippet` tool with a complete {_tools.code_run.lang_prose()} program, "
|
||||
"then paste the returned `fence` into your reply. It really runs, in a throwaway "
|
||||
"directory with no network and a few seconds of CPU. Describe only the output it "
|
||||
"returned.\n"
|
||||
)
|
||||
|
||||
|
||||
_CODING_KEYWORDS = frozenset({
|
||||
"code", "coding", "function", "class", "method", "variable", "bug", "error",
|
||||
@@ -416,8 +429,16 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
# is implemented using a tool called render_preview... renders it live in
|
||||
# a sandbox" — this text, recited as fact. A hint for a tool that isn't
|
||||
# being offered is pure contamination.
|
||||
# Read once here rather than at the tools block below: the run-track
|
||||
# hint and the run-track schema have to agree about whether the tool is
|
||||
# on offer, and a second lookup is a second thing to keep in step.
|
||||
_policy = app_settings.get("action_tool_policy", "off")
|
||||
allow_actions = _policy != "off"
|
||||
|
||||
if _tools.wants_render_preview(message):
|
||||
system_prompt = (system_prompt + _RENDER_PREAMBLE) if system_prompt else _RENDER_PREAMBLE.lstrip()
|
||||
if allow_actions and _tools.wants_code_run(message):
|
||||
system_prompt = (system_prompt + _RUN_PREAMBLE) if system_prompt else _RUN_PREAMBLE.lstrip()
|
||||
|
||||
# ── MindTrace pre-flight ──────────────────────────────────────────
|
||||
_trace_intent = _detect_intent(message) if message else "chat"
|
||||
@@ -479,13 +500,17 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
# Tools: playbook allowlist, plus render_preview only when this turn
|
||||
# looks like a visual ask (always advertising it forced a non-stream
|
||||
# tool round on every chat and felt like "stuck thinking").
|
||||
_policy = app_settings.get("action_tool_policy", "off")
|
||||
allow_actions = _policy != "off"
|
||||
_pb_tools = list(getattr(_main_pb, "tools", None) or []) if _main_pb else []
|
||||
schemas_by_name: dict = {}
|
||||
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
||||
for s in _tools.standing_schemas():
|
||||
schemas_by_name[s["function"]["name"]] = s
|
||||
# run_snippet rides the same cue mechanism but stays behind the action
|
||||
# gate: it executes code on this machine. With the policy "off", "run
|
||||
# this" gets an explanation and a code block, never a subprocess.
|
||||
if allow_actions and _tools.wants_code_run(message):
|
||||
for s in _tools.run_schemas():
|
||||
schemas_by_name[s["function"]["name"]] = s
|
||||
for s in _tools.schemas_for(_pb_tools, allow_actions):
|
||||
schemas_by_name[s["function"]["name"]] = s
|
||||
schemas = list(schemas_by_name.values())
|
||||
|
||||
+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