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:
+54
-2
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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}"})
|
||||
Reference in New Issue
Block a user