forked from enderofwings/NexusOS
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f20975cd | ||
|
|
6d6aa8bdb0 | ||
|
|
12af13b019 | ||
|
|
a06366001b | ||
|
|
3e4fc9beb3 | ||
|
|
e60ed13361 | ||
|
|
2614bd10a6 | ||
|
|
35f7461ca4 | ||
|
|
5ea8b2ad72 | ||
|
|
99381f7e9e | ||
|
|
ef176dbb68 | ||
|
|
a0f033142f | ||
|
|
8bc8123bfa | ||
|
|
c4d7dc42f2 | ||
|
|
0d26f630e6 | ||
|
|
3e89df142b | ||
|
|
214ce07d1f | ||
|
|
93d78c0ad3 | ||
|
|
9ed2908170 | ||
|
|
9ca37057eb | ||
|
|
da3509eb04 | ||
|
|
6f5094b5fc | ||
|
|
1449280fcd | ||
|
|
5f67d19e80 | ||
|
|
9104c724c4 | ||
|
|
d579502a5b | ||
|
|
952ef8a0c4 | ||
|
|
7262e7730e | ||
|
|
656c14caf3 | ||
|
|
2ebe93b4f7 | ||
|
|
d56d579755 | ||
|
|
c3a7b6eefd |
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: ponyman
|
||||||
|
description: "Minimalist coding agent with a caveman-speaking toggle. Use for pragmatic bug fixes, small implementations, reviews, and cleanup where the shortest correct solution matters. Say 'caveman mode' for compressed speech or 'normal mode' for standard speech."
|
||||||
|
tools: [Read, Grep, Glob, Edit, Write, Bash, TodoWrite]
|
||||||
|
---
|
||||||
|
You are Ponytail Caveman, a pragmatic senior coding agent.
|
||||||
|
|
||||||
|
Your engineering rule is ponytail minimalism: understand the real control path, reuse existing code, prefer the standard library and native platform features, and make the smallest correct change. Fix root causes. Do not add speculative abstractions, dependencies, boilerplate, or unrelated refactors. Never simplify away security, validation, error handling, accessibility, or tests needed to protect changed behavior. After this statement, the rest of the readme will be in caveman talk to provide a reference for how it should sound.
|
||||||
|
|
||||||
|
Caveman talk dumb. Grunt words. "Me", "you", "big", "broke", "good". Short. Sound like cave person poke rock with stick. BUT point always land — reader still know what happen and what do next. Dumb sound, smart meaning. Keep code, file name, command, error word exact — no dumb those.
|
||||||
|
|
||||||
|
## How Me Talk
|
||||||
|
- No word say: me talk normal. Clear.
|
||||||
|
- You say `caveman mode`, `talk caveman`, or `/caveman`: me go dumb caveman. Still say enough, point land.
|
||||||
|
- You say `normal mode`, `talk normally`, or `/caveman normal`: me talk normal again.
|
||||||
|
- Me keep same talk till you change it.
|
||||||
|
- Talk change word only. Me brain and safe stay smart.
|
||||||
|
|
||||||
|
## Me Do Work Like This
|
||||||
|
1. Find thing. File, symbol, broke part, command, or test.
|
||||||
|
2. Look small part near. Make one guess me can prove wrong. Pick one cheap check.
|
||||||
|
3. Fix right code path. Smallest patch. No more.
|
||||||
|
4. Run small check. Now, not later.
|
||||||
|
5. Add or fix test for tricky part. Security, save-data, parse, error path — these most.
|
||||||
|
6. Run big check when change touch many module.
|
||||||
|
7. Other dirty change — no touch. Never reset, revert, commit, or make branch unless you ask.
|
||||||
|
|
||||||
|
## Me Pick Tool
|
||||||
|
- Read and look before me edit.
|
||||||
|
- Use pattern and command repo already got.
|
||||||
|
- Use real parser/API for structured data.
|
||||||
|
- Use `apply_patch` for hand edit.
|
||||||
|
- Like focused test, lint, typecheck, or build more than diff-only check.
|
||||||
|
- Comment rare, only useful. No talk what code already say.
|
||||||
|
|
||||||
|
## Me Say Back
|
||||||
|
Normal mode: say what change, what me check, what risk left. Few short line.
|
||||||
|
|
||||||
|
Caveman mode: dumb short grunt, point still land. Like this:
|
||||||
|
`Me fix big bug. Add test. pytest: 8 pass. One warning still there — old deprecation, no scare.`
|
||||||
|
|
||||||
|
Look-over work: bad thing first, worst on top, with file link and how me fix. No bad thing → say so, name test gap or risk left.
|
||||||
@@ -13,7 +13,6 @@ synapse/memory/memory.db
|
|||||||
synapse/memory/memory.db-wal
|
synapse/memory/memory.db-wal
|
||||||
synapse/memory/memory.db-shm
|
synapse/memory/memory.db-shm
|
||||||
assets/gitnexus-logo.svg
|
assets/gitnexus-logo.svg
|
||||||
/data/curry.db
|
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ cd interface/web && npm run build
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Python venv
|
### 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). macOS uses `requirements-base.txt` without an overlay because Ollama handles inference outside the venv. `bin/sync.py` (`requirements()`) selects the appropriate requirements for 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.
|
`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 +134,9 @@ A bundled Ollama binary lives at `ollama/bin/ollama`. `OllamaManager` in `synaps
|
|||||||
### Frontend (`interface/web/`)
|
### 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.
|
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
|
### Persistent Storage
|
||||||
Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import.
|
Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import.
|
||||||
|
|
||||||
### Curry (`synapse/curry_core.py` + `synapse/curry_store.py`)
|
|
||||||
`curry_core.py` is vendored from [Athena-Pro/Curry](https://github.com/Athena-Pro/Curry), with two deliberate deviations from upstream documented in the file's own docstring (a sandbox-escape fix and a `check_same_thread=False` connection fix) — an immutable, versioned fact store (constants, functions, model registrations, inference provenance) backed by its own SQLite file (`CURRY_DB` in `nexus_config.py`, separate from `memory.db`). `curry_store.py` opens it into a module-level singleton (`curry_db`) at import time — the same pattern as `memory.store.store` / `playbooks.store.playbook_store` — so it's preloaded and callable from anywhere in the backend without extra setup. It ships inside the wheel (`bin/check.sh`'s packaging gate asserts this) and has no external dependencies of its own. Ten `curry_*` tools in `tools.py` expose it to chat (`curry_declare_constant`, `curry_call_function`, etc.); the five that write or execute are ACTION tools in `ALWAYS_ASK_ACTION_TOOLS`, same approval floor as `edit_source`. Re-sync `curry_core.py` from upstream by hand, not by script.
|
|
||||||
|
|
||||||
### Direct tool invocation (`synapse/slash_commands.py`)
|
|
||||||
A chat message that's nothing but `/tool_name(arg=val, ...)` (Python-call-shaped, arguments parsed via `ast.literal_eval` only — no names, no calls, no attribute access) dispatches straight through `tools.dispatch()`, skipping model selection, context assembly, and the ask-policy approval round-trip. A human typing it is the approval. Wired into `chat_stream_endpoint` as an early short-circuit; the TUI's `_handle_slash` falls through to the backend for anything shaped like a tool call that isn't one of its own local meta-commands (`/help`, `/model`, `/new`).
|
|
||||||
|
|
||||||
### Logs & Runtime State
|
### Logs & Runtime State
|
||||||
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
|
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
|
||||||
- `runtime/logs/ollama.log`, `runtime/logs/chat.log`
|
- `runtime/logs/ollama.log`, `runtime/logs/chat.log`
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
# NexusOS
|
# NexusOS
|
||||||
|
|
||||||
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
||||||
Python/FastAPI backend, a bundled Ollama instance for inference, persistent
|
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
|
||||||
memory, and a React frontend. No external AI provider is called. Ollama is
|
persistent memory service, and a React frontend. Ollama is local by default;
|
||||||
local by default; Termux and container installs can explicitly point at a
|
Termux and container installs can explicitly point at a separately managed
|
||||||
separately managed endpoint.
|
endpoint.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ backend/frontend/Ollama stack itself runs natively, no VM or container needed.
|
|||||||
# web UI, memory DB.
|
# web UI, memory DB.
|
||||||
./install-macos.sh
|
./install-macos.sh
|
||||||
|
|
||||||
# 3. Launch (memory :8001, backend :8000 — backend also serves the built UI)
|
# 3. Launch (backend :8000 — also serves the built UI)
|
||||||
./launch_nexus.sh
|
./launch_nexus.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+6
-16
@@ -9,18 +9,14 @@ cd "$(dirname "$0")/.."
|
|||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
|
|
||||||
if [ -x Promethean/bin/python ]; then
|
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
|
|
||||||
echo "!! no Promethean venv - run ./install.sh first" >&2
|
echo "!! no Promethean venv - run ./install.sh first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== pytest =="
|
echo "== pytest =="
|
||||||
# Explicit dirs: a bare `pytest` would walk Promethean/ and node_modules too.
|
# 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 =="
|
echo "== eslint =="
|
||||||
if [ -d interface/web/node_modules ]; then
|
if [ -d interface/web/node_modules ]; then
|
||||||
@@ -61,13 +57,13 @@ done
|
|||||||
echo "== packaging =="
|
echo "== packaging =="
|
||||||
# The wheel is the other shippable artifact, so it belongs in the same gate:
|
# 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.
|
# 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
|
rm -rf .build-check
|
||||||
if "$NEXUS_CHECK_PY" -m build --outdir .build-check >/dev/null 2>&1; then
|
if Promethean/bin/python -m build --outdir .build-check >/dev/null 2>&1; then
|
||||||
"$NEXUS_CHECK_PY" -m twine check .build-check/* || fail=1
|
Promethean/bin/python -m twine check .build-check/* || fail=1
|
||||||
# The compiled UI has to actually be inside the wheel - a wheel that
|
# The compiled UI has to actually be inside the wheel - a wheel that
|
||||||
# builds but ships no dist/ serves a blank page.
|
# 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
|
import glob, sys, zipfile
|
||||||
wheels = glob.glob(".build-check/*.whl")
|
wheels = glob.glob(".build-check/*.whl")
|
||||||
if not wheels:
|
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)")
|
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
|
||||||
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
|
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
|
||||||
sys.exit("wheel is missing the seed playbooks")
|
sys.exit("wheel is missing the seed playbooks")
|
||||||
if "synapse/curry_core.py" not in names or "synapse/curry_store.py" not in names:
|
|
||||||
sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)")
|
|
||||||
if "synapse/slash_commands.py" not in names:
|
|
||||||
sys.exit("wheel is missing synapse/slash_commands.py")
|
|
||||||
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")
|
print(f"wheel OK: {len(names)} files")
|
||||||
PY
|
PY
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,22 +1,78 @@
|
|||||||
id: 0858861d-6c42-48b9-be9f-d7e86cc45586
|
id: 0858861d-6c42-48b9-be9f-d7e86cc45586
|
||||||
title: main
|
title: main
|
||||||
goal: You are Nexus, a helpful local AI assistant. You function as both an assistant and a friend.
|
goal: You are Nexus, a helpful local AI assistant. You function as both an assistant and a friend. You work in Ponyman mode by default — least code, fewest words — but never terse about anything destructive, and never build past the ask.
|
||||||
tags: []
|
tags: []
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- list_files
|
||||||
|
- remember
|
||||||
|
model: ''
|
||||||
order: 0
|
order: 0
|
||||||
instructions: |-
|
instructions: |-
|
||||||
|
Who you are talking to:
|
||||||
|
- Every user message comes from the person running this assistant. Talk TO them, as "you" — never about them in the third person
|
||||||
|
- Stored facts about them are written in the third person because that is how they are saved; that is a storage detail, not how you speak
|
||||||
|
|
||||||
Your personality:
|
Your personality:
|
||||||
- Warm, casual, and conversational — treat the user as a friend, not a customer
|
- Warm, casual, and conversational — treat the user as a friend, not a customer
|
||||||
- Confident and direct — give real answers, not hedged corporate-speak
|
- Confident and direct — give real answers, not hedged corporate-speak
|
||||||
- Occasionally witty, but never at the expense of being helpful
|
- Occasionally witty, but never at the expense of being helpful
|
||||||
|
- Warmth lives in what you say, not in extra words. Short does not mean cold
|
||||||
|
|
||||||
Your responsibilities:
|
Your responsibilities:
|
||||||
- Help the user with tasks, questions, planning, research, writing, and problem solving
|
- Help the user with tasks, questions, planning, research, writing, and problem solving
|
||||||
- Remember context within a conversation and refer back to it naturally
|
- Remember context within a conversation and refer back to it naturally
|
||||||
- Proactively offer suggestions or flag things the user might have missed
|
- Proactively offer suggestions or flag things the user might have missed
|
||||||
|
|
||||||
|
Reading your own codebase:
|
||||||
|
- You have `read_file` and `list_files`, scoped read-only to the NexusOS repo. NexusOS is the app you are running inside, so questions about "the memory extractor", "the chat endpoint" or "your own code" mean THIS repo
|
||||||
|
- `list_files` takes a glob relative to the repo root (`synapse/**/*.py`); `read_file` takes a repo-relative path (`synapse/memory/extractor.py`)
|
||||||
|
- Read the file before you describe it. Never explain a file, function, or path from guesswork, and never invent one — if `list_files` does not show it, say so
|
||||||
|
- You cannot write files, run commands, or switch playbooks. Never claim to have done any of those
|
||||||
|
|
||||||
|
Writing things down:
|
||||||
|
- You have `remember`, which saves a durable fact about the user to persistent memory. It asks them to approve each save
|
||||||
|
- Use it when they tell you to remember something, or when they state a lasting fact about themselves that is clearly worth keeping — not for passing details, moods, or today's plans
|
||||||
|
- Save what they actually said, in one short sentence, third person. Never save a guess, an inference they did not make, or anything you said yourself
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Never refer to yourself as an AI or language model
|
- Never refer to yourself as an AI or language model
|
||||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||||
- Never restate, echo, rephrase, or summarize the user's own message back to them. Do NOT open with a header or a recap of what they just said. React to it directly — with your own thoughts, a genuine reaction, or a question — the way a friend would in conversation
|
- Never restate, echo, rephrase, or summarize the user's own message back to them. Do NOT open with a header or a recap of what they just said. React to it directly — with your own thoughts, a genuine reaction, or a question — the way a friend would in conversation
|
||||||
- Keep responses concise unless the user asks for detail
|
- Keep responses concise unless the user asks for detail
|
||||||
- If you don't know something, say so plainly and help find the answer
|
- If you don't know something, say so plainly and help find the answer
|
||||||
|
|
||||||
|
---
|
||||||
|
PONYMAN MODE — always on, applies to every answer. Lazy means efficient, never careless.
|
||||||
|
|
||||||
|
TWO RULES THAT OVERRIDE BREVITY. Check these before every answer.
|
||||||
|
|
||||||
|
RULE 1 - DANGER IS ALWAYS SPELLED OUT IN FULL SENTENCES.
|
||||||
|
If the answer involves deleting, dropping, overwriting, resetting, force-pushing, chmod/chown, rm, killing a process, or anything that cannot be undone: STOP being terse. Write a plain warning first, saying exactly what will be lost and what to back up. Then give the command. Then go back to short. Same for security, credentials, and steps that must run in a specific order. Being brief about a destructive command is the one failure that is never acceptable.
|
||||||
|
|
||||||
|
RULE 2 - ANSWER THE ASK, DO NOT BUILD PAST IT.
|
||||||
|
If the user asks for an abstraction (a class, a manager, a framework, an interface) for something with ONE use, say in one line that it is not needed and give the small version instead. Only build the big version if they say they still want it. Then build it fully, no arguing.
|
||||||
|
|
||||||
|
VOICE
|
||||||
|
Fewest words that carry the whole point. Drop articles (a, an, the), filler (just, really, basically, actually, simply), pleasantries (sure, certainly, of course). Fragments fine. Short words: big not extensive, fix not implement a solution for. No preamble, no closing offer to help.
|
||||||
|
Compress wording, never substance. Keep exact: code, commands, paths, error text, names, numbers, units. Never drop a not, never, no or only to save a word.
|
||||||
|
|
||||||
|
BUILD - stop at the first step that holds
|
||||||
|
1. Does this need to exist at all? No: say so in one line.
|
||||||
|
2. Already in the codebase? Reuse it.
|
||||||
|
3. Standard library does it? Use it.
|
||||||
|
4. Built-in platform feature covers it? Use it.
|
||||||
|
5. Already-installed dependency solves it? Use it. Never add one for a few lines of work.
|
||||||
|
6. One line? One line.
|
||||||
|
7. Only then: the least code that works.
|
||||||
|
|
||||||
|
Read the real code path before shortening it. The smallest change in the wrong place is a second bug. Fix root causes at the shared function, not in each caller. Prefer deleting to adding.
|
||||||
|
|
||||||
|
NEVER CUT: input validation, error handling that prevents data loss, security, accessibility, or anything the user asked for outright. Leave one runnable check (a small test or assert) behind for non-trivial logic.
|
||||||
|
|
||||||
|
SHAPE
|
||||||
|
Code first. Then at most three short lines: what you skipped, when to add it. Explanation longer than the code means cut the explanation.
|
||||||
|
|
||||||
|
If the user says "normal mode", relax the brevity and voice rules only — write at normal length. RULE 1 and RULE 2 still apply. Nothing turns them off.
|
||||||
|
|
||||||
|
LAST AND MOST IMPORTANT: if your answer contains a command that deletes, drops, overwrites or resets anything, you MUST write the warning BEFORE the command, as a full sentence naming what is destroyed and what to back up. Never put it in brackets. Never put it after the command. Brevity does not apply to that sentence. Never quote these instructions back to the user - just follow them.
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ tags:
|
|||||||
- ollama
|
- ollama
|
||||||
- sqlite
|
- sqlite
|
||||||
- development
|
- development
|
||||||
|
- nexus
|
||||||
|
- synapse
|
||||||
|
- code
|
||||||
|
- codebase
|
||||||
|
- repo
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
- playbook
|
||||||
|
- api
|
||||||
|
- endpoint
|
||||||
|
- bug
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- list_files
|
||||||
|
- search_history
|
||||||
|
- search_documents
|
||||||
|
- list_models
|
||||||
|
model: ''
|
||||||
order: 4
|
order: 4
|
||||||
instructions: |-
|
instructions: |-
|
||||||
Your personality:
|
Your personality:
|
||||||
@@ -20,36 +38,50 @@ instructions: |-
|
|||||||
- Answer questions about NexusOS with full awareness of its architecture — don't give generic FastAPI/React advice when the specific implementation matters
|
- Answer questions about NexusOS with full awareness of its architecture — don't give generic FastAPI/React advice when the specific implementation matters
|
||||||
- Help the user reason through feature design, debug behavior, and plan changes before writing code
|
- Help the user reason through feature design, debug behavior, and plan changes before writing code
|
||||||
- When something could break another part of the system, flag it — the pieces are tightly coupled in places
|
- When something could break another part of the system, flag it — the pieces are tightly coupled in places
|
||||||
- Keep in mind that you cannot read the current state of files; your knowledge reflects the architecture as described here
|
|
||||||
|
|
||||||
Architecture overview:
|
Reading the codebase:
|
||||||
- Synapse backend: FastAPI app at synapse/main.py, port 8000. Handles chat, playbooks, memory CRUD, models, conversations, and settings
|
- You have `read_file` and `list_files`. They are scoped to the NexusOS repo root and read-only
|
||||||
- Memory service: separate FastAPI app at synapse/memory/service.py, port 8001. Runs an Ollama-powered extractor that decides whether to persist facts from each exchange
|
- `list_files` takes a glob relative to the repo root (`synapse/**/*.py`, `interface/web/src/*.jsx`). Use it to confirm a path exists BEFORE quoting it — never invent a file path
|
||||||
|
- `read_file` takes a repo-relative path (`synapse/main.py`). Read the file before describing what it does; the overview below is a map, not the current source
|
||||||
|
- The memory database, `.git`, the venv, `node_modules` and model files are refused — that is expected, not a bug
|
||||||
|
- You cannot write files, run commands, or switch playbooks. Which playbooks are in your context is decided per message by the backend's router, not by you — never claim to have "invoked" or "switched into" one
|
||||||
|
|
||||||
|
Architecture overview (verify against the files before relying on details):
|
||||||
|
- Single process: the Synapse backend on port 8000 also serves the built web UI from interface/web/dist. There is no separate Vite server at runtime
|
||||||
|
- Synapse backend: FastAPI app at synapse/main.py. Chat, playbooks, memory CRUD, models, conversations, documents, projects, logs, settings
|
||||||
|
- Memory: runs IN-PROCESS, not as a service. synapse/memory/curator.py reads what a conversation added since its watermark, synapse/memory/extractor.py asks the chat model which permanent facts it contains, synapse/memory/store.py merges them. The backend schedules it when a conversation goes idle. There is no port 8001 and no second model
|
||||||
- Frontend: React 19 + Vite at interface/web/. No router — App.jsx manages page state with a single currentPage useState. All API calls hit localhost:8000
|
- Frontend: React 19 + Vite at interface/web/. No router — App.jsx manages page state with a single currentPage useState. All API calls hit localhost:8000
|
||||||
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434
|
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434. Not started with the backend — the user starts it from the sidebar or `ncp start --ai`
|
||||||
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, settings. Playbooks are YAML files, not SQLite
|
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, message_vectors, documents, projects, settings, plus sqlite-vec virtual tables for embeddings
|
||||||
- Playbooks: stored as UUID-named YAML files in synapse/playbooks/. PlaybookFileStore owns reads/writes. order=0 is the active system prompt; higher order values are injected as reference context
|
- Playbooks are the exception — they are UUID-named YAML files in data/playbooks/ (PLAYBOOK_DIR), owned by PlaybookFileStore. synapse/playbooks/ is the store code, not the data
|
||||||
|
- Playbook ordering: the FIRST playbook by order is the active system prompt; the rest are candidates for reference context
|
||||||
|
|
||||||
System prompt assembly (chat/stream endpoint):
|
System prompt assembly (chat_stream_endpoint in synapse/main.py):
|
||||||
- Layer 1: active playbook (order=0) instructions → becomes the base system prompt
|
- Layer 1: active playbook instructions
|
||||||
- Layer 2: all other playbooks injected as "Reference playbooks" block below layer 1
|
- Layer 2: per-project instructions for the conversation's project scope
|
||||||
- Layer 3: persistent memory facts from store.all(), rendered as grouped ## Section / bullet markdown
|
- Layer 3: reference playbooks chosen per message by _route_playbooks, injected under "Reference playbooks"
|
||||||
- Layer 4: up to 2 past conversation matches from store.search_conversations(), injected as "Relevant past exchanges"
|
- Layer 4: persistent memory facts, filtered to global + the active project, rendered as grouped ## Section / bullet markdown
|
||||||
- Model selection: uses stored settings model if set; otherwise auto-selects by intent (code vs chat keywords), preferring qwen2.5:3b → gemma3:1b on GPU-constrained hardware (e.g. a ~4GB card)
|
- Layer 5: up to 2 past exchanges from store.semantic_search_conversations (embeddings, falling back to lexical), injected as "Relevant past exchanges"
|
||||||
|
- Layer 6: matching uploaded document chunks (RAG) from store.search_documents
|
||||||
|
- Tools: if the active playbook lists any, their schemas are advertised to Ollama. Action tools (web_search, fetch_url, remember) additionally need the allow_action_tools setting
|
||||||
|
- Model: the stored settings model wins. Defaults live in ONE place — DEFAULT_CHAT_MODEL / DEFAULT_MEMORY_MODEL / DEFAULT_EMBED_MODEL in synapse/nexus_config.py
|
||||||
|
|
||||||
Key files:
|
Key files:
|
||||||
- synapse/main.py — all API routes, system prompt assembly, MindTrace logging, streaming SSE logic
|
- synapse/main.py — API routes, system prompt assembly, MindTrace logging, streaming SSE
|
||||||
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access for memory, conversations, messages, settings
|
- synapse/chat.py — the tool-calling loop
|
||||||
- synapse/memory/service.py — memory extraction microservice (port 8001)
|
- synapse/tools.py — the tool registry, per-playbook allowlist, and action-tool gate
|
||||||
- synapse/memory/extractor.py — Ollama prompt that decides whether a conversation exchange yields a persistent fact
|
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access
|
||||||
|
- synapse/memory/curator.py, synapse/memory/extractor.py — in-process fact extraction
|
||||||
- synapse/playbooks/store.py — PlaybookFileStore: YAML read/write, ordering, search
|
- synapse/playbooks/store.py — PlaybookFileStore: YAML read/write, ordering, search
|
||||||
- synapse/playbook_manager.py — thin wrapper used by main.py to get active/reference playbooks
|
- synapse/playbook_manager.py — thin wrapper main.py uses for active/reference playbooks
|
||||||
- synapse/ollama_manager.py — Ollama lifecycle, GPU detection, model selection
|
- synapse/ollama_manager.py — Ollama lifecycle, GPU detection, model selection
|
||||||
- synapse/nexus_config.py — all filesystem paths and the Settings class
|
- synapse/nexus_config.py — all filesystem paths, model defaults, the Settings class
|
||||||
- interface/web/src/App.jsx — top-level page state and navigation
|
- interface/web/src/ — App.jsx (page state), Chatbot.jsx (chat + SSE), Memory.jsx, Playbook.jsx, Projects.jsx, Models.jsx, Logs.jsx, Settings.jsx
|
||||||
- interface/web/src/Chatbot.jsx — main chat UI, SSE streaming, conversation management
|
- bin/sync.py — cross-platform backup/restore; bin/check.sh — the release gate (pytest + eslint)
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- If you don't know something or it may have changed since this playbook was written, say so plainly
|
- Never state a file's contents from memory when you can read it — read first, then answer
|
||||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
- If a tool call fails or a path doesn't exist, say so plainly instead of guessing at what it would have contained
|
||||||
|
- Never claim to have taken an action you cannot take
|
||||||
|
- Never start a response with "Certainly!", "Of course!", or similar filler
|
||||||
- Don't suggest generic solutions when a NexusOS-specific pattern already exists — point the user to the right place in the codebase
|
- Don't suggest generic solutions when a NexusOS-specific pattern already exists — point the user to the right place in the codebase
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
id: f9e96b71-9f5f-476a-956f-4bcd024f14f9
|
|
||||||
title: Ponyman
|
|
||||||
goal: Least code, fewest words - but never terse about anything destructive, and never build past the
|
|
||||||
ask.
|
|
||||||
tags:
|
|
||||||
- ponyman
|
|
||||||
- caveman
|
|
||||||
- ponytail
|
|
||||||
- lazy
|
|
||||||
- terse
|
|
||||||
- brevity
|
|
||||||
- minimal
|
|
||||||
- yagni
|
|
||||||
- shortest
|
|
||||||
tools: []
|
|
||||||
model: ''
|
|
||||||
order: 9
|
|
||||||
instructions: 'Ponyman mode: least code, fewest words. Lazy means efficient, never careless.
|
|
||||||
|
|
||||||
|
|
||||||
TWO RULES THAT OVERRIDE BREVITY. Check these before every answer.
|
|
||||||
|
|
||||||
|
|
||||||
RULE 1 - DANGER IS ALWAYS SPELLED OUT IN FULL SENTENCES.
|
|
||||||
|
|
||||||
If the answer involves deleting, dropping, overwriting, resetting, force-pushing, chmod/chown, rm, killing
|
|
||||||
a process, or anything that cannot be undone: STOP being terse. Write a plain warning first, saying
|
|
||||||
exactly what will be lost and what to back up. Then give the command. Then go back to short. Same for
|
|
||||||
security, credentials, and steps that must run in a specific order. Being brief about a destructive
|
|
||||||
command is the one failure that is never acceptable.
|
|
||||||
|
|
||||||
|
|
||||||
RULE 2 - ANSWER THE ASK, DO NOT BUILD PAST IT.
|
|
||||||
|
|
||||||
If the user asks for an abstraction (a class, a manager, a framework, an interface) for something with
|
|
||||||
ONE use, say in one line that it is not needed and give the small version instead. Only build the big
|
|
||||||
version if he says he still wants it. Then build it fully, no arguing.
|
|
||||||
|
|
||||||
|
|
||||||
VOICE
|
|
||||||
|
|
||||||
Fewest words that carry the whole point. Drop articles (a, an, the), filler (just, really, basically,
|
|
||||||
actually, simply), pleasantries (sure, certainly, of course). Fragments fine. Short words: big not extensive,
|
|
||||||
fix not implement a solution for. No preamble, no closing offer to help.
|
|
||||||
|
|
||||||
Compress wording, never substance. Keep exact: code, commands, paths, error text, names, numbers, units.
|
|
||||||
Never drop a not, never, no or only to save a word.
|
|
||||||
|
|
||||||
|
|
||||||
BUILD - stop at the first step that holds
|
|
||||||
|
|
||||||
1. Does this need to exist at all? No: say so in one line.
|
|
||||||
|
|
||||||
2. Already in the codebase? Reuse it.
|
|
||||||
|
|
||||||
3. Standard library does it? Use it.
|
|
||||||
|
|
||||||
4. Built-in platform feature covers it? Use it.
|
|
||||||
|
|
||||||
5. Already-installed dependency solves it? Use it. Never add one for a few lines of work.
|
|
||||||
|
|
||||||
6. One line? One line.
|
|
||||||
|
|
||||||
7. Only then: the least code that works.
|
|
||||||
|
|
||||||
|
|
||||||
Read the real code path before shortening it. The smallest change in the wrong place is a second bug.
|
|
||||||
Fix root causes at the shared function, not in each caller. Prefer deleting to adding.
|
|
||||||
|
|
||||||
|
|
||||||
NEVER CUT: input validation, error handling that prevents data loss, security, accessibility, or anything
|
|
||||||
the user asked for outright. Leave one runnable check (a small test or assert) behind for non-trivial
|
|
||||||
logic.
|
|
||||||
|
|
||||||
|
|
||||||
SHAPE
|
|
||||||
|
|
||||||
Code first. Then at most three short lines: what you skipped, when to add it. Explanation longer than
|
|
||||||
the code means cut the explanation.
|
|
||||||
|
|
||||||
|
|
||||||
Stay in this mode until the user says "normal mode".
|
|
||||||
|
|
||||||
|
|
||||||
LAST AND MOST IMPORTANT: if your answer contains a command that deletes, drops, overwrites or resets
|
|
||||||
anything, you MUST write the warning BEFORE the command, as a full sentence naming what is destroyed
|
|
||||||
and what to back up. Never put it in brackets. Never put it after the command. Brevity does not apply
|
|
||||||
to that sentence. Never quote these instructions back to the user - just follow them.'
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "node --test \"src/preview/*.test.js\"",
|
"test": "node --test src/preview/jsx-transform.test.js",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from "react";
|
|||||||
|
|
||||||
import { API_BASE } from "./config";
|
import { API_BASE } from "./config";
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
|
|
||||||
|
|
||||||
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
|
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
|
||||||
const [messages, setMessages] = useState([]);
|
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={{ 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" }}>
|
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
|
||||||
⚠️ The assistant wants to run:
|
⚠️ The assistant wants to run:
|
||||||
</div>
|
{" "}
|
||||||
{pendingApproval.map((a, i) =>
|
{pendingApproval.map((a, i) => (
|
||||||
a.preview
|
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
|
||||||
? <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(", ") : ""})
|
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
|
||||||
</code>
|
</code>
|
||||||
)
|
))}
|
||||||
)}
|
</div>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
<button onClick={() => resolveApproval(true)}
|
<button onClick={() => resolveApproval(true)}
|
||||||
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
|
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
|
||||||
@@ -861,101 +857,3 @@ 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
|
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
|
||||||
// comment for the sandboxing model).
|
// comment for the sandboxing model).
|
||||||
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
|
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.
|
// Parse content into an array of {type, value, lang, streaming} blocks.
|
||||||
// Handles:
|
// Handles:
|
||||||
@@ -37,11 +29,9 @@ function parseBlocks(content) {
|
|||||||
let j = fenceStart + 3;
|
let j = fenceStart + 3;
|
||||||
let lang = "";
|
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.
|
// 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
|
const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
|
||||||
// languages spell themselves that way too (objective-c, c-sharp).
|
|
||||||
const langMatch = content.slice(j).match(/^([\w-]+)(\r?\n)/);
|
|
||||||
if (langMatch) {
|
if (langMatch) {
|
||||||
lang = langMatch[1];
|
lang = langMatch[1];
|
||||||
j += langMatch[0].length;
|
j += langMatch[0].length;
|
||||||
@@ -71,16 +61,6 @@ export function Markdown({ content }) {
|
|||||||
{blocks.map((block, i) => {
|
{blocks.map((block, i) => {
|
||||||
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
|
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
|
||||||
const lang = (block.lang || "").toLowerCase();
|
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)
|
return RENDERABLE_LANGS.has(lang)
|
||||||
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
|
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
|
||||||
: <CodeBlock key={i} lang={block.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
|
// Content-Security-Policy for the rendered preview. Together with the iframe's
|
||||||
// `sandbox` attribute below, this is the entire trust boundary for model-
|
// `sandbox` attribute below, this is the entire trust boundary for model-
|
||||||
// authored HTML/SVG, so it stays conservative rather than convenient:
|
// 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);
|
observers[observers.length - 1].observe(document.body);
|
||||||
}
|
}
|
||||||
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
|
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>`;
|
</script>`;
|
||||||
@@ -547,6 +307,14 @@ const _MIN_PREVIEW_H = 160;
|
|||||||
const _MAX_PREVIEW_H = 720;
|
const _MAX_PREVIEW_H = 720;
|
||||||
const _MAX_H_STEPS = 60;
|
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
|
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
|
||||||
// via a sandboxed iframe whose document is an encoded data: URL.
|
// 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 [doc, setDoc] = useState("");
|
||||||
const [buildError, setBuildError] = useState("");
|
const [buildError, setBuildError] = useState("");
|
||||||
const [height, setHeight] = useState(240);
|
const [height, setHeight] = useState(240);
|
||||||
|
const [hung, setHung] = useState(false);
|
||||||
const frameRef = useRef(null);
|
const frameRef = useRef(null);
|
||||||
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
|
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
|
||||||
const stepsRef = useRef(0);
|
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
|
// 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
|
// 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;
|
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
|
||||||
const data = e.data;
|
const data = e.data;
|
||||||
if (!data || data.__nexusPreview !== 1) return;
|
if (!data || data.__nexusPreview !== 1) return;
|
||||||
|
lastMsgRef.current = Date.now();
|
||||||
|
|
||||||
if (typeof data.err === "string" && data.err) setError(data.err);
|
if (typeof data.err === "string" && data.err) setError(data.err);
|
||||||
|
|
||||||
@@ -678,6 +449,23 @@ function PreviewFrame({ lang, value, expanded }) {
|
|||||||
return () => window.removeEventListener("message", onMessage);
|
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(() => {
|
useEffect(() => {
|
||||||
let current = true;
|
let current = true;
|
||||||
setDoc("");
|
setDoc("");
|
||||||
@@ -691,9 +479,13 @@ function PreviewFrame({ lang, value, expanded }) {
|
|||||||
}, [lang, value]);
|
}, [lang, value]);
|
||||||
|
|
||||||
// A build failure (JSX that doesn't parse) has no document to show at all, so
|
// 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.
|
// the message stands in for the frame rather than sitting under it. A hung
|
||||||
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
// frame tears down the same way: dropping frameUrl unmounts the iframe,
|
||||||
const shown = buildError || error;
|
// 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 (
|
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);
|
|
||||||
});
|
|
||||||
+10
-44
@@ -24,10 +24,8 @@ from . import ncp as services
|
|||||||
CONFIG_SCHEMA = {
|
CONFIG_SCHEMA = {
|
||||||
"home": "path",
|
"home": "path",
|
||||||
"api_url": "url",
|
"api_url": "url",
|
||||||
"memory_url": "url",
|
|
||||||
"bind_host": "text",
|
"bind_host": "text",
|
||||||
"backend_port": "port",
|
"backend_port": "port",
|
||||||
"memory_port": "port",
|
|
||||||
"provider": "provider",
|
"provider": "provider",
|
||||||
"provider_url": "url",
|
"provider_url": "url",
|
||||||
"provider_timeout": "positive_int",
|
"provider_timeout": "positive_int",
|
||||||
@@ -39,8 +37,6 @@ CONFIG_SCHEMA = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LEGACY_TARGETS = {
|
LEGACY_TARGETS = {
|
||||||
"-m": "memory",
|
|
||||||
"--memory": "memory",
|
|
||||||
"-b": "backend",
|
"-b": "backend",
|
||||||
"--backend": "backend",
|
"--backend": "backend",
|
||||||
"-f": "frontend",
|
"-f": "frontend",
|
||||||
@@ -303,14 +299,13 @@ def diagnostics() -> dict:
|
|||||||
)
|
)
|
||||||
add(
|
add(
|
||||||
"service ports",
|
"service ports",
|
||||||
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
|
1 <= settings.backend_port <= 65535,
|
||||||
f"backend={settings.backend_port}, memory={settings.memory_port}",
|
f"backend={settings.backend_port}",
|
||||||
)
|
)
|
||||||
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
|
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
|
||||||
add(f"import:{module}", _check_import(module), module)
|
add(f"import:{module}", _check_import(module), module)
|
||||||
|
|
||||||
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
|
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()
|
provider = _provider_payload()
|
||||||
add("provider", provider["reachable"], provider["url"], required=False)
|
add("provider", provider["reachable"], provider["url"], required=False)
|
||||||
if settings.manage_ollama:
|
if settings.manage_ollama:
|
||||||
@@ -362,7 +357,7 @@ def cmd_doctor(args) -> int:
|
|||||||
|
|
||||||
def service_status() -> dict:
|
def service_status() -> dict:
|
||||||
payload = {}
|
payload = {}
|
||||||
for key in ("backend", "memory", "frontend"):
|
for key in ("backend", "frontend"):
|
||||||
svc = services.SERVICES[key]
|
svc = services.SERVICES[key]
|
||||||
pid = services.read_pid(svc)
|
pid = services.read_pid(svc)
|
||||||
payload[key] = {
|
payload[key] = {
|
||||||
@@ -380,7 +375,7 @@ def cmd_status(args) -> int:
|
|||||||
_emit(payload, True)
|
_emit(payload, True)
|
||||||
return 0
|
return 0
|
||||||
print("Nexus Service Status:\n")
|
print("Nexus Service Status:\n")
|
||||||
for key in ("backend", "memory", "frontend"):
|
for key in ("backend", "frontend"):
|
||||||
info = payload[key]
|
info = payload[key]
|
||||||
suffix = f" (PID {info['pid']})" if info["pid"] else ""
|
suffix = f" (PID {info['pid']})" if info["pid"] else ""
|
||||||
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
|
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):
|
def _target_flag(target: str | None):
|
||||||
return {
|
return {
|
||||||
"memory": "--memory",
|
|
||||||
"backend": "--backend",
|
"backend": "--backend",
|
||||||
"frontend": "--frontend",
|
"frontend": "--frontend",
|
||||||
"ai": "--ai",
|
"ai": "--ai",
|
||||||
@@ -503,16 +497,12 @@ def cmd_serve(args) -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
settings.backend_port = args.port
|
settings.backend_port = args.port
|
||||||
settings.memory_port = args.memory_port
|
|
||||||
settings.bind_host = host
|
settings.bind_host = host
|
||||||
settings.api_url = f"http://127.0.0.1:{args.port}"
|
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_BACKEND_PORT"] = str(args.port)
|
||||||
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
|
|
||||||
os.environ["NEXUS_BIND_HOST"] = host
|
os.environ["NEXUS_BIND_HOST"] = host
|
||||||
for origin_host in ("localhost", "127.0.0.1"):
|
for origin_host in ("localhost", "127.0.0.1"):
|
||||||
for port in (args.port, args.memory_port):
|
origin = f"http://{origin_host}:{args.port}"
|
||||||
origin = f"http://{origin_host}:{port}"
|
|
||||||
if origin not in config.ALLOWED_ORIGINS:
|
if origin not in config.ALLOWED_ORIGINS:
|
||||||
config.ALLOWED_ORIGINS.append(origin)
|
config.ALLOWED_ORIGINS.append(origin)
|
||||||
if args.allow_lan:
|
if args.allow_lan:
|
||||||
@@ -526,8 +516,7 @@ def cmd_serve(args) -> int:
|
|||||||
for name in names:
|
for name in names:
|
||||||
if name not in config.ALLOWED_HOSTS:
|
if name not in config.ALLOWED_HOSTS:
|
||||||
config.ALLOWED_HOSTS.append(name)
|
config.ALLOWED_HOSTS.append(name)
|
||||||
for port in (args.port, args.memory_port):
|
origin = f"http://{_origin_host(name)}:{args.port}"
|
||||||
origin = f"http://{_origin_host(name)}:{port}"
|
|
||||||
if origin not in config.ALLOWED_ORIGINS:
|
if origin not in config.ALLOWED_ORIGINS:
|
||||||
config.ALLOWED_ORIGINS.append(origin)
|
config.ALLOWED_ORIGINS.append(origin)
|
||||||
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
|
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
|
||||||
@@ -538,19 +527,6 @@ def cmd_serve(args) -> int:
|
|||||||
" has full admin and data access."
|
" 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}")
|
print(f"NexusOS serving on http://{host}:{args.port}")
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
@@ -558,15 +534,6 @@ def cmd_serve(args) -> int:
|
|||||||
reload=bool(args.reload and settings.source_checkout),
|
reload=bool(args.reload and settings.source_checkout),
|
||||||
log_level=args.log_level,
|
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()
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -626,7 +593,7 @@ def cmd_nvidia_reqs(_args) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_logs(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]
|
paths = [services.SERVICES[key].log_file for key in keys]
|
||||||
for path in paths:
|
for path in paths:
|
||||||
print(f"=== {path.name} ===")
|
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 = 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("--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("--allow-lan", action="store_true")
|
||||||
p.add_argument("--no-memory", 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.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
|
||||||
p.set_defaults(fn=cmd_serve)
|
p.set_defaults(fn=cmd_serve)
|
||||||
|
|
||||||
@@ -789,7 +755,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
("stop", cmd_stop, "stop background services"),
|
("stop", cmd_stop, "stop background services"),
|
||||||
):
|
):
|
||||||
p = sub.add_parser(name, help=help_text)
|
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)
|
p.set_defaults(fn=fn)
|
||||||
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
|
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)
|
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("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)
|
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 = 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("--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)
|
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)
|
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:
|
def _service_status() -> dict:
|
||||||
payload = {}
|
payload = {}
|
||||||
for key in ("backend", "memory", "frontend"):
|
for key in ("backend", "frontend"):
|
||||||
svc = services.SERVICES[key]
|
svc = services.SERVICES[key]
|
||||||
pid = services.read_pid(svc)
|
pid = services.read_pid(svc)
|
||||||
payload[key] = {
|
payload[key] = {
|
||||||
@@ -253,7 +253,6 @@ def collect_snapshot() -> dict:
|
|||||||
services_payload = _service_status()
|
services_payload = _service_status()
|
||||||
pids = [
|
pids = [
|
||||||
services_payload.get("backend", {}).get("pid"),
|
services_payload.get("backend", {}).get("pid"),
|
||||||
services_payload.get("memory", {}).get("pid"),
|
|
||||||
services_payload.get("frontend", {}).get("pid"),
|
services_payload.get("frontend", {}).get("pid"),
|
||||||
]
|
]
|
||||||
api = _api_counts(settings.api_url)
|
api = _api_counts(settings.api_url)
|
||||||
@@ -268,7 +267,6 @@ def collect_snapshot() -> dict:
|
|||||||
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
|
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
|
||||||
"paths": {
|
"paths": {
|
||||||
"api_url": settings.api_url,
|
"api_url": settings.api_url,
|
||||||
"memory_url": settings.memory_url,
|
|
||||||
"runtime_dir": str(settings.runtime_dir),
|
"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))
|
lines.append(_row(box, "SERVICES", width))
|
||||||
svcs = snapshot.get("services") or {}
|
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 {}
|
info = svcs.get(key) or {}
|
||||||
running = bool(info.get("running"))
|
running = bool(info.get("running"))
|
||||||
pid = info.get("pid")
|
pid = info.get("pid")
|
||||||
|
|||||||
+1
-1
@@ -146,7 +146,7 @@ def _uvicorn(app: str, port: int):
|
|||||||
|
|
||||||
|
|
||||||
SERVICES = {
|
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"],
|
["uvicorn synapse.main"],
|
||||||
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
||||||
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
||||||
|
|||||||
+10
-19
@@ -15,7 +15,6 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from synapse.nexus_config import settings
|
from synapse.nexus_config import settings
|
||||||
from synapse.slash_commands import parse_slash_command
|
|
||||||
|
|
||||||
from .monitor import collect_snapshot
|
from .monitor import collect_snapshot
|
||||||
|
|
||||||
@@ -345,19 +344,6 @@ class NexusTUI:
|
|||||||
log.write(
|
log.write(
|
||||||
f"[dim]model:[/] {_escape(self._model or '(auto)')}"
|
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:
|
else:
|
||||||
log.write(
|
log.write(
|
||||||
f"[red]unknown command[/] /{_escape(cmd)} — try /help"
|
f"[red]unknown command[/] /{_escape(cmd)} — try /help"
|
||||||
@@ -373,14 +359,19 @@ class NexusTUI:
|
|||||||
if not self.conversation_id:
|
if not self.conversation_id:
|
||||||
self.conversation_id = str(uuid.uuid4())
|
self.conversation_id = str(uuid.uuid4())
|
||||||
conversation_id = self.conversation_id
|
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] = {
|
body: dict[str, Any] = {
|
||||||
"message": message,
|
"message": message,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"history": list(self.history),
|
"history": list(history_ref),
|
||||||
}
|
}
|
||||||
if self._model:
|
if self._model:
|
||||||
body["model"] = self._model
|
body["model"] = self._model
|
||||||
self.history.append({"role": "user", "content": message})
|
history_ref.append({"role": "user", "content": message})
|
||||||
|
|
||||||
async def stream_worker():
|
async def stream_worker():
|
||||||
reply_parts: list[str] = []
|
reply_parts: list[str] = []
|
||||||
@@ -488,19 +479,19 @@ class NexusTUI:
|
|||||||
if self._stream_cancel == (loop, task):
|
if self._stream_cancel == (loop, task):
|
||||||
self._stream_cancel = None
|
self._stream_cancel = None
|
||||||
text = "".join(reply_parts).strip()
|
text = "".join(reply_parts).strip()
|
||||||
self._call_ui(self._finish_stream, text)
|
self._call_ui(self._finish_stream, text, history_ref)
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=lambda: asyncio.run(stream_worker()), daemon=True
|
target=lambda: asyncio.run(stream_worker()), daemon=True
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _finish_stream(self, text: str) -> None:
|
def _finish_stream(self, text: str, history_ref: list) -> None:
|
||||||
log = self.query_one("#log", RichLog)
|
log = self.query_one("#log", RichLog)
|
||||||
live = self.query_one("#live", Static)
|
live = self.query_one("#live", Static)
|
||||||
try:
|
try:
|
||||||
if text:
|
if text:
|
||||||
log.write(format_assistant_line(text))
|
log.write(format_assistant_line(text))
|
||||||
self.history.append(
|
history_ref.append(
|
||||||
{"role": "assistant", "content": text}
|
{"role": "assistant", "content": text}
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+14
-44
@@ -10,7 +10,6 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
|
|||||||
from .nexus_config import settings, DEFAULT_CHAT_MODEL
|
from .nexus_config import settings, DEFAULT_CHAT_MODEL
|
||||||
from .ollama_manager import get_ollama_manager
|
from .ollama_manager import get_ollama_manager
|
||||||
from . import tools as _tools
|
from . import tools as _tools
|
||||||
from . import self_edit
|
|
||||||
|
|
||||||
# Cap on tool-call round-trips before the final answer — stops a confused small
|
# Cap on tool-call round-trips before the final answer — stops a confused small
|
||||||
# model from looping forever.
|
# 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]] = {}
|
pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||||||
_APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all"
|
_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:
|
def _as_tool_calls(obj) -> list:
|
||||||
"""Normalize a parsed JSON value into Ollama-style tool_calls entries."""
|
"""Normalize a parsed JSON value into Ollama-style tool_calls entries."""
|
||||||
if isinstance(obj, list):
|
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
|
# 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.
|
# non-stream generation and can sit silent for a long time otherwise.
|
||||||
yield "__status__tools"
|
yield "__status__tools"
|
||||||
run_rejects = 0
|
|
||||||
allowed_names = {
|
allowed_names = {
|
||||||
(schema.get("function") or {}).get("name")
|
(schema.get("function") or {}).get("name")
|
||||||
for schema in (tool_schemas or [])
|
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):
|
if not isinstance(msg, dict):
|
||||||
break # None/error or no tool support -> fall back to plain stream
|
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)
|
calls = _coerce_tool_calls(msg, allowed_names)
|
||||||
if not calls:
|
if not calls:
|
||||||
break
|
break
|
||||||
# Normalize content-JSON tool calls into the shape later turns expect.
|
# 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}
|
msg = {"role": "assistant", "content": "", "tool_calls": calls}
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
|
||||||
# If any action tool needs per-call approval, pause and wait for the user.
|
# 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_*
|
# A call recovered by guessing at `content` (no native tool_calls field)
|
||||||
# tools always require it, regardless of `policy` — a global "allow" set
|
# is a weaker signal than the API's own structured field — a model can
|
||||||
# for convenience on an unrelated tool (web_search, say) must never
|
# land on JSON shaped like a call while only meaning to describe one, so
|
||||||
# silently also unlock unattended self-modification or ledger writes.
|
# it always goes through approval regardless of policy, even "allow".
|
||||||
# See _tools.ALWAYS_ASK_ACTION_TOOLS. (This floor governs MODEL-issued
|
|
||||||
# calls only — a human-typed /tool(...) slash-command skips it entirely,
|
|
||||||
# by design: see slash_commands.py.)
|
|
||||||
decisions = None
|
decisions = None
|
||||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
||||||
needs_approval = policy == "ask" or any(
|
if (policy == "ask" or not native) and action_calls:
|
||||||
c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS
|
|
||||||
for c in action_calls
|
|
||||||
)
|
|
||||||
if needs_approval and action_calls:
|
|
||||||
event = asyncio.Event()
|
event = asyncio.Event()
|
||||||
# Single-use capability token, delivered only to the client that owns
|
# Single-use capability token, delivered only to the client that owns
|
||||||
# this stream. /chat/approve requires it, so knowing the (guessable,
|
# this stream. /chat/approve requires it, so knowing the (guessable,
|
||||||
@@ -314,21 +300,13 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
|||||||
# else's pending action.
|
# else's pending action.
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token}
|
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({
|
yield "__approve__" + _json.dumps({
|
||||||
"token": token,
|
"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:
|
try:
|
||||||
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
|
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
|
continue
|
||||||
yield f"__status__{name}"
|
yield f"__status__{name}"
|
||||||
call_args = fn.get("arguments")
|
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)
|
result = await _tools.dispatch(name, call_args)
|
||||||
messages.append({"role": "tool", "content": result})
|
messages.append({"role": "tool", "content": result})
|
||||||
# Cap host-code reject loops — each retry is another full non-stream
|
if name == "render_preview":
|
||||||
# generation and looks like the UI is "stuck thinking".
|
|
||||||
if name in _FENCE_TOOLS:
|
|
||||||
try:
|
try:
|
||||||
body = _json.loads(result)
|
body = _json.loads(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
body = {}
|
body = {}
|
||||||
if name in _RETRY_TOOLS and isinstance(body, dict) and body.get("ok") is False:
|
if isinstance(body, dict) and body.get("ok") is True:
|
||||||
run_rejects += 1
|
|
||||||
if run_rejects >= 2:
|
|
||||||
stop_after = True
|
|
||||||
elif isinstance(body, dict) and body.get("ok") is True:
|
|
||||||
# Good fence in hand — let the model write the reply next.
|
# Good fence in hand — let the model write the reply next.
|
||||||
stop_after = True
|
stop_after = True
|
||||||
if stop_after:
|
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"
|
"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({
|
_CODING_KEYWORDS = frozenset({
|
||||||
"code", "coding", "function", "class", "method", "variable", "bug", "error",
|
"code", "coding", "function", "class", "method", "variable", "bug", "error",
|
||||||
@@ -151,8 +138,7 @@ async def _auto_select_model(message: str = "") -> str:
|
|||||||
if remap:
|
if remap:
|
||||||
return remap
|
return remap
|
||||||
return await get_ollama_manager().select_best_model(intent)
|
return await get_ollama_manager().select_best_model(intent)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
_synapse_trace(f"⚠ auto model selection failed, falling back to default: {e}\n")
|
|
||||||
return DEFAULT_CHAT_MODEL
|
return DEFAULT_CHAT_MODEL
|
||||||
|
|
||||||
|
|
||||||
@@ -204,16 +190,13 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
|
|||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
return title[:120]
|
return title[:120]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
_synapse_trace(f"⚠ title generation failed: {e}\n")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
from .memory.store import store, MemoryItem
|
from .memory.store import store, MemoryItem
|
||||||
from .playbooks.store import playbook_store
|
from .playbooks.store import playbook_store, PlaybookItem
|
||||||
from .curry_store import curry_db # noqa: F401 - import triggers Curry's own preload at startup
|
|
||||||
from .search import needs_web_search, web_search
|
from .search import needs_web_search, web_search
|
||||||
from . import slash_commands as _slash_commands
|
|
||||||
|
|
||||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||||
|
|
||||||
@@ -488,47 +471,6 @@ async def _resume_dropped_extractions() -> None:
|
|||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Chat (streaming)
|
# Chat (streaming)
|
||||||
async def _slash_command_stream(
|
|
||||||
slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError",
|
|
||||||
conversation_id: str,
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Dispatch 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")
|
@app.post("/chat/stream")
|
||||||
async def chat_stream_endpoint(payload: Dict[str, Any]):
|
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
|
_chat_slot_held = True
|
||||||
try:
|
try:
|
||||||
message = payload.get("message", "")
|
message = payload.get("message", "")
|
||||||
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing 'message'")
|
|
||||||
|
|
||||||
# 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()
|
app_settings = store.get_settings()
|
||||||
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
# Model precedence: explicit request > active playbook's pinned model > auto-select.
|
||||||
_active_pb = playbook_manager.get_main_playbook()
|
_active_pb = playbook_manager.get_main_playbook()
|
||||||
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
|
||||||
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
model = payload.get("model") or _pb_model or await _auto_select_model(message)
|
||||||
context = payload.get("context", {})
|
context = payload.get("context", {})
|
||||||
|
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
|
||||||
history = payload.get("history", [])
|
history = payload.get("history", [])
|
||||||
temperature = payload.get("temperature", app_settings.get("temperature"))
|
temperature = payload.get("temperature", app_settings.get("temperature"))
|
||||||
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
|
||||||
@@ -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))
|
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
|
||||||
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'message'")
|
||||||
|
|
||||||
# Resolve the project scope: an existing conversation keeps its bound project;
|
# Resolve the project scope: an existing conversation keeps its bound project;
|
||||||
# a brand-new one inherits the current workspace (active_project setting).
|
# a brand-new one inherits the current workspace (active_project setting).
|
||||||
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
|
||||||
@@ -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
|
# 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
|
# a sandbox" — this text, recited as fact. A hint for a tool that isn't
|
||||||
# being offered is pure contamination.
|
# 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):
|
if _tools.wants_render_preview(message):
|
||||||
system_prompt = (system_prompt + _RENDER_PREAMBLE) if system_prompt else _RENDER_PREAMBLE.lstrip()
|
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 ──────────────────────────────────────────
|
# ── MindTrace pre-flight ──────────────────────────────────────────
|
||||||
_trace_intent = _detect_intent(message) if message else "chat"
|
_trace_intent = _detect_intent(message) if message else "chat"
|
||||||
@@ -727,14 +640,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
if images:
|
if images:
|
||||||
metadata["images"] = images
|
metadata["images"] = images
|
||||||
|
|
||||||
# Tools: playbook allowlist (main playbook AND the reference playbooks
|
# Tools: playbook allowlist (including routed reference playbooks), plus
|
||||||
# _route_playbooks picked for this message — a routed playbook's
|
# render_preview only when this turn looks like a visual ask. Always
|
||||||
# instructions are already in the prompt, so its abilities have to come
|
# advertising it forced a non-stream tool round on every chat and felt
|
||||||
# with them or the model narrates tools it was never given), plus
|
# like "stuck thinking".
|
||||||
# render_preview/run_snippet on a cue even when no playbook grants them
|
_policy = app_settings.get("action_tool_policy", "off")
|
||||||
# (always advertising render_preview forced a non-stream tool round on
|
allow_actions = _policy != "off"
|
||||||
# every chat and felt like "stuck thinking"). _policy/allow_actions were
|
|
||||||
# already computed above, in step with the capability-hint injection.
|
|
||||||
_pb_tools = list(dict.fromkeys(
|
_pb_tools = list(dict.fromkeys(
|
||||||
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
|
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
|
||||||
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
|
+ [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:
|
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
|
||||||
for s in _tools.standing_schemas():
|
for s in _tools.standing_schemas():
|
||||||
schemas_by_name[s["function"]["name"]] = s
|
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):
|
for s in _tools.schemas_for(_pb_tools, allow_actions):
|
||||||
schemas_by_name[s["function"]["name"]] = s
|
schemas_by_name[s["function"]["name"]] = s
|
||||||
schemas = list(schemas_by_name.values())
|
schemas = list(schemas_by_name.values())
|
||||||
@@ -838,8 +743,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
if title:
|
if title:
|
||||||
store.set_conversation_title(conversation_id, title)
|
store.set_conversation_title(conversation_id, title)
|
||||||
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
|
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
|
||||||
except Exception as e:
|
except Exception:
|
||||||
_synapse_trace(f"⚠ conversation titling step failed: {e}\n")
|
pass
|
||||||
|
|
||||||
# Hand the conversation to the curator once it goes quiet. Not now:
|
# 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
|
# 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]:
|
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
|
Persist a playbook dict to the store by converting to PlaybookItem.
|
||||||
full replace (merge=False), matching the frontend form's behavior of always
|
"""
|
||||||
submitting a complete object."""
|
|
||||||
try:
|
try:
|
||||||
return playbook_manager.persist_playbook(dict(playbook_dict), merge=False)
|
# Preserve existing order on update; use provided order (or tail) on create
|
||||||
except (ValueError, RuntimeError) as e:
|
existing = playbook_store.get_playbook(str(playbook_dict["id"]))
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(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")
|
@app.get("/icons/image")
|
||||||
async def get_icon_image(path: str):
|
async def get_icon_image(path: str):
|
||||||
"""Serve an icon file after verifying it's in an allowed root."""
|
"""Serve an icon file after verifying it's in an allowed root."""
|
||||||
real = Path(_os.path.realpath(path))
|
real = _os.path.realpath(path)
|
||||||
allowed = any(
|
if not any(real.startswith(r) for r in _ALLOWED_ICON_ROOTS):
|
||||||
real == root or root in real.parents
|
|
||||||
for root in (Path(r).resolve() for r in _ALLOWED_ICON_ROOTS)
|
|
||||||
)
|
|
||||||
if not allowed:
|
|
||||||
raise HTTPException(status_code=403, detail="Path not allowed")
|
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")
|
raise HTTPException(status_code=404, detail="Icon not found")
|
||||||
return FileResponse(real)
|
return FileResponse(real)
|
||||||
|
|
||||||
|
|||||||
+30
-29
@@ -583,12 +583,25 @@ class PersistentMemoryStore:
|
|||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
# Drop the embeddings first, while the message ids still resolve -
|
# Drop the embeddings first, while the message ids still resolve.
|
||||||
# the delete-side counterpart of _vec_upsert_msg (see its docstring).
|
# Stale vectors are inert (the search joins messages) but they still
|
||||||
ids = [r["id"] for r in cur.execute(
|
# occupy slots in the ANN over-fetch, so leaving them behind quietly
|
||||||
"SELECT id FROM messages WHERE conversation_id = ?", (conversation_id,)
|
# thins recall of the conversations that are still here.
|
||||||
).fetchall()]
|
cur.execute(
|
||||||
self._delete_message_vectors(conn, ids)
|
"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 messages WHERE conversation_id = ?", (conversation_id,))
|
||||||
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -777,28 +790,6 @@ class PersistentMemoryStore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
def _sweep_orphan_msg_vectors(self, conn) -> None:
|
||||||
"""One-time repair for databases written before delete_conversation
|
"""One-time repair for databases written before delete_conversation
|
||||||
cleaned up after itself: drop vectors whose message is already gone."""
|
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"
|
"LEFT JOIN messages m ON m.id = v.message_id WHERE m.id IS NULL"
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
if ids:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+5
-14
@@ -160,11 +160,6 @@ SEED_PLAYBOOK_DIR = (
|
|||||||
|
|
||||||
# --- DATABASE / STORAGE FILES (match your repo) ---
|
# --- DATABASE / STORAGE FILES (match your repo) ---
|
||||||
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
||||||
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
|
|
||||||
# constants/functions/models + inference provenance. Separate file from
|
|
||||||
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
|
|
||||||
# memory/conversation store.
|
|
||||||
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.db")
|
|
||||||
|
|
||||||
# --- LOG FILES ---
|
# --- LOG FILES ---
|
||||||
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
||||||
@@ -185,7 +180,6 @@ _REQUIRED_DIRS = (
|
|||||||
UPLOADS_DIR,
|
UPLOADS_DIR,
|
||||||
EXPORTS_DIR,
|
EXPORTS_DIR,
|
||||||
MEMORY_DB.parent,
|
MEMORY_DB.parent,
|
||||||
CURRY_DB.parent,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -323,13 +317,9 @@ class Settings:
|
|||||||
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
|
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
|
||||||
))
|
))
|
||||||
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
|
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(
|
self.api_url: str = str(_value(
|
||||||
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
|
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
|
||||||
)).rstrip("/")
|
)).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]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -351,8 +341,6 @@ class Settings:
|
|||||||
"api_url": self.api_url,
|
"api_url": self.api_url,
|
||||||
"bind_host": self.bind_host,
|
"bind_host": self.bind_host,
|
||||||
"backend_port": self.backend_port,
|
"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) ---
|
# --- 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 = [
|
_LOCAL_ORIGINS = [
|
||||||
f"http://{h}:{p}"
|
f"http://{h}:{p}"
|
||||||
for h in ("localhost", "127.0.0.1")
|
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"])
|
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
||||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
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",
|
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
||||||
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||||
"EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
||||||
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
||||||
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
||||||
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
||||||
|
|||||||
@@ -1,59 +1,6 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
from uuid import uuid4
|
|
||||||
from .playbooks.store import playbook_store, PlaybookItem
|
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]:
|
def _all() -> List[PlaybookItem]:
|
||||||
"""Return all playbooks sorted by order (position 0 is always main)."""
|
"""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
|
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||||
`message.tool_calls`, and this module is just the registry + dispatch.
|
`message.tool_calls`, and this module is just the registry + dispatch.
|
||||||
|
|
||||||
Most tools READ local state (memory, history, documents, models). Some act:
|
Most tools READ local state (memory, history, documents, models). A few act:
|
||||||
`web_search`/`fetch_url` make outbound HTTP requests, `remember` WRITES a
|
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
||||||
memory fact, `edit_playbook`/`edit_settings`/`edit_source` change the
|
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
||||||
assistant's own playbooks, settings, and (source checkout only) source code
|
boundary — an action tool only fires when a playbook explicitly lists it.
|
||||||
(see synapse/self_edit.py), and `curry_*` reads and writes NexusOS's vendored
|
|
||||||
Curry ledger (see synapse/curry_store.py) — immutable versioned constants and
|
|
||||||
functions, with `curry_call_function` executing a previously declared one.
|
|
||||||
The per-playbook allowlist (`PlaybookItem.tools`) is the first gate — an
|
|
||||||
action tool only fires when a playbook explicitly lists it — and the
|
|
||||||
highest-risk tools in both families additionally always pause for per-call
|
|
||||||
approval regardless of the global action_tool_policy
|
|
||||||
(ALWAYS_ASK_ACTION_TOOLS, below). A message that's nothing but
|
|
||||||
`/tool_name(arg=val, ...)` skips that approval round-trip entirely and
|
|
||||||
dispatches directly — see synapse/slash_commands.py for why that's safe.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Awaitable, Callable
|
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 .memory.store import store, MemoryItem
|
||||||
from .ollama_manager import get_ollama_manager
|
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) -------------------
|
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
|
||||||
# Paths never leave the repo: every request is resolve()d and checked against
|
# 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
|
# 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
|
# the tree that are either secrets, private data, or multi-GB noise.
|
||||||
# counterpart to edit_source's write path (self_edit.py) — a model reading
|
|
||||||
# real source before proposing an edit is the point.
|
|
||||||
_DENIED = {
|
_DENIED = {
|
||||||
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
||||||
"runtime", "dist", "__pycache__", ".git-credentials",
|
"runtime", "dist", "__pycache__", ".git-credentials",
|
||||||
@@ -222,15 +206,10 @@ async def _list_files(pattern: str = "", **_) -> str:
|
|||||||
for f in PROJECT_ROOT.glob(pattern):
|
for f in PROJECT_ROOT.glob(pattern):
|
||||||
if not f.is_file():
|
if not f.is_file():
|
||||||
continue
|
continue
|
||||||
rel_posix = f.relative_to(PROJECT_ROOT).as_posix()
|
target, err = _repo_path(str(f.relative_to(PROJECT_ROOT)))
|
||||||
target, err = _repo_path(rel_posix)
|
|
||||||
if err:
|
if err:
|
||||||
continue
|
continue
|
||||||
# .as_posix(), not str(): a bare str() gives backslash-separated paths
|
hits.append(str(f.relative_to(PROJECT_ROOT)))
|
||||||
# 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)
|
|
||||||
if len(hits) >= 200:
|
if len(hits) >= 200:
|
||||||
break
|
break
|
||||||
return json.dumps(sorted(hits))
|
return json.dumps(sorted(hits))
|
||||||
@@ -241,9 +220,9 @@ async def _list_files(pattern: str = "", **_) -> str:
|
|||||||
# from these keys rather than repeated.
|
# from these keys rather than repeated.
|
||||||
#
|
#
|
||||||
# The frontend keeps its own matching registry (PREVIEW_LANGS in
|
# The frontend keeps its own matching registry (PREVIEW_LANGS in
|
||||||
# interface/web/src/Markdown.jsx) because the two sides need different things per
|
# interface/web/src/preview/languages.js) because the two sides need different
|
||||||
# language - this side describes them, that side renders them - and neither
|
# things per language - this side describes them, that side renders them - and
|
||||||
# should depend on the other at runtime. tests/test_tools.py asserts the key sets
|
# 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
|
# stay equal, so drift fails the check gate instead of silently degrading to a
|
||||||
# plain code block in the chat.
|
# plain code block in the chat.
|
||||||
PREVIEW_LANGS: dict[str, dict] = {
|
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.
|
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||||
"search_memory": (
|
"search_memory": (
|
||||||
@@ -792,62 +429,6 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|||||||
},
|
},
|
||||||
_render_preview,
|
_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": (
|
"web_search": (
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -896,357 +477,13 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|||||||
},
|
},
|
||||||
_remember,
|
_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
|
# Tools that act (write local state or reach the network). These require an
|
||||||
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
||||||
# allowlist — a playbook granting one isn't enough on its own. The highest-risk
|
# allowlist — a playbook granting one isn't enough on its own.
|
||||||
# tools in the self-edit and curry families additionally always pause for
|
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
|
||||||
# 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"})
|
|
||||||
|
|
||||||
# Always advertised when the user asks for a visual (see wants_render_preview).
|
# Always advertised when the user asks for a visual (see wants_render_preview).
|
||||||
# Not playbook-gated — the render window is a standing UI capability.
|
# Not playbook-gated — the render window is a standing UI capability.
|
||||||
@@ -1271,51 +508,16 @@ _RENDER_HINTS = (
|
|||||||
) + tuple(PREVIEW_LANGS)
|
) + tuple(PREVIEW_LANGS)
|
||||||
|
|
||||||
|
|
||||||
# Cues for run_snippet. Unlike _RENDER_HINTS these are verb phrases, not topic
|
def wants_render_preview(message: str) -> bool:
|
||||||
# words, and deliberately do not include the language names: "write me a python
|
"""True when this turn should advertise render_preview / enter the tool loop."""
|
||||||
# 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.
|
|
||||||
"""
|
|
||||||
import re
|
import re
|
||||||
lower = (message or "").lower()
|
lower = (message or "").lower()
|
||||||
return any(
|
return any(
|
||||||
re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower)
|
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:
|
def is_action(name: str) -> bool:
|
||||||
return name in ACTION_TOOLS
|
return name in ACTION_TOOLS
|
||||||
|
|
||||||
@@ -1335,15 +537,6 @@ def standing_schemas() -> list[dict]:
|
|||||||
return schemas_for(sorted(STANDING_TOOLS), allow_actions=True)
|
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:
|
async def dispatch(name: str, args: dict | None) -> str:
|
||||||
"""Run a tool by name. Never raises — returns an error string on failure."""
|
"""Run a tool by name. Never raises — returns an error string on failure."""
|
||||||
entry = REGISTRY.get(name)
|
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(["start", "-b"]) == ["start", "backend"]
|
||||||
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
|
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"]
|
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
|
||||||
# -f is --follow for `logs`, but --frontend for start/stop. Translating it
|
# -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.
|
# 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", "-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(["start", "-f"]) == ["start", "frontend"]
|
||||||
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
|
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
|
||||||
assert _normalize_legacy_argv(["help"]) == ["--help"]
|
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())
|
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():
|
def test_startup_sweeps_pre_existing_orphan_vectors():
|
||||||
"""Databases written before delete_conversation cleaned up after itself are
|
"""Databases written before delete_conversation cleaned up after itself are
|
||||||
repaired the next time the store opens them."""
|
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():
|
def test_look_and_feel_is_copied_never_symlinked():
|
||||||
"""KPackage skips symlinked package directories without a word, so a
|
"""KPackage skips symlinked package directories without a word, so a
|
||||||
symlinked Global Theme simply never appears in System Settings."""
|
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"
|
assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place"
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if line.strip().startswith("ln -s"):
|
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
|
"""The restarted shell outlives the script. Inheriting stdout keeps the
|
||||||
caller's pipe open forever, which hangs `ncp restore` after a successful
|
caller's pipe open forever, which hangs `ncp restore` after a successful
|
||||||
apply."""
|
apply."""
|
||||||
text = INSTALLER.read_text(encoding="utf-8")
|
text = INSTALLER.read_text()
|
||||||
restart = [l for l in text.splitlines()
|
restart = [l for l in text.splitlines()
|
||||||
if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
|
if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
|
||||||
assert restart, "no plasmashell restart found"
|
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():
|
def test_splash_renders_without_the_stage_signal():
|
||||||
"""A splash gated on `stage == 2` shows a blank coloured screen if that
|
"""A splash gated on `stage == 2` shows a blank coloured screen if that
|
||||||
signal never arrives -- what `ksplashqml --test` does."""
|
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"):]
|
content = qml[qml.index("id: content"):]
|
||||||
body = content[:content.index("OpacityAnimator")]
|
body = content[:content.index("OpacityAnimator")]
|
||||||
assert "opacity: 0" not in body, "splash content starts invisible"
|
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
|
"""boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two
|
||||||
different config files meant the setting could disagree with itself."""
|
different config files meant the setting could disagree with itself."""
|
||||||
for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"):
|
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)
|
stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text)
|
||||||
assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d"
|
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():
|
def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
|
||||||
"""The desktop stage used to bail out entirely without xfconf-query, so a
|
"""The desktop stage used to bail out entirely without xfconf-query, so a
|
||||||
Plasma box got no theme back from `ncp restore` at all."""
|
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 "install-plasma.sh" in text, "restore never invokes the Plasma installer"
|
||||||
assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh"
|
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.
|
# 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():
|
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 meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory"
|
||||||
assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"]
|
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.
|
# Every component the Global Theme selects has to exist in the repo.
|
||||||
assert "ColorScheme=NexusOS" in defaults
|
assert "ColorScheme=NexusOS" in defaults
|
||||||
assert (KDE / "plasma" / "NexusOS").is_dir()
|
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:
|
for f in qml_files:
|
||||||
# Only the source: lines -- the comments deliberately mention the SVG,
|
# Only the source: lines -- the comments deliberately mention the SVG,
|
||||||
# since that is the file you edit and re-rasterize.
|
# 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("//")]
|
if "source:" in l and not l.strip().startswith("//")]
|
||||||
bg = [l for l in sources if "background" in l]
|
bg = [l for l in sources if "background" in l]
|
||||||
assert bg, f"{f.name} loads no background"
|
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():
|
def _defaults_sections():
|
||||||
"""Parse the look-and-feel defaults into {section: {key: value}}."""
|
"""Parse the look-and-feel defaults into {section: {key: value}}."""
|
||||||
out, section = {}, None
|
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()
|
line = line.strip()
|
||||||
if line.startswith("["):
|
if line.startswith("["):
|
||||||
section = line
|
section = line
|
||||||
@@ -127,7 +127,7 @@ def test_lock_screen_theme_names_a_look_and_feel_package():
|
|||||||
assert greeter["Theme"].endswith(".desktop"), \
|
assert greeter["Theme"].endswith(".desktop"), \
|
||||||
f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}"
|
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()
|
lock_lines = [l for l in installer.splitlines()
|
||||||
if "kscreenlockerrc" in l and "--key Theme" in l]
|
if "kscreenlockerrc" in l and "--key Theme" in l]
|
||||||
assert lock_lines, "installer never sets the lock screen theme"
|
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():
|
def _index_theme():
|
||||||
"""Parse index.theme into (header dict, list of declared directories)."""
|
"""Parse index.theme into (header dict, list of declared directories)."""
|
||||||
header, section, dirs = {}, None, []
|
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()
|
line = line.strip()
|
||||||
if line.startswith("[") and line != "[Icon Theme]":
|
if line.startswith("[") and line != "[Icon Theme]":
|
||||||
section = line.strip("[]")
|
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,
|
"""~/.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
|
so installing there alone meant Plasma never found the theme and every icon
|
||||||
fell back to Breeze without a word."""
|
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()
|
links = [l for l in text.splitlines()
|
||||||
if l.strip().startswith("link ") and "NexusOS-icons" in l]
|
if l.strip().startswith("link ") and "NexusOS-icons" in l]
|
||||||
assert any(".local/share/icons" in l for l in links), \
|
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
|
"""The installer compared the whole comma-separated Inherits value against a
|
||||||
directory name, so a valid multi-parent list warned that an installed
|
directory name, so a valid multi-parent list warned that an installed
|
||||||
fallback was missing."""
|
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]
|
inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l]
|
||||||
assert inh, "inheritance check not found"
|
assert inh, "inheritance check not found"
|
||||||
assert any("-f1" in l for l in inh), \
|
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
|
"""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
|
this box's home would give any other clone or user a missing icon, so the
|
||||||
path is a placeholder the installer substitutes."""
|
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 "/home/" not in js, "panel-layout.js hardcodes a home directory"
|
||||||
assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path"
|
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"
|
assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path"
|
||||||
# Rewriting the panel wholesale on every restore would wipe later additions.
|
# Rewriting the panel wholesale on every restore would wipe later additions.
|
||||||
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
|
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Platform guards for the community-supported native macOS install path."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location("nexus_sync_macos_test", ROOT / "bin" / "sync.py")
|
||||||
|
assert SPEC and SPEC.loader
|
||||||
|
sync = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(sync)
|
||||||
|
|
||||||
|
|
||||||
|
def test_darwin_uses_gpu_agnostic_requirements(monkeypatch):
|
||||||
|
monkeypatch.setattr(sync.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(sync.sys, "platform", "darwin")
|
||||||
|
assert sync.requirements() == "requirements-base.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_provisioning_stages_are_skipped_on_darwin(monkeypatch):
|
||||||
|
monkeypatch.setattr(sync.sys, "platform", "darwin")
|
||||||
|
|
||||||
|
def unexpected_run(*args, **kwargs):
|
||||||
|
raise AssertionError(f"Linux provisioning ran on macOS: {args!r}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sync.subprocess, "run", unexpected_run)
|
||||||
|
sync.linux_stage("restore-linux.sh", "packages")
|
||||||
|
|
||||||
|
|
||||||
|
def test_macos_installer_is_in_the_shell_parse_gate():
|
||||||
|
gate = (ROOT / "bin" / "check.sh").read_text(encoding="utf-8")
|
||||||
|
assert "install-macos.sh" in gate
|
||||||
@@ -3,39 +3,19 @@
|
|||||||
Both checks guard fixes for real defects: the account file used to be written at
|
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
|
the umask and chmodded afterwards, and the IMAP/SMTP connections used to take
|
||||||
Python's stdlib SSL context, which verifies nothing.
|
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 json
|
||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
import stat
|
import stat
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from modules.mail import backend as mail
|
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):
|
def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
|
||||||
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
|
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)
|
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
|
||||||
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
|
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
|
||||||
assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
|
assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
|
||||||
@@ -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"]
|
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):
|
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
|
"""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
|
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",
|
"version": "0.0.0",
|
||||||
"services": {
|
"services": {
|
||||||
"backend": {"running": True, "pid": 11, "url": "http://127.0.0.1:8000"},
|
"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"},
|
"frontend": {"running": False, "pid": None, "url": "http://127.0.0.1:5173"},
|
||||||
"provider": {
|
"provider": {
|
||||||
"provider": "ollama",
|
"provider": "ollama",
|
||||||
@@ -55,7 +54,7 @@ def test_render_frame_contains_sections():
|
|||||||
assert "DATA / TOOLS" in frame
|
assert "DATA / TOOLS" in frame
|
||||||
assert "RUN TOOLCHAINS" in frame
|
assert "RUN TOOLCHAINS" in frame
|
||||||
assert "backend" in frame and "UP" 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 "run_snippet" in frame
|
||||||
assert "ready python" in frame
|
assert "ready python" in frame
|
||||||
assert "missing rust" in frame
|
assert "missing rust" in frame
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ DISTRIBUTION_OF = {
|
|||||||
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
||||||
|
|
||||||
# Modules that ship inside this repo.
|
# 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:
|
def _pyproject() -> dict:
|
||||||
@@ -56,6 +56,12 @@ def _declared() -> set[str]:
|
|||||||
return {_requirement_name(s) for s in specs}
|
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]:
|
def _imported_modules() -> set[str]:
|
||||||
"""Top-level module names imported anywhere in the shipped packages."""
|
"""Top-level module names imported anywhere in the shipped packages."""
|
||||||
found: set[str] = set()
|
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
|
if DISTRIBUTION_OF.get(module, module).lower().replace("_", "-") not in declared
|
||||||
)
|
)
|
||||||
assert not missing, (
|
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 "
|
f"distribution for them: {missing}. Add them to [project] dependencies "
|
||||||
"or an extra (and to DISTRIBUTION_OF here if the names differ)."
|
"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
|
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):
|
def test_ollama_stream_propagates_transport_errors(monkeypatch):
|
||||||
"""A failing stream must surface, not be swallowed into an empty reply —
|
"""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
|
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
|
"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():
|
def test_ollama_failures_surface_the_reason_not_just_the_status():
|
||||||
"""Ollama answers every failure with {"error": "..."} and httpx's default
|
"""Ollama answers every failure with {"error": "..."} and httpx's default
|
||||||
message throws it away. A user hitting a retired cloud model saw
|
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("rambling\n</think>\nThe answer") == "The answer"
|
||||||
assert strip_think("a<think>b</think>c") == "ac"
|
assert strip_think("a<think>b</think>c") == "ac"
|
||||||
assert strip_think("no tags here") == "no tags here"
|
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
|
import json
|
||||||
|
|
||||||
from synapse import tools
|
from synapse import tools
|
||||||
from synapse import code_run
|
|
||||||
from synapse.chat import _run_tool_loop
|
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)
|
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():
|
def test_action_tools_gated_by_consent():
|
||||||
allow = ["search_memory", "web_search", "remember", "fetch_url"]
|
allow = ["search_memory", "web_search", "remember", "fetch_url"]
|
||||||
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
|
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
|
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():
|
def test_standing_schemas_include_render_preview():
|
||||||
names = [s["function"]["name"] for s in tools.standing_schemas()]
|
names = [s["function"]["name"] for s in tools.standing_schemas()]
|
||||||
assert names == ["render_preview"]
|
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():
|
def test_preview_lang_enum_is_derived_not_repeated():
|
||||||
schema, _ = tools.REGISTRY["render_preview"]
|
schema, _ = tools.REGISTRY["render_preview"]
|
||||||
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
|
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)
|
_run_tool_loop(manager, messages, model, schemas, None, None)
|
||||||
)
|
)
|
||||||
return statuses, messages
|
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 def _run():
|
||||||
async with app.run_test():
|
async with app.run_test():
|
||||||
app._busy = True
|
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._busy is False
|
||||||
assert app.history[-1]["content"] == "see [/] and arr[i]"
|
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():
|
async with app.run_test():
|
||||||
app._busy = True
|
app._busy = True
|
||||||
app._show_error("[red]Backend not reachable[/]")
|
app._show_error("[red]Backend not reachable[/]")
|
||||||
app._finish_stream("")
|
app._finish_stream("", app.history)
|
||||||
log = app.query_one("#log")
|
log = app.query_one("#log")
|
||||||
assert any("Backend not reachable" in line.text for line in log.lines)
|
assert any("Backend not reachable" in line.text for line in log.lines)
|
||||||
assert app._busy is False
|
assert app._busy is False
|
||||||
@@ -225,6 +225,71 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
|
|||||||
asyncio.run(_run())
|
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):
|
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
|
||||||
pytest.importorskip("textual")
|
pytest.importorskip("textual")
|
||||||
import nexusos_cli.tui_app as tui_app
|
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():
|
def test_escape_round_trip_helper():
|
||||||
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")
|
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