Add update_task fields, list_tasks, and update_todo tools
- update_note: extend with status/priority/due_date fields so task attributes can be changed via chat (mark done, set priority, move due date). body is now optional — task field updates work without touching content. - list_tasks: new core tool with status/priority/due_before/due_after/limit filters backed by list_notes(is_task=True). Enables queries like "overdue tasks", "high priority tasks", "what's in progress". - update_todo: new CalDAV tool to modify VTODO summary, due date, description, and priority — follows update_event pattern (modify component, rebuild ical, save). Completes the CalDAV todo CRUD suite. - tools.py: add update_todo import + execute case (type: todo_updated) - llm.py: add list_tasks and update_todo to available actions + guidance - intent.py: routing rules for mark-done/priority/due-date → update_note, overdue/in-progress/high-priority queries → list_tasks, CalDAV todo updates → update_todo - ToolCallCard.vue: tasks list block (linked titles + due + priority badges), todo_updated label, tool-task-priority CSS classes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,10 @@ const label = computed(() => {
|
||||
return "Created note";
|
||||
case "note_updated":
|
||||
return "Updated note";
|
||||
case "tasks":
|
||||
return "Tasks";
|
||||
case "todo_updated":
|
||||
return "Updated todo";
|
||||
case "search":
|
||||
return "Searched notes";
|
||||
case "event":
|
||||
@@ -95,7 +99,7 @@ const calendarList = computed(() => {
|
||||
const todoData = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
const type = props.toolCall.result.type;
|
||||
if (!data || (type !== "todo" && type !== "todo_completed" && type !== "todo_deleted")) return null;
|
||||
if (!data || (type !== "todo" && type !== "todo_completed" && type !== "todo_deleted" && type !== "todo_updated")) return null;
|
||||
return data as { summary: string; due?: string; status?: string };
|
||||
});
|
||||
|
||||
@@ -141,6 +145,19 @@ function formatEventTime(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const taskList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "tasks") return null;
|
||||
const results = data.results as Array<{ id: number; title: string; status: string; priority: string; due_date?: string }> | undefined;
|
||||
return results ?? [];
|
||||
});
|
||||
|
||||
const taskCount = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "tasks") return 0;
|
||||
return (data.total as number) ?? 0;
|
||||
});
|
||||
|
||||
const searchResults = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "search") return null;
|
||||
@@ -215,6 +232,17 @@ async function applyTag(tag: string) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="taskList !== null">
|
||||
<span class="tool-search-info">{{ taskCount }} found</span>
|
||||
<div v-if="taskList.length > 0" class="tool-event-list">
|
||||
<div v-for="(t, i) in taskList.slice(0, 5)" :key="i" class="tool-event-item">
|
||||
<router-link :to="`/tasks/${t.id}`" class="tool-event-item-title">{{ t.title }}</router-link>
|
||||
<span v-if="t.due_date" class="tool-event-item-time">{{ t.due_date }}</span>
|
||||
<span v-if="t.priority && t.priority !== 'none'" class="tool-task-priority" :class="`priority-${t.priority}`">{{ t.priority }}</span>
|
||||
</div>
|
||||
<div v-if="taskList.length > 5" class="tool-event-more">+{{ taskList.length - 5 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="eventList !== null">
|
||||
<span class="tool-search-info">{{ eventCount }} found</span>
|
||||
<div v-if="eventList.length > 0" class="tool-event-list">
|
||||
@@ -341,6 +369,17 @@ async function applyTag(tag: string) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tool-task-priority {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.priority-high { color: var(--color-danger, #e74c3c); }
|
||||
.priority-medium { color: var(--color-warning, #f59e0b); }
|
||||
.priority-low { color: var(--color-text-muted); }
|
||||
.tool-completed {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-success, #2ecc71);
|
||||
|
||||
@@ -575,6 +575,81 @@ async def complete_todo(
|
||||
return await asyncio.to_thread(_complete)
|
||||
|
||||
|
||||
async def update_todo(
|
||||
user_id: int,
|
||||
query: str,
|
||||
summary: str | None = None,
|
||||
due: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: int | None = None,
|
||||
timezone: str | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Update a CalDAV todo matching the query."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
|
||||
def _do_update():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
todos = calendar.todos(include_completed=True)
|
||||
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for t in todos:
|
||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VTODO":
|
||||
s = str(component.get("SUMMARY", ""))
|
||||
if q in s.lower():
|
||||
matches.append((t, component))
|
||||
|
||||
if not matches:
|
||||
raise ValueError(f"No todo found matching '{query}'.")
|
||||
if len(matches) > 3:
|
||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
||||
raise ValueError(
|
||||
f"Too many matches ({len(matches)}) for '{query}'. "
|
||||
f"Be more specific. Found: {', '.join(titles[:10])}"
|
||||
)
|
||||
|
||||
todo_obj, component = matches[0]
|
||||
|
||||
if summary:
|
||||
component["SUMMARY"] = summary
|
||||
if description is not None:
|
||||
if "DESCRIPTION" in component:
|
||||
del component["DESCRIPTION"]
|
||||
component.add("description", description)
|
||||
if priority is not None:
|
||||
if "PRIORITY" in component:
|
||||
del component["PRIORITY"]
|
||||
component.add("priority", priority)
|
||||
if due:
|
||||
if "DUE" in component:
|
||||
del component["DUE"]
|
||||
try:
|
||||
dt = datetime.fromisoformat(due)
|
||||
dt = _apply_timezone(dt, tz)
|
||||
component.add("due", dt)
|
||||
except ValueError:
|
||||
component.add("due", date_type.fromisoformat(due))
|
||||
|
||||
# Rebuild ical data and save
|
||||
cal_data = icalendar.Calendar()
|
||||
cal_data.add("prodid", "-//FabledAssistant//EN")
|
||||
cal_data.add("version", "2.0")
|
||||
cal_data.add_component(component)
|
||||
todo_obj.data = cal_data.to_ical().decode("utf-8")
|
||||
todo_obj.save()
|
||||
|
||||
return _parse_vtodo(component)
|
||||
|
||||
return await asyncio.to_thread(_do_update)
|
||||
|
||||
|
||||
async def delete_todo(
|
||||
user_id: int,
|
||||
query: str,
|
||||
|
||||
@@ -77,8 +77,13 @@ Rules:
|
||||
- Infer reasonable defaults: birthdays and holidays are all-day + yearly recurring; "weekly meeting" is weekly recurring.
|
||||
- Use descriptive titles: "My Birthday" not just "Birthday", "Team Standup" not just "Meeting".
|
||||
- "add to", "update", "edit", "expand", "flesh out", "modify", "append to", "continue writing" a note → use update_note with query=<note title from context> and mode="append" for additions or mode="replace" for full rewrites.
|
||||
- "mark as done", "complete", "finish", "mark in progress", "start" a task → use update_note with status field.
|
||||
- "set priority", "change priority", "make it high priority" → use update_note with priority field.
|
||||
- "set due date", "move due date", "due on Friday" for a task → use update_note with due_date field.
|
||||
- "what are my overdue tasks", "show overdue", "tasks due today", "high priority tasks", "in progress tasks", "what's due this week" → use list_tasks with appropriate status/priority/due_before/due_after filters. For overdue, set due_before to today's date.
|
||||
- If a note was created earlier in the conversation and the user provides more content for it, use update_note (not create_note).
|
||||
- Use create_note ONLY when the user explicitly wants a brand new note that doesn't already exist.
|
||||
- "update", "change", "rename", "set due date on" a CalDAV todo → use update_todo.
|
||||
- "update", "change", "move", "reschedule" an event → use update_event.
|
||||
- "delete", "cancel", "remove" an event → use delete_event.
|
||||
- "which calendars", "list calendars", "my calendars" → use list_calendars.
|
||||
|
||||
@@ -250,21 +250,22 @@ async def build_context(
|
||||
tool_lines = [
|
||||
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
|
||||
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
|
||||
"Available actions: create_task, create_note, update_note, search_notes.",
|
||||
"Available actions: create_task, create_note, update_note, list_tasks, search_notes.",
|
||||
]
|
||||
if has_caldav:
|
||||
tool_lines[-1] = (
|
||||
"Available actions: create_task, create_note, update_note, search_notes, "
|
||||
"Available actions: create_task, create_note, update_note, list_tasks, search_notes, "
|
||||
"create_event, list_events, search_events, update_event, delete_event, "
|
||||
"list_calendars, create_todo, list_todos, complete_todo, delete_todo."
|
||||
"list_calendars, create_todo, list_todos, update_todo, complete_todo, delete_todo."
|
||||
)
|
||||
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
|
||||
tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.")
|
||||
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
|
||||
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
|
||||
tool_lines.append(
|
||||
"Use update_note to edit/expand an existing note. "
|
||||
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. "
|
||||
"Use create_note ONLY for genuinely new notes with a different title. "
|
||||
"Use list_tasks to find tasks by status, priority, or due date (e.g. overdue, high priority, in progress). "
|
||||
"If a note was created earlier in the conversation and the user provides more content for it, use update_note."
|
||||
)
|
||||
tool_guidance = "\n".join(tool_lines)
|
||||
|
||||
@@ -15,6 +15,7 @@ from fabledassistant.services.caldav import (
|
||||
list_todos,
|
||||
search_events,
|
||||
update_event,
|
||||
update_todo,
|
||||
)
|
||||
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note
|
||||
from fabledassistant.services.tag_suggestions import suggest_tags
|
||||
@@ -79,8 +80,9 @@ _CORE_TOOLS = [
|
||||
"function": {
|
||||
"name": "update_note",
|
||||
"description": (
|
||||
"Update an existing note's content or title. "
|
||||
"Use this when the user asks to add to, edit, expand, flesh out, or modify a note that already exists. "
|
||||
"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": {
|
||||
@@ -88,26 +90,40 @@ _CORE_TOOLS = [
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Title or keyword to find the note to update",
|
||||
"description": "Title or keyword to find the note or task to update",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "New note content in markdown",
|
||||
"description": "New note content in markdown (omit if only updating task fields)",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Optional new title for the note",
|
||||
"description": "Optional new title",
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["replace", "append"],
|
||||
"description": (
|
||||
"How to apply the new body: 'replace' overwrites the existing content (default), "
|
||||
"'append' adds the new content after the existing content"
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"required": ["query", "body"],
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -128,6 +144,45 @@ _CORE_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of tasks to return (default 10)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
@@ -370,6 +425,47 @@ _CALDAV_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_todo",
|
||||
"description": "Update a CalDAV todo's summary, due date, description, or priority.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search term to find the todo to update (matches against summary)",
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "New summary/title for the todo",
|
||||
},
|
||||
"due": {
|
||||
"type": "string",
|
||||
"description": "New due date or datetime in ISO 8601 format",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New description",
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"description": "New priority (1=highest, 9=lowest)",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Optional IANA timezone for due datetime",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to search in",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -508,6 +604,12 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
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"])
|
||||
|
||||
updated = await update_note(user_id, note.id, **update_fields)
|
||||
if updated is None:
|
||||
@@ -524,6 +626,38 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
|
||||
elif tool_name == "list_tasks":
|
||||
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")),
|
||||
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,
|
||||
"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", "")
|
||||
notes, total = await list_notes(user_id=user_id, q=query, limit=5)
|
||||
@@ -671,6 +805,23 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "update_todo":
|
||||
result = await update_todo(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
summary=arguments.get("summary"),
|
||||
due=arguments.get("due"),
|
||||
description=arguments.get("description"),
|
||||
priority=arguments.get("priority"),
|
||||
timezone=arguments.get("timezone"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "todo_updated",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
elif tool_name == "complete_todo":
|
||||
result = await complete_todo(
|
||||
user_id=user_id,
|
||||
|
||||
Reference in New Issue
Block a user