Skip to content
Source
Sign inSign up

AntigravityTextGeneration.ts

346 lines13.0 KB

1import { ProviderSetupError, TextGenerationError } from "@t3tools/contracts";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Ref from "effect/Ref";
9import * as Schema from "effect/Schema";
10import * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12import { type AcpError, AcpRequestError } from "effect-acp/errors";
13​
14import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts";
15import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts";
16import {
17 ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
18 isAntigravitySignInRequiredError,
19} from "../provider/antigravityAuthSupport.ts";
20import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
21import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
22​
23const ANTIGRAVITY_TIMEOUT_MS = 180_000;
24const MAX_OUTPUT_CHARS = 128_000;
25const isTextGenerationError = Schema.is(TextGenerationError);
26const isProviderSetupError = Schema.is(ProviderSetupError);
27const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4)));
28const Configuration = Schema.Record(Schema.String, Schema.Unknown);
29const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration));
30const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration);
31​
32type AntigravityTextRuntime = Pick<
33 AcpSessionRuntime.AcpSessionRuntime["Service"],
34 | "start"
35 | "setMode"
36 | "getConfigOptions"
37 | "getEvents"
38 | "setModel"
39 | "setConfigOption"
40 | "prompt"
41 | "cancel"
42 | "handleSessionUpdate"
43 | "handleRequestPermission"
44 | "handleElicitation"
45 | "handleReadTextFile"
46 | "handleWriteTextFile"
47 | "handleCreateTerminal"
48 | "handleTerminalOutput"
49 | "handleTerminalWaitForExit"
50 | "handleTerminalKill"
51 | "handleTerminalRelease"
52 | "handleUnknownExtRequest"
53>;
54​
55export interface AntigravityTextGenerationOptions {
56 readonly profileDirectory: string;
57 readonly defaultModel?: Effect.Effect<string | undefined>;
58 readonly makeRuntime: (
59 cwd: string,
60 ) => Effect.Effect<AntigravityTextRuntime, AcpError | ProviderSetupError, Scope.Scope>;
61 readonly withProcess: <A, E, R>(
62 stop: Effect.Effect<void>,
63 task: Effect.Effect<A, E, R>,
64 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
66​
67/** Global hooks and MCP servers can run before a helper can deny a tool request. */
68export const isAntigravityTextGenerationAvailable = Effect.fn(
69 "isAntigravityTextGenerationAvailable",
70)(function* (profileDirectory: string) {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 for (const name of ["hooks.json", "mcp_config.json"]) {
74 const configurationPath = path.join(profileDirectory, "config", name);
75 if (!(yield* fs.exists(configurationPath))) {
76 continue;
77 }
78 const info = yield* fs.stat(configurationPath);
79 if (info.type !== "File" || info.size > 64_000n) {
80 return false;
81 }
82 const empty = yield* fs.readFileString(configurationPath).pipe(
83 Effect.flatMap(decodeConfiguration),
84 Effect.flatMap((configuration) =>
85 decodeConfigurationObject(
86 configuration[name === "hooks.json" ? "hooks" : "mcpServers"] ?? configuration,
87 ),
88 ),
89 Effect.map((configuration) => Object.keys(configuration).length === 0),
90 Effect.orElseSucceed(() => false),
91 );
92 if (!empty) return false;
93 }
94 return true;
95});
96​
97export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGeneration")(function* (
98 options: AntigravityTextGenerationOptions,
99) {
100 const fs = yield* FileSystem.FileSystem;
101 const path = yield* Path.Path;
102 const hasOnlyRuntimeDirectories = Effect.fn(
103 "AntigravityTextGeneration.hasOnlyRuntimeDirectories",
104 )(function* (cwd: string) {
105 const root = yield* fs.realPath(cwd);
106 // ACP creates these empty skill directories before the first prompt.
107 const allowed = new Set([
108 path.join(root, ".agents"),
109 path.join(root, ".agents", "skills"),
110 path.join(root, ".gemini"),
111 path.join(root, ".gemini", "skills"),
112 ]);
113 const directories = [root];
114 for (const directory of directories) {
115 for (const name of yield* fs.readDirectory(directory)) {
116 const entry = path.join(directory, name);
117 if (
118 !allowed.has(entry) ||
119 (yield* fs.realPath(entry)) !== entry ||
120 (yield* fs.stat(entry)).type !== "Directory"
121 ) {
122 return false;
123 }
124 directories.push(entry);
125 }
126 }
127 return true;
128 });
129​
130 const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe(
131 Effect.provideService(FileSystem.FileSystem, fs),
132 Effect.provideService(Path.Path, path),
133 );
134​
135 const runAntigravityJson = Effect.fn("AntigravityTextGeneration.runJson")(
136 function* <S extends Schema.Top>(input: TextGenerationOperations.Request<S>) {
137 const { operation } = input;
138 const scope = yield* Scope.make();
139 yield* Effect.addFinalizer((exit) => Scope.close(scope, exit));
140 const helper = Effect.gen(function* () {
141 if (!(yield* available)) {
142 return yield* new TextGenerationError({
143 operation,
144 detail:
145 "Antigravity text generation is unavailable for profiles with global hooks or MCP configuration. Select another system model.",
146 });
147 }
148​
149 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-" });
150 let sessionId: string | undefined;
151 yield* Effect.addFinalizer(() =>
152 removeAntigravitySessionFiles({
153 profileDirectory: options.profileDirectory,
154 sessionId,
155 cwd,
156 }).pipe(
157 Effect.provideService(FileSystem.FileSystem, fs),
158 Effect.provideService(Path.Path, path),
159 ),
160 );
161​
162 const rawResult = yield* Effect.gen(function* () {
163 const runtime = yield* options.makeRuntime(cwd);
164 yield* runtime.getEvents().pipe(
165 Stream.runForEach((event) =>
166 event._tag === "EventStreamBarrier"
167 ? Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid)
168 : Effect.void,
169 ),
170 Effect.forkScoped,
171 );
172 const output = yield* Ref.make("");
173 const rejected = yield* Deferred.make<never, TextGenerationError>();
174 const reject = (detail: string) =>
175 Deferred.fail(rejected, new TextGenerationError({ operation, detail })).pipe(
176 Effect.asVoid,
177 );
178 const rejectToolRequest = () =>
179 reject("Antigravity text generation requested a tool or user input.").pipe(
180 Effect.andThen(
181 Effect.fail(
182 new AcpRequestError({
183 code: -32601,
184 errorMessage: "Tools and user input are disabled for text generation.",
185 }),
186 ),
187 ),
188 );
189​
190 yield* runtime.handleRequestPermission(() =>
191 reject("Antigravity text generation requested a tool permission or user input.").pipe(
192 Effect.as({ outcome: { outcome: "cancelled" as const } }),
193 ),
194 );
195 yield* runtime.handleElicitation(() =>
196 reject("Antigravity text generation requested user input.").pipe(
197 Effect.as({ action: "decline" as const }),
198 ),
199 );
200 yield* runtime.handleReadTextFile(rejectToolRequest);
201 yield* runtime.handleWriteTextFile(rejectToolRequest);
202 yield* runtime.handleCreateTerminal(rejectToolRequest);
203 yield* runtime.handleTerminalOutput(rejectToolRequest);
204 yield* runtime.handleTerminalWaitForExit(rejectToolRequest);
205 yield* runtime.handleTerminalKill(rejectToolRequest);
206 yield* runtime.handleTerminalRelease(rejectToolRequest);
207 yield* runtime.handleUnknownExtRequest(rejectToolRequest);
208 yield* runtime.handleSessionUpdate((notification) =>
209 Effect.gen(function* () {
210 const update = notification.update;
211 if (
212 update.sessionUpdate === "tool_call" ||
213 update.sessionUpdate === "tool_call_update"
214 ) {
215 return yield* reject("Antigravity attempted tool work during text generation.");
216 }
217 if (
218 notification.sessionId !== sessionId ||
219 update.sessionUpdate !== "agent_message_chunk" ||
220 update.content.type !== "text"
221 ) {
222 return;
223 }
224 const text = update.content.text;
225 const exceeded = yield* Ref.modify(output, (current) =>
226 current.length + text.length > MAX_OUTPUT_CHARS
227 ? [true, current]
228 : [false, current + text],
229 );
230 if (exceeded) {
231 return yield* reject("Antigravity text generation exceeded the output limit.");
232 }
233 }),
234 );
235​
236 return yield* Effect.gen(function* () {
237 const started = yield* runtime.start();
238 sessionId = started.sessionId;
239 if (!isNativeSessionId(sessionId)) {
240 return yield* new TextGenerationError({
241 operation,
242 detail: "Antigravity returned an invalid text helper session ID.",
243 });
244 }
245 yield* runtime.setMode("default");
246 yield* applyAntigravityAcpModelSelection({
247 runtime,
248 model: input.modelSelection.model,
249 defaultModel: yield* options.defaultModel ?? Effect.undefined,
250 mapError: (cause) =>
251 new TextGenerationError({
252 operation,
253 detail: "Could not select the Antigravity model for text generation.",
254 cause,
255 }),
256 });
257​
258 const result = yield* runtime.prompt({
259 prompt: [
260 {
261 type: "text",
262 text: [
263 "Use only the input below. Do not use tools, read or write files, run commands, or ask questions.",
264 "Return only the requested JSON object.",
265 "",
266 input.prompt,
267 ].join("\n"),
268 },
269 ],
270 });
271 if (yield* Deferred.isDone(rejected)) {
272 return yield* Deferred.await(rejected);
273 }
274 if (result.stopReason === "cancelled") {
275 return yield* new TextGenerationError({
276 operation,
277 detail: "Antigravity text generation was cancelled.",
278 });
279 }
280 return (yield* Ref.get(output)).trim();
281 }).pipe(
282 Effect.onInterrupt(() =>
283 runtime.cancel.pipe(Effect.timeoutOption(2_000), Effect.ignore),
284 ),
285 Effect.raceFirst(Deferred.await(rejected)),
286 );
287 }).pipe(Effect.scoped);
288​
289 if (!(yield* hasOnlyRuntimeDirectories(cwd))) {
290 return yield* new TextGenerationError({
291 operation,
292 detail: "Antigravity wrote files during text generation.",
293 });
294 }
295 if (!rawResult) {
296 return yield* new TextGenerationError({
297 operation,
298 detail: "Antigravity returned empty text generation output.",
299 });
300 }
301 return yield* TextGenerationOperations.decodeJsonReply(input, "Antigravity", rawResult);
302 }).pipe(
303 Effect.scoped,
304 Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS),
305 Effect.flatMap(
306 Option.match({
307 onNone: () =>
308 Effect.fail(
309 new TextGenerationError({
310 operation,
311 detail: "Antigravity text generation timed out.",
312 }),
313 ),
314 onSome: Effect.succeed,
315 }),
316 ),
317 );
318​
319 return yield* options
320 .withProcess(Scope.close(scope, Exit.void), helper)
321 .pipe(Effect.provideService(Scope.Scope, scope));
322 },
323 (effect, input) =>
324 effect.pipe(
325 Effect.mapError((cause) =>
326 isTextGenerationError(cause)
327 ? cause
328 : new TextGenerationError({
329 operation: input.operation,
330 detail: isProviderSetupError(cause)
331 ? cause.detail
332 : isAntigravitySignInRequiredError(cause)
333 ? ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE
334 : "Antigravity text generation failed.",
335 cause,
336 }),
337 ),
338 Effect.scoped,
339 ),
340 );
341​
342 return TextGenerationOperations.fromRunner(
343 "AntigravityTextGeneration",
344 TextGenerationOperations.withoutInvestigation("Antigravity", runAntigravityJson),
345 );
346});