## 主要变更 ### 1. Context Window 可配置化(v0.3.1 延续) - DeepSeek/Agnes contextWindow 不再写死 1M,可在设置中配置(min 4096) - 修复 Engine 128K vs Adapter 1M 不一致 bug,Engine 从 adapter.getContextWindow() 读取 - 热重载 configSig 加入 contextWindow,配置变化即时生效 ### 2. 新增 11 个工具(15 → 26 个) - Git 工具集(4):git_status, git_diff, git_log, git_commit - 开发工具集(3):lint_code, run_tests, project_info - HTTP 请求(1):http_request(Node 18+ fetch + AbortController) - TODO 管理(1):todo_write(会话级内存 + LRU 淘汰) - 结构化思考(1):think(无副作用思考空间) - 图片查看(1):view_image(base64 data URL,多模态 LLM 支持) ### 3. 审计修复(2 FAIL + 14 WARN) - FAIL-1: registry.ts truncateResult 添加 dataUrl 白名单(图片不被截断) - FAIL-2: todo.ts 添加 LRU 策略 + clearSession 静态方法 - WARN-1: run_tests filter 字符白名单校验,防 cmd 元字符注入 - WARN-2: dataUrl 检测前置到 stringify 之前,避免大图片无意义序列化 - WARN-3: todo.ts 实现真正 LRU(访问刷新位置,非 FIFO) - WARN-4: git_diff maxBuffer 提升至 5MB,支持超大变更集 - WARN-5: log.info 移至 DelegateTaskTool 注册后,输出正确的 26 - WARN-6: riskColors 添加 critical: 'error' 键 - WARN-7: 所有 execFileAsync 显式设置 encoding: 'utf-8' - WARN-8: git_log --author 拆分为独立参数 - WARN-9: todo_write 权限从 WRITE 改为 READ - WARN-10: description 区分与 task_manager 的不同用途 ### 4. PolicyEngine 策略 - 新增 11 条策略,26 个工具 + mcp_* 全覆盖 - git_commit: WRITE + requireConfirmation - todo_write: READ(内存操作)
245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
/**
|
|
* TODO 管理工具(1 个)
|
|
*
|
|
* todo_write — 会话级 TODO 列表管理
|
|
*
|
|
* 使用内存 Map 存储,按 sessionId 隔离。
|
|
* 与 task_manager 区别:todo_write 用于临时思考拆解(内存,会话级),
|
|
* task_manager 用于持久化任务跟踪(数据库,跨会话)。
|
|
*
|
|
* v0.3.1 修复 FAIL-2: 添加 LRU 策略,限制最大会话数 50,
|
|
* 超出时淘汰最久未访问的会话。提供 clearSession 静态方法供外部清理。
|
|
*
|
|
* v0.3.1 修复 WARN-3: 真正的 LRU 实现 — 访问命中时通过 delete + re-insert
|
|
* 刷新会话位置,确保活跃会话不被淘汰,仅淘汰最久未访问的会话。
|
|
*/
|
|
|
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
|
import type { MetonaToolDef } from '../../../harness/types';
|
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
|
import log from 'electron-log';
|
|
|
|
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
|
type TodoPriority = 'high' | 'medium' | 'low';
|
|
|
|
interface TodoItem {
|
|
id: string;
|
|
content: string;
|
|
status: TodoStatus;
|
|
priority: TodoPriority;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
const VALID_STATUSES: TodoStatus[] = ['pending', 'in_progress', 'completed'];
|
|
const VALID_PRIORITIES: TodoPriority[] = ['high', 'medium', 'low'];
|
|
|
|
// v0.3.1 修复 FAIL-2: LRU 策略限制最大会话数,防止内存泄漏
|
|
const MAX_SESSIONS = 50;
|
|
|
|
export class TodoWriteTool implements IMetonaTool {
|
|
readonly definition: MetonaToolDef = {
|
|
name: 'todo_write',
|
|
description: 'Manage a session-level TODO list in memory. Actions: create, update, list, complete, clear. Unlike task_manager (persistent DB), todo_write is for temporary task breakdown within a session and does not persist across sessions.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
action: {
|
|
type: 'string',
|
|
description: 'Operation type',
|
|
enum: ['create', 'update', 'list', 'complete', 'clear'],
|
|
},
|
|
id: { type: 'string', description: 'TODO item ID (required for update/complete)' },
|
|
content: { type: 'string', description: 'TODO content (required for create/update)' },
|
|
status: {
|
|
type: 'string',
|
|
description: 'Status (for update)',
|
|
enum: ['pending', 'in_progress', 'completed'],
|
|
},
|
|
priority: {
|
|
type: 'string',
|
|
description: 'Priority (for create, default medium)',
|
|
enum: ['high', 'medium', 'low'],
|
|
},
|
|
},
|
|
required: ['action'],
|
|
},
|
|
// v0.3.1 修复 WARN-9: 改为 CUSTOM(内存操作,非数据库)
|
|
category: MetonaToolCategory.CUSTOM,
|
|
riskLevel: MetonaRiskLevel.SAFE,
|
|
requiresPermission: false,
|
|
timeoutMs: 5_000,
|
|
};
|
|
|
|
// v0.3.1 修复 FAIL-2: 会话级 TODO 存储 + LRU 淘汰
|
|
private static todosBySession = new Map<string, Map<string, TodoItem>>();
|
|
private static counter = 0;
|
|
|
|
/**
|
|
* v0.3.1 修复 FAIL-2: 清理指定会话的 TODO(供外部调用,如会话结束时)
|
|
*/
|
|
static clearSession(sessionId: string): void {
|
|
const removed = TodoWriteTool.todosBySession.delete(sessionId);
|
|
if (removed) {
|
|
log.debug(`[TodoWriteTool] Cleared TODOs for session ${sessionId}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v0.3.1 修复 FAIL-2: LRU 淘汰 — 超过 MAX_SESSIONS 时删除最久未访问的会话
|
|
* Map 的迭代顺序按插入顺序,第一个即最久未访问
|
|
*
|
|
* v0.3.1 修复 WARN-3: 配合 getSessionMap 的访问刷新位置,实现真正的 LRU。
|
|
* 活跃会话因访问而刷新到 Map 末尾,不会被淘汰。
|
|
*/
|
|
private static evictIfFull(): void {
|
|
while (TodoWriteTool.todosBySession.size >= MAX_SESSIONS) {
|
|
const oldest = TodoWriteTool.todosBySession.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
TodoWriteTool.todosBySession.delete(oldest);
|
|
log.debug(`[TodoWriteTool] LRU evicted session ${oldest}`);
|
|
}
|
|
}
|
|
|
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
|
try {
|
|
const action = args.action as string;
|
|
|
|
switch (action) {
|
|
case 'create':
|
|
return this.create(args, context);
|
|
case 'update':
|
|
return this.update(args, context);
|
|
case 'list':
|
|
return this.list(context);
|
|
case 'complete':
|
|
return this.complete(args, context);
|
|
case 'clear':
|
|
return this.clear(context);
|
|
default:
|
|
return { success: false, error: `Unknown action: ${action}` };
|
|
}
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
return { error: errMsg, success: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取指定会话的 TODO Map
|
|
*
|
|
* v0.3.1 修复 WARN-3: 真正的 LRU — 命中已有会话时通过 delete + re-insert
|
|
* 将会话移到 Map 末尾(最近使用位置),防止活跃会话被淘汰。
|
|
* 新会话插入前触发 evictIfFull。
|
|
*/
|
|
private getSessionMap(context: ToolExecutionContext): Map<string, TodoItem> {
|
|
const existing = TodoWriteTool.todosBySession.get(context.sessionId);
|
|
if (existing) {
|
|
// LRU 刷新位置:delete + re-insert → 移到 Map 末尾
|
|
TodoWriteTool.todosBySession.delete(context.sessionId);
|
|
TodoWriteTool.todosBySession.set(context.sessionId, existing);
|
|
return existing;
|
|
}
|
|
// 新会话插入前检查 LRU 淘汰
|
|
TodoWriteTool.evictIfFull();
|
|
const map = new Map<string, TodoItem>();
|
|
TodoWriteTool.todosBySession.set(context.sessionId, map);
|
|
return map;
|
|
}
|
|
|
|
private create(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
|
const content = args.content as string;
|
|
if (!content) {
|
|
return { success: false, id: '', todo: null, error: 'content is required for create action' };
|
|
}
|
|
|
|
const priority = (args.priority as TodoPriority) ?? 'medium';
|
|
if (!VALID_PRIORITIES.includes(priority)) {
|
|
return { success: false, id: '', todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
|
}
|
|
|
|
const now = Date.now();
|
|
const id = `todo_${now}_${TodoWriteTool.counter++}`;
|
|
const todo: TodoItem = {
|
|
id,
|
|
content,
|
|
status: 'pending',
|
|
priority,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
this.getSessionMap(context).set(id, todo);
|
|
|
|
return { success: true, id, todo };
|
|
}
|
|
|
|
private update(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
|
const id = args.id as string;
|
|
if (!id) {
|
|
return { success: false, id: '', todo: null, error: 'id is required for update action' };
|
|
}
|
|
|
|
const map = this.getSessionMap(context);
|
|
const todo = map.get(id);
|
|
if (!todo) {
|
|
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
|
|
}
|
|
|
|
if (args.content !== undefined) {
|
|
todo.content = args.content as string;
|
|
}
|
|
if (args.status !== undefined) {
|
|
const status = args.status as TodoStatus;
|
|
if (!VALID_STATUSES.includes(status)) {
|
|
return { success: false, id, todo: null, error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}` };
|
|
}
|
|
todo.status = status;
|
|
}
|
|
if (args.priority !== undefined) {
|
|
const priority = args.priority as TodoPriority;
|
|
if (!VALID_PRIORITIES.includes(priority)) {
|
|
return { success: false, id, todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
|
}
|
|
todo.priority = priority;
|
|
}
|
|
todo.updatedAt = Date.now();
|
|
|
|
return { success: true, id, todo };
|
|
}
|
|
|
|
private list(context: ToolExecutionContext): unknown {
|
|
const map = this.getSessionMap(context);
|
|
const todos = Array.from(map.values()).sort((a, b) => a.createdAt - b.createdAt);
|
|
return {
|
|
todos,
|
|
count: todos.length,
|
|
pending: todos.filter((t) => t.status === 'pending').length,
|
|
completed: todos.filter((t) => t.status === 'completed').length,
|
|
};
|
|
}
|
|
|
|
private complete(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
|
const id = args.id as string;
|
|
if (!id) {
|
|
return { success: false, id: '', todo: null, error: 'id is required for complete action' };
|
|
}
|
|
|
|
const map = this.getSessionMap(context);
|
|
const todo = map.get(id);
|
|
if (!todo) {
|
|
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
|
|
}
|
|
|
|
todo.status = 'completed';
|
|
todo.updatedAt = Date.now();
|
|
return { success: true, id, todo };
|
|
}
|
|
|
|
private clear(context: ToolExecutionContext): unknown {
|
|
const map = this.getSessionMap(context);
|
|
const cleared = map.size;
|
|
map.clear();
|
|
return { success: true, cleared };
|
|
}
|
|
}
|