-
Notifications
You must be signed in to change notification settings - Fork 9.7k
Expand file tree
/
Copy pathantigravity.ts
More file actions
239 lines (229 loc) Β· 10.1 KB
/
Copy pathantigravity.ts
File metadata and controls
239 lines (229 loc) Β· 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { readFile as fsReadFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { DEFAULT_MODEL_OPTION } from './shared.js';
import type { RuntimeAgentDef } from '../types.js';
// `agy` v1.0.3 still has no `--model` flag (upstream issue #35), but the
// TUI's Switch-Model picker writes the choice to its settings.json, and
// every `agy -p` invocation re-reads that file on startup β verified by
// capturing the `--log-file` line `Propagating selected model override to
// backend: label="<model>"`. So we can route OD's model picker through
// settings.json: when the user picks a concrete model in Settings, the
// daemon writes the label into agy's settings.json right before spawn,
// and the resulting print-mode run uses that model.
//
// Two ids the picker exposes are special:
// - 'default' : leave settings.json untouched, so agy keeps
// whatever the user last picked in its own TUI.
// (Respects user choice when they switch models
// from `agy` directly.)
// - any other id : the literal display label agy expects (e.g.
// "Gemini 3.1 Pro (High)", "Claude Sonnet 4.6
// (Thinking)"). We persist it before spawn.
//
// `supportsCustomModel: false` because the label set is a server-side
// enum β a typed id agy doesn't recognise resolves to a silent
// `availableModels` cache miss + empty print-mode output, which surfaces
// to the user as a generic "empty response" error.
//
// The 8 model labels mirror what `Switch Model` in agy's TUI lists for
// consumer-tier accounts as of 2026-05-28. The set is small and stable
// enough to ship statically until upstream adds a programmatic
// `agy models` subcommand (also tracked under issue #35).
const ANTIGRAVITY_SETTINGS_PATH = join(
homedir(),
'.gemini',
'antigravity-cli',
'settings.json',
);
export function writeAntigravityModelSelection(
label: string,
settingsPath: string = ANTIGRAVITY_SETTINGS_PATH,
): void {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>;
}
} catch {
// Corrupt JSON β fall through and rewrite the file from scratch so
// the next spawn starts from a known-good state.
}
}
existing.model = label;
mkdirSync(dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, `${JSON.stringify(existing, null, 2)}\n`);
}
// Per-process serialization for write-settings β spawn β agy-reads
// cycles on antigravity. `~/.gemini/antigravity-cli/settings.json` is
// process-global, so two OD runs that both pick concrete (non-default)
// models can race: run A writes model A, spawn A starts, run B writes
// model B before A's agy has read settings.json β A then executes on
// model B. The daemon serialises non-default antigravity spawns
// through this chain: each acquire awaits the previous release, and
// each release fires only after the spawned agy actually emits
// `Propagating selected model override to backend: label="<X>"` in
// its `--log-file` (which is the upstream signal that settings.json
// has been read).
let antigravityLockChain: Promise<void> = Promise.resolve();
export async function acquireAntigravityModelLock(): Promise<() => void> {
const previous = antigravityLockChain;
let release: () => void = () => {};
antigravityLockChain = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
return release;
}
// Visible for tests. Resets the module-level lock chain so a test that
// installed a hanging acquirer can release it without leaking state to
// subsequent test cases. Production code never calls this.
export function _resetAntigravityModelLockForTests(): void {
antigravityLockChain = Promise.resolve();
}
export interface WaitForAgyModelOptions {
timeoutMs?: number;
pollIntervalMs?: number;
// Override for tests; production reads the daemon-owned log file path.
readFile?: (path: string) => Promise<string>;
// Override `Date.now` for tests; production uses the wall clock.
now?: () => number;
// Stops polling when fired. Production wires this to `child.once('exit')`
// so the watcher cancels as soon as agy exits β the lock release is
// then driven by the exit handler rather than the helper's return
// value, eliminating the slow-startup race the looper review at
// 263fd2fe7 flagged: if a cold agy takes >timeoutMs to read its
// settings.json, we'd otherwise return false, the caller would
// release the lock, and a concurrent run B could rewrite
// settings.json before A's agy actually read it.
abortSignal?: AbortSignal;
}
// Polls agy's `--log-file` for the line
// `Propagating selected model override to backend: label="<expectedModel>"`
// which `model_config_manager.go` emits once agy has finished reading
// `~/.gemini/antigravity-cli/settings.json` and sent the model
// override to the upstream backend. Returns true on observed signal,
// false on timeout OR abort. Never throws β a missing log file is
// treated as "not yet seen" so the polling loop keeps retrying until
// either the deadline or the abort signal fires.
//
// IMPORTANT: callers MUST NOT use a `false` return as a "go ahead and
// release the settings.json lock" signal β false means "I gave up
// polling," not "agy definitely didn't read this." Release the lock
// only on (a) a `true` return, OR (b) child exit. See server.ts for
// the wiring.
export async function waitForAgyToReadModel(
logFilePath: string,
expectedModel: string,
options: WaitForAgyModelOptions = {},
): Promise<boolean> {
const timeoutMs = options.timeoutMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 250;
const readFile =
options.readFile ?? ((path: string) => fsReadFile(path, 'utf8'));
const now = options.now ?? Date.now;
const abortSignal = options.abortSignal;
if (abortSignal?.aborted) return false;
const escaped = expectedModel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
`Propagating selected model override to backend: label="${escaped}"`,
);
const deadline = now() + timeoutMs;
while (now() < deadline) {
if (abortSignal?.aborted) return false;
try {
const content = await readFile(logFilePath);
if (pattern.test(content)) return true;
} catch {
// Log file may not have appeared yet; keep polling.
}
if (now() >= deadline) break;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, pollIntervalMs);
const onAbort = () => {
clearTimeout(timer);
resolve();
};
abortSignal?.addEventListener('abort', onAbort, { once: true });
});
}
return false;
}
export const antigravityAgentDef = {
id: 'antigravity',
name: 'Antigravity',
bin: 'agy',
versionArgs: ['--version'],
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'Gemini 3.1 Pro (High)', label: 'Gemini 3.1 Pro (High)' },
{ id: 'Gemini 3.1 Pro (Low)', label: 'Gemini 3.1 Pro (Low)' },
{ id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' },
{ id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' },
{ id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' },
{
id: 'Claude Sonnet 4.6 (Thinking)',
label: 'Claude Sonnet 4.6 (Thinking)',
},
{ id: 'Claude Opus 4.6 (Thinking)', label: 'Claude Opus 4.6 (Thinking)' },
{ id: 'GPT-OSS 120B (Medium)', label: 'GPT-OSS 120B (Medium)' },
],
supportsCustomModel: false,
// We deliberately do NOT opt into `resumesSessionViaCli` / agy's `-c`
// resume flag on follow-up turns. Tested both shapes; `-c` activates
// agy's internal agentic loop (multi-step model retries, tool calls,
// fallback-to-cached-response on tool errors) which can't be steered
// from OD's system-prompt OVERRIDE β even with the strongest wording
// we got an identical byte-for-byte form re-emission on turn 2 when
// turn 1's tool-call retry path returned the cached form response.
//
// Instead we treat agy as a stateless plain adapter like qwen /
// deepseek: every spawn gets the full OD-rendered transcript via
// `buildDaemonTranscript`, and that transcript's prior assistant
// turns are sanitized to strip `<question-form>` markup + form-schema
// JSON fences (see `sanitizePriorAssistantTurnForTranscript` in
// apps/web/src/providers/daemon.ts). The stronger OVERRIDE block
// composed in server.ts gives a second line of defense for weak
// plain-stream models like Gemini 3.5 Flash.
buildArgs: (
_prompt,
_imagePaths,
_extra = [],
options = {},
runtimeContext = {},
) => {
if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) {
writeAntigravityModelSelection(
options.model,
runtimeContext.antigravitySettingsPath,
);
}
// We no longer use `-p -` because recent `agy` versions treat `-` as a literal
// prompt string instead of reading from stdin (see issue #5495).
// Instead, we use `promptViaFile: true` so the daemon securely prepares a
// managed temp file per-run and cleans it up after the agent exits.
if (!runtimeContext.promptFilePath) {
throw new Error('antigravity requires runtimeContext.promptFilePath when promptViaFile is true');
}
const args: string[] = [];
if (runtimeContext.agentLogFilePath) {
args.push('--log-file', runtimeContext.agentLogFilePath);
}
args.push('-p');
args.push(`Read the system instructions, conversation history, and user request from the file ${runtimeContext.promptFilePath}. Follow the instructions strictly and provide the final response to the user's latest request.`);
return args;
},
promptViaStdin: false,
promptViaFile: true,
streamFormat: 'plain',
installUrl: 'https://antigravity.google/cli',
docsUrl: 'https://antigravity.google/docs/cli-overview',
} satisfies RuntimeAgentDef;