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:
2026-08-25 22:11:00 -05:00
co-authored by Claude Sonnet 5
parent cb3d3f0a1f
commit 663e540b6b
9 changed files with 878 additions and 34 deletions
+204
View File
@@ -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
+83
View File
@@ -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())