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