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,225 @@
|
||||
"""Calendar and event tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fabledassistant.services.events import (
|
||||
create_event as events_create_event,
|
||||
delete_event as events_delete_event,
|
||||
find_events_by_query,
|
||||
list_events as events_list_events,
|
||||
search_events as events_search_events,
|
||||
update_event as events_update_event,
|
||||
)
|
||||
from fabledassistant.services.tools._helpers import resolve_project
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"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"],
|
||||
)
|
||||
async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
start_str = arguments["start"]
|
||||
end_str = arguments.get("end")
|
||||
all_day = arguments.get("all_day", False)
|
||||
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()}
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"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"],
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
)
|
||||
async def list_events_tool(*, user_id, arguments, **_ctx):
|
||||
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},
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_events",
|
||||
description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
|
||||
parameters={
|
||||
"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"],
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
)
|
||||
async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
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],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_event",
|
||||
description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
|
||||
parameters={
|
||||
"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"],
|
||||
)
|
||||
async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
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()}
|
||||
|
||||
|
||||
@tool(
|
||||
name="delete_event",
|
||||
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"},
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
async def delete_event_tool(*, user_id, arguments, **_ctx):
|
||||
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}}
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_calendars",
|
||||
description="List all available calendars. Use this when the user asks which calendars they have.",
|
||||
parameters={},
|
||||
read_only=True,
|
||||
requires="caldav",
|
||||
)
|
||||
async def list_calendars_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.caldav import list_calendars
|
||||
|
||||
calendars = await list_calendars(user_id=user_id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "calendars",
|
||||
"data": {"count": len(calendars), "calendars": calendars},
|
||||
}
|
||||
Reference in New Issue
Block a user