round_robin mode to Routere2e/testdata/dual_model_plan_implement_review.bot (archived)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:
| 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.
Add a round_robin mode to the router node in the v1 DSL.
router refine_selector:
mode: round_robin
edge_index = counter % len(edges)loopCounters)resume after pause, the counter is restored from the storerouter 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.
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.
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.
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.
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.
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.
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.
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.
round_robin composes naturally with the other primitives:
as loop(N)): the alternation stops when the loop
expiresfan_out_all upstream/downstream: you can alternate the
implementer while parallelizing the reviewersToday, 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.
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.
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.
The IR compiler must enforce additional constraints for round_robin:
with {} feeds N
targets)Mitigation: these validations are simple to implement and follow the
existing model in pkg/dsl/ir/validate.go.
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.
| 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 |
// In RunState or equivalent
type RunState struct {
// ... existing fields ...
LoopCounters map[string]int // existing
RoundRobinCounters map[string]int // new — key: router nodeID
}
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
}
}
round_robin, compiling, validating (≥ 2 edges,
< 2 edges = error)round_robin over 2 targets,
asserting alternation across 4 iterationsround_robin + bounded loop + resume,
asserting counter persistencefan_out_all is unchangedOnce 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.
roundRobinCounters) to
persist and restoreRouterMode type is ready for future extensions (weighted,
random, etc.) without architectural changesThe 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:
claude_code (delegate) + the in-process claw API with an OpenAI
model (model: "openai/gpt-5.4-mini"), orclaude_code instances configured with different Claude models
(e.g. Sonnet vs. Opus), orclaw models (e.g. anthropic/claude-... vs.
openai/gpt-...).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.