WIP: feat(curry): add versioned ledger tools and direct commands #10
@@ -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