refactor(tools): decorator-based tool registry replaces monolithic tools.py

Split 2566-line tools.py into a tools/ package with @tool decorator
registration. Each tool's schema, metadata, and implementation live
together. Briefing eligibility is now a briefing=True flag instead of
a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG)
uses requires= metadata. Public API (get_tools_for_user, execute_tool)
unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 10:01:00 -04:00
parent 5275be8588
commit ce2d76447c
15 changed files with 2129 additions and 2624 deletions
+253
View File
@@ -0,0 +1,253 @@
"""Task tools: create, list, delete, log work."""
from __future__ import annotations
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
from fabledassistant.services.tools._helpers import (
_PUNCT_RE,
fuzzy_title_match,
parse_due_date,
resolve_project,
schedule_embedding,
)
from fabledassistant.services.tools._registry import tool
@tool(
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={
"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"],
)
async def create_task_tool(*, user_id, arguments, **_ctx):
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 = ""
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,
}
@tool(
name="list_tasks",
description="List tasks with optional filters. For overdue tasks, set due_before to today's date.",
parameters={
"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)"},
},
read_only=True,
briefing=True,
)
async def list_tasks_tool(*, user_id, arguments, **_ctx):
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:
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,
exclude_paused_projects=project_id is None,
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},
}
@tool(
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={
"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"],
)
async def delete_task_tool(*, user_id, arguments, **_ctx):
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}}
@tool(
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={
"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"],
)
async def log_work_tool(*, user_id, arguments, **_ctx):
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
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}