feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
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-]+$/;
|
||||
|
||||
function findPiecePath(name: string, customDir: string | undefined): string | null {
|
||||
if (customDir) {
|
||||
const customPath = join(customDir, `${name}.yaml`);
|
||||
if (existsSync(customPath)) return customPath;
|
||||
}
|
||||
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 customDir). 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 | undefined): boolean {
|
||||
if (customDir) {
|
||||
const customPath = join(customDir, `${name}.yaml`);
|
||||
if (existsSync(customPath)) 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 }> = [];
|
||||
if (ctx.customPiecesDir && existsSync(ctx.customPiecesDir)) dirs.push({ dir: ctx.customPiecesDir, 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 };
|
||||
}
|
||||
|
||||
// カスタムディレクトリがあればそこに、なければ builtin に書き込み
|
||||
const targetDir = ctx.customPiecesDir ?? 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user