import type { AnyAgentTool } from "./pi-tools.types.js"; export type RequiredParamGroup = { keys: readonly string[]; allowEmpty?: boolean; label?: string; }; const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying."; function parameterValidationError(message: string): Error { return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`); } export const CLAUDE_PARAM_GROUPS = { read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }], write: [ { keys: ["path", "file_path"], label: "path (path or file_path)" }, { keys: ["content"], label: "content" }, ], edit: [ { keys: ["path", "file_path"], label: "path (path or file_path)" }, { keys: ["oldText", "old_string"], label: "oldText (oldText or old_string)", }, { keys: ["newText", "new_string"], label: "newText (newText or new_string)", allowEmpty: true, }, ], } as const; function extractStructuredText(value: unknown, depth = 0): string | undefined { if (depth > 6) { return undefined; } if (typeof value === "string") { return value; } if (Array.isArray(value)) { const parts = value .map((entry) => extractStructuredText(entry, depth + 1)) .filter((entry): entry is string => typeof entry === "string"); return parts.length > 0 ? parts.join("") : undefined; } if (!value || typeof value !== "object") { return undefined; } const record = value as Record; if (typeof record.text === "string") { return record.text; } if (typeof record.content === "string") { return record.content; } if (Array.isArray(record.content)) { return extractStructuredText(record.content, depth + 1); } if (Array.isArray(record.parts)) { return extractStructuredText(record.parts, depth + 1); } if (typeof record.value === "string" && record.value.length > 0) { const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; const kind = typeof record.kind === "string" ? record.kind.toLowerCase() : ""; if (type.includes("text") || kind === "text") { return record.value; } } return undefined; } function normalizeTextLikeParam(record: Record, key: string) { const value = record[key]; if (typeof value === "string") { return; } const extracted = extractStructuredText(value); if (typeof extracted === "string") { record[key] = extracted; } } // Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions. // Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText. // This prevents models trained on Claude Code from getting stuck in tool-call loops. export function normalizeToolParams(params: unknown): Record | undefined { if (!params || typeof params !== "object") { return undefined; } const record = params as Record; const normalized = { ...record }; // file_path → path (read, write, edit) if ("file_path" in normalized && !("path" in normalized)) { normalized.path = normalized.file_path; delete normalized.file_path; } // old_string → oldText (edit) if ("old_string" in normalized && !("oldText" in normalized)) { normalized.oldText = normalized.old_string; delete normalized.old_string; } // new_string → newText (edit) if ("new_string" in normalized && !("newText" in normalized)) { normalized.newText = normalized.new_string; delete normalized.new_string; } // Some providers/models emit text payloads as structured blocks instead of raw strings. // Normalize these for write/edit so content matching and writes stay deterministic. normalizeTextLikeParam(normalized, "content"); normalizeTextLikeParam(normalized, "oldText"); normalizeTextLikeParam(normalized, "newText"); return normalized; } export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAgentTool { const schema = tool.parameters && typeof tool.parameters === "object" ? (tool.parameters as Record) : undefined; if (!schema || !schema.properties || typeof schema.properties !== "object") { return tool; } const properties = { ...(schema.properties as Record) }; const required = Array.isArray(schema.required) ? schema.required.filter((key): key is string => typeof key === "string") : []; let changed = false; const aliasPairs: Array<{ original: string; alias: string }> = [ { original: "path", alias: "file_path" }, { original: "oldText", alias: "old_string" }, { original: "newText", alias: "new_string" }, ]; for (const { original, alias } of aliasPairs) { if (!(original in properties)) { continue; } if (!(alias in properties)) { properties[alias] = properties[original]; changed = true; } const idx = required.indexOf(original); if (idx !== -1) { required.splice(idx, 1); changed = true; } } if (!changed) { return tool; } return { ...tool, parameters: { ...schema, properties, required, }, }; } export function assertRequiredParams( record: Record | undefined, groups: readonly RequiredParamGroup[], toolName: string, ): void { if (!record || typeof record !== "object") { throw parameterValidationError(`Missing parameters for ${toolName}`); } const missingLabels: string[] = []; for (const group of groups) { const satisfied = group.keys.some((key) => { if (!(key in record)) { return false; } const value = record[key]; if (typeof value !== "string") { return false; } if (group.allowEmpty) { return true; } return value.trim().length > 0; }); if (!satisfied) { const label = group.label ?? group.keys.join(" or "); missingLabels.push(label); } } if (missingLabels.length > 0) { const joined = missingLabels.join(", "); const noun = missingLabels.length === 1 ? "parameter" : "parameters"; throw parameterValidationError(`Missing required ${noun}: ${joined}`); } } // Generic wrapper to normalize parameters for any tool. export function wrapToolParamNormalization( tool: AnyAgentTool, requiredParamGroups?: readonly RequiredParamGroup[], ): AnyAgentTool { const patched = patchToolSchemaForClaudeCompatibility(tool); return { ...patched, execute: async (toolCallId, params, signal, onUpdate) => { const normalized = normalizeToolParams(params); const record = normalized ?? (params && typeof params === "object" ? (params as Record) : undefined); if (requiredParamGroups?.length) { assertRequiredParams(record, requiredParamGroups, tool.name); } return tool.execute(toolCallId, normalized ?? params, signal, onUpdate); }, }; }