"""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