diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 7b525c74c8..03cc91e3bd 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -229,6 +229,7 @@ export { CODING_AGENT_DRIVER_CONFIG_ENV, CODING_AGENT_DRIVER_NAMES, createCodingAgentDriver, + createDefaultCliSubprocessSpawn, createFakeCodingAgentDriverForFactory, isConfiguredCodingAgentDriver, resolveConfiguredCodingAgentDriverNames, diff --git a/packages/gittensory-engine/src/miner/driver-factory.ts b/packages/gittensory-engine/src/miner/driver-factory.ts index 352b471e6b..ba1af60ccd 100644 --- a/packages/gittensory-engine/src/miner/driver-factory.ts +++ b/packages/gittensory-engine/src/miner/driver-factory.ts @@ -1,6 +1,7 @@ // CodingAgentDriver factory + provider-style config resolution (#4289). Mirrors `src/selfhost/ai-config.ts:41-74`: // parse a comma-separated provider list, validate each name against what is actually configured, deny-by-default // on unknown/unconfigured names, and expose a model/effort config map analogous to `SELF_HOST_REVIEWER_MODEL_ENV`. +// Concrete backends: CLI-subprocess (#4266) and Agent-SDK (#4267), plus the built-in `noop` stub. import { createFakeCodingAgentDriver, @@ -17,17 +18,44 @@ import { } from "./coding-agent-mode.js"; import type { CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js"; import { guardCodingAgentDriverResult, type LintGuardOptions, type LintGuardResult } from "./lint-guard.js"; +import { + createCliSubprocessCodingAgentDriver, + type CliSubprocessSpawnFn, +} from "./cli-subprocess-driver.js"; +import { + createAgentSdkCodingAgentDriver, + type AgentSdkHooks, + type AgentSdkQueryFn, +} from "./agent-sdk-driver.js"; -/** Provider names the factory knows how to resolve today. Concrete CLI/SDK drivers land in #4266/#4267. */ -export const CODING_AGENT_DRIVER_NAMES = Object.freeze(["noop"] as const); +/** Provider names the factory knows how to resolve today. */ +export const CODING_AGENT_DRIVER_NAMES = Object.freeze([ + "noop", + "cli-subprocess", + "agent-sdk", +] as const); export type CodingAgentDriverName = (typeof CODING_AGENT_DRIVER_NAMES)[number]; /** Per-provider env keys for coding-agent configuration (mirrors `SELF_HOST_REVIEWER_MODEL_ENV`). */ -export const CODING_AGENT_DRIVER_CONFIG_ENV: Readonly> = - Object.freeze({ - noop: {}, - }); +export const CODING_AGENT_DRIVER_CONFIG_ENV: Readonly< + Record +> = Object.freeze({ + noop: {}, + "cli-subprocess": { + model: "MINER_CODING_AGENT_CLI_MODEL", + maxTurns: "MINER_CODING_AGENT_MAX_TURNS", + command: "MINER_CODING_AGENT_CLI", + timeoutMs: "MINER_CODING_AGENT_TIMEOUT_MS", + }, + "agent-sdk": { + model: "MINER_CODING_AGENT_SDK_MODEL", + maxTurns: "MINER_CODING_AGENT_MAX_TURNS", + }, +}); + +const DEFAULT_CLI_COMMAND = "claude"; +const DEFAULT_CLI_TIMEOUT_MS = 120_000; function parseDriverNames(env: Record): string[] { return (env.MINER_CODING_AGENT_PROVIDER ?? "") @@ -36,6 +64,66 @@ function parseDriverNames(env: Record): string[] { .filter(Boolean); } +function firstConfigured(...values: Array): string | undefined { + return values.find((value) => value !== undefined && value.trim() !== ""); +} + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + if (raw === undefined || raw.trim() === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +/** + * Default CLI spawn — lazy `node:child_process` import, mirroring `src/selfhost/ai.ts`'s `defaultSpawn`. + * The factory stays sync; the import runs on the first `run()` call. + */ +export function createDefaultCliSubprocessSpawn(): CliSubprocessSpawnFn { + /* v8 ignore start -- real child_process path; factory tests inject a fake SpawnFn (same convention as the CLI driver). */ + let impl: CliSubprocessSpawnFn | undefined; + let loading: Promise | undefined; + return async (cmd, args, opts) => { + if (!impl) { + loading ??= (async () => { + const cp = await import("node:child_process"); + const real: CliSubprocessSpawnFn = (spawnCmd, spawnArgs, spawnOpts) => + new Promise((resolve) => { + const child = cp.spawn(spawnCmd, [...spawnArgs], { + cwd: spawnOpts.cwd, + env: spawnOpts.env as NodeJS.ProcessEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + }); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ stdout, code: null, stderr, timedOut: true }); + }, spawnOpts.timeoutMs); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ stdout, code: null, stderr: error.message }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ stdout, code, stderr }); + }); + }); + impl = real; + return real; + })(); + impl = await loading; + } + return impl(cmd, args, opts); + }; + /* v8 ignore stop */ +} + /** True when `name` is a known, configured coding-agent driver. Unknown names → false (deny-by-default). */ export function isConfiguredCodingAgentDriver( name: string, @@ -43,6 +131,10 @@ export function isConfiguredCodingAgentDriver( ): boolean { switch (name) { case "noop": + // Local CLIs need no API key (same posture as `claude-code`/`codex` in `isConfiguredSelfHostProvider`). + case "cli-subprocess": + // Agent-SDK is a package dependency; the real `query()` path is a lazy dynamic import behind the driver default. + case "agent-sdk": return true; default: return false; @@ -60,6 +152,16 @@ export type CreateCodingAgentDriverOptions = { env?: Record | undefined; /** Test seam — inject a fake driver instead of constructing the named provider. */ driver?: CodingAgentDriver | undefined; + /** Injected CLI spawn for `cli-subprocess` (defaults to a real `child_process` spawn). */ + spawn?: CliSubprocessSpawnFn | undefined; + /** Injected Agent-SDK `query()` for `agent-sdk` (defaults to the real SDK export). */ + query?: AgentSdkQueryFn | undefined; + /** Forwarded onto the Agent-SDK session (`PreToolUse` etc.). */ + hooks?: AgentSdkHooks | undefined; + /** Override the CLI binary name (else `MINER_CODING_AGENT_CLI` / `claude`). */ + command?: string | undefined; + /** Override the CLI wall-clock budget (else `MINER_CODING_AGENT_TIMEOUT_MS` / 120s). */ + timeoutMs?: number | undefined; }; /** Resolve a concrete driver for `providerName`. Throws on unknown/unconfigured providers (fail-closed). */ @@ -73,6 +175,24 @@ export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions) switch (name) { case "noop": return createNoopCodingAgentDriver(); + case "cli-subprocess": { + const command = + firstConfigured(options.command, env.MINER_CODING_AGENT_CLI) ?? DEFAULT_CLI_COMMAND; + const timeoutMs = + options.timeoutMs ?? + parsePositiveInt(env.MINER_CODING_AGENT_TIMEOUT_MS, DEFAULT_CLI_TIMEOUT_MS); + return createCliSubprocessCodingAgentDriver({ + command, + spawn: options.spawn ?? createDefaultCliSubprocessSpawn(), + parentEnv: env, + timeoutMs, + }); + } + case "agent-sdk": + return createAgentSdkCodingAgentDriver({ + query: options.query, + hooks: options.hooks, + }); /* v8 ignore next -- isConfiguredCodingAgentDriver already rejects unknown names before this switch. */ default: throw new Error(`unconfigured_coding_agent_driver:${name}`); @@ -87,6 +207,11 @@ export type RunCodingAgentAttemptOptions = { task: CodingAgentDriverTask; log?: AttemptLogSink | undefined; driver?: CodingAgentDriver | undefined; + spawn?: CliSubprocessSpawnFn | undefined; + query?: AgentSdkQueryFn | undefined; + hooks?: AgentSdkHooks | undefined; + command?: string | undefined; + timeoutMs?: number | undefined; /** When supplied, the driver result is run through the lint guard (#4276) before being returned, so a * live coding-agent edit that fails its own package's typecheck/node --check never reads as `ok: true`. */ lintGuard?: LintGuardOptions | undefined; @@ -109,6 +234,11 @@ export async function runCodingAgentAttempt( providerName: options.providerName, env: options.env, driver: options.driver, + spawn: options.spawn, + query: options.query, + hooks: options.hooks, + command: options.command, + timeoutMs: options.timeoutMs, }); const result = await invokeCodingAgentDriver(driver, mode, options.task, options.log); if (!options.lintGuard) return { mode, result }; diff --git a/packages/gittensory-engine/test/driver-factory.test.ts b/packages/gittensory-engine/test/driver-factory.test.ts index 09accf277e..e50bdf7727 100644 --- a/packages/gittensory-engine/test/driver-factory.test.ts +++ b/packages/gittensory-engine/test/driver-factory.test.ts @@ -1,12 +1,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + CODING_AGENT_DRIVER_CONFIG_ENV, + CODING_AGENT_DRIVER_NAMES, createAttemptLogBuffer, createFakeCodingAgentDriver, createCodingAgentDriver, + createDefaultCliSubprocessSpawn, isConfiguredCodingAgentDriver, resolveConfiguredCodingAgentDriverNames, runCodingAgentAttempt, + type AgentSdkQueryFn, + type CliSubprocessSpawnFn, type CodingAgentDriverTask, } from "../dist/index.js"; @@ -18,16 +23,26 @@ const task: CodingAgentDriverTask = { maxTurns: 8, }; +test("CODING_AGENT_DRIVER_NAMES includes concrete backends", () => { + assert.deepEqual([...CODING_AGENT_DRIVER_NAMES], ["noop", "cli-subprocess", "agent-sdk"]); + assert.equal(CODING_AGENT_DRIVER_CONFIG_ENV["cli-subprocess"].command, "MINER_CODING_AGENT_CLI"); + assert.equal(CODING_AGENT_DRIVER_CONFIG_ENV["agent-sdk"].model, "MINER_CODING_AGENT_SDK_MODEL"); +}); + test("isConfiguredCodingAgentDriver is deny-by-default for unknown names", () => { assert.equal(isConfiguredCodingAgentDriver("noop", {}), true); + assert.equal(isConfiguredCodingAgentDriver("cli-subprocess", {}), true); + assert.equal(isConfiguredCodingAgentDriver("agent-sdk", {}), true); assert.equal(isConfiguredCodingAgentDriver("claude-code", {}), false); assert.equal(isConfiguredCodingAgentDriver("unknown", {}), false); }); test("resolveConfiguredCodingAgentDriverNames filters to configured providers only", () => { assert.deepEqual( - resolveConfiguredCodingAgentDriverNames({ MINER_CODING_AGENT_PROVIDER: "noop,unknown" }), - ["noop"], + resolveConfiguredCodingAgentDriverNames({ + MINER_CODING_AGENT_PROVIDER: "noop,cli-subprocess,unknown,agent-sdk", + }), + ["noop", "cli-subprocess", "agent-sdk"], ); }); @@ -35,6 +50,36 @@ test("createCodingAgentDriver throws for unconfigured providers", () => { assert.throws(() => createCodingAgentDriver({ providerName: "unknown" }), /unconfigured_coding_agent_driver/); }); +test("createCodingAgentDriver resolves cli-subprocess via injected spawn", async () => { + const calls: Array<{ cmd: string; timeoutMs: number }> = []; + const spawn: CliSubprocessSpawnFn = async (cmd, _args, opts) => { + calls.push({ cmd, timeoutMs: opts.timeoutMs }); + return { stdout: "ok", code: 0 }; + }; + const driver = createCodingAgentDriver({ + providerName: "cli-subprocess", + env: { MINER_CODING_AGENT_CLI: "codex", MINER_CODING_AGENT_TIMEOUT_MS: "30000" }, + spawn, + }); + const result = await driver.run(task); + assert.equal(result.ok, true); + assert.deepEqual(calls, [{ cmd: "codex", timeoutMs: 30_000 }]); +}); + +test("createCodingAgentDriver resolves agent-sdk via injected query", async () => { + const query: AgentSdkQueryFn = async function* () { + yield { type: "result", subtype: "success", result: "done", num_turns: 2 }; + }; + const driver = createCodingAgentDriver({ providerName: "agent-sdk", query }); + const result = await driver.run(task); + assert.equal(result.ok, true); + assert.equal(result.turnsUsed, 2); +}); + +test("createDefaultCliSubprocessSpawn is a function", () => { + assert.equal(typeof createDefaultCliSubprocessSpawn(), "function"); +}); + test("runCodingAgentAttempt wires mode + driver + attempt log end-to-end", async () => { const log = createAttemptLogBuffer(); const fake = createFakeCodingAgentDriver(); diff --git a/packages/gittensory-miner/docs/coding-agent-driver.md b/packages/gittensory-miner/docs/coding-agent-driver.md index deccc856e8..b9ae1c1ece 100644 --- a/packages/gittensory-miner/docs/coding-agent-driver.md +++ b/packages/gittensory-miner/docs/coding-agent-driver.md @@ -44,10 +44,11 @@ interface CodingAgentDriver { } ``` -Two reference implementations ship today for tests: `createFakeCodingAgentDriver` (records the last task, no IO) and +Two reference implementations ship for tests: `createFakeCodingAgentDriver` (records the last task, no IO) and `createNoopCodingAgentDriver` (default-OFF stub). The two real backends — a CLI-subprocess driver (#4266) and an -Agent-SDK driver (#4267) — are the seam's first concrete implementations; until they land, `createCodingAgentDriver` -resolves the built-in `noop` driver (`CODING_AGENT_DRIVER_NAMES` currently `["noop"]`). +Agent-SDK driver (#4267) — are registered in the factory as `cli-subprocess` and `agent-sdk` +(`CODING_AGENT_DRIVER_NAMES` is `["noop", "cli-subprocess", "agent-sdk"]`). Select one via +`MINER_CODING_AGENT_PROVIDER` (comma-separated; unknown names are denied by default). ## The surrounding primitives @@ -87,7 +88,7 @@ To add a driver beyond the CLI-subprocess and Agent-SDK backends: runCodingAgentAttempt(options) ├─ resolveCodingAgentExecutionMode(...) → paused | dry_run | live ├─ if !codingAgentModeExecutes(mode): → record a shadow/no-op attempt-log event, return without spawning - ├─ createCodingAgentDriver({ name, ... }) → the configured driver (today: noop) + ├─ createCodingAgentDriver({ name, ... }) → noop | cli-subprocess | agent-sdk └─ invokeCodingAgentDriver(driver, task, mode, log) ├─ log: attempt started ├─ driver.run(task) → edits inside task.workingDirectory only, ≤ task.maxTurns diff --git a/test/unit/coding-agent-miner.test.ts b/test/unit/coding-agent-miner.test.ts index 0ac6530b4a..a524b5b59a 100644 --- a/test/unit/coding-agent-miner.test.ts +++ b/test/unit/coding-agent-miner.test.ts @@ -7,6 +7,7 @@ import { codingAgentModeExecutes, createAttemptLogBuffer, createCodingAgentDriver, + createDefaultCliSubprocessSpawn, createFakeCodingAgentDriver, createFakeCodingAgentDriverForFactory, createNoopCodingAgentDriver, @@ -21,6 +22,8 @@ import { resolveCodingAgentModeFromConfig, resolveConfiguredCodingAgentDriverNames, runCodingAgentAttempt, + type AgentSdkQueryFn, + type CliSubprocessSpawnFn, type CodingAgentDriverResult, type CodingAgentDriverTask, type LintGuardSpawnFn, @@ -296,21 +299,27 @@ describe("invokeCodingAgentDriver (#4313)", () => { }); describe("coding-agent driver factory (#4289)", () => { - it("exposes the noop provider registry", () => { - expect([...CODING_AGENT_DRIVER_NAMES]).toEqual(["noop"]); + it("exposes the noop + concrete provider registry", () => { + expect([...CODING_AGENT_DRIVER_NAMES]).toEqual(["noop", "cli-subprocess", "agent-sdk"]); expect(CODING_AGENT_DRIVER_CONFIG_ENV.noop).toEqual({}); + expect(CODING_AGENT_DRIVER_CONFIG_ENV["cli-subprocess"].command).toBe("MINER_CODING_AGENT_CLI"); + expect(CODING_AGENT_DRIVER_CONFIG_ENV["agent-sdk"].model).toBe("MINER_CODING_AGENT_SDK_MODEL"); }); it("isConfiguredCodingAgentDriver is deny-by-default for unknown names", () => { expect(isConfiguredCodingAgentDriver("noop", {})).toBe(true); + expect(isConfiguredCodingAgentDriver("cli-subprocess", {})).toBe(true); + expect(isConfiguredCodingAgentDriver("agent-sdk", {})).toBe(true); expect(isConfiguredCodingAgentDriver("claude-code", {})).toBe(false); expect(isConfiguredCodingAgentDriver("unknown", {})).toBe(false); }); it("resolveConfiguredCodingAgentDriverNames filters to configured providers only", () => { expect( - resolveConfiguredCodingAgentDriverNames({ MINER_CODING_AGENT_PROVIDER: " noop , unknown , " }), - ).toEqual(["noop"]); + resolveConfiguredCodingAgentDriverNames({ + MINER_CODING_AGENT_PROVIDER: " noop , cli-subprocess , unknown , agent-sdk , ", + }), + ).toEqual(["noop", "cli-subprocess", "agent-sdk"]); expect(resolveConfiguredCodingAgentDriverNames({})).toEqual([]); }); @@ -323,6 +332,110 @@ describe("coding-agent driver factory (#4289)", () => { expect(() => createCodingAgentDriver({ providerName: "unknown" })).toThrow(/unconfigured_coding_agent_driver/); }); + it("createCodingAgentDriver resolves cli-subprocess with injected spawn and env/option overrides", async () => { + const calls: Array<{ cmd: string; opts: Parameters[2] }> = []; + const spawn: CliSubprocessSpawnFn = async (cmd, _args, opts) => { + calls.push({ cmd, opts }); + return { stdout: "ok", code: 0 }; + }; + + const fromEnv = createCodingAgentDriver({ + providerName: "cli-subprocess", + env: { MINER_CODING_AGENT_CLI: "codex", MINER_CODING_AGENT_TIMEOUT_MS: "45000" }, + spawn, + }); + await fromEnv.run(task); + expect(calls[0]?.cmd).toBe("codex"); + expect(calls[0]?.opts.timeoutMs).toBe(45_000); + + const fromOptions = createCodingAgentDriver({ + providerName: " CLI-SUBPROCESS ", + command: "claude", + timeoutMs: 9_000, + spawn, + }); + await fromOptions.run(task); + expect(calls[1]?.cmd).toBe("claude"); + expect(calls[1]?.opts.timeoutMs).toBe(9_000); + + const defaults = createCodingAgentDriver({ providerName: "cli-subprocess", spawn }); + await defaults.run(task); + expect(calls[2]?.cmd).toBe("claude"); + expect(calls[2]?.opts.timeoutMs).toBe(120_000); + + // Malformed / empty timeout env falls back to the default ceiling. + const badTimeout = createCodingAgentDriver({ + providerName: "cli-subprocess", + env: { MINER_CODING_AGENT_TIMEOUT_MS: "not-a-number" }, + spawn, + }); + await badTimeout.run(task); + expect(calls[3]?.opts.timeoutMs).toBe(120_000); + + const emptyTimeout = createCodingAgentDriver({ + providerName: "cli-subprocess", + env: { MINER_CODING_AGENT_TIMEOUT_MS: " " }, + spawn, + }); + await emptyTimeout.run(task); + expect(calls[4]?.opts.timeoutMs).toBe(120_000); + + const blankCommand = createCodingAgentDriver({ + providerName: "cli-subprocess", + env: { MINER_CODING_AGENT_CLI: " " }, + spawn, + }); + await blankCommand.run(task); + expect(calls[5]?.cmd).toBe("claude"); + }); + + it("createCodingAgentDriver resolves agent-sdk with an injected query and forwards hooks", async () => { + const seen: Array<{ prompt: string; hooks: unknown }> = []; + const query: AgentSdkQueryFn = async function* ({ prompt, options }) { + seen.push({ prompt, hooks: options.hooks }); + yield { type: "result", subtype: "success", result: "done", num_turns: 1 }; + }; + const hooks = { PreToolUse: [] }; + const driver = createCodingAgentDriver({ + providerName: "agent-sdk", + query, + hooks, + }); + const result = await driver.run(task); + expect(result.ok).toBe(true); + expect(seen[0]).toEqual({ prompt: task.instructions, hooks }); + }); + + it("createCodingAgentDriver builds concrete drivers without injected backends (default seams)", () => { + // Covers the `spawn ?? createDefaultCliSubprocessSpawn()` and `query`/`hooks` undefined arms without IO. + expect(createCodingAgentDriver({ providerName: "cli-subprocess" }).run).toBeTypeOf("function"); + expect(createCodingAgentDriver({ providerName: "agent-sdk" }).run).toBeTypeOf("function"); + expect(createDefaultCliSubprocessSpawn()).toBeTypeOf("function"); + }); + + it("runCodingAgentAttempt forwards spawn/query onto the resolved concrete driver", async () => { + const spawn: CliSubprocessSpawnFn = async () => ({ stdout: "cli-ok", code: 0 }); + const cli = await runCodingAgentAttempt({ + providerName: "cli-subprocess", + task, + spawn, + }); + expect(cli.mode).toBe("live"); + expect(cli.result.ok).toBe(true); + expect(cli.result.summary).toContain("claude completed"); + + const query: AgentSdkQueryFn = async function* () { + yield { type: "result", subtype: "success", result: "sdk-ok", num_turns: 1 }; + }; + const sdk = await runCodingAgentAttempt({ + providerName: "agent-sdk", + task, + query, + }); + expect(sdk.result.ok).toBe(true); + expect(sdk.result.summary).toContain("sdk-ok"); + }); + it("createFakeCodingAgentDriverForFactory is an identity helper", () => { expect(createFakeCodingAgentDriverForFactory().run).toBeTypeOf("function"); });