"""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)