"""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)"
_FETCH_MAX_REDIRECTS = 5
def _ip_is_blocked(ip: str) -> bool:
"""True if an address is one an outbound fetch has no business reaching:
loopback, RFC1918/ULA private, link-local (incl. 169.254.169.254 cloud
metadata), multicast, reserved, or unspecified. IPv4-mapped IPv6 is unwrapped
first so ::ffff:127.0.0.1 can't sneak a loopback past the check."""
import ipaddress
try:
addr = ipaddress.ip_address(ip.split("%")[0]) # drop any IPv6 zone id
except ValueError:
return True # unparseable -> refuse rather than guess
mapped = getattr(addr, "ipv4_mapped", None)
if mapped is not None:
addr = mapped
return (
addr.is_loopback or addr.is_private or addr.is_link_local
or addr.is_multicast or addr.is_reserved or addr.is_unspecified
)
def _ssrf_guard(host: str) -> str | None:
"""Resolve a hostname and return an error string if ANY of its A/AAAA
records is a blocked address, else None. Checking every answer stops a name
from smuggling one private record alongside a public one.
ponytail: this validates then httpx re-resolves on connect, so a sub-second
DNS-rebind could still slip a private address through the TOCTOU gap. That's
an advanced attack against a playbook-gated, single-user tool; pin the
connection to the resolved IP if this ever faces untrusted callers."""
import socket
if not host:
return "missing host"
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror as e:
return f"cannot resolve host: {e}"
ips = {info[4][0] for info in infos}
if not ips:
return "host did not resolve"
blocked = [ip for ip in ips if _ip_is_blocked(ip)]
if blocked:
return f"refusing to fetch a private/loopback/link-local address ({', '.join(sorted(blocked))})"
return None
async def _fetch_url(url: str = "", **_) -> str:
import re
import httpx
from urllib.parse import urlparse, urljoin
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
return json.dumps({"error": "url must start with http:// or https://"})
# SSRF guard: validate the host of the initial URL AND every redirect hop
# against the private/loopback/link-local block-list before connecting, so a
# granted fetch_url can't be steered at 127.0.0.1:11434, cloud metadata, or
# LAN hosts — and a public URL can't 302 its way there either.
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as c:
for _ in range(_FETCH_MAX_REDIRECTS + 1):
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return json.dumps({"error": "only http(s) URLs are allowed"})
err = _ssrf_guard(parsed.hostname or "")
if err:
return json.dumps({"error": f"blocked: {err}"})
r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
location = r.headers.get("location")
if r.is_redirect and location:
url = urljoin(url, location)
continue
r.raise_for_status()
html = r.text
break
else:
return json.dumps({"error": "too many redirects"})
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"})
# Canvas drawing APIs a real visualization must use — resizing width/height alone
# clears the buffer and draws nothing (a failure mode small models hit often).
_CANVAS_DRAW_APIS = (
"fillrect", "strokerect", "filltext", "stroketext", "lineto", "arc(",
"beziercurveto", "quadraticcurveto", "fill(", "stroke(", "putimagedata",
"drawimage", "ellips(",
)
# Rejection threshold for a preview stage. Tiny 40×40 tiles (a recurring
# small-model collapse, often copied from earlier demos) can't show a sequence.
#
# These numbers are a threshold, never advice: every message that mentions a
# size quotes _STAGE_W/_STAGE_H instead. Weak models copy the first dimensions
# they read, so a message saying "at least 320x200, prefer 480x280" reliably
# produces 320x200 — three separate transcripts landed on exactly the minimum,
# including one that had a 480x280 example in front of it. Name one size.
_MIN_CANVAS_W = 320
_MIN_CANVAS_H = 200
# The size to ask for, and the only one any message should mention.
_STAGE_W = 480
_STAGE_H = 280
# Domain-agnostic interactive shell returned on reject as a *pattern* to adapt —
# not a finished demo for any particular algorithm. The model must implement
# generate() for the user's request (or ask them to clarify first).
_INTERACTIVE_SCAFFOLD_HTML = """
"""
def _wants_data_visual(purpose: str = "", title: str = "", markup: str = "") -> bool:
"""True when the submission claims to be a chart/plot/interactive visual."""
blob = f"{purpose} {title} {markup}".lower()
return any(k in blob for k in (
"plot", "chart", "graph", "visual", "sequence", "orbit", "interactive",
"demo", "canvas", "diagram", "animation", "simulate", "conjecture",
))
# Component counterpart to _INTERACTIVE_SCAFFOLD_HTML, handed back when a
# jsx/tsx submission is rejected. Same contract: a pattern to adapt, not a demo
# to paste. No imports — the preview puts hooks and h/render in scope already,
# and there is no module loader in the sandbox to satisfy an import anyway.
_INTERACTIVE_SCAFFOLD_JSX = """export default function App() {
const canvasRef = useRef(null);
const [n, setN] = useState(20);
/** Return an array of numbers for THIS demo. */
function generate(count) {
// TODO: implement the user's algorithm / data here. Do not leave empty.
const seq = [];
for (let i = 0; i < count; i++) seq.push(i); // placeholder — replace
return seq;
}
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const seq = generate(n);
const max = Math.max(1, ...seq);
const w = canvas.width, h = canvas.height, pad = 16;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
seq.forEach((v, i) => {
const x = pad + i * ((w - 2 * pad) / Math.max(1, seq.length - 1));
const y = h - pad - (v / max) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
}, [n]);
return (
);
}"""
def _wants_chart(purpose: str = "", title: str = "", markup: str = "") -> bool:
"""True only when the submission claims to draw *data* — a narrower test
than _wants_data_visual, which also counts "interactive" and "demo".
That wider net is right for an HTML fence, where an interactive demo with no
canvas is usually a model writing prose and calling it a visualization. It
is wrong for a component fence: a JSX counter or form is interactive through
its own elements and state, and demanding a