Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export {
CODING_AGENT_DRIVER_CONFIG_ENV,
CODING_AGENT_DRIVER_NAMES,
createCodingAgentDriver,
createDefaultCliSubprocessSpawn,
createFakeCodingAgentDriverForFactory,
isConfiguredCodingAgentDriver,
resolveConfiguredCodingAgentDriverNames,
Expand Down
142 changes: 136 additions & 6 deletions packages/gittensory-engine/src/miner/driver-factory.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Record<CodingAgentDriverName, { model?: string; maxTurns?: string }>> =
Object.freeze({
noop: {},
});
export const CODING_AGENT_DRIVER_CONFIG_ENV: Readonly<
Record<CodingAgentDriverName, { model?: string; maxTurns?: string; command?: string; timeoutMs?: string }>
> = 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, string | undefined>): string[] {
return (env.MINER_CODING_AGENT_PROVIDER ?? "")
Expand All @@ -36,13 +64,77 @@ function parseDriverNames(env: Record<string, string | undefined>): string[] {
.filter(Boolean);
}

function firstConfigured(...values: Array<string | undefined>): 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<CliSubprocessSpawnFn> | 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,
_env: Record<string, string | undefined>,
): 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;
Expand All @@ -60,6 +152,16 @@ export type CreateCodingAgentDriverOptions = {
env?: Record<string, string | undefined> | 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). */
Expand All @@ -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}`);
Expand All @@ -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;
Expand All @@ -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 };
Expand Down
49 changes: 47 additions & 2 deletions packages/gittensory-engine/test/driver-factory.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,23 +23,63 @@ 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"],
);
});

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();
Expand Down
9 changes: 5 additions & 4 deletions packages/gittensory-miner/docs/coding-agent-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading