Files
NexusOS/synapse/tools.py
T
jonandClaude Opus 4.8 492020b547 feat: per-conversation project binding + action-tool consent gate
- Conversations bind to a project on creation; RAG scopes to the
  conversation's project, not the global setting.
- Action tools (web_search/fetch_url/remember) are withheld unless
  allow_action_tools is enabled (off by default). Settings toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:29:40 -05:00

243 lines
8.4 KiB
Python

"""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.
Most tools READ local state (memory, history, documents, models). A few act:
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
boundary — an action tool only fires when a playbook explicitly lists it.
"""
from __future__ import annotations
import json
from typing import Awaitable, Callable
from .memory.store import store, MemoryItem
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")})
async def _web_search(query: str = "", **_) -> str:
import asyncio as _a
from .search import web_search
res = await _a.to_thread(web_search, query or "", 4)
return res or "(no results)"
async def _fetch_url(url: str = "", **_) -> str:
import re
import httpx
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
return json.dumps({"error": "url must start with http:// or https://"})
# ponytail: no SSRF allow/deny-list — local single-user assistant, and the
# tool only runs when a playbook explicitly grants fetch_url. Add host
# filtering if this ever serves multiple/untrusted users.
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as c:
r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
r.raise_for_status()
html = r.text
except Exception as e:
return json.dumps({"error": f"fetch failed: {e}"})
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", html)
text = re.sub(r"(?s)<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:4000]
async def _remember(text: str = "", section: str = "General", **_) -> str:
"""WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
import uuid as _uuid
text = (text or "").strip()
if not text:
return json.dumps({"error": "text is required"})
store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
return json.dumps({"saved": text, "section": section or "General"})
# 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,
),
"web_search": (
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web (DuckDuckGo) and return the top result snippets.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
_web_search,
),
"fetch_url": (
{
"type": "function",
"function": {
"name": "fetch_url",
"description": "Fetch a web page and return its visible text (truncated).",
"parameters": {
"type": "object",
"properties": {"url": {"type": "string", "description": "http(s) URL"}},
"required": ["url"],
},
},
},
_fetch_url,
),
"remember": (
{
"type": "function",
"function": {
"name": "remember",
"description": "Save a durable fact to the user's persistent memory.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "the fact to remember"},
"section": {"type": "string", "description": "optional category, e.g. Health"},
},
"required": ["text"],
},
},
},
_remember,
),
}
# Tools that act (write local state or reach the network). These require an
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
# allowlist — a playbook granting one isn't enough on its own.
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
def is_action(name: str) -> bool:
return name in ACTION_TOOLS
def schemas_for(names: list[str], allow_actions: bool = True) -> list[dict]:
"""Tool schemas for a playbook's allowlist; unknown names are dropped.
When allow_actions is False, action tools are withheld so the model can't
even call them."""
return [
REGISTRY[n][0] for n in (names or [])
if n in REGISTRY and (allow_actions or not is_action(n))
]
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}"})