feat: playbook tools, RAG, vision, voice, chat controls, faster boot

- Tool-using playbooks: read-only tool registry (search_memory,
  search_history, search_documents, list_models, get_time), per-playbook
  allowlist, agentic loop, and a "running tool" status indicator.
- Document ingest / RAG: documents table + chunker + embed/cosine retrieval
  reusing the existing stack; Documents page (upload/paste, viewer, delete);
  top-k chunks injected into the system prompt.
- Vision chat: attach images, base64 into /api/chat.
- Voice I/O: Web Speech dictation + read-aloud (browser-native, no backend).
- Chat controls: stop, regenerate, edit-and-resend; num_ctx knob in Settings.
- Per-playbook model override.
- History polish: per-conversation + bulk ShareGPT export.
- Faster ncp start: UI up first, Ollama warms in the background, with a
  per-phase timing readout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-23 13:31:48 -05:00
co-authored by Claude Opus 4.8
parent f514d3dbe5
commit f509034fdd
15 changed files with 1061 additions and 96 deletions
+54 -2
View File
@@ -8,6 +8,11 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
from .nexus_config import settings, DEFAULT_CHAT_MODEL
from .ollama_manager import get_ollama_manager
from . import tools as _tools
# Cap on tool-call round-trips before the final answer — stops a confused small
# model from looping forever.
MAX_TOOL_STEPS = 5
# -------------------------
@@ -126,6 +131,38 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
yield str(item)
async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu):
"""Let the model call read-only tools before the final streamed answer.
Mutates `messages` IN PLACE, appending the assistant tool-call turns and
their `role:"tool"` results, and yields a `__status__<tool>` sentinel before
each tool runs (surfaced to the UI as a "running tool" indicator).
Non-streamed — tool calls arrive as whole messages. Degrades to an untouched
`messages` if the model can't do tool calling.
ponytail: the turn that finally returns content is thrown away and the answer
is re-generated by the streaming turn (one wasted call). Simpler than
streaming a maybe-already-complete message; revisit if latency matters.
"""
for _ in range(MAX_TOOL_STEPS):
msg = await manager.chat(
messages=messages, model=model, stream=False,
temperature=temperature, num_gpu=num_gpu, tools=tool_schemas,
)
if not isinstance(msg, dict):
break # None/error or no tool support -> fall back to plain stream
calls = msg.get("tool_calls")
if not calls:
break
messages.append(msg)
for c in calls:
fn = c.get("function", {})
name = fn.get("name", "")
yield f"__status__{name}"
result = await _tools.dispatch(name, fn.get("arguments"))
messages.append({"role": "tool", "content": result})
# -------------------------
# Streaming implementation
# -------------------------
@@ -143,6 +180,7 @@ async def stream_chat_response(
model = metadata.get("model") or DEFAULT_CHAT_MODEL
temperature = metadata.get("temperature")
num_gpu = metadata.get("num_gpu")
num_ctx = metadata.get("num_ctx")
think = metadata.get("think", False)
# Build messages array for /api/chat multi-turn format
@@ -151,7 +189,21 @@ async def stream_chat_response(
messages.append({"role": "system", "content": system})
for msg in (history or []):
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": user_message})
user_msg: Dict[str, Any] = {"role": "user", "content": user_message}
images = metadata.get("images") # base64 strings (no data: prefix) for vision models
if images:
user_msg["images"] = images
messages.append(user_msg)
# Tool-using playbooks: run read-only tool calls, then stream the final answer
# with their results already in the messages array.
tool_schemas = metadata.get("tools")
if tool_schemas:
try:
async for status in _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu):
yield status
except Exception:
_logger.exception("tool loop failed; streaming without tools")
_logger.info("stream_chat_response: starting stream (model=%s, turns=%d, timeout=%s)", model, len(messages), timeout)
@@ -162,7 +214,7 @@ async def stream_chat_response(
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
try:
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu, think=think)
maybe_iter = manager.chat(messages=messages, model=model, stream=True, temperature=temperature, num_gpu=num_gpu, think=think, num_ctx=num_ctx)
async_gen = _normalize_to_async_generator(maybe_iter)
buffer_parts: list[str] = []
+112 -19
View File
@@ -19,6 +19,7 @@ from .nexus_config import settings, VERSION, DEFAULT_CHAT_MODEL
from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from .playbook_manager import PlaybookManager
from . import tools as _tools
def _render_memory_block(facts) -> str:
"""Render memory items as grouped ## Section / - bullet markdown.
@@ -230,11 +231,15 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
try:
message = payload.get("message", "")
app_settings = store.get_settings()
model = payload.get("model") or await _auto_select_model(message)
# Model precedence: explicit request > active playbook's pinned model > auto-select.
_active_pb = playbooks.get_main_playbook()
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
model = payload.get("model") or _pb_model or await _auto_select_model(message)
context = payload.get("context", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature"))
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
think = payload.get("think", app_settings.get("think", False))
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
@@ -280,6 +285,13 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
doc_hits = await store.search_documents(message, get_ollama_manager().embed, limit=3)
if doc_hits:
doc_block = "\n\n".join(f"[{d['title']}]\n{d['text']}" for d in doc_hits)
separator = "\n\n---\nRelevant documents (cite as source material):\n\n"
system_prompt = (system_prompt + separator + doc_block) if system_prompt else doc_block
# Fetch web search results for time-sensitive queries
search_results = ""
if needs_web_search(message):
@@ -338,7 +350,19 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
_synapse_trace(f"{'' * 55}\n")
# ── end MindTrace pre-flight ──────────────────────────────────────
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature, "num_gpu": num_gpu, "think": think}
metadata: Dict[str, Any] = {"model": model, "context": context, "system": system_prompt, "temperature": temperature, "num_gpu": num_gpu, "num_ctx": num_ctx, "think": think}
# Vision: base64 images (data: prefix stripped by the client) ride on the user turn.
images = payload.get("images")
if images:
metadata["images"] = images
# Tool-using playbook: advertise the active playbook's allowlisted tools.
if _main_pb and getattr(_main_pb, "tools", None):
schemas = _tools.schemas_for(_main_pb.tools)
if schemas:
metadata["tools"] = schemas
_synapse_trace(f" TOOLS : {', '.join(_main_pb.tools)}\n")
# Persist conversation and user message before streaming
store.create_conversation(conversation_id)
@@ -363,6 +387,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
pass
yield f"event: meta\ndata: {chunk[8:]}\n\n"
continue
if chunk.startswith("__status__"):
yield f"event: status\ndata: {_json.dumps({'tool': chunk[10:]})}\n\n"
continue
response_chunks.append(chunk)
yield f"data: {_json.dumps(chunk)}\n\n"
except _asyncio.TimeoutError:
@@ -637,13 +664,26 @@ async def ollama_status_endpoint():
# -------------------------
# Ollama Start
# -------------------------
_warm_tasks: set = set()
async def _warm_default_model():
"""Load the default model into RAM/VRAM so the first chat isn't a cold read.
Returns the model name. Raises are the caller's to swallow."""
s = store.get_settings()
warm_model = s.get("model") or await ollama.select_best_model()
num_gpu = await ollama.resolve_num_gpu(s.get("gpu_offload", -1), warm_model)
await ollama.warm(warm_model, num_gpu)
return warm_model
@app.post("/ollama/start")
async def ollama_start_endpoint():
async def ollama_start_endpoint(background: bool = False):
try:
global ollama
if ollama is None:
ollama = initialize_ollama()
# start_async, NOT start: the sync one polls with time.sleep(1) up to 30
# times, which blocks the event loop — the whole backend (including the
# UI's 5s status poll) goes dead while Ollama boots, so a slow start
@@ -653,19 +693,24 @@ async def ollama_start_endpoint():
is_running = ollama.is_running() if hasattr(ollama, "is_running") else True
# Warm the default model so the first chat isn't a cold load. This blocks
# until the model is resident, so the UI's Start finishes only once the AI
# is actually ready to answer. Best-effort — warm() never raises.
# Warm the default model so the first chat isn't a cold load.
# background=False (UI "Start AI"): block until resident, so the button
# finishes only once the AI can actually answer.
# background=True (`ncp start`): fire-and-forget so boot returns fast and
# the model warms concurrently — Ollama serialises the load, so a first
# chat that arrives mid-warm simply waits on the same load.
warmed = None
if is_running:
try:
s = store.get_settings()
warm_model = s.get("model") or await ollama.select_best_model()
num_gpu = await ollama.resolve_num_gpu(s.get("gpu_offload", -1), warm_model)
await ollama.warm(warm_model, num_gpu)
warmed = warm_model
except Exception:
pass
if background:
task = _asyncio.create_task(_warm_default_model())
_warm_tasks.add(task) # hold a ref (asyncio only weak-refs tasks)
task.add_done_callback(_warm_tasks.discard)
warmed = "background"
else:
try:
warmed = await _warm_default_model()
except Exception:
pass
return {"status": "started" if is_running else "failed", "running": is_running, "warmed": warmed}
except Exception as e:
@@ -701,6 +746,8 @@ async def get_playbooks():
"goal": p.goal,
"instructions": getattr(p, "instructions", ""),
"tags": getattr(p, "tags", []),
"tools": getattr(p, "tools", []),
"model": getattr(p, "model", ""),
}
for p in playbook_list
]
@@ -749,11 +796,13 @@ def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
goal=playbook_dict.get("goal", ""),
instructions=playbook_dict.get("instructions", ""),
tags=playbook_dict.get("tags", []),
tools=playbook_dict.get("tools", []),
model=playbook_dict.get("model", ""),
order=order
)
playbook_store.add_playbook(playbook_item)
# Return as dict
return {
"id": playbook_item.id,
@@ -761,6 +810,8 @@ def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
"goal": playbook_item.goal,
"instructions": playbook_item.instructions,
"tags": playbook_item.tags,
"tools": playbook_item.tools,
"model": playbook_item.model,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
@@ -810,6 +861,8 @@ async def get_playbook(id: UUID):
"goal": getattr(pb, "goal", None) or (pb.get("goal") if isinstance(pb, dict) else None),
"instructions": getattr(pb, "instructions", "") or (pb.get("instructions") if isinstance(pb, dict) else ""),
"tags": getattr(pb, "tags", []) or (pb.get("tags") if isinstance(pb, dict) else []),
"tools": getattr(pb, "tools", []) or (pb.get("tools") if isinstance(pb, dict) else []),
"model": getattr(pb, "model", "") or (pb.get("model") if isinstance(pb, dict) else ""),
}
return result
except HTTPException:
@@ -880,6 +933,41 @@ async def delete_playbook_endpoint(id: UUID):
raise HTTPException(status_code=500, detail=str(e))
# -------------------------
# Documents (RAG)
# -------------------------
@app.get("/documents")
async def list_documents():
return {"documents": store.list_documents()}
@app.post("/documents")
async def add_document(payload: Dict[str, Any] = Body(...)):
title = (payload.get("title") or "").strip()
content = (payload.get("content") or "").strip()
if not title or not content:
raise HTTPException(status_code=400, detail="title and content are required")
result = await store.add_document(title, content, get_ollama_manager().embed)
if result["chunks"] == 0:
raise HTTPException(status_code=400, detail="no text to index")
return result
@app.get("/documents/{doc_id}")
async def get_document(doc_id: str):
chunks = store.get_document(doc_id)
if not chunks:
raise HTTPException(status_code=404, detail="Not Found")
return {"doc_id": doc_id, "chunks": chunks}
@app.delete("/documents/{doc_id}")
async def delete_document(doc_id: str):
if not store.delete_document(doc_id):
raise HTTPException(status_code=404, detail="Not Found")
return {"status": "deleted"}
# -------------------------
# Unified Search
# -------------------------
@@ -914,19 +1002,24 @@ async def get_conversations(q: Optional[str] = None):
@app.get("/conversations/export")
async def export_conversations(min_turns: int = 1):
async def export_conversations(min_turns: int = 1, conversation_id: Optional[str] = None):
"""Export conversations as ShareGPT JSONL for fine-tuning.
Each line is one conversation:
{"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]}
Query params:
min_turns — minimum user/assistant exchanges to include (default 1)
min_turns — minimum user/assistant exchanges to include (default 1)
conversation_id — export just this one conversation (default: all)
"""
from fastapi.responses import Response
import datetime
conversations = store.all_conversations()
if conversation_id:
one = store.get_conversation(conversation_id)
conversations = [one] if one else []
else:
conversations = store.all_conversations()
lines = []
for conv in conversations:
+118
View File
@@ -150,6 +150,22 @@ class PersistentMemoryStore:
)
""")
# RAG: one row per chunk. doc_id groups the chunks of a single uploaded
# document; embedding is a JSON vector (search_document-prefixed), set at
# ingest so retrieval needs no backfill.
cur.execute("""
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL,
title TEXT NOT NULL,
chunk_idx INTEGER NOT NULL,
text TEXT NOT NULL,
embedding TEXT,
created_at REAL NOT NULL
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_documents_doc_id ON documents (doc_id)")
cur.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -602,6 +618,106 @@ class PersistentMemoryStore:
"matches": matches,
}
# -----------------------------
# Documents (RAG)
# -----------------------------
@staticmethod
def _chunk_text(text: str, size: int = 800) -> List[str]:
"""Split on blank lines, then pack paragraphs into ~`size`-char chunks.
ponytail: naive char-based packing, no token counting or overlap — good
enough for local recall; add overlap if retrieval misses boundaries."""
chunks: List[str] = []
buf = ""
for para in (p.strip() for p in text.split("\n\n")):
if not para:
continue
if buf and len(buf) + len(para) + 2 > size:
chunks.append(buf)
buf = para
else:
buf = f"{buf}\n\n{para}" if buf else para
if buf:
chunks.append(buf)
return chunks
async def add_document(self, title: str, content: str, embed_fn) -> dict:
"""Chunk, embed, and store a document. Returns {doc_id, chunks}."""
import uuid as _uuid
doc_id = str(_uuid.uuid4())
pieces = self._chunk_text(content)
now = time.time()
conn = self._connect()
cur = conn.cursor()
for i, piece in enumerate(pieces):
vec = await embed_fn(self._EMBED_DOC_PREFIX + piece[:2000])
cur.execute(
"INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(str(_uuid.uuid4()), doc_id, title, i, piece,
json.dumps(vec) if vec else None, now),
)
conn.commit()
conn.close()
return {"doc_id": doc_id, "title": title, "chunks": len(pieces)}
def list_documents(self) -> List[dict]:
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
FROM documents GROUP BY doc_id, title ORDER BY created_at DESC
""")
rows = cur.fetchall()
conn.close()
return [dict(r) for r in rows]
def get_document(self, doc_id: str) -> List[dict]:
"""Ordered chunks of one document: [{chunk_idx, text}]."""
conn = self._connect()
cur = conn.cursor()
cur.execute(
"SELECT chunk_idx, text FROM documents WHERE doc_id = ? ORDER BY chunk_idx",
(doc_id,),
)
rows = cur.fetchall()
conn.close()
return [dict(r) for r in rows]
def delete_document(self, doc_id: str) -> bool:
conn = self._connect()
cur = conn.cursor()
cur.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,))
deleted = cur.rowcount
conn.commit()
conn.close()
return deleted > 0
async def search_documents(
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
) -> List[dict]:
"""Top-`limit` document chunks most similar to `query`. Returns
[{title, text, score}]. Empty on no query / embeddings down."""
if not query or not query.strip():
return []
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
if not query_vec:
return []
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
scored = []
for row in cur.fetchall():
try:
vec = json.loads(row["embedding"])
except Exception:
continue
score = _cosine(query_vec, vec)
if score >= min_score:
scored.append({"title": row["title"], "text": row["text"], "score": score})
conn.close()
scored.sort(key=lambda d: d["score"], reverse=True)
return scored[:limit]
# -----------------------------
# Settings API
# -----------------------------
@@ -611,6 +727,8 @@ class PersistentMemoryStore:
# latency for chat/memory. Turn on for hard multi-step problems.
"think": False,
"temperature": 0.7,
# Context window (tokens Ollama keeps in view). 0 → Ollama's model default.
"num_ctx": 0,
"system_prompt": "",
"timeout": 120,
# How long Ollama keeps the model resident in VRAM between messages.
+18 -6
View File
@@ -175,7 +175,7 @@ def _preferred_model(models: list, preference) -> str | None:
return None
def _chat_options(temperature: float | None, num_gpu: int | None) -> dict:
def _chat_options(temperature: float | None, num_gpu: int | None, num_ctx: int | None = None) -> dict:
"""Assemble the Ollama `options` block from the knobs we expose.
Returns an empty dict when nothing is set so callers can omit `options`
@@ -186,6 +186,8 @@ def _chat_options(temperature: float | None, num_gpu: int | None) -> dict:
opts["temperature"] = temperature
if num_gpu is not None:
opts["num_gpu"] = num_gpu
if num_ctx: # 0 / None -> let Ollama use the model default
opts["num_ctx"] = num_ctx
return opts
@@ -503,10 +505,16 @@ class OllamaManager:
temperature: float | None = None,
num_gpu: int | None = None,
think: bool = False,
tools: list | None = None,
num_ctx: int | None = None,
**kwargs,
):
"""Multi-turn chat via /api/chat (accepts a messages array with roles).
When `tools` is given (non-stream only), the request advertises them and
the FULL message dict is returned (so the caller sees `tool_calls`);
otherwise the response content string is returned as before.
`think` toggles Qwen3-style reasoning. Default off: the hidden <think>
block is pure latency for chat/memory. Ollama ignores it for models that
don't support thinking.
@@ -516,12 +524,14 @@ class OllamaManager:
if stream:
return self._chat_stream(
messages=messages, model=model, temperature=temperature,
num_gpu=num_gpu, think=think, start=start,
num_gpu=num_gpu, think=think, start=start, num_ctx=num_ctx,
)
else:
body: dict = {"model": model, "messages": messages, "stream": False}
body["think"] = think
opts = _chat_options(temperature, num_gpu)
if tools:
body["tools"] = tools
opts = _chat_options(temperature, num_gpu, num_ctx)
if opts:
body["options"] = opts
self._apply_keep_alive(body)
@@ -529,7 +539,9 @@ class OllamaManager:
r = await client.post(f"{self._api_base}/api/chat", json=body)
elapsed = time.perf_counter() - start
r.raise_for_status()
return r.json().get("message", {}).get("content", "")
message = r.json().get("message", {})
# Tool callers need the whole message (tool_calls); others want content.
return message if tools else message.get("content", "")
except Exception as e:
elapsed = time.perf_counter() - start
_log.exception("chat error after %.3fs: %s", elapsed, e)
@@ -643,12 +655,12 @@ class OllamaManager:
async def _chat_stream(self, messages: list, model: str, start: float,
temperature: float | None = None, num_gpu: int | None = None,
think: bool = False):
think: bool = False, num_ctx: int | None = None):
"""Async generator streaming tokens, then a final __meta__ stats sentinel."""
try:
body: dict = {"model": model, "messages": messages, "stream": True}
body["think"] = think # see chat(): reasoning off by default for speed
opts = _chat_options(temperature, num_gpu)
opts = _chat_options(temperature, num_gpu, num_ctx)
if opts:
body["options"] = opts
self._apply_keep_alive(body)
+6
View File
@@ -22,6 +22,8 @@ class PlaybookItem(BaseModel):
goal: str
instructions: str
tags: List[str] = []
tools: List[str] = []
model: str = ""
order: int = 0
@@ -43,6 +45,8 @@ class PlaybookFileStore:
goal=data.get("goal", ""),
instructions=data.get("instructions", ""),
tags=data.get("tags", []),
tools=data.get("tools", []),
model=data.get("model", ""),
order=data.get("order", 0),
)
except Exception:
@@ -58,6 +62,8 @@ class PlaybookFileStore:
"title": item.title,
"goal": item.goal,
"tags": item.tags,
"tools": item.tools,
"model": item.model,
"order": item.order,
"instructions": self._clean(item.instructions),
}
+138
View File
@@ -0,0 +1,138 @@
"""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}"})