diff --git a/strix/agents/StrixAgent/system_prompt.jinja b/strix/agents/StrixAgent/system_prompt.jinja index 276a0fe..df5b64a 100644 --- a/strix/agents/StrixAgent/system_prompt.jinja +++ b/strix/agents/StrixAgent/system_prompt.jinja @@ -114,11 +114,13 @@ OPERATIONAL PRINCIPLES: TASK TRACKING: - USE THE TODO TOOL EXTENSIVELY - this is critical for staying organized and focused - Each subagent has their own INDEPENDENT todo list - your todos are private to you -- At the START of any task: Create todos to break down your work into clear steps +- KEEP THE LIST SHORT-HORIZON: track only the next few concrete steps (3-6 max), not long-term goals. +- REWRITE TODOS AS YOU LEARN: update, trim, or reprioritize the list whenever plans change or tasks finish. +- At the START of any task: Create todos to break down your next steps into clear actions - BEFORE starting a task: Mark it as "in_progress" - this shows what you're actively doing - AFTER completing a task: Mark it as "done" immediately - don't wait -- When you discover new tasks: Add them as todos right away -- ALWAYS follow this workflow: create → in_progress → done +- When you discover new tasks: Add them as todos right away and reprioritize; avoid dumping the whole project plan upfront +- ALWAYS follow this workflow: create → in_progress → done, iterating frequently - A well-maintained todo list prevents going in circles, forgetting tasks, and losing focus - If you're unsure what to do next: Check your todo list first diff --git a/strix/interface/tool_components/todo_renderer.py b/strix/interface/tool_components/todo_renderer.py index c5503b6..1a3b3a5 100644 --- a/strix/interface/tool_components/todo_renderer.py +++ b/strix/interface/tool_components/todo_renderer.py @@ -20,7 +20,7 @@ def _truncate(text: str, length: int = 80) -> str: def _format_todo_lines( - cls: type[BaseToolRenderer], result: dict[str, Any], limit: int = 10 + cls: type[BaseToolRenderer], result: dict[str, Any], limit: int = 25 ) -> list[str]: todos = result.get("todos") if not isinstance(todos, list) or not todos: @@ -67,7 +67,7 @@ class CreateTodoRenderer(BaseToolRenderer): if result and isinstance(result, dict): if result.get("success"): lines = [header] - lines.extend(_format_todo_lines(cls, result, limit=10)) + lines.extend(_format_todo_lines(cls, result)) content_text = "\n".join(lines) else: error = result.get("error", "Failed to create todo") @@ -92,7 +92,7 @@ class ListTodosRenderer(BaseToolRenderer): if result and isinstance(result, dict): if result.get("success"): lines = [header] - lines.extend(_format_todo_lines(cls, result, limit=10)) + lines.extend(_format_todo_lines(cls, result)) content_text = "\n".join(lines) else: error = result.get("error", "Unable to list todos") @@ -117,7 +117,7 @@ class UpdateTodoRenderer(BaseToolRenderer): if result and isinstance(result, dict): if result.get("success"): lines = [header] - lines.extend(_format_todo_lines(cls, result, limit=10)) + lines.extend(_format_todo_lines(cls, result)) content_text = "\n".join(lines) else: error = result.get("error", "Failed to update todo") @@ -142,7 +142,7 @@ class MarkTodoDoneRenderer(BaseToolRenderer): if result and isinstance(result, dict): if result.get("success"): lines = [header] - lines.extend(_format_todo_lines(cls, result, limit=10)) + lines.extend(_format_todo_lines(cls, result)) content_text = "\n".join(lines) else: error = result.get("error", "Failed to mark todo done") @@ -167,7 +167,7 @@ class MarkTodoPendingRenderer(BaseToolRenderer): if result and isinstance(result, dict): if result.get("success"): lines = [header] - lines.extend(_format_todo_lines(cls, result, limit=10)) + lines.extend(_format_todo_lines(cls, result)) content_text = "\n".join(lines) else: error = result.get("error", "Failed to reopen todo") @@ -192,7 +192,7 @@ class DeleteTodoRenderer(BaseToolRenderer): if result and isinstance(result, dict): if result.get("success"): lines = [header] - lines.extend(_format_todo_lines(cls, result, limit=10)) + lines.extend(_format_todo_lines(cls, result)) content_text = "\n".join(lines) else: error = result.get("error", "Failed to remove todo") diff --git a/strix/tools/todo/todo_actions.py b/strix/tools/todo/todo_actions.py index 4d9dfe2..60c084a 100644 --- a/strix/tools/todo/todo_actions.py +++ b/strix/tools/todo/todo_actions.py @@ -47,6 +47,70 @@ def _sorted_todos(agent_id: str) -> list[dict[str, Any]]: return todos_list +def _normalize_todo_ids(raw_ids: Any) -> list[str]: + if raw_ids is None: + return [] + + if isinstance(raw_ids, str): + stripped = raw_ids.strip() + if not stripped: + return [] + try: + data = json.loads(stripped) + except json.JSONDecodeError: + data = stripped.split(",") if "," in stripped else [stripped] + if isinstance(data, list): + return [str(item).strip() for item in data if str(item).strip()] + return [str(data).strip()] + + if isinstance(raw_ids, list): + return [str(item).strip() for item in raw_ids if str(item).strip()] + + return [str(raw_ids).strip()] + + +def _normalize_bulk_updates(raw_updates: Any) -> list[dict[str, Any]]: + if raw_updates is None: + return [] + + data = raw_updates + if isinstance(raw_updates, str): + stripped = raw_updates.strip() + if not stripped: + return [] + try: + data = json.loads(stripped) + except json.JSONDecodeError as e: + raise ValueError("Updates must be valid JSON") from e + + if isinstance(data, dict): + data = [data] + + if not isinstance(data, list): + raise TypeError("Updates must be a list of update objects") + + normalized: list[dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + raise TypeError("Each update must be an object with todo_id") + + todo_id = item.get("todo_id") or item.get("id") + if not todo_id: + raise ValueError("Each update must include 'todo_id'") + + normalized.append( + { + "todo_id": str(todo_id).strip(), + "title": item.get("title"), + "description": item.get("description"), + "priority": item.get("priority"), + "status": item.get("status"), + } + ) + + return normalized + + def _normalize_bulk_todos(raw_todos: Any) -> list[dict[str, Any]]: if raw_todos is None: return [] @@ -233,146 +297,272 @@ def list_todos( } -@register_tool(sandbox_execution=False) -def update_todo( - agent_state: Any, +def _apply_single_update( + agent_todos: dict[str, dict[str, Any]], todo_id: str, title: str | None = None, description: str | None = None, priority: str | None = None, status: str | None = None, +) -> dict[str, Any] | None: + if todo_id not in agent_todos: + return {"todo_id": todo_id, "error": f"Todo with ID '{todo_id}' not found"} + + todo = agent_todos[todo_id] + + if title is not None: + if not title.strip(): + return {"todo_id": todo_id, "error": "Title cannot be empty"} + todo["title"] = title.strip() + + if description is not None: + todo["description"] = description.strip() if description else None + + if priority is not None: + try: + todo["priority"] = _normalize_priority(priority, str(todo.get("priority", "normal"))) + except ValueError as exc: + return {"todo_id": todo_id, "error": str(exc)} + + if status is not None: + status_candidate = status.lower() + if status_candidate not in VALID_STATUSES: + return { + "todo_id": todo_id, + "error": f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}", + } + todo["status"] = status_candidate + if status_candidate == "done": + todo["completed_at"] = datetime.now(UTC).isoformat() + else: + todo["completed_at"] = None + + todo["updated_at"] = datetime.now(UTC).isoformat() + return None + + +@register_tool(sandbox_execution=False) +def update_todo( + agent_state: Any, + todo_id: str | None = None, + title: str | None = None, + description: str | None = None, + priority: str | None = None, + status: str | None = None, + updates: Any | None = None, ) -> dict[str, Any]: try: agent_id = agent_state.agent_id agent_todos = _get_agent_todos(agent_id) - if todo_id not in agent_todos: - return {"success": False, "error": f"Todo with ID '{todo_id}' not found"} + updates_to_apply: list[dict[str, Any]] = [] - todo = agent_todos[todo_id] + if updates is not None: + updates_to_apply.extend(_normalize_bulk_updates(updates)) - if title is not None: - if not title.strip(): - return {"success": False, "error": "Title cannot be empty"} - todo["title"] = title.strip() - - if description is not None: - todo["description"] = description.strip() if description else None - - if priority is not None: - try: - todo["priority"] = _normalize_priority( - priority, str(todo.get("priority", "normal")) - ) - except ValueError as exc: - return {"success": False, "error": str(exc)} - - if status is not None: - status_candidate = status.lower() - if status_candidate not in VALID_STATUSES: - return { - "success": False, - "error": f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}", + if todo_id is not None: + updates_to_apply.append( + { + "todo_id": todo_id, + "title": title, + "description": description, + "priority": priority, + "status": status, } - todo["status"] = status_candidate - if status_candidate == "done": - todo["completed_at"] = datetime.now(UTC).isoformat() - else: - todo["completed_at"] = None + ) - todo["updated_at"] = datetime.now(UTC).isoformat() + if not updates_to_apply: + return { + "success": False, + "error": "Provide todo_id or 'updates' list to update.", + } + + updated: list[str] = [] + errors: list[dict[str, Any]] = [] + + for update in updates_to_apply: + error = _apply_single_update( + agent_todos, + update["todo_id"], + update.get("title"), + update.get("description"), + update.get("priority"), + update.get("status"), + ) + if error: + errors.append(error) + else: + updated.append(update["todo_id"]) todos_list = _sorted_todos(agent_id) - return { - "success": True, + response: dict[str, Any] = { + "success": len(errors) == 0, + "updated": updated, + "updated_count": len(updated), "todos": todos_list, "total_count": len(todos_list), } + if errors: + response["errors"] = errors + except (ValueError, TypeError) as e: return {"success": False, "error": str(e)} + else: + return response @register_tool(sandbox_execution=False) def mark_todo_done( agent_state: Any, - todo_id: str, + todo_id: str | None = None, + todo_ids: Any | None = None, ) -> dict[str, Any]: try: agent_id = agent_state.agent_id agent_todos = _get_agent_todos(agent_id) - if todo_id not in agent_todos: - return {"success": False, "error": f"Todo with ID '{todo_id}' not found"} + ids_to_mark: list[str] = [] + if todo_ids is not None: + ids_to_mark.extend(_normalize_todo_ids(todo_ids)) + if todo_id is not None: + ids_to_mark.append(todo_id) - todo = agent_todos[todo_id] - todo["status"] = "done" - todo["completed_at"] = datetime.now(UTC).isoformat() - todo["updated_at"] = datetime.now(UTC).isoformat() + if not ids_to_mark: + return {"success": False, "error": "Provide todo_id or todo_ids to mark as done."} + + marked: list[str] = [] + errors: list[dict[str, Any]] = [] + timestamp = datetime.now(UTC).isoformat() + + for tid in ids_to_mark: + if tid not in agent_todos: + errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"}) + continue + + todo = agent_todos[tid] + todo["status"] = "done" + todo["completed_at"] = timestamp + todo["updated_at"] = timestamp + marked.append(tid) todos_list = _sorted_todos(agent_id) - return { - "success": True, + response: dict[str, Any] = { + "success": len(errors) == 0, + "marked_done": marked, + "marked_count": len(marked), "todos": todos_list, "total_count": len(todos_list), } + if errors: + response["errors"] = errors + except (ValueError, TypeError) as e: return {"success": False, "error": str(e)} + else: + return response @register_tool(sandbox_execution=False) def mark_todo_pending( agent_state: Any, - todo_id: str, + todo_id: str | None = None, + todo_ids: Any | None = None, ) -> dict[str, Any]: try: agent_id = agent_state.agent_id agent_todos = _get_agent_todos(agent_id) - if todo_id not in agent_todos: - return {"success": False, "error": f"Todo with ID '{todo_id}' not found"} + ids_to_mark: list[str] = [] + if todo_ids is not None: + ids_to_mark.extend(_normalize_todo_ids(todo_ids)) + if todo_id is not None: + ids_to_mark.append(todo_id) - todo = agent_todos[todo_id] - todo["status"] = "pending" - todo["completed_at"] = None - todo["updated_at"] = datetime.now(UTC).isoformat() + if not ids_to_mark: + return {"success": False, "error": "Provide todo_id or todo_ids to mark as pending."} + + marked: list[str] = [] + errors: list[dict[str, Any]] = [] + timestamp = datetime.now(UTC).isoformat() + + for tid in ids_to_mark: + if tid not in agent_todos: + errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"}) + continue + + todo = agent_todos[tid] + todo["status"] = "pending" + todo["completed_at"] = None + todo["updated_at"] = timestamp + marked.append(tid) todos_list = _sorted_todos(agent_id) - return { - "success": True, + response: dict[str, Any] = { + "success": len(errors) == 0, + "marked_pending": marked, + "marked_count": len(marked), "todos": todos_list, "total_count": len(todos_list), } + if errors: + response["errors"] = errors + except (ValueError, TypeError) as e: return {"success": False, "error": str(e)} + else: + return response @register_tool(sandbox_execution=False) def delete_todo( agent_state: Any, - todo_id: str, + todo_id: str | None = None, + todo_ids: Any | None = None, ) -> dict[str, Any]: try: agent_id = agent_state.agent_id agent_todos = _get_agent_todos(agent_id) - if todo_id not in agent_todos: - return {"success": False, "error": f"Todo with ID '{todo_id}' not found"} + ids_to_delete: list[str] = [] + if todo_ids is not None: + ids_to_delete.extend(_normalize_todo_ids(todo_ids)) + if todo_id is not None: + ids_to_delete.append(todo_id) - del agent_todos[todo_id] + if not ids_to_delete: + return {"success": False, "error": "Provide todo_id or todo_ids to delete."} + + deleted: list[str] = [] + errors: list[dict[str, Any]] = [] + + for tid in ids_to_delete: + if tid not in agent_todos: + errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"}) + continue + + del agent_todos[tid] + deleted.append(tid) todos_list = _sorted_todos(agent_id) - return { - "success": True, + response: dict[str, Any] = { + "success": len(errors) == 0, + "deleted": deleted, + "deleted_count": len(deleted), "todos": todos_list, "total_count": len(todos_list), } + if errors: + response["errors"] = errors + except (ValueError, TypeError) as e: return {"success": False, "error": str(e)} + else: + return response diff --git a/strix/tools/todo/todo_actions_schema.xml b/strix/tools/todo/todo_actions_schema.xml index 08091a4..254b5a9 100644 --- a/strix/tools/todo/todo_actions_schema.xml +++ b/strix/tools/todo/todo_actions_schema.xml @@ -6,10 +6,11 @@ do not interfere with other agents' todos. Use this to your advantage. WORKFLOW - Follow this for EVERY task: - 1. Create todos at the START to break down your work - 2. BEFORE starting a task: Mark it as "in_progress" using update_todo - 3. AFTER completing a task: Mark it as "done" using mark_todo_done - 4. When you discover new tasks: Add them as todos right away + 1. Keep the list short-horizon: track only the next few concrete steps (3-6 max), not long-term goals. + 2. Create/update todos as you learn new info; drop or rewrite items when plans change. + 3. BEFORE starting a task: Mark it as "in_progress" using update_todo. + 4. AFTER completing a task: Mark it as "done" using mark_todo_done. + 5. When you discover new tasks: Add them right away and re-prioritize; avoid giant upfront lists. ALWAYS mark the current task as in_progress before working on it. This shows what you're actively doing. Then mark it done when finished. Never skip these status updates. @@ -107,94 +108,122 @@ - Update an existing todo item's title, description, priority, or status. + Update one or multiple todo items. Supports bulk updates in a single call. - - ID of the todo to update + + ID of a single todo to update (for simple updates) + + + Bulk update multiple todos at once. JSON array of objects with todo_id and fields to update: [{"todo_id": "abc", "status": "done"}, {"todo_id": "def", "priority": "high"}] - New title for the todo + New title (used with todo_id) - New description for the todo + New description (used with todo_id) - New priority: "low", "normal", "high", "critical" + New priority: "low", "normal", "high", "critical" (used with todo_id) - New status: "pending", "in_progress", "done" + New status: "pending", "in_progress", "done" (used with todo_id) - Response containing: - success: Whether the update was successful + Response containing: - updated: List of updated todo IDs - updated_count: Number updated - todos: Full sorted todo list - errors: Any failed updates - # Update priority and add description - - abc123 - critical - Found potential RCE vector, needs immediate attention - - - # Mark as in progress + # Single update abc123 in_progress + + + # Bulk update - mark multiple todos with different statuses in ONE call + + [{"todo_id": "abc123", "status": "done"}, {"todo_id": "def456", "status": "in_progress"}, {"todo_id": "ghi789", "priority": "critical"}] - Mark a todo item as completed. DO THIS IMMEDIATELY after finishing a task. -
Mark todos as done right after completing them - don't wait! This keeps your list accurate, - helps track progress, and gives you a clear picture of what's been accomplished vs what remains.
+ Mark one or multiple todos as completed in a single call. DO THIS IMMEDIATELY after finishing tasks. +
Mark todos as done right after completing them - don't wait! Supports marking multiple todos at once + to save tool calls. This keeps your list accurate and gives you a clear picture of progress.
- - ID of the todo to mark as done + + ID of a single todo to mark as done + + + Mark multiple todos done at once. JSON array of IDs: ["abc123", "def456"] or comma-separated: "abc123, def456" - Response containing: - success: Whether the operation was successful + Response containing: - marked_done: List of IDs marked done - marked_count: Number marked - todos: Full sorted list - errors: Any failures + # Mark single todo done abc123 + + + # Mark multiple todos done in ONE call + + ["abc123", "def456", "ghi789"]
- Mark a todo item as pending (reopen a completed task). -
Use this to reopen a task that was marked done but needs more work.
+ Mark one or multiple todos as pending (reopen completed tasks). +
Use this to reopen tasks that were marked done but need more work. Supports bulk operations.
- - ID of the todo to mark as pending + + ID of a single todo to mark as pending + + + Mark multiple todos pending at once. JSON array of IDs: ["abc123", "def456"] or comma-separated: "abc123, def456" - Response containing: - success: Whether the operation was successful + Response containing: - marked_pending: List of IDs marked pending - marked_count: Number marked - todos: Full sorted list - errors: Any failures + # Mark single todo pending abc123 + + + # Mark multiple todos pending in ONE call + + ["abc123", "def456"]
- Delete a todo item. -
Use this to remove todos that are no longer relevant or were created by mistake.
+ Delete one or multiple todos in a single call. +
Use this to remove todos that are no longer relevant. Supports bulk deletion to save tool calls.
- - ID of the todo to delete + + ID of a single todo to delete + + + Delete multiple todos at once. JSON array of IDs: ["abc123", "def456"] or comma-separated: "abc123, def456" - Response containing: - success: Whether the deletion was successful + Response containing: - deleted: List of deleted IDs - deleted_count: Number deleted - todos: Remaining todos - errors: Any failures + # Delete single todo abc123 + + + # Delete multiple todos in ONE call + + ["abc123", "def456", "ghi789"]