maestro/src/engine/tools/pieces.ts
2026-06-04 03:03:12 +00:00

323 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, ToolDef> = {
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<string>();
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
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;
}
}