forked from enderofwings/NexusOS
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ff8c0c24 | ||
|
|
13bffc41e9 | ||
|
|
841464f9b1 | ||
|
|
1183ab5282 | ||
|
|
4a6d1bf8bb | ||
|
|
9ed2908170 | ||
|
|
9ca37057eb | ||
|
|
da3509eb04 | ||
|
|
6f5094b5fc | ||
|
|
1449280fcd |
@@ -13,6 +13,7 @@ synapse/memory/memory.db
|
|||||||
synapse/memory/memory.db-wal
|
synapse/memory/memory.db-wal
|
||||||
synapse/memory/memory.db-shm
|
synapse/memory/memory.db-shm
|
||||||
assets/gitnexus-logo.svg
|
assets/gitnexus-logo.svg
|
||||||
|
/data/curry.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -50,25 +50,28 @@ uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
|
|||||||
cd interface/web && npm run dev
|
cd interface/web && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
**Management CLI** (`ncp`) — start/stop services with PID tracking, plus terminal
|
**Management CLI** (`nexus` / `ncp`) — start/stop services with PID tracking, plus
|
||||||
access to the same features as the web UI (all via the REST API on `:8000`):
|
terminal access to the same features as the web UI (REST API on `:8000`):
|
||||||
```bash
|
```bash
|
||||||
./management/nexus-cli.sh start # starts backend + frontend
|
./management/nexus-cli.sh start # starts backend + frontend
|
||||||
./management/nexus-cli.sh stop
|
./management/nexus-cli.sh stop
|
||||||
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
|
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
|
||||||
|
|
||||||
# Feature commands (dispatch to nexusos_cli/nexus_api.py — httpx, no TUI):
|
# Interactive TUI (Hermes/OpenClaw-style; needs pip install 'nexusos-ai[tui]'):
|
||||||
ncp chat "<message>" # stream a reply (POST /chat/stream)
|
nexus # bare command opens the Textual chat TUI
|
||||||
ncp memory list|add <text>|rm <id>
|
nexus tui # same, explicit
|
||||||
ncp playbook list|show <id> # first playbook (*) is the active system prompt
|
|
||||||
ncp history [query] # recent conversations
|
# Feature one-shots (dispatch to nexusos_cli/nexus_api.py — httpx):
|
||||||
|
nexus chat send "<message>" # stream a reply (POST /chat/stream)
|
||||||
|
nexus memory list|add <text>|rm <id>
|
||||||
|
nexus playbook list|show <id> # first playbook (*) is the active system prompt
|
||||||
|
nexus history [query] # recent conversations
|
||||||
|
nexus monitor # ASCII status dashboard (no prompt)
|
||||||
```
|
```
|
||||||
The old curses TUIs (`nexus-chat.py`, `nexus-playbook.py`) were removed in favor of
|
The interactive TUI lives in `nexusos_cli/tui_app.py` (Textual, optional extra).
|
||||||
these API-backed subcommands. The CLI covers chat, memory, playbooks, and history;
|
One-shot subcommands and `nexus monitor` remain for scripts. The CLI package is
|
||||||
the web UI and control panel expose the remaining management features.
|
`nexusos_cli/` (what the wheel ships); `management/` keeps desktop-only pieces —
|
||||||
The CLI itself lives in `nexusos_cli/` (that is what the wheel ships and what
|
shell wrappers, Tk control panel, XFCE panel wiring.
|
||||||
`nexus`/`ncp`/`nexusos` dispatch to); `management/` keeps the desktop-only
|
|
||||||
pieces — the shell wrappers, the Tk control panel, and the XFCE panel wiring.
|
|
||||||
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
|
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
|
||||||
`bin/panel/nexus-popup.py`) stays.
|
`bin/panel/nexus-popup.py`) stays.
|
||||||
|
|
||||||
@@ -123,6 +126,12 @@ React 19 + Vite. No routing library — `App.jsx` manages page state in a single
|
|||||||
### Persistent Storage
|
### Persistent Storage
|
||||||
Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import.
|
Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import.
|
||||||
|
|
||||||
|
### Curry (`synapse/curry_core.py` + `synapse/curry_store.py`)
|
||||||
|
`curry_core.py` is vendored from [Athena-Pro/Curry](https://github.com/Athena-Pro/Curry), with two deliberate deviations from upstream documented in the file's own docstring (a sandbox-escape fix and a `check_same_thread=False` connection fix) — an immutable, versioned fact store (constants, functions, model registrations, inference provenance) backed by its own SQLite file (`CURRY_DB` in `nexus_config.py`, separate from `memory.db`). `curry_store.py` opens it into a module-level singleton (`curry_db`) at import time — the same pattern as `memory.store.store` / `playbooks.store.playbook_store` — so it's preloaded and callable from anywhere in the backend without extra setup. It ships inside the wheel (`bin/check.sh`'s packaging gate asserts this) and has no external dependencies of its own. Ten `curry_*` tools in `tools.py` expose it to chat (`curry_declare_constant`, `curry_call_function`, etc.); the five that write or execute are ACTION tools in `ALWAYS_ASK_ACTION_TOOLS`, same approval floor as `edit_source`. Re-sync `curry_core.py` from upstream by hand, not by script.
|
||||||
|
|
||||||
|
### Direct tool invocation (`synapse/slash_commands.py`)
|
||||||
|
A chat message that's nothing but `/tool_name(arg=val, ...)` (Python-call-shaped, arguments parsed via `ast.literal_eval` only — no names, no calls, no attribute access) dispatches straight through `tools.dispatch()`, skipping model selection, context assembly, and the ask-policy approval round-trip. A human typing it is the approval. Wired into `chat_stream_endpoint` as an early short-circuit; the TUI's `_handle_slash` falls through to the backend for anything shaped like a tool call that isn't one of its own local meta-commands (`/help`, `/model`, `/new`).
|
||||||
|
|
||||||
### Logs & Runtime State
|
### Logs & Runtime State
|
||||||
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
|
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
|
||||||
- `runtime/logs/ollama.log`, `runtime/logs/chat.log`
|
- `runtime/logs/ollama.log`, `runtime/logs/chat.log`
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ if not any(n.startswith("synapse/_resources/web/") for n in names):
|
|||||||
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
|
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
|
||||||
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
|
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
|
||||||
sys.exit("wheel is missing the seed playbooks")
|
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")
|
print(f"wheel OK: {len(names)} files")
|
||||||
PY
|
PY
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ and seed playbooks. Extras keep platform-sensitive dependencies optional:
|
|||||||
- `desktop`: desktop process support and Windows pywebview
|
- `desktop`: desktop process support and Windows pywebview
|
||||||
- `search`: DuckDuckGo web search for chat
|
- `search`: DuckDuckGo web search for chat
|
||||||
- `mail`: IMAP mail reading
|
- `mail`: IMAP mail reading
|
||||||
|
- `tui`: Textual interactive chat UI (`nexus` with no subcommand)
|
||||||
- `all`: every optional capability at once
|
- `all`: every optional capability at once
|
||||||
|
|
||||||
## Common commands
|
## Common commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
nexus Interactive chat TUI (needs nexusos-ai[tui])
|
||||||
|
nexus tui Same as bare nexus
|
||||||
nexus init Create writable state and seed playbooks
|
nexus init Create writable state and seed playbooks
|
||||||
nexus doctor [--fix] [--json] Diagnose the install and provider
|
nexus doctor [--fix] [--json] Diagnose the install and provider
|
||||||
nexus paths [--json] Show package, state, and asset locations
|
nexus paths [--json] Show package, state, and asset locations
|
||||||
|
|||||||
+36
-3
@@ -398,6 +398,24 @@ def cmd_monitor(args) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_tui(args) -> int:
|
||||||
|
"""Interactive Hermes/OpenClaw-style chat TUI (requires nexusos-ai[tui])."""
|
||||||
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||||
|
print(
|
||||||
|
"The TUI needs a terminal. Use: nexus chat send \"…\"\n"
|
||||||
|
"Or run `nexus` in an interactive shell.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
from .tui_app import run_tui
|
||||||
|
except ImportError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
api = getattr(args, "api_url", None) or settings.api_url
|
||||||
|
return run_tui(api_url=api)
|
||||||
|
|
||||||
|
|
||||||
def _target_flag(target: str | None):
|
def _target_flag(target: str | None):
|
||||||
return {
|
return {
|
||||||
"memory": "--memory",
|
"memory": "--memory",
|
||||||
@@ -722,10 +740,20 @@ def _port(value: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="nexus",
|
||||||
|
description=(
|
||||||
|
"NexusOS local AI runtime and API client. "
|
||||||
|
"With no subcommand, opens the interactive TUI (needs nexusos-ai[tui])."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
|
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
|
||||||
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
|
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
# Bare `nexus` → TUI. Subcommands remain for scripts and one-shots.
|
||||||
|
sub = parser.add_subparsers(dest="command", required=False)
|
||||||
|
|
||||||
|
p = sub.add_parser("tui", help="interactive chat TUI (default when no subcommand)")
|
||||||
|
p.set_defaults(fn=cmd_tui)
|
||||||
|
|
||||||
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
|
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
|
||||||
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
||||||
@@ -827,7 +855,12 @@ def _normalize_legacy_argv(argv) -> list[str]:
|
|||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(_normalize_legacy_argv(argv))
|
argv = _normalize_legacy_argv(argv)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not getattr(args, "command", None):
|
||||||
|
# Bare `nexus` / `ncp` / `nexusos` → interactive TUI.
|
||||||
|
args.command = "tui"
|
||||||
|
args.fn = cmd_tui
|
||||||
if args.command == "config":
|
if args.command == "config":
|
||||||
if args.action in ("get", "unset") and not args.key:
|
if args.action in ("get", "unset") and not args.key:
|
||||||
parser.error(f"config {args.action} requires KEY")
|
parser.error(f"config {args.action} requires KEY")
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
"""Hermes/OpenClaw-style interactive TUI for NexusOS.
|
||||||
|
|
||||||
|
Optional: needs the ``tui`` extra (Textual). Launched by a bare ``nexus`` when
|
||||||
|
stdin/stdout are a TTY. Classic one-shots (``nexus chat send``, ``nexus monitor``,
|
||||||
|
``nexus status``, …) stay on the argparse tree.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
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
|
||||||
|
|
||||||
|
# Between SSE chunks a silent backend must not pin the UI forever. Connect stays
|
||||||
|
# short; the overall stream may run minutes.
|
||||||
|
_STREAM_TIMEOUT = httpx.Timeout(None, connect=5.0, read=120.0, write=30.0, pool=5.0)
|
||||||
|
_APPROVAL_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_textual():
|
||||||
|
try:
|
||||||
|
from textual.app import App
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||||
|
except ImportError as e: # pragma: no cover - optional extra
|
||||||
|
raise ImportError(
|
||||||
|
"The interactive TUI needs the 'tui' extra — "
|
||||||
|
"pip install 'nexusos-ai[tui]' (or: pip install textual)."
|
||||||
|
) from e
|
||||||
|
return App, Binding, Footer, Header, Input, RichLog, Static
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(text: str) -> str:
|
||||||
|
"""Make model/user text safe for Rich markup widgets.
|
||||||
|
|
||||||
|
Rich's own escape is the only version that round-trips. Escaping every
|
||||||
|
backslash by hand looks equivalent but is not: Rich un-escapes ``\\[`` and
|
||||||
|
never collapses ``\\\\``, so doubling them puts the doubles on screen -
|
||||||
|
every Windows path and regex escape in a reply renders wrong.
|
||||||
|
|
||||||
|
Imported inside the function so the module still loads without the ``tui``
|
||||||
|
extra. Rich is not declared in pyproject: Textual depends on it, so it is
|
||||||
|
present whenever the TUI can run at all, and tests/test_packaging_deps.py
|
||||||
|
lists it in TRANSITIVE for that reason.
|
||||||
|
"""
|
||||||
|
from rich.markup import escape
|
||||||
|
|
||||||
|
return escape(text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_user_line(message: str) -> str:
|
||||||
|
return f"[bold green]you>[/] {_escape(message)}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_assistant_line(text: str) -> str:
|
||||||
|
return f"[bold blue]nexus>[/] {_escape(text)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _deny_tool_request(
|
||||||
|
*,
|
||||||
|
api_url: str,
|
||||||
|
conversation_id: str,
|
||||||
|
payload: str,
|
||||||
|
client_factory=httpx.Client,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Immediately deny a TUI action request and let the stream resume.
|
||||||
|
|
||||||
|
The web client presents an approval dialog, but the TUI does not yet have
|
||||||
|
that interaction. Denying with the stream's capability token preserves the
|
||||||
|
``ask`` safety boundary without leaving the backend waiting for five minutes.
|
||||||
|
"""
|
||||||
|
request = json.loads(payload)
|
||||||
|
token = request.get("token") or ""
|
||||||
|
actions = request.get("actions") or []
|
||||||
|
names = [
|
||||||
|
action.get("name", "")
|
||||||
|
for action in actions
|
||||||
|
if isinstance(action, dict) and action.get("name")
|
||||||
|
]
|
||||||
|
if not token or not names:
|
||||||
|
raise ValueError("invalid tool approval request")
|
||||||
|
body = {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"token": token,
|
||||||
|
"decisions": {name: False for name in names},
|
||||||
|
}
|
||||||
|
with client_factory(base_url=api_url, timeout=_APPROVAL_TIMEOUT) as client:
|
||||||
|
response = client.post("/chat/approve", json=body)
|
||||||
|
response.raise_for_status()
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _status_line(snap: dict | None = None) -> str:
|
||||||
|
"""Format a snapshot. Pass ``snap`` — do not omit it on the UI thread."""
|
||||||
|
if snap is None:
|
||||||
|
snap = collect_snapshot()
|
||||||
|
svcs = snap.get("services") or {}
|
||||||
|
api = snap.get("api") or {}
|
||||||
|
host = snap.get("host") or {}
|
||||||
|
parts = [f"NexusOS {snap.get('version', '')}"]
|
||||||
|
for key in ("backend", "memory", "provider"):
|
||||||
|
info = svcs.get(key) or {}
|
||||||
|
if key == "provider":
|
||||||
|
up = bool(info.get("reachable"))
|
||||||
|
else:
|
||||||
|
up = bool(info.get("running"))
|
||||||
|
parts.append(f"{key}={'UP' if up else 'DOWN'}")
|
||||||
|
if api.get("online"):
|
||||||
|
parts.append(f"tools={api.get('action_tool_policy') or '—'}")
|
||||||
|
cpu = host.get("cpu_pct")
|
||||||
|
if cpu is not None:
|
||||||
|
parts.append(f"cpu={cpu:.0f}%")
|
||||||
|
chains = snap.get("toolchains") or []
|
||||||
|
ready = [c["lang"] for c in chains if c.get("ready")]
|
||||||
|
if ready:
|
||||||
|
parts.append("run=" + ",".join(ready))
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_status(snap: dict | None = None) -> str:
|
||||||
|
"""One-line strip for the bar under the chat log."""
|
||||||
|
if snap is None:
|
||||||
|
snap = collect_snapshot()
|
||||||
|
host = snap.get("host") or {}
|
||||||
|
api = snap.get("api") or {}
|
||||||
|
recent = snap.get("recent_tools") or []
|
||||||
|
cpu = host.get("cpu_pct")
|
||||||
|
mem = host.get("mem_pct")
|
||||||
|
bits = []
|
||||||
|
if cpu is not None:
|
||||||
|
bits.append(f"cpu {cpu:.0f}%")
|
||||||
|
if mem is not None:
|
||||||
|
bits.append(f"mem {mem:.0f}%")
|
||||||
|
if api.get("online"):
|
||||||
|
bits.append(
|
||||||
|
f"memories={api.get('memories') if api.get('memories') is not None else '—'} "
|
||||||
|
f"chats={api.get('conversations') if api.get('conversations') is not None else '—'}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bits.append("api DOWN — nexus start")
|
||||||
|
if recent:
|
||||||
|
bits.append("recent " + ", ".join(recent[:4]))
|
||||||
|
return " │ ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
class NexusTUI:
|
||||||
|
"""Factory so Textual imports stay lazy until run()."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_app(*, api_url: str | None = None):
|
||||||
|
App, Binding, Footer, Header, Input, RichLog, Static = _require_textual()
|
||||||
|
base = (api_url or settings.api_url).rstrip("/")
|
||||||
|
|
||||||
|
class AppImpl(App):
|
||||||
|
CSS = """
|
||||||
|
Screen { layout: vertical; }
|
||||||
|
#status {
|
||||||
|
height: 1;
|
||||||
|
dock: top;
|
||||||
|
background: $boost;
|
||||||
|
color: $text;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
#strip {
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
#log {
|
||||||
|
height: 1fr;
|
||||||
|
border: tall $accent;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
#live {
|
||||||
|
height: auto;
|
||||||
|
max-height: 8;
|
||||||
|
padding: 0 1;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
#prompt { dock: bottom; }
|
||||||
|
"""
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("ctrl+c", "interrupt", "Interrupt", priority=True),
|
||||||
|
Binding("ctrl+d", "quit", "Quit", priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.api_url = base
|
||||||
|
self.conversation_id: str | None = None
|
||||||
|
self.history: list[dict] = []
|
||||||
|
self._model: str | None = None
|
||||||
|
self._busy = False
|
||||||
|
self._stop_stream = threading.Event()
|
||||||
|
self._stream_cancel: (
|
||||||
|
tuple[asyncio.AbstractEventLoop, asyncio.Task] | None
|
||||||
|
) = None
|
||||||
|
self._status_lock = threading.Lock()
|
||||||
|
self._status_pending = False
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
# Placeholders only — never collect_snapshot() on the UI thread.
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
yield Static("NexusOS …", id="status")
|
||||||
|
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
|
||||||
|
yield Static("", id="live")
|
||||||
|
yield Static("collecting status…", id="strip")
|
||||||
|
yield Input(
|
||||||
|
placeholder="Message Nexus… (/help for commands)",
|
||||||
|
id="prompt",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.title = "NexusOS"
|
||||||
|
self.sub_title = self.api_url
|
||||||
|
log = self.query_one("#log", RichLog)
|
||||||
|
log.write("[bold]NexusOS[/] interactive TUI")
|
||||||
|
log.write(
|
||||||
|
"Type a message and Enter. "
|
||||||
|
"Slash: /help /status /new /model /quit"
|
||||||
|
)
|
||||||
|
log.write(f"API: {_escape(self.api_url)}")
|
||||||
|
log.write("")
|
||||||
|
self._schedule_status_refresh()
|
||||||
|
self.set_interval(2.0, self._schedule_status_refresh)
|
||||||
|
self.query_one("#prompt", Input).focus()
|
||||||
|
|
||||||
|
def _schedule_status_refresh(self) -> None:
|
||||||
|
"""Kick a worker; never call collect_snapshot on the event loop."""
|
||||||
|
with self._status_lock:
|
||||||
|
if self._status_pending:
|
||||||
|
return
|
||||||
|
self._status_pending = True
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
snap = collect_snapshot()
|
||||||
|
self._call_ui(self._apply_status, snap)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with self._status_lock:
|
||||||
|
self._status_pending = False
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _apply_status(self, snap: dict) -> None:
|
||||||
|
self.query_one("#status", Static).update(_status_line(snap))
|
||||||
|
self.query_one("#strip", Static).update(_compact_status(snap))
|
||||||
|
|
||||||
|
def _call_ui(self, callback, *args) -> None:
|
||||||
|
"""call_from_thread, but never after quit (avoids CancelledError
|
||||||
|
traceback garbling the restored shell)."""
|
||||||
|
if not self.is_running:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.call_from_thread(callback, *args)
|
||||||
|
except BaseException:
|
||||||
|
# CancelledError is BaseException; also ignore post-exit races.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _show_error(self, message: str) -> None:
|
||||||
|
"""Write a stream error to the persistent transcript."""
|
||||||
|
self.query_one("#log", RichLog).write(message)
|
||||||
|
|
||||||
|
def _cancel_stream(self) -> None:
|
||||||
|
"""Cancel the task that owns the socket read.
|
||||||
|
|
||||||
|
Closing a synchronous httpx client from the UI thread does not
|
||||||
|
reliably unblock its worker-thread read on macOS. Async task
|
||||||
|
cancellation is delivered to the pending read itself.
|
||||||
|
"""
|
||||||
|
self._stop_stream.set()
|
||||||
|
cancel = self._stream_cancel
|
||||||
|
if cancel is not None:
|
||||||
|
loop, task = cancel
|
||||||
|
loop.call_soon_threadsafe(task.cancel)
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
self._cancel_stream()
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
def action_interrupt(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
self._cancel_stream()
|
||||||
|
self.query_one("#log", RichLog).write(
|
||||||
|
"[yellow]▸ interrupt requested[/]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
text = (event.value or "").strip()
|
||||||
|
event.input.value = ""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
if text.startswith("/"):
|
||||||
|
self._handle_slash(text)
|
||||||
|
return
|
||||||
|
if self._busy:
|
||||||
|
self.query_one("#log", RichLog).write(
|
||||||
|
"[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._start_chat(text)
|
||||||
|
|
||||||
|
def _handle_slash(self, text: str) -> None:
|
||||||
|
log = self.query_one("#log", RichLog)
|
||||||
|
cmd, _, rest = text[1:].partition(" ")
|
||||||
|
cmd = cmd.lower().strip()
|
||||||
|
rest = rest.strip()
|
||||||
|
if cmd in ("q", "quit", "exit"):
|
||||||
|
self.exit()
|
||||||
|
elif cmd in ("h", "help"):
|
||||||
|
log.write(
|
||||||
|
"[bold]/help[/] this list\n"
|
||||||
|
"[bold]/status[/] refresh service strip\n"
|
||||||
|
"[bold]/new[/] fresh conversation\n"
|
||||||
|
"[bold]/model[/] \\[name] pin model for next turns\n"
|
||||||
|
"[bold]/quit[/] leave the TUI\n"
|
||||||
|
"One-shot: [dim]nexus chat send \"…\"[/]"
|
||||||
|
)
|
||||||
|
elif cmd == "status":
|
||||||
|
self._schedule_status_refresh()
|
||||||
|
log.write("[dim]refreshing status…[/]")
|
||||||
|
elif cmd == "new":
|
||||||
|
self.conversation_id = None
|
||||||
|
self.history = []
|
||||||
|
log.write("[bold cyan]— new conversation —[/]")
|
||||||
|
elif cmd == "model":
|
||||||
|
if rest:
|
||||||
|
self._model = rest
|
||||||
|
log.write(f"[dim]model pinned:[/] {_escape(rest)}")
|
||||||
|
else:
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_chat(self, message: str) -> None:
|
||||||
|
log = self.query_one("#log", RichLog)
|
||||||
|
live = self.query_one("#live", Static)
|
||||||
|
log.write(format_user_line(message))
|
||||||
|
live.update("[bold blue]nexus>[/] [dim]…[/]")
|
||||||
|
self._busy = True
|
||||||
|
self._stop_stream.clear()
|
||||||
|
if not self.conversation_id:
|
||||||
|
self.conversation_id = str(uuid.uuid4())
|
||||||
|
conversation_id = self.conversation_id
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"message": message,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"history": list(self.history),
|
||||||
|
}
|
||||||
|
if self._model:
|
||||||
|
body["model"] = self._model
|
||||||
|
self.history.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
async def stream_worker():
|
||||||
|
reply_parts: list[str] = []
|
||||||
|
task = asyncio.current_task()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if task is None: # pragma: no cover - asyncio guarantees it
|
||||||
|
raise RuntimeError("stream worker has no task")
|
||||||
|
self._stream_cancel = (loop, task)
|
||||||
|
try:
|
||||||
|
if self._stop_stream.is_set():
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.api_url, timeout=_STREAM_TIMEOUT
|
||||||
|
) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST", "/chat/stream", json=body
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = (await resp.aread()).decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)[:300]
|
||||||
|
self._call_ui(
|
||||||
|
self._show_error,
|
||||||
|
f"[red]error HTTP {resp.status_code}[/] "
|
||||||
|
f"{_escape(detail)}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event = "message"
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if self._stop_stream.is_set():
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
if line == "":
|
||||||
|
event = "message"
|
||||||
|
continue
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event = line[6:].strip()
|
||||||
|
continue
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
payload = line[5:].strip()
|
||||||
|
kind = event
|
||||||
|
if kind in ("message", ""):
|
||||||
|
kind = "chunk"
|
||||||
|
payload = json.loads(payload)
|
||||||
|
if kind == "chunk":
|
||||||
|
reply_parts.append(payload)
|
||||||
|
preview = "".join(reply_parts)
|
||||||
|
if len(preview) > 4000:
|
||||||
|
preview = "…" + preview[-4000:]
|
||||||
|
self._call_ui(
|
||||||
|
live.update,
|
||||||
|
format_assistant_line(preview),
|
||||||
|
)
|
||||||
|
elif kind == "tool_request":
|
||||||
|
try:
|
||||||
|
names = _deny_tool_request(
|
||||||
|
api_url=self.api_url,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
shown = ", ".join(names)
|
||||||
|
self._call_ui(
|
||||||
|
log.write,
|
||||||
|
"[yellow]▸ denied action tool "
|
||||||
|
f"{_escape(shown)} — interactive "
|
||||||
|
"approval is not yet available in "
|
||||||
|
"the TUI[/]",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._call_ui(
|
||||||
|
self._show_error,
|
||||||
|
"[red]tool denial failed:[/] "
|
||||||
|
f"{_escape(str(exc))}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif kind == "error":
|
||||||
|
try:
|
||||||
|
detail = json.loads(payload).get(
|
||||||
|
"detail", payload
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
detail = payload
|
||||||
|
self._call_ui(
|
||||||
|
self._show_error,
|
||||||
|
f"[red]error:[/] "
|
||||||
|
f"{_escape(str(detail))}",
|
||||||
|
)
|
||||||
|
elif kind == "done":
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except httpx.ConnectError:
|
||||||
|
self._call_ui(
|
||||||
|
self._show_error,
|
||||||
|
f"[red]Backend not reachable at "
|
||||||
|
f"{_escape(self.api_url)}. Start it: nexus start[/]",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._call_ui(
|
||||||
|
self._show_error,
|
||||||
|
f"[red]{_escape(type(exc).__name__)}:[/] "
|
||||||
|
f"{_escape(str(exc))}",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if self._stream_cancel == (loop, task):
|
||||||
|
self._stream_cancel = None
|
||||||
|
text = "".join(reply_parts).strip()
|
||||||
|
self._call_ui(self._finish_stream, text)
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: asyncio.run(stream_worker()), daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _finish_stream(self, text: str) -> None:
|
||||||
|
log = self.query_one("#log", RichLog)
|
||||||
|
live = self.query_one("#live", Static)
|
||||||
|
try:
|
||||||
|
if text:
|
||||||
|
log.write(format_assistant_line(text))
|
||||||
|
self.history.append(
|
||||||
|
{"role": "assistant", "content": text}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Always clear busy — a MarkupError must not wedge the TUI.
|
||||||
|
live.update("")
|
||||||
|
self._busy = False
|
||||||
|
self._schedule_status_refresh()
|
||||||
|
|
||||||
|
return AppImpl()
|
||||||
|
|
||||||
|
|
||||||
|
def run_tui(*, api_url: str | None = None) -> int:
|
||||||
|
"""Run the Textual app. Returns a process exit code."""
|
||||||
|
app = NexusTUI.build_app(api_url=api_url)
|
||||||
|
app.run()
|
||||||
|
return 0
|
||||||
@@ -42,6 +42,7 @@ mail = ["imap-tools>=1.7,<2"]
|
|||||||
# synapse/search.py imports this lazily behind a bare except, so without it
|
# synapse/search.py imports this lazily behind a bare except, so without it
|
||||||
# declared the chat web-search path silently returns nothing.
|
# declared the chat web-search path silently returns nothing.
|
||||||
search = ["duckduckgo-search>=6,<9"]
|
search = ["duckduckgo-search>=6,<9"]
|
||||||
|
tui = ["textual>=1.0,<3"]
|
||||||
desktop = [
|
desktop = [
|
||||||
"psutil>=5.9,<8",
|
"psutil>=5.9,<8",
|
||||||
"pywebview>=5,<7; platform_system == 'Windows'",
|
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||||
@@ -64,6 +65,7 @@ all = [
|
|||||||
"faster-whisper>=1.1,<2",
|
"faster-whisper>=1.1,<2",
|
||||||
"imap-tools>=1.7,<2",
|
"imap-tools>=1.7,<2",
|
||||||
"duckduckgo-search>=6,<9",
|
"duckduckgo-search>=6,<9",
|
||||||
|
"textual>=1.0,<3",
|
||||||
"pywebview>=5,<7; platform_system == 'Windows'",
|
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
+8
-2
@@ -167,10 +167,16 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
|||||||
break
|
break
|
||||||
messages.append(msg)
|
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
|
decisions = None
|
||||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
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()
|
event = asyncio.Event()
|
||||||
# Single-use capability token, delivered only to the client that owns
|
# Single-use capability token, delivered only to the client that owns
|
||||||
# this stream. /chat/approve requires it, so knowing the (guessable,
|
# this stream. /chat/approve requires it, so knowing the (guessable,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
"""NexusOS's own Curry instance: preloaded at import time, ready to be called.
|
||||||
|
|
||||||
|
Curry (curry_core.py, vendored alongside this file) is an immutable, versioned
|
||||||
|
fact store - constants, functions, model registrations, and inference
|
||||||
|
provenance, backed by SQLite. Nothing in NexusOS wires chat/model-authored
|
||||||
|
content into it yet; this module only makes it available - `from
|
||||||
|
synapse.curry_store import curry_db` and call `declare_constant`,
|
||||||
|
`get_constant_latest`, `declare_function`, `call_function`, etc. directly, the
|
||||||
|
same way `synapse.memory.store.store` and `synapse.playbooks.store.playbook_store`
|
||||||
|
are used elsewhere in this codebase.
|
||||||
|
|
||||||
|
Kept as a separate database file (CURRY_DB) from the memory/conversation store
|
||||||
|
on purpose: Curry's schema and lifecycle are independent of the memory store's.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .curry_core import Curry
|
||||||
|
from .nexus_config import CURRY_DB
|
||||||
|
|
||||||
|
curry_db = Curry(str(CURRY_DB))
|
||||||
|
|
||||||
|
__all__ = ["curry_db"]
|
||||||
+61
-4
@@ -182,7 +182,9 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
|
|||||||
|
|
||||||
from .memory.store import store, MemoryItem
|
from .memory.store import store, MemoryItem
|
||||||
from .playbooks.store import playbook_store, PlaybookItem
|
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 .search import needs_web_search, web_search
|
||||||
|
from . import slash_commands as _slash_commands
|
||||||
|
|
||||||
MEMORY_SERVICE = settings.memory_url
|
MEMORY_SERVICE = settings.memory_url
|
||||||
|
|
||||||
@@ -460,6 +462,38 @@ async def _resume_dropped_extractions() -> None:
|
|||||||
# -------------------------
|
# -------------------------
|
||||||
# Chat (streaming)
|
# 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")
|
@app.post("/chat/stream")
|
||||||
async def chat_stream_endpoint(payload: Dict[str, Any]):
|
async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||||
# Bound concurrent chats so a flood can't fan out unlimited model inference.
|
# Bound concurrent chats so a flood can't fan out unlimited model inference.
|
||||||
@@ -469,13 +503,39 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
_chat_slot_held = True
|
_chat_slot_held = True
|
||||||
try:
|
try:
|
||||||
message = payload.get("message", "")
|
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()
|
app_settings = store.get_settings()
|
||||||
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
||||||
_active_pb = playbook_manager.get_main_playbook()
|
_active_pb = playbook_manager.get_main_playbook()
|
||||||
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
_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)
|
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
||||||
context = payload.get("context", {})
|
context = payload.get("context", {})
|
||||||
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
|
||||||
history = payload.get("history", [])
|
history = payload.get("history", [])
|
||||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
temperature = payload.get("temperature", app_settings.get("temperature"))
|
||||||
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
||||||
@@ -483,9 +543,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||||
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
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;
|
# Resolve the project scope: an existing conversation keeps its bound project;
|
||||||
# a brand-new one inherits the current workspace (active_project setting).
|
# a brand-new one inherits the current workspace (active_project setting).
|
||||||
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ SEED_PLAYBOOK_DIR = (
|
|||||||
|
|
||||||
# --- DATABASE / STORAGE FILES (match your repo) ---
|
# --- DATABASE / STORAGE FILES (match your repo) ---
|
||||||
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
||||||
|
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
|
||||||
|
# constants/functions/models + inference provenance. Separate file from
|
||||||
|
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
|
||||||
|
# memory/conversation store.
|
||||||
|
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.db")
|
||||||
|
|
||||||
# --- LOG FILES ---
|
# --- LOG FILES ---
|
||||||
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
||||||
@@ -180,6 +185,7 @@ _REQUIRED_DIRS = (
|
|||||||
UPLOADS_DIR,
|
UPLOADS_DIR,
|
||||||
EXPORTS_DIR,
|
EXPORTS_DIR,
|
||||||
MEMORY_DB.parent,
|
MEMORY_DB.parent,
|
||||||
|
CURRY_DB.parent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -424,7 +430,7 @@ __all__ = ["Settings", "settings", "path", "VERSION",
|
|||||||
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
||||||
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||||
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
"EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
||||||
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
||||||
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
||||||
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
||||||
|
|||||||
@@ -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
|
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||||
`message.tool_calls`, and this module is just the registry + dispatch.
|
`message.tool_calls`, and this module is just the registry + dispatch.
|
||||||
|
|
||||||
Most tools READ local state (memory, history, documents, models). A few act:
|
Most tools READ local state (memory, history, documents, models). Some act:
|
||||||
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
`web_search`/`fetch_url` make outbound HTTP requests, `remember` writes a
|
||||||
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
memory fact, and `curry_*` reads or writes NexusOS's vendored Curry ledger.
|
||||||
boundary — an action tool only fires when a playbook explicitly lists it.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
from .curry_store import curry_db
|
||||||
from .memory.store import store, MemoryItem
|
from .memory.store import store, MemoryItem
|
||||||
from .ollama_manager import get_ollama_manager
|
from .ollama_manager import get_ollama_manager
|
||||||
|
|
||||||
@@ -215,6 +219,124 @@ async def _list_files(pattern: str = "", **_) -> str:
|
|||||||
return json.dumps(sorted(hits))
|
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.
|
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||||
"search_memory": (
|
"search_memory": (
|
||||||
@@ -360,13 +482,229 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|||||||
},
|
},
|
||||||
_remember,
|
_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
|
# 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
|
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
||||||
# allowlist — a playbook granting one isn't enough on its own.
|
# allowlist — a playbook granting one isn't enough on its own. Curry writes and
|
||||||
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
|
# 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:
|
def is_action(name: str) -> bool:
|
||||||
|
|||||||
@@ -31,8 +31,23 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|||||||
def test_help_exposes_portable_command_tree(tmp_path):
|
def test_help_exposes_portable_command_tree(tmp_path):
|
||||||
result = run_cli(tmp_path, "--help")
|
result = run_cli(tmp_path, "--help")
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor"):
|
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor", "tui"):
|
||||||
assert command in result.stdout
|
assert command in result.stdout
|
||||||
|
assert "interactive TUI" in result.stdout or "TUI" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_nexus_defaults_to_tui_command():
|
||||||
|
"""No subcommand → TUI entry (Hermes-style). Non-TTY exits 2 without launching."""
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from nexusos_cli.cli import build_parser, cmd_tui
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args([])
|
||||||
|
assert args.command is None # filled in by main()
|
||||||
|
with mock.patch("sys.stdin.isatty", return_value=False), \
|
||||||
|
mock.patch("sys.stdout.isatty", return_value=False):
|
||||||
|
assert cmd_tui(args) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_cli_spellings_remain_compatible():
|
def test_legacy_cli_spellings_remain_compatible():
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""synapse/curry_core.py (vendored) + synapse/curry_store.py (NexusOS's preload).
|
||||||
|
|
||||||
|
Two concerns: the vendor sync didn't silently drop the sandbox fix from
|
||||||
|
https://github.com/Athena-Pro/Curry/pull/4, and curry_store gives NexusOS a
|
||||||
|
live instance for the registered chat tools.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from synapse.curry_core import Curry, TypeSignature
|
||||||
|
from synapse import curry_store
|
||||||
|
|
||||||
|
|
||||||
|
def test_curry_store_is_preloaded_and_open():
|
||||||
|
# curry_store.curry_db is a module-level singleton constructed at import
|
||||||
|
# time (mirrors synapse.memory.store.store / synapse.playbooks.store.playbook_store)
|
||||||
|
# - by the time this test runs, it has already opened its database file.
|
||||||
|
assert isinstance(curry_store.curry_db, Curry)
|
||||||
|
assert curry_store.curry_db.conn.execute("SELECT 1").fetchone()[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_curry_db_path_matches_nexus_config(tmp_path, monkeypatch):
|
||||||
|
from synapse import nexus_config
|
||||||
|
assert str(curry_store.curry_db.db_path) == str(nexus_config.CURRY_DB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendored_sandbox_fix_rejects_format_dunder_escape(tmp_path):
|
||||||
|
# Regression test for the vendored fix: a body that hides dunder-attribute
|
||||||
|
# traversal inside a str.format() field spec must still be rejected at
|
||||||
|
# declare time, not just the literal '.__class__' form. If a future
|
||||||
|
# re-vendor from upstream drops the fix, this is what catches it.
|
||||||
|
db = Curry(str(tmp_path / "sandbox_check.db"))
|
||||||
|
db.declare_function("helper", 1, "1")
|
||||||
|
|
||||||
|
exploit = "'{0.__globals__}'.format(helper)"
|
||||||
|
with pytest.raises(ValueError, match="format"):
|
||||||
|
db.declare_function("evil", 1, exploit, function_bindings={"helper": 1})
|
||||||
|
|
||||||
|
# the original, always-caught dunder-attribute form stays blocked too
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
db.declare_function("evil2", 1, "x.__class__", expected_args=["x"])
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendored_curry_basic_versioning_roundtrip(tmp_path):
|
||||||
|
db = Curry(str(tmp_path / "roundtrip.db"))
|
||||||
|
db.declare_constant("rate", 1, 0.1, TypeSignature.FLOAT64.value)
|
||||||
|
db.declare_function(
|
||||||
|
"apply_rate", 1, "amount * (1 + rate)",
|
||||||
|
constant_bindings={"rate": 1}, expected_args=["amount"],
|
||||||
|
)
|
||||||
|
assert db.call_function("apply_rate", 1, {"amount": 100}) == 110.00000000000001
|
||||||
|
db.close()
|
||||||
@@ -29,7 +29,8 @@ DISTRIBUTION_OF = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Provided by another declared distribution rather than named directly.
|
# Provided by another declared distribution rather than named directly.
|
||||||
TRANSITIVE = {"starlette", "socketio", "engineio"}
|
# rich: Textual depends on it, so the tui extra already pulls it in.
|
||||||
|
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
||||||
|
|
||||||
# Modules that ship inside this repo.
|
# Modules that ship inside this repo.
|
||||||
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""TUI helpers and headless App.run_test coverage."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from nexusos_cli.tui_app import (
|
||||||
|
_compact_status,
|
||||||
|
_deny_tool_request,
|
||||||
|
_escape,
|
||||||
|
_status_line,
|
||||||
|
format_assistant_line,
|
||||||
|
format_user_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ApprovalResponse:
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _ApprovalClient:
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def post(self, path, *, json):
|
||||||
|
self.calls.append((path, json, self.kwargs))
|
||||||
|
return _ApprovalResponse()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_line_mentions_services():
|
||||||
|
snap = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"services": {
|
||||||
|
"backend": {"running": True},
|
||||||
|
"memory": {"running": False},
|
||||||
|
"provider": {"reachable": True},
|
||||||
|
},
|
||||||
|
"api": {"online": True, "action_tool_policy": "ask"},
|
||||||
|
"host": {"cpu_pct": 10.0},
|
||||||
|
"toolchains": [{"lang": "python", "ready": True}],
|
||||||
|
}
|
||||||
|
line = _status_line(snap)
|
||||||
|
assert "backend=UP" in line
|
||||||
|
assert "memory=DOWN" in line
|
||||||
|
assert "provider=UP" in line
|
||||||
|
assert "tools=ask" in line
|
||||||
|
assert "run=python" in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_status_handles_api_down():
|
||||||
|
snap = {
|
||||||
|
"host": {},
|
||||||
|
"api": {"online": False},
|
||||||
|
"recent_tools": [],
|
||||||
|
}
|
||||||
|
assert "api DOWN" in _compact_status(snap)
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_preserves_code_brackets_in_display():
|
||||||
|
raw = "idx = arr[i] and rng = [a-z]+"
|
||||||
|
plain = Text.from_markup(format_assistant_line(raw)).plain
|
||||||
|
assert "arr[i]" in plain
|
||||||
|
assert "[a-z]+" in plain
|
||||||
|
# Unescaped markup would drop the bracket contents.
|
||||||
|
assert plain != "nexus> idx = arr and rng = +"
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_tag_in_model_output_does_not_raise():
|
||||||
|
raw = "close with [/] please"
|
||||||
|
plain = Text.from_markup(format_assistant_line(raw)).plain
|
||||||
|
assert "[/]" in plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_line_escapes_markup():
|
||||||
|
plain = Text.from_markup(format_user_line("use [bold] please")).plain
|
||||||
|
assert "[bold]" in plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_stream_markup_does_not_wedge_busy():
|
||||||
|
"""A stray '[/]' used to raise before _busy=False and lock the TUI forever."""
|
||||||
|
pytest.importorskip("textual")
|
||||||
|
from nexusos_cli.tui_app import NexusTUI
|
||||||
|
|
||||||
|
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async with app.run_test():
|
||||||
|
app._busy = True
|
||||||
|
app._finish_stream("see [/] and arr[i]")
|
||||||
|
assert app._busy is False
|
||||||
|
assert app.history[-1]["content"] == "see [/] and arr[i]"
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_error_remains_visible_after_finish():
|
||||||
|
pytest.importorskip("textual")
|
||||||
|
from nexusos_cli.tui_app import NexusTUI
|
||||||
|
|
||||||
|
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async with app.run_test():
|
||||||
|
app._busy = True
|
||||||
|
app._show_error("[red]Backend not reachable[/]")
|
||||||
|
app._finish_stream("")
|
||||||
|
log = app.query_one("#log")
|
||||||
|
assert any("Backend not reachable" in line.text for line in log.lines)
|
||||||
|
assert app._busy is False
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["ctrl+c", "ctrl+d"])
|
||||||
|
def test_priority_exit_bindings_reach_app_while_prompt_is_focused(key):
|
||||||
|
pytest.importorskip("textual")
|
||||||
|
from nexusos_cli.tui_app import NexusTUI
|
||||||
|
|
||||||
|
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
assert app.is_running
|
||||||
|
await pilot.press(key)
|
||||||
|
await pilot.pause()
|
||||||
|
assert not app.is_running
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_request_is_denied_with_stream_token():
|
||||||
|
_ApprovalClient.calls.clear()
|
||||||
|
names = _deny_tool_request(
|
||||||
|
api_url="http://localhost:8000",
|
||||||
|
conversation_id="conversation-1",
|
||||||
|
payload='{"token":"secret","actions":[{"name":"run_snippet"}]}',
|
||||||
|
client_factory=_ApprovalClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert names == ["run_snippet"]
|
||||||
|
path, body, client_kwargs = _ApprovalClient.calls[-1]
|
||||||
|
assert path == "/chat/approve"
|
||||||
|
assert body == {
|
||||||
|
"conversation_id": "conversation-1",
|
||||||
|
"token": "secret",
|
||||||
|
"decisions": {"run_snippet": False},
|
||||||
|
}
|
||||||
|
assert client_kwargs["base_url"] == "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
|
||||||
|
pytest.importorskip("textual")
|
||||||
|
import nexusos_cli.tui_app as tui_app
|
||||||
|
|
||||||
|
stream_started = threading.Event()
|
||||||
|
release_stream = threading.Event()
|
||||||
|
denied_for = []
|
||||||
|
|
||||||
|
class _StreamResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def aiter_lines(self):
|
||||||
|
stream_started.set()
|
||||||
|
await asyncio.to_thread(release_stream.wait, 2)
|
||||||
|
yield "event: tool_request"
|
||||||
|
yield 'data: {"token":"secret","actions":[{"name":"run_snippet"}]}'
|
||||||
|
yield ""
|
||||||
|
yield "event: done"
|
||||||
|
yield "data: {}"
|
||||||
|
|
||||||
|
class _StreamClient:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stream(self, *args, **kwargs):
|
||||||
|
return _StreamResponse()
|
||||||
|
|
||||||
|
def _capture_denial(*, conversation_id, **kwargs):
|
||||||
|
denied_for.append(conversation_id)
|
||||||
|
return ["run_snippet"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
|
||||||
|
monkeypatch.setattr(tui_app, "_deny_tool_request", _capture_denial)
|
||||||
|
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async with app.run_test():
|
||||||
|
app._start_chat("run it")
|
||||||
|
assert await asyncio.to_thread(stream_started.wait, 2)
|
||||||
|
original_id = app.conversation_id
|
||||||
|
app._handle_slash("/new")
|
||||||
|
assert app.conversation_id is None
|
||||||
|
release_stream.set()
|
||||||
|
for _ in range(200):
|
||||||
|
if not app._busy:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert app._busy is False
|
||||||
|
assert denied_for == [original_id]
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
|
||||||
|
pytest.importorskip("textual")
|
||||||
|
import nexusos_cli.tui_app as tui_app
|
||||||
|
|
||||||
|
first_stream_started = threading.Event()
|
||||||
|
|
||||||
|
class _StreamResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def __init__(self, call_number):
|
||||||
|
self.call_number = call_number
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def aiter_lines(self):
|
||||||
|
if self.call_number == 1:
|
||||||
|
first_stream_started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
yield "data: \"READY\""
|
||||||
|
yield ""
|
||||||
|
yield "event: done"
|
||||||
|
yield "data: {}"
|
||||||
|
|
||||||
|
class _StreamClient:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stream(self, *args, **kwargs):
|
||||||
|
type(self).calls += 1
|
||||||
|
return _StreamResponse(type(self).calls)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
|
||||||
|
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||||
|
|
||||||
|
async def _wait_until_idle():
|
||||||
|
for _ in range(100):
|
||||||
|
if not app._busy:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
pytest.fail("stream did not become idle within one second")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
app._start_chat("first")
|
||||||
|
assert await asyncio.to_thread(first_stream_started.wait, 2)
|
||||||
|
await pilot.press("ctrl+c")
|
||||||
|
await _wait_until_idle()
|
||||||
|
|
||||||
|
log = app.query_one("#log")
|
||||||
|
assert any("interrupt requested" in line.text for line in log.lines)
|
||||||
|
assert not any("ReadTimeout" in line.text for line in log.lines)
|
||||||
|
|
||||||
|
app._start_chat("second")
|
||||||
|
await _wait_until_idle()
|
||||||
|
assert app.history[-1] == {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "READY",
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
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