Add LLM tool calling for creating tasks, notes, and searching from chat
Ollama tool/function calling integration allows the LLM to create tasks, create notes, and search existing notes on behalf of the user during chat. Multi-round tool loop (max 5 rounds) lets the model execute tools then produce a natural language response. Tool results are persisted in a new JSONB column on messages and rendered as compact cards with linked titles. - Migration 0013: add tool_calls JSONB column to messages - New services/tools.py: tool definitions + execute_tool dispatcher - llm.py: ChatChunk dataclass, stream_chat_with_tools(), date in system prompt - generation_task.py: multi-round tool call loop with SSE tool_call events - Frontend: ToolCallRecord type, streamingToolCalls in store, ToolCallCard component, rendering in ChatMessage and ChatView streaming bubble Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""Tool definitions and executor for LLM tool calling."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from fabledassistant.services.notes import create_note, list_notes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOOL_DEFINITIONS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_task",
|
||||
"description": "Create a new task for the user. Use this when the user asks you to add a task, todo, or reminder.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The task title",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Optional task description or details",
|
||||
},
|
||||
"due_date": {
|
||||
"type": "string",
|
||||
"description": "Optional due date in YYYY-MM-DD format",
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["none", "low", "medium", "high"],
|
||||
"description": "Task priority level",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_note",
|
||||
"description": "Create a new note for the user. Use this when the user asks you to write down, save, or record something as a note.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The note title",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "The note content in markdown",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_notes",
|
||||
"description": "Search the user's notes and tasks. Use this when the user asks about their existing notes, tasks, or wants to find something they've written.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query to find notes/tasks",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _parse_due_date(value: str | None) -> date | None:
|
||||
"""Parse a due date string, returning None on failure."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Invalid due_date format: %s", value)
|
||||
return None
|
||||
|
||||
|
||||
async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"""Execute a tool call and return the result."""
|
||||
try:
|
||||
if tool_name == "create_task":
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Task"),
|
||||
body=arguments.get("body", ""),
|
||||
status="todo",
|
||||
priority=arguments.get("priority", "none"),
|
||||
due_date=_parse_due_date(arguments.get("due_date")),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "task",
|
||||
"data": {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"status": note.status,
|
||||
"priority": note.priority,
|
||||
"due_date": str(note.due_date) if note.due_date else None,
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "create_note":
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Note"),
|
||||
body=arguments.get("body", ""),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "note",
|
||||
"data": {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "search_notes":
|
||||
query = arguments.get("query", "")
|
||||
notes, total = await list_notes(user_id=user_id, q=query, limit=5)
|
||||
results = []
|
||||
for n in notes:
|
||||
results.append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"type": "task" if n.status is not None else "note",
|
||||
"status": n.status,
|
||||
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
|
||||
})
|
||||
return {
|
||||
"success": True,
|
||||
"type": "search",
|
||||
"data": {
|
||||
"query": query,
|
||||
"total": total,
|
||||
"results": results,
|
||||
},
|
||||
}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Tool execution failed: %s", tool_name)
|
||||
return {"success": False, "error": str(e)}
|
||||
Reference in New Issue
Block a user