feat: configure executor models

This commit is contained in:
liujing
2026-07-20 13:54:11 +08:00
parent c1a982a179
commit 4026f04b93
8 changed files with 396 additions and 14 deletions
+56 -8
View File
@@ -6,6 +6,7 @@
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type {
ExecutorConfig,
@@ -18,6 +19,10 @@ import { EXECUTOR_KINDS, VCS_KINDS } from "./workflow-types.js";
const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
function globalConfigPath(): string {
return path.join(os.homedir(), ".agent-workflow", "config.json");
}
const DEFAULTS = {
executor: "reasonix" as ExecutorKind,
maxCycles: 5,
@@ -44,6 +49,19 @@ export function loadProjectWorkflowConfig(repoRoot: string): WorkflowProjectConf
return validateProjectConfig(raw, filePath);
}
/** Load and parse ~/.agent-workflow/config.json. */
export function loadGlobalWorkflowConfig(): WorkflowProjectConfig | undefined {
const filePath = globalConfigPath();
if (!fs.existsSync(filePath)) return undefined;
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (err) {
throw new WorkflowConfigError(`Failed to parse global config: ${(err as Error).message}`);
}
return validateProjectConfig(raw, filePath);
}
function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig {
if (!raw || typeof raw !== "object") {
throw new WorkflowConfigError(`${filePath}: must be a JSON object`);
@@ -67,6 +85,20 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
if (obj.timeoutSeconds !== undefined && (typeof obj.timeoutSeconds !== "number" || !Number.isInteger(obj.timeoutSeconds) || obj.timeoutSeconds < 1)) {
throw new WorkflowConfigError(`${filePath}: timeoutSeconds must be a positive integer`);
}
if (obj.executors !== undefined) {
if (!obj.executors || typeof obj.executors !== "object") {
throw new WorkflowConfigError(`${filePath}: executors must be an object`);
}
for (const kind of EXECUTOR_KINDS) {
const exec = (obj.executors as Record<string, unknown>)[kind];
if (exec && typeof exec === "object") {
const model = (exec as Record<string, unknown>).model;
if (model !== undefined && (typeof model !== "string" || !model.trim())) {
throw new WorkflowConfigError(`${filePath}: executors.${kind}.model must be a non-empty string`);
}
}
}
}
// Prohibit secrets in config
const forbidden = ["apikey", "api_key", "token", "secret", "password", "credential"];
const lower = JSON.stringify(raw).toLowerCase();
@@ -82,42 +114,58 @@ export interface WorkflowCliOverrides {
executor?: ExecutorKind;
vcs?: VcsKind;
maxCycles?: number;
timeoutSeconds?: number;
}
/** Merge CLI > project config > defaults into a single resolved config. */
/** Merge CLI > project config > global config > defaults into a single resolved config. */
export function resolveWorkflowConfig(
repoRoot: string,
cli: WorkflowCliOverrides = {},
): ResolvedWorkflowConfig {
const project = loadProjectWorkflowConfig(repoRoot);
const globalConfig = loadGlobalWorkflowConfig();
const executor: ExecutorKind = cli.executor ?? (project?.defaultExecutor) ?? DEFAULTS.executor;
const executor: ExecutorKind = cli.executor ?? project?.defaultExecutor ?? globalConfig?.defaultExecutor ?? DEFAULTS.executor;
if (!EXECUTOR_KINDS.includes(executor)) {
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`);
}
// VCS selection precedence: CLI --vcs, then project defaultVcs (unless "auto"), then auto-detect
// VCS selection precedence: CLI --vcs, then project defaultVcs, then global defaultVcs.
// An explicit project value of "auto" overrides a global value; "auto" means fall through to detection.
let vcs: VcsKind | undefined;
if (cli.vcs) {
if (!VCS_KINDS.includes(cli.vcs)) {
throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`);
}
vcs = cli.vcs;
} else if (project?.defaultVcs && project.defaultVcs !== "auto") {
vcs = project.defaultVcs as VcsKind;
} else if (project?.defaultVcs !== undefined) {
if (project.defaultVcs !== "auto") {
vcs = project.defaultVcs as VcsKind;
}
} else if (globalConfig?.defaultVcs && globalConfig.defaultVcs !== "auto") {
vcs = globalConfig.defaultVcs as VcsKind;
}
// If vcs is still undefined, caller will auto-detect
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? DEFAULTS.maxCycles;
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? globalConfig?.maxCycles ?? DEFAULTS.maxCycles;
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new WorkflowConfigError(`maxCycles must be a positive integer, got: ${maxCycles}`);
}
const timeoutSeconds = project?.timeoutSeconds ?? DEFAULTS.timeoutSeconds;
const timeoutSeconds = cli.timeoutSeconds ?? project?.timeoutSeconds ?? globalConfig?.timeoutSeconds ?? DEFAULTS.timeoutSeconds;
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
throw new WorkflowConfigError(`timeoutSeconds must be a positive integer, got: ${timeoutSeconds}`);
}
const globalExecutorConfig = (globalConfig?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {};
const projectExecutorConfig = (project?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {};
const executorConfig: ExecutorConfig = {
...((project?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {}),
...globalExecutorConfig,
...projectExecutorConfig,
};
if (executorConfig.model !== undefined && (typeof executorConfig.model !== "string" || !executorConfig.model.trim())) {
throw new WorkflowConfigError(`Executor "${executor}" model must be a non-empty string`);
}
return {
executor,
+65 -1
View File
@@ -564,6 +564,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
});
state.executorConfig = config.executorConfig;
state.timeoutSeconds = config.timeoutSeconds;
state.configSourceRoot = configSourceRoot ?? repoRoot;
state.enginePid = process.pid;
state.status = "executing";
if (codexThreadId) {
@@ -595,6 +596,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
maxCycles: config.maxCycles,
vcsKind,
vcsBaseline,
...(config.executorConfig.model ? { model: config.executorConfig.model } : {}),
...(state.taskId ? { taskId: state.taskId, taskSessionReused: state.taskSessionReused } : {}),
},
});
@@ -630,7 +632,11 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
runId,
timestamp: nowIso(),
cycleIndex,
data: { executor: config.executor, sessionMode: taskSessionHandle ? "task_reuse" : "new" },
data: {
executor: config.executor,
sessionMode: taskSessionHandle ? "task_reuse" : "new",
...(config.executorConfig.model ? { model: config.executorConfig.model } : {}),
},
});
if (taskSessionHandle) {
appendEvent(dir, {
@@ -698,6 +704,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
exitCode: execResult.exitCode,
durationMs: execResult.durationMs,
failed: execResult.failed,
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}),
},
});
@@ -1069,9 +1076,48 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
});
}
applyReloadedExecutorModel(state, dir, nextCycle);
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
}
function reloadExecutorModel(state: WorkflowState): string | undefined {
// Fix the executor kind to the one frozen at run start so a changed defaultExecutor
// in the config cannot redirect the model lookup to a different executor.
const config = resolveWorkflowConfig(state.configSourceRoot ?? state.repoRoot, { executor: state.executor });
return config.executorConfig.model;
}
/**
* Reload the executor model from the latest config and apply it to the run state.
* Other executor settings remain the snapshot taken at run start.
* On invalid config, transition to blocked before the executor launches.
*/
function applyReloadedExecutorModel(
state: WorkflowState,
dir: string,
cycleIndex: number,
): void {
let reloadedModel: string | undefined;
try {
reloadedModel = reloadExecutorModel(state);
} catch (err) {
transitionToTerminal(
state,
dir,
"blocked",
`Executor configuration is invalid: ${(err as Error).message}`,
{ cycleIndex },
);
throw new WorkflowEngineError(`Cannot resume: ${(err as Error).message}`);
}
if (reloadedModel !== undefined) {
state.executorConfig = { ...(state.executorConfig ?? {}), model: reloadedModel };
} else {
state.executorConfig = { ...(state.executorConfig ?? {}) };
delete state.executorConfig.model;
}
}
async function resumeExecutorCycle(
state: WorkflowState,
dir: string,
@@ -1098,6 +1144,18 @@ async function resumeExecutorCycle(
registerAttemptLog(dir, cycleRecord, execLogPath);
writeState(dir, state); // persist so retry can discover the log
appendEvent(dir, {
kind: "executor_started",
runId: state.runId,
timestamp: nowIso(),
cycleIndex,
data: {
executor: state.executor,
sessionMode: "resume",
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
},
});
let execResult;
try {
execResult = await adapter.resume({
@@ -1145,6 +1203,7 @@ async function resumeExecutorCycle(
exitCode: execResult.exitCode,
durationMs: execResult.durationMs,
failed: execResult.failed,
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}),
},
});
@@ -1404,6 +1463,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
&& (state.stopDescription?.startsWith("Executor exited with failure:") === true
|| state.stopDescription?.startsWith("Executor failed to resume:") === true
|| state.stopDescription?.startsWith("Executor resume failed:") === true
|| state.stopDescription?.startsWith("Executor configuration is invalid:") === true
|| state.stopDescription === "No session handle available for resume."));
if (!retryableFailure) {
throw new WorkflowEngineError(
@@ -1519,6 +1579,8 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
state.updatedAt = nowIso();
writeState(dir, state);
applyReloadedExecutorModel(state, dir, state.currentCycle);
if (opts.sessionId) {
appendEvent(dir, {
kind: "executor_session_rebound",
@@ -1555,6 +1617,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
data: {
executor: state.executor,
recoveryMode,
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
},
});
@@ -1730,6 +1793,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
// 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion)
try {
applyReloadedExecutorModel(state, dir, nextCycle);
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
} catch (err) {
// resumeExecutorCycle already handles errors and releases lock
+40
View File
@@ -162,6 +162,46 @@ touch '${sessionFile}.meta'
expect(result.sessionHandle?.kind).toBe("reasonix");
});
it("start passes --model when configured", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const adapter = createExecutorAdapter("reasonix");
await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin, model: "custom-reasonix-model" },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--model");
expect(capturedArgs).toContain("custom-reasonix-model");
});
it("resume passes --model when configured", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const sessionFile = path.join(tmpDir, "session.jsonl");
fs.writeFileSync(sessionFile, "{}\n");
const adapter = createExecutorAdapter("reasonix");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "Fix null pointer at src/index.ts",
handle: { kind: "reasonix", sessionFilePath: sessionFile },
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin, model: "resume-model" },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--model");
expect(capturedArgs).toContain("resume-model");
});
it("resume sends a complete continued plan for task-level reuse", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
+8 -1
View File
@@ -568,7 +568,12 @@ class ReasonixAdapter implements ExecutorAdapter {
const snapshotDirs = [projectsDir];
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot));
const args: string[] = ["run", "--dir", opts.repoRoot, prompt];
const args: string[] = [
"run",
"--dir", opts.repoRoot,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
@@ -619,9 +624,11 @@ class ReasonixAdapter implements ExecutorAdapter {
"run",
"--dir", opts.repoRoot,
"--resume", handle.sessionFilePath,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
+210 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadProjectWorkflowConfig } from "./workflow-config.js";
import { loadProjectWorkflowConfig, resolveWorkflowConfig } from "./workflow-config.js";
import {
WorkflowLockError,
acquireLock,
@@ -97,6 +97,104 @@ describe("Project workflow config", () => {
});
});
describe("Resolved workflow config", () => {
let tmpDir: string;
let repoRoot: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-resolve-test-"));
repoRoot = path.join(tmpDir, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
originalHome = process.env.HOME;
process.env.HOME = tmpDir;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeGlobalConfig(config: unknown): void {
const globalDir = path.join(tmpDir, ".agent-workflow");
fs.mkdirSync(globalDir, { recursive: true });
fs.writeFileSync(path.join(globalDir, "config.json"), JSON.stringify(config));
}
function writeProjectConfig(config: unknown): void {
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify(config));
}
it("merges global < project < CLI per field", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "agy",
defaultVcs: "none",
maxCycles: 10,
timeoutSeconds: 999,
executors: {
reasonix: { binary: "global-reasonix", model: "global-model" },
},
});
writeProjectConfig({
version: "1",
defaultExecutor: "reasonix",
maxCycles: 3,
executors: {
reasonix: { model: "project-model" },
},
});
const config = resolveWorkflowConfig(repoRoot, { maxCycles: 4 });
expect(config.executor).toBe("reasonix");
expect(config.maxCycles).toBe(4);
expect(config.timeoutSeconds).toBe(999);
expect(config.vcs).toBe("none");
expect(config.executorConfig).toMatchObject({
binary: "global-reasonix",
model: "project-model",
});
});
it("treats explicit project defaultVcs auto as an override that suppresses global defaultVcs", () => {
writeGlobalConfig({
version: "1",
defaultVcs: "git",
});
writeProjectConfig({
version: "1",
defaultVcs: "auto",
});
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.vcs).toBeUndefined();
});
it("uses global defaults when project config is absent", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "claude",
executors: {
claude: { model: "claude-sonnet" },
},
});
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.executor).toBe("claude");
expect(config.executorConfig.model).toBe("claude-sonnet");
});
it("rejects invalid model values", () => {
writeProjectConfig({
version: "1",
defaultExecutor: "reasonix",
executors: { reasonix: { model: "" } },
});
expect(() => resolveWorkflowConfig(repoRoot, {})).toThrow(/non-empty string/);
});
});
describe("Retry-execute recovery", () => {
let tmpDir: string;
let repoRoot: string;
@@ -267,6 +365,117 @@ echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}'
.toBe("initial_continuation");
});
it("blocks before executor launch when reloaded model config is invalid, then recovers after correction", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
const runId = "invalid-model-retry";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const sessionFile = path.join(dir, "session.jsonl");
fs.writeFileSync(sessionFile, '{"type":"session"}');
const configRoot = path.join(tmpDir, "config-root");
fs.mkdirSync(configRoot, { recursive: true });
fs.writeFileSync(
path.join(configRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", executors: { reasonix: { model: "" } } }),
);
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: plan(),
executor: "reasonix",
maxCycles: 3,
});
state.status = "blocked";
state.stopDescription = "Executor exited with failure: test error";
state.configSourceRoot = configRoot;
state.cycles = [{
cycleIndex: 1,
startedAt: new Date().toISOString(),
executorLogFile: "cycle-1-exec.log",
executorAttemptLogs: ["cycle-1-exec.log"],
}];
state.sessionHandle = { kind: "reasonix", sessionFilePath: sessionFile };
state.executorConfig = { binary: writeFakeExecutor(sessionFile) };
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed");
writeState(dir, state);
await expect(retryExecuteWorkflow({ runId })).rejects.toThrow(/non-empty string/);
let recovered = readState(dir)!;
expect(recovered.status).toBe("blocked");
expect(recovered.stopDescription).toMatch(/Executor configuration is invalid:/);
// Fix the config and retry again.
fs.writeFileSync(
path.join(configRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", executors: { reasonix: { model: "fixed-model" } } }),
);
await retryExecuteWorkflow({ runId });
recovered = readState(dir)!;
expect(recovered.status).toBe("awaiting_review");
expect(recovered.executorConfig?.model).toBe("fixed-model");
const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8")
.trim().split("\n").map((line) => JSON.parse(line));
expect(events.find((event) => event.kind === "executor_started")?.data.model)
.toBe("fixed-model");
expect(events.find((event) => event.kind === "executor_retried")?.data.model)
.toBe("fixed-model");
});
it("reloads model for the run's frozen executor even when defaultExecutor changes", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
const runId = "frozen-executor-model";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const sessionFile = path.join(dir, "session.jsonl");
fs.writeFileSync(sessionFile, '{"type":"session"}');
const configRoot = path.join(tmpDir, "frozen-config-root");
fs.mkdirSync(configRoot, { recursive: true });
fs.writeFileSync(
path.join(configRoot, "agent-workflow.json"),
JSON.stringify({
version: "1",
defaultExecutor: "claude",
executors: {
claude: { model: "claude-other" },
reasonix: { model: "frozen-reasonix-model" },
},
}),
);
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: plan(),
executor: "reasonix",
maxCycles: 3,
});
state.status = "blocked";
state.stopDescription = "Executor exited with failure: test error";
state.configSourceRoot = configRoot;
state.cycles = [{
cycleIndex: 1,
startedAt: new Date().toISOString(),
executorLogFile: "cycle-1-exec.log",
executorAttemptLogs: ["cycle-1-exec.log"],
}];
state.sessionHandle = { kind: "reasonix", sessionFilePath: sessionFile };
state.executorConfig = { binary: writeFakeExecutor(sessionFile) };
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed");
writeState(dir, state);
await retryExecuteWorkflow({ runId });
const recovered = readState(dir)!;
expect(recovered.status).toBe("awaiting_review");
expect(recovered.executor).toBe("reasonix");
expect(recovered.executorConfig?.model).toBe("frozen-reasonix-model");
});
it("recovers a missing Reasonix handle for a blocked fix cycle", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
+2
View File
@@ -204,6 +204,8 @@ export interface WorkflowState {
vcsBaseline?: VcsBaseline;
/** VCS kind detected or specified at start. */
vcsKind?: VcsKind;
/** Directory used to load agent-workflow.json; persisted for per-cycle model reloads. */
configSourceRoot?: string;
executor: ExecutorKind;
executorConfig?: ExecutorConfig;
/** Consecutive executor inactivity allowed before the host aborts the attempt. */