Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/llm/antigravity-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Standing regression guards for AntigravityCliProvider's model registry
* and retry policy. Runs on Node's built-in test runner, no new deps:
* pnpm tsx --test src/llm/antigravity-cli.test.ts
*
* The live-CLI check skips cleanly when `agy` is not installed, so this
* file costs nothing on a machine without Antigravity.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
AntigravityCliProvider, AGY_TIER_MODELS, agyRetryDelayMs,
} from './antigravity-cli.js';
import { EmptyCompletionError } from './provider.js';

const provider = new AntigravityCliProvider();

test('tier defaults are explicit effort-suffixed models, never auto', async () => {
const ids = new Set((await provider.listModels()).map(m => m.id));
for (const [tier, model] of Object.entries(AGY_TIER_MODELS)) {
assert.notEqual(model, 'auto', `${tier} must not default to auto`);
assert.match(model, /-(low|medium|high)$/, `${tier} model ${model} lacks effort suffix`);
assert.ok(ids.has(model), `${tier} model ${model} not in AGY_MODELS`);
}
assert.deepEqual(provider.describe().defaultTierModels, AGY_TIER_MODELS);
});

test('resolveModel: explicit model > tier default > auto', () => {
assert.equal(provider.resolveModel({ model: 'gemini-3.1-pro-low' }), 'gemini-3.1-pro-low');
assert.equal(provider.resolveModel({ tier: 'fast' }), 'gemini-3.7-flash-low');
assert.equal(provider.resolveModel({ model: 'claude-sonnet-4-6', tier: 'fast' }), 'claude-sonnet-4-6');
assert.equal(provider.resolveModel(), 'auto');
});

test('agyRetryDelayMs: empty completions resample instantly, transient errors keep backoff', () => {
assert.equal(agyRetryDelayMs(new EmptyCompletionError('empty', 'stop'), 1, 1000), 0);
assert.equal(agyRetryDelayMs(new EmptyCompletionError('empty', 'stop'), 3, 4000), 0);
assert.equal(agyRetryDelayMs(new Error('idle for 360000ms'), 1, 1000), 1000);
assert.equal(agyRetryDelayMs(new Error('boom'), 3, 4000), 4000);
});

test('sunset gemini-3.5 ids migrate saved routing to the current line, same effort', async () => {
const migrations = provider.describe().modelMigrations ?? {};
const ids = new Set((await provider.listModels()).map(m => m.id));
for (const suffix of ['high', 'medium', 'low']) {
const from = `gemini-3.5-flash-${suffix}`;
const to = migrations[from];
assert.equal(to, `gemini-3.7-flash-${suffix}`, `sunset id ${from} should migrate to the 3.7 line`);
assert.ok(ids.has(to), `migration target ${to} not in AGY_MODELS`);
}
});

