From 657b3e1ccdcebff0bd2a8abcad3e98b8be114b33 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 28 Mar 2026 01:10:05 +0700 Subject: [PATCH] feat(loop): add worker/verifier model selection and archive support Loop runs can now specify separate provider/model for worker and verifier agents (--provider, --model, --verify-provider, --verify-model). The --archive flag preserves agent conversation history after each iteration instead of destroying them. --- packages/cli/src/commands/loop/inspect.ts | 6 + packages/cli/src/commands/loop/run.ts | 15 ++ packages/cli/src/commands/loop/types.ts | 13 ++ .../server/src/server/agent/agent-manager.ts | 33 ++++ .../server/src/server/loop-service.test.ts | 152 ++++++++++++++++-- packages/server/src/server/loop-service.ts | 36 ++++- .../server/src/server/loop/rpc-schemas.ts | 16 +- packages/server/src/server/session.ts | 7 + 8 files changed, 259 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/loop/inspect.ts b/packages/cli/src/commands/loop/inspect.ts index 3b2afa922..8f571a0ee 100644 --- a/packages/cli/src/commands/loop/inspect.ts +++ b/packages/cli/src/commands/loop/inspect.ts @@ -37,9 +37,15 @@ function toRows(loop: LoopRecord): InspectRow[] { { key: "Status", value: loop.status }, { key: "Cwd", value: loop.cwd }, { key: "Provider", value: loop.provider }, + { key: "Model", value: loop.model ?? "null" }, + { key: "WorkerProvider", value: loop.workerProvider ?? "null" }, + { key: "WorkerModel", value: loop.workerModel ?? "null" }, + { key: "VerifierProvider", value: loop.verifierProvider ?? "null" }, + { key: "VerifierModel", value: loop.verifierModel ?? "null" }, { key: "Prompt", value: loop.prompt }, { key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" }, { key: "VerifyChecks", value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]" }, + { key: "Archive", value: String(loop.archive) }, { key: "SleepMs", value: String(loop.sleepMs) }, { key: "MaxIterations", value: loop.maxIterations === null ? "null" : String(loop.maxIterations) }, { key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) }, diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts index f40c3a634..4fa6a14d9 100644 --- a/packages/cli/src/commands/loop/run.ts +++ b/packages/cli/src/commands/loop/run.ts @@ -18,8 +18,13 @@ export interface LoopRunRow { } export interface LoopRunOptions extends CommandOptions { + provider?: "claude" | "codex" | "opencode"; + model?: string; + verifyProvider?: "claude" | "codex" | "opencode"; + verifyModel?: string; verify?: string; verifyCheck?: string[]; + archive?: boolean; name?: string; sleep?: string; maxIterations?: string; @@ -40,6 +45,10 @@ export function addLoopRunOptions(command: Command): Command { return command .description("Start a loop") .argument("", "Prompt for each fresh worker iteration") + .option("--provider ", "Default provider for worker and verifier agents") + .option("--model ", "Default model for worker and verifier agents") + .option("--verify-provider ", "Provider for the verifier agent") + .option("--verify-model ", "Model for the verifier agent") .option("--verify ", "Verifier agent prompt") .option( "--verify-check ", @@ -47,6 +56,7 @@ export function addLoopRunOptions(command: Command): Command { collectMultiple, [], ) + .option("--archive", "Archive worker and verifier agents after each iteration") .option("--name ", "Optional loop name") .option("--sleep ", "Delay between iterations (for example: 30s, 5m)") .option("--max-iterations ", "Maximum number of iterations") @@ -88,10 +98,15 @@ function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInpu return { prompt, cwd: process.cwd(), + ...(options.provider ? { provider: options.provider } : {}), + ...(options.model?.trim() ? { model: options.model.trim() } : {}), + ...(options.verifyProvider ? { verifierProvider: options.verifyProvider } : {}), + ...(options.verifyModel?.trim() ? { verifierModel: options.verifyModel.trim() } : {}), ...(verifyPrompt ? { verifyPrompt } : {}), ...(options.verifyCheck && options.verifyCheck.length > 0 ? { verifyChecks: options.verifyCheck } : {}), + ...(options.archive ? { archive: true } : {}), ...(options.name?.trim() ? { name: options.name.trim() } : {}), ...(options.sleep ? { sleepMs: parseDuration(options.sleep) } : {}), ...(options.maxIterations ? { maxIterations: parseMaxIterations(options.maxIterations) } : {}), diff --git a/packages/cli/src/commands/loop/types.ts b/packages/cli/src/commands/loop/types.ts index 9568f9e52..c951b3e95 100644 --- a/packages/cli/src/commands/loop/types.ts +++ b/packages/cli/src/commands/loop/types.ts @@ -46,8 +46,14 @@ export interface LoopRecord { prompt: string; cwd: string; provider: "claude" | "codex" | "opencode"; + model: string | null; + workerProvider: "claude" | "codex" | "opencode" | null; + workerModel: string | null; + verifierProvider: "claude" | "codex" | "opencode" | null; + verifierModel: string | null; verifyPrompt: string | null; verifyChecks: string[]; + archive: boolean; sleepMs: number; maxIterations: number | null; maxTimeMs: number | null; @@ -116,8 +122,15 @@ export interface LoopStopPayload { export interface LoopRunInput { prompt: string; cwd: string; + provider?: "claude" | "codex" | "opencode"; + model?: string; + workerProvider?: "claude" | "codex" | "opencode"; + workerModel?: string; + verifierProvider?: "claude" | "codex" | "opencode"; + verifierModel?: string; verifyPrompt?: string; verifyChecks?: string[]; + archive?: boolean; name?: string; sleepMs?: number; maxIterations?: number; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 436847586..58f8e5fee 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -860,6 +860,39 @@ export class AgentManager { this.logger.trace({ agentId }, "closeAgent: completed"); } + async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { + const agent = this.requireAgent(agentId); + if (!this.registry) { + throw new Error("Agent storage is not configured"); + } + + await this.registry.applySnapshot(agent, { + internal: agent.internal, + }); + const stored = await this.registry.get(agentId); + if (!stored) { + throw new Error(`Agent ${agentId} not found in storage after snapshot`); + } + + const archivedAt = new Date().toISOString(); + const normalizedStatus = + stored.lastStatus === "running" || stored.lastStatus === "initializing" + ? "idle" + : stored.lastStatus; + + await this.registry.upsert({ + ...stored, + archivedAt, + lastStatus: normalizedStatus, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }); + await this.closeAgent(agentId); + + return { archivedAt }; + } + async setAgentMode(agentId: string, modeId: string): Promise { const agent = this.requireAgent(agentId); await agent.session.setMode(modeId); diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 8c6cae22e..2f9a09e7c 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -19,6 +19,7 @@ import type { AgentSlashCommand, AgentRuntimeInfo, ListModelsOptions, + AgentProvider, } from "./agent/agent-sdk-types.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { AgentManager } from "./agent/agent-manager.js"; @@ -39,10 +40,15 @@ interface ScriptedAgentBehavior { } class ScriptedAgentClient implements AgentClient { - readonly provider = "claude" as const; + readonly provider: AgentProvider; readonly capabilities = TEST_CAPABILITIES; - constructor(private readonly behavior: ScriptedAgentBehavior) {} + constructor( + provider: AgentProvider, + private readonly behavior: ScriptedAgentBehavior, + ) { + this.provider = provider; + } async isAvailable(): Promise { return true; @@ -52,7 +58,7 @@ class ScriptedAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { - return new ScriptedAgentSession(config, this.behavior); + return new ScriptedAgentSession(config, this.provider, this.behavior); } async resumeSession( @@ -61,10 +67,11 @@ class ScriptedAgentClient implements AgentClient { ): Promise { return new ScriptedAgentSession( { - provider: "claude", + provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides, }, + this.provider, this.behavior, ); } @@ -75,7 +82,6 @@ class ScriptedAgentClient implements AgentClient { } class ScriptedAgentSession implements AgentSession { - readonly provider = "claude" as const; readonly capabilities = TEST_CAPABILITIES; readonly id = randomUUID(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); @@ -84,6 +90,7 @@ class ScriptedAgentSession implements AgentSession { constructor( private readonly config: AgentSessionConfig, + readonly provider: AgentProvider, private readonly behavior: ScriptedAgentBehavior, ) {} @@ -220,7 +227,7 @@ describe("LoopService", () => { const state = { workerRuns: 0 }; const manager = new AgentManager({ clients: { - claude: new ScriptedAgentClient({ + claude: new ScriptedAgentClient("claude", { async onRun({ config }) { state.workerRuns += 1; if (config.title?.includes("worker") && state.workerRuns >= 2) { @@ -247,9 +254,7 @@ describe("LoopService", () => { maxIterations: 3, }); - while ((await service.inspectLoop(loop.id)).status === "running") { - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForLoopCompletion(service, loop.id); const finalLoop = await service.inspectLoop(loop.id); expect(finalLoop.status).toBe("succeeded"); @@ -262,10 +267,125 @@ describe("LoopService", () => { expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); }); + test("uses worker and verifier provider-model settings when provided", async () => { + const workerConfigs: AgentSessionConfig[] = []; + const verifierConfigs: AgentSessionConfig[] = []; + const manager = new AgentManager({ + clients: { + codex: new ScriptedAgentClient("codex", { + async onRun({ config }) { + workerConfigs.push(config); + writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); + return "done"; + }, + }), + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + verifierConfigs.push(config); + return "{\"passed\":true,\"reason\":\"verified\"}"; + }, + }), + }, + registry: storage, + logger, + }); + const service = new LoopService({ paseoHome, agentManager: manager, logger }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Create done.txt", + cwd: workspaceDir, + provider: "codex", + model: "fallback-model", + workerModel: "gpt-5.4", + verifyPrompt: "Confirm that done.txt exists in the workspace.", + verifierProvider: "claude", + verifierModel: "sonnet", + maxIterations: 1, + }); + + await waitForLoopCompletion(service, loop.id); + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("succeeded"); + expect(finalLoop.provider).toBe("codex"); + expect(finalLoop.model).toBe("fallback-model"); + expect(finalLoop.workerProvider).toBeNull(); + expect(finalLoop.workerModel).toBe("gpt-5.4"); + expect(finalLoop.verifierProvider).toBe("claude"); + expect(finalLoop.verifierModel).toBe("sonnet"); + expect(workerConfigs).toHaveLength(1); + expect(workerConfigs[0]).toMatchObject({ + provider: "codex", + model: "gpt-5.4", + internal: true, + }); + expect(verifierConfigs).toHaveLength(1); + expect(verifierConfigs[0]).toMatchObject({ + provider: "claude", + model: "sonnet", + internal: true, + }); + }); + + test("archives worker and verifier agents after each iteration when requested", async () => { + const archivedAgentIds: string[] = []; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); + return "created done.txt"; + } + return "{\"passed\":true,\"reason\":\"done.txt exists\"}"; + }, + }), + }, + registry: storage, + logger, + }); + const archiveAgent = manager.archiveAgent.bind(manager); + manager.archiveAgent = async (agentId) => { + archivedAgentIds.push(agentId); + await archiveAgent(agentId); + }; + const service = new LoopService({ paseoHome, agentManager: manager, logger }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Create done.txt", + cwd: workspaceDir, + verifyPrompt: "Confirm that done.txt exists in the workspace.", + archive: true, + maxIterations: 1, + }); + + await waitForLoopCompletion(service, loop.id); + + const finalLoop = await service.inspectLoop(loop.id); + const iteration = finalLoop.iterations[0]; + expect(finalLoop.archive).toBe(true); + expect(iteration?.workerAgentId).toBeTruthy(); + expect(iteration?.verifierAgentId).toBeTruthy(); + expect(archivedAgentIds).toEqual([iteration!.workerAgentId!, iteration!.verifierAgentId!]); + await storage.flush(); + await expect(storage.get(iteration!.workerAgentId!)).resolves.toMatchObject({ + id: iteration!.workerAgentId!, + archivedAt: expect.any(String), + internal: true, + }); + await expect(storage.get(iteration!.verifierAgentId!)).resolves.toMatchObject({ + id: iteration!.verifierAgentId!, + archivedAt: expect.any(String), + internal: true, + }); + }); + test("uses verifier prompt when provided", async () => { const manager = new AgentManager({ clients: { - claude: new ScriptedAgentClient({ + claude: new ScriptedAgentClient("claude", { async onRun({ config }) { if (config.title?.includes("worker")) { await fsMkdir(workspaceDir); @@ -292,9 +412,7 @@ describe("LoopService", () => { maxIterations: 1, }); - while ((await service.inspectLoop(loop.id)).status === "running") { - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForLoopCompletion(service, loop.id); const finalLoop = await service.inspectLoop(loop.id); expect(finalLoop.status).toBe("succeeded"); @@ -313,7 +431,7 @@ describe("LoopService", () => { }); const manager = new AgentManager({ clients: { - claude: new ScriptedAgentClient({ + claude: new ScriptedAgentClient("claude", { async onRun({ config }) { if (config.title?.includes("worker")) { await blocker; @@ -354,3 +472,9 @@ async function fsMkdir(target: string): Promise { function pathExists(target: string): boolean { return existsSync(target); } + +async function waitForLoopCompletion(service: LoopService, loopId: string): Promise { + while ((await service.inspectLoop(loopId)).status === "running") { + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 45da03d16..81d48b351 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -72,8 +72,14 @@ const LoopRecordSchema = z.object({ prompt: z.string(), cwd: z.string(), provider: z.enum(["claude", "codex", "opencode"]), + model: z.string().nullable(), + workerProvider: z.enum(["claude", "codex", "opencode"]).nullable(), + workerModel: z.string().nullable(), + verifierProvider: z.enum(["claude", "codex", "opencode"]).nullable(), + verifierModel: z.string().nullable(), verifyPrompt: z.string().nullable(), verifyChecks: z.array(z.string()), + archive: z.boolean(), sleepMs: z.number().int().nonnegative(), maxIterations: z.number().int().positive().nullable(), maxTimeMs: z.number().int().positive().nullable(), @@ -104,8 +110,14 @@ export interface LoopRunOptions { prompt: string; cwd: string; provider?: AgentProvider; + model?: string; + workerProvider?: AgentProvider; + workerModel?: string; + verifierProvider?: AgentProvider; + verifierModel?: string; verifyPrompt?: string; verifyChecks?: string[]; + archive?: boolean; name?: string; sleepMs?: number; maxIterations?: number; @@ -364,8 +376,14 @@ export class LoopService { prompt, cwd: path.resolve(input.cwd), provider: input.provider ?? DEFAULT_LOOP_PROVIDER, + model: normalizePrompt(input.model, "model"), + workerProvider: input.workerProvider ?? null, + workerModel: normalizePrompt(input.workerModel, "workerModel"), + verifierProvider: input.verifierProvider ?? null, + verifierModel: normalizePrompt(input.verifierModel, "verifierModel"), verifyPrompt, verifyChecks, + archive: input.archive ?? false, sleepMs: ensureNonNegativeInteger(input.sleepMs, "sleepMs"), maxIterations: ensurePositiveInteger(input.maxIterations, "maxIterations"), maxTimeMs: ensurePositiveInteger(input.maxTimeMs, "maxTimeMs"), @@ -634,7 +652,11 @@ export class LoopService { loop.updatedAt = nowIso(); await this.persist(); try { - await this.options.agentManager.closeAgent(agent.id); + if (loop.archive) { + await this.options.agentManager.archiveAgent(agent.id); + } else { + await this.options.agentManager.closeAgent(agent.id); + } } catch { // Ignore cleanup errors for internal loop workers. } @@ -745,7 +767,11 @@ export class LoopService { loop.updatedAt = nowIso(); await this.persist(); try { - await this.options.agentManager.closeAgent(verifierAgent.id); + if (loop.archive) { + await this.options.agentManager.archiveAgent(verifierAgent.id); + } else { + await this.options.agentManager.closeAgent(verifierAgent.id); + } } catch { // Ignore cleanup errors for internal loop verifiers. } @@ -754,8 +780,9 @@ export class LoopService { private buildWorkerConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig { return { - provider: loop.provider, + provider: loop.workerProvider ?? loop.provider, cwd: loop.cwd, + model: loop.workerModel ?? loop.model ?? undefined, title: buildWorkerTitle(loop, iteration.index), internal: true, }; @@ -763,8 +790,9 @@ export class LoopService { private buildVerifierConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig { return { - provider: loop.provider, + provider: loop.verifierProvider ?? loop.provider, cwd: loop.cwd, + model: loop.verifierModel ?? loop.model ?? undefined, title: buildVerifierTitle(loop, iteration.index), internal: true, }; diff --git a/packages/server/src/server/loop/rpc-schemas.ts b/packages/server/src/server/loop/rpc-schemas.ts index a60f26df7..4644c9f18 100644 --- a/packages/server/src/server/loop/rpc-schemas.ts +++ b/packages/server/src/server/loop/rpc-schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AgentProviderSchema } from "../agent/provider-manifest.js"; export const LoopLogEntrySchema = z.object({ seq: z.number().int().positive(), @@ -45,9 +46,15 @@ export const LoopRecordSchema = z.object({ name: z.string().nullable(), prompt: z.string(), cwd: z.string(), - provider: z.enum(["claude", "codex", "opencode"]), + provider: AgentProviderSchema, + model: z.string().nullable(), + workerProvider: AgentProviderSchema.nullable(), + workerModel: z.string().nullable(), + verifierProvider: AgentProviderSchema.nullable(), + verifierModel: z.string().nullable(), verifyPrompt: z.string().nullable(), verifyChecks: z.array(z.string()), + archive: z.boolean(), sleepMs: z.number().int().nonnegative(), maxIterations: z.number().int().positive().nullable(), maxTimeMs: z.number().int().positive().nullable(), @@ -80,8 +87,15 @@ export const LoopRunRequestSchema = z.object({ requestId: z.string(), prompt: z.string().trim().min(1), cwd: z.string(), + provider: AgentProviderSchema.optional(), + model: z.string().trim().min(1).optional(), + workerProvider: AgentProviderSchema.optional(), + workerModel: z.string().trim().min(1).optional(), + verifierProvider: AgentProviderSchema.optional(), + verifierModel: z.string().trim().min(1).optional(), verifyPrompt: z.string().trim().min(1).optional(), verifyChecks: z.array(z.string().trim().min(1)).optional(), + archive: z.boolean().optional(), name: z.string().trim().min(1).optional(), sleepMs: z.number().int().nonnegative().optional(), maxIterations: z.number().int().positive().optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4ebf081fa..265a3b133 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7993,8 +7993,15 @@ export class Session { const loop = await this.loopService.runLoop({ prompt: request.prompt, cwd: request.cwd, + provider: request.provider, + model: request.model, + workerProvider: request.workerProvider, + workerModel: request.workerModel, + verifierProvider: request.verifierProvider, + verifierModel: request.verifierModel, verifyPrompt: request.verifyPrompt, verifyChecks: request.verifyChecks, + archive: request.archive, name: request.name, sleepMs: request.sleepMs, maxIterations: request.maxIterations,