diff --git a/README.md b/README.md index 5195cec..e125391 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,8 @@ configureAgent(codexEngine()); configureAgent(geminiEngine()); ``` +If you do not call `configureAgent(...)`, Rig now auto-selects a default engine from environment variables: `COPILOT_SDK_URI` → `copilotEngine()`, then `RIG_ENGINE` (`copilot` | `anthropic` | `codex` | `gemini`) if set, then `ANTHROPIC_API_KEY` → `anthropicEngine()`, `OPENAI_API_KEY` → `codexEngine()`, `GEMINI_API_KEY`/`GOOGLE_API_KEY` → `geminiEngine()`, otherwise `copilotEngine()`. + `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. @@ -304,7 +306,7 @@ configureAgent(geminiEngine()); ## Copilot SDK adapter -By default it connects to an already-running Copilot server via HTTP (`COPILOT_SDK_URI`, then `localhost:7777`). +When Copilot is selected, it connects to an already-running Copilot server via HTTP (`COPILOT_SDK_URI`, then `localhost:7777`). Pass `--server` to spawn the server over stdio when launching a program. Run `node skills/rig/rig.ts --help` for CLI usage; the launcher also accepts common help aliases such as `-h`, `help`, `/help`, and `/?`. diff --git a/skills/rig/references/runtime.md b/skills/rig/references/runtime.md index 121abaf..177937e 100644 --- a/skills/rig/references/runtime.md +++ b/skills/rig/references/runtime.md @@ -85,6 +85,13 @@ configureAgent(codexEngine()); configureAgent(geminiEngine()); ``` +- If you do not call `configureAgent(...)`, Rig auto-selects an engine from env vars: + - `COPILOT_SDK_URI` → `copilotEngine()` + - `RIG_ENGINE` (`copilot` | `anthropic` | `codex` | `gemini`) to force a specific default when Copilot URI is not set. + - `ANTHROPIC_API_KEY` → `anthropicEngine()` + - `OPENAI_API_KEY` → `codexEngine()` + - `GEMINI_API_KEY` or `GOOGLE_API_KEY` → `geminiEngine()` + - otherwise → `copilotEngine()` - `copilotEngine()` uses the Copilot SDK HTTP transport by default; launcher `--server` selects stdio. - `piEngine({ provider })` uses `@earendil-works/pi-agent-core` and requires a provider for model lookup. - `anthropicEngine()` uses `@anthropic-ai/sdk` and reads `ANTHROPIC_API_KEY`. diff --git a/skills/rig/rig.ts b/skills/rig/rig.ts index 7f672de..0a264a2 100644 --- a/skills/rig/rig.ts +++ b/skills/rig/rig.ts @@ -441,6 +441,63 @@ function resolveDefaultCopilotUri(): string { return process.env["COPILOT_SDK_URI"] ?? "localhost:7777"; } +type DefaultEngineKind = "copilot" | "anthropic" | "codex" | "gemini"; + +type DefaultEngineOptions = { + cwd?: string; + startServer?: boolean; +}; + +function hasNonEmptyEnv(name: string): boolean { + const value = process.env[name]; + return typeof value === "string" && value.trim().length > 0; +} + +function resolveDefaultEngineKind(options: DefaultEngineOptions = {}): DefaultEngineKind { + if (options.startServer) { + return "copilot"; + } + if (hasNonEmptyEnv("COPILOT_SDK_URI")) { + return "copilot"; + } + const configuredEngine = process.env["RIG_ENGINE"]?.trim().toLowerCase(); + if (configuredEngine === "copilot" || configuredEngine === "anthropic" || configuredEngine === "codex" || configuredEngine === "gemini") { + return configuredEngine; + } + if (hasNonEmptyEnv("ANTHROPIC_API_KEY")) { + return "anthropic"; + } + if (hasNonEmptyEnv("OPENAI_API_KEY")) { + return "codex"; + } + if (hasNonEmptyEnv("GEMINI_API_KEY") || hasNonEmptyEnv("GOOGLE_API_KEY")) { + return "gemini"; + } + return "copilot"; +} + +function defaultAgentFactory(options: DefaultEngineOptions = {}): AgentFactory { + return async (agentOptions) => { + const kind = resolveDefaultEngineKind(options); + if (kind === "anthropic") { + const { anthropicEngine } = await import("./engines/anthropic.ts"); + return anthropicEngine()(agentOptions); + } + if (kind === "codex") { + const { codexEngine } = await import("./engines/codex.ts"); + return codexEngine(options.cwd ? { thread: { workingDirectory: options.cwd } } : {})(agentOptions); + } + if (kind === "gemini") { + const { geminiEngine } = await import("./engines/gemini.ts"); + return geminiEngine(options.cwd ? { cwd: options.cwd } : {})(agentOptions); + } + const copilotOptions = options.cwd + ? resolveCopilotOptions(options.cwd, options.startServer ? { startServer: true } : {}) + : options.startServer ? { server: true } : {}; + return copilotEngine(copilotOptions)(agentOptions); + }; +} + export function copilotEngine(options: CopilotEngineOptions = {}): AgentFactory { const { server, connection, ...clientOptions } = options; return async (agentOptions) => { @@ -1176,7 +1233,7 @@ export class AgentError extends Error { } } -let currentAgentFactory: AgentFactory = copilotEngine(); +let currentAgentFactory: AgentFactory = defaultAgentFactory(); /** * Mounts an engine and executes a rig program file. @@ -1186,7 +1243,7 @@ export async function launchRigProgram(programPath: string, options: LaunchOptio const cwd = options.cwd ?? process.cwd(); const resolvedPath = isAbsolute(programPath) ? programPath : resolve(cwd, programPath); - configureAgent(copilotEngine(resolveCopilotOptions(cwd, options))); + configureAgent(defaultAgentFactory({ cwd, ...(options.startServer ? { startServer: true } : {}) })); await import(pathToFileURL(resolvedPath).href); } @@ -1426,7 +1483,7 @@ async function runRootAgentFromStdin( throw new Error(`Usage: ${scriptName} `); } - configureAgent(copilotEngine(resolveCopilotOptions(cwd, options))); + configureAgent(defaultAgentFactory({ cwd, ...(options.startServer ? { startServer: true } : {}) })); const mod = await import(pathToFileURL(resolvedPath).href); const rootAgent = asRootProgram(mod.default, "launcher-root"); if (!rootAgent) { @@ -1460,7 +1517,7 @@ async function runProgramCodeFromStdin( io.stdout.write("typecheck passed\n"); return; } - configureAgent(copilotEngine(resolveCopilotOptions(cwd, options))); + configureAgent(defaultAgentFactory({ cwd, ...(options.startServer ? { startServer: true } : {}) })); const mod = await import(pathToFileURL(tempProgramPath).href); const rootAgent = asRootProgram(mod.default, "launcher-inline-root"); if (!rootAgent) { @@ -1502,7 +1559,7 @@ function renderLauncherUsage(scriptName: string): string { } /** - * Entry-point CLI that parses `argv`, wires a `copilotEngine`, and runs the + * Entry-point CLI that parses `argv`, wires the default engine selection, and runs the * agent program. Two modes are supported: * * - **File mode** (`runLauncherCli(["path/to/prog.ts"])`): reads the agent diff --git a/src/launcher-default-engine.test.ts b/src/launcher-default-engine.test.ts index df08a93..52f886d 100644 --- a/src/launcher-default-engine.test.ts +++ b/src/launcher-default-engine.test.ts @@ -1,6 +1,6 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, it, vi } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const approveAll = vi.fn(); @@ -13,7 +13,45 @@ const mocks = vi.hoisted(() => { copilotClientCtor(options); return { createSession, stop: stopClient }; }; - return { approveAll, createSession, stopClient, copilotClientCtor, defaultForUri, forUri, CopilotClient }; + + const anthropicConstructor = vi.fn(); + const anthropicToolRunner = vi.fn(() => ({ + async runUntilDone() { + return { content: [{ type: "text", text: JSON.stringify("anthropic-mounted") }] }; + }, + params: { messages: [] }, + })); + const Anthropic = function (this: unknown, options: unknown) { + anthropicConstructor(options); + return { beta: { messages: { toolRunner: anthropicToolRunner } } }; + }; + const betaTool = vi.fn((tool) => tool); + + const codexConstructor = vi.fn(); + const codexRun = vi.fn(async () => ({ finalResponse: JSON.stringify("codex-mounted") })); + const codexStartThread = vi.fn(() => ({ run: codexRun })); + const Codex = function (this: unknown, options: unknown) { + codexConstructor(options); + return { startThread: codexStartThread }; + }; + + return { + approveAll, + createSession, + stopClient, + copilotClientCtor, + defaultForUri, + forUri, + CopilotClient, + anthropicConstructor, + anthropicToolRunner, + Anthropic, + betaTool, + codexConstructor, + codexRun, + codexStartThread, + Codex, + }; }); vi.mock("@github/copilot-sdk", () => ({ @@ -21,9 +59,29 @@ vi.mock("@github/copilot-sdk", () => ({ CopilotClient: mocks.CopilotClient, RuntimeConnection: { forUri: mocks.forUri, forStdio: vi.fn() }, })); +vi.mock("@anthropic-ai/sdk", () => ({ default: mocks.Anthropic })); +vi.mock("@anthropic-ai/sdk/helpers/beta/json-schema", () => ({ betaTool: mocks.betaTool })); +vi.mock("@openai/codex-sdk", () => ({ Codex: mocks.Codex })); import { agent, launchRigProgram, s } from "rig"; +beforeEach(() => { + mocks.copilotClientCtor.mockClear(); + mocks.createSession.mockReset(); + mocks.anthropicConstructor.mockClear(); + mocks.anthropicToolRunner.mockClear(); + mocks.betaTool.mockClear(); + mocks.codexConstructor.mockClear(); + mocks.codexRun.mockClear(); + mocks.codexStartThread.mockClear(); + delete process.env["COPILOT_SDK_URI"]; + delete process.env["RIG_ENGINE"]; + delete process.env["ANTHROPIC_API_KEY"]; + delete process.env["OPENAI_API_KEY"]; + delete process.env["GEMINI_API_KEY"]; + delete process.env["GOOGLE_API_KEY"]; +}); + it("uses the launcher cwd when mounting the default copilot engine", async () => { const sendAndWait = vi.fn().mockResolvedValue(JSON.stringify("default-mounted")); mocks.createSession.mockResolvedValue({ sendAndWait }); @@ -70,3 +128,63 @@ it("uses COPILOT_SDK_URI when mounting the default copilot engine", async () => mocks.forUri.mockImplementation(mocks.defaultForUri); } }); + +it("prefers COPILOT_SDK_URI over RIG_ENGINE when mounting the default engine", async () => { + const sendAndWait = vi.fn().mockResolvedValue(JSON.stringify("copilot-preferred")); + mocks.createSession.mockResolvedValue({ sendAndWait }); + process.env["COPILOT_SDK_URI"] = "http://127.0.0.1:4242"; + process.env["RIG_ENGINE"] = "anthropic"; + process.env["ANTHROPIC_API_KEY"] = "test-key"; + mocks.forUri.mockImplementation(((url: string) => ({ kind: "uri", url })) as any); + + const fixturePath = resolve(dirname(fileURLToPath(import.meta.url)), "./launcher.fixture.ts"); + + try { + await launchRigProgram(fixturePath); + + const call = agent({ + name: "launcher-default-engine-copilot-preferred-test", + input: s.object({}), + }); + const result = await call({}); + expect(result).toBe("copilot-preferred"); + expect(mocks.forUri).toHaveBeenCalledWith("http://127.0.0.1:4242"); + expect(mocks.copilotClientCtor).toHaveBeenCalled(); + expect(mocks.anthropicConstructor).not.toHaveBeenCalled(); + } finally { + mocks.forUri.mockImplementation(mocks.defaultForUri); + } +}); + +it("automatically mounts anthropicEngine when ANTHROPIC_API_KEY is set", async () => { + process.env["ANTHROPIC_API_KEY"] = "test-key"; + const fixturePath = resolve(dirname(fileURLToPath(import.meta.url)), "./launcher.fixture.ts"); + + await launchRigProgram(fixturePath); + + const call = agent({ + name: "launcher-default-engine-anthropic-test", + input: s.object({}), + }); + const result = await call({}); + expect(result).toBe("anthropic-mounted"); + expect(mocks.anthropicConstructor).toHaveBeenCalledWith({}); + expect(mocks.copilotClientCtor).not.toHaveBeenCalled(); +}); + +it("automatically mounts codexEngine when OPENAI_API_KEY is set", async () => { + process.env["OPENAI_API_KEY"] = "test-key"; + const fixturePath = resolve(dirname(fileURLToPath(import.meta.url)), "./launcher.fixture.ts"); + + await launchRigProgram(fixturePath); + + const call = agent({ + name: "launcher-default-engine-codex-test", + input: s.object({}), + }); + const result = await call({}); + expect(result).toBe("codex-mounted"); + expect(mocks.codexConstructor).toHaveBeenCalledWith({}); + expect(mocks.codexStartThread).toHaveBeenCalledWith(expect.objectContaining({ model: "small" })); + expect(mocks.copilotClientCtor).not.toHaveBeenCalled(); +});