iterion

ADR-001: Adding the round_robin mode to Router

Context

The Iterion v1 DSL orchestrates multi-agent workflows with a small set of primitives: agent, judge, router, human, tool, compute, plus convergence via await: on downstream nodes. The router originally supported a single mode — fan_out_all — which spawns every outgoing branch in parallel.

When a workflow needs to alternate between two agents at each iteration of a loop (e.g. Claude refines on turn 1, Codex on turn 2), the v1 DSL forces a cross-pair pattern: structurally duplicating the nodes and crossing the rejection edges between the two pairs.

This pattern surfaced while designing the dual_model_plan_implement_review.bot workflow, which orchestrates:

Measured cost of the cross-pair pattern

Metric With cross-pair With round_robin (estimated)
Nodes 46 ~23
Edges 60 ~30
.bot lines ~550 ~280
Duplicated prompts 0 (shared) 0
Duplicated nodes 23 (everything except prompts/schemas) 0

The duplication is purely structural — every duplicated node has the same delegate, the same prompts, the same schemas. Its only purpose is to provide an anchor point for different edges.

Decision

Add a round_robin mode to the router node in the v1 DSL.

Syntax

router refine_selector:
  mode: round_robin

Semantics

Usage example

router refine_selector:
  mode: round_robin

agent claude_refine:
  backend: "claude_code"
  ...

agent codex_refine:
  backend: "codex"
  ...

workflow example:
  ...
  val_judge -> refine_selector when not ready as refine_loop(4)

  refine_selector -> claude_refine with { ... }
  refine_selector -> codex_refine with { ... }

  claude_refine -> val_fanout with { ... }
  codex_refine -> val_fanout with { ... }
  ...

On the first pass: claude_refine is selected. On the second pass: codex_refine is selected. On the third pass: claude_refine again. And so on.

Simplified workflow

With round_robin, the dual_model_plan_implement_review.bot workflow collapses to:

plan_fanout (fan_out_all) → claude_plan + codex_plan → plans_converge → merge_plans
  → val_fanout (fan_out_all) → claude_val + codex_val → val_converge → val_judge
    → [ready] → impl_selector (round_robin) → claude_implement | codex_implement
    → [not ready] → refine_selector (round_robin) → claude_refine | codex_refine
      → val_fanout (loop)
  → review_fanout (fan_out_all) → claude_review + codex_review → review_converge → review_judge
    → [approved] → done
    → [not approved] → plan_fanout (outer loop with reviews)

23 nodes, zero duplication, intent visible at a glance.

Alternatives considered

1. Status quo — cross-pair pattern only

The cross-pair pattern works and requires no runtime changes. It is used in existing examples such as e2e/testdata/feature_request_dual_model.bot.

Rejected because: duplication grows combinatorially. Alternation between 2 agents doubles the node count. With 3 agents the cross-pair would produce 3× the nodes with 6 crossed paths. At 4, the explosion is unmanageable. The pattern does not scale.

2. Sub-workflows / macros

Encapsulate the cross-pair pattern in a reusable sub-workflow to hide the duplication.

Rejected because: the v1 DSL has no sub-workflow support. Adding it would be a much larger change than a new router mode, with implications for variable scoping, artifacts, and the store. Disproportionate to the problem at hand.

3. Conditional router with user-provided state

Allow the router to evaluate an expression on a previous node’s output to choose an edge (e.g. mode: condition, when last_refiner == "claude" -> codex_refine).

Rejected because: introduces a mini expression language into the DSL, complicates parsing and validation, and round_robin covers the main use case (deterministic alternation) more simply.

Arguments in favor

1. Drastic surface reduction

Half of the dual_model_plan_implement_review.bot file is structural boilerplate that adds nothing for the reader. Each duplicated node has exactly the same delegate, the same prompts, the same schemas — only its name differs to anchor different edges.

2. Readability and declarative intent

The cross-pair encodes the “alternate” intent indirectly, through graph structure. A reader has to mentally reconstruct the pattern to recognize that an alternation is happening. With round_robin, the intent is explicit and declarative.

3. Maintainability

With cross-pair, modifying a prompt, a schema, or a with {} mapping requires propagating the change across all pairs. A miss creates silent divergence between pairs. With round_robin, each node exists exactly once.

