From 2b587612d5ac2fff19c26dab5fdcfc1006292d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 11 May 2026 08:51:35 +0000 Subject: [PATCH] refactor: replace maxRounds with supervisor check interval Removes maxRounds as a hard stop limit from the entire stack. The supervisor (already configured via workflow.yaml supervisorInterval) is now the sole termination authority. Changes across 27 files in 11 packages: - workflow-protocol: StartStep.meta is now empty, StartNodePayload drops maxRounds - workflow-cas: isStartPayload no longer checks maxRounds - workflow-execute: engine, worker, fork-thread all stripped of maxRounds plumbing - cli-workflow: --max-rounds flag removed from CLI and HTTP API - workflow-runtime: build-context and create-workflow no longer reference maxRounds - workflow-dashboard: UI no longer sends maxRounds - workflow-template-develop/solve-issue: moderator no longer checks rounds remaining - All tests updated Fixes #185 --- .../cli-workflow/__tests__/gc-cli.test.ts | 6 +- .../src/commands/serve/routes-thread.ts | 3 +- .../src/commands/thread/dispatch.ts | 9 +-- .../cli-workflow/src/commands/thread/run.ts | 3 +- packages/cli-workflow/src/run-argv.ts | 29 ++-------- packages/cli-workflow/src/skill.ts | 6 -- .../__tests__/create-llm-adapter.test.ts | 2 +- packages/workflow-cas/src/nodes.ts | 1 - packages/workflow-dashboard/src/api.ts | 3 +- packages/workflow-dashboard/src/app.tsx | 2 +- .../src/components/markdown.tsx | 6 +- .../src/components/record-card.tsx | 2 +- .../src/components/run-dialog.tsx | 26 +-------- .../workflow-execute/__tests__/engine.test.ts | 9 +-- .../__tests__/gc-mark-sweep.test.ts | 1 - .../workflow-execute/src/engine/engine.ts | 35 +----------- .../src/engine/fork-thread.ts | 7 +-- packages/workflow-execute/src/engine/types.ts | 3 +- .../workflow-execute/src/engine/worker.ts | 8 +-- .../src/extract/extract-fn.ts | 6 +- .../workflow-execute/src/workflow-as-agent.ts | 1 - .../__tests__/moderator-table.test.ts | 2 +- packages/workflow-protocol/src/cas-types.ts | 1 - packages/workflow-protocol/src/types.ts | 2 +- .../__tests__/build-context.test.ts | 8 +-- .../workflow-runtime/src/build-context.ts | 4 +- .../workflow-runtime/src/create-workflow.ts | 15 ++--- .../__tests__/develop-template.test.ts | 55 ++++++++----------- .../src/moderator.ts | 4 +- .../src/roles/coder.ts | 6 +- .../__tests__/solve-issue-template.test.ts | 21 ++++--- .../__tests__/build-agent-prompt.test.ts | 2 +- 32 files changed, 84 insertions(+), 204 deletions(-) diff --git a/packages/cli-workflow/__tests__/gc-cli.test.ts b/packages/cli-workflow/__tests__/gc-cli.test.ts index 284586d..4ba455c 100644 --- a/packages/cli-workflow/__tests__/gc-cli.test.ts +++ b/packages/cli-workflow/__tests__/gc-cli.test.ts @@ -45,7 +45,7 @@ describe("gc cli and garbageCollectCas", () => { { name: "demo", hash: bundleHash, - maxRounds: 5, + depth: 0, }, promptHash, @@ -100,7 +100,7 @@ describe("gc cli and garbageCollectCas", () => { { name: "demo", hash: bundleHash, - maxRounds: 5, + depth: 0, }, promptHash, @@ -135,7 +135,7 @@ describe("gc cli and garbageCollectCas", () => { { name: "demo", hash: bundleHash, - maxRounds: 5, + depth: 0, }, promptHash, diff --git a/packages/cli-workflow/src/commands/serve/routes-thread.ts b/packages/cli-workflow/src/commands/serve/routes-thread.ts index 14294b1..c8879ee 100644 --- a/packages/cli-workflow/src/commands/serve/routes-thread.ts +++ b/packages/cli-workflow/src/commands/serve/routes-thread.ts @@ -156,13 +156,12 @@ export function createThreadRoutes(storageRoot: string): Hono { const name = body.workflow; const prompt = body.prompt; - const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10; if (typeof name !== "string" || typeof prompt !== "string") { return c.json({ error: "workflow (string) and prompt (string) are required" }, 400); } - const result = await cmdRun(storageRoot, name, prompt, maxRounds); + const result = await cmdRun(storageRoot, name, prompt); if (!result.ok) { return c.json({ error: result.error }, 400); } diff --git a/packages/cli-workflow/src/commands/thread/dispatch.ts b/packages/cli-workflow/src/commands/thread/dispatch.ts index f28e51f..ee48bd5 100644 --- a/packages/cli-workflow/src/commands/thread/dispatch.ts +++ b/packages/cli-workflow/src/commands/thread/dispatch.ts @@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise< return 1; } - const result = await cmdRun( - storageRoot, - parsed.value.name, - parsed.value.prompt, - parsed.value.maxRounds, - ); + const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt); if (!result.ok) { printCliError(result.error); return 1; @@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise export const THREAD_SUBCOMMAND_TABLE: Record = { run: { handler: dispatchRun, - args: " [--prompt ] [--max-rounds N]", + args: " [--prompt ]", description: "Start a new thread executing a workflow", }, list: { diff --git a/packages/cli-workflow/src/commands/thread/run.ts b/packages/cli-workflow/src/commands/thread/run.ts index 87e8e11..cf9c29b 100644 --- a/packages/cli-workflow/src/commands/thread/run.ts +++ b/packages/cli-workflow/src/commands/thread/run.ts @@ -10,7 +10,6 @@ export async function cmdRun( storageRoot: string, name: string, prompt: string, - maxRounds: number, ): Promise> { const nameOk = validateCliWorkflowName(name); if (!nameOk.ok) { @@ -41,7 +40,7 @@ export async function cmdRun( threadId, workflowName: name, prompt, - options: { maxRounds, depth: 0 }, + options: { depth: 0 }, }, { awaitResponseLine: false }, ); diff --git a/packages/cli-workflow/src/run-argv.ts b/packages/cli-workflow/src/run-argv.ts index e520211..101c77e 100644 --- a/packages/cli-workflow/src/run-argv.ts +++ b/packages/cli-workflow/src/run-argv.ts @@ -3,12 +3,12 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol"; export type ParsedRunArgv = { name: string; prompt: string; - maxRounds: number; }; -type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number }; - -function parseFlagAt(argv: string[], index: number): Result | null { +function parseFlagAt( + argv: string[], + index: number, +): Result<{ kind: "prompt"; value: string }, string> | null { const flag = argv[index]; if (flag === "--prompt") { const value = argv[index + 1]; @@ -17,24 +17,12 @@ function parseFlagAt(argv: string[], index: number): Result | nu } return ok({ kind: "prompt", value }); } - if (flag === "--max-rounds") { - const value = argv[index + 1]; - if (value === undefined) { - return err("missing value for --max-rounds"); - } - const n = Number(value); - if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { - return err("--max-rounds must be a non-negative integer"); - } - return ok({ kind: "max-rounds", value: n }); - } return null; } export function parseRunArgv(argv: string[]): Result { let name: string | undefined; let prompt = ""; - let maxRounds = 10; let i = 0; const first = argv[0]; @@ -54,12 +42,7 @@ export function parseRunArgv(argv: string[]): Result { } const flag = parsed.value; - if (flag.kind === "prompt") { - prompt = flag.value; - i += 2; - continue; - } - maxRounds = flag.value; + prompt = flag.value; i += 2; } @@ -67,5 +50,5 @@ export function parseRunArgv(argv: string[]): Result { return err("run requires "); } - return ok({ name, prompt, maxRounds }); + return ok({ name, prompt }); } diff --git a/packages/cli-workflow/src/skill.ts b/packages/cli-workflow/src/skill.ts index b59b198..047a064 100644 --- a/packages/cli-workflow/src/skill.ts +++ b/packages/cli-workflow/src/skill.ts @@ -107,12 +107,6 @@ ${commandSections.join("\n\n")} | \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) | | \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) | -## Defaults - -| Setting | CLI | HTTP API | -|---------|-----|----------| -| \`maxRounds\` | 10 | 10 | - ## Exit Codes | Code | Meaning | diff --git a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts index 387812e..c40f8c7 100644 --- a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts +++ b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts @@ -8,7 +8,7 @@ function makeCtx(userContent: string): AgentContext { start: { role: START, content: userContent, - meta: { maxRounds: 10 }, + meta: {}, timestamp: 1, }, depth: 0, diff --git a/packages/workflow-cas/src/nodes.ts b/packages/workflow-cas/src/nodes.ts index 8a2a7f5..e0b62c3 100644 --- a/packages/workflow-cas/src/nodes.ts +++ b/packages/workflow-cas/src/nodes.ts @@ -21,7 +21,6 @@ function isStartPayload(value: unknown): value is StartNodePayload { return ( typeof value.name === "string" && typeof value.hash === "string" && - typeof value.maxRounds === "number" && typeof value.depth === "number" ); } diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts index 64211ff..d3b1714 100644 --- a/packages/workflow-dashboard/src/api.ts +++ b/packages/workflow-dashboard/src/api.ts @@ -133,9 +133,8 @@ export function runThread( agent: string, workflow: string, prompt: string, - maxRounds: number = 10, ): Promise<{ threadId: string }> { - return postJson(agentBase(agent), "/threads", { workflow, prompt, maxRounds }); + return postJson(agentBase(agent), "/threads", { workflow, prompt }); } export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> { diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx index 6f58391..846da5f 100644 --- a/packages/workflow-dashboard/src/app.tsx +++ b/packages/workflow-dashboard/src/app.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { hasApiKey, clearApiKey } from "./api.ts"; +import { clearApiKey, hasApiKey } from "./api.ts"; import { LoginPage } from "./components/login.tsx"; import { RunDialog } from "./components/run-dialog.tsx"; import { Sidebar } from "./components/sidebar.tsx"; diff --git a/packages/workflow-dashboard/src/components/markdown.tsx b/packages/workflow-dashboard/src/components/markdown.tsx index 97e2b31..d369e39 100644 --- a/packages/workflow-dashboard/src/components/markdown.tsx +++ b/packages/workflow-dashboard/src/components/markdown.tsx @@ -1,10 +1,10 @@ -import ReactMarkdown from "react-markdown"; import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; import { - createHighlighter, - type HighlighterGeneric, type BundledLanguage, type BundledTheme, + createHighlighter, + type HighlighterGeneric, } from "shiki"; let highlighterPromise: Promise> | null = null; diff --git a/packages/workflow-dashboard/src/components/record-card.tsx b/packages/workflow-dashboard/src/components/record-card.tsx index 677ddc1..da51773 100644 --- a/packages/workflow-dashboard/src/components/record-card.tsx +++ b/packages/workflow-dashboard/src/components/record-card.tsx @@ -1,4 +1,4 @@ -import type { ThreadStartRecord, RoleRecord, WorkflowResultRecord, ThreadRecord } from "../api.ts"; +import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts"; import { Markdown } from "./markdown.tsx"; const ROLE_COLORS: Record = { diff --git a/packages/workflow-dashboard/src/components/run-dialog.tsx b/packages/workflow-dashboard/src/components/run-dialog.tsx index 9a53dc3..dca976a 100644 --- a/packages/workflow-dashboard/src/components/run-dialog.tsx +++ b/packages/workflow-dashboard/src/components/run-dialog.tsx @@ -12,7 +12,6 @@ export function RunDialog({ agent, onClose, onCreated }: Props) { const workflows = useFetch(() => listWorkflows(agent), [agent]); const [workflow, setWorkflow] = useState(""); const [prompt, setPrompt] = useState(""); - const [maxRounds, setMaxRounds] = useState(10); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -22,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) { setSubmitting(true); setError(null); try { - const result = await runThread(agent, workflow, prompt, maxRounds); + const result = await runThread(agent, workflow, prompt); onCreated(result.threadId); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -91,29 +90,6 @@ export function RunDialog({ agent, onClose, onCreated }: Props) { placeholder="Enter the task prompt..." /> -
- - setMaxRounds(Number(e.target.value))} - min={1} - max={100} - className="w-24 px-3 py-2 rounded border text-sm" - style={{ - background: "var(--color-bg)", - borderColor: "var(--color-border)", - color: "var(--color-text)", - }} - /> -
{error && (

{error} diff --git a/packages/workflow-execute/__tests__/engine.test.ts b/packages/workflow-execute/__tests__/engine.test.ts index 7d6cef9..831e548 100644 --- a/packages/workflow-execute/__tests__/engine.test.ts +++ b/packages/workflow-execute/__tests__/engine.test.ts @@ -34,7 +34,6 @@ function noLogger(): (tag: string, content: string) => void { function makeOptions(overrides: Partial): ExecuteThreadOptions { return { - maxRounds: 5, depth: 0, signal: new AbortController().signal, awaitAfterEachYield: async () => {}, @@ -107,7 +106,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => { wf, "demo", { prompt: "hello", steps: [] }, - makeOptions({ storageRoot, maxRounds: 5 }), + makeOptions({ storageRoot }), io, noLogger(), ); @@ -127,7 +126,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => { expect(startNode.type).toBe("start"); expect((startNode.payload as Record).name).toBe("demo"); expect((startNode.payload as Record).hash).toBe(bundleHash); - expect((startNode.payload as Record).maxRounds).toBe(5); const refs = startNode.refs as string[]; expect(refs.length).toBe(1); @@ -164,7 +162,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => { const opts = makeOptions({ storageRoot, - maxRounds: 5, awaitAfterEachYield: async () => { const text = await readFile(join(bundleDir, "threads.json"), "utf8"); const parsed = JSON.parse(text) as Record; @@ -228,7 +225,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => { wf, "demo", { prompt: "p", steps: [] }, - makeOptions({ storageRoot, maxRounds: 5 }), + makeOptions({ storageRoot }), io, noLogger(), ); @@ -279,7 +276,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => { wf, "demo", { prompt: "p", steps: [] }, - makeOptions({ storageRoot, maxRounds: 5 }), + makeOptions({ storageRoot }), io, noLogger(), ); diff --git a/packages/workflow-execute/__tests__/gc-mark-sweep.test.ts b/packages/workflow-execute/__tests__/gc-mark-sweep.test.ts index b9bbab6..96afe02 100644 --- a/packages/workflow-execute/__tests__/gc-mark-sweep.test.ts +++ b/packages/workflow-execute/__tests__/gc-mark-sweep.test.ts @@ -45,7 +45,6 @@ describe("garbageCollectCas (mark-and-sweep)", () => { { name: "demo", hash: bundleHash, - maxRounds: 5, depth: 0, }, promptHash, diff --git a/packages/workflow-execute/src/engine/engine.ts b/packages/workflow-execute/src/engine/engine.ts index aca0ce1..c2222a9 100644 --- a/packages/workflow-execute/src/engine/engine.ts +++ b/packages/workflow-execute/src/engine/engine.ts @@ -284,21 +284,6 @@ async function driveWorkflowGenerator(params: { }); } - if (written >= executeOptions.maxRounds) { - logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`); - return await finalizeThread({ - cas, - bundleDir, - threadId, - startHash, - chain, - completion: { - returnCode: 0, - summary: `completed: reached maxRounds (${executeOptions.maxRounds})`, - }, - }); - } - const iterResult = await gen.next(); if (iterResult.done) { @@ -383,7 +368,7 @@ async function driveWorkflowGenerator(params: { * Persistence layout (RFC v3 — CAS-based thread storage): * - Thread chain is written as immutable CAS blobs: a single {@link StartNode} * plus one {@link StateNode} per role step (including a final `__end__` - * state on completion / abort / `maxRounds`). + * state on completion / abort). * - The active thread head is published in `/threads.json`; on * completion it is removed and a record is appended to * `/history/{YYYY-MM-DD}.jsonl`. @@ -433,7 +418,6 @@ export async function executeThread( { name: workflowName, hash: io.hash, - maxRounds: options.maxRounds, depth: options.depth, }, promptHash, @@ -475,21 +459,6 @@ export async function executeThread( const nowMs = Date.now(); - if (options.maxRounds <= 0) { - logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`); - return await finalizeThread({ - cas: io.cas, - bundleDir, - threadId: io.threadId, - startHash, - chain, - completion: { - returnCode: 0, - summary: `completed: reached maxRounds (${options.maxRounds})`, - }, - }); - } - const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas); if (!registryRuntime.ok) { throw new Error(registryRuntime.error); @@ -501,7 +470,7 @@ export async function executeThread( start: { role: START, content: input.prompt, - meta: { maxRounds: options.maxRounds }, + meta: {}, timestamp: nowMs, }, steps: input.steps.map((out, i) => ({ diff --git a/packages/workflow-execute/src/engine/fork-thread.ts b/packages/workflow-execute/src/engine/fork-thread.ts index 477734d..c01b859 100644 --- a/packages/workflow-execute/src/engine/fork-thread.ts +++ b/packages/workflow-execute/src/engine/fork-thread.ts @@ -104,9 +104,7 @@ async function readPromptText(cas: CasStore, promptHash: string): Promise -> { +}): Promise> { const yamlText = await params.cas.get(params.startHash); if (yamlText === null) { return err(`start node missing in CAS: ${params.startHash}`); @@ -127,7 +125,6 @@ async function readStartWorkflowIdentity(params: { const p = parsed.node.payload; return ok({ workflowName: p.name, - maxRounds: p.maxRounds, depth: p.depth, prompt: prompt.value, }); @@ -317,7 +314,7 @@ export async function prepareCasFork(params: { hash: params.bundleHash, sourceThreadId: params.sourceThreadId, prompt: id.value.prompt, - runOptions: { maxRounds: id.value.maxRounds, depth: id.value.depth }, + runOptions: { depth: id.value.depth }, steps, stepTimestamps, forkContinuation: cont.value, diff --git a/packages/workflow-execute/src/engine/types.ts b/packages/workflow-execute/src/engine/types.ts index 1f9e6f6..b3de42f 100644 --- a/packages/workflow-execute/src/engine/types.ts +++ b/packages/workflow-execute/src/engine/types.ts @@ -39,7 +39,6 @@ export type PrefilledDiskStep = { }; export type ExecuteThreadOptions = { - maxRounds: number; /** Passed to the bundle thread context as `ThreadContext.depth`. */ depth: number; signal: AbortSignal; @@ -68,7 +67,7 @@ export type CasForkPlan = { hash: string; sourceThreadId: string; prompt: string; - runOptions: { maxRounds: number; depth: number }; + runOptions: { depth: number }; steps: RoleOutput[]; stepTimestamps: number[]; forkContinuation: ForkContinuationOptions; diff --git a/packages/workflow-execute/src/engine/worker.ts b/packages/workflow-execute/src/engine/worker.ts index f080d69..f8407b8 100644 --- a/packages/workflow-execute/src/engine/worker.ts +++ b/packages/workflow-execute/src/engine/worker.ts @@ -32,7 +32,7 @@ type RunCommand = { threadId: string; workflowName: string; prompt: string; - options: { maxRounds: number; depth: number }; + options: { depth: number }; steps: RoleOutput[]; /** Timestamps aligned with `steps` for replay / fork restore; length must match `steps` when steps are non-empty. */ stepTimestamps: number[] | null; @@ -185,10 +185,6 @@ function parseRunControlPayload(rec: Record): RunCommand | null return null; } const optRec = options as Record; - const maxRounds = optRec.maxRounds; - if (typeof maxRounds !== "number") { - return null; - } const depthRaw = optRec.depth; const depth = typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0; @@ -210,7 +206,7 @@ function parseRunControlPayload(rec: Record): RunCommand | null threadId, workflowName, prompt, - options: { maxRounds, depth }, + options: { depth }, steps: parsedSteps.steps, stepTimestamps: parsedSteps.stepTimestamps, forkSourceThreadId, diff --git a/packages/workflow-execute/src/extract/extract-fn.ts b/packages/workflow-execute/src/extract/extract-fn.ts index 6864517..cbd919f 100644 --- a/packages/workflow-execute/src/extract/extract-fn.ts +++ b/packages/workflow-execute/src/extract/extract-fn.ts @@ -1,10 +1,6 @@ import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas"; import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor"; -import type { - ExtractFn, - ExtractResult, - LlmProvider, -} from "@uncaged/workflow-runtime"; +import type { ExtractFn, ExtractResult, LlmProvider } from "@uncaged/workflow-runtime"; import type * as z from "zod/v4"; import { extractFunctionToolFromZodSchema } from "./llm-extract.js"; diff --git a/packages/workflow-execute/src/workflow-as-agent.ts b/packages/workflow-execute/src/workflow-as-agent.ts index 72e225c..87663c7 100644 --- a/packages/workflow-execute/src/workflow-as-agent.ts +++ b/packages/workflow-execute/src/workflow-as-agent.ts @@ -95,7 +95,6 @@ export function workflowAsAgent( workflowName, input, { - maxRounds: ctx.start.meta.maxRounds, depth: nextDepth, signal: signalNever.signal, awaitAfterEachYield: async () => {}, diff --git a/packages/workflow-protocol/__tests__/moderator-table.test.ts b/packages/workflow-protocol/__tests__/moderator-table.test.ts index ff534c3..5e01f9c 100644 --- a/packages/workflow-protocol/__tests__/moderator-table.test.ts +++ b/packages/workflow-protocol/__tests__/moderator-table.test.ts @@ -24,7 +24,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext start: { role: START, content: "test", - meta: { maxRounds: 10 }, + meta: {}, timestamp: Date.now(), } as StartStep, steps, diff --git a/packages/workflow-protocol/src/cas-types.ts b/packages/workflow-protocol/src/cas-types.ts index e6944a1..1d3e5ea 100644 --- a/packages/workflow-protocol/src/cas-types.ts +++ b/packages/workflow-protocol/src/cas-types.ts @@ -3,7 +3,6 @@ export type StartNodePayload = { name: string; hash: string; - maxRounds: number; depth: number; }; diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 64513aa..944a193 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -46,7 +46,7 @@ export type RoleOutput = { export type StartStep = { role: typeof START; content: string; - meta: { maxRounds: number }; + meta: Record; timestamp: number; }; diff --git a/packages/workflow-runtime/__tests__/build-context.test.ts b/packages/workflow-runtime/__tests__/build-context.test.ts index 70f9c18..6632f20 100644 --- a/packages/workflow-runtime/__tests__/build-context.test.ts +++ b/packages/workflow-runtime/__tests__/build-context.test.ts @@ -27,7 +27,7 @@ describe("buildThreadContext", () => { const bundleHash = "BHAAAAAAAAAAA"; const startHash = await putStartNode( cas, - { name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 }, + { name: "demo", hash: bundleHash, depth: 2 }, promptHash, ); @@ -59,7 +59,6 @@ describe("buildThreadContext", () => { expect(ctx.depth).toBe(2); expect(ctx.start.role).toBe(START); expect(ctx.start.content).toBe("hello-task"); - expect(ctx.start.meta.maxRounds).toBe(99); expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]); expect(ctx.steps[0]?.refs).toEqual([art]); expect(ctx.steps[1]?.refs).toEqual([]); @@ -72,7 +71,7 @@ describe("buildThreadContext", () => { const promptHash = await cas.put("only-prompt"); const startHash = await putStartNode( cas, - { name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 }, + { name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 }, promptHash, ); @@ -80,7 +79,6 @@ describe("buildThreadContext", () => { expect(ctx.steps).toEqual([]); expect(ctx.start.content).toBe("only-prompt"); expect(ctx.depth).toBe(1); - expect(ctx.start.meta.maxRounds).toBe(3); }); test("omits __end__ states from steps", async () => { @@ -89,7 +87,7 @@ describe("buildThreadContext", () => { const bundleHash = "BHCCCCCCCCCCC"; const startHash = await putStartNode( cas, - { name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 }, + { name: "demo", hash: bundleHash, depth: 0 }, promptHash, ); diff --git a/packages/workflow-runtime/src/build-context.ts b/packages/workflow-runtime/src/build-context.ts index c64b284..6d3bccb 100644 --- a/packages/workflow-runtime/src/build-context.ts +++ b/packages/workflow-runtime/src/build-context.ts @@ -57,7 +57,7 @@ async function threadFromStartHead( start: { role: START, content: prompt, - meta: { maxRounds: p.maxRounds }, + meta: {}, timestamp: 0, }, steps: [], @@ -116,7 +116,7 @@ async function threadFromStateHead( start: { role: START, content: prompt, - meta: { maxRounds: sp.maxRounds }, + meta: {}, timestamp: firstTs, }, steps, diff --git a/packages/workflow-runtime/src/create-workflow.ts b/packages/workflow-runtime/src/create-workflow.ts index 8f9d643..310f6f7 100644 --- a/packages/workflow-runtime/src/create-workflow.ts +++ b/packages/workflow-runtime/src/create-workflow.ts @@ -101,9 +101,10 @@ async function advanceOneRound( ); const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta); - const contentHash = artifactRefs.length === 0 - ? agentContentHash - : await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs); + const contentHash = + artifactRefs.length === 0 + ? agentContentHash + : await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs); const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash]; const step = { @@ -144,17 +145,9 @@ export function createWorkflow( if (thread.start.role !== START) { throw new Error(`workflow loop expected start role to be ${START}`); } - const maxRounds = thread.start.meta.maxRounds; let currentThread = thread as ModeratorContext; while (true) { - if (currentThread.steps.length >= maxRounds) { - return { - returnCode: 0, - summary: `completed: reached maxRounds (${maxRounds})`, - }; - } - const outcome = await advanceOneRound(def, binding, { thread: currentThread, runtime, diff --git a/packages/workflow-template-develop/__tests__/develop-template.test.ts b/packages/workflow-template-develop/__tests__/develop-template.test.ts index 8f80296..c37619c 100644 --- a/packages/workflow-template-develop/__tests__/develop-template.test.ts +++ b/packages/workflow-template-develop/__tests__/develop-template.test.ts @@ -13,23 +13,20 @@ const DEFAULT_PHASES: PlannerMeta["phases"] = [ }, ]; -function makeStart(maxRounds: number): ModeratorContext["start"] { +function makeStart(): ModeratorContext["start"] { return { role: START, content: "Implement the feature", - meta: { maxRounds }, + meta: {}, timestamp: 0, }; } -function makeCtx( - maxRounds: number, - steps: ModeratorContext["steps"], -): ModeratorContext { +function makeCtx(steps: ModeratorContext["steps"]): ModeratorContext { return { threadId: "01TEST000000000000000000TR", depth: 0, - start: makeStart(maxRounds), + start: makeStart(), steps, }; } @@ -90,20 +87,18 @@ function committerStep(meta: CommitterMeta): RoleStep { describe("developModerator", () => { test("routes initial → planner → coder → reviewer → tester → committer → END", () => { - expect(developModerator(makeCtx(20, []))).toBe("planner"); - expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder"); - expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer"); - expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe( + expect(developModerator(makeCtx([]))).toBe("planner"); + expect(developModerator(makeCtx([plannerStep()]))).toBe("coder"); + expect(developModerator(makeCtx([plannerStep(), coderStep()]))).toBe("reviewer"); + expect(developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true)]))).toBe( "tester", ); expect( - developModerator( - makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]), - ), + developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true), testerStep(true)])), ).toBe("committer"); expect( developModerator( - makeCtx(20, [ + makeCtx([ plannerStep(), coderStep(), reviewerStep(true), @@ -120,7 +115,7 @@ describe("developModerator", () => { coderStep(), reviewerStep(false), ]; - expect(developModerator(makeCtx(20, steps))).toBe("coder"); + expect(developModerator(makeCtx(steps))).toBe("coder"); }); test("reviewer rejects → END when max rounds exhausted", () => { @@ -129,7 +124,7 @@ describe("developModerator", () => { coderStep(), reviewerStep(false), ]; - expect(developModerator(makeCtx(4, steps))).toBe(END); + expect(developModerator(makeCtx(steps))).toBe(END); }); test("tester failed → coder retry when budget allows", () => { @@ -139,7 +134,7 @@ describe("developModerator", () => { reviewerStep(true), testerStep(false), ]; - expect(developModerator(makeCtx(20, steps))).toBe("coder"); + expect(developModerator(makeCtx(steps))).toBe("coder"); }); test("tester failed → END when max rounds exhausted", () => { @@ -149,7 +144,7 @@ describe("developModerator", () => { reviewerStep(true), testerStep(false), ]; - expect(developModerator(makeCtx(5, steps))).toBe(END); + expect(developModerator(makeCtx(steps))).toBe(END); }); test("multiple planner phases → coder until all complete, then reviewer", () => { @@ -157,13 +152,11 @@ describe("developModerator", () => { { hash: "AA000001", title: "first phase" }, { hash: "AA000002", title: "second phase" }, ]; - expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder"); - expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe( - "coder", - ); + expect(developModerator(makeCtx([plannerStep(phases)]))).toBe("coder"); + expect(developModerator(makeCtx([plannerStep(phases), coderStep("AA000001")]))).toBe("coder"); expect( developModerator( - makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]), + makeCtx([plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]), ), ).toBe("reviewer"); }); @@ -175,7 +168,7 @@ describe("developModerator", () => { { hash: "BB000003", title: "verify" }, { hash: "BB000004", title: "polish" }, ]; - expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe( + expect(developModerator(makeCtx([plannerStep(phases), coderStep("BB000004")]))).toBe( "reviewer", ); }); @@ -185,9 +178,7 @@ describe("developModerator", () => { { hash: "CC000001", title: "first phase" }, { hash: "CC000002", title: "second phase" }, ]; - expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe( - "coder", - ); + expect(developModerator(makeCtx([plannerStep(phases), coderStep("all-done")]))).toBe("coder"); }); test("incomplete phases → END when max rounds exhausted", () => { @@ -199,7 +190,7 @@ describe("developModerator", () => { plannerStep(phases), coderStep("DD000001"), ]; - expect(developModerator(makeCtx(3, steps))).toBe(END); + expect(developModerator(makeCtx(steps))).toBe(END); }); test("committer → END for any committer meta status", () => { @@ -220,9 +211,9 @@ describe("developModerator", () => { reviewerStep(true), testerStep(true), ]; - expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END); - expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END); - expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END); + expect(developModerator(makeCtx([...base, committed]))).toBe(END); + expect(developModerator(makeCtx([...base, recoverable]))).toBe(END); + expect(developModerator(makeCtx([...base, unrecoverable]))).toBe(END); }); }); diff --git a/packages/workflow-template-develop/src/moderator.ts b/packages/workflow-template-develop/src/moderator.ts index c56eff9..0da46bf 100644 --- a/packages/workflow-template-develop/src/moderator.ts +++ b/packages/workflow-template-develop/src/moderator.ts @@ -52,8 +52,8 @@ const allPhasesComplete: ModeratorCondition = { const hasRoundsRemaining: ModeratorCondition = { name: "hasRoundsRemaining", - description: "There are rounds remaining before hitting maxRounds", - check: (ctx) => ctx.steps.length < ctx.start.meta.maxRounds - 1, + description: "Always true — supervisor controls termination", + check: () => true, }; const reviewApproved: ModeratorCondition = { diff --git a/packages/workflow-template-develop/src/roles/coder.ts b/packages/workflow-template-develop/src/roles/coder.ts index 7ba2d36..27ddca3 100644 --- a/packages/workflow-template-develop/src/roles/coder.ts +++ b/packages/workflow-template-develop/src/roles/coder.ts @@ -2,7 +2,11 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime"; import * as z from "zod/v4"; export const coderMetaSchema = z.object({ - completedPhase: z.string().describe("The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash."), + completedPhase: z + .string() + .describe( + "The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.", + ), filesChanged: z.array(z.string()), summary: z.string(), }); diff --git a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts index 88c7208..5af8e56 100644 --- a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -98,23 +98,22 @@ function installMockToolCallCompletions( }; } -function makeStart(maxRounds: number): ModeratorContext["start"] { +function makeStart(): ModeratorContext["start"] { return { role: START, content: "Fix the flaky login test", - meta: { maxRounds }, + meta: {}, timestamp: 0, }; } function makeCtx( - maxRounds: number, steps: ModeratorContext["steps"], ): ModeratorContext { return { threadId: "01TEST000000000000000000TR", depth: 0, - start: makeStart(maxRounds), + start: makeStart(), steps, }; } @@ -182,7 +181,7 @@ function makeThread(prompt: string) { start: { role: START, content: prompt, - meta: { maxRounds: 20 }, + meta: {}, timestamp: Date.now(), }, steps: [], @@ -191,12 +190,12 @@ function makeThread(prompt: string) { describe("solveIssueModerator", () => { test("routes initial → preparer → developer → submitter → END", () => { - expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer"); - expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer"); - expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter"); + expect(solveIssueModerator(makeCtx([]))).toBe("preparer"); + expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer"); + expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter"); expect( solveIssueModerator( - makeCtx(20, [ + makeCtx([ preparerStep(), developerStep(), submitterStep({ @@ -211,7 +210,7 @@ describe("solveIssueModerator", () => { test("submitter failed → END", () => { expect( solveIssueModerator( - makeCtx(20, [ + makeCtx([ preparerStep(), developerStep(), submitterStep({ status: "failed", error: "gh not authenticated" }), @@ -225,7 +224,7 @@ describe("solveIssueModerator", () => { // routed to END, since the moderator is a closed switch over known roles. expect( solveIssueModerator( - makeCtx(20, [ + makeCtx([ preparerStep(), developerStep(), submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }), diff --git a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts index c49f664..5bbeba0 100644 --- a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts +++ b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts @@ -7,7 +7,7 @@ function startTask(content: string): AgentContext["start"] { return { role: START, content, - meta: { maxRounds: 5 }, + meta: {}, timestamp: 1, }; }