Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions packages/core/src/plugin/command/workflow-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,45 @@ DAG; for a verdict, the matching audit is primary. Do not concatenate two comple

Read the selected reference, retarget its objective and instructions, and
remove phases current evidence already covers. Start its saved `spec_path`
directly only when target and acceptance evidence match. If none fits, load
`guide(topic="blocks")` and compose a task-local YAML. Load
directly only when target and acceptance evidence match. If none fits, compose
a task-local graph. Load `guide(topic="blocks")` for block contracts and
`guide(topic="patterns")` only when domains overlap. Use low-level nodes only
for fields blocks cannot express.

Write the graph to YAML and validate that `spec_path` before start. Fix every
diagnostic in the same file and revalidate; validation creates no workflow. A
successful start returns the exact workflow ID. The parent owns the graph,
controls, and final report; children own bounded work. End after start and let
the workflow wake the parent. Do not poll merely to wait or claim an unstarted
graph is running.
Prefer `workflow(action="draft")` over hand-writing YAML: pass the structured
`config` (same fields as the YAML below) and the tool renders and validates the
spec file, returning the `spec_path` to start. Field-name drift is impossible
because the parameter schema rejects unknown fields. Hand-write YAML only for
features draft does not carry (admission, custom bindings). The exact start
shape, for that fallback and for reading draft output:

```yaml
title: Implement session recovery
config:
name: implement-session-recovery
objective: Implement session recovery with focused tests and review.
blocks:
- id: map
kind: explore
instruction: Locate the ownership and persistence seams.
- id: coding
kind: coding
depends_on: [map]
- id: review
kind: review
depends_on: [coding]
```

Top level is `title`/`mode`/`admission` (optional) and `config` (required);
`objective` lives INSIDE `config`; every block field is one of `id` (required),
`kind` (required), `depends_on`, `instruction`, `worker_type`, `required`,
`report_to_parent` — never `worker`, `prompt`, or `agent`.

Validate that `spec_path` before start. Fix every diagnostic in the same file
and revalidate; validation creates no workflow. A successful start returns the
exact workflow ID. The parent owns the graph, controls, and final report;
children own bounded work. End after start and let the workflow wake the
parent. Do not poll merely to wait or claim an unstarted graph is running.

## Progressive guidance

Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/plugin/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ describe("CommandPlugin.Plugin", () => {

it.effect("keeps always-on guidance small and loads detailed topics progressively", () =>
Effect.sync(() => {
expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(5_000)
// Budget admits the inline start-spec example (one-hop field reference
// for hand-written YAML) while keeping per-action manuals progressive.
expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(6_500)
expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes")
expect(CommandPlugin.WorkflowContent).toMatch(/even one project\s+file/)
expect(CommandPlugin.WorkflowContent).toMatch(/isolated utility\s+scripts/)
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/dag/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,32 @@ function collectLeafIssues(issue: unknown, path: readonly string[], out: LeafIss
if (message) out.push({ path: nextPath.join("") || "$", message })
}

// High-frequency authoring drift: fields the model reaches for from
// neighboring vocabularies, mapped to the field that exists. The decode leaf
// only carries the tag ("UnexpectedKey"); the offending field name lives in
// the diagnostic path, so both are matched.
const FIELD_DRIFT_HINTS: Record<string, string> = {
worker: "worker_type",
workers: "worker_type",
agent: "worker_type",
prompt: "instruction",
task: "instruction",
objective: "config.objective",
graph: "config",
spec: "config",
nodes: "blocks (or vice versa — exactly one graph source)",
blocks: "nodes (or vice versa — exactly one graph source)",
}

function driftHint(path: string, message: string) {
for (const [wrong, right] of Object.entries(FIELD_DRIFT_HINTS)) {
if (message.includes(`"${wrong}"`) || path.includes(`["${wrong}"]`)) {
return `Did you mean "${right}"? Every block field is one of id, kind, depends_on, instruction, worker_type, required, report_to_parent; objective lives inside config`
}
}
return "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes"
}

export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] {
const leaves: LeafIssue[] = []
collectLeafIssues(isRecord(error) && error.issue !== undefined ? error.issue : error, basePath ? [basePath] : [], leaves)
Expand All @@ -358,7 +384,7 @@ export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] {
code: DIAGNOSTIC_CODES.schemaInvalid,
path: leaf.path,
message: leaf.message,
hint: "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes",
hint: driftHint(leaf.path, leaf.message),
}),
),
)
Expand Down
77 changes: 76 additions & 1 deletion packages/opencode/src/tool/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SessionID } from "@/session/schema"
import { createAdmissionRecord } from "@/dag/admission"
import { TerminalViolationError } from "@opencode-ai/core/dag/core/types"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { stringify as yamlStringify } from "yaml"
import { assertExternalDirectoryEffect } from "./external-directory"
import path from "node:path"

