Skip to content
Draft
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,25 +230,30 @@ configureAgent(async ({ model, systemMessage, tools }) => {
});
```

Rig includes factories for Copilot, Codex, pi-agent, and Anthropic:
Rig includes factories for Copilot, Codex, Gemini CLI, pi-agent, and Anthropic:

```ts
import { configureAgent } from "rig";
import { anthropicEngine } from "rig/engines/anthropic";
import { codexEngine } from "rig/engines/codex";
import { geminiEngine } from "rig/engines/gemini";
import { piEngine } from "rig/engines/pi";

configureAgent(piEngine({ provider: "anthropic" }));
// or, using ANTHROPIC_API_KEY:
configureAgent(anthropicEngine());
// or, using Codex authentication:
configureAgent(codexEngine());
// or, using an installed and authenticated Gemini CLI:
configureAgent(geminiEngine());
```

`piEngine()` uses the maintained `@earendil-works/pi-agent-core` package and requires the provider for model lookup. `anthropicEngine()` uses `@anthropic-ai/sdk`. Both adapters preserve conversation state across repair turns and map Rig tools to their SDK tool runners.

`codexEngine()` uses `@openai/codex-sdk`, preserves its thread across repair turns, and accepts Codex client options plus thread options under `thread`. Rig system messages become Codex developer instructions. The Codex SDK does not expose custom tool registration, so the adapter rejects agents with Rig tools.

`geminiEngine()` runs the installed `gemini` executable in headless JSON mode and resumes its session across repair turns. It accepts `command`, `cwd`, additional CLI `args`, environment variables, and an optional `approvalMode`. Rig system messages are prepended to the first prompt. The Gemini CLI does not expose custom tool registration through its command line, so the adapter rejects agents with Rig tools.

## Copilot SDK adapter

By default it connects to an already-running Copilot server via HTTP (`COPILOT_SDK_URI`, then `localhost:7777`).
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"./addons": "./skills/rig/addons.ts",
"./engines/anthropic": "./skills/rig/engines/anthropic.ts",
"./engines/codex": "./skills/rig/engines/codex.ts",
"./engines/gemini": "./skills/rig/engines/gemini.ts",
"./engines/pi": "./skills/rig/engines/pi.ts"
},
"scripts": {
Expand Down
168 changes: 168 additions & 0 deletions skills/rig/engines/gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { AgentFactory } from "../rig.ts";

export type GeminiEngineOptions = {
command?: string;
cwd?: string;
args?: string[];
env?: NodeJS.ProcessEnv;
approvalMode?: "default" | "auto_edit" | "yolo" | "plan";
};

type GeminiOutput = {
session_id?: string;
response?: string;
error?: {
message?: string;
};
};

export function geminiEngine(options: GeminiEngineOptions = {}): AgentFactory {
const {
command = "gemini",
cwd,
args: extraArgs = [],
env,
approvalMode,
} = options;

return (agentOptions) => {
if (agentOptions.tools && agentOptions.tools.length > 0) {
throw new Error("geminiEngine does not support Rig tools");
}
const systemMessage = stringSystemMessage(agentOptions.systemMessage);
const closeController = new AbortController();
const activeTurns = new Set<Promise<unknown>>();
let activeProcess: ChildProcessWithoutNullStreams | undefined;
let sessionId: string | undefined;

return {
async ask(prompt, askOptions = {}) {
throwIfAborted(closeController.signal);
if (activeProcess) {
throw new Error("geminiEngine does not support concurrent turns");
}
const signal = askOptions.signal
? AbortSignal.any([askOptions.signal, closeController.signal])
: closeController.signal;
throwIfAborted(signal);
const nextSessionId = sessionId ?? randomUUID();
const fullPrompt = sessionId === undefined && systemMessage
? `${systemMessage}\n\n${prompt}`
: prompt;
const cliArgs = [
...extraArgs,
"--model",
agentOptions.model,
"--output-format",
"json",
...(approvalMode ? ["--approval-mode", approvalMode] : []),
...(sessionId ? ["--resume", sessionId] : ["--session-id", nextSessionId]),
"--prompt",
fullPrompt,
];
const activeTurn = runGemini(command, cliArgs, {
...(cwd !== undefined && { cwd }),
env: { ...process.env, ...env },
signal,
onSpawn(child) {
activeProcess = child;
},
});
activeTurns.add(activeTurn);
try {
const output = await activeTurn;
sessionId = output.session_id ?? nextSessionId;
if (output.error) {
throw new Error(output.error.message ?? "Gemini CLI request failed");
}
return output.response ?? "";
} finally {
activeProcess = undefined;
activeTurns.delete(activeTurn);
}
},
async close() {
closeController.abort(new DOMException("Agent closed", "AbortError"));
await Promise.allSettled(activeTurns);
},
};
};
}

function runGemini(
command: string,
args: string[],
options: {
cwd?: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
onSpawn(child: ChildProcessWithoutNullStreams): void;
},
): Promise<GeminiOutput> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
signal: options.signal,
});
options.onSpawn(child);
let stdout = "";
let stderr = "";
let processError: unknown;
let killTimer: NodeJS.Timeout | undefined;
const forceKill = () => {
killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
killTimer.unref();
};
options.signal.addEventListener("abort", forceKill, { once: true });
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", (error) => {
processError = error;
});
child.once("close", (code) => {
options.signal.removeEventListener("abort", forceKill);
if (killTimer) {
clearTimeout(killTimer);
}
if (processError) {
reject(processError);
return;
}
if (code !== 0) {
reject(new Error(stderr.trim() || `Gemini CLI exited with code ${code}`));
return;
}
try {
resolve(JSON.parse(stdout) as GeminiOutput);
} catch {
reject(new Error("Gemini CLI returned invalid JSON"));
}
});
});
}

function stringSystemMessage(systemMessage: unknown): string | undefined {
if (systemMessage === undefined) {
return undefined;
}
if (typeof systemMessage !== "string") {
throw new TypeError("geminiEngine requires systemMessage to be a string");
}
return systemMessage;
}

function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new DOMException("Aborted", "AbortError");
}
}
182 changes: 182 additions & 0 deletions src/engines/gemini.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
randomUUID: vi.fn(() => "new-session"),
}));

vi.mock("node:child_process", () => ({ spawn: mocks.spawn }));
vi.mock("node:crypto", () => ({ randomUUID: mocks.randomUUID }));

import { defineTool } from "rig";
import { geminiEngine } from "rig/engines/gemini";

type MockChild = EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
};

function childProcess(): MockChild {
const child = new EventEmitter() as MockChild;
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
return child;
}

function respond(child: MockChild, output: unknown, code = 0): void {
queueMicrotask(() => {
child.stdout.end(JSON.stringify(output));
child.emit("close", code);
});
}

beforeEach(() => {
mocks.spawn.mockReset();
mocks.randomUUID.mockClear();
});

it("starts and resumes a Gemini CLI session", async () => {
mocks.spawn
.mockImplementationOnce(() => {
const child = childProcess();
respond(child, { session_id: "saved-session", response: "first response" });
return child;
})
.mockImplementationOnce(() => {
const child = childProcess();
respond(child, { session_id: "saved-session", response: "second response" });
return child;
});
const runtimeAgent = await geminiEngine()({
model: "gemini-test",
systemMessage: "Be concise.",
});

await expect(runtimeAgent.ask("first")).resolves.toBe("first response");
await expect(runtimeAgent.ask("second")).resolves.toBe("second response");

expect(mocks.spawn.mock.calls[0]![1]).toEqual([
"--model",
"gemini-test",
"--output-format",
"json",
"--session-id",
"new-session",
"--prompt",
"Be concise.\n\nfirst",
]);
expect(mocks.spawn.mock.calls[1]![1]).toEqual([
"--model",
"gemini-test",
"--output-format",
"json",
"--resume",
"saved-session",
"--prompt",
"second",
]);
});

it("forwards Gemini CLI process options", async () => {
mocks.spawn.mockImplementationOnce(() => {
const child = childProcess();
respond(child, { response: "ok" });
return child;
});
const runtimeAgent = await geminiEngine({
command: "/opt/bin/gemini",
cwd: "/workspace",
args: ["--sandbox"],
env: { GEMINI_API_KEY: "test-key" },
approvalMode: "plan",
})({ model: "gemini-test" });

await runtimeAgent.ask("hello");

expect(mocks.spawn).toHaveBeenCalledWith(
"/opt/bin/gemini",
[
"--sandbox",
"--model",
"gemini-test",
"--output-format",
"json",
"--approval-mode",
"plan",
"--session-id",
"new-session",
"--prompt",
"hello",
],
expect.objectContaining({
cwd: "/workspace",
env: expect.objectContaining({ GEMINI_API_KEY: "test-key" }),
signal: expect.any(AbortSignal),
}),
);
});

it("reports CLI and JSON response errors", async () => {
mocks.spawn
.mockImplementationOnce(() => {
const child = childProcess();
queueMicrotask(() => {
child.stderr.end("authentication failed");
child.emit("close", 1);
});
return child;
})
.mockImplementationOnce(() => {
const child = childProcess();
respond(child, { error: { message: "request failed" } });
return child;
});
const runtimeAgent = await geminiEngine()({ model: "gemini-test" });

await expect(runtimeAgent.ask("first")).rejects.toThrow("authentication failed");
await expect(runtimeAgent.ask("second")).rejects.toThrow("request failed");
});

it("rejects unsupported agent options", () => {
const tool = defineTool("echo", { handler: () => "ok" });

expect(() => geminiEngine()({ model: "gemini-test", tools: [tool] }))
.toThrow("geminiEngine does not support Rig tools");
expect(() => geminiEngine()({ model: "gemini-test", systemMessage: [] }))
.toThrow("geminiEngine requires systemMessage to be a string");
});

it("aborts an active Gemini CLI process when closed", async () => {
let processSignal: AbortSignal | undefined;
mocks.spawn.mockImplementationOnce((_command, _args, options) => {
const child = childProcess();
processSignal = options.signal;
options.signal.addEventListener("abort", () => {
child.emit("error", options.signal.reason);
queueMicrotask(() => child.emit("close", null));
}, { once: true });
return child;
});
const runtimeAgent = await geminiEngine()({ model: "gemini-test" });
const result = runtimeAgent.ask("hello");

await runtimeAgent.close();

await expect(result).rejects.toThrow("Agent closed");
expect(processSignal?.aborted).toBe(true);
await expect(runtimeAgent.ask("after close")).rejects.toThrow("Agent closed");
});

it("does not spawn for a pre-aborted turn", async () => {
const runtimeAgent = await geminiEngine()({ model: "gemini-test" });
const controller = new AbortController();
controller.abort(new Error("cancelled"));

await expect(runtimeAgent.ask("hello", { signal: controller.signal }))
.rejects.toThrow("cancelled");
expect(mocks.spawn).not.toHaveBeenCalled();
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"rig": ["./skills/rig/rig.ts"],
"rig/engines/anthropic": ["./skills/rig/engines/anthropic.ts"],
"rig/engines/codex": ["./skills/rig/engines/codex.ts"],
"rig/engines/gemini": ["./skills/rig/engines/gemini.ts"],
"rig/engines/pi": ["./skills/rig/engines/pi.ts"],
"rig/*": ["./src/*.ts"]
}
Expand Down
Loading