forked from enderofwings/NexusOS
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f7461ca4 | ||
|
|
99381f7e9e | ||
|
|
ef176dbb68 | ||
|
|
a0f033142f | ||
|
|
8bc8123bfa | ||
|
|
c4d7dc42f2 | ||
|
|
0d26f630e6 | ||
|
|
3e89df142b | ||
|
|
9ed2908170 | ||
|
|
9ca37057eb | ||
|
|
da3509eb04 | ||
|
|
6f5094b5fc | ||
|
|
1449280fcd | ||
|
|
5f67d19e80 | ||
|
|
9104c724c4 | ||
|
|
d579502a5b | ||
|
|
952ef8a0c4 | ||
|
|
7262e7730e | ||
|
|
656c14caf3 | ||
|
|
2ebe93b4f7 | ||
|
|
d56d579755 | ||
|
|
c3a7b6eefd |
@@ -13,7 +13,6 @@ synapse/memory/memory.db
|
||||
synapse/memory/memory.db-wal
|
||||
synapse/memory/memory.db-shm
|
||||
assets/gitnexus-logo.svg
|
||||
/data/curry.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.DS_Store
|
||||
|
||||
@@ -37,17 +37,6 @@ desktop shortcut runs and still works directly.
|
||||
```
|
||||
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
|
||||
|
||||
**macOS (community-supported):**
|
||||
```bash
|
||||
./install-macos.sh # one-time: Homebrew packages + venv + web build, via bin/sync.py
|
||||
./launch_nexus.sh # same script as Linux - it's plain bash, no Linux-only calls
|
||||
```
|
||||
No bundled Ollama binary (Linux x86-64 only) and no XFCE desktop branding — both
|
||||
already no-op on macOS via `bin/sync.py`'s `linux_stage()`. Ollama is instead the
|
||||
Homebrew-installed native binary, picked up automatically because
|
||||
`OllamaManager` falls back to `ollama` on PATH when the bundled binary is
|
||||
absent; that gets full Metal GPU acceleration with no extra config.
|
||||
|
||||
**Individual services via CLI:**
|
||||
```bash
|
||||
# From nexus-core/ with Promethean venv active:
|
||||
@@ -106,7 +95,7 @@ cd interface/web && npm run build
|
||||
## Architecture
|
||||
|
||||
### Python venv
|
||||
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, CPU/Windows, or — via an explicit `sys.platform == "darwin"` check, since `os.name` alone can't tell macOS apart from Linux — `requirements-base.txt` with no overlay at all for macOS, from the host, and installs that alone by default — fast, no multi-GB downloads.
|
||||
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host and installs that alone by default — fast, no multi-GB downloads.
|
||||
|
||||
`requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers.
|
||||
|
||||
@@ -134,37 +123,9 @@ A bundled Ollama binary lives at `ollama/bin/ollama`. `OllamaManager` in `synaps
|
||||
### Frontend (`interface/web/`)
|
||||
React 19 + Vite. No routing library — `App.jsx` manages page state in a single `currentPage` useState. All API calls hit `http://localhost:8000` (configured in `src/config.js`). Built to `dist/` (gitignored) via `npm run build` and served by the backend at `:8000` — the mount is in `synapse/main.py` (`_DIST` at `/`, guarded by `is_dir()`), so `dist/` must be built for the UI to appear. Pages: Chatbot, Playbook editor, Conversation History, Models, Memory, Settings, Logs.
|
||||
|
||||
### Code Tracks (`synapse/tools.py` + `synapse/code_run.py`)
|
||||
|
||||
Two separate tools, split by *where the code runs*:
|
||||
|
||||
- **`render_preview`** — validates markup and returns a fence the chat renders in
|
||||
an opaque-origin `sandbox="allow-scripts"` iframe. Nothing executes
|
||||
server-side. Languages: `PREVIEW_LANGS` in `synapse/tools.py`, mirrored by
|
||||
`interface/web/src/preview/languages.js`.
|
||||
- **`run_snippet`** — compiles and runs a single file on the host via
|
||||
`synapse/code_run.py`, and returns a ```nexus-run fence carrying the source and
|
||||
its captured output. Languages: `RUN_LANGS` in `synapse/code_run.py`, mirrored
|
||||
by `interface/web/src/preview/run-langs.js`.
|
||||
|
||||
Each pair of registries is asserted equal by `tests/test_tools.py` — nothing
|
||||
couples them at runtime, so drift fails the check gate instead of silently
|
||||
degrading in the chat.
|
||||
|
||||
`run_snippet` is an **action tool**: `action_tool_policy` gates it (`off` by
|
||||
default, `ask` = per-call Approve/Deny in chat). Read the `code_run.py` module
|
||||
docstring before touching it — it runs code as the current user and is explicit
|
||||
about which of its five layers are load-bearing and which are only a tripwire.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
|
||||
- `runtime/logs/ollama.log`, `runtime/logs/chat.log`
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
# NexusOS
|
||||
|
||||
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
||||
Python/FastAPI backend, a bundled Ollama instance for inference, persistent
|
||||
memory, and a React frontend. No external AI provider is called. Ollama is
|
||||
local by default; Termux and container installs can explicitly point at a
|
||||
separately managed endpoint.
|
||||
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
|
||||
persistent memory service, and a React frontend. Ollama is local by default;
|
||||
Termux and container installs can explicitly point at a separately managed
|
||||
endpoint.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -134,8 +134,7 @@ ncp web
|
||||
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
|
||||
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
|
||||
`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay).
|
||||
macOS uses `requirements-base.txt` directly, no overlay — see the macOS section
|
||||
below. `bin/sync.py` picks the right one for the host.
|
||||
`bin/sync.py` picks the right one for the host.
|
||||
|
||||
`./install.sh` is also the update path — re-run it any time to pull the latest
|
||||
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
|
||||
@@ -173,38 +172,10 @@ The app opens at `:8000`; click **Start AI** to launch Ollama. The installer
|
||||
uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama
|
||||
does all inference over HTTP).
|
||||
|
||||
### macOS
|
||||
|
||||
Community-supported — no bundled Ollama binary or XFCE desktop branding (that
|
||||
stage is Linux/XFCE-only and already skips itself here), but the
|
||||
backend/frontend/Ollama stack itself runs natively, no VM or container needed.
|
||||
|
||||
```bash
|
||||
# 1. Install Homebrew first if you don't have it: https://brew.sh
|
||||
|
||||
# 2. Build everything: Homebrew packages (Python, Node, git, Ollama), venv,
|
||||
# web UI, memory DB.
|
||||
./install-macos.sh
|
||||
|
||||
# 3. Launch (memory :8001, backend :8000 — backend also serves the built UI)
|
||||
./launch_nexus.sh
|
||||
```
|
||||
|
||||
Ollama here is the Homebrew-installed native binary, not the Linux-only bundled
|
||||
one — `OllamaManager` already falls back to `ollama` on PATH when
|
||||
`ollama/bin/ollama` doesn't exist, so **Start AI** in the sidebar (or `ollama
|
||||
serve` in a terminal) uses it with full Metal GPU acceleration automatically,
|
||||
no configuration needed.
|
||||
|
||||
`./install-macos.sh` is also the update path, same idea as Linux — re-run it
|
||||
any time to pull the latest and rebuild; `--check` dry-runs it. It's a thin
|
||||
wrapper over `bin/sync.py restore`, the same code Linux and `ncp restore`
|
||||
(any platform) run.
|
||||
|
||||
### Individual services
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
# Linux
|
||||
source Promethean/bin/activate
|
||||
|
||||
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
|
||||
@@ -228,7 +199,7 @@ Nexus's dependencies out of the system Python. To add a package, activate it
|
||||
and `pip install` as usual:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
# Linux
|
||||
source Promethean/bin/activate
|
||||
pip install <package>
|
||||
```
|
||||
@@ -303,9 +274,7 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
|
||||
- **Filesystem paths** — `synapse/nexus_config.py`
|
||||
- **Frontend API base URL** — `interface/web/src/config.js`
|
||||
- **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay;
|
||||
`requirements-windows.txt` = standalone CPU runtime; macOS uses
|
||||
`requirements-base.txt` with no overlay (Ollama, not this venv, does
|
||||
inference — natively, with Metal)
|
||||
`requirements-windows.txt` = standalone CPU runtime
|
||||
|
||||
## Issues and feature requests
|
||||
|
||||
|
||||
+7
-17
@@ -9,18 +9,14 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
fail=0
|
||||
|
||||
if [ -x Promethean/bin/python ]; then
|
||||
NEXUS_CHECK_PY=Promethean/bin/python
|
||||
elif [ -x Promethean/Scripts/python.exe ]; then
|
||||
NEXUS_CHECK_PY=Promethean/Scripts/python.exe
|
||||
else
|
||||
if [ ! -x Promethean/bin/python ]; then
|
||||
echo "!! no Promethean venv - run ./install.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== pytest =="
|
||||
# Explicit dirs: a bare `pytest` would walk Promethean/ and node_modules too.
|
||||
"$NEXUS_CHECK_PY" -m pytest -q tests management || fail=1
|
||||
Promethean/bin/python -m pytest -q tests management || fail=1
|
||||
|
||||
echo "== eslint =="
|
||||
if [ -d interface/web/node_modules ]; then
|
||||
@@ -54,20 +50,20 @@ else
|
||||
fi
|
||||
|
||||
echo "== shell parse =="
|
||||
for f in scripts/install-termux.sh install-macos.sh launch_nexus.sh management/nexus-cli.sh; do
|
||||
for f in scripts/install-termux.sh launch_nexus.sh management/nexus-cli.sh; do
|
||||
[ -f "$f" ] && { bash -n "$f" || fail=1; }
|
||||
done
|
||||
|
||||
echo "== packaging =="
|
||||
# The wheel is the other shippable artifact, so it belongs in the same gate:
|
||||
# a broken pyproject or a missing web build only shows up at build time.
|
||||
if "$NEXUS_CHECK_PY" -c "import build, twine" 2>/dev/null; then
|
||||
if Promethean/bin/python -c "import build, twine" 2>/dev/null; then
|
||||
rm -rf .build-check
|
||||
if "$NEXUS_CHECK_PY" -m build --outdir .build-check >/dev/null 2>&1; then
|
||||
"$NEXUS_CHECK_PY" -m twine check .build-check/* || fail=1
|
||||
if Promethean/bin/python -m build --outdir .build-check >/dev/null 2>&1; then
|
||||
Promethean/bin/python -m twine check .build-check/* || fail=1
|
||||
# The compiled UI has to actually be inside the wheel - a wheel that
|
||||
# builds but ships no dist/ serves a blank page.
|
||||
"$NEXUS_CHECK_PY" - <<'PY' || fail=1
|
||||
Promethean/bin/python - <<'PY' || fail=1
|
||||
import glob, sys, zipfile
|
||||
wheels = glob.glob(".build-check/*.whl")
|
||||
if not wheels:
|
||||
@@ -77,12 +73,6 @@ 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)")
|
||||
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
|
||||
sys.exit("wheel is missing the seed playbooks")
|
||||
if "synapse/curry_core.py" not in names or "synapse/curry_store.py" not in names:
|
||||
sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)")
|
||||
if "synapse/slash_commands.py" not in names:
|
||||
sys.exit("wheel is missing synapse/slash_commands.py")
|
||||
if not any(n.startswith("modules/") for n in names):
|
||||
sys.exit("wheel is missing the modules/ package (mail, network) - check pyproject.toml packages=[...]")
|
||||
print(f"wheel OK: {len(names)} files")
|
||||
PY
|
||||
else
|
||||
|
||||
+3
-12
@@ -66,11 +66,9 @@ def ensure_exec_bits() -> None:
|
||||
|
||||
|
||||
def linux_stage(script: str, *args) -> None:
|
||||
"""Run one of the Linux-only bash stages. A no-op on Windows and macOS,
|
||||
where apt, xfconf, plank and the rest have nothing to act on. os.name is
|
||||
'posix' on both Linux and macOS, so the Windows-only os.name check alone
|
||||
doesn't exclude macOS - needs the explicit darwin check too."""
|
||||
if os.name == "nt" or sys.platform == "darwin":
|
||||
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
|
||||
xfconf, plank and the rest have nothing to act on."""
|
||||
if os.name == "nt":
|
||||
return
|
||||
path = ROOT / "bin" / script
|
||||
bash = shutil.which("bash")
|
||||
@@ -111,13 +109,6 @@ def requirements() -> str:
|
||||
"""Pick the PyTorch overlay for this host."""
|
||||
if os.name == "nt":
|
||||
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
|
||||
if sys.platform == "darwin":
|
||||
# No ROCm/CUDA overlay applies here, and none is needed: Ollama does
|
||||
# all inference over HTTP (see requirements-ml.txt), and on macOS
|
||||
# that's a natively-installed, Metal-accelerated Ollama binary
|
||||
# (OllamaManager falls back to it on PATH - see synapse/ollama_manager.py),
|
||||
# entirely outside this venv.
|
||||
return "requirements-base.txt"
|
||||
if shutil.which("nvidia-smi"):
|
||||
return "requirements-nvidia.txt"
|
||||
lspci = shutil.which("lspci")
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
# NexusOS installer, macOS. One painless command:
|
||||
#
|
||||
# git clone <repo> nexus-core && cd nexus-core && ./install-macos.sh
|
||||
#
|
||||
# Mirrors install.sh's philosophy: every portable step - git pull, venv, pip
|
||||
# with the right overlay, npm build - lives in bin/sync.py, shared with Linux
|
||||
# and Windows. This script only does what sync.py can't do for itself on a
|
||||
# bare Mac: install the Homebrew packages needed before Python even exists to
|
||||
# run sync.py with. Re-run any time to update; --check dry-runs it.
|
||||
#
|
||||
# Ollama itself is *not* fetched here - bin/fetch-ollama.sh only ships a Linux
|
||||
# x86-64 binary, and linux_stage() in bin/sync.py already no-ops on macOS, so
|
||||
# that stage is skipped entirely. The Homebrew `ollama` installed below is
|
||||
# picked up automatically instead: synapse/ollama_manager.py prefers the
|
||||
# bundled Linux binary and falls back to whatever `ollama` it finds on PATH,
|
||||
# which on macOS is this one - with full Metal GPU acceleration, no flags
|
||||
# needed. The XFCE desktop branding (theme/panel/splash) is Linux-only and
|
||||
# already gated off macOS the same way; nothing to install for it here.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v brew >/dev/null; then
|
||||
echo "Homebrew is required (it installs Python/Node/git/Ollama)." >&2
|
||||
echo "Install it, then re-run this script: https://brew.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing/checking system packages (python@3.12, node, git, ollama)..."
|
||||
brew install python@3.12 node git ollama
|
||||
|
||||
py="$(brew --prefix python@3.12)/bin/python3.12"
|
||||
if [ ! -x "$py" ]; then
|
||||
echo "python3.12 not found at $py after brew install - check 'brew doctor'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prefer the venv interpreter once it exists, same as install.sh; the brewed
|
||||
# interpreter above is only the bootstrap case on a fresh clone. sync.py is
|
||||
# stdlib-only either way.
|
||||
[ -x "Promethean/bin/python" ] && py="Promethean/bin/python"
|
||||
|
||||
exec "$py" bin/sync.py restore "$@"
|
||||
@@ -10,7 +10,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test \"src/preview/*.test.js\"",
|
||||
"test": "node --test src/preview/jsx-transform.test.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from "react";
|
||||
|
||||
import { API_BASE } from "./config";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
|
||||
|
||||
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
@@ -735,16 +734,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
||||
<div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
|
||||
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
|
||||
⚠️ The assistant wants to run:
|
||||
{" "}
|
||||
{pendingApproval.map((a, i) => (
|
||||
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
|
||||
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
{pendingApproval.map((a, i) =>
|
||||
a.preview
|
||||
? <ActionPreview key={i} action={a} />
|
||||
: (
|
||||
<code key={i} style={{ display: "inline-block", color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem", marginBottom: "0.4rem" }}>
|
||||
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
|
||||
</code>
|
||||
)
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button onClick={() => resolveApproval(true)}
|
||||
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
|
||||
@@ -860,102 +856,4 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One self-edit tool's approval preview: a real, server-computed diff instead
|
||||
// of the flat "name(arg, arg)" one-liner used for every other action tool.
|
||||
// This is what makes "always ask first" mean actual informed consent for
|
||||
// edit_source/edit_playbook/edit_settings — the human reviews what will
|
||||
// actually change, not a description of it. See synapse/self_edit.py's
|
||||
// preview_* functions, which compute exactly what's rendered here.
|
||||
function ActionPreview({ action }) {
|
||||
const { name, preview } = action;
|
||||
const banner = (text) => (
|
||||
<div style={{
|
||||
padding: "0.4rem 0.6rem", marginBottom: "0.4rem", background: "#3a2a10",
|
||||
border: "1px solid #8a6a2a", borderRadius: "6px", color: "#ffd580",
|
||||
fontSize: "0.8rem", fontWeight: 600,
|
||||
}}>
|
||||
⚠ {text}
|
||||
</div>
|
||||
);
|
||||
const diffBox = (lines) => (
|
||||
<pre style={{
|
||||
background: "#0d0d0d", border: "1px solid #333", borderRadius: "6px",
|
||||
padding: "0.5rem 0.7rem", margin: "0 0 0.4rem", fontSize: "0.8rem",
|
||||
lineHeight: "1.4", maxHeight: "16rem", overflow: "auto",
|
||||
}}>
|
||||
{lines.length === 0 && <span style={{ color: "#666" }}>(no changes)</span>}
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{l.text || " "}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
|
||||
if (!preview.ok) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>{name}</code>
|
||||
{" — this will fail:"}
|
||||
</div>
|
||||
<div style={{ color: "#ff8a80", fontSize: "0.8rem", marginBottom: "0.4rem" }}>{preview.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "edit_source") {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_source</code>
|
||||
{" "}{preview.path}{preview.is_new_file ? " (new file)" : ""}
|
||||
</div>
|
||||
{diffBox(diffLines(preview.diff))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "edit_playbook") {
|
||||
const keys = ["title", "goal", "instructions", "tags", "tools", "model"];
|
||||
const lines = keyValueDiffLines(preview.before, preview.after, keys);
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_playbook</code>
|
||||
{" "}{preview.is_new ? "(new playbook)" : preview.after?.title}
|
||||
</div>
|
||||
{preview.becomes_main_playbook && banner("this will become the active system prompt")}
|
||||
{diffBox(lines)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "edit_settings") {
|
||||
const before = {}, after = {};
|
||||
for (const [k, v] of Object.entries(preview.applied || {})) {
|
||||
before[k] = v.before;
|
||||
after[k] = v.after;
|
||||
}
|
||||
const lines = keyValueDiffLines(before, after, Object.keys(preview.applied || {}));
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_settings</code>
|
||||
</div>
|
||||
{preview.policy_change && banner("this changes the tool-approval policy itself")}
|
||||
{preview.system_prompt_change && banner("this changes the fallback system prompt")}
|
||||
{diffBox(lines)}
|
||||
{preview.ignored_unknown && preview.ignored_unknown.length > 0 && (
|
||||
<div style={{ fontSize: "0.75rem", color: "#888" }}>
|
||||
ignored (not a real setting): {preview.ignored_unknown.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+40
-248
@@ -6,14 +6,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
|
||||
// comment for the sandboxing model).
|
||||
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
|
||||
// The execution track's display half: a ```nexus-run fence is a run result
|
||||
// (source + captured output), not a program to execute here. See run-langs.js.
|
||||
import { RUN_FENCE_LANG, parseRunResult } from "./preview/run-langs.js";
|
||||
// Self-modification's display half: a ```nexus-edit fence is the result of an
|
||||
// already-applied, already-approved edit_source/edit_playbook/edit_settings
|
||||
// call. See self-edit-langs.js.
|
||||
import { EDIT_FENCE_LANG, parseEditResult } from "./preview/self-edit-langs.js";
|
||||
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
|
||||
|
||||
// Parse content into an array of {type, value, lang, streaming} blocks.
|
||||
// Handles:
|
||||
@@ -37,11 +29,9 @@ function parseBlocks(content) {
|
||||
let j = fenceStart + 3;
|
||||
let lang = "";
|
||||
|
||||
// Language specifier is valid only when tag-chars are followed by a newline.
|
||||
// Language specifier is valid only when word-chars are followed by a newline.
|
||||
// If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
|
||||
// Hyphens count: `nexus-run` is a tag this file dispatches on, and real
|
||||
// languages spell themselves that way too (objective-c, c-sharp).
|
||||
const langMatch = content.slice(j).match(/^([\w-]+)(\r?\n)/);
|
||||
const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
|
||||
if (langMatch) {
|
||||
lang = langMatch[1];
|
||||
j += langMatch[0].length;
|
||||
@@ -71,16 +61,6 @@ export function Markdown({ content }) {
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
|
||||
const lang = (block.lang || "").toLowerCase();
|
||||
if (lang === RUN_FENCE_LANG) {
|
||||
// A half-streamed envelope is not parseable JSON, so the block shows
|
||||
// as code until the fence closes and then becomes the run panel.
|
||||
const run = block.streaming ? null : parseRunResult(block.value);
|
||||
if (run) return <RunBlock key={i} run={run} />;
|
||||
}
|
||||
if (lang === EDIT_FENCE_LANG) {
|
||||
const edit = block.streaming ? null : parseEditResult(block.value);
|
||||
if (edit) return <EditBlock key={i} edit={edit} />;
|
||||
}
|
||||
return RENDERABLE_LANGS.has(lang)
|
||||
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
|
||||
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
|
||||
@@ -146,229 +126,6 @@ function CodeBlock({ lang, value, streaming }) {
|
||||
);
|
||||
}
|
||||
|
||||
// A finished run: the source that was executed and what it printed, in one
|
||||
// block with an Output/Code toggle.
|
||||
//
|
||||
// Nothing executes in the browser here, which is the whole difference from
|
||||
// RenderBlock below. The program already ran on the host (synapse/code_run.py)
|
||||
// under the user's per-call approval; by the time this renders, the result is
|
||||
// history. So there is no iframe, no CSP and no sandbox in this component - the
|
||||
// only untrusted thing present is *text*, and React escapes it.
|
||||
//
|
||||
// Output and stderr are shown together rather than on separate tabs: a program
|
||||
// that printed three lines and then panicked is telling one story, and splitting
|
||||
// it hides which half the reader needs. Exit code sits in the header because a
|
||||
// silent non-zero exit is otherwise invisible.
|
||||
function RunBlock({ run }) {
|
||||
const [tab, setTab] = useState("output");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(run.source.trimEnd()).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const failed = run.exitCode !== null && run.exitCode !== 0;
|
||||
const empty = !run.stdout.trim() && !run.stderr.trim();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: "#0d0d0d",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "6px",
|
||||
margin: "0.5rem 0",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.3rem 0.75rem",
|
||||
background: "#161616",
|
||||
borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||
<TabButton active={tab === "output"} onClick={() => setTab("output")}>
|
||||
Output
|
||||
</TabButton>
|
||||
<TabButton active={tab === "code"} onClick={() => setTab("code")}>
|
||||
Code
|
||||
</TabButton>
|
||||
<span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
|
||||
{run.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{run.exitCode !== null && (
|
||||
<span style={{
|
||||
fontSize: "0.7rem",
|
||||
fontFamily: "monospace",
|
||||
color: failed ? "#ff8a80" : "#4caf50",
|
||||
}}>
|
||||
exit {run.exitCode}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tab === "output" ? (
|
||||
<div style={{
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "0.85rem",
|
||||
lineHeight: "1.5",
|
||||
fontFamily: "monospace",
|
||||
maxHeight: "24rem",
|
||||
overflow: "auto",
|
||||
}}>
|
||||
{empty && (
|
||||
<span style={{ color: "#555" }}>
|
||||
(the program printed nothing)
|
||||
</span>
|
||||
)}
|
||||
{run.stdout && (
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", color: "#ddd" }}>
|
||||
{run.stdout.replace(/\n$/, "")}
|
||||
</pre>
|
||||
)}
|
||||
{run.stderr && (
|
||||
<pre style={{
|
||||
margin: run.stdout ? "0.5rem 0 0" : 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#ff8a80",
|
||||
}}>
|
||||
{run.stderr.replace(/\n$/, "")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<pre style={{
|
||||
padding: "0.75rem 1rem",
|
||||
overflowX: "auto",
|
||||
fontSize: "0.85rem",
|
||||
lineHeight: "1.5",
|
||||
margin: 0,
|
||||
fontFamily: "monospace",
|
||||
}}>
|
||||
<code>{run.source.trimEnd()}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A finished self-edit: the diff/change that was actually applied, plus (for
|
||||
// edit_source) the git commit it landed as. Like RunBlock, nothing executes
|
||||
// here — the edit already happened on the backend under approval, and by the
|
||||
// time this renders it's history. Styled the same way as CodeBlock/RunBlock
|
||||
// for visual consistency, with diff lines colored via the shared
|
||||
// diff-view.js vocabulary rather than a syntax-highlighting library.
|
||||
function EditBlock({ edit }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyText =
|
||||
edit.kind === "source" ? edit.diff
|
||||
: edit.kind === "playbook" ? edit.instructions
|
||||
: JSON.stringify(edit.applied, null, 2);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText((copyText || "").trimEnd()).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const title =
|
||||
edit.kind === "source" ? `edited ${edit.path}`
|
||||
: edit.kind === "playbook" ? `playbook: ${edit.title || edit.id}`
|
||||
: "settings changed";
|
||||
|
||||
const lines =
|
||||
edit.kind === "source" ? diffLines(edit.diff)
|
||||
: edit.kind === "settings"
|
||||
? keyValueDiffLines(
|
||||
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.before])),
|
||||
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.after])),
|
||||
Object.keys(edit.applied),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: "#0d0d0d",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "6px",
|
||||
margin: "0.5rem 0",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.3rem 0.75rem",
|
||||
background: "#161616",
|
||||
borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.75rem", color: "#888", fontFamily: "monospace" }}>
|
||||
{title}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{edit.commit && (
|
||||
<span style={{ fontSize: "0.7rem", fontFamily: "monospace", color: "#7a92a8" }}>
|
||||
commit {edit.commit}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{edit.kind === "playbook" && edit.isMainPlaybook && (
|
||||
<div style={{
|
||||
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
|
||||
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
⚠ this is now the active system prompt
|
||||
</div>
|
||||
)}
|
||||
{(edit.kind === "settings") && (edit.policyChange || edit.systemPromptChange) && (
|
||||
<div style={{
|
||||
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
|
||||
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
⚠ {edit.policyChange && "changed the tool-approval policy"}
|
||||
{edit.policyChange && edit.systemPromptChange && " and "}
|
||||
{edit.systemPromptChange && "changed the fallback system prompt"}
|
||||
</div>
|
||||
)}
|
||||
{edit.kind === "playbook" ? (
|
||||
<pre style={{
|
||||
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
|
||||
lineHeight: "1.5", margin: 0, fontFamily: "monospace", color: "#ccc",
|
||||
}}>
|
||||
<code>{(edit.instructions || "").trimEnd()}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<pre style={{
|
||||
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
|
||||
lineHeight: "1.5", margin: 0, fontFamily: "monospace",
|
||||
maxHeight: "24rem", overflowY: "auto",
|
||||
}}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{l.text || " "}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Security-Policy for the rendered preview. Together with the iframe's
|
||||
// `sandbox` attribute below, this is the entire trust boundary for model-
|
||||
// authored HTML/SVG, so it stays conservative rather than convenient:
|
||||
@@ -486,6 +243,9 @@ const _PREVIEW_BOOTSTRAP = `<script>
|
||||
observers[observers.length - 1].observe(document.body);
|
||||
}
|
||||
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
|
||||
// Heartbeat: the parent's watchdog needs a message even when nothing is
|
||||
// changing, or an idle-but-alive frame reads the same as a hung one.
|
||||
setInterval(post, 1000);
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
@@ -547,6 +307,14 @@ const _MIN_PREVIEW_H = 160;
|
||||
const _MAX_PREVIEW_H = 720;
|
||||
const _MAX_H_STEPS = 60;
|
||||
|
||||
// A frame that never posts again — a synchronous `while(true)` in the user's
|
||||
// own script, or a runaway re-render loop the bootstrap's own coalescing
|
||||
// can't outpace — has nothing else to signal it. Silence past this long since
|
||||
// mount (or since the last message) is treated as hung and the frame is torn
|
||||
// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
|
||||
// gets close to this.
|
||||
const _WATCHDOG_MS = 6000;
|
||||
|
||||
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
|
||||
// via a sandboxed iframe whose document is an encoded data: URL.
|
||||
//
|
||||
@@ -650,9 +418,11 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
const [doc, setDoc] = useState("");
|
||||
const [buildError, setBuildError] = useState("");
|
||||
const [height, setHeight] = useState(240);
|
||||
const [hung, setHung] = useState(false);
|
||||
const frameRef = useRef(null);
|
||||
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
|
||||
const stepsRef = useRef(0);
|
||||
const lastMsgRef = useRef(0); // set for real by the watchdog effect below
|
||||
|
||||
// Receive the bootstrap's reports. The frame is on an opaque origin, so
|
||||
// e.origin is the string "null" and proves nothing - identify the sender by
|
||||
@@ -662,6 +432,7 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
|
||||
const data = e.data;
|
||||
if (!data || data.__nexusPreview !== 1) return;
|
||||
lastMsgRef.current = Date.now();
|
||||
|
||||
if (typeof data.err === "string" && data.err) setError(data.err);
|
||||
|
||||
@@ -678,6 +449,23 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
// Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a
|
||||
// synchronous infinite loop in the model's own script, which blocks even
|
||||
// the bootstrap's heartbeat from ever running — gets torn down rather than
|
||||
// left spinning. Checked on an interval rather than a single timeout so a
|
||||
// message arriving late (slow compile, heavy first paint) keeps resetting
|
||||
// the clock instead of tripping early.
|
||||
useEffect(() => {
|
||||
lastMsgRef.current = Date.now();
|
||||
const id = setInterval(() => {
|
||||
if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
|
||||
setHung(true);
|
||||
clearInterval(id);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [lang, value]);
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setDoc("");
|
||||
@@ -691,9 +479,13 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
}, [lang, value]);
|
||||
|
||||
// A build failure (JSX that doesn't parse) has no document to show at all, so
|
||||
// the message stands in for the frame rather than sitting under it.
|
||||
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
||||
const shown = buildError || error;
|
||||
// the message stands in for the frame rather than sitting under it. A hung
|
||||
// frame tears down the same way: dropping frameUrl unmounts the iframe,
|
||||
// which is what actually stops a runaway script from holding the tab.
|
||||
const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
||||
const shown = hung
|
||||
? "Preview stopped responding (likely an infinite loop) and was stopped."
|
||||
: buildError || error;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* diff-view.js — shared plain-text diff-line classification, no dependency.
|
||||
*
|
||||
* There is no diff/syntax-highlighting library in this project (see
|
||||
* package.json), so a unified-diff string is colored by hand: split into
|
||||
* lines, tag each by its leading character. Used by both the self-edit
|
||||
* approval banner (Chatbot.jsx, reviewing a change BEFORE it's applied) and
|
||||
* the nexus-edit result block (Markdown.jsx, showing one AFTER) — one small
|
||||
* shared vocabulary so both read the same way.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Split unified-diff text (from difflib.unified_diff on the backend) into
|
||||
* {text, kind} lines. kind is one of "add" | "remove" | "hunk" | "file" |
|
||||
* "context". The "+++"/"---" file markers and "@@" hunk headers get their own
|
||||
* kind so they aren't colored as if they were real added/removed lines (a
|
||||
* bare "+++" line is not "code that was added").
|
||||
*/
|
||||
export function diffLines(text) {
|
||||
return (text || "").replace(/\n$/, "").split("\n").map((line) => {
|
||||
if (line.startsWith("+++") || line.startsWith("---")) return { text: line, kind: "file" };
|
||||
if (line.startsWith("@@")) return { text: line, kind: "hunk" };
|
||||
if (line.startsWith("+")) return { text: line, kind: "add" };
|
||||
if (line.startsWith("-")) return { text: line, kind: "remove" };
|
||||
return { text: line, kind: "context" };
|
||||
});
|
||||
}
|
||||
|
||||
export const DIFF_LINE_COLOR = {
|
||||
add: "#8aff8a",
|
||||
remove: "#ff8a80",
|
||||
hunk: "#7a92a8",
|
||||
file: "#7a92a8",
|
||||
context: "#ccc",
|
||||
};
|
||||
|
||||
/**
|
||||
* before/after key-value pairs (playbook fields, settings keys) rendered
|
||||
* through the same add/remove vocabulary as a real diff, so a settings or
|
||||
* playbook change reads the same way a source diff does: one line removed,
|
||||
* one line added, per changed key. Unchanged keys are omitted entirely —
|
||||
* this is "what's different," not a full dump.
|
||||
*/
|
||||
export function keyValueDiffLines(before, after, keys) {
|
||||
const lines = [];
|
||||
for (const key of keys) {
|
||||
const b = before ? before[key] : undefined;
|
||||
const a = after ? after[key] : undefined;
|
||||
const bStr = JSON.stringify(b);
|
||||
const aStr = JSON.stringify(a);
|
||||
if (bStr === aStr) continue;
|
||||
if (b !== undefined) lines.push({ text: `- ${key}: ${bStr}`, kind: "remove" });
|
||||
if (a !== undefined) lines.push({ text: `+ ${key}: ${aStr}`, kind: "add" });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { diffLines, keyValueDiffLines } from "./diff-view.js";
|
||||
|
||||
test("diffLines tags add/remove/hunk/file/context lines", () => {
|
||||
const text = "--- a/x.py\n+++ b/x.py\n@@ -1,1 +1,1 @@\n-old\n+new\n unchanged\n";
|
||||
const lines = diffLines(text);
|
||||
assert.equal(lines[0].kind, "file");
|
||||
assert.equal(lines[1].kind, "file");
|
||||
assert.equal(lines[2].kind, "hunk");
|
||||
assert.equal(lines[3].kind, "remove");
|
||||
assert.equal(lines[4].kind, "add");
|
||||
assert.equal(lines[5].kind, "context");
|
||||
});
|
||||
|
||||
test("diffLines drops exactly one trailing newline, not trailing blank lines", () => {
|
||||
const lines = diffLines("a\nb\n");
|
||||
assert.equal(lines.length, 2);
|
||||
assert.equal(lines[1].text, "b");
|
||||
});
|
||||
|
||||
test("diffLines on empty text returns one empty context line, not a crash", () => {
|
||||
const lines = diffLines("");
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0].text, "");
|
||||
});
|
||||
|
||||
test("keyValueDiffLines only emits changed keys", () => {
|
||||
const lines = keyValueDiffLines({ a: 1, b: 2 }, { a: 1, b: 3 }, ["a", "b"]);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0].text, /^- b: 2$/);
|
||||
assert.match(lines[1].text, /^\+ b: 3$/);
|
||||
});
|
||||
|
||||
test("keyValueDiffLines handles a null before (brand-new object)", () => {
|
||||
const lines = keyValueDiffLines(null, { title: "New" }, ["title"]);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0].kind, "add");
|
||||
assert.match(lines[0].text, /title: "New"/);
|
||||
});
|
||||
|
||||
test("keyValueDiffLines emits nothing when nothing changed", () => {
|
||||
assert.deepEqual(keyValueDiffLines({ a: 1 }, { a: 1 }, ["a"]), []);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* run-langs.js — what the chat can display a *run result* for, one entry per
|
||||
* language.
|
||||
*
|
||||
* This is the display half of the execution track, and the counterpart to
|
||||
* languages.js. The distinction between the two is where the code runs:
|
||||
*
|
||||
* languages.js the fence IS the program; the browser runs it in a sandboxed
|
||||
* iframe, and this side has to build a document for it.
|
||||
* run-langs.js the program already ran, on the host, in synapse/code_run.py.
|
||||
* Nothing executes here — the fence carries source and captured
|
||||
* output as JSON, and this side only labels and lays it out.
|
||||
*
|
||||
* So an entry needs far less than a preview entry does: no toBody, no line
|
||||
* offsets, no runtime to inline. Just how to name the language to the reader.
|
||||
*
|
||||
* The backend keeps a matching registry (RUN_LANGS in synapse/code_run.py) that
|
||||
* says how each language is *executed*. Neither depends on the other at runtime;
|
||||
* tests/test_tools.py asserts the key sets stay equal.
|
||||
*/
|
||||
|
||||
export const RUN_LANGS = {
|
||||
python: { label: "Python" },
|
||||
c: { label: "C" },
|
||||
cpp: { label: "C++" },
|
||||
rust: { label: "Rust" },
|
||||
erlang: { label: "Erlang" },
|
||||
};
|
||||
|
||||
// The fence tag run_snippet emits. Not a real language: the block's body is the
|
||||
// JSON envelope { lang, source, stdout, stderr, exit_code }, which keeps a run's
|
||||
// output attached to the source that produced it. A model pasting the fence
|
||||
// cannot paste output without the code, or code with output it never produced.
|
||||
export const RUN_FENCE_LANG = "nexus-run";
|
||||
|
||||
/**
|
||||
* Parse a nexus-run fence body. Returns null for anything that isn't a
|
||||
* well-formed envelope naming a known language — the caller then falls back to
|
||||
* showing the block as plain code, which is the honest thing to do with a
|
||||
* result we can't vouch for.
|
||||
*/
|
||||
export function parseRunResult(text) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || typeof data !== "object") return null;
|
||||
if (!Object.prototype.hasOwnProperty.call(RUN_LANGS, data.lang)) return null;
|
||||
if (typeof data.source !== "string") return null;
|
||||
return {
|
||||
lang: data.lang,
|
||||
label: RUN_LANGS[data.lang].label,
|
||||
source: data.source,
|
||||
stdout: typeof data.stdout === "string" ? data.stdout : "",
|
||||
stderr: typeof data.stderr === "string" ? data.stderr : "",
|
||||
exitCode: Number.isInteger(data.exit_code) ? data.exit_code : null,
|
||||
};
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* The run-result envelope: what parseRunResult will and won't accept.
|
||||
*
|
||||
* Everything this parses arrived as text a language model chose to paste into
|
||||
* its reply, so the interesting cases are all the malformed ones. A bad envelope
|
||||
* has to return null - the caller then shows the raw block as code, which is
|
||||
* ugly but honest - rather than yield a half-built object that renders as a run
|
||||
* that never happened.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { RUN_LANGS, RUN_FENCE_LANG, parseRunResult } from "./run-langs.js";
|
||||
|
||||
const envelope = (over = {}) => JSON.stringify({
|
||||
lang: "python",
|
||||
source: "print(1)",
|
||||
stdout: "1\n",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("the fence tag is the one the backend emits", () => {
|
||||
assert.equal(RUN_FENCE_LANG, "nexus-run");
|
||||
});
|
||||
|
||||
test("every language has a display label", () => {
|
||||
for (const [name, spec] of Object.entries(RUN_LANGS)) {
|
||||
assert.equal(typeof spec.label, "string", name);
|
||||
assert.ok(spec.label.length, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a well-formed envelope parses into display fields", () => {
|
||||
const run = parseRunResult(envelope());
|
||||
assert.equal(run.lang, "python");
|
||||
assert.equal(run.label, "Python");
|
||||
assert.equal(run.source, "print(1)");
|
||||
assert.equal(run.stdout, "1\n");
|
||||
assert.equal(run.exitCode, 0);
|
||||
});
|
||||
|
||||
test("a backtick-escaped source round-trips through JSON", () => {
|
||||
// run_snippet re-encodes ` as ` so the source cannot close the fence.
|
||||
const run = parseRunResult('{"lang":"python","source":"x = \\u0060a\\u0060","exit_code":0}');
|
||||
assert.equal(run.source, "x = `a`");
|
||||
});
|
||||
|
||||
test("a non-zero exit code is preserved, not coerced away", () => {
|
||||
// `exitCode || null` would turn 0 into null and hide a clean exit; a plain
|
||||
// falsy check on the other side would call a failing program successful.
|
||||
assert.equal(parseRunResult(envelope({ exit_code: 2 })).exitCode, 2);
|
||||
assert.equal(parseRunResult(envelope({ exit_code: 0 })).exitCode, 0);
|
||||
});
|
||||
|
||||
test("a missing exit code becomes null rather than a guess", () => {
|
||||
assert.equal(parseRunResult(envelope({ exit_code: undefined })).exitCode, null);
|
||||
assert.equal(parseRunResult(envelope({ exit_code: "0" })).exitCode, null);
|
||||
});
|
||||
|
||||
test("absent streams read as empty, never undefined", () => {
|
||||
const run = parseRunResult('{"lang":"c","source":"int main(){}","exit_code":0}');
|
||||
assert.equal(run.stdout, "");
|
||||
assert.equal(run.stderr, "");
|
||||
});
|
||||
|
||||
test("malformed or foreign envelopes are rejected", () => {
|
||||
assert.equal(parseRunResult("not json at all"), null);
|
||||
assert.equal(parseRunResult("null"), null);
|
||||
assert.equal(parseRunResult("[1,2,3]"), null);
|
||||
assert.equal(parseRunResult('"a string"'), null);
|
||||
assert.equal(parseRunResult(envelope({ lang: "haskell" })), null);
|
||||
assert.equal(parseRunResult(envelope({ source: undefined })), null);
|
||||
assert.equal(parseRunResult(envelope({ source: 42 })), null);
|
||||
});
|
||||
|
||||
test("a prototype key is not mistaken for a supported language", () => {
|
||||
// `data.lang in RUN_LANGS` would be true for "toString" and read the label
|
||||
// off Object.prototype - a run panel titled with a function body.
|
||||
assert.equal(parseRunResult(envelope({ lang: "toString" })), null);
|
||||
assert.equal(parseRunResult(envelope({ lang: "constructor" })), null);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* self-edit-langs.js — display half of the self-modification tools
|
||||
* (edit_source / edit_playbook / edit_settings), the counterpart to
|
||||
* run-langs.js. Nothing executes or applies here: by the time this parses a
|
||||
* fence, the write (if any) already happened on the backend under the user's
|
||||
* per-call approval, in synapse/self_edit.py. This side only labels and lays
|
||||
* out what was returned.
|
||||
*
|
||||
* One fence tag, three payload shapes (source / playbook / settings), because
|
||||
* all three go through the same approval round-trip and the same "carry what
|
||||
* happened in one block" idea as run_snippet's nexus-run fence.
|
||||
*/
|
||||
|
||||
export const EDIT_FENCE_LANG = "nexus-edit";
|
||||
|
||||
/**
|
||||
* Parse a nexus-edit fence body. Returns null for anything malformed or of an
|
||||
* unrecognized kind — the caller falls back to showing the block as plain
|
||||
* code, the same honest-fallback behavior as parseRunResult.
|
||||
*/
|
||||
export function parseEditResult(text) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || typeof data !== "object") return null;
|
||||
|
||||
if (data.kind === "source") {
|
||||
if (typeof data.path !== "string" || typeof data.diff !== "string") return null;
|
||||
return {
|
||||
kind: "source",
|
||||
path: data.path,
|
||||
diff: data.diff,
|
||||
commit: typeof data.commit === "string" ? data.commit : null,
|
||||
instruction: typeof data.instruction === "string" ? data.instruction : "",
|
||||
};
|
||||
}
|
||||
|
||||
if (data.kind === "playbook") {
|
||||
if (typeof data.id !== "string") return null;
|
||||
return {
|
||||
kind: "playbook",
|
||||
id: data.id,
|
||||
title: typeof data.title === "string" ? data.title : "",
|
||||
goal: typeof data.goal === "string" ? data.goal : "",
|
||||
instructions: typeof data.instructions === "string" ? data.instructions : "",
|
||||
isMainPlaybook: !!data.is_main_playbook,
|
||||
};
|
||||
}
|
||||
|
||||
if (data.kind === "settings") {
|
||||
if (!data.applied || typeof data.applied !== "object") return null;
|
||||
return {
|
||||
kind: "settings",
|
||||
applied: data.applied,
|
||||
ignoredUnknown: Array.isArray(data.ignored_unknown) ? data.ignored_unknown : [],
|
||||
policyChange: !!data.policy_change,
|
||||
systemPromptChange: !!data.system_prompt_change,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* The self-edit result envelope: what parseEditResult will and won't accept.
|
||||
*
|
||||
* Same reasoning as run-langs.test.js: this parses text a model chose to
|
||||
* paste, so the malformed cases matter as much as the well-formed ones. A bad
|
||||
* envelope must return null so the caller falls back to plain code, not a
|
||||
* half-built object that renders a change that never happened.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EDIT_FENCE_LANG, parseEditResult } from "./self-edit-langs.js";
|
||||
|
||||
test("the fence tag is the one the backend emits", () => {
|
||||
assert.equal(EDIT_FENCE_LANG, "nexus-edit");
|
||||
});
|
||||
|
||||
test("a well-formed source envelope parses into display fields", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "source", ok: true, path: "synapse/tools.py", diff: "--- a\n+++ b\n",
|
||||
commit: "abc1234", instruction: "restart needed",
|
||||
}));
|
||||
assert.equal(edit.kind, "source");
|
||||
assert.equal(edit.path, "synapse/tools.py");
|
||||
assert.equal(edit.commit, "abc1234");
|
||||
assert.equal(edit.instruction, "restart needed");
|
||||
});
|
||||
|
||||
test("a source envelope with no commit reads as null, not undefined", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "source", path: "x.py", diff: "", commit: null,
|
||||
}));
|
||||
assert.equal(edit.commit, null);
|
||||
});
|
||||
|
||||
test("a source envelope missing path or diff is rejected", () => {
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "source", diff: "x" })), null);
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "source", path: "x.py" })), null);
|
||||
});
|
||||
|
||||
test("a well-formed playbook envelope parses into display fields", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "playbook", id: "p1", title: "T", goal: "G", instructions: "I",
|
||||
is_main_playbook: true,
|
||||
}));
|
||||
assert.equal(edit.kind, "playbook");
|
||||
assert.equal(edit.id, "p1");
|
||||
assert.equal(edit.isMainPlaybook, true);
|
||||
});
|
||||
|
||||
test("a playbook envelope missing id is rejected", () => {
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "playbook", title: "T" })), null);
|
||||
});
|
||||
|
||||
test("a well-formed settings envelope parses into display fields", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "settings",
|
||||
applied: { model: { before: "a", after: "b" } },
|
||||
ignored_unknown: ["nope"],
|
||||
policy_change: true,
|
||||
system_prompt_change: false,
|
||||
}));
|
||||
assert.equal(edit.kind, "settings");
|
||||
assert.deepEqual(edit.applied, { model: { before: "a", after: "b" } });
|
||||
assert.deepEqual(edit.ignoredUnknown, ["nope"]);
|
||||
assert.equal(edit.policyChange, true);
|
||||
assert.equal(edit.systemPromptChange, false);
|
||||
});
|
||||
|
||||
test("a settings envelope missing applied is rejected", () => {
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "settings" })), null);
|
||||
});
|
||||
|
||||
test("malformed or foreign envelopes are rejected", () => {
|
||||
assert.equal(parseEditResult("not json"), null);
|
||||
assert.equal(parseEditResult("null"), null);
|
||||
assert.equal(parseEditResult("[1,2,3]"), null);
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "unknown_kind" })), null);
|
||||
assert.equal(parseEditResult(JSON.stringify({ path: "x.py", diff: "" })), null);
|
||||
});
|
||||
+21
-55
@@ -24,10 +24,8 @@ from . import ncp as services
|
||||
CONFIG_SCHEMA = {
|
||||
"home": "path",
|
||||
"api_url": "url",
|
||||
"memory_url": "url",
|
||||
"bind_host": "text",
|
||||
"backend_port": "port",
|
||||
"memory_port": "port",
|
||||
"provider": "provider",
|
||||
"provider_url": "url",
|
||||
"provider_timeout": "positive_int",
|
||||
@@ -39,8 +37,6 @@ CONFIG_SCHEMA = {
|
||||
}
|
||||
|
||||
LEGACY_TARGETS = {
|
||||
"-m": "memory",
|
||||
"--memory": "memory",
|
||||
"-b": "backend",
|
||||
"--backend": "backend",
|
||||
"-f": "frontend",
|
||||
@@ -303,14 +299,13 @@ def diagnostics() -> dict:
|
||||
)
|
||||
add(
|
||||
"service ports",
|
||||
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
|
||||
f"backend={settings.backend_port}, memory={settings.memory_port}",
|
||||
1 <= settings.backend_port <= 65535,
|
||||
f"backend={settings.backend_port}",
|
||||
)
|
||||
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
|
||||
add(f"import:{module}", _check_import(module), module)
|
||||
|
||||
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
|
||||
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
|
||||
provider = _provider_payload()
|
||||
add("provider", provider["reachable"], provider["url"], required=False)
|
||||
if settings.manage_ollama:
|
||||
@@ -362,7 +357,7 @@ def cmd_doctor(args) -> int:
|
||||
|
||||
def service_status() -> dict:
|
||||
payload = {}
|
||||
for key in ("backend", "memory", "frontend"):
|
||||
for key in ("backend", "frontend"):
|
||||
svc = services.SERVICES[key]
|
||||
pid = services.read_pid(svc)
|
||||
payload[key] = {
|
||||
@@ -380,7 +375,7 @@ def cmd_status(args) -> int:
|
||||
_emit(payload, True)
|
||||
return 0
|
||||
print("Nexus Service Status:\n")
|
||||
for key in ("backend", "memory", "frontend"):
|
||||
for key in ("backend", "frontend"):
|
||||
info = payload[key]
|
||||
suffix = f" (PID {info['pid']})" if info["pid"] else ""
|
||||
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
|
||||
@@ -418,7 +413,6 @@ def cmd_tui(args) -> int:
|
||||
|
||||
def _target_flag(target: str | None):
|
||||
return {
|
||||
"memory": "--memory",
|
||||
"backend": "--backend",
|
||||
"frontend": "--frontend",
|
||||
"ai": "--ai",
|
||||
@@ -503,18 +497,14 @@ def cmd_serve(args) -> int:
|
||||
return 2
|
||||
|
||||
settings.backend_port = args.port
|
||||
settings.memory_port = args.memory_port
|
||||
settings.bind_host = host
|
||||
settings.api_url = f"http://127.0.0.1:{args.port}"
|
||||
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
|
||||
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
|
||||
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
|
||||
os.environ["NEXUS_BIND_HOST"] = host
|
||||
for origin_host in ("localhost", "127.0.0.1"):
|
||||
for port in (args.port, args.memory_port):
|
||||
origin = f"http://{origin_host}:{port}"
|
||||
if origin not in config.ALLOWED_ORIGINS:
|
||||
config.ALLOWED_ORIGINS.append(origin)
|
||||
origin = f"http://{origin_host}:{args.port}"
|
||||
if origin not in config.ALLOWED_ORIGINS:
|
||||
config.ALLOWED_ORIGINS.append(origin)
|
||||
if args.allow_lan:
|
||||
# Widen to the addresses this bind actually answers on - NOT "*".
|
||||
# ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding
|
||||
@@ -526,10 +516,9 @@ def cmd_serve(args) -> int:
|
||||
for name in names:
|
||||
if name not in config.ALLOWED_HOSTS:
|
||||
config.ALLOWED_HOSTS.append(name)
|
||||
for port in (args.port, args.memory_port):
|
||||
origin = f"http://{_origin_host(name)}:{port}"
|
||||
if origin not in config.ALLOWED_ORIGINS:
|
||||
config.ALLOWED_ORIGINS.append(origin)
|
||||
origin = f"http://{_origin_host(name)}:{args.port}"
|
||||
if origin not in config.ALLOWED_ORIGINS:
|
||||
config.ALLOWED_ORIGINS.append(origin)
|
||||
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
|
||||
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS))
|
||||
print(
|
||||
@@ -538,35 +527,13 @@ def cmd_serve(args) -> int:
|
||||
" has full admin and data access."
|
||||
)
|
||||
|
||||
memory_proc = None
|
||||
memory_log = None
|
||||
try:
|
||||
if not args.no_memory and not _http_ok(settings.memory_url + "/"):
|
||||
log_path = settings.runtime_dir / "memory.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory_log = open(log_path, "ab")
|
||||
memory_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
|
||||
"--host", host, "--port", str(args.memory_port)],
|
||||
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
|
||||
print(f"NexusOS serving on http://{host}:{args.port}")
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"synapse.main:sio_app", host=host, port=args.port,
|
||||
reload=bool(args.reload and settings.source_checkout),
|
||||
log_level=args.log_level,
|
||||
)
|
||||
finally:
|
||||
if memory_proc is not None and memory_proc.poll() is None:
|
||||
memory_proc.terminate()
|
||||
try:
|
||||
memory_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
memory_proc.kill()
|
||||
if memory_log is not None:
|
||||
memory_log.close()
|
||||
print(f"NexusOS serving on http://{host}:{args.port}")
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"synapse.main:sio_app", host=host, port=args.port,
|
||||
reload=bool(args.reload and settings.source_checkout),
|
||||
log_level=args.log_level,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -626,7 +593,7 @@ def cmd_nvidia_reqs(_args) -> int:
|
||||
|
||||
|
||||
def cmd_logs(args) -> int:
|
||||
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
|
||||
keys = ("backend", "frontend") if args.target == "all" else (args.target,)
|
||||
paths = [services.SERVICES[key].log_file for key in keys]
|
||||
for path in paths:
|
||||
print(f"=== {path.name} ===")
|
||||
@@ -779,8 +746,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
p = sub.add_parser("serve", help="run NexusOS in the foreground")
|
||||
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
|
||||
p.add_argument("--memory-port", type=_port, default=settings.memory_port)
|
||||
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
|
||||
p.add_argument("--allow-lan", action="store_true")
|
||||
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
|
||||
p.set_defaults(fn=cmd_serve)
|
||||
|
||||
@@ -789,7 +755,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
("stop", cmd_stop, "stop background services"),
|
||||
):
|
||||
p = sub.add_parser(name, help=help_text)
|
||||
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
|
||||
p.add_argument("target", nargs="?", choices=["all", "backend", "frontend", "ai"], default="all")
|
||||
p.set_defaults(fn=fn)
|
||||
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
|
||||
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
|
||||
@@ -799,7 +765,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
|
||||
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
|
||||
p = sub.add_parser("logs", help="read or follow service logs")
|
||||
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
|
||||
p.add_argument("target", nargs="?", choices=["all", "backend", "frontend"], default="all")
|
||||
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
|
||||
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
|
||||
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
|
||||
|
||||
@@ -62,7 +62,7 @@ def _provider_payload() -> dict:
|
||||
|
||||
def _service_status() -> dict:
|
||||
payload = {}
|
||||
for key in ("backend", "memory", "frontend"):
|
||||
for key in ("backend", "frontend"):
|
||||
svc = services.SERVICES[key]
|
||||
pid = services.read_pid(svc)
|
||||
payload[key] = {
|
||||
@@ -253,7 +253,6 @@ def collect_snapshot() -> dict:
|
||||
services_payload = _service_status()
|
||||
pids = [
|
||||
services_payload.get("backend", {}).get("pid"),
|
||||
services_payload.get("memory", {}).get("pid"),
|
||||
services_payload.get("frontend", {}).get("pid"),
|
||||
]
|
||||
api = _api_counts(settings.api_url)
|
||||
@@ -268,7 +267,6 @@ def collect_snapshot() -> dict:
|
||||
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
|
||||
"paths": {
|
||||
"api_url": settings.api_url,
|
||||
"memory_url": settings.memory_url,
|
||||
"runtime_dir": str(settings.runtime_dir),
|
||||
},
|
||||
}
|
||||
@@ -328,7 +326,7 @@ def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | No
|
||||
|
||||
lines.append(_row(box, "SERVICES", width))
|
||||
svcs = snapshot.get("services") or {}
|
||||
for key, label in (("backend", "backend"), ("memory", "memory"), ("frontend", "frontend")):
|
||||
for key, label in (("backend", "backend"), ("frontend", "frontend")):
|
||||
info = svcs.get(key) or {}
|
||||
running = bool(info.get("running"))
|
||||
pid = info.get("pid")
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ def _uvicorn(app: str, port: int):
|
||||
|
||||
|
||||
SERVICES = {
|
||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, ROOT,
|
||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
|
||||
["uvicorn synapse.main"],
|
||||
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
||||
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
||||
|
||||
+10
-19
@@ -15,7 +15,6 @@ 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
|
||||
|
||||
@@ -345,19 +344,6 @@ class NexusTUI:
|
||||
log.write(
|
||||
f"[dim]model:[/] {_escape(self._model or '(auto)')}"
|
||||
)
|
||||
elif parse_slash_command(text) is not None:
|
||||
# Shaped like /tool_name(arg=val, ...) rather than one of
|
||||
# the local meta-commands above — not handled here, sent
|
||||
# to the backend as-is. chat_stream_endpoint recognizes
|
||||
# and dispatches it directly (see synapse/slash_commands.py);
|
||||
# a malformed one still goes through so the user sees the
|
||||
# backend's own error, with full context, in one place.
|
||||
if self._busy:
|
||||
log.write(
|
||||
"[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
|
||||
)
|
||||
else:
|
||||
self._start_chat(text)
|
||||
else:
|
||||
log.write(
|
||||
f"[red]unknown command[/] /{_escape(cmd)} — try /help"
|
||||
@@ -373,14 +359,19 @@ class NexusTUI:
|
||||
if not self.conversation_id:
|
||||
self.conversation_id = str(uuid.uuid4())
|
||||
conversation_id = self.conversation_id
|
||||
# The list object itself, not self.history - /new reassigns
|
||||
# self.history to a fresh list, and a stream that outlives that
|
||||
# must keep appending its reply to the conversation it actually
|
||||
# belongs to, not whatever self.history now points at.
|
||||
history_ref = self.history
|
||||
body: dict[str, Any] = {
|
||||
"message": message,
|
||||
"conversation_id": conversation_id,
|
||||
"history": list(self.history),
|
||||
"history": list(history_ref),
|
||||
}
|
||||
if self._model:
|
||||
body["model"] = self._model
|
||||
self.history.append({"role": "user", "content": message})
|
||||
history_ref.append({"role": "user", "content": message})
|
||||
|
||||
async def stream_worker():
|
||||
reply_parts: list[str] = []
|
||||
@@ -488,19 +479,19 @@ class NexusTUI:
|
||||
if self._stream_cancel == (loop, task):
|
||||
self._stream_cancel = None
|
||||
text = "".join(reply_parts).strip()
|
||||
self._call_ui(self._finish_stream, text)
|
||||
self._call_ui(self._finish_stream, text, history_ref)
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: asyncio.run(stream_worker()), daemon=True
|
||||
).start()
|
||||
|
||||
def _finish_stream(self, text: str) -> None:
|
||||
def _finish_stream(self, text: str, history_ref: list) -> 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(
|
||||
history_ref.append(
|
||||
{"role": "assistant", "content": text}
|
||||
)
|
||||
finally:
|
||||
|
||||
+14
-44
@@ -10,7 +10,6 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
|
||||
from .nexus_config import settings, DEFAULT_CHAT_MODEL
|
||||
from .ollama_manager import get_ollama_manager
|
||||
from . import tools as _tools
|
||||
from . import self_edit
|
||||
|
||||
# Cap on tool-call round-trips before the final answer — stops a confused small
|
||||
# model from looping forever.
|
||||
@@ -140,12 +139,6 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
|
||||
pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||||
_APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all"
|
||||
|
||||
# Host code screening has a concrete safety contract, so run_snippet retains its
|
||||
# bounded retry and second-attempt scaffold. Browser previews are packaged as-is.
|
||||
_RETRY_TOOLS = frozenset({"run_snippet"})
|
||||
_FENCE_TOOLS = frozenset({"render_preview", "run_snippet"})
|
||||
|
||||
|
||||
def _as_tool_calls(obj) -> list:
|
||||
"""Normalize a parsed JSON value into Ollama-style tool_calls entries."""
|
||||
if isinstance(obj, list):
|
||||
@@ -271,7 +264,6 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
# Let the UI show activity immediately — the first tool-turn is a full
|
||||
# non-stream generation and can sit silent for a long time otherwise.
|
||||
yield "__status__tools"
|
||||
run_rejects = 0
|
||||
allowed_names = {
|
||||
(schema.get("function") or {}).get("name")
|
||||
for schema in (tool_schemas or [])
|
||||
@@ -284,29 +276,23 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
)
|
||||
if not isinstance(msg, dict):
|
||||
break # None/error or no tool support -> fall back to plain stream
|
||||
native = bool(msg.get("tool_calls"))
|
||||
calls = _coerce_tool_calls(msg, allowed_names)
|
||||
if not calls:
|
||||
break
|
||||
# Normalize content-JSON tool calls into the shape later turns expect.
|
||||
if not msg.get("tool_calls"):
|
||||
if not native:
|
||||
msg = {"role": "assistant", "content": "", "tool_calls": calls}
|
||||
messages.append(msg)
|
||||
|
||||
# If any action tool needs per-call approval, pause and wait for the user.
|
||||
# edit_playbook/edit_settings/edit_source and the write/execute curry_*
|
||||
# tools always require it, regardless of `policy` — a global "allow" set
|
||||
# for convenience on an unrelated tool (web_search, say) must never
|
||||
# silently also unlock unattended self-modification or ledger writes.
|
||||
# See _tools.ALWAYS_ASK_ACTION_TOOLS. (This floor governs MODEL-issued
|
||||
# calls only — a human-typed /tool(...) slash-command skips it entirely,
|
||||
# by design: see slash_commands.py.)
|
||||
# A call recovered by guessing at `content` (no native tool_calls field)
|
||||
# is a weaker signal than the API's own structured field — a model can
|
||||
# land on JSON shaped like a call while only meaning to describe one, so
|
||||
# it always goes through approval regardless of policy, even "allow".
|
||||
decisions = None
|
||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
||||
needs_approval = policy == "ask" or any(
|
||||
c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS
|
||||
for c in action_calls
|
||||
)
|
||||
if needs_approval and action_calls:
|
||||
if (policy == "ask" or not native) and action_calls:
|
||||
event = asyncio.Event()
|
||||
# Single-use capability token, delivered only to the client that owns
|
||||
# this stream. /chat/approve requires it, so knowing the (guessable,
|
||||
@@ -314,21 +300,13 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
# else's pending action.
|
||||
token = secrets.token_urlsafe(32)
|
||||
pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token}
|
||||
|
||||
def _action_entry(c):
|
||||
name = c.get("function", {}).get("name", "")
|
||||
args = c.get("function", {}).get("arguments")
|
||||
entry = {"name": name, "arguments": args}
|
||||
if name in self_edit.PREVIEWABLE:
|
||||
try:
|
||||
entry["preview"] = self_edit.preview_for(name, args or {})
|
||||
except Exception as e:
|
||||
entry["preview"] = {"ok": False, "error": f"preview failed: {e}"}
|
||||
return entry
|
||||
|
||||
yield "__approve__" + _json.dumps({
|
||||
"token": token,
|
||||
"actions": [_action_entry(c) for c in action_calls],
|
||||
"actions": [
|
||||
{"name": c.get("function", {}).get("name", ""),
|
||||
"arguments": c.get("function", {}).get("arguments")}
|
||||
for c in action_calls
|
||||
],
|
||||
})
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
|
||||
@@ -347,22 +325,14 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
continue
|
||||
yield f"__status__{name}"
|
||||
call_args = fn.get("arguments")
|
||||
if name in _RETRY_TOOLS and isinstance(call_args, dict):
|
||||
call_args = {**call_args, "_attempt": run_rejects}
|
||||
result = await _tools.dispatch(name, call_args)
|
||||
messages.append({"role": "tool", "content": result})
|
||||
# Cap host-code reject loops — each retry is another full non-stream
|
||||
# generation and looks like the UI is "stuck thinking".
|
||||
if name in _FENCE_TOOLS:
|
||||
if name == "render_preview":
|
||||
try:
|
||||
body = _json.loads(result)
|
||||
except Exception:
|
||||
body = {}
|
||||
if name in _RETRY_TOOLS and isinstance(body, dict) and body.get("ok") is False:
|
||||
run_rejects += 1
|
||||
if run_rejects >= 2:
|
||||
stop_after = True
|
||||
elif isinstance(body, dict) and body.get("ok") is True:
|
||||
if isinstance(body, dict) and body.get("ok") is True:
|
||||
# Good fence in hand — let the model write the reply next.
|
||||
stop_after = True
|
||||
if stop_after:
|
||||
|
||||
@@ -1,604 +0,0 @@
|
||||
"""Run a short code snippet in an ephemeral working directory.
|
||||
|
||||
This is the *second* track of the code-preview feature and deliberately not the
|
||||
first. `render_preview` (synapse/tools.py) executes nothing server-side: it
|
||||
validates markup and the chat UI renders it inside an opaque-origin iframe.
|
||||
That model fits HTML/SVG/JSX and cannot fit C, Rust or Erlang, which need a real
|
||||
toolchain. So those go through here instead, and the result is shown as terminal
|
||||
output rather than a rendered document.
|
||||
|
||||
WHAT THIS IS NOT
|
||||
----------------
|
||||
Not a security sandbox. Snippets run as the current user on the host. What this
|
||||
module actually provides is *containment by limits*, layered:
|
||||
|
||||
1. Consent run_snippet is an ACTION tool, so it is withheld entirely
|
||||
unless `action_tool_policy` is "ask"/"allow" — and on "ask"
|
||||
every call waits for the user's Approve/Deny in chat.
|
||||
2. Screening `critique` rejects the obvious-abuse shapes (sockets, process
|
||||
spawning, absolute paths) before anything is written to disk.
|
||||
3. Isolation cwd is a fresh temp dir that is deleted afterwards; home and
|
||||
temp environment variables point at it; the environment is
|
||||
scrubbed to a small allowlist.
|
||||
4. Limits wall-clock timeout, RLIMIT_CPU/AS/FSIZE/NPROC on POSIX,
|
||||
truncated output.
|
||||
5. Network on Linux, `unshare -rn` when unprivileged user namespaces are
|
||||
available (probed once, see `_net_isolation`). Nowhere else —
|
||||
macOS and Windows get no network isolation at all.
|
||||
|
||||
Layer 2 is a tripwire, not a boundary: arbitrary Python can evade any regex
|
||||
trivially. It exists to catch a model that reaches for `requests` by habit, not
|
||||
an adversary. The load-bearing layers are 1 and 3–5. Anyone wanting a real
|
||||
boundary should run this under a container or a VM; that is a deployment
|
||||
decision this module does not make for them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
# Wall clock. Compilation gets its own, larger budget: rustc on a cold cache
|
||||
# routinely spends longer than any snippet is allowed to *run*, and killing a
|
||||
# compile at the run timeout would look like the code hung when it never started.
|
||||
RUN_TIMEOUT = 5.0
|
||||
COMPILE_TIMEOUT = 25.0
|
||||
|
||||
MAX_SOURCE = 100_000 # chars of source accepted
|
||||
MAX_OUTPUT = 20_000 # chars of stdout/stderr returned, per stream
|
||||
MAX_STDIN = 10_000
|
||||
|
||||
# Applied to the run step only. A compiler legitimately needs more address space
|
||||
# than a snippet does and forks a linker, so imposing these on the compile step
|
||||
# breaks the toolchain rather than containing the snippet.
|
||||
_MEM_BYTES = 512 * 1024 * 1024
|
||||
_MAX_PROCS = 64
|
||||
_MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
# RLIMIT_NPROC is counted per real UID, not per run. An absolute "512" is
|
||||
# therefore 512 minus whatever the user already has — enough on a quiet host,
|
||||
# zero on a busy macOS desktop. Erlang needs relative headroom for BEAM's
|
||||
# boot process tree; other languages keep the absolute _MAX_PROCS (blocking
|
||||
# fork is the intent there).
|
||||
_ERLANG_PROC_HEADROOM = 256
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static screening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _shared_issues(source: str) -> list[str]:
|
||||
stripped = source.strip()
|
||||
if len(stripped) < 8:
|
||||
return ["source is empty or too short to run."]
|
||||
issues: list[str] = []
|
||||
# Only flag URLs inside string literals. A citation in a comment
|
||||
# (`/* see https://… */`) is not a network reach and bouncing it costs a
|
||||
# useless retry round — this layer is a tripwire, not a parser.
|
||||
if re.search(r"""['"][^'"]*https?://[^'"]*['"]""", source):
|
||||
issues.append(
|
||||
"source contains an http(s) URL string — the runner has no network "
|
||||
"access. Inline the data you need."
|
||||
)
|
||||
if re.search(r"\b(TODO|FIXME|your code here|implement this)\b", source, re.I):
|
||||
issues.append("source still contains a placeholder — send the finished code.")
|
||||
# An absolute path is either reaching outside the ephemeral dir or is a
|
||||
# machine-specific guess that will not exist. Relative paths are fine: cwd
|
||||
# is the temp dir and goes away with it.
|
||||
# Windows: match `C:\…` and `C:\\…` (raw / escaped). Unix: common roots.
|
||||
if re.search(
|
||||
r"""['"](?:/(?:etc|home|root|usr|var|proc|sys)/|[A-Za-z]:[/\\])""",
|
||||
source,
|
||||
):
|
||||
issues.append(
|
||||
"source references an absolute filesystem path. The snippet runs in a "
|
||||
"throwaway directory — use relative paths, or inline the data."
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _deny(source: str, rules: list[tuple[str, str]]) -> list[str]:
|
||||
return [msg for pattern, msg in rules if re.search(pattern, source)]
|
||||
|
||||
|
||||
_NO_NET = "the runner has no network access"
|
||||
_NO_SPAWN = "the runner does not allow spawning other processes"
|
||||
|
||||
_PY_RULES = [
|
||||
(r"\b(?:import|from)\s+(?:socket|ssl|ftplib|smtplib|telnetlib|urllib|http)\b",
|
||||
f"networking module imported — {_NO_NET}."),
|
||||
(r"\bimport\s+(?:requests|httpx|aiohttp|urllib3)\b",
|
||||
f"HTTP client imported — {_NO_NET}."),
|
||||
(r"\b(?:import|from)\s+(?:subprocess|multiprocessing)\b",
|
||||
f"subprocess/multiprocessing imported — {_NO_SPAWN}."),
|
||||
(r"\bos\.(?:system|popen|exec[lv]|fork|spawn|kill)\b",
|
||||
f"os process call — {_NO_SPAWN}."),
|
||||
(r"\b(?:import|from)\s+ctypes\b",
|
||||
"ctypes imported — the runner does not allow native calls."),
|
||||
]
|
||||
|
||||
_C_RULES = [
|
||||
(r"#\s*include\s*<(?:sys/socket|netinet/|arpa/|netdb)",
|
||||
f"socket header included — {_NO_NET}."),
|
||||
(r"\b(?:system|popen|fork|execv?[lpe]*)\s*\(",
|
||||
f"process call — {_NO_SPAWN}."),
|
||||
]
|
||||
|
||||
_RUST_RULES = [
|
||||
(r"\bstd::net\b", f"std::net used — {_NO_NET}."),
|
||||
(r"\bstd::process::(?:Command|abort)\b", f"std::process::Command used — {_NO_SPAWN}."),
|
||||
]
|
||||
|
||||
_ERL_RULES = [
|
||||
(r"\b(?:gen_tcp|gen_udp|httpc|inets|ssl)\b", f"networking module used — {_NO_NET}."),
|
||||
(r"\bos:cmd\b", f"os:cmd/1 used — {_NO_SPAWN}."),
|
||||
]
|
||||
|
||||
|
||||
def _entry_point(pattern: str, message: str):
|
||||
"""Most of these languages fail with a linker/loader error rather than a
|
||||
useful one when the entry point is missing, so name it up front."""
|
||||
def check(source: str) -> list[str]:
|
||||
return [] if re.search(pattern, source) else [message]
|
||||
return check
|
||||
|
||||
|
||||
def _critique(rules: list[tuple[str, str]], entry=None):
|
||||
def check(source: str) -> list[str]:
|
||||
issues = _shared_issues(source)
|
||||
if issues and issues[0].startswith("source is empty"):
|
||||
return issues # nothing else is worth saying about an empty body
|
||||
issues += _deny(source, rules)
|
||||
if entry:
|
||||
issues += entry(source)
|
||||
return issues
|
||||
return check
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drivers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _source_name(default: str):
|
||||
return lambda _source: default
|
||||
|
||||
|
||||
def _erlang_source_name(source: str) -> str:
|
||||
"""escript reads the same file two different ways, chosen by extension: a
|
||||
`.erl` file is compiled as a module (needs -module/-export), anything else
|
||||
is a plain script (needs only main/1). Models write both, so let the source
|
||||
pick its own filename instead of forcing one dialect."""
|
||||
return "main.erl" if re.search(r"^\s*-module\s*\(", source, re.M) else "main.escript"
|
||||
|
||||
|
||||
def _erlang_write_source(source: str) -> str:
|
||||
"""OTP 28+ escript rejects a shebang-less `.escript` with 'Premature end of
|
||||
file'. Module-form `.erl` files do not need one. Inject only when missing so
|
||||
a model that already wrote `#!/usr/bin/env escript` is left alone."""
|
||||
if _erlang_source_name(source).endswith(".escript"):
|
||||
stripped = source.lstrip()
|
||||
if not stripped.startswith("#!"):
|
||||
return "#!/usr/bin/env escript\n" + source
|
||||
return source
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _compiled_tool(lang: str, candidates: tuple[str, ...]) -> str | None:
|
||||
"""Resolve a compiler, verifying the complete Windows toolchain once.
|
||||
|
||||
A compiler executable alone is not a usable toolchain on Windows: rustc's
|
||||
MSVC target also needs Microsoft's linker, and an MSYS2 driver can remain on
|
||||
PATH after one of its runtime DLLs has broken. Both cases otherwise make the
|
||||
capability monitor say "ready" and turn every snippet into a compile error.
|
||||
POSIX keeps the cheap historical which(1) check; the release hosts there
|
||||
install compiler packages atomically.
|
||||
"""
|
||||
found = [tool for name in candidates if (tool := shutil.which(name))]
|
||||
if sys.platform != "win32":
|
||||
return found[0] if found else None
|
||||
for tool in found:
|
||||
if _probe_compiled_tool(lang, tool):
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def _probe_compiled_tool(lang: str, tool: str) -> bool:
|
||||
"""Compile a minimal known-good program with the runner's real child env."""
|
||||
source = {
|
||||
"c": "int main(void){return 0;}",
|
||||
"cpp": "int main(){return 0;}",
|
||||
"rust": "fn main() {}",
|
||||
}[lang]
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="nexus-toolchain-") as tmp:
|
||||
workdir = Path(tmp)
|
||||
entry = RUN_LANGS[lang]
|
||||
src = workdir / entry["source_name"](source)
|
||||
src.write_text(source, encoding="utf-8")
|
||||
exe = str(workdir / "probe.exe")
|
||||
built = _spawn(
|
||||
entry["compile"](tool, str(src), exe),
|
||||
workdir,
|
||||
_child_env(lang, workdir),
|
||||
COMPILE_TIMEOUT,
|
||||
constrain_memory=False,
|
||||
)
|
||||
return built.returncode == 0 and Path(exe).is_file()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
# The one place that says which languages can be executed. Each entry owns that
|
||||
# language's screening, toolchain probe and argv. The tool schema's `lang` enum,
|
||||
# the capability line in the system prompt and the dispatch below are all derived
|
||||
# from these keys rather than repeating them.
|
||||
#
|
||||
# The frontend keeps a matching registry (RUN_LANGS in
|
||||
# interface/web/src/preview/run-langs.js) because the two sides need different
|
||||
# things per language — this side executes, that side labels and displays — and
|
||||
# neither should depend on the other at runtime. tests/test_tools.py asserts the
|
||||
# key sets stay equal, so drift fails the check gate instead of silently showing
|
||||
# a run result with the wrong language on it.
|
||||
RUN_LANGS: dict[str, dict] = {
|
||||
"python": {
|
||||
"summary": "Python script (stdlib only)",
|
||||
"tool": lambda: sys.executable,
|
||||
"install": "Python is bundled with NexusOS; this should not happen.",
|
||||
"source_name": _source_name("main.py"),
|
||||
# -I is isolated mode: ignores PYTHON* env vars, the user site-packages
|
||||
# dir and the script's own directory on sys.path.
|
||||
"compile": None,
|
||||
"run": lambda tool, src, _exe: [tool, "-I", src],
|
||||
"critique": _critique(_PY_RULES),
|
||||
},
|
||||
"c": {
|
||||
"summary": "single-file C program (C11, libm linked)",
|
||||
"tool": lambda: _compiled_tool("c", ("cc", "gcc", "clang")),
|
||||
"install": "install a C compiler (clang or gcc)",
|
||||
"source_name": _source_name("main.c"),
|
||||
"compile": lambda cc, src, exe: [cc, "-std=c11", "-O0", "-Wall", "-o", exe, src, "-lm"],
|
||||
"run": lambda _tool, _src, exe: [exe],
|
||||
"critique": _critique(_C_RULES, _entry_point(
|
||||
r"\bmain\s*\(", "no main() — a C program needs `int main(void)`.")),
|
||||
},
|
||||
"cpp": {
|
||||
"summary": "single-file C++ program (C++17)",
|
||||
"tool": lambda: _compiled_tool("cpp", ("c++", "g++", "clang++")),
|
||||
"install": "install a C++ compiler (clang++ or g++)",
|
||||
"source_name": _source_name("main.cpp"),
|
||||
"compile": lambda cc, src, exe: [cc, "-std=c++17", "-O0", "-Wall", "-o", exe, src],
|
||||
"run": lambda _tool, _src, exe: [exe],
|
||||
"critique": _critique(_C_RULES, _entry_point(
|
||||
r"\bmain\s*\(", "no main() — a C++ program needs `int main()`.")),
|
||||
},
|
||||
"rust": {
|
||||
"summary": "single-file Rust program (2021 edition, std only)",
|
||||
"tool": lambda: _compiled_tool("rust", ("rustc",)),
|
||||
"install": "install Rust (https://rustup.rs)",
|
||||
"source_name": _source_name("main.rs"),
|
||||
# Debug build: -O roughly triples compile time for snippets that run for
|
||||
# milliseconds either way.
|
||||
"compile": lambda cc, src, exe: [cc, "--edition", "2021", "-o", exe, src],
|
||||
"run": lambda _tool, _src, exe: [exe],
|
||||
"critique": _critique(_RUST_RULES, _entry_point(
|
||||
r"\bfn\s+main\s*\(", "no main() — a Rust program needs `fn main()`.")),
|
||||
},
|
||||
"erlang": {
|
||||
"summary": "escript program with a main/1 entry point",
|
||||
"tool": lambda: shutil.which("escript"),
|
||||
"install": "install Erlang/OTP (provides escript)",
|
||||
"source_name": _erlang_source_name,
|
||||
"compile": None,
|
||||
"run": lambda tool, src, _exe: [tool, src],
|
||||
"critique": _critique(_ERL_RULES, _entry_point(
|
||||
r"\bmain\s*\(", "no main/1 — escript calls `main(Args)`.")),
|
||||
},
|
||||
}
|
||||
|
||||
# Spellings a model reaches for that aren't the registry key. Resolved before
|
||||
# lookup so `c++` and `py` work without doubling the tool schema's enum.
|
||||
ALIASES = {
|
||||
"c++": "cpp", "cc": "c", "py": "python", "python3": "python",
|
||||
"rs": "rust", "erl": "erlang", "escript": "erlang",
|
||||
}
|
||||
|
||||
|
||||
def resolve_lang(lang: str) -> str:
|
||||
key = (lang or "").strip().lower()
|
||||
return ALIASES.get(key, key)
|
||||
|
||||
|
||||
def lang_prose() -> str:
|
||||
"""'python, c or rust' — the runnable languages as a phrase for prompts."""
|
||||
names = list(RUN_LANGS)
|
||||
if len(names) < 2:
|
||||
return names[0] if names else ""
|
||||
return f"{', '.join(names[:-1])} or {names[-1]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Environment variables the child keeps. Everything else is dropped: the snippet
|
||||
# has no business seeing API keys, proxy settings or the user's shell config, and
|
||||
# PYTHON*/LD_* in particular would let ambient config change how it runs.
|
||||
_ENV_KEEP = ("PATH", "LANG", "LC_ALL", "TERM", "SYSTEMROOT", "COMSPEC")
|
||||
|
||||
# rustc is usually a rustup shim, and a shim with HOME rewritten cannot find its
|
||||
# own toolchain. Passing these through is what makes Rust work at all here; they
|
||||
# point at read-only toolchain data, not at anything the snippet should write.
|
||||
_ENV_KEEP_BY_LANG = {"rust": ("RUSTUP_HOME", "CARGO_HOME", "RUSTUP_TOOLCHAIN")}
|
||||
|
||||
|
||||
def _child_env(lang: str, workdir: Path) -> dict:
|
||||
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
|
||||
for k in _ENV_KEEP_BY_LANG.get(lang, ()):
|
||||
if k in os.environ:
|
||||
env[k] = os.environ[k]
|
||||
if lang == "rust" and "RUSTUP_HOME" not in env:
|
||||
# HOME is about to be rewritten, so resolve rustup's default location
|
||||
# against the real home while we still know it.
|
||||
default = Path.home() / ".rustup"
|
||||
if default.is_dir():
|
||||
env["RUSTUP_HOME"] = str(default)
|
||||
env.setdefault("CARGO_HOME", str(Path.home() / ".cargo"))
|
||||
scratch = str(workdir)
|
||||
env["HOME"] = scratch
|
||||
env["TMPDIR"] = scratch
|
||||
# Windows ignores HOME/TMPDIR in its standard path helpers. Without these,
|
||||
# expanduser() reaches the real profile and GetTempPath() falls back to the
|
||||
# Windows directory; GCC and rustc then either escape the scratch directory
|
||||
# or fail because a normal user cannot write there.
|
||||
env["TEMP"] = scratch
|
||||
env["TMP"] = scratch
|
||||
if os.name == "nt":
|
||||
env["USERPROFILE"] = scratch
|
||||
env.setdefault("LC_ALL", "C.UTF-8")
|
||||
return env
|
||||
|
||||
|
||||
def _user_process_count() -> int | None:
|
||||
"""How many processes this UID already owns. None when we cannot count
|
||||
(Windows, or psutil missing) — callers must not invent an absolute cap."""
|
||||
if os.name == "nt" or not hasattr(os, "getuid"):
|
||||
return None
|
||||
try:
|
||||
import psutil # optional; process extra
|
||||
except ImportError:
|
||||
return None
|
||||
uid = os.getuid()
|
||||
n = 0
|
||||
for proc in psutil.process_iter(["uids"]):
|
||||
try:
|
||||
uids = proc.info.get("uids")
|
||||
if uids is not None and getattr(uids, "real", None) == uid:
|
||||
n += 1
|
||||
except (psutil.Error, TypeError, AttributeError):
|
||||
continue
|
||||
return n
|
||||
|
||||
|
||||
def _max_procs_for(lang: str) -> int | None:
|
||||
"""Soft RLIMIT_NPROC for this run, or None to leave the limit unset.
|
||||
|
||||
Erlang: current per-UID count + headroom (RLIMIT_NPROC is UID-scoped).
|
||||
Everything else: the absolute _MAX_PROCS tripwire against fork bombs.
|
||||
"""
|
||||
if lang == "erlang":
|
||||
current = _user_process_count()
|
||||
if current is None:
|
||||
return None
|
||||
return current + _ERLANG_PROC_HEADROOM
|
||||
return _MAX_PROCS
|
||||
|
||||
|
||||
def _limits(constrain_memory: bool, max_procs: int | None = None):
|
||||
"""preexec_fn applying POSIX rlimits, or None where they don't exist.
|
||||
|
||||
RLIMIT_CPU is a backstop for the wall-clock timeout: a snippet that ignores
|
||||
SIGTERM still loses the CPU. The memory and process caps are skipped for
|
||||
compilation — see _MEM_BYTES.
|
||||
|
||||
`max_procs=None` with constrain_memory means "do not set RLIMIT_NPROC"
|
||||
(used when we cannot compute a relative Erlang ceiling). Passing an int
|
||||
always sets it.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return None
|
||||
try:
|
||||
import resource
|
||||
except ImportError: # pragma: no cover - POSIX only
|
||||
return None
|
||||
|
||||
cpu = int(COMPILE_TIMEOUT if not constrain_memory else RUN_TIMEOUT) + 1
|
||||
wanted = [("RLIMIT_CPU", cpu), ("RLIMIT_FSIZE", _MAX_FILE_BYTES), ("RLIMIT_CORE", 0)]
|
||||
if constrain_memory:
|
||||
wanted.append(("RLIMIT_AS", _MEM_BYTES))
|
||||
# Distinguish "caller omitted" (use default) from "explicitly skip"
|
||||
# by requiring the kw to be passed — see _spawn.
|
||||
if max_procs is not None:
|
||||
wanted.append(("RLIMIT_NPROC", max_procs))
|
||||
|
||||
def apply(): # runs in the forked child, between fork and exec
|
||||
# Every limit is set independently and failure is swallowed. Which of
|
||||
# these exist, and which can be lowered, varies by platform (macOS has
|
||||
# no usable RLIMIT_AS, RLIMIT_NPROC is absent on some POSIX systems) —
|
||||
# and an exception raised here does not "skip a limit", it aborts the
|
||||
# spawn entirely. Partial limits are the right failure mode; no run at
|
||||
# all is not.
|
||||
for name, soft in wanted:
|
||||
which = getattr(resource, name, None)
|
||||
if which is None:
|
||||
continue
|
||||
try:
|
||||
_, hard = resource.getrlimit(which)
|
||||
if hard != resource.RLIM_INFINITY:
|
||||
soft = min(soft, hard)
|
||||
resource.setrlimit(which, (soft, hard))
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
|
||||
return apply
|
||||
|
||||
|
||||
_net_isolation_cache: list | None = None
|
||||
|
||||
|
||||
def _net_isolation() -> list[str]:
|
||||
"""argv prefix that drops the child into an empty network namespace, or [].
|
||||
|
||||
Linux only, and only where unprivileged user namespaces are enabled — which
|
||||
is a kernel/distro setting we can't change and shouldn't fail over. Probed
|
||||
once and cached; an empty list means the run simply has host networking, and
|
||||
callers must not treat this as a guarantee either way.
|
||||
"""
|
||||
global _net_isolation_cache
|
||||
if _net_isolation_cache is not None:
|
||||
return _net_isolation_cache
|
||||
_net_isolation_cache = []
|
||||
if sys.platform.startswith("linux") and shutil.which("unshare"):
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["unshare", "-rn", "true"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
_net_isolation_cache = ["unshare", "-rn"]
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return _net_isolation_cache
|
||||
|
||||
|
||||
def _clip(raw: bytes) -> str:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
if len(text) <= MAX_OUTPUT:
|
||||
return text
|
||||
return text[:MAX_OUTPUT] + f"\n... [truncated at {MAX_OUTPUT} characters]"
|
||||
|
||||
|
||||
def _spawn(argv: list[str], workdir: Path, env: dict, timeout: float,
|
||||
stdin: str = "", constrain_memory: bool = True,
|
||||
max_procs: int | None = _MAX_PROCS):
|
||||
"""Spawn a child. `max_procs` defaults to `_MAX_PROCS`; pass `None` to skip
|
||||
RLIMIT_NPROC entirely (Erlang, when a relative ceiling cannot be computed)."""
|
||||
return subprocess.run(
|
||||
argv,
|
||||
cwd=str(workdir),
|
||||
env=env,
|
||||
input=stdin.encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
preexec_fn=_limits(constrain_memory, max_procs=max_procs),
|
||||
)
|
||||
|
||||
|
||||
def run(lang: str, source: str, stdin: str = "") -> dict:
|
||||
"""Compile (if needed) and run `source`. Blocking — call from a thread.
|
||||
|
||||
Returns {ok, lang, stage, exit_code, stdout, stderr, error}. `ok` is False
|
||||
only when the snippet could not be run at all (missing toolchain, compile
|
||||
error, timeout); a program that runs and exits non-zero is a successful run
|
||||
with a non-zero exit_code, because its stderr is the answer the user wants.
|
||||
"""
|
||||
key = resolve_lang(lang)
|
||||
entry = RUN_LANGS.get(key)
|
||||
if entry is None:
|
||||
return {"ok": False, "lang": lang, "stage": "lang",
|
||||
"error": f"cannot run {lang!r} — use {lang_prose()}."}
|
||||
|
||||
tool = entry["tool"]()
|
||||
if not tool:
|
||||
return {"ok": False, "lang": key, "stage": "toolchain",
|
||||
"error": f"no toolchain for {key} on this machine — {entry['install']}. "
|
||||
"Show the code instead of running it."}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="nexus-run-") as tmp:
|
||||
workdir = Path(tmp)
|
||||
body = _erlang_write_source(source) if key == "erlang" else source
|
||||
src = workdir / entry["source_name"](source)
|
||||
src.write_text(body, encoding="utf-8")
|
||||
exe = str(workdir / ("program.exe" if sys.platform == "win32" else "program"))
|
||||
env = _child_env(key, workdir)
|
||||
max_procs = _max_procs_for(key)
|
||||
|
||||
if entry["compile"]:
|
||||
try:
|
||||
built = _spawn(entry["compile"](tool, str(src), exe), workdir, env,
|
||||
COMPILE_TIMEOUT, constrain_memory=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "lang": key, "stage": "compile",
|
||||
"error": f"compilation timed out after {COMPILE_TIMEOUT:g}s."}
|
||||
except OSError as e:
|
||||
return {"ok": False, "lang": key, "stage": "compile",
|
||||
"error": f"could not start the compiler: {e}"}
|
||||
if built.returncode != 0:
|
||||
return {"ok": False, "lang": key, "stage": "compile",
|
||||
"exit_code": built.returncode,
|
||||
"stdout": _clip(built.stdout), "stderr": _clip(built.stderr),
|
||||
"error": "compilation failed — read stderr, fix the source, "
|
||||
"and call run_snippet again."}
|
||||
|
||||
argv = _net_isolation() + entry["run"](tool, str(src), exe)
|
||||
try:
|
||||
done = _spawn(argv, workdir, env, RUN_TIMEOUT, stdin=stdin[:MAX_STDIN],
|
||||
max_procs=max_procs)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return {"ok": False, "lang": key, "stage": "run",
|
||||
"stdout": _clip(e.stdout or b""), "stderr": _clip(e.stderr or b""),
|
||||
"error": f"the program did not finish within {RUN_TIMEOUT:g}s — "
|
||||
"it is probably looping. Bound the work and try again."}
|
||||
except OSError as e:
|
||||
return {"ok": False, "lang": key, "stage": "run",
|
||||
"error": f"could not start the program: {e}"}
|
||||
|
||||
stdout = _clip(done.stdout)
|
||||
stderr = _clip(done.stderr)
|
||||
# BEAM failing to fork under RLIMIT_NPROC looks like a snippet bug if we
|
||||
# report ok=True. Call it out as a runner limit so the model does not keep
|
||||
# rewriting a correct program.
|
||||
if (
|
||||
key == "erlang"
|
||||
and done.returncode != 0
|
||||
and "Resource temporarily unavailable" in stderr
|
||||
):
|
||||
return {
|
||||
"ok": False,
|
||||
"lang": key,
|
||||
"stage": "run",
|
||||
"exit_code": done.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"error": (
|
||||
"Erlang could not start under the process limit (host already has "
|
||||
"many processes for this user). Free some processes and retry, or "
|
||||
"show the code instead of running it."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"lang": key,
|
||||
"stage": "run",
|
||||
"exit_code": done.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
|
||||
def critique(lang: str, source: str) -> list[str]:
|
||||
"""Static screening for one snippet. See the module docstring on what this
|
||||
is worth: a tripwire against habitual network/process reaches, not a
|
||||
boundary."""
|
||||
key = resolve_lang(lang)
|
||||
entry = RUN_LANGS.get(key)
|
||||
if entry is None:
|
||||
return [f"cannot run {lang!r} — use {lang_prose()}."]
|
||||
if len(source) > MAX_SOURCE:
|
||||
return [f"source is over {MAX_SOURCE} characters — send a single focused snippet."]
|
||||
return entry["critique"](source)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
"""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"]
|
||||
+48
-124
@@ -84,19 +84,6 @@ _RENDER_PREAMBLE = (
|
||||
"CSS/JS, no network.\n"
|
||||
)
|
||||
|
||||
# The execution track's hint, on the same terms as the render one: offered only
|
||||
# when the tool behind it is, for the same contamination reason. The distinction
|
||||
# it has to carry is which track a request belongs to — a model that reaches for
|
||||
# run_snippet to "preview" an HTML page gets a compile error, and one that
|
||||
# reaches for render_preview to run a C program gets a plain code block.
|
||||
_RUN_PREAMBLE = (
|
||||
"\n\n---\nCode runner: when the answer depends on what code actually does, call "
|
||||
f"the `run_snippet` tool with a complete {_tools.code_run.lang_prose()} program, "
|
||||
"then paste the returned `fence` into your reply. It really runs, in a throwaway "
|
||||
"directory with no network and a few seconds of CPU. Describe only the output it "
|
||||
"returned.\n"
|
||||
)
|
||||
|
||||
|
||||
_CODING_KEYWORDS = frozenset({
|
||||
"code", "coding", "function", "class", "method", "variable", "bug", "error",
|
||||
@@ -151,8 +138,7 @@ async def _auto_select_model(message: str = "") -> str:
|
||||
if remap:
|
||||
return remap
|
||||
return await get_ollama_manager().select_best_model(intent)
|
||||
except Exception as e:
|
||||
_synapse_trace(f"⚠ auto model selection failed, falling back to default: {e}\n")
|
||||
except Exception:
|
||||
return DEFAULT_CHAT_MODEL
|
||||
|
||||
|
||||
@@ -204,16 +190,13 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
|
||||
if not title:
|
||||
return None
|
||||
return title[:120]
|
||||
except Exception as e:
|
||||
_synapse_trace(f"⚠ title generation failed: {e}\n")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
from .memory.store import store, MemoryItem
|
||||
from .playbooks.store import playbook_store
|
||||
from .curry_store import curry_db # noqa: F401 - import triggers Curry's own preload at startup
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
from .search import needs_web_search, web_search
|
||||
from . import slash_commands as _slash_commands
|
||||
|
||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||
|
||||
@@ -488,47 +471,6 @@ async def _resume_dropped_extractions() -> None:
|
||||
|
||||
# -------------------------
|
||||
# Chat (streaming)
|
||||
async def _slash_command_stream(
|
||||
slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError",
|
||||
conversation_id: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Dispatch a parsed slash-command and stream its result the same shape a
|
||||
normal reply streams in — a single content chunk, `event: done`, nothing
|
||||
else. No model call, no tool-loop, no approval round-trip: see
|
||||
slash_commands.py for why that's the deliberate design here."""
|
||||
if isinstance(slash, _slash_commands.SlashCommandError):
|
||||
yield f"event: error\ndata: {_json.dumps({'detail': slash.text})}\n\n"
|
||||
return
|
||||
|
||||
if slash.tool not in _tools.REGISTRY:
|
||||
yield (
|
||||
"event: error\ndata: "
|
||||
f"{_json.dumps({'detail': f'unknown tool: {slash.tool}'})}\n\n"
|
||||
)
|
||||
return
|
||||
|
||||
yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n"
|
||||
raw_result = await _tools.dispatch(slash.tool, slash.args)
|
||||
|
||||
# Tools that emit a fence (curry_*, edit_*, run_snippet) carry it as
|
||||
# `"fence"` in their JSON result — reuse it verbatim so the existing
|
||||
# nexus-run/nexus-edit renderers pick it up with no new frontend code.
|
||||
# Anything else is shown as pretty-printed JSON.
|
||||
content = raw_result
|
||||
try:
|
||||
parsed = _json.loads(raw_result)
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("fence"), str):
|
||||
content = parsed["fence"]
|
||||
else:
|
||||
content = _json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
store.add_message(conversation_id, "assistant", content)
|
||||
yield f"data: {_json.dumps(content)}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
|
||||
|
||||
# -------------------------
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
@@ -539,37 +481,13 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
_chat_slot_held = True
|
||||
try:
|
||||
message = payload.get("message", "")
|
||||
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||
|
||||
# Direct tool invocation: /tool_name(arg=val, ...). A human typing this
|
||||
# IS the approval, so it skips model selection, RAG/playbook context
|
||||
# assembly, and the ask-policy round-trip entirely — see
|
||||
# slash_commands.py for what it does and does not bypass.
|
||||
_slash = _slash_commands.parse_slash_command(message)
|
||||
if _slash is not None:
|
||||
store.create_conversation(conversation_id, "")
|
||||
store.add_message(conversation_id, "user", message)
|
||||
_slash_inner = _slash_command_stream(_slash, conversation_id)
|
||||
|
||||
async def _slash_guarded() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
async for _chunk in _slash_inner:
|
||||
yield _chunk
|
||||
finally:
|
||||
_CHAT_INFLIGHT.release()
|
||||
|
||||
_chat_slot_held = False
|
||||
return StreamingResponse(_slash_guarded(), media_type="text/event-stream")
|
||||
|
||||
app_settings = store.get_settings()
|
||||
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
||||
_active_pb = playbook_manager.get_main_playbook()
|
||||
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
||||
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
||||
context = payload.get("context", {})
|
||||
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
||||
history = payload.get("history", [])
|
||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
||||
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
||||
@@ -577,6 +495,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||
|
||||
# Resolve the project scope: an existing conversation keeps its bound project;
|
||||
# a brand-new one inherits the current workspace (active_project setting).
|
||||
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
||||
@@ -657,16 +578,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
# is implemented using a tool called render_preview... renders it live in
|
||||
# a sandbox" — this text, recited as fact. A hint for a tool that isn't
|
||||
# being offered is pure contamination.
|
||||
# Read once here rather than at the tools block below: the run-track
|
||||
# hint and the run-track schema have to agree about whether the tool is
|
||||
# on offer, and a second lookup is a second thing to keep in step.
|
||||
_policy = app_settings.get("action_tool_policy", "off")
|
||||
allow_actions = _policy != "off"
|
||||
|
||||
if _tools.wants_render_preview(message):
|
||||
system_prompt = (system_prompt + _RENDER_PREAMBLE) if system_prompt else _RENDER_PREAMBLE.lstrip()
|
||||
if allow_actions and _tools.wants_code_run(message):
|
||||
system_prompt = (system_prompt + _RUN_PREAMBLE) if system_prompt else _RUN_PREAMBLE.lstrip()
|
||||
|
||||
# ── MindTrace pre-flight ──────────────────────────────────────────
|
||||
_trace_intent = _detect_intent(message) if message else "chat"
|
||||
@@ -727,14 +640,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if images:
|
||||
metadata["images"] = images
|
||||
|
||||
# Tools: playbook allowlist (main playbook AND the reference playbooks
|
||||
# _route_playbooks picked for this message — a routed playbook's
|
||||
# instructions are already in the prompt, so its abilities have to come
|
||||
# with them or the model narrates tools it was never given), plus
|
||||
# render_preview/run_snippet on a cue even when no playbook grants them
|
||||
# (always advertising render_preview forced a non-stream tool round on
|
||||
# every chat and felt like "stuck thinking"). _policy/allow_actions were
|
||||
# already computed above, in step with the capability-hint injection.
|
||||
# Tools: playbook allowlist (including routed reference playbooks), plus
|
||||
# render_preview only when this turn looks like a visual ask. Always
|
||||
# advertising it forced a non-stream tool round on every chat and felt
|
||||
# like "stuck thinking".
|
||||
_policy = app_settings.get("action_tool_policy", "off")
|
||||
allow_actions = _policy != "off"
|
||||
_pb_tools = list(dict.fromkeys(
|
||||
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
|
||||
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
|
||||
@@ -743,12 +654,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
||||
for s in _tools.standing_schemas():
|
||||
schemas_by_name[s["function"]["name"]] = s
|
||||
# run_snippet rides the same cue mechanism but stays behind the action
|
||||
# gate: it executes code on this machine. With the policy "off", "run
|
||||
# this" gets an explanation and a code block, never a subprocess.
|
||||
if allow_actions and _tools.wants_code_run(message):
|
||||
for s in _tools.run_schemas():
|
||||
schemas_by_name[s["function"]["name"]] = s
|
||||
for s in _tools.schemas_for(_pb_tools, allow_actions):
|
||||
schemas_by_name[s["function"]["name"]] = s
|
||||
schemas = list(schemas_by_name.values())
|
||||
@@ -838,8 +743,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
||||
if title:
|
||||
store.set_conversation_title(conversation_id, title)
|
||||
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
|
||||
except Exception as e:
|
||||
_synapse_trace(f"⚠ conversation titling step failed: {e}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hand the conversation to the curator once it goes quiet. Not now:
|
||||
# the curator is the chat model, and Ollama runs one request at a
|
||||
@@ -1261,14 +1166,37 @@ def _find_playbook_by_id(playbook_id: str) -> Tuple[Optional[Any], Optional[str]
|
||||
|
||||
|
||||
def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist a playbook dict to the store. Thin wrapper over the shared,
|
||||
mergeable implementation in playbook_manager — this call site always does a
|
||||
full replace (merge=False), matching the frontend form's behavior of always
|
||||
submitting a complete object."""
|
||||
"""
|
||||
Persist a playbook dict to the store by converting to PlaybookItem.
|
||||
"""
|
||||
try:
|
||||
return playbook_manager.persist_playbook(dict(playbook_dict), merge=False)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
||||
# Preserve existing order on update; use provided order (or tail) on create
|
||||
existing = playbook_store.get_playbook(str(playbook_dict["id"]))
|
||||
order = existing.order if existing else playbook_dict.get("order", len(playbook_store.all_playbooks()))
|
||||
|
||||
playbook_item = PlaybookItem(
|
||||
id=str(playbook_dict["id"]),
|
||||
title=playbook_dict.get("title", ""),
|
||||
goal=playbook_dict.get("goal", ""),
|
||||
instructions=playbook_dict.get("instructions", ""),
|
||||
tags=playbook_dict.get("tags", []),
|
||||
tools=playbook_dict.get("tools", []),
|
||||
model=playbook_dict.get("model", ""),
|
||||
order=order
|
||||
)
|
||||
|
||||
playbook_store.add_playbook(playbook_item)
|
||||
|
||||
# Return as dict
|
||||
return {
|
||||
"id": playbook_item.id,
|
||||
"title": playbook_item.title,
|
||||
"goal": playbook_item.goal,
|
||||
"instructions": playbook_item.instructions,
|
||||
"tags": playbook_item.tags,
|
||||
"tools": playbook_item.tools,
|
||||
"model": playbook_item.model,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
||||
|
||||
@@ -1718,14 +1646,10 @@ async def list_icon_apps():
|
||||
@app.get("/icons/image")
|
||||
async def get_icon_image(path: str):
|
||||
"""Serve an icon file after verifying it's in an allowed root."""
|
||||
real = Path(_os.path.realpath(path))
|
||||
allowed = any(
|
||||
real == root or root in real.parents
|
||||
for root in (Path(r).resolve() for r in _ALLOWED_ICON_ROOTS)
|
||||
)
|
||||
if not allowed:
|
||||
real = _os.path.realpath(path)
|
||||
if not any(real.startswith(r) for r in _ALLOWED_ICON_ROOTS):
|
||||
raise HTTPException(status_code=403, detail="Path not allowed")
|
||||
if not real.is_file():
|
||||
if not _os.path.isfile(real):
|
||||
raise HTTPException(status_code=404, detail="Icon not found")
|
||||
return FileResponse(real)
|
||||
|
||||
|
||||
+31
-30
@@ -583,12 +583,25 @@ class PersistentMemoryStore:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
# Drop the embeddings first, while the message ids still resolve -
|
||||
# the delete-side counterpart of _vec_upsert_msg (see its docstring).
|
||||
ids = [r["id"] for r in cur.execute(
|
||||
"SELECT id FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||
).fetchall()]
|
||||
self._delete_message_vectors(conn, ids)
|
||||
# Drop the embeddings first, while the message ids still resolve.
|
||||
# Stale vectors are inert (the search joins messages) but they still
|
||||
# occupy slots in the ANN over-fetch, so leaving them behind quietly
|
||||
# thins recall of the conversations that are still here.
|
||||
cur.execute(
|
||||
"DELETE FROM message_vectors WHERE message_id IN "
|
||||
"(SELECT id FROM messages WHERE conversation_id = ?)",
|
||||
(conversation_id,),
|
||||
)
|
||||
# vec_enabled only says the extension loaded; the virtual table is
|
||||
# created lazily on the first semantic search, so check for it.
|
||||
if self.vec_enabled and cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
|
||||
).fetchone():
|
||||
cur.execute(
|
||||
"DELETE FROM vec_messages WHERE rowid IN "
|
||||
"(SELECT id FROM messages WHERE conversation_id = ?)",
|
||||
(conversation_id,),
|
||||
)
|
||||
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
|
||||
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||
conn.commit()
|
||||
@@ -777,28 +790,6 @@ class PersistentMemoryStore:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _delete_message_vectors(self, conn, message_ids) -> None:
|
||||
"""Drop the cached embeddings of messages that are about to be deleted,
|
||||
mirroring the removal into the ANN index — the delete-side counterpart of
|
||||
`_vec_upsert_msg`. Retrieval already ignores orphans (it inner-joins
|
||||
messages), but `messages.id` is AUTOINCREMENT so a stale vector is never
|
||||
overwritten either: without this the table and index only ever grow."""
|
||||
ids = list(message_ids)
|
||||
if not ids:
|
||||
return
|
||||
for i in range(0, len(ids), 500): # stay under SQLite's variable limit
|
||||
batch = ids[i:i + 500]
|
||||
conn.execute(
|
||||
f"DELETE FROM message_vectors WHERE message_id IN ({','.join('?' * len(batch))})",
|
||||
batch,
|
||||
)
|
||||
if self.vec_enabled:
|
||||
try:
|
||||
for mid in ids:
|
||||
conn.execute("DELETE FROM vec_messages WHERE rowid = ?", (mid,))
|
||||
except Exception:
|
||||
pass # index absent / extension unavailable — it's only a mirror
|
||||
|
||||
def _sweep_orphan_msg_vectors(self, conn) -> None:
|
||||
"""One-time repair for databases written before delete_conversation
|
||||
cleaned up after itself: drop vectors whose message is already gone."""
|
||||
@@ -808,7 +799,17 @@ class PersistentMemoryStore:
|
||||
"LEFT JOIN messages m ON m.id = v.message_id WHERE m.id IS NULL"
|
||||
).fetchall()]
|
||||
if ids:
|
||||
self._delete_message_vectors(conn, ids)
|
||||
conn.execute(
|
||||
"DELETE FROM message_vectors WHERE message_id NOT IN "
|
||||
"(SELECT id FROM messages)"
|
||||
)
|
||||
if self.vec_enabled and conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
|
||||
).fetchone():
|
||||
for message_id in ids:
|
||||
conn.execute(
|
||||
"DELETE FROM vec_messages WHERE rowid = ?", (message_id,)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1239,4 +1240,4 @@ class PersistentMemoryStore:
|
||||
from ..nexus_config import MEMORY_DB
|
||||
|
||||
DB_PATH = MEMORY_DB
|
||||
store = PersistentMemoryStore(DB_PATH)
|
||||
store = PersistentMemoryStore(DB_PATH)
|
||||
|
||||
+5
-14
@@ -160,11 +160,6 @@ SEED_PLAYBOOK_DIR = (
|
||||
|
||||
# --- DATABASE / STORAGE FILES (match your repo) ---
|
||||
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 ---
|
||||
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
||||
@@ -185,7 +180,6 @@ _REQUIRED_DIRS = (
|
||||
UPLOADS_DIR,
|
||||
EXPORTS_DIR,
|
||||
MEMORY_DB.parent,
|
||||
CURRY_DB.parent,
|
||||
)
|
||||
|
||||
|
||||
@@ -323,13 +317,9 @@ class Settings:
|
||||
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
|
||||
))
|
||||
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
|
||||
self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
|
||||
self.api_url: str = str(_value(
|
||||
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
|
||||
)).rstrip("/")
|
||||
self.memory_url: str = str(_value(
|
||||
"memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
|
||||
)).rstrip("/")
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -351,8 +341,6 @@ class Settings:
|
||||
"api_url": self.api_url,
|
||||
"bind_host": self.bind_host,
|
||||
"backend_port": self.backend_port,
|
||||
"memory_port": self.memory_port,
|
||||
"memory_url": self.memory_url,
|
||||
}
|
||||
|
||||
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
|
||||
@@ -373,7 +361,10 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
|
||||
_LOCAL_ORIGINS = [
|
||||
f"http://{h}:{p}"
|
||||
for h in ("localhost", "127.0.0.1")
|
||||
for p in (_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000), 5173)
|
||||
for p in (
|
||||
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
|
||||
5173,
|
||||
)
|
||||
]
|
||||
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
||||
@@ -426,7 +417,7 @@ __all__ = ["Settings", "settings", "path", "VERSION",
|
||||
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
||||
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||
"EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
||||
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
||||
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
||||
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
||||
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
from .playbooks.store import playbook_store, PlaybookItem
|
||||
|
||||
# Fields a caller can supply; anything else in a persist dict is ignored.
|
||||
# `order` is deliberately excluded — see persist_playbook.
|
||||
_EDITABLE_FIELDS = ("title", "goal", "instructions", "tags", "tools", "model")
|
||||
_FIELD_DEFAULTS = {"title": "", "goal": "", "instructions": "", "tags": [], "tools": [], "model": ""}
|
||||
|
||||
|
||||
def persist_playbook(data: dict, *, merge: bool) -> dict:
|
||||
"""Write a playbook dict to the store, returning it as a plain dict.
|
||||
|
||||
`merge=False` is today's behavior (main.py's HTTP form handlers): a full
|
||||
replace, with pydantic defaults for anything omitted. `merge=True` (used by
|
||||
the edit_playbook tool) instead keeps each field's *existing* value when the
|
||||
caller's dict doesn't supply it — a model calling with a partial argument
|
||||
set must not silently blank out the fields it didn't mention.
|
||||
|
||||
`order` is never taken from `data` in merge mode: it is preserved from the
|
||||
existing playbook on update, or appended at the tail on create. Position 0
|
||||
is unconditionally the active system prompt (see get_main_playbook) — moving
|
||||
a playbook there is `make_main`'s job, never an accidental side effect of an
|
||||
ordinary field edit.
|
||||
"""
|
||||
existing_id = str(data.get("id") or "")
|
||||
existing = playbook_store.get_playbook(existing_id) if existing_id else None
|
||||
|
||||
if merge:
|
||||
fields = {}
|
||||
for key in _EDITABLE_FIELDS:
|
||||
if key in data and data[key] is not None:
|
||||
fields[key] = data[key]
|
||||
elif existing is not None:
|
||||
fields[key] = getattr(existing, key)
|
||||
else:
|
||||
fields[key] = _FIELD_DEFAULTS[key]
|
||||
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
|
||||
raise ValueError("title, goal, and instructions are required to create a new playbook")
|
||||
else:
|
||||
fields = {key: data.get(key, _FIELD_DEFAULTS[key]) for key in _EDITABLE_FIELDS}
|
||||
|
||||
order = existing.order if existing else data.get("order", len(playbook_store.all_playbooks()))
|
||||
item = PlaybookItem(id=existing_id or str(uuid4()), order=order, **fields)
|
||||
playbook_store.add_playbook(item)
|
||||
return item.model_dump()
|
||||
|
||||
|
||||
def make_main(playbook_id: str) -> None:
|
||||
"""Reorder so `playbook_id` is position 0 (the active system prompt)."""
|
||||
all_ids = [p.id for p in playbook_store.all_playbooks()]
|
||||
if playbook_id not in all_ids:
|
||||
raise ValueError(f"no playbook with id {playbook_id!r}")
|
||||
ordered = [playbook_id] + [pid for pid in all_ids if pid != playbook_id]
|
||||
playbook_store.reorder_playbooks(ordered)
|
||||
|
||||
|
||||
def _all() -> List[PlaybookItem]:
|
||||
"""Return all playbooks sorted by order (position 0 is always main)."""
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""The assistant's ability to change what it is: its own playbooks, its own
|
||||
runtime settings, and (in a source checkout only) its own source files.
|
||||
|
||||
WHAT THIS IS NOT
|
||||
----------------
|
||||
Not a way to skip human review. Every function here is either read-only
|
||||
(the `preview_*`/`diff_text` functions, safe to call before anything is
|
||||
approved) or is only ever reached after the per-call approval round-trip in
|
||||
chat.py — and `edit_playbook`/`edit_settings`/`edit_source` are *always*
|
||||
gated that way, regardless of the global `action_tool_policy` setting (see
|
||||
`ALWAYS_ASK_TOOLS`). This module is the mechanism the approval actually acts
|
||||
on, layered:
|
||||
|
||||
1. Consent edit_* tools are ACTION tools with a hardcoded approval
|
||||
floor — chat.py pauses for Approve/Deny even when the
|
||||
global policy is "allow", so flipping that setting for an
|
||||
unrelated tool (web_search, say) can never silently unlock
|
||||
unattended self-modification too.
|
||||
2. Real diff the human reviews a diff computed here, server-side, from
|
||||
what is actually on disk versus the model's proposed
|
||||
`new_content` — never a diff or description the model wrote
|
||||
itself. A model can misdescribe a change; it cannot make
|
||||
difflib misreport one.
|
||||
3. Boundary edit_source is confined to one root (settings.project_root)
|
||||
via the same realpath + `Path.parents` check that closed a
|
||||
sibling-directory bypass in main.py's /icons/image, plus a
|
||||
denylist of dangerous subtrees inside that root (.git, the
|
||||
venv, node_modules, build output, runtime state).
|
||||
4. Audit trail every applied source edit is committed to git (best-effort;
|
||||
a missing git binary or a non-repo root degrades the result
|
||||
to `commit: None`, it never blocks the write). This is a
|
||||
reversibility net, not a substitute for layer 1 — the
|
||||
approval already happened before anything is written.
|
||||
5. Size caps MAX_FILE_CHARS/MAX_DIFF_CHARS bound what a single call can
|
||||
submit or what the approval UI has to render, mirroring
|
||||
code_run.py's MAX_SOURCE/MAX_OUTPUT.
|
||||
|
||||
A file edit does not hot-reload the running process. Python does not re-import
|
||||
a changed module on its own, and the frontend's production build is the static
|
||||
`dist/` the backend serves — editing interface/web/src/*.jsx only affects a
|
||||
developer's own `npm run dev` session, if one happens to be running. Every
|
||||
successful edit_source result says so explicitly, because both the model and
|
||||
the human reviewing it will otherwise reasonably expect an instant effect that
|
||||
does not happen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .memory.store import store
|
||||
from .nexus_config import settings
|
||||
from .playbooks.store import playbook_store
|
||||
from . import playbook_manager
|
||||
|
||||
MAX_FILE_CHARS = 200_000 # chars of proposed file content accepted
|
||||
MAX_DIFF_CHARS = 20_000 # chars of diff text shown/returned, clipped like code_run.MAX_OUTPUT
|
||||
|
||||
# Subdirectories of settings.project_root that edit_source must never touch,
|
||||
# even though they're inside the one allowed root. `data`/`runtime` land here
|
||||
# in a source checkout (see nexus_config.py DATA_DIR/RUNTIME_DIR) and are owned
|
||||
# by their own tools (edit_settings, the memory store), not raw file writes.
|
||||
_DENYLIST_SUBDIRS = frozenset({
|
||||
".git", "Promethean", "node_modules", "dist", "__pycache__",
|
||||
"runtime", "data", ".venv", "venv",
|
||||
})
|
||||
|
||||
# Tools that must always pause for human approval, regardless of the global
|
||||
# action_tool_policy setting. Shared by tools.py (ACTION_TOOLS membership) and
|
||||
# chat.py (the approval-gating condition) so there is one definition of the
|
||||
# floor, not two that could drift apart.
|
||||
ALWAYS_ASK_TOOLS = frozenset({"edit_playbook", "edit_settings", "edit_source"})
|
||||
|
||||
# Tool names whose approval payload gets a computed, human-readable preview
|
||||
# attached before the human ever sees the Approve/Deny prompt.
|
||||
PREVIEWABLE = frozenset({"edit_source", "edit_playbook", "edit_settings"})
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if WINDOWS else 0
|
||||
|
||||
|
||||
class PathError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_source_path(rel_path: str) -> Path:
|
||||
"""A path the model gave us, resolved and boundary-checked against
|
||||
settings.project_root. Raises PathError with a human-readable reason on
|
||||
any rejection — the same message is shown in the pre-approval preview and
|
||||
returned as the tool's error, so both audiences see exactly why."""
|
||||
raw = (rel_path or "").strip()
|
||||
if not raw:
|
||||
raise PathError("path is required")
|
||||
# Cheap rejection before ever touching the filesystem: an absolute path or
|
||||
# a Windows drive prefix is never a legitimate "file in this project" path.
|
||||
if raw.startswith(("/", "\\")) or (len(raw) > 1 and raw[1] == ":"):
|
||||
raise PathError(f"path must be relative to the project root, not absolute: {raw!r}")
|
||||
|
||||
root = Path(os.path.realpath(str(settings.project_root)))
|
||||
candidate = root / raw
|
||||
real = Path(os.path.realpath(str(candidate)))
|
||||
|
||||
# Same real == root or root in real.parents pattern as
|
||||
# icons/compositor.py::_is_allowed_path — a bare str.startswith() here
|
||||
# would let a sibling directory that merely shares a prefix pass, exactly
|
||||
# the bug just fixed in main.py's /icons/image.
|
||||
if not (real == root or root in real.parents):
|
||||
raise PathError(f"path escapes the project root: {raw!r}")
|
||||
|
||||
try:
|
||||
top = real.relative_to(root).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
top = ""
|
||||
if top in _DENYLIST_SUBDIRS:
|
||||
raise PathError(f"{top}/ is off-limits to edit_source — use its own tool if there is one")
|
||||
|
||||
return real
|
||||
|
||||
|
||||
def source_checkout_required() -> None:
|
||||
"""Raise if this install has no live, editable source tree to write into.
|
||||
|
||||
In a wheel install, synapse/ lives inside site-packages with no sibling
|
||||
repo — settings.project_root would just be the installed package dir, and
|
||||
there is nothing to commit into. Same precedent as nexusos_cli/ncp.py
|
||||
refusing to start the Vite dev server in a wheel install."""
|
||||
if not settings.source_checkout:
|
||||
raise RuntimeError(
|
||||
"edit_source is unavailable — this is not a source checkout, so "
|
||||
"there is no live project tree to edit or commit into."
|
||||
)
|
||||
|
||||
|
||||
def diff_text(path_display: str, before: str, after: str) -> str:
|
||||
lines = difflib.unified_diff(
|
||||
before.splitlines(keepends=True),
|
||||
after.splitlines(keepends=True),
|
||||
fromfile=path_display,
|
||||
tofile=path_display,
|
||||
)
|
||||
text = "".join(lines)
|
||||
if len(text) <= MAX_DIFF_CHARS:
|
||||
return text
|
||||
return text[:MAX_DIFF_CHARS] + f"\n... [truncated at {MAX_DIFF_CHARS} characters, {len(text)} total]"
|
||||
|
||||
|
||||
def preview_source_edit(path: str, new_content: str) -> dict:
|
||||
"""Read-only: never raises. Computes the real diff between what's on disk
|
||||
and the proposed new_content, so the approval UI shows ground truth before
|
||||
anything is written. Degrades to {"ok": False, "error": ...} on any
|
||||
rejection (bad path, wheel install, oversized content) rather than
|
||||
crashing the approval payload — the human still sees why it would fail."""
|
||||
try:
|
||||
source_checkout_required()
|
||||
real = _resolve_source_path(path)
|
||||
new_content = new_content or ""
|
||||
if len(new_content) > MAX_FILE_CHARS:
|
||||
return {"ok": False, "error": f"new_content is over {MAX_FILE_CHARS} characters"}
|
||||
before = real.read_text(encoding="utf-8") if real.is_file() else ""
|
||||
display = str(real.relative_to(Path(os.path.realpath(str(settings.project_root)))))
|
||||
return {
|
||||
"ok": True,
|
||||
"path": display,
|
||||
"is_new_file": not real.is_file(),
|
||||
"diff": diff_text(display, before, new_content),
|
||||
}
|
||||
except (PathError, RuntimeError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"could not compute preview: {e}"}
|
||||
|
||||
|
||||
def _git_commit(real_path: Path, summary: str) -> str | None:
|
||||
"""Best-effort audit-trail commit. Never raises, never undoes the write
|
||||
that already happened — a missing git binary or a project root that isn't
|
||||
a repo just means commit stays None."""
|
||||
root = str(settings.project_root)
|
||||
try:
|
||||
rel = str(real_path.relative_to(Path(os.path.realpath(root))))
|
||||
message = f"self-edit: {(summary or rel)[:180]}"
|
||||
subprocess.run(
|
||||
["git", "add", "--", rel], cwd=root, check=True,
|
||||
capture_output=True, creationflags=_NO_WINDOW,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", message, "--", rel], cwd=root, check=True,
|
||||
capture_output=True, creationflags=_NO_WINDOW,
|
||||
)
|
||||
sha = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"], cwd=root, check=True,
|
||||
capture_output=True, text=True, creationflags=_NO_WINDOW,
|
||||
)
|
||||
return sha.stdout.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def apply_source_edit(path: str, new_content: str, summary: str = "") -> dict:
|
||||
"""Write an approved edit_source call. Re-validates everything preview did
|
||||
— never trust that nothing changed between preview and approval — then
|
||||
writes, diffs against the pre-write content, and commits."""
|
||||
try:
|
||||
source_checkout_required()
|
||||
real = _resolve_source_path(path)
|
||||
new_content = new_content or ""
|
||||
if len(new_content) > MAX_FILE_CHARS:
|
||||
return {"ok": False, "error": f"new_content is over {MAX_FILE_CHARS} characters"}
|
||||
except (PathError, RuntimeError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
before = real.read_text(encoding="utf-8") if real.is_file() else ""
|
||||
display = str(real.relative_to(Path(os.path.realpath(str(settings.project_root)))))
|
||||
real.parent.mkdir(parents=True, exist_ok=True)
|
||||
real.write_text(new_content, encoding="utf-8")
|
||||
return {
|
||||
"ok": True,
|
||||
"path": display,
|
||||
"diff": diff_text(display, before, new_content),
|
||||
"commit": _git_commit(real, summary),
|
||||
}
|
||||
|
||||
|
||||
def preview_playbook_edit(args: dict) -> dict:
|
||||
"""Read-only before/after preview for edit_playbook. Mirrors the merge
|
||||
semantics of playbook_manager.persist_playbook(merge=True) without writing
|
||||
anything, so the approval UI shows exactly what will actually change."""
|
||||
try:
|
||||
pb_id = str(args.get("id") or "")
|
||||
existing = playbook_store.get_playbook(pb_id) if pb_id else None
|
||||
make_active = bool(args.get("make_active"))
|
||||
fields = {}
|
||||
for key in ("title", "goal", "instructions", "tags", "tools", "model"):
|
||||
if key in args and args[key] is not None:
|
||||
fields[key] = args[key]
|
||||
elif existing is not None:
|
||||
fields[key] = getattr(existing, key)
|
||||
else:
|
||||
fields[key] = [] if key in ("tags", "tools") else ""
|
||||
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
|
||||
return {"ok": False, "error": "title, goal, and instructions are required to create a new playbook"}
|
||||
is_new = existing is None
|
||||
becomes_main = make_active or (is_new and not playbook_store.all_playbooks())
|
||||
return {
|
||||
"ok": True,
|
||||
"is_new": is_new,
|
||||
"before": existing.model_dump() if existing else None,
|
||||
"after": fields,
|
||||
"becomes_main_playbook": becomes_main,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"could not compute preview: {e}"}
|
||||
|
||||
|
||||
def preview_settings_edit(args: dict) -> dict:
|
||||
"""Read-only before/after preview for edit_settings, split into keys that
|
||||
will actually apply versus ones update_settings would silently ignore —
|
||||
the approval UI must never imply an unknown key will take effect."""
|
||||
try:
|
||||
changes = args.get("changes") or {}
|
||||
current = store.get_settings()
|
||||
applied: dict[str, dict[str, Any]] = {}
|
||||
ignored_unknown: list[str] = []
|
||||
for key, value in changes.items():
|
||||
if key in store._SETTINGS_DEFAULTS:
|
||||
applied[key] = {"before": current.get(key), "after": value}
|
||||
else:
|
||||
ignored_unknown.append(key)
|
||||
return {
|
||||
"ok": True,
|
||||
"applied": applied,
|
||||
"ignored_unknown": ignored_unknown,
|
||||
"policy_change": "action_tool_policy" in applied,
|
||||
"system_prompt_change": "system_prompt" in applied,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"could not compute preview: {e}"}
|
||||
|
||||
|
||||
def preview_for(name: str, args: dict) -> dict:
|
||||
"""Single dispatch entry point chat.py calls to enrich an approval payload."""
|
||||
if name == "edit_source":
|
||||
return preview_source_edit(args.get("path", ""), args.get("new_content", ""))
|
||||
if name == "edit_playbook":
|
||||
return preview_playbook_edit(args)
|
||||
if name == "edit_settings":
|
||||
return preview_settings_edit(args)
|
||||
return {"ok": False, "error": f"no previewer for {name}"}
|
||||
@@ -1,94 +0,0 @@
|
||||
"""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)
|
||||
+15
-822
@@ -3,30 +3,16 @@
|
||||
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||
`message.tool_calls`, and this module is just the registry + dispatch.
|
||||
|
||||
Most tools READ local state (memory, history, documents, models). Some act:
|
||||
`web_search`/`fetch_url` make outbound HTTP requests, `remember` WRITES a
|
||||
memory fact, `edit_playbook`/`edit_settings`/`edit_source` change the
|
||||
assistant's own playbooks, settings, and (source checkout only) source code
|
||||
(see synapse/self_edit.py), and `curry_*` reads and writes NexusOS's vendored
|
||||
Curry ledger (see synapse/curry_store.py) — immutable versioned constants and
|
||||
functions, with `curry_call_function` executing a previously declared one.
|
||||
The per-playbook allowlist (`PlaybookItem.tools`) is the first gate — an
|
||||
action tool only fires when a playbook explicitly lists it — and the
|
||||
highest-risk tools in both families additionally always pause for per-call
|
||||
approval regardless of the global action_tool_policy
|
||||
(ALWAYS_ASK_ACTION_TOOLS, below). A message that's nothing but
|
||||
`/tool_name(arg=val, ...)` skips that approval round-trip entirely and
|
||||
dispatches directly — see synapse/slash_commands.py for why that's safe.
|
||||
Most tools READ local state (memory, history, documents, models). A few act:
|
||||
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
||||
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
||||
boundary — an action tool only fires when a playbook explicitly lists it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from . import code_run
|
||||
from . import playbook_manager
|
||||
from .curry_store import curry_db
|
||||
from . import self_edit
|
||||
from .memory.store import store, MemoryItem
|
||||
from .ollama_manager import get_ollama_manager
|
||||
|
||||
@@ -172,9 +158,7 @@ async def _remember(text: str = "", section: str = "General", **_) -> str:
|
||||
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
|
||||
# Paths never leave the repo: every request is resolve()d and checked against
|
||||
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
|
||||
# the tree that are either secrets, private data, or multi-GB noise. Read-only
|
||||
# counterpart to edit_source's write path (self_edit.py) — a model reading
|
||||
# real source before proposing an edit is the point.
|
||||
# the tree that are either secrets, private data, or multi-GB noise.
|
||||
_DENIED = {
|
||||
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
||||
"runtime", "dist", "__pycache__", ".git-credentials",
|
||||
@@ -222,15 +206,10 @@ async def _list_files(pattern: str = "", **_) -> str:
|
||||
for f in PROJECT_ROOT.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
rel_posix = f.relative_to(PROJECT_ROOT).as_posix()
|
||||
target, err = _repo_path(rel_posix)
|
||||
target, err = _repo_path(str(f.relative_to(PROJECT_ROOT)))
|
||||
if err:
|
||||
continue
|
||||
# .as_posix(), not str(): a bare str() gives backslash-separated paths
|
||||
# on Windows, which don't match the forward-slash patterns this tool's
|
||||
# own schema documents (e.g. "synapse/**/*.py") and that read_file
|
||||
# expects back.
|
||||
hits.append(rel_posix)
|
||||
hits.append(str(f.relative_to(PROJECT_ROOT)))
|
||||
if len(hits) >= 200:
|
||||
break
|
||||
return json.dumps(sorted(hits))
|
||||
@@ -241,9 +220,9 @@ async def _list_files(pattern: str = "", **_) -> str:
|
||||
# from these keys rather than repeated.
|
||||
#
|
||||
# The frontend keeps its own matching registry (PREVIEW_LANGS in
|
||||
# interface/web/src/Markdown.jsx) because the two sides need different things per
|
||||
# language - this side describes them, that side renders them - and neither
|
||||
# should depend on the other at runtime. tests/test_tools.py asserts the key sets
|
||||
# interface/web/src/preview/languages.js) because the two sides need different
|
||||
# things per language - this side describes them, that side renders them - and
|
||||
# neither should depend on the other at runtime. tests/test_tools.py asserts the key sets
|
||||
# stay equal, so drift fails the check gate instead of silently degrading to a
|
||||
# plain code block in the chat.
|
||||
PREVIEW_LANGS: dict[str, dict] = {
|
||||
@@ -297,348 +276,6 @@ async def _render_preview(
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_snippet — the execution track
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# render_preview and run_snippet are deliberately separate tools over separate
|
||||
# registries. A preview is packaged here and parsed/rendered by the browser
|
||||
# inside a sandboxed frame; a snippet is *executed* on the host by
|
||||
# synapse/code_run.py and
|
||||
# comes back as terminal output. Stretching one tool across both would have meant
|
||||
# a `lang` enum where half the values run server-side and half don't, and a
|
||||
# single description that could only be vague about which. The split is the
|
||||
# feature: the model picks a track by picking a tool.
|
||||
#
|
||||
# Result fence: one ```nexus-run block whose body is JSON (source + streams +
|
||||
# exit code), so the code and the output it produced cannot be separated by a
|
||||
# model pasting only half of it. Backticks in the source are re-encoded as \u0060
|
||||
# — still valid JSON, and it cannot terminate the fence early.
|
||||
_RUN_FENCE_LANG = "nexus-run"
|
||||
|
||||
|
||||
def _run_fence(payload: dict) -> str:
|
||||
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
|
||||
return f"```{_RUN_FENCE_LANG}\n{body}\n```"
|
||||
|
||||
|
||||
def _run_lang_prose() -> str:
|
||||
return code_run.lang_prose()
|
||||
|
||||
|
||||
def _run_scaffold_for(lang: str) -> str:
|
||||
"""Minimal entry-point patterns for a struggling model. Shown from the
|
||||
second host-code rejection on."""
|
||||
return {
|
||||
"python": "print(sum(range(10)))\n",
|
||||
"c": (
|
||||
"#include <stdio.h>\n"
|
||||
"int main(void) {\n"
|
||||
' printf("%d\\n", 42);\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
"cpp": (
|
||||
"#include <iostream>\n"
|
||||
"int main() {\n"
|
||||
' std::cout << 42 << "\\n";\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
"rust": 'fn main() { println!("{}", 42); }\n',
|
||||
"erlang": 'main(_) -> io:format("~p~n", [42]).\n',
|
||||
}.get(lang, f"(a short self-contained {lang} program that prints to stdout)\n")
|
||||
|
||||
|
||||
def _with_run_scaffold(payload: dict, lang: str, attempt: int) -> dict:
|
||||
"""Attach a starting pattern from the second rejection on. First rejection
|
||||
stays issues-only so a pasteable scaffold does not become the answer."""
|
||||
if attempt < 1:
|
||||
return payload
|
||||
return {
|
||||
**payload,
|
||||
"scaffold": _run_scaffold_for(lang),
|
||||
"scaffold_note": (
|
||||
"A pattern to adapt, not an answer to paste. Keep the entry point, "
|
||||
"print results to stdout, and drop anything you do not use."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _run_snippet(
|
||||
lang: str = "",
|
||||
source: str = "",
|
||||
stdin: str = "",
|
||||
title: str = "",
|
||||
_attempt: int = 0,
|
||||
**_,
|
||||
) -> str:
|
||||
"""Compile and run a snippet, returning a fence that carries both the source
|
||||
and what it printed.
|
||||
|
||||
ACTION tool: it executes code on this machine. `action_tool_policy` gates it
|
||||
(withheld on "off", per-call Approve/Deny on "ask"), and synapse/code_run.py
|
||||
documents exactly how much containment the run itself gets — which is less
|
||||
than the word "sandbox" would imply.
|
||||
|
||||
`_attempt` is supplied by the tool loop, not by the model — it is how many
|
||||
times this call has already been rejected in the current turn."""
|
||||
import asyncio as _a
|
||||
|
||||
key = code_run.resolve_lang(lang)
|
||||
source = (source or "").strip()
|
||||
title = (title or "").strip()
|
||||
|
||||
if key not in code_run.RUN_LANGS:
|
||||
return json.dumps({
|
||||
"ok": False,
|
||||
"error": f"lang must be {_run_lang_prose()} (got {lang!r}). "
|
||||
"For HTML, SVG or JSX use render_preview instead — those are "
|
||||
"rendered in the browser, not executed here.",
|
||||
})
|
||||
if not source:
|
||||
return json.dumps(_with_run_scaffold({
|
||||
"ok": False,
|
||||
"error": f"source is required — send the complete {key} program, "
|
||||
"entry point included.",
|
||||
}, key, _attempt))
|
||||
|
||||
issues = code_run.critique(key, source)
|
||||
if issues:
|
||||
return json.dumps(_with_run_scaffold({
|
||||
"ok": False,
|
||||
"issues": issues,
|
||||
"hint": (
|
||||
"Fix these and call run_snippet again. The program runs in a throwaway "
|
||||
"directory with no network and a few seconds of CPU: no downloads, no "
|
||||
"absolute paths, no unbounded loops. Print your results to stdout."
|
||||
),
|
||||
}, key, _attempt))
|
||||
|
||||
result = await _a.to_thread(code_run.run, key, source, stdin or "")
|
||||
if not result.get("ok"):
|
||||
# A failed compile hands back the compiler's own diagnostics: they name
|
||||
# the line and the fix, and paraphrasing them here would only lose that.
|
||||
return json.dumps({k: v for k, v in result.items() if v not in ("", None)})
|
||||
|
||||
return json.dumps({
|
||||
"ok": True,
|
||||
"lang": key,
|
||||
"exit_code": result["exit_code"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
"title": title or f"{key} output",
|
||||
"instruction": (
|
||||
"This ran for real — the output below is what it printed. Write a short "
|
||||
"intro, then paste the fenced block exactly as it is. Do not wrap it in a "
|
||||
"second fence, retype the output, or claim results it does not show."
|
||||
),
|
||||
"fence": _run_fence({
|
||||
"lang": key,
|
||||
"source": source,
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
"exit_code": result["exit_code"],
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
# Result fence for the three self-edit tools: same "carry the change and what
|
||||
# happened to it in one block" idea as _run_fence, so the model can't paste a
|
||||
# claimed diff that doesn't match what was actually applied.
|
||||
_EDIT_FENCE_LANG = "nexus-edit"
|
||||
|
||||
|
||||
def _edit_fence(payload: dict) -> str:
|
||||
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
|
||||
return f"```{_EDIT_FENCE_LANG}\n{body}\n```"
|
||||
|
||||
|
||||
async def _edit_source(path: str = "", new_content: str = "", summary: str = "", **_) -> str:
|
||||
"""ACTION tool: writes a file in the live project tree and commits it. See
|
||||
synapse/self_edit.py for the boundary check, the size cap, and exactly what
|
||||
the git commit does and doesn't guarantee."""
|
||||
import asyncio as _a
|
||||
|
||||
result = await _a.to_thread(self_edit.apply_source_edit, path, new_content or "", summary or "")
|
||||
if result.get("ok"):
|
||||
result["instruction"] = (
|
||||
"This was written and committed for real. Paste the fence unchanged, then "
|
||||
"tell the user plainly that a restart is needed for it to take effect — "
|
||||
"this file is not reloaded into the running process."
|
||||
)
|
||||
result["fence"] = _edit_fence({"kind": "source", **result})
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
async def _edit_playbook(
|
||||
id: str = "", title: str = "", goal: str = "", instructions: str = "",
|
||||
tags: list | None = None, tools: list | None = None, model: str = "",
|
||||
make_active: bool = False, **_,
|
||||
) -> str:
|
||||
"""ACTION tool: create or update a playbook. Fields left unset keep their
|
||||
current value — this merges, it does not replace. make_active is a
|
||||
separate, explicit flag: without it, an edit can never accidentally become
|
||||
the active system prompt."""
|
||||
try:
|
||||
result = playbook_manager.persist_playbook(
|
||||
{
|
||||
"id": id, "title": title, "goal": goal, "instructions": instructions,
|
||||
"tags": tags, "tools": tools, "model": model,
|
||||
},
|
||||
merge=True,
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"ok": False, "error": str(e)})
|
||||
if make_active:
|
||||
playbook_manager.make_main(result["id"])
|
||||
result["is_main_playbook"] = True
|
||||
result["ok"] = True
|
||||
result["fence"] = _edit_fence({"kind": "playbook", **result})
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
async def _edit_settings(changes: dict | None = None, **_) -> str:
|
||||
"""ACTION tool: change one or more runtime settings. Unknown keys are
|
||||
silently ignored, exactly like PUT /settings already does."""
|
||||
changes = changes if isinstance(changes, dict) else {}
|
||||
if not changes:
|
||||
return json.dumps({"ok": False, "error": "changes must be a non-empty object"})
|
||||
preview = self_edit.preview_settings_edit({"changes": changes})
|
||||
if not preview.get("ok"):
|
||||
return json.dumps(preview)
|
||||
if not preview.get("applied"):
|
||||
return json.dumps({
|
||||
"ok": False,
|
||||
"error": "no recognized settings keys in changes",
|
||||
"ignored_unknown": preview.get("ignored_unknown", []),
|
||||
})
|
||||
store.update_settings({k: v["after"] for k, v in preview["applied"].items()})
|
||||
preview["ok"] = True
|
||||
preview["fence"] = _edit_fence({"kind": "settings", **preview})
|
||||
return json.dumps(preview)
|
||||
|
||||
|
||||
# Curry (synapse/curry_core.py, vendored) — NexusOS's immutable, versioned
|
||||
# fact store. Its own methods raise (KeyError/ValueError/TypeError/RuntimeError)
|
||||
# on the failures a caller should see as a normal, expected result rather than
|
||||
# a crash (unknown id, version conflict, retired reference, etc.) — dispatch()
|
||||
# would already catch anything unhandled, but that produces a generic
|
||||
# "toolname failed: ..." string instead of the {"ok": False, "error": ...}
|
||||
# shape every other tool in this file returns, so it's caught locally here too.
|
||||
_CURRY_FENCE_LANG = "nexus-curry"
|
||||
|
||||
|
||||
def _curry_fence(payload: dict) -> str:
|
||||
body = json.dumps(payload, ensure_ascii=False, default=str).replace("`", "\\u0060")
|
||||
return f"```{_CURRY_FENCE_LANG}\n{body}\n```"
|
||||
|
||||
|
||||
async def _curry_call(fn, *args, **kwargs) -> dict:
|
||||
# Not run.to_thread()'d like run_snippet/self_edit's blocking work: curry_db
|
||||
# holds one sqlite3 connection for its whole lifetime (unlike
|
||||
# PersistentMemoryStore, which opens/closes a fresh one per call), and
|
||||
# sqlite3 forbids using a connection from any thread but the one that
|
||||
# created it. curry_db is created at import time on the same thread this
|
||||
# runs on (the asyncio event loop thread), so calling it directly here is
|
||||
# both correct and, for local-file SQLite, fast enough not to need
|
||||
# offloading anyway.
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
return {"ok": True, "result": result}
|
||||
except (KeyError, ValueError, TypeError, RuntimeError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _curry_declare_constant(
|
||||
id: str = "", version: int = 0, value=None, type_signature: str = "",
|
||||
description: str = "", **_,
|
||||
) -> str:
|
||||
"""ACTION tool: declare a new, immutable version of a named constant."""
|
||||
out = await _curry_call(
|
||||
curry_db.declare_constant, id, version, value, type_signature, description or None
|
||||
)
|
||||
if out["ok"]:
|
||||
out = {"ok": True, "id": id, "version": version}
|
||||
out["fence"] = _curry_fence({"kind": "declare_constant", **out})
|
||||
return json.dumps(out)
|
||||
|
||||
|
||||
async def _curry_get_constant(id: str = "", version: int = 0, **_) -> str:
|
||||
"""Retrieve a constant by exact id and version."""
|
||||
return json.dumps(await _curry_call(curry_db.get_constant, id, version))
|
||||
|
||||
|
||||
async def _curry_get_constant_latest(id: str = "", **_) -> str:
|
||||
"""Retrieve the most recent active (non-retired) version of a constant."""
|
||||
return json.dumps(await _curry_call(curry_db.get_constant_latest, id))
|
||||
|
||||
|
||||
async def _curry_list_constants(active_only: bool = True, **_) -> str:
|
||||
"""List all declared constants and their latest versions."""
|
||||
return json.dumps(await _curry_call(curry_db.list_constants, active_only))
|
||||
|
||||
|
||||
async def _curry_retire_constant(id: str = "", version: int = 0, reason: str = "", **_) -> str:
|
||||
"""ACTION tool: retire (tombstone) a constant version. Does not delete it —
|
||||
the version stays readable by exact id+version, just excluded from
|
||||
"latest" lookups and blocked from new declarations that depend on it."""
|
||||
out = await _curry_call(
|
||||
curry_db.retire_constant_with_reason, id, version, reason or "retired via tool call"
|
||||
)
|
||||
return json.dumps(out)
|
||||
|
||||
|
||||
async def _curry_declare_function(
|
||||
name: str = "", version: int = 0, body: str = "",
|
||||
constant_bindings: dict | None = None, function_bindings: dict | None = None,
|
||||
is_pure: bool = False, expected_args: list | None = None,
|
||||
description: str = "", arg_descriptions: dict | None = None, **_,
|
||||
) -> str:
|
||||
"""ACTION tool: declare a new, immutable version of a named function. Body
|
||||
is a single Python expression (no statements) over stdlib-only builtins,
|
||||
checked by curry_core.py's own static validator before this ever runs —
|
||||
but that validator is a tripwire against habitual mistakes, not a
|
||||
security boundary; treat it the same as run_snippet's containment."""
|
||||
out = await _curry_call(
|
||||
curry_db.declare_function, name, version, body,
|
||||
constant_bindings or {}, function_bindings or {}, is_pure,
|
||||
expected_args, description or None, arg_descriptions,
|
||||
)
|
||||
if out["ok"]:
|
||||
out = {"ok": True, "name": name, "version": version}
|
||||
out["fence"] = _curry_fence({"kind": "declare_function", **out})
|
||||
return json.dumps(out)
|
||||
|
||||
|
||||
async def _curry_get_function(name: str = "", version: int = 0, **_) -> str:
|
||||
"""Retrieve a function definition by exact name and version."""
|
||||
return json.dumps(await _curry_call(curry_db.get_function, name, version))
|
||||
|
||||
|
||||
async def _curry_list_functions(active_only: bool = True, **_) -> str:
|
||||
"""List all declared functions and their latest versions."""
|
||||
return json.dumps(await _curry_call(curry_db.list_functions, active_only))
|
||||
|
||||
|
||||
async def _curry_call_function(name: str = "", version: int = 0, args: dict | None = None, **_) -> str:
|
||||
"""ACTION tool: execute a previously declared function version with the
|
||||
given runtime arguments. Locked constant/function dependencies resolve
|
||||
automatically; pure functions are memoized."""
|
||||
out = await _curry_call(curry_db.call_function, name, version, args or {})
|
||||
if out["ok"]:
|
||||
out["fence"] = _curry_fence({"kind": "call_function", "name": name, "version": version, **out})
|
||||
return json.dumps(out)
|
||||
|
||||
|
||||
async def _curry_retire_function(name: str = "", version: int = 0, reason: str = "", **_) -> str:
|
||||
"""ACTION tool: retire (tombstone) a function version. Does not delete it."""
|
||||
out = await _curry_call(
|
||||
curry_db.retire_function_with_reason, name, version, reason or "retired via tool call"
|
||||
)
|
||||
return json.dumps(out)
|
||||
|
||||
|
||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
"search_memory": (
|
||||
@@ -792,62 +429,6 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_render_preview,
|
||||
),
|
||||
"run_snippet": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_snippet",
|
||||
# Same house style as render_preview: imperative, second person,
|
||||
# nothing the model can recite back in place of acting.
|
||||
"description": (
|
||||
f"Compile and run a short {code_run.lang_prose()} program on this "
|
||||
"machine and get its real output back. Use this when the answer "
|
||||
"depends on what the code actually does — output, a computed "
|
||||
"result, whether it compiles. Write one self-contained file with "
|
||||
"its entry point; there is no package manager, no network, a "
|
||||
f"throwaway working directory and {code_run.RUN_TIMEOUT:g}s of "
|
||||
"runtime, so bound your loops and print results to stdout. "
|
||||
"For HTML, SVG or JSX use render_preview instead. "
|
||||
"Rejected: fix what `issues` or `stderr` says and send it again. "
|
||||
"Accepted: paste the returned `fence` into your reply unchanged, "
|
||||
"and describe only the output it actually contains."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"enum": list(code_run.RUN_LANGS),
|
||||
"description": (
|
||||
"Language to run: "
|
||||
+ "; ".join(
|
||||
f"{name} ({spec['summary']})"
|
||||
for name, spec in code_run.RUN_LANGS.items()
|
||||
)
|
||||
),
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short label for what this program does.",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The complete single-file program, entry point included. "
|
||||
"Standard library only."
|
||||
),
|
||||
},
|
||||
"stdin": {
|
||||
"type": "string",
|
||||
"description": "Optional text piped to the program's stdin.",
|
||||
},
|
||||
},
|
||||
"required": ["lang", "source"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_run_snippet,
|
||||
),
|
||||
"web_search": (
|
||||
{
|
||||
"type": "function",
|
||||
@@ -896,357 +477,13 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||
},
|
||||
_remember,
|
||||
),
|
||||
"edit_source": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_source",
|
||||
"description": (
|
||||
"Rewrite a file in this project's own source tree and commit the "
|
||||
"change. Requires human approval every time — the person reviews a "
|
||||
"real diff before anything is written. Send the COMPLETE new file "
|
||||
"content, not a patch; the server computes the diff itself. `path` "
|
||||
"is relative to the project root (e.g. \"synapse/tools.py\"), never "
|
||||
"absolute. Only available in a source checkout, not a packaged "
|
||||
"install. Writing the file does not restart the running process — "
|
||||
"say so plainly once it's applied."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Project-relative path to the file, e.g. synapse/tools.py",
|
||||
},
|
||||
"new_content": {
|
||||
"type": "string",
|
||||
"description": "The complete replacement content of the file.",
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "One line describing the change, used as the commit message.",
|
||||
},
|
||||
},
|
||||
"required": ["path", "new_content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_edit_source,
|
||||
),
|
||||
"edit_playbook": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_playbook",
|
||||
"description": (
|
||||
"Create or update a playbook — the instructions that shape how the "
|
||||
"assistant behaves. Requires human approval every time. Fields you "
|
||||
"omit keep their current value; this merges into the existing "
|
||||
"playbook, it does not replace it. Set make_active=true only when "
|
||||
"this playbook should become the active system prompt — never as a "
|
||||
"side effect of an ordinary edit. Omit `id` to create a new playbook."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Existing playbook id to update; omit to create new."},
|
||||
"title": {"type": "string", "description": "Short name for the playbook."},
|
||||
"goal": {"type": "string", "description": "One-line statement of what this playbook is for."},
|
||||
"instructions": {"type": "string", "description": "The actual instructions/system prompt text."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Routing tags."},
|
||||
"tools": {"type": "array", "items": {"type": "string"}, "description": "Tool names this playbook grants."},
|
||||
"model": {"type": "string", "description": "Preferred Ollama model for this playbook, or blank for auto."},
|
||||
"make_active": {
|
||||
"type": "boolean",
|
||||
"description": "Set true to make this the active system prompt. Default false.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
_edit_playbook,
|
||||
),
|
||||
"edit_settings": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_settings",
|
||||
"description": (
|
||||
"Change one or more runtime settings (e.g. model, temperature, "
|
||||
"action_tool_policy, memory_model). Requires human approval every "
|
||||
"time. Send only the keys you actually want to change — unrecognized "
|
||||
"keys are silently ignored, and existing values are left untouched."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"changes": {
|
||||
"type": "object",
|
||||
"description": "Partial map of setting name to new value.",
|
||||
},
|
||||
},
|
||||
"required": ["changes"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_edit_settings,
|
||||
),
|
||||
"curry_declare_constant": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_declare_constant",
|
||||
"description": (
|
||||
"Declare a new, immutable version of a named constant in the Curry "
|
||||
"ledger. Requires human approval every time. `version` must exceed "
|
||||
"the constant's current max version — versions are append-only, "
|
||||
"never overwritten. type_signature is one of Float64, Int32, "
|
||||
"String, Blob, Json, Tokens, Currency, Bool."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
"version": {"type": "integer", "description": "Must exceed the current max version for this id."},
|
||||
"value": {"description": "The value to store, matching type_signature."},
|
||||
"type_signature": {"type": "string", "description": "Float64 | Int32 | String | Blob | Json | Tokens | Currency | Bool"},
|
||||
"description": {"type": "string", "description": "What this constant means and why this value."},
|
||||
},
|
||||
"required": ["id", "version", "value", "type_signature"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_declare_constant,
|
||||
),
|
||||
"curry_get_constant": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_get_constant",
|
||||
"description": "Retrieve a Curry constant by its exact id and version.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
"version": {"type": "integer", "description": "Exact version to retrieve."},
|
||||
},
|
||||
"required": ["id", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_get_constant,
|
||||
),
|
||||
"curry_get_constant_latest": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_get_constant_latest",
|
||||
"description": "Retrieve the most recent active (non-retired) version of a Curry constant.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_get_constant_latest,
|
||||
),
|
||||
"curry_list_constants": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_list_constants",
|
||||
"description": "List every constant declared in the Curry ledger.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_only": {"type": "boolean", "description": "If true (default), exclude retired constants."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_list_constants,
|
||||
),
|
||||
"curry_retire_constant": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_retire_constant",
|
||||
"description": (
|
||||
"Retire (tombstone) a Curry constant version. Requires human approval "
|
||||
"every time. This does not delete anything — the version stays "
|
||||
"readable by exact id+version, it's just excluded from 'latest' "
|
||||
"lookups going forward."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Constant identifier."},
|
||||
"version": {"type": "integer", "description": "Version to retire."},
|
||||
"reason": {"type": "string", "description": "Why this version is being retired."},
|
||||
},
|
||||
"required": ["id", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_retire_constant,
|
||||
),
|
||||
"curry_declare_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_declare_function",
|
||||
"description": (
|
||||
"Declare a new, immutable version of a named function in the Curry "
|
||||
"ledger. Requires human approval every time. body is a SINGLE Python "
|
||||
"expression (no statements, no imports) over stdlib-only builtins — "
|
||||
"reference bound constants/functions by name via constant_bindings / "
|
||||
"function_bindings, and any additional runtime arguments via "
|
||||
"expected_args. `version` must exceed the function's current max "
|
||||
"version."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Must exceed the current max version for this name."},
|
||||
"body": {"type": "string", "description": "Single Python expression, e.g. \"amount * (1 + rate)\"."},
|
||||
"constant_bindings": {"type": "object", "description": "Dict mapping constant id to the exact version to bind, e.g. {\"rate\": 1}."},
|
||||
"function_bindings": {"type": "object", "description": "Dict mapping nested function name to the exact version to bind."},
|
||||
"is_pure": {"type": "boolean", "description": "If true, results are memoized in the execution cache."},
|
||||
"expected_args": {"type": "array", "items": {"type": "string"}, "description": "Runtime argument names the caller must supply to curry_call_function."},
|
||||
"description": {"type": "string", "description": "What this function computes and which constants it binds."},
|
||||
"arg_descriptions": {"type": "object", "description": "Per-argument hint strings, e.g. {\"amount\": \"USD, e.g. 100.00\"}."},
|
||||
},
|
||||
"required": ["name", "version", "body"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_declare_function,
|
||||
),
|
||||
"curry_get_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_get_function",
|
||||
"description": "Retrieve a Curry function definition by its exact name and version.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Exact version to retrieve."},
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_get_function,
|
||||
),
|
||||
"curry_list_functions": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_list_functions",
|
||||
"description": "List every function declared in the Curry ledger, including expected_args for building curry_call_function calls.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_only": {"type": "boolean", "description": "If true (default), exclude retired functions."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_list_functions,
|
||||
),
|
||||
"curry_call_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_call_function",
|
||||
"description": (
|
||||
"Execute a previously declared Curry function version with runtime "
|
||||
"arguments. Requires human approval every time. Use "
|
||||
"curry_list_functions or curry_get_function first to discover "
|
||||
"expected_args."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Exact version to execute."},
|
||||
"args": {"type": "object", "description": "Runtime arguments as a flat dict, e.g. {\"amount\": 100}."},
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_call_function,
|
||||
),
|
||||
"curry_retire_function": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "curry_retire_function",
|
||||
"description": (
|
||||
"Retire (tombstone) a Curry function version. Requires human approval "
|
||||
"every time. Does not delete anything."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Function name."},
|
||||
"version": {"type": "integer", "description": "Version to retire."},
|
||||
"reason": {"type": "string", "description": "Why this version is being retired."},
|
||||
},
|
||||
"required": ["name", "version"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_curry_retire_function,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Tools that act (write local state or reach the network). These require an
|
||||
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
||||
# allowlist — a playbook granting one isn't enough on its own. The highest-risk
|
||||
# tools in the self-edit and curry families additionally always pause for
|
||||
# per-call approval regardless of that global policy — see
|
||||
# ALWAYS_ASK_ACTION_TOOLS and chat.py. curry_call_function is an action
|
||||
# because it executes code (a declared function body), the same reasoning
|
||||
# that makes run_snippet an action tool despite not writing to any ledger.
|
||||
ACTION_TOOLS = frozenset({
|
||||
"web_search", "fetch_url", "remember", "run_snippet",
|
||||
"edit_playbook", "edit_settings", "edit_source",
|
||||
"curry_declare_constant", "curry_retire_constant",
|
||||
"curry_declare_function", "curry_retire_function", "curry_call_function",
|
||||
})
|
||||
|
||||
# Curry write/execute tools that always pause for per-call approval regardless
|
||||
# of the global action_tool_policy, on the same reasoning as
|
||||
# self_edit.ALWAYS_ASK_TOOLS: a policy of "allow" set for convenience on an
|
||||
# unrelated tool must never silently also unlock unattended ledger writes or
|
||||
# code execution. Read-only curry_get_*/curry_list_* tools are not action
|
||||
# tools at all and are unaffected.
|
||||
CURRY_ALWAYS_ASK_TOOLS = frozenset({
|
||||
"curry_declare_constant", "curry_retire_constant",
|
||||
"curry_declare_function", "curry_retire_function", "curry_call_function",
|
||||
})
|
||||
|
||||
# The union chat.py actually checks — one place, so a future tool family
|
||||
# doesn't have to remember there are two sets to update.
|
||||
ALWAYS_ASK_ACTION_TOOLS = self_edit.ALWAYS_ASK_TOOLS | CURRY_ALWAYS_ASK_TOOLS
|
||||
|
||||
# Action tools offered on a *cue* rather than only via a playbook allowlist —
|
||||
# the run track is a standing UI capability like the render window, but unlike
|
||||
# render_preview it executes code, so it stays behind the action gate. Listed
|
||||
# here so main.py can advertise it on a "run this" without a playbook edit.
|
||||
CUED_ACTION_TOOLS = frozenset({"run_snippet"})
|
||||
# allowlist — a playbook granting one isn't enough on its own.
|
||||
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
|
||||
|
||||
# Always advertised when the user asks for a visual (see wants_render_preview).
|
||||
# Not playbook-gated — the render window is a standing UI capability.
|
||||
@@ -1271,51 +508,16 @@ _RENDER_HINTS = (
|
||||
) + tuple(PREVIEW_LANGS)
|
||||
|
||||
|
||||
# Cues for run_snippet. Unlike _RENDER_HINTS these are verb phrases, not topic
|
||||
# words, and deliberately do not include the language names: "write me a python
|
||||
# function" is not a request to execute anything, and putting `python` in here
|
||||
# would drag every mention of the language into a slow non-stream tool round.
|
||||
# Asking for the *output* is the signal, so that is what these match.
|
||||
_RUN_HINTS = (
|
||||
"run this", "run it", "run that", "run the code", "run this code",
|
||||
"run my code", "run the program", "run and show", "actually run",
|
||||
"run snippet", "run_snippet", "execute this", "execute it", "execute the code",
|
||||
"compile", "compiles", "does it compile", "does this compile",
|
||||
"show the output", "show me the output", "what does it print",
|
||||
"what does this print", "what's the output", "whats the output",
|
||||
"what is the output", "actual output", "real output",
|
||||
)
|
||||
|
||||
|
||||
def _mentions(message: str, hints: tuple[str, ...]) -> bool:
|
||||
"""True if any hint appears in `message` as a whole word/phrase.
|
||||
|
||||
The lookarounds rather than \\b: several hints end in a non-word character
|
||||
("what's the output"), where \\b would anchor to the apostrophe instead of
|
||||
the phrase and match inside longer words.
|
||||
"""
|
||||
def wants_render_preview(message: str) -> bool:
|
||||
"""True when this turn should advertise render_preview / enter the tool loop."""
|
||||
import re
|
||||
lower = (message or "").lower()
|
||||
return any(
|
||||
re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower)
|
||||
for hint in hints
|
||||
for hint in _RENDER_HINTS
|
||||
)
|
||||
|
||||
|
||||
def wants_render_preview(message: str) -> bool:
|
||||
"""True when this turn should advertise render_preview / enter the tool loop."""
|
||||
return _mentions(message, _RENDER_HINTS)
|
||||
|
||||
|
||||
def wants_code_run(message: str) -> bool:
|
||||
"""True when this turn is asking for code to actually be executed.
|
||||
|
||||
Only a hint: run_snippet is an action tool, so this can never be what makes
|
||||
it available — `action_tool_policy` still has to be off "off" first.
|
||||
"""
|
||||
return _mentions(message, _RUN_HINTS)
|
||||
|
||||
|
||||
def is_action(name: str) -> bool:
|
||||
return name in ACTION_TOOLS
|
||||
|
||||
@@ -1335,15 +537,6 @@ def standing_schemas() -> list[dict]:
|
||||
return schemas_for(sorted(STANDING_TOOLS), allow_actions=True)
|
||||
|
||||
|
||||
def run_schemas() -> list[dict]:
|
||||
"""Schemas that ship with "run this" turns (currently just run_snippet).
|
||||
|
||||
Callers must have already established that actions are permitted — these are
|
||||
action tools and schemas_for would happily hand them over regardless.
|
||||
"""
|
||||
return schemas_for(sorted(CUED_ACTION_TOOLS), allow_actions=True)
|
||||
|
||||
|
||||
async def dispatch(name: str, args: dict | None) -> str:
|
||||
"""Run a tool by name. Never raises — returns an error string on failure."""
|
||||
entry = REGISTRY.get(name)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Test-process isolation for persistent runtime state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# curry_store constructs its SQLite singleton during test collection. Point it
|
||||
# at a per-run directory before any test module imports synapse, so the release
|
||||
# gate is repeatable and never writes test constants into the checkout's live
|
||||
# data/curry.db.
|
||||
_TEST_STATE = Path(tempfile.mkdtemp(prefix="nexus-pytest-"))
|
||||
os.environ["NEXUS_CURRY_DB"] = str(_TEST_STATE / "curry.db")
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
module = sys.modules.get("synapse.curry_store")
|
||||
if module is not None:
|
||||
module.curry_db.close()
|
||||
shutil.rmtree(_TEST_STATE, ignore_errors=True)
|
||||
@@ -1 +0,0 @@
|
||||
"""Data-driven snippet probes exercised by tests/test_snippet_probes.py."""
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Catalog of code-snippet probes for the execution track.
|
||||
|
||||
Each probe is a small, self-contained program (or deliberate reject) that the
|
||||
suite in tests/test_snippet_probes.py runs automatically. Keeping the corpus
|
||||
here — not inlined in the test file — means adding a language or a case is a
|
||||
data change, and the meta-tests can assert every RUN_LANGS key is covered.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Probe:
|
||||
"""One automatic snippet probe.
|
||||
|
||||
kind:
|
||||
run — critique must be clean, then code_run.run (skip if no toolchain)
|
||||
screen — critique must report at least one issue matching needle;
|
||||
never executed
|
||||
tool — same as run, but also through tools.dispatch("run_snippet")
|
||||
"""
|
||||
|
||||
id: str
|
||||
lang: str
|
||||
source: str
|
||||
kind: str = "run"
|
||||
expect_stdout: str | None = None # exact strip() match when set
|
||||
expect_stdout_contains: tuple[str, ...] = ()
|
||||
expect_stderr_contains: tuple[str, ...] = ()
|
||||
expect_exit: int | None = 0 # None = don't care; run-ok can be nonzero
|
||||
expect_ok: bool = True # False => stage failure (compile/timeout/…)
|
||||
expect_stage: str | None = None
|
||||
screen_needle: str | None = None # required substring in critique issues
|
||||
stdin: str = ""
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corpus — keep each source short; the suite runs every probe on every check.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROBES: tuple[Probe, ...] = (
|
||||
# --- python (always available) -----------------------------------------
|
||||
Probe(
|
||||
id="py-hello",
|
||||
lang="python",
|
||||
source="print('probe-ok', 6 * 7)",
|
||||
expect_stdout="probe-ok 42",
|
||||
tags=("smoke", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-stdin",
|
||||
lang="python",
|
||||
source="import sys\nprint(sys.stdin.read().strip().upper())",
|
||||
stdin="nexus\n",
|
||||
expect_stdout="NEXUS",
|
||||
tags=("stdin", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-nonzero-exit",
|
||||
lang="python",
|
||||
source="import sys\nprint('before')\nsys.exit(3)",
|
||||
expect_stdout="before",
|
||||
expect_exit=3,
|
||||
tags=("exit", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-traceback",
|
||||
lang="python",
|
||||
source="print(1 / 0)",
|
||||
expect_exit=None, # nonzero, exact code is interpreter-dependent enough
|
||||
expect_stderr_contains=("ZeroDivisionError",),
|
||||
tags=("stderr", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-alias-py",
|
||||
lang="py",
|
||||
source="print('alias')",
|
||||
expect_stdout="alias",
|
||||
tags=("alias", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-tool-envelope",
|
||||
lang="python",
|
||||
source="print('via-tool', 2 + 2)",
|
||||
kind="tool",
|
||||
expect_stdout="via-tool 4",
|
||||
tags=("tool", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-screen-network",
|
||||
lang="python",
|
||||
source="import socket\nprint(socket.gethostname())",
|
||||
kind="screen",
|
||||
screen_needle="network",
|
||||
tags=("screen", "python"),
|
||||
),
|
||||
Probe(
|
||||
id="py-screen-subprocess",
|
||||
lang="python",
|
||||
source="import subprocess\nsubprocess.run(['true'])",
|
||||
kind="screen",
|
||||
screen_needle="processes",
|
||||
tags=("screen", "python"),
|
||||
),
|
||||
|
||||
# --- c -----------------------------------------------------------------
|
||||
Probe(
|
||||
id="c-hello",
|
||||
lang="c",
|
||||
source=(
|
||||
"#include <stdio.h>\n"
|
||||
"int main(void) {\n"
|
||||
' printf("c-probe %d\\n", 6 * 7);\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
expect_stdout="c-probe 42",
|
||||
tags=("smoke", "c"),
|
||||
),
|
||||
Probe(
|
||||
id="c-math",
|
||||
lang="c",
|
||||
source=(
|
||||
"#include <stdio.h>\n"
|
||||
"#include <math.h>\n"
|
||||
"int main(void) {\n"
|
||||
' printf("%.0f\\n", sqrt(144.0));\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
expect_stdout="12",
|
||||
tags=("libm", "c"),
|
||||
),
|
||||
Probe(
|
||||
id="c-compile-error",
|
||||
lang="c",
|
||||
source="int main(void) { return nope; }\n",
|
||||
expect_ok=False,
|
||||
expect_stage="compile",
|
||||
expect_exit=None,
|
||||
expect_stderr_contains=("nope",),
|
||||
tags=("compile", "c"),
|
||||
),
|
||||
Probe(
|
||||
id="c-screen-system",
|
||||
lang="c",
|
||||
source='#include <stdlib.h>\nint main(void){ system("true"); return 0; }\n',
|
||||
kind="screen",
|
||||
screen_needle="processes",
|
||||
tags=("screen", "c"),
|
||||
),
|
||||
Probe(
|
||||
id="c-screen-no-main",
|
||||
lang="c",
|
||||
source="int add(int a){ return a + 1; }\n",
|
||||
kind="screen",
|
||||
screen_needle="main()",
|
||||
tags=("screen", "c"),
|
||||
),
|
||||
|
||||
# --- cpp ---------------------------------------------------------------
|
||||
Probe(
|
||||
id="cpp-hello",
|
||||
lang="cpp",
|
||||
source=(
|
||||
"#include <iostream>\n"
|
||||
"int main() {\n"
|
||||
' std::cout << "cpp-probe " << (6 * 7) << "\\n";\n'
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
),
|
||||
expect_stdout="cpp-probe 42",
|
||||
tags=("smoke", "cpp"),
|
||||
),
|
||||
Probe(
|
||||
id="cpp-alias",
|
||||
lang="c++",
|
||||
source=(
|
||||
"#include <iostream>\n"
|
||||
"int main(){ std::cout << 9 << \"\\n\"; }\n"
|
||||
),
|
||||
expect_stdout="9",
|
||||
tags=("alias", "cpp"),
|
||||
),
|
||||
Probe(
|
||||
id="cpp-screen-socket",
|
||||
lang="cpp",
|
||||
source="#include <sys/socket.h>\nint main(){ return 0; }\n",
|
||||
kind="screen",
|
||||
screen_needle="network",
|
||||
tags=("screen", "cpp"),
|
||||
),
|
||||
|
||||
# --- rust --------------------------------------------------------------
|
||||
Probe(
|
||||
id="rust-hello",
|
||||
lang="rust",
|
||||
source='fn main() { println!("rust-probe {}", (1..=10).sum::<i32>()); }\n',
|
||||
expect_stdout="rust-probe 55",
|
||||
tags=("smoke", "rust"),
|
||||
),
|
||||
Probe(
|
||||
id="rust-alias-rs",
|
||||
lang="rs",
|
||||
source='fn main() { println!("rs"); }\n',
|
||||
expect_stdout="rs",
|
||||
tags=("alias", "rust"),
|
||||
),
|
||||
Probe(
|
||||
id="rust-compile-error",
|
||||
lang="rust",
|
||||
source="fn main() { let x: i32 = \"nope\"; }\n",
|
||||
expect_ok=False,
|
||||
expect_stage="compile",
|
||||
expect_exit=None,
|
||||
tags=("compile", "rust"),
|
||||
),
|
||||
Probe(
|
||||
id="rust-screen-command",
|
||||
lang="rust",
|
||||
source='fn main() { let _ = std::process::Command::new("true"); }\n',
|
||||
kind="screen",
|
||||
screen_needle="processes",
|
||||
tags=("screen", "rust"),
|
||||
),
|
||||
|
||||
# --- erlang ------------------------------------------------------------
|
||||
Probe(
|
||||
id="erl-escript",
|
||||
lang="erlang",
|
||||
source='main(_) -> io:format("erl-probe ~p~n", [lists:sum(lists:seq(1, 10))]).\n',
|
||||
expect_stdout="erl-probe 55",
|
||||
tags=("smoke", "erlang"),
|
||||
),
|
||||
Probe(
|
||||
id="erl-module",
|
||||
lang="erlang",
|
||||
source=(
|
||||
"-module(main).\n"
|
||||
"-export([main/1]).\n"
|
||||
'main(_) -> io:format("~p~n", [7 * 6]).\n'
|
||||
),
|
||||
expect_stdout="42",
|
||||
tags=("module", "erlang"),
|
||||
),
|
||||
Probe(
|
||||
id="erl-alias",
|
||||
lang="erl",
|
||||
source='main(_) -> io:format("alias~n").\n',
|
||||
expect_stdout="alias",
|
||||
tags=("alias", "erlang"),
|
||||
),
|
||||
Probe(
|
||||
id="erl-screen-os-cmd",
|
||||
lang="erlang",
|
||||
source='main(_) -> os:cmd("true").\n',
|
||||
kind="screen",
|
||||
screen_needle="processes",
|
||||
tags=("screen", "erlang"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def probes_by_tag(*tags: str) -> tuple[Probe, ...]:
|
||||
wanted = set(tags)
|
||||
return tuple(p for p in PROBES if wanted.intersection(p.tags))
|
||||
|
||||
|
||||
def covered_langs() -> set[str]:
|
||||
"""Canonical RUN_LANGS keys touched by at least one probe (aliases resolved)."""
|
||||
from synapse import code_run
|
||||
return {code_run.resolve_lang(p.lang) for p in PROBES}
|
||||
@@ -55,12 +55,10 @@ def test_legacy_cli_spellings_remain_compatible():
|
||||
|
||||
assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"]
|
||||
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
|
||||
assert _normalize_legacy_argv(["logs", "-m", "--follow"]) == ["logs", "memory", "--follow"]
|
||||
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
|
||||
# -f is --follow for `logs`, but --frontend for start/stop. Translating it
|
||||
# for logs turned `logs -f` into a one-shot tail of the frontend log.
|
||||
assert _normalize_legacy_argv(["logs", "-f"]) == ["logs", "-f"]
|
||||
assert _normalize_legacy_argv(["logs", "-m", "-f"]) == ["logs", "memory", "-f"]
|
||||
assert _normalize_legacy_argv(["start", "-f"]) == ["start", "frontend"]
|
||||
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
|
||||
assert _normalize_legacy_argv(["help"]) == ["--help"]
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
"""The execution track: synapse/code_run.py.
|
||||
|
||||
Python is the only toolchain guaranteed present (it is the interpreter running
|
||||
these tests), so it carries the behavioural coverage — limits, streams, exit
|
||||
codes, isolation. The compiled languages are covered for the parts that hold
|
||||
without their toolchain installed (screening, argv shape, the missing-toolchain
|
||||
message) and skipped where they need it, so this file passes on a machine with
|
||||
no clang and no rustc as well as on one with both.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from synapse import code_run
|
||||
|
||||
|
||||
def _requires(lang):
|
||||
entry = code_run.RUN_LANGS[lang]
|
||||
return pytest.mark.skipif(
|
||||
not entry["tool"](), reason=f"no {lang} toolchain on this machine"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_lang_has_a_complete_driver():
|
||||
for name, entry in code_run.RUN_LANGS.items():
|
||||
assert entry["summary"], name
|
||||
assert callable(entry["tool"]), name
|
||||
assert callable(entry["source_name"]), name
|
||||
assert callable(entry["run"]), name
|
||||
assert callable(entry["critique"]), name
|
||||
assert entry["install"], name
|
||||
|
||||
|
||||
def test_aliases_resolve_to_real_langs():
|
||||
for alias, target in code_run.ALIASES.items():
|
||||
assert target in code_run.RUN_LANGS, alias
|
||||
assert code_run.resolve_lang("C++") == "cpp"
|
||||
assert code_run.resolve_lang(" Py ") == "python"
|
||||
assert code_run.resolve_lang("javascript") == "javascript" # unknown passes through
|
||||
|
||||
|
||||
def test_erlang_source_name_follows_the_dialect():
|
||||
"""escript reads the same bytes two ways depending on the extension. Writing
|
||||
a bare main/1 script to main.erl makes it a module with no exports, which
|
||||
fails at load with an error that says nothing about the real problem."""
|
||||
assert code_run._erlang_source_name("main(_) -> ok.") == "main.escript"
|
||||
assert code_run._erlang_source_name("-module(main).\nmain(_) -> ok.") == "main.erl"
|
||||
|
||||
|
||||
def test_erlang_escript_gets_a_shebang_when_missing():
|
||||
"""OTP 28+ rejects shebang-less .escript with 'Premature end of file'."""
|
||||
bare = "main(_) -> ok.\n"
|
||||
out = code_run._erlang_write_source(bare)
|
||||
assert out.startswith("#!/usr/bin/env escript\n")
|
||||
assert out.endswith(bare)
|
||||
already = "#!/usr/bin/env escript\nmain(_) -> ok.\n"
|
||||
assert code_run._erlang_write_source(already) == already
|
||||
module = "-module(main).\n-export([main/1]).\nmain(_) -> ok.\n"
|
||||
assert code_run._erlang_write_source(module) == module
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Screening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_clean_snippets_pass_screening():
|
||||
assert code_run.critique("python", "print(sum(range(100)))") == []
|
||||
assert code_run.critique("c", '#include <stdio.h>\nint main(void){puts("x");}') == []
|
||||
assert code_run.critique("rust", 'fn main(){ println!("x"); }') == []
|
||||
assert code_run.critique("erlang", 'main(_) -> io:format("x~n").') == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang,source,needle", [
|
||||
("python", "import socket\nprint(socket)", "network"),
|
||||
("python", "import requests\nprint(requests)", "network"),
|
||||
("python", "import subprocess\nsubprocess.run(['ls'])", "processes"),
|
||||
("c", "#include <sys/socket.h>\nint main(void){return 0;}", "network"),
|
||||
("c", '#include <stdlib.h>\nint main(void){system("ls");}', "processes"),
|
||||
("rust", "fn main(){ std::process::Command::new(\"ls\"); }", "processes"),
|
||||
("erlang", 'main(_) -> os:cmd("ls").', "processes"),
|
||||
])
|
||||
def test_screening_catches_the_obvious_reaches(lang, source, needle):
|
||||
issues = code_run.critique(lang, source)
|
||||
assert issues, f"{lang} snippet passed screening"
|
||||
assert any(needle in i for i in issues), issues
|
||||
|
||||
|
||||
def test_screening_catches_absolute_paths_and_urls():
|
||||
assert any("absolute" in i for i in code_run.critique(
|
||||
"python", 'data = open("/etc/passwd").read()\nprint(data)'))
|
||||
# Escaped and raw Windows paths both count.
|
||||
assert any("absolute" in i for i in code_run.critique(
|
||||
"python", 'data = open("C:\\\\Users\\\\me").read()'))
|
||||
assert any("absolute" in i for i in code_run.critique(
|
||||
"python", r'data = open(r"C:\Users\me").read()'))
|
||||
assert any("http" in i for i in code_run.critique(
|
||||
"python", 'print("see https://example.com/data.csv")'))
|
||||
|
||||
|
||||
def test_url_in_a_comment_is_not_a_network_reach():
|
||||
"""A citation in a comment is not fetch(); bouncing it costs a useless retry."""
|
||||
c = (
|
||||
"/* spec: https://en.cppreference.com/w/c/string */\n"
|
||||
"#include <stdio.h>\n"
|
||||
"int main(void){ puts(\"ok\"); return 0; }\n"
|
||||
)
|
||||
assert code_run.critique("c", c) == []
|
||||
|
||||
|
||||
def test_erlang_nproc_ceiling_is_relative_to_the_user(monkeypatch):
|
||||
"""RLIMIT_NPROC is UID-scoped; an absolute 512 is not 512 of headroom."""
|
||||
monkeypatch.setattr(code_run, "_user_process_count", lambda: 400)
|
||||
assert code_run._max_procs_for("erlang") == 400 + code_run._ERLANG_PROC_HEADROOM
|
||||
assert code_run._max_procs_for("python") == code_run._MAX_PROCS
|
||||
monkeypatch.setattr(code_run, "_user_process_count", lambda: None)
|
||||
assert code_run._max_procs_for("erlang") is None
|
||||
|
||||
|
||||
def test_missing_entry_point_is_named_up_front():
|
||||
"""Without this the user sees a linker error about _main, or an escript load
|
||||
failure — neither of which says 'you forgot the entry point'."""
|
||||
assert any("main()" in i for i in code_run.critique("c", "int add(int a){return a+1;}"))
|
||||
assert any("main()" in i for i in code_run.critique("rust", "fn add(a: i32) -> i32 { a }"))
|
||||
assert any("main/1" in i for i in code_run.critique("erlang", "add(A) -> A + 1."))
|
||||
|
||||
|
||||
def test_empty_source_says_so_and_stops():
|
||||
issues = code_run.critique("python", " ")
|
||||
assert issues == ["source is empty or too short to run."]
|
||||
|
||||
|
||||
def test_unknown_lang_is_refused_by_both_entry_points():
|
||||
assert code_run.critique("brainfuck", "+++.")[0].startswith("cannot run")
|
||||
assert code_run.run("brainfuck", "+++.")["ok"] is False
|
||||
|
||||
|
||||
def test_oversized_source_is_refused_without_running():
|
||||
issues = code_run.critique("python", "x = 1\n" * 40_000)
|
||||
assert issues and "characters" in issues[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution (python: always available)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stdout_and_exit_code_come_back():
|
||||
out = code_run.run("python", "print('hello', 6 * 7)")
|
||||
assert out["ok"] is True
|
||||
assert out["stdout"].strip() == "hello 42"
|
||||
assert out["exit_code"] == 0
|
||||
|
||||
|
||||
def test_streams_are_kept_separate():
|
||||
out = code_run.run("python", "import sys\nprint('o')\nprint('e', file=sys.stderr)")
|
||||
assert out["stdout"].strip() == "o"
|
||||
assert out["stderr"].strip() == "e"
|
||||
|
||||
|
||||
def test_nonzero_exit_is_still_a_successful_run():
|
||||
out = code_run.run("python", "raise SystemExit(3)")
|
||||
assert out["ok"] is True
|
||||
assert out["exit_code"] == 3
|
||||
|
||||
|
||||
def test_a_traceback_is_returned_not_raised():
|
||||
out = code_run.run("python", "print(1/0)")
|
||||
assert out["ok"] is True
|
||||
assert out["exit_code"] != 0
|
||||
assert "ZeroDivisionError" in out["stderr"]
|
||||
|
||||
|
||||
def test_stdin_is_piped_in():
|
||||
out = code_run.run("python", "import sys\nprint(sys.stdin.read().upper())", stdin="abc")
|
||||
assert out["stdout"].strip() == "ABC"
|
||||
|
||||
|
||||
def test_a_program_reading_stdin_with_none_given_does_not_hang():
|
||||
"""stdin is always closed rather than inherited: a snippet calling input()
|
||||
with nothing piped in would otherwise block on the server's own stdin until
|
||||
the wall clock killed it, five seconds of 'thinking' for nothing."""
|
||||
out = code_run.run("python", "print(input('prompt: '))")
|
||||
assert out["ok"] is True
|
||||
assert "EOFError" in out["stderr"]
|
||||
|
||||
|
||||
def test_an_infinite_loop_is_killed_and_explained():
|
||||
out = code_run.run("python", "while True:\n pass")
|
||||
assert out["ok"] is False
|
||||
assert out["stage"] == "run"
|
||||
assert "did not finish" in out["error"]
|
||||
|
||||
|
||||
def test_output_is_truncated_not_streamed_whole():
|
||||
out = code_run.run("python", f"print('x' * {code_run.MAX_OUTPUT * 3})")
|
||||
assert len(out["stdout"]) < code_run.MAX_OUTPUT + 200
|
||||
assert "truncated" in out["stdout"]
|
||||
|
||||
|
||||
def test_the_working_directory_is_ephemeral():
|
||||
"""Two runs must not see each other's files. The temp dir is the only thing
|
||||
standing between a snippet and the user's cwd, so its lifetime is worth a
|
||||
test rather than an assumption."""
|
||||
first = code_run.run("python", "open('note.txt', 'w').write('hi')\nprint('wrote')")
|
||||
assert first["ok"] is True, first
|
||||
second = code_run.run("python", "import os\nprint(os.path.exists('note.txt'))")
|
||||
assert second["stdout"].strip() == "False"
|
||||
|
||||
|
||||
def test_the_child_does_not_inherit_the_servers_environment():
|
||||
"""A snippet has no business reading the process environment it happens to
|
||||
be spawned from — that is where an API key would be."""
|
||||
import os
|
||||
os.environ["NEXUS_RUN_LEAK_PROBE"] = "secret"
|
||||
try:
|
||||
out = code_run.run(
|
||||
"python", "import os\nprint(os.environ.get('NEXUS_RUN_LEAK_PROBE'))"
|
||||
)
|
||||
finally:
|
||||
os.environ.pop("NEXUS_RUN_LEAK_PROBE", None)
|
||||
assert out["stdout"].strip() == "None"
|
||||
|
||||
|
||||
def test_home_points_at_the_scratch_dir():
|
||||
"""HOME is rewritten so a snippet writing a dotfile writes it somewhere that
|
||||
gets deleted, rather than into the user's real home."""
|
||||
out = code_run.run("python", "import os\nprint(os.path.expanduser('~'))")
|
||||
assert "nexus-run-" in out["stdout"]
|
||||
|
||||
|
||||
def test_temp_points_at_the_scratch_dir():
|
||||
"""Compilers and snippets must not fall back to a host temp directory."""
|
||||
out = code_run.run("python", "import tempfile\nprint(tempfile.gettempdir())")
|
||||
assert "nexus-run-" in out["stdout"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Toolchains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_windows_compiler_resolver_skips_broken_candidates(monkeypatch):
|
||||
"""An executable on PATH is not enough when its linker/runtime is broken."""
|
||||
code_run._compiled_tool.cache_clear()
|
||||
monkeypatch.setattr(code_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
code_run.shutil, "which", lambda name: f"C:\\tools\\{name}.exe"
|
||||
)
|
||||
probes = []
|
||||
|
||||
def probe(lang, tool):
|
||||
probes.append((lang, tool))
|
||||
return tool.endswith("clang.exe")
|
||||
|
||||
monkeypatch.setattr(code_run, "_probe_compiled_tool", probe)
|
||||
try:
|
||||
assert code_run._compiled_tool("c", ("gcc", "clang")) == "C:\\tools\\clang.exe"
|
||||
assert probes == [
|
||||
("c", "C:\\tools\\gcc.exe"),
|
||||
("c", "C:\\tools\\clang.exe"),
|
||||
]
|
||||
finally:
|
||||
code_run._compiled_tool.cache_clear()
|
||||
|
||||
|
||||
def test_a_missing_toolchain_explains_itself(monkeypatch):
|
||||
"""The model has to be able to tell 'you cannot run this here' from 'your
|
||||
code is wrong' — otherwise it rewrites a correct program repeatedly."""
|
||||
monkeypatch.setitem(
|
||||
code_run.RUN_LANGS["rust"], "tool", lambda: None
|
||||
)
|
||||
out = code_run.run("rust", 'fn main(){ println!("x"); }')
|
||||
assert out["ok"] is False
|
||||
assert out["stage"] == "toolchain"
|
||||
assert "rustup.rs" in out["error"]
|
||||
assert "Show the code instead" in out["error"]
|
||||
|
||||
|
||||
@_requires("c")
|
||||
def test_c_compiles_and_runs():
|
||||
out = code_run.run("c", '#include <stdio.h>\nint main(void){printf("%d\\n", 6*7);return 0;}')
|
||||
assert out["ok"] is True, out
|
||||
assert out["stdout"].strip() == "42"
|
||||
|
||||
|
||||
@_requires("c")
|
||||
def test_a_compile_error_comes_back_as_a_compile_error():
|
||||
"""Stage matters: the compiler's diagnostics are the useful payload, and
|
||||
labelling this a run failure would hide that nothing ever executed."""
|
||||
out = code_run.run("c", "int main(void){ return oops; }")
|
||||
assert out["ok"] is False
|
||||
assert out["stage"] == "compile"
|
||||
assert "oops" in out["stderr"]
|
||||
|
||||
|
||||
@_requires("cpp")
|
||||
def test_cpp_compiles_and_runs():
|
||||
out = code_run.run("cpp", '#include <iostream>\nint main(){std::cout << 6*7 << "\\n";}')
|
||||
assert out["ok"] is True, out
|
||||
assert out["stdout"].strip() == "42"
|
||||
|
||||
|
||||
@_requires("rust")
|
||||
def test_rust_compiles_and_runs():
|
||||
out = code_run.run("rust", 'fn main(){ println!("{}", (1..=10).sum::<i32>()); }')
|
||||
assert out["ok"] is True, out
|
||||
assert out["stdout"].strip() == "55"
|
||||
|
||||
|
||||
@_requires("erlang")
|
||||
def test_erlang_runs_a_bare_escript():
|
||||
out = code_run.run("erlang", 'main(_) -> io:format("~p~n", [lists:sum(lists:seq(1,10))]).')
|
||||
assert out["ok"] is True, out
|
||||
assert out["stdout"].strip() == "55"
|
||||
|
||||
|
||||
@_requires("erlang")
|
||||
def test_erlang_runs_a_module_form_script():
|
||||
out = code_run.run(
|
||||
"erlang",
|
||||
"-module(main).\n-export([main/1]).\nmain(_) -> io:format(\"~p~n\", [7*6]).",
|
||||
)
|
||||
assert out["ok"] is True, out
|
||||
assert out["stdout"].strip() == "42"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="unshare is Linux-only")
|
||||
def test_network_isolation_probe_is_honest():
|
||||
"""Either we got a namespace or we did not — but the probe must never claim
|
||||
one it did not verify, because the tool description promises 'no network' on
|
||||
the strength of it."""
|
||||
prefix = code_run._net_isolation()
|
||||
assert prefix in ([], ["unshare", "-rn"])
|
||||
if prefix:
|
||||
assert shutil.which("unshare")
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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 actually gives
|
||||
NexusOS a live, callable instance without wiring it into any chat-facing tool.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from synapse.curry_core import Curry, TypeSignature
|
||||
from synapse import curry_store
|
||||
|
||||
|
||||
def test_curry_store_is_preloaded_and_callable():
|
||||
# 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)
|
||||
curry_store.curry_db.declare_constant("t_preload_check", 1, 1, TypeSignature.INT32.value)
|
||||
assert curry_store.curry_db.get_constant_latest("t_preload_check")["value"] == 1
|
||||
curry_store.curry_db.retire_constant("t_preload_check", 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()
|
||||
@@ -136,40 +136,6 @@ def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_delete_conversation_removes_message_vectors():
|
||||
"""Deleting a conversation must take its embeddings with it — orphaned
|
||||
message_vectors rows are invisible to recall but grow the DB forever."""
|
||||
s = _store()
|
||||
|
||||
async def run():
|
||||
s.create_conversation("c1")
|
||||
s.add_message("c1", "user", "tell me about lego star wars")
|
||||
s.add_message("c1", "assistant", "lego star wars is a fun game")
|
||||
s.create_conversation("c2")
|
||||
s.add_message("c2", "user", "gpu vega vram notes")
|
||||
|
||||
# the first search lazily backfills a vector for every message
|
||||
await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
|
||||
conn = s._connect()
|
||||
assert conn.execute("SELECT COUNT(*) FROM message_vectors").fetchone()[0] == 3
|
||||
conn.close()
|
||||
|
||||
s.delete_conversation("c1")
|
||||
|
||||
conn = s._connect()
|
||||
orphans = conn.execute(
|
||||
"SELECT COUNT(*) FROM message_vectors v "
|
||||
"LEFT JOIN messages m ON m.id = v.message_id WHERE m.id IS NULL"
|
||||
).fetchone()[0]
|
||||
assert orphans == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM message_vectors").fetchone()[0] == 1 # c2 untouched
|
||||
if s.vec_enabled: # the ANN mirror is pruned too, not just the JSON table
|
||||
assert conn.execute("SELECT COUNT(*) FROM vec_messages").fetchone()[0] == 1
|
||||
conn.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_startup_sweeps_pre_existing_orphan_vectors():
|
||||
"""Databases written before delete_conversation cleaned up after itself are
|
||||
repaired the next time the store opens them."""
|
||||
|
||||
+15
-15
@@ -19,7 +19,7 @@ THEME_INSTALLER = REPO / "assets" / "themes" / "install-theme.sh"
|
||||
def test_look_and_feel_is_copied_never_symlinked():
|
||||
"""KPackage skips symlinked package directories without a word, so a
|
||||
symlinked Global Theme simply never appears in System Settings."""
|
||||
text = INSTALLER.read_text(encoding="utf-8")
|
||||
text = INSTALLER.read_text()
|
||||
assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place"
|
||||
for line in text.splitlines():
|
||||
if line.strip().startswith("ln -s"):
|
||||
@@ -31,7 +31,7 @@ def test_plasmashell_restart_is_detached_from_the_callers_stdout():
|
||||
"""The restarted shell outlives the script. Inheriting stdout keeps the
|
||||
caller's pipe open forever, which hangs `ncp restore` after a successful
|
||||
apply."""
|
||||
text = INSTALLER.read_text(encoding="utf-8")
|
||||
text = INSTALLER.read_text()
|
||||
restart = [l for l in text.splitlines()
|
||||
if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
|
||||
assert restart, "no plasmashell restart found"
|
||||
@@ -44,7 +44,7 @@ def test_plasmashell_restart_is_detached_from_the_callers_stdout():
|
||||
def test_splash_renders_without_the_stage_signal():
|
||||
"""A splash gated on `stage == 2` shows a blank coloured screen if that
|
||||
signal never arrives -- what `ksplashqml --test` does."""
|
||||
qml = (LNF / "contents" / "splash" / "Splash.qml").read_text(encoding="utf-8")
|
||||
qml = (LNF / "contents" / "splash" / "Splash.qml").read_text()
|
||||
content = qml[qml.index("id: content"):]
|
||||
body = content[:content.index("OpacityAnimator")]
|
||||
assert "opacity: 0" not in body, "splash content starts invisible"
|
||||
@@ -55,7 +55,7 @@ def test_sddm_theme_is_configured_in_exactly_one_place():
|
||||
"""boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two
|
||||
different config files meant the setting could disagree with itself."""
|
||||
for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"):
|
||||
text = script.read_text(encoding="utf-8")
|
||||
text = script.read_text()
|
||||
stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text)
|
||||
assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d"
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_sddm_theme_is_configured_in_exactly_one_place():
|
||||
def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
|
||||
"""The desktop stage used to bail out entirely without xfconf-query, so a
|
||||
Plasma box got no theme back from `ncp restore` at all."""
|
||||
text = (REPO / "bin" / "restore-linux.sh").read_text(encoding="utf-8")
|
||||
text = (REPO / "bin" / "restore-linux.sh").read_text()
|
||||
assert "install-plasma.sh" in text, "restore never invokes the Plasma installer"
|
||||
assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh"
|
||||
# The XFCE check must not be able to skip the Plasma branch or the branding.
|
||||
@@ -72,11 +72,11 @@ def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
|
||||
|
||||
|
||||
def test_global_theme_package_is_well_formed():
|
||||
meta = json.loads((LNF / "metadata.json").read_text(encoding="utf-8"))
|
||||
meta = json.loads((LNF / "metadata.json").read_text())
|
||||
assert meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory"
|
||||
assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"]
|
||||
|
||||
defaults = (LNF / "contents" / "defaults").read_text(encoding="utf-8")
|
||||
defaults = (LNF / "contents" / "defaults").read_text()
|
||||
# Every component the Global Theme selects has to exist in the repo.
|
||||
assert "ColorScheme=NexusOS" in defaults
|
||||
assert (KDE / "plasma" / "NexusOS").is_dir()
|
||||
@@ -96,7 +96,7 @@ def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
|
||||
for f in qml_files:
|
||||
# Only the source: lines -- the comments deliberately mention the SVG,
|
||||
# since that is the file you edit and re-rasterize.
|
||||
sources = [l for l in f.read_text(encoding="utf-8").splitlines()
|
||||
sources = [l for l in f.read_text().splitlines()
|
||||
if "source:" in l and not l.strip().startswith("//")]
|
||||
bg = [l for l in sources if "background" in l]
|
||||
assert bg, f"{f.name} loads no background"
|
||||
@@ -108,7 +108,7 @@ def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
|
||||
def _defaults_sections():
|
||||
"""Parse the look-and-feel defaults into {section: {key: value}}."""
|
||||
out, section = {}, None
|
||||
for line in (LNF / "contents" / "defaults").read_text(encoding="utf-8").splitlines():
|
||||
for line in (LNF / "contents" / "defaults").read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
section = line
|
||||
@@ -127,7 +127,7 @@ def test_lock_screen_theme_names_a_look_and_feel_package():
|
||||
assert greeter["Theme"].endswith(".desktop"), \
|
||||
f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}"
|
||||
|
||||
installer = INSTALLER.read_text(encoding="utf-8")
|
||||
installer = INSTALLER.read_text()
|
||||
lock_lines = [l for l in installer.splitlines()
|
||||
if "kscreenlockerrc" in l and "--key Theme" in l]
|
||||
assert lock_lines, "installer never sets the lock screen theme"
|
||||
@@ -141,7 +141,7 @@ def test_lock_screen_theme_names_a_look_and_feel_package():
|
||||
def _index_theme():
|
||||
"""Parse index.theme into (header dict, list of declared directories)."""
|
||||
header, section, dirs = {}, None, []
|
||||
for line in (ICONS / "index.theme").read_text(encoding="utf-8").splitlines():
|
||||
for line in (ICONS / "index.theme").read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[") and line != "[Icon Theme]":
|
||||
section = line.strip("[]")
|
||||
@@ -180,7 +180,7 @@ def test_icon_theme_is_installed_where_qt_looks():
|
||||
"""~/.icons is the GTK/XFCE legacy path. Qt/KF5 searches XDG data dirs only,
|
||||
so installing there alone meant Plasma never found the theme and every icon
|
||||
fell back to Breeze without a word."""
|
||||
text = THEME_INSTALLER.read_text(encoding="utf-8")
|
||||
text = THEME_INSTALLER.read_text()
|
||||
links = [l for l in text.splitlines()
|
||||
if l.strip().startswith("link ") and "NexusOS-icons" in l]
|
||||
assert any(".local/share/icons" in l for l in links), \
|
||||
@@ -193,7 +193,7 @@ def test_inherits_check_reads_only_the_primary_parent():
|
||||
"""The installer compared the whole comma-separated Inherits value against a
|
||||
directory name, so a valid multi-parent list warned that an installed
|
||||
fallback was missing."""
|
||||
text = THEME_INSTALLER.read_text(encoding="utf-8")
|
||||
text = THEME_INSTALLER.read_text()
|
||||
inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l]
|
||||
assert inh, "inheritance check not found"
|
||||
assert any("-f1" in l for l in inh), \
|
||||
@@ -204,11 +204,11 @@ def test_panel_layout_is_portable():
|
||||
"""The panel script sets the launcher icon by absolute path. Hardcoding
|
||||
this box's home would give any other clone or user a missing icon, so the
|
||||
path is a placeholder the installer substitutes."""
|
||||
js = (KDE / "panel-layout.js").read_text(encoding="utf-8")
|
||||
js = (KDE / "panel-layout.js").read_text()
|
||||
assert "/home/" not in js, "panel-layout.js hardcodes a home directory"
|
||||
assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path"
|
||||
|
||||
installer = INSTALLER.read_text(encoding="utf-8")
|
||||
installer = INSTALLER.read_text()
|
||||
assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path"
|
||||
# Rewriting the panel wholesale on every restore would wipe later additions.
|
||||
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
|
||||
|
||||
@@ -3,41 +3,21 @@
|
||||
Both checks guard fixes for real defects: the account file used to be written at
|
||||
the umask and chmodded afterwards, and the IMAP/SMTP connections used to take
|
||||
Python's stdlib SSL context, which verifies nothing.
|
||||
|
||||
The 0600-mode assertions are POSIX-only: NTFS has no rwx-owner/group/other bit
|
||||
model, so os.open(..., 0o600) on Windows creates a normal read-write file and
|
||||
stat.S_IMODE reports 0o666 regardless of the mode argument -- Python's mode
|
||||
param there only round-trips the read-only *attribute*, not real ACL-based
|
||||
per-user access control (that needs pywin32/icacls, out of scope for a local
|
||||
single-user app whose own user-profile directory is already the actual access
|
||||
boundary on Windows). Skip rather than assert something the OS can't provide.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import stat
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.mail import backend as mail
|
||||
|
||||
_WINDOWS_NO_POSIX_MODE = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="0600 is a POSIX permission model; NTFS has no equivalent bits to assert on",
|
||||
)
|
||||
|
||||
|
||||
def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
||||
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
|
||||
|
||||
# The mode assertion only means something on POSIX; the rest of this test
|
||||
# (no temp file left behind, password round-trip) is platform-independent
|
||||
# and must keep running on Windows.
|
||||
if sys.platform != "win32":
|
||||
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
|
||||
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
|
||||
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
|
||||
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
|
||||
assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
|
||||
|
||||
# The password round-trips to disk but never to the API.
|
||||
@@ -47,7 +27,6 @@ def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
|
||||
assert json.loads((tmp_path / "mail_accounts.json").read_text())["accounts"]
|
||||
|
||||
|
||||
@_WINDOWS_NO_POSIX_MODE
|
||||
def test_account_file_is_0600_while_it_is_being_written(tmp_path, monkeypatch):
|
||||
"""The old code wrote at the umask and chmodded afterwards, so the file sat
|
||||
world-readable for the length of the write. Assert the handle it is written
|
||||
|
||||
@@ -22,7 +22,6 @@ def test_render_frame_contains_sections():
|
||||
"version": "0.0.0",
|
||||
"services": {
|
||||
"backend": {"running": True, "pid": 11, "url": "http://127.0.0.1:8000"},
|
||||
"memory": {"running": False, "pid": None, "url": "http://127.0.0.1:8001"},
|
||||
"frontend": {"running": False, "pid": None, "url": "http://127.0.0.1:5173"},
|
||||
"provider": {
|
||||
"provider": "ollama",
|
||||
@@ -55,7 +54,7 @@ def test_render_frame_contains_sections():
|
||||
assert "DATA / TOOLS" in frame
|
||||
assert "RUN TOOLCHAINS" in frame
|
||||
assert "backend" in frame and "UP" in frame
|
||||
assert "memory" in frame and "DOWN" in frame
|
||||
assert "frontend" in frame and "DOWN" in frame
|
||||
assert "run_snippet" in frame
|
||||
assert "ready python" in frame
|
||||
assert "missing rust" in frame
|
||||
|
||||
@@ -33,7 +33,7 @@ DISTRIBUTION_OF = {
|
||||
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
||||
|
||||
# Modules that ship inside this repo.
|
||||
FIRST_PARTY = {"synapse", "nexusos_cli", "management", "bin", "tests", "modules"}
|
||||
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
@@ -56,6 +56,12 @@ def _declared() -> set[str]:
|
||||
return {_requirement_name(s) for s in specs}
|
||||
|
||||
|
||||
def test_wheel_includes_every_shipped_package():
|
||||
wheel = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]
|
||||
configured = set(wheel["packages"])
|
||||
assert configured == set(SHIPPED_PACKAGES)
|
||||
|
||||
|
||||
def _imported_modules() -> set[str]:
|
||||
"""Top-level module names imported anywhere in the shipped packages."""
|
||||
found: set[str] = set()
|
||||
@@ -89,7 +95,7 @@ def test_every_third_party_import_is_a_declared_dependency():
|
||||
if DISTRIBUTION_OF.get(module, module).lower().replace("_", "-") not in declared
|
||||
)
|
||||
assert not missing, (
|
||||
"synapse/nexusos_cli import these, but pyproject.toml declares no "
|
||||
"a shipped package imports these, but pyproject.toml declares no "
|
||||
f"distribution for them: {missing}. Add them to [project] dependencies "
|
||||
"or an extra (and to DISTRIBUTION_OF here if the names differ)."
|
||||
)
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
"""Self-modification: synapse/self_edit.py, plus the ACTION_TOOLS/approval-floor
|
||||
wiring in tools.py and chat.py that gates it.
|
||||
|
||||
Path-boundary tests mirror the sibling-directory-bypass idiom already used for
|
||||
/icons/image (tests/test_smoke.py) and icons/compositor.py — the same bug class,
|
||||
fixed the same way, tested the same way.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from synapse import self_edit
|
||||
from synapse import tools
|
||||
from synapse import playbook_manager
|
||||
from synapse.nexus_config import settings
|
||||
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||
|
||||
|
||||
def _requires_git():
|
||||
return pytest.mark.skipif(not shutil.which("git"), reason="git not installed")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_project(tmp_path, monkeypatch):
|
||||
"""A throwaway project root with the subdirs edit_source must know about."""
|
||||
root = tmp_path / "proj"
|
||||
(root / "synapse").mkdir(parents=True)
|
||||
(root / ".git").mkdir()
|
||||
(root / "runtime").mkdir()
|
||||
(root / "data").mkdir()
|
||||
monkeypatch.setattr(settings, "project_root", root)
|
||||
monkeypatch.setattr(settings, "source_checkout", True)
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_source_path_allows_a_file_under_root(fake_project):
|
||||
f = fake_project / "synapse" / "foo.py"
|
||||
f.write_text("x", encoding="utf-8")
|
||||
resolved = self_edit._resolve_source_path("synapse/foo.py")
|
||||
assert resolved == f.resolve()
|
||||
|
||||
|
||||
def test_resolve_source_path_denies_sibling_directory_bypass(fake_project, tmp_path):
|
||||
# A sibling directory that merely shares a string prefix with the allowed
|
||||
# root ("proj-evil" vs "proj") must not pass — the exact bug class fixed
|
||||
# today in main.py's /icons/image.
|
||||
sibling = tmp_path / "proj-evil"
|
||||
sibling.mkdir()
|
||||
(sibling / "x.py").write_text("evil", encoding="utf-8")
|
||||
with pytest.raises(self_edit.PathError):
|
||||
self_edit._resolve_source_path("../proj-evil/x.py")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subdir", sorted(self_edit._DENYLIST_SUBDIRS))
|
||||
def test_resolve_source_path_denies_each_denylisted_subdir(fake_project, subdir):
|
||||
with pytest.raises(self_edit.PathError):
|
||||
self_edit._resolve_source_path(f"{subdir}/whatever.txt")
|
||||
|
||||
|
||||
def test_resolve_source_path_denies_absolute_paths(fake_project):
|
||||
with pytest.raises(self_edit.PathError):
|
||||
self_edit._resolve_source_path("C:\\Windows\\System32\\evil.py")
|
||||
with pytest.raises(self_edit.PathError):
|
||||
self_edit._resolve_source_path("/etc/passwd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# source_checkout gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_source_checkout_gating(fake_project, monkeypatch):
|
||||
monkeypatch.setattr(settings, "source_checkout", False)
|
||||
with pytest.raises(RuntimeError):
|
||||
self_edit.source_checkout_required()
|
||||
preview = self_edit.preview_source_edit("synapse/foo.py", "x")
|
||||
assert preview["ok"] is False
|
||||
assert "not a source checkout" in preview["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff computed from ground truth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_preview_computes_a_real_diff_not_the_models_claim(fake_project):
|
||||
f = fake_project / "synapse" / "foo.py"
|
||||
f.write_text("print('old')\n", encoding="utf-8")
|
||||
preview = self_edit.preview_source_edit("synapse/foo.py", "print('new')\n")
|
||||
assert preview["ok"] is True
|
||||
assert "-print('old')" in preview["diff"]
|
||||
assert "+print('new')" in preview["diff"]
|
||||
|
||||
|
||||
def test_preview_rejects_oversized_content(fake_project):
|
||||
huge = "x" * (self_edit.MAX_FILE_CHARS + 1)
|
||||
preview = self_edit.preview_source_edit("synapse/foo.py", huge)
|
||||
assert preview["ok"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply + git commit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_requires_git()
|
||||
def test_apply_source_edit_writes_and_commits(fake_project):
|
||||
subprocess.run(["git", "init"], cwd=fake_project, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@test"], cwd=fake_project, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "test"], cwd=fake_project, check=True, capture_output=True)
|
||||
|
||||
result = self_edit.apply_source_edit("synapse/foo.py", "print('applied')\n", "add foo")
|
||||
assert result["ok"] is True
|
||||
assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('applied')\n"
|
||||
assert result["commit"] is not None
|
||||
|
||||
log = subprocess.run(["git", "log", "--oneline"], cwd=fake_project, check=True,
|
||||
capture_output=True, text=True)
|
||||
assert "self-edit: add foo" in log.stdout
|
||||
|
||||
|
||||
def test_apply_source_edit_degrades_to_no_commit_when_git_fails(fake_project, monkeypatch):
|
||||
# No `git init` here — fake_project/.git exists as a plain directory, not a
|
||||
# real repo, so git commands fail. The write must still succeed.
|
||||
result = self_edit.apply_source_edit("synapse/foo.py", "print('ok')\n", "x")
|
||||
assert result["ok"] is True
|
||||
assert result["commit"] is None
|
||||
assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('ok')\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playbook merge semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fake_playbooks(tmp_path, monkeypatch):
|
||||
store = PlaybookFileStore(tmp_path / "playbooks")
|
||||
monkeypatch.setattr(playbook_manager, "playbook_store", store)
|
||||
monkeypatch.setattr(self_edit, "playbook_store", store)
|
||||
return store
|
||||
|
||||
|
||||
def test_edit_playbook_merge_preserves_omitted_fields(fake_playbooks):
|
||||
fake_playbooks.add_playbook(PlaybookItem(
|
||||
id="p1", title="Title", goal="Goal", instructions="Do X",
|
||||
tags=["a"], tools=["remember"], model="m1", order=0,
|
||||
))
|
||||
result = playbook_manager.persist_playbook({"id": "p1", "instructions": "Do Y"}, merge=True)
|
||||
assert result["instructions"] == "Do Y"
|
||||
assert result["title"] == "Title"
|
||||
assert result["goal"] == "Goal"
|
||||
assert result["tags"] == ["a"]
|
||||
assert result["tools"] == ["remember"]
|
||||
assert result["model"] == "m1"
|
||||
|
||||
|
||||
def test_edit_playbook_requires_full_fields_for_new(fake_playbooks):
|
||||
with pytest.raises(ValueError):
|
||||
playbook_manager.persist_playbook({"instructions": "only this"}, merge=True)
|
||||
|
||||
|
||||
def test_edit_playbook_create_appends_at_tail_not_main(fake_playbooks):
|
||||
fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
|
||||
result = playbook_manager.persist_playbook(
|
||||
{"title": "B", "goal": "g2", "instructions": "i2"}, merge=True,
|
||||
)
|
||||
assert result["order"] != 0
|
||||
main = fake_playbooks.all_playbooks()[0]
|
||||
assert main.id == "p1"
|
||||
|
||||
|
||||
def test_make_main_reassigns_order_zero_without_corrupting_the_rest(fake_playbooks):
|
||||
fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
|
||||
fake_playbooks.add_playbook(PlaybookItem(id="p2", title="B", goal="g", instructions="i", order=1))
|
||||
playbook_manager.make_main("p2")
|
||||
all_pb = fake_playbooks.all_playbooks()
|
||||
assert all_pb[0].id == "p2"
|
||||
assert {p.id for p in all_pb} == {"p1", "p2"}
|
||||
|
||||
|
||||
def test_preview_playbook_edit_flags_becomes_main(fake_playbooks):
|
||||
fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
|
||||
fake_playbooks.add_playbook(PlaybookItem(id="p2", title="B", goal="g", instructions="i", order=1))
|
||||
preview = self_edit.preview_playbook_edit({"id": "p2", "make_active": True})
|
||||
assert preview["ok"] is True
|
||||
assert preview["becomes_main_playbook"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings edit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_edit_settings_ignores_unknown_keys():
|
||||
preview = self_edit.preview_settings_edit({"changes": {"model": "llama3.1:8b", "not_a_real_key": 1}})
|
||||
assert preview["ok"] is True
|
||||
assert "model" in preview["applied"]
|
||||
assert "not_a_real_key" in preview["ignored_unknown"]
|
||||
|
||||
|
||||
def test_edit_settings_flags_policy_and_system_prompt_changes():
|
||||
preview = self_edit.preview_settings_edit({"changes": {"action_tool_policy": "allow"}})
|
||||
assert preview["policy_change"] is True
|
||||
preview2 = self_edit.preview_settings_edit({"changes": {"system_prompt": "be nice"}})
|
||||
assert preview2["system_prompt_change"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approval floor + payload enrichment (chat.py wiring)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _EditManager:
|
||||
"""Returns one edit_settings tool_call, then plain content."""
|
||||
def __init__(self, args):
|
||||
self.n = 0
|
||||
self.args = args
|
||||
|
||||
async def chat(self, **_):
|
||||
self.n += 1
|
||||
if self.n == 1:
|
||||
return {"role": "assistant",
|
||||
"tool_calls": [{"function": {"name": "edit_settings", "arguments": self.args}}]}
|
||||
return {"role": "assistant", "content": "done"}
|
||||
|
||||
|
||||
def _drive(policy, decision, args, monkeypatch, preview_stub=None):
|
||||
from synapse import chat as chatmod
|
||||
|
||||
async def fake_dispatch(name, call_args):
|
||||
return json.dumps({"ok": True})
|
||||
monkeypatch.setattr(tools, "dispatch", fake_dispatch)
|
||||
|
||||
if preview_stub is not None:
|
||||
monkeypatch.setattr(self_edit, "preview_for", lambda name, a: preview_stub)
|
||||
|
||||
async def run():
|
||||
messages = [{"role": "user", "content": "change a setting"}]
|
||||
schemas = tools.schemas_for(["edit_settings"])
|
||||
gen = chatmod._run_tool_loop(_EditManager(args), messages, "m", schemas, None, None,
|
||||
conversation_id="conv2", policy=policy)
|
||||
statuses = []
|
||||
approve_payload = None
|
||||
async for s in gen:
|
||||
statuses.append(s)
|
||||
if s.startswith("__approve__"):
|
||||
approve_payload = json.loads(s[len("__approve__"):])
|
||||
w = chatmod.pending_approvals["conv2"]
|
||||
w["decisions"] = {"edit_settings": decision}
|
||||
w["event"].set()
|
||||
return statuses, approve_payload
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_edit_settings_requires_approval_even_when_policy_is_allow(monkeypatch):
|
||||
statuses, payload = _drive("allow", True, {"changes": {"model": "x"}}, monkeypatch)
|
||||
assert any(s.startswith("__approve__") for s in statuses)
|
||||
assert payload is not None
|
||||
|
||||
|
||||
def test_approve_payload_carries_the_computed_preview(monkeypatch):
|
||||
stub = {"ok": True, "applied": {"model": {"before": "a", "after": "x"}}}
|
||||
statuses, payload = _drive("ask", True, {"changes": {"model": "x"}}, monkeypatch, preview_stub=stub)
|
||||
assert payload["actions"][0]["preview"] == stub
|
||||
|
||||
|
||||
def test_approve_payload_degrades_gracefully_when_preview_raises(monkeypatch):
|
||||
def boom(name, args):
|
||||
raise RuntimeError("preview exploded")
|
||||
monkeypatch.setattr(self_edit, "preview_for", boom)
|
||||
statuses, payload = _drive("ask", True, {"changes": {"model": "x"}}, monkeypatch)
|
||||
assert payload is not None
|
||||
assert payload["actions"][0]["preview"]["ok"] is False
|
||||
|
||||
|
||||
def test_action_tools_include_self_edit_and_are_gated_by_consent():
|
||||
allow = ["edit_playbook", "edit_settings", "edit_source"]
|
||||
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
|
||||
off = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=False)]
|
||||
assert set(on) == set(allow)
|
||||
assert off == []
|
||||
for name in allow:
|
||||
assert tools.is_action(name)
|
||||
assert name in self_edit.ALWAYS_ASK_TOOLS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: dispatch("edit_source", ...) through the real preview/apply path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_requires_git()
|
||||
def test_dispatch_edit_source_end_to_end(fake_project):
|
||||
subprocess.run(["git", "init"], cwd=fake_project, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@test"], cwd=fake_project, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "test"], cwd=fake_project, check=True, capture_output=True)
|
||||
|
||||
out = asyncio.run(tools.dispatch("edit_source", {
|
||||
"path": "synapse/foo.py", "new_content": "print('e2e')\n", "summary": "e2e test",
|
||||
}))
|
||||
payload = json.loads(out)
|
||||
assert payload["ok"] is True
|
||||
assert payload["commit"] is not None
|
||||
assert "nexus-edit" in payload["fence"]
|
||||
assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('e2e')\n"
|
||||
@@ -1,204 +0,0 @@
|
||||
"""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
|
||||
+56
-94
@@ -216,36 +216,6 @@ def test_icon_source_requires_real_allowed_file_boundary(tmp_path):
|
||||
module._ALLOWED_ROOTS[:] = old_roots
|
||||
|
||||
|
||||
def test_icons_image_endpoint_requires_real_allowed_root_boundary(tmp_path):
|
||||
# Sibling directories that merely share a string prefix with an allowed
|
||||
# root (e.g. "icons-other" vs "icons") must not pass the check.
|
||||
from synapse import main
|
||||
|
||||
allowed = tmp_path / "icons"
|
||||
allowed.mkdir()
|
||||
source = allowed / "app.svg"
|
||||
source.write_text("<svg />")
|
||||
sibling = tmp_path / "icons-other"
|
||||
sibling.mkdir()
|
||||
evil = sibling / "app.svg"
|
||||
evil.write_text("<svg />")
|
||||
|
||||
old_roots = list(main._ALLOWED_ICON_ROOTS)
|
||||
main._ALLOWED_ICON_ROOTS[:] = [str(allowed)]
|
||||
try:
|
||||
client = TestClient(app)
|
||||
ok = client.get("/icons/image", params={"path": str(source)})
|
||||
assert ok.status_code == 200
|
||||
|
||||
blocked = client.get("/icons/image", params={"path": str(evil)})
|
||||
assert blocked.status_code == 403
|
||||
|
||||
missing = client.get("/icons/image", params={"path": str(allowed / "missing.svg")})
|
||||
assert missing.status_code in (403, 404)
|
||||
finally:
|
||||
main._ALLOWED_ICON_ROOTS[:] = old_roots
|
||||
|
||||
|
||||
def test_ollama_stream_propagates_transport_errors(monkeypatch):
|
||||
"""A failing stream must surface, not be swallowed into an empty reply —
|
||||
and it must carry Ollama's own explanation, since that is the only part the
|
||||
@@ -534,6 +504,62 @@ def test_curator_drops_fabricated_facts():
|
||||
"i really prefer short answers over long explanations") is None
|
||||
|
||||
|
||||
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
|
||||
from synapse import main
|
||||
# Fake git so the test never touches the network. Behind → the remote
|
||||
# VERSION file, not this checkout's, is what the UI advertises.
|
||||
calls = {
|
||||
("rev-list", "--count", "HEAD..origin/main"): "3",
|
||||
("show", "origin/main:VERSION"): "9.9.9\n",
|
||||
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
|
||||
}
|
||||
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
|
||||
body = TestClient(app).get("/update/check").json()
|
||||
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
|
||||
|
||||
# An unreachable remote must not 500 the sidebar.
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("could not resolve host")
|
||||
monkeypatch.setattr(main, "_git", boom)
|
||||
body = TestClient(app).get("/update/check").json()
|
||||
assert body["behind"] == 0 and "could not resolve host" in body["error"]
|
||||
|
||||
|
||||
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
|
||||
import subprocess
|
||||
from synapse import main
|
||||
seen = {}
|
||||
|
||||
def fake_popen(argv, **kw):
|
||||
seen["argv"], seen["kw"] = argv, kw
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(main, "_update_running", False)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
client = TestClient(app)
|
||||
assert client.post("/update/apply").json()["started"] is True
|
||||
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
|
||||
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
|
||||
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
|
||||
|
||||
# Double-click must not launch a second pull/rebuild over the first.
|
||||
assert client.post("/update/apply").json()["started"] is False
|
||||
|
||||
|
||||
def test_preview_iframe_cannot_navigate_to_a_network_url():
|
||||
"""The child CSP blocks resource loads; the parent CSP must separately
|
||||
block a sandboxed frame from navigating its own browsing context."""
|
||||
index = (REPO_ROOT / "interface" / "web" / "index.html").read_text(encoding="utf-8")
|
||||
markdown = (REPO_ROOT / "interface" / "web" / "src" / "Markdown.jsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "frame-src data:" in index
|
||||
assert 'sandbox="allow-scripts"' in markdown
|
||||
assert "encodeURIComponent(doc)" in markdown
|
||||
assert "src={frameUrl}" in markdown
|
||||
assert "srcDoc={doc}" not in markdown
|
||||
|
||||
|
||||
def test_ollama_failures_surface_the_reason_not_just_the_status():
|
||||
"""Ollama answers every failure with {"error": "..."} and httpx's default
|
||||
message throws it away. A user hitting a retired cloud model saw
|
||||
@@ -623,67 +649,3 @@ def test_think_blocks_never_reach_the_reply():
|
||||
assert strip_think("rambling\n</think>\nThe answer") == "The answer"
|
||||
assert strip_think("a<think>b</think>c") == "ac"
|
||||
assert strip_think("no tags here") == "no tags here"
|
||||
|
||||
|
||||
def test_render_hint_is_only_added_when_the_tool_is_offered():
|
||||
"""The hint and the tool must share one condition. Unconditional, it turned
|
||||
up recited as fact inside an answer about LRU caches."""
|
||||
from synapse import tools as t
|
||||
assert t.wants_render_preview("draw me a chart")
|
||||
assert not t.wants_render_preview("Summarize what a thread-safe LRU cache needs")
|
||||
|
||||
|
||||
def test_preview_iframe_cannot_navigate_to_a_network_url():
|
||||
"""The child CSP blocks resource loads; the parent CSP must separately
|
||||
block a sandboxed frame from navigating its own browsing context."""
|
||||
index = (REPO_ROOT / "interface" / "web" / "index.html").read_text(encoding="utf-8")
|
||||
markdown = (REPO_ROOT / "interface" / "web" / "src" / "Markdown.jsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "frame-src data:" in index
|
||||
assert 'sandbox="allow-scripts"' in markdown
|
||||
assert "encodeURIComponent(doc)" in markdown
|
||||
assert "src={frameUrl}" in markdown
|
||||
assert "srcDoc={doc}" not in markdown
|
||||
|
||||
|
||||
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
|
||||
from synapse import main
|
||||
# Fake git so the test never touches the network. Behind → the remote
|
||||
# VERSION file, not this checkout's, is what the UI advertises.
|
||||
calls = {
|
||||
("rev-list", "--count", "HEAD..origin/main"): "3",
|
||||
("show", "origin/main:VERSION"): "9.9.9\n",
|
||||
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
|
||||
}
|
||||
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
|
||||
body = TestClient(app).get("/update/check").json()
|
||||
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
|
||||
|
||||
# An unreachable remote must not 500 the sidebar.
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("could not resolve host")
|
||||
monkeypatch.setattr(main, "_git", boom)
|
||||
body = TestClient(app).get("/update/check").json()
|
||||
assert body["behind"] == 0 and "could not resolve host" in body["error"]
|
||||
|
||||
|
||||
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
|
||||
import subprocess
|
||||
from synapse import main
|
||||
seen = {}
|
||||
|
||||
def fake_popen(argv, **kw):
|
||||
seen["argv"], seen["kw"] = argv, kw
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(main, "_update_running", False)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
client = TestClient(app)
|
||||
assert client.post("/update/apply").json()["started"] is True
|
||||
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
|
||||
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
|
||||
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
|
||||
|
||||
# Double-click must not launch a second pull/rebuild over the first.
|
||||
assert client.post("/update/apply").json()["started"] is False
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Automatic code-snippet probe suite.
|
||||
|
||||
Walks the catalog in tests/snippet_probes/catalog.py and, for every probe:
|
||||
|
||||
* screen — asserts critique rejects it (never executed)
|
||||
* run — asserts critique is clean, then runs via synapse.code_run
|
||||
(skipped cleanly when the host has no toolchain)
|
||||
* tool — same as run, plus tools.dispatch("run_snippet") envelope checks
|
||||
|
||||
Adding a language to RUN_LANGS without a smoke probe fails
|
||||
test_every_run_lang_has_a_smoke_probe. That is the point of the catalog:
|
||||
coverage is automatic and visible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from synapse import code_run, tools
|
||||
from tests.snippet_probes.catalog import PROBES, Probe
|
||||
|
||||
|
||||
def _has_toolchain(lang: str) -> bool:
|
||||
key = code_run.resolve_lang(lang)
|
||||
entry = code_run.RUN_LANGS.get(key)
|
||||
return bool(entry and entry["tool"]())
|
||||
|
||||
|
||||
def _ids(probes=PROBES):
|
||||
return [p.id for p in probes]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog integrity (runs even when every compiled toolchain is missing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_probe_ids_are_unique():
|
||||
ids = [p.id for p in PROBES]
|
||||
assert len(ids) == len(set(ids)), "duplicate probe ids in catalog"
|
||||
|
||||
|
||||
def test_every_run_lang_has_a_smoke_probe():
|
||||
"""New RUN_LANGS entry without a smoke probe = silent blind spot."""
|
||||
smoke = {
|
||||
code_run.resolve_lang(p.lang)
|
||||
for p in PROBES
|
||||
if "smoke" in p.tags and p.kind in ("run", "tool")
|
||||
}
|
||||
missing = set(code_run.RUN_LANGS) - smoke
|
||||
assert not missing, (
|
||||
f"RUN_LANGS without a smoke probe: {sorted(missing)}. "
|
||||
"Add a Probe(..., tags=('smoke', ...)) to tests/snippet_probes/catalog.py."
|
||||
)
|
||||
|
||||
|
||||
def test_every_run_lang_has_a_screen_probe():
|
||||
screened = {
|
||||
code_run.resolve_lang(p.lang)
|
||||
for p in PROBES
|
||||
if p.kind == "screen"
|
||||
}
|
||||
missing = set(code_run.RUN_LANGS) - screened
|
||||
assert not missing, (
|
||||
f"RUN_LANGS without a screening probe: {sorted(missing)}."
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_langs_resolve_into_run_langs_or_aliases():
|
||||
for probe in PROBES:
|
||||
key = code_run.resolve_lang(probe.lang)
|
||||
assert key in code_run.RUN_LANGS, (
|
||||
f"probe {probe.id!r} lang={probe.lang!r} resolves to unknown {key!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-probe execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("probe", PROBES, ids=_ids())
|
||||
def test_snippet_probe(probe: Probe):
|
||||
if probe.kind == "screen":
|
||||
_assert_screen(probe)
|
||||
return
|
||||
|
||||
issues = code_run.critique(probe.lang, probe.source)
|
||||
assert issues == [], f"{probe.id}: unexpected critique issues: {issues}"
|
||||
|
||||
if not _has_toolchain(probe.lang):
|
||||
pytest.skip(f"no {code_run.resolve_lang(probe.lang)} toolchain on this machine")
|
||||
|
||||
result = code_run.run(probe.lang, probe.source, stdin=probe.stdin)
|
||||
_assert_run_result(probe, result)
|
||||
|
||||
if probe.kind == "tool":
|
||||
_assert_tool_envelope(probe, result)
|
||||
|
||||
|
||||
def _assert_screen(probe: Probe) -> None:
|
||||
assert probe.screen_needle, f"{probe.id}: screen probe needs screen_needle"
|
||||
issues = code_run.critique(probe.lang, probe.source)
|
||||
assert issues, f"{probe.id}: expected screening to reject the snippet"
|
||||
assert any(probe.screen_needle in i for i in issues), (
|
||||
f"{probe.id}: needle {probe.screen_needle!r} not in {issues}"
|
||||
)
|
||||
# Screening is the whole point — do not execute a rejected snippet.
|
||||
# (A future change that runs despite issues would be a security regression.)
|
||||
|
||||
|
||||
def _assert_run_result(probe: Probe, result: dict) -> None:
|
||||
assert result.get("ok") is probe.expect_ok, (
|
||||
f"{probe.id}: ok={result.get('ok')} expected {probe.expect_ok}; full={result}"
|
||||
)
|
||||
if probe.expect_stage is not None:
|
||||
assert result.get("stage") == probe.expect_stage, result
|
||||
|
||||
if not probe.expect_ok:
|
||||
# Failure path: still check optional stderr/stdout breadcrumbs.
|
||||
for needle in probe.expect_stderr_contains:
|
||||
assert needle in (result.get("stderr") or ""), result
|
||||
for needle in probe.expect_stdout_contains:
|
||||
assert needle in (result.get("stdout") or ""), result
|
||||
return
|
||||
|
||||
stdout = (result.get("stdout") or "").strip()
|
||||
stderr = (result.get("stderr") or "")
|
||||
if probe.expect_stdout is not None:
|
||||
assert stdout == probe.expect_stdout, (
|
||||
f"{probe.id}: stdout {stdout!r} != {probe.expect_stdout!r}"
|
||||
)
|
||||
for needle in probe.expect_stdout_contains:
|
||||
assert needle in stdout, result
|
||||
for needle in probe.expect_stderr_contains:
|
||||
assert needle in stderr, result
|
||||
if probe.expect_exit is not None:
|
||||
assert result.get("exit_code") == probe.expect_exit, result
|
||||
else:
|
||||
# Explicit "don't care" still requires that a run happened.
|
||||
assert "exit_code" in result, result
|
||||
|
||||
|
||||
def _assert_tool_envelope(probe: Probe, direct: dict) -> None:
|
||||
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
||||
"lang": probe.lang,
|
||||
"source": probe.source,
|
||||
"stdin": probe.stdin,
|
||||
})))
|
||||
assert out.get("ok") is probe.expect_ok, out
|
||||
assert "fence" in out and out["fence"].startswith("```nexus-run\n"), out
|
||||
body = out["fence"].split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
envelope = json.loads(body)
|
||||
assert envelope.get("lang") == code_run.resolve_lang(probe.lang)
|
||||
if probe.expect_stdout is not None:
|
||||
assert (envelope.get("stdout") or "").strip() == probe.expect_stdout
|
||||
# Direct driver and tool path must agree on exit for successful runs.
|
||||
if probe.expect_ok and probe.expect_exit is not None:
|
||||
assert out.get("exit_code") == direct.get("exit_code") == probe.expect_exit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One-shot inventory (useful when running the file directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_probe_inventory_lists_toolchain_readiness():
|
||||
"""Not an assertion about readiness — just fails if the inventory shape
|
||||
breaks, so `pytest -k inventory -s` is a quick host capability dump."""
|
||||
rows = []
|
||||
for name, entry in code_run.RUN_LANGS.items():
|
||||
tool = entry["tool"]()
|
||||
n = sum(1 for p in PROBES if code_run.resolve_lang(p.lang) == name)
|
||||
rows.append({"lang": name, "ready": bool(tool), "probes": n, "tool": tool})
|
||||
assert rows and all(r["probes"] >= 1 for r in rows)
|
||||
# Printed only under -s; kept as a structured object for debuggability.
|
||||
print("snippet-probe inventory:", json.dumps(rows, indent=2))
|
||||
+103
-170
@@ -10,7 +10,6 @@ import asyncio
|
||||
import json
|
||||
|
||||
from synapse import tools
|
||||
from synapse import code_run
|
||||
from synapse.chat import _run_tool_loop
|
||||
|
||||
|
||||
@@ -94,6 +93,52 @@ def test_ask_policy_skips_on_deny(monkeypatch):
|
||||
assert any(m["role"] == "tool" and "declined" in m["content"] for m in messages)
|
||||
|
||||
|
||||
class _ContentJsonActionManager:
|
||||
"""Small-model shape: dumps the action call into `content`, no native
|
||||
`tool_calls` field — the lower-confidence path the "allow" bypass must
|
||||
not trust."""
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
|
||||
async def chat(self, **_):
|
||||
self.n += 1
|
||||
if self.n == 1:
|
||||
return {"role": "assistant",
|
||||
"content": json.dumps({"name": "remember", "arguments": {"text": "x"}})}
|
||||
return {"role": "assistant", "content": "done"}
|
||||
|
||||
|
||||
def test_content_json_action_call_asks_even_under_allow_policy(monkeypatch):
|
||||
"""A call recovered by guessing at `content` is weaker evidence than the
|
||||
API's own structured tool_calls field — a model can land on JSON shaped
|
||||
like a call while only meaning to describe one. It must still go through
|
||||
approval even when action_tool_policy is "allow", the default that lets a
|
||||
*native* tool_calls field run unattended."""
|
||||
from synapse import chat as chatmod
|
||||
|
||||
async def fake_dispatch(name, args):
|
||||
return "saved-ok"
|
||||
monkeypatch.setattr(tools, "dispatch", fake_dispatch)
|
||||
|
||||
async def run():
|
||||
messages = [{"role": "user", "content": "remember x"}]
|
||||
schemas = tools.schemas_for(["remember"])
|
||||
gen = chatmod._run_tool_loop(_ContentJsonActionManager(), messages, "m", schemas, None, None,
|
||||
conversation_id="conv", policy="allow")
|
||||
statuses = []
|
||||
async for s in gen:
|
||||
statuses.append(s)
|
||||
if s.startswith("__approve__"):
|
||||
w = chatmod.pending_approvals["conv"]
|
||||
w["decisions"] = {"remember": True}
|
||||
w["event"].set()
|
||||
return statuses
|
||||
|
||||
statuses = asyncio.run(run())
|
||||
assert any(s.startswith("__approve__") for s in statuses)
|
||||
assert "__status__remember" in statuses
|
||||
|
||||
|
||||
def test_action_tools_gated_by_consent():
|
||||
allow = ["search_memory", "web_search", "remember", "fetch_url"]
|
||||
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
|
||||
@@ -154,6 +199,63 @@ def test_tool_loop_degrades_when_model_returns_no_dict():
|
||||
assert messages == before # untouched -> falls back to a plain stream
|
||||
|
||||
|
||||
def test_read_file_stays_inside_the_repo():
|
||||
"""The repo-file tools are the fix for the model inventing paths like
|
||||
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
|
||||
import json
|
||||
|
||||
def read(p):
|
||||
return asyncio.run(tools._read_file(p))
|
||||
|
||||
assert "escapes" in read("../../etc/passwd")
|
||||
# a leading slash is treated as repo-relative, so it lands nowhere real
|
||||
assert "root:" not in read("/etc/passwd")
|
||||
assert "required" in read("")
|
||||
# private data and heavy trees are refused even though they're in-repo
|
||||
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
|
||||
assert "not readable" in read(denied), denied
|
||||
assert "does not exist" in read("nexus/nlp.py")
|
||||
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
|
||||
|
||||
|
||||
def test_list_files_globs_the_repo_without_leaking_denied_paths():
|
||||
import json
|
||||
|
||||
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
|
||||
assert "synapse/main.py" in hits
|
||||
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
|
||||
|
||||
|
||||
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
|
||||
"""A reference playbook routed into the prompt must bring its tools with it.
|
||||
Without this the model reads instructions like "you can read the codebase"
|
||||
while being advertised zero tools — and narrates tool calls it never made."""
|
||||
from synapse.main import _route_playbooks
|
||||
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||
|
||||
# Own store, not data/playbooks: the live set is the operator's, and a
|
||||
# published clone ships different playbooks - this asserted on data that
|
||||
# travels with one machine.
|
||||
store = PlaybookFileStore(tmp_path)
|
||||
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
|
||||
instructions="i", order=0))
|
||||
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
|
||||
instructions="You can read the codebase.", order=1,
|
||||
tags=["synapse", "backend"],
|
||||
tools=["read_file", "list_files"]))
|
||||
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
||||
import synapse.playbook_manager as pm
|
||||
|
||||
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
|
||||
names = {pb.title for pb in routed}
|
||||
assert "NexusOS Developer" in names, names
|
||||
|
||||
granted = {t for pb in routed for t in (pb.tools or [])}
|
||||
assert {"read_file", "list_files"} <= granted, granted
|
||||
# none of them are action tools, so they survive the default policy (off)
|
||||
assert tools.schemas_for(sorted(granted), allow_actions=False)
|
||||
|
||||
|
||||
def test_standing_schemas_include_render_preview():
|
||||
names = [s["function"]["name"] for s in tools.standing_schemas()]
|
||||
assert names == ["render_preview"]
|
||||
@@ -288,118 +390,6 @@ def test_preview_langs_match_the_frontend_registry():
|
||||
)
|
||||
|
||||
|
||||
_FRONTEND_RUN_REGISTRY = ("interface", "web", "src", "preview", "run-langs.js")
|
||||
|
||||
|
||||
def _frontend_registry_keys(parts: tuple, name: str) -> list:
|
||||
"""Top-level keys of a `export const <name> = {...}` object literal."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
src = Path(__file__).resolve().parents[1].joinpath(*parts)
|
||||
text = src.read_text(encoding="utf-8")
|
||||
body = re.search(rf"^export const {name} = \{{\n(.*?)^\}};", text, re.S | re.M)
|
||||
assert body, f"could not find a {name} object literal in {src}"
|
||||
return re.findall(r"^ (\w+):", body.group(1), re.M)
|
||||
|
||||
|
||||
def test_run_langs_match_the_frontend_registry():
|
||||
"""Same failure mode as the preview registries, one track over: a language
|
||||
the backend can run but the frontend does not know about renders as raw JSON
|
||||
in the chat, and one the frontend labels but the backend refuses produces a
|
||||
tool error the user never asked for. Nothing at runtime couples them."""
|
||||
assert _frontend_registry_keys(_FRONTEND_RUN_REGISTRY, "RUN_LANGS") == list(
|
||||
code_run.RUN_LANGS
|
||||
), (
|
||||
"RUN_LANGS differs between synapse/code_run.py and "
|
||||
"interface/web/src/preview/run-langs.js - add the language to both."
|
||||
)
|
||||
|
||||
|
||||
def test_run_fence_tag_matches_the_frontend():
|
||||
"""The tag is the handshake: run_snippet emits it, Markdown.jsx dispatches on
|
||||
it. A mismatch shows the JSON envelope to the user as a code block."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
src = Path(__file__).resolve().parents[1].joinpath(*_FRONTEND_RUN_REGISTRY)
|
||||
m = re.search(r'export const RUN_FENCE_LANG = "([^"]+)"', src.read_text(encoding="utf-8"))
|
||||
assert m and m.group(1) == tools._RUN_FENCE_LANG
|
||||
|
||||
|
||||
def test_run_lang_enum_is_derived_not_repeated():
|
||||
schema, _ = tools.REGISTRY["run_snippet"]
|
||||
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
|
||||
assert enum == list(code_run.RUN_LANGS)
|
||||
|
||||
|
||||
def test_run_snippet_is_an_action_tool():
|
||||
"""It executes code on the host, so action_tool_policy has to gate it.
|
||||
Slipping into STANDING_TOOLS (where render_preview lives, ungated) would make
|
||||
every 'run this' a subprocess with no consent step anywhere."""
|
||||
assert tools.is_action("run_snippet")
|
||||
assert "run_snippet" not in tools.STANDING_TOOLS
|
||||
assert "run_snippet" in tools.CUED_ACTION_TOOLS
|
||||
# ...and withholding actions has to actually withhold it.
|
||||
assert tools.schemas_for(["run_snippet"], allow_actions=False) == []
|
||||
|
||||
|
||||
def test_wants_code_run_needs_a_verb_not_a_language():
|
||||
"""A language name must not arm the run track. `python` in _RUN_HINTS would
|
||||
drag every mention of the language into a non-stream tool round - the exact
|
||||
'stuck thinking' problem that kept render_preview off by default."""
|
||||
assert tools.wants_code_run("run this and show me the output")
|
||||
assert tools.wants_code_run("does this compile?")
|
||||
assert not tools.wants_code_run("write me a python function that sorts a list")
|
||||
assert not tools.wants_code_run("explain how rust ownership works")
|
||||
|
||||
|
||||
def test_run_snippet_rejects_a_preview_language():
|
||||
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
||||
"lang": "html", "source": "<p>hello there</p>",
|
||||
})))
|
||||
assert out["ok"] is False
|
||||
assert "render_preview" in out["error"]
|
||||
|
||||
|
||||
def test_run_snippet_fence_survives_backticks_in_the_source():
|
||||
"""A backtick in the source would close the ```nexus-run fence early, and the
|
||||
rest of the envelope would spill into the chat as prose."""
|
||||
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
||||
"lang": "python", "source": "s = '``` still inside'\nprint(len(s))",
|
||||
})))
|
||||
assert out["ok"] is True, out
|
||||
body = out["fence"].split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
assert "```" not in body
|
||||
assert json.loads(body)["source"].startswith("s = '```")
|
||||
|
||||
|
||||
def test_run_snippet_reports_a_program_that_fails():
|
||||
"""A non-zero exit is a successful run, not a tool failure: its stderr is the
|
||||
answer. Reporting ok=False here would send the model into a retry loop over
|
||||
a program that did exactly what it was asked to demonstrate."""
|
||||
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
||||
"lang": "python",
|
||||
"source": "import sys\nprint('before')\nsys.exit(2)",
|
||||
})))
|
||||
assert out["ok"] is True
|
||||
assert out["exit_code"] == 2
|
||||
assert "before" in out["stdout"]
|
||||
|
||||
|
||||
def test_run_snippet_escalates_a_scaffold_on_retry():
|
||||
"""First host-code reject is issues-only; second gets an entry-point pattern.
|
||||
_attempt is supplied by the tool loop."""
|
||||
first = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
||||
"lang": "python", "source": "import socket\nprint(1)", "_attempt": 0,
|
||||
})))
|
||||
assert first["ok"] is False
|
||||
assert "scaffold" not in first
|
||||
second = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
||||
"lang": "python", "source": "import socket\nprint(1)", "_attempt": 1,
|
||||
})))
|
||||
assert second["ok"] is False
|
||||
assert "scaffold" in second and "print" in second["scaffold"]
|
||||
|
||||
|
||||
def test_preview_lang_enum_is_derived_not_repeated():
|
||||
schema, _ = tools.REGISTRY["render_preview"]
|
||||
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
|
||||
@@ -644,60 +634,3 @@ async def _drain_with_messages(manager, model, schemas, user="draw a circle"):
|
||||
_run_tool_loop(manager, messages, model, schemas, None, None)
|
||||
)
|
||||
return statuses, messages
|
||||
|
||||
|
||||
def test_read_file_stays_inside_the_repo():
|
||||
"""The repo-file tools are the fix for the model inventing paths like
|
||||
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
|
||||
import json
|
||||
|
||||
def read(p):
|
||||
return asyncio.run(tools._read_file(p))
|
||||
|
||||
assert "escapes" in read("../../etc/passwd")
|
||||
# a leading slash is treated as repo-relative, so it lands nowhere real
|
||||
assert "root:" not in read("/etc/passwd")
|
||||
assert "required" in read("")
|
||||
# private data and heavy trees are refused even though they're in-repo
|
||||
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
|
||||
assert "not readable" in read(denied), denied
|
||||
assert "does not exist" in read("nexus/nlp.py")
|
||||
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
|
||||
|
||||
|
||||
def test_list_files_globs_the_repo_without_leaking_denied_paths():
|
||||
import json
|
||||
|
||||
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
|
||||
assert "synapse/main.py" in hits
|
||||
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
|
||||
|
||||
|
||||
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
|
||||
"""A reference playbook routed into the prompt must bring its tools with it.
|
||||
Without this the model reads instructions like "you can read the codebase"
|
||||
while being advertised zero tools — and narrates tool calls it never made."""
|
||||
from synapse.main import _route_playbooks
|
||||
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
||||
|
||||
# Own store, not data/playbooks: the live set is the operator's, and a
|
||||
# published clone ships different playbooks - this asserted on data that
|
||||
# travels with one machine.
|
||||
store = PlaybookFileStore(tmp_path)
|
||||
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
|
||||
instructions="i", order=0))
|
||||
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
|
||||
instructions="You can read the codebase.", order=1,
|
||||
tags=["synapse", "backend"],
|
||||
tools=["read_file", "list_files"]))
|
||||
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
||||
import synapse.playbook_manager as pm
|
||||
|
||||
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
|
||||
names = {pb.title for pb in routed}
|
||||
assert "NexusOS Developer" in names, names
|
||||
|
||||
granted = {t for pb in routed for t in (pb.tools or [])}
|
||||
assert {"read_file", "list_files"} <= granted, granted
|
||||
# none of them are action tools, so they survive the default policy (off)
|
||||
assert tools.schemas_for(sorted(granted), allow_actions=False)
|
||||
|
||||
+67
-85
@@ -98,7 +98,7 @@ def test_finish_stream_markup_does_not_wedge_busy():
|
||||
async def _run():
|
||||
async with app.run_test():
|
||||
app._busy = True
|
||||
app._finish_stream("see [/] and arr[i]")
|
||||
app._finish_stream("see [/] and arr[i]", app.history)
|
||||
assert app._busy is False
|
||||
assert app.history[-1]["content"] == "see [/] and arr[i]"
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_stream_error_remains_visible_after_finish():
|
||||
async with app.run_test():
|
||||
app._busy = True
|
||||
app._show_error("[red]Backend not reachable[/]")
|
||||
app._finish_stream("")
|
||||
app._finish_stream("", app.history)
|
||||
log = app.query_one("#log")
|
||||
assert any("Backend not reachable" in line.text for line in log.lines)
|
||||
assert app._busy is False
|
||||
@@ -225,6 +225,71 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_new_mid_stream_does_not_leak_reply_into_next_conversation(monkeypatch):
|
||||
"""A stream still in flight when /new resets self.history must keep
|
||||
appending its reply to the conversation it was actually answering, not
|
||||
whatever self.history now points at - otherwise the old reply's text
|
||||
silently rides along in the next request's history payload."""
|
||||
pytest.importorskip("textual")
|
||||
import nexusos_cli.tui_app as tui_app
|
||||
|
||||
stream_started = threading.Event()
|
||||
release_stream = threading.Event()
|
||||
|
||||
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 'data: "the old reply"'
|
||||
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()
|
||||
|
||||
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
|
||||
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("first question")
|
||||
assert await asyncio.to_thread(stream_started.wait, 2)
|
||||
old_history = app.history
|
||||
app._handle_slash("/new")
|
||||
assert app.history is not old_history
|
||||
release_stream.set()
|
||||
for _ in range(200):
|
||||
if not app._busy:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert app._busy is False
|
||||
# The reply landed on the abandoned conversation's own list...
|
||||
assert any(m["content"] == "the old reply" for m in old_history)
|
||||
# ...never on the fresh one /new started.
|
||||
assert app.history == []
|
||||
|
||||
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
|
||||
@@ -301,86 +366,3 @@ def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
|
||||
|
||||
def test_escape_round_trip_helper():
|
||||
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")
|
||||
|
||||
|
||||
def test_slash_tool_call_shape_forwards_to_start_chat(monkeypatch):
|
||||
"""/tool_name(arg=val) isn't a local meta-command — it must reach the
|
||||
backend (synapse/slash_commands.py + chat_stream_endpoint dispatch it),
|
||||
not fall into the generic 'unknown command' branch."""
|
||||
pytest.importorskip("textual")
|
||||
from nexusos_cli.tui_app import NexusTUI
|
||||
|
||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
|
||||
|
||||
async def _run():
|
||||
async with app.run_test():
|
||||
text = '/curry_call_function(name="double", version=1, args={"x": 21})'
|
||||
app._handle_slash(text)
|
||||
assert calls == [text]
|
||||
log = app.query_one("#log")
|
||||
assert not any("unknown command" in line.text for line in log.lines)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_slash_malformed_tool_call_still_forwards_for_the_backend_error(monkeypatch):
|
||||
"""Even a malformed /tool(...) is forwarded rather than swallowed locally
|
||||
— the backend's parser gives a clearer, more specific error than the
|
||||
TUI's generic 'unknown command' would."""
|
||||
pytest.importorskip("textual")
|
||||
from nexusos_cli.tui_app import NexusTUI
|
||||
|
||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
|
||||
|
||||
async def _run():
|
||||
async with app.run_test():
|
||||
text = "/curry_call_function(x=__import__('os'))"
|
||||
app._handle_slash(text)
|
||||
assert calls == [text]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_slash_local_meta_commands_still_handled_locally(monkeypatch):
|
||||
"""A known local command must still be handled in-TUI, never forwarded —
|
||||
the new tool-call passthrough is strictly the fallback branch."""
|
||||
pytest.importorskip("textual")
|
||||
from nexusos_cli.tui_app import NexusTUI
|
||||
|
||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
|
||||
|
||||
async def _run():
|
||||
async with app.run_test():
|
||||
app._handle_slash("/help")
|
||||
assert calls == []
|
||||
log = app.query_one("#log")
|
||||
assert any("this list" in line.text for line in log.lines)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_slash_unknown_bare_command_still_rejected(monkeypatch):
|
||||
"""A genuinely unknown command (no parens, not a local command) keeps the
|
||||
existing 'unknown command' behavior rather than silently forwarding
|
||||
anything that starts with /."""
|
||||
pytest.importorskip("textual")
|
||||
from nexusos_cli.tui_app import NexusTUI
|
||||
|
||||
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
|
||||
|
||||
async def _run():
|
||||
async with app.run_test():
|
||||
app._handle_slash("/frobnicate")
|
||||
assert calls == []
|
||||
log = app.query_one("#log")
|
||||
assert any("unknown command" in line.text for line in log.lines)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
Reference in New Issue
Block a user