import type { Message } from "@earendil-works/pi-ai"; import { isMutatingTool } from "./long-running-guard.ts"; import { expectsImplementationMutation } from "./task-intent.ts"; export { expectsImplementationMutation }; const READ_ONLY_BUILTIN_TOOLS = new Set([ "read", "grep", "find", "ls", "web_search", "fetch_content", "get_search_content", "intercom", "contact_supervisor", ]); // Cursor native edit/write often land as thinking traces (inactive_trace / // transcript_trace) rather than toolCall parts when native tool replay is off // or the tool is inactive in context. Match pi-cursor-sdk display labels. const CURSOR_FILE_MUTATION_THINKING = /(?:^|\n)\s*Cursor (?:edit|write)\s*:/i; interface CompletionMutationGuardInput { agent: string; task: string; messages: Message[]; tools?: string[]; mcpDirectTools?: string[]; } interface CompletionMutationGuardResult { expectedMutation: boolean; attemptedMutation: boolean; triggered: boolean; } export function hasMutationToolCapability(tools: string[] | undefined, mcpDirectTools: string[] | undefined): boolean { if ((mcpDirectTools?.length ?? 0) > 0) return true; if (tools === undefined) return true; return !tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool)); } function hasCheckpointMutationEvidence(message: Message): boolean { const record = message as unknown as { role?: string; type?: string; customType?: unknown; data?: unknown; details?: unknown; }; if ((record.role !== "custom" && record.type !== "custom") || record.customType !== "pi-checkpoint") return false; const details = typeof record.details === "object" && record.details !== null && !Array.isArray(record.details) ? record.details as Record : undefined; const data = typeof record.data === "object" && record.data !== null && !Array.isArray(record.data) ? record.data as Record : typeof details?.beforeCommit === "string" || typeof details?.afterCommit === "string" ? details : details?.data && typeof details.data === "object" && !Array.isArray(details.data) ? details.data as Record : undefined; return typeof data?.beforeCommit === "string" && typeof data.afterCommit === "string" && data.beforeCommit !== data.afterCommit; } export function hasMutationToolCall(messages: Message[]): boolean { for (const message of messages) { if (hasCheckpointMutationEvidence(message)) return true; if (message.role !== "assistant") continue; for (const part of message.content) { if (part.type === "thinking" && CURSOR_FILE_MUTATION_THINKING.test(part.thinking)) return true; if (part.type !== "toolCall") continue; const args = typeof part.arguments === "object" && part.arguments !== null && !Array.isArray(part.arguments) ? part.arguments as Record : {}; if (isMutatingTool(part.name, args)) return true; } } return false; } export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult { const expectedMutation = hasMutationToolCapability(input.tools, input.mcpDirectTools) ? expectsImplementationMutation(input.agent, input.task) : false; const attemptedMutation = hasMutationToolCall(input.messages); return { expectedMutation, attemptedMutation, triggered: expectedMutation && !attemptedMutation, }; }