feat: direct tool invocation via /tool_name(arg=val) + wire Curry as real tools
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
+33
-15
@@ -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()
|
||||
|
||||
+60
-4
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
+344
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user