Load Sucrase only when a JSX/TSX preview is opened, remove the hand-written transform, and leave subjective render evaluation to the reader while retaining structural fence validation.
1356 lines
57 KiB
Python
1356 lines
57 KiB
Python
"""Tools a playbook can call during chat.
|
|
|
|
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
|
`message.tool_calls`, and this module is just the registry + dispatch.
|
|
|
|
Most tools READ local state (memory, history, documents, models). Some act:
|
|
`web_search`/`fetch_url` make outbound HTTP requests, `remember` WRITES a
|
|
memory fact, `edit_playbook`/`edit_settings`/`edit_source` change the
|
|
assistant's own playbooks, settings, and (source checkout only) source code
|
|
(see synapse/self_edit.py), and `curry_*` reads and writes NexusOS's vendored
|
|
Curry ledger (see synapse/curry_store.py) — immutable versioned constants and
|
|
functions, with `curry_call_function` executing a previously declared one.
|
|
The per-playbook allowlist (`PlaybookItem.tools`) is the first gate — an
|
|
action tool only fires when a playbook explicitly lists it — and the
|
|
highest-risk tools in both families additionally always pause for per-call
|
|
approval regardless of the global action_tool_policy
|
|
(ALWAYS_ASK_ACTION_TOOLS, below). A message that's nothing but
|
|
`/tool_name(arg=val, ...)` skips that approval round-trip entirely and
|
|
dispatches directly — see synapse/slash_commands.py for why that's safe.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Awaitable, Callable
|
|
|
|
from . import code_run
|
|
from . import playbook_manager
|
|
from .curry_store import curry_db
|
|
from . import self_edit
|
|
from .memory.store import store, MemoryItem
|
|
from .ollama_manager import get_ollama_manager
|
|
|
|
|
|
async def _search_memory(query: str = "", **_) -> str:
|
|
q = (query or "").strip().lower()
|
|
hits = [
|
|
{"section": it.section, "text": it.text}
|
|
for it in store.all()
|
|
if not q
|
|
or q in it.text.lower()
|
|
or q in (it.section or "").lower()
|
|
or any(q in t.lower() for t in it.tags)
|
|
]
|
|
return json.dumps(hits[:20])
|
|
|
|
|
|
async def _search_history(query: str = "", **_) -> str:
|
|
# Hybrid recall: semantic (embeddings) unioned with lexical, falls back to
|
|
# lexical if embeddings are down. Same retrieval the chat endpoint uses.
|
|
convs = await store.semantic_search_conversations(
|
|
query or "", get_ollama_manager().embed, limit=3
|
|
)
|
|
return json.dumps([{"matches": c.get("matches", [])} for c in convs])
|
|
|
|
|
|
async def _list_models(**_) -> str:
|
|
return json.dumps(await get_ollama_manager().list_models())
|
|
|
|
|
|
async def _search_documents(query: str = "", **_) -> str:
|
|
hits = await store.search_documents(query or "", get_ollama_manager().embed, limit=3)
|
|
return json.dumps([{"title": h["title"], "text": h["text"]} for h in hits])
|
|
|
|
|
|
async def _get_time(**_) -> str:
|
|
from datetime import datetime
|
|
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
|
|
|
|
|
|
async def _web_search(query: str = "", **_) -> str:
|
|
import asyncio as _a
|
|
from .search import web_search
|
|
res = await _a.to_thread(web_search, query or "", 4)
|
|
return res or "(no results)"
|
|
|
|
|
|
_FETCH_MAX_REDIRECTS = 5
|
|
|
|
|
|
def _ip_is_blocked(ip: str) -> bool:
|
|
"""True if an address is one an outbound fetch has no business reaching:
|
|
loopback, RFC1918/ULA private, link-local (incl. 169.254.169.254 cloud
|
|
metadata), multicast, reserved, or unspecified. IPv4-mapped IPv6 is unwrapped
|
|
first so ::ffff:127.0.0.1 can't sneak a loopback past the check."""
|
|
import ipaddress
|
|
try:
|
|
addr = ipaddress.ip_address(ip.split("%")[0]) # drop any IPv6 zone id
|
|
except ValueError:
|
|
return True # unparseable -> refuse rather than guess
|
|
mapped = getattr(addr, "ipv4_mapped", None)
|
|
if mapped is not None:
|
|
addr = mapped
|
|
return (
|
|
addr.is_loopback or addr.is_private or addr.is_link_local
|
|
or addr.is_multicast or addr.is_reserved or addr.is_unspecified
|
|
)
|
|
|
|
|
|
def _ssrf_guard(host: str) -> str | None:
|
|
"""Resolve a hostname and return an error string if ANY of its A/AAAA
|
|
records is a blocked address, else None. Checking every answer stops a name
|
|
from smuggling one private record alongside a public one.
|
|
|
|
ponytail: this validates then httpx re-resolves on connect, so a sub-second
|
|
DNS-rebind could still slip a private address through the TOCTOU gap. That's
|
|
an advanced attack against a playbook-gated, single-user tool; pin the
|
|
connection to the resolved IP if this ever faces untrusted callers."""
|
|
import socket
|
|
if not host:
|
|
return "missing host"
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
except socket.gaierror as e:
|
|
return f"cannot resolve host: {e}"
|
|
ips = {info[4][0] for info in infos}
|
|
if not ips:
|
|
return "host did not resolve"
|
|
blocked = [ip for ip in ips if _ip_is_blocked(ip)]
|
|
if blocked:
|
|
return f"refusing to fetch a private/loopback/link-local address ({', '.join(sorted(blocked))})"
|
|
return None
|
|
|
|
|
|
async def _fetch_url(url: str = "", **_) -> str:
|
|
import re
|
|
import httpx
|
|
from urllib.parse import urlparse, urljoin
|
|
url = (url or "").strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
return json.dumps({"error": "url must start with http:// or https://"})
|
|
# SSRF guard: validate the host of the initial URL AND every redirect hop
|
|
# against the private/loopback/link-local block-list before connecting, so a
|
|
# granted fetch_url can't be steered at 127.0.0.1:11434, cloud metadata, or
|
|
# LAN hosts — and a public URL can't 302 its way there either.
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as c:
|
|
for _ in range(_FETCH_MAX_REDIRECTS + 1):
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
return json.dumps({"error": "only http(s) URLs are allowed"})
|
|
err = _ssrf_guard(parsed.hostname or "")
|
|
if err:
|
|
return json.dumps({"error": f"blocked: {err}"})
|
|
r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
|
|
location = r.headers.get("location")
|
|
if r.is_redirect and location:
|
|
url = urljoin(url, location)
|
|
continue
|
|
r.raise_for_status()
|
|
html = r.text
|
|
break
|
|
else:
|
|
return json.dumps({"error": "too many redirects"})
|
|
except Exception as e:
|
|
return json.dumps({"error": f"fetch failed: {e}"})
|
|
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", html)
|
|
text = re.sub(r"(?s)<[^>]+>", " ", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
return text[:4000]
|
|
|
|
|
|
async def _remember(text: str = "", section: str = "General", **_) -> str:
|
|
"""WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
|
|
import uuid as _uuid
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return json.dumps({"error": "text is required"})
|
|
store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
|
|
return json.dumps({"saved": text, "section": section or "General"})
|
|
|
|
|
|
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
|
|
# Paths never leave the repo: every request is resolve()d and checked against
|
|
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
|
|
# the tree that are either secrets, private data, or multi-GB noise. Read-only
|
|
# counterpart to edit_source's write path (self_edit.py) — a model reading
|
|
# real source before proposing an edit is the point.
|
|
_DENIED = {
|
|
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
|
"runtime", "dist", "__pycache__", ".git-credentials",
|
|
}
|
|
_READ_MAX = 60_000
|
|
|
|
|
|
def _repo_path(rel: str) -> "tuple[object, str | None]":
|
|
"""Resolve a repo-relative path. Returns (path, error-string)."""
|
|
from .nexus_config import PROJECT_ROOT
|
|
rel = (rel or "").strip().lstrip("/")
|
|
if not rel:
|
|
return None, "path is required"
|
|
target = (PROJECT_ROOT / rel).resolve()
|
|
if not target.is_relative_to(PROJECT_ROOT):
|
|
return None, "path escapes the project root"
|
|
parts = set(target.relative_to(PROJECT_ROOT).parts)
|
|
if parts & _DENIED or target.name.endswith((".db", ".db.sql", ".pem", ".key")):
|
|
return None, f"{rel} is not readable"
|
|
return target, None
|
|
|
|
|
|
async def _read_file(path: str = "", **_) -> str:
|
|
target, err = _repo_path(path)
|
|
if err:
|
|
return json.dumps({"error": err})
|
|
if not target.is_file():
|
|
return json.dumps({"error": f"{path} does not exist"})
|
|
try:
|
|
text = target.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as e:
|
|
return json.dumps({"error": f"cannot read {path}: {e}"})
|
|
return json.dumps({
|
|
"path": path,
|
|
"truncated": len(text) > _READ_MAX,
|
|
"content": text[:_READ_MAX],
|
|
})
|
|
|
|
|
|
async def _list_files(pattern: str = "", **_) -> str:
|
|
"""Glob the repo so the model discovers real paths instead of inventing them."""
|
|
from .nexus_config import PROJECT_ROOT
|
|
pattern = (pattern or "**/*.py").strip().lstrip("/")
|
|
hits = []
|
|
for f in PROJECT_ROOT.glob(pattern):
|
|
if not f.is_file():
|
|
continue
|
|
rel_posix = f.relative_to(PROJECT_ROOT).as_posix()
|
|
target, err = _repo_path(rel_posix)
|
|
if err:
|
|
continue
|
|
# .as_posix(), not str(): a bare str() gives backslash-separated paths
|
|
# on Windows, which don't match the forward-slash patterns this tool's
|
|
# own schema documents (e.g. "synapse/**/*.py") and that read_file
|
|
# expects back.
|
|
hits.append(rel_posix)
|
|
if len(hits) >= 200:
|
|
break
|
|
return json.dumps(sorted(hits))
|
|
|
|
|
|
# The one place that says which languages the render window supports. The tool
|
|
# schema's `lang` enum and the capability line in the system prompt are derived
|
|
# from these keys rather than repeated.
|
|
#
|
|
# The frontend keeps its own matching registry (PREVIEW_LANGS in
|
|
# interface/web/src/Markdown.jsx) because the two sides need different things per
|
|
# language - this side describes them, that side renders them - 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 degrading to a
|
|
# plain code block in the chat.
|
|
PREVIEW_LANGS: dict[str, dict] = {
|
|
"html": {"summary": "self-contained HTML document"},
|
|
"svg": {"summary": "standalone SVG image"},
|
|
"jsx": {"summary": "single Preact/React component (JSX)"},
|
|
"tsx": {"summary": "single Preact/React component (TypeScript JSX)"},
|
|
}
|
|
|
|
|
|
def _lang_prose() -> str:
|
|
"""'html or svg' — the supported languages as a phrase for prompts/errors."""
|
|
names = list(PREVIEW_LANGS)
|
|
if len(names) < 2:
|
|
return names[0] if names else ""
|
|
return f"{', '.join(names[:-1])} or {names[-1]}"
|
|
|
|
|
|
async def _render_preview(
|
|
lang: str = "html",
|
|
title: str = "",
|
|
markup: str = "",
|
|
purpose: str = "",
|
|
**_,
|
|
) -> str:
|
|
"""Package a live-preview fence. Read-only: nothing is executed server-side;
|
|
the chat UI parses and renders the fence in a sandboxed iframe."""
|
|
lang = (lang or "html").strip().lower()
|
|
markup = (markup or "").strip()
|
|
title = (title or "").strip()
|
|
purpose = (purpose or "").strip()
|
|
|
|
if lang not in PREVIEW_LANGS:
|
|
return json.dumps({"ok": False, "error": f"lang must be {_lang_prose()}"})
|
|
if not markup:
|
|
return json.dumps({
|
|
"ok": False,
|
|
"error": f"markup is required — send the complete {lang} preview.",
|
|
})
|
|
|
|
fence = f"```{lang}\n{markup}\n```"
|
|
return json.dumps({
|
|
"ok": True,
|
|
"title": title or None,
|
|
"purpose": purpose or None,
|
|
"instruction": (
|
|
"Write a short intro, then paste this fenced block exactly as it is. "
|
|
"Do not wrap it in a second fence, resize it, or rewrite the code."
|
|
),
|
|
"fence": fence,
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_snippet — the execution track
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# render_preview and run_snippet are deliberately separate tools over separate
|
|
# registries. A preview is packaged here and parsed/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 host-code rejection on."""
|
|
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"],
|
|
}),
|
|
})
|
|
|
|
|
|
# Result fence for the three self-edit tools: same "carry the change and what
|
|
# happened to it in one block" idea as _run_fence, so the model can't paste a
|
|
# claimed diff that doesn't match what was actually applied.
|
|
_EDIT_FENCE_LANG = "nexus-edit"
|
|
|
|
|
|
def _edit_fence(payload: dict) -> str:
|
|
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
|
|
return f"```{_EDIT_FENCE_LANG}\n{body}\n```"
|
|
|
|
|
|
async def _edit_source(path: str = "", new_content: str = "", summary: str = "", **_) -> str:
|
|
"""ACTION tool: writes a file in the live project tree and commits it. See
|
|
synapse/self_edit.py for the boundary check, the size cap, and exactly what
|
|
the git commit does and doesn't guarantee."""
|
|
import asyncio as _a
|
|
|
|
result = await _a.to_thread(self_edit.apply_source_edit, path, new_content or "", summary or "")
|
|
if result.get("ok"):
|
|
result["instruction"] = (
|
|
"This was written and committed for real. Paste the fence unchanged, then "
|
|
"tell the user plainly that a restart is needed for it to take effect — "
|
|
"this file is not reloaded into the running process."
|
|
)
|
|
result["fence"] = _edit_fence({"kind": "source", **result})
|
|
return json.dumps(result)
|
|
|
|
|
|
async def _edit_playbook(
|
|
id: str = "", title: str = "", goal: str = "", instructions: str = "",
|
|
tags: list | None = None, tools: list | None = None, model: str = "",
|
|
make_active: bool = False, **_,
|
|
) -> str:
|
|
"""ACTION tool: create or update a playbook. Fields left unset keep their
|
|
current value — this merges, it does not replace. make_active is a
|
|
separate, explicit flag: without it, an edit can never accidentally become
|
|
the active system prompt."""
|
|
try:
|
|
result = playbook_manager.persist_playbook(
|
|
{
|
|
"id": id, "title": title, "goal": goal, "instructions": instructions,
|
|
"tags": tags, "tools": tools, "model": model,
|
|
},
|
|
merge=True,
|
|
)
|
|
except ValueError as e:
|
|
return json.dumps({"ok": False, "error": str(e)})
|
|
if make_active:
|
|
playbook_manager.make_main(result["id"])
|
|
result["is_main_playbook"] = True
|
|
result["ok"] = True
|
|
result["fence"] = _edit_fence({"kind": "playbook", **result})
|
|
return json.dumps(result)
|
|
|
|
|
|
async def _edit_settings(changes: dict | None = None, **_) -> str:
|
|
"""ACTION tool: change one or more runtime settings. Unknown keys are
|
|
silently ignored, exactly like PUT /settings already does."""
|
|
changes = changes if isinstance(changes, dict) else {}
|
|
if not changes:
|
|
return json.dumps({"ok": False, "error": "changes must be a non-empty object"})
|
|
preview = self_edit.preview_settings_edit({"changes": changes})
|
|
if not preview.get("ok"):
|
|
return json.dumps(preview)
|
|
if not preview.get("applied"):
|
|
return json.dumps({
|
|
"ok": False,
|
|
"error": "no recognized settings keys in changes",
|
|
"ignored_unknown": preview.get("ignored_unknown", []),
|
|
})
|
|
store.update_settings({k: v["after"] for k, v in preview["applied"].items()})
|
|
preview["ok"] = True
|
|
preview["fence"] = _edit_fence({"kind": "settings", **preview})
|
|
return json.dumps(preview)
|
|
|
|
|
|
# Curry (synapse/curry_core.py, vendored) — NexusOS's immutable, versioned
|
|
# fact store. Its own methods raise (KeyError/ValueError/TypeError/RuntimeError)
|
|
# on the failures a caller should see as a normal, expected result rather than
|
|
# a crash (unknown id, version conflict, retired reference, etc.) — dispatch()
|
|
# would already catch anything unhandled, but that produces a generic
|
|
# "toolname failed: ..." string instead of the {"ok": False, "error": ...}
|
|
# shape every other tool in this file returns, so it's caught locally here too.
|
|
_CURRY_FENCE_LANG = "nexus-curry"
|
|
|
|
|
|
def _curry_fence(payload: dict) -> str:
|
|
body = json.dumps(payload, ensure_ascii=False, default=str).replace("`", "\\u0060")
|
|
return f"```{_CURRY_FENCE_LANG}\n{body}\n```"
|
|
|
|
|
|
async def _curry_call(fn, *args, **kwargs) -> dict:
|
|
# Not run.to_thread()'d like run_snippet/self_edit's blocking work: curry_db
|
|
# holds one sqlite3 connection for its whole lifetime (unlike
|
|
# PersistentMemoryStore, which opens/closes a fresh one per call), and
|
|
# sqlite3 forbids using a connection from any thread but the one that
|
|
# created it. curry_db is created at import time on the same thread this
|
|
# runs on (the asyncio event loop thread), so calling it directly here is
|
|
# both correct and, for local-file SQLite, fast enough not to need
|
|
# offloading anyway.
|
|
try:
|
|
result = fn(*args, **kwargs)
|
|
return {"ok": True, "result": result}
|
|
except (KeyError, ValueError, TypeError, RuntimeError) as e:
|
|
return {"ok": False, "error": str(e)}
|
|
|
|
|
|
async def _curry_declare_constant(
|
|
id: str = "", version: int = 0, value=None, type_signature: str = "",
|
|
description: str = "", **_,
|
|
) -> str:
|
|
"""ACTION tool: declare a new, immutable version of a named constant."""
|
|
out = await _curry_call(
|
|
curry_db.declare_constant, id, version, value, type_signature, description or None
|
|
)
|
|
if out["ok"]:
|
|
out = {"ok": True, "id": id, "version": version}
|
|
out["fence"] = _curry_fence({"kind": "declare_constant", **out})
|
|
return json.dumps(out)
|
|
|
|
|
|
async def _curry_get_constant(id: str = "", version: int = 0, **_) -> str:
|
|
"""Retrieve a constant by exact id and version."""
|
|
return json.dumps(await _curry_call(curry_db.get_constant, id, version))
|
|
|
|
|
|
async def _curry_get_constant_latest(id: str = "", **_) -> str:
|
|
"""Retrieve the most recent active (non-retired) version of a constant."""
|
|
return json.dumps(await _curry_call(curry_db.get_constant_latest, id))
|
|
|
|
|
|
async def _curry_list_constants(active_only: bool = True, **_) -> str:
|
|
"""List all declared constants and their latest versions."""
|
|
return json.dumps(await _curry_call(curry_db.list_constants, active_only))
|
|
|
|
|
|
async def _curry_retire_constant(id: str = "", version: int = 0, reason: str = "", **_) -> str:
|
|
"""ACTION tool: retire (tombstone) a constant version. Does not delete it —
|
|
the version stays readable by exact id+version, just excluded from
|
|
"latest" lookups and blocked from new declarations that depend on it."""
|
|
out = await _curry_call(
|
|
curry_db.retire_constant_with_reason, id, version, reason or "retired via tool call"
|
|
)
|
|
return json.dumps(out)
|
|
|
|
|
|
async def _curry_declare_function(
|
|
name: str = "", version: int = 0, body: str = "",
|
|
constant_bindings: dict | None = None, function_bindings: dict | None = None,
|
|
is_pure: bool = False, expected_args: list | None = None,
|
|
description: str = "", arg_descriptions: dict | None = None, **_,
|
|
) -> str:
|
|
"""ACTION tool: declare a new, immutable version of a named function. Body
|
|
is a single Python expression (no statements) over stdlib-only builtins,
|
|
checked by curry_core.py's own static validator before this ever runs —
|
|
but that validator is a tripwire against habitual mistakes, not a
|
|
security boundary; treat it the same as run_snippet's containment."""
|
|
out = await _curry_call(
|
|
curry_db.declare_function, name, version, body,
|
|
constant_bindings or {}, function_bindings or {}, is_pure,
|
|
expected_args, description or None, arg_descriptions,
|
|
)
|
|
if out["ok"]:
|
|
out = {"ok": True, "name": name, "version": version}
|
|
out["fence"] = _curry_fence({"kind": "declare_function", **out})
|
|
return json.dumps(out)
|
|
|
|
|
|
async def _curry_get_function(name: str = "", version: int = 0, **_) -> str:
|
|
"""Retrieve a function definition by exact name and version."""
|
|
return json.dumps(await _curry_call(curry_db.get_function, name, version))
|
|
|
|
|
|
async def _curry_list_functions(active_only: bool = True, **_) -> str:
|
|
"""List all declared functions and their latest versions."""
|
|
return json.dumps(await _curry_call(curry_db.list_functions, active_only))
|
|
|
|
|
|
async def _curry_call_function(name: str = "", version: int = 0, args: dict | None = None, **_) -> str:
|
|
"""ACTION tool: execute a previously declared function version with the
|
|
given runtime arguments. Locked constant/function dependencies resolve
|
|
automatically; pure functions are memoized."""
|
|
out = await _curry_call(curry_db.call_function, name, version, args or {})
|
|
if out["ok"]:
|
|
out["fence"] = _curry_fence({"kind": "call_function", "name": name, "version": version, **out})
|
|
return json.dumps(out)
|
|
|
|
|
|
async def _curry_retire_function(name: str = "", version: int = 0, reason: str = "", **_) -> str:
|
|
"""ACTION tool: retire (tombstone) a function version. Does not delete it."""
|
|
out = await _curry_call(
|
|
curry_db.retire_function_with_reason, name, version, reason or "retired via tool call"
|
|
)
|
|
return json.dumps(out)
|
|
|
|
|
|
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
|
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|
"search_memory": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_memory",
|
|
"description": "Search the user's persistent memory facts. Empty query returns all facts.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string", "description": "text to match"}},
|
|
},
|
|
},
|
|
},
|
|
_search_memory,
|
|
),
|
|
"search_history": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_history",
|
|
"description": "Search past conversations for exchanges containing the query text.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
_search_history,
|
|
),
|
|
"list_models": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_models",
|
|
"description": "List the locally installed Ollama models.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
_list_models,
|
|
),
|
|
"read_file": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_file",
|
|
"description": "Read a source file from the NexusOS repository. Path is relative to the project root, e.g. 'synapse/main.py'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string", "description": "repo-relative file path"}},
|
|
"required": ["path"],
|
|
},
|
|
},
|
|
},
|
|
_read_file,
|
|
),
|
|
"list_files": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_files",
|
|
"description": "List files in the NexusOS repository matching a glob, e.g. 'synapse/**/*.py' or 'interface/web/src/*.jsx'. Use this to find real paths before reading.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"pattern": {"type": "string", "description": "glob relative to the project root"}},
|
|
},
|
|
},
|
|
},
|
|
_list_files,
|
|
),
|
|
"search_documents": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_documents",
|
|
"description": "Search the user's uploaded documents for relevant passages.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
_search_documents,
|
|
),
|
|
"get_time": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_time",
|
|
"description": "Get the current local date and time.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
_get_time,
|
|
),
|
|
"render_preview": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "render_preview",
|
|
# Written as instructions TO you, imperative and short. Earlier
|
|
# versions narrated what "the user" wants and listed numbered
|
|
# requirements; weak models echoed that narration back as their
|
|
# reply — asking the user to clarify an already-clear request,
|
|
# in the third person, instead of building anything. Keep this
|
|
# terse, keep it second-person, and add nothing the model can
|
|
# recite in place of acting.
|
|
"description": (
|
|
f"Package a working visual or interactive demo as self-contained "
|
|
f"{_lang_prose()}. Inline required CSS and JS; the sandbox has no "
|
|
"network, so external resources will not load. Paste the returned "
|
|
"`fence` into your reply unchanged."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"lang": {
|
|
"type": "string",
|
|
"enum": list(PREVIEW_LANGS),
|
|
"description": (
|
|
"Preview language tag for the fenced block: "
|
|
+ "; ".join(
|
|
f"{name} ({spec['summary']})"
|
|
for name, spec in PREVIEW_LANGS.items()
|
|
)
|
|
),
|
|
},
|
|
"title": {
|
|
"type": "string",
|
|
"description": "Short label for the visual.",
|
|
},
|
|
"purpose": {
|
|
"type": "string",
|
|
"description": "One sentence: what this visual shows.",
|
|
},
|
|
"markup": {
|
|
"type": "string",
|
|
"description": (
|
|
"Complete self-contained source for the selected preview "
|
|
"language. React, ReactDOM, Preact, and Preact hooks are "
|
|
"available locally; other packages and external resources "
|
|
"cannot be loaded."
|
|
),
|
|
},
|
|
},
|
|
"required": ["lang", "markup"],
|
|
},
|
|
},
|
|
},
|
|
_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",
|
|
"function": {
|
|
"name": "web_search",
|
|
"description": "Search the web (DuckDuckGo) and return the top result snippets.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
_web_search,
|
|
),
|
|
"fetch_url": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "fetch_url",
|
|
"description": "Fetch a web page and return its visible text (truncated).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"url": {"type": "string", "description": "http(s) URL"}},
|
|
"required": ["url"],
|
|
},
|
|
},
|
|
},
|
|
_fetch_url,
|
|
),
|
|
"remember": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "remember",
|
|
"description": "Save a durable fact to the user's persistent memory.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {"type": "string", "description": "the fact to remember"},
|
|
"section": {"type": "string", "description": "optional category, e.g. Health"},
|
|
},
|
|
"required": ["text"],
|
|
},
|
|
},
|
|
},
|
|
_remember,
|
|
),
|
|
"edit_source": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "edit_source",
|
|
"description": (
|
|
"Rewrite a file in this project's own source tree and commit the "
|
|
"change. Requires human approval every time — the person reviews a "
|
|
"real diff before anything is written. Send the COMPLETE new file "
|
|
"content, not a patch; the server computes the diff itself. `path` "
|
|
"is relative to the project root (e.g. \"synapse/tools.py\"), never "
|
|
"absolute. Only available in a source checkout, not a packaged "
|
|
"install. Writing the file does not restart the running process — "
|
|
"say so plainly once it's applied."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Project-relative path to the file, e.g. synapse/tools.py",
|
|
},
|
|
"new_content": {
|
|
"type": "string",
|
|
"description": "The complete replacement content of the file.",
|
|
},
|
|
"summary": {
|
|
"type": "string",
|
|
"description": "One line describing the change, used as the commit message.",
|
|
},
|
|
},
|
|
"required": ["path", "new_content"],
|
|
},
|
|
},
|
|
},
|
|
_edit_source,
|
|
),
|
|
"edit_playbook": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "edit_playbook",
|
|
"description": (
|
|
"Create or update a playbook — the instructions that shape how the "
|
|
"assistant behaves. Requires human approval every time. Fields you "
|
|
"omit keep their current value; this merges into the existing "
|
|
"playbook, it does not replace it. Set make_active=true only when "
|
|
"this playbook should become the active system prompt — never as a "
|
|
"side effect of an ordinary edit. Omit `id` to create a new playbook."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "Existing playbook id to update; omit to create new."},
|
|
"title": {"type": "string", "description": "Short name for the playbook."},
|
|
"goal": {"type": "string", "description": "One-line statement of what this playbook is for."},
|
|
"instructions": {"type": "string", "description": "The actual instructions/system prompt text."},
|
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "Routing tags."},
|
|
"tools": {"type": "array", "items": {"type": "string"}, "description": "Tool names this playbook grants."},
|
|
"model": {"type": "string", "description": "Preferred Ollama model for this playbook, or blank for auto."},
|
|
"make_active": {
|
|
"type": "boolean",
|
|
"description": "Set true to make this the active system prompt. Default false.",
|
|
},
|
|
},
|
|
"required": [],
|
|
},
|
|
},
|
|
},
|
|
_edit_playbook,
|
|
),
|
|
"edit_settings": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "edit_settings",
|
|
"description": (
|
|
"Change one or more runtime settings (e.g. model, temperature, "
|
|
"action_tool_policy, memory_model). Requires human approval every "
|
|
"time. Send only the keys you actually want to change — unrecognized "
|
|
"keys are silently ignored, and existing values are left untouched."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"changes": {
|
|
"type": "object",
|
|
"description": "Partial map of setting name to new value.",
|
|
},
|
|
},
|
|
"required": ["changes"],
|
|
},
|
|
},
|
|
},
|
|
_edit_settings,
|
|
),
|
|
"curry_declare_constant": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_declare_constant",
|
|
"description": (
|
|
"Declare a new, immutable version of a named constant in the Curry "
|
|
"ledger. Requires human approval every time. `version` must exceed "
|
|
"the constant's current max version — versions are append-only, "
|
|
"never overwritten. type_signature is one of Float64, Int32, "
|
|
"String, Blob, Json, Tokens, Currency, Bool."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "Constant identifier."},
|
|
"version": {"type": "integer", "description": "Must exceed the current max version for this id."},
|
|
"value": {"description": "The value to store, matching type_signature."},
|
|
"type_signature": {"type": "string", "description": "Float64 | Int32 | String | Blob | Json | Tokens | Currency | Bool"},
|
|
"description": {"type": "string", "description": "What this constant means and why this value."},
|
|
},
|
|
"required": ["id", "version", "value", "type_signature"],
|
|
},
|
|
},
|
|
},
|
|
_curry_declare_constant,
|
|
),
|
|
"curry_get_constant": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_get_constant",
|
|
"description": "Retrieve a Curry constant by its exact id and version.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "Constant identifier."},
|
|
"version": {"type": "integer", "description": "Exact version to retrieve."},
|
|
},
|
|
"required": ["id", "version"],
|
|
},
|
|
},
|
|
},
|
|
_curry_get_constant,
|
|
),
|
|
"curry_get_constant_latest": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_get_constant_latest",
|
|
"description": "Retrieve the most recent active (non-retired) version of a Curry constant.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "Constant identifier."},
|
|
},
|
|
"required": ["id"],
|
|
},
|
|
},
|
|
},
|
|
_curry_get_constant_latest,
|
|
),
|
|
"curry_list_constants": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_list_constants",
|
|
"description": "List every constant declared in the Curry ledger.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"active_only": {"type": "boolean", "description": "If true (default), exclude retired constants."},
|
|
},
|
|
"required": [],
|
|
},
|
|
},
|
|
},
|
|
_curry_list_constants,
|
|
),
|
|
"curry_retire_constant": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_retire_constant",
|
|
"description": (
|
|
"Retire (tombstone) a Curry constant version. Requires human approval "
|
|
"every time. This does not delete anything — the version stays "
|
|
"readable by exact id+version, it's just excluded from 'latest' "
|
|
"lookups going forward."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "Constant identifier."},
|
|
"version": {"type": "integer", "description": "Version to retire."},
|
|
"reason": {"type": "string", "description": "Why this version is being retired."},
|
|
},
|
|
"required": ["id", "version"],
|
|
},
|
|
},
|
|
},
|
|
_curry_retire_constant,
|
|
),
|
|
"curry_declare_function": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_declare_function",
|
|
"description": (
|
|
"Declare a new, immutable version of a named function in the Curry "
|
|
"ledger. Requires human approval every time. body is a SINGLE Python "
|
|
"expression (no statements, no imports) over stdlib-only builtins — "
|
|
"reference bound constants/functions by name via constant_bindings / "
|
|
"function_bindings, and any additional runtime arguments via "
|
|
"expected_args. `version` must exceed the function's current max "
|
|
"version."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Function name."},
|
|
"version": {"type": "integer", "description": "Must exceed the current max version for this name."},
|
|
"body": {"type": "string", "description": "Single Python expression, e.g. \"amount * (1 + rate)\"."},
|
|
"constant_bindings": {"type": "object", "description": "Dict mapping constant id to the exact version to bind, e.g. {\"rate\": 1}."},
|
|
"function_bindings": {"type": "object", "description": "Dict mapping nested function name to the exact version to bind."},
|
|
"is_pure": {"type": "boolean", "description": "If true, results are memoized in the execution cache."},
|
|
"expected_args": {"type": "array", "items": {"type": "string"}, "description": "Runtime argument names the caller must supply to curry_call_function."},
|
|
"description": {"type": "string", "description": "What this function computes and which constants it binds."},
|
|
"arg_descriptions": {"type": "object", "description": "Per-argument hint strings, e.g. {\"amount\": \"USD, e.g. 100.00\"}."},
|
|
},
|
|
"required": ["name", "version", "body"],
|
|
},
|
|
},
|
|
},
|
|
_curry_declare_function,
|
|
),
|
|
"curry_get_function": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_get_function",
|
|
"description": "Retrieve a Curry function definition by its exact name and version.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Function name."},
|
|
"version": {"type": "integer", "description": "Exact version to retrieve."},
|
|
},
|
|
"required": ["name", "version"],
|
|
},
|
|
},
|
|
},
|
|
_curry_get_function,
|
|
),
|
|
"curry_list_functions": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_list_functions",
|
|
"description": "List every function declared in the Curry ledger, including expected_args for building curry_call_function calls.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"active_only": {"type": "boolean", "description": "If true (default), exclude retired functions."},
|
|
},
|
|
"required": [],
|
|
},
|
|
},
|
|
},
|
|
_curry_list_functions,
|
|
),
|
|
"curry_call_function": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_call_function",
|
|
"description": (
|
|
"Execute a previously declared Curry function version with runtime "
|
|
"arguments. Requires human approval every time. Use "
|
|
"curry_list_functions or curry_get_function first to discover "
|
|
"expected_args."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Function name."},
|
|
"version": {"type": "integer", "description": "Exact version to execute."},
|
|
"args": {"type": "object", "description": "Runtime arguments as a flat dict, e.g. {\"amount\": 100}."},
|
|
},
|
|
"required": ["name", "version"],
|
|
},
|
|
},
|
|
},
|
|
_curry_call_function,
|
|
),
|
|
"curry_retire_function": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "curry_retire_function",
|
|
"description": (
|
|
"Retire (tombstone) a Curry function version. Requires human approval "
|
|
"every time. Does not delete anything."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Function name."},
|
|
"version": {"type": "integer", "description": "Version to retire."},
|
|
"reason": {"type": "string", "description": "Why this version is being retired."},
|
|
},
|
|
"required": ["name", "version"],
|
|
},
|
|
},
|
|
},
|
|
_curry_retire_function,
|
|
),
|
|
}
|
|
|
|
|
|
# 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. The highest-risk
|
|
# tools in the self-edit and curry families additionally always pause for
|
|
# per-call approval regardless of that global policy — see
|
|
# ALWAYS_ASK_ACTION_TOOLS and chat.py. curry_call_function is an action
|
|
# because it executes code (a declared function body), the same reasoning
|
|
# that makes run_snippet an action tool despite not writing to any ledger.
|
|
ACTION_TOOLS = frozenset({
|
|
"web_search", "fetch_url", "remember", "run_snippet",
|
|
"edit_playbook", "edit_settings", "edit_source",
|
|
"curry_declare_constant", "curry_retire_constant",
|
|
"curry_declare_function", "curry_retire_function", "curry_call_function",
|
|
})
|
|
|
|
# Curry write/execute tools that always pause for per-call approval regardless
|
|
# of the global action_tool_policy, on the same reasoning as
|
|
# self_edit.ALWAYS_ASK_TOOLS: a policy of "allow" set for convenience on an
|
|
# unrelated tool must never silently also unlock unattended ledger writes or
|
|
# code execution. Read-only curry_get_*/curry_list_* tools are not action
|
|
# tools at all and are unaffected.
|
|
CURRY_ALWAYS_ASK_TOOLS = frozenset({
|
|
"curry_declare_constant", "curry_retire_constant",
|
|
"curry_declare_function", "curry_retire_function", "curry_call_function",
|
|
})
|
|
|
|
# The union chat.py actually checks — one place, so a future tool family
|
|
# doesn't have to remember there are two sets to update.
|
|
ALWAYS_ASK_ACTION_TOOLS = self_edit.ALWAYS_ASK_TOOLS | CURRY_ALWAYS_ASK_TOOLS
|
|
|
|
# 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.
|
|
STANDING_TOOLS = frozenset({"render_preview"})
|
|
|
|
# User-message cues that justify running the (slow, non-stream) tool loop with
|
|
# render_preview. Kept narrow so ordinary chat isn't blocked behind a tool turn.
|
|
_RENDER_HINTS = (
|
|
"visual", "visuals", "visualize", "visualization", "chart", "charts",
|
|
"graph", "graphs", "diagram", "diagrams", "canvas", "plot", "plots",
|
|
"interactive", "animation", "animations", "render_preview",
|
|
"render preview", "svg", "draw me", "live preview",
|
|
"demonstrate", "demo", "html demo", "html snippet", "html file",
|
|
# Ways of asking for something that reacts to the pointer. "interactive"
|
|
# alone missed "mouse-over sensitive", and with it the whole feature.
|
|
"hover", "mouse", "drag", "click on", "real-time", "realtime",
|
|
"simulation", "simulations", "simulate", "particle", "particles", "animate",
|
|
# Every language the render window can display. Naming one is asking for a
|
|
# preview, and this way a language added to PREVIEW_LANGS starts hinting
|
|
# for itself instead of being unreachable until someone edits this tuple -
|
|
# which is exactly what happened to jsx/tsx.
|
|
) + tuple(PREVIEW_LANGS)
|
|
|
|
|
|
# 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 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
|
|
|
|
|
|
def schemas_for(names: list[str], allow_actions: bool = True) -> list[dict]:
|
|
"""Tool schemas for a playbook's allowlist; unknown names are dropped.
|
|
When allow_actions is False, action tools are withheld so the model can't
|
|
even call them."""
|
|
return [
|
|
REGISTRY[n][0] for n in (names or [])
|
|
if n in REGISTRY and (allow_actions or not is_action(n))
|
|
]
|
|
|
|
|
|
def standing_schemas() -> list[dict]:
|
|
"""Schemas that ship with visual turns (currently just render_preview)."""
|
|
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)
|
|
if not entry:
|
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
try:
|
|
return await entry[1](**(args or {}))
|
|
except Exception as e: # a broken tool must not kill the chat loop
|
|
return json.dumps({"error": f"{name} failed: {e}"})
|