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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions packages/antigravity/package.json
Original file line number Diff line number Diff line change
@@ -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:*" }
}
206 changes: 206 additions & 0 deletions packages/antigravity/src/driver.ts
Original file line number Diff line number Diff line change
@@ -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<RunRecord> {
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<number | null>((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);
}
}
2 changes: 2 additions & 0 deletions packages/antigravity/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @agentry/antigravity — antigravity driver.
export * from './driver';
33 changes: 33 additions & 0 deletions packages/antigravity/test/build-args.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
104 changes: 104 additions & 0 deletions packages/antigravity/test/parse.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
5 changes: 5 additions & 0 deletions packages/antigravity/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@agentry/claude": "workspace:*",
"@agentry/codex": "workspace:*",
"@agentry/gemini": "workspace:*",
"@agentry/antigravity": "workspace:*",
"@agentry/mcp": "workspace:*"
}
}
Loading
Loading