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:
2026-02-17 23:22:02 -05:00
parent 70cba72a80
commit 4df5ec2d65
5 changed files with 284 additions and 13 deletions
+40 -1
View File
@@ -19,6 +19,10 @@ const label = computed(() => {
return "Created note"; return "Created note";
case "note_updated": case "note_updated":
return "Updated note"; return "Updated note";
case "tasks":
return "Tasks";
case "todo_updated":
return "Updated todo";
case "search": case "search":
return "Searched notes"; return "Searched notes";
case "event": case "event":
@@ -95,7 +99,7 @@ const calendarList = computed(() => {
const todoData = computed(() => { const todoData = computed(() => {
const data = props.toolCall.result.data; const data = props.toolCall.result.data;
const type = props.toolCall.result.type; 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 }; 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 searchResults = computed(() => {
const data = props.toolCall.result.data; const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "search") return null; if (!data || props.toolCall.result.type !== "search") return null;
@@ -215,6 +232,17 @@ async function applyTag(tag: string) {
</div> </div>
</div> </div>
</template> </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"> <template v-else-if="eventList !== null">
<span class="tool-search-info">{{ eventCount }} found</span> <span class="tool-search-info">{{ eventCount }} found</span>
<div v-if="eventList.length > 0" class="tool-event-list"> <div v-if="eventList.length > 0" class="tool-event-list">
@@ -341,6 +369,17 @@ async function applyTag(tag: string) {
text-decoration: line-through; text-decoration: line-through;
opacity: 0.7; 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 { .tool-completed {
text-decoration: line-through; text-decoration: line-through;
color: var(--color-success, #2ecc71); color: var(--color-success, #2ecc71);
+75
View File
@@ -575,6 +575,81 @@ async def complete_todo(
return await asyncio.to_thread(_complete) 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( async def delete_todo(
user_id: int, user_id: int,
query: str, query: str,
+5
View File
@@ -77,8 +77,13 @@ Rules:
- Infer reasonable defaults: birthdays and holidays are all-day + yearly recurring; "weekly meeting" is weekly recurring. - 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". - 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. - "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). - 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. - 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. - "update", "change", "move", "reschedule" an event → use update_event.
- "delete", "cancel", "remove" an event → use delete_event. - "delete", "cancel", "remove" an event → use delete_event.
- "which calendars", "list calendars", "my calendars" → use list_calendars. - "which calendars", "list calendars", "my calendars" → use list_calendars.
+5 -4
View File
@@ -250,21 +250,22 @@ async def build_context(
tool_lines = [ 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.", "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.", "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: if has_caldav:
tool_lines[-1] = ( 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, " "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("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("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("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("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
tool_lines.append( 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 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." "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) tool_guidance = "\n".join(tool_lines)
+159 -8
View File
@@ -15,6 +15,7 @@ from fabledassistant.services.caldav import (
list_todos, list_todos,
search_events, search_events,
update_event, update_event,
update_todo,
) )
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags from fabledassistant.services.tag_suggestions import suggest_tags
@@ -79,8 +80,9 @@ _CORE_TOOLS = [
"function": { "function": {
"name": "update_note", "name": "update_note",
"description": ( "description": (
"Update an existing note's content or title. " "Update an existing note or task — content, title, status, priority, or due date. "
"Use this when the user asks to add to, edit, expand, flesh out, or modify a note that already exists. " "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." "NEVER use create_note when updating an existing note — always use update_note."
), ),
"parameters": { "parameters": {
@@ -88,26 +90,40 @@ _CORE_TOOLS = [
"properties": { "properties": {
"query": { "query": {
"type": "string", "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": { "body": {
"type": "string", "type": "string",
"description": "New note content in markdown", "description": "New note content in markdown (omit if only updating task fields)",
}, },
"title": { "title": {
"type": "string", "type": "string",
"description": "Optional new title for the note", "description": "Optional new title",
}, },
"mode": { "mode": {
"type": "string", "type": "string",
"enum": ["replace", "append"], "enum": ["replace", "append"],
"description": ( "description": (
"How to apply the new body: 'replace' overwrites the existing content (default), " "How to apply the new body: 'replace' overwrites existing content (default), "
"'append' adds the new content after the existing content" "'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 # 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", "type": "function",
"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 update_fields["body"] = note.body + "\n\n" + new_body
else: else:
update_fields["body"] = new_body 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) updated = await update_note(user_id, note.id, **update_fields)
if updated is None: if updated is None:
@@ -524,6 +626,38 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"suggested_tags": suggested, "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": elif tool_name == "search_notes":
query = arguments.get("query", "") query = arguments.get("query", "")
notes, total = await list_notes(user_id=user_id, q=query, limit=5) 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": elif tool_name == "complete_todo":
result = await complete_todo( result = await complete_todo(
user_id=user_id, user_id=user_id,