Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
209 lines
8.5 KiB
Python
209 lines
8.5 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
|
|
|
|
from synapse import tools
|
|
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"}]
|
|
gen = chatmod._run_tool_loop(_ActionManager(), messages, "m", [{}], 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)
|
|
))
|
|
|
|
# one status sentinel per tool run
|
|
assert statuses == ["__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 == [] # no tool ran
|
|
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)
|