Skip to content
Like what we’re building? Star on GitHub

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:

  • Parallel planning (Claude + Codex) with a Claude-driven merge
  • A validation/refinement loop with the refiner alternating between models
  • Implementation with the implementer alternating between models
  • Parallel review with a fall-back to planning on rejection

Measured cost of the cross-pair pattern

MetricWith cross-pairWith round_robin (estimated)
Nodes46~23
Edges60~30
.bot lines~550~280
Duplicated prompts0 (shared)0
Duplicated nodes23 (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

iter
router refine_selector:
  mode: round_robin

Semantics

  • On each traversal of the router, exactly one outgoing edge is activated, selected by a cyclic counter: edge_index = counter % len(edges)
  • The counter is auto-incremented on each traversal
  • The counter is persisted in the run state (analogous to loopCounters)
  • The counter starts at 0 (the first edge declared in the workflow)
  • On resume after pause, the counter is restored from the store

Usage example

iter
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
  refine_selector -> codex_refine

  claude_refine -> val_fanout
  codex_refine -> val_fanout
  # …

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:

  • With bounded loops (as loop(N)): the alternation stops when the loop expires
  • With fan_out_all upstream/downstream: you can alternate the implementer while parallelizing the reviewers
  • Future extension to N agents without combinatorial explosion

5. New patterns become possible

  • Team rotation across 3+ agents
  • Asymmetric implementer/reviewer alternation
  • Model diversity on repeated tasks (avoiding single-model bias)

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:

  • At least 2 outgoing edges (otherwise it is a regular node)
  • Target input schemas must be compatible (the same with {} feeds N targets)

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

FileModification
grammar/iterion_v1.ebnfAdd round_robin to the router_mode rule
grammar/V1_SCOPE.mdDocument the new mode
pkg/dsl/ast/ast.goAdd RouterRoundRobin to the RouterMode enum
pkg/dsl/parser/Parse round_robin as a value of mode:
pkg/dsl/ir/ir.goAdd RouterRoundRobin to the IR RouterMode type
pkg/dsl/ir/compile.goCompile the AST mode into IR
pkg/dsl/ir/validate.goValidate ≥ 2 outgoing edges, compatible schemas
pkg/runtime/engine.goEdge selection by counter % len(edges) in execRouter / findNext
pkg/store/Serialize/deserialize roundRobinCounters in the run state
pkg/cli/diagram.goDistinct visual representation for round_robin

State structure

go
// 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)

go
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

  • Unit: parsing round_robin, compiling, validating (≥ 2 edges, < 2 edges = error)
  • Integration: minimal workflow with round_robin over 2 targets, asserting alternation across 4 iterations
  • E2E: workflow with round_robin + bounded loop + resume, asserting counter persistence
  • Regression: ensure fan_out_all is unchanged

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

  • The v1 DSL gains a routing primitive that covers a frequent use case (agent alternation) without resorting to structural duplication
  • The runtime gains an additional state vector (roundRobinCounters) to persist and restore
  • Future workflows can express alternation patterns declaratively and concisely
  • The cross-pair pattern remains available for cases where finer control is required
  • The RouterMode type is ready for future extensions (weighted, random, etc.) without architectural changes

Addendum (2026-08-13) — Codex backend status

The round_robin pattern and its Claude Code + Codex example remain valid. Codex is a supported explicit CLI backend. Its native tool set cannot currently be narrowed through Iterion's tools: list and it cannot run inside Iterion's outer Docker/Kubernetes sandbox; those are documented capability boundaries, not a deprecation signal.

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.