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:
@@ -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}
|
||||
Reference in New Issue
Block a user