/** * DevLoop · persistence-tools * * 持久化 5 件套 (M-10/11/12/13 接入): * - write_todolist 初始化 agent-state/{agent}-todo.json * - update_todo_item 更新单条 item + 同步 progress_pct * - update_runtime_state 修 agent-state/{agent}.json (current_step/total_steps) * - timeline_append append timeline.ndjson * - record_memory 写 memory/devloop/{agent}/ 下 .md * * 所有 Agent 都能用(toolPolicy 里写)。 * 每个工具签名: { name, description, inputSchema, execute(input, ctx) } * * 规格: docs/prd/newfunction/_spec/sdd-control.md · C-15 * docs/prd/newfunction/_spec/sdd-model.md · M-10/11/12/13 * docs/prd/newfunction/_spec/arch-tool-registry.md · §③ */ const fs = require('fs'); const path = require('path'); const pathResolver = require('../../../services/devloop/path-resolver'); const timelineLogger = require('../../../services/devloop/timeline-logger'); const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); // ──────────────────────────────────────────────────────────────── // write_todolist // ──────────────────────────────────────────────────────────────── const write_todolist = { name: 'write_todolist', description: '初始化 agent-state/{agent}-todo.json,写入完整 todolist。Agent 开工第一件事。', inputSchema: { type: 'object', required: ['items'], properties: { items: { type: 'array', minItems: 1, items: { type: 'object', required: ['id', 'description'], properties: { id: { type: 'string', pattern: '^step-\\d{3}$' }, description: { type: 'string' }, depends_on: { type: 'array', items: { type: 'string' } }, }, }, }, }, }, async execute({ items }, ctx) { _requireCtx(ctx, ['pid', 'agent', 'call_id']); const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); const todolist = { agent: ctx.agent, call_id: ctx.call_id, created_at: new Date().toISOString(), items: items.map((it, i) => ({ id: it.id, description: it.description, depends_on: it.depends_on || [], status: i === 0 ? 'doing' : 'todo', ...(i === 0 ? { started_at: new Date().toISOString() } : {}), })), current: items[0]?.id || null, progress_pct: 0, }; fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); if (ctx.sseBroker) { ctx.sseBroker.emit(ctx.pid, 'agent.todolist', { agent: ctx.agent, call_id: ctx.call_id, items: todolist.items, }); } return { ok: true, total: items.length, path: p }; }, }; // ──────────────────────────────────────────────────────────────── // update_todo_item // ──────────────────────────────────────────────────────────────── const update_todo_item = { name: 'update_todo_item', description: '更新 todolist 中单条 item 的 status;自动重算 progress_pct + 发 SSE。', inputSchema: { type: 'object', required: ['id', 'status'], properties: { id: { type: 'string', pattern: '^step-\\d{3}$' }, status: { type: 'string', enum: ['todo', 'doing', 'done', 'failed'], }, output_ref: { type: 'string' }, error: { type: 'string' }, }, }, async execute({ id, status, output_ref, error }, ctx) { _requireCtx(ctx, ['pid', 'agent']); const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); if (!fs.existsSync(p)) { throw new ToolValidationError('todolist', `not found at ${p} (call write_todolist first)`); } const todolist = JSON.parse(fs.readFileSync(p, 'utf8')); const item = todolist.items.find(it => it.id === id); if (!item) { throw new ToolValidationError('id', `unknown step: ${id}`); } const from = item.status; item.status = status; if (status === 'doing' && !item.started_at) { item.started_at = new Date().toISOString(); } if (status === 'done' || status === 'failed') { item.finished_at = new Date().toISOString(); if (output_ref) item.output_ref = output_ref; if (error) item.error = error; } // 重算 progress_pct const done = todolist.items.filter(it => it.status === 'done').length; todolist.progress_pct = Math.round((done / todolist.items.length) * 100); // 找下一个 todo 设为 doing if (status === 'done') { const next = todolist.items.find(it => it.status === 'todo'); if (next) { next.status = 'doing'; next.started_at = new Date().toISOString(); todolist.current = next.id; } else { todolist.current = null; } } fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); if (ctx.sseBroker) { ctx.sseBroker.emit(ctx.pid, 'agent.step', { agent: ctx.agent, call_id: ctx.call_id || todolist.call_id, item_id: id, from, to: status, progress_pct: todolist.progress_pct, current_desc: todolist.items.find(it => it.id === todolist.current)?.description, }); } return { ok: true, progress_pct: todolist.progress_pct }; }, }; // ──────────────────────────────────────────────────────────────── // update_runtime_state // ──────────────────────────────────────────────────────────────── const update_runtime_state = { name: 'update_runtime_state', description: '更新 M-10 AgentRuntimeState 的 current_step / total_steps(不改 status)。', inputSchema: { type: 'object', required: ['current_step', 'total_steps'], properties: { current_step: { type: 'integer', minimum: 0 }, total_steps: { type: 'integer', minimum: 1 }, }, }, async execute({ current_step, total_steps }, ctx) { _requireCtx(ctx, ['pid', 'agent']); const p = pathResolver.resolveAgentState(ctx.pid, ctx.agent); if (!fs.existsSync(p)) { throw new ToolValidationError('state', `runtime state not found at ${p}`); } const state = JSON.parse(fs.readFileSync(p, 'utf8')); state.current_step = current_step; state.total_steps = total_steps; state.updated_at = new Date().toISOString(); fs.writeFileSync(p, JSON.stringify(state, null, 2)); return { ok: true, current_step, total_steps }; }, }; // ──────────────────────────────────────────────────────────────── // timeline_append // ──────────────────────────────────────────────────────────────── const timeline_append = { name: 'timeline_append', description: 'Append 一条事件到 timeline.ndjson (M-13)。', inputSchema: { type: 'object', required: ['kind', 'summary'], properties: { kind: { type: 'string' }, ref: { type: 'string' }, summary: { type: 'string', maxLength: 280 }, payload: { type: 'object' }, }, }, async execute({ kind, ref, summary, payload }, ctx) { _requireCtx(ctx, ['pid', 'agent']); const ev = timelineLogger.append(ctx.pid, { source: ctx.agent, kind, ref, summary, payload, }); return { ok: true, ts: ev.ts }; }, }; // ──────────────────────────────────────────────────────────────── // record_memory // ──────────────────────────────────────────────────────────────── const record_memory = { name: 'record_memory', description: '在 memory/devloop/{agent}/ 下写一条 .md (M-11)。同 topic 是 upsert。', inputSchema: { type: 'object', required: ['topic', 'content', 'relevance'], properties: { topic: { type: 'string', minLength: 2 }, content: { type: 'string', minLength: 10 }, relevance: { type: 'number', minimum: 0, maximum: 1 }, }, }, async execute({ topic, content, relevance }, ctx) { _requireCtx(ctx, ['agent', 'company_id']); const dir = pathResolver.resolveMemoryDir(ctx.company_id, ctx.agent); fs.mkdirSync(dir, { recursive: true }); const slug = topic.toLowerCase() .replace(/[^a-z0-9一-龥]+/g, '-') .replace(/^-+|-+$/g, ''); const p = path.join(dir, `${slug}.md`); let existing = null; if (fs.existsSync(p)) { const raw = fs.readFileSync(p, 'utf8'); const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (m) { const fm = {}; for (const line of m[1].split('\n')) { const kv = line.match(/^(\w+):\s*(.*)$/); if (kv) fm[kv[1]] = kv[2].trim(); } existing = { fm, body: m[2] }; } } const projects = existing?.fm?.project_refs ? existing.fm.project_refs.replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean) : []; if (ctx.pid && !projects.includes(ctx.pid)) projects.push(ctx.pid); const hits = existing?.fm?.hits ? (parseInt(existing.fm.hits, 10) + 1) : 1; const frontmatter = [ '---', `agent: ${ctx.agent}`, `topic: ${topic}`, `project_refs: [${projects.join(', ')}]`, `last_updated: ${new Date().toISOString()}`, `relevance_score: ${relevance}`, `hits: ${hits}`, '---', '', ].join('\n'); fs.writeFileSync(p, frontmatter + content + '\n'); return { ok: true, path: p, upserted: !!existing, hits }; }, }; // ──────────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────────── function _requireCtx(ctx, keys) { for (const k of keys) { if (ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); } } module.exports = { write_todolist, update_todo_item, update_runtime_state, timeline_append, record_memory, };