forked from enderofwings/NexusOS
feat: playbook tools, RAG, vision, voice, chat controls, faster boot
- Tool-using playbooks: read-only tool registry (search_memory, search_history, search_documents, list_models, get_time), per-playbook allowlist, agentic loop, and a "running tool" status indicator. - Document ingest / RAG: documents table + chunker + embed/cosine retrieval reusing the existing stack; Documents page (upload/paste, viewer, delete); top-k chunks injected into the system prompt. - Vision chat: attach images, base64 into /api/chat. - Voice I/O: Web Speech dictation + read-aloud (browser-native, no backend). - Chat controls: stop, regenerate, edit-and-resend; num_ctx knob in Settings. - Per-playbook model override. - History polish: per-conversation + bulk ShareGPT export. - Faster ncp start: UI up first, Ollama warms in the background, with a per-phase timing readout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Document ingest / RAG store — hermetic (fake embeddings, temp DB)."""
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
|
||||
def _store():
|
||||
return PersistentMemoryStore(Path(tempfile.mkdtemp()) / "t.db")
|
||||
|
||||
|
||||
def test_chunker_packs_and_splits():
|
||||
s = _store()
|
||||
one = s._chunk_text("short one.\n\nshort two.")
|
||||
assert one == ["short one.\n\nshort two."] # both fit one chunk
|
||||
many = s._chunk_text("a" * 700 + "\n\n" + "b" * 700)
|
||||
assert len(many) == 2 # each paragraph near the size cap -> own chunk
|
||||
|
||||
|
||||
async def _fake_embed(text):
|
||||
kws = ["lego", "star", "wars", "gpu", "vega"]
|
||||
v = [float(text.lower().count(k)) for k in kws]
|
||||
return v if any(v) else None
|
||||
|
||||
|
||||
def test_add_list_search_delete_roundtrip():
|
||||
async def run():
|
||||
s = _store()
|
||||
r = await s.add_document(
|
||||
"Guide",
|
||||
"Beat the lego star wars boss with the force.\n\nUnrelated gpu vega notes.",
|
||||
_fake_embed,
|
||||
)
|
||||
assert r["chunks"] >= 1
|
||||
assert [d["title"] for d in s.list_documents()] == ["Guide"]
|
||||
|
||||
hits = await s.search_documents("lego star wars", _fake_embed, limit=2, min_score=0.1)
|
||||
assert hits and "lego" in hits[0]["text"].lower()
|
||||
# scores are sorted descending
|
||||
assert all(hits[i]["score"] >= hits[i + 1]["score"] for i in range(len(hits) - 1))
|
||||
|
||||
# get_document returns ordered chunks for the viewer
|
||||
chunks = s.get_document(r["doc_id"])
|
||||
assert [c["chunk_idx"] for c in chunks] == list(range(len(chunks)))
|
||||
|
||||
assert s.delete_document(r["doc_id"]) is True
|
||||
assert s.list_documents() == []
|
||||
assert s.get_document(r["doc_id"]) == [] # gone -> no chunks
|
||||
assert s.delete_document(r["doc_id"]) is False # already gone
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_search_empty_query_returns_nothing():
|
||||
s = _store()
|
||||
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
||||
@@ -43,6 +43,14 @@ def test_keep_alive_pins_the_model():
|
||||
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
|
||||
|
||||
|
||||
def test_num_ctx_option_only_when_positive():
|
||||
# 0 / None -> omit num_ctx so Ollama uses the model default; positive -> set it.
|
||||
from synapse.ollama_manager import _chat_options
|
||||
assert "num_ctx" not in _chat_options(None, None, 0)
|
||||
assert "num_ctx" not in _chat_options(None, None, None)
|
||||
assert _chat_options(None, None, 8192)["num_ctx"] == 8192
|
||||
|
||||
|
||||
def test_default_models_have_one_source_of_truth():
|
||||
# The curator default is the config constant, not a copy of it.
|
||||
assert PersistentMemoryStore._SETTINGS_DEFAULTS["memory_model"] == DEFAULT_MEMORY_MODEL
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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]
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user