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";
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);
+75
View File
@@ -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,
+5
View File
@@ -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.
+5 -4
View File
@@ -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)
+159 -8
View File
@@ -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,