"""Read-only tools a playbook can call during chat. Ollama drives the calling: `/api/chat` with a `tools` param returns `message.tool_calls`, and this module is just the registry + dispatch. Every tool here only READS local state (SQLite, Ollama) — no side effects. The per-playbook allowlist (`PlaybookItem.tools`) is the security boundary; keep the registry read-only until the loop is trusted. """ from __future__ import annotations import json from typing import Awaitable, Callable from .memory.store import store from .ollama_manager import get_ollama_manager async def _search_memory(query: str = "", **_) -> str: q = (query or "").strip().lower() hits = [ {"section": it.section, "text": it.text} for it in store.all() if not q or q in it.text.lower() or q in (it.section or "").lower() or any(q in t.lower() for t in it.tags) ] return json.dumps(hits[:20]) async def _search_history(query: str = "", **_) -> str: # Hybrid recall: semantic (embeddings) unioned with lexical, falls back to # lexical if embeddings are down. Same retrieval the chat endpoint uses. convs = await store.semantic_search_conversations( query or "", get_ollama_manager().embed, limit=3 ) return json.dumps([{"matches": c.get("matches", [])} for c in convs]) async def _list_models(**_) -> str: return json.dumps(await get_ollama_manager().list_models()) async def _search_documents(query: str = "", **_) -> str: hits = await store.search_documents(query or "", get_ollama_manager().embed, limit=3) return json.dumps([{"title": h["title"], "text": h["text"]} for h in hits]) async def _get_time(**_) -> str: from datetime import datetime return json.dumps({"now": datetime.now().isoformat(timespec="seconds")}) # name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format. REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = { "search_memory": ( { "type": "function", "function": { "name": "search_memory", "description": "Search the user's persistent memory facts. Empty query returns all facts.", "parameters": { "type": "object", "properties": {"query": {"type": "string", "description": "text to match"}}, }, }, }, _search_memory, ), "search_history": ( { "type": "function", "function": { "name": "search_history", "description": "Search past conversations for exchanges containing the query text.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, _search_history, ), "list_models": ( { "type": "function", "function": { "name": "list_models", "description": "List the locally installed Ollama models.", "parameters": {"type": "object", "properties": {}}, }, }, _list_models, ), "search_documents": ( { "type": "function", "function": { "name": "search_documents", "description": "Search the user's uploaded documents for relevant passages.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, _search_documents, ), "get_time": ( { "type": "function", "function": { "name": "get_time", "description": "Get the current local date and time.", "parameters": {"type": "object", "properties": {}}, }, }, _get_time, ), } def schemas_for(names: list[str]) -> list[dict]: """Tool schemas for a playbook's allowlist; unknown names are dropped.""" return [REGISTRY[n][0] for n in (names or []) if n in REGISTRY] async def dispatch(name: str, args: dict | None) -> str: """Run a tool by name. Never raises — returns an error string on failure.""" entry = REGISTRY.get(name) if not entry: return json.dumps({"error": f"unknown tool: {name}"}) try: return await entry[1](**(args or {})) except Exception as e: # a broken tool must not kill the chat loop return json.dumps({"error": f"{name} failed: {e}"})