import { resolve, join } from 'path'; import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'fs'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; const BUILTIN_PIECES_DIR = resolve(process.cwd(), 'pieces'); const VALID_NAME = /^[a-z0-9-]+$/; /** Normalise `string | string[] | undefined` → `string[]` (may be empty). */ function toCustomDirs(customDir: string | string[] | undefined): string[] { if (!customDir) return []; return Array.isArray(customDir) ? customDir : [customDir]; } /** * Search all custom dirs in order, then fall back to built-in. * Returns the first path that exists, or null. */ function findPiecePath(name: string, customDir: string | string[] | undefined): string | null { for (const d of toCustomDirs(customDir)) { const p = join(d, `${name}.yaml`); if (existsSync(p)) return p; } const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`); if (existsSync(builtinPath)) return builtinPath; return null; } /** * A piece is "built-in" when it lives only under the bundled BUILTIN_PIECES_DIR * (no override in any custom dir). Built-ins are git-tracked and shipped with the * app — letting the LLM rewrite them in place corrupts the install (a real * incident: the agent silently replaced game-tweet-generator with a version * missing max_movements, making every subsequent run abort instantly). The * LLM should use CreatePiece with a new name to derive a customized variant * instead. */ function isBuiltinOnly(name: string, customDir: string | string[] | undefined): boolean { for (const d of toCustomDirs(customDir)) { if (existsSync(join(d, `${name}.yaml`))) return false; } const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`); return existsSync(builtinPath); } // --- Validation (same logic as pieces-api.ts) --- function validatePiece(piece: any): string | null { if (!piece.name || !VALID_NAME.test(piece.name)) return 'name must be lowercase alphanumeric with hyphens'; if (!piece.description) return 'description is required'; if (!Array.isArray(piece.movements) || piece.movements.length === 0) return 'movements must be non-empty array'; if (!piece.initial_movement) return 'initial_movement is required'; // Required so the runtime loop has a hard ceiling. Without this a // forgotten/0/garbage value makes `while (steps < piece.max_movements)` // false on the first iteration → the run aborts immediately with // "Exceeded max movements (undefined)". if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) { return 'max_movements is required (positive integer, e.g. 50 for short tasks, 999 for open-ended ones)'; } const names = new Set(piece.movements.map((m: any) => m.name)); if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement'; // Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS. // Terminal moves (COMPLETE/ABORT/ASK) go through the `complete` tool now. // default_next is engine-internal and still accepts COMPLETE/ABORT/ASK. const validRuleNexts = new Set([...names, 'WAIT_SUBTASKS']); const validDefaultNexts = new Set([...names, 'COMPLETE', 'ABORT', 'ASK', 'WAIT_SUBTASKS']); for (const m of piece.movements) { if (!m.name) return 'each movement must have a name'; if (m.default_next && !validDefaultNexts.has(m.default_next)) { return `movement "${m.name}": default_next "${m.default_next}" is invalid`; } if (Array.isArray(m.rules)) { for (const r of m.rules) { if (!validRuleNexts.has(r.next)) { if (r.next === 'COMPLETE' || r.next === 'ABORT' || r.next === 'ASK') { return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves)`; } return `movement "${m.name}": rule next "${r.next}" is invalid`; } } } } return null; } // --- Tool definitions --- const LIST_PIECES_DEF: ToolDef = { type: 'function', function: { name: 'ListPieces', description: '全 Piece(実行テンプレート: ツール制限・movement フロー制御)の一覧を取得する。Skill(参照知識)の一覧は ListSkills を使うこと。新規作成前に必ず実行。詳細は ReadToolDoc({ name: "ListPieces" })。', parameters: { type: 'object', properties: {}, required: [], }, }, }; const GET_PIECE_DEF: ToolDef = { type: 'function', function: { name: 'GetPiece', description: '指定 Piece(実行テンプレート)の完全な YAML 定義を取得する。Skill の全文取得には ReadSkill を使うこと。詳細は ReadToolDoc({ name: "GetPiece" })。', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Piece 名(例: chat, general, research)' }, }, required: ['name'], }, }, }; const CREATE_PIECE_DEF: ToolDef = { type: 'function', function: { name: 'CreatePiece', description: '新 Piece(実行テンプレート: movement + allowed_tools を定義)を YAML から作成する。Skill の追加には InstallSkill を使うこと。詳細は ReadToolDoc({ name: "CreatePiece" })。', parameters: { type: 'object', properties: { yaml_content: { type: 'string', description: 'Piece の完全な YAML 定義。name, description, initial_movement, movements を含むこと。', }, }, required: ['yaml_content'], }, }, }; const UPDATE_PIECE_DEF: ToolDef = { type: 'function', function: { name: 'UpdatePiece', description: '既存 Piece を完全な YAML で全体置換する(差分更新ではない)。詳細は ReadToolDoc({ name: "UpdatePiece" })。', parameters: { type: 'object', properties: { name: { type: 'string', description: '更新対象の Piece 名' }, yaml_content: { type: 'string', description: '更新後の完全な YAML 定義', }, }, required: ['name', 'yaml_content'], }, }, }; export const TOOL_DEFS: Record = { ListPieces: LIST_PIECES_DEF, GetPiece: GET_PIECE_DEF, CreatePiece: CREATE_PIECE_DEF, UpdatePiece: UPDATE_PIECE_DEF, }; // --- Tool execution --- function executeListPieces(ctx: ToolContext): ToolResult { try { const seen = new Set(); const pieces: Array<{ name: string; description: string; keywords: string[]; custom: boolean }> = []; const dirs: Array<{ dir: string; custom: boolean }> = []; for (const d of toCustomDirs(ctx.customPiecesDir)) { if (existsSync(d)) dirs.push({ dir: d, custom: true }); } dirs.push({ dir: BUILTIN_PIECES_DIR, custom: false }); for (const { dir, custom } of dirs) { const files = readdirSync(dir).filter(f => f.endsWith('.yaml')); for (const f of files) { const name = f.replace('.yaml', ''); if (seen.has(name)) continue; seen.add(name); try { const raw = readFileSync(join(dir, f), 'utf-8'); const p = parseYaml(raw); pieces.push({ name: p.name ?? name, description: (p.description ?? '').split('\n')[0].trim(), keywords: p.triggers?.keywords ?? [], custom, }); } catch { pieces.push({ name, description: '(parse error)', keywords: [], custom }); } } } const lines = pieces.map(p => { const kw = p.keywords.length > 0 ? ` [keywords: ${p.keywords.join(', ')}]` : ''; const tag = p.custom ? ' (custom)' : ''; return `- ${p.name}: ${p.description}${kw}${tag}`; }); return { output: `登録済み Piece (${pieces.length}件):\n${lines.join('\n')}`, isError: false }; } catch (e) { return { output: `Failed to list pieces: ${(e as Error).message}`, isError: true }; } } function executeGetPiece(input: Record, ctx: ToolContext): ToolResult { const name = input['name'] as string; if (!name || !VALID_NAME.test(name)) { return { output: 'Invalid piece name. Use lowercase alphanumeric with hyphens.', isError: true }; } const filePath = findPiecePath(name, ctx.customPiecesDir); if (!filePath) { return { output: `Piece "${name}" not found.`, isError: true }; } try { const raw = readFileSync(filePath, 'utf-8'); return { output: raw, isError: false }; } catch (e) { return { output: `Failed to read piece: ${(e as Error).message}`, isError: true }; } } function executeCreatePiece(input: Record, ctx: ToolContext): ToolResult { const yamlContent = input['yaml_content'] as string; if (!yamlContent) { return { output: 'yaml_content is required.', isError: true }; } let piece: any; try { piece = parseYaml(yamlContent); } catch (e) { return { output: `YAML parse error: ${(e as Error).message}`, isError: true }; } const error = validatePiece(piece); if (error) { return { output: `Validation error: ${error}`, isError: true }; } // 両ディレクトリで名前衝突確認 if (findPiecePath(piece.name, ctx.customPiecesDir)) { return { output: `Piece "${piece.name}" already exists. Use UpdatePiece to modify it.`, isError: true }; } // Write to the FIRST custom dir (per-user dir), or fall back to builtin if no custom dirs. const customDirs = toCustomDirs(ctx.customPiecesDir); const targetDir = customDirs[0] ?? BUILTIN_PIECES_DIR; mkdirSync(targetDir, { recursive: true }); const filePath = join(targetDir, `${piece.name}.yaml`); try { writeFileSync(filePath, stringifyYaml(piece, { lineWidth: 120 }), 'utf-8'); return { output: `Piece "${piece.name}" を作成しました。`, isError: false }; } catch (e) { return { output: `Failed to create piece: ${(e as Error).message}`, isError: true }; } } function executeUpdatePiece(input: Record, ctx: ToolContext): ToolResult { const name = input['name'] as string; const yamlContent = input['yaml_content'] as string; if (!name || !VALID_NAME.test(name)) { return { output: 'Invalid piece name.', isError: true }; } if (!yamlContent) { return { output: 'yaml_content is required.', isError: true }; } // Refuse to overwrite git-tracked built-in pieces. Force the LLM to use // CreatePiece with a new name when it wants a customized variant. if (isBuiltinOnly(name, ctx.customPiecesDir)) { return { output: `Piece "${name}" は組み込み (built-in) のため UpdatePiece では編集できません。カスタマイズが必要なら CreatePiece で別名 (例: "${name}-custom") として新規作成してください。`, isError: true, }; } const filePath = findPiecePath(name, ctx.customPiecesDir); if (!filePath) { return { output: `Piece "${name}" not found. Use CreatePiece to create it.`, isError: true }; } let piece: any; try { piece = parseYaml(yamlContent); } catch (e) { return { output: `YAML parse error: ${(e as Error).message}`, isError: true }; } piece.name = name; const error = validatePiece(piece); if (error) { return { output: `Validation error: ${error}`, isError: true }; } try { writeFileSync(filePath, stringifyYaml(piece, { lineWidth: 120 }), 'utf-8'); return { output: `Piece "${name}" を更新しました。`, isError: false }; } catch (e) { return { output: `Failed to update piece: ${(e as Error).message}`, isError: true }; } } export async function executeTool( name: string, input: Record, ctx: ToolContext, ): Promise { switch (name) { case 'ListPieces': return executeListPieces(ctx); case 'GetPiece': return executeGetPiece(input, ctx); case 'CreatePiece': return executeCreatePiece(input, ctx); case 'UpdatePiece': return executeUpdatePiece(input, ctx); default: return null; } }