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