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 @@ -44,6 +44,7 @@ All TypeScript is executed directly via `tsx` — no build step required for dev
| `@agentry/core` | Event model, config, sandbox, runner, assertions |
| `@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/mcp` | `MockMcpServer` (JSON-RPC + stdio shim) + MCP matchers |
| `agentry` | CLI (`agentry test`, `record`, `init`, `doctor`) |

Expand Down Expand Up @@ -247,6 +248,7 @@ The SPEC describes the full vision. What is implemented vs. planned:
**Implemented**
- 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)
- 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
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@agentry/core": "workspace:*",
"@agentry/claude": "workspace:*",
"@agentry/codex": "workspace:*",
"@agentry/gemini": "workspace:*",
"@agentry/mcp": "workspace:*"
}
}
15 changes: 15 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawnSync } from 'node:child_process';
import { ClaudeDriver } from '@agentry/claude';
import { CodexDriver } from '@agentry/codex';
import { GeminiDriver } from '@agentry/gemini';

/** \`agentry doctor\` — probe installed agent CLIs and print driver capabilities. */
export async function cmdDoctor(): Promise<number> {
Expand Down Expand Up @@ -33,5 +34,19 @@ export async function cmdDoctor(): Promise<number> {
for (const [k, val] of Object.entries(codexCaps)) {
console.log(` ${k}: ${JSON.stringify(val)}`);
}

const geminiBin = process.env.AGENTRY_GEMINI_BIN ?? 'gemini';
const gv = spawnSync(geminiBin, ['--version'], { encoding: 'utf8' });
if (gv.status === 0) {
console.log(`\n gemini ✓ ${gv.stdout.trim()}`);
} else {
console.log(`\n gemini ✗ not found (looked for '${geminiBin}')`);
}

console.log('\n gemini driver capabilities:');
const gcaps = new GeminiDriver().capabilities();
for (const [k, val] of Object.entries(gcaps)) {
console.log(` ${k}: ${JSON.stringify(val)}`);
}
return 0;
}
3 changes: 3 additions & 0 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ import {
} from '@agentry/core';
import { ClaudeDriver } from '@agentry/claude';
import { CodexDriver } from '@agentry/codex';
import { GeminiDriver } from '@agentry/gemini';
import { discoverTests } from '../discover';

function selectDriver(agent: string | undefined): AgentDriver {
switch (agent) {
case 'codex':
return new CodexDriver();
case 'gemini':
return new GeminiDriver();
case 'claude':
default:
return new ClaudeDriver();
Expand Down
12 changes: 12 additions & 0 deletions packages/gemini/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@agentry/gemini",
"version": "0.0.0",
"type": "module",
"description": "Agentry driver for Gemini CLI (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:*" }
}
222 changes: 222 additions & 0 deletions packages/gemini/src/driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Gemini CLI driver (SPEC §4.2). Drives `gemini -p --output-format stream-json`
* headlessly and normalizes the native stream into AgentEvents.
*
* Gemini streams assistant text as delta chunks, so `parseGeminiEvents` is a
* pure whole-stream mapping (native events → AgentEvents) that coalesces
* consecutive assistant deltas into a single message. LLM interception via
* base-url is unproven, so declared 'none' and not wired to Agentry's proxy;
* transcript record/replay works, wire cassette does not apply. The final
* `result` event carries authoritative stats (no per-token cost).
*/
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 Gemini `stream-json` stream into AgentEvents, coalescing assistant deltas. */
export function parseGeminiEvents(raws: Raw[], f: EventFactory, runId: string): AgentEvent[] {
const out: AgentEvent[] = [];
let pending = '';

const flush = () => {
if (!pending) return;
out.push(
f.make(
{ type: 'message', role: 'assistant', text: pending },
{ turnId: 'assistant', source: 'agent', agentNativeType: 'message' },
),
);
pending = '';
};

for (const raw of raws) {
const type = raw?.type;

if (type === 'init') {
out.push(
f.make(
{ type: 'run.start', runId, agent: 'gemini', model: raw.model },
{ turnId: 'init', source: 'agent', agentNativeType: 'init', raw },
),
);
continue;
}

if (type === 'message') {
if (raw.role === 'assistant') {
pending += raw.content ?? '';
continue;
}
flush();
out.push(
f.make(
{ type: 'message', role: 'user', text: raw.content ?? '' },
{ turnId: 'user', source: 'agent', agentNativeType: 'message', raw },
),
);
continue;
}

if (type === 'tool_use') {
flush();
out.push(
f.make(
{ type: 'tool_use', id: raw.tool_id, name: raw.tool_name, args: raw.parameters },
{ turnId: 'assistant', source: 'agent', capability: 'tool', agentNativeType: 'tool_use', raw },
),
);
continue;
}

if (type === 'tool_result') {
out.push(
f.make(
{ type: 'tool_result', id: raw.tool_id, name: '', result: raw.output, isError: raw.status === 'error' },
{ turnId: 'tool', source: 'agent', capability: 'tool', agentNativeType: 'tool_result', raw },
),
);
continue;
}

if (type === 'error') {
out.push(
f.make(
{ type: 'error', kind: 'api', detail: { severity: raw.severity, message: raw.message } },
{ turnId: 'error', source: 'agent', agentNativeType: 'error', raw },
),
);
continue;
}

if (type === 'result') {
flush();
const stats = raw.stats ?? {};
out.push(
f.make(
{
type: 'usage',
inputTokens: stats.input_tokens ?? 0,
outputTokens: stats.output_tokens ?? 0,
cacheReadTokens: stats.cached,
},
{ turnId: 'result', source: 'agent', agentNativeType: 'result.usage', raw: stats },
),
);
const reason: RunEndReason = raw.status === 'error' ? 'error' : 'completed';
out.push(
f.make(
{ type: 'run.end', runId, exitCode: raw.status === 'error' ? 1 : 0, reason },
{ turnId: 'result', source: 'agent', agentNativeType: 'result', raw },
),
);
continue;
}
}

flush();
return out;
}

/** Aggregate the authoritative usage (from the `result` usage event). */
function aggregateUsage(events: AgentEvent[]): Usage {
const totals: Usage = { inputTokens: 0, outputTokens: 0, costUSD: 0 };
for (const e of events) {
if (isEvent(e, 'usage')) {
totals.inputTokens += e.payload.inputTokens;
totals.outputTokens += e.payload.outputTokens;
totals.costUSD = (totals.costUSD ?? 0) + (e.payload.costUSD ?? 0);
}
}
return totals;
}

export function buildArgs(opts: RunOptions): string[] {
const args = ['-p', opts.prompt, '--output-format', 'stream-json', '-m', opts.model, '--skip-trust'];
if (opts.permissionMode === 'bypassPermissions') args.push('--approval-mode', 'yolo');
else args.push('--approval-mode', 'default');
if (opts.allowedTools?.length) args.push('--allowed-tools', ...opts.allowedTools);
if (opts.extraArgs?.length) args.push(...opts.extraArgs);
return args;
}

let runCounter = 0;

export class GeminiDriver implements AgentDriver {
readonly id = 'gemini';
constructor(private readonly bin = process.env.AGENTRY_GEMINI_BIN ?? 'gemini') {}

capabilities(): DriverCapabilities {
return {
structuredStream: true,
llmInterception: 'none',
mcpTransports: ['stdio', 'http', 'sse'],
toolPermissionControl: true,
nativeBudgetControl: false,
};
}

async run(opts: RunOptions): Promise<RunRecord> {
const runId = `gemini-${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 = parseGeminiEvents(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) {
// synthesize a terminal event so the stream is always well-formed (SPEC §6)
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/gemini/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @agentry/gemini — gemini driver.
export * from './driver';
33 changes: 33 additions & 0 deletions packages/gemini/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: 'Create hello.txt', model: 'gemini-2.5-flash', cwd: '/tmp/x' };

describe('buildArgs', () => {
it('emits the headless stream-json base invocation', () => {
expect(buildArgs(base)).toEqual([
'-p',
'Create hello.txt',
'--output-format',
'stream-json',
'-m',
'gemini-2.5-flash',
'--skip-trust',
'--approval-mode',
'default',
]);
});

it('uses yolo approval mode when permissionMode is bypassPermissions', () => {
const args = buildArgs({ ...base, permissionMode: 'bypassPermissions' });
const i = args.indexOf('--approval-mode');
expect(i).toBeGreaterThanOrEqual(0);
expect(args[i + 1]).toBe('yolo');
});

it('appends extraArgs verbatim at the end', () => {
const args = buildArgs({ ...base, extraArgs: ['--foo', 'bar'] });
expect(args.slice(-2)).toEqual(['--foo', 'bar']);
});
});
Loading
Loading