Adds synapse/slash_commands.py: a chat message that's nothing but /tool_name(arg=val, arg=val) dispatches straight through tools.dispatch(), skipping model selection, RAG/playbook context assembly, and the ask-policy approval round-trip entirely. A human typing this IS the approval - there's no one else to ask - so it's a deliberate, reviewed bypass of the approval step specifically, not of anything a tool validates internally (path boundaries, size caps, Curry's own sandbox checks all still run). Argument values parse via ast.literal_eval only: strings/numbers/bools/None/literal containers, no names, no calls, no attribute access - a malformed or hostile-looking argument fails to parse rather than executing anything. Wired into chat_stream_endpoint (main.py) as an early short-circuit, before any of the RAG/model-selection work that a slash-command doesn't need. Web needed no changes (it already forwards raw text unchanged); the TUI previously swallowed every leading "/" locally and never reached the backend with it, so tui_app.py's _handle_slash now falls through to _start_chat for anything shaped like a tool call while still handling its own local meta-commands (/help, /model, /new, ...) exactly as before. Also finally wires Curry in as ten real tools (curry_declare_constant, curry_get_constant/_latest, curry_list_constants, curry_retire_constant, curry_declare_function, curry_get_function, curry_list_functions, curry_call_function, curry_retire_function) - deferred from the vendoring pass. The five write/execute ones are ACTION tools in the same always-ask-regardless-of-global-policy floor as edit_source (ALWAYS_ASK_ACTION_TOOLS, generalized in tools.py from the old self_edit-only ALWAYS_ASK_TOOLS so future tool families share one place to register into). curry_call_function is gated as an action for the same reason run_snippet is: it executes code, even sandboxed. Fixed a real bug surfaced while wiring this up: curry_db is a long-lived singleton holding one sqlite3 connection (unlike NexusOS's own memory store, which opens/closes a fresh connection per call specifically to dodge this), and sqlite3 forbids using a connection from a different thread than created it. That's a non-issue in production (uvicorn's single event-loop thread), but Starlette's TestClient runs the ASGI app through an anyio portal thread, so it broke immediately under test. Fixed at the source (curry_core.py, Curry.__init__) with check_same_thread=False, documented as a second deliberate vendoring deviation alongside the PR #4 sandbox fix - there was never real concurrent access here, just an overly strict same-thread assertion tripping on a thread-identity change with only one logical caller. Verified: 244 backend tests pass (18 new for the parser + endpoint wiring + curry tool registration, 4 new for the TUI passthrough); the 12 pre-existing C/C++/Rust toolchain failures are unrelated and unchanged. Confirmed by hand over the real HTTP endpoint: successful dispatch, zero tool_request events (approval bypass working as designed), a format()-dunder exploit attempt still rejected by the vendored sandbox fix even through the new tool registration, malformed arguments rejected before ever reaching dispatch, and an unknown tool name rejected cleanly. Wheel rebuilt and content-checked (bin/check.sh's gate now also asserts slash_commands.py ships). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
"""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)
|