| 1 | import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; |
| 2 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Ref from "effect/Ref"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as Scope from "effect/Scope"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import { type AcpError, AcpRequestError } from "effect-acp/errors"; |
| 13 | |
| 14 | import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; |
| 15 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; |
| 16 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 17 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; |
| 18 | |
| 19 | const ANTIGRAVITY_TIMEOUT_MS = 180_000; |
| 20 | const MAX_OUTPUT_CHARS = 128_000; |
| 21 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 22 | const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); |
| 23 | const Configuration = Schema.Record(Schema.String, Schema.Unknown); |
| 24 | const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration)); |
| 25 | const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); |
| 26 | |
| 27 | type 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 | |
| 49 | export 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. */ |
| 65 | export 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. */ |
| 95 | export 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 | }); |