Expand Down Expand Up @@ -110,6 +111,17 @@ const Guide = Schema.Struct({
"blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index",
}),
})
const Draft = Schema.Struct({
action: Schema.Literal("draft").annotate({
description:
'Render a structured graph into a validated YAML spec file and return its spec_path — no workflow is created. Preferred over hand-writing YAML: field names are schema-checked here, eliminating serialization drift',
}),
title: Schema.optional(Schema.String).annotate({ description: "Optional workflow title" }),
config: DagValidation.WorkflowGraphSchema.annotate({
description:
'Exactly one graph shape: { name, objective, blocks: [{ id, kind, depends_on?, instruction?, worker_type?, required?, report_to_parent? }], node_defaults?, max_concurrency?, max_node_replan_attempts?, max_total_nodes? } or the low-level { name, nodes: [...] } form. Fields are exhaustive — no others exist',
}),
})
const ValidationProfile = Schema.optional(Schema.Literals(["portable", "environment"])).annotate({
description:
"portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, project/global/path specs environment",
Expand All @@ -132,6 +144,7 @@ const ActionParams = Schema.Union([
List,
Read,
Guide,
Draft,
ValidatePath,
])

Expand Down Expand Up @@ -251,7 +264,7 @@ export const WorkflowTool = Tool.define<
formatValidationError: (error) =>
[
`Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`,
'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.',
'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; draft {title?, config}; validate {spec_path, profile?}. Put graph content in draft (structured, schema-checked) or a .yaml/.yml file; session/project identity is never a parameter.',
].join("\n"),
execute: (call: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -300,6 +313,36 @@ export const WorkflowTool = Tool.define<
metadata: {},
}
}
case "draft": {
const specPath = yield* writeDraftSpec(params.config, params.title, callingSession.directory).pipe(
Effect.orDie,
)
const result = yield* authoring.prepare({
action: "start",
source: { kind: "yaml", source: specPath, content: yield* readDraftSpec(specPath).pipe(Effect.orDie) },
profile: "portable",
})
if (!result.valid) {
return {
title: `Workflow draft written with validation errors: ${params.config.name}`,
output: [
`spec_path: ${specPath}`,
"The file is on disk; fix the errors by calling draft again with corrected fields. Diagnostics:",
...result.errors.map((d) => `- [${d.code}] ${d.path}: ${d.message}${d.hint ? ` (${d.hint})` : ""}`),
].join("\n"),
metadata: {},
}
}
return {
title: `Workflow draft valid: ${params.config.name}`,
output: [
`spec_path: ${specPath}`,
`nodes: ${result.nodes.length}`,
'Next: workflow(action="start", spec_path) — or extend the file first for low-level fields, then start.',
].join("\n"),
metadata: {},
}
}
case "list": {
const entries = yield* DagWorkflows.list(callingSession.directory)
if (entries.length === 0) {
Expand Down Expand Up @@ -729,6 +772,38 @@ function validationOutput(result: DagValidation.ValidationResult) {
}
}

const DRAFT_DIRECTORY = path.join(".opencode", "workflow-drafts")
const DRAFT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/

function writeDraftSpec(
config: DagValidation.StartGraph,
title: string | undefined,
directory: string,
): Effect.Effect<string, Error> {
return Effect.gen(function* () {
if (!DRAFT_NAME_PATTERN.test(config.name)) {
return yield* Effect.fail(
new Error(
`Workflow name must match ${DRAFT_NAME_PATTERN.source} (it becomes the spec filename): ${config.name}`,
),
)
}
const dir = path.join(directory, DRAFT_DIRECTORY)
yield* Effect.promise(() => Bun.write(Bun.file(path.join(dir, ".keep")), ""))
const specPath = path.join(dir, `${config.name}.yaml`)
const content = yamlStringify({ ...(title ? { title } : {}), config })
yield* Effect.promise(() => Bun.write(specPath, content))
return specPath
})
}

function readDraftSpec(specPath: string) {
return Effect.tryPromise({
try: () => Bun.file(specPath).text(),
catch: (error) => new Error(`Failed to read draft spec ${specPath}: ${String(error)}`),
})
}

function loadSpecFile(specPath: string, directory: string, ctx: Tool.Context) {
return Effect.gen(function* () {
const filepath = yield* resolveSpecPath(specPath, directory, ctx)
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/test/dag/workflow-authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ const start = {
}

describe("WorkflowAuthoring source-to-graph seam", () => {
it.effect("maps high-frequency field drift to the field that exists", () =>
Effect.gen(function* () {
const authoring = WorkflowAuthoring.make()
const result = yield* authoring.prepare({
action: "start",
source: {
kind: "yaml",
source: "drift.yaml",
content: [
"config:",
" name: drift",
" objective: Field drift probe.",
" blocks:",
" - id: a",
" kind: coding",
" worker: general",
].join("\n"),
},
profile: "portable",
})
expect(result.valid).toBe(false)
expect(result.errors.some((e) => e.hint.includes('Did you mean "worker_type"?'))).toBe(true)
}),
)

it.effect("keeps every block-guide YAML envelope executable", () =>
Effect.gen(function* () {
const guide = CommandPlugin.WorkflowBlocksContent
Expand Down
Loading
Loading