Skip to content
Open
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
13 changes: 13 additions & 0 deletions .factory/factory.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export default defineConfig({
// Add a workflow by importing it and listing it here.
workflows: [hello, implementParentIssue],

// The coding agent a step runs on ("claude" or "opencode") unless the
// workflow, the run or the ctx.agent call names one, and the model each
// agent runs on unless one of those names a model. Factory always sends a
// model; ids are the agent's own and passed verbatim. This project's runs
// stay on opencode, where they ran before agents were selectable.
agent: {
default: "opencode",
models: {
claude: "sonnet",
opencode: "opencode/big-pickle",
},
},

// How many runs may be in flight at once. A run over the limit is refused,
// not queued.
maxConcurrentRuns: 2,
Expand Down
18 changes: 11 additions & 7 deletions .factory/workflows/implement-parent-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,17 @@
* agent owns them here, because each sub-issue's PR is created mid-run (not
* once, at the end), which `writeBack`'s single-write shape doesn't cover.
*
* Inputs: the parent issue number and an optional `model` override, forwarded
* as the per-call model for every agent step (run.ts's precedence:
* per-call > workflow default > runtime fallback).
* Inputs: the parent issue number and an optional `agent` and `model`,
* forwarded as the per-call choice for every agent step. Left out, the run's
* own choice applies, then the config's `agent.default` / `agent.models`
* (ADR 0013 §2; a model only applies to the agent it was named with).
*
* Output: the array of pull requests the loop opened, `{ issueNumber, prUrl,
* branch }` per sub-issue served.
*/

import { defineWorkflow, Schema, type AgentCallOptions } from "@frebreco/factory";

const DEFAULT_MODEL = "omniroute/opencode-go/glm-5.3-flash";

const NextIssueOutput = Schema.Struct({
/** The sub-issue to work on next, or null when every sub-issue is served/blockered. */
issueNumber: Schema.NullOr(Schema.Int),
Expand All @@ -42,6 +41,7 @@ const PullRequest = Schema.Struct({

const Input = Schema.Struct({
parentIssueNumber: Schema.Int,
agent: Schema.optional(Schema.Literals(["claude", "opencode"])),
model: Schema.optional(Schema.String),
});

Expand Down Expand Up @@ -140,8 +140,12 @@ To undo a link, use removeCloseIssueReferences with the same inputs. It only
works while the issue is open.
`;

const agentOpts = (input: { readonly model?: string }): AgentCallOptions => ({
model: input.model ?? DEFAULT_MODEL,
const agentOpts = (input: {
readonly agent?: AgentCallOptions["agent"];
readonly model?: string;
}): AgentCallOptions => ({
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.model !== undefined ? { model: input.model } : {}),
});

export default defineWorkflow("implement-parent-issue", {
Expand Down
4 changes: 2 additions & 2 deletions e2e/implement-issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import { hostExec } from "../src/lib/exec";
import { createCorpusReplayAdapter } from "../src/replay/adapter";
import { fakeAgents, createCorpusReplayAdapter } from "../src/replay/adapter";
import { makeAgentRuntime } from "../src/runtime/agent-runtime";
import { startRun } from "../src/runtime/run";
import implementIssue from "./implement-issue";
Expand Down Expand Up @@ -121,7 +121,7 @@ describe("implement-issue workflow, replayed against the recorded round-trip cor
const events: Array<unknown> = [];
const handle = startRun(
implementIssue,
makeAgentRuntime(createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS)),
makeAgentRuntime(fakeAgents(createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS))),
{
runId: "test-run-corpus-replay",
dir: workDir,
Expand Down
42 changes: 27 additions & 15 deletions e2e/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { join } from "node:path";
import { defineConfig } from "../src/config";
import { hostExec } from "../src/lib/exec";
import { appendEvent, openStore } from "../src/persistence/store";
import { createCorpusReplayAdapter, createSlowFakeAdapter } from "../src/replay/adapter";
import {
fakeAgents,
createCorpusReplayAdapter,
createSlowFakeAdapter,
} from "../src/replay/adapter";
import { makeAgentRuntime } from "../src/runtime/agent-runtime";
import { startRun } from "../src/runtime/run";
import { startDaemon } from "../src/server/daemon";
Expand Down Expand Up @@ -138,25 +142,33 @@ async function seedCorpusRuns(root: string, db: ReturnType<typeof openStore>): P
process.env.PATH = `${binDir}:${originalPath}`;

await awaitRun(
startRun(implementIssue, makeAgentRuntime(createCorpusReplayAdapter(CORPUS_ROUND_TRIP)), {
runId: "run-static-corpus",
dir: workDir,
repo: { slug: "local/fixture", baseBranch: "main" },
input: { issueNumber: 1 },
onEvent: (event) => appendEvent(db, event),
}),
startRun(
implementIssue,
makeAgentRuntime(fakeAgents(createCorpusReplayAdapter(CORPUS_ROUND_TRIP))),
{
runId: "run-static-corpus",
dir: workDir,
repo: { slug: "local/fixture", baseBranch: "main" },
input: { issueNumber: 1 },
onEvent: (event) => appendEvent(db, event),
},
),
);

await Bun.sleep(20);

// A second, cheap run so list ordering has more than one data point.
await awaitRun(
startRun(echoWorkflow, makeAgentRuntime(createCorpusReplayAdapter(CORPUS_ONE_STEP)), {
runId: "run-static-echo",
dir: workDir,
input: {},
onEvent: (event) => appendEvent(db, event),
}),
startRun(
echoWorkflow,
makeAgentRuntime(fakeAgents(createCorpusReplayAdapter(CORPUS_ONE_STEP))),
{
runId: "run-static-echo",
dir: workDir,
input: {},
onEvent: (event) => appendEvent(db, event),
},
),
);

await Bun.sleep(20);
Expand Down Expand Up @@ -276,7 +288,7 @@ async function main(): Promise<void> {
timezone: "UTC",
},
],
agent: { adapter: createSlowFakeAdapter(SLOW_CHUNKS, 1_000) },
agent: fakeAgents(createSlowFakeAdapter(SLOW_CHUNKS, 1_000)),
});
const { server } = await startDaemon({ dbPath, port, config });
console.log(`factory e2e: listening on http://localhost:${server.port}`);
Expand Down
2 changes: 1 addition & 1 deletion spikes/13-acp-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const adapter = acpAdapter(definition, {

const dir = await scratchRepo();
console.log(`${mode} ${agent} model=${model} dir=${dir}`);
const runtime = makeAgentRuntime(adapter);
const runtime = makeAgentRuntime({ adapter, default: agent });
const t0 = Date.now();
const handle = startRun(mode === "isolation" ? isolation : hello, runtime, {
runId: `spike-${stamp}`,
Expand Down
26 changes: 26 additions & 0 deletions src/cli-argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,32 @@ describe("CLI argv parsing with effect/unstable/cli", () => {
expect(Option.getOrNull(r.url)).toBe("http://localhost:3000");
});

test("--agent and --model (ADR 0013 §2)", async () => {
const result = await Effect.runPromise(
runFactory(["start", "wf", "--input", "{}", "--agent", "claude", "--model", "haiku"]),
);
const r = result as { agent: Option.Option<string>; model: Option.Option<string> };
expect(Option.getOrNull(r.agent)).toBe("claude");
expect(Option.getOrNull(r.model)).toBe("haiku");
});

test("--agent and --model are optional", async () => {
const result = await Effect.runPromise(runFactory(["start", "wf", "--input", "{}"]));
const r = result as { agent: Option.Option<string>; model: Option.Option<string> };
expect(Option.isNone(r.agent)).toBe(true);
expect(Option.isNone(r.model)).toBe(true);
});

test("an unknown --agent fails", async () => {
let failed = false;
try {
await Effect.runPromise(runFactory(["start", "wf", "--input", "{}", "--agent", "codex"]));
} catch {
failed = true;
}
expect(failed).toBe(true);
});

test("missing --input fails", async () => {
let failed = false;
try {
Expand Down
24 changes: 24 additions & 0 deletions src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@ import {
type CliOptions,
type StartCliOptions,
} from "./cli";
import { ACP_AGENT_KINDS, type AcpAgentKind } from "./runtime/acp-agents";
import { startDaemon, type DaemonOptions } from "./server/daemon";

const DEFAULT_DB_PATH = ".factory/factory.db";
const DEFAULT_DAEMON_URL = "http://localhost:3000";

/** `--agent` / `--model` as the run's agent choice, absent when neither is given. */
function agentChoiceOf(
agent: AcpAgentKind | undefined,
model: string | undefined,
): Pick<StartCliOptions, "agent"> {
if (agent === undefined && model === undefined) return {};
return {
agent: {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
},
};
}

// Each command's flag/argument spec is exported on its own so the argv tests
// parse against these exact specs with a capturing handler, instead of a
// hand-copied tree that could drift from the real one.
Expand Down Expand Up @@ -112,6 +127,14 @@ export const startConfig = {
Flag.withDefault(false),
Flag.withDescription("Stream the run's events and exit with its exit code"),
),
agent: Flag.Literals("agent", ACP_AGENT_KINDS).pipe(
Flag.optional,
Flag.withDescription("The agent this run's steps use, unless a step names one"),
),
model: Flag.String("model").pipe(
Flag.optional,
Flag.withDescription("The model this run's steps use, unless a step names one"),
),
};

export const startCommand = Command.make("start", startConfig, (config) =>
Expand All @@ -130,6 +153,7 @@ export const startCommand = Command.make("start", startConfig, (config) =>
workflowId: config.workflowId,
input,
watch: config.watch,
...agentChoiceOf(Option.getOrUndefined(config.agent), Option.getOrUndefined(config.model)),
baseUrl: Option.getOrElse(config.url, () => process.env.FACTORY_URL ?? DEFAULT_DAEMON_URL),
};

Expand Down
8 changes: 4 additions & 4 deletions src/cli.start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import { defineConfig } from "./config";
import { getRunEvents, openStore } from "./persistence/store";
import { createSlowFakeAdapter } from "./replay/adapter";
import { createSlowFakeAdapter, fakeAgents } from "./replay/adapter";
import registryWorkflow from "../test/fixtures/registry-workflow";
import { startDaemon } from "./server/daemon";

Expand All @@ -26,16 +26,16 @@ async function startTestDaemon(delayMs: number, root: string): Promise<TestDaemo
dbPath,
port: 0,
config: defineConfig({
agent: {
adapter: createSlowFakeAdapter(
agent: fakeAgents(
createSlowFakeAdapter(
[
{ type: "TEXT_MESSAGE_START" },
{ type: "TEXT_MESSAGE_CONTENT", delta: "hi" },
{ type: "TEXT_MESSAGE_END" },
],
delayMs,
),
},
),
repo: {
sshUrl: seed,
identity: { name: "Factory", email: "factory@factory.test" },
Expand Down
20 changes: 11 additions & 9 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import { getRunEvents, listRuns, openStore } from "./persistence/store";
import { createSlowFakeAdapter } from "./replay/adapter";
import { createSlowFakeAdapter, fakeAgents } from "./replay/adapter";
import { runCli } from "./cli";

const ECHO_WORKFLOW = `${import.meta.dir}/../test/fixtures/echo-workflow.ts`;
Expand All @@ -19,13 +19,15 @@ describe("runCli", () => {
dir,
outPath,
dbPath: join(dir, "factory.db"),
adapter: createSlowFakeAdapter(
[
{ type: "TEXT_MESSAGE_START" },
{ type: "TEXT_MESSAGE_CONTENT", delta: "hi" },
{ type: "TEXT_MESSAGE_END" },
],
1,
agent: fakeAgents(
createSlowFakeAdapter(
[
{ type: "TEXT_MESSAGE_START" },
{ type: "TEXT_MESSAGE_CONTENT", delta: "hi" },
{ type: "TEXT_MESSAGE_END" },
],
1,
),
),
});

Expand Down Expand Up @@ -66,7 +68,7 @@ describe("runCli", () => {
dir,
outPath,
dbPath: join(dir, "factory.db"),
adapter: createSlowFakeAdapter([], 1),
agent: fakeAgents(createSlowFakeAdapter([], 1)),
});
} catch (err) {
thrown = err;
Expand Down
30 changes: 19 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* `factory run <workflow.ts>` (STATUS.md phase 1: "CLI-driven, in-memory; no
* server, no sqlite"). Dynamic-imports a workflow module's `default` export,
* optionally clones a fresh working tree, runs it against real opencode,
* optionally clones a fresh working tree, runs it on the agents the config names (ADR 0013),
* and streams `RunEvent`s to stdout and an NDJSON file. `SIGINT` cancels the
* in-flight run rather than killing the process outright, so the runtime's
* own cancellation path (`RunCancelledSignal`, `AgentStepFinished{outcome:
Expand All @@ -27,8 +27,8 @@ import { resetClone } from "./lib/clone";
import { loadWorkflow } from "./lib/load-workflow";
import { streamSse } from "./lib/sse-client";
import { appendEvent, getRunEvents, listRuns, openStore } from "./persistence/store";
import type { AgentAdapter } from "./runtime/agent-adapter";
import { makeAgentRuntime } from "./runtime/agent-runtime";
import type { AgentChoice } from "./runtime/agent-choice";
import { makeAgentRuntime, type AgentRuntimeConfig } from "./runtime/agent-runtime";
import { startRun } from "./runtime/run";
import { factoryCommand } from "./cli-commands";

Expand All @@ -40,10 +40,11 @@ export interface CliOptions {
readonly outPath: string;
readonly dbPath: string;
/**
* Injectable agent adapter for tests. When omitted, `runCli` falls back to
* `factory.config.ts`'s `agent.adapter` (or the live opencode adapter).
* Injectable agent runtime for tests. When omitted, `runCli` uses
* `factory.config.ts`'s `agent` block (the ACP runtime unless it injects an
* adapter), or the bare defaults without a config.
*/
readonly adapter?: AgentAdapter;
readonly agent?: AgentRuntimeConfig;
}

function formatEvent(event: RunEvent): string {
Expand All @@ -66,17 +67,18 @@ export async function runCli(options: CliOptions): Promise<number> {

const runId = `run-${Date.now()}`;
let repo: RunRepo | undefined;
let adapter = options.adapter;
let agent: AgentRuntimeConfig = {};
try {
const config = await loadFactoryConfig();
repo = { slug: config.repo.slug, baseBranch: config.repo.baseBranch };
adapter ??= config.agent.adapter;
agent = config.agent;
} catch {
repo = undefined;
}
// The direct-run path's composition root (issue #36): one runtime for this
// one run, disposed once the run settles.
const runtime = makeAgentRuntime(adapter);
// one run, disposed once the run settles. Without a config the steps run
// on `DEFAULT_AGENT` and must name their model themselves.
const runtime = makeAgentRuntime(options.agent ?? agent);
const handle = startRun(workflow, runtime, {
runId,
dir: options.dir,
Expand Down Expand Up @@ -151,6 +153,8 @@ export interface StartCliOptions {
readonly workflowId: string;
readonly input: unknown;
readonly watch: boolean;
/** The run level of the agent/model choice (ADR 0013 §2): `--agent` / `--model`. */
readonly agent?: AgentChoice;
}

export interface WatchSseOptions {
Expand Down Expand Up @@ -215,7 +219,11 @@ export async function startCli(options: StartCliOptions): Promise<number> {
res = await fetch(`${options.baseUrl}/api/runs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workflowId: options.workflowId, input: options.input }),
body: JSON.stringify({
workflowId: options.workflowId,
input: options.input,
...(options.agent !== undefined ? { agent: options.agent } : {}),
}),
});
} catch (err) {
console.error(
Expand Down
Loading
Loading