Files
FabledScribe/src/fabledassistant/services/tools.py
T
bvandeusen d7e1fe6aab UX: queue bubbles, dashboard queuing, duplicate confirm buttons, short-note fix
- Dashboard chat: allow typing/sending during streaming (queued in store)
  placeholder updates to "Type to queue…" during generation
- ChatView/WorkspaceView: replace queued chip with actual pending message
  bubbles — light grey, dashed border, "Queued" badge above content; clear
  button below the stack
- ToolCallCard: when tool returns requires_confirmation=True, show
  "Similar content found" label (not "Error"), link to the similar note,
  and "Create Anyway" / "Don't Create" buttons that auto-send the reply
- tools.py: fuzzy title match now returns requires_confirmation=True with
  similar_note data instead of a hard error, so numbered-series notes
  (Lore: X 0, Lore: X 1) can be created with one button click; semantic
  match responses also include similar_note for the link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 23:32:21 -04:00

1581 lines
70 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
import re
from datetime import date, datetime
from difflib import SequenceMatcher
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",
},
"confirmed": {
"type": "boolean",
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing task.",
},
},
"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",
},
"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 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": ["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)",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "search_notes",
"description": (
"Search notes and tasks using semantic understanding. Finds content related to a concept, "
"theme, or topic — even when exact keywords don't appear in the title or body. "
"Use this when the user asks what notes they have about a topic, wants to explore related "
"content, or is looking for something they've written. Falls back to keyword search "
"automatically if semantic search is unavailable."
),
"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. 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, 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. "
"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. IMPORTANT: You MUST ask the user to confirm the project name and intent before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
"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"},
"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 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. IMPORTANT: You MUST ask the user to confirm the milestone name and which project it belongs to before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
"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": "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"],
},
},
},
]
# 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
_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) -> 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:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
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"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
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 = await get_note_by_title(user_id, task_title)
if existing is not None:
item_type = "task" if existing.status is not None else "note"
return {"success": False, "error": f"A {item_type} titled '{task_title}' already exists (id: {existing.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, limit=20)
near, ratio = _fuzzy_title_match(task_title, candidates)
if near is not None:
item_type = "task" if near.status is not None else "note"
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A {item_type} 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.87)
if sem_hits:
best_score, best_note = sem_hits[0]
item_type = "task" if best_note.status is not None else "note"
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A {item_type} 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=task_title,
body=task_body,
status="todo",
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,
},
"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:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
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"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
project_id = proj.id
existing = await get_note_by_title(user_id, note_title)
if existing is not None:
item_type = "task" if existing.status is not None else "note"
return {"success": False, "error": f"A {item_type} titled '{note_title}' already exists (id: {existing.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, limit=20)
near, ratio = _fuzzy_title_match(note_title, candidates)
if near is not None:
item_type = "task" if near.status is not None else "note"
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A {item_type} 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.87)
if sem_hits:
best_score, best_note = sem_hits[0]
item_type = "task" if best_note.status is not None else "note"
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A {item_type} 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:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
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"Project '{project_name}' not found. Use list_projects to see available projects."}
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 "")
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:
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
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,
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:
from fabledassistant.services.projects import get_project_by_title as _gpbt2, list_projects as _lp2
proj = await _gpbt2(user_id, project_name)
if proj is None:
all_p = await _lp2(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:
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":
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
project_id = None
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
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"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."}
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_project_by_title as _gpbt, list_projects as _lp
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 _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"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
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 == "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 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")
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."}
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 update_project to modify it instead of creating a duplicate."}
all_projects = await _lp(user_id)
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects)
if near_proj is not None:
return {"success": False, "error": f"A project with a very similar title '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). Use update_project to modify it instead of creating a duplicate."}
project = await _create_project(
user_id,
title=proj_title,
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."
),
},
}
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}
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)}