323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
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;
|
||
}
|
||
}
|