From a9737db1769b7e25f4b6fec241673f176109ed9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:47:10 +0000 Subject: [PATCH 1/2] Add Gemini CLI agent integration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- README.md | 7 +- package.json | 1 + skills/rig/engines/gemini.ts | 150 +++++++++++++++++++++++++++++++ src/engines/gemini.test.ts | 169 +++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + vitest.config.ts | 1 + 6 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 skills/rig/engines/gemini.ts create mode 100644 src/engines/gemini.test.ts diff --git a/README.md b/README.md index 562b9c8..318ee0b 100644 --- a/README.md +++ b/README.md @@ -230,12 +230,13 @@ 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" })); @@ -243,12 +244,16 @@ configureAgent(piEngine({ provider: "anthropic" })); 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`). diff --git a/package.json b/package.json index 8d335b6..684d451 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/skills/rig/engines/gemini.ts b/skills/rig/engines/gemini.ts new file mode 100644 index 0000000..44a3620 --- /dev/null +++ b/skills/rig/engines/gemini.ts @@ -0,0 +1,150 @@ +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>(); + 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; + 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 { + 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 = ""; + 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", reject); + child.once("close", (code) => { + 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"); + } +} diff --git a/src/engines/gemini.test.ts b/src/engines/gemini.test.ts new file mode 100644 index 0000000..43c870a --- /dev/null +++ b/src/engines/gemini.test.ts @@ -0,0 +1,169 @@ +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; +}; + +function childProcess(): MockChild { + const child = new EventEmitter() as MockChild; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + 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); + }, { 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"); +}); diff --git a/tsconfig.json b/tsconfig.json index fac3235..97ec57c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } diff --git a/vitest.config.ts b/vitest.config.ts index f955303..7d8533c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ { find: /^rig$/, replacement: resolve(__dirname, "skills/rig/rig.ts") }, { find: /^rig\/engines\/anthropic$/, replacement: resolve(__dirname, "skills/rig/engines/anthropic.ts") }, { find: /^rig\/engines\/codex$/, replacement: resolve(__dirname, "skills/rig/engines/codex.ts") }, + { find: /^rig\/engines\/gemini$/, replacement: resolve(__dirname, "skills/rig/engines/gemini.ts") }, { find: /^rig\/engines\/pi$/, replacement: resolve(__dirname, "skills/rig/engines/pi.ts") }, { find: /^rig\/(.*)$/, replacement: resolve(__dirname, "src/$1") }, ], From fe0b8f676cc7992cf00f4ebc7ecbd09659a17224 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:51:46 +0000 Subject: [PATCH 2/2] Harden Gemini process cancellation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- skills/rig/engines/gemini.ts | 20 +++++++++++++++++++- src/engines/gemini.test.ts | 13 +++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/skills/rig/engines/gemini.ts b/skills/rig/engines/gemini.ts index 44a3620..bc3bbf6 100644 --- a/skills/rig/engines/gemini.ts +++ b/skills/rig/engines/gemini.ts @@ -47,6 +47,7 @@ export function geminiEngine(options: GeminiEngineOptions = {}): AgentFactory { 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}` @@ -110,6 +111,13 @@ function runGemini( 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) => { @@ -118,8 +126,18 @@ function runGemini( child.stderr.on("data", (chunk: string) => { stderr += chunk; }); - child.once("error", reject); + 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; diff --git a/src/engines/gemini.test.ts b/src/engines/gemini.test.ts index 43c870a..ca6dda1 100644 --- a/src/engines/gemini.test.ts +++ b/src/engines/gemini.test.ts @@ -16,12 +16,14 @@ import { geminiEngine } from "rig/engines/gemini"; type MockChild = EventEmitter & { stdout: PassThrough; stderr: PassThrough; + kill: ReturnType; }; function childProcess(): MockChild { const child = new EventEmitter() as MockChild; child.stdout = new PassThrough(); child.stderr = new PassThrough(); + child.kill = vi.fn(); return child; } @@ -155,6 +157,7 @@ it("aborts an active Gemini CLI process when closed", async () => { processSignal = options.signal; options.signal.addEventListener("abort", () => { child.emit("error", options.signal.reason); + queueMicrotask(() => child.emit("close", null)); }, { once: true }); return child; }); @@ -167,3 +170,13 @@ it("aborts an active Gemini CLI process when closed", async () => { 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(); +});