diff --git a/CHANGELOG.md b/CHANGELOG.md index 983ef252..3018b377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Changed + +- Completing a workflow step is a `submit_output` tagged with that step's id. + `advance_workflow` is gone. Already-complete and not-current ids are + acknowledged without advancing. The unused `autoAdvance` workflow field is + removed. + ### Fixed - Failed sessions with an `error` string in `run.json` are valid resume diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f1aa751d..2ff7a4a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -173,8 +173,7 @@ Compaction replaces older turns with a structured, workflow-aware summary rather - `ask_operator` — Pauses for a clarifying question with a list of options. - `present` — Renders structured UI from a JSON view spec instead of pasting tables into chat. -- `submit_output` — Workflow step advancement when `step` is set (observed by the workflow coordinator). -- `advance_workflow` — Advances the active workflow to its next step (observed by the director). Only advertised while a workflow is running. +- `submit_output` — Completes a workflow step when `step` is set. The step id is compared atomically against the current step (`complete()`); already-complete ids (behind the cursor) and not-current ids (future or unknown) are acknowledged without advancing. Always advertised so activating a workflow does not grow the tools array. Core agent tools (advertised in every chat turn) include `manage_tasks`, `tool_search`, `use_skill`, **`task`** (spawn a sub-agent), and **`search_agents`** when sub-agent profiles are available — see Sub-agents below. @@ -186,7 +185,7 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l - `capabilities.ts` — `detectCapabilities` maps the live tool surface to abstract capabilities (`ticket-tracker`, `code-host`, `doc-search`) by name pattern; `resolveStep` decides whether a step runs. A capability override set forces integrations off per run. Adding a capability is a data edit, not a logic change. - `runtime.ts` — `WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `workflow.json` under the session state root for resume. -- `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and advances the runtime when `advance_workflow` (or a `submit_output` tagged `{ step }`) completes. Shared by both directors. +- `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and compare-and-advances the runtime when a `submit_output` tagged `{ step }` completes. Already-complete and not-current ids are acknowledged without moving the cursor. Shared by both directors. Fresh and resumed runs share one listener path. - The built-in recipes: the atomics `update-ticket`, `improve-docs`, `write-tests`, `triage-bug`, `code-review`, `scope-project`, and the `build-feature` composite that chains them. Invocation: workflows are **not** top-level slash commands. Recipe definitions load into the `WORKFLOWS` registry from **enabled workflow/command plugins** at startup; command surfaces on those plugins (e.g. a workflow plugin's command prefix such as `/mywf scope`). Slash commands may also be authored as data-only markdown (`commands/*.md`, no `index.ts`); see PLUGINS.md. The model never suggests or auto-starts workflows from ordinary chat. Skills (bundled `corbits-skills`, enabled plugins, or `.agents/skills/`) load on demand via `use_skill` or as `/` slash commands when `user-invocable` is not `false` (see Skills below). The TUI surfaces state via `src/tui/workflow-controller.ts` (lifecycle, capability overrides, resume) — the header shows step progress (`⟳ name · step/total label`). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 1ee1a1c9..e80faa19 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -342,7 +342,7 @@ Positional arguments after flags are joined into the optional initial task deliv ### Inference - OpenAI-compatible chat completions, streamed via `@intx/inference` -- JSON-schema tool definitions for director-layer tools (`ask_operator`, `present`, `submit_output`, `advance_workflow`) and agent tools (`manage_tasks`, `tool_search`, `use_skill`, `search_agents`, …) +- JSON-schema tool definitions for director-layer tools (`ask_operator`, `present`, `submit_output`) and agent tools (`manage_tasks`, `tool_search`, `use_skill`, `search_agents`, …) ### State Persistence diff --git a/src/agent/director.ts b/src/agent/director.ts index 3ec66339..13e6fdf8 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -99,9 +99,9 @@ const IDLE_OPEN_TASK_NUDGE = const WORKFLOW_OPEN_TASK_NUDGE = "\n\nYou are ending your turn while tasks are still open (todo/doing) and a " + - "workflow step is active. Continue working with tools, call advance_workflow " + - "once the step is complete, or mark finished tasks done with manage_tasks. " + - "Do not end your turn with tasks still open."; + "workflow step is active. Continue working with tools, call submit_output " + + "with this step's id once the step is complete, or mark finished tasks done " + + "with manage_tasks. Do not end your turn with tasks still open."; const DECLINED_OPEN_TASK_NUDGE = "\n\nThe operator declined the tool call. Do not retry the declined action. " + @@ -243,27 +243,12 @@ export const presentDefinition: ToolDefinition = { }, }; -export const advanceWorkflowDefinition: ToolDefinition = { - name: "advance_workflow", - description: - "Call this when the current workflow step is finished to advance to the next step. " + - "Include an optional note summarizing what the step accomplished.", - inputSchema: { - type: "object", - properties: { - note: { - type: "string", - description: "Optional summary of what this step accomplished", - }, - }, - }, -}; - export const submitOutputDefinition: ToolDefinition = { name: "submit_output", description: - "Call this when the task is fully complete (include summary) or to advance " + - "a workflow step (include step id).", + "Call this when the task is fully complete (include summary) or to complete " + + "a workflow step (step id is required to advance; already-complete and " + + "not-current step ids are acknowledged without advancing).", inputSchema: { type: "object", properties: { @@ -274,8 +259,8 @@ export const submitOutputDefinition: ToolDefinition = { step: { type: "string", description: - "Workflow step ID to advance. When present this is a " + - "step-advancement signal, not a terminal task submission.", + "Workflow step ID to complete. Required to advance a workflow. " + + "Compared atomically against the current step.", }, }, }, @@ -480,12 +465,13 @@ class ChatDirectorImpl extends DefaultDirector { result: ReactorAction | ReactorAction[], ): ReactorAction | ReactorAction[] { const active = this.workflowCoordinator?.isActive() === true; - // advance_workflow rides on the wire every turn, workflow or not, so + // submit_output rides on the wire every turn, workflow or not, so // activating a workflow never grows the tools array and busts the cache - // prefix. Outside a workflow it is a harmless no-op the director ignores. - const tools = this._toolDefinitions.some((t) => t.name === advanceWorkflowDefinition.name) + // prefix. Outside a workflow it is a harmless no-op the director ignores + // unless the call is a terminal task submission. + const tools = this._toolDefinitions.some((t) => t.name === submitOutputDefinition.name) ? this._toolDefinitions - : [...this._toolDefinitions, advanceWorkflowDefinition]; + : [...this._toolDefinitions, submitOutputDefinition]; const directive = active ? (this.workflowCoordinator?.directive() ?? null) : null; @@ -694,7 +680,7 @@ class ChatDirectorImpl extends DefaultDirector { const path = pathResult instanceof type.errors ? "" : pathResult.path; if (isCodeFile(path)) this.lspTriggerCalls.add(block.id); } - if (block.name === "advance_workflow" || block.name === "submit_output") { + if (block.name === "submit_output") { this.workflowCalls.set(block.id, { name: block.name, args: block.arguments }); } if (block.name === "ask_operator") { @@ -791,10 +777,15 @@ class ChatDirectorImpl extends DefaultDirector { ), ]; } + const stepId = coordinator.currentStepId(); + const stepClause = + stepId !== null + ? `call submit_output with { "step": "${stepId}" } now` + : "call submit_output with this step's id now"; const nudge = - "\n\nYou have not yet called advance_workflow. " + - "If this step is complete, call advance_workflow now. " + - "Otherwise continue working with tools."; + `\n\nYou have not yet completed this workflow step. ` + + `If this step is complete, ${stepClause}. ` + + `Otherwise continue working with tools.`; const passThrough = actions.filter( (a): a is Exclude => a.type !== "wait" && a.type !== "reply", @@ -816,8 +807,9 @@ class ChatDirectorImpl extends DefaultDirector { (a): a is Exclude => a.type !== "wait" && a.type !== "reply", ); - // Inside a workflow the terminal action is advance_workflow, so point - // the nudge at it rather than the general manage_tasks guidance. + // Inside a workflow the terminal action is submit_output with the + // current step id, so point the nudge at it rather than the general + // manage_tasks guidance. const nudge = coordinator?.isActive() === true ? WORKFLOW_OPEN_TASK_NUDGE : IDLE_OPEN_TASK_NUDGE; return [...passThrough, inferWithNudge(capabilities, nudge)]; diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 7aaf985e..b4554c48 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -219,7 +219,7 @@ const TOOL_SUMMARIES: Record = { search_agents: "find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", - submit_output: "signal the task is complete — the only way to finish", + submit_output: "signal the task is complete, or complete a workflow step by passing its step id", ask_operator: "pause and ask the user when blocked or genuinely ambiguous; put long rationale in a transcript reply first, then call with a short question and short option labels only", present: diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 9c367540..a28cd6fd 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -4,9 +4,9 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; import { createPosixTools, type ToolPlugin } from "@intx/tools-posix"; import { - advanceWorkflowDefinition, askOperatorDefinition, presentDefinition, + submitOutputDefinition, } from "../agent/director.js"; import { manageTasksDefinition } from "./tasks.js"; import { validateView } from "../tui/view/index.js"; @@ -34,6 +34,7 @@ import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; import { advertisedToolNamesForSessionMode, type ToolAvailability } from "./tool-search.js"; import type { ProviderCatalogEntry } from "../config/index.js"; import type { AgentProfile } from "./profiles.js"; +import type { WorkflowCompleteResult } from "../workflows/types.js"; import { createTaskTool, runSubAgent, @@ -74,8 +75,9 @@ const AskOperatorArgs = type({ options: "string[]", }); -const AdvanceWorkflowArgs = type({ - "note?": "string", +const SubmitOutputArgs = type({ + "summary?": "string", + "step?": "string", }); // The operator can pick one of the offered options, type a free-form answer, or @@ -131,10 +133,14 @@ export interface AgentToolsetArgs { getContextDir?: () => string | undefined; // Per-project settings.env, merged into the run_shell tool's spawn environment. shellEnv?: Record; - // Whether a workflow is currently running. advance_workflow rides the wire + // Whether a workflow is currently running. submit_output rides the wire // every turn (workflow or not), so the model can call it with nothing active; // this lets its handler report an honest no-op instead of a false advance. isWorkflowActive?: () => boolean; + // Compare-and-advance the live workflow. The handler reports this result + // instead of reconstructing the cursor; omitted (exec, tests) never claims + // an advance. + completeWorkflowStep?: (stepId: string) => WorkflowCompleteResult; // Primary session mode (always orchestrator; kept for call-site wiring). sessionMode?: SessionMode; // Session-start facts gating lsp advertisement. Omitted callers (tests, @@ -459,21 +465,35 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise): Promise => { - if (args.isWorkflowActive?.() === false) { - return "No active workflow — nothing to advance."; + const parsed = SubmitOutputArgs(rawArgs); + const step = parsed instanceof type.errors ? undefined : parsed.step; + const summary = parsed instanceof type.errors ? undefined : parsed.summary; + const workflowActive = args.isWorkflowActive?.() === true; + if (workflowActive) { + if (step === undefined || step.length === 0) { + return "Error: workflow completion requires a step identifier."; + } + const result = args.completeWorkflowStep?.(step) ?? "not-current"; + if (result === "advanced") { + const note = summary !== undefined && summary.length > 0 ? ` (${summary})` : ""; + return `Workflow step marked complete${note}. Advancing to the next step.`; + } + if (result === "already-complete") { + return "This workflow step is already complete. No advance."; + } + return "This workflow step is not current. No advance."; } - const parsed = AdvanceWorkflowArgs(rawArgs); - if (parsed instanceof type.errors) { - return "Acknowledged."; + if (step !== undefined && step.length > 0) { + return "No active workflow — nothing to advance."; } - const note = parsed.note !== undefined ? ` (${parsed.note})` : ""; - return `Workflow step marked complete${note}. Advancing to the next step.`; + return "Acknowledged."; }, }), ]; diff --git a/src/director.test.ts b/src/director.test.ts index 0e89b0a0..851eeb25 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -716,9 +716,9 @@ describe("updateToolDefinitions rewrites infer tools", () => { expect(JSON.stringify(after)).toBe(JSON.stringify(before)); }); - // advance_workflow is always on the wire so a workflow going active never grows + // submit_output is always on the wire so a workflow going active never grows // the array and busts the provider cache prefix. - test("advance_workflow is advertised even with no active workflow", async () => { + test("submit_output is advertised even with no active workflow", async () => { const director = createChatDirector("base-prompt", [], { onTasksChange: () => {} }); director.updateToolDefinitions([lateTool]); @@ -730,7 +730,7 @@ describe("updateToolDefinitions rewrites infer tools", () => { const actions = Array.isArray(result) ? result : [result]; const inferAction = actions.find((a) => a.type === "infer") as Record | undefined; - expect(inferToolNames(inferAction)).toContain("advance_workflow"); + expect(inferToolNames(inferAction)).toContain("submit_output"); }); // End-to-end: tool_search matches an MCP tool, the runner's promote wiring @@ -781,13 +781,13 @@ describe("updateToolDefinitions rewrites infer tools", () => { const afterTools = after as { name: string }[]; const afterNames = afterTools.map((t) => t.name); expect(afterNames).toContain("mcp__linear__list_issues"); - // advance_workflow rides along separately (see withCurrentTools), appended + // submit_output rides along separately (see withCurrentTools), appended // after computeAdvertised's result every turn — strip it before comparing // the fixed built-in prefix, which must survive untouched ahead of the // newly appended MCP tool. - const beforePrefix = beforeNames.filter((n) => n !== "advance_workflow"); + const beforePrefix = beforeNames.filter((n) => n !== "submit_output"); const afterPrefix = afterNames.filter( - (n) => n !== "advance_workflow" && n !== "mcp__linear__list_issues", + (n) => n !== "submit_output" && n !== "mcp__linear__list_issues", ); expect(afterPrefix).toEqual(beforePrefix); expect(afterNames.indexOf("mcp__linear__list_issues")).toBe(beforePrefix.length); @@ -822,8 +822,11 @@ describe("updateToolDefinitions rewrites infer tools", () => { }); }); -describe("advance_workflow handler", () => { - const buildToolset = (isWorkflowActive: () => boolean) => +describe("submit_output workflow handler", () => { + const buildToolset = (opts: { + isWorkflowActive: () => boolean; + completeWorkflowStep?: (stepId: string) => "advanced" | "already-complete" | "not-current"; + }) => createAgentToolset({ cwd: process.cwd(), permissionGate: createPermissionGate({ @@ -832,28 +835,116 @@ describe("advance_workflow handler", () => { skipPermissions: true, }), onOperatorGate: async () => ({ kind: "cancel" }), - isWorkflowActive, + isWorkflowActive: opts.isWorkflowActive, + ...(opts.completeWorkflowStep !== undefined + ? { completeWorkflowStep: opts.completeWorkflowStep } + : {}), }); - const runAdvance = async (toolset: Awaited>) => { + const runSubmit = async ( + toolset: Awaited>, + args: Record, + ) => { const result = await toolset.dynamicRunner.run( - { id: "aw", name: "advance_workflow", arguments: {} }, + { id: "so", name: "submit_output", arguments: args }, new AbortController().signal, ); await toolset.dispose(); return String(result.content); }; - test("reports an honest no-op when no workflow is active", async () => { - const content = await runAdvance(await buildToolset(() => false)); + test("reports an honest no-op when no workflow is active and a step is tagged", async () => { + const content = await runSubmit(await buildToolset({ isWorkflowActive: () => false }), { + step: "a", + }); expect(content).toContain("No active workflow"); expect(content).not.toContain("Advancing"); }); - test("acknowledges advancement when a workflow is active", async () => { - const content = await runAdvance(await buildToolset(() => true)); + test("requires a step identifier while a workflow is active", async () => { + const content = await runSubmit( + await buildToolset({ + isWorkflowActive: () => true, + completeWorkflowStep: () => "advanced", + }), + { summary: "done" }, + ); + expect(content).toContain("requires a step identifier"); + expect(content).not.toContain("Advancing"); + }); + + test("reports complete() when the step advances", async () => { + const content = await runSubmit( + await buildToolset({ + isWorkflowActive: () => true, + completeWorkflowStep: (id) => (id === "a" ? "advanced" : "not-current"), + }), + { step: "a" }, + ); expect(content).toContain("Advancing to the next step"); }); + + test("reports already-complete without claiming an advance", async () => { + const content = await runSubmit( + await buildToolset({ + isWorkflowActive: () => true, + completeWorkflowStep: () => "already-complete", + }), + { step: "a" }, + ); + expect(content).toContain("already complete"); + expect(content).not.toContain("Advancing"); + }); + + test("does not report a not-current step as already complete", async () => { + const content = await runSubmit( + await buildToolset({ + isWorkflowActive: () => true, + completeWorkflowStep: () => "not-current", + }), + { step: "b" }, + ); + expect(content).toContain("not current"); + expect(content).not.toContain("already complete"); + expect(content).not.toContain("Advancing"); + }); + + test("omitted completeWorkflowStep does not claim an advance", async () => { + const content = await runSubmit(await buildToolset({ isWorkflowActive: () => true }), { + step: "a", + }); + expect(content).toContain("not current"); + expect(content).not.toContain("Advancing"); + }); + + test("parallel submit_output only one reports Advancing", async () => { + const { WorkflowRuntime } = await import("./workflows/runtime.js"); + const workflow = { + name: "simple", + description: "two steps", + steps: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ], + }; + const runtime = new WorkflowRuntime(new Map(), () => workflow); + runtime.start(workflow); + const toolset = await buildToolset({ + isWorkflowActive: () => true, + completeWorkflowStep: (stepId) => runtime.complete(stepId), + }); + const run = (id: string, step: string) => + toolset.dynamicRunner.run( + { id, name: "submit_output", arguments: { step } }, + new AbortController().signal, + ); + const [first, second] = await Promise.all([run("so-1", "a"), run("so-2", "a")]); + await toolset.dispose(); + const contents = [String(first.content), String(second.content)]; + expect(contents.filter((c) => c.includes("Advancing"))).toHaveLength(1); + expect(contents.filter((c) => c.includes("already complete"))).toHaveLength(1); + expect(runtime.currentStep()?.id).toBe("b"); + }); }); describe("transient nudges", () => { diff --git a/src/tui/runner.ts b/src/tui/runner.ts index f7e65189..f4f9e4cc 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1258,7 +1258,7 @@ export async function runTUI(initialConfig: Config): Promise { toolAvailability, ); // The workflow controller is built below, after the toolset; the holder lets - // advance_workflow's handler read live workflow-active state without a + // submit_output's handler complete the live workflow without a // construction-order cycle. const workflowControllerHolder: { instance?: WorkflowController } = {}; @@ -1282,6 +1282,8 @@ export async function runTUI(initialConfig: Config): Promise { getContextDir: () => workdir, isWorkflowActive: () => workflowControllerHolder.instance?.isActive() === true, + completeWorkflowStep: (stepId) => + workflowControllerHolder.instance?.complete(stepId) ?? "not-current", ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), onOperatorGate: (question, options) => new Promise((resolve) => { diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index 239ec98e..e0fe24fd 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -60,7 +60,6 @@ const TOOL_ACTIVITY_STATES: Readonly> = { delete_file: "building", manage_tasks: "planning", task: "planning", - advance_workflow: "planning", tool_search: "researching", search_agents: "researching", ask_operator: "waiting", diff --git a/src/tui/workflow-controller.ts b/src/tui/workflow-controller.ts index c0e74ab8..8e3f0bbe 100644 --- a/src/tui/workflow-controller.ts +++ b/src/tui/workflow-controller.ts @@ -12,7 +12,12 @@ import { saveWorkflowState, warnWorkflowPersistenceFailure, } from "../workflows/state.js"; -import type { CapabilityName, StepStatus, Workflow } from "../workflows/types.js"; +import type { + CapabilityName, + StepStatus, + Workflow, + WorkflowCompleteResult, +} from "../workflows/types.js"; import type { WorkflowEvent } from "../workflows/runtime.js"; export interface CapabilityStatus { @@ -105,6 +110,10 @@ export class WorkflowController { return this.runtime?.isActive() === true; } + complete(stepId: string): WorkflowCompleteResult { + return this.coordinator?.complete(stepId) ?? "not-current"; + } + list(): { name: string; description: string }[] { return WORKFLOWS.map((w) => ({ name: w.name, description: w.description })); } @@ -130,7 +139,7 @@ export class WorkflowController { ); } - private attach(workflow: Workflow): void { + private attachRuntime(workflow: Workflow, restore?: boolean): WorkflowRuntime { const runtime = new WorkflowRuntime(this.capabilityMap()); const coordinator = new WorkflowCoordinator( runtime, @@ -139,8 +148,22 @@ export class WorkflowController { this.publish(); }, workflow.stepThrough === true, - workflow.autoAdvance === true, ); + this.listen(runtime); + this.runtime = runtime; + this.coordinator = coordinator; + this.args.getDirector()?.setWorkflowCoordinator(coordinator); + if (restore !== true) { + runtime.start(workflow); + this.persist(); + this.publish(); + } + return runtime; + } + + // Shared by start and resume so a restored run records history the same way + // a fresh run does. + private listen(runtime: WorkflowRuntime): void { runtime.on((event: WorkflowEvent) => { if (event.type === "workflow-complete") { // status() returns an empty shell here because runtime.done is already @@ -154,12 +177,10 @@ export class WorkflowController { this.persist(); this.publish(); }); - this.runtime = runtime; - this.coordinator = coordinator; - this.args.getDirector()?.setWorkflowCoordinator(coordinator); - runtime.start(workflow); - this.persist(); - this.publish(); + } + + private attach(workflow: Workflow): void { + this.attachRuntime(workflow); } // Start a workflow by name. If one is already active, the first call asks for @@ -188,24 +209,8 @@ export class WorkflowController { const rootName = state.stack[0]?.workflow; const workflow = rootName !== undefined ? findWorkflow(rootName) : undefined; if (workflow === undefined) return; - const runtime = new WorkflowRuntime(this.capabilityMap()); + const runtime = this.attachRuntime(workflow, true); runtime.restore(state); - const coordinator = new WorkflowCoordinator( - runtime, - () => { - this.persist(); - this.publish(); - }, - workflow.stepThrough === true, - workflow.autoAdvance === true, - ); - runtime.on(() => { - this.persist(); - this.publish(); - }); - this.runtime = runtime; - this.coordinator = coordinator; - this.args.getDirector()?.setWorkflowCoordinator(coordinator); this.publish(); } diff --git a/src/workflows/coordinator.ts b/src/workflows/coordinator.ts index 49f4d766..505b4f2f 100644 --- a/src/workflows/coordinator.ts +++ b/src/workflows/coordinator.ts @@ -1,5 +1,5 @@ import type { WorkflowRuntime } from "./runtime.js"; -import type { WorkflowStep } from "./types.js"; +import type { WorkflowCompleteResult, WorkflowStep } from "./types.js"; // Bridges the workflow runtime and a director. A director consults the // coordinator for the directive to inject into each turn's system prompt and @@ -16,10 +16,6 @@ export class WorkflowCoordinator { // When true the workflow pauses after each step for user confirmation; the // directive tells the agent to gate via ask_operator before advancing. private readonly stepThrough = false, - // When true the directive instructs the agent to use step-tagged - // submit_output rather than advance_workflow, so workflows drive themselves - // to completion without requiring explicit tool call selection. - private readonly autoAdvance = false, ) {} isActive(): boolean { @@ -30,6 +26,19 @@ export class WorkflowCoordinator { return this.runtime.isComplete(); } + currentStepId(): string | null { + return this.runtime.currentStep()?.id ?? null; + } + + // True when `stepId` belongs to the active frame and sits behind the cursor + // (completed or skipped). Unknown and future ids are not past. + isPastStep(stepId: string): boolean { + const view = this.runtime.view(); + if (view === null) return false; + const idx = view.steps.findIndex((s) => s.step.id === stepId); + return idx !== -1 && idx < view.stepIndex; + } + // True when the current step is a gate — the agent must pause and wait for // the operator. Used by the chat director to decide when to keep looping // autonomously vs. when to hand back to the user. @@ -59,44 +68,40 @@ export class WorkflowCoordinator { ` ask_operator to confirm before you advance.`, ); } - if (this.autoAdvance) { - lines.push( - `When this step is complete, call submit_output with { "step": "${step.id}" } to advance to the next step.`, - ); - } else { - lines.push( - `When this step is complete, call advance_workflow to continue` + - ` (or submit_output with { "step": "${step.id}" }).`, - ); - } + lines.push( + `When this step is complete, call submit_output with { "step": "${step.id}" } to advance to the next step.`, + ); return lines.join("\n"); } - // Handle a completed tool call, advancing the runtime when it is an - // advance_workflow or a step-tagged submit_output. Returns true when the - // runtime advanced (used by tests; the directors already reset their idle - // counters on any tool call, so a workflow advance is never seen as a stall). + // Handle a completed tool call. Only a step-tagged submit_output can move + // the runtime, and only via compare-and-advance against the current step. + // Returns true when the runtime advanced (used by tests; the directors + // already reset their idle counters on any tool call, so a workflow + // advance is never seen as a stall). Already-complete and not-current + // completions are acknowledged here without moving the cursor. handleToolDone(name: string | undefined, args: unknown, isError: boolean): boolean { if (isError || !this.runtime.isActive()) return false; - if (name === "advance_workflow") { - this.runtime.advance(); - this.persist(); - return true; - } - if (name === "submit_output" && stepMatchesCurrent(args, this.runtime.currentStep())) { - this.runtime.advance(); - this.persist(); - return true; - } - return false; + if (name !== "submit_output") return false; + const stepId = stepIdOf(args); + if (stepId === null) return false; + return this.complete(stepId) === "advanced"; + } + + // Compare-and-advance, persist on a real move, and return the complete() + // result so callers (submit_output's handler) report it instead of + // reconstructing the cursor. + complete(stepId: string): WorkflowCompleteResult { + const result = this.runtime.complete(stepId); + if (result === "advanced") this.persist(); + return result; } } -function stepMatchesCurrent(args: unknown, current: WorkflowStep | null): boolean { - if (current === null) return false; - if (typeof args !== "object" || args === null) return false; +function stepIdOf(args: unknown): string | null { + if (typeof args !== "object" || args === null) return null; const step = (args as Record).step; - return typeof step === "string" && step === current.id; + return typeof step === "string" && step.length > 0 ? step : null; } function guidanceFor(step: WorkflowStep): string[] { diff --git a/src/workflows/definition.ts b/src/workflows/definition.ts index 81e67e18..b80b91fc 100644 --- a/src/workflows/definition.ts +++ b/src/workflows/definition.ts @@ -23,7 +23,6 @@ export interface Workflow { description: string; autoInvoke?: string; stepThrough?: boolean; - autoAdvance?: boolean; steps: WorkflowStep[]; } diff --git a/src/workflows/runtime.ts b/src/workflows/runtime.ts index d80324ad..95a3e73e 100644 --- a/src/workflows/runtime.ts +++ b/src/workflows/runtime.ts @@ -8,6 +8,7 @@ import { type WorkflowFrame, type WorkflowState, type WorkflowStep, + type WorkflowCompleteResult, } from "./types.js"; export type WorkflowEvent = @@ -95,6 +96,25 @@ export class WorkflowRuntime { return workflow.steps[frame.stepIndex] ?? null; } + // Compare-and-advance against the current step. Matching `stepId` advances + // atomically (check and move happen in this call). A step already behind the + // cursor is already-complete; a future, unknown, or inactive id is + // not-current. Neither acknowledged case moves the cursor, so a retry cannot + // skip ahead. + complete(stepId: string): WorkflowCompleteResult { + const current = this.currentStep(); + if (current !== null && current.id === stepId) { + this.advance(); + return "advanced"; + } + const view = this.view(); + if (view !== null) { + const idx = view.steps.findIndex((s) => s.step.id === stepId); + if (idx !== -1 && idx < view.stepIndex) return "already-complete"; + } + return "not-current"; + } + // Mark the current step complete and move to the next runnable step. Pops // finished sub-workflow frames and descends into sub-workflow references as // needed. Emits step-complete for the step left behind. diff --git a/src/workflows/types.ts b/src/workflows/types.ts index fe957289..f3ec1560 100644 --- a/src/workflows/types.ts +++ b/src/workflows/types.ts @@ -8,6 +8,11 @@ export type { export type StepStatus = "pending" | "active" | "completed" | "skipped"; +// Result of compare-and-advance: the matching current step moves the cursor; +// anything already behind it is already-complete; unknown and future ids are +// not-current. Callers report this instead of reconstructing the cursor. +export type WorkflowCompleteResult = "advanced" | "already-complete" | "not-current"; + // One entry on the runtime call stack. The active frame is the last element; // nested sub-workflows push new frames and pop on completion. export interface WorkflowFrame { diff --git a/tests/helpers/workflows.ts b/tests/helpers/workflows.ts index 234fcdb0..486795d1 100644 --- a/tests/helpers/workflows.ts +++ b/tests/helpers/workflows.ts @@ -14,7 +14,6 @@ import { const scope: Workflow = { name: "scope", description: "Scope a feature or task — creates a ticket or a local scope file", - autoAdvance: true, steps: [ { id: "research", @@ -49,7 +48,6 @@ const scope: Workflow = { const build: Workflow = { name: "build", description: "Full implementation workflow: implement, document, and review", - autoAdvance: true, steps: [ { id: "fetch-ticket", @@ -94,7 +92,6 @@ const build: Workflow = { const review: Workflow = { name: "review", description: "Multi-agent review and synthesis", - autoAdvance: true, steps: [ { id: "core-review", diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index 154eb456..781624ac 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -127,9 +127,9 @@ await withMockedModule(import.meta.resolve("../../../src/agent/director.js"), () description: "Present structured output", inputSchema: { type: "object", properties: {}, required: [] }, } as ToolDefinition, - advanceWorkflowDefinition: { - name: "advance_workflow", - description: "Advance workflow", + submitOutputDefinition: { + name: "submit_output", + description: "Submit output", inputSchema: { type: "object", properties: {}, required: [] }, } as ToolDefinition, createChatDirector: mock(() => ({})), diff --git a/tests/unit/workflow-controller.test.ts b/tests/unit/workflow-controller.test.ts index 668c5847..26ea932d 100644 --- a/tests/unit/workflow-controller.test.ts +++ b/tests/unit/workflow-controller.test.ts @@ -98,28 +98,29 @@ test("reset detaches the workflow", async () => { }); }); -test("attach() passes autoAdvance to coordinator — directive uses submit_output step tag", async () => { +test("directive uses submit_output with the current step id", async () => { await withController([], async (controller, director, _cwd) => { - controller.start("build"); // build has autoAdvance: true + controller.start("build"); const coordinator = director.coordinator!; expect(coordinator).toBeDefined(); const directive = coordinator.directive(); expect(directive).not.toBeNull(); - // autoAdvance=true: directive must use step-tagged submit_output only, not advance_workflow. expect(directive).toContain("submit_output"); + expect(directive).toContain('"step":'); expect(directive).not.toContain("advance_workflow"); }); }); test("history() entry after workflow completion contains the workflow name and steps", async () => { await withController([], async (controller, _director, _cwd) => { - controller.start("review"); // review has 3 steps + controller.start("review"); const coordinator = (controller as unknown as { coordinator: WorkflowCoordinator }) .coordinator!; - // Advance through all steps to trigger workflow-complete. - coordinator.handleToolDone("advance_workflow", {}, false); - coordinator.handleToolDone("advance_workflow", {}, false); - coordinator.handleToolDone("advance_workflow", {}, false); + while (coordinator.isActive()) { + const stepId = coordinator.currentStepId(); + expect(stepId).not.toBeNull(); + coordinator.handleToolDone("submit_output", { step: stepId }, false); + } expect(controller.isActive()).toBe(false); const history = controller.history(); expect(history).toHaveLength(1); @@ -128,6 +129,27 @@ test("history() entry after workflow completion contains the workflow name and s }); }); +test("resume() uses the same completion listener as a fresh start", async () => { + await withController([], async (controller, director, cwd, home) => { + const workflow = findWorkflow("review"); + expect(workflow).toBeDefined(); + const runtime = new WorkflowRuntime(new Map()); + runtime.start(workflow!); + await saveWorkflowState(cwd, "session-1", runtime.state(), home); + + await controller.resume(); + expect(controller.isActive()).toBe(true); + const coordinator = director.coordinator!; + while (coordinator.isActive()) { + const stepId = coordinator.currentStepId(); + expect(stepId).not.toBeNull(); + coordinator.handleToolDone("submit_output", { step: stepId }, false); + } + expect(controller.history()).toHaveLength(1); + expect(controller.history()[0]!.name).toBe("review"); + }); +}); + test("resume() restores an on-disk workflow snapshot for the session", async () => { await withController([], async (controller, director, cwd, home) => { const workflow = findWorkflow("review"); diff --git a/tests/unit/workflows-definitions.test.ts b/tests/unit/workflows-definitions.test.ts index 4dd7293e..7445c27d 100644 --- a/tests/unit/workflows-definitions.test.ts +++ b/tests/unit/workflows-definitions.test.ts @@ -48,12 +48,6 @@ test("build workflow completes with no capabilities, skipping ticket steps", () expect(ids).toContain("gate"); }); -test("autoAdvance is set on sample workflows", () => { - for (const name of ["scope", "build", "review"]) { - expect(findWorkflow(name)?.autoAdvance).toBe(true); - } -}); - test("every sample workflow drains to completion under full capabilities", () => { for (const name of ["scope", "review", "build"]) { expect(() => drive(name, fullCaps)).not.toThrow(); diff --git a/tests/unit/workflows-director.test.ts b/tests/unit/workflows-director.test.ts index f7649428..534aa8d0 100644 --- a/tests/unit/workflows-director.test.ts +++ b/tests/unit/workflows-director.test.ts @@ -120,10 +120,11 @@ test("the active step directive is injected into the inferred system prompt", as expect(prompt).toContain("BASE PROMPT"); expect(ephemeralNudgeText(result)).toContain("[WORKFLOW STEP 1/2: Step A]"); const toolNames = (infers[0]?.options?.tools ?? []).map((t) => t.name); - expect(toolNames).toContain("advance_workflow"); + expect(toolNames).toContain("submit_output"); + expect(toolNames).not.toContain("advance_workflow"); }); -test("an advance_workflow tool call advances the runtime through the director", async () => { +test("a submit_output tool call with the current step id advances the runtime through the director", async () => { const runtime = new WorkflowRuntime(emptyCaps, (n) => (n === "flow" ? flow : undefined)); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); @@ -137,7 +138,14 @@ test("an advance_workflow tool call advances the runtime through the director", type: "inference.done", turn: { role: "assistant", - content: [{ type: "tool_call", id: "call-1", name: "advance_workflow", arguments: {} }], + content: [ + { + type: "tool_call", + id: "call-1", + name: "submit_output", + arguments: { step: "a" }, + }, + ], model: "test-model", timestamp: 0, }, @@ -152,6 +160,72 @@ test("an advance_workflow tool call advances the runtime through the director", expect(runtime.currentStep()?.id).toBe("b"); }); +test("a stale submit_output does not skip ahead through the director", async () => { + const runtime = new WorkflowRuntime(emptyCaps, (n) => (n === "flow" ? flow : undefined)); + runtime.start(flow); + const coordinator = new WorkflowCoordinator(runtime); + const director = createChatDirector("BASE", [], { + onTasksChange: () => {}, + workflowCoordinator: coordinator, + }); + const caps = makeCapabilities(); + + const turn: ReactorInboundEvent = { + type: "inference.done", + turn: { + role: "assistant", + content: [ + { + type: "tool_call", + id: "call-1", + name: "submit_output", + arguments: { step: "a" }, + }, + ], + model: "test-model", + timestamp: 0, + }, + usage, + source: { sourceId: "t", provider: "openai", model: "test-model" }, + }; + await director.decide(turn, state, caps); + await director.decide( + { type: "tool.done", result: { callId: "call-1", content: "Advancing.", isError: false } }, + state, + caps, + ); + expect(runtime.currentStep()?.id).toBe("b"); + + const stale: ReactorInboundEvent = { + type: "inference.done", + turn: { + role: "assistant", + content: [ + { + type: "tool_call", + id: "call-2", + name: "submit_output", + arguments: { step: "a" }, + }, + ], + model: "test-model", + timestamp: 0, + }, + usage, + source: { sourceId: "t", provider: "openai", model: "test-model" }, + }; + await director.decide(stale, state, caps); + await director.decide( + { + type: "tool.done", + result: { callId: "call-2", content: "already complete", isError: false }, + }, + state, + caps, + ); + expect(runtime.currentStep()?.id).toBe("b"); +}); + function hasInfer(result: ReactorAction | ReactorAction[]): boolean { return (Array.isArray(result) ? result : [result]).some((a) => a.type === "infer"); } @@ -229,9 +303,9 @@ function emptyTurn(): ReactorInboundEvent { } // A content-free terminal turn inside an active workflow is nudged by the -// general open-task guard, which must point at advance_workflow rather than the +// general open-task guard, which must point at submit_output rather than the // non-workflow manage_tasks guidance. -test("a content-free workflow turn with open tasks nudges toward advance_workflow", async () => { +test("a content-free workflow turn with open tasks nudges toward submit_output", async () => { const runtime = new WorkflowRuntime(emptyCaps, (n) => (n === "flow" ? flow : undefined)); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); @@ -244,7 +318,7 @@ test("a content-free workflow turn with open tasks nudges toward advance_workflo await director.decide(manageTasksTurn("doing"), state, caps); const result = await director.decide(emptyTurn(), state, caps); - // The active step directive always appends its own "call advance_workflow" + // The active step directive always appends its own "call submit_output" // line, so assert against text unique to the workflow nudge and the absence // of the general nudge's phrasing — otherwise the test passes either way. const infers = inferActions(result); diff --git a/tests/unit/workflows-runtime.test.ts b/tests/unit/workflows-runtime.test.ts index 9d98637f..bc607683 100644 --- a/tests/unit/workflows-runtime.test.ts +++ b/tests/unit/workflows-runtime.test.ts @@ -140,16 +140,23 @@ test("coordinator directive includes the ordinal, label, prompt, and completion const directive = coord.directive(); expect(directive).toContain("[WORKFLOW STEP 1/2: A]"); expect(directive).toContain("do a"); - expect(directive).toContain("advance_workflow"); + expect(directive).toContain("submit_output"); expect(directive).toContain('"step": "a"'); + expect(directive).not.toContain("advance_workflow"); }); -test("coordinator advances on advance_workflow", () => { +test("runtime complete is a compare-and-advance against the current step", () => { const rt = new WorkflowRuntime(empty, resolver); rt.start(simple); - const coord = new WorkflowCoordinator(rt); - expect(coord.handleToolDone("advance_workflow", {}, false)).toBe(true); + expect(rt.complete("b")).toBe("not-current"); + expect(rt.complete("a")).toBe("advanced"); + expect(rt.currentStep()?.id).toBe("b"); + expect(rt.complete("a")).toBe("already-complete"); expect(rt.currentStep()?.id).toBe("b"); + expect(rt.complete("zzz")).toBe("not-current"); + expect(rt.currentStep()?.id).toBe("b"); + expect(rt.complete("b")).toBe("advanced"); + expect(rt.isComplete()).toBe(true); }); test("coordinator advances on submit_output tagged with the current step", () => { @@ -160,6 +167,23 @@ test("coordinator advances on submit_output tagged with the current step", () => expect(rt.currentStep()?.id).toBe("b"); }); +test("coordinator requires a step identifier to complete", () => { + const rt = new WorkflowRuntime(empty, resolver); + rt.start(simple); + const coord = new WorkflowCoordinator(rt); + expect(coord.handleToolDone("submit_output", { summary: "done" }, false)).toBe(false); + expect(coord.handleToolDone("submit_output", {}, false)).toBe(false); + expect(rt.currentStep()?.id).toBe("a"); +}); + +test("coordinator does not advance on advance_workflow", () => { + const rt = new WorkflowRuntime(empty, resolver); + rt.start(simple); + const coord = new WorkflowCoordinator(rt); + expect(coord.handleToolDone("advance_workflow", {}, false)).toBe(false); + expect(rt.currentStep()?.id).toBe("a"); +}); + test("coordinator ignores submit_output tagged with a different step", () => { const rt = new WorkflowRuntime(empty, resolver); rt.start(simple); @@ -168,10 +192,35 @@ test("coordinator ignores submit_output tagged with a different step", () => { expect(rt.currentStep()?.id).toBe("a"); }); +test("coordinator treats completed steps as past and future ids as not past", () => { + const rt = new WorkflowRuntime(empty, resolver); + rt.start(simple); + const coord = new WorkflowCoordinator(rt); + expect(coord.isPastStep("a")).toBe(false); + expect(coord.isPastStep("b")).toBe(false); + expect(coord.handleToolDone("submit_output", { step: "a" }, false)).toBe(true); + expect(coord.isPastStep("a")).toBe(true); + expect(coord.isPastStep("b")).toBe(false); + expect(coord.isPastStep("zzz")).toBe(false); +}); + +test("duplicate and stale submit_output completions do not advance", () => { + const rt = new WorkflowRuntime(empty, resolver); + rt.start(simple); + const coord = new WorkflowCoordinator(rt); + expect(coord.handleToolDone("submit_output", { step: "a" }, false)).toBe(true); + expect(rt.currentStep()?.id).toBe("b"); + expect(coord.handleToolDone("submit_output", { step: "a" }, false)).toBe(false); + expect(rt.currentStep()?.id).toBe("b"); + expect(coord.handleToolDone("submit_output", { step: "b" }, false)).toBe(true); + expect(rt.isComplete()).toBe(true); + expect(coord.handleToolDone("submit_output", { step: "b" }, false)).toBe(false); +}); + test("coordinator ignores errored tool calls", () => { const rt = new WorkflowRuntime(empty, resolver); rt.start(simple); const coord = new WorkflowCoordinator(rt); - expect(coord.handleToolDone("advance_workflow", {}, true)).toBe(false); + expect(coord.handleToolDone("submit_output", { step: "a" }, true)).toBe(false); expect(rt.currentStep()?.id).toBe("a"); });