persistence-tools.js 11 KB
/**
 * 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,
};