| 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, |
| 3 | type AntigravityAuthMethod, |
| 4 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 5 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 6 | type ProviderSendTurnInput, |
| 7 | type RuntimeMode, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Path from "effect/Path"; |
| 14 | import * as Scope from "effect/Scope"; |
| 15 | import * as Stream from "effect/Stream"; |
| 16 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 18 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 19 | |
| 20 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 21 | import { |
| 22 | makeAntigravityStderrHandler, |
| 23 | makeAntigravityStdoutTransform, |
| 24 | } from "../antigravityAuthSupport.ts"; |
| 25 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 26 | import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; |
| 27 | |
| 28 | export interface AntigravityAcpRuntimeInput extends Omit< |
| 29 | AcpSessionRuntime.AcpSessionRuntimeOptions, |
| 30 | | "authMethodId" |
| 31 | | "authenticateEagerly" |
| 32 | | "cancelBehavior" |
| 33 | | "clientCapabilities" |
| 34 | | "onStderr" |
| 35 | | "resumeMethod" |
| 36 | | "transformSessionUpdate" |
| 37 | | "transformStdout" |
| 38 | > { |
| 39 | readonly processEnvironment?: NodeJS.ProcessEnv; |
| 40 | /** Device CLI environment supplied for this provider session. */ |
| 41 | readonly agentDeviceEnvironment?: Readonly<Record<string, string>>; |
| 42 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 43 | readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 44 | /** |
| 45 | * Advertise `fs.readTextFile` and `fs.writeTextFile`. The agent then routes |
| 46 | * workspace reads and writes through T3, which turns each edit into a |
| 47 | * `session/request_permission` with the file content, instead of writing |
| 48 | * through its own tools. Chat sessions turn this on. Setup, probe, and text |
| 49 | * generation helpers leave it off so they never touch a workspace. |
| 50 | */ |
| 51 | readonly clientFileSystem?: boolean; |
| 52 | /** ACP `authenticate` method id. Defaults to the personal Google account flow. */ |
| 53 | readonly authMethod?: AntigravityAuthMethod; |
| 54 | } |
| 55 | |
| 56 | /** Normal launches reject browser login; only the auth flow supplies `onAuthorizationUrl`. */ |
| 57 | export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* ( |
| 58 | input: AntigravityAcpRuntimeInput, |
| 59 | ): Effect.fn.Return< |
| 60 | AcpSessionRuntime.AcpSessionRuntime["Service"], |
| 61 | EffectAcpErrors.AcpError, |
| 62 | Crypto.Crypto | Scope.Scope |
| 63 | > { |
| 64 | const context = yield* Layer.build( |
| 65 | AcpSessionRuntime.layer({ |
| 66 | ...input, |
| 67 | authMethodId: input.authMethod ?? "oauth-personal", |
| 68 | authenticateEagerly: true, |
| 69 | resumeMethod: "resume", |
| 70 | cancelBehavior: "wait-for-prompt", |
| 71 | clientCapabilities: { |
| 72 | fs: { |
| 73 | readTextFile: input.clientFileSystem === true, |
| 74 | writeTextFile: input.clientFileSystem === true, |
| 75 | }, |
| 76 | terminal: false, |
| 77 | }, |
| 78 | transformStdout: makeAntigravityStdoutTransform( |
| 79 | input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, |
| 80 | ), |
| 81 | onStderr: makeAntigravityStderrHandler( |
| 82 | input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, |
| 83 | ), |
| 84 | transformSessionUpdate: normalizeAntigravitySessionUpdate, |
| 85 | }).pipe( |
| 86 | Layer.provide( |
| 87 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), |
| 88 | ), |
| 89 | ), |
| 90 | ); |
| 91 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); |
| 92 | }); |
| 93 | |
| 94 | export function antigravityPermissionMode(runtimeMode: RuntimeMode): string { |
| 95 | switch (runtimeMode) { |
| 96 | case "full-access": |
| 97 | return "yolo"; |
| 98 | case "auto-accept-edits": |
| 99 | return "auto_edit"; |
| 100 | case "auto": |
| 101 | case "approval-required": |
| 102 | return "default"; |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | function antigravityModelOptions( |
| 107 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, |
| 108 | ) { |
| 109 | const model = configOptions.find((option) => option.id === "model"); |
| 110 | if (model?.type !== "select") return []; |
| 111 | return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Resolves the model a turn should run on. A saved selection is reapplied |
| 116 | * as-is. The provider default alias resolves to `defaultModel` when the |
| 117 | * account offers it, so T3 can pick a newer model than the one Google marks |
| 118 | * current. Otherwise the agent's current selection stands. |
| 119 | */ |
| 120 | function resolveAntigravityModel(input: { |
| 121 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; |
| 122 | readonly model: string | null | undefined; |
| 123 | readonly defaultModel?: string | undefined; |
| 124 | }): string | undefined { |
| 125 | const modelConfig = input.configOptions.find((option) => option.id === "model"); |
| 126 | const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; |
| 127 | if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model; |
| 128 | const options = antigravityModelOptions(input.configOptions); |
| 129 | return input.defaultModel && options.some((option) => option.value === input.defaultModel) |
| 130 | ? input.defaultModel |
| 131 | : current; |
| 132 | } |
| 133 | |
| 134 | /** Never replace a saved selection with the default returned by a cold resume. */ |
| 135 | export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpModelSelection")( |
| 136 | function* <E>(input: { |
| 137 | readonly runtime: Pick< |
| 138 | AcpSessionRuntime.AcpSessionRuntime["Service"], |
| 139 | "getConfigOptions" | "setModel" |
| 140 | >; |
| 141 | readonly model: string | null | undefined; |
| 142 | /** Model to select for the provider default alias. See `resolveAntigravityModel`. */ |
| 143 | readonly defaultModel?: string | undefined; |
| 144 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; |
| 145 | }): Effect.fn.Return<string | undefined, E> { |
| 146 | const configOptions = yield* input.runtime.getConfigOptions; |
| 147 | const modelConfig = configOptions.find((option) => option.id === "model"); |
| 148 | const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; |
| 149 | const resolved = resolveAntigravityModel({ |
| 150 | configOptions, |
| 151 | model: input.model, |
| 152 | defaultModel: input.defaultModel, |
| 153 | }); |
| 154 | // The default alias never sends an internal ID. It selects the manifest |
| 155 | // default when that differs from the agent's current model, and otherwise |
| 156 | // leaves the agent's choice alone. |
| 157 | const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL; |
| 158 | if (resolved === undefined || (!explicit && resolved === current)) return current; |
| 159 | const options = antigravityModelOptions(configOptions); |
| 160 | if (!options.some((option) => option.value === resolved)) { |
| 161 | return yield* Effect.fail( |
| 162 | input.mapError( |
| 163 | EffectAcpErrors.AcpRequestError.invalidParams( |
| 164 | `Antigravity model '${resolved}' is unavailable for this Google account. Select an available model.`, |
| 165 | ), |
| 166 | ), |
| 167 | ); |
| 168 | } |
| 169 | yield* input.runtime.setModel(resolved).pipe(Effect.mapError(input.mapError)); |
| 170 | return resolved; |
| 171 | }, |
| 172 | ); |
| 173 | |
| 174 | const IMAGE_MIME_TYPES = new Set(["image/bmp", "image/jpeg", "image/png", "image/webp"]); |
| 175 | // Formats the bundled SDK's Audio type accepts. Anything else is rejected up front. |
| 176 | const AUDIO_MIME_TYPES = new Set([ |
| 177 | "audio/aac", |
| 178 | "audio/flac", |
| 179 | "audio/mp3", |
| 180 | "audio/mpeg", |
| 181 | "audio/mp4", |
| 182 | "audio/m4a", |
| 183 | "audio/x-m4a", |
| 184 | "audio/ogg", |
| 185 | "audio/wav", |
| 186 | "audio/x-wav", |
| 187 | "audio/webm", |
| 188 | ]); |
| 189 | const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; |
| 190 | const TEXT_MIME_TYPES = new Set([ |
| 191 | "application/json", |
| 192 | "application/ld+json", |
| 193 | "application/javascript", |
| 194 | "application/typescript", |
| 195 | "application/xml", |
| 196 | "application/yaml", |
| 197 | "application/x-yaml", |
| 198 | "application/x-sh", |
| 199 | ]); |
| 200 | const TEXT_FILE_EXTENSIONS = new Set([ |
| 201 | ".txt", |
| 202 | ".md", |
| 203 | ".mdx", |
| 204 | ".json", |
| 205 | ".jsonl", |
| 206 | ".yaml", |
| 207 | ".yml", |
| 208 | ".toml", |
| 209 | ".xml", |
| 210 | ".csv", |
| 211 | ".tsv", |
| 212 | ".js", |
| 213 | ".jsx", |
| 214 | ".mjs", |
| 215 | ".cjs", |
| 216 | ".ts", |
| 217 | ".tsx", |
| 218 | ".html", |
| 219 | ".css", |
| 220 | ".scss", |
| 221 | ".less", |
| 222 | ".py", |
| 223 | ".rs", |
| 224 | ".go", |
| 225 | ".java", |
| 226 | ".kt", |
| 227 | ".swift", |
| 228 | ".c", |
| 229 | ".h", |
| 230 | ".cc", |
| 231 | ".cpp", |
| 232 | ".hpp", |
| 233 | ".cs", |
| 234 | ".rb", |
| 235 | ".php", |
| 236 | ".sh", |
| 237 | ".bash", |
| 238 | ".zsh", |
| 239 | ".sql", |
| 240 | ".graphql", |
| 241 | ".svelte", |
| 242 | ".vue", |
| 243 | ".log", |
| 244 | ".diff", |
| 245 | ".patch", |
| 246 | ".ini", |
| 247 | ".conf", |
| 248 | ]); |
| 249 | const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; |
| 250 | const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; |
| 251 | |
| 252 | /** |
| 253 | * Sends supported uploads as native ACP content. Other files, and native |
| 254 | * candidates over their limits, reach the agent through the saved path |
| 255 | * ProviderService puts in the text block. |
| 256 | */ |
| 257 | export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { |
| 258 | readonly input: ProviderSendTurnInput["input"]; |
| 259 | readonly attachments: ProviderSendTurnInput["attachments"]; |
| 260 | readonly attachmentsDir: string; |
| 261 | }): Effect.fn.Return< |
| 262 | ReadonlyArray<EffectAcpSchema.ContentBlock>, |
| 263 | EffectAcpErrors.AcpError, |
| 264 | FileSystem.FileSystem | Path.Path |
| 265 | > { |
| 266 | const fileSystem = yield* FileSystem.FileSystem; |
| 267 | const path = yield* Path.Path; |
| 268 | const blocks: Array<EffectAcpSchema.ContentBlock> = []; |
| 269 | const text = input.input?.trim(); |
| 270 | if (text) blocks.push({ type: "text", text }); |
| 271 | let totalBytes = 0; |
| 272 | |
| 273 | for (const attachment of input.attachments ?? []) { |
| 274 | const isPastedText = |
| 275 | attachment.type === "file" && |
| 276 | "source" in attachment && |
| 277 | attachment.source?._tag === "pasted-text"; |
| 278 | // ProviderService has already put the file path in the text block. Keep a |
| 279 | // folded clipboard paste lazy so the agent can search or sample it rather |
| 280 | // than paying to embed the entire resource in context immediately. |
| 281 | const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; |
| 282 | const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); |
| 283 | const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); |
| 284 | const pdf = attachment.type === "file" && mimeType === "application/pdf"; |
| 285 | const textFile = |
| 286 | attachment.type === "file" && |
| 287 | (mimeType.startsWith("text/") || |
| 288 | TEXT_MIME_TYPES.has(mimeType) || |
| 289 | TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); |
| 290 | const isPathOnly = |
| 291 | attachment.type === "file" && (isPastedText || (!audio && !pdf && !textFile)); |
| 292 | if (attachment.type === "image" && !image) { |
| 293 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 294 | `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, |
| 295 | ); |
| 296 | } |
| 297 | const attachmentPath = resolveAttachmentPath({ |
| 298 | attachmentsDir: input.attachmentsDir, |
| 299 | attachment, |
| 300 | }); |
| 301 | if (!attachmentPath) { |
| 302 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 303 | `Invalid attachment '${attachment.name}'.`, |
| 304 | ); |
| 305 | } |
| 306 | const info = yield* fileSystem |
| 307 | .stat(attachmentPath) |
| 308 | .pipe( |
| 309 | Effect.mapError(() => |
| 310 | EffectAcpErrors.AcpRequestError.invalidParams( |
| 311 | `Could not read attachment '${attachment.name}'.`, |
| 312 | ), |
| 313 | ), |
| 314 | ); |
| 315 | if (info.type !== "File") { |
| 316 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 317 | `Could not read attachment '${attachment.name}'.`, |
| 318 | ); |
| 319 | } |
| 320 | if (isPathOnly) continue; |
| 321 | const size = Number(info.size); |
| 322 | const limit = image |
| 323 | ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES |
| 324 | : audio |
| 325 | ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES |
| 326 | : pdf |
| 327 | ? MAX_TOTAL_ATTACHMENT_BYTES |
| 328 | : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; |
| 329 | if ( |
| 330 | attachment.type === "file" && |
| 331 | (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES) |
| 332 | ) { |
| 333 | continue; |
| 334 | } |
| 335 | totalBytes += size; |
| 336 | if (size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { |
| 337 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 338 | `Image '${attachment.name}' is too large. Antigravity accepts images up to 10 MiB and 50 MiB of native attachments per message.`, |
| 339 | ); |
| 340 | } |
| 341 | const uri = yield* path.toFileUrl(attachmentPath).pipe( |
| 342 | Effect.map((url) => url.href), |
| 343 | Effect.mapError(() => |
| 344 | EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`), |
| 345 | ), |
| 346 | ); |
| 347 | if (pdf) { |
| 348 | blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType }); |
| 349 | continue; |
| 350 | } |
| 351 | const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe( |
| 352 | Stream.runCollect, |
| 353 | Effect.map((chunks) => Buffer.concat(chunks)), |
| 354 | Effect.mapError(() => |
| 355 | EffectAcpErrors.AcpRequestError.invalidParams( |
| 356 | `Could not read attachment '${attachment.name}'.`, |
| 357 | ), |
| 358 | ), |
| 359 | ); |
| 360 | totalBytes += bytes.length - size; |
| 361 | if (bytes.length > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { |
| 362 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 363 | `Attachment '${attachment.name}' changed while being read and is too large.`, |
| 364 | ); |
| 365 | } |
| 366 | if (image) { |
| 367 | blocks.push({ type: "image", data: Buffer.from(bytes).toString("base64"), mimeType }); |
| 368 | } else if (audio) { |
| 369 | blocks.push({ type: "audio", data: Buffer.from(bytes).toString("base64"), mimeType }); |
| 370 | } else { |
| 371 | const decoded = yield* Effect.try({ |
| 372 | try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), |
| 373 | catch: () => |
| 374 | EffectAcpErrors.AcpRequestError.invalidParams( |
| 375 | `Attachment '${attachment.name}' is not a UTF-8 text file.`, |
| 376 | ), |
| 377 | }); |
| 378 | if (decoded.includes("\0")) { |
| 379 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 380 | `Attachment '${attachment.name}' contains binary data.`, |
| 381 | ); |
| 382 | } |
| 383 | blocks.push({ type: "resource", resource: { uri, mimeType, text: decoded } }); |
| 384 | } |
| 385 | } |
| 386 | if (blocks.length === 0) { |
| 387 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( |
| 388 | "A turn requires text or supported attachments.", |
| 389 | ); |
| 390 | } |
| 391 | return blocks; |
| 392 | }); |