diff --git a/bin/check.sh b/bin/check.sh index dbaad84..21ecf4b 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -65,6 +65,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 e479407..f15b81f 100644 --- a/synapse/chat.py +++ b/synapse/chat.py @@ -167,10 +167,16 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu break messages.append(msg) - # If any action tool needs per-call approval, pause and wait for the user. + # Curry write/execute tools always require approval when model-issued, + # even if the global policy allows lower-risk actions. A human-typed + # /tool(...) command is dispatched separately by main.py. decisions = None action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))] - if policy == "ask" and action_calls: + needs_approval = policy == "ask" or any( + c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS + for c in action_calls + ) + if needs_approval and action_calls: event = asyncio.Event() # Single-use capability token, delivered only to the client that owns # this stream. /chat/approve requires it, so knowing the (guessable, 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 d1a9140..edb3fdf 100644 --- a/synapse/main.py +++ b/synapse/main.py @@ -184,6 +184,7 @@ from .memory.store import store, MemoryItem from .playbooks.store import playbook_store, PlaybookItem 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 @@ -461,6 +462,38 @@ async def _resume_dropped_extractions() -> None: # ------------------------- # Chat (streaming) # ------------------------- +async def _slash_command_stream( + slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError", + conversation_id: str, +) -> AsyncGenerator[str, None]: + """Dispatch an explicit slash-command without a model or approval round-trip.""" + 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: + detail = f"unknown tool: {slash.tool}" + yield f"event: error\ndata: {_json.dumps({'detail': detail})}\n\n" + return + + yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n" + raw_result = await _tools.dispatch(slash.tool, slash.args) + + 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]): # Bound concurrent chats so a flood can't fan out unlimited model inference. @@ -470,13 +503,39 @@ 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'") + + # A whole-message /tool_name(arg=val, ...) command is an explicit human + # action. It skips model selection and approval but not the tool's own + # validation; slash_commands.py accepts literal keyword values only. + slash = _slash_commands.parse_slash_command(message) + if slash is not None: + project_id = store.conversation_project(conversation_id) + if project_id is None: + project_id = store.get_settings().get("active_project", "") + store.create_conversation(conversation_id, project_id or "") + store.add_message(conversation_id, "user", message) + slash_stream = _slash_command_stream(slash, conversation_id) + + async def _slash_guarded() -> AsyncGenerator[str, None]: + try: + async for chunk in slash_stream: + 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 = playbook_manager.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)) @@ -484,9 +543,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'") - # Resolve the project scope: an existing conversation keeps its bound project; # a brand-new one inherits the current workspace (active_project setting). # Everything project-scoped below (instructions, memory facts, RAG) uses it. 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 38863a4..d773232 100644 --- a/synapse/tools.py +++ b/synapse/tools.py @@ -3,16 +3,20 @@ 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). A few act: -`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a -memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security -boundary — an action tool only fires when a playbook explicitly lists it. +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 `curry_*` reads or writes NexusOS's vendored Curry ledger. +The per-playbook allowlist (`PlaybookItem.tools`) is the first gate. Curry +write/execute tools additionally require per-call approval when model-issued. +A message consisting only of `/tool_name(arg=val, ...)` dispatches directly; +see `synapse/slash_commands.py` for that explicit-human-command boundary. """ from __future__ import annotations import json from typing import Awaitable, Callable +from .curry_store import curry_db from .memory.store import store, MemoryItem from .ollama_manager import get_ollama_manager @@ -215,6 +219,124 @@ async def _list_files(pattern: str = "", **_) -> str: return json.dumps(sorted(hits)) +# Curry (synapse/curry_core.py, vendored) — immutable, versioned constants and +# functions. Expected caller errors keep the same structured JSON shape as the +# other tools instead of falling through dispatch()'s generic error envelope. +_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: + # Curry holds one SQLite connection. Calls stay on the event-loop thread, + # where these local database operations are short and naturally serialized. + try: + result = fn(*args, **kwargs) + return {"ok": True, "result": result} + except (KeyError, ValueError, TypeError, RuntimeError) as exc: + return {"ok": False, "error": str(exc)} + + +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: + return json.dumps(await _curry_call(curry_db.get_constant, id, version)) + + +async def _curry_get_constant_latest(id: str = "", **_) -> str: + return json.dumps(await _curry_call(curry_db.get_constant_latest, id)) + + +async def _curry_list_constants(active_only: bool = True, **_) -> str: + 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: + 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 one statically validated expression.""" + 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: + return json.dumps(await _curry_call(curry_db.get_function, name, version)) + + +async def _curry_list_functions(active_only: bool = True, **_) -> str: + 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: + 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: + 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": ( @@ -360,13 +482,229 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = { }, _remember, ), + "curry_declare_constant": ( + { + "type": "function", + "function": { + "name": "curry_declare_constant", + "description": ( + "Declare a new immutable version of a Curry constant. " + "Requires per-call human approval when model-issued." + ), + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Constant identifier."}, + "version": {"type": "integer", "description": "A new, higher version."}, + "value": {"description": "Value matching type_signature."}, + "type_signature": { + "type": "string", + "description": ( + "Float64 | Int32 | String | Blob | Json | Tokens | " + "Currency | Bool" + ), + }, + "description": {"type": "string"}, + }, + "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 exact id and version.", + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "version": {"type": "integer"}, + }, + "required": ["id", "version"], + }, + }, + }, + _curry_get_constant, + ), + "curry_get_constant_latest": ( + { + "type": "function", + "function": { + "name": "curry_get_constant_latest", + "description": "Retrieve the latest active version of a Curry constant.", + "parameters": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + }, + _curry_get_constant_latest, + ), + "curry_list_constants": ( + { + "type": "function", + "function": { + "name": "curry_list_constants", + "description": "List Curry constants.", + "parameters": { + "type": "object", + "properties": {"active_only": {"type": "boolean"}}, + }, + }, + }, + _curry_list_constants, + ), + "curry_retire_constant": ( + { + "type": "function", + "function": { + "name": "curry_retire_constant", + "description": ( + "Retire, but do not delete, a Curry constant version. " + "Requires per-call human approval when model-issued." + ), + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "version": {"type": "integer"}, + "reason": {"type": "string"}, + }, + "required": ["id", "version"], + }, + }, + }, + _curry_retire_constant, + ), + "curry_declare_function": ( + { + "type": "function", + "function": { + "name": "curry_declare_function", + "description": ( + "Declare a new immutable Curry function version. The body is one " + "statically validated Python expression. Requires per-call human " + "approval when model-issued." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "integer"}, + "body": {"type": "string"}, + "constant_bindings": {"type": "object"}, + "function_bindings": {"type": "object"}, + "is_pure": {"type": "boolean"}, + "expected_args": { + "type": "array", + "items": {"type": "string"}, + }, + "description": {"type": "string"}, + "arg_descriptions": {"type": "object"}, + }, + "required": ["name", "version", "body"], + }, + }, + }, + _curry_declare_function, + ), + "curry_get_function": ( + { + "type": "function", + "function": { + "name": "curry_get_function", + "description": "Retrieve a Curry function by exact name and version.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "integer"}, + }, + "required": ["name", "version"], + }, + }, + }, + _curry_get_function, + ), + "curry_list_functions": ( + { + "type": "function", + "function": { + "name": "curry_list_functions", + "description": "List Curry functions and their expected arguments.", + "parameters": { + "type": "object", + "properties": {"active_only": {"type": "boolean"}}, + }, + }, + }, + _curry_list_functions, + ), + "curry_call_function": ( + { + "type": "function", + "function": { + "name": "curry_call_function", + "description": ( + "Execute an exact Curry function version with runtime arguments. " + "Requires per-call human approval when model-issued." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "integer"}, + "args": {"type": "object"}, + }, + "required": ["name", "version"], + }, + }, + }, + _curry_call_function, + ), + "curry_retire_function": ( + { + "type": "function", + "function": { + "name": "curry_retire_function", + "description": ( + "Retire, but do not delete, a Curry function version. " + "Requires per-call human approval when model-issued." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "integer"}, + "reason": {"type": "string"}, + }, + "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. -ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"}) +# allowlist — a playbook granting one isn't enough on its own. Curry writes and +# execution additionally require per-call approval for model-issued calls. +CURRY_ALWAYS_ASK_TOOLS = frozenset({ + "curry_declare_constant", + "curry_retire_constant", + "curry_declare_function", + "curry_retire_function", + "curry_call_function", +}) +ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"}) | CURRY_ALWAYS_ASK_TOOLS +ALWAYS_ASK_ACTION_TOOLS = CURRY_ALWAYS_ASK_TOOLS def is_action(name: str) -> bool: 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())