Open History to see the last commit
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 HostProcess from "@t3tools/shared/HostProcess";
10import * as SecureRandom from "effect/crypto/SecureRandom";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";
15import * as Predicate from "effect/Predicate";
16import * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
19import * as EffectAcpErrors from "effect-acp/errors";
20import type * as EffectAcpSchema from "effect-acp/compat";
21
22import {
23 makeAntigravityStderrHandler,
24 makeAntigravityStdoutTransform,
25} from "../antigravityAuthSupport.ts";
26import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
27import packageJson from "../../../package.json" with { type: "json" };
28import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts";
29
30export interface AntigravityAcpRuntimeInput extends Omit<
31 AcpSessionRuntime.AcpSessionRuntimeOptions,
32 | "authMethodId"
33 | "authenticateEagerly"
34 | "cancelBehavior"
35 | "clientCapabilities"
36 | "clientInfo"
37 | "onStderr"
38 | "resumeMethod"
39 | "sessionMeta"
40 | "transformSessionUpdate"
41 | "transformStdout"
42> {
43 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
44 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
45 /** Serve `fs/read_text_file` for runtimes whose native reader is not qualified. */
46 readonly clientFileReads?: boolean;
47 readonly clientCapabilities?: AcpSessionRuntime.AcpSessionRuntimeOptions["clientCapabilities"];
48 /** The installed version, known before `initialize`; it picks the file reader and client name. */
49 readonly runtimeVersion?: string | null;
50 /** Conversational sessions only; helpers and probes leave the system prompt untouched. */
51 readonly sessionSystemPromptAppend?: string;
52 readonly authMethod?: AntigravityAuthMethod;
53}
54
55function parseAntigravityVersion(version: string | null | undefined) {
56 const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version?.trim() ?? "");
57 return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])];
58}
59
60function compareVersions(left: ReadonlyArray<number>, right: ReadonlyArray<number>) {
61 for (let index = 0; index < 3; index++) {
62 const difference = (left[index] ?? 0) - (right[index] ?? 0);
63 if (difference !== 0) return difference;
64 }
65 return 0;
66}
67
68/**
69 * 1.4.2 through 1.4.5 create or overwrite skills/system/run-workflow/SKILL.md in the workspace
70 * unless `run_workflow` is disabled. Older builds offer the tool and leave the file alone.
71 */
72function antigravityWritesRunWorkflowSkill(version: string | null | undefined): boolean {
73 const parsed = parseAntigravityVersion(version);
74 return (
75 parsed !== undefined &&
76 compareVersions(parsed, [1, 4, 2]) >= 0 &&
77 compareVersions(parsed, [1, 4, 5]) <= 0
78 );
79}
80
81/**
82 * Releases T3 qualified for native range reads and linked video. On 1.4.5 the client-read path
83 * sends a whole file to serve one line, and the native reader keeps diff edits.
84 */
85export function isQualifiedAntigravityRelease(version: string | null | undefined): boolean {
86 return version?.trim() === "1.4.5";
87}
88
89// `agy acp` 1.5.1 offers every model to every ACP client; older runtimes allowlist editors.
90const OPEN_MODEL_ACCESS_RELEASE = [1, 5, 1] as const;
91
92/**
93 * 1.4.5 appends `systemPrompt.append` to its system instructions on new, load and resume, and
94 * keeps none of it between processes, so every activation sends it again.
95 */
96export const antigravityAppendsSystemPrompt = (
97 initializeResult: EffectAcpSchema.InitializeResponse,
98) => {
99 if (isQualifiedAntigravityRelease(initializeResult.agentInfo?.version)) return true;
100 const extension = initializeResult.agentCapabilities?._meta?.antigravity;
101 return (
102 Predicate.isObject(extension) &&
103 Predicate.isObject(extension.systemPrompt) &&
104 extension.systemPrompt.append === true
105 );
106};
107
108export function antigravitySessionMeta(
109 initializeResult: EffectAcpSchema.InitializeResponse,
110 systemPromptAppend?: string,
111): EffectAcpSchema.NewSessionRequest["_meta"] | undefined {
112 const antigravity = {
113 ...(antigravityWritesRunWorkflowSkill(initializeResult.agentInfo?.version)
114 ? { disabledTools: ["run_workflow"] }
115 : {}),
116 ...(systemPromptAppend !== undefined && antigravityAppendsSystemPrompt(initializeResult)
117 ? { systemPrompt: { append: systemPromptAppend } }
118 : {}),
119 };
120 return Object.keys(antigravity).length === 0 ? undefined : { antigravity };
121}
122
123/** Older releases offer non-Gemini models only to allowlisted clients such as Zed. */
124export function antigravityClientInfo(version: string | null | undefined) {
125 const parsed = parseAntigravityVersion(version);
126 return parsed !== undefined && compareVersions(parsed, OPEN_MODEL_ACCESS_RELEASE) >= 0
127 ? { name: "t3-code", title: "T3 Code", version: packageJson.version }
128 : { name: "zed", version: "0.0.0" };
129}
130
131export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* (
132 input: AntigravityAcpRuntimeInput,
133): Effect.fn.Return<
134 AcpSessionRuntime.AcpSessionRuntime["Service"],
135 EffectAcpErrors.AcpError,
136 SecureRandom.SecureRandom | Scope.Scope
137> {
138 const context = yield* Layer.build(
139 AcpSessionRuntime.layer({
140 ...input,
141 authMethodId: input.authMethod ?? "oauth-personal",
142 authenticateEagerly: true,
143 resumeMethod: "resume",
144 cancelBehavior: "wait-for-prompt",
145 clientInfo: antigravityClientInfo(input.runtimeVersion),
146 sessionMeta: (initializeResult) =>
147 antigravitySessionMeta(initializeResult, input.sessionSystemPromptAppend),
148 clientCapabilities: {
149 ...input.clientCapabilities,
150 // Advertising writes swaps the agent's diff edits for whole-file client_edit_file.
151 fs: {
152 readTextFile:
153 input.clientFileReads === true && !isQualifiedAntigravityRelease(input.runtimeVersion),
154 writeTextFile: false,
155 },
156 terminal: false,
157 },
158 transformStdout: makeAntigravityStdoutTransform(
159 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
160 ),
161 onStderr: makeAntigravityStderrHandler(
162 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
163 ),
164 transformSessionUpdate: normalizeAntigravitySessionUpdate,
165 }).pipe(
166 Layer.provide(
167 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
168 ),
169 ),
170 );
171 return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context));
172});
173
174export function antigravityPermissionMode(runtimeMode: RuntimeMode): string {
175 switch (runtimeMode) {
176 case "full-access":
177 return "yolo";
178 case "auto-accept-edits":
179 return "auto_edit";
180 case "auto":
181 case "approval-required":
182 return "default";
183 }
184}
185
186/**
187 * The agy CLI rejects the managed runtime's `yolo` and `auto_edit`. It stays in `default`, where
188 * it asks before acting and T3 answers from the live runtime policy, so permission changes apply
189 * on the next turn without a restart.
190 */
191export function antigravityCliSessionMode(modeId: string): string {
192 return modeId === "yolo" || modeId === "auto_edit" ? "default" : modeId;
193}
194
195/** The runtime mode an Antigravity session mode grants; the inverse of `antigravityPermissionMode`. */
196export function antigravityRuntimeModeForSessionMode(modeId: string): RuntimeMode | undefined {
197 switch (modeId) {
198 case "yolo":
199 return "full-access";
200 case "auto_edit":
201 return "auto-accept-edits";
202 case "default":
203 return "approval-required";
204 default:
205 return undefined;
206 }
207}
208
209export function antigravityModelOptions(
210 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
211) {
212 const model = configOptions.find((option) => option.id === "model");
213 if (model?.type !== "select") return [];
214 return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options));
215}
216
217/** Runtime 1.4 reports thinking as its own option; 1.3 encoded it in the model ID. */
218export function antigravityThinkingConfig(
219 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
220) {
221 const option = configOptions?.find((candidate) => candidate.category === "thought_level");
222 return option?.type === "select" ? option : undefined;
223}
224
225/**
226 * Runtime 1.4 lists `gemini-3.8-flash` where 1.3 listed `gemini-3.8-flash-high`. The agent
227 * still accepts the old IDs, so pass them through; the current model may have no thinking option.
228 */
229function legacyAntigravityFamily(
230 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
231 model: string,
232) {
233 return antigravityModelOptions(configOptions)
234 .map((option) => option.value)
235 .filter((family) => model.startsWith(`${family}-`))
236 .toSorted((left, right) => right.length - left.length)[0];
237}
238
239function resolveAntigravityModel(input: {
240 readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
241 readonly model: string | null | undefined;
242 readonly defaultModel?: string | undefined;
243}): string | undefined {
244 const modelConfig = input.configOptions.find((option) => option.id === "model");
245 const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined;
246 if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model;
247 const options = antigravityModelOptions(input.configOptions);
248 return input.defaultModel &&
249 (options.some((option) => option.value === input.defaultModel) ||
250 legacyAntigravityFamily(input.configOptions, input.defaultModel) !== undefined)
251 ? input.defaultModel
252 : current;
253}
254
255/** Never replace a saved selection with the default returned by a cold resume. */
256export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpModelSelection")(
257 function* <E>(input: {
258 readonly runtime: Pick<
259 AcpSessionRuntime.AcpSessionRuntime["Service"],
260 "getConfigOptions" | "setModel" | "setConfigOption"
261 >;
262 readonly model: string | null | undefined;
263 readonly defaultModel?: string | undefined;
264 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
265 }): Effect.fn.Return<string | undefined, E> {
266 const configOptions = yield* input.runtime.getConfigOptions;
267 const modelConfig = configOptions.find((option) => option.id === "model");
268 const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined;
269 const resolved = resolveAntigravityModel({
270 configOptions,
271 model: input.model,
272 defaultModel: input.defaultModel,
273 });
274 const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL;
275 if (resolved === undefined || (!explicit && resolved === current)) return current;
276 const options = antigravityModelOptions(configOptions);
277 const legacyFamily = options.some((option) => option.value === resolved)
278 ? undefined
279 : legacyAntigravityFamily(configOptions, resolved);
280 if (legacyFamily !== undefined) {
281 yield* input.runtime
282 .setConfigOption("model", resolved, { allowUnlistedValue: true })
283 .pipe(Effect.mapError(input.mapError));
284 return legacyFamily;
285 }
286 if (!options.some((option) => option.value === resolved)) {
287 return yield* Effect.fail(
288 input.mapError(
289 EffectAcpErrors.AcpRequestError.invalidParams(
290 `Antigravity model '${resolved}' is unavailable for this Google account. Select an available model.`,
291 ),
292 ),
293 );
294 }
295 yield* input.runtime.setModel(resolved).pipe(Effect.mapError(input.mapError));
296 return resolved;
297 },
298);
299
300const IMAGE_MIME_TYPES = new Set(["image/bmp", "image/jpeg", "image/png", "image/webp"]);
301const AUDIO_MIME_TYPES = new Set([
302 "audio/aac",
303 "audio/flac",
304 "audio/mp3",
305 "audio/mpeg",
306 "audio/mp4",
307 "audio/m4a",
308 "audio/x-m4a",
309 "audio/ogg",
310 "audio/wav",
311 "audio/x-wav",
312 "audio/webm",
313]);
314const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024;
315// The agent inlines linked video bytes into one model request, so keep it under the inline limit.
316const ANTIGRAVITY_MAX_VIDEO_ATTACHMENT_BYTES = 20 * 1024 * 1024;
317
318/**
319 * Only Gemini takes video. The evidence is the session's resolved model, so an account default
320 * counts once the agent reports it; opaque ids such as `agy-model` need a Gemini display name.
321 */
322function antigravityModelAcceptsVideo(
323 model: { readonly id: string; readonly name: string | undefined } | undefined,
324): boolean {
325 return model !== undefined && (/^gemini-/i.test(model.id) || /^gemini\b/i.test(model.name ?? ""));
326}
327const TEXT_MIME_TYPES = new Set([
328 "application/json",
329 "application/ld+json",
330 "application/javascript",
331 "application/typescript",
332 "application/xml",
333 "application/yaml",
334 "application/x-yaml",
335 "application/x-sh",
336]);
337const TEXT_FILE_EXTENSIONS = new Set([
338 ".txt",
339 ".md",
340 ".mdx",
341 ".json",
342 ".jsonl",
343 ".yaml",
344 ".yml",
345 ".toml",
346 ".xml",
347 ".csv",
348 ".tsv",
349 ".js",
350 ".jsx",
351 ".mjs",
352 ".cjs",
353 ".ts",
354 ".tsx",
355 ".html",
356 ".css",
357 ".scss",
358 ".less",
359 ".py",
360 ".rs",
361 ".go",
362 ".java",
363 ".kt",
364 ".swift",
365 ".c",
366 ".h",
367 ".cc",
368 ".cpp",
369 ".hpp",
370 ".cs",
371 ".rb",
372 ".php",
373 ".sh",
374 ".bash",
375 ".zsh",
376 ".sql",
377 ".graphql",
378 ".svelte",
379 ".vue",
380 ".log",
381 ".diff",
382 ".patch",
383 ".ini",
384 ".conf",
385]);
386const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024;
387const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES;
388
389export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: {
390 readonly input: ProviderSendTurnInput["input"];
391 readonly attachments: ProviderSendTurnInput["attachments"];
392 readonly resolveAttachmentPath: (
393 attachment: NonNullable<ProviderSendTurnInput["attachments"]>[number],
394 ) => string | null;
395 readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"];
396 /** Linked video needs a runtime whose `resource_link` handling is qualified and a Gemini model. */
397 readonly agentVersion?: string | undefined;
398 readonly currentModel?: { readonly id: string; readonly name: string | undefined } | undefined;
399}): Effect.fn.Return<
400 ReadonlyArray<EffectAcpSchema.ContentBlock>,
401 EffectAcpErrors.AcpError,
402 FileSystem.FileSystem | Path.Path
403> {
404 const fileSystem = yield* FileSystem.FileSystem;
405 const path = yield* Path.Path;
406 // The agent runs on this host.
407 const platform = yield* HostProcess.Platform;
408 const blocks: Array<EffectAcpSchema.ContentBlock> = [];
409 const text = input.input?.trim();
410 if (text) blocks.push({ type: "text", text });
411 let totalBytes = 0;
412
413 for (const attachment of input.attachments ?? []) {
414 const isPastedText =
415 attachment.type === "file" &&
416 "source" in attachment &&
417 attachment.source?._tag === "pasted-text";
418 // The text already carries the path; keep folded pastes lazy so agents can search without embedding the whole file.
419 const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? "";
420 const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType);
421 const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType);
422 const pdf = attachment.type === "file" && mimeType === "application/pdf";
423 const video =
424 attachment.type === "file" &&
425 mimeType === "video/mp4" &&
426 isQualifiedAntigravityRelease(input.agentVersion) &&
427 // The agent turns a file URI into a path with unquote(urlparse(uri).path), which drops
428 // Windows drives and UNC hosts. Keep the saved path there until Google fixes it.
429 platform !== "win32" &&
430 antigravityModelAcceptsVideo(input.currentModel);
431 const textFile =
432 attachment.type === "file" &&
433 (mimeType.startsWith("text/") ||
434 TEXT_MIME_TYPES.has(mimeType) ||
435 TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase()));
436 const isPathOnly =
437 (attachment.type === "image" && (!image || input.promptCapabilities?.image !== true)) ||
438 (attachment.type === "file" &&
439 (isPastedText ||
440 (audio
441 ? input.promptCapabilities?.audio !== true
442 : pdf || textFile
443 ? input.promptCapabilities?.embeddedContext !== true
444 : !video)));
445 const attachmentPath = input.resolveAttachmentPath(attachment);
446 if (!attachmentPath) {
447 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
448 `Invalid attachment '${attachment.name}'.`,
449 );
450 }
451 const info = yield* fileSystem
452 .stat(attachmentPath)
453 .pipe(
454 Effect.mapError(() =>
455 EffectAcpErrors.AcpRequestError.invalidParams(
456 `Could not read attachment '${attachment.name}'.`,
457 ),
458 ),
459 );
460 if (info.type !== "File") {
461 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
462 `Could not read attachment '${attachment.name}'.`,
463 );
464 }
465 if (isPathOnly) continue;
466 const size = Number(info.size);
467 const limit = image
468 ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
469 : audio
470 ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES
471 : pdf
472 ? MAX_TOTAL_ATTACHMENT_BYTES
473 : video
474 ? ANTIGRAVITY_MAX_VIDEO_ATTACHMENT_BYTES
475 : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES;
476 if (
477 attachment.type === "file" &&
478 (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES)
479 ) {
480 continue;
481 }
482 totalBytes += size;
483 if (size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
484 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
485 `Image '${attachment.name}' is too large. Antigravity accepts images up to 10 MiB and 50 MiB of native attachments per message.`,
486 );
487 }
488 const uri = yield* path.toFileUrl(attachmentPath).pipe(
489 Effect.map((url) => url.href),
490 Effect.mapError(() =>
491 EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`),
492 ),
493 );
494 if (pdf || video) {
495 blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType, size });
496 continue;
497 }
498 const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe(
499 Stream.runCollect,
500 Effect.map((chunks) => Buffer.concat(chunks)),
501 Effect.mapError(() =>
502 EffectAcpErrors.AcpRequestError.invalidParams(
503 `Could not read attachment '${attachment.name}'.`,
504 ),
505 ),
506 );
507 totalBytes += bytes.length - size;
508 if (bytes.length > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
509 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
510 `Attachment '${attachment.name}' changed while being read and is too large.`,
511 );
512 }
513 if (image) {
514 blocks.push({ type: "image", data: Buffer.from(bytes).toString("base64"), mimeType });
515 } else if (audio) {
516 blocks.push({ type: "audio", data: Buffer.from(bytes).toString("base64"), mimeType });
517 } else {
518 const decoded = yield* Effect.try({
519 try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
520 catch: () =>
521 EffectAcpErrors.AcpRequestError.invalidParams(
522 `Attachment '${attachment.name}' is not a UTF-8 text file.`,
523 ),
524 });
525 if (decoded.includes("\0")) {
526 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
527 `Attachment '${attachment.name}' contains binary data.`,
528 );
529 }
530 blocks.push({ type: "resource", resource: { uri, mimeType, text: decoded } });
531 }
532 }
533 if (blocks.length === 0) {
534 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
535 "A turn requires text or supported attachments.",
536 );
537 }
538 return blocks;
539});