feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复: - 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
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 任务管理工具(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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user