import type { ProviderApprovalOption, ProviderUserInputAnswers, UserInputQuestion, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as AcpWireSchema from "effect-acp/schema-v1"; import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; import { ANTIGRAVITY_EDIT_DIFF_OMITTED, antigravityEditPatch, isAntigravitySnippetPatch, readAntigravityPreEditFile, } from "./AntigravityEditLocation.ts"; const TOOL_TEXT_LIMIT = 8_000; const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; const QUESTION_LABEL_LIMIT = 512; const NativeToolFields = Schema.Struct({ command: Schema.optional(Schema.String), CommandLine: Schema.optional(Schema.String), command_line: Schema.optional(Schema.String), commandLine: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), Cwd: Schema.optional(Schema.String), WorkingDirectory: Schema.optional(Schema.String), working_dir: Schema.optional(Schema.String), workingDir: Schema.optional(Schema.String), combinedOutput: Schema.optional(Schema.String), combined_output: Schema.optional(Schema.String), exitCode: Schema.optional(Schema.Int), exit_code: Schema.optional(Schema.Int), imagePath: Schema.optional(Schema.String), }); const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields); const decodeSingleAnswer = Schema.decodeUnknownOption( Schema.Union([Schema.String, Schema.Tuple([Schema.String])]), ); const decodeToolCallContent = Schema.decodeUnknownOption(AcpWireSchema.ToolCallContent); /** Native questions share the permission method, but their choices are not approvals. */ export function isAntigravityUserInputRequest( request: EffectAcpSchema.RequestPermissionRequest, ): boolean { return request.toolCall.toolCallId.startsWith("interaction_"); } /** Copy truncated text so V8 cannot retain the original large string. */ const SECURITY_WARNING_META_KEY = "agy.security.warning"; const WARNING_TEXT_LIMIT = 512; const decodeSecurityWarning = Schema.decodeUnknownOption( Schema.Struct({ title: Schema.optional(Schema.String), message: Schema.optional(Schema.String), }), ); /** Expose shell/web Allow Always _meta warnings as option text for both clients. */ function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY])); const text = warning?.message?.trim() || warning?.title?.trim(); if (!text) return undefined; return text.length > WARNING_TEXT_LIMIT ? copyBoundedText(`${text.slice(0, WARNING_TEXT_LIMIT - 3)}...`) : text; } export function antigravityApprovalOptions( request: EffectAcpSchema.RequestPermissionRequest, ): ReadonlyArray { if (isAntigravityUserInputRequest(request)) return []; const options: ProviderApprovalOption[] = []; const optionWithKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => request.options.find((entry) => entry.kind === kind && entry.optionId.trim()); const once = optionWithKind("allow_once"); if (once) { options.push({ decision: "accept", label: "Allow once" }); } const always = optionWithKind("allow_always"); if (always) { const warning = antigravitySecurityWarning(always); options.push({ decision: "acceptForSession", label: "Allow for this thread", ...(warning ? { warning } : {}), }); } if (optionWithKind("reject_once")) { options.push({ decision: "decline", label: "Deny" }); } options.push({ decision: "cancel", label: "Cancel" }); return options; } function copyBoundedText(text: string): string { return Buffer.from(text, "utf16le").toString("utf16le"); } function questionLabel(option: EffectAcpSchema.PermissionOption): string { const label = option.name.trim() || option.optionId; return label.length > QUESTION_LABEL_LIMIT ? copyBoundedText(`${label.slice(0, QUESTION_LABEL_LIMIT - 3)}...`) : label; } export function extractAntigravityUserInputQuestion( request: EffectAcpSchema.RequestPermissionRequest, ): UserInputQuestion | undefined { if (!isAntigravityUserInputRequest(request) || request.options.length === 0) { return undefined; } const ids = new Set(); for (const option of request.options) { if (!option.optionId.trim() || ids.has(option.optionId)) { return undefined; } ids.add(option.optionId); } const question = request.toolCall.title?.trim() || "Choose an option."; return { id: request.toolCall.toolCallId, header: "Question", question: question.length > TOOL_TEXT_LIMIT ? copyBoundedText(`${question.slice(0, TOOL_TEXT_LIMIT - 3)}...`) : question, multiSelect: false, allowCustomAnswer: false, options: request.options.map((option) => ({ value: option.optionId, label: questionLabel(option), description: questionLabel(option), })), }; } export function makeAntigravityUserInputResponse( request: EffectAcpSchema.RequestPermissionRequest, answers: ProviderUserInputAnswers, ): EffectAcpSchema.RequestPermissionResponse | undefined { if (extractAntigravityUserInputQuestion(request) === undefined) { return undefined; } const answer = Option.getOrUndefined(decodeSingleAnswer(answers[request.toolCall.toolCallId])); const value = typeof answer === "string" ? answer : answer?.[0]; if (value === undefined) { return undefined; } const exact = request.options.find((option) => option.optionId === value); if (exact) { return { outcome: { outcome: "selected", optionId: exact.optionId } }; } const matchingLabels = request.options.filter((option) => questionLabel(option) === value); const option = matchingLabels.length === 1 ? matchingLabels[0] : undefined; return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : undefined; } function boundText(text: string, limit = TOOL_TEXT_LIMIT): string { return text.length <= limit ? text : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`); } interface ToolPayloadBudget { nodes: number; text: number; } function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: number): unknown { if (depth > 12 || budget.nodes-- <= 0) { return undefined; } if (typeof value === "string") { if (/^data:image\//i.test(value) || budget.text <= 0) { return undefined; } const text = boundText(value, Math.min(TOOL_TEXT_LIMIT, budget.text)); budget.text -= text.length; return text; } if (Array.isArray(value)) { const result: unknown[] = []; for (const entry of value) { if (budget.nodes <= 0) break; const sanitized = sanitizeToolValue(entry, budget, depth + 1); if (sanitized !== undefined) result.push(sanitized); } return result; } if (!Predicate.isObject(value)) { return value; } const entries: Array = []; for (const [key, entry] of Object.entries(value)) { if (budget.nodes <= 0) break; if ( (value.type === "image" && (key === "data" || key === "blob")) || (key === "blob" && typeof value.mimeType === "string" && value.mimeType.startsWith("image/")) || ((key === "formatted_output" || key === "formattedOutput") && (entry === value.combinedOutput || entry === value.combined_output)) ) { continue; } const sanitized = sanitizeToolValue(entry, budget, depth + 1); if (sanitized !== undefined) entries.push([key, sanitized]); } return Object.fromEntries(entries); } export function sanitizeAntigravityToolPayload(payload: unknown): unknown { return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); } const isJsonRecord = Schema.is(Schema.Record(Schema.String, Schema.Json)); const ANTIGRAVITY_CLIENT_READ_TITLES = new Set([ "Running client_view_file", "Run client_view_file?", ]); function antigravityClientReadPath(update: { readonly title?: string | null; readonly locations?: ReadonlyArray<{ readonly path: string }> | null; readonly rawInput?: unknown; }): string | undefined { if (!update.title || !ANTIGRAVITY_CLIENT_READ_TITLES.has(update.title)) return undefined; const target = update.locations?.[0]?.path ?? (Predicate.isObject(update.rawInput) ? update.rawInput.absolute_path : undefined); return typeof target === "string" && target ? target : undefined; } /** * The agent never completes a subagent's `client_view_file` call, then fails every open call * with the next unrelated step error. Report reads T3 served as completed instead. */ export function makeAntigravityReadRepair() { type Outcome = "served" | "refused"; const openReads = new Map< string, { readonly path: string; readonly sessionId: string; outcome?: Outcome } >(); // The agent runs a client tool in a background task, so its request can precede the announcement. const earlyOutcomes: Array<{ readonly path: string; readonly outcome: Outcome }> = []; return { /** Earlier turns' rows are already settled, and their unanswered reads must not take credit. */ reset: () => { openReads.clear(); earlyOutcomes.length = 0; }, /** * Each `fs/read_text_file` request answers the oldest open read of its path, in order. * Returns the call a served read completes, so T3 can report it without waiting. */ attempted: (path: string, served: boolean) => { const outcome = served ? "served" : "refused"; for (const [toolCallId, read] of openReads) { if (read.path !== path || read.outcome !== undefined) continue; read.outcome = outcome; return served ? { sessionId: read.sessionId, toolCallId } : undefined; } earlyOutcomes.push({ path, outcome }); return undefined; }, repair: ( notification: EffectAcpSchema.SessionNotification, ): EffectAcpSchema.SessionNotification => { const update = notification.update; if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { return notification; } const path = antigravityClientReadPath(update); if (path !== undefined && !openReads.has(update.toolCallId)) { const early = earlyOutcomes.findIndex((entry) => entry.path === path); const outcome = early === -1 ? undefined : earlyOutcomes.splice(early, 1)[0]?.outcome; const sessionId = notification.sessionId; openReads.set( update.toolCallId, outcome === undefined ? { path, sessionId } : { path, sessionId, outcome }, ); } const read = openReads.get(update.toolCallId); if (update.status === "pending" || update.status === "in_progress") { return read?.outcome === "served" ? { ...notification, update: { ...update, status: "completed" } } : notification; } if (update.status !== "completed" && update.status !== "failed") return notification; openReads.delete(update.toolCallId); if (update.status === "completed" || read?.outcome !== "served") return notification; const { rawOutput: _stepError, ...rest } = update; return { ...notification, update: { ...rest, status: "completed" } }; }, }; } export function normalizeAntigravitySessionUpdate( notification: EffectAcpSchema.SessionNotification, ): EffectAcpSchema.SessionNotification { const update = notification.update; if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { return notification; } const contentBudget = { nodes: 512, text: 32_000 }; const content = update.content?.flatMap((entry) => { const decoded = Option.getOrUndefined( decodeToolCallContent(sanitizeToolValue(entry, contentBudget, 0)), ); return decoded === undefined ? [] : [decoded]; }); const meta = sanitizeAntigravityToolPayload(update._meta); return { ...notification, update: { ...update, ...(typeof update.title === "string" ? { title: boundText(update.title) } : {}), ...(update.rawInput !== undefined ? { rawInput: sanitizeAntigravityToolPayload(update.rawInput) } : {}), ...(update.rawOutput !== undefined ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } : {}), ...(update.content !== undefined ? { content: content ?? [] } : {}), ...(update._meta !== undefined ? { _meta: isJsonRecord(meta) ? meta : null } : {}), }, }; } function localImagePath(imagePath: string | undefined): string | undefined { if (!imagePath || imagePath.length > TOOL_TEXT_LIMIT) { return undefined; } const path = imagePath.trim(); if (!isWorkspaceImagePreviewPath(path)) { return undefined; } if (/^file:\/\//i.test(path)) { try { const url = new URL(path); if (url.hostname && url.hostname !== "localhost") return undefined; const pathname = decodeURIComponent(url.pathname); return /^\/[a-z]:\//i.test(pathname) ? pathname.slice(1) : pathname; } catch { return undefined; } } return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path; } function hasTruncatedText(value: unknown): boolean { if (typeof value === "string") return value.startsWith(TOOL_TEXT_TRUNCATED); return Predicate.isObject(value) && Object.values(value).some(hasTruncatedText); } /** The ACP adapter renders a diff's `patch.text` ahead of its bare old/new snippets. */ function withAntigravityEditPatches( content: ReadonlyArray, data: Record, readPreEditFile: ((path: string) => string | undefined) | undefined, ) { return content.map((entry) => { if (!Predicate.isObject(entry) || entry.type !== "diff" || typeof entry.path !== "string") return entry; // Keep the first patch: once the edit runs, the file no longer holds the old text. A later // update's shared text budget can cut or drop it, and a cut patch would misplace the hunk. if (entry.patch !== undefined) { const patch: unknown = entry.patch; const cut = Predicate.isObject(patch) && patch.format !== "omitted" && (typeof patch.text !== "string" || patch.text.startsWith(TOOL_TEXT_TRUNCATED)); return cut ? { ...entry, patch: { format: "omitted" } } : entry; } // Bounding keeps only the tail of long input, so a cut snippet cannot be located. if (hasTruncatedText(data.rawInput)) return { ...entry, patch: { format: "omitted" } }; const text = antigravityEditPatch({ path: entry.path, rawInput: data.rawInput, locations: data.locations, readPreEditFile, }); // A truncated patch would no longer parse, and bare snippets would render at line 1. if ( text === ANTIGRAVITY_EDIT_DIFF_OMITTED || (text !== undefined && text.length > TOOL_TEXT_LIMIT) ) return { ...entry, patch: { format: "omitted" } }; if (text === undefined) return entry; return { ...entry, patch: isAntigravitySnippetPatch(text) ? { format: "snippet", text } : { text }, }; }); } export function normalizeAntigravityToolCall( toolCall: AcpToolCallState, readPreEditFile = readAntigravityPreEditFile, ): AcpToolCallState { const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput)); const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput)); const nativeCommand = input?.CommandLine ?? input?.command_line ?? input?.commandLine ?? input?.command ?? output?.commandLine ?? output?.command_line ?? toolCall.command; const command = nativeCommand?.trim() ? boundText(nativeCommand.trim()) : undefined; const nativeCwd = input?.Cwd ?? input?.WorkingDirectory ?? input?.working_dir ?? input?.workingDir ?? input?.cwd ?? output?.workingDir ?? output?.working_dir; const cwd = nativeCwd?.trim() ? boundText(nativeCwd.trim()) : undefined; const nativeOutput = output?.combinedOutput ?? output?.combined_output; const aggregatedOutput = nativeOutput === undefined ? undefined : boundText(nativeOutput); const exitCode = output?.exitCode ?? output?.exit_code; const imagePath = localImagePath(output?.imagePath); const sanitizedData = sanitizeAntigravityToolPayload(toolCall.data); const data: Record = Predicate.isObject(sanitizedData) ? sanitizedData : {}; const kind = toolCall.kind ?? (command !== undefined ? "execute" : undefined); if (kind !== undefined) data.kind = kind; if (command !== undefined) data.command = command; if (cwd !== undefined) data.cwd = cwd; if (imagePath !== undefined) data.imagePath = imagePath; if (kind === "edit") { const content = Array.isArray(data.content) ? data.content : []; // Full-access edits report only a path; without a hunk the checkpoint diff is the review. data.content = content.some((entry) => Predicate.isObject(entry) && entry.type === "diff") ? withAntigravityEditPatches( content, data, toolCall.status === "pending" ? readPreEditFile : undefined, ) : [...content, { type: "diff", patch: { format: "omitted" } }]; } if (kind === "execute") { data.item = { ...(Predicate.isObject(data.item) ? data.item : {}), ...(command !== undefined ? { command } : {}), ...(cwd !== undefined ? { cwd } : {}), ...(aggregatedOutput !== undefined ? { aggregatedOutput } : {}), ...(exitCode !== undefined ? { exitCode } : {}), }; } return { ...toolCall, ...(kind !== undefined ? { kind } : {}), ...(command !== undefined ? { command } : {}), ...(toolCall.title !== undefined ? { title: boundText(toolCall.title) } : {}), ...(command !== undefined ? { detail: command } : toolCall.detail !== undefined ? { detail: boundText(toolCall.detail) } : {}), data, }; } const ANTIGRAVITY_SUBAGENT_TITLES = new Set([ "Running start_subagent", "Run start_subagent?", "Running invoke_subagent", "Run invoke_subagent?", ]); /** The ACP agent reports subagent calls as ordinary tools without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload?: unknown, ): "subagent" | "mcp" | undefined { if ( (toolCall.kind !== undefined && toolCall.kind !== "other") || toolCall.title === undefined || !ANTIGRAVITY_SUBAGENT_TITLES.has(toolCall.title) ) return undefined; const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; const meta = Predicate.isObject(update) ? update._meta : toolCall.data.meta; return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent"; } export function antigravitySubagentOutput(toolCall: AcpToolCallState): string | undefined { const output = toolCall.data.rawOutput; return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined; }