"""Tool definitions and executor for LLM tool calling.""" import asyncio import logging import re from datetime import date, datetime, timezone from difflib import SequenceMatcher from fabledassistant.services.caldav import ( is_caldav_configured, list_calendars, ) from fabledassistant.services.events import ( create_event as events_create_event, list_events as events_list_events, search_events as events_search_events, update_event as events_update_event, delete_event as events_delete_event, find_events_by_query, ) 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)) async def _resolve_project(user_id: int, project_name: str): """Exact-then-fuzzy project lookup. Returns the Project or None. Resolution order: 1. Exact title match (case-insensitive via DB) 2. project_name is a substring of an existing title ("Famous Supply" in "Famous Supply Co.") 3. Existing title is a substring of project_name ("Famous Supply Co." in "Famous Supply Co. UK") 4. SequenceMatcher ratio >= 0.55 (catches abbreviations / partial names) """ from fabledassistant.services.projects import get_project_by_title, list_projects proj = await get_project_by_title(user_id, project_name) if proj is not None: return proj needle = project_name.lower().strip() all_p = await list_projects(user_id) # Substring checks first (deterministic) for p in all_p: haystack = p.title.lower().strip() if needle in haystack or haystack in needle: return p # SequenceMatcher fallback for abbreviations / partial names best, best_r = None, 0.0 for p in all_p: r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio() if r >= 0.55 and r > best_r: best, best_r = p, r return best # 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", }, "status": { "type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Initial task status (default: todo)", }, "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", }, "tags": { "type": "array", "items": {"type": "string"}, "description": "Tags for the task (without # prefix)", }, "project": { "type": "string", "description": "Optional project name to assign this task to. Only set this if the user explicitly named a project. Do NOT infer a project from the task content or context.", }, "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", }, "confirmed": { "type": "boolean", "description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing task.", }, "recurrence_rule": { "type": "object", "description": 'Optional recurrence. Interval form: {"type":"interval","every":3,"unit":"month"} (units: day/week/month/year). Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Annual: add "month":6.', }, }, "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. Only set this if the user explicitly named a project. Do NOT infer a project from the note content or context.", }, "confirmed": { "type": "boolean", "description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing note.", }, }, "required": ["title"], }, }, }, { "type": "function", "function": { "name": "update_note", "description": "Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.", "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", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel 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": ["add", "remove", "replace"], "description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)", }, "project": { "type": "string", "description": "Optional project name to assign this note/task to (set to empty string to remove project)", }, "milestone": { "type": "string", "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)", }, "recurrence_rule": { "type": "object", "description": "Set or update recurrence (same format as create_task). Pass null to remove.", }, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "search_notes", "description": "Search notes and tasks by semantic meaning — finds content by concept or theme even when exact keywords don't appear.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords", }, "type": { "type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both.", }, "project": { "type": "string", "description": "Optional project name to restrict search to notes in that project", }, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "get_note", "description": ( "Retrieve the full content of a specific note or task. Use this when the user asks to read, " "view, or check what a particular note or task says. Returns the complete 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, tag, or project. 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", }, "project": { "type": "string", "description": "Filter notes by project name", }, "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. " "Always confirm with the user first — this cannot be undone." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Title or keyword to find the note to delete", }, "confirmed": { "type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this note deleted.", }, }, "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. " "Always confirm with the user first — this cannot be undone." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Title or keyword to find the task to delete", }, "confirmed": { "type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this task deleted.", }, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "list_tasks", "description": "List tasks with optional filters. For overdue tasks, set due_before to today's date.", "parameters": { "type": "object", "properties": { "q": { "type": "string", "description": "Optional keyword filter — searches title and body", }, "status": { "type": "array", "items": { "type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], }, "description": "Filter by one or more statuses. Omit for all.", }, "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 list_projects first to check for duplicates. Only call after user has explicitly confirmed.", "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"}, "color": {"type": "string", "description": "Optional hex color for the project (e.g. '#6366f1')"}, "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."}, }, "required": ["title", "confirmed"], }, }, }, { "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 title, status, description, goal, or color.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Project name to find"}, "title": {"type": "string", "description": "New project title"}, "status": {"type": "string", "enum": ["active", "completed", "archived"]}, "description": {"type": "string"}, "goal": {"type": "string"}, "color": {"type": "string", "description": "Hex color (e.g. '#6366f1'), or empty string to clear"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "create_milestone", "description": "Create a milestone inside a project. Confirm name and project with user before calling.", "parameters": { "type": "object", "properties": { "project": {"type": "string", "description": "Project title"}, "title": {"type": "string", "description": "Milestone title"}, "description": {"type": "string", "description": "Optional description"}, "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this milestone created."}, }, "required": ["project", "title", "confirmed"], }, }, }, { "type": "function", "function": { "name": "update_milestone", "description": "Update the title, description, or status of an existing milestone.", "parameters": { "type": "object", "properties": { "project": {"type": "string", "description": "Project title the milestone belongs to"}, "milestone": {"type": "string", "description": "Current milestone title to look up"}, "title": {"type": "string", "description": "New title (omit to keep current)"}, "description": {"type": "string", "description": "New description (omit to keep current)"}, "status": {"type": "string", "enum": ["active", "done"], "description": "New status: 'active' (in progress) or 'done' (completed)"}, }, "required": ["project", "milestone"], }, }, }, { "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"], }, }, }, { "type": "function", "function": { "name": "log_work", "description": ( "Add a work log entry to a task to record progress, work done, or time spent. " "Use this when the user says they worked on, completed, or spent time on a task." ), "parameters": { "type": "object", "properties": { "task": { "type": "string", "description": "Title or keyword identifying the task (required)", }, "content": { "type": "string", "description": "Description of the work done (required)", }, "duration_minutes": { "type": "integer", "description": "Optional time spent in minutes", }, }, "required": ["task", "content"], }, }, }, { "type": "function", "function": { "name": "get_weather", "description": ( "Get the weather forecast. By default returns today only — use days=7 when the user " "explicitly asks for a multi-day forecast or 'this week'. " "Pass a city/region name to look up any location, or omit to get the user's configured home/work locations." ), "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved.", }, "days": { "type": "integer", "description": ( "Number of days to return (1–8). Default 1 (today only). " "Use 7 or 8 when the user asks for a weekly forecast or multiple days." ), }, }, "required": [], }, }, }, { "type": "function", "function": { "name": "get_rss_items", "description": ( "Get recent items from the user's RSS feeds (news, blogs, Reddit, podcasts). " "Returns titles, URLs, and summaries of recent posts." ), "parameters": { "type": "object", "properties": { "limit": { "type": "integer", "description": "Number of items to return (default 15, max 50)", }, "category": { "type": "string", "description": "Filter by feed category (e.g. 'news', 'tech'). Omit for all.", }, }, "required": [], }, }, }, { "type": "function", "function": { "name": "add_rss_feed", "description": "Add an RSS/Atom feed. Use when user asks to subscribe to or track a feed, blog, subreddit, or podcast.", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "The RSS/Atom feed URL to add.", }, "category": { "type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure.", }, }, "required": ["url"], }, }, }, { "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", }, "start": { "type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)", }, "end": { "type": "string", "description": "Optional end date or datetime", }, "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", }, "color": { "type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')", }, "all_day": { "type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)", }, "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", }, "calendar_name": { "type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar.", }, "project": { "type": "string", "description": "Optional project name to associate this event with", }, }, "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. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.", "parameters": { "type": "object", "properties": { "date_from": { "type": "string", "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day.", }, "date_to": { "type": "string", "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day.", }, }, "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", }, "include_past": { "type": "boolean", "description": "Set to true to include past events in results (default: future events only)", }, }, "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", }, "all_day": { "type": "boolean", "description": "Whether the event is all-day", }, "description": { "type": "string", "description": "New event description", }, "location": { "type": "string", "description": "New event location", }, "color": { "type": "string", "description": "New hex color for the event (e.g. '#6366f1')", }, "recurrence": { "type": "string", "description": "New iCalendar RRULE", }, "reminder_minutes": { "type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder.", }, }, "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)", }, }, "required": ["query"], }, }, }, ] # CalDAV tools — only included when user has CalDAV configured _CALDAV_TOOLS = [ { "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 30–120 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 and display images inline. Use ONLY when user explicitly asks to see or show an image or photo. Not for factual questions.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "The image search query", } }, "required": ["query"], }, }, } ] _URL_TOOLS = [ { "type": "function", "function": { "name": "read_article", "description": ( "Fetch and read the full text of a web page or article from a URL. " "Use when the user shares a URL and wants you to read it, " "or to get the full content of a linked page. " "Do NOT use search_web for URLs — use this tool instead." ), "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "The URL to fetch and read"} }, "required": ["url"], }, }, } ] _RAG_TOOLS = [ { "type": "function", "function": { "name": "search_projects", "description": "Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Search query — project name, topic, or theme", } }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "set_rag_scope", "description": "Change the RAG scope for this conversation. Use project_id= to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.", "parameters": { "type": "object", "properties": { "project_id": { "type": ["integer", "null"], "description": "Project ID to scope to, null for orphan-only, -1 for all notes", } }, "required": ["project_id"], }, }, }, { "type": "function", "function": { "name": "calculate", "description": ( "Evaluate a mathematical expression and return the exact result. Use this for any " "arithmetic, percentages, unit conversions, or multi-step calculations where precision " "matters. Supports standard math operators (+, -, *, /, **, %) and all Python math " "module functions (sqrt, log, sin, cos, floor, ceil, etc.)." ), "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')", }, }, "required": ["expression"], }, }, }, ] _ENTITY_TOOLS = [ { "type": "function", "function": { "name": "create_person", "description": ( "Save a person to the user's knowledge base. Use this when the user introduces " "someone by name or asks to remember details about a person (family member, friend, " "contact, colleague). Stored people are used to resolve names in future prompts." ), "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "Full name of the person"}, "relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"}, "phone": {"type": "string", "description": "Phone number"}, "email": {"type": "string", "description": "Email address"}, "birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"}, "address": {"type": "string", "description": "Home or mailing address"}, "notes": {"type": "string", "description": "Any additional free-form notes about this person"}, }, "required": ["name"], }, }, }, { "type": "function", "function": { "name": "update_person", "description": ( "Update details about a saved person. Use this when the user corrects or adds new " "information about someone already in the knowledge base." ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Name or keyword to find the person"}, "relationship": {"type": "string", "description": "Updated relationship to the user"}, "phone": {"type": "string", "description": "Updated phone number"}, "email": {"type": "string", "description": "Updated email address"}, "birthday": {"type": "string", "description": "Updated birthday in YYYY-MM-DD format"}, "address": {"type": "string", "description": "Updated home or mailing address"}, "notes": {"type": "string", "description": "Updated free-form notes about this person"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "create_place", "description": ( "Save a named location to the user's knowledge base. Use this when the user wants " "to remember a place (business, venue, address). Stored places are used to auto-fill " "locations in calendar events and other prompts." ), "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"}, "address": {"type": "string", "description": "Street address"}, "phone": {"type": "string", "description": "Phone number"}, "hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"}, "url": {"type": "string", "description": "Website URL"}, "notes": {"type": "string", "description": "Any additional free-form notes about this place"}, }, "required": ["name"], }, }, }, { "type": "function", "function": { "name": "update_place", "description": ( "Update details about a saved place. Use this when the user corrects or adds new " "information about a location already in the knowledge base." ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Name or keyword to find the place"}, "address": {"type": "string", "description": "Updated street address"}, "phone": {"type": "string", "description": "Updated phone number"}, "hours": {"type": "string", "description": "Updated opening hours"}, "url": {"type": "string", "description": "Updated website URL"}, "notes": {"type": "string", "description": "Updated free-form notes about this place"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "create_list", "description": ( "Create a named list (shopping list, to-do list, packing list, etc.). " "Items are stored as a markdown task list. Use add_to_list to add items to an existing list." ), "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"}, "items": { "type": "array", "items": {"type": "string"}, "description": "Initial list items (unchecked)", }, "store": {"type": "string", "description": "Optional associated store or place name"}, "tags": { "type": "array", "items": {"type": "string"}, "description": "Tags for the list", }, }, "required": ["name"], }, }, }, { "type": "function", "function": { "name": "add_to_list", "description": "Add one or more items to an existing list. Items are appended as unchecked entries.", "parameters": { "type": "object", "properties": { "list_name": {"type": "string", "description": "Name of the list to add items to"}, "items": { "type": "array", "items": {"type": "string"}, "description": "Items to add", }, }, "required": ["list_name", "items"], }, }, }, { "type": "function", "function": { "name": "clear_checked_items", "description": "Remove all checked/completed items from a list, keeping only unchecked items.", "parameters": { "type": "object", "properties": { "list_name": {"type": "string", "description": "Name of the list to clear"}, }, "required": ["list_name"], }, }, }, ] _PROFILE_TOOLS = [ { "type": "function", "function": { "name": "get_profile", "description": ( "Retrieve the user's stored profile: name, job title, industry, expertise level, " "preferred response style, tone, and interests. Use this when you need to personalise " "a response and the user's profile hasn't already been injected into context." ), "parameters": { "type": "object", "properties": {}, }, }, }, { "type": "function", "function": { "name": "update_profile", "description": ( "Update the user's stored profile when they share personal information: their name, " "job, industry, expertise, preferred response style, tone, or interests. " "Only set fields the user has explicitly mentioned." ), "parameters": { "type": "object", "properties": { "display_name": {"type": "string", "description": "User's preferred display name"}, "job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"}, "industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"}, "expertise_level": { "type": "string", "enum": ["novice", "intermediate", "expert"], "description": "User's general expertise level", }, "response_style": { "type": "string", "enum": ["concise", "balanced", "detailed"], "description": "Preferred response length/depth", }, "tone": { "type": "string", "enum": ["casual", "professional", "technical"], "description": "Preferred communication tone", }, "interests": { "type": "array", "items": {"type": "string"}, "description": "List of topics or hobbies the user is interested in", }, }, }, }, }, ] 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) tools.extend(_URL_TOOLS) tools.extend(_RAG_TOOLS) tools.extend(_ENTITY_TOOLS) tools.extend(_PROFILE_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 _PUNCT_RE = re.compile(r"[^\w\s]") def _fuzzy_title_match(title: str, candidates, threshold: float = 0.82): """Return (best_match, ratio) if any candidate's title is similar enough to *title*. Uses SequenceMatcher ratio, which handles insertions/deletions/substitutions. A threshold of 0.82 catches near-duplicates like "Game Premise" / "Game Premise Notes" while leaving clearly different titles alone. Returns (None, 0.0) when no candidate meets the threshold. """ needle = title.lower().strip() best, best_r = None, 0.0 for c in candidates: r = SequenceMatcher(None, needle, c.title.lower().strip()).ratio() if r >= threshold and r > best_r: best, best_r = c, r return best, best_r async def execute_tool( user_id: int, tool_name: str, arguments: dict, conv_id: int | None = None, workspace_project_id: int | None = None, ) -> 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 = "" # Resolve project and milestone BEFORE creating to fail fast on not-found project_name = arguments.get("project") milestone_name = arguments.get("milestone") parent_task_name = arguments.get("parent_task") pre_fields: dict = {} if project_name: proj = await _resolve_project(user_id, project_name) if proj is None: return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."} pre_fields["project_id"] = proj.id if milestone_name: from fabledassistant.services.milestones import get_milestone_by_title as _gmbt ms = await _gmbt(user_id, proj.id, milestone_name) if ms is None: return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."} pre_fields["milestone_id"] = ms.id existing_tasks, _ = await list_notes(user_id=user_id, q=task_title, is_task=True, limit=1) exact = next((t for t in existing_tasks if t.title.lower() == task_title.lower()), None) if exact is not None: return {"success": False, "error": f"A task titled '{task_title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate."} clean_q = _PUNCT_RE.sub(" ", task_title).strip() candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=True, limit=20) near, ratio = _fuzzy_title_match(task_title, candidates) if near is not None: return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A task with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."} if not arguments.get("confirmed") and len(task_body.strip()) >= 80: from fabledassistant.services.embeddings import semantic_search_notes as _ssn sem_query = f"{task_title}\n{task_body}".strip() sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=True) if sem_hits: best_score, best_note = sem_hits[0] return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A task with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."} task_tags = arguments.get("tags", []) if not isinstance(task_tags, list): task_tags = [] task_status = arguments.get("status", "todo") note = await create_note( user_id=user_id, title=task_title, body=task_body, tags=task_tags, status=task_status, priority=arguments.get("priority", "none"), due_date=_parse_due_date(arguments.get("due_date")), project_id=pre_fields.get("project_id"), milestone_id=pre_fields.get("milestone_id"), ) suggested = await suggest_tags(user_id, task_title, task_body) _schedule_embedding(note.id, user_id, task_title, task_body) update_fields: dict = {} 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 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, "milestone_id": note.milestone_id, "parent_id": note.parent_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 = [] # Resolve project BEFORE creating to fail fast on not-found project_name = arguments.get("project") project_id = None if project_name: proj = await _resolve_project(user_id, project_name) if proj is None: return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."} project_id = proj.id existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1) exact = next((n for n in existing_notes if n.title.lower() == note_title.lower()), None) if exact is not None: return {"success": False, "error": f"A note titled '{note_title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate."} clean_q = _PUNCT_RE.sub(" ", note_title).strip() candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=False, limit=20) near, ratio = _fuzzy_title_match(note_title, candidates) if near is not None: return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A note with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."} if not arguments.get("confirmed") and len(note_body.strip()) >= 80: from fabledassistant.services.embeddings import semantic_search_notes as _ssn sem_query = f"{note_title}\n{note_body}".strip() sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=False) if sem_hits: best_score, best_note = sem_hits[0] return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A note with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."} note = await create_note( user_id=user_id, title=note_title, body=note_body, tags=note_tags, project_id=project_id, ) suggested = await suggest_tags(user_id, note_title, note_body) _schedule_embedding(note.id, user_id, note_title, note_body) 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", "add") 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: proj = await _resolve_project(user_id, project_name) if proj is None: return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."} update_fields["project_id"] = proj.id else: update_fields["project_id"] = None update_fields["milestone_id"] = None # clear milestone when project is cleared if "milestone" in arguments: milestone_name = arguments["milestone"] if milestone_name: # Resolve the project context for milestone lookup ref_project_id = update_fields.get("project_id") or note.project_id if ref_project_id is None: return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."} from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd ms = await _gmbt_upd(user_id, ref_project_id, milestone_name) if ms is None: return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."} update_fields["milestone_id"] = ms.id else: update_fields["milestone_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 "") item_type = "task" if updated.status is not None else "note" return { "success": True, "type": "note_updated", "updated": f"{item_type} '{updated.title}' (id: {updated.id})", "data": { "id": updated.id, "title": updated.title, "item_type": item_type, "status": updated.status, "tags": updated.tags or [], "project_id": updated.project_id, }, "suggested_tags": suggested, } elif tool_name == "list_tasks": project_id = None milestone_id = None project_name = arguments.get("project") milestone_name = arguments.get("milestone") if project_name: proj = await _resolve_project(user_id, project_name) if proj: project_id = proj.id 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 elif milestone_name: # No project specified — search milestone by name across all projects from fabledassistant.services.milestones import find_milestone_by_title ms = await find_milestone_by_title(user_id, milestone_name) if ms: milestone_id = ms.id notes, total = await list_notes( user_id=user_id, q=arguments.get("q") or None, 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") project_name = arguments.get("project") is_task: bool | None = None if type_filter == "note": is_task = False elif type_filter == "task": is_task = True # Resolve project name → id search_project_id: int | None = None if project_name: proj = await _resolve_project(user_id, project_name) if proj: search_project_id = proj.id results_map: dict[int, dict] = {} # 1) Semantic search — broad threshold (0.40) since this is an intentional query try: from fabledassistant.services.embeddings import semantic_search_notes as _ssn sem_results = await _ssn( user_id, query, limit=8, threshold=0.40, project_id=search_project_id, ) for score, n in sem_results: n_is_task = n.status is not None if is_task is True and not n_is_task: continue if is_task is False and n_is_task: continue results_map[n.id] = { "id": n.id, "title": n.title, "type": "task" if n_is_task else "note", "status": n.status, "relevance": f"{round(score * 100)}%", "preview": (n.body[:300] + "…") if n.body and len(n.body) > 300 else (n.body or ""), } logger.debug("search_notes semantic: %d results for %r", len(results_map), query) except Exception: logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True) # 2) Keyword search — merge in anything not already found try: kw_notes, _ = await list_notes( user_id=user_id, q=query, is_task=is_task, project_id=search_project_id, limit=8, ) for n in kw_notes: if n.id not in results_map: results_map[n.id] = { "id": n.id, "title": n.title, "type": "task" if n.status is not None else "note", "status": n.status, "relevance": "keyword", "preview": (n.body[:300] + "…") if n.body and len(n.body) > 300 else (n.body or ""), } except Exception: logger.warning("Keyword fallback in search_notes failed", exc_info=True) results = list(results_map.values())[:8] return { "success": True, "type": "search", "data": { "query": query, "count": len(results), "results": results, }, } elif tool_name == "create_event": start_str = arguments["start"] end_str = arguments.get("end") all_day = arguments.get("all_day", False) # Parse start — accept date-only (YYYY-MM-DD) or full ISO datetime if "T" not in start_str and " " not in start_str: all_day = True start_str = f"{start_str}T00:00:00" try: start_dt = datetime.fromisoformat(start_str) if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=timezone.utc) except (ValueError, TypeError): return {"success": False, "error": f"Invalid start datetime: {start_str!r}"} end_dt = None if end_str: if "T" not in end_str and " " not in end_str: end_str = f"{end_str}T00:00:00" try: end_dt = datetime.fromisoformat(end_str) if end_dt.tzinfo is None: end_dt = end_dt.replace(tzinfo=timezone.utc) except (ValueError, TypeError): return {"success": False, "error": f"Invalid end datetime: {end_str!r}"} project_id = None project_name = arguments.get("project") if project_name: proj = await _resolve_project(user_id, project_name) if proj: project_id = proj.id event = await events_create_event( user_id=user_id, title=arguments.get("title", "Untitled Event"), start_dt=start_dt, end_dt=end_dt, all_day=all_day, description=arguments.get("description") or "", location=arguments.get("location") or "", color=arguments.get("color") or "", recurrence=arguments.get("recurrence"), project_id=project_id, duration=arguments.get("duration"), reminder_minutes=arguments.get("reminder_minutes"), attendees=arguments.get("attendees"), calendar_name=arguments.get("calendar_name"), ) return { "success": True, "type": "event", "data": event.to_dict(), } elif tool_name == "list_events": try: date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00")) date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00")) except (ValueError, TypeError, KeyError) as exc: return {"success": False, "error": f"Invalid date range: {exc}"} if date_from.tzinfo is None: date_from = date_from.replace(tzinfo=timezone.utc) if date_to.tzinfo is None: date_to = date_to.replace(tzinfo=timezone.utc) events = await events_list_events( user_id=user_id, date_from=date_from, date_to=date_to, ) return { "success": True, "type": "events", "data": { "count": len(events), "events": events, }, } elif tool_name == "search_events": events = await events_search_events( user_id=user_id, query=arguments.get("query", ""), include_past=arguments.get("include_past", False), ) return { "success": True, "type": "events", "data": { "query": arguments.get("query", ""), "count": len(events), "events": [e.to_dict() for e in events], }, } elif tool_name == "update_event": query = arguments.get("query", "") matches = await find_events_by_query(user_id=user_id, query=query) if not matches: return {"success": False, "error": f"No event found matching '{query}'."} event_to_update = matches[0] fields: dict = {} for str_field in ("title", "description", "location", "color", "recurrence"): if arguments.get(str_field) is not None: fields[str_field] = arguments[str_field] if arguments.get("all_day") is not None: fields["all_day"] = arguments["all_day"] if "reminder_minutes" in arguments: rm = arguments["reminder_minutes"] fields["reminder_minutes"] = None if rm == 0 else rm for dt_field, key in (("start_dt", "start"), ("end_dt", "end")): val = arguments.get(key) if val: try: dt = datetime.fromisoformat(val.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) fields[dt_field] = dt except (ValueError, TypeError): return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"} updated = await events_update_event( user_id=user_id, event_id=event_to_update.id, **fields, ) if updated is None: return {"success": False, "error": "Event not found or update failed."} return { "success": True, "type": "event_updated", "data": updated.to_dict(), } elif tool_name == "delete_event": query = arguments.get("query", "") matches = await find_events_by_query(user_id=user_id, query=query) if not matches: return {"success": False, "error": f"No event found matching '{query}'."} event_to_delete = matches[0] await events_delete_event(user_id=user_id, event_id=event_to_delete.id) return { "success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}, } 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 project_id = None project_name = arguments.get("project") if project_name: proj = await _resolve_project(user_id, project_name) if proj is None: return {"success": False, "error": f"Project '{project_name}' not found."} project_id = proj.id notes, total = await list_notes( user_id=user_id, q=arguments.get("q") or arguments.get("query"), tags=tags, is_task=False, project_id=project_id, 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 [], "updated_at": n.updated_at.isoformat(), "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."} if not arguments.get("confirmed"): return { "success": False, "requires_confirmation": True, "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", } 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."} if not arguments.get("confirmed"): return { "success": False, "requires_confirmation": True, "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", } 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.milestones import create_milestone as _cm, get_project_milestone_summary project_name = arguments.get("project", "") ms_title_pre = arguments.get("title", "Untitled Milestone") if not project_name: return {"success": False, "error": "project is required"} if not arguments.get("confirmed"): return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."} proj = await _resolve_project(user_id, project_name) if proj is None: return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."} ms_title = ms_title_pre from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms existing_ms = await _gmbt2(user_id, proj.id, ms_title) if existing_ms is not None: return {"success": False, "error": f"A milestone titled '{existing_ms.title}' already exists in '{proj.title}' (id: {existing_ms.id}). Use update_milestone to modify it instead."} all_ms = await _lms(user_id, proj.id) near_ms, ratio = _fuzzy_title_match(ms_title, all_ms) if near_ms is not None: return {"success": False, "error": f"A milestone with a very similar title '{near_ms.title}' already exists in '{proj.title}' (id: {near_ms.id}, similarity: {ratio:.0%}). Use update_milestone to modify it instead."} ms = await _cm( user_id, proj.id, title=ms_title, description=arguments.get("description"), ) return {"success": True, "type": "milestone", "data": ms.to_dict()} elif tool_name == "update_milestone": from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um project_name = arguments.get("project", "") milestone_name = arguments.get("milestone", "") if not project_name or not milestone_name: return {"success": False, "error": "Both project and milestone are required"} proj = await _resolve_project(user_id, project_name) if proj is None: return {"success": False, "error": f"Project '{project_name}' not found"} ms = await _gmbt(user_id, proj.id, milestone_name) if ms is None: return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."} fields: dict[str, object] = {} if "title" in arguments: fields["title"] = arguments["title"] if "description" in arguments: fields["description"] = arguments["description"] if "status" in arguments: fields["status"] = arguments["status"] if not fields: return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"} updated = await _um(user_id, ms.id, **fields) return {"success": True, "type": "milestone", "data": updated.to_dict()} elif tool_name == "list_milestones": from fabledassistant.services.milestones import get_project_milestone_summary project_name = arguments.get("project", "") proj = await _resolve_project(user_id, project_name) 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 == "read_article": from fabledassistant.services.rss import _fetch_full_article url = arguments.get("url", "").strip() if not url: return {"success": False, "error": "No URL provided"} content = await _fetch_full_article(url) if not content: return {"success": False, "error": f"Could not fetch article content from {url}"} _TOOL_CONTENT_CAP = 40_000 truncated = len(content) > _TOOL_CONTENT_CAP return { "success": True, "type": "article_content", "url": url, "content": content[:_TOOL_CONTENT_CAP], "truncated": truncated, } 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 intercepted in generation_task.py (round 0) before execute_tool. # Reaching here indicates a code path regression. topic = arguments.get("topic", "") logger.error("research_topic reached execute_tool — should have been intercepted upstream in generation_task.py. topic=%r", topic) return { "success": False, "error": "Research could not be started. Please try again.", } elif tool_name == "create_project": from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp proj_title = arguments.get("title", "New Project") # Always check for existing/similar projects first — even when confirmed=true existing_proj = await _gpbt(user_id, proj_title) if existing_proj is not None: return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."} all_projects = await _lp(user_id) # Broad fuzzy check (0.55) catches partial names and abbreviations near_proj, ratio = _fuzzy_title_match(proj_title, all_projects, threshold=0.55) if near_proj is not None: return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."} if not arguments.get("confirmed"): return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."} project = await _create_project( user_id, title=proj_title, description=arguments.get("description", ""), goal=arguments.get("goal", ""), color=arguments.get("color") or None, ) 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 ("title", "status", "description", "goal"): if k in arguments: fields[k] = arguments[k] if "color" in arguments: fields["color"] = arguments["color"] or None # empty string → None (clear) 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." ), }, } elif tool_name == "log_work": from fabledassistant.services.task_logs import create_log as _create_log task_query = arguments.get("task", "") content = arguments.get("content", "").strip() if not task_query: return {"success": False, "error": "task is required"} if not content: return {"success": False, "error": "content is required"} duration_minutes = arguments.get("duration_minutes") if duration_minutes is not None: try: duration_minutes = int(duration_minutes) except (TypeError, ValueError): duration_minutes = None # Resolve task by exact title then fuzzy search (tasks only) note = await get_note_by_title(user_id, task_query) if note is None or note.status is None: notes, _ = await list_notes(user_id=user_id, q=task_query, is_task=True, limit=5) note = notes[0] if notes else None if note is None: return {"success": False, "error": f"No task found matching '{task_query}'."} try: log = await _create_log(user_id, note.id, content, duration_minutes) except ValueError as e: return {"success": False, "error": str(e)} return {"success": True, "log": log.to_dict(), "task": note.title} elif tool_name == "get_weather": from fabledassistant.services.weather import ( get_cached_weather, geocode, _fetch_open_meteo, parse_forecast ) from fabledassistant.services.briefing_pipeline import _get_temp_unit from datetime import timezone as _tz location = (arguments.get("location") or "").strip() num_days = max(1, min(8, int(arguments.get("days") or 1))) temp_unit = await _get_temp_unit(user_id) def _apply_unit(days: list[dict]) -> list[dict]: if temp_unit != "F": return days def _c_to_f(v: float | None) -> float | None: return round(v * 9 / 5 + 32, 1) if v is not None else None return [ {**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))} for d in days ] _known = {"home", "work", "all", ""} if location.lower() not in _known: # Live geocode + fetch for arbitrary city try: lat, lon, label = await geocode(location) raw = await _fetch_open_meteo(lat, lon) days = _apply_unit(parse_forecast(raw))[:num_days] return {"success": True, "data": {"locations": [{ "location_key": "query", "location_label": label, "fetched_at": datetime.now(_tz.utc).isoformat(), "days": days, "temp_unit": temp_unit, "changes_since_last_fetch": [], }]}} except Exception as e: return {"success": False, "error": f"Could not get weather for '{location}': {e}"} # Fall back to cached configured locations locations = await get_cached_weather(user_id) if location.lower() not in ("all", ""): locations = [loc for loc in locations if loc["location_key"] == location.lower()] locations = [ {**loc, "days": _apply_unit(loc.get("days", []))[:num_days], "temp_unit": temp_unit} for loc in locations ] return {"success": True, "data": {"locations": locations}} elif tool_name == "get_rss_items": from fabledassistant.services.rss import get_recent_items limit = min(int(arguments.get("limit", 15)), 50) items = await get_recent_items(user_id, limit=limit) return {"data": {"items": items, "count": len(items)}} elif tool_name == "add_rss_feed": import asyncio as _asyncio from fabledassistant.models import async_session as _async_session from fabledassistant.models.rss_feed import RssFeed from fabledassistant.services.rss import fetch_and_cache_feed from sqlalchemy import select as _select url = str(arguments.get("url", "")).strip() if not url: return {"error": "url is required"} category = arguments.get("category") or None async with _async_session() as session: existing = await session.execute( _select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url) ) if existing.scalars().first(): return {"error": "Feed already added", "url": url} feed = RssFeed(user_id=user_id, url=url, title="", category=category) session.add(feed) await session.commit() await session.refresh(feed) feed_id = feed.id _asyncio.create_task(fetch_and_cache_feed(feed_id, url)) return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}} elif tool_name == "search_projects": from difflib import SequenceMatcher query = str(arguments.get("query", "")).lower() from fabledassistant.services.projects import list_projects projects = await list_projects(user_id) scored: list[tuple[float, object]] = [] for p in projects: combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower() base_score = SequenceMatcher(None, query, combined).ratio() # Bonus for keyword overlap query_words = set(query.split()) overlap = sum(1 for w in query_words if w in combined) score = base_score + overlap * 0.05 scored.append((score, p)) scored.sort(key=lambda x: x[0], reverse=True) results = [] for score, p in scored[:5]: results.append({ "id": p.id, "title": p.title, "summary_snippet": (p.auto_summary or p.description or "")[:200], "score": round(score, 3), }) return {"type": "projects_list", "data": {"projects": results}} elif tool_name == "set_rag_scope": if workspace_project_id is not None: return {"success": False, "error": "Cannot change RAG scope in workspace view"} if conv_id is None: return {"success": False, "error": "No conversation context available"} project_id = arguments.get("project_id") # None, -1, or positive int # Validate positive project_id belongs to user if project_id is not None and project_id != -1: from fabledassistant.services.projects import get_project proj = await get_project(user_id, int(project_id)) if proj is None: return {"success": False, "error": "Project not found"} scope_label = proj.title elif project_id == -1: scope_label = "All notes" else: scope_label = "Orphan notes only" from fabledassistant.services.chat import update_conversation await update_conversation(user_id, conv_id, rag_project_id=project_id) return {"success": True, "type": "rag_scope_set", "scope_label": scope_label} elif tool_name == "create_person": name = str(arguments.get("name", "")).strip() if not name: return {"success": False, "error": "name is required"} meta: dict = {} for field in ("relationship", "phone", "email", "birthday", "address"): val = arguments.get(field) if val: meta[field] = str(val) extra_notes = arguments.get("notes", "") body_lines = [] if meta.get("relationship"): body_lines.append(f"**Relationship:** {meta['relationship']}") if meta.get("phone"): body_lines.append(f"**Phone:** {meta['phone']}") if meta.get("email"): body_lines.append(f"**Email:** {meta['email']}") if meta.get("birthday"): body_lines.append(f"**Birthday:** {meta['birthday']}") if meta.get("address"): body_lines.append(f"**Address:** {meta['address']}") if extra_notes: body_lines.append(f"\n{extra_notes}") note = await create_note( user_id=user_id, title=name, body="\n".join(body_lines), tags=["person"], note_type="person", entity_meta=meta, ) _schedule_embedding(note.id, user_id, name, note.body) return {"success": True, "type": "person", "data": {"id": note.id, "name": name}} elif tool_name == "create_place": name = str(arguments.get("name", "")).strip() if not name: return {"success": False, "error": "name is required"} meta = {} for field in ("address", "phone", "hours", "url"): val = arguments.get(field) if val: meta[field] = str(val) extra_notes = arguments.get("notes", "") body_lines = [] if meta.get("address"): body_lines.append(f"**Address:** {meta['address']}") if meta.get("phone"): body_lines.append(f"**Phone:** {meta['phone']}") if meta.get("hours"): body_lines.append(f"**Hours:** {meta['hours']}") if meta.get("url"): body_lines.append(f"**Website:** {meta['url']}") if extra_notes: body_lines.append(f"\n{extra_notes}") note = await create_note( user_id=user_id, title=name, body="\n".join(body_lines), tags=["place"], note_type="place", entity_meta=meta, ) _schedule_embedding(note.id, user_id, name, note.body) return {"success": True, "type": "place", "data": {"id": note.id, "name": name}} elif tool_name == "update_person": query = str(arguments.get("query", "")).strip() if not query: return {"success": False, "error": "query is required"} existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) target = next((n for n in existing if n.note_type == "person"), None) if target is None: return {"success": False, "error": f"No person found matching '{query}'. Use create_person to add them."} meta = dict(target.entity_meta or {}) for field in ("relationship", "phone", "email", "birthday", "address"): val = arguments.get(field) if val is not None: meta[field] = str(val) body_lines = [] if meta.get("relationship"): body_lines.append(f"**Relationship:** {meta['relationship']}") if meta.get("phone"): body_lines.append(f"**Phone:** {meta['phone']}") if meta.get("email"): body_lines.append(f"**Email:** {meta['email']}") if meta.get("birthday"): body_lines.append(f"**Birthday:** {meta['birthday']}") if meta.get("address"): body_lines.append(f"**Address:** {meta['address']}") extra_notes = arguments.get("notes") if extra_notes is not None: body_lines.append(f"\n{extra_notes}") new_body = "\n".join(body_lines) updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) if updated is None: return {"success": False, "error": "Failed to update person."} _schedule_embedding(target.id, user_id, target.title, new_body) return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}} elif tool_name == "update_place": query = str(arguments.get("query", "")).strip() if not query: return {"success": False, "error": "query is required"} existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) target = next((n for n in existing if n.note_type == "place"), None) if target is None: return {"success": False, "error": f"No place found matching '{query}'. Use create_place to add it."} meta = dict(target.entity_meta or {}) for field in ("address", "phone", "hours", "url"): val = arguments.get(field) if val is not None: meta[field] = str(val) body_lines = [] if meta.get("address"): body_lines.append(f"**Address:** {meta['address']}") if meta.get("phone"): body_lines.append(f"**Phone:** {meta['phone']}") if meta.get("hours"): body_lines.append(f"**Hours:** {meta['hours']}") if meta.get("url"): body_lines.append(f"**Website:** {meta['url']}") extra_notes = arguments.get("notes") if extra_notes is not None: body_lines.append(f"\n{extra_notes}") new_body = "\n".join(body_lines) updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) if updated is None: return {"success": False, "error": "Failed to update place."} _schedule_embedding(target.id, user_id, target.title, new_body) return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}} elif tool_name == "create_list": name = str(arguments.get("name", "")).strip() if not name: return {"success": False, "error": "name is required"} items = arguments.get("items") or [] if not isinstance(items, list): items = [] body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) meta = {} store = arguments.get("store") if store: meta["store"] = str(store) tags = arguments.get("tags") or [] if not isinstance(tags, list): tags = [] note = await create_note( user_id=user_id, title=name, body=body, tags=tags, note_type="list", entity_meta=meta, ) _schedule_embedding(note.id, user_id, name, body) return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}} elif tool_name == "add_to_list": list_name = str(arguments.get("list_name", "")).strip() items = arguments.get("items") or [] if not list_name: return {"success": False, "error": "list_name is required"} if not isinstance(items, list) or not items: return {"success": False, "error": "items must be a non-empty array"} existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) if target is None: target = next((n for n in existing if n.note_type == "list"), None) if target is None: return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."} new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) existing_body = (target.body or "").rstrip() new_body = f"{existing_body}\n{new_lines}".lstrip() await update_note(user_id=user_id, note_id=target.id, body=new_body) _schedule_embedding(target.id, user_id, target.title, new_body) return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}} elif tool_name == "clear_checked_items": list_name = str(arguments.get("list_name", "")).strip() if not list_name: return {"success": False, "error": "list_name is required"} existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) if target is None: target = next((n for n in existing if n.note_type == "list"), None) if target is None: return {"success": False, "error": f"No list named '{list_name}' found."} import re as _re body = target.body or "" cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip() await update_note(user_id=user_id, note_id=target.id, body=cleaned) _schedule_embedding(target.id, user_id, target.title, cleaned) return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}} elif tool_name == "get_profile": from fabledassistant.services.user_profile import get_profile as _get_profile profile = await _get_profile(user_id) return { "success": True, "type": "profile", "data": { "display_name": profile.display_name or "", "job_title": profile.job_title or "", "industry": profile.industry or "", "expertise_level": profile.expertise_level or "intermediate", "response_style": profile.response_style or "balanced", "tone": profile.tone or "casual", "interests": profile.interests or [], }, } elif tool_name == "update_profile": from fabledassistant.services.user_profile import update_profile as _update_profile, VALID_EXPERTISE, VALID_STYLES, VALID_TONES data: dict = {} for field in ("display_name", "job_title", "industry"): val = arguments.get(field) if val is not None: data[field] = str(val) expertise = arguments.get("expertise_level") if expertise in VALID_EXPERTISE: data["expertise_level"] = expertise style = arguments.get("response_style") if style in VALID_STYLES: data["response_style"] = style tone = arguments.get("tone") if tone in VALID_TONES: data["tone"] = tone interests = arguments.get("interests") if isinstance(interests, list): data["interests"] = [str(i) for i in interests if str(i).strip()] if not data: return {"success": False, "error": "No valid fields provided to update."} profile = await _update_profile(user_id, data) return { "success": True, "type": "profile_updated", "data": {"fields_updated": list(data.keys())}, } elif tool_name == "calculate": import math as _math expr = str(arguments.get("expression", "")).strip() if not expr: return {"success": False, "error": "expression is required"} allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")} allowed_names["abs"] = abs allowed_names["round"] = round try: result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307 except Exception as calc_err: return {"success": False, "error": f"Could not evaluate expression: {calc_err}"} return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}} 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)}