// Registry staleness tripwire: the hardcoded catalog must match the live
// CLI. It fires only on a successful listing that lacks one of our ids —
// exactly how it caught the gemini-3.5 sunset the day this file was
// written. Skips when the binary is absent.
test('AGY_MODELS matches live `agy models` output', async (t) => {
let out: string;
try {
out = execFileSync('agy', ['models'], { encoding: 'utf8', timeout: 30_000 });
} catch {
t.skip('agy binary unavailable — tripwire needs a live CLI');
return;
}
const live = new Set(
out.split('\n').filter(l => l.includes('\t')).map(l => l.split('\t')[0].trim()),
);
for (const m of await provider.listModels()) {
if (m.id === 'auto') continue;
assert.ok(live.has(m.id), `${m.id} vanished from \`agy models\` — update AGY_MODELS + AGY_TIER_MODELS`);
}
});
118 changes: 89 additions & 29 deletions src/llm/antigravity-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,37 @@
* stream on stdout. Nothing is scraped off a screen: the reply arrives as
* data and token usage comes back with it.
*
* TOOL ACCESS - unresolved, and a deliberate note rather than an oversight.
* Unlike `claude` (`--tools ""`) there is no way to run `agy` as a plain
* text generator: its init event advertises ~56 tools (run_command,
* write_to_file, call_mcp_tool, the browser suite) and `--mode plan` warns
* it "has no effect while slash command expansion is disabled". Two
* consequences callers should know about:
* TOOL ACCESS - closed as no-mechanism after investigation (LINC#571,
* agy 1.1.23). Unlike `claude` (`--tools ""`) there is no way to run `agy`
* as a plain text generator: no `--safe-mode`/`--config-dir`/tool-strip
* flag exists, `--sandbox` does not shrink the catalog, and the config
* paths (`~/.gemini/config/*`, skills included) are hardcoded in the
* binary. Isolating via a HOME override was considered and REJECTED: agy's
* OAuth state lives under `~/.gemini/`, and forking it silently forks the
* credential store (the classic overnight login-freeze failure mode). A
* custom `--agent` may become an isolation surface once agent definitions
* are documented. Consequences callers should know about:
*
* 1. Every request pays ~15k input tokens for a tool catalog Abjects can
* never use, because capabilities are routed through objects on the
* message bus rather than the CLI's own tool layer.
* 2. Headless mode cannot answer a permission prompt, so a tool call is
* auto-denied - and the model frequently gives up at that point and
* 1. Every request pays ~24.2k input tokens for a 57-tool catalog Abjects
* can never use (measured; capabilities are routed through objects on
* the message bus rather than the CLI's own tool layer). The prefill is
* never cache-hit across requests either - agy assembles the request,
* so the cross-request cache miss is an upstream problem; caching does
* work between turns inside one multi-turn call.
* 2. Headless mode cannot answer a permission prompt, so a gated tool call
* is auto-denied - and the model frequently gives up at that point and
* returns an empty answer with status SUCCESS. {@link TOOLLESS_NOTE}
* tells the model up front not to bother, and an empty result is
* surfaced as {@link EmptyCompletionError} rather than as success.
* surfaced as {@link EmptyCompletionError} rather than as success; the
* retry then resamples instantly ({@link agyRetryDelayMs}) since the
* abandonment is stochastic, not load-shedding.
*
* Denial is not total either: allow-rules in the user's agy settings.json
* (e.g. `command(find)`) apply to these sessions too, so treat
* antigravity-cli as a provider with tool access and run it somewhere
* harmless. See {@link AntigravityCliProvider.sandbox}.
* Denial is not total either - this is confirmed LIVE, not theoretical:
* bench probes saw headless sessions EXECUTE `run_command` (listing $HOME)
* and `write_to_file` (writes corralled into agy's per-session
* `~/.gemini/antigravity-cli/brain/<id>/` scratch) under the user's
* allow-rules. Treat antigravity-cli as a provider with tool access and
* run it somewhere harmless. See {@link AntigravityCliProvider.sandbox}.
*
* Reports under provider name `'antigravity-cli'`.
*/
Expand All @@ -52,6 +63,7 @@ import {
LLMProviderDescription,
LLMStreamChunk,
ModelInfo,
ModelTier,
cliIsRetryable,
} from './provider.js';

Expand All @@ -72,28 +84,61 @@ const AUTO_MODEL = 'auto';
* (exit 1, result status ERROR), so image requests must be routed away from
* this provider rather than degraded.
*/
const AGY_MODELS: ModelInfo[] = [
const AGY_MODELS = [
{ id: AUTO_MODEL, name: 'Auto (recommended)', vision: false },
{ id: 'gemini-3.7-flash-high', name: 'Gemini 3.7 Flash (High)', vision: false },
{ id: 'gemini-3.7-flash-medium', name: 'Gemini 3.7 Flash (Medium)', vision: false },
{ id: 'gemini-3.7-flash-low', name: 'Gemini 3.7 Flash (Low)', vision: false },
{ id: 'gemini-3.6-flash-high', name: 'Gemini 3.6 Flash (High)', vision: false },
{ id: 'gemini-3.6-flash-medium', name: 'Gemini 3.6 Flash (Medium)', vision: false },
{ id: 'gemini-3.6-flash-low', name: 'Gemini 3.6 Flash (Low)', vision: false },
{ id: 'gemini-3.5-flash-high', name: 'Gemini 3.5 Flash (High)', vision: false },
{ id: 'gemini-3.5-flash-medium', name: 'Gemini 3.5 Flash (Medium)', vision: false },
{ id: 'gemini-3.5-flash-low', name: 'Gemini 3.5 Flash (Low)', vision: false },
{ id: 'gemini-3.1-pro-high', name: 'Gemini 3.1 Pro (High)', vision: false },
{ id: 'gemini-3.1-pro-low', name: 'Gemini 3.1 Pro (Low)', vision: false },
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Thinking)', vision: false },
{ id: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 (Thinking)', vision: false },
{ id: 'gpt-oss-120b-medium', name: 'GPT-OSS 120B (Medium)', vision: false },
];
] as const satisfies readonly ModelInfo[];

/**
* Union of the catalog's ids, so a tier default or migration target that
* names a model not in AGY_MODELS is a compile error, not a runtime
* surprise on the first live call.
*/
type AgyModelId = (typeof AGY_MODELS)[number]['id'];

function shouldOmitModelFlag(model: string | undefined): boolean {
return !model || model === AUTO_MODEL;
}

/**
* Default model per routing tier. Explicit effort-suffixed IDs, never
* AUTO_MODEL: "auto" resolves server-side to a high thinking budget
* (measured: gemini-3.7-flash-high behavior), which is a tail-latency
* amplifier on hard prompts (LINC#571). Explicit suffixes bound thinking
* per tier; AUTO_MODEL remains user-selectable in AGY_MODELS.
*
* code and smart share flash-high DELIBERATELY: there is no higher non-pro
* rung — the pro models reject --effort and pay pro-grade latency, wrong
* for a tier default. Kept honest by the `agy models` tripwire test.
*/
export const AGY_TIER_MODELS: Record<ModelTier, AgyModelId> = {
smart: 'gemini-3.7-flash-high',
balanced: 'gemini-3.7-flash-medium',
fast: 'gemini-3.7-flash-low',
code: 'gemini-3.7-flash-high',
};

/**
* Retry delay policy (LINC#571): an empty completion is the model
* stochastically abandoning the turn (denied tool, reasoning-only reply) —
* not load-shedding — so there is nothing external to wait out and the
* fix is an instant resample. Everything else keeps the exponential
* backoff it was handed.
*/
export function agyRetryDelayMs(err: unknown, _attempt: number, defaultDelayMs: number): number {
return err instanceof EmptyCompletionError ? 0 : defaultDelayMs;
}

/**
* How long the process can be entirely silent before we give up on it.
* Resets on every byte of output, so a long-but-progressing generation keeps
Expand Down Expand Up @@ -174,9 +219,13 @@ export class AntigravityCliProvider extends BaseLLMProvider {
* The model a request runs on. Without this override the base class
* reports 'default' for an unrouted call, which the ledger then files
* under a model name that exists nowhere and matches no price entry.
* Tier-routed calls resolve through AGY_TIER_MODELS (mirroring the
* direct Gemini provider) so a tier never silently rides "auto".
*/
override resolveModel(options?: LLMCompletionOptions): string {
return options?.model ?? AUTO_MODEL;
if (options?.model) return options.model;
if (options?.tier) return AGY_TIER_MODELS[options.tier];
return AUTO_MODEL;
}

async complete(messages: LLMMessage[], options?: LLMCompletionOptions): Promise<LLMCompletionResult> {
Expand Down Expand Up @@ -222,7 +271,7 @@ export class AntigravityCliProvider extends BaseLLMProvider {
finishReason: 'stop',
usage,
};
}, { isRetryable: agyIsRetryable, label: 'antigravity-cli.complete' });
}, { isRetryable: agyIsRetryable, delayMs: agyRetryDelayMs, label: 'antigravity-cli.complete' });
}

