Skip to content
Source
Sign inSign up
Open History to see the last commit

AntigravityProvider.ts

550 lines21.1 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 ANTIGRAVITY_DEFAULT_MODEL,
4 ProviderDriverKind,
5 type AntigravitySettings,
6 type ProviderSetupError,
7 type ServerProvider,
8 type ServerProviderModel,
9 type ServerProviderSlashCommand,
10 type ServerProviderUsageLimits,
11} from "@t3tools/contracts";
12import { createModelCapabilities } from "@t3tools/shared/model";
13import * as Context from "effect/Context";
14import * as DateTime from "effect/DateTime";
15import * as Deferred from "effect/Deferred";
16import * as Effect from "effect/Effect";
17import * as FiberHandle from "effect/FiberHandle";
18import * as Option from "effect/Option";
19import * as Predicate from "effect/Predicate";
20import * as Result from "effect/Result";
21import * as Stream from "effect/Stream";
22import * as SubscriptionRef from "effect/SubscriptionRef";
23import type * as EffectAcpErrors from "effect-acp/errors";
24import type * as EffectAcpSchema from "effect-acp/compat";
25​
26import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
27import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig";
28import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
29import {
30 makeManualOnlyProviderMaintenanceCapabilities,
31 type ProviderMaintenanceCapabilities,
32} from "@t3tools/provider-core/server/maintenanceResolver";
33import {
34 buildServerProvider,
35 isCommandMissingCause,
36 type ServerProviderDraft,
37} from "@t3tools/provider-core/server/snapshotProbe";
38import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits";
39​
40import { antigravityThinkingConfig } from "./acp/AntigravityAcpSupport.ts";
41​
42const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
43const MAX_WORKSPACE_SNAPSHOTS = 32;
44const HEALTH_CHECK_TIMEOUT_MS = 90_000;
45const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";
46const AUTH_UNCHECKED_MESSAGE =
47 "Antigravity is installed. Google account access is not checked yet.";
48// Set while a finished quota read republishes, so that health check does not read again.
49const PublishingUsage = Context.Reference<boolean>("t3/provider/AntigravityPublishingUsage", {
50 defaultValue: () => false,
51});
52​
53type SessionSetupResult = Pick<
54 AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"],
55 "configOptions" | "models"
56>;
57​
58type ThinkingConfig = NonNullable<ReturnType<typeof antigravityThinkingConfig>>;
59​
60/** Thinking options each model reported when selected; `null` means it has none. */
61export type AntigravityThinkingLevels = ReadonlyMap<string, ThinkingConfig | null>;
62​
63function thinkingCapabilities(config: ThinkingConfig | null | undefined, observed: boolean) {
64 const descriptor = config
65 ? acpProviderOptionDescriptors({ configOptions: [config], modeState: undefined })[0]
66 : undefined;
67 if (descriptor?.type !== "select") return EMPTY_MODEL_CAPABILITIES;
68 // Another model's selected level is not this model's default.
69 const { currentValue: _currentValue, ...unobserved } = descriptor;
70 return createModelCapabilities({ optionDescriptors: [observed ? descriptor : unobserved] });
72​
73export function buildAntigravityModelsFromSession(
74 setup: SessionSetupResult,
75 thinkingLevels: AntigravityThinkingLevels = new Map(),
76): ReadonlyArray<ServerProviderModel> {
77 const config = setup.configOptions?.find(
78 (option) => option.id === "model" || option.category === "model",
79 );
80 const currentValue =
81 config?.type === "select" ? config.currentValue : setup.models?.currentModelId;
82 const entries =
83 config?.type === "select"
84 ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options))
85 : config === undefined
86 ? (setup.models?.availableModels.map((model) => ({
87 value: model.modelId,
88 name: model.name,
89 })) ?? [])
90 : [];
91 const currentThinking = antigravityThinkingConfig(setup.configOptions);
92 const seen = new Set<string>();
93 // The agent can list two IDs under one name; label the later ones so the picker tells them apart.
94 const names = new Set<string>();
95 return entries.flatMap((entry): ServerProviderModel[] => {
96 if (!entry.value.trim() || seen.has(entry.value)) return [];
97 seen.add(entry.value);
98 const baseName = entry.name.trim() ? entry.name : entry.value;
99 const name = names.has(baseName) ? `${baseName} (${entry.value})` : baseName;
100 names.add(baseName);
101 const observed =
102 entry.value === currentValue ? (currentThinking ?? null) : thinkingLevels.get(entry.value);
103 return [
104 {
105 slug: entry.value,
106 name,
107 isCustom: false,
108 ...(entry.value === currentValue
109 ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }
110 : {}),
111 capabilities:
112 observed === undefined
113 ? thinkingCapabilities(currentThinking, false)
114 : thinkingCapabilities(observed, true),
115 },
116 ];
117 });
119​
120function nativeCommands(
121 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
122): ReadonlyArray<ServerProviderSlashCommand> {
123 const seen = new Set<string>();
124 return commands.flatMap((command): ServerProviderSlashCommand[] => {
125 if (!command.name.trim() || seen.has(command.name)) return [];
126 seen.add(command.name);
127 const description = command.description.trim();
128 const hint = command.input?.hint.trim();
129 return [
130 {
131 name: command.name,
132 ...(description ? { description } : {}),
133 ...(hint ? { input: { hint } } : {}),
134 },
135 ];
136 });
138​
139function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean {
140 if (error._tag === "AcpSpawnError") {
141 return (
142 isCommandMissingCause(error.cause) ||
143 (Predicate.isObject(error.cause) && error.cause.code === "ENOENT")
144 );
145 }
146 return (
147 error._tag === "ProviderSetupError" &&
148 error.operation === "resolve" &&
149 /not installed|missing|incomplete|does not publish/i.test(error.detail)
150 );
152​
153interface AntigravityProviderState {
154 readonly draft: ServerProviderDraft;
155 readonly authRevision: number;
157​
158interface AntigravityProviderOptions {
159 readonly environment?: NodeJS.ProcessEnv;
160 readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect<ServerProvider>;
161 readonly probe: Effect.Effect<
162 EffectAcpSchema.InitializeResponse,
163 EffectAcpErrors.AcpError | ProviderSetupError
164 >;
165 readonly supportsTextGeneration: Effect.Effect<boolean>;
166 readonly probeUsage?: Effect.Effect<ServerProviderUsageLimits>;
167 readonly clearUsage?: Effect.Effect<void>;
168 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
169 readonly auth?: { readonly type: string; readonly label: string };
171​
172export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* (
173 settings: AntigravitySettings,
174 options: AntigravityProviderOptions,
175) {
176 const usesCredentials =
177 options.auth?.type === "gemini-api-key" || options.auth?.type === "agent-platform";
178 const signInMessage = usesCredentials
179 ? `Connect with ${options.auth.label} to use Antigravity and load models.`
180 : SIGN_IN_MESSAGE;
181 const authUncheckedMessage = usesCredentials
182 ? `Antigravity is installed. Connect with ${options.auth.label} to load models.`
183 : AUTH_UNCHECKED_MESSAGE;
184 const checkedAt = DateTime.formatIso(yield* DateTime.now);
185 const initialDraft = {
186 ...buildServerProvider({
187 presentation: {
188 displayName: "Antigravity",
189 showInteractionModeToggle: false,
190 // No automatic review, so Auto falls back to Supervised instead of approving everything.
191 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"],
192 },
193 enabled: settings.enabled,
194 checkedAt,
195 models: [],
196 probe: {
197 installed: false,
198 version: null,
199 status: "warning",
200 auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) },
201 message: settings.enabled
202 ? "Checking Antigravity availability."
203 : "Antigravity is disabled in T3 Code settings.",
204 },
205 }),
206 setup: { canAuthenticate: true, canInstall: true },
207 supportsConversationRollback: false,
208 supportsTextGeneration: false,
209 workspaceSnapshots: [],
210 } satisfies ServerProviderDraft;
211 const metadata = yield* SubscriptionRef.make<AntigravityProviderState>({
212 draft: initialDraft,
213 authRevision: 0,
214 });
215 // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them.
216 const discoveredSkills = new Map<string, ServerProvider["skills"]>();
217 const thinkingLevels = new Map<string, ThinkingConfig | null>();
218 const observeThinkingLevels = (
219 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
220 ) => {
221 const model = configOptions?.find(
222 (option) => option.id === "model" || option.category === "model",
223 );
224 if (model?.type === "select" && model.currentValue) {
225 thinkingLevels.set(model.currentValue, antigravityThinkingConfig(configOptions) ?? null);
226 }
227 };
228 const getSnapshot = SubscriptionRef.get(metadata).pipe(
229 Effect.flatMap((state) => options.stampIdentity(state.draft)),
230 );
231​
232 // The quota read can take 30 seconds, so it never blocks readiness or a session start.
233 // One read runs at a time; sign-in and sign-out interrupt it so a stale account never lands.
234 const usageRead = yield* FiberHandle.make();
235 const republish = yield* Deferred.make<Effect.Effect<ServerProvider>>();
236 const scope = yield* Effect.scope;
237 const refreshUsage = Effect.fn("AntigravityProvider.refreshUsage")(function* () {
238 if (!options.probeUsage || (yield* PublishingUsage)) return;
239 yield* FiberHandle.run(
240 usageRead,
241 options.probeUsage.pipe(
242 Effect.flatMap((probed) =>
243 SubscriptionRef.update(metadata, (state) => ({
244 ...state,
245 draft: {
246 ...state.draft,
247 usageLimits: resolveUsageLimitsAfterProbe({
248 published: state.draft.usageLimits,
249 probed,
250 }),
251 },
252 })),
253 ),
254 // Enrichment cannot change published limits; a health check publishes them.
255 Effect.andThen(
256 Deferred.await(republish).pipe(
257 Effect.flatten,
258 Effect.provideService(PublishingUsage, true),
259 Effect.ignoreCause({ log: true }),
260 Effect.forkIn(scope),
261 ),
262 ),
263 ),
264 // Store the fiber before it runs, so a sign-out during the read can interrupt it.
265 { onlyIfMissing: true, startImmediately: false },
266 );
267 });
268 const resetUsage = Effect.gen(function* () {
269 yield* FiberHandle.clear(usageRead);
270 if (options.clearUsage) yield* options.clearUsage;
271 });
272​
273 const checkProvider = Effect.fn("checkAntigravityProvider")(function* () {
274 if (!settings.enabled) return yield* getSnapshot;
275 const before = yield* SubscriptionRef.get(metadata);
276 const timeoutMs = resolveProviderProbeTimeoutMs(
277 "antigravity",
278 HEALTH_CHECK_TIMEOUT_MS,
279 options.environment,
280 );
281 const result = yield* options.probe.pipe(Effect.timeoutOption(timeoutMs), Effect.result);
282 const initialized =
283 Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined;
284 const version = initialized?.agentInfo?.version;
285 const failure = Result.isFailure(result) ? result.failure : undefined;
286 const missingInstallation = failure !== undefined && isMissingInstallation(failure);
287 const errorMessage =
288 initialized !== undefined
289 ? undefined
290 : failure?._tag === "ProviderSetupError"
291 ? failure.detail.trim() || "Antigravity could not complete its local health check."
292 : missingInstallation
293 ? "Antigravity ACP runtime is not installed or its executable could not be found."
294 : failure
295 ? "Antigravity could not complete its local health check."
296 : `Antigravity did not respond to its local health check within ${timeoutMs / 1_000} seconds.`;
297 const supportsTextGeneration =
298 initialized !== undefined ? yield* options.supportsTextGeneration : false;
299 const updatedAt = DateTime.formatIso(yield* DateTime.now);
300 const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => {
301 if (state.authRevision !== before.authRevision) return state;
302 const { message: _previousMessage, ...draft } = state.draft;
303 const authenticated = draft.auth.status === "authenticated";
304 const message =
305 errorMessage ??
306 (authenticated
307 ? undefined
308 : draft.auth.status === "unauthenticated"
309 ? signInMessage
310 : authUncheckedMessage);
311 return {
312 ...state,
313 draft: {
314 ...draft,
315 installed: !missingInstallation,
316 // Lightweight custom-launcher probes lack versions; preserve the real handshake's version.
317 version: version === "unknown" ? draft.version : version || draft.version,
318 status: errorMessage ? "error" : authenticated ? "ready" : "warning",
319 checkedAt: updatedAt,
320 usageLimits: missingInstallation ? undefined : draft.usageLimits,
321 ...(missingInstallation
322 ? {
323 models: [],
324 slashCommands: [],
325 skills: [],
326 workspaceSnapshots: [],
327 supportsTextGeneration: false,
328 }
329 : {}),
330 ...(initialized !== undefined
331 ? {
332 supportsTextGeneration:
333 supportsTextGeneration && draft.auth.status !== "unauthenticated",
334 }
335 : {}),
336 ...(message ? { message } : {}),
337 },
338 } satisfies AntigravityProviderState;
339 });
340 if (initialized !== undefined) yield* refreshUsage();
341 return yield* options.stampIdentity(next.draft);
342 });
343​
344 const maintenanceCapabilities =
345 options.maintenanceCapabilities ??
346 makeManualOnlyProviderMaintenanceCapabilities({
347 provider: ProviderDriverKind.make("antigravity"),
348 packageName: null,
349 });
350 const managed = yield* makeManagedServerProvider({
351 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
352 getSettings: Effect.succeed(settings),
353 streamSettings: Stream.empty,
354 haveSettingsChanged: () => false,
355 initialSnapshot: () => getSnapshot,
356 checkProvider: checkProvider(),
357 enrichSnapshot: ({ publishSnapshot }) =>
358 SubscriptionRef.changes(metadata).pipe(
359 Stream.runForEach((state) =>
360 options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)),
361 ),
362 ),
363 });
364 yield* Deferred.succeed(republish, managed.refresh);
365​
366 const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* (
367 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
368 cwd?: string,
369 ) {
370 if (!settings.enabled) return;
371 observeThinkingLevels(started.sessionSetupResult.configOptions);
372 const before = yield* SubscriptionRef.get(metadata);
373 const supportsTextGeneration = yield* options.supportsTextGeneration;
374 const updatedAt = DateTime.formatIso(yield* DateTime.now);
375 yield* SubscriptionRef.update(metadata, (state) => {
376 if (
377 state.authRevision !== before.authRevision &&
378 state.draft.auth.status === "unauthenticated"
379 ) {
380 return state;
381 }
382 const { message: _previousMessage, ...draft } = state.draft;
383 const workspaces = draft.workspaceSnapshots ?? [];
384 const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined;
385 return {
386 authRevision: state.authRevision + 1,
387 draft: {
388 ...draft,
389 installed: true,
390 status: "ready",
391 version: started.initializeResult.agentInfo?.version || draft.version,
392 auth: {
393 status: "authenticated",
394 type: options.auth?.type ?? "oauth-personal",
395 label: options.auth?.label ?? "Google account",
396 },
397 checkedAt: updatedAt,
398 models: buildAntigravityModelsFromSession(started.sessionSetupResult, thinkingLevels),
399 supportsTextGeneration,
400 ...(cwd
401 ? {
402 workspaceSnapshots: [
403 ...workspaces.filter((entry) => entry.cwd !== cwd),
404 {
405 cwd,
406 checkedAt: updatedAt,
407 slashCommands: workspace?.slashCommands ?? draft.slashCommands,
408 skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [],
409 },
410 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
411 }
412 : {}),
413 },
414 } satisfies AntigravityProviderState;
415 });
416 if (!before.draft.usageLimits || before.draft.usageLimits.unavailable) yield* refreshUsage();
417 });
418​
419 const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* (
420 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
421 ) {
422 observeThinkingLevels(configOptions);
423 const models = buildAntigravityModelsFromSession({ configOptions }, thinkingLevels);
424 yield* SubscriptionRef.update(metadata, (state) => {
425 if (state.draft.auth.status !== "authenticated") return state;
426 return { ...state, draft: { ...state.draft, models } };
427 });
428 });
429​
430 const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* (
431 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
432 cwd?: string,
433 ) {
434 if (!settings.enabled) return;
435 const slashCommands = nativeCommands(commands);
436 const updatedAt = DateTime.formatIso(yield* DateTime.now);
437 yield* SubscriptionRef.update(metadata, (state) => {
438 if (state.draft.auth.status === "unauthenticated") return state;
439 return {
440 ...state,
441 draft: {
442 ...state.draft,
443 slashCommands,
444 ...(cwd
445 ? {
446 workspaceSnapshots: [
447 ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd),
448 {
449 cwd,
450 checkedAt: updatedAt,
451 slashCommands,
452 skills:
453 state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ??
454 discoveredSkills.get(cwd) ??
455 [],
456 },
457 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
458 }
459 : {}),
460 },
461 };
462 });
463 });
464​
465 const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () {
466 yield* resetUsage;
467 if (!settings.enabled) return;
468 const updatedAt = DateTime.formatIso(yield* DateTime.now);
469 yield* SubscriptionRef.update(
470 metadata,
471 (state) =>
472 ({
473 authRevision: state.authRevision + 1,
474 draft: {
475 ...state.draft,
476 auth: {
477 status: "unauthenticated",
478 ...(usesCredentials ? options.auth : {}),
479 },
480 status: "warning",
481 message: signInMessage,
482 checkedAt: updatedAt,
483 models: [],
484 slashCommands: [],
485 skills: [],
486 workspaceSnapshots: [],
487 supportsTextGeneration: false,
488 usageLimits: undefined,
489 },
490 }) satisfies AntigravityProviderState,
491 );
492 discoveredSkills.clear();
493 thinkingLevels.clear();
494 });
495​
496 const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* (
497 cwd: string,
498 skills?: ServerProvider["skills"],
499 ) {
500 if (skills) {
501 discoveredSkills.set(cwd, skills);
502 yield* SubscriptionRef.update(metadata, (state) =>
503 state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd)
504 ? {
505 ...state,
506 draft: {
507 ...state.draft,
508 workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) =>
509 entry.cwd === cwd ? { ...entry, skills } : entry,
510 ),
511 },
512 }
513 : state,
514 );
515 }
516 const snapshot = yield* getSnapshot;
517 const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd);
518 const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? [];
519 return workspace
520 ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills }
521 : { ...snapshot, skills: resolvedSkills };
522 });
523​
524 return {
525 snapshot: { ...managed, getSnapshot },
526 onSessionStarted,
527 onConfigOptionsUpdated,
528 onAvailableCommands,
529 // The new sign-in may be another account. Clear and republish its limits so a failed read
530 // cannot fall back to them; the session start then reads the new account's limits.
531 onSignedIn: resetUsage.pipe(
532 Effect.andThen(
533 SubscriptionRef.update(metadata, (state) => {
534 const { usageLimits: _previousAccount, ...draft } = state.draft;
535 return { ...state, draft };
536 }),
537 ),
538 Effect.andThen(
539 Deferred.await(republish).pipe(
540 Effect.flatten,
541 Effect.provideService(PublishingUsage, true),
542 ),
543 ),
544 Effect.asVoid,
545 ),
546 onSignedOut: clearAccountMetadata(),
547 onAuthRequired: clearAccountMetadata(),
548 snapshotForCwd,
549 };
550});