4. Composability

round_robin composes naturally with the other primitives:

5. New patterns become possible

Arguments against

1. Introduces state in the router

Today, the fan_out_all router is stateless: it reads its edges and spawns them. round_robin requires a persistent counter. This breaks the invariant “a node depends only on its inputs and the graph.”

Mitigation: loopCounters are already persistent runtime state, managed and serialized analogously. roundRobinCounters follow exactly the same pattern — this is not a precedent, it is a natural extension.

2. Semantics inside loops

When a round_robin is reached via a bounded loop, the question arises: when do we increment the counter? On every traversal or on every full loop cycle?

Resolution: increment on every traversal — that is the simplest and most intuitive semantics. One loop cycle = one traversal = one increment. The counter is a monotonically increasing integer modulo N.

3. Determinism and debugging

The execution path depends on the traversal history (the counter), not only on node outputs. This complicates debugging: “why was Codex chosen?” requires inspecting the counter’s internal state.

Mitigation: emit a router_selected event in the run log, recording the chosen edge and the counter value. The inspect --events tool makes this information visible.

4. More complex graph validation

The IR compiler must enforce additional constraints for round_robin:

Mitigation: these validations are simple to implement and follow the existing model in pkg/dsl/ir/validate.go.

5. Risk of feature creep

After round_robin, demand will follow for weighted_round_robin, random, least_recently_used

Mitigation: limit v1 to fan_out_all and round_robin. Advanced modes are future extensions, explicitly out of scope. The RouterMode type is already an extensible enum.

Implementation plan

Files affected

File Modification
grammar/iterion_v1.ebnf Add round_robin to the router_mode rule
grammar/V1_SCOPE.md Document the new mode
pkg/dsl/ast/ast.go Add RouterRoundRobin to the RouterMode enum
pkg/dsl/parser/ Parse round_robin as a value of mode:
pkg/dsl/ir/ir.go Add RouterRoundRobin to the IR RouterMode type
pkg/dsl/ir/compile.go Compile the AST mode into IR
pkg/dsl/ir/validate.go Validate ≥ 2 outgoing edges, compatible schemas
pkg/runtime/engine.go Edge selection by counter % len(edges) in execRouter / findNext
pkg/store/ Serialize/deserialize roundRobinCounters in the run state
pkg/cli/diagram.go Distinct visual representation for round_robin

State structure

// In RunState or equivalent
type RunState struct {
    // ... existing fields ...
    LoopCounters       map[string]int  // existing
    RoundRobinCounters map[string]int  // new — key: router nodeID
}

Runtime logic (pseudo-code)

func (e *Engine) execRouter(ctx context.Context, rs *RunState, nodeID string) (string, error) {
    node := e.workflow.Graph.Nodes[nodeID]
    edges := e.workflow.Graph.EdgesFrom(nodeID)

    switch node.RouterMode {
    case ir.RouterFanOutAll:
        return e.execFanOut(ctx, rs, nodeID)

    case ir.RouterRoundRobin:
        counter := rs.RoundRobinCounters[nodeID]
        selectedEdge := edges[counter % len(edges)]
        rs.RoundRobinCounters[nodeID] = counter + 1
        // Resolve inputs via the selected edge's with{}
        // Execute the target node
        return selectedEdge.Target, nil
    }
}

Tests

Migration of existing workflows

Once round_robin is implemented, the dual_model_plan_implement_review.bot workflow can be simplified from 46 to 23 nodes. Existing examples that use the cross-pair pattern (e.g. e2e/testdata/feature_request_dual_model.bot) remain valid — cross-pair is a usage pattern, not a DSL constraint.

Consequences


Addendum (2026-04-28) — Backend recommendations

The round_robin pattern described above remains fully valid. However, the original choice of illustrating alternation with Claude Code + Codex is no longer recommended: since this ADR was written, accumulated experience has shown that the codex backend has significant limitations (its tool set cannot be configured, it tends to fill its own context window, and its integration is less polished). The compiler now emits a C030 warning when a node uses backend: "codex".

For new workflows using round_robin, prefer alternating between:

Historical examples that used codex in this role have been migrated in the same commit; see e2e/testdata/dual_model_plan_implement_review.bot for the current (archived) version.