1import {
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";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import * as EffectAcpErrors from "effect-acp/errors";
18import type * as EffectAcpSchema from "effect-acp/compat";
19
20import { resolveAttachmentPath } from "../../attachmentStore.ts";
21import {
22 makeAntigravityStderrHandler,
23 makeAntigravityStdoutTransform,
24} from "../antigravityAuthSupport.ts";
25import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
26import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts";
27
28export 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`. */
57export 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
94export 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
106function 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 */
120function 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. */
135export 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
174const 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.
176const 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]);
189const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024;
190const 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]);
200const 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]);
249const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024;
250const 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 */
257export 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});