"""Task agent — full CRUD for tasks and task comments.""" from __future__ import annotations from datetime import datetime, timezone import re from typing import Any from langchain_core.tools import tool from app.core.ws_context import execute_on_client _UUID_RE = re.compile( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" ) def _is_uuid(value: str) -> bool: return bool(_UUID_RE.match(value)) # ── Task tools ──────────────────────────────────────────────────────── @tool async def list_tasks( project_id: str = "", status: str = "", priority: str = "", assignee: str = "", search: str = "", order_by: str = "", order_dir: str = "", due_date_from: int = -1, due_date_to: int = -1, created_at_from: int = -1, created_at_to: int = -1, completed_at_from: int = -1, completed_at_to: int = -1, is_ai_suggested: int = -1, limit: int = 50, offset: int = 0, ) -> str: """List tasks with optional filters. Returns up to `limit` results (default 50). project_id: UUID of the project to scope results to. status: filter by status — todo | in_progress | done. priority: filter by priority — high | medium | low. assignee: substring to match against assignee names. search: substring search across title and description. order_by: sort field — dueDate | priority | createdAt | completedAt. order_dir: asc (default) | desc. due_date_from / due_date_to: ms epoch range for dueDate. Use -1 to omit. created_at_from / created_at_to: ms epoch range for createdAt. Use -1 to omit. completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit. is_ai_suggested: 0 or 1 to filter by AI-suggested flag; -1 = any. limit: max rows to return (default 50). Use with offset to paginate. offset: skip first N rows (default 0). Tip — combine *_from and *_to for a closed range; pass only one for open-ended. Tip — prefer count_tasks for "how many" questions to avoid listing rows. Tip — for natural-language windows ("today", "tomorrow", "this week", "last month", etc.) take due_date_from / due_date_to verbatim from the DATE CONTEXT block in the system prompt; do not compute boundaries from the current UTC instant. """ normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else "" filters: dict[str, Any] = { "projectId": normalized_project_id or None, "status": status or None, "priority": priority or None, "search": search or None, "orderBy": order_by or None, "orderDir": order_dir or None, "limit": limit, "offset": offset, } if assignee: filters["assignee"] = assignee if due_date_from != -1: filters["dueDateFrom"] = due_date_from if due_date_to != -1: filters["dueDateTo"] = due_date_to if created_at_from != -1: filters["createdAtFrom"] = created_at_from if created_at_to != -1: filters["createdAtTo"] = created_at_to if completed_at_from != -1: filters["completedAtFrom"] = completed_at_from if completed_at_to != -1: filters["completedAtTo"] = completed_at_to if is_ai_suggested != -1: filters["isAiSuggested"] = is_ai_suggested result = await execute_on_client(action="select", table="tasks", filters=filters) rows = result.get("rows", []) if not rows: return "No tasks found matching the given filters." lines = [ f"- {r['title']} (status: {r['status']}, priority: {r['priority']}, " f"dueDate: {r.get('dueDate')}, completedAt: {r.get('completedAt')}, id: {r['id']})" for r in rows ] return f"Found {len(rows)} task(s):\n" + "\n".join(lines) @tool async def count_tasks( project_id: str = "", status: str = "", priority: str = "", assignee: str = "", search: str = "", due_date_from: int = -1, due_date_to: int = -1, created_at_from: int = -1, created_at_to: int = -1, completed_at_from: int = -1, completed_at_to: int = -1, is_ai_suggested: int = -1, ) -> str: """Count tasks matching the given filters without returning rows. Use this instead of list_tasks for "how many" questions — it is much cheaper. Same filter parameters as list_tasks (no limit/offset/order_by needed). due_date_from / due_date_to: ms epoch range for dueDate. Use -1 to omit. created_at_from / created_at_to: ms epoch range for createdAt. Use -1 to omit. completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit. Tip — for natural-language windows take due_date_from / due_date_to from the DATE CONTEXT block; do not compute boundaries from the current UTC instant. """ normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else "" filters: dict[str, Any] = { "projectId": normalized_project_id or None, "status": status or None, "priority": priority or None, "search": search or None, } if assignee: filters["assignee"] = assignee if due_date_from != -1: filters["dueDateFrom"] = due_date_from if due_date_to != -1: filters["dueDateTo"] = due_date_to if created_at_from != -1: filters["createdAtFrom"] = created_at_from if created_at_to != -1: filters["createdAtTo"] = created_at_to if completed_at_from != -1: filters["completedAtFrom"] = completed_at_from if completed_at_to != -1: filters["completedAtTo"] = completed_at_to if is_ai_suggested != -1: filters["isAiSuggested"] = is_ai_suggested result = await execute_on_client(action="count", table="tasks", filters=filters) return f"Task count: {result.get('count', 0)}" @tool async def create_task( title: str, description: str = "", status: str = "todo", priority: str = "medium", assignees: str = "[]", due_date: int = 0, project_id: str = "", is_ai_suggested: int = 0, ) -> str: """Create a new task. title: task title (required) description: optional details status: todo | in_progress | done (default: todo) priority: high | medium | low (default: medium) assignees: JSON-encoded array of assignee names, e.g. '["Alice"]' due_date: Unix timestamp in milliseconds; 0 means no due date project_id: optional UUID of the parent project is_ai_suggested: 1 if proactively suggested, 0 if user-requested completedAt is set automatically when status is 'done'. """ result = await execute_on_client( action="insert", table="tasks", data={ "title": title, "description": description or None, "status": status, "priority": priority, "assignee": assignees, "dueDate": due_date or None, "projectId": project_id or None, "isAiSuggested": is_ai_suggested, }, ) row = result["row"] return ( f"Task created: '{row['title']}' " f"(id: {row['id']}, status: {row['status']}, priority: {row['priority']})" ) @tool async def update_task( task_id: str, title: str = "", description: str = "", status: str = "", priority: str = "", assignees: str = "", due_date: int = -1, project_id: str = "", ) -> str: """Update fields on an existing task. Only pass fields you want to change. task_id: the task's UUID (required) due_date: -1 means unchanged; 0 clears the due date; any positive value sets it completedAt is managed automatically: - setting status to 'done' records the current timestamp - changing status away from 'done' clears completedAt """ updates: dict[str, Any] = {} if title: updates["title"] = title if description: updates["description"] = description if status: updates["status"] = status if priority: updates["priority"] = priority if assignees: updates["assignee"] = assignees if due_date != -1: updates["dueDate"] = due_date or None if project_id: updates["projectId"] = project_id result = await execute_on_client( action="update", table="tasks", data={"id": task_id, "updates": updates}, ) row = result["row"] return f"Task updated: '{row['title']}' (id: {row['id']}, status: {row['status']})" @tool async def delete_task(task_id: str) -> str: """Delete a task permanently by its UUID.""" await execute_on_client(action="delete", table="tasks", data={"id": task_id}) return f"Task {task_id} deleted." @tool async def list_tasks_due_today(user_timezone: str = "UTC", include_done: bool = False) -> str: """List all tasks whose due date falls on today's date. user_timezone: IANA timezone name (e.g. 'Europe/Rome', 'America/New_York'). Always pass the user's timezone so 'today' is computed in their local time. include_done: set True to also include already-completed tasks due today (default False). """ try: from zoneinfo import ZoneInfo tz = ZoneInfo(user_timezone or "UTC") except Exception: tz = timezone.utc now_local = datetime.now(tz=tz) start_dt = datetime(now_local.year, now_local.month, now_local.day, tzinfo=tz) start_ms = int(start_dt.timestamp() * 1000) end_ms = start_ms + 86_400_000 - 1 filters: dict[str, Any] = {"dueDateFrom": start_ms, "dueDateTo": end_ms} if not include_done: filters["status"] = "todo" result = await execute_on_client( action="select", table="tasks", filters=filters, ) rows = result.get("rows", []) if not rows: return "No tasks are due today." lines = [ f"- {r['title']} (priority: {r['priority']}, status: {r['status']}, id: {r['id']})" for r in rows ] return f"Tasks due today ({len(rows)}):\n" + "\n".join(lines) # ── Task comment tools ──────────────────────────────────────────────── @tool async def list_task_comments(task_id: str) -> str: """List all comments on a task by its UUID.""" result = await execute_on_client( action="select", table="taskComments", filters={"taskId": task_id}, ) rows = result.get("rows", []) if not rows: return f"No comments found for task {task_id}." lines = [f"- [{r['author']}]: {r['content']} (id: {r['id']})" for r in rows] return f"Found {len(rows)} comment(s):\n" + "\n".join(lines) @tool async def add_task_comment(task_id: str, author: str, content: str) -> str: """Add a comment to a task. task_id: UUID of the task to comment on author: name or ID of the comment author content: comment text """ result = await execute_on_client( action="insert", table="taskComments", data={"taskId": task_id, "author": author, "content": content}, ) row = result.get("row", {}) row_author = row.get("author", author) row_task_id = row.get("taskId") or row.get("task_id") or task_id row_comment_id = row.get("id", "unknown") return f"Comment added by {row_author} on task {row_task_id} (comment id: {row_comment_id})." @tool async def delete_task_comment(comment_id: str) -> str: """Delete a task comment by its UUID.""" await execute_on_client(action="delete", table="taskComments", data={"id": comment_id}) return f"Comment {comment_id} deleted." # ── Agent ───────────────────────────────────────────────────────────── TASK_TOOLS: list[Any] = [ list_tasks, count_tasks, create_task, update_task, delete_task, list_tasks_due_today, list_task_comments, add_task_comment, delete_task_comment, ] TASK_READ_TOOLS: list[Any] = [ list_tasks, count_tasks, list_tasks_due_today, list_task_comments, ]