流式渲染修复: - runId 机制防止 abort 后旧流事件污染新 run - run lock 防止并发 run 污染引擎状态 - abort race 提前退出工具执行等待 - TERMINATED 状态通过 stateChange 发射 - tool_call_delta 流式参数拼接 + pending 占位替换 - 首轮卡片创建路径统一,traceStep 按 ID 精确匹配 - compressed 事件转发为 toast 通知 安全增强: - ConfirmationHook 支持持久化自动执行(跨会话) - 设置面板新增自动执行工具管理 UI - SandboxManager 双重安全校验 fail-closed - 审计日志链式哈希防篡改 - PromptInjectionDefender 中文注入标记清理 - scanCode 28 模式 + base64/$() 检测 - validatePath realpathSync 防符号链接逃逸 - code-search 使用 execFile 防命令注入 新增工具: - file_editor、code_search、task_manager、diff_viewer 其他: - Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试 - MemoryManager TF-IDF 语义检索 - run_command Windows 中文编码修复(chcp 65001) - 版本号 0.2.0 → 0.2.1
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
/**
|
|
* 任务管理工具(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<string, unknown>): 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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
|
|
return { success: true, task: mapRow(updated) };
|
|
}
|
|
|
|
private listTasks(args: Record<string, unknown>, 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<Record<string, unknown>>;
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> | 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<Record<string, unknown>>;
|
|
|
|
return {
|
|
success: true,
|
|
task: mapRow(row),
|
|
subtasks: subtasks.map(mapRow),
|
|
};
|
|
}
|
|
}
|