diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 398deb79ba..e67e2bce6d 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -9,6 +9,8 @@ import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" import DAG_FLOW_PROMPT from "./command/dag-flow.txt" import DAG_TEMPLATE_UPDATE_PROMPT from "./command/dag-template-update.txt" +import workflowRouting from "./command/workflow-routing.md" with { type: "text" } +import workflowBlocks from "./command/workflow-blocks.md" with { type: "text" } import workflowContent from "./command/workflow.md" with { type: "text" } import orchestrationPolicy from "./command/orchestration-policy.md" with { type: "text" } import orchestrationDomains from "./command/orchestration-domains.md" with { type: "text" } @@ -16,10 +18,11 @@ import orchestrationDomains from "./command/orchestration-domains.md" with { typ export const DagFlowDescription = "Start a dependency-graph multi-agent workflow for the supplied task" export const DagTemplateUpdateDescription = "Update the global DAG reference templates from opencode-dag-config" export const WorkflowFactsContent = workflowContent +export const WorkflowBlocksContent = workflowBlocks export const OrchestrationPolicyContent = orchestrationPolicy export const OrchestrationDomainsContent = orchestrationDomains -export const WorkflowContent = `${WorkflowFactsContent}\n\n${OrchestrationPolicyContent}\n\n${OrchestrationDomainsContent}` -export const DagFlowContent = `${DAG_FLOW_PROMPT}\n\n${WorkflowContent}` +export const WorkflowContent = workflowRouting +export const DagFlowContent = DAG_FLOW_PROMPT export const Plugin = define({ id: "command", diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index 033b646d63..d5edd9bd41 100644 --- a/packages/core/src/plugin/command/dag-flow.txt +++ b/packages/core/src/plugin/command/dag-flow.txt @@ -1,43 +1,39 @@ # Start a DAG Workflow -The user invoked `/dag-flow` to start a new orchestration task. - $ARGUMENTS -If the content inside `` is empty or contains only whitespace, ask the user what task should be orchestrated. Do not call the `workflow` tool until the user provides a task. - -For a non-empty task: - -1. Before starting, classify the task as `brainstorm`, `review`, or `develop`, then select the closest reference topology from the workflow library. Saved workflow names resolve through three scopes (first match wins): project `.opencode/workflows/`, global `/workflows/` (curated by the `opencode-dag-config` repo), then the builtin templates embedded in release binaries. - Run `workflow(action: "list")` to see every template that actually resolves in this environment with its scope, then pick by NAME: - - design documents, requirement deep-dives, architecture decisions, or design-level debugging → saved workflow `design-decision-loop` - - end-to-end implementation with multiple modules, wiring, tests, and review → saved workflow `parallel-development-loop` - - deep review of an already-built module, subsystem, or codebase → saved workflow `deep-review-dag-module` - - a small bounded working-tree change review → saved workflow `change-review` - - none of the above names resolves (bare dev checkout without the config repo) → compose the smallest fresh graph; do not force an unrelated reference -2. Treat the selected saved spec as a reviewed topology reference, not as a script to replay blindly. Start a saved workflow by name only when its embedded target and inputs already match the request. Otherwise read the reference, derive one inline `spec`, inject the complete `/dag-flow` task into its root planning/exploration prompt, retarget its lanes, and pass it directly to `workflow(action=start)`. Do not create a transient YAML file. -3. The derived graph may expand or prune non-protected lanes. Record the selected `reference_template`, every added node, and every prune as `{node, prune_reason, replacement_coverage}` in the first planning/exploration artifact; require the next fresh review gate to audit that manifest. Missing prune evidence is fail-closed. -4. Preserve the selected reference's protected spine: fresh-context local review, deterministic/evidence verification where applicable, one final arbiter, and PASS-only finalization. Gates return `PASS | LOOP | BLOCKED` with reason, evidence, minimal `loop_scope`, and `stop_reason`. `LOOP` means pause → replan new local correction/review nodes → resume; never create a cycle or restart terminal nodes. -5. During compilation, preserve every user constraint in the graph, including named `@agent` roles, exact model selections, read-only or "Do not modify files" scope, required checks, forbidden actions, and requested deliverables. -6. Resolve capability slots against the eligible configured worker types shown in the `workflow` tool description. Do not invent a missing role or model; if a required capability cannot be resolved, do not start and report the gap. -7. Scale one consolidated graph to the task's blast radius. Related flows for this user objective become nodes and edges under the same workflow ID. A small, well-bounded target gets the smallest useful dependency graph. A large or system-level target (an entire module, subsystem, or codebase) is never satisfied by a single wave of parallel opinions: stage exploration, independent analysis, evidence verification, and synthesis as separate dependent waves. Keep independent viewpoints or work packages parallel and use real fan-in nodes for synthesis, arbitration, integration, and final reporting. -8. For a large-target review or audit, require every reviewer to cite file:line evidence and to mark claims it could not verify. Insert a verification wave between the reviewers and the arbiter that checks disputed, unverified, and uncovered scope against the actual code, so the arbiter rules on verified findings only. -9. Call the `workflow` tool with `action=start` and inline `spec` in this response. Use `spec_path` only when the selected saved workflow already matches or persistence was explicitly requested. Merely printing a plan, graph, JSON, or YAML does not mean a workflow was started. -10. Do not claim the workflow is running unless the tool call succeeds. On success, report the exact Workflow ID and initial state returned by the tool, then tell the user to run `/dag` for live inspection. -11. The workflow runs asynchronously and wakes this parent session when attention or a terminal result is ready. Do not poll it with `action=status`, sleep, retry, or loop merely to wait. End the current response after the brief success report. -12. On failure, state that the workflow was not started and report the actual error. Never invent a Workflow ID or start a replacement workflow unless the user explicitly asked for automatic retries. -13. A completed aggregate node must actually contain the requested synthesis. Never describe unresolved placeholders or an aggregate-node error message as a successful final result. - -## Resume-first: continue an interrupted workflow before restarting - -When the current task maps to a previously interrupted workflow (same task retried or resumed), the default instinct to "restart the whole graph" is usually wrong — completed node outputs are durable and reusable. First read `workflow(action=status)` on the prior workflow: every failed node carries `error_class` (runtime classes: timeout / exec_failed / verdict_fail) plus `error_reason` — except nodes cancelled via replan (failed with reason "cancelled via replan", no error_class) and rows written before the error_class migration — triage per the Node failure triage section in the workflow guidance, then recover in this order, and only fall back to a full restart when nothing reusable exists: - -1. **Paused recovery (crash recovery)**: if the prior workflow is `paused`, never open a new one. The failed node is terminal and immutable — add a replacement node under a NEW id, rewire its pending dependents' `depends_on` to the new id, then `control(resume)`. Downstream nodes stay pending and keep their state. -2. **Continue from completed waves**: if the prior workflow is terminal (`failed` or `cancelled`) but has nodes that `completed` before the failure, their final outputs are still valid. Extract each completed node's output (its final text result, e.g. from the node session's persisted parts or any artifact it wrote) and compile a **continuation spec** that starts at the first unfinished wave. Inject the reused outputs as static context into the downstream node prompts (do not re-run them), add only the missing nodes, and `workflow(action=start)` it. Record `reused_nodes` in the manifest. -3. **Full restart**: only when no completed-node output is reusable — zero completed nodes, or their outputs are empty/irrelevant to the remaining work — re-derive the full graph and start it. - -Fail-closed guard: before starting a continuation, verify every reused output is present and non-empty; if extraction is incomplete, fall back to the affected node's fresh run rather than silently continuing on empty input. Never discard completed work to rerun it from zero unless extraction genuinely fails. - -Use the orchestration guidance below to design and manage the workflow. +If the task is empty, ask for it and do not start a workflow. Otherwise load +the `orchestration-router` skill and route the request through one consolidated +graph. `/dag-flow` explicitly selects DAG execution, but it does not waive a +material user decision. + +When the route requires a decision checkpoint or GRILL qualification, inspect +discoverable facts first, proactively write recommended answers, surface the +compact brief in the main conversation, and ask for one combined confirmation. +Do not call `workflow(action="start")` until that confirmation arrives. Do not +put the checkpoint in a child node. If the request is already bounded and +confirmed, start without manufacturing another question. + +Prefer composable blocks for a fresh flow. Load +`workflow(action="guide", topic="blocks")` only if the block contract is not +already in context. Use inline `spec` for one-off work; use `spec_path` only +when a saved workflow already matches or persistence was requested. Preserve +the task, user constraints, named roles, read-only limits, acceptance checks, +and confirmed decisions in the objective and block instructions. + +Call the workflow tool with `action=start` in the first response after the +route is ready. Printing a plan, JSON, or YAML does not start it. Never invent +worker types or model IDs. If a configured capability or model is unavailable, +report the actual gap and leave the workflow uncreated. + +On success, report the exact Workflow ID and initial state, tell the user they +can run `/dag` for live inspection, and end the response. The workflow wakes +the parent when attention is needed. Do not poll, sleep, or loop to wait. On +failure, state that it did not start and report the real error; do not invent a +replacement run. + +A final synthesis block must contain the requested result rather than a plan or +placeholder. The parent verifies that artifact, disposes of any non-ACCEPT +review verdict, and gives the user one final report. diff --git a/packages/core/src/plugin/command/orchestration-policy.md b/packages/core/src/plugin/command/orchestration-policy.md index f591c5e762..b51985896f 100644 --- a/packages/core/src/plugin/command/orchestration-policy.md +++ b/packages/core/src/plugin/command/orchestration-policy.md @@ -61,13 +61,15 @@ Choose the smallest child execution mode that can safely complete the request: 1. Use direct execution only for conversation, trivial state inspection, workflow control, final synthesis, or an explicit user opt-out. -2. Use one `task` subagent for one independent non-trivial leaf assignment when - no graph-level coordination is needed. The parent launches it once, consumes - its result, and does not duplicate the leaf work. -3. Use one live `workflow` DAG when one user objective contains staged - dependencies, two or more related workstreams, a quality gate, unknown-size - discovery, adaptive repair, or an explicit multi-role or multi-model - requirement. +2. Use one `task` subagent for one independent non-trivial leaf assignment + outside a project-level source or test change when no graph-level + coordination is needed. The parent launches it once, consumes its result, + and does not duplicate the leaf work. +3. Use one live `workflow` DAG for project-level source or test changes, even + when only one project file is expected, and whenever one user objective + contains staged dependencies, two or more related workstreams, a quality + gate, unknown-size discovery, adaptive repair, or an explicit multi-role or + multi-model requirement. "Smallest" is measured against the Depth Ladder: a mode or graph that cannot deliver the ladder's hard minimum for the target size is not safe, merely @@ -99,7 +101,8 @@ graph. You MUST NOT create an admission child node, QA workflow, separate persona, or privileged command. `GRILL-ME` selects `GRILL`; equivalent explicit requests for adversarial qualification do the same. -Cover these six dimensions, asking only material unresolved questions: +Cover these six dimensions, resolving repository-discoverable facts before +asking the user: 1. goal; 2. scope; @@ -108,15 +111,23 @@ Cover these six dimensions, asking only material unresolved questions: 5. evidence and review; 6. risks and failure modes. -Use one adaptive policy with bounded modes: +Use one parent-owned recommendation and confirmation interaction. Fill every +material open decision with a recommended answer based on available evidence, +show alternatives only when they change the result, then ask the user for one +combined confirmation. Do not drip questions across several turns. A user +correction creates a revised brief and one replacement confirmation; unchanged +facts are not asked again. -- `LIGHT`: at most 1 question round for a nearly complete brief. -- `STANDARD`: at most 3 question rounds and the default for deep admission. -- `GRILL`: at most 5 question rounds, probing contradictions, hidden - assumptions, evidence quality, failure modes, and falsifiers. +The modes control challenge depth, not the number of user question rounds: -Stop early as soon as the brief is ready. Exhausting a budget with unresolved -blockers yields `NOT_READY`; it never silently yields `READY`. +- `LIGHT`: validate a nearly complete brief and expose only blockers. +- `STANDARD`: test scope, acceptance evidence, dependencies, and material + delivery risks. +- `GRILL`: additionally probe contradictions, hidden assumptions, evidence + quality, failure modes, and falsifiers, while still recommending an answer + for every surfaced choice. + +Unresolved blockers yield `NOT_READY`; they never silently yield `READY`. Maintain a versioned Requirement Brief with this structure: @@ -138,7 +149,8 @@ Maintain a versioned Requirement Brief with this structure: } ``` -Before start, show a concise brief summary and verdict: +Before start, proactively show the recommended answers, a concise brief +summary, and verdict: `READY | NOT_READY | WAIVED`, plus QA mode, brief revision, and remaining blockers. `READY` requires a non-empty goal, scope boundaries, acceptance criteria, evidence obligations, review plan, and no blocking diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md new file mode 100644 index 0000000000..d2aaa7a6b4 --- /dev/null +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -0,0 +1,110 @@ +# Composable Workflow Blocks + +Blocks are the high-level interface for assembling a one-off workflow. The +tool compiles them into ordinary durable DAG nodes before validation and +persistence. Existing node-based YAML remains compatible. + +## Shape + +Use `objective` and `blocks` inside `config` for **start**, or alongside +`blocks` for **extend**. A replan uses the same fields inside `fragment`. + +```yaml +config: + name: implement-session-recovery + objective: Implement session recovery with focused tests and evidence-backed review. + blocks: + - id: map + kind: explore + instruction: Locate the ownership and persistence seams. + - id: design + kind: plan + depends_on: [map] + - id: implement + kind: coding + depends_on: [design] + skills: [tdd] + - id: checks + kind: verify + depends_on: [implement] + - id: decision + kind: review + depends_on: [checks] + skills: [code-review] +``` + +Each block accepts: + +- `id`: unique dependency address and the ID of its compiled exit node. +- `kind`: `explore`, `plan`, `prototype`, `debug`, `coding`, `verify`, + `review`, or `synthesize`. +- `depends_on`: upstream block IDs; omitted means a root block. +- `instruction`: target-specific text added to the built-in block contract. +- `skills`: relevant skill names the child loads lazily when available. +- `worker_type`, `required`, `report_to_parent`: optional overrides. + +`objective` is required and is injected into every generated node. Use blocks +or nodes, never both. Block IDs use letters, numbers, underscores, and hyphens. +Dependencies must be acyclic; they may name blocks in the submitted fragment +or existing durable node IDs during **extend** and replan. + +## Block contracts + +- `explore`: read-only repository mapping and evidence collection. +- `plan`: implementation-ready decomposition, seams, checks, and risks. +- `prototype`: the smallest throwaway experiment that resolves a runnable + uncertainty; it does not silently become production code. +- `debug`: expands to reproduce/evidence followed by root-cause diagnosis. +- `coding`: bounded production implementation plus focused tests and checks. +- `verify`: deterministic acceptance checks with explicit PASS/FAIL evidence. +- `review`: expands to independent standards and intent reviews, then one + structured arbiter returning `ACCEPT | REVISE | REJECT | BLOCKED`. +- `synthesize`: resolves dependency outputs into the parent-facing result. + +Every compiled block is required by default. `review` and `synthesize` report +to the parent by default; other blocks stay quiet. A block immediately after a +review gate is conditioned on `ACCEPT`. Because the condition language handles +one verdict reference, fan multiple review lanes into one review block before +continuing. + +## Composition routes + +Choose only blocks justified by current evidence: + +- Product or architecture decision: parallel `explore` lanes → `plan` options + → `review` or `synthesize`. +- Project feature: optional `explore` → `plan` → parallel `coding` packages → + `verify` → `review`. +- Hard bug: `debug` → `coding` → `verify` → `review`. +- Runnable design uncertainty: `prototype` → `plan`; keep the prototype + disposable unless the confirmed scope explicitly promotes it. +- Existing implementation review: `explore` scope lanes → `review`; add a + separate verification block first when test evidence is required. + +Do not add a phase merely because it exists. Skip exploration when repository +facts are already known, skip a prototype when ordinary inspection resolves +the question, and keep independent work parallel. Use `synthesize` only when +multiple outputs need reconciliation. + +## Parent decision checkpoint + +User qualification is not a DAG block. Before executable blocks start, the +parent gathers facts it can discover, creates recommended answers for every +material open decision, displays one compact decision brief, and asks for one +combined confirmation. The brief contains the recommended route, alternatives +only where they change the result, assumptions, risks, scope, and acceptance +evidence. A correction from the user updates the brief; unchanged confirmed +facts are not asked again. + +After confirmation, encode the decision in `objective` and block instructions. +If the request is already fully bounded and confirmed, do not manufacture a +redundant checkpoint. Child nodes never ask the user to make product or scope +decisions. + +## When to use low-level nodes + +Drop to `nodes` for custom template bindings, several conditional branches, +special output schemas, exact retry/cancel/restart controls, or deep diff-review +metadata. Load `guide(topic=interface)` for the full node interface and +`guide(topic=policy)` for gate and recovery contracts. Do not poll a running +workflow; reporting blocks wake the parent when a decision is actionable. diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md new file mode 100644 index 0000000000..86032b3fc9 --- /dev/null +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -0,0 +1,61 @@ +# Workflow Orchestration + +In the user-facing parent session, use this tool proactively when one user +objective needs staged, parallel, quality-gated, or adaptive execution. A slash +command is not required. A DAG child session executes its assigned block and +must not recursively route that assignment into another workflow. + +## Execution Mode Selection + +- Use direct execution for conversation, a small read-only lookup, or one or + two isolated utility scripts outside a project-level change. +- Use one `task` subagent for one independent non-trivial leaf assignment. +- Use one `workflow` DAG for project-level source or test changes (even when + only one project file is expected), work that crosses module boundaries, + product/architecture planning that needs repository exploration, or any + staged/parallel/gated/adaptive objective. + +Related work for one objective belongs in one live workflow. The parent +conversation owns user decisions, scope, checkpoints, workflow control, and +the final synthesis. Child nodes own executable leaf work. Explicit requests +for “single agent”, “do not use DAG”, or direct work disable proactive DAG +selection. Read-only scope changes what nodes may do; it does not by itself +disable a useful exploration or review DAG. + +For a project-level route, load the `orchestration-router` skill before +constructing the graph. It selects the smallest useful sequence of composable +blocks and defines the one-confirmation decision checkpoint. Do not place user +questioning inside a child node. + +## Progressive guidance + +Load details only when needed: + +- **guide** without `topic`: compact topic index. +- **guide** `topic=blocks`: composable block schema and examples. +- **guide** `topic=interface`: low-level node fields and tool semantics. +- **guide** `topic=policy`: admission, gates, recovery, and bounded repair. +- **guide** `topic=patterns`: larger domain playbooks. + +## Actions + +- **start** creates one workflow from exactly one inline `spec` or saved + `spec_path`. +- **extend** adds nodes or blocks to the same objective. +- **status** reads durable state when the user asks or before a control + decision; it is not a waiting mechanism. +- **control** pauses, resumes, cancels, replans, steps, or completes a workflow. +- **list** shows saved workflow specs and their resolution scope. +- **read** returns one saved spec so the parent can retarget it before start. + +Prefer high-level `blocks` for a fresh one-off flow. Use low-level `nodes` when +the task needs custom bindings, conditions, output schemas, or review metadata. +Never provide both. Reusable saved YAML remains valid and may use either form. +When a saved route is generic, call **read**, replace its objective and +block-specific instructions with the confirmed request, then pass the result +as an inline **start** spec. Start by `spec_path` only when the saved target +already matches exactly. + +The workflow runs asynchronously and wakes the parent at actionable reporting +nodes or terminal state. Do not poll, sleep, or loop merely to wait. Never +claim a workflow started unless **start** returned its exact workflow ID. diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index 937a1b9f11..bfa260a205 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -11,20 +11,22 @@ Compile every graph under the Tiered Orchestration Doctrine and Depth Ladder in ## When to start a workflow -Use one live workflow when a user objective has any of these structural -signals: +Use one live workflow for project-level source or test changes, even when only +one project file is expected, and when a user objective has any of these +structural signals: - **Staged**: clear phase boundaries where later phases depend on earlier outputs (explore → plan → implement → verify). - **Parallelizable**: ≥2 related sub-units can execute concurrently (same fix across 5 packages). - **Quality gate**: intermediate output must pass review before downstream work begins (architecture review before implementation). - **Adaptive scope**: discovery may reveal an unknown number of work packages or require a bounded repair wave. -Use one `task` subagent for one independent non-trivial leaf assignment. Keep -related staged, parallel, gated, or adaptive flows under one workflow ID; use -`extend` or `control(replan)` instead of starting disconnected DAGs. An explicit -`/dag-flow` request always selects a workflow. Direct tools in the parent are -reserved for conversation, trivial state inspection, workflow control, and -final synthesis. +Use one `task` subagent for one independent non-trivial leaf assignment outside +a project-level source or test change. Keep related staged, parallel, gated, or +adaptive flows under one workflow ID; use `extend` or `control(replan)` instead +of starting disconnected DAGs. An explicit `/dag-flow` request always selects +a workflow. Explicit “single agent”, “do not use DAG”, and direct-execution +requests opt out. Direct tools in the parent are reserved for conversation, +trivial state inspection, workflow control, and final synthesis. ## Standard and deep workflow entry @@ -90,9 +92,9 @@ resolves nowhere fails with the directories that were searched. Prefer a saved workflow when the user names a recurring procedure ("run the code review workflow") and the saved target/inputs already match: starting it -is one call, and its graph has already been reviewed. `/dag-flow` may also read -a saved workflow as a topology reference, then derive a one-off spec that -injects the current task, retargets module lanes, and records additions/prunes. +is one call, and its graph has already been reviewed. When only its topology +matches, call `{ action: "read", spec_path: "code-review" }`, retarget its objective and block instructions to the current task, prune or add lanes, then +start that edited value as an inline spec. `read` never starts a workflow. Compose a fresh inline `spec` when the task is one-off or no reference fits. To turn a working one-off spec into a saved workflow, persist it as YAML in one of the two workflow-library directories under a descriptive name. @@ -549,6 +551,10 @@ execution order are computed automatically. scope) with their names, titles, and node counts. This lists reusable specs, not running workflows; use `status` for a workflow's live state. +**read** — Return one saved workflow as structured JSON without starting it. +Pass `spec_path`, then retarget generic objectives and block instructions in +the parent before using the edited result as an inline `start` spec. + **extend** — Add nodes to a running workflow. Existing nodes are unaffected; new nodes are immediately eligible for scheduling if their dependencies are met. It also accepts a genuinely additive wave after a reporting leaf diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 43b76800ea..c027b73302 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -9,10 +9,12 @@ import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } import configureHooksContent from "./skill/configure-hooks.md" with { type: "text" } import createDagWorkflowContent from "./skill/create-dag-workflow.md" with { type: "text" } +import orchestrationRouterContent from "./skill/orchestration-router.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent export const ConfigureHooksContent = configureHooksContent export const CreateDagWorkflowContent = createDagWorkflowContent +export const OrchestrationRouterContent = orchestrationRouterContent export const CustomizeOpencodeDescription = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." @@ -21,7 +23,10 @@ export const ConfigureHooksDescription = "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." export const CreateDagWorkflowDescription = - "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers the project (.opencode/workflows/) and global (config dir) scopes, the spec file shape, and how to verify a new workflow. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." + "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers project/global scopes, composable blocks, low-level nodes, and verification. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." + +export const OrchestrationRouterDescription = + "Use proactively in the user-facing parent session, without waiting for /dag-flow, whenever one objective changes project source or tests (even one project file), crosses module boundaries, needs repository-backed product/architecture planning, or has staged, parallel, quality-gated, or adaptive execution. Routes work through a parent-owned decision checkpoint and composable DAG blocks. Do not use inside a DAG child session, for one or two isolated utility scripts, simple lookup/conversation, or when the user explicitly requests direct work, one agent, or no DAG." export const Plugin = define({ id: "skill", @@ -60,6 +65,17 @@ export const Plugin = define({ }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "orchestration-router", + description: OrchestrationRouterDescription, + location: AbsolutePath.make("/builtin/orchestration-router.md"), + content: OrchestrationRouterContent, + }), + }), + ) }) }), }) diff --git a/packages/core/src/plugin/skill/create-dag-workflow.md b/packages/core/src/plugin/skill/create-dag-workflow.md index f0091531fc..58c23fc2ae 100644 --- a/packages/core/src/plugin/skill/create-dag-workflow.md +++ b/packages/core/src/plugin/skill/create-dag-workflow.md @@ -8,9 +8,9 @@ A saved workflow is a YAML spec that lives on disk under a name, so a recurring multi-agent procedure can be started with one call instead of being redesigned -every time. This skill covers authoring one. The `workflow` tool's own -documentation covers graph semantics — read it for node fields, collaboration -patterns, and replanning. +every time. This skill covers authoring one. Load +`workflow(action: "guide", topic: "blocks")` for the high-level interface or +`topic: "interface"` for low-level node fields and replanning. ## Where the file goes @@ -35,9 +35,9 @@ gets reused, so a wrong assumption gets repeated: 1. **The trigger.** What does the user say to run this? That phrasing should be recognizable in the workflow's `title`. 2. **The phases.** Which steps genuinely depend on an earlier step's output, and which are independent? Only real data dependencies become `depends_on` edges; everything else runs in parallel. -3. **The gate.** Is there a point where downstream work must not start until quality is confirmed? That becomes a node with `output_schema` returning a verdict plus a `condition` on its dependents. -4. **The inputs.** Does the graph need per-run values (a target module, a diff range)? A saved spec is static, so express them as static `prompt_template.input` defaults and state in the node prompt that the parent may narrow the target — or keep the node prompt broad enough to work unchanged. -5. **The finish.** What does a successful run produce, and which node reports it? Give that node `report_to_parent: true`. +3. **The gate.** Is there a point where downstream work must not start until quality is confirmed? Prefer a `review` block; use low-level nodes for custom verdict branches. +4. **The inputs.** Does the graph need per-run values? Put the stable purpose in `objective` and retargetable details in block `instruction`; use low-level template inputs only when bindings are necessary. +5. **The finish.** What does success produce? End with `review` when its verdict is the result, or `synthesize` when several accepted artifacts need a parent-facing report. ## File shape @@ -47,52 +47,27 @@ config: name: code-review max_concurrency: 5 node_defaults: - required: false - report_to_parent: false worker_config: timeout_ms: 600000 - nodes: - - id: explore - name: explore - worker_type: explore - depends_on: [] - required: true - prompt_template: - id: code-explore - input: - target: "the packages changed in the working tree" - - - id: review-logic - name: review-logic - worker_type: general - depends_on: [explore] - prompt_template: { id: review-logic } - - - id: review-arch - name: review-arch - worker_type: general - depends_on: [explore] - prompt_template: { id: review-arch } - - - id: arbitrate - name: arbitrate - worker_type: general - depends_on: [review-logic, review-arch] - required: true - report_to_parent: true - output_schema: - type: object - required: [verdict, summary, findings] - properties: - verdict: - type: string - enum: [ACCEPT, REVISE, REJECT, BLOCKED] - summary: { type: string } - findings: { type: array } - prompt_template: - inline: "Two reviewers produced findings. Submit one deduplicated verdict with evidence-backed findings." + objective: Review the working-tree change against repository standards and confirmed intent. + blocks: + - id: survey + kind: explore + instruction: Inspect the complete diff, affected modules, and repository instructions. + - id: checks + kind: verify + depends_on: [survey] + instruction: Run the documented gates from the affected package directories. + - id: decision + kind: review + depends_on: [survey, checks] ``` +Use blocks for the common explore/plan/prototype/debug/coding/verify/review/ +synthesize routes. Drop to `nodes` only for custom bindings, multiple verdict +branches, specialized output schemas, restart/cancel fragments, or deep diff +review metadata. Never declare both `blocks` and `nodes` in one graph. + `title` and `config` sit at the file root. A deep workflow adds `mode: deep` and an `admission` block at the same level — but admission answers are per-request, so a saved spec is usually `standard`; let the parent run the @@ -111,7 +86,7 @@ admission Q&A and write a one-off deep spec when depth is needed. A spec is only proven by a real start. After writing the file: -1. `workflow(action: "list")` — confirm the name resolves and the reported node count matches the file. A file missing from the listing is in the wrong directory or has the wrong extension (`.yaml`/`.yml` only). +1. `workflow(action: "list")` — confirm the name resolves and the reported block/node count matches the file. A file missing from the listing is in the wrong directory or has the wrong extension (`.yaml`/`.yml` only). 2. `workflow(action: "start", spec_path: "")` on a small, real target. Schema and graph validation happen here: an invalid spec fails the start with the offending field, and no workflow is created. 3. Read the wake report when it arrives. A graph that "succeeded" while its fan-in node produced an empty synthesis is not working — check that the reporting node's output actually contains the comparison or decision the procedure exists to produce. diff --git a/packages/core/src/plugin/skill/orchestration-router.md b/packages/core/src/plugin/skill/orchestration-router.md new file mode 100644 index 0000000000..9d264918a5 --- /dev/null +++ b/packages/core/src/plugin/skill/orchestration-router.md @@ -0,0 +1,92 @@ + + +# Orchestration Router + +Turn one user objective into the smallest execution route that preserves user +control and produces verifiable evidence. The router decides; workflow blocks +execute. Do not copy the whole playbook into the parent response. + +This skill belongs to the user-facing parent session. If the current prompt +identifies this session as a DAG child or assigns one bounded block, execute +that assignment directly and do not create a nested workflow. + +## 1. Establish facts before asking + +Read repository instructions and inspect enough code, tests, history, or +runtime evidence to answer discoverable questions yourself. Separate: + +- confirmed facts; +- decisions only the user can make; +- runnable uncertainties best answered by a disposable prototype; +- implementation work suitable for child sessions. + +Do not ask the user for file locations, conventions, or current behavior that +the repository can reveal. + +## 2. Select the execution lane + +Use direct work for conversation, a bounded lookup, or one or two isolated +utility scripts outside a project-level change. Use one task child for one +independent non-trivial leaf. Use one workflow without waiting for `/dag-flow` +whenever the objective changes project source or tests—even when only one +project file is expected—spans modules, requires repository-backed product or +architecture planning, or has staged, parallel, gated, or adaptive work. + +Honor explicit “single agent”, “do not use DAG”, and direct-execution requests. +Keep all related work for one objective under one workflow ID; adapt it with +extend/replan rather than creating disconnected graphs. + +## 3. Run one parent-owned decision checkpoint when needed + +Use a decision checkpoint for material product choices, conflicting +constraints, high-blast-radius architecture, or an explicit GRILL request. It +must happen in the parent conversation before executable DAG blocks start. + +Generate recommended answers proactively. Present one compact brief containing: + +1. recommended route and why; +2. scope in/out and acceptance evidence; +3. assumptions and risks; +4. alternatives only where the choice materially changes the result; +5. one combined confirmation request. + +Wait for that confirmation. Do not hide the recommendation inside tool output, +start speculative implementation, or delegate the questions to a child. If the +user changes an answer, revise only affected fields and ask one new combined +confirmation. If the request already supplies an equivalent confirmed brief, +do not repeat the checkpoint. + +## 4. Compose blocks from the route + +Call `workflow(action="guide", topic="blocks")` when the block interface is +not already in context. Select only justified blocks: + +- product/design: evidence lanes → competing plans when useful → synthesis or + review decision; +- feature: optional explore → plan → independent coding packages → verify → + review; +- bug: debug → coding → verify → review; +- runnable uncertainty: prototype detour → update the plan; +- review-only: scope exploration → independent review and arbitration. + +When a reusable route matches the topology, call +`workflow(action="read", spec_path="")`, retarget the objective and +block instructions to the confirmed request, prune unjustified blocks, and +start the edited result as an inline spec. Start the saved `spec_path` directly +only when its target already matches exactly. + +Use a skill name on a block only when it appears in the available skill +catalog. Test-first implementation and standards/spec review belong in their +respective coding and review blocks, not in the always-on router prompt. + +## 5. Preserve ownership boundaries + +The parent owns the confirmed brief, graph shape, user interaction, workflow +controls, checkpoint disposal, and final report. Children own repository +exploration, implementation, checks, and bounded review artifacts. Do not have +the parent perform executable leaf work after choosing a workflow. + +Start only after required confirmation. Report the returned workflow ID and +end the turn; the runtime wakes the parent later. On wake, dispose of a +non-ACCEPT verdict by targeted extension/replan or a reasoned stop. Never poll +merely to wait, and never describe an unstarted graph as running. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index bff6223f04..e4213fce3d 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -49,21 +49,40 @@ describe("CommandPlugin.Plugin", () => { template: CommandPlugin.DagFlowContent, }) expect(CommandPlugin.DagFlowContent).toContain("$ARGUMENTS") - expect(CommandPlugin.DagFlowContent).toContain("workflow` tool with `action=start") + expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') expect(CommandPlugin.DagFlowContent).toContain("exact Workflow ID") expect(CommandPlugin.DagFlowContent).toContain("run `/dag`") + expect(CommandPlugin.DagFlowContent).toContain("orchestration-router") + expect(CommandPlugin.DagFlowContent).toContain("one combined confirmation") }), ) it.effect("documents the smallest child execution mode", () => Effect.sync(() => { expect(CommandPlugin.WorkflowContent).toContain("## Execution Mode Selection") - expect(CommandPlugin.WorkflowContent).toContain("Use direct execution only") + expect(CommandPlugin.WorkflowContent).toContain("Use direct execution for") expect(CommandPlugin.WorkflowContent).toContain("one `task` subagent") - expect(CommandPlugin.WorkflowContent).toContain("Related flows for one user objective") + expect(CommandPlugin.WorkflowContent).toContain("Related work for one objective") + expect(CommandPlugin.WorkflowFactsContent).toContain("project-level source or test changes") + expect(CommandPlugin.WorkflowFactsContent).toMatch(/even when only\s+one project file/) expect(CommandPlugin.WorkflowFactsContent).not.toContain("when ANY") expect(CommandPlugin.WorkflowFactsContent).not.toContain("- **Multi-model**:") - expect(CommandPlugin.DagFlowContent).toContain("workflow` tool with `action=start") + expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') + }), + ) + + it.effect("keeps always-on guidance small and loads detailed topics progressively", () => + Effect.sync(() => { + expect(CommandPlugin.WorkflowContent.length).toBeLessThan(5_000) + expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes") + expect(CommandPlugin.WorkflowContent).toContain("only one project file") + expect(CommandPlugin.WorkflowContent).toContain("isolated utility scripts") + expect(CommandPlugin.WorkflowContent).toContain("orchestration-router") + expect(CommandPlugin.WorkflowContent).toContain("**guide**") + expect(CommandPlugin.WorkflowContent).not.toContain("# Orchestration Domains") + expect(CommandPlugin.WorkflowBlocksContent).toContain("# Composable Workflow Blocks") + expect(CommandPlugin.WorkflowBlocksContent).toContain("combined confirmation") + expect(CommandPlugin.WorkflowFactsContent.length).toBeGreaterThan(CommandPlugin.WorkflowContent.length) }), ) @@ -73,8 +92,9 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT perform executable leaf work") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one `task` subagent") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one live `workflow` DAG") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("outside a project-level source or test change") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one user objective") - expect(CommandPlugin.DagFlowContent).toContain("one consolidated graph") + expect(CommandPlugin.DagFlowContent).toMatch(/one consolidated\s+graph/) }), ) @@ -82,6 +102,9 @@ describe("CommandPlugin.Plugin", () => { Effect.sync(() => { expect(CommandPlugin.WorkflowFactsContent).toContain("For a one-off graph, pass `spec` inline") expect(CommandPlugin.WorkflowFactsContent).toContain("Use `spec_path` only") + expect(CommandPlugin.WorkflowContent).toContain("**read**") + expect(CommandPlugin.WorkflowFactsContent).toContain('{ action: "read", spec_path: "code-review" }') + expect(CommandPlugin.WorkflowFactsContent).toContain("retarget its objective and block instructions") expect(CommandPlugin.WorkflowFactsContent).not.toContain("Never inline graph nodes") expect(CommandPlugin.WorkflowFactsContent).not.toContain("Before any graph-carrying action") expect(CommandPlugin.DagFlowContent).toContain("inline `spec`") @@ -151,7 +174,9 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("unverified_claims") expect(CommandPlugin.OrchestrationPolicyContent).toContain("claim-verification wave") expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT be a silent end of the graph") - expect(CommandPlugin.OrchestrationDomainsContent).toContain("**Verification wave (mandatory for module scope and larger)**") + expect(CommandPlugin.OrchestrationDomainsContent).toContain( + "**Verification wave (mandatory for module scope and larger)**", + ) expect(CommandPlugin.OrchestrationDomainsContent).toContain("never the end of the task") }), ) @@ -201,14 +226,16 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("## Replan Protocol (pause-first)") expect(CommandPlugin.OrchestrationPolicyContent).toContain("IMMEDIATELY issue `control(pause)`") expect(CommandPlugin.OrchestrationPolicyContent).toContain("replan is valid while paused") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("Pause does not interrupt nodes that are already running") + expect(CommandPlugin.OrchestrationPolicyContent).toContain( + "Pause does not interrupt nodes that are already running", + ) expect(CommandPlugin.WorkflowFactsContent).toContain("always pause FIRST") }), ) it.effect("defines productized orchestration domain playbooks", () => Effect.sync(() => { - expect(CommandPlugin.WorkflowContent).toContain("# Orchestration Domains") + expect(CommandPlugin.WorkflowContent).not.toContain("# Orchestration Domains") expect(CommandPlugin.OrchestrationDomainsContent).toContain("## The Simulated Audit Loop") expect(CommandPlugin.OrchestrationDomainsContent).toContain("NOT a cyclic edge and NOT a harness loop") expect(CommandPlugin.OrchestrationDomainsContent).toContain("NEW ids (terminal nodes are") @@ -255,10 +282,7 @@ describe("CommandPlugin.Plugin", () => { ] for (const fixture of fixtures) { - expect( - CommandPlugin.OrchestrationPolicyContent, - fixture.name, - ).toContain(fixture.expected) + expect(CommandPlugin.OrchestrationPolicyContent, fixture.name).toContain(fixture.expected) } }), ) @@ -289,12 +313,15 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain('"out": []') expect(CommandPlugin.OrchestrationPolicyContent).not.toContain("in_scope") expect(CommandPlugin.OrchestrationPolicyContent).not.toContain("out_of_scope") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("`LIGHT`: at most 1 question round") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("`STANDARD`: at most 3 question rounds") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("`GRILL`: at most 5 question rounds") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("Stop early as soon as the brief is ready") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("combined confirmation") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("The modes control challenge depth") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("`LIGHT`: validate a nearly complete brief") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("`GRILL`: additionally probe contradictions") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("recommending an answer") expect(CommandPlugin.OrchestrationPolicyContent).toContain("READY | NOT_READY | WAIVED") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("continue QA, reduce scope, use `standard`, or explicitly waive") + expect(CommandPlugin.OrchestrationPolicyContent).toContain( + "continue QA, reduce scope, use `standard`, or explicitly waive", + ) expect(CommandPlugin.OrchestrationPolicyContent).toContain("waiver_reason") expect(CommandPlugin.OrchestrationPolicyContent).toContain("acknowledged_risks") expect(CommandPlugin.OrchestrationPolicyContent).toContain("Material changes") @@ -320,12 +347,8 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain( "REJECT → corrected implementation → verification(PASS) → new diff review", ) - expect(CommandPlugin.OrchestrationPolicyContent).toContain( - "Synthetic stress-test graphs", - ) - expect(CommandPlugin.OrchestrationPolicyContent).toContain( - "MUST NOT claim implementation-diff assurance", - ) + expect(CommandPlugin.OrchestrationPolicyContent).toContain("Synthetic stress-test graphs") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT claim implementation-diff assurance") }), ) @@ -346,7 +369,7 @@ describe("CommandPlugin.Plugin", () => { } expect(CommandPlugin.WorkflowFactsContent).toContain("input_mapping:") expect(CommandPlugin.WorkflowFactsContent).toContain("findings: explore") - expect(CommandPlugin.WorkflowFactsContent).toContain('condition: \'gate.output.verdict == "ACCEPT"\'') + expect(CommandPlugin.WorkflowFactsContent).toContain("condition: 'gate.output.verdict == \"ACCEPT\"'") expect(CommandPlugin.WorkflowFactsContent).not.toContain('input: { findings: "from explore" }') expect(CommandPlugin.WorkflowFactsContent).not.toContain("Gate failure cancels the workflow automatically") expect(CommandPlugin.WorkflowFactsContent).toContain("Static `prompt_template.input`") @@ -358,11 +381,10 @@ describe("CommandPlugin.Plugin", () => { /`dag\.jsonc` tier, then the\s+configured agent model, then the parent-session model/, ) expect(CommandPlugin.WorkflowFactsContent).toContain("Propose-then-assemble") - const reviewExample = CommandPlugin.WorkflowFactsContent - .slice( - CommandPlugin.WorkflowFactsContent.indexOf("### 3. Adversarial Review"), - CommandPlugin.WorkflowFactsContent.indexOf("### 4. Diverge-Converge"), - ) + const reviewExample = CommandPlugin.WorkflowFactsContent.slice( + CommandPlugin.WorkflowFactsContent.indexOf("### 3. Adversarial Review"), + CommandPlugin.WorkflowFactsContent.indexOf("### 4. Diverge-Converge"), + ) expect(reviewExample).toContain("report_to_parent: true") expect(reviewExample).toContain("output_schema:") expect(reviewExample).toContain("required: [verdict, summary, findings, required_actions, next_action]") @@ -372,7 +394,7 @@ describe("CommandPlugin.Plugin", () => { // continuation node keeps non-ACCEPT verdicts from dead-ending the graph. expect(reviewExample).toContain("condition: 'arbitrate.output.verdict != \"ACCEPT\"'") expect(CommandPlugin.WorkflowFactsContent).toContain("an early\n`control(complete)` workflow remains terminal") - expect(CommandPlugin.DagFlowContent).toContain("must actually contain the requested synthesis") + expect(CommandPlugin.DagFlowContent).toContain("must contain the requested result") }), ) }) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 070752d3a0..c032e70dc3 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -54,11 +54,32 @@ describe("SkillPlugin.Plugin", () => { expect.objectContaining({ name: "create-dag-workflow", description: expect.stringContaining("reusable DAG workflow"), + content: expect.stringContaining("Never declare both `blocks` and `nodes`"), }), ) }), ) + it.effect("registers the proactive orchestration router as a lazy built-in skill", () => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) + + expect(yield* skill.list()).toContainEqual( + expect.objectContaining({ + name: "orchestration-router", + description: expect.stringContaining("without waiting for /dag-flow"), + content: expect.stringContaining("one combined confirmation"), + }), + ) + const router = (yield* skill.list()).find((item) => item.name === "orchestration-router") + expect(router?.description).toContain("even one project file") + expect(router?.description).toContain("isolated utility scripts") + expect(router?.content).toContain('workflow(action="read"') + expect(router?.content).toContain("retarget the objective") + }), + ) + it.effect("does not register workflow as a built-in skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts new file mode 100644 index 0000000000..ef93a76c52 --- /dev/null +++ b/packages/opencode/src/dag/blocks.ts @@ -0,0 +1,313 @@ +import { Schema } from "effect" +import type { NodeConfig } from "./dag" + +export const WORKFLOW_BLOCK_KINDS = [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", +] as const + +export type WorkflowBlockKind = (typeof WORKFLOW_BLOCK_KINDS)[number] + +export const WorkflowBlock = Schema.Struct({ + id: Schema.String.annotate({ description: "Unique block identifier; dependencies target block IDs" }), + kind: Schema.Literals(WORKFLOW_BLOCK_KINDS).annotate({ + description: "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + }), + depends_on: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Block IDs this block waits for. Defaults to []", + }), + instruction: Schema.optional(Schema.String).annotate({ + description: "Task-specific instruction added to the block's built-in execution contract", + }), + skills: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Relevant skills the child should load lazily before working", + }), + worker_type: Schema.optional(Schema.String).annotate({ + description: "Optional configured agent override; defaults from the block kind", + }), + required: Schema.optional(Schema.Boolean).annotate({ + description: + "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + }), + report_to_parent: Schema.optional(Schema.Boolean).annotate({ + description: "Override wake behavior. Review decisions and synthesis report by default", + }), +}) +export type WorkflowBlock = typeof WorkflowBlock.Type + +export interface WorkflowBlockGraph { + objective: string + blocks: WorkflowBlock[] +} + +export interface WorkflowBlockCompileOptions { + known_dependencies?: string[] +} + +const VERDICT_SCHEMA = { + type: "object", + required: ["verdict", "summary", "findings", "required_actions"], + properties: { + verdict: { + type: "string", + enum: ["ACCEPT", "REVISE", "REJECT", "BLOCKED"], + }, + summary: { type: "string" }, + findings: { type: "array" }, + required_actions: { type: "array" }, + }, +} as const + +const BLOCK_CONTRACTS: Record = { + explore: + "Inspect the target read-only. Map relevant modules, constraints, existing conventions, and evidence with file references. Do not implement.", + plan: "Produce an implementation-ready plan from repository evidence and dependency outputs. Name seams, work packages, acceptance checks, and unresolved risks. Do not implement.", + prototype: + "Build only the smallest throwaway experiment needed to answer the stated uncertainty. Separate observations from production recommendations and do not integrate it unless explicitly instructed.", + debug: + "Diagnose the smallest falsifiable root-cause hypothesis from reproduced evidence. Distinguish cause from symptom and identify the narrowest safe repair plus a regression check.", + coding: + "Implement the bounded production change. Follow repository instructions, preserve unrelated work, add or update focused tests, run relevant checks, and report changed files plus evidence.", + verify: + "Verify the supplied work against acceptance criteria using deterministic checks where available. Report commands, results, uncovered claims, and a clear PASS or FAIL conclusion. Do not hide failures.", + review: + "Review independently against repository standards and the confirmed intent. Cite concrete evidence, separate blockers from suggestions, and identify claims that still need verification.", + synthesize: + "Combine dependency outputs into one decision-ready result. Resolve conflicts using evidence, preserve material uncertainty, and state the outcome, rationale, residual risks, and next action.", +} + +export function compileWorkflowBlocks( + graph: WorkflowBlockGraph, + options: WorkflowBlockCompileOptions = {}, +): NodeConfig[] { + if (graph.objective.trim() === "") throw new Error("Block workflow requires a non-empty objective") + if (graph.blocks.length === 0) throw new Error("Block workflow requires at least one block") + + const blockIDs = graph.blocks.map((block) => block.id) + const duplicateBlockIDs = uniqueDuplicates(blockIDs) + if (duplicateBlockIDs.length > 0) { + throw new Error(`Block workflow has duplicate block ids: ${duplicateBlockIDs.join(", ")}`) + } + + const known = new Set([...blockIDs, ...(options.known_dependencies ?? [])]) + for (const block of graph.blocks) { + if (block.id.trim() === "") throw new Error("Block workflow contains an empty block id") + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(block.id)) { + throw new Error(`Block "${block.id}" must use only letters, numbers, underscores, and hyphens`) + } + for (const dependency of block.depends_on ?? []) { + if (!known.has(dependency)) { + throw new Error(`Block "${block.id}" depends on unknown block "${dependency}"`) + } + } + const reviewDependencies = (block.depends_on ?? []).filter( + (dependency) => graph.blocks.find((candidate) => candidate.id === dependency)?.kind === "review", + ) + if (reviewDependencies.length > 1) { + throw new Error( + `Block "${block.id}" depends on multiple review gates (${reviewDependencies.join(", ")}); fan them into one review block first`, + ) + } + } + assertAcyclic(graph.blocks) + + const nodes = graph.blocks.flatMap((block) => compileBlock(graph.objective, block, graph.blocks)) + const duplicateNodeIDs = uniqueDuplicates(nodes.map((node) => node.id)) + if (duplicateNodeIDs.length > 0) { + throw new Error( + `Block expansion creates duplicate node ids: ${duplicateNodeIDs.join(", ")}. Rename the colliding block`, + ) + } + return nodes +} + +function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowBlock[]): NodeConfig[] { + const dependencies = block.depends_on ?? [] + const required = block.required ?? (block.kind === "plan" || block.kind === "verify" || block.kind === "synthesize") + const reviewDependency = dependencies.find( + (dependency) => blocks.find((candidate) => candidate.id === dependency)?.kind === "review", + ) + const condition = reviewDependency ? `${reviewDependency}.output.verdict == "ACCEPT"` : undefined + + if (block.kind === "debug") { + const evidenceID = `${block.id}--evidence` + return [ + node({ + id: evidenceID, + name: `${block.id}: reproduce and collect evidence`, + workerType: block.worker_type ?? "explore", + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: + "Reproduce or characterize the failure read-only where possible. Capture exact symptoms, commands, logs, boundaries, and the smallest falsifiable observations. Do not patch the code.", + required: block.required ?? false, + reportToParent: false, + condition, + }), + node({ + id: block.id, + name: `${block.id}: diagnose root cause`, + workerType: block.worker_type ?? "general", + dependencies: [evidenceID], + objective, + instruction: block.instruction, + skills: block.skills, + contract: BLOCK_CONTRACTS.debug, + required: block.required ?? true, + reportToParent: block.report_to_parent ?? false, + }), + ] + } + + if (block.kind === "review") { + const standardsID = `${block.id}--standards` + const intentID = `${block.id}--intent` + return [ + node({ + id: standardsID, + name: `${block.id}: standards review`, + workerType: block.worker_type ?? "general", + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: `${BLOCK_CONTRACTS.review} Focus on documented repository standards, architecture constraints, correctness, and verification evidence.`, + required: block.required ?? false, + reportToParent: false, + condition, + }), + node({ + id: intentID, + name: `${block.id}: intent review`, + workerType: block.worker_type ?? "general", + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: `${BLOCK_CONTRACTS.review} Focus on the confirmed goal, scope, acceptance criteria, and user-visible behavior.`, + required: block.required ?? false, + reportToParent: false, + condition, + }), + node({ + id: block.id, + name: `${block.id}: review decision`, + workerType: block.worker_type ?? "general", + dependencies: [standardsID, intentID], + objective, + instruction: block.instruction, + skills: block.skills, + contract: [ + "Arbitrate the two independent reviews finding by finding.", + "Reject unsupported claims, deduplicate overlaps, and submit one structured result with verdict ACCEPT, REVISE, REJECT, or BLOCKED.", + "Use ACCEPT only when no material required action remains.", + ].join(" "), + required: block.required ?? true, + reportToParent: block.report_to_parent ?? true, + outputSchema: VERDICT_SCHEMA, + }), + ] + } + + return [ + node({ + id: block.id, + name: `${block.id}: ${block.kind}`, + workerType: block.worker_type ?? workerType(block.kind), + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: BLOCK_CONTRACTS[block.kind], + required, + reportToParent: block.report_to_parent ?? block.kind === "synthesize", + condition, + }), + ] +} + +function node(input: { + id: string + name: string + workerType: string + dependencies: readonly string[] + objective: string + instruction?: string + skills?: readonly string[] + contract: string + required: boolean + reportToParent: boolean + condition?: string + outputSchema?: Record +}): NodeConfig { + const skillInstruction = input.skills?.length + ? `Before working, load these relevant skills with the skill tool when available: ${input.skills.join(", ")}. If one is unavailable, state that limitation and continue from repository evidence.` + : "" + const instruction = input.instruction?.trim() ? "Block-specific instruction:\n{{instruction}}" : "" + return { + id: input.id, + name: input.name, + worker_type: input.workerType, + depends_on: [...input.dependencies], + required: input.required, + report_to_parent: input.reportToParent, + prompt_template: { + inline: [ + "Workflow objective:\n{{objective}}", + instruction, + skillInstruction, + input.contract, + "Use dependency outputs as evidence and return a concise artifact that downstream blocks can consume. Do not ask the user questions from this child session.", + ] + .filter(Boolean) + .join("\n\n"), + input: { + objective: input.objective, + ...(input.instruction?.trim() ? { instruction: input.instruction.trim() } : {}), + }, + }, + ...(input.condition ? { condition: input.condition } : {}), + ...(input.outputSchema ? { output_schema: input.outputSchema } : {}), + } +} + +function workerType(kind: WorkflowBlockKind) { + if (kind === "explore") return "explore" + if (kind === "plan") return "plan" + if (kind === "coding" || kind === "prototype") return "build" + return "general" +} + +function uniqueDuplicates(values: string[]) { + return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))] +} + +function assertAcyclic(blocks: WorkflowBlock[]) { + const blockIDs = new Set(blocks.map((block) => block.id)) + const remaining = new Map( + blocks.map((block) => [ + block.id, + new Set((block.depends_on ?? []).filter((dependency) => blockIDs.has(dependency))), + ]), + ) + while (remaining.size > 0) { + const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id) + if (ready.length === 0) { + throw new Error(`Block workflow contains a dependency cycle involving: ${[...remaining.keys()].join(", ")}`) + } + for (const id of ready) remaining.delete(id) + for (const dependencies of remaining.values()) { + for (const id of ready) dependencies.delete(id) + } + } +} + +export * as DagBlocks from "./blocks" diff --git a/packages/opencode/src/dag/workflows.ts b/packages/opencode/src/dag/workflows.ts index dfc510b49d..b8b98d2ede 100644 --- a/packages/opencode/src/dag/workflows.ts +++ b/packages/opencode/src/dag/workflows.ts @@ -45,6 +45,8 @@ export interface Entry { readonly title?: string /** Node count, for a one-glance sense of the graph's size. */ readonly nodes?: number + /** Block count when the saved spec uses the high-level interface. */ + readonly blocks?: number } /** Builtin templates compiled into the binary from opencode-dag-config. */ @@ -147,15 +149,17 @@ function scopes(projectDir: string) { } /** Best-effort listing metadata from a file-backed spec. */ -async function describe(file: string): Promise<{ title?: string; nodes?: number }> { - const text = await Bun.file(file).text().catch(() => undefined) +async function describe(file: string): Promise<{ title?: string; nodes?: number; blocks?: number }> { + const text = await Bun.file(file) + .text() + .catch(() => undefined) return text === undefined ? {} : parseMeta(text) } /** Parse title/node metadata from spec content (shared with builtin entries). * A malformed spec still lists — hiding it would make a typo look like a * missing file; the start path reports the real parse error. */ -async function parseMeta(text: string): Promise<{ title?: string; nodes?: number }> { +async function parseMeta(text: string): Promise<{ title?: string; nodes?: number; blocks?: number }> { const parsed = await Promise.resolve(text) .then((value) => Bun.YAML.parse(value)) .catch(() => undefined) @@ -163,5 +167,10 @@ async function parseMeta(text: string): Promise<{ title?: string; nodes?: number const config = isRecord(parsed["config"]) ? parsed["config"] : undefined const title = typeof parsed["title"] === "string" ? parsed["title"] : undefined const nodes = config && Array.isArray(config["nodes"]) ? config["nodes"].length : undefined - return { ...(title ? { title } : {}), ...(nodes === undefined ? {} : { nodes }) } + const blocks = config && Array.isArray(config["blocks"]) ? config["blocks"].length : undefined + return { + ...(title ? { title } : {}), + ...(nodes === undefined ? {} : { nodes }), + ...(blocks === undefined ? {} : { blocks }), + } } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 4f31a4fc79..252afaf725 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -43,6 +43,7 @@ const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ ]) // Tools that modify files on disk — trigger FileChanged hook after execution const FILE_CHANGING_TOOLS = new Set(["edit", "write", "apply_patch", "multiedit", "patch"]) +const ROOT_ONLY_TOOLS = new Set([MemorySearch.MemorySearchTool.id, "workflow"]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: Agent.Info @@ -100,13 +101,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { providerID: input.model.providerID, agent: input.agent, })) { + if (input.session.parentID && ROOT_ONLY_TOOLS.has(item.id)) continue if ( item.id === MemorySearch.MemorySearchTool.id && - (input.session.parentID || - Permission.disabled( - [MemorySearch.MemorySearchTool.id], - Permission.merge(input.agent.permission, input.session.permission ?? []), - ).has(MemorySearch.MemorySearchTool.id)) + Permission.disabled( + [MemorySearch.MemorySearchTool.id], + Permission.merge(input.agent.permission, input.session.permission ?? []), + ).has(MemorySearch.MemorySearchTool.id) ) continue const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 06999e4556..4a6803a961 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -50,6 +50,13 @@ const CREATE_DAG_WORKFLOW_SKILL_NAME = "create-dag-workflow" const CREATE_DAG_WORKFLOW_SKILL_DESCRIPTION = SkillPlugin.CreateDagWorkflowDescription const CREATE_DAG_WORKFLOW_SKILL_BODY = SkillPlugin.CreateDagWorkflowContent +// Built-in routing skill. Its compact catalog description makes project-level +// orchestration proactive; the full decision and block-composition playbook is +// loaded only when the model invokes the skill. +const ORCHESTRATION_ROUTER_SKILL_NAME = "orchestration-router" +const ORCHESTRATION_ROUTER_SKILL_DESCRIPTION = SkillPlugin.OrchestrationRouterDescription +const ORCHESTRATION_ROUTER_SKILL_BODY = SkillPlugin.OrchestrationRouterContent + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -309,6 +316,12 @@ export const layer = Layer.effect( location: "", content: CREATE_DAG_WORKFLOW_SKILL_BODY, } + s.skills[ORCHESTRATION_ROUTER_SKILL_NAME] = { + name: ORCHESTRATION_ROUTER_SKILL_NAME, + description: ORCHESTRATION_ROUTER_SKILL_DESCRIPTION, + location: "", + content: ORCHESTRATION_ROUTER_SKILL_BODY, + } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 4cae53c6e6..47485624fa 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -5,6 +5,7 @@ import { Dag } from "@/dag/dag" import { DagConfig } from "@/dag/config" import { DagWorkflows } from "@/dag/workflows" import { DagModel } from "@/dag/model" +import { DagBlocks } from "@/dag/blocks" import { Agent } from "@/agent/agent" import { Question } from "@/question" import { Session } from "@/session/session" @@ -29,14 +30,16 @@ const NodeSchema = Schema.Struct({ worker_type: Schema.String.annotate({ description: "Agent type (explore, build, general, plan, or custom)" }), depends_on: Schema.Array(Schema.String).annotate({ description: "Node IDs this node waits for ([] for root)" }), required: Schema.optional(Schema.Boolean).annotate({ - description: "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + description: + "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", }), prompt_template: Schema.Struct({ id: Schema.optional(Schema.String), inline: Schema.optional(Schema.String), input: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }).annotate({ - description: 'Template: { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default', + description: + 'Template: { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default', }), worker_config: Schema.optional( Schema.Struct({ @@ -44,22 +47,34 @@ const NodeSchema = Schema.Struct({ }), ).annotate({ description: "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config" }), input_mapping: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ - description: 'Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID', + description: + 'Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID', }), report_to_parent: Schema.optional(Schema.Boolean).annotate({ - description: "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + description: + "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + }), + condition: Schema.optional(Schema.String).annotate({ + description: "Expression evaluated before spawn; node is skipped if false", + }), + restart: Schema.optional(Schema.Boolean).annotate({ + description: + "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", }), - condition: Schema.optional(Schema.String).annotate({ description: "Expression evaluated before spawn; node is skipped if false" }), - restart: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id" }), cancel: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Cancel this node" }), - output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "JSON Schema; child agent must call submit_result to submit structured output" }), + output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "JSON Schema; child agent must call submit_result to submit structured output", + }), review: Schema.optional( Schema.Struct({ phase: Schema.Literals(["design", "diff"]), implementation_node_id: Schema.optional(Schema.String), verification_node_id: Schema.optional(Schema.String), }), - ).annotate({ description: '(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id' }), + ).annotate({ + description: + "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + }), }) const WorkflowGraphSchema = Schema.Struct({ @@ -78,9 +93,21 @@ const WorkflowGraphSchema = Schema.Struct({ description: "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", }), max_concurrency: Schema.optional(Schema.Number).annotate({ description: "Max parallel nodes. Default: 5" }), - max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ description: "Max replan restarts per node ID. Default: 5" }), - max_total_nodes: Schema.optional(Schema.Number).annotate({ description: "Cumulative node cap across the workflow lifetime. Default: 100" }), - nodes: Schema.Array(NodeSchema).annotate({ description: "Node declarations" }), + max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ + description: "Max replan restarts per node ID. Default: 5", + }), + max_total_nodes: Schema.optional(Schema.Number).annotate({ + description: "Cumulative node cap across the workflow lifetime. Default: 100", + }), + objective: Schema.optional(Schema.String).annotate({ + description: "Required when using blocks; injected into every generated child prompt", + }), + blocks: Schema.optional(Schema.Array(DagBlocks.WorkflowBlock)).annotate({ + description: "High-level graph compiled into nodes. Use blocks or nodes, never both", + }), + nodes: Schema.optional(Schema.Array(NodeSchema)).annotate({ + description: "Low-level node declarations. Use nodes or blocks, never both", + }), }) // Exported so the committed workflow library can be validated in tests. @@ -92,7 +119,9 @@ export const StartSpec = Schema.Struct({ }) const ExtendSpec = Schema.Struct({ - nodes: Schema.Array(NodeSchema), + objective: Schema.optional(Schema.String), + blocks: Schema.optional(Schema.Array(DagBlocks.WorkflowBlock)), + nodes: Schema.optional(Schema.Array(NodeSchema)), }) const ReplanSpec = Schema.Struct({ @@ -104,13 +133,32 @@ const decodeExtendSpec = Schema.decodeUnknownEffect(ExtendSpec) const decodeReplanSpec = Schema.decodeUnknownEffect(ReplanSpec) export const Parameters = Schema.Struct({ - action: Schema.Literals(["start", "extend", "control", "status", "list"]).annotate({ description: "start: create workflow; extend: add nodes; control: pause/resume/cancel/replan/step/complete; status: inspect durable workflow and node state; list: show saved workflow specs in the library (not running workflows)" }), - spec: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "(start/extend/control replan) Inline structured spec for a one-off graph. Use this or spec_path, never both" }), - spec_path: Schema.optional(Schema.String).annotate({ description: '(start/extend/control replan) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory' }), - session_id: Schema.optional(Schema.String).annotate({ description: "(start) Parent session ID; when provided, it must match the calling session" }), - project_id: Schema.optional(Schema.String).annotate({ description: "(start) Optional Project ID; must match the parent session project" }), + action: Schema.Literals(["start", "extend", "control", "status", "list", "read", "guide"]).annotate({ + description: + "start: create workflow; extend: add nodes or blocks; control: pause/resume/cancel/replan/step/complete; status: inspect durable state; list: show saved specs; read: inspect one saved spec before retargeting it; guide: load detailed guidance only when needed", + }), + topic: Schema.optional(Schema.Literals(["blocks", "interface", "policy", "patterns"])).annotate({ + description: + "(guide) blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", + }), + spec: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: + "(start/extend/control replan) Inline structured spec for a one-off graph. Use this or spec_path, never both", + }), + spec_path: Schema.optional(Schema.String).annotate({ + description: + '(start/extend/control replan/read) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory', + }), + session_id: Schema.optional(Schema.String).annotate({ + description: "(start) Parent session ID; when provided, it must match the calling session", + }), + project_id: Schema.optional(Schema.String).annotate({ + description: "(start) Optional Project ID; must match the parent session project", + }), workflow_id: Schema.optional(Schema.String).annotate({ description: "(extend/control/status) Target workflow ID" }), - operation: Schema.optional(Schema.Literals(["pause", "resume", "cancel", "replan", "step", "complete"])).annotate({ description: "(control) Operation to perform" }), + operation: Schema.optional(Schema.Literals(["pause", "resume", "cancel", "replan", "step", "complete"])).annotate({ + description: "(control) Operation to perform", + }), }) // ============================================================================ @@ -147,7 +195,39 @@ export const WorkflowTool = Tool.define< parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const callingSession = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) + if (callingSession.parentID) { + return yield* Effect.die( + new Error("Workflow orchestration is available only to the main conversation, not child agents"), + ) + } switch (params.action) { + case "guide": { + if (!params.topic) { + return { + title: "Workflow guide topics", + output: [ + "Load only the topic needed for the current decision:", + "- blocks: compose explore/plan/prototype/debug/coding/verify/review/synthesize blocks", + "- interface: low-level node fields, bindings, model resolution, and tool actions", + "- policy: deep admission, gates, checkpoints, recovery, and bounded repair", + "- patterns: larger review, engineering, diagnosis, and audit topologies", + ].join("\n"), + metadata: {}, + } + } + const content = { + blocks: CommandPlugin.WorkflowBlocksContent, + interface: CommandPlugin.WorkflowFactsContent, + policy: CommandPlugin.OrchestrationPolicyContent, + patterns: CommandPlugin.OrchestrationDomainsContent, + }[params.topic] + return { + title: `Workflow guide: ${params.topic}`, + output: content, + metadata: {}, + } + } case "list": { const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) const entries = yield* DagWorkflows.list(session.directory) @@ -165,7 +245,11 @@ export const WorkflowTool = Tool.define< [ `${entry.name} [${entry.scope}]`, entry.title ? ` — ${entry.title}` : "", - entry.nodes === undefined ? "" : ` (${entry.nodes} nodes)`, + entry.nodes !== undefined + ? ` (${entry.nodes} nodes)` + : entry.blocks !== undefined + ? ` (${entry.blocks} blocks)` + : "", `\n ${entry.path}`, ].join(""), ) @@ -173,6 +257,22 @@ export const WorkflowTool = Tool.define< metadata: {}, } } + case "read": { + if (!params.spec_path || params.spec) { + return yield* Effect.die( + new Error("read requires exactly one 'spec_path' and does not accept inline 'spec'"), + ) + } + const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(undefined, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) + return { + title: `Workflow spec: ${params.spec_path}`, + output: JSON.stringify(specFile.value, null, 2), + metadata: {}, + } + } case "status": { if (!params.workflow_id) return yield* Effect.die(new Error("status requires 'workflow_id'")) const workflow = yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) @@ -230,58 +330,67 @@ export const WorkflowTool = Tool.define< if (params.project_id && params.project_id !== session.projectID) { return yield* Effect.die(new Error("project_id must match the parent session project")) } - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) const spec = yield* decodeStartSpec(specFile.value).pipe( Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) + const config = compileGraph(spec.config, specFile.path) const missingModels = yield* findNodesWithoutModel({ - nodes: spec.config.nodes, - defaults: spec.config.node_defaults, + nodes: config.nodes, + defaults: config.node_defaults, directory: session.directory, parent: session.model, agents, }) if (missingModels.length > 0) { - yield* question.ask({ - sessionID, - questions: [{ - header: "DAG model", - question: `No model is available for DAG node${missingModels.length > 1 ? "s" : ""} ${missingModels.map((node) => `"${node}"`).join(", ")}. Configure the advanced/standard tiers in dag.jsonc, a model on the selected worker agent, or a parent-session model before starting. How would you like to proceed?`, - custom: false, - options: [ + yield* question + .ask({ + sessionID, + questions: [ { - label: "Configure first", - description: "Do not start the workflow; configure a model and retry.", - }, - { - label: "Cancel workflow", - description: "Abandon this workflow start.", + header: "DAG model", + question: `No model is available for DAG node${missingModels.length > 1 ? "s" : ""} ${missingModels.map((node) => `"${node}"`).join(", ")}. Configure the advanced/standard tiers in dag.jsonc, a model on the selected worker agent, or a parent-session model before starting. How would you like to proceed?`, + custom: false, + options: [ + { + label: "Configure first", + description: "Do not start the workflow; configure a model and retry.", + }, + { + label: "Cancel workflow", + description: "Abandon this workflow start.", + }, + ], }, ], - }], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }).pipe(Effect.orDie) + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + .pipe(Effect.orDie) return { title: "Workflow not started: model required", output: `No workflow was created. Missing model for: ${missingModels.join(", ")}. Configure dag.jsonc, the worker agent, or the parent session, then retry.`, metadata: {}, } } - const dagID = yield* dag.create({ - projectID: session.projectID, - sessionID, - title: spec.title ?? spec.config.name, - config: { - ...spec.config, - mode: spec.mode ?? "standard", - ...(spec.admission ? { admission: createAdmissionRecord(spec.admission) } : {}), - } as WorkflowConfig, - }).pipe(Effect.orDie) + const dagID = yield* dag + .create({ + projectID: session.projectID, + sessionID, + title: spec.title ?? config.name, + config: { + ...config, + mode: spec.mode ?? "standard", + ...(spec.admission ? { admission: createAdmissionRecord(spec.admission) } : {}), + } as WorkflowConfig, + }) + .pipe(Effect.orDie) const mode = spec.mode ?? "standard" return { - title: `Workflow started: ${spec.config.name}`, - output: `\n${spec.config.nodes.length} nodes registered.\nDo not poll this workflow. It runs asynchronously and will wake the parent session when attention is required.\n`, + title: `Workflow started: ${config.name}`, + output: `\n${config.nodes.length} nodes registered.\nDo not poll this workflow. It runs asynchronously and will wake the parent session when attention is required.\n`, metadata: { workflowId: dagID } as Metadata, } } @@ -289,13 +398,19 @@ export const WorkflowTool = Tool.define< if (!params.workflow_id) return yield* Effect.die(new Error("extend requires 'workflow_id'")) yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) const spec = yield* decodeExtendSpec(specFile.value).pipe( Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) + const knownDependencies = (yield* dag.store.getNodes(params.workflow_id).pipe(Effect.orDie)).map( + (node) => node.id, + ) + const nodes = compileNodeSource(spec, specFile.path, knownDependencies) const r = yield* withTerminalRecovery( - dag.extend(params.workflow_id, spec.nodes as NodeConfig[]), + dag.extend(params.workflow_id, nodes), "Terminal workflows are immutable except for the additive-extend reopen, which requires the workflow to have completed naturally at a wake-eligible reporting checkpoint (fragment adds new node ids; no early control(complete); no executed node beyond the checkpoint — condition-skipped dependents are fine). When the reopen does not apply, recover by starting a NEW workflow spec that reuses this workflow's completed outputs as static input.", ).pipe(Effect.orDie) return { @@ -306,40 +421,65 @@ export const WorkflowTool = Tool.define< } case "control": { if (!params.workflow_id || !params.operation) { - return yield* Effect.die(new Error( - `control requires 'workflow_id' and 'operation' (got workflow_id=${params.workflow_id ?? ""}, operation=${params.operation ?? ""}). Example: { action: "control", workflow_id: "dag_...", operation: "pause" }. On a cancel/replan intent, issue pause FIRST — it needs no spec and freezes scheduling instantly while you compose the replan.`, - )) + return yield* Effect.die( + new Error( + `control requires 'workflow_id' and 'operation' (got workflow_id=${params.workflow_id ?? ""}, operation=${params.operation ?? ""}). Example: { action: "control", workflow_id: "dag_...", operation: "pause" }. On a cancel/replan intent, issue pause FIRST — it needs no spec and freezes scheduling instantly while you compose the replan.`, + ), + ) } const wfId = params.workflow_id yield* requireOwnedWorkflow(wfId, ctx.sessionID) switch (params.operation) { case "pause": yield* dag.pause(wfId).pipe(Effect.orDie) - return { title: "Workflow paused", output: `\nNote: pause stops new node spawns only — nodes already running continue to completion. To stop a running node, submit a replan spec marking it restart: true or cancel: true (replan is valid while paused).`, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow paused", + output: `\nNote: pause stops new node spawns only — nodes already running continue to completion. To stop a running node, submit a replan spec marking it restart: true or cancel: true (replan is valid while paused).`, + metadata: { workflowId: wfId } as Metadata, + } case "resume": yield* dag.resume(wfId).pipe(Effect.orDie) - return { title: "Workflow resumed", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow resumed", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } case "cancel": yield* dag.cancel(wfId).pipe(Effect.orDie) - return { title: "Workflow cancelled", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow cancelled", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } case "complete": yield* dag.complete(wfId).pipe(Effect.orDie) - return { title: "Workflow completed (early)", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow completed (early)", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } case "replan": { const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) const spec = yield* decodeReplanSpec(specFile.value).pipe( Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) + const knownDependencies = (yield* dag.store.getNodes(wfId).pipe(Effect.orDie)).map((node) => node.id) + const fragment = compileGraph(spec.fragment, specFile.path, knownDependencies) // The graph raced to terminal while the fragment was being // composed (the pause-first protocol was skipped). Surface // the recovery options instead of a bare iron-law rejection. const r = yield* withTerminalRecovery( - dag.replan(wfId, { nodes: spec.fragment.nodes as NodeConfig[] }), + dag.replan(wfId, { nodes: fragment.nodes }), "The workflow reached a terminal status before the replan arrived — terminal workflows are immutable. Recover by starting a new workflow with the updated node definitions, or extend if a reporting leaf checkpoint naturally completed the graph. Next time issue control(pause) BEFORE composing the spec.", ).pipe(Effect.orDie) - const ignored = r.ignore.length > 0 ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` : "" + const ignored = + r.ignore.length > 0 + ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` + : "" return { title: `Workflow replanned: +${r.add.length} -${r.cancel.length} ↻${r.restart.length}`, output: `\nAdded: ${r.add.join(", ")}\nCancelled: ${r.cancel.join(", ")}\nRestarted: ${r.restart.join(", ")}\nReplaced: ${r.replace.join(", ")}${ignored}\n`, @@ -349,9 +489,17 @@ export const WorkflowTool = Tool.define< case "step": { const r = yield* dag.step(wfId).pipe(Effect.orDie) if (r.status === "no_ready_nodes") { - return { title: "Workflow step: no ready nodes", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow step: no ready nodes", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } + } + return { + title: `Workflow stepped: ${r.nodeID ?? "no node"}`, + output: ``, + metadata: { workflowId: wfId, ...r } as Metadata, } - return { title: `Workflow stepped: ${r.nodeID ?? "no node"}`, output: ``, metadata: { workflowId: wfId, ...r } as Metadata } } } } @@ -361,6 +509,33 @@ export const WorkflowTool = Tool.define< }), ) +type WorkflowGraphInput = Schema.Schema.Type +type NodeSource = Pick + +function compileGraph(graph: WorkflowGraphInput, source: string, knownDependencies?: string[]) { + const nodes = compileNodeSource(graph, source, knownDependencies) + const { objective: _objective, blocks: _blocks, nodes: _nodes, ...config } = graph + return { + ...config, + nodes, + } as WorkflowConfig +} + +function compileNodeSource(source: NodeSource, path: string, knownDependencies?: string[]) { + const hasNodes = source.nodes !== undefined + const hasBlocks = source.blocks !== undefined + if (hasNodes === hasBlocks) throw new Error(`Invalid workflow graph ${path}: use exactly one of nodes or blocks`) + if (source.nodes) return source.nodes as NodeConfig[] + if (!source.objective) throw new Error(`Invalid workflow graph ${path}: blocks require objective`) + return DagBlocks.compileWorkflowBlocks( + { + objective: source.objective, + blocks: source.blocks as DagBlocks.WorkflowBlock[], + }, + { known_dependencies: knownDependencies }, + ) +} + function readWorkflowSpec( spec: Record | undefined, specPath: string | undefined, @@ -369,15 +544,17 @@ function readWorkflowSpec( ) { return Effect.gen(function* () { if (spec && specPath) { - return yield* Effect.fail(new Error( - "Workflow configuration accepts exactly one source: remove either 'spec' or 'spec_path'.", - )) + return yield* Effect.fail( + new Error("Workflow configuration accepts exactly one source: remove either 'spec' or 'spec_path'."), + ) } if (spec) return { path: "", value: spec } if (!specPath) { - return yield* Effect.fail(new Error( - `Workflow configuration requires exactly one of 'spec' or 'spec_path'. Pass an inline structured spec for a one-off graph, or a saved workflow name/path through 'spec_path'.`, - )) + return yield* Effect.fail( + new Error( + `Workflow configuration requires exactly one of 'spec' or 'spec_path'. Pass an inline structured spec for a one-off graph, or a saved workflow name/path through 'spec_path'.`, + ), + ) } const filepath = yield* resolveSpecPath(specPath, directory, ctx) @@ -399,9 +576,9 @@ function readWorkflowSpec( return yield* Effect.fail(new Error(`Workflow spec not found: ${filepath}`)) } if (file.size > MAX_WORKFLOW_SPEC_BYTES) { - return yield* Effect.fail(new Error( - `Workflow spec is too large: ${file.size} bytes exceeds ${MAX_WORKFLOW_SPEC_BYTES}`, - )) + return yield* Effect.fail( + new Error(`Workflow spec is too large: ${file.size} bytes exceeds ${MAX_WORKFLOW_SPEC_BYTES}`), + ) } const content = yield* Effect.tryPromise({ try: () => file.text(), @@ -433,9 +610,11 @@ function resolveSpecPath(specPath: string, directory: string, ctx: Tool.Context) if (DagWorkflows.isName(specPath)) { const entry = yield* DagWorkflows.resolve(specPath, directory) if (entry) return entry.path - return yield* Effect.fail(new Error( - `Saved workflow not found: "${specPath}". Searched ${searchedScopes(directory)}. Run workflow(action: "list") to see what is available, or pass a path to a .yaml spec file.`, - )) + return yield* Effect.fail( + new Error( + `Saved workflow not found: "${specPath}". Searched ${searchedScopes(directory)}. Run workflow(action: "list") to see what is available, or pass a path to a .yaml spec file.`, + ), + ) } const filepath = path.isAbsolute(specPath) ? path.normalize(specPath) : path.resolve(directory, specPath) if (![".yaml", ".yml"].includes(path.extname(filepath).toLowerCase())) { @@ -483,16 +662,16 @@ function findNodesWithoutModel(input: { Effect.map((info) => info as Agent.Info | undefined), Effect.catchCause(() => Effect.succeed(undefined)), ) - return DagModel.resolve({ - tier: DagConfig.tierModel(config, { - required: node.required ?? input.defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, - workerType: node.worker_type, - }), - agent: agent?.model, - parent: input.parent - ? { modelID: input.parent.id, providerID: input.parent.providerID } - : undefined, - }) === undefined + return ( + DagModel.resolve({ + tier: DagConfig.tierModel(config, { + required: node.required ?? input.defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, + workerType: node.worker_type, + }), + agent: agent?.model, + parent: input.parent ? { modelID: input.parent.id, providerID: input.parent.providerID } : undefined, + }) === undefined + ) }), { concurrency: "unbounded" }, ).pipe(Effect.map((nodes) => nodes.map((node) => node.id))) diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts new file mode 100644 index 0000000000..0089347112 --- /dev/null +++ b/packages/opencode/test/dag/blocks.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "bun:test" +import { DagBlocks } from "@/dag/blocks" +import { DagConfig } from "@/dag/config" + +describe("workflow blocks", () => { + it("compiles a staged route and carries objective, instructions, skills, and dependencies", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Add durable session recovery", + blocks: [ + { + id: "map", + kind: "explore", + instruction: "Locate persistence ownership", + }, + { + id: "build", + kind: "coding", + depends_on: ["map"], + skills: ["tdd"], + }, + { + id: "verify", + kind: "verify", + depends_on: ["build"], + }, + ], + }) + + expect(nodes.map((node) => ({ id: node.id, worker: node.worker_type, dependsOn: node.depends_on }))).toEqual([ + { id: "map", worker: "explore", dependsOn: [] }, + { id: "build", worker: "build", dependsOn: ["map"] }, + { id: "verify", worker: "general", dependsOn: ["build"] }, + ]) + expect(nodes[0]?.prompt_template.input).toEqual({ + objective: "Add durable session recovery", + instruction: "Locate persistence ownership", + }) + expect(nodes[1]?.prompt_template.inline).toContain("load these relevant skills") + expect(nodes[1]?.prompt_template.inline).toContain("tdd") + expect(nodes.map((node) => ({ id: node.id, required: node.required }))).toEqual([ + { id: "map", required: false }, + { id: "build", required: false }, + { id: "verify", required: true }, + ]) + }) + + it("expands debug into evidence and diagnosis nodes", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Find the source of a timeout", + blocks: [{ id: "root-cause", kind: "debug", report_to_parent: true }], + }) + + expect(nodes.map((node) => node.id)).toEqual(["root-cause--evidence", "root-cause"]) + expect(nodes[0]).toMatchObject({ + worker_type: "explore", + depends_on: [], + report_to_parent: false, + }) + expect(nodes[1]).toMatchObject({ + worker_type: "general", + depends_on: ["root-cause--evidence"], + report_to_parent: true, + }) + }) + + it("expands review into two independent lanes and a reporting verdict gate", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Review the implementation", + blocks: [ + { id: "implementation", kind: "coding" }, + { id: "decision", kind: "review", depends_on: ["implementation"] }, + { id: "report", kind: "synthesize", depends_on: ["decision"] }, + ], + }) + + expect(nodes.map((node) => node.id)).toEqual([ + "implementation", + "decision--standards", + "decision--intent", + "decision", + "report", + ]) + expect(nodes.find((node) => node.id === "decision")).toMatchObject({ + depends_on: ["decision--standards", "decision--intent"], + required: true, + report_to_parent: true, + output_schema: { + type: "object", + properties: { + verdict: { enum: ["ACCEPT", "REVISE", "REJECT", "BLOCKED"] }, + }, + }, + }) + expect(nodes.find((node) => node.id === "report")?.condition).toBe('decision.output.verdict == "ACCEPT"') + }) + + it("routes volume blocks to the standard tier and decision blocks to the advanced tier", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Deliver a reviewed project change", + blocks: [ + { id: "map", kind: "explore" }, + { id: "plan", kind: "plan", depends_on: ["map"] }, + { id: "experiment", kind: "prototype", depends_on: ["map"] }, + { id: "diagnose", kind: "debug", depends_on: ["map"] }, + { id: "build", kind: "coding", depends_on: ["plan", "diagnose"] }, + { id: "verify", kind: "verify", depends_on: ["build", "experiment"] }, + { id: "decision", kind: "review", depends_on: ["verify"] }, + { id: "report", kind: "synthesize", depends_on: ["decision"] }, + ], + }) + const models = Object.fromEntries( + nodes.map((node) => [ + node.id, + DagConfig.tierModel( + { model: { advanced: "test/advanced", standard: "test/standard" } }, + { required: node.required ?? false, workerType: node.worker_type }, + )?.modelID, + ]), + ) + + expect(models).toEqual({ + map: "standard", + plan: "advanced", + experiment: "standard", + "diagnose--evidence": "standard", + diagnose: "advanced", + build: "standard", + verify: "advanced", + "decision--standards": "standard", + "decision--intent": "standard", + decision: "advanced", + report: "advanced", + }) + }) + + it("rejects ambiguous dependencies and expansion collisions", () => { + expect(() => + DagBlocks.compileWorkflowBlocks({ + objective: "Invalid graph", + blocks: [{ id: "build", kind: "coding", depends_on: ["missing"] }], + }), + ).toThrow('Block "build" depends on unknown block "missing"') + + expect(() => + DagBlocks.compileWorkflowBlocks({ + objective: "Colliding graph", + blocks: [ + { id: "check", kind: "review" }, + { id: "check--intent", kind: "verify" }, + ], + }), + ).toThrow("Block expansion creates duplicate node ids: check--intent") + + expect(() => + DagBlocks.compileWorkflowBlocks({ + objective: "Cyclic graph", + blocks: [ + { id: "a", kind: "plan", depends_on: ["b"] }, + { id: "b", kind: "plan", depends_on: ["a"] }, + ], + }), + ).toThrow("dependency cycle") + + expect(() => + DagBlocks.compileWorkflowBlocks({ + objective: "Unsafe ID", + blocks: [{ id: "review.output", kind: "review" }], + }), + ).toThrow("must use only letters") + }) + + it("allows an extension block to depend on an existing durable node", () => { + const nodes = DagBlocks.compileWorkflowBlocks( + { + objective: "Continue from durable evidence", + blocks: [{ id: "repair", kind: "coding", depends_on: ["existing-evidence"] }], + }, + { known_dependencies: ["existing-evidence"] }, + ) + + expect(nodes[0]?.depends_on).toEqual(["existing-evidence"]) + }) + + it("requires one objective and one review dependency per continuation", () => { + expect(() => DagBlocks.compileWorkflowBlocks({ objective: " ", blocks: [{ id: "x", kind: "coding" }] })).toThrow( + "non-empty objective", + ) + expect(() => + DagBlocks.compileWorkflowBlocks({ + objective: "Ambiguous gates", + blocks: [ + { id: "review-a", kind: "review" }, + { id: "review-b", kind: "review" }, + { id: "build", kind: "coding", depends_on: ["review-a", "review-b"] }, + ], + }), + ).toThrow("depends on multiple review gates") + }) +}) diff --git a/packages/opencode/test/dag/workflow-child-tools.test.ts b/packages/opencode/test/dag/workflow-child-tools.test.ts new file mode 100644 index 0000000000..48f2278d88 --- /dev/null +++ b/packages/opencode/test/dag/workflow-child-tools.test.ts @@ -0,0 +1,109 @@ +import { describe, expect } from "bun:test" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect, Layer, Schema } from "effect" +import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import { MessageID, SessionID } from "@/session/schema" +import { SessionProcessor } from "@/session/processor" +import { Session } from "@/session/session" +import { SessionTools } from "@/session/tools" +import { Tool } from "@/tool/tool" +import { ToolRegistry } from "@/tool/registry" +import type { TaskPromptOps } from "@/tool/task" +import { Truncate } from "@/tool/truncate" +import { testEffect } from "../lib/effect" +import { ProviderTest } from "../fake/provider" + +const Parameters = Schema.Struct({}) +const workflowDefinition: Tool.Def = { + id: "workflow", + description: "workflow", + parameters: Parameters, + execute: () => Effect.succeed({ title: "workflow", output: "started", metadata: {} }), +} +const trigger: Plugin.Interface["trigger"] = (_name, _input, output) => Effect.succeed(output) +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Truncate.defaultLayer, + Layer.mock(Plugin.Service, { + init: () => Effect.void, + list: () => Effect.succeed([]), + trigger, + }), + Layer.mock(Permission.Service, { ask: () => Effect.void }), + Layer.mock(MCP.Service, { clients: () => Effect.succeed({}), tools: () => Effect.succeed({}) }), + Layer.mock(ToolRegistry.Service, { tools: () => Effect.succeed([workflowDefinition]) }), + ), +) + +describe("workflow child boundary", () => { + it.instance("exposes workflow to the main conversation but not to child agents", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const build = yield* agents.get("build") + const parentID = SessionID.make("ses_workflow_tool_parent") + + expect(yield* resolvedToolIDs(build, session(parentID))).toContain("workflow") + expect(yield* resolvedToolIDs(build, session(SessionID.make("ses_workflow_tool_child"), parentID))).not.toContain( + "workflow", + ) + }), + ) +}) + +function resolvedToolIDs(agent: Agent.Info, info: Session.Info) { + const userID = MessageID.make(`msg_${info.id}`) + const processor: Pick = { + message: { + id: MessageID.make(`msg_assistant_${info.id}`), + role: "assistant", + sessionID: info.id, + parentID: userID, + mode: agent.name, + agent: agent.name, + path: { cwd: info.directory, root: info.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + time: { created: 1 }, + }, + updateToolCall: () => Effect.void.pipe(Effect.as(undefined)), + completeToolCall: () => Effect.void, + } + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + resolvePromptParts: () => Effect.succeed([]), + prompt: () => Effect.die(new Error("prompt should not run while resolving tools")), + } + return SessionTools.resolve({ + agent, + model: ProviderTest.model({ + providerID: ProviderV2.ID.make("test"), + id: ModelV2.ID.make("test-model"), + }), + session: info, + processor, + bypassAgentCheck: false, + messages: [], + promptOps, + }).pipe(Effect.map((tools) => Object.keys(tools))) +} + +function session(id: SessionID, parentID?: SessionID): Session.Info { + return { + id, + slug: "workflow-child-tools", + projectID: ProjectV2.ID.global, + directory: "/tmp/opencode", + parentID, + title: "Workflow child tools", + version: "1.0.0", + time: { created: 1, updated: 1 }, + } +} diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 402136775e..75910a66aa 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -302,6 +302,8 @@ const runtime = testEffect( slug: "workflow-test", projectID, directory: workflowSpecDirectory, + parentID: + id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, title: "Workflow test", version: "test", time: { created: 0, updated: 0 }, @@ -373,7 +375,7 @@ function toolContext() { } describe("workflow tool schema (negative tests)", () => { - it("action field accepts start/extend/control/status/list", () => { + it("action field accepts start/extend/control/status/list/read/guide", () => { const decode = Schema.decodeUnknownSync(Parameters) expect(() => decode({ action: "start", spec_path: ".opencode/workflows/test.yaml" })).not.toThrow() expect(() => decode({ action: "extend", workflow_id: "wf-1", spec_path: ".opencode/workflows/extend.yaml" })).not.toThrow() @@ -381,6 +383,8 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "status", workflow_id: "wf-1" })).not.toThrow() // list browses the saved-spec library and needs no workflow_id. expect(() => decode({ action: "list" })).not.toThrow() + expect(() => decode({ action: "read", spec_path: "project-change-route" })).not.toThrow() + expect(() => decode({ action: "guide", topic: "blocks" })).not.toThrow() }) it("retains an inline structured spec", () => { @@ -444,12 +448,28 @@ describe("workflow tool schema (negative tests)", () => { }) describe("workflow tool execution", () => { + runtime.effect("rejects workflow orchestration from a child session even if invoked directly", () => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const exit = yield* workflow.execute( + { action: "list" }, + { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }, + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("main conversation") + expect(published).toHaveLength(0) + }), + ) + runtime.effect("description retains the workflow action reference after guidance migration", () => Effect.gen(function* () { const info = yield* WorkflowTool const workflow = yield* info.init() - for (const action of ["start", "extend", "status", "control"]) { + for (const action of ["guide", "start", "extend", "status", "control", "list", "read"]) { expect(workflow.description).toContain(`**${action}**`) } expect(workflow.description).toContain("Do not poll") @@ -457,6 +477,21 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("loads detailed workflow guidance by topic instead of in the always-on description", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const index = yield* workflow.execute({ action: "guide" }, toolContext()) + const blocks = yield* workflow.execute({ action: "guide", topic: "blocks" }, toolContext()) + + expect(workflow.description.length).toBeLessThan(5_000) + expect(index.output).toContain("blocks: compose") + expect(index.output).not.toContain("# Composable Workflow Blocks") + expect(blocks.output).toContain("# Composable Workflow Blocks") + expect(blocks.output).toContain("kind: coding") + }), + ) + runtime.effect("status returns the durable workflow and node state", () => Effect.gen(function* () { const info = yield* WorkflowTool @@ -585,6 +620,45 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("starts from composable blocks and persists only compiled nodes", () => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + Schema.decodeUnknownSync(Parameters)({ + action: "start", + spec: { + config: { + name: "block-start", + objective: "Implement and review session recovery", + blocks: [ + { id: "build", kind: "coding", skills: ["tdd"] }, + { id: "review", kind: "review", depends_on: ["build"] }, + ], + }, + }, + }), + toolContext(), + ) + + expect(result.title).toBe("Workflow started: block-start") + expect(result.output).toContain("4 nodes registered") + const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { + config?: string + } + const config = JSON.parse(created.config ?? "{}") + expect(config).not.toHaveProperty("blocks") + expect(config).not.toHaveProperty("objective") + expect(config.nodes.map((node: { id: string }) => node.id)).toEqual([ + "build", + "review--standards", + "review--intent", + "review", + ]) + }), + ) + runtime.effect("extends from an inline structured spec without a file", () => Effect.gen(function* () { published.length = 0 @@ -614,6 +688,30 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("extends with blocks that depend on an existing durable node", () => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + Schema.decodeUnknownSync(Parameters)({ + action: "extend", + workflow_id: "dag_status", + spec: { + objective: "Repair from the current diagnostic evidence", + blocks: [{ id: "repair", kind: "coding", depends_on: ["node_running"] }], + }, + }), + toolContext(), + ) + + expect(result.title).toBe("Workflow extended: 1 nodes added") + expect(published.find((event) => event.type === DagEvent.NodeRegistered.type)?.data).toEqual( + expect.objectContaining({ nodeID: "repair", dependsOn: ["node_running"] }), + ) + }), + ) + runtime.effect("replans from an inline structured spec without a file", () => Effect.gen(function* () { published.length = 0 @@ -1390,6 +1488,48 @@ describe("workflow tool saved workflows", () => { }), }) satisfies Tool.Context + runtime.effect("read returns a saved route for parent retargeting without starting it", () => + withGlobalConfigDir(() => + Effect.gen(function* () { + published.length = 0 + yield* Effect.promise(() => + Bun.write( + path.join(workflowSpecDirectory, ".opencode", "workflows", "saved-readable.yaml"), + [ + "title: Saved readable route", + "config:", + " name: saved-readable", + " objective: Replace this generic objective", + " blocks:", + " - id: map", + " kind: explore", + "", + ].join("\n"), + ), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + const asked: unknown[] = [] + + const result = yield* workflow.execute( + { action: "read", spec_path: "saved-readable" }, + contextWith(asked), + ) + + expect(result.title).toBe("Workflow spec: saved-readable") + expect(JSON.parse(result.output)).toMatchObject({ + title: "Saved readable route", + config: { + objective: "Replace this generic objective", + blocks: [{ id: "map", kind: "explore" }], + }, + }) + expect(asked).toHaveLength(0) + expect(published).toHaveLength(0) + }), + ), + ) + runtime.effect("start resolves a bare name against the project workflow library", () => withGlobalConfigDir(() => Effect.gen(function* () { @@ -1463,6 +1603,10 @@ describe("workflow tool saved workflows", () => { Promise.all([ Bun.write(path.join(globalDir, "workflows", "shared.yaml"), savedSpec("global-shared")), Bun.write(path.join(globalDir, "workflows", "global-only.yaml"), savedSpec("global-only")), + Bun.write( + path.join(globalDir, "workflows", "block-flow.yaml"), + "title: block flow title\nconfig:\n name: block-flow\n objective: Review a bounded change\n blocks:\n - id: decision\n kind: review\n", + ), Bun.write( path.join(workflowSpecDirectory, ".opencode", "workflows", "shared.yaml"), savedSpec("project-shared"), @@ -1476,6 +1620,7 @@ describe("workflow tool saved workflows", () => { expect(result.output).toContain("shared [project] — project-shared title") expect(result.output).toContain("global-only [global] — global-only title") + expect(result.output).toContain("block-flow [global] — block flow title (1 blocks)") expect(result.output).not.toContain("global-shared") }), ), diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 456fd2afcd..755ebfefeb 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -83,11 +83,13 @@ describe("skill", () => { Effect.gen(function* () { const skill = yield* Skill.Service expect(yield* skill.get("workflow")).toBeUndefined() - expect((yield* skill.all()).filter((item) => item.location === "").map((item) => item.name)).toEqual([ - "customize-opencode", - "configure-hooks", - "create-dag-workflow", - ]) + expect( + (yield* skill.all()).filter((item) => item.location === "").map((item) => item.name), + ).toEqual(["customize-opencode", "configure-hooks", "create-dag-workflow", "orchestration-router"]) + expect(yield* skill.get("orchestration-router")).toMatchObject({ + description: expect.stringContaining("without waiting for /dag-flow"), + location: "", + }) }), { git: true }, ),