Files
FabledScribe/src/fabledassistant/services/tools.py
T
bvandeusen 012eb1d46b Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation
## Projects & Milestones (Phases A + G)
- New models: Project, Milestone (Project → Milestone → Task hierarchy)
- notes table: project_id + milestone_id FKs; parent_id FK constraint activated
- Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones)
- Services: projects.py, milestones.py (CRUD + progress tracking)
- Routes: /api/projects + /api/projects/<id>/milestones
- LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools
- Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated

## RAG Auto-injection (Phase B)
- Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each)
- excluded_note_ids param; ChatView "Auto-included" sidebar section

## Summarisation improvements (Phase C)
- Threshold 20→30, keep-recent 6→8, max_tokens 200→400
- Two-pass summarisation for histories >50 messages

## Browser push notifications (Phase E)
- PushSubscription model + migration; pywebpush dependency
- /api/push routes; VAPID config; fire-and-forget on generation complete
- Frontend: sw.js, push store, Settings toggle

## PWA manifest (Phase F)
- manifest.json, Apple meta tags, service worker registration in main.ts

## Tag normalisation
- All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize)
- Note/Task types gain project_id + milestone_id fields; store signatures updated

## CalDAV
- Radicale embedded server reverted; back to user-configured external CalDAV

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:52:21 -05:00

