From 440e4c6339eac29ecce2219632e4bc62d62be385 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 19:42:57 +0000 Subject: [PATCH] feat(workflow): choose agent and model separately, ACP as the default runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0013 §2-§4 on the authoring and config surface. - `agent?: "claude" | "opencode"` next to `model` on `AgentCallOptions`, workflow `agent` defaults, schedule `agent`, and the run request (`POST /api/runs` `agent: {agent?, model?}`, `factory start --agent --model`). - `config.agent`: `default`, `models`, `hostSettings` (default "ignore"), validated at load; `adapter` stays the injection point for replay/fakes. - Resolution (`runtime/agent-choice.ts`): agent from the most specific level that names one, else `agent.default`; model from the most specific level at or above the one that chose the agent, else `agent.models[agent]`. A step whose model resolves to nothing fails before it starts, naming the fix. - The default runtime is the ACP runtime: one `acpAdapter` per agent definition, picked per step by the new `AgentAdapterOptions.agent`. The agent runtime service carries the config rung (default agent, models). - `AgentStepStarted.agent` (optional for old logs); run detail shows it. - `factory init` and this repo's config write `agent.default` and `agent.models`. The daemon checks the configured agents at start and logs what cannot run. Removed `DEFAULT_MODEL` (#40): model ids belong to the agent and the host, and an agent left on its own default runs on the host's settings, so factory keeps no built-in model; projects set `agent.models`. Removed `permissionMode` (#39): nothing read it, and every permission ask is now answered by the ACP client (`allow_always`, then `allow_once`), so a per-call mode would promise control the runtime overrides. A behavioural mode (e.g. read-only plan) can come back as an explicit, validated `mode` passthrough. Closes #64, closes #39, closes #40 Co-Authored-By: Claude Opus 5.5 (1M context) --- .factory/factory.config.ts | 13 ++ .factory/workflows/implement-parent-issue.ts | 18 +- e2e/implement-issue.test.ts | 4 +- e2e/server.ts | 42 ++-- spikes/13-acp-agents.ts | 2 +- src/cli-argv.test.ts | 26 +++ src/cli-commands.ts | 24 +++ src/cli.start.test.ts | 8 +- src/cli.test.ts | 20 +- src/cli.ts | 30 ++- src/config.test.ts | 103 +++++++++ src/config.ts | 162 ++++++++++++-- src/events.test.ts | 27 +++ src/events.ts | 7 + src/index.ts | 2 + src/init.test.ts | 25 +++ src/init.ts | 12 ++ src/replay/adapter.test.ts | 15 +- src/replay/adapter.ts | 12 ++ src/runtime/acp-adapter.test.ts | 6 +- src/runtime/agent-adapter.ts | 12 +- src/runtime/agent-choice.test.ts | 148 +++++++++++++ src/runtime/agent-choice.ts | 91 ++++++++ src/runtime/agent-runtime.test.ts | 102 ++++++++- src/runtime/agent-runtime.ts | 91 ++++++-- src/runtime/agent-step.test.ts | 19 +- src/runtime/agent-step.ts | 3 + src/runtime/agent-step.usage.test.ts | 7 +- src/runtime/run-signals.test.ts | 18 +- src/runtime/run.test.ts | 212 +++++++++++++++---- src/runtime/run.ts | 31 ++- src/server/concurrency.test.ts | 50 +++-- src/server/daemon-runtime.ts | 16 +- src/server/daemon.agents.test.ts | 96 +++++++++ src/server/daemon.ts | 35 ++- src/server/http.test.ts | 145 ++++++++++--- src/server/http.ts | 39 +++- src/server/runs.ts | 7 +- src/server/scheduler.ts | 3 +- src/server/shutdown.test.ts | 12 +- src/server/test-daemon.ts | 7 +- src/server/two-daemons.test.ts | 6 +- src/web/lib/run-events.test.ts | 36 ++++ src/web/lib/run-events.ts | 8 +- src/web/pages/run-detail.tsx | 1 + src/workflow.dispatch.test.ts | 28 +-- src/workflow.ts | 21 +- 47 files changed, 1545 insertions(+), 257 deletions(-) create mode 100644 src/runtime/agent-choice.test.ts create mode 100644 src/runtime/agent-choice.ts create mode 100644 src/server/daemon.agents.test.ts diff --git a/.factory/factory.config.ts b/.factory/factory.config.ts index 02ee707..d6614e0 100644 --- a/.factory/factory.config.ts +++ b/.factory/factory.config.ts @@ -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, diff --git a/.factory/workflows/implement-parent-issue.ts b/.factory/workflows/implement-parent-issue.ts index 44d70bd..0bed938 100644 --- a/.factory/workflows/implement-parent-issue.ts +++ b/.factory/workflows/implement-parent-issue.ts @@ -12,9 +12,10 @@ * 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. @@ -22,8 +23,6 @@ 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), @@ -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), }); @@ -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", { diff --git a/e2e/implement-issue.test.ts b/e2e/implement-issue.test.ts index cad95f6..768d292 100644 --- a/e2e/implement-issue.test.ts +++ b/e2e/implement-issue.test.ts @@ -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"; @@ -121,7 +121,7 @@ describe("implement-issue workflow, replayed against the recorded round-trip cor const events: Array = []; const handle = startRun( implementIssue, - makeAgentRuntime(createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS)), + makeAgentRuntime(fakeAgents(createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS))), { runId: "test-run-corpus-replay", dir: workDir, diff --git a/e2e/server.ts b/e2e/server.ts index 0a86181..31d4e2c 100644 --- a/e2e/server.ts +++ b/e2e/server.ts @@ -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"; @@ -138,25 +142,33 @@ async function seedCorpusRuns(root: string, db: ReturnType): 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); @@ -276,7 +288,7 @@ async function main(): Promise { 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}`); diff --git a/spikes/13-acp-agents.ts b/spikes/13-acp-agents.ts index cc776fa..8c42cd7 100644 --- a/spikes/13-acp-agents.ts +++ b/spikes/13-acp-agents.ts @@ -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}`, diff --git a/src/cli-argv.test.ts b/src/cli-argv.test.ts index f57742d..cbd8a8e 100644 --- a/src/cli-argv.test.ts +++ b/src/cli-argv.test.ts @@ -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; model: Option.Option }; + 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; model: Option.Option }; + 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 { diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 0e03f67..140415c 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -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 { + 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. @@ -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) => @@ -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), }; diff --git a/src/cli.start.test.ts b/src/cli.start.test.ts index 9f7851c..b4c9da0 100644 --- a/src/cli.start.test.ts +++ b/src/cli.start.test.ts @@ -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"; @@ -26,8 +26,8 @@ async function startTestDaemon(delayMs: number, root: string): Promise { 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, + ), ), }); @@ -66,7 +68,7 @@ describe("runCli", () => { dir, outPath, dbPath: join(dir, "factory.db"), - adapter: createSlowFakeAdapter([], 1), + agent: fakeAgents(createSlowFakeAdapter([], 1)), }); } catch (err) { thrown = err; diff --git a/src/cli.ts b/src/cli.ts index ed4b0c7..3b3a0b2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ /** * `factory run ` (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: @@ -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"; @@ -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 { @@ -66,17 +67,18 @@ export async function runCli(options: CliOptions): Promise { 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, @@ -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 { @@ -215,7 +219,11 @@ export async function startCli(options: StartCliOptions): Promise { 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( diff --git a/src/config.test.ts b/src/config.test.ts index 36cacb3..5ebcc89 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_RETAINED_WORKSPACES, DEFAULT_SCHEDULE_TIMEZONE, DEFAULT_WORKSPACE_ROOT, + configuredAgents, defineConfig, type ScheduleConfigInput, } from "./config"; @@ -280,3 +281,105 @@ describe("defineSchedule inference", () => { typeCheckScheduleDefinition(TYPED_WORKFLOW); }); }); + +describe("defineConfig agent (ADR 0013 §2, §3)", () => { + const withAgent = (agent: unknown) => defineConfig({ ...baseInput(), agent: agent as never }); + + test("defaults: opencode, no models, host settings ignored for both agents", () => { + const config = defineConfig(baseInput()); + expect(config.agent).toEqual({ + default: "opencode", + models: {}, + hostSettings: { claude: "ignore", opencode: "ignore" }, + }); + }); + + test("carries default, models and hostSettings through", () => { + const config = withAgent({ + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + hostSettings: { opencode: "include" }, + }); + expect(config.agent).toEqual({ + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + hostSettings: { claude: "ignore", opencode: "include" }, + }); + }); + + test("keeps an injected adapter", () => { + const adapter = { prepareWorkspace: async () => {}, stream: () => [] as never }; + expect(withAgent({ adapter }).agent.adapter).toBe(adapter); + }); + + test.each([ + [{ default: "codex" }, /agent.default must be one of "claude" \| "opencode" \(got "codex"\)/], + [{ models: { codex: "x" } }, /agent.models has an unknown agent "codex"/], + [{ models: { claude: "" } }, /agent.models.claude must be a non-empty model id/], + [{ models: { claude: 4 } }, /agent.models.claude must be a non-empty model id \(got 4\)/], + [ + { hostSettings: { claude: "yes" } }, + /agent.hostSettings.claude must be "ignore" or "include"/, + ], + [{ hostSettings: { codex: "ignore" } }, /agent.hostSettings has an unknown agent "codex"/], + ])("rejects %j at load", (agent, message) => { + expect(() => withAgent(agent)).toThrow(message); + }); + + test("a workflow's unknown agent fails at load, naming the workflow", () => { + const workflow = defineWorkflow("bad-agent", { + input: Schema.Struct({}), + agent: { agent: "codex" as never }, + run: async () => ({}), + }); + expect(() => defineConfig({ ...baseInput(), workflows: [workflow] })).toThrow( + /workflow "bad-agent"'s agent has an unknown agent "codex"/, + ); + }); + + test("a schedule's unknown agent or empty model fails at load, naming the schedule", () => { + const workflow = defineWorkflow("wf", { input: Schema.Struct({}), run: async () => ({}) }); + const schedule = (agent: unknown): ScheduleConfigInput => ({ + id: "nightly", + workflow: "wf", + input: {}, + cron: "0 3 * * *", + agent: agent as never, + }); + expect(() => + defineConfig({ + ...baseInput(), + workflows: [workflow], + schedules: [schedule({ agent: "x" })], + }), + ).toThrow(/schedule "nightly"'s agent has an unknown agent "x"/); + expect(() => + defineConfig({ ...baseInput(), workflows: [workflow], schedules: [schedule({ model: "" })] }), + ).toThrow(/schedule "nightly"'s agent has an invalid model ""/); + const ok = defineConfig({ + ...baseInput(), + workflows: [workflow], + schedules: [schedule({ agent: "claude", model: "haiku" })], + }); + expect(ok.schedules[0]?.agent).toEqual({ agent: "claude", model: "haiku" }); + }); + + test("configuredAgents: the default, agents with a model, and agents a workflow or schedule names", () => { + const pinned = defineWorkflow("pinned", { + input: Schema.Struct({}), + agent: { agent: "claude" }, + run: async () => ({}), + }); + expect(configuredAgents(defineConfig(baseInput()))).toEqual(["opencode"]); + expect(configuredAgents(withAgent({ default: "claude" }))).toEqual(["claude"]); + expect( + configuredAgents( + withAgent({ default: "claude", models: { opencode: "opencode/big-pickle" } }), + ), + ).toEqual(["claude", "opencode"]); + expect(configuredAgents(defineConfig({ ...baseInput(), workflows: [pinned] }))).toEqual([ + "claude", + "opencode", + ]); + }); +}); diff --git a/src/config.ts b/src/config.ts index 10fdd20..a2db5e4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,8 @@ import { Cron, Result, SchemaParser } from "effect"; import type { GitIdentity } from "./lib/clone"; import type { WorkflowDefinition } from "./workflow"; import type { AgentAdapter } from "./runtime/agent-adapter"; +import { ACP_AGENT_KINDS, type AcpAgentKind, type HostSettings } from "./runtime/acp-agents"; +import { DEFAULT_AGENT, isAcpAgentKind, type AgentChoice } from "./runtime/agent-choice"; export const DEFAULT_WORKSPACE_ROOT = ".factory/workspaces"; export const DEFAULT_MAX_CONCURRENT_RUNS = 3; @@ -70,12 +72,135 @@ export interface FactoryConfig { */ readonly maxDispatchDepth: number; readonly maxChildrenPerRun: number; + /** The agent runtime and the config rung of the agent/model choice, defaults applied. */ + readonly agent: AgentConfig; +} + +/** + * The project's agent settings (ADR 0013 §2, §3), as the operator writes them. + * + * ```ts + * agent: { + * default: "claude", + * models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + * hostSettings: { opencode: "include" }, + * } + * ``` + */ +export interface AgentConfigInput { + /** + * The agent a step runs on when neither the call, the run, the schedule nor + * the workflow names one. Default `"opencode"` (`DEFAULT_AGENT`). + */ + readonly default?: AcpAgentKind; /** - * Issue #36: the agent runtime. `adapter` unset means the runtime's default - * (the live opencode adapter, `runtime/agent-runtime.ts`), so existing - * configs keep working unchanged. + * Each agent's model, used when no level that may name one does (a model + * never carries across a change of agent). Factory always sends a model and + * has no built-in one: a step whose model resolves to nothing fails before + * the agent starts. Ids are the agent's own and passed verbatim. */ - readonly agent: { readonly adapter?: AgentAdapter }; + readonly models?: Readonly>>; + /** + * Whether runs see the operator's own agent settings (`~/.claude`, + * `~/.config/opencode`), per agent. Default `"ignore"`: a run reads the + * project's agent configuration only. Credentials stay available either way. + */ + readonly hostSettings?: Readonly>>; + /** + * Issue #36: replaces the ACP runtime with another adapter — the injection + * point for replay and fakes. An injected adapter still receives the + * resolved agent and model. + */ + readonly adapter?: AgentAdapter; +} + +/** `AgentConfigInput` with its defaults applied. */ +export interface AgentConfig { + readonly default: AcpAgentKind; + readonly models: Readonly>>; + readonly hostSettings: Readonly>; + readonly adapter?: AgentAdapter; +} + +const HOST_SETTINGS: ReadonlyArray = ["ignore", "include"]; + +function describeAgentKinds(): string { + return ACP_AGENT_KINDS.map((kind) => JSON.stringify(kind)).join(" | "); +} + +function checkAgentChoice(where: string, choice: AgentChoice | undefined): void { + if (choice === undefined) return; + if (choice.agent !== undefined && !isAcpAgentKind(choice.agent)) { + throw new Error( + `${where} has an unknown agent ${JSON.stringify(choice.agent)} (expected ${describeAgentKinds()})`, + ); + } + if (choice.model !== undefined && (typeof choice.model !== "string" || choice.model === "")) { + throw new Error( + `${where} has an invalid model ${JSON.stringify(choice.model)} (expected a non-empty string)`, + ); + } +} + +/** Validate `agent` and apply its defaults. Throws naming the offending field. */ +export function resolveAgentConfig(input: AgentConfigInput = {}): AgentConfig { + const fallback = input.default ?? DEFAULT_AGENT; + if (!isAcpAgentKind(fallback)) { + throw new Error( + `agent.default must be one of ${describeAgentKinds()} (got ${JSON.stringify(input.default)})`, + ); + } + const models: Partial> = {}; + for (const [key, model] of Object.entries(input.models ?? {})) { + if (!isAcpAgentKind(key)) { + throw new Error( + `agent.models has an unknown agent "${key}" (expected ${describeAgentKinds()})`, + ); + } + if (model === undefined) continue; + if (typeof model !== "string" || model === "") { + throw new Error( + `agent.models.${key} must be a non-empty model id (got ${JSON.stringify(model)})`, + ); + } + models[key] = model; + } + const hostSettings: Record = { claude: "ignore", opencode: "ignore" }; + for (const [key, value] of Object.entries(input.hostSettings ?? {})) { + if (!isAcpAgentKind(key)) { + throw new Error( + `agent.hostSettings has an unknown agent "${key}" (expected ${describeAgentKinds()})`, + ); + } + if (value === undefined) continue; + if (!HOST_SETTINGS.includes(value)) { + throw new Error( + `agent.hostSettings.${key} must be "ignore" or "include" (got ${JSON.stringify(value)})`, + ); + } + hostSettings[key] = value; + } + return { + default: fallback, + models, + hostSettings, + ...(input.adapter !== undefined ? { adapter: input.adapter } : {}), + }; +} + +/** + * The agents a config can run on: the default, every agent with a configured + * model, and every agent a workflow or schedule names. What the daemon checks + * at start (ADR 0013 Consequences). A per-call choice is not visible here. + */ +export function configuredAgents(config: FactoryConfig): AcpAgentKind[] { + const kinds = new Set([config.agent.default]); + for (const kind of ACP_AGENT_KINDS) if (config.agent.models[kind] !== undefined) kinds.add(kind); + for (const workflow of config.workflows) + if (workflow.agent?.agent !== undefined) kinds.add(workflow.agent.agent); + for (const schedule of config.schedules) + if (schedule.agent?.agent !== undefined) kinds.add(schedule.agent.agent); + return ACP_AGENT_KINDS.filter((kind) => kinds.has(kind)); } /** @@ -111,14 +236,10 @@ export interface ScheduleConfigInput { /** Fire once when the daemon starts. Default `false`. */ readonly runOnStart?: boolean; /** - * Schedule-level agent default, above the workflow's and below a per-call - * option's — precedence for anything a schedule can override follows the - * rule already used for agent options: - * run request > schedule > workflow > config default. + * The schedule's runs' agent and model: the run level of the choice, below + * a per-call option and above the workflow's and the config's (ADR 0013 §2). */ - readonly agent?: { - readonly model?: string; - }; + readonly agent?: AgentChoice; } /** @@ -138,9 +259,7 @@ export interface ScheduleDefinition { readonly timezone?: string; readonly overlap?: ScheduleOverlapPolicy; readonly runOnStart?: boolean; - readonly agent?: { - readonly model?: string; - }; + readonly agent?: AgentChoice; } /** @@ -164,7 +283,7 @@ export interface ScheduleConfig { readonly timezone: string; readonly overlap: ScheduleOverlapPolicy; readonly runOnStart: boolean; - readonly agent: { readonly model?: string } | undefined; + readonly agent: AgentChoice | undefined; } export interface FactoryConfigInput { @@ -176,11 +295,8 @@ export interface FactoryConfigInput { readonly maxDispatchDepth?: number; readonly maxChildrenPerRun?: number; readonly schedules?: ReadonlyArray>; - /** - * Issue #36: the agent runtime. Optional — when omitted the runtime uses - * the live opencode adapter. - */ - readonly agent?: { readonly adapter?: AgentAdapter }; + /** The agents and their models (ADR 0013). Optional; see `AgentConfigInput`. */ + readonly agent?: AgentConfigInput; } export function defineConfig(config: FactoryConfigInput): FactoryConfig { @@ -208,6 +324,9 @@ export function defineConfig(config: FactoryConfigInput): FactoryConfig { `maxChildrenPerRun must be an integer >= 1 (got ${JSON.stringify(config.maxChildrenPerRun)})`, ); } + const agent = resolveAgentConfig(config.agent); + for (const workflow of config.workflows) + checkAgentChoice(`workflow "${workflow.id}"'s agent`, workflow.agent); const schedules = normalizeSchedules(config.schedules ?? []); validateSchedules(schedules, config.workflows); return { @@ -228,7 +347,7 @@ export function defineConfig(config: FactoryConfigInput): FactoryConfig { retainedWorkspaces, maxDispatchDepth, maxChildrenPerRun, - agent: config.agent?.adapter !== undefined ? { adapter: config.agent.adapter } : {}, + agent, }; } @@ -313,6 +432,7 @@ export function validateSchedules( throw new Error(`duplicate schedule id "${schedule.id}"`); } ids.add(schedule.id); + checkAgentChoice(`schedule "${schedule.id}"'s agent`, schedule.agent); const workflow = workflows.find((w) => w.id === schedule.workflow) ?? diff --git a/src/events.test.ts b/src/events.test.ts index 4c096ed..8a35b7a 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -392,3 +392,30 @@ describe("Factory lifecycle events", () => { }); }); }); + +describe("AgentStepStarted.agent (ADR 0013 §2)", () => { + const started = (extra: Record) => ({ + runId: "r", + seq: 0, + ts: 0, + payload: { + _tag: "AgentStepStarted", + stepId: "step-0", + name: "implement", + model: "sonnet", + prompt: "p", + structured: false, + ...extra, + }, + }); + + test("records the agent beside the model", () => { + const event = decodeRunEvent(started({ agent: "claude" })); + expect(event.payload._tag === "AgentStepStarted" && event.payload.agent).toBe("claude"); + }); + + test("a log written before agents were selectable has none, and still decodes", () => { + const event = decodeRunEvent(started({})); + expect(event.payload._tag === "AgentStepStarted" && "agent" in event.payload).toBe(false); + }); +}); diff --git a/src/events.ts b/src/events.ts index ff6de50..a79bf62 100644 --- a/src/events.ts +++ b/src/events.ts @@ -160,6 +160,13 @@ export const RunEventPayload = Schema.TaggedUnion({ */ stepId: Schema.String, name: Schema.String, + /** + * The agent the step ran on (ADR 0013 §2): `claude`, `opencode`. Optional + * because logs written before agents were selectable have none (they + * all ran on opencode). + */ + agent: Schema.optional(Schema.String), + /** The agent's own model id, as sent to it. */ model: Schema.String, prompt: Schema.String, /** Whether an `output` schema was passed, i.e. whether to expect structured output. */ diff --git a/src/index.ts b/src/index.ts index a61afba..3027d8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export { defineWorkflow, Schema } from "./workflow"; export type { AgentCallOptions, + AgentKind, AgentResult, AssertCallback, AssertResult, @@ -47,6 +48,7 @@ export { } from "./config"; export type { + AgentConfigInput, FactoryConfig, FactoryConfigInput, RepoConfig, diff --git a/src/init.test.ts b/src/init.test.ts index fb7e0fb..b4d830b 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -9,6 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { hostExec } from "./lib/exec"; +import { loadFactoryConfig } from "./config"; import { detectRepo, initProject, slugFromRemote } from "./init"; let cwd: string; @@ -88,6 +89,30 @@ describe("initProject", () => { expect(workflow).toContain(`defineWorkflow("hello"`); }); + test("writes the default agent and a model for each agent (ADR 0013 §2)", async () => { + await makeGitRepo(cwd, "git@github.com:acme/widgets.git"); + await initProject({ cwd }); + + const config = await read(join(cwd, ".factory/factory.config.ts")); + expect(config).toContain(`default: "claude"`); + expect(config).toContain(`claude: "sonnet"`); + expect(config).toContain(`opencode: "opencode/big-pickle"`); + // The generated config loads, and the agent block validates. The temp + // project has no node_modules, so point the package import at this source. + const index = join(import.meta.dir, "index.ts"); + for (const file of [".factory/factory.config.ts", ".factory/workflows/hello.ts"]) { + const path = join(cwd, file); + await Bun.write( + path, + (await read(path)).replaceAll(`"@frebreco/factory"`, JSON.stringify(index)), + ); + } + const loaded = await loadFactoryConfig(join(cwd, ".factory/factory.config.ts")); + expect(loaded.agent.default).toBe("claude"); + expect(loaded.agent.models).toEqual({ claude: "sonnet", opencode: "opencode/big-pickle" }); + expect(loaded.agent.hostSettings).toEqual({ claude: "ignore", opencode: "ignore" }); + }); + test("marks the repo block as needing edits when there is no remote", async () => { const result = await initProject({ cwd }); diff --git a/src/init.ts b/src/init.ts index 3800f41..12c986f 100644 --- a/src/init.ts +++ b/src/init.ts @@ -143,6 +143,18 @@ export default defineConfig({ // Add a workflow by importing it and listing it here. workflows: [hello], + // 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. + agent: { + default: "claude", + 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, diff --git a/src/replay/adapter.test.ts b/src/replay/adapter.test.ts index 1f9295d..6ed3224 100644 --- a/src/replay/adapter.test.ts +++ b/src/replay/adapter.test.ts @@ -3,7 +3,12 @@ import { describe, expect, test } from "bun:test"; import { AgentRuntimeLayer } from "../runtime/agent-runtime"; import { buildAgentStepEffect } from "../runtime/agent-step"; import type { AgentAdapterYield } from "../runtime/agent-adapter"; -import { createCorpusReplayAdapter, createSlowFakeAdapter, loadCorpusBlocks } from "./adapter"; +import { + fakeAgents, + createCorpusReplayAdapter, + createSlowFakeAdapter, + loadCorpusBlocks, +} from "./adapter"; const FULL_ROUND_TRIP_CORPUS = `${import.meta.dir}/../../test/corpus/run-1789308170212.ndjson`; @@ -30,6 +35,7 @@ describe("createCorpusReplayAdapter", () => { const options = { threadId: "t", dir: "/tmp", + agent: "opencode" as const, model: "m", prompt: "p", abortController: new AbortController(), @@ -53,13 +59,14 @@ describe("createCorpusReplayAdapter", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "opencode-go/deepseek-v4.1-flash", prompt: "irrelevant, replay ignores it", onChunk: (chunk) => chunks.push(chunk), }); const outcome = await Effect.runPromise( - Effect.provide(handle.effect, AgentRuntimeLayer(adapter)), + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), ); expect(outcome.chunkCount).toBe(39); @@ -74,6 +81,7 @@ describe("createCorpusReplayAdapter", () => { const stream = adapter.stream({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", abortController: new AbortController(), @@ -107,13 +115,14 @@ describe("createSlowFakeAdapter", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: () => {}, }); const outcome = await Effect.runPromise( - Effect.provide(handle.effect, AgentRuntimeLayer(adapter)), + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), ); expect(outcome.chunkCount).toBe(3); expect(outcome.finalText).toBe("hi"); diff --git a/src/replay/adapter.ts b/src/replay/adapter.ts index b3713ae..e049d2b 100644 --- a/src/replay/adapter.ts +++ b/src/replay/adapter.ts @@ -26,6 +26,7 @@ import type { AgentSignal, } from "../runtime/agent-adapter"; import { extractOpencodeSignal } from "../runtime/opencode-adapter"; +import type { AgentRuntimeConfig } from "../runtime/agent-runtime"; export interface CorpusStepBlock { readonly step: string; @@ -129,3 +130,14 @@ export function createSlowFakeAdapter( }, }; } + +/** + * A model for every agent, so a fake-driven step resolves without naming one + * (ADR 0013 §2: factory always sends a model). The fakes ignore it. + */ +export const FAKE_AGENT_MODELS = { claude: "fake-model", opencode: "fake-model" } as const; + +/** An agent runtime config that runs every step on `adapter`, with `FAKE_AGENT_MODELS`. */ +export function fakeAgents(adapter: AgentAdapter): AgentRuntimeConfig { + return { adapter, models: FAKE_AGENT_MODELS }; +} diff --git a/src/runtime/acp-adapter.test.ts b/src/runtime/acp-adapter.test.ts index a2a2511..e24a983 100644 --- a/src/runtime/acp-adapter.test.ts +++ b/src/runtime/acp-adapter.test.ts @@ -13,6 +13,7 @@ import type { RunEvent } from "../events"; import { defineWorkflow, Schema } from "../workflow"; import { makeAgentRuntime } from "./agent-runtime"; import { startRun } from "./run"; +import { fakeAgents } from "../replay/adapter"; const FAKE = join(import.meta.dir, "../../test/fixtures/fake-acp-agent.ts"); @@ -49,6 +50,7 @@ async function collect( for await (const y of adapter.stream({ threadId: "thread", dir: options.dir ?? import.meta.dir, + agent: "opencode", model: options.model ?? "fake/fast", prompt, outputSchema: options.outputSchema, @@ -257,7 +259,9 @@ describe("acpAdapter", () => { test("through the runtime: a cancelled run cancels the step and leaves no agent process", async () => { const diagnostics: AcpDiagnostic[] = []; const runtime = makeAgentRuntime( - acpAdapter(fake(), { onDiagnostic: (d) => diagnostics.push(d), cancelGraceMs: 300 }), + fakeAgents( + acpAdapter(fake(), { onDiagnostic: (d) => diagnostics.push(d), cancelGraceMs: 300 }), + ), ); const workflow = defineWorkflow("acp-cancel", { input: Schema.Struct({}), diff --git a/src/runtime/agent-adapter.ts b/src/runtime/agent-adapter.ts index 64f297f..af934ff 100644 --- a/src/runtime/agent-adapter.ts +++ b/src/runtime/agent-adapter.ts @@ -1,6 +1,6 @@ /** * The seam between `runAgentStep` (runtime/agent-step.ts) and whatever - * actually produces a chunk stream. Live opencode for real runs, corpus + * actually produces a chunk stream. The ACP runtime for real runs, corpus * replay for `bun test` (STATUS.md phase 1: "make the fake adapter a corpus * replayer") — same runtime code path either way, only this function swaps. * @@ -11,9 +11,19 @@ * field for it (`AgentStepFinished.sessionId`, `.output`, `.error`). */ +import type { AcpAgentKind } from "./acp-agents"; + export interface AgentAdapterOptions { readonly threadId: string; readonly dir: string; + /** + * The agent this step runs on, resolved like `model` (ADR 0013 §2). The + * runtime's default adapter launches it; an adapter that stands in for + * every agent (replay, fakes, an injected single-agent adapter) may ignore + * it. + */ + readonly agent: AcpAgentKind; + /** The agent's own model id, always present (ADR 0013 §2). */ readonly model: string; readonly prompt: string; /** JSON Schema, converted from the workflow's Effect Schema at this boundary. */ diff --git a/src/runtime/agent-choice.test.ts b/src/runtime/agent-choice.test.ts new file mode 100644 index 0000000..1e97314 --- /dev/null +++ b/src/runtime/agent-choice.test.ts @@ -0,0 +1,148 @@ +/** + * ADR 0013 §2's resolution rule, as a table. Levels are most specific first: + * call, run (request or schedule), workflow; the config rung is `defaults`. + */ + +import { describe, expect, test } from "bun:test"; +import { + AgentChoiceError, + DEFAULT_AGENT, + resolveAgentChoice, + type AgentChoice, + type AgentDefaults, + type ResolvedAgent, +} from "./agent-choice"; +import type { AgentCallOptions, WorkflowAgentDefaults } from "../workflow"; + +const CONFIG: AgentDefaults = { + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, +}; + +interface Row { + readonly name: string; + readonly call?: AgentChoice; + readonly run?: AgentChoice; + readonly workflow?: AgentChoice; + readonly defaults?: AgentDefaults; + readonly expected: ResolvedAgent | RegExp; +} + +const ROWS: ReadonlyArray = [ + { + name: "nothing named: config default agent and its model", + expected: { agent: "claude", model: "sonnet" }, + }, + { + name: "a model with no agent anywhere applies to the default agent", + workflow: { model: "opus" }, + expected: { agent: "claude", model: "opus" }, + }, + { + name: "the most specific model wins when no level names an agent", + call: { model: "haiku" }, + run: { model: "opus" }, + workflow: { model: "sonnet-4" }, + expected: { agent: "claude", model: "haiku" }, + }, + { + name: "the call's agent wins over the workflow's", + call: { agent: "opencode" }, + workflow: { agent: "claude" }, + expected: { agent: "opencode", model: "opencode/big-pickle" }, + }, + { + name: "a model below the level that changed the agent does not carry across", + call: { agent: "opencode" }, + workflow: { model: "sonnet" }, + expected: { agent: "opencode", model: "opencode/big-pickle" }, + }, + { + name: "a model below the chooser is dropped even when it is the run's", + call: { agent: "opencode" }, + run: { agent: "claude", model: "opus" }, + expected: { agent: "opencode", model: "opencode/big-pickle" }, + }, + { + name: "the chooser's own model applies", + workflow: { agent: "opencode", model: "opencode/other" }, + expected: { agent: "opencode", model: "opencode/other" }, + }, + { + name: "a model above the chooser applies to the chosen agent", + call: { model: "opencode/fast" }, + workflow: { agent: "opencode", model: "opencode/other" }, + expected: { agent: "opencode", model: "opencode/fast" }, + }, + { + name: "the run level chooses the agent over the workflow's", + run: { agent: "opencode" }, + workflow: { agent: "claude", model: "opus" }, + expected: { agent: "opencode", model: "opencode/big-pickle" }, + }, + { + name: "the run level's model applies to the workflow's agent", + run: { model: "opus" }, + workflow: { agent: "claude", model: "sonnet" }, + expected: { agent: "claude", model: "opus" }, + }, + { + name: "a call naming the same agent as the workflow still drops the workflow's model", + call: { agent: "claude" }, + workflow: { agent: "claude", model: "opus" }, + expected: { agent: "claude", model: "sonnet" }, + }, + { + name: "no model for the chosen agent fails, naming the agent and the config key", + call: { agent: "opencode" }, + defaults: { default: "claude", models: { claude: "sonnet" } }, + expected: /no model for agent "opencode".*agent\.models\.opencode/, + }, + { + name: "no config at all: the built-in default agent, and no model", + defaults: { default: DEFAULT_AGENT, models: {} }, + expected: /no model for agent "opencode"/, + }, + { + name: "no config, but the workflow names a model", + workflow: { model: "opencode/big-pickle" }, + defaults: { default: DEFAULT_AGENT, models: {} }, + expected: { agent: "opencode", model: "opencode/big-pickle" }, + }, + { + name: "an unknown agent fails", + call: { agent: "codex" as never }, + expected: /unknown agent "codex"; expected one of claude, opencode/, + }, +]; + +describe("resolveAgentChoice (ADR 0013 §2)", () => { + for (const row of ROWS) { + test(row.name, () => { + const resolve = () => + resolveAgentChoice([row.call, row.run, row.workflow], row.defaults ?? CONFIG); + if (row.expected instanceof RegExp) { + expect(resolve).toThrow(AgentChoiceError); + expect(resolve).toThrow(row.expected); + } else { + expect(resolve()).toEqual(row.expected); + } + }); + } +}); + +describe("what ADR 0013 removed", () => { + test("there is no built-in model: DEFAULT_MODEL is gone (#40)", async () => { + const run = await import("./run"); + expect("DEFAULT_MODEL" in run).toBe(false); + }); + + test("permissionMode is not an agent option (#39): every ask is answered by the runtime", () => { + // Type-level: these fail `bun run typecheck` if the field comes back. + // @ts-expect-error permissionMode was removed from AgentCallOptions + const call: AgentCallOptions = { permissionMode: "acceptEdits" }; + // @ts-expect-error permissionMode was removed from WorkflowAgentDefaults + const defaults: WorkflowAgentDefaults = { permissionMode: "default" }; + expect([call, defaults]).toHaveLength(2); + }); +}); diff --git a/src/runtime/agent-choice.ts b/src/runtime/agent-choice.ts new file mode 100644 index 0000000..232abb2 --- /dev/null +++ b/src/runtime/agent-choice.ts @@ -0,0 +1,91 @@ +/** + * Which agent runs a `ctx.agent` step, and on which model (ADR 0013 §2). + * + * `agent` and `model` are separate fields at every level of the precedence + * chain, most specific first: + * + * call (`ctx.agent` options) > run (the run request, or the schedule that + * started it) > workflow (`defineWorkflow`'s `agent`) > config + * (`agent.default` / `agent.models`) + * + * - **Agent:** the most specific level that names one, else + * `config.agent.default`. + * - **Model:** the most specific level that names one, counting only the + * level that chose the agent and the levels more specific than it; else + * `config.agent.models[agent]`. + * + * A model never carries across a change of agent: a workflow pinned to + * `sonnet`, called once with `{ agent: "opencode" }`, runs on opencode's + * configured model, not on an invalid `sonnet`. And factory always sends a + * model — an agent left on its own default runs on the host's settings + * (finding 13 §1) — so a step whose model resolves to nothing fails before + * the agent starts, naming the fix. + */ + +import { ACP_AGENT_KINDS, type AcpAgentKind } from "./acp-agents"; + +/** One level's say in the choice. Either field may be absent. */ +export interface AgentChoice { + readonly agent?: AcpAgentKind; + readonly model?: string; +} + +/** The config rung: the default agent, and each agent's default model. */ +export interface AgentDefaults { + readonly default: AcpAgentKind; + readonly models: Readonly>>; +} + +/** What a step runs on. */ +export interface ResolvedAgent { + readonly agent: AcpAgentKind; + readonly model: string; +} + +/** + * The agent a project without `agent.default` runs on. opencode, because it + * was factory's only agent before ADR 0013. There is deliberately no + * built-in *model*: model ids belong to the agent and the host (ADR 0013 §2). + */ +export const DEFAULT_AGENT: AcpAgentKind = "opencode"; + +export function isAcpAgentKind(value: unknown): value is AcpAgentKind { + return typeof value === "string" && (ACP_AGENT_KINDS as ReadonlyArray).includes(value); +} + +/** A step's agent or model could not be resolved. Thrown before the agent starts. */ +export class AgentChoiceError extends Error { + override readonly name = "AgentChoiceError"; +} + +/** + * Resolve `levels` (most specific first; `undefined` for a level that says + * nothing) over `defaults`. Throws `AgentChoiceError` for an unknown agent + * name or an unresolvable model. + */ +export function resolveAgentChoice( + levels: ReadonlyArray, + defaults: AgentDefaults, +): ResolvedAgent { + for (const level of levels) { + if (level?.agent !== undefined && !isAcpAgentKind(level.agent)) { + throw new AgentChoiceError( + `unknown agent ${JSON.stringify(level.agent)}; expected one of ${ACP_AGENT_KINDS.join(", ")}`, + ); + } + } + const chooser = levels.findIndex((level) => level?.agent !== undefined); + const agent = chooser === -1 ? defaults.default : (levels[chooser]!.agent as AcpAgentKind); + // Only the chooser and the levels above it may name the model: a model + // named below the level that changed the agent belongs to another agent. + const eligible = chooser === -1 ? levels : levels.slice(0, chooser + 1); + const model = + eligible.find((level) => level?.model !== undefined)?.model ?? defaults.models[agent]; + if (model === undefined) { + throw new AgentChoiceError( + `no model for agent "${agent}": name one with \`model\` on the ctx.agent call, the run, ` + + `the schedule or the workflow, or set \`agent.models.${agent}\` in factory.config.ts`, + ); + } + return { agent, model }; +} diff --git a/src/runtime/agent-runtime.test.ts b/src/runtime/agent-runtime.test.ts index 1d968f2..9d30e30 100644 --- a/src/runtime/agent-runtime.test.ts +++ b/src/runtime/agent-runtime.test.ts @@ -1,24 +1,51 @@ /** - * The agent runtime as a context service (ADR 0012 §4). + * The agent runtime as a context service (ADR 0012 §4), and its default: the + * ACP runtime, which launches the agent each step resolved to (ADR 0013 §1). */ import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; import { Effect, ManagedRuntime } from "effect"; -import { createSlowFakeAdapter } from "../replay/adapter"; +import type { RunEvent } from "../events"; +import { fakeAgents, createSlowFakeAdapter } from "../replay/adapter"; +import { defineWorkflow, Schema } from "../workflow"; +import type { AcpAgentDefinition, AcpAgentKind, AcpAgentOptions } from "./acp-agents"; import { opencodeAdapter } from "./opencode-adapter"; -import { AgentRuntime, AgentRuntimeLayer } from "./agent-runtime"; +import { + acpAgentsAdapter, + AgentRuntime, + AgentRuntimeLayer, + makeAgentRuntime, +} from "./agent-runtime"; +import { startRun } from "./run"; + +const FAKE = join(import.meta.dir, "../../test/fixtures/fake-acp-agent.ts"); describe("AgentRuntime service", () => { - test("without an adapter, the layer provides the live opencode adapter", async () => { + test("without an adapter, the layer provides the ACP runtime and the bare defaults", async () => { const runtime = ManagedRuntime.make(AgentRuntimeLayer()); - const { adapter } = await runtime.runPromise(AgentRuntime); - expect(adapter).toBe(opencodeAdapter); + const { adapter, defaults } = await runtime.runPromise(AgentRuntime); + expect(adapter).not.toBe(opencodeAdapter); + expect(defaults).toEqual({ default: "opencode", models: {} }); + await runtime.dispose(); + }); + + test("the layer carries the config's default agent and models", async () => { + const runtime = makeAgentRuntime({ + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + }); + const { defaults } = await runtime.runPromise(AgentRuntime); + expect(defaults).toEqual({ + default: "claude", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + }); await runtime.dispose(); }); test("AgentRuntimeLayer swaps the adapter via context", async () => { const fake = createSlowFakeAdapter([], 1); - const runtime = ManagedRuntime.make(AgentRuntimeLayer(fake)); + const runtime = ManagedRuntime.make(AgentRuntimeLayer(fakeAgents(fake))); const { adapter } = await runtime.runPromise(AgentRuntime); expect(adapter).toBe(fake); await runtime.dispose(); @@ -30,9 +57,68 @@ describe("AgentRuntime service", () => { const runtime = yield* AgentRuntime; return runtime.adapter; }); - const runtime = ManagedRuntime.make(AgentRuntimeLayer(fake)); + const runtime = ManagedRuntime.make(AgentRuntimeLayer(fakeAgents(fake))); const adapter = await runtime.runPromise(program); expect(adapter).toBe(fake); await runtime.dispose(); }); }); + +describe("acpAgentsAdapter", () => { + test("each step launches the agent it resolved to, on its model, with that agent's hostSettings", async () => { + const defined: Array<{ agent: AcpAgentKind; options: AcpAgentOptions }> = []; + // Both agents are the fake ACP agent, told apart by name; the fake's + // `hello` reply names the session's model. + const define = (agent: AcpAgentKind, options: AcpAgentOptions): AcpAgentDefinition => { + defined.push({ agent, options }); + return { agent, command: [process.execPath, FAKE] }; + }; + const adapter = acpAgentsAdapter({ claude: "include" }, define); + + const events: Array = []; + const workflow = defineWorkflow("two-agents", { + input: Schema.Struct({}), + run: async (ctx) => { + const a = await ctx.agent("on-claude", "hello", { agent: "claude" }); + const b = await ctx.agent("on-opencode", "hello", { agent: "opencode" }); + const c = await ctx.agent("on-claude-again", "hello", { + agent: "claude", + model: "fake/smart", + }); + return { texts: [a.finalText, b.finalText, c.finalText] }; + }, + }); + const runtime = makeAgentRuntime({ + adapter, + models: { claude: "fake/fast", opencode: "fake/default" }, + }); + const handle = startRun(workflow, runtime, { + runId: "run-two-agents", + dir: import.meta.dir, + input: {}, + onEvent: (e) => events.push(e), + }); + const outcome = await handle.result; + await runtime.dispose(); + + expect(outcome.outcome).toBe("completed"); + const texts = + outcome.outcome === "completed" ? (outcome.output as { texts: string[] }).texts : []; + expect(texts[0]).toContain("fake/fast"); + expect(texts[1]).toContain("fake/default"); + expect(texts[2]).toContain("fake/smart"); + // One definition per agent, built on first use, with its own setting. + expect(defined).toEqual([ + { agent: "claude", options: { hostSettings: "include" } }, + { agent: "opencode", options: { hostSettings: "ignore" } }, + ]); + const started = events.flatMap((e) => + e.payload._tag === "AgentStepStarted" ? [[e.payload.agent, e.payload.model]] : [], + ); + expect(started).toEqual([ + ["claude", "fake/fast"], + ["opencode", "fake/default"], + ["claude", "fake/smart"], + ]); + }); +}); diff --git a/src/runtime/agent-runtime.ts b/src/runtime/agent-runtime.ts index 09cfaee..11a39d5 100644 --- a/src/runtime/agent-runtime.ts +++ b/src/runtime/agent-runtime.ts @@ -4,44 +4,101 @@ * The runtime is the daemon's first service to move into Effect's dependency * injection: it stops being a value threaded through every interface between * the CLI and the step runner, and becomes a service resolved from context at - * the point of use (`runtime/agent-step.ts`). + * the point of use (`runtime/agent-step.ts`, `runtime/run.ts`). * - * The service shape is deliberately small: it currently exposes only the - * adapter, which is the part issue #36 needs to make selectable from config. + * It carries the adapter (issue #36) and the config rung of the agent/model + * choice (ADR 0013 §2): the default agent and each agent's default model. + * The step resolves its agent and model against those, and hands both to the + * adapter. + * + * The default adapter is the ACP runtime (ADR 0013 §1): one generic ACP + * adapter per agent definition, picked per step by `AgentAdapterOptions.agent`. + * `config.agent.adapter` stays the injection point for replay and fakes; an + * injected adapter receives the resolved agent and may ignore it. */ import { Context, Layer, ManagedRuntime } from "effect"; -import type { AgentAdapter } from "./agent-adapter"; -import { opencodeAdapter } from "./opencode-adapter"; +import { acpAdapter } from "./acp-adapter"; +import { + acpAgent, + type AcpAgentDefinition, + type AcpAgentKind, + type AcpAgentOptions, + type HostSettings, +} from "./acp-agents"; +import type { AgentAdapter, AgentAdapterOptions } from "./agent-adapter"; +import { DEFAULT_AGENT, type AgentDefaults } from "./agent-choice"; export interface AgentRuntimeShape { /** The adapter that produces agent chunk streams (ADR 0012 §2). */ readonly adapter: AgentAdapter; + /** The config rung of the agent/model resolution (ADR 0013 §2). */ + readonly defaults: AgentDefaults; +} + +/** What builds an agent runtime: `factory.config.ts`'s `agent` block, all optional. */ +export interface AgentRuntimeConfig { + /** Replaces the ACP runtime (replay, fakes). */ + readonly adapter?: AgentAdapter; + /** The agent a step runs on when no level names one. Default `DEFAULT_AGENT`. */ + readonly default?: AcpAgentKind; + /** Each agent's model when no level that may name one does. */ + readonly models?: Readonly>>; + /** Whether runs see the host's own settings, per agent. Default `"ignore"` (ADR 0013 §3). */ + readonly hostSettings?: Readonly>>; +} + +/** + * The ACP runtime: each step launches the agent it resolved to, through one + * generic `acpAdapter` per agent definition. Definitions are built on first + * use, so an agent nobody calls is never set up. + */ +export function acpAgentsAdapter( + hostSettings: Readonly>> = {}, + /** The definition for an agent; `acpAgent` outside of tests. */ + define: (agent: AcpAgentKind, options: AcpAgentOptions) => AcpAgentDefinition = acpAgent, +): AgentAdapter { + const adapters = new Map(); + const adapterFor = (agent: AcpAgentKind): AgentAdapter => { + let adapter = adapters.get(agent); + if (adapter === undefined) { + adapter = acpAdapter(define(agent, { hostSettings: hostSettings[agent] ?? "ignore" })); + adapters.set(agent, adapter); + } + return adapter; + }; + return { + // Permission is answered by the client; there is nothing to prepare. + async prepareWorkspace(): Promise {}, + stream: (options: AgentAdapterOptions) => adapterFor(options.agent).stream(options), + }; } /** - * The agent-runtime service. Yield it inside an Effect to read the adapter; - * provide it with `AgentRuntimeLayer`. + * The agent-runtime service. Yield it inside an Effect to read the adapter + * and the defaults; provide it with `AgentRuntimeLayer`. */ export class AgentRuntime extends Context.Service()( "AgentRuntime", ) {} /** - * A layer that provides `adapter`, or the live opencode adapter when none is - * given. This is the single place the opencode default lives: config leaves - * `agent.adapter` unset unless a project chooses one. + * A layer that provides the agent runtime for `config`: its `adapter`, or + * the ACP runtime when none is given. This is the single place the default + * lives. */ -export const AgentRuntimeLayer = ( - adapter: AgentAdapter = opencodeAdapter, -): Layer.Layer => Layer.succeed(AgentRuntime, { adapter }); +export const AgentRuntimeLayer = (config: AgentRuntimeConfig = {}): Layer.Layer => + Layer.succeed(AgentRuntime, { + adapter: config.adapter ?? acpAgentsAdapter(config.hostSettings), + defaults: { default: config.default ?? DEFAULT_AGENT, models: config.models ?? {} }, + }); /** * Build a `ManagedRuntime` providing the agent runtime — the composition root - * of the daemon and the CLI's direct-run path, and a convenience for tests. - * The caller owns it and must `dispose()` it once its runs are done. + * of the CLI's direct-run path, and a convenience for tests. The caller owns + * it and must `dispose()` it once its runs are done. */ export const makeAgentRuntime = ( - adapter?: AgentAdapter, + config?: AgentRuntimeConfig, ): ManagedRuntime.ManagedRuntime => - ManagedRuntime.make(AgentRuntimeLayer(adapter)); + ManagedRuntime.make(AgentRuntimeLayer(config)); diff --git a/src/runtime/agent-step.test.ts b/src/runtime/agent-step.test.ts index eac7f0c..8257fb4 100644 --- a/src/runtime/agent-step.test.ts +++ b/src/runtime/agent-step.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { AgentAdapterYield, AgentSignal } from "./agent-adapter"; import { AgentRuntimeLayer } from "./agent-runtime"; import { abortableIterable, buildAgentStepEffect } from "./agent-step"; +import { fakeAgents } from "../replay/adapter"; function makeYield(chunk: unknown, signal?: AgentSignal): AgentAdapterYield { return signal !== undefined ? { chunk, signal } : { chunk }; @@ -37,13 +38,14 @@ describe("buildAgentStepEffect signal extraction", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: () => {}, }); const outcome = await Effect.runPromise( - Effect.provide(handle.effect, AgentRuntimeLayer(adapter)), + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), ); expect(outcome.sessionId).toBe("ses_abc"); expect(outcome.finalText).toBe("hello"); @@ -65,13 +67,14 @@ describe("buildAgentStepEffect signal extraction", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: () => {}, }); const outcome = await Effect.runPromise( - Effect.provide(handle.effect, AgentRuntimeLayer(adapter)), + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), ); expect(outcome.structuredOutput).toEqual(outputObject); }); @@ -88,13 +91,14 @@ describe("buildAgentStepEffect signal extraction", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: () => {}, }); const outcome = await Effect.runPromise( - Effect.provide(handle.effect, AgentRuntimeLayer(adapter)), + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), ); expect(outcome.runError).toBe("something broke"); }); @@ -107,12 +111,13 @@ describe("buildAgentStepEffect signal extraction", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: (chunk) => chunks.push(chunk), }); - await Effect.runPromise(Effect.provide(handle.effect, AgentRuntimeLayer(adapter))); + await Effect.runPromise(Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter)))); expect(chunks).toHaveLength(1); expect(chunks[0]).toBe(rawChunk); }); @@ -127,13 +132,14 @@ describe("buildAgentStepEffect signal extraction", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: () => {}, }); const outcome = await Effect.runPromise( - Effect.provide(handle.effect, AgentRuntimeLayer(adapter)), + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), ); expect(outcome.chunkCount).toBe(3); expect(outcome.finalText).toBe("no signals here"); @@ -156,13 +162,14 @@ describe("buildAgentStepEffect signal extraction", () => { const handle = buildAgentStepEffect({ threadId: "t", dir: "/tmp", + agent: "opencode", model: "m", prompt: "p", onChunk: () => {}, }); expect(streamed).toBe(0); - const runtime = ManagedRuntime.make(AgentRuntimeLayer(adapter)); + const runtime = ManagedRuntime.make(AgentRuntimeLayer(fakeAgents(adapter))); const outcome = await runtime.runPromise(handle.effect); await runtime.dispose(); expect(streamed).toBe(1); diff --git a/src/runtime/agent-step.ts b/src/runtime/agent-step.ts index 4c6ea27..5eb1f7b 100644 --- a/src/runtime/agent-step.ts +++ b/src/runtime/agent-step.ts @@ -38,6 +38,7 @@ import { Effect, Schema, Stream } from "effect"; import type { AgentStepUsage } from "../events"; +import type { AcpAgentKind } from "./acp-agents"; import type { AgentAdapterYield } from "./agent-adapter"; import { AgentRuntime } from "./agent-runtime"; @@ -78,6 +79,7 @@ export class AgentStepChunkError extends Schema.TaggedError export interface AgentStepEffectOptions { readonly threadId: string; readonly dir: string; + readonly agent: AcpAgentKind; readonly model: string; readonly prompt: string; readonly outputSchema?: unknown; @@ -197,6 +199,7 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep adapter.stream({ threadId: options.threadId, dir: options.dir, + agent: options.agent, model: options.model, prompt: options.prompt, outputSchema: options.outputSchema, diff --git a/src/runtime/agent-step.usage.test.ts b/src/runtime/agent-step.usage.test.ts index 67dffe8..ec2be4b 100644 --- a/src/runtime/agent-step.usage.test.ts +++ b/src/runtime/agent-step.usage.test.ts @@ -17,7 +17,7 @@ import { Effect } from "effect"; import { agentStepContextTokens } from "../events"; import type { AgentAdapter, AgentAdapterYield } from "./agent-adapter"; import { AgentRuntimeLayer } from "./agent-runtime"; -import { loadCorpusBlocks } from "../replay/adapter"; +import { fakeAgents, loadCorpusBlocks } from "../replay/adapter"; import { buildAgentStepEffect } from "./agent-step"; const CORPUS = "test/corpus/run-1789308170212.ndjson"; @@ -34,11 +34,14 @@ async function runBlock(chunks: ReadonlyArray) { const handle = buildAgentStepEffect({ threadId: "thread", dir: ".", + agent: "opencode", model: "model", prompt: "prompt", onChunk: () => {}, }); - return await Effect.runPromise(Effect.provide(handle.effect, AgentRuntimeLayer(adapter))); + return await Effect.runPromise( + Effect.provide(handle.effect, AgentRuntimeLayer(fakeAgents(adapter))), + ); } describe("agent step usage", () => { diff --git a/src/runtime/run-signals.test.ts b/src/runtime/run-signals.test.ts index 88f5034..5167610 100644 --- a/src/runtime/run-signals.test.ts +++ b/src/runtime/run-signals.test.ts @@ -10,7 +10,7 @@ import { describe, expect, test } from "bun:test"; import type { RunEvent } from "../events"; import { defineWorkflow, Schema } from "../workflow"; -import { createSlowFakeAdapter } from "../replay/adapter"; +import { fakeAgents, createSlowFakeAdapter } from "../replay/adapter"; import { makeAgentRuntime } from "./agent-runtime"; import { startRun } from "./run"; @@ -39,7 +39,7 @@ async function runWith( signals: Parameters[2], ) { const events: Array = []; - const runtime = makeAgentRuntime(createSlowFakeAdapter(chunks, 1, signals)); + const runtime = makeAgentRuntime(fakeAgents(createSlowFakeAdapter(chunks, 1, signals))); const handle = startRun(agentWorkflow(), runtime, { runId: "run-signals", dir: "/tmp", @@ -69,9 +69,11 @@ describe("signals populate AgentStepFinished as before (issue #35)", () => { test("a structured-output signal is decoded into AgentStepFinished.output (tier 1)", async () => { const events: Array = []; const runtime = makeAgentRuntime( - createSlowFakeAdapter([...TEXT_CHUNKS, { type: "CUSTOM", name: "anything-at-all" }], 1, [ - { index: 3, signal: { _tag: "structuredOutput", value: { where: "from-signal" } } }, - ]), + fakeAgents( + createSlowFakeAdapter([...TEXT_CHUNKS, { type: "CUSTOM", name: "anything-at-all" }], 1, [ + { index: 3, signal: { _tag: "structuredOutput", value: { where: "from-signal" } } }, + ]), + ), ); const handle = startRun(agentWorkflow(SIGNAL_SCHEMA), runtime, { runId: "run-signals-output", @@ -87,7 +89,7 @@ describe("signals populate AgentStepFinished as before (issue #35)", () => { test("without a signal, tier 2 re-parses the final text — the fallback is unchanged", async () => { const events: Array = []; - const runtime = makeAgentRuntime(createSlowFakeAdapter(TEXT_CHUNKS, 1)); + const runtime = makeAgentRuntime(fakeAgents(createSlowFakeAdapter(TEXT_CHUNKS, 1))); const handle = startRun(agentWorkflow(SIGNAL_SCHEMA), runtime, { runId: "run-signals-tier2", dir: "/tmp", @@ -144,7 +146,9 @@ describe("token usage on AgentStepFinished", () => { let handle: ReturnType | undefined; // The step is cancelled right after its usage chunk lands, while the next // (slow) chunk is still pending. - const runtime = makeAgentRuntime(createSlowFakeAdapter([USAGE_CHUNK, ...TEXT_CHUNKS], 50)); + const runtime = makeAgentRuntime( + fakeAgents(createSlowFakeAdapter([USAGE_CHUNK, ...TEXT_CHUNKS], 50)), + ); handle = startRun(agentWorkflow(), runtime, { runId: "run-usage-cancelled", dir: "/tmp", diff --git a/src/runtime/run.test.ts b/src/runtime/run.test.ts index 01c8f7d..1c2ffa5 100644 --- a/src/runtime/run.test.ts +++ b/src/runtime/run.test.ts @@ -9,10 +9,10 @@ import { describe, expect, test } from "bun:test"; import type { RunEvent } from "../events"; import { defineWorkflow, Schema } from "../workflow"; -import { createSlowFakeAdapter } from "../replay/adapter"; +import { fakeAgents, createSlowFakeAdapter } from "../replay/adapter"; import { makeAgentRuntime } from "./agent-runtime"; import { startRun, RunCancelledSignal } from "./run"; -import type { AgentAdapter } from "./agent-adapter"; +import type { AgentAdapter, AgentAdapterOptions } from "./agent-adapter"; describe("RunCancelledSignal as TaggedError (#34)", () => { test("carries the _tag and the cancellation message", () => { @@ -41,12 +41,16 @@ describe("startRun cancellation", () => { }, }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter(SLOW_CHUNKS, 20)), { - runId: "run-cancel-test", - dir: "/tmp", - input: {}, - onEvent: (event) => events.push(event), - }); + const handle = startRun( + workflow, + makeAgentRuntime(fakeAgents(createSlowFakeAdapter(SLOW_CHUNKS, 20))), + { + runId: "run-cancel-test", + dir: "/tmp", + input: {}, + onEvent: (event) => events.push(event), + }, + ); // Let the first chunk or two land, then cancel while the step is still streaming. await new Promise((resolve) => setTimeout(resolve, 30)); @@ -75,12 +79,16 @@ describe("startRun cancellation", () => { }, }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter(SLOW_CHUNKS, 1)), { - runId: "run-no-cancel-test", - dir: "/tmp", - input: {}, - onEvent: () => {}, - }); + const handle = startRun( + workflow, + makeAgentRuntime(fakeAgents(createSlowFakeAdapter(SLOW_CHUNKS, 1))), + { + runId: "run-no-cancel-test", + dir: "/tmp", + input: {}, + onEvent: () => {}, + }, + ); const outcome = await handle.result; expect(outcome.outcome).toBe("completed"); @@ -105,7 +113,7 @@ describe("startRun workspace kind (issue #13)", () => { }, }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter([])), { + const handle = startRun(workflow, makeAgentRuntime(fakeAgents(createSlowFakeAdapter([]))), { runId: "run-scratch-wb", dir: "/tmp/nothing", input: {}, @@ -139,7 +147,7 @@ describe("startRun workspace kind (issue #13)", () => { }), }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter([])), { + const handle = startRun(workflow, makeAgentRuntime(fakeAgents(createSlowFakeAdapter([]))), { runId: "run-clone-wb", dir: "/tmp/nothing", input: {}, @@ -164,7 +172,7 @@ describe("startRun workspace kind (issue #13)", () => { }); const scratchEvents: Array = []; - await startRun(workflow, makeAgentRuntime(createSlowFakeAdapter([])), { + await startRun(workflow, makeAgentRuntime(fakeAgents(createSlowFakeAdapter([]))), { runId: "run-kind-scratch", dir: "/tmp/s", input: {}, @@ -172,7 +180,7 @@ describe("startRun workspace kind (issue #13)", () => { onEvent: (event) => scratchEvents.push(event), }).result; const cloneEvents: Array = []; - await startRun(workflow, makeAgentRuntime(createSlowFakeAdapter([])), { + await startRun(workflow, makeAgentRuntime(fakeAgents(createSlowFakeAdapter([]))), { runId: "run-kind-clone", dir: "/tmp/c", input: {}, @@ -198,13 +206,17 @@ describe("startRun schedule trigger (issue #16)", () => { return { finalText: result.finalText }; }, }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter(SLOW_CHUNKS, 5)), { - runId: "run-by-schedule", - dir: "/tmp", - input: { issueNumber: 7 }, - scheduleId: "nightly", - onEvent: (event) => events.push(event), - }); + const handle = startRun( + workflow, + makeAgentRuntime(fakeAgents(createSlowFakeAdapter(SLOW_CHUNKS, 5))), + { + runId: "run-by-schedule", + dir: "/tmp", + input: { issueNumber: 7 }, + scheduleId: "nightly", + onEvent: (event) => events.push(event), + }, + ); await handle.result; const started = events.find((e) => e.payload._tag === "RunStarted"); expect(started?.payload).toMatchObject({ scheduleId: "nightly" }); @@ -216,7 +228,7 @@ describe("startRun schedule trigger (issue #16)", () => { input: Schema.Struct({}), run: async () => ({}), }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter([])), { + const handle = startRun(workflow, makeAgentRuntime(fakeAgents(createSlowFakeAdapter([]))), { runId: "run-manual", dir: "/tmp", input: {}, @@ -242,13 +254,17 @@ describe("startRun model precedence (issue #16)", () => { return {}; }, }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter(SLOW_CHUNKS, 5)), { - runId: "run-models", - dir: "/tmp", - input: {}, - agentOverrides: { model: "schedule-override" }, - onEvent: (event) => events.push(event), - }); + const handle = startRun( + workflow, + makeAgentRuntime(fakeAgents(createSlowFakeAdapter(SLOW_CHUNKS, 5))), + { + runId: "run-models", + dir: "/tmp", + input: {}, + agentOverrides: { model: "schedule-override" }, + onEvent: (event) => events.push(event), + }, + ); const outcome = await handle.result; expect(outcome.outcome).toBe("completed"); @@ -269,12 +285,16 @@ describe("startRun model precedence (issue #16)", () => { return {}; }, }); - const handle = startRun(workflow, makeAgentRuntime(createSlowFakeAdapter(SLOW_CHUNKS, 5)), { - runId: "run-models-fallback", - dir: "/tmp", - input: {}, - onEvent: (event) => events.push(event), - }); + const handle = startRun( + workflow, + makeAgentRuntime(fakeAgents(createSlowFakeAdapter(SLOW_CHUNKS, 5))), + { + runId: "run-models-fallback", + dir: "/tmp", + input: {}, + onEvent: (event) => events.push(event), + }, + ); const outcome = await handle.result; expect(outcome.outcome).toBe("completed"); const started = events.find((e) => e.payload._tag === "AgentStepStarted"); @@ -284,6 +304,116 @@ describe("startRun model precedence (issue #16)", () => { }); }); +describe("startRun agent and model (ADR 0013 §2)", () => { + function recordingAdapter(): AgentAdapter & { + readonly calls: Array>; + } { + const calls: Array> = []; + const inner = createSlowFakeAdapter(SLOW_CHUNKS, 1); + return { + calls, + prepareWorkspace: inner.prepareWorkspace, + stream: (options) => { + calls.push({ agent: options.agent, model: options.model }); + return inner.stream(options); + }, + }; + } + + const startedSteps = (events: ReadonlyArray) => + events.flatMap((e) => + e.payload._tag === "AgentStepStarted" + ? [{ agent: e.payload.agent, model: e.payload.model }] + : [], + ); + + test("each level resolves, the adapter gets the agent and model, and AgentStepStarted records both", async () => { + const events: Array = []; + const adapter = recordingAdapter(); + const workflow = defineWorkflow("agent-choice", { + input: Schema.Struct({}), + agent: { agent: "claude", model: "opus" }, + run: async (ctx) => { + await ctx.agent("workflow-level", "p"); + await ctx.agent("model-only", "p", { model: "haiku" }); + await ctx.agent("agent-change", "p", { agent: "opencode" }); + await ctx.agent("both", "p", { agent: "opencode", model: "opencode/other" }); + return {}; + }, + }); + const handle = startRun( + workflow, + makeAgentRuntime({ + adapter, + default: "opencode", + models: { claude: "sonnet", opencode: "opencode/big-pickle" }, + }), + { runId: "run-agent-choice", dir: "/tmp", input: {}, onEvent: (e) => events.push(e) }, + ); + expect((await handle.result).outcome).toBe("completed"); + + const expected: Array> = [ + { agent: "claude", model: "opus" }, + { agent: "claude", model: "haiku" }, + // The workflow's `opus` belongs to claude: it does not carry across. + { agent: "opencode", model: "opencode/big-pickle" }, + { agent: "opencode", model: "opencode/other" }, + ]; + expect(startedSteps(events)).toEqual(expected); + expect(adapter.calls).toEqual(expected); + }); + + test("the run level (request or schedule) sits between the call and the workflow", async () => { + const events: Array = []; + const workflow = defineWorkflow("agent-choice-run-level", { + input: Schema.Struct({}), + agent: { model: "workflow-model" }, + run: async (ctx) => { + await ctx.agent("step", "p"); + return {}; + }, + }); + const handle = startRun( + workflow, + makeAgentRuntime({ adapter: recordingAdapter(), models: { claude: "sonnet" } }), + { + runId: "run-agent-choice-run-level", + dir: "/tmp", + input: {}, + agentOverrides: { agent: "claude" }, + onEvent: (e) => events.push(e), + }, + ); + expect((await handle.result).outcome).toBe("completed"); + expect(startedSteps(events)).toEqual([{ agent: "claude", model: "sonnet" }]); + }); + + test("an unresolvable model fails the run before the step starts, naming the fix", async () => { + const events: Array = []; + const adapter = recordingAdapter(); + const workflow = defineWorkflow("agent-choice-unresolvable", { + input: Schema.Struct({}), + run: async (ctx) => { + await ctx.agent("implement", "p", { agent: "claude" }); + return {}; + }, + }); + const handle = startRun( + workflow, + makeAgentRuntime({ adapter, models: { opencode: "opencode/big-pickle" } }), + { runId: "run-agent-choice-none", dir: "/tmp", input: {}, onEvent: (e) => events.push(e) }, + ); + const outcome = await handle.result; + expect(outcome.outcome).toBe("failed"); + expect(outcome.outcome === "failed" && outcome.error).toBe( + 'agent step "implement": no model for agent "claude": name one with `model` on the ctx.agent call, ' + + "the run, the schedule or the workflow, or set `agent.models.claude` in factory.config.ts", + ); + expect(startedSteps(events)).toEqual([]); + expect(adapter.calls).toEqual([]); + }); +}); + describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { function trackingAdapter(): AgentAdapter & { readonly prepared: Array } { const prepared: Array = []; @@ -305,7 +435,7 @@ describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { run: async () => ({}), }); - await startRun(workflow, makeAgentRuntime(adapter), { + await startRun(workflow, makeAgentRuntime(fakeAgents(adapter)), { runId: "run-prep-clone", dir: "/tmp/clone-dir", input: {}, @@ -323,7 +453,7 @@ describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { run: async () => ({}), }); - await startRun(workflow, makeAgentRuntime(adapter), { + await startRun(workflow, makeAgentRuntime(fakeAgents(adapter)), { runId: "run-prep-scratch", dir: "/tmp/scratch-dir", input: {}, diff --git a/src/runtime/run.ts b/src/runtime/run.ts index ad5f5eb..58cc236 100644 --- a/src/runtime/run.ts +++ b/src/runtime/run.ts @@ -29,6 +29,7 @@ import type { WriteBackCallOptions, } from "../workflow"; import { DedupeKeyError } from "../lib/dedupe"; +import { resolveAgentChoice, type AgentChoice, type ResolvedAgent } from "./agent-choice"; import { AgentRuntime } from "./agent-runtime"; import { buildAgentStepEffect } from "./agent-step"; @@ -41,9 +42,6 @@ export class RunCancelledSignal extends Schema.TaggedError() } } -/** Matches the spike's model (STATUS.md); overridden per-call or per-workflow. */ -export const DEFAULT_MODEL = "opencode-go/deepseek-v4.1-flash"; - /** * The deployment-known half of write-back (D27/D32): the repo slug pushed to * `gh pr create` and the branch PRs open against. The runtime supplies it @@ -96,11 +94,11 @@ export interface StartRunOptions { */ readonly scheduleId?: string; /** - * Issue #16: agent-level overrides the starting schedule carries (its - * `agent.model`). Sits between a per-call option and the workflow's own - * default in the precedence chain. + * The run level of the agent/model choice (ADR 0013 §2): what the run + * request or the starting schedule (issue #16) names. Sits between a + * per-call option and the workflow's own default. */ - readonly agentOverrides?: { readonly model?: string }; + readonly agentOverrides?: AgentChoice; readonly onEvent: (event: RunEvent) => void; } @@ -248,11 +246,20 @@ export function startRun( ): Promise> => { if (cancelled) throw RunCancelledSignal.of(); + // ADR 0013 §2: call > run (request or schedule) > workflow > config. An + // unresolvable choice fails here, before the step starts and before any + // agent process does. + const { defaults } = await runtime.runPromise(Effect.service(AgentRuntime)); + let choice: ResolvedAgent; + try { + choice = resolveAgentChoice([opts, options.agentOverrides, workflow.agent], defaults); + } catch (err) { + throw new Error(`agent step "${name}": ${err instanceof Error ? err.message : String(err)}`); + } + const { agent, model } = choice; + if (cancelled) throw RunCancelledSignal.of(); + const stepId = nextStepId(); - // Precedence (issue #16): per-call option > the starting schedule's - // override > the workflow's `agent` default > the runtime fallback. - const model = - opts?.model ?? options.agentOverrides?.model ?? workflow.agent?.model ?? DEFAULT_MODEL; const outputSchema = opts?.output !== undefined ? Schema.toJsonSchemaDocument(opts.output) : undefined; @@ -260,6 +267,7 @@ export function startRun( _tag: "AgentStepStarted", stepId, name, + agent, model, prompt, structured: opts?.output !== undefined, @@ -269,6 +277,7 @@ export function startRun( const handle = buildAgentStepEffect({ threadId: options.runId, dir: options.dir, + agent, model, prompt, outputSchema, diff --git a/src/server/concurrency.test.ts b/src/server/concurrency.test.ts index f2691a0..c34d38b 100644 --- a/src/server/concurrency.test.ts +++ b/src/server/concurrency.test.ts @@ -18,7 +18,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 { fakeAgents, createSlowFakeAdapter } from "../replay/adapter"; import { admitRun } from "./admission"; import { serve } from "./http"; import { makeDaemonRuntime } from "./daemon-runtime"; @@ -74,13 +74,15 @@ describe("phase 5 P1: per-run working trees (D28) over the real server", () => { const server = serve({ db, runtime: makeDaemonRuntime( - createSlowFakeAdapter( - [ - { type: "TEXT_MESSAGE_START" }, - { type: "TEXT_MESSAGE_CONTENT", delta: "hi" }, - { type: "TEXT_MESSAGE_END" }, - ], - 30, + fakeAgents( + createSlowFakeAdapter( + [ + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "hi" }, + { type: "TEXT_MESSAGE_END" }, + ], + 30, + ), ), ), port: 0, @@ -163,13 +165,15 @@ describe("phase 5 P1: per-run working trees (D28) over the real server", () => { const server = serve({ db, runtime: makeDaemonRuntime( - createSlowFakeAdapter( - [ - { type: "TEXT_MESSAGE_START" }, - { type: "TEXT_MESSAGE_CONTENT", delta: "hi" }, - { type: "TEXT_MESSAGE_END" }, - ], - 300, + fakeAgents( + createSlowFakeAdapter( + [ + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "hi" }, + { type: "TEXT_MESSAGE_END" }, + ], + 300, + ), ), ), port: 0, @@ -225,13 +229,15 @@ describe("phase 5 P1: per-run working trees (D28) over the real server", () => { const server = serve({ db, runtime: makeDaemonRuntime( - createSlowFakeAdapter( - [ - { type: "TEXT_MESSAGE_START" }, - { type: "TEXT_MESSAGE_CONTENT", delta: "hi" }, - { type: "TEXT_MESSAGE_END" }, - ], - 1_000, + fakeAgents( + createSlowFakeAdapter( + [ + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "hi" }, + { type: "TEXT_MESSAGE_END" }, + ], + 1_000, + ), ), ), port: 0, diff --git a/src/server/daemon-runtime.ts b/src/server/daemon-runtime.ts index f1fde25..d4e14d2 100644 --- a/src/server/daemon-runtime.ts +++ b/src/server/daemon-runtime.ts @@ -16,8 +16,7 @@ import { Effect, Layer, ManagedRuntime, type Context } from "effect"; import { DedupeRegistry, DedupeRegistryLayer } from "../lib/dedupe"; import { RefreshGates, RefreshGatesLayer } from "../lib/workspace"; -import type { AgentAdapter } from "../runtime/agent-adapter"; -import { AgentRuntime, AgentRuntimeLayer } from "../runtime/agent-runtime"; +import { AgentRuntime, AgentRuntimeLayer, type AgentRuntimeConfig } from "../runtime/agent-runtime"; import { RunPubSub, RunPubSubLayer } from "./pubsub"; import { RunRegistry, RunRegistryLayer } from "./runs"; @@ -27,10 +26,13 @@ export type DaemonServices = AgentRuntime | RunRegistry | RunPubSub | DedupeRegi /** The daemon's `ManagedRuntime`: the agent runtime plus its per-daemon state. */ export type DaemonRuntime = ManagedRuntime.ManagedRuntime; -/** The daemon's layer: `adapter` (or the opencode default) plus fresh per-daemon state. */ -export const DaemonLayer = (adapter?: AgentAdapter): Layer.Layer => +/** + * The daemon's layer: the agent runtime for `agent` (config's `agent` block — + * the ACP runtime unless it injects an adapter) plus fresh per-daemon state. + */ +export const DaemonLayer = (agent?: AgentRuntimeConfig): Layer.Layer => Layer.mergeAll( - AgentRuntimeLayer(adapter), + AgentRuntimeLayer(agent), RunRegistryLayer, RunPubSubLayer, DedupeRegistryLayer, @@ -41,8 +43,8 @@ export const DaemonLayer = (adapter?: AgentAdapter): Layer.Layer * Build a daemon runtime. The caller owns it and must `dispose()` it; tests * build one per case for isolated state. */ -export const makeDaemonRuntime = (adapter?: AgentAdapter): DaemonRuntime => - ManagedRuntime.make(DaemonLayer(adapter)); +export const makeDaemonRuntime = (agent?: AgentRuntimeConfig): DaemonRuntime => + ManagedRuntime.make(DaemonLayer(agent)); /** * Resolve one of the daemon's services from its runtime, synchronously — the diff --git a/src/server/daemon.agents.test.ts b/src/server/daemon.agents.test.ts new file mode 100644 index 0000000..dc6df99 --- /dev/null +++ b/src/server/daemon.agents.test.ts @@ -0,0 +1,96 @@ +/** + * ADR 0013 Consequences: the daemon checks the configured agents at start and + * names what is missing, rather than failing the first step. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defineConfig, type AgentConfigInput } from "../config"; +import { createSlowFakeAdapter } from "../replay/adapter"; +import type { AcpAgentKind } from "../runtime/acp-agents"; +import { defineWorkflow, Schema } from "../workflow"; +import { reportAgentProblems, startDaemon } from "./daemon"; + +const PINNED = defineWorkflow("pinned-to-claude", { + input: Schema.Struct({}), + agent: { agent: "claude" }, + workspace: { kind: "scratch" }, + run: async () => ({}), +}); + +function config(agent: AgentConfigInput) { + return defineConfig({ + repo: { + sshUrl: "git@github.com:acme/widgets.git", + identity: { name: "Factory", email: "factory@acme.test" }, + baseBranch: "main", + slug: "acme/widgets", + }, + workflows: [PINNED], + agent, + }); +} + +describe("agent availability at daemon start (ADR 0013)", () => { + const dir = mkdtempSync(join(tmpdir(), "factory-daemon-agents-")); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + test("startDaemon checks the configured agents", async () => { + const checked: Array> = []; + const handle = await startDaemon({ + dbPath: join(dir, "checked.db"), + port: 0, + config: config({ default: "opencode" }), + checkAgents: async (agents) => { + checked.push(agents); + return []; + }, + }); + await handle.stop(); + // The default, and claude because a workflow names it. + expect(checked).toEqual([["claude", "opencode"]]); + }); + + test("an injected adapter skips the check: no agent process runs", async () => { + let calls = 0; + const handle = await startDaemon({ + dbPath: join(dir, "injected.db"), + port: 0, + config: config({ adapter: createSlowFakeAdapter([], 1) }), + checkAgents: async () => { + calls += 1; + return []; + }, + }); + await handle.stop(); + expect(calls).toBe(0); + }); + + test("reportAgentProblems logs each problem, naming the agent", async () => { + const lines: string[] = []; + const problems = await reportAgentProblems( + ["claude", "opencode"], + async () => [{ agent: "opencode", problem: "`opencode` is not on PATH" }], + (line) => lines.push(line), + ); + expect(problems).toHaveLength(1); + expect(lines).toEqual([ + "agents: opencode is configured but cannot run on this host: `opencode` is not on PATH", + ]); + }); + + test("reportAgentProblems logs a failing check instead of throwing", async () => { + const lines: string[] = []; + const problems = await reportAgentProblems( + ["claude"], + async () => { + throw new Error("boom"); + }, + (line) => lines.push(line), + ); + expect(problems).toEqual([]); + expect(lines).toEqual(["agents: availability check failed: boom"]); + }); +}); diff --git a/src/server/daemon.ts b/src/server/daemon.ts index 31fb20b..b2783f2 100644 --- a/src/server/daemon.ts +++ b/src/server/daemon.ts @@ -32,7 +32,8 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { Effect, Fiber } from "effect"; type AnyFiber = Fiber.Fiber; -import type { FactoryConfig } from "../config"; +import { configuredAgents, type FactoryConfig } from "../config"; +import { checkAgents, type AcpAgentKind, type AgentProblem } from "../runtime/acp-agents"; import { openStore } from "../persistence/store"; import { serve } from "./http"; import { @@ -61,6 +62,8 @@ export interface DaemonOptions { * them, over `DEFAULT_SHUTDOWN_TIMEOUT_MS`. */ readonly shutdownTimeoutMs?: number; + /** The agent availability check run at start, over `checkAgents` (tests). */ + readonly checkAgents?: (agents: ReadonlyArray) => Promise; } export interface DaemonHandle { @@ -82,6 +85,28 @@ export interface DaemonHandle { readonly stop: () => Promise; } +/** + * Log every configured agent that cannot run on this host. Returns the + * problems for tests; never throws — a failing check is itself only logged. + */ +export async function reportAgentProblems( + agents: ReadonlyArray, + check: (agents: ReadonlyArray) => Promise = checkAgents, + log: (line: string) => void = console.warn, +): Promise { + let problems: AgentProblem[]; + try { + problems = await check(agents); + } catch (err) { + log(`agents: availability check failed: ${err instanceof Error ? err.message : String(err)}`); + return []; + } + for (const { agent, problem } of problems) { + log(`agents: ${agent} is configured but cannot run on this host: ${problem}`); + } + return problems; +} + /** After disposal, how long `stop()` still waits for cancelled runs to record their end. */ const SETTLE_AFTER_DISPOSE_MS = 2_000; @@ -92,11 +117,17 @@ export async function startDaemon(options: DaemonOptions): Promise await mkdir(dirname(options.dbPath), { recursive: true }); const db = openStore(options.dbPath); - const runtime = makeDaemonRuntime(options.config?.agent.adapter); + const runtime = makeDaemonRuntime(options.config?.agent); // Build the layers now: a failing (or, one day, asynchronous) layer // surfaces at startup, and every later `serviceOf` reads the built context. await runtime.context(); + // ADR 0013: name a configured agent that cannot run on this host now, + // rather than fail its first step. In the background — it only logs. + if (options.config !== undefined && options.config.agent.adapter === undefined) { + void reportAgentProblems(configuredAgents(options.config), options.checkAgents ?? checkAgents); + } + const server = serve({ db, runtime, diff --git a/src/server/http.test.ts b/src/server/http.test.ts index 780950e..a1e19ca 100644 --- a/src/server/http.test.ts +++ b/src/server/http.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { Schema } from "effect"; import { defineWorkflow, type WorkflowDefinition } from "../workflow"; -import { createSlowFakeAdapter } from "../replay/adapter"; +import { fakeAgents, createSlowFakeAdapter } from "../replay/adapter"; import type { AgentAdapter } from "../runtime/agent-adapter"; import { getRunEvents, openStore } from "../persistence/store"; import { defineConfig, loadFactoryConfig } from "../config"; @@ -95,7 +95,7 @@ describe("GET /api/workflows (D30)", () => { const config = await loadFactoryConfig(FIXTURE_CONFIG); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config, }); @@ -125,7 +125,7 @@ describe("GET /api/workflows (D30)", () => { const adapter = createSlowFakeAdapter([], 1); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -149,7 +149,7 @@ describe("GET /api/workflows (D30)", () => { const adapter = createSlowFakeAdapter([], 1); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -173,7 +173,7 @@ describe("phase 4 SPA serving", () => { const adapter = createSlowFakeAdapter([{ type: "TEXT_MESSAGE_START" }], 1); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -260,7 +260,7 @@ describe("SSE keepalive", () => { const adapter = createSlowFakeAdapter([], 1); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, sseKeepaliveMs: 25, }); @@ -301,7 +301,7 @@ describe("SSE keepalive", () => { const adapter = createSlowFakeAdapter([], 1); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, sseKeepaliveMs: 10, }); @@ -346,7 +346,7 @@ describe("phase 3 HTTP API + SSE", () => { ); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -399,7 +399,7 @@ describe("phase 3 HTTP API + SSE", () => { ); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -439,7 +439,7 @@ describe("phase 3 HTTP API + SSE", () => { ); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -482,7 +482,7 @@ describe("phase 3 HTTP API + SSE", () => { ); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -524,7 +524,7 @@ describe("phase 3 HTTP API + SSE", () => { ); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -574,7 +574,7 @@ describe("POST /api/runs {workflowId, input} (D31)", () => { const config = await loadFactoryConfig(FIXTURE_CONFIG); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config, }); @@ -605,7 +605,7 @@ describe("POST /api/runs {workflowId, input} (D31)", () => { const config = await loadFactoryConfig(FIXTURE_CONFIG); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config, }); @@ -656,7 +656,7 @@ describe("POST /api/runs {workflowId, input} (D31)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config, }); @@ -724,7 +724,7 @@ describe("POST /api/runs {workflowId, input} (D31)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config, }); @@ -796,7 +796,7 @@ describe("a scratch workflow through POST /api/runs (issue #13)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config, }); @@ -872,7 +872,7 @@ describe("ctx.dispatch through POST /api/runs (issue #14)", () => { const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, config: defineConfig({ repo: { @@ -970,7 +970,7 @@ describe("GET /api/schedules (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config: schedulesConfig(root, workflow), }); @@ -1039,7 +1039,7 @@ describe("GET /api/schedules (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config, }); @@ -1065,7 +1065,7 @@ describe("GET /api/schedules (issue #17)", () => { const db = openStore(join(root, "factory.db")); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, }); const base = `http://localhost:${server.port}`; @@ -1111,7 +1111,7 @@ describe("GET /api/schedules (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config, }); @@ -1212,7 +1212,7 @@ describe("POST /api/schedules/:id/run (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config, }); @@ -1266,7 +1266,7 @@ describe("POST /api/schedules/:id/run (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config, }); @@ -1309,7 +1309,7 @@ describe("POST /api/schedules/:id/run (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config, }); @@ -1374,7 +1374,7 @@ describe("POST /api/schedules/:id/run (issue #17)", () => { }); const server = serve({ db, - runtime: makeDaemonRuntime(createSlowFakeAdapter([], 1)), + runtime: makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))), port: 0, config, }); @@ -1425,7 +1425,7 @@ describe("adapter.prepareWorkspace through the legacy POST /api/runs (#37)", () const adapter = trackingAdapter(); const server = serve({ db, - runtime: makeDaemonRuntime(adapter), + runtime: makeDaemonRuntime(fakeAgents(adapter)), port: 0, }); const base = `http://localhost:${server.port}`; @@ -1461,3 +1461,94 @@ describe("adapter.prepareWorkspace through the legacy POST /api/runs (#37)", () } }); }); + +describe("POST /api/runs agent choice (ADR 0013 §2)", () => { + async function withServer( + fn: (base: string, db: ReturnType) => Promise, + ): Promise { + const root = mkdtempSync(join(tmpdir(), "factory-agent-choice-test-")); + const db = openStore(join(root, "factory.db")); + const config = defineConfig({ + repo: { + sshUrl: join(root, "seed-not-used"), + identity: { name: "Factory", email: "factory@factory.test" }, + baseBranch: "main", + slug: "acme/widgets", + }, + workflows: [registryScratchWorkflow], + workspaceRoot: join(root, "workspaces"), + agent: { default: "opencode", models: { claude: "sonnet", opencode: "opencode/big-pickle" } }, + }); + const server = serve({ + db, + runtime: makeDaemonRuntime({ ...config.agent, adapter: createSlowFakeAdapter([], 1) }), + port: 0, + config, + }); + try { + await fn(`http://localhost:${server.port}`, db); + } finally { + await server.stop(true); + db.close(); + rmSync(root, { recursive: true, force: true }); + } + } + + const post = (base: string, body: unknown) => + fetch(`${base}/api/runs`, { method: "POST", body: JSON.stringify(body) }); + + const stepStarted = (db: ReturnType, runId: string) => { + const event = getRunEvents(db, runId).find((e) => e.payload._tag === "AgentStepStarted"); + return event?.payload._tag === "AgentStepStarted" + ? { agent: event.payload.agent, model: event.payload.model } + : undefined; + }; + + test("the request's agent picks that agent's configured model", async () => { + await withServer(async (base, db) => { + const res = await post(base, { + workflowId: "registry-scratch-test", + input: {}, + agent: { agent: "claude" }, + }); + expect(res.status).toBe(201); + const { runId } = (await res.json()) as { runId: string }; + await waitForTerminal(db, runId, 10_000); + expect(stepStarted(db, runId)).toEqual({ agent: "claude", model: "sonnet" }); + }); + }); + + test("without a choice, the config's default agent and model apply", async () => { + await withServer(async (base, db) => { + const res = await post(base, { workflowId: "registry-scratch-test", input: {} }); + const { runId } = (await res.json()) as { runId: string }; + await waitForTerminal(db, runId, 10_000); + expect(stepStarted(db, runId)).toEqual({ agent: "opencode", model: "opencode/big-pickle" }); + }); + }); + + test("the request's model applies", async () => { + await withServer(async (base, db) => { + const res = await post(base, { + workflowId: "registry-scratch-test", + input: {}, + agent: { agent: "claude", model: "haiku" }, + }); + const { runId } = (await res.json()) as { runId: string }; + await waitForTerminal(db, runId, 10_000); + expect(stepStarted(db, runId)).toEqual({ agent: "claude", model: "haiku" }); + }); + }); + + test.each([ + [{ agent: "codex" }, /agent.agent must be one of claude, opencode/], + [{ model: "" }, /agent.model must be a non-empty string/], + ["claude", /agent must be an object/], + ])("400s for agent %j", async (agent, message) => { + await withServer(async (base) => { + const res = await post(base, { workflowId: "registry-scratch-test", input: {}, agent }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch(message); + }); + }); +}); diff --git a/src/server/http.ts b/src/server/http.ts index c41829f..9cea88e 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -32,6 +32,8 @@ * (ADR 0009 §5) — the runtime is only the way into the context. */ +import { ACP_AGENT_KINDS } from "../runtime/acp-agents"; +import { isAcpAgentKind, type AgentChoice } from "../runtime/agent-choice"; import type { Database } from "bun:sqlite"; import { isTerminal, type RunEvent } from "../events"; import type { FactoryConfig } from "../config"; @@ -173,7 +175,7 @@ function configRunOptions( extra: { readonly scheduleId?: string; readonly dedupeKey?: string; - readonly agentOverrides?: { readonly model?: string }; + readonly agentOverrides?: AgentChoice; } = {}, ): Omit { return { @@ -202,10 +204,40 @@ interface StartRunBody { * holding run — never a silently dropped duplicate. */ readonly dedupeKey?: unknown; + /** + * The run level of the agent/model choice (ADR 0013 §2): `{ agent?, model? }`, + * above the workflow's default and the config's, below a per-call option. + */ + readonly agent?: unknown; readonly dir?: string; readonly clone?: { readonly sshUrl: string; readonly identity: GitIdentity }; } +/** A request body's `agent` field, validated: the choice, or the 400 message. */ +export function parseAgentChoice( + raw: unknown, +): { readonly choice: AgentChoice | undefined } | { readonly error: string } { + if (raw === undefined || raw === null) return { choice: undefined }; + if (typeof raw !== "object" || Array.isArray(raw)) { + return { error: "agent must be an object: { agent?, model? }" }; + } + const { agent, model } = raw as { agent?: unknown; model?: unknown }; + if (agent !== undefined && !isAcpAgentKind(agent)) { + return { + error: `agent.agent must be one of ${ACP_AGENT_KINDS.join(", ")} (got ${JSON.stringify(agent)})`, + }; + } + if (model !== undefined && (typeof model !== "string" || model === "")) { + return { error: "agent.model must be a non-empty string" }; + } + return { + choice: { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model: model as string } : {}), + }, + }; +} + function json(body: unknown, init?: { readonly status?: number }): Response { return new Response(JSON.stringify(body), { status: init?.status, @@ -379,6 +411,9 @@ export function createHandler(options: ServerOptions): (req: Request) => Promise } catch { return json({ error: "invalid JSON body" }, { status: 400 }); } + const parsedAgent = parseAgentChoice(body.agent); + if ("error" in parsedAgent) return json({ error: parsedAgent.error }, { status: 400 }); + const agentOverrides = parsedAgent.choice; if (typeof body.workflowId === "string") { if ( body.dedupeKey !== undefined && @@ -410,6 +445,7 @@ export function createHandler(options: ServerOptions): (req: Request) => Promise const startOptions: StartTrackedRunOptions = { ...configRunOptions(runEnv, maxConcurrentRuns, { dedupeKey: typeof body.dedupeKey === "string" ? body.dedupeKey : undefined, + ...(agentOverrides !== undefined ? { agentOverrides } : {}), }), input: decodedInput, }; @@ -481,6 +517,7 @@ export function createHandler(options: ServerOptions): (req: Request) => Promise ...(runEnv !== undefined ? { dispatchEnv: dispatchEnvFor(runEnv) } : {}), input: body.input, ...(typeof body.dedupeKey === "string" ? { dedupeKey: body.dedupeKey } : {}), + ...(agentOverrides !== undefined ? { agentOverrides } : {}), ...(body.clone !== undefined && typeof body.dir === "string" ? { prepareWorkspace: true } : {}), diff --git a/src/server/runs.ts b/src/server/runs.ts index 935f1c6..9c77449 100644 --- a/src/server/runs.ts +++ b/src/server/runs.ts @@ -32,6 +32,7 @@ * code over plain maps. */ +import type { AgentChoice } from "../runtime/agent-choice"; import { Context, Effect, Layer, Schema } from "effect"; import type { Database } from "bun:sqlite"; import { rm } from "node:fs/promises"; @@ -320,10 +321,10 @@ export interface StartTrackedRunOptions { */ readonly scheduleId?: string; /** - * Issue #16: agent-level overrides the starting schedule carries. Passed - * through to the run's model precedence chain. + * The run level of the agent/model choice (ADR 0013 §2): what the run + * request or the starting schedule (issue #16) names. */ - readonly agentOverrides?: { readonly model?: string }; + readonly agentOverrides?: AgentChoice; /** * ADR 0012 §3 (#37): when true, the runtime calls `adapter.prepareWorkspace` * before the workflow runs. The caller sets this when it has done a diff --git a/src/server/scheduler.ts b/src/server/scheduler.ts index 79bd710..765bee0 100644 --- a/src/server/scheduler.ts +++ b/src/server/scheduler.ts @@ -30,6 +30,7 @@ * `tickOnce` receives them as ordinary arguments in `SchedulerDeps`. */ +import type { AgentChoice } from "../runtime/agent-choice"; import { Clock, Cron, Effect, Schedule, Schema } from "effect"; import type { Database } from "bun:sqlite"; import type { FactoryConfig } from "../config"; @@ -58,7 +59,7 @@ export interface RuntimeSchedule { readonly cron: Cron.Cron; readonly overlap: "skip" | "stack"; readonly runOnStart: boolean; - readonly agent: { readonly model?: string } | undefined; + readonly agent: AgentChoice | undefined; } export function scheduleDedupeKey(schedule: RuntimeSchedule): string { diff --git a/src/server/shutdown.test.ts b/src/server/shutdown.test.ts index 48da70c..e294928 100644 --- a/src/server/shutdown.test.ts +++ b/src/server/shutdown.test.ts @@ -14,7 +14,7 @@ import { Schema } from "effect"; import { defineConfig } from "../config"; import { RefreshGates } from "../lib/workspace"; import { getRunEvents, openStore } from "../persistence/store"; -import { createSlowFakeAdapter } from "../replay/adapter"; +import { fakeAgents, createSlowFakeAdapter } from "../replay/adapter"; import type { AgentAdapter, AgentAdapterYield } from "../runtime/agent-adapter"; import { makeAgentRuntime } from "../runtime/agent-runtime"; import { startRun } from "../runtime/run"; @@ -120,7 +120,7 @@ async function makeDaemon(root: string, sshUrl = join(root, "no-remote")): Promi dbPath: join(root, "factory.db"), port: 0, config: defineConfig({ - agent: { adapter: createSlowFakeAdapter([], 1) }, + agent: fakeAgents(createSlowFakeAdapter([], 1)), repo: { sshUrl, identity: { name: "Factory", email: "factory@factory.test" }, @@ -242,7 +242,7 @@ describe("daemon shutdown cancels active runs (#38)", () => { const pidFile = join(root, "child.pid"); const dbPath = join(root, "factory.db"); const db = openStore(dbPath); - const runtime = makeDaemonRuntime(createSlowFakeAdapter([], 1)); + const runtime = makeDaemonRuntime(fakeAgents(createSlowFakeAdapter([], 1))); try { const runId = await startTrackedRun(runtime, db, longExec, { workspace: { @@ -269,7 +269,7 @@ describe("daemon shutdown cancels active runs (#38)", () => { test("a cancel mid-agent-step records RunCancelled promptly even if the adapter never yields again", async () => { const adapter = waitingAdapter({ cooperative: false }); const events: Array = []; - const handle = startRun(agentStep, makeAgentRuntime(adapter), { + const handle = startRun(agentStep, makeAgentRuntime(fakeAgents(adapter)), { runId: "run-blocked-agent", dir: tmpdir(), input: {}, @@ -293,7 +293,7 @@ describe("daemon shutdown cancels active runs (#38)", () => { dbPath: join(root, "factory.db"), port: 0, config: defineConfig({ - agent: { adapter }, + agent: fakeAgents(adapter), repo: { sshUrl: join(root, "no-remote"), identity: { name: "Factory", email: "factory@factory.test" }, @@ -329,7 +329,7 @@ describe("daemon shutdown cancels active runs (#38)", () => { port: 0, shutdownTimeoutMs: 200, config: defineConfig({ - agent: { adapter: waitingAdapter({ cooperative: false }) }, + agent: fakeAgents(waitingAdapter({ cooperative: false })), repo: { sshUrl: join(root, "no-remote"), identity: { name: "Factory", email: "factory@factory.test" }, diff --git a/src/server/test-daemon.ts b/src/server/test-daemon.ts index 695d994..5b37e04 100644 --- a/src/server/test-daemon.ts +++ b/src/server/test-daemon.ts @@ -10,6 +10,7 @@ import type { DedupeRegistryShape } from "../lib/dedupe"; import { DedupeRegistry } from "../lib/dedupe"; import { RefreshGates, type RefreshGatesShape } from "../lib/workspace"; +import { FAKE_AGENT_MODELS } from "../replay/adapter"; import type { AgentAdapter } from "../runtime/agent-adapter"; import { makeDaemonRuntime, serviceOf, type DaemonRuntime } from "./daemon-runtime"; import { RunPubSub, type PubSub } from "./pubsub"; @@ -24,7 +25,11 @@ export interface TestDaemon { } export function createTestDaemon(adapter?: AgentAdapter): TestDaemon { - const runtime = makeDaemonRuntime(adapter); + const runtime = makeDaemonRuntime({ + ...(adapter !== undefined ? { adapter } : {}), + // Factory always sends a model (ADR 0013 §2); the fakes ignore it. + models: FAKE_AGENT_MODELS, + }); return { runtime, registry: serviceOf(runtime, RunRegistry), diff --git a/src/server/two-daemons.test.ts b/src/server/two-daemons.test.ts index 82ad6a8..5185d77 100644 --- a/src/server/two-daemons.test.ts +++ b/src/server/two-daemons.test.ts @@ -13,7 +13,7 @@ import { describe, expect, test } from "bun:test"; import { Schema } from "effect"; import { defineWorkflow } from "../workflow"; import { defineConfig } from "../config"; -import { createSlowFakeAdapter } from "../replay/adapter"; +import { createSlowFakeAdapter, fakeAgents } from "../replay/adapter"; import { startDaemon } from "./daemon"; import { isTerminal, type RunEvent } from "../events"; import type { DaemonHandle } from "./daemon"; @@ -98,7 +98,7 @@ describe("two daemons in one process (#38)", () => { dbPath: join(root, "daemon1.db"), port: 0, config: defineConfig({ - agent: { adapter }, + agent: fakeAgents(adapter), repo: { sshUrl: join(root, "seed-not-used"), identity: { name: "Factory", email: "factory@factory.test" }, @@ -115,7 +115,7 @@ describe("two daemons in one process (#38)", () => { dbPath: join(root, "daemon2.db"), port: 0, config: defineConfig({ - agent: { adapter }, + agent: fakeAgents(adapter), repo: { sshUrl: join(root, "seed-not-used"), identity: { name: "Factory", email: "factory@factory.test" }, diff --git a/src/web/lib/run-events.test.ts b/src/web/lib/run-events.test.ts index 9191fbc..9475540 100644 --- a/src/web/lib/run-events.test.ts +++ b/src/web/lib/run-events.test.ts @@ -55,6 +55,42 @@ describe("deriveSteps", () => { expect(step.sessionId).toBe("ses-1"); }); + test("an agent step carries its agent beside the model, and an old log has none (ADR 0013 §2)", () => { + const started = (stepId: string, agent: string | undefined): RunEvent => + event(stepId === "step-0" ? 1 : 2, { + _tag: "AgentStepStarted", + stepId, + name: stepId, + ...(agent !== undefined ? { agent } : {}), + model: "sonnet", + prompt: "p", + structured: false, + }); + const finished = event(3, { + _tag: "AgentStepFinished", + stepId: "step-0", + name: "step-0", + outcome: "completed", + chunkCount: 0, + durationMs: 1, + finalText: "", + }); + const steps = deriveSteps([ + STARTED, + started("step-0", "claude"), + started("step-1", undefined), + finished, + ]); + const agents = steps.map((step) => (step.kind === "agent" ? [step.agent, step.model] : [])); + // The finished step keeps the agent it started with. + expect(agents).toEqual([ + ["claude", "sonnet"], + [undefined, "sonnet"], + ]); + expect(summarizeEvent(started("step-0", "claude"))).toBe("step-0 · claude · sonnet"); + expect(summarizeEvent(started("step-1", undefined))).toBe("step-1 · sonnet"); + }); + test("an agent step takes usage from AgentStepFinished, not from the raw chunk", () => { // The numbers are step-0 of run-69780571 verbatim. The RUN_FINISHED chunk // is present and carries the adapter's under-reported `totalTokens: 3648`; diff --git a/src/web/lib/run-events.ts b/src/web/lib/run-events.ts index 76290f1..36a5b62 100644 --- a/src/web/lib/run-events.ts +++ b/src/web/lib/run-events.ts @@ -37,6 +37,8 @@ interface StepBase { export interface AgentStepView extends StepBase { readonly kind: "agent"; readonly stepId: string; + /** The agent the step ran on; absent in logs from before agents were selectable. */ + readonly agent: string | undefined; readonly model: string; readonly structured: boolean; readonly prompt: string; @@ -141,6 +143,7 @@ export function deriveSteps( durationMs: undefined, descriptor: live ? "streaming" : "killed mid-step", stepId: payload.stepId, + agent: payload.agent, model: payload.model, structured: payload.structured, prompt: payload.prompt, @@ -181,6 +184,7 @@ export function deriveSteps( durationMs: payload.durationMs, descriptor: `${payload.chunkCount} chunks`, stepId: payload.stepId, + agent: base?.agent, model: base?.model ?? "unknown", structured: base?.structured ?? false, prompt: base?.prompt ?? "", @@ -460,7 +464,9 @@ export function summarizeEvent(event: RunEvent): string { case "RunCancelled": return `cancelled · ${payload.durationMs}ms`; case "AgentStepStarted": - return `${payload.name} · ${payload.model}`; + return payload.agent !== undefined + ? `${payload.name} · ${payload.agent} · ${payload.model}` + : `${payload.name} · ${payload.model}`; case "AgentChunk": return payload.chunkType; case "AgentStepFinished": diff --git a/src/web/pages/run-detail.tsx b/src/web/pages/run-detail.tsx index fdd61e3..a77f3f3 100644 --- a/src/web/pages/run-detail.tsx +++ b/src/web/pages/run-detail.tsx @@ -648,6 +648,7 @@ function StepDetails({ case "agent": fields.push( fieldRow("stepId", step.stepId), + fieldRow("agent", step.agent ?? "—"), fieldRow("model", step.model), fieldRow("structured", String(step.structured)), fieldRow("outcome", ), diff --git a/src/workflow.dispatch.test.ts b/src/workflow.dispatch.test.ts index 3619842..66e4d58 100644 --- a/src/workflow.dispatch.test.ts +++ b/src/workflow.dispatch.test.ts @@ -18,7 +18,7 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { Schema } from "effect"; import type { RunEvent } from "./events"; -import { createSlowFakeAdapter } from "./replay/adapter"; +import { fakeAgents, createSlowFakeAdapter } from "./replay/adapter"; import { makeAgentRuntime } from "./runtime/agent-runtime"; import { startRun } from "./runtime/run"; import { defineWorkflow } from "./workflow"; @@ -57,7 +57,7 @@ describe("ctx.dispatch (issue #14)", () => { }, }); - const handle = startRun(parent, makeAgentRuntime(SLOW_ADAPTER), { + const handle = startRun(parent, makeAgentRuntime(fakeAgents(SLOW_ADAPTER)), { runId: "run-parent-1", dir: root, input: {}, @@ -93,22 +93,26 @@ describe("ctx.dispatch (issue #14)", () => { let childResult: Promise<{ outcome: string }> | undefined; - const handle = startRun(parent, makeAgentRuntime(SLOW_ADAPTER), { + const handle = startRun(parent, makeAgentRuntime(fakeAgents(SLOW_ADAPTER)), { runId: "run-parent-x", dir: root, input: {}, dispatch: (childWorkflow, input) => { void childWorkflow.id; childEvents = []; - const childRun = startRun(childWorkflow as never, makeAgentRuntime(SLOW_ADAPTER), { - runId: "run-child-x", - dir: root, - input, - parentRunId: "run-parent-x", - onEvent: (event) => { - childEvents.push(event); + const childRun = startRun( + childWorkflow as never, + makeAgentRuntime(fakeAgents(SLOW_ADAPTER)), + { + runId: "run-child-x", + dir: root, + input, + parentRunId: "run-parent-x", + onEvent: (event) => { + childEvents.push(event); + }, }, - }); + ); childResult = childRun.result; return Promise.resolve("run-child-x"); }, @@ -145,7 +149,7 @@ describe("ctx.dispatch (issue #14)", () => { run: async (ctx) => ctx.dispatch(numberedChild, { n: 1 }), }); - const handle = startRun(parent, makeAgentRuntime(SLOW_ADAPTER), { + const handle = startRun(parent, makeAgentRuntime(fakeAgents(SLOW_ADAPTER)), { runId: "run-no-daemon", dir: root, input: {}, diff --git a/src/workflow.ts b/src/workflow.ts index d443045..9980f0c 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -12,17 +12,29 @@ import { Schema } from "effect"; import type { ExecResult } from "./lib/exec"; +import type { AcpAgentKind } from "./runtime/acp-agents"; import type { WriteBackResult } from "./lib/writeback"; export { Schema }; +/** The coding agents a step can run on (ADR 0013). */ +export type AgentKind = AcpAgentKind; + /** - * Per-call agent options. Precedence for `model`/`permissionMode`: - * per-call option > workflow's `agent` default > runtime fallback. + * Per-call agent options. `agent` and `model` resolve separately (ADR 0013 + * §2, `runtime/agent-choice.ts`), most specific level first: this call > the + * run request or schedule > the workflow's `agent` default > the config's + * `agent.default` / `agent.models`. A model named below the level that chose + * the agent does not apply: `{ agent: "opencode" }` here runs on opencode's + * configured model even when the workflow pins a Claude model. + * + * There is no permission option: every permission ask is answered by the + * runtime (ADR 0013 §4). */ export interface AgentCallOptions { + readonly agent?: AgentKind; + /** Passed to the agent verbatim — its own id (`sonnet`, `opencode/big-pickle`). */ readonly model?: string; - readonly permissionMode?: "acceptEdits" | "default"; /** An Effect Schema. Converted to JSON Schema only at the adapter boundary. */ readonly output?: Schema.Codec; } @@ -127,9 +139,10 @@ export interface WorkflowCtx { ): Promise; } +/** A workflow's agent defaults: one level of `AgentCallOptions`' precedence chain. */ export interface WorkflowAgentDefaults { + readonly agent?: AgentKind; readonly model?: string; - readonly permissionMode?: "acceptEdits" | "default"; } /**