diff --git a/bin/check.sh b/bin/check.sh index 263930d..3eaea06 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -75,6 +75,8 @@ if not any(n.startswith("synapse/_resources/playbooks/") for n in names): sys.exit("wheel is missing the seed playbooks") if "synapse/curry_core.py" not in names or "synapse/curry_store.py" not in names: sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)") +if "synapse/slash_commands.py" not in names: + sys.exit("wheel is missing synapse/slash_commands.py") print(f"wheel OK: {len(names)} files") PY else diff --git a/nexusos_cli/tui_app.py b/nexusos_cli/tui_app.py index 31c4135..68b7e3a 100644 --- a/nexusos_cli/tui_app.py +++ b/nexusos_cli/tui_app.py @@ -15,6 +15,7 @@ from typing import Any import httpx from synapse.nexus_config import settings +from synapse.slash_commands import parse_slash_command from .monitor import collect_snapshot @@ -344,6 +345,19 @@ class NexusTUI: log.write( f"[dim]model:[/] {_escape(self._model or '(auto)')}" ) + elif parse_slash_command(text) is not None: + # Shaped like /tool_name(arg=val, ...) rather than one of + # the local meta-commands above — not handled here, sent + # to the backend as-is. chat_stream_endpoint recognizes + # and dispatches it directly (see synapse/slash_commands.py); + # a malformed one still goes through so the user sees the + # backend's own error, with full context, in one place. + if self._busy: + log.write( + "[yellow]Still streaming — wait or Ctrl+C to interrupt[/]" + ) + else: + self._start_chat(text) else: log.write( f"[red]unknown command[/] /{_escape(cmd)} — try /help" diff --git a/synapse/chat.py b/synapse/chat.py index dccad72..5a4a1b1 100644 --- a/synapse/chat.py +++ b/synapse/chat.py @@ -350,14 +350,17 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu messages.append(msg) # If any action tool needs per-call approval, pause and wait for the user. - # edit_playbook/edit_settings/edit_source always require it, regardless of - # `policy` — a global "allow" set for convenience on an unrelated tool - # (web_search, say) must never silently also unlock unattended - # self-modification. See self_edit.ALWAYS_ASK_TOOLS. + # edit_playbook/edit_settings/edit_source and the write/execute curry_* + # tools always require it, regardless of `policy` — a global "allow" set + # for convenience on an unrelated tool (web_search, say) must never + # silently also unlock unattended self-modification or ledger writes. + # See _tools.ALWAYS_ASK_ACTION_TOOLS. (This floor governs MODEL-issued + # calls only — a human-typed /tool(...) slash-command skips it entirely, + # by design: see slash_commands.py.) decisions = None action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))] needs_approval = policy == "ask" or any( - c.get("function", {}).get("name", "") in self_edit.ALWAYS_ASK_TOOLS + c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS for c in action_calls ) if needs_approval and action_calls: diff --git a/synapse/curry_core.py b/synapse/curry_core.py index 71aa3e6..c101af2 100644 --- a/synapse/curry_core.py +++ b/synapse/curry_core.py @@ -7,21 +7,27 @@ for NexusOS. Kept as a single self-contained, stdlib-only file specifically so it can be vendored cleanly like this - no external dependencies, no package metadata of its own to reconcile with pyproject.toml. -Includes the fix from https://github.com/Athena-Pro/Curry/pull/4: a function -body could pass validate_function_body's AST check by hiding dunder-attribute -traversal inside a str.format()/str.format_map() field spec (e.g. -'{0.__globals__}'.format(x)), which the AST walk never inspects since it only -looks at literal Attribute/Name nodes, not string constant contents. Any -function with a function_bindings dependency hands eval_context a real Python -closure, and a closure's __globals__ is this module's own namespace - so that -was a working sandbox escape, not a theoretical one. Re-sync deliberately, not -automatically: pull upstream changes by hand and re-diff against this file's -history rather than scripting the sync, so a change here always has a reason -attached to it. +Two deliberate deviations from upstream, both explained at their call site +rather than just here — re-sync by hand and re-diff against this file's +history rather than scripting the sync, so every change here keeps its reason +attached: -Only Curry's own database operations execute against this file (declare_*, -call_function, etc.) - nothing in NexusOS wires model-authored content into -declare_function today. See synapse/curry_store.py for how NexusOS opens it. +1. The fix from https://github.com/Athena-Pro/Curry/pull/4 (validate_function_body, + below): a function body could pass the AST check by hiding dunder-attribute + traversal inside a str.format()/str.format_map() field spec (e.g. + '{0.__globals__}'.format(x)), which the AST walk never inspects since it + only looks at literal Attribute/Name nodes, not string constant contents — + a working sandbox escape, not a theoretical one. +2. check_same_thread=False on the connection (Curry.__init__, below) — a + long-lived singleton created at import time can legitimately be called + from a different OS thread than it was constructed on (Starlette's + TestClient runs the ASGI app through an anyio portal thread); nothing here + adds genuinely concurrent access, it relaxes an overly strict assertion. + +curry_declare_function/curry_call_function ARE reachable from model-issued +tool calls in NexusOS (see synapse/tools.py) — both are ACTION tools requiring +per-call human approval (synapse/tools.py's ALWAYS_ASK_ACTION_TOOLS), same as +run_snippet. See synapse/curry_store.py for how NexusOS opens this file. """ import sqlite3 @@ -95,7 +101,19 @@ class Curry: """Initialize Curry with SQLite backend.""" self.db_path = db_path self.fallback_db = fallback_db - self.conn = sqlite3.connect(db_path, uri=uri) + # NexusOS deviation: check_same_thread=False. self.conn is held for + # this object's whole lifetime (unlike NexusOS's own memory store, + # which opens/closes a fresh connection per call specifically to avoid + # this), and a long-lived singleton created at import time can + # legitimately be called from a different OS thread than it was + # constructed on — e.g. Starlette's TestClient runs the ASGI app + # through an anyio portal thread, and any future to_thread-offloaded + # caller would too. There is still only ever one logical caller at a + # time here (asyncio's single event loop + the GIL serialize access; + # nothing in NexusOS calls curry_db from two threads concurrently) — + # this relaxes sqlite3's same-thread assertion, it does not add real + # concurrent access that wasn't already being serialized. + self.conn = sqlite3.connect(db_path, uri=uri, check_same_thread=False) self.conn.row_factory = sqlite3.Row self.conn.execute("PRAGMA journal_mode=WAL;") self._initialize_schema() diff --git a/synapse/main.py b/synapse/main.py index c9dd425..8136f0a 100644 --- a/synapse/main.py +++ b/synapse/main.py @@ -209,6 +209,7 @@ from .memory.store import store, MemoryItem from .playbooks.store import playbook_store from .curry_store import curry_db # noqa: F401 - import triggers Curry's own preload at startup from .search import needs_web_search, web_search +from . import slash_commands as _slash_commands MEMORY_SERVICE = settings.memory_url @@ -335,6 +336,47 @@ async def root(): # ------------------------- # Chat (streaming) +async def _slash_command_stream( + slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError", + conversation_id: str, +) -> AsyncGenerator[str, None]: + """Dispatch a parsed slash-command and stream its result the same shape a + normal reply streams in — a single content chunk, `event: done`, nothing + else. No model call, no tool-loop, no approval round-trip: see + slash_commands.py for why that's the deliberate design here.""" + if isinstance(slash, _slash_commands.SlashCommandError): + yield f"event: error\ndata: {_json.dumps({'detail': slash.text})}\n\n" + return + + if slash.tool not in _tools.REGISTRY: + yield ( + "event: error\ndata: " + f"{_json.dumps({'detail': f'unknown tool: {slash.tool}'})}\n\n" + ) + return + + yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n" + raw_result = await _tools.dispatch(slash.tool, slash.args) + + # Tools that emit a fence (curry_*, edit_*, run_snippet) carry it as + # `"fence"` in their JSON result — reuse it verbatim so the existing + # nexus-run/nexus-edit renderers pick it up with no new frontend code. + # Anything else is shown as pretty-printed JSON. + content = raw_result + try: + parsed = _json.loads(raw_result) + if isinstance(parsed, dict) and isinstance(parsed.get("fence"), str): + content = parsed["fence"] + else: + content = _json.dumps(parsed, indent=2, ensure_ascii=False) + except (TypeError, ValueError): + pass + + store.add_message(conversation_id, "assistant", content) + yield f"data: {_json.dumps(content)}\n\n" + yield "event: done\ndata: {}\n\n" + + # ------------------------- @app.post("/chat/stream") async def chat_stream_endpoint(payload: Dict[str, Any]): @@ -345,13 +387,37 @@ async def chat_stream_endpoint(payload: Dict[str, Any]): _chat_slot_held = True try: message = payload.get("message", "") + conversation_id = payload.get("conversation_id") or str(_uuid.uuid4()) + + if not message: + raise HTTPException(status_code=400, detail="Missing 'message'") + + # Direct tool invocation: /tool_name(arg=val, ...). A human typing this + # IS the approval, so it skips model selection, RAG/playbook context + # assembly, and the ask-policy round-trip entirely — see + # slash_commands.py for what it does and does not bypass. + _slash = _slash_commands.parse_slash_command(message) + if _slash is not None: + store.create_conversation(conversation_id, "") + store.add_message(conversation_id, "user", message) + _slash_inner = _slash_command_stream(_slash, conversation_id) + + async def _slash_guarded() -> AsyncGenerator[str, None]: + try: + async for _chunk in _slash_inner: + yield _chunk + finally: + _CHAT_INFLIGHT.release() + + _chat_slot_held = False + return StreamingResponse(_slash_guarded(), media_type="text/event-stream") + app_settings = store.get_settings() # Model precedence: explicit request > active playbook's pinned model > auto-select. _active_pb = playbooks.get_main_playbook() _pb_model = _active_pb.model if (_active_pb and _active_pb.model) else "" model = payload.get("model") or _pb_model or await _auto_select_model(message) context = payload.get("context", {}) - conversation_id = payload.get("conversation_id") or str(_uuid.uuid4()) history = payload.get("history", []) temperature = payload.get("temperature", app_settings.get("temperature")) num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0)) @@ -359,9 +425,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]): gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1)) num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model) - if not message: - raise HTTPException(status_code=400, detail="Missing 'message'") - rendered_message = message # chat has no template vars; render_prompt is for the playbook path system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "") diff --git a/synapse/slash_commands.py b/synapse/slash_commands.py new file mode 100644 index 0000000..2a573d5 --- /dev/null +++ b/synapse/slash_commands.py @@ -0,0 +1,94 @@ +"""Direct tool invocation from chat input: `/tool_name(arg=val, arg=val)`. + +A human typing this IS the approval — there's no one else to ask — so a +recognized slash-command skips the ask-policy round-trip entirely and +dispatches straight through `tools.dispatch()`, the same entry point a +model-issued tool call already goes through. It does not bypass anything a +tool validates internally (path boundaries, size caps, Curry's own sandbox +checks, etc.) — only the human-approval step, which this message already is. + +Argument values are parsed with `ast.literal_eval`, not `eval()`: strings, +numbers, booleans, None, and literal lists/dicts/tuples only. There is no way +to reference a name, call a function, or access an attribute in this syntax — +a malformed or hostile-looking argument fails to parse rather than executing +anything, which is the "lint, not run" property that makes this different +from just typing Python. + +The whole message must be nothing but the command — this is a deliberate +command line, not a directive embedded in prose. Anything else (including a +message that merely starts with `/` but isn't shaped like this) falls through +to the normal chat/model path unchanged. +""" +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from typing import Any, Optional + +# name(args) where name is a plain identifier — the same shape as a Python +# function call, so it reads the way the tool's own schema already documents +# it. re.DOTALL: argument values (e.g. a multi-line body= string) may +# legitimately contain newlines. +_COMMAND_RE = re.compile(r"^/([A-Za-z_][A-Za-z0-9_]*)\((.*)\)\s*$", re.DOTALL) + + +@dataclass +class SlashCommand: + tool: str + args: dict[str, Any] + + +@dataclass +class SlashCommandError: + text: str + + +def parse_slash_command(message: str) -> Optional[SlashCommand | SlashCommandError]: + """Parse `/tool_name(arg=val, ...)`. + + Returns None when `message` isn't shaped like a slash-command at all (the + caller should treat it as an ordinary chat message). Returns + SlashCommandError when it looks like one but is malformed — that's worth + telling the user about rather than silently sending "/curry_call_fnction(...)" + to the model as if it were prose. + """ + stripped = (message or "").strip() + match = _COMMAND_RE.match(stripped) + if not match: + return None + + tool_name, raw_args = match.group(1), match.group(2).strip() + if not raw_args: + return SlashCommand(tool=tool_name, args={}) + + # Parse "k1=v1, k2=v2" as keyword arguments to a call with no positional + # arguments and no function to actually call — ast.parse(mode='eval') on a + # synthetic call expression reuses Python's own keyword-argument grammar + # (quoting, nesting, trailing commas) instead of hand-rolling a parser for + # it, while call() as a bare name is never resolved or invoked. + try: + tree = ast.parse(f"call({raw_args})", mode="eval") + except SyntaxError as e: + return SlashCommandError(f"could not parse arguments for /{tool_name}(...): {e}") + + call_node = tree.body + if not isinstance(call_node, ast.Call) or call_node.args: + return SlashCommandError( + f"/{tool_name}(...) arguments must be keyword form: arg=value, arg=value" + ) + + args: dict[str, Any] = {} + for kw in call_node.keywords: + if kw.arg is None: # **mapping unpacking — no source for that here + return SlashCommandError(f"/{tool_name}(...) does not support **-unpacking") + try: + args[kw.arg] = ast.literal_eval(kw.value) + except (ValueError, SyntaxError): + return SlashCommandError( + f"/{tool_name}(...): argument '{kw.arg}' must be a literal " + "(string, number, bool, None, list, dict, or tuple) — not an " + "expression, name, or call" + ) + + return SlashCommand(tool=tool_name, args=args) diff --git a/synapse/tools.py b/synapse/tools.py index 61eaaf6..f815ab1 100644 --- a/synapse/tools.py +++ b/synapse/tools.py @@ -5,13 +5,18 @@ Ollama drives the calling: `/api/chat` with a `tools` param returns Most tools READ local state (memory, history, documents, models). Some act: `web_search`/`fetch_url` make outbound HTTP requests, `remember` WRITES a -memory fact, and `edit_playbook`/`edit_settings`/`edit_source` change the -assistant's own playbooks, settings, and (source checkout only) source code — -see synapse/self_edit.py for what that last group actually does and does not -protect against. The per-playbook allowlist (`PlaybookItem.tools`) is the -first gate — an action tool only fires when a playbook explicitly lists it — -and the three self-edit tools additionally always pause for per-call approval -regardless of the global action_tool_policy (self_edit.ALWAYS_ASK_TOOLS). +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 @@ -20,6 +25,7 @@ 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 @@ -971,6 +977,126 @@ async def _edit_settings(changes: dict | None = None, **_) -> str: 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": ( @@ -1299,19 +1425,256 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = { }, _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 three -# self-edit tools additionally always pause for per-call approval regardless -# of that global policy — see self_edit.ALWAYS_ASK_TOOLS and chat.py. +# 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 diff --git a/tests/test_slash_commands.py b/tests/test_slash_commands.py new file mode 100644 index 0000000..faeef03 --- /dev/null +++ b/tests/test_slash_commands.py @@ -0,0 +1,204 @@ +"""synapse/slash_commands.py (the /tool_name(arg=val) parser) and its wiring +into chat_stream_endpoint (direct dispatch, no model call, no approval +round-trip) plus the ten curry_* tools it can now reach. +""" +import json + +import pytest +from fastapi.testclient import TestClient + +from synapse.slash_commands import SlashCommand, SlashCommandError, parse_slash_command +from synapse.main import app +from synapse import tools + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +def test_parses_keyword_arguments_as_python_literals(): + result = parse_slash_command('/curry_call_function(name="x", version=1, args={"a": 1})') + assert result == SlashCommand( + tool="curry_call_function", + args={"name": "x", "version": 1, "args": {"a": 1}}, + ) + + +def test_parses_no_arguments(): + assert parse_slash_command("/curry_list_functions()") == SlashCommand(tool="curry_list_functions", args={}) + + +def test_non_slash_message_returns_none(): + assert parse_slash_command("just chatting, not a command") is None + + +def test_slash_without_parens_returns_none(): + # The TUI's own local commands (/model foo, /new) use this shape — must + # never be mistaken for a tool call. + assert parse_slash_command("/model gpt") is None + + +def test_slash_embedded_in_prose_returns_none(): + assert parse_slash_command('hey /curry_call_function(name="x", version=1) run this') is None + + +def test_name_or_call_as_argument_value_is_rejected(): + # ast.literal_eval only accepts literals — a bare name or a call is a + # parse failure, not a value, so nothing here is ever evaluated. + result = parse_slash_command("/curry_call_function(x=some_name)") + assert isinstance(result, SlashCommandError) + result2 = parse_slash_command('/curry_call_function(x=__import__("os"))') + assert isinstance(result2, SlashCommandError) + + +def test_positional_arguments_are_rejected(): + result = parse_slash_command("/curry_call_function(1, 2)") + assert isinstance(result, SlashCommandError) + + +def test_double_star_unpacking_is_rejected(): + result = parse_slash_command('/curry_call_function(**{"a": 1})') + assert isinstance(result, SlashCommandError) + + +def test_malformed_syntax_is_rejected(): + result = parse_slash_command("/curry_call_function(name=)") + assert isinstance(result, SlashCommandError) + + +# --------------------------------------------------------------------------- +# Curry tool registration +# --------------------------------------------------------------------------- + +_CURRY_ACTION_TOOLS = { + "curry_declare_constant", "curry_retire_constant", + "curry_declare_function", "curry_retire_function", "curry_call_function", +} +_CURRY_READ_TOOLS = { + "curry_get_constant", "curry_get_constant_latest", "curry_list_constants", + "curry_get_function", "curry_list_functions", +} + + +def test_all_curry_tools_registered(): + for name in _CURRY_ACTION_TOOLS | _CURRY_READ_TOOLS: + assert name in tools.REGISTRY + + +def test_curry_write_and_execute_tools_are_gated_actions(): + for name in _CURRY_ACTION_TOOLS: + assert tools.is_action(name), name + assert name in tools.ALWAYS_ASK_ACTION_TOOLS, name + + +def test_curry_read_tools_are_not_actions(): + for name in _CURRY_READ_TOOLS: + assert not tools.is_action(name), name + + +# --------------------------------------------------------------------------- +# End-to-end HTTP: direct dispatch, no model call, no approval round-trip +# --------------------------------------------------------------------------- + +@pytest.fixture +def client(): + return TestClient(app) + + +def _sse_events(body: str) -> list[tuple[str, str]]: + events = [] + event_type = "message" + for block in body.split("\n\n"): + for line in block.splitlines(): + if line.startswith("event: "): + event_type = line[len("event: "):].strip() + elif line.startswith("data: "): + events.append((event_type, line[len("data: "):])) + event_type = "message" + return events + + +def test_slash_command_dispatches_without_model_call(client, monkeypatch): + from synapse import chat as chatmod + + async def _boom(*a, **k): + raise AssertionError("the model must not be called for a slash-command") + monkeypatch.setattr(chatmod, "stream_chat_response", _boom) + + resp = client.post("/chat/stream", json={ + "message": '/curry_list_functions()', + "conversation_id": "test-slash-http-1", + }) + events = _sse_events(resp.text) + assert ("status", json.dumps({"tool": "curry_list_functions"})) in events + assert any(t == "done" for t, _ in events) + + +def test_slash_command_skips_approval_round_trip(client, monkeypatch): + async def _fake_dispatch(name, args): + return json.dumps({"ok": True, "result": "did it"}) + monkeypatch.setattr(tools, "dispatch", _fake_dispatch) + + resp = client.post("/chat/stream", json={ + "message": '/curry_call_function(name="x", version=1, args={})', + "conversation_id": "test-slash-http-2", + }) + events = _sse_events(resp.text) + assert not any(t == "tool_request" for t, _ in events) + assert any(t == "done" for t, _ in events) + + +def test_slash_command_uses_fence_from_result_when_present(client, monkeypatch): + async def _fake_dispatch(name, args): + return json.dumps({"ok": True, "fence": "```nexus-curry\n{\"kind\": \"x\"}\n```"}) + monkeypatch.setattr(tools, "dispatch", _fake_dispatch) + + resp = client.post("/chat/stream", json={ + "message": '/curry_call_function(name="x", version=1, args={})', + "conversation_id": "test-slash-http-3", + }) + events = _sse_events(resp.text) + content = [d for t, d in events if t == "message"] + assert content and "nexus-curry" in content[0] + + +def test_slash_command_unknown_tool_yields_error_not_a_chat_reply(client): + resp = client.post("/chat/stream", json={ + "message": "/not_a_real_tool(a=1)", + "conversation_id": "test-slash-http-4", + }) + events = _sse_events(resp.text) + assert any(t == "error" for t, _ in events) + assert not any(t == "status" for t, _ in events) + + +def test_slash_command_malformed_yields_error(client): + resp = client.post("/chat/stream", json={ + "message": "/curry_call_function(x=some_name)", + "conversation_id": "test-slash-http-5", + }) + events = _sse_events(resp.text) + assert any(t == "error" for t, _ in events) + + +def test_message_with_leading_slash_but_not_command_shaped_goes_to_chat(client, monkeypatch): + # e.g. "/model gpt" or plain prose starting with "/" - must still reach + # the normal model path, not be swallowed as a broken slash-command. + called = {} + + async def _fake_stream(*a, **k): + called["hit"] = True + return + yield # pragma: no cover - make this an async generator + + # main.py did `from .chat import stream_chat_response`, a separate name + # binding from chat.stream_chat_response - patch the one main.py actually + # calls. + from synapse import main as mainmod + monkeypatch.setattr(mainmod, "stream_chat_response", _fake_stream) + + client.post("/chat/stream", json={ + "message": "/model gpt", + "conversation_id": "test-slash-http-6", + }) + assert called.get("hit") is True diff --git a/tests/test_tui.py b/tests/test_tui.py index 9d11f32..d235e71 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -301,3 +301,86 @@ def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch): def test_escape_round_trip_helper(): assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]") + + +def test_slash_tool_call_shape_forwards_to_start_chat(monkeypatch): + """/tool_name(arg=val) isn't a local meta-command — it must reach the + backend (synapse/slash_commands.py + chat_stream_endpoint dispatch it), + not fall into the generic 'unknown command' branch.""" + pytest.importorskip("textual") + from nexusos_cli.tui_app import NexusTUI + + app = NexusTUI.build_app(api_url="http://127.0.0.1:9") + calls: list[str] = [] + monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text)) + + async def _run(): + async with app.run_test(): + text = '/curry_call_function(name="double", version=1, args={"x": 21})' + app._handle_slash(text) + assert calls == [text] + log = app.query_one("#log") + assert not any("unknown command" in line.text for line in log.lines) + + asyncio.run(_run()) + + +def test_slash_malformed_tool_call_still_forwards_for_the_backend_error(monkeypatch): + """Even a malformed /tool(...) is forwarded rather than swallowed locally + — the backend's parser gives a clearer, more specific error than the + TUI's generic 'unknown command' would.""" + pytest.importorskip("textual") + from nexusos_cli.tui_app import NexusTUI + + app = NexusTUI.build_app(api_url="http://127.0.0.1:9") + calls: list[str] = [] + monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text)) + + async def _run(): + async with app.run_test(): + text = "/curry_call_function(x=__import__('os'))" + app._handle_slash(text) + assert calls == [text] + + asyncio.run(_run()) + + +def test_slash_local_meta_commands_still_handled_locally(monkeypatch): + """A known local command must still be handled in-TUI, never forwarded — + the new tool-call passthrough is strictly the fallback branch.""" + pytest.importorskip("textual") + from nexusos_cli.tui_app import NexusTUI + + app = NexusTUI.build_app(api_url="http://127.0.0.1:9") + calls: list[str] = [] + monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text)) + + async def _run(): + async with app.run_test(): + app._handle_slash("/help") + assert calls == [] + log = app.query_one("#log") + assert any("this list" in line.text for line in log.lines) + + asyncio.run(_run()) + + +def test_slash_unknown_bare_command_still_rejected(monkeypatch): + """A genuinely unknown command (no parens, not a local command) keeps the + existing 'unknown command' behavior rather than silently forwarding + anything that starts with /.""" + pytest.importorskip("textual") + from nexusos_cli.tui_app import NexusTUI + + app = NexusTUI.build_app(api_url="http://127.0.0.1:9") + calls: list[str] = [] + monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text)) + + async def _run(): + async with app.run_test(): + app._handle_slash("/frobnicate") + assert calls == [] + log = app.query_one("#log") + assert any("unknown command" in line.text for line in log.lines) + + asyncio.run(_run())