forked from enderofwings/NexusOS
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:
+8
-5
@@ -350,14 +350,17 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
messages.append(msg)
|
||||
|
||||
# If any action tool needs per-call approval, pause and wait for the user.
|
||||
# edit_playbook/edit_settings/edit_source always require it, regardless of
|
||||
# `policy` — a global "allow" set for convenience on an unrelated tool
|
||||
# (web_search, say) must never silently also unlock unattended
|
||||
# self-modification. See self_edit.ALWAYS_ASK_TOOLS.
|
||||
# edit_playbook/edit_settings/edit_source and the write/execute curry_*
|
||||
# tools always require it, regardless of `policy` — a global "allow" set
|
||||
# for convenience on an unrelated tool (web_search, say) must never
|
||||
# silently also unlock unattended self-modification or ledger writes.
|
||||
# See _tools.ALWAYS_ASK_ACTION_TOOLS. (This floor governs MODEL-issued
|
||||
# calls only — a human-typed /tool(...) slash-command skips it entirely,
|
||||
# by design: see slash_commands.py.)
|
||||
decisions = None
|
||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
||||
needs_approval = policy == "ask" or any(
|
||||
c.get("function", {}).get("name", "") in self_edit.ALWAYS_ASK_TOOLS
|
||||
c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS
|
||||
for c in action_calls
|
||||
)
|
||||
if needs_approval and action_calls:
|
||||
|
||||
+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()
|
||||
|
||||
+67
-4
@@ -209,6 +209,7 @@ from .memory.store import store, MemoryItem
|
||||
from .playbooks.store import playbook_store
|
||||
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
|
||||
|
||||
@@ -335,6 +336,47 @@ async def root():
|
||||
|
||||
# -------------------------
|
||||
# Chat (streaming)
|
||||
async def _slash_command_stream(
|
||||
slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError",
|
||||
conversation_id: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Dispatch a parsed slash-command and stream its result the same shape a
|
||||
normal reply streams in — a single content chunk, `event: done`, nothing
|
||||
else. No model call, no tool-loop, no approval round-trip: see
|
||||
slash_commands.py for why that's the deliberate design here."""
|
||||
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:
|
||||
yield (
|
||||
"event: error\ndata: "
|
||||
f"{_json.dumps({'detail': f'unknown tool: {slash.tool}'})}\n\n"
|
||||
)
|
||||
return
|
||||
|
||||
yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n"
|
||||
raw_result = await _tools.dispatch(slash.tool, slash.args)
|
||||
|
||||
# Tools that emit a fence (curry_*, edit_*, run_snippet) carry it as
|
||||
# `"fence"` in their JSON result — reuse it verbatim so the existing
|
||||
# nexus-run/nexus-edit renderers pick it up with no new frontend code.
|
||||
# Anything else is shown as pretty-printed JSON.
|
||||
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]):
|
||||
@@ -345,13 +387,37 @@ 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'")
|
||||
|
||||
# Direct tool invocation: /tool_name(arg=val, ...). A human typing this
|
||||
# IS the approval, so it skips model selection, RAG/playbook context
|
||||
# assembly, and the ask-policy round-trip entirely — see
|
||||
# slash_commands.py for what it does and does not bypass.
|
||||
_slash = _slash_commands.parse_slash_command(message)
|
||||
if _slash is not None:
|
||||
store.create_conversation(conversation_id, "")
|
||||
store.add_message(conversation_id, "user", message)
|
||||
_slash_inner = _slash_command_stream(_slash, conversation_id)
|
||||
|
||||
async def _slash_guarded() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
async for _chunk in _slash_inner:
|
||||
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 = playbooks.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))
|
||||
@@ -359,9 +425,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'")
|
||||
|
||||
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
|
||||
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
|
||||
|
||||
|
||||
@@ -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)
|
||||
+373
-10
@@ -5,13 +5,18 @@ Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||
|
||||
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 `edit_playbook`/`edit_settings`/`edit_source` change the
|
||||
assistant's own playbooks, settings, and (source checkout only) source code —
|
||||
see synapse/self_edit.py for what that last group actually does and does not
|
||||
protect against. The per-playbook allowlist (`PlaybookItem.tools`) is the
|
||||
first gate — an action tool only fires when a playbook explicitly lists it —
|
||||
and the three self-edit tools additionally always pause for per-call approval
|
||||
regardless of the global action_tool_policy (self_edit.ALWAYS_ASK_TOOLS).
|
||||
memory fact, `edit_playbook`/`edit_settings`/`edit_source` change the
|
||||
assistant's own playbooks, settings, and (source checkout only) source code
|
||||
(see synapse/self_edit.py), and `curry_*` reads and writes NexusOS's vendored
|
||||
Curry ledger (see synapse/curry_store.py) — immutable versioned constants and
|
||||
functions, with `curry_call_function` executing a previously declared one.
|
||||
The per-playbook allowlist (`PlaybookItem.tools`) is the first gate — an
|
||||
action tool only fires when a playbook explicitly lists it — and the
|
||||
highest-risk tools in both families additionally always pause for per-call
|
||||
approval regardless of the global action_tool_policy
|
||||
(ALWAYS_ASK_ACTION_TOOLS, below). A message that's nothing but
|
||||
`/tool_name(arg=val, ...)` skips that approval round-trip entirely and
|
||||
dispatches directly — see synapse/slash_commands.py for why that's safe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +25,7 @@ from typing import Awaitable, Callable
|
||||
|
||||
from . import code_run
|
||||
from . import playbook_manager
|
||||
from .curry_store import curry_db
|
||||
from . import self_edit
|
||||
from .memory.store import store, MemoryItem
|
||||
from .ollama_manager import get_ollama_manager
|
||||
@@ -971,6 +977,126 @@ async def _edit_settings(changes: dict | None = None, **_) -> str:
|
||||
return json.dumps(preview)
|
||||
|
||||
|
||||
# Curry (synapse/curry_core.py, vendored) — NexusOS's immutable, versioned
|
||||
# fact store. Its own methods raise (KeyError/ValueError/TypeError/RuntimeError)
|
||||
# on the failures a caller should see as a normal, expected result rather than
|
||||
# a crash (unknown id, version conflict, retired reference, etc.) — dispatch()
|
||||
# would already catch anything unhandled, but that produces a generic
|
||||
# "toolname failed: ..." string instead of the {"ok": False, "error": ...}
|
||||
# shape every other tool in this file returns, so it's caught locally here too.
|
||||
_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:
|
||||
# Not run.to_thread()'d like run_snippet/self_edit's blocking work: curry_db
|
||||
# holds one sqlite3 connection for its whole lifetime (unlike
|
||||
# PersistentMemoryStore, which opens/closes a fresh one per call), and
|
||||
# sqlite3 forbids using a connection from any thread but the one that
|
||||
# created it. curry_db is created at import time on the same thread this
|
||||
# runs on (the asyncio event loop thread), so calling it directly here is
|
||||
# both correct and, for local-file SQLite, fast enough not to need
|
||||
# offloading anyway.
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
return {"ok": True, "result": result}
|
||||
except (KeyError, ValueError, TypeError, RuntimeError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
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:
|
||||
"""Retrieve a constant by exact id and version."""
|
||||
return json.dumps(await _curry_call(curry_db.get_constant, id, version))
|
||||
|
||||
|
||||
async def _curry_get_constant_latest(id: str = "", **_) -> str:
|
||||
"""Retrieve the most recent active (non-retired) version of a constant."""
|
||||
return json.dumps(await _curry_call(curry_db.get_constant_latest, id))
|
||||
|
||||
|
||||
async def _curry_list_constants(active_only: bool = True, **_) -> str:
|
||||
"""List all declared constants and their latest versions."""
|
||||
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:
|
||||
"""ACTION tool: retire (tombstone) a constant version. Does not delete it —
|
||||
the version stays readable by exact id+version, just excluded from
|
||||
"latest" lookups and blocked from new declarations that depend on it."""
|
||||
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 a new, immutable version of a named function. Body
|
||||
is a single Python expression (no statements) over stdlib-only builtins,
|
||||
checked by curry_core.py's own static validator before this ever runs —
|
||||
but that validator is a tripwire against habitual mistakes, not a
|
||||
security boundary; treat it the same as run_snippet's containment."""
|
||||
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:
|
||||
"""Retrieve a function definition by exact name and version."""
|
||||
return json.dumps(await _curry_call(curry_db.get_function, name, version))
|
||||
|
||||
|
||||
async def _curry_list_functions(active_only: bool = True, **_) -> str:
|
||||
"""List all declared functions and their latest versions."""
|
||||
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:
|
||||
"""ACTION tool: execute a previously declared function version with the
|
||||
given runtime arguments. Locked constant/function dependencies resolve
|
||||
automatically; pure functions are memoized."""
|
||||
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:
|
||||
"""ACTION tool: retire (tombstone) a function version. Does not delete it."""
|
||||
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": (
|
||||
@@ -1299,19 +1425,256 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_edit_settings,
|
||||
),
|
||||
"curry_declare_constant": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_declare_constant",
|
||||
"description": (
|
||||
"Declare a new, immutable version of a named constant in the Curry "
|
||||
"ledger. Requires human approval every time. `version` must exceed "
|
||||
"the constant's current max version — versions are append-only, "
|
||||
"never overwritten. type_signature is one of Float64, Int32, "
|
||||
"String, Blob, Json, Tokens, Currency, Bool."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
"version": {"type": "integer", "description": "Must exceed the current max version for this id."},
|
||||
"value": {"description": "The value to store, matching type_signature."},
|
||||
"type_signature": {"type": "string", "description": "Float64 | Int32 | String | Blob | Json | Tokens | Currency | Bool"},
|
||||
"description": {"type": "string", "description": "What this constant means and why this value."},
|
||||
},
|
||||
"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 its exact id and version.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
"version": {"type": "integer", "description": "Exact version to retrieve."},
|
||||
},
|
||||
"required": ["id", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_get_constant,
|
||||
),
|
||||
"curry_get_constant_latest": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_get_constant_latest",
|
||||
"description": "Retrieve the most recent active (non-retired) version of a Curry constant.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_get_constant_latest,
|
||||
),
|
||||
"curry_list_constants": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_list_constants",
|
||||
"description": "List every constant declared in the Curry ledger.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_only": {"type": "boolean", "description": "If true (default), exclude retired constants."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_list_constants,
|
||||
),
|
||||
"curry_retire_constant": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_retire_constant",
|
||||
"description": (
|
||||
"Retire (tombstone) a Curry constant version. Requires human approval "
|
||||
"every time. This does not delete anything — the version stays "
|
||||
"readable by exact id+version, it's just excluded from 'latest' "
|
||||
"lookups going forward."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
"version": {"type": "integer", "description": "Version to retire."},
|
||||
"reason": {"type": "string", "description": "Why this version is being retired."},
|
||||
},
|
||||
"required": ["id", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_retire_constant,
|
||||
),
|
||||
"curry_declare_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_declare_function",
|
||||
"description": (
|
||||
"Declare a new, immutable version of a named function in the Curry "
|
||||
"ledger. Requires human approval every time. body is a SINGLE Python "
|
||||
"expression (no statements, no imports) over stdlib-only builtins — "
|
||||
"reference bound constants/functions by name via constant_bindings / "
|
||||
"function_bindings, and any additional runtime arguments via "
|
||||
"expected_args. `version` must exceed the function's current max "
|
||||
"version."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Must exceed the current max version for this name."},
|
||||
"body": {"type": "string", "description": "Single Python expression, e.g. \"amount * (1 + rate)\"."},
|
||||
"constant_bindings": {"type": "object", "description": "Dict mapping constant id to the exact version to bind, e.g. {\"rate\": 1}."},
|
||||
"function_bindings": {"type": "object", "description": "Dict mapping nested function name to the exact version to bind."},
|
||||
"is_pure": {"type": "boolean", "description": "If true, results are memoized in the execution cache."},
|
||||
"expected_args": {"type": "array", "items": {"type": "string"}, "description": "Runtime argument names the caller must supply to curry_call_function."},
|
||||
"description": {"type": "string", "description": "What this function computes and which constants it binds."},
|
||||
"arg_descriptions": {"type": "object", "description": "Per-argument hint strings, e.g. {\"amount\": \"USD, e.g. 100.00\"}."},
|
||||
},
|
||||
"required": ["name", "version", "body"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_declare_function,
|
||||
),
|
||||
"curry_get_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_get_function",
|
||||
"description": "Retrieve a Curry function definition by its exact name and version.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Exact version to retrieve."},
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_get_function,
|
||||
),
|
||||
"curry_list_functions": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_list_functions",
|
||||
"description": "List every function declared in the Curry ledger, including expected_args for building curry_call_function calls.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_only": {"type": "boolean", "description": "If true (default), exclude retired functions."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_list_functions,
|
||||
),
|
||||
"curry_call_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_call_function",
|
||||
"description": (
|
||||
"Execute a previously declared Curry function version with runtime "
|
||||
"arguments. Requires human approval every time. Use "
|
||||
"curry_list_functions or curry_get_function first to discover "
|
||||
"expected_args."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Exact version to execute."},
|
||||
"args": {"type": "object", "description": "Runtime arguments as a flat dict, e.g. {\"amount\": 100}."},
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_call_function,
|
||||
),
|
||||
"curry_retire_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_retire_function",
|
||||
"description": (
|
||||
"Retire (tombstone) a Curry function version. Requires human approval "
|
||||
"every time. Does not delete anything."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Version to retire."},
|
||||
"reason": {"type": "string", "description": "Why this version is being retired."},
|
||||
},
|
||||
"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. The three
|
||||
# self-edit tools additionally always pause for per-call approval regardless
|
||||
# of that global policy — see self_edit.ALWAYS_ASK_TOOLS and chat.py.
|
||||
# allowlist — a playbook granting one isn't enough on its own. The highest-risk
|
||||
# tools in the self-edit and curry families additionally always pause for
|
||||
# per-call approval regardless of that global policy — see
|
||||
# ALWAYS_ASK_ACTION_TOOLS and chat.py. curry_call_function is an action
|
||||
# because it executes code (a declared function body), the same reasoning
|
||||
# that makes run_snippet an action tool despite not writing to any ledger.
|
||||
ACTION_TOOLS = frozenset({
|
||||
"web_search", "fetch_url", "remember", "run_snippet",
|
||||
"edit_playbook", "edit_settings", "edit_source",
|
||||
"curry_declare_constant", "curry_retire_constant",
|
||||
"curry_declare_function", "curry_retire_function", "curry_call_function",
|
||||
})
|
||||
|
||||
# Curry write/execute tools that always pause for per-call approval regardless
|
||||
# of the global action_tool_policy, on the same reasoning as
|
||||
# self_edit.ALWAYS_ASK_TOOLS: a policy of "allow" set for convenience on an
|
||||
# unrelated tool must never silently also unlock unattended ledger writes or
|
||||
# code execution. Read-only curry_get_*/curry_list_* tools are not action
|
||||
# tools at all and are unaffected.
|
||||
CURRY_ALWAYS_ASK_TOOLS = frozenset({
|
||||
"curry_declare_constant", "curry_retire_constant",
|
||||
"curry_declare_function", "curry_retire_function", "curry_call_function",
|
||||
})
|
||||
|
||||
# The union chat.py actually checks — one place, so a future tool family
|
||||
# doesn't have to remember there are two sets to update.
|
||||
ALWAYS_ASK_ACTION_TOOLS = self_edit.ALWAYS_ASK_TOOLS | CURRY_ALWAYS_ASK_TOOLS
|
||||
|
||||
# Action tools offered on a *cue* rather than only via a playbook allowlist —
|
||||
# the run track is a standing UI capability like the render window, but unlike
|
||||
# render_preview it executes code, so it stays behind the action gate. Listed
|
||||
|
||||
Reference in New Issue
Block a user