1306 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tool definitions and executor for LLM tool calling."""
import asyncio
import logging
from datetime import date, datetime
from fabledassistant.services.caldav import (
create_event,
delete_event,
is_caldav_configured,
list_calendars,
list_events,
search_events,
update_event,
)
from fabledassistant.config import Config
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags
logger = logging.getLogger(__name__)
def _schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> None:
"""Fire-and-forget: update the embedding for a note after it's created/modified."""
from fabledassistant.services.embeddings import upsert_note_embedding
text = f"{title}\n{body}".strip() if body else (title or "")
if text:
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
# Core tools — always available
_CORE_TOOLS = [
{
"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",
},
"project": {
"type": "string",
"description": "Optional project name to assign this task to",
},
"parent_task": {
"type": "string",
"description": "Optional title of a parent task to make this a sub-task of",
},
"milestone": {
"type": "string",
"description": "Optional milestone title within the project to assign this task to",
},
},
"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",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
},
"project": {
"type": "string",
"description": "Optional project name to assign this note to",
},
},
"required": ["title"],
},
},
},
{
"type": "function",
"function": {
"name": "update_note",
"description": (
"Update an existing note or task — content, title, status, priority, or due date. "
"Use this when the user asks to add to, edit, expand, or modify a note, "
"OR to mark a task done/in-progress, change its priority, or set a due date. "
"NEVER use create_note when updating an existing note — always use update_note."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the note or task to update",
},
"body": {
"type": "string",
"description": "New note content in markdown (omit if only updating task fields)",
},
"title": {
"type": "string",
"description": "Optional new title",
},
"mode": {
"type": "string",
"enum": ["replace", "append"],
"description": (
"How to apply the new body: 'replace' overwrites existing content (default), "
"'append' adds after existing content"
),
},
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "New task status. Use to mark a task done, start it, etc.",
},
"priority": {
"type": "string",
"enum": ["none", "low", "medium", "high"],
"description": "New task priority",
},
"due_date": {
"type": "string",
"description": "New due date in YYYY-MM-DD format",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to apply (see tag_mode for how they're applied)",
},
"tag_mode": {
"type": "string",
"enum": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
},
"project": {
"type": "string",
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
},
},
"required": ["query"],
},
},
},
{
"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",
},
"type": {
"type": "string",
"enum": ["note", "task"],
"description": "Restrict results to only notes or only tasks. Omit to search both.",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_note",
"description": (
"Retrieve the full content of a specific note. Use this when the user asks to read, "
"view, or check what a particular note says. Returns the complete note body."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to identify the note",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "list_notes",
"description": (
"Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse "
"their notes — by recency, keyword, or tag. For tasks, use list_tasks instead."
),
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Optional keyword filter",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Filter notes that have ALL of these tags",
},
"sort": {
"type": "string",
"enum": ["updated_at", "created_at", "title"],
"description": "Sort field (default: updated_at)",
},
"limit": {
"type": "integer",
"description": "Maximum number of notes to return (default 10)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "delete_note",
"description": (
"Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the note to delete",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_task",
"description": (
"Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the task to delete",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "list_tasks",
"description": (
"List the user's tasks with optional filters. Use this when the user asks about their tasks "
"by status (e.g. 'what's in progress'), priority (e.g. 'high priority tasks'), "
"or due date (e.g. 'overdue tasks', 'due this week'). "
"For 'overdue', set due_before to today's date. For 'due today', set due_after to today and due_before to tomorrow."
),
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "Filter by task status",
},
"priority": {
"type": "string",
"enum": ["none", "low", "medium", "high"],
"description": "Filter by priority level",
},
"due_before": {
"type": "string",
"description": "Return tasks due before this date (YYYY-MM-DD). Use today's date to find overdue tasks.",
},
"due_after": {
"type": "string",
"description": "Return tasks due on or after this date (YYYY-MM-DD)",
},
"project": {
"type": "string",
"description": "Filter tasks by project name",
},
"milestone": {
"type": "string",
"description": "Filter tasks by milestone name within a project",
},
"limit": {
"type": "integer",
"description": "Maximum number of tasks to return (default 10)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "create_project",
"description": "Create a new project. Use when the user asks to create a project, initiative, or campaign to organize related tasks and notes.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Project title"},
"description": {"type": "string", "description": "Brief description"},
"goal": {"type": "string", "description": "The goal or desired outcome"},
},
"required": ["title"],
},
},
},
{
"type": "function",
"function": {
"name": "list_projects",
"description": "List the user's projects. Use when asked about projects, initiatives, or campaigns.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "completed", "archived"],
"description": "Filter by status (omit for all)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_project",
"description": "Get details and summary of a specific project.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Project name or keyword to find it"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "update_project",
"description": "Update a project's status, description, or goal.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Project name to find"},
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
"description": {"type": "string"},
"goal": {"type": "string"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "create_milestone",
"description": "Create a new milestone inside a project for grouping and tracking related tasks.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project title (will be created if not found)"},
"title": {"type": "string", "description": "Milestone title"},
"description": {"type": "string", "description": "Optional description"},
},
"required": ["project", "title"],
},
},
},
{
"type": "function",
"function": {
"name": "list_milestones",
"description": "List milestones for a project with their progress percentages.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project name to look up"},
},
"required": ["project"],
},
},
},
]
# CalDAV tools — only included when user has CalDAV configured
_CALDAV_TOOLS = [
{
"type": "function",
"function": {
"name": "create_event",
"description": "Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "A descriptive event title (e.g. 'John's Birthday' not just 'Birthday')",
},
"start": {
"type": "string",
"description": "Start date (YYYY-MM-DD for all-day) or datetime (ISO 8601, e.g. 2025-01-15T14:00:00)",
},
"end": {
"type": "string",
"description": "Optional end date or datetime in same format as start",
},
"duration": {
"type": "integer",
"description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)",
},
"description": {
"type": "string",
"description": "Optional event description",
},
"location": {
"type": "string",
"description": "Optional event location",
},
"all_day": {
"type": "boolean",
"description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)",
},
"recurrence": {
"type": "string",
"description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)",
},
"reminder_minutes": {
"type": "integer",
"description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)",
},
"attendees": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of attendee email addresses",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York'). Falls back to user's caldav_timezone setting.",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
},
},
"required": ["title", "start"],
},
},
},
{
"type": "function",
"function": {
"name": "list_events",
"description": "List calendar events in a date range. Use this when the user asks what events or meetings they have.",
"parameters": {
"type": "object",
"properties": {
"date_from": {
"type": "string",
"description": "Start of range in ISO 8601 format (e.g. 2025-01-15T00:00:00)",
},
"date_to": {
"type": "string",
"description": "End of range in ISO 8601 format (e.g. 2025-01-22T23:59:59)",
},
},
"required": ["date_from", "date_to"],
},
},
},
{
"type": "function",
"function": {
"name": "search_events",
"description": "Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keyword to match against event titles, locations, and descriptions",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "update_event",
"description": "Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the event to update (matches against title)",
},
"title": {
"type": "string",
"description": "New title for the event",
},
"start": {
"type": "string",
"description": "New start datetime in ISO 8601 format",
},
"end": {
"type": "string",
"description": "New end datetime in ISO 8601 format",
},
"description": {
"type": "string",
"description": "New event description",
},
"location": {
"type": "string",
"description": "New event location",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York')",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to search in",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_event",
"description": "Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the event to delete (matches against title)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to search in",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "list_calendars",
"description": "List all available calendars. Use this when the user asks which calendars they have.",
"parameters": {
"type": "object",
"properties": {},
},
},
},
]
_SEARCH_TOOLS = [
{
"type": "function",
"function": {
"name": "search_web",
"description": (
"Search the web for quick information and answer the user's question from results. "
"Use for factual lookups, current events, version numbers, quick definitions, or any question "
"where a fast web answer is needed without creating a note. "
"Use research_topic instead when the user explicitly wants a comprehensive note or deep research."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query"}
},
"required": ["query"],
},
},
}
]
_RESEARCH_TOOLS = [
{
"type": "function",
"function": {
"name": "research_topic",
"description": (
"Research a topic by searching the web and compiling a note with cited sources. "
"Use for 'research X', 'look up X', 'find information about X', "
"'compile notes on X'. Takes 30120 seconds."
),
"parameters": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"description": "The topic or question to research",
}
},
"required": ["topic"],
},
},
}
]
_IMAGE_TOOLS = [
{
"type": "function",
"function": {
"name": "search_images",
"description": (
"Search the web for images and display them inline in the response. "
"ONLY use this when the user explicitly asks to SEE, SHOW, VIEW, or DISPLAY "
"an image or photo — e.g. 'show me a picture of X', 'what does X look like', "
"'find a photo of X', 'display an image of X'. "
"Do NOT use for general descriptions, factual questions, or when the user just "
"wants information without visual content. Returns at most 2 images."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The image search query",
}
},
"required": ["query"],
},
},
}
]
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
tools = list(_CORE_TOOLS)
if await is_caldav_configured(user_id):
tools.extend(_CALDAV_TOOLS)
if Config.searxng_enabled():
tools.extend(_SEARCH_TOOLS)
tools.extend(_RESEARCH_TOOLS)
tools.extend(_IMAGE_TOOLS)
logger.debug("User %d: %d tools available", user_id, len(tools))
return tools
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":
task_title = arguments.get("title", "Untitled Task")
task_body = arguments.get("body", "")
if not isinstance(task_title, str):
return {"success": False, "error": "title must be a string. Call create_task once per task."}
if not isinstance(task_body, str):
task_body = ""
note = await create_note(
user_id=user_id,
title=task_title,
body=task_body,
status="todo",
priority=arguments.get("priority", "none"),
due_date=_parse_due_date(arguments.get("due_date")),
)
suggested = await suggest_tags(user_id, task_title, task_body)
_schedule_embedding(note.id, user_id, task_title, task_body)
# Handle optional project and parent_task assignment
project_name = arguments.get("project")
parent_task_name = arguments.get("parent_task")
update_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
if parent_task_name:
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
if parent_notes:
update_fields["parent_id"] = parent_notes[0].id
milestone_name = arguments.get("milestone")
if milestone_name and project_name:
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import get_or_create_milestone
proj = await _gocp(user_id, project_name)
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
update_fields["milestone_id"] = ms.id
if update_fields:
note = await update_note(user_id, note.id, **update_fields)
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,
"project_id": note.project_id,
},
"suggested_tags": suggested,
}
elif tool_name == "create_note":
note_title = arguments.get("title", "Untitled Note")
note_body = arguments.get("body", "")
note_tags = arguments.get("tags", [])
if not isinstance(note_title, str):
return {"success": False, "error": "title must be a string. Call create_note once per note."}
if not isinstance(note_body, str):
note_body = ""
if not isinstance(note_tags, list):
note_tags = []
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=note_tags,
)
suggested = await suggest_tags(user_id, note_title, note_body)
_schedule_embedding(note.id, user_id, note_title, note_body)
# Handle optional project assignment
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
note = await update_note(user_id, note.id, project_id=proj.id)
return {
"success": True,
"type": "note",
"data": {
"id": note.id,
"title": note.title,
"project_id": note.project_id,
},
"suggested_tags": suggested,
}
elif tool_name == "update_note":
query = arguments.get("query", "")
new_body = arguments.get("body", "")
new_title = arguments.get("title")
mode = arguments.get("mode", "replace")
# Find the note: exact title match first, then fuzzy search
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
# Prefer notes (not tasks) when there are mixed results
note_only = [n for n in notes if n.status is None]
candidates = note_only or notes
if not candidates:
return {"success": False, "error": f"No note found matching '{query}'."}
note = candidates[0]
# Build the update payload
update_fields: dict = {}
if new_title:
update_fields["title"] = new_title
if new_body:
if mode == "append" and note.body:
update_fields["body"] = note.body + "\n\n" + new_body
else:
update_fields["body"] = new_body
if "status" in arguments:
update_fields["status"] = arguments["status"]
if "priority" in arguments:
update_fields["priority"] = arguments["priority"]
if "due_date" in arguments:
update_fields["due_date"] = _parse_due_date(arguments["due_date"])
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "replace")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
if t not in existing:
existing.append(t)
update_fields["tags"] = existing
elif tag_mode == "remove":
existing = list(note.tags or [])
update_fields["tags"] = [t for t in existing if t not in new_tags]
else:
update_fields["tags"] = new_tags
if "project" in arguments:
project_name = arguments["project"]
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
return {"success": False, "error": "Failed to update note."}
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
_schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
return {
"success": True,
"type": "note_updated",
"data": {
"id": updated.id,
"title": updated.title,
},
"suggested_tags": suggested,
}
elif tool_name == "list_tasks":
project_id = None
milestone_id = None
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
proj = await get_project_by_title(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
if matches:
proj = matches[0]
if proj:
project_id = proj.id
milestone_name = arguments.get("milestone")
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
if ms:
milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
is_task=True,
status=arguments.get("status"),
priority=arguments.get("priority"),
due_before=_parse_due_date(arguments.get("due_before")),
due_after=_parse_due_date(arguments.get("due_after")),
project_id=project_id,
milestone_id=milestone_id,
limit=int(arguments.get("limit", 10)),
sort="due_date",
order="asc",
)
results = []
for n in notes:
results.append({
"id": n.id,
"title": n.title,
"status": n.status,
"priority": n.priority,
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
})
return {
"success": True,
"type": "tasks",
"data": {
"total": total,
"count": len(results),
"results": results,
},
}
elif tool_name == "search_notes":
query = arguments.get("query", "")
type_filter = arguments.get("type")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
notes, total = await list_notes(user_id=user_id, q=query, is_task=is_task, 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,
},
}
elif tool_name == "create_event":
result = await create_event(
user_id=user_id,
title=arguments.get("title", "Untitled Event"),
start=arguments["start"],
end=arguments.get("end"),
duration=arguments.get("duration"),
description=arguments.get("description"),
location=arguments.get("location"),
all_day=arguments.get("all_day", False),
recurrence=arguments.get("recurrence"),
timezone=arguments.get("timezone"),
reminder_minutes=arguments.get("reminder_minutes"),
attendees=arguments.get("attendees"),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "event",
"data": result,
}
elif tool_name == "list_events":
events = await list_events(
user_id=user_id,
date_from=arguments["date_from"],
date_to=arguments["date_to"],
)
return {
"success": True,
"type": "events",
"data": {
"count": len(events),
"events": events,
},
}
elif tool_name == "search_events":
events = await search_events(
user_id=user_id,
query=arguments.get("query", ""),
)
return {
"success": True,
"type": "events",
"data": {
"query": arguments.get("query", ""),
"count": len(events),
"events": events,
},
}
elif tool_name == "update_event":
result = await update_event(
user_id=user_id,
query=arguments["query"],
title=arguments.get("title"),
start=arguments.get("start"),
end=arguments.get("end"),
description=arguments.get("description"),
location=arguments.get("location"),
timezone=arguments.get("timezone"),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "event_updated",
"data": result,
}
elif tool_name == "delete_event":
result = await delete_event(
user_id=user_id,
query=arguments["query"],
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "event_deleted",
"data": result,
}
elif tool_name == "list_calendars":
calendars = await list_calendars(user_id=user_id)
return {
"success": True,
"type": "calendars",
"data": {
"count": len(calendars),
"calendars": calendars,
},
}
elif tool_name == "get_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
return {
"success": True,
"type": "note_content",
"data": {
"id": note.id,
"title": note.title,
"body": note.body or "",
"tags": note.tags or [],
},
}
elif tool_name == "list_notes":
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
)
results = [
{
"id": n.id,
"title": n.title,
"tags": n.tags or [],
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes
]
return {
"success": True,
"type": "notes_list",
"data": {
"total": total,
"count": len(results),
"results": results,
},
}
elif tool_name == "delete_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
if note.status is not None:
return {"success": False, "error": f"'{note.title}' is a task. Use delete_task instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete note."}
return {
"success": True,
"type": "note_deleted",
"data": {"id": note.id, "title": note.title},
}
elif tool_name == "delete_task":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=True, limit=3)
if not notes:
return {"success": False, "error": f"No task found matching '{query}'."}
note = notes[0]
if note.status is None:
return {"success": False, "error": f"'{note.title}' is a note. Use delete_note instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete task."}
return {
"success": True,
"type": "task_deleted",
"data": {"id": note.id, "title": note.title},
}
elif tool_name == "create_milestone":
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
project_name = arguments.get("project", "")
if not project_name:
return {"success": False, "error": "project is required"}
proj = await _gocp(user_id, project_name)
ms = await _cm(
user_id,
proj.id,
title=arguments.get("title", "Untitled Milestone"),
description=arguments.get("description"),
)
return {"success": True, "type": "milestone", "data": ms.to_dict()}
elif tool_name == "list_milestones":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"No project found matching '{project_name}'"}
summary = await get_project_milestone_summary(user_id, proj.id)
return {
"success": True,
"type": "milestones_list",
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
}
elif tool_name == "search_web":
from fabledassistant.services.research import _search_searxng
query = arguments.get("query", "")
results = await _search_searxng(query)
if not results:
return {"success": False, "error": f"No results found for '{query}'"}
return {
"success": True,
"type": "web_search",
"data": {
"query": query,
"results": results,
"count": len(results),
},
}
elif tool_name == "research_topic":
# Research is always handled upstream in generation_task.py (round 0).
# This fallback exists in case it somehow reaches execute_tool.
topic = arguments.get("topic", "")
return {
"success": True,
"type": "research_pending",
"data": {"topic": topic},
}
elif tool_name == "create_project":
from fabledassistant.services.projects import create_project as _create_project
project = await _create_project(
user_id,
title=arguments.get("title", "New Project"),
description=arguments.get("description", ""),
goal=arguments.get("goal", ""),
)
return {"success": True, "type": "project", "data": project.to_dict()}
elif tool_name == "list_projects":
from fabledassistant.services.projects import list_projects as _list_projects
projects = await _list_projects(user_id, status=arguments.get("status"))
return {
"success": True,
"type": "projects_list",
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
}
elif tool_name == "get_project":
from fabledassistant.services.projects import (
get_project_by_title as _gpbt,
get_project_summary as _gps,
list_projects as _lp,
)
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
summary = await _gps(user_id, project.id)
data = project.to_dict()
data["summary"] = summary
return {"success": True, "type": "project_detail", "data": data}
elif tool_name == "update_project":
from fabledassistant.services.projects import (
get_project_by_title as _gpbt,
update_project as _up,
list_projects as _lp,
)
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
fields = {}
for k in ("status", "description", "goal"):
if k in arguments:
fields[k] = arguments[k]
updated = await _up(user_id, project.id, **fields)
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
elif tool_name == "search_images":
from fabledassistant.services.images import fetch_and_store_image
from fabledassistant.services.research import _search_searxng_images
query = arguments.get("query", "")
if not query:
return {"success": False, "error": "query is required"}
raw_results = await _search_searxng_images(query)
if not raw_results:
return {"success": False, "error": f"No images found for '{query}'"}
# Fetch and cache up to 2 images
images = []
for r in raw_results[:2]:
img_url = r.get("img_src", "")
if not img_url:
continue
record = await fetch_and_store_image(
url=img_url,
title=r.get("title"),
source_domain=r.get("source_domain"),
)
if record:
title = record.title or query
page_url = r.get("page_url", "")
source_domain = r.get("source_domain", "")
images.append({
"embed": f"![{title}](/api/images/{record.id})",
"citation": f"*Source: [{source_domain or 'image'}]({page_url})*",
"page_url": page_url,
"title": title,
})
if not images:
return {"success": False, "error": f"Could not retrieve images for '{query}'"}
return {
"success": True,
"type": "image_search",
"data": {
"query": query,
"images": images,
"instructions": (
"Embed each image in your response by writing the 'embed' field verbatim, "
"then the 'citation' field on the next line. Do not paraphrase or list URLs."
),
},
}
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)}