/** * 任务管理工具(1 个) * * task_manager — 创建/更新/列出/完成任务 * * 支持 Agent 在执行复杂任务时将任务分解为子任务并跟踪状态。 * 任务持久化到 SQLite tasks 表。 * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html */ import { nanoid } from 'nanoid'; import type Database from 'better-sqlite3'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled'; export type TaskPriority = 'low' | 'medium' | 'high' | 'critical'; export interface Task { id: string; sessionId: string; title: string; description: string; status: TaskStatus; priority: TaskPriority; parentId: string | null; assignedTo: string | null; order: number; createdAt: number; updatedAt: number; completedAt: number | null; } /** 从数据库行映射到 Task 对象 */ function mapRow(row: Record): Task { return { id: row.id as string, sessionId: row.session_id as string, title: row.title as string, description: row.description as string, status: row.status as TaskStatus, priority: row.priority as TaskPriority, parentId: (row.parent_id as string) ?? null, assignedTo: (row.assigned_to as string) ?? null, order: row.order_idx as number, createdAt: row.created_at as number, updatedAt: row.updated_at as number, completedAt: (row.completed_at as number) ?? null, }; } // T3.5: 合法枚举值 const VALID_STATUSES: TaskStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled']; const VALID_PRIORITIES: TaskPriority[] = ['low', 'medium', 'high', 'critical']; export class TaskManagerTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'task_manager', description: 'Manage tasks for complex multi-step work. Supports operations: create, update, list, complete, delete. Tasks are persisted per session and can have parent-child relationships.', parameters: { type: 'object', properties: { operation: { type: 'string', description: 'Task operation', enum: ['create', 'update', 'list', 'complete', 'delete', 'get'], }, title: { type: 'string', description: 'Task title (for create)' }, description: { type: 'string', description: 'Task description (for create/update)' }, task_id: { type: 'string', description: 'Task ID (for update/complete/delete/get)' }, status: { type: 'string', description: 'New status (for update)', enum: ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'] }, priority: { type: 'string', description: 'Priority (for create/update)', enum: ['low', 'medium', 'high', 'critical'] }, parent_id: { type: 'string', description: 'Parent task ID (for create, establishes subtask relationship)' }, }, required: ['operation'], }, category: MetonaToolCategory.DATABASE, riskLevel: MetonaRiskLevel.LOW, requiresPermission: false, timeoutMs: 10_000, }; constructor(private getDB: () => Database.Database) {} async execute(args: Record, context: ToolExecutionContext): Promise { try { const operation = args.operation as string; switch (operation) { case 'create': return this.createTask(args, context); case 'update': return this.updateTask(args, context); case 'list': return this.listTasks(args, context); case 'complete': return this.completeTask(args, context); case 'delete': return this.deleteTask(args, context); case 'get': return this.getTask(args, context); default: return { success: false, error: `Unknown operation: ${operation}` }; } } catch (err) { return { success: false, error: (err as Error).message }; } } private createTask(args: Record, context: ToolExecutionContext): unknown { const db = this.getDB(); const id = `task_${nanoid(12)}`; const now = Date.now(); const sessionId = context.sessionId; const title = args.title as string; const description = (args.description as string) ?? ''; const priority = (args.priority as TaskPriority) ?? 'medium'; const parentId = (args.parent_id as string) ?? null; if (!title) { return { success: false, error: 'title is required for create operation' }; } // T3.5: 校验 priority 枚举值 if (!VALID_PRIORITIES.includes(priority)) { return { success: false, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; } // 获取同 session 同 parent 下的最大 order const maxOrderRow = db.prepare( 'SELECT MAX(order_idx) as max_order FROM tasks WHERE session_id = ? AND parent_id IS ?', ).get(sessionId, parentId) as { max_order: number | null } | undefined; const order = (maxOrderRow?.max_order ?? -1) + 1; db.prepare(` INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, assigned_to, order_idx, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now); return { success: true, task: { id, sessionId, title, description, status: 'pending', priority, parentId, assignedTo: null, order, createdAt: now, updatedAt: now, completedAt: null, }, }; } private updateTask(args: Record, context: ToolExecutionContext): unknown { const db = this.getDB(); const taskId = args.task_id as string; if (!taskId) { return { success: false, error: 'task_id is required for update operation' }; } // T3.1: 校验任务属于当前会话 const existing = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId); if (!existing) { return { success: false, error: `Task not found: ${taskId}` }; } // T3.5: 校验枚举值 if (args.status !== undefined && !VALID_STATUSES.includes(args.status as TaskStatus)) { return { success: false, error: `Invalid status: ${args.status}. Must be one of: ${VALID_STATUSES.join(', ')}` }; } if (args.priority !== undefined && !VALID_PRIORITIES.includes(args.priority as TaskPriority)) { return { success: false, error: `Invalid priority: ${args.priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; } const updates: string[] = []; const values: unknown[] = []; if (args.title !== undefined) { updates.push('title = ?'); values.push(args.title); } if (args.description !== undefined) { updates.push('description = ?'); values.push(args.description); } if (args.status !== undefined) { updates.push('status = ?'); values.push(args.status); if (args.status === 'completed') { updates.push('completed_at = ?'); values.push(Date.now()); } } if (args.priority !== undefined) { updates.push('priority = ?'); values.push(args.priority); } if (updates.length === 0) { return { success: false, error: 'No fields to update' }; } updates.push('updated_at = ?'); values.push(Date.now()); values.push(taskId); values.push(context.sessionId); // T3.1: UPDATE 语句加 AND session_id = ? 防止越权 db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values); const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record; return { success: true, task: mapRow(updated) }; } private listTasks(args: Record, context: ToolExecutionContext): unknown { const db = this.getDB(); const sessionId = context.sessionId; const status = args.status as string | undefined; let sql = 'SELECT * FROM tasks WHERE session_id = ?'; const values: unknown[] = [sessionId]; if (status) { sql += ' AND status = ?'; values.push(status); } sql += ' ORDER BY order_idx ASC, created_at ASC'; const rows = db.prepare(sql).all(...values) as Array>; const tasks = rows.map(mapRow); return { success: true, tasks, count: tasks.length, by_status: { pending: tasks.filter((t) => t.status === 'pending').length, in_progress: tasks.filter((t) => t.status === 'in_progress').length, completed: tasks.filter((t) => t.status === 'completed').length, blocked: tasks.filter((t) => t.status === 'blocked').length, cancelled: tasks.filter((t) => t.status === 'cancelled').length, }, }; } private completeTask(args: Record, context: ToolExecutionContext): unknown { const db = this.getDB(); const taskId = args.task_id as string; if (!taskId) { return { success: false, error: 'task_id is required for complete operation' }; } // T3.1: 校验任务属于当前会话 const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId); if (!existing) { return { success: false, error: `Task not found: ${taskId}` }; } const now = Date.now(); // T3.1: UPDATE 语句加 AND session_id = ? 防止越权 const result = db.prepare( 'UPDATE tasks SET status = ?, completed_at = ?, updated_at = ? WHERE id = ? AND session_id = ?', ).run('completed', now, now, taskId, context.sessionId); if (result.changes === 0) { return { success: false, error: `Task not found: ${taskId}` }; } return { success: true, task_id: taskId, completed_at: now }; } private deleteTask(args: Record, context: ToolExecutionContext): unknown { const db = this.getDB(); const taskId = args.task_id as string; if (!taskId) { return { success: false, error: 'task_id is required for delete operation' }; } // T3.1: 校验任务属于当前会话 const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId); if (!existing) { return { success: false, error: `Task not found: ${taskId}` }; } // T3.2/T3.3: 递归 CTE 查出所有后代任务 ID const descendantIds = db.prepare(` WITH RECURSIVE descendants AS ( SELECT id FROM tasks WHERE id = ? UNION ALL SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id ) SELECT id FROM descendants `).all(taskId) as Array<{ id: string }>; const allIds = descendantIds.map((r) => r.id); // 事务包裹批量删除 const deleteMany = db.transaction(() => { const placeholders = allIds.map(() => '?').join(','); db.prepare(`DELETE FROM tasks WHERE id IN (${placeholders})`).run(...allIds); }); deleteMany(); return { success: true, task_id: taskId, deleted: allIds.length }; } private getTask(args: Record, context: ToolExecutionContext): unknown { const db = this.getDB(); const taskId = args.task_id as string; if (!taskId) { return { success: false, error: 'task_id is required for get operation' }; } // T3.1: 校验任务属于当前会话 const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record | undefined; if (!row) { return { success: false, error: `Task not found: ${taskId}` }; } // 获取子任务(同样限制在当前会话内) const subtasks = db.prepare('SELECT * FROM tasks WHERE parent_id = ? AND session_id = ? ORDER BY order_idx ASC').all(taskId, context.sessionId) as Array>; return { success: true, task: mapRow(row), subtasks: subtasks.map(mapRow), }; } }