forked from enderofwings/NexusOS
Reconciles 17 commits of this session's work (self-alteration tools, vendored Curry, slash-command dispatch, Windows toolchain/gate fixes) against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/ system for mail and network, in-app updates, the standalone memory microservice folded into an in-process curator, KDE desktop theme overhaul). Nine real conflicts, each resolved by hand after reading both sides' actual diffs rather than picking one side wholesale: - synapse/tools.py, tests/test_tools.py: origin/main's diff here was small and clean (read_file/list_files, two new tests) despite git's diff3 flagging the whole file as one conflict blob -- reset to this branch's version and hand-spliced their addition in at the same points they used, rather than trying to reconcile a false 800-line conflict. Found and fixed a real bug while verifying: _list_files returned backslash-separated paths on Windows, which don't match the forward-slash glob patterns the tool's own schema documents. - synapse/main.py: kept this branch's cue-based standing advertisement of render_preview/run_snippet (independent of any playbook granting them) AND adopted origin/main's fix for routed reference playbooks not bringing their own tools along -- dropping either would have been a real regression, not just a style difference. Also: the standalone memory service (port 8001) is gone upstream, so its dead CORS/kill- target entries were removed; NEXUS_BACKEND_PORT parameterization and the manage_ollama-conditional kill logic (this branch's remote-Ollama support) were kept over origin/main's hardcoded equivalents. - synapse/memory/store.py: kept this branch's _delete_message_vectors helper (already reused elsewhere, batches to stay under SQLite's variable limit) over origin/main's inline duplicate of the same fix. - synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead memory-service port/service entries; kept NEXUS_BACKEND_PORT env override and the manage_ollama-conditional kill-target list. - CLAUDE.md, README.md: merged both sides' additions, no real conflict. Found and fixed three more issues while independently verifying the merged tree, none of them mine or origin/main's alone -- only visible once both sides actually ran together: - modules/ (the new mail+network package) was never added to pyproject.toml's wheel `packages` list OR the sdist's `include` allowlist, so `from modules.registry import ROUTERS` in main.py would ImportError on any wheel install. Fixed both; bin/check.sh's packaging gate now asserts modules/ actually ships. tests/ test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were updated to recognize the new package. - tests/test_mail_creds.py's 0600-mode assertions are POSIX-only -- NTFS has no equivalent permission bits, so os.open(path, 0o600) on Windows just creates a normal file and stat.S_IMODE reports 0o666 regardless. Made the assertions platform-aware rather than skip real coverage (the temp-file-cleanup and password round-trip checks in the same test still run on Windows) or paper over a genuine OS limitation with a fake pass. - tests/test_kde_theme.py used bare Path.read_text() in fifteen places; Windows' default locale encoding (cp1252, not UTF-8) can't decode a real UTF-8 byte in the QML it reads, and did fail on one of the fifteen. Fixed all fifteen, not just the one that happened to trip today, since the other fourteen were equally fragile. Verified: full bin/check.sh reports OK end-to-end on this Windows checkout -- pytest (tests + management): 295 passed, 0 failed, 9 skipped; eslint clean; frontend node:test 57/57; PowerShell/shell parse clean; wheel + sdist pass twine check and now correctly carry modules/ (60 files, up from 52 pre-merge). synapse.main:app builds with 74 routes (up from 54 pre-merge, matching the new Projects/mail/ network endpoints).
952 lines
38 KiB
Python
952 lines
38 KiB
Python
"""Tool-using playbook loop — the read-only MVP.
|
|
|
|
Run from nexus-core/ with the Promethean venv active: pytest -q
|
|
|
|
Hermetic: a fake manager stands in for Ollama, so no network/model is needed.
|
|
Guards the two pieces that would silently break the feature: the allowlist
|
|
filter and the tool-call loop's terminate-on-content behaviour.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
|
|
from synapse import tools
|
|
from synapse import code_run
|
|
from synapse.chat import _run_tool_loop
|
|
|
|
|
|
def test_schemas_for_drops_unknown_names():
|
|
schemas = tools.schemas_for(["search_memory", "not_a_tool"])
|
|
names = [s["function"]["name"] for s in schemas]
|
|
assert names == ["search_memory"]
|
|
assert tools.schemas_for([]) == []
|
|
|
|
|
|
async def _drain(gen):
|
|
return [s async for s in gen]
|
|
|
|
|
|
def test_remember_writes_a_memory_fact(tmp_path, monkeypatch):
|
|
# The `remember` action tool persists a fact through the store.
|
|
import synapse.memory.store as store_mod
|
|
from synapse.memory.store import PersistentMemoryStore
|
|
fresh = PersistentMemoryStore(tmp_path / "m.db")
|
|
monkeypatch.setattr(tools, "store", fresh)
|
|
out = asyncio.run(tools.dispatch("remember", {"text": "user likes tea", "section": "Prefs"}))
|
|
assert "user likes tea" in out
|
|
assert any(it.text == "user likes tea" for it in fresh.all())
|
|
|
|
|
|
def test_action_tools_registered():
|
|
for name in ("web_search", "fetch_url", "remember"):
|
|
assert name in tools.REGISTRY
|
|
names = [s["function"]["name"] for s in tools.schemas_for(["web_search", "remember", "nope"])]
|
|
assert names == ["web_search", "remember"]
|
|
|
|
|
|
class _ActionManager:
|
|
"""Returns a `remember` (action) tool_call once, then plain content."""
|
|
def __init__(self):
|
|
self.n = 0
|
|
|
|
async def chat(self, **_):
|
|
self.n += 1
|
|
if self.n == 1:
|
|
return {"role": "assistant",
|
|
"tool_calls": [{"function": {"name": "remember", "arguments": {"text": "x"}}}]}
|
|
return {"role": "assistant", "content": "done"}
|
|
|
|
|
|
def _drive_with_decision(decision, monkeypatch):
|
|
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(_ActionManager(), messages, "m", schemas, None, None,
|
|
conversation_id="conv", policy="ask")
|
|
statuses = []
|
|
async for s in gen:
|
|
statuses.append(s)
|
|
if s.startswith("__approve__"):
|
|
w = chatmod.pending_approvals["conv"]
|
|
w["decisions"] = {"remember": decision}
|
|
w["event"].set()
|
|
return statuses, messages
|
|
|
|
return asyncio.run(run())
|
|
|
|
|
|
def test_ask_policy_pauses_then_runs_on_approve(monkeypatch):
|
|
statuses, messages = _drive_with_decision(True, monkeypatch)
|
|
assert any(s.startswith("__approve__") for s in statuses) # paused for approval
|
|
assert "__status__remember" in statuses # approved -> ran
|
|
assert any(m["role"] == "tool" and "saved-ok" in m["content"] for m in messages)
|
|
|
|
|
|
def test_ask_policy_skips_on_deny(monkeypatch):
|
|
statuses, messages = _drive_with_decision(False, monkeypatch)
|
|
assert any(s.startswith("__approve__") for s in statuses)
|
|
assert "__status__remember" not in statuses # denied -> never ran
|
|
assert any(m["role"] == "tool" and "declined" in m["content"] for m in messages)
|
|
|
|
|
|
def test_action_tools_gated_by_consent():
|
|
allow = ["search_memory", "web_search", "remember", "fetch_url"]
|
|
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
|
|
off = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=False)]
|
|
assert set(on) == set(allow) # all pass when actions allowed
|
|
assert off == ["search_memory"] # action tools withheld when not
|
|
assert tools.is_action("remember") and not tools.is_action("search_memory")
|
|
|
|
|
|
class _FakeManager:
|
|
"""Returns a tool_call on the first chat() call, plain content after."""
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def chat(self, **_):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
return {
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{"function": {"name": "search_memory", "arguments": {"query": "gpu"}}}
|
|
],
|
|
}
|
|
return {"role": "assistant", "content": "here is the answer"}
|
|
|
|
|
|
def test_tool_loop_runs_tool_then_stops(monkeypatch):
|
|
async def fake_dispatch(name, args):
|
|
assert name == "search_memory"
|
|
assert args == {"query": "gpu"}
|
|
return '[{"section": "GPU", "text": "Vega 20 4GB"}]'
|
|
|
|
monkeypatch.setattr(tools, "dispatch", fake_dispatch)
|
|
|
|
messages = [{"role": "user", "content": "what gpu do i have?"}]
|
|
schemas = tools.schemas_for(["search_memory"])
|
|
statuses = asyncio.run(_drain(
|
|
_run_tool_loop(_FakeManager(), messages, "m", schemas, None, None)
|
|
))
|
|
|
|
# heartbeat + one status sentinel per tool run
|
|
assert statuses == ["__status__tools", "__status__search_memory"]
|
|
# messages mutated in place: user -> assistant(tool_calls) -> tool(result);
|
|
# the final content turn is NOT appended (the streaming turn regenerates it).
|
|
assert [m["role"] for m in messages] == ["user", "assistant", "tool"]
|
|
assert "Vega 20" in messages[-1]["content"]
|
|
|
|
|
|
def test_tool_loop_degrades_when_model_returns_no_dict():
|
|
class _NoToolManager:
|
|
async def chat(self, **_):
|
|
return None # model can't do tools / errored
|
|
|
|
messages = [{"role": "user", "content": "hi"}]
|
|
before = list(messages)
|
|
statuses = asyncio.run(_drain(_run_tool_loop(_NoToolManager(), messages, "m", [{}], None, None)))
|
|
assert statuses == ["__status__tools"] # heartbeat only; no tool ran
|
|
assert messages == before # untouched -> falls back to a plain stream
|
|
|
|
|
|
def test_standing_schemas_include_render_preview():
|
|
names = [s["function"]["name"] for s in tools.standing_schemas()]
|
|
assert names == ["render_preview"]
|
|
assert "render_preview" in tools.STANDING_TOOLS
|
|
assert not tools.is_action("render_preview")
|
|
assert tools.wants_render_preview("visualize Collatz with a chart")
|
|
assert not tools.wants_render_preview("what's the weather vibe today")
|
|
|
|
|
|
def test_render_preview_rejects_canvas_that_never_draws():
|
|
bad = """<!DOCTYPE html><html><body>
|
|
<canvas id="c" width="480" height="240"></canvas>
|
|
<script>
|
|
const canvas = document.getElementById('c');
|
|
function spin(num) {
|
|
while (num !== 1) {
|
|
num = num % 2 === 0 ? num / 2 : 3 * num + 1;
|
|
canvas.width = canvas.width;
|
|
}
|
|
}
|
|
spin(40);
|
|
</script></body></html>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html", "title": "Demo", "markup": bad,
|
|
})))
|
|
assert out["ok"] is False
|
|
joined = " ".join(out.get("issues", []))
|
|
assert "draw" in joined.lower() or "getcontext" in joined.lower()
|
|
assert "scaffold" not in out # withheld on a first rejection
|
|
|
|
|
|
def test_render_preview_rejects_forty_by_forty_stub_with_scaffold():
|
|
# Tiny stubs fail critique; tool returns fix hints + generic scaffold — not a
|
|
# canned Collatz/Recamán demo.
|
|
bad = """<!DOCTYPE html><html><head><style>
|
|
.colla { width: 40px; height: 40px; background-color: #2563eb; }
|
|
</style></head><body>
|
|
<canvas id="c" width="40" height="40"></canvas>
|
|
<script>
|
|
const canvas = document.getElementById('c');
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillRect(0, 0, 40, 40);
|
|
</script></body></html>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html", "markup": bad, "purpose": "interactive plot demo",
|
|
})))
|
|
assert out["ok"] is False
|
|
assert out.get("repaired") is not True
|
|
assert "fence" not in out or not out.get("fence")
|
|
assert "scaffold" not in out # withheld on a first rejection
|
|
assert "issues" in out
|
|
|
|
|
|
def test_render_preview_accepts_canvas_that_plots():
|
|
good = """<!DOCTYPE html><html><body>
|
|
<canvas id="c" width="480" height="240"></canvas>
|
|
<input id="n" type="number" value="27">
|
|
<button onclick="go()">Go</button>
|
|
<script>
|
|
const c = document.getElementById('c');
|
|
const ctx = c.getContext('2d');
|
|
function go() {
|
|
let n = +document.getElementById('n').value, seq = [];
|
|
while (n !== 1 && seq.length < 500) { seq.push(n); n = n % 2 === 0 ? n/2 : 3*n+1; }
|
|
seq.push(1);
|
|
const max = Math.max(...seq);
|
|
ctx.clearRect(0,0,c.width,c.height);
|
|
ctx.beginPath();
|
|
seq.forEach((v,i) => {
|
|
const x = i * (c.width / Math.max(1, seq.length-1));
|
|
const y = c.height - (v / max) * (c.height - 8);
|
|
if (i === 0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
});
|
|
ctx.stroke();
|
|
}
|
|
go();
|
|
</script></body></html>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html", "markup": good, "purpose": "line plot of an iterative sequence",
|
|
})))
|
|
assert out["ok"] is True
|
|
assert out["fence"].startswith("```html\n")
|
|
assert "getContext" in out["fence"]
|
|
assert out.get("repaired") is not True
|
|
|
|
|
|
def test_code_that_throws_is_left_to_the_previews_own_error_channel():
|
|
"""This markup is broken twice over: getContext() is assigned to `canvas`
|
|
but drawn with `ctx`, and collatz() is called as coll(). Both used to be
|
|
rejected here by regex. Both now reach the browser, which reports them
|
|
precisely — verified against the real preview:
|
|
|
|
"Uncaught ReferenceError: ctx is not defined (line 4)"
|
|
"Uncaught ReferenceError: coll is not defined (line 5)"
|
|
|
|
Static guessing at runtime failures only ever caught the spellings someone
|
|
anticipated; the error channel catches every one of them and carries a line
|
|
number. What stays in the critique is the opposite case — markup that runs
|
|
perfectly and still isn't a visualization."""
|
|
broken_at_runtime = """<!DOCTYPE html><html><body>
|
|
<canvas id="c" width="480" height="280"></canvas>
|
|
<script>
|
|
const canvas = document.getElementById('c').getContext('2d');
|
|
function collatz(n) {
|
|
const s = [];
|
|
while (n !== 1 && s.length < 500) {
|
|
s.push(n);
|
|
n = n % 2 === 0 ? n / 2 : n * 3 + 1;
|
|
}
|
|
s.push(1);
|
|
return s;
|
|
}
|
|
function plot() {
|
|
const seq = coll(document.getElementById('n').value);
|
|
const max = Math.max(...seq), w = canvas.width, h = canvas.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.beginPath();
|
|
seq.forEach((v, i) => {
|
|
const x = i * (w / Math.max(1, seq.length - 1));
|
|
const y = h - (v / max) * h;
|
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
}
|
|
</script></body></html>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html",
|
|
"purpose": "interactive sequence plot",
|
|
"markup": broken_at_runtime,
|
|
})))
|
|
assert out["ok"] is True, out.get("issues")
|
|
|
|
|
|
def test_render_preview_rejects_decorative_svg_without_guessing_algorithm():
|
|
bad = """<!DOCTYPE html><html><body>
|
|
<div class="wrap">
|
|
<input id="n" type="number" value="27"><button id="go">Plot</button>
|
|
</div>
|
|
<svg width="480" height="200" viewBox="0 0 480 200">
|
|
<defs><linearGradient id="g"><stop offset="0%" stop-color="#1a1a1a"/></linearGradient></defs>
|
|
<rect x="0" y="0" width="480" height="200" fill="url(#g)"/>
|
|
<line x1="0" y1="100" x2="480" y2="100" stroke="#fff"/>
|
|
</svg>
|
|
<script>
|
|
document.getElementById('go').onclick = function() {
|
|
var line = document.createElementNS('line');
|
|
document.querySelector('.wrap').appendChild(line);
|
|
};
|
|
</script></body></html>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html",
|
|
"title": "Unknown sequence",
|
|
"purpose": "interactive sequence demo",
|
|
"markup": bad,
|
|
})))
|
|
assert out["ok"] is False
|
|
assert out.get("repaired") is not True
|
|
blob = json.dumps(out).lower()
|
|
assert "recaman" not in blob and "collatz" not in blob
|
|
assert "scaffold" not in out # withheld on a first rejection
|
|
|
|
|
|
def test_no_sequence_render_seed_helper():
|
|
assert not hasattr(tools, "sequence_render_seed")
|
|
|
|
|
|
_FRONTEND_REGISTRY = ("interface", "web", "src", "preview", "languages.js")
|
|
|
|
|
|
def _frontend_preview_langs() -> list[str]:
|
|
"""Top-level keys of PREVIEW_LANGS in the frontend's preview registry."""
|
|
import re
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parents[1].joinpath(*_FRONTEND_REGISTRY)
|
|
text = src.read_text(encoding="utf-8")
|
|
body = re.search(r"^export const PREVIEW_LANGS = \{\n(.*?)^\};", text, re.S | re.M)
|
|
assert body, f"could not find a PREVIEW_LANGS object literal in {src}"
|
|
return re.findall(r"^ (\w+):", body.group(1), re.M)
|
|
|
|
|
|
def test_preview_langs_match_the_frontend_registry():
|
|
"""The render window is two registries — synapse/tools.py validates a
|
|
language, interface/web/src/Markdown.jsx renders it — and a language present
|
|
in only one degrades silently: the model emits a fence the UI shows as a
|
|
plain code block, or the UI offers a preview the tool refuses to produce.
|
|
Nothing at runtime couples them, so this is what keeps them in step."""
|
|
# Plain ASCII in the message: this is read off a Windows console, where
|
|
# pytest's output encoding mangles non-ASCII into replacement characters.
|
|
assert _frontend_preview_langs() == list(tools.PREVIEW_LANGS), (
|
|
"PREVIEW_LANGS differs between synapse/tools.py and "
|
|
"interface/web/src/Markdown.jsx - add the language to both."
|
|
)
|
|
|
|
|
|
_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():
|
|
"""Same discipline as render_preview: first reject is issues-only; second
|
|
gets a pattern. _attempt is supplied by the tool loop."""
|
|
first = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
|
"lang": "python", "source": "import socket\nprint(1)", "_attempt": 0,
|
|
})))
|
|
assert first["ok"] is False
|
|
assert "scaffold" not in first
|
|
second = json.loads(asyncio.run(tools.dispatch("run_snippet", {
|
|
"lang": "python", "source": "import socket\nprint(1)", "_attempt": 1,
|
|
})))
|
|
assert second["ok"] is False
|
|
assert "scaffold" in second and "print" in second["scaffold"]
|
|
|
|
|
|
def test_preview_lang_enum_is_derived_not_repeated():
|
|
schema, _ = tools.REGISTRY["render_preview"]
|
|
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
|
|
assert enum == list(tools.PREVIEW_LANGS)
|
|
|
|
|
|
def test_render_preview_rejects_unknown_lang():
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "python", "markup": "print('hi')" * 5,
|
|
})))
|
|
assert out["ok"] is False
|
|
assert "lang must be" in out["error"]
|
|
|
|
|
|
def test_render_preview_accepts_a_jsx_component():
|
|
good = """export default function Counter() {
|
|
const [n, setN] = useState(0);
|
|
return (
|
|
<div>
|
|
<button onClick={() => setN(n + 1)}>count {n}</button>
|
|
</div>
|
|
);
|
|
}"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": good, "purpose": "interactive counter",
|
|
})))
|
|
assert out["ok"] is True, out.get("issues")
|
|
assert out["fence"].startswith("```jsx\n")
|
|
|
|
|
|
def test_interactive_ui_needs_no_canvas_but_a_chart_does():
|
|
"""Forms and calculators are interactive through DOM elements in either
|
|
HTML or JSX; only a request claiming to be a chart needs a drawing surface."""
|
|
component = """export default function Form() {
|
|
const [name, setName] = useState("");
|
|
return <label>Name <input value={name} onInput={(e) => setName(e.target.value)} /></label>;
|
|
}"""
|
|
ok = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": component, "purpose": "an interactive demo",
|
|
})))
|
|
assert ok["ok"] is True, ok.get("issues")
|
|
|
|
bad = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": component, "purpose": "a chart of the results",
|
|
})))
|
|
assert bad["ok"] is False
|
|
assert any("canvas" in i for i in bad["issues"])
|
|
|
|
html = """<!doctype html><html><body>
|
|
<label>Value <input id="value" type="number" value="2"></label>
|
|
<button onclick="result.textContent = +value.value * 2">Double</button>
|
|
<output id="result">4</output>
|
|
</body></html>"""
|
|
html_ok = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html", "markup": html, "purpose": "an interactive calculator demo",
|
|
})))
|
|
assert html_ok["ok"] is True, html_ok.get("issues")
|
|
|
|
|
|
def test_scaffold_is_withheld_until_the_model_has_failed_twice():
|
|
"""A complete, styled, runnable document handed to a struggling model gets
|
|
pasted rather than adapted — and then persists in the conversation and comes
|
|
back as retrieved context for later requests, carrying its example domain
|
|
with it. A transcript showed this scaffold's CSS reappearing verbatim in an
|
|
answer to an unrelated prompt, in a conversation where the tool was never
|
|
called. So the first rejection says only what is wrong."""
|
|
for args in ({"lang": "html", "markup": "<div>too short</div>"},
|
|
{"lang": "jsx", "markup": ""}):
|
|
first = json.loads(asyncio.run(tools.dispatch("render_preview", args)))
|
|
assert first["ok"] is False
|
|
assert "scaffold" not in first, args
|
|
assert "issues" in first or "error" in first
|
|
|
|
again = json.loads(asyncio.run(tools.dispatch(
|
|
"render_preview", {**args, "_attempt": 1})))
|
|
assert again["ok"] is False
|
|
assert "scaffold" in again, args
|
|
|
|
|
|
def test_repeat_reject_hands_back_the_language_that_was_asked_for():
|
|
"""Answering a rejected component with a full HTML document tells the model
|
|
to write the wrong thing entirely."""
|
|
jsx = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": "<div>too short</div>", "_attempt": 1,
|
|
})))
|
|
assert "export default function App" in jsx["scaffold"]
|
|
assert "<!DOCTYPE html>" not in jsx["scaffold"]
|
|
|
|
html = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html", "markup": "<div>too short</div>", "_attempt": 1,
|
|
})))
|
|
assert "<!DOCTYPE html>" in html["scaffold"]
|
|
|
|
empty = json.loads(asyncio.run(tools.dispatch(
|
|
"render_preview", {"lang": "tsx", "markup": "", "_attempt": 1})))
|
|
assert "export default function App" in empty["scaffold"]
|
|
|
|
|
|
def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
|
|
"""Each of these is a real prompt from a transcript where the render window
|
|
should have been reachable. The first one was not: no hint matched
|
|
'mouse-over sensitive ... jsx', so the tool was never advertised and the
|
|
model answered about Euler's formula instead."""
|
|
for prompt in (
|
|
"Create a mouse-over sensitive Euler fluid field as a jsx or tsx",
|
|
"write me a small tsx component",
|
|
"make the particles react to hover",
|
|
"a real-time simulation I can drag",
|
|
):
|
|
assert tools.wants_render_preview(prompt), prompt
|
|
|
|
# Still narrow: ordinary chat must not pay for a tool turn.
|
|
for prompt in (
|
|
"what's the weather vibe today",
|
|
"summarise this email thread",
|
|
"write a concise paragraph about caching",
|
|
):
|
|
assert not tools.wants_render_preview(prompt), prompt
|
|
|
|
assert tools.wants_render_preview("compare these graphs")
|
|
|
|
|
|
def test_external_preview_resources_are_rejected_in_attributes_and_css():
|
|
for markup in (
|
|
'<img src=https://example.com/chart.png alt="chart">',
|
|
'<style>.chart { background: url("https://example.com/chart.png"); }</style>',
|
|
'<style>@import "https://example.com/chart.css";</style>',
|
|
):
|
|
issues = tools._critique_shared(markup)
|
|
assert any("external http(s)" in issue for issue in issues), markup
|
|
|
|
|
|
def test_every_preview_language_hints_for_itself():
|
|
for lang in tools.PREVIEW_LANGS:
|
|
assert lang in tools._RENDER_HINTS, lang
|
|
|
|
|
|
def test_size_guidance_never_quotes_the_minimum():
|
|
"""Weak models copy the first dimensions they read. Three transcripts
|
|
produced exactly 320x200 — the old minimum — including one that had a
|
|
480x280 example in front of it. Only the wanted size may be spoken."""
|
|
schema, _ = tools.REGISTRY["render_preview"]
|
|
surfaces = [json.dumps(schema)]
|
|
for lang, markup in (("html", '<canvas width="40" height="40"></canvas>' + "x" * 60),
|
|
("svg", '<svg width="40" height="40"><rect/></svg>' + "x" * 60)):
|
|
surfaces.append(json.dumps(asyncio.run(
|
|
tools._render_preview(lang=lang, markup=markup, purpose="a chart"))))
|
|
blob = " ".join(surfaces)
|
|
assert str(tools._MIN_CANVAS_W) not in blob, "the minimum leaked into guidance"
|
|
assert str(tools._STAGE_W) in blob
|
|
|
|
|
|
def test_prose_only_component_is_rejected_like_a_prose_page():
|
|
"""The JSX that started the Euler misunderstanding: a component returning
|
|
three paragraphs. It was accepted because the prose check lived only on the
|
|
html side."""
|
|
prose = """export default function App() {
|
|
return (
|
|
<div>
|
|
<h1>Euler's Formula</h1>
|
|
<p>The sum of the first n natural numbers is:</p>
|
|
<p>{`f(x) = ${sumOfCubes(10)}`}</p>
|
|
<p>For example, the sum of the cubes of the first 10 is: {sumOfCubes(10)}</p>
|
|
</div>
|
|
);
|
|
}"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": prose, "purpose": "Euler fluid field",
|
|
})))
|
|
assert out["ok"] is False
|
|
assert any("not a visualization" in i for i in out["issues"])
|
|
|
|
|
|
def test_a_path_parked_in_defs_is_not_a_plot():
|
|
"""Straight from a transcript: a long <path> inside <defs> — never drawn —
|
|
passed as proof of a real chart while the preview rendered an empty box."""
|
|
undrawn = (
|
|
'<svg width="480" height="280" xmlns="http://www.w3.org/2000/svg"><defs>'
|
|
'<path d="M10,20L 10,190L 20,180L 30,170L 40,160L 50,150L 60,140L 70,130L 200L 0L" />'
|
|
'</defs><rect x="0" y="0" width="480" height="280" fill="none" stroke="#000" /></svg>'
|
|
)
|
|
assert tools._decorative_svg_not_plot(undrawn)
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "svg", "markup": undrawn, "purpose": "a plot of the field",
|
|
})))
|
|
assert out["ok"] is False
|
|
|
|
# The same path where it actually renders is still a plot.
|
|
drawn = undrawn.replace("<defs>", "").replace("</defs>", "")
|
|
assert not tools._decorative_svg_not_plot(drawn)
|
|
|
|
|
|
def test_render_nudge_never_reaches_the_streaming_turn():
|
|
"""The nudge is a synthetic user turn. Left in place it becomes the last
|
|
thing the user appears to have said, and the model answers it — which is
|
|
exactly what shipped: "please provide the user's request for the rendering",
|
|
twice, in place of a bouncing particle system."""
|
|
from synapse.chat import _strip_internal_turns, _render_nudge_text
|
|
real = {"role": "user", "content": "draw me a bouncing particle system"}
|
|
kept = _strip_internal_turns([
|
|
real,
|
|
{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "x"}}]},
|
|
{"role": "tool", "content": "{}"},
|
|
{"role": "user", "content": _render_nudge_text()},
|
|
])
|
|
assert kept[-1] == real
|
|
assert len(kept) == 2
|
|
assert kept[0]["role"] == "user"
|
|
assert "Tool results" in kept[0]["content"]
|
|
assert "{}" in kept[0]["content"]
|
|
|
|
|
|
def test_normal_tool_results_reach_streaming_turn():
|
|
"""Flatten Ollama's tool roles without discarding the retrieved data."""
|
|
from synapse.chat import _strip_internal_turns
|
|
request = {"role": "user", "content": "what GPU do I have?"}
|
|
kept = _strip_internal_turns([
|
|
request,
|
|
{"role": "assistant", "content": "", "tool_calls": [{
|
|
"function": {"name": "search_memory", "arguments": {"query": "GPU"}},
|
|
}]},
|
|
{"role": "tool", "content": '[{"text":"Vega 20 4GB"}]'},
|
|
])
|
|
assert kept[-1] == request
|
|
assert "Vega 20 4GB" in kept[-2]["content"]
|
|
assert all(m.get("role") != "tool" and not m.get("tool_calls") for m in kept)
|
|
|
|
|
|
def test_render_nudge_says_only_what_to_do_next():
|
|
"""It cannot refer to something the model can't see, offer a way out, or
|
|
name a size or language that isn't the one we want — it gets answered
|
|
literally."""
|
|
from synapse.chat import _render_nudge_text
|
|
nudge = _render_nudge_text().lower()
|
|
assert "this user request" not in nudge # dangling reference -> "please provide it"
|
|
assert "clarif" not in nudge # escape hatch -> it gets taken
|
|
assert str(tools._MIN_CANVAS_W) not in nudge
|
|
assert f"{tools._STAGE_W}x{tools._STAGE_H}" in nudge
|
|
for lang in tools.PREVIEW_LANGS: # not a hardcoded "html or svg"
|
|
assert lang in nudge, lang
|
|
|
|
|
|
def test_every_language_offers_a_scaffold():
|
|
for lang in tools.PREVIEW_LANGS:
|
|
assert tools._scaffold_for(lang), f"{lang} has no scaffold"
|
|
|
|
|
|
def test_render_preview_rejects_jsx_with_no_component():
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": "const x = 1;\nconsole.log(x);\n// nothing to mount",
|
|
})))
|
|
assert out["ok"] is False
|
|
assert any("No component to mount" in i for i in out["issues"])
|
|
|
|
|
|
def test_render_preview_rejects_jsx_importing_a_third_party_module():
|
|
src = """import { motion } from "framer-motion";
|
|
export default function App() {
|
|
return <motion.div>hello there friend</motion.div>;
|
|
}"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": src,
|
|
})))
|
|
assert out["ok"] is False
|
|
assert any("framer-motion" in i for i in out["issues"])
|
|
|
|
|
|
def test_render_preview_allows_react_imports_in_jsx():
|
|
src = """import { useState } from "react";
|
|
export default function App() {
|
|
const [n] = useState(0);
|
|
return <p>count is {n} right now</p>;
|
|
}"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "jsx", "markup": src,
|
|
})))
|
|
assert out["ok"] is True, out.get("issues")
|
|
|
|
|
|
def test_render_preview_rejects_tiny_decorative_tile():
|
|
stub = """<div style="width:40px;height:40px;background:#2563eb;border:1px solid #000"></div>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html", "markup": stub,
|
|
})))
|
|
assert out["ok"] is False
|
|
|
|
|
|
def test_render_preview_rejects_prose_page():
|
|
bad = """<!DOCTYPE html><html><body>
|
|
<div><h1>Some Topic</h1>
|
|
<p>This explains an idea in several paragraphs without drawing anything.</p>
|
|
<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>
|
|
<p>To view a live Preview/Code toggle, use:</p>
|
|
<pre><html><body><p>more prose</p></body></html></pre>
|
|
</div></body></html>"""
|
|
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
|
"lang": "html",
|
|
"title": "Topic",
|
|
"purpose": "interactive demo",
|
|
"markup": bad,
|
|
})))
|
|
assert out["ok"] is False
|
|
assert "scaffold" not in out # withheld on a first rejection
|
|
assert out.get("repaired") is not True
|
|
|
|
|
|
def test_last_ok_render_fence_prefers_tool_result():
|
|
from synapse.chat import _last_ok_render_fence
|
|
fence, meta = _last_ok_render_fence([
|
|
{"role": "tool", "content": json.dumps({
|
|
"ok": True,
|
|
"fence": "```html\n<canvas width=\"480\" height=\"280\"></canvas>\n```",
|
|
})},
|
|
])
|
|
assert fence.startswith("```html")
|
|
assert meta.get("ok") is True
|
|
|
|
|
|
def test_coerce_tool_calls_from_content_json():
|
|
from synapse.chat import _coerce_tool_calls
|
|
# Structured field wins.
|
|
structured = {"role": "assistant", "tool_calls": [
|
|
{"function": {"name": "get_time", "arguments": {}}}
|
|
]}
|
|
assert _coerce_tool_calls(structured)[0]["function"]["name"] == "get_time"
|
|
# Small models dump a complete call into content.
|
|
content_call = {
|
|
"role": "assistant",
|
|
"content": '{"name":"render_preview","arguments":{"lang":"svg","markup":"<svg/>"}}',
|
|
}
|
|
calls = _coerce_tool_calls(content_call, {"render_preview"})
|
|
assert len(calls) == 1
|
|
assert calls[0]["function"]["name"] == "render_preview"
|
|
assert calls[0]["function"]["arguments"]["lang"] == "svg"
|
|
|
|
# JSON quoted as part of an explanation is output, not an instruction to
|
|
# execute a tool (especially important for action tools such as remember).
|
|
embedded = {
|
|
"role": "assistant",
|
|
"content": (
|
|
'For example: {"name":"remember","arguments":{"text":"do not save"}} '
|
|
"is the tool-call shape."
|
|
),
|
|
}
|
|
assert _coerce_tool_calls(embedded, {"remember"}) == []
|
|
|
|
# Even a whole JSON object cannot call a tool that was not advertised.
|
|
assert _coerce_tool_calls(content_call, {"search_memory"}) == []
|
|
|
|
|
|
def test_tool_loop_runs_content_json_tool_call(monkeypatch):
|
|
"""qwen-style: first turn returns content-JSON tool call, second returns text."""
|
|
from synapse import chat as chatmod
|
|
|
|
class _ContentJsonManager:
|
|
def __init__(self):
|
|
self.n = 0
|
|
|
|
async def chat(self, **_):
|
|
self.n += 1
|
|
if self.n == 1:
|
|
return {
|
|
"role": "assistant",
|
|
"content": json.dumps({
|
|
"name": "render_preview",
|
|
"arguments": {
|
|
"lang": "svg",
|
|
"markup": (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200">'
|
|
'<circle cx="160" cy="100" r="60" fill="red"/></svg>'
|
|
),
|
|
},
|
|
}),
|
|
}
|
|
return {"role": "assistant", "content": "done"}
|
|
|
|
statuses, messages = asyncio.run(_drain_with_messages(
|
|
_ContentJsonManager(), "m", tools.standing_schemas(),
|
|
user="draw a circle",
|
|
))
|
|
assert any(s == "__status__render_preview" for s in statuses)
|
|
tool_msgs = [m for m in messages if m.get("role") == "tool"]
|
|
assert tool_msgs
|
|
assert json.loads(tool_msgs[0]["content"])["ok"] is True
|
|
|
|
|
|
def test_tool_loop_nudges_render_preview_on_visual_ask():
|
|
"""First turn skips tools; nudge forces a second turn that calls render_preview."""
|
|
class _SkipThenCall:
|
|
def __init__(self):
|
|
self.n = 0
|
|
|
|
async def chat(self, **_):
|
|
self.n += 1
|
|
if self.n == 1:
|
|
return {"role": "assistant", "content": "Sure, here is a chart in prose."}
|
|
if self.n == 2:
|
|
return {
|
|
"role": "assistant",
|
|
"tool_calls": [{
|
|
"function": {
|
|
"name": "render_preview",
|
|
"arguments": {
|
|
"lang": "svg",
|
|
"markup": (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="280">'
|
|
'<rect width="480" height="280" fill="#111"/>'
|
|
'<text x="24" y="150" fill="#eee" font-size="24">hi</text></svg>'
|
|
),
|
|
},
|
|
}
|
|
}],
|
|
}
|
|
return {"role": "assistant", "content": "done"}
|
|
|
|
statuses, messages = asyncio.run(_drain_with_messages(
|
|
_SkipThenCall(), "m", tools.standing_schemas(),
|
|
user="Visualize the Collatz conjecture with an interactive chart",
|
|
))
|
|
assert any(s == "__status__render_preview" for s in statuses)
|
|
assert any(
|
|
m.get("role") == "user" and "render_preview tool now" in (m.get("content") or "")
|
|
for m in messages
|
|
)
|
|
|
|
|
|
async def _drain_with_messages(manager, model, schemas, user="draw a circle"):
|
|
messages = [{"role": "user", "content": user}]
|
|
statuses = await _drain(
|
|
_run_tool_loop(manager, messages, model, schemas, None, None)
|
|
)
|
|
return statuses, messages
|
|
|
|
|
|
def test_read_file_stays_inside_the_repo():
|
|
"""The repo-file tools are the fix for the model inventing paths like
|
|
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
|
|
import json
|
|
|
|
def read(p):
|
|
return asyncio.run(tools._read_file(p))
|
|
|
|
assert "escapes" in read("../../etc/passwd")
|
|
# a leading slash is treated as repo-relative, so it lands nowhere real
|
|
assert "root:" not in read("/etc/passwd")
|
|
assert "required" in read("")
|
|
# private data and heavy trees are refused even though they're in-repo
|
|
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
|
|
assert "not readable" in read(denied), denied
|
|
assert "does not exist" in read("nexus/nlp.py")
|
|
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
|
|
|
|
|
|
def test_list_files_globs_the_repo_without_leaking_denied_paths():
|
|
import json
|
|
|
|
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
|
|
assert "synapse/main.py" in hits
|
|
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
|
|
|
|
|
|
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
|
|
"""A reference playbook routed into the prompt must bring its tools with it.
|
|
Without this the model reads instructions like "you can read the codebase"
|
|
while being advertised zero tools — and narrates tool calls it never made."""
|
|
from synapse.main import _route_playbooks
|
|
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
|
|
|
|
# Own store, not data/playbooks: the live set is the operator's, and a
|
|
# published clone ships different playbooks - this asserted on data that
|
|
# travels with one machine.
|
|
store = PlaybookFileStore(tmp_path)
|
|
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
|
|
instructions="i", order=0))
|
|
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
|
|
instructions="You can read the codebase.", order=1,
|
|
tags=["synapse", "backend"],
|
|
tools=["read_file", "list_files"]))
|
|
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
|
|
import synapse.playbook_manager as pm
|
|
|
|
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
|
|
names = {pb.title for pb in routed}
|
|
assert "NexusOS Developer" in names, names
|
|
|
|
granted = {t for pb in routed for t in (pb.tools or [])}
|
|
assert {"read_file", "list_files"} <= granted, granted
|
|
# none of them are action tools, so they survive the default policy (off)
|
|
assert tools.schemas_for(sorted(granted), allow_actions=False)
|