async *stream(messages: LLMMessage[], options?: LLMCompletionOptions): AsyncIterable<LLMStreamChunk> {
Expand Down Expand Up @@ -255,7 +304,8 @@ export class AntigravityCliProvider extends BaseLLMProvider {
throw err;
}
if (!agyIsRetryable(err)) throw err;
const delay = Math.min(initialDelayMs * Math.pow(backoffFactor, attempt - 1), maxDelayMs);
const backoff = Math.min(initialDelayMs * Math.pow(backoffFactor, attempt - 1), maxDelayMs);
const delay = agyRetryDelayMs(err, attempt, backoff);
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`[antigravity-cli.stream] attempt ${attempt}/${maxAttempts} failed: ${msg.slice(0, 200)} — retrying in ${delay}ms`);
Expand Down Expand Up @@ -382,7 +432,7 @@ export class AntigravityCliProvider extends BaseLLMProvider {
}

async listModels(): Promise<ModelInfo[]> {
return AGY_MODELS;
return [...AGY_MODELS];
}

override describe(): LLMProviderDescription {
Expand All @@ -398,8 +448,16 @@ export class AntigravityCliProvider extends BaseLLMProvider {
+ 'tools, so a request can read the filesystem and run commands its settings allow. '
+ 'Install Antigravity CLI: agy install or https://antigravity.google',
},
models: AGY_MODELS,
defaultTierModels: { smart: AUTO_MODEL, balanced: AUTO_MODEL, fast: AUTO_MODEL, code: AUTO_MODEL },
models: [...AGY_MODELS],
defaultTierModels: AGY_TIER_MODELS,
// agy sunset the 3.5 flash line (caught live by the models tripwire
// test); migrate any saved tier routing to the current 3.7 line at
// the same effort, matching the tier defaults above.
modelMigrations: {
'gemini-3.5-flash-high': 'gemini-3.7-flash-high',
'gemini-3.5-flash-medium': 'gemini-3.7-flash-medium',
'gemini-3.5-flash-low': 'gemini-3.7-flash-low',
} satisfies Record<string, AgyModelId>,
};
}

Expand Down Expand Up @@ -430,9 +488,11 @@ export class AntigravityCliProvider extends BaseLLMProvider {

// Effort-suffixed IDs (see AGY_MODELS) are self-contained; a separate
// --effort flag is rejected by pro/claude models, so never pass one.
const model = options?.model;
// Resolution matches what the ledger reports: explicit model, then the
// tier default, then auto (no flag).
const model = this.resolveModel(options);
if (!shouldOmitModelFlag(model)) {
argv.push('--model', model!);
argv.push('--model', model);
}

return { argv, stdin: `${JSON.stringify(buildStreamJsonUserMessage(messages))}\n` };
Expand Down
23 changes: 21 additions & 2 deletions src/llm/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,19 @@ export interface RetryOptions {
isRetryable?: (err: unknown) => boolean;
/** Hook for logging — called once before each retry sleep. */
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
/**
* Per-error delay override. Called with the error, the attempt number,
* and the delay the exponential backoff would have used; its return value
* is slept instead. Return 0 for an instant retry — right when the
* failure is a stochastic model behavior (e.g. EmptyCompletionError)
* rather than load-shedding, so there is nothing external to wait out.
*/
delayMs?: (err: unknown, attempt: number, defaultDelayMs: number) => number;
/** Diagnostic label used by onRetry's default formatter. */
label?: string;
}

const DEFAULT_RETRY_OPTS: Required<Omit<RetryOptions, 'isRetryable' | 'onRetry' | 'label'>> = {
const DEFAULT_RETRY_OPTS: Required<Omit<RetryOptions, 'isRetryable' | 'onRetry' | 'label' | 'delayMs'>> = {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
Expand Down Expand Up @@ -331,10 +339,21 @@ export async function withRetries<T>(fn: () => Promise<T>, opts: RetryOptions =
if (attempt >= cfg.maxAttempts || !isRetryable(err)) {
throw err;
}
const delay = Math.min(
const backoff = Math.min(
cfg.initialDelayMs * Math.pow(cfg.backoffFactor, attempt - 1),
cfg.maxDelayMs,
);
// The hook gets the same blast-radius discipline as onRetry (never let
// a delay policy crash the retry) plus the bound the backoff always
// had: a hook cannot sleep past maxDelayMs or return a negative/NaN
// delay, no matter what a future caller's policy computes.
let delay = backoff;
if (opts.delayMs) {
try {
const v = opts.delayMs(err, attempt, backoff);
if (Number.isFinite(v)) delay = Math.max(0, Math.min(v, cfg.maxDelayMs));
} catch { /* never let a delay policy crash retry */ }
}
if (opts.onRetry) {
try { opts.onRetry(err, attempt, delay); } catch { /* never let logging crash retry */ }
} else {
Expand Down