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
15 changes: 9 additions & 6 deletions packages/core/src/plugin/command/orchestration-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,18 +158,21 @@ If a required capability has no eligible role, report the missing capability and

## Model Assignment

Omit `node.model` by default. Let the existing configuration fallback remain authoritative:
Never emit `node.model` or `config.node_defaults.model`. Model assignment belongs
to runtime configuration, not the workflow graph:

`node.model` → `config.node_defaults.model` → configured agent model → parent session model
`dag.jsonc` tier → configured agent model → parent session model

Pin a model only when the user supplies an exact provider/model pair for a node or policy slot. Store the provider in `providerID` and only the provider-local `modelID` in `modelID`; never repeat the provider prefix inside `modelID`.

Qualitative labels such as "strong", "fast", or "cheap" may guide capability and role selection, but you MUST NOT invent a model identifier. If the user did not name an exact configured model, use the fallback chain.
Qualitative labels such as "strong", "fast", or "cheap" guide tier placement,
but you MUST NOT invent a model identifier. If every configured source is
missing, the workflow tool starts parent-session QA and does not create the
workflow. Ask the user to configure a `dag.jsonc` tier, the selected agent, or
the parent-session model, then retry.

Prefer expressing "strong model for judgment, fast model for volume" through
tier placement — `required: true` and `review`/`review-*` workers resolve to
the advanced tier of `dag.jsonc`, everything else to standard — rather than
per-node pins.
graph-level model fields.

## Profile: Brainstorm

Expand Down
48 changes: 16 additions & 32 deletions packages/core/src/plugin/command/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# Workflow Orchestration

The `workflow` tool orchestrates heavy tasks as dependency-graph multi-agent workflows. Each node runs as a real child session with its own agent, tools, and optionally its own model. This skill covers when to start a workflow, how to structure it, and how to adapt it at runtime.
The `workflow` tool orchestrates heavy tasks as dependency-graph multi-agent workflows. Each node runs as a real child session with its own agent and tools. This skill covers when to start a workflow, how to structure it, and how to adapt it at runtime.

Compile every graph under the Tiered Orchestration Doctrine and Depth Ladder in the orchestration policy below: advanced-tier judgment nodes conduct and check, standard-tier nodes carry the volume, and accuracy is bought with breadth (concurrent fan-out) and depth (verdict-gated waves) rather than with a single trusted pass.

Expand Down Expand Up @@ -81,27 +81,14 @@ config:
report_to_parent: false
worker_config:
timeout_ms: 600000
model:
providerID: local-proxy-compatible
modelID: glm-5.2
```

This is the preferred place for a workflow-wide model. If both the node and
`config.node_defaults.model` omit it, resolution continues to the selected
agent model and then the parent session model.

When overriding a model, split the provider and provider-local model ID:

```yaml
model:
providerID: local-proxy-compatible
modelID: glm-5.2
```

Do not put `local-proxy-compatible/glm-5.2` into `modelID` while also setting `providerID`; that repeats the provider prefix.
Omit `node.model` unless the user supplied an exact provider/model selection.
Qualitative requests such as "use a strong model" must not be converted into an
invented model identifier.
Never emit `node.model` or `config.node_defaults.model`. Model selection is
configuration-owned: critical nodes (`required: true` and review workers) use
the `advanced` tier in `dag.jsonc`, other nodes use `standard`, then resolution
falls back to the selected agent model and the parent-session model. If no
source provides a model, the workflow tool starts parent-session QA and leaves
the workflow uncreated so the user can configure a model and retry.

## Collaboration Patterns

Expand Down Expand Up @@ -270,9 +257,8 @@ config:
inline: "The arbiter did not accept. Verify each required action against the actual code and produce a corrected, evidence-backed action plan."
```

Reviewer nodes may use different exact models when the user selected them;
otherwise omit `model` and let workflow, agent, and parent configuration provide
the defaults. The arbiter is `required: true` — its execution failure signals
Reviewer nodes use the `advanced` tier from `dag.jsonc`. The arbiter is
`required: true` — its execution failure signals
that the artifact could not be confidently accepted, while its successful
business verdict must still be interpreted. On `ACCEPT` the conditioned
`deep-dive` node is skipped and the workflow completes; on any other verdict
Expand Down Expand Up @@ -358,20 +344,19 @@ paused, until you act.

## Model Assignment Strategy

Each node MAY specify `model: { modelID, providerID }` to pin a specific model.
`modelID` is the provider-local model ID; never repeat `providerID` inside it.
If omitted, resolution follows `config.node_defaults.model`, then the configured
agent model and then the parent session model. Pin only an exact user-supplied
selection and never invent an identifier from a qualitative request.
Workflow definitions MUST NOT specify `node.model` or
`config.node_defaults.model`. Resolution follows the `dag.jsonc` tier, then the
configured agent model, then the parent-session model. If all three are absent,
the workflow tool asks the user to configure a model and does not create the
workflow.

- Expensive models for planning, review, and arbitration — high-stakes decisions where reasoning quality matters.
- Fast models for mechanical implementation — well-specified edits where speed and cost matter.
- Diverse models in adversarial review — reduces single-model blind spots.

The two-tier defaults in `dag.jsonc` implement this split mechanically:
The two-tier defaults in `dag.jsonc` implement the split mechanically:
`required: true` nodes and `review`/`review-*` workers resolve to the
`advanced` tier, every other node to `standard`. Prefer expressing the split
through tier placement rather than per-node pins.
`advanced` tier, every other node to `standard`.

## Prompt Templates

Expand Down Expand Up @@ -462,7 +447,6 @@ checkpoint naturally completed the current graph; an early
| `depends_on` | yes | Array of node IDs this node waits for (`[]` for root) |
| `required` | no | If true and this node fails, the workflow terminalizes as failed. Default: false |
| `prompt_template` | yes | `{ id: "..." }` or `{ inline: "...", input: {...} }` |
| `model` | no | `{ modelID, providerID }` override |
| `condition` | no | Expression evaluated before spawn; node is skipped if false |
| `input_mapping` | no | Map upstream node outputs into template variables |
| `report_to_parent` | no | If true, the parent agent is woken when this node completes or fails. The workflow's terminal status always wakes the parent regardless of this flag |
Expand Down
19 changes: 12 additions & 7 deletions packages/core/test/plugin/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ describe("CommandPlugin.Plugin", () => {

it.effect("documents config-first model fallback without invented identifiers", () =>
Effect.sync(() => {
expect(CommandPlugin.OrchestrationPolicyContent).toContain("Omit `node.model` by default")
expect(CommandPlugin.OrchestrationPolicyContent).toContain(
"`node.model` `config.node_defaults.model` → configured agent model → parent session model",
"Never emit `node.model` or `config.node_defaults.model`",
)
expect(CommandPlugin.OrchestrationPolicyContent).toContain("exact provider/model pair")
expect(CommandPlugin.OrchestrationPolicyContent).toContain("providerID")
expect(CommandPlugin.OrchestrationPolicyContent).toContain("provider-local `modelID`")
expect(CommandPlugin.OrchestrationPolicyContent).toContain(
"`dag.jsonc` tier → configured agent model → parent session model",
)
expect(CommandPlugin.OrchestrationPolicyContent).toContain("workflow tool starts parent-session QA")
expect(CommandPlugin.OrchestrationPolicyContent).toContain("does not create the\nworkflow")
expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT invent a model identifier")
}),
)
Expand Down Expand Up @@ -327,8 +328,12 @@ describe("CommandPlugin.Plugin", () => {
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`")
expect(CommandPlugin.WorkflowFactsContent).toContain("provider-local model ID")
expect(CommandPlugin.WorkflowFactsContent).toContain("agent model and then the parent session model")
expect(CommandPlugin.WorkflowFactsContent).toContain(
"Workflow definitions MUST NOT specify `node.model` or\n`config.node_defaults.model`",
)
expect(CommandPlugin.WorkflowFactsContent).toMatch(
/`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(
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/dag/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export type Info = typeof Info.Type

const DEFAULT_CONTENT = `{
// DAG workflow defaults — applies to every DAG child session.
// Model resolution per node: node.model → config.node_defaults.model
// → worker agent model → this file's tier → parent session model.
// Model resolution per node: persisted legacy node model
// → this file's tier → worker agent model → parent session model.
// Format: "provider/model", e.g. "anthropic/claude-sonnet-4-5".
"model": {
// Advanced tier — critical nodes: required: true and review/arbiter workers.
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/dag/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export * as DagModel from "./model"

export type Ref = {
modelID: string
providerID: string
}

/**
* Resolve one DAG node model from most specific to broadest. Persisted node
* models remain first for compatibility with existing workflows; new workflow
* tool inputs omit them and normally resolve through the configured DAG tier.
*/
export function resolve(input: {
node?: Ref
tier?: Ref
agent?: Ref
parent?: Ref
}) {
return input.node ?? input.tier ?? input.agent ?? input.parent
}
25 changes: 17 additions & 8 deletions packages/opencode/src/dag/runtime/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ import { SessionID, MessageID } from "@/session/schema"
import { deriveSubagentSessionPermission } from "@/agent/subagent-permissions"
import { SessionPrompt } from "@/session/prompt"
import { Dag } from "../dag"
import { DagModel } from "../model"
import { validateReviewResult } from "../review-lifecycle"
import { InvalidTransitionError, TerminalViolationError } from "@opencode-ai/core/dag/core/types"
import type { DagStore } from "@opencode-ai/core/dag/store"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { registerCaptureSlot, clearCaptureSlot } from "./capture"

type PromptParts = SessionPrompt.PromptInput["parts"]
Expand All @@ -47,7 +50,7 @@ export interface NodeSpawnInput {
timeoutMs?: number
reportToParent?: boolean
reviewImplementationFingerprint?: string
/** dag.jsonc tier default — slots between the agent model and the parent-session model. */
/** dag.jsonc tier default — authoritative unless a persisted legacy node model exists. */
fallbackModel?: { modelID: string; providerID: string }
/** dag.jsonc thinking_depth — forwarded as the prompt variant (no-op unless the model defines it). */
variant?: string
Expand Down Expand Up @@ -86,18 +89,24 @@ export function spawnNode(
: undefined
const nodeModel = persistedNodeModel
? {
modelID: persistedNodeModel.modelID as never,
providerID: persistedNodeModel.providerID as never,
modelID: persistedNodeModel.modelID,
providerID: persistedNodeModel.providerID,
}
: undefined
const model = nodeModel
?? (agent.model ? { modelID: agent.model.modelID, providerID: agent.model.providerID } : undefined)
?? (input.fallbackModel ? { modelID: input.fallbackModel.modelID as never, providerID: input.fallbackModel.providerID as never } : undefined)
?? (parent.model ? { modelID: parent.model.id, providerID: parent.model.providerID } : undefined)
if (!model) {
const resolvedModel = DagModel.resolve({
node: nodeModel,
tier: input.fallbackModel,
agent: agent.model,
parent: parent.model ? { modelID: parent.model.id, providerID: parent.model.providerID } : undefined,
})
if (!resolvedModel) {
yield* dag.nodeFailed(input.dagID, input.nodeID, `no model configured for agent: ${agent.name}`, "exec_failed")
return yield* Effect.fail(new Error(`No model configured for agent: ${agent.name}`))
}
const model = {
modelID: ModelV2.ID.make(resolvedModel.modelID),
providerID: ProviderV2.ID.make(resolvedModel.providerID),
}

const childPermission = deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
Expand Down
90 changes: 79 additions & 11 deletions packages/opencode/src/tool/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import * as Tool from "./tool"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { Effect, Schema } from "effect"
import { Dag } from "@/dag/dag"
import { DagConfig } from "@/dag/config"
import { DagModel } from "@/dag/model"
import { Agent } from "@/agent/agent"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import type { NodeConfig, WorkflowConfig } from "@/dag/dag"
Expand Down Expand Up @@ -41,9 +45,6 @@ const NodeSchema = Schema.Struct({
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" }),
model: Schema.optional(Schema.Struct({ modelID: Schema.String, providerID: Schema.String })).annotate({
description: 'Optional node override. modelID is provider-local, e.g. { providerID: "local-proxy-compatible", modelID: "glm-5.2" }, never repeat providerID inside modelID. Omit to inherit config.node_defaults.model, then the agent or parent-session model',
}),
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" }),
Expand All @@ -67,15 +68,9 @@ const WorkflowGraphSchema = Schema.Struct({
}),
),
report_to_parent: Schema.optional(Schema.Boolean),
model: Schema.optional(
Schema.Struct({
modelID: Schema.String,
providerID: Schema.String,
}),
),
}),
).annotate({
description: "Defaults inherited by nodes that omit required, worker_config, report_to_parent, or model",
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" }),
Expand Down Expand Up @@ -103,11 +98,17 @@ export const Parameters = Schema.Struct({

type Metadata = { workflowId?: string; added?: string[]; cancel?: string[]; restart?: string[]; replace?: string[] }

export const WorkflowTool = Tool.define<typeof Parameters, Metadata, Dag.Service | Session.Service>(
export const WorkflowTool = Tool.define<
typeof Parameters,
Metadata,
Dag.Service | Session.Service | Agent.Service | Question.Service
>(
id,
Effect.gen(function* () {
const dag = yield* Dag.Service
const sessions = yield* Session.Service
const agents = yield* Agent.Service
const question = yield* Question.Service

return {
description: CommandPlugin.WorkflowContent,
Expand Down Expand Up @@ -170,6 +171,39 @@ export const WorkflowTool = Tool.define<typeof Parameters, Metadata, Dag.Service
if (params.project_id && params.project_id !== session.projectID) {
return yield* Effect.die(new Error("project_id must match the parent session project"))
}
const missingModels = yield* findNodesWithoutModel({
nodes: params.config.nodes,
defaults: params.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: [
{
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)
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,
Expand Down Expand Up @@ -256,3 +290,37 @@ export const WorkflowTool = Tool.define<typeof Parameters, Metadata, Dag.Service
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
}),
)

function findNodesWithoutModel(input: {
nodes: ReadonlyArray<Schema.Schema.Type<typeof NodeSchema>>
defaults?: Schema.Schema.Type<typeof WorkflowGraphSchema>["node_defaults"]
directory: string
parent?: Session.Info["model"]
agents: Agent.Interface
}) {
if (input.nodes.length === 0) return Effect.succeed([])
return Effect.gen(function* () {
const config = yield* DagConfig.load(input.directory)
return yield* Effect.filter(
input.nodes,
(node) =>
Effect.gen(function* () {
const agent = yield* input.agents.get(node.worker_type).pipe(
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
}),
{ concurrency: "unbounded" },
).pipe(Effect.map((nodes) => nodes.map((node) => node.id)))
})
}
Loading
Loading