diff --git a/README.md b/README.md index 0e77c3a..1ee5df6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ All TypeScript is executed directly via `tsx` — no build step required for dev | `@agentry/claude` | Claude Code driver (`claude -p --output-format stream-json`) | | `@agentry/codex` | Codex CLI driver (`codex exec --json`) | | `@agentry/gemini` | Gemini CLI driver (`gemini -p --output-format stream-json`) | +| `@agentry/antigravity` | Antigravity driver (`agy -p --output-format stream-json`) | | `@agentry/mcp` | `MockMcpServer` (JSON-RPC + stdio shim) + MCP matchers | | `agentry` | CLI (`agentry test`, `record`, `init`, `doctor`) | @@ -249,6 +250,7 @@ The SPEC describes the full vision. What is implemented vs. planned: - Claude driver (`claude -p --output-format stream-json`) - Codex driver (`codex exec --json`) - Gemini driver (`gemini -p --output-format stream-json`; coalesces streaming assistant deltas) +- Antigravity driver (`agy -p --output-format stream-json`; event-stream normalization faithful, sandbox fs-diff best-effort since agy uses its own scratch dir) - Normalized causal event model (`tool_use`, `tool_result`, `mcp_request`, `llm_request`/`llm_response`, `message`, `usage`, `run.end`, `plugin`, `skill`, `fs`) - Assertions, Tiers 1–3: `toHaveToolCall`, `toHaveCalledToolTimes`, `toUseToolsFrom`, `toHaveCalledAll`, `toHaveMcpRequest`, `toFinishWithin`, `toHaveFile`, `toMatchSchema` - Skill/plugin effect matchers: `toHaveLoadedPlugin`, `toFireHook` (CH2) and `toInjectContext`, `toRegisterTools` (CH1, via the proxy) diff --git a/packages/antigravity/package.json b/packages/antigravity/package.json new file mode 100644 index 0000000..6a3c485 --- /dev/null +++ b/packages/antigravity/package.json @@ -0,0 +1,12 @@ +{ + "name": "@agentry/antigravity", + "version": "0.0.0", + "type": "module", + "description": "Agentry driver for Antigravity (agy) headless stream-json.", + "exports": { ".": "./src/index.ts" }, + "publishConfig": { + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } + }, + "scripts": { "build": "tsup src/index.ts --format esm --dts --clean" }, + "dependencies": { "@agentry/core": "workspace:*" } +} diff --git a/packages/antigravity/src/driver.ts b/packages/antigravity/src/driver.ts new file mode 100644 index 0000000..9a5e314 --- /dev/null +++ b/packages/antigravity/src/driver.ts @@ -0,0 +1,206 @@ +/** + * Antigravity (`agy`) driver. Drives `agy -p --output-format stream-json` + * headlessly (stdin ignored so the CLI never blocks) and normalizes the native + * `event`-discriminated stream into AgentEvents. + * + * `parseAntigravityEvents` is a pure whole-stream mapping (native events → + * AgentEvents) so it can be unit-tested without spawning the CLI. Note: agy + * writes to its own project/scratch dir by default, so sandbox fs-diff capture + * is best-effort even though event-stream normalization is faithful. + */ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { + EventFactory, + RunRecord, + type AgentDriver, + type AgentEvent, + type DriverCapabilities, + type RunOptions, + type RunResult, + type RunEndReason, + type Usage, + isEvent, +} from '@agentry/core'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Raw = any; + +/** Map a full agy `stream-json` stream into the normalized AgentEvent stream. */ +export function parseAntigravityEvents(raws: Raw[], f: EventFactory, runId: string): AgentEvent[] { + const out: AgentEvent[] = []; + let bufferIndex: number | undefined; + let bufferText = ''; + + const flush = () => { + if (bufferIndex === undefined) return; + out.push( + f.make( + { type: 'message', role: 'assistant', text: bufferText }, + { turnId: `step-${bufferIndex}`, source: 'agent', agentNativeType: 'agent_response' }, + ), + ); + bufferIndex = undefined; + bufferText = ''; + }; + + for (const raw of raws) { + const event = raw?.event; + + if (event === 'init') { + out.push( + f.make( + { type: 'run.start', runId, agent: 'antigravity', model: raw.model ?? '' }, + { turnId: 'init', source: 'agent', agentNativeType: 'init', raw }, + ), + ); + continue; + } + + if (event === 'step_update') { + const su = raw.step_update ?? {}; + if (su.step_type === 'tool') { + if (bufferIndex !== undefined && su.step_index !== bufferIndex) flush(); + if (su.state === 'ACTIVE') { + out.push( + f.make( + { type: 'tool_use', id: String(su.step_index), name: su.tool_name, args: su.tool_info?.parameters }, + { turnId: `step-${su.step_index}`, source: 'agent', capability: 'tool', agentNativeType: 'step_update/tool', raw: su }, + ), + ); + } else if (su.state === 'DONE') { + out.push( + f.make( + { type: 'tool_result', id: String(su.step_index), name: su.tool_name, result: su.tool_info?.result, isError: false }, + { turnId: `step-${su.step_index}`, source: 'agent', capability: 'tool', agentNativeType: 'step_update/tool', raw: su }, + ), + ); + } + } else if (su.step_type === 'agent_response' && su.text_delta !== undefined) { + if (bufferIndex !== undefined && su.step_index !== bufferIndex) flush(); + bufferIndex = su.step_index; + bufferText += su.text_delta; + } else if (bufferIndex !== undefined && su.step_index !== bufferIndex) { + flush(); + } + continue; + } + + if (event === 'result') { + flush(); + const r = raw.result ?? {}; + const u = r.usage ?? {}; + out.push( + f.make( + { + type: 'usage', + inputTokens: u.input_tokens ?? 0, + outputTokens: u.output_tokens ?? 0, + cacheReadTokens: u.cache_read_tokens, + }, + { turnId: 'result', source: 'agent', agentNativeType: 'result.usage', raw: u }, + ), + ); + const reason: RunEndReason = r.status === 'SUCCESS' ? 'completed' : 'error'; + out.push( + f.make( + { type: 'run.end', runId, exitCode: r.status === 'SUCCESS' ? 0 : 1, reason }, + { turnId: 'result', source: 'agent', agentNativeType: 'result', raw }, + ), + ); + continue; + } + } + + flush(); + return out; +} + +/** Aggregate the authoritative usage (from the terminal `result` usage event). */ +function aggregateUsage(events: AgentEvent[]): Usage { + const totals: Usage = { inputTokens: 0, outputTokens: 0 }; + for (const e of events) { + if (isEvent(e, 'usage')) { + totals.inputTokens += e.payload.inputTokens; + totals.outputTokens += e.payload.outputTokens; + if (e.payload.cacheReadTokens !== undefined) { + totals.cacheReadTokens = (totals.cacheReadTokens ?? 0) + e.payload.cacheReadTokens; + } + } + } + return totals; +} + +export function buildArgs(opts: RunOptions): string[] { + const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--model', opts.model, '--add-dir', opts.cwd]; + if (opts.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions'); + if (opts.extraArgs?.length) args.push(...opts.extraArgs); + return args; +} + +let runCounter = 0; + +export class AntigravityDriver implements AgentDriver { + readonly id = 'antigravity'; + constructor(private readonly bin = process.env.AGENTRY_ANTIGRAVITY_BIN ?? 'agy') {} + + capabilities(): DriverCapabilities { + return { + structuredStream: true, + llmInterception: 'none', + mcpTransports: [], + toolPermissionControl: true, + nativeBudgetControl: false, + }; + } + + async run(opts: RunOptions): Promise { + const runId = `antigravity-${runCounter++}`; + const factory = new EventFactory(runId); + const raws: unknown[] = []; + + const child = spawn(this.bin, buildArgs(opts), { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const rl = createInterface({ input: child.stdout }); + rl.on('line', (line) => { + const s = line.trim(); + if (!s) return; + try { + raws.push(JSON.parse(s)); + } catch { + return; // tolerate non-JSON noise + } + }); + + let stderr = ''; + child.stderr.on('data', (d) => (stderr += String(d))); + + let timer: NodeJS.Timeout | undefined; + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => resolve(code)); + if (opts.timeoutMs) timer = setTimeout(() => child.kill('SIGTERM'), opts.timeoutMs); + opts.signal?.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); + }).finally(() => timer && clearTimeout(timer)); + + const events = parseAntigravityEvents(raws, factory, runId); + + const ended = events.find((e) => isEvent(e, 'run.end')); + const reason: RunEndReason = ended && isEvent(ended, 'run.end') ? ended.payload.reason : exitCode === 0 ? 'completed' : 'crash'; + if (!ended) { + events.push( + factory.make( + { type: 'run.end', runId, exitCode, reason }, + { turnId: 'result', source: 'runner', agentNativeType: 'synthetic' }, + ), + ); + } + + const result: RunResult = { exitCode, reason, usage: aggregateUsage(events) }; + return new RunRecord(events, result); + } +} diff --git a/packages/antigravity/src/index.ts b/packages/antigravity/src/index.ts new file mode 100644 index 0000000..ac3cec7 --- /dev/null +++ b/packages/antigravity/src/index.ts @@ -0,0 +1,2 @@ +// @agentry/antigravity — antigravity driver. +export * from './driver'; diff --git a/packages/antigravity/test/build-args.test.ts b/packages/antigravity/test/build-args.test.ts new file mode 100644 index 0000000..6e0a9c8 --- /dev/null +++ b/packages/antigravity/test/build-args.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import type { RunOptions } from '@agentry/core'; +import { buildArgs } from '../src/driver'; + +const base: RunOptions = { prompt: 'write hello.txt', model: 'claude-sonnet-4-6', cwd: '/tmp/x' }; + +describe('buildArgs', () => { + it('emits the headless stream-json base invocation', () => { + expect(buildArgs(base)).toEqual([ + '-p', + 'write hello.txt', + '--output-format', + 'stream-json', + '--model', + 'claude-sonnet-4-6', + '--add-dir', + '/tmp/x', + ]); + }); + + it('adds --dangerously-skip-permissions for bypassPermissions', () => { + expect(buildArgs({ ...base, permissionMode: 'bypassPermissions' })).toContain('--dangerously-skip-permissions'); + }); + + it('omits --dangerously-skip-permissions by default', () => { + expect(buildArgs(base)).not.toContain('--dangerously-skip-permissions'); + }); + + it('appends extraArgs verbatim at the end', () => { + const args = buildArgs({ ...base, extraArgs: ['--foo', 'bar'] }); + expect(args.slice(-2)).toEqual(['--foo', 'bar']); + }); +}); diff --git a/packages/antigravity/test/parse.test.ts b/packages/antigravity/test/parse.test.ts new file mode 100644 index 0000000..ac53cbb --- /dev/null +++ b/packages/antigravity/test/parse.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { EventFactory, RunRecord, isEvent, type AgentEvent } from '@agentry/core'; +import { parseAntigravityEvents } from '../src/driver'; + +// Captured agy stream-json ground-truth sample. +const SAMPLE = [ + { + event: 'init', + conversation_id: 'c1', + init: { cwd: '/tmp/x', tools: ['write_to_file', 'run_command'], permission_mode: 'bypass' }, + }, + { + event: 'step_update', + step_update: { conversation_id: 'c1', step_index: 0, state: 'DONE', step_type: 'user_input' }, + }, + { + event: 'step_update', + step_update: { + conversation_id: 'c1', + step_index: 3, + state: 'DONE', + step_type: 'agent_response', + usage: { input_tokens: 16947, output_tokens: 702, thinking_tokens: 619, cache_read_tokens: 0, total_tokens: 17649 }, + }, + }, + { + event: 'step_update', + step_update: { + conversation_id: 'c1', + step_index: 4, + state: 'ACTIVE', + step_type: 'tool', + tool_name: 'write_to_file', + tool_info: { name: 'write_to_file', parameters: { TargetFile: 'hello.txt' } }, + }, + }, + { + event: 'step_update', + step_update: { + conversation_id: 'c1', + step_index: 4, + state: 'DONE', + step_type: 'tool', + tool_name: 'write_to_file', + tool_info: { name: 'write_to_file', parameters: { TargetFile: 'hello.txt' } }, + }, + }, + { + event: 'step_update', + step_update: { conversation_id: 'c1', step_index: 7, state: 'ACTIVE', step_type: 'agent_response', text_delta: 'done' }, + }, + { + event: 'step_update', + step_update: { conversation_id: 'c1', step_index: 7, state: 'DONE', step_type: 'agent_response', text_delta: '.' }, + }, + { + event: 'result', + result: { + conversation_id: 'c1', + status: 'SUCCESS', + response: 'done.', + duration_seconds: 12, + num_turns: 1, + usage: { input_tokens: 22664, output_tokens: 1084, thinking_tokens: 923, cache_read_tokens: 12164, total_tokens: 23748 }, + }, + }, +]; + +function parseAll(): AgentEvent[] { + const f = new EventFactory('r', () => 0); + return parseAntigravityEvents(SAMPLE, f, 'r'); +} + +describe('parseAntigravityEvents', () => { + const events = parseAll(); + const rec = new RunRecord(events); + + it('maps init to run.start with the antigravity agent', () => { + const start = events.find((e) => isEvent(e, 'run.start')); + expect(start && isEvent(start, 'run.start') && start.payload.agent).toBe('antigravity'); + }); + + it('maps the tool step to a tool call', () => { + expect(rec.toolCalls).toHaveLength(1); + expect(rec.toolCalls[0]!.payload.name).toBe('write_to_file'); + expect(rec.toolCalls[0]!.payload.args).toEqual({ TargetFile: 'hello.txt' }); + }); + + it('coalesces agent_response text_delta by step_index into one message', () => { + expect(rec.assistantMessages).toHaveLength(1); + expect(rec.lastMessage).toBe('done.'); + }); + + it('maps the terminal result to authoritative usage + run.end completed', () => { + const usage = events.filter((e) => isEvent(e, 'usage')); + expect(usage).toHaveLength(1); + expect(rec.usage.inputTokens).toBe(22664); + expect(rec.usage.outputTokens).toBe(1084); + const u = usage[0]!; + expect(isEvent(u, 'usage') && u.payload.cacheReadTokens).toBe(12164); + const end = events.find((e) => isEvent(e, 'run.end')); + expect(end && isEvent(end, 'run.end') && end.payload.reason).toBe('completed'); + }); +}); diff --git a/packages/antigravity/tsconfig.json b/packages/antigravity/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/antigravity/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index a6192ca..91846b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,6 +15,7 @@ "@agentry/claude": "workspace:*", "@agentry/codex": "workspace:*", "@agentry/gemini": "workspace:*", + "@agentry/antigravity": "workspace:*", "@agentry/mcp": "workspace:*" } } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 76655b6..989fce3 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { ClaudeDriver } from '@agentry/claude'; import { CodexDriver } from '@agentry/codex'; import { GeminiDriver } from '@agentry/gemini'; +import { AntigravityDriver } from '@agentry/antigravity'; /** \`agentry doctor\` — probe installed agent CLIs and print driver capabilities. */ export async function cmdDoctor(): Promise { @@ -48,5 +49,19 @@ export async function cmdDoctor(): Promise { for (const [k, val] of Object.entries(gcaps)) { console.log(` ${k}: ${JSON.stringify(val)}`); } + + const agyBin = process.env.AGENTRY_ANTIGRAVITY_BIN ?? 'agy'; + const av = spawnSync(agyBin, ['--version'], { encoding: 'utf8' }); + if (av.status === 0) { + console.log(`\n agy ✓ ${av.stdout.trim()}`); + } else { + console.log(`\n agy ✗ not found (looked for '${agyBin}')`); + } + + console.log('\n antigravity driver capabilities:'); + const agyCaps = new AntigravityDriver().capabilities(); + for (const [k, val] of Object.entries(agyCaps)) { + console.log(` ${k}: ${JSON.stringify(val)}`); + } return 0; } diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index c318f1e..eb2636e 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -16,6 +16,7 @@ import { import { ClaudeDriver } from '@agentry/claude'; import { CodexDriver } from '@agentry/codex'; import { GeminiDriver } from '@agentry/gemini'; +import { AntigravityDriver } from '@agentry/antigravity'; import { discoverTests } from '../discover'; function selectDriver(agent: string | undefined): AgentDriver { @@ -24,6 +25,8 @@ function selectDriver(agent: string | undefined): AgentDriver { return new CodexDriver(); case 'gemini': return new GeminiDriver(); + case 'antigravity': + return new AntigravityDriver(); case 'claude': default: return new ClaudeDriver(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f4b1e..b1a228d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,12 @@ importers: specifier: workspace:* version: link:../../packages/cli + packages/antigravity: + dependencies: + '@agentry/core': + specifier: workspace:* + version: link:../core + packages/claude: dependencies: '@agentry/core': @@ -38,6 +44,9 @@ importers: packages/cli: dependencies: + '@agentry/antigravity': + specifier: workspace:* + version: link:../antigravity '@agentry/claude': specifier: workspace:* version: link:../claude diff --git a/tsconfig.json b/tsconfig.json index 68f78e1..0a190c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "@agentry/claude": ["packages/claude/src/index.ts"], "@agentry/codex": ["packages/codex/src/index.ts"], "@agentry/gemini": ["packages/gemini/src/index.ts"], + "@agentry/antigravity": ["packages/antigravity/src/index.ts"], "@agentry/mcp": ["packages/mcp/src/index.ts"] } }, diff --git a/vitest.config.ts b/vitest.config.ts index 19081f4..2c0c0db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ '@agentry/claude': new URL('./packages/claude/src/index.ts', import.meta.url).pathname, '@agentry/codex': new URL('./packages/codex/src/index.ts', import.meta.url).pathname, '@agentry/gemini': new URL('./packages/gemini/src/index.ts', import.meta.url).pathname, + '@agentry/antigravity': new URL('./packages/antigravity/src/index.ts', import.meta.url).pathname, '@agentry/mcp': new URL('./packages/mcp/src/index.ts', import.meta.url).pathname, }, },