The Auction / Contract-Net Pattern, Implemented via the Reactive Reducer
A little background
Nine patterns in. The supervisor article centralized routing - one agent picks the next specialist from a fixed table. This one is different: the assignment itself is a decision made at run time, from bids. That’s the auction / contract-net pattern. A task is advertised to a pool of candidate agents, which compete for the work based on capability, confidence, cost, latency, availability, or model quality. An orchestrator evaluates the bids and awards the task to the most appropriate candidate. Assignment stops being a static routing rule and becomes a dynamic optimization.
In most frameworks, that optimization is a prompt: the orchestrator reads the candidates’ self-descriptions and picks one. The scoring function is whatever the model felt like that day, and you find out what it was by reading the trace. The reactive reducer makes the auction structural: the bids are typed context fields, the award is a typed write that the guards react to, and the scoring function is declared in the award state’s objective - readable before the run, not reconstructed after it.
The pattern
Auction / Contract-Net. A task is advertised to a pool of candidate agents, which compete or bid for the work based on factors such as capability, confidence, cost, latency, availability, model quality, or tool access. An orchestrator evaluates the bids and awards the task to the most appropriate candidate. This makes assignment itself a dynamic optimization problem rather than a static routing rule.
Good for:
- Large heterogeneous agent pools
- Cost optimization
- Dynamic resource allocation
- Model routing
- Systems where agents have different capabilities or availability
Common failure points:
- Agents may misrepresent or poorly calibrate confidence
- Bidding introduces coordination overhead
- Poor scoring functions select the wrong agent
- Cheapest or fastest agent is not necessarily the best
- Bids can become difficult to compare across heterogeneous agents
The machine: the task is advertised, the bids are typed, the award is a write
A translation task that could go to three candidates: a fast model (cheap, quick, lower quality), a standard model (balanced), or a premium model (expensive, slow, high quality). Each bids with a confidence, a cost estimate, and a rationale. The orchestrator scores the bids and awards the task.
fsm:
name: translation-auction
version: 1
initialState: task
context:
task: { type: string } # the advertised work
bids: { type: map, of: string } # the bid pool, keyed by bidder
confidence: { type: map, of: enum, values: [low, medium, high] } # the comparable dimension
winner: { type: enum, values: [fast, standard, premium] } # the pool, declared and enforced
rationale: { type: string } # why the orchestrator awarded it
result: { type: string } # the awarded candidate's output
answer: { type: string } # final deliverable
agents:
orchestrator: { canTransition: [task, award, execute, deliver] }
states:
task:
description: >-
Advertise the task: updateContext(field=task, value=<the work, with quality/cost constraints>).
requires:
- { name: task, from: agent }
transitions:
- { toState: bidding }
bidding:
description: >-
Each candidate bids on "{{ context.task }}". A bid is a cost estimate and a one-line
rationale; confidence is stated separately as low, medium, or high. Bid honestly - the
orchestrator scores all bids against the task's constraints.
requires:
# Same two fields, three named sources. Entries are keyed by the actual bidder, so each
# requirement is satisfied only by ITS candidate - nobody can bid on another's behalf.
- { name: bids, from: { agent: fast }, prompt: "Your cost estimate and rationale." }
- { name: confidence, from: { agent: fast }, prompt: "Your confidence: low, medium, or high." }
- { name: bids, from: { agent: standard }, prompt: "Your cost estimate and rationale." }
- { name: confidence, from: { agent: standard }, prompt: "Your confidence: low, medium, or high." }
- { name: bids, from: { agent: premium }, prompt: "Your cost estimate and rationale." }
- { name: confidence, from: { agent: premium }, prompt: "Your confidence: low, medium, or high." }
transitions:
# No join guard - the six required map entries are the join.
- { toState: award }
award:
description: >-
The bid pool: {{ context.bids }}. Stated confidence: {{ context.confidence }}.
Score them against the task's constraints (quality vs cost vs latency) and award the work.
Stage: updateContext(field=winner, value=<fast|standard|premium>) and
updateContext(field=rationale, value=<why that candidate, against which constraints>).
requires:
- { name: winner, from: { agent: orchestrator } }
- { name: rationale, from: { agent: orchestrator } }
transitions:
- { toState: execute }
execute:
description: >-
You won the auction - {{ context.rationale }}. Do the task: "{{ context.task }}".
Stage: updateContext(field=result, value=<output>).
requires:
# ONE execution state, not one per candidate. The delegate summoned here is whichever
# candidate `award` chose; the enum on `winner` is what constrains the pool.
- { name: result, from: { agent: "{{ context.winner }}" } }
transitions:
- { toState: deliver }
deliver:
description: "Compose the final deliverable from the result. Stage: updateContext(field=answer, value=<deliverable>)."
requires:
- { name: answer, from: { agent: orchestrator } }
transitions:
- { toState: done }
done:
description: "Delivered."
terminal: true
policy:
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
Auction / Contract-Net work flow:
Three things to hold onto:
- The bid pool is one field, not one field per bidder.
bidsis amap, and every entry is keyed by whoever actually wrote it. Three requirements name the same field with three different sources, so each is satisfied only by its own candidate - the join is the requirement set, and nobody can bid on another’s behalf. The advertisement is thetaskfield, templated into every bidder’s objective. There’s no auction house and no bid registry; the pool is a field in the context store, and adding a fourth candidate adds a requirement, not a guard. - The award is a typed write, and the winner’s name is the routing. The orchestrator reads the whole pool in one place, scores it, and writes
winner- an enum over the candidate pool - plus arationalecapturing why. Noroutefield shadowingwinner, and no trigger to consume afterwards. - One execution state, and the delegate is chosen at run time.
from: { agent: "{{ context.winner }}" }resolves against live context when the state is entered, so the machine summons whichever candidate won. That’s what collapses three near-identicalrun_*states into one, and it’s whyfast/standard/premiumdon’t appear inagents:at all - they never drive the workflow, they answer an ask. The pool is declared once, as the enum onwinner, and the enum is what enforces it: a value outside it is rejected at the write, not discovered at the summon.
Walking the auction
The orchestrator advertises the task: a 4,000-word technical document, quality matters more than cost, deadline is tomorrow. bidding parks and summons all three candidates at once. Each reads the task and bids: the fast model bids low cost with medium confidence, rationale is speed; the standard model bids balanced; the premium model bids high cost with high confidence, rationale is quality. Each write lands under its author’s key. When all six entries are keyed in, the state’s requirements are satisfied and the machine moves to award.
The orchestrator reads the pool - one bids map and one confidence map, side by side - against the task’s constraints. Quality matters more than cost, so it awards to the premium model: winner = premium, rationale = "quality-weighted task; premium's high confidence and quality rationale fit the constraints best". execute is entered, and now the template on its from: resolves: the machine summons premium, not because there’s an edge that says so, but because that’s what winner holds. The premium model does the work and stages result. The orchestrator composes the final deliverable. done.
What the reducer does to the failure points
Agents may misrepresent or poorly calibrate confidence. The machine can’t stop a candidate from over-bidding - that’s a calibration problem. But it can make the bids inspectable, attributable, and comparable. confidence is a map of an enum, so every candidate’s confidence is drawn from the same three-value vocabulary and an out-of-vocabulary value is rejected at the write rather than argued about at scoring time. Attribution is forced from the writer, so an over-bid is pinned to whoever made it. And rationale records why the orchestrator picked what it picked - so a mis-calibrated bid that won is visible in the store alongside the reasoning that let it win. The fix is calibration in the candidate agents (or a validate predicate on the bid), not a different auction shape.
Bidding introduces coordination overhead. True - the auction is a fan-out with a join, and that costs a round of concurrency. But the overhead is bounded and visible: the bidding state parks until all bids are in, and the parked record shows you exactly how long the pool took. And the overhead is the price of the pattern’s benefit - dynamic assignment. If the overhead isn’t worth it for a given task, you don’t run the auction; you route statically (the supervisor pattern). The auction is a tool you reach for when the assignment is genuinely dynamic, not a default you pay for on every task.
Poor scoring functions select the wrong agent. The scoring function is declared, not emergent. The award state’s objective says score the bids against the task’s constraints - and the task’s constraints are a staged field the orchestrator reads. You can read the scoring policy before the run (it’s in the YAML), and you can change it by changing the objective. A poor scoring function is a poor objective, which is a prompt you can review and improve - not a behavior you discover in the trace. And because rationale captures the orchestrator’s reasoning, a wrong award is legible: you can see which constraints it weighed and how.
Worth being clear about what’s not declared here: the scoring is still an LLM turn reading a map. Because confidence is an enum, some of it could be - an edge guarded on confidence['agent:premium'] == 'high' is a real, pure, readable predicate - but the version above deliberately leaves the weighing to the orchestrator, because “quality matters more than cost, deadline is tomorrow” is not a predicate. The dial is there if you want more of the decision in the guard and less in the prompt.
Cheapest or fastest agent is not necessarily the best. The orchestrator scores all bid dimensions - confidence, cost, rationale - against the task’s stated constraints, not just cost. The task field carries the constraints (quality matters more than cost, deadline is tomorrow), and the orchestrator’s objective tells it to weigh them. A cheap bid on a quality-weighted task loses to a high-confidence bid, because the scoring is against the constraints, not against the price tag. The cheapest-fastest-not-best failure is a scoring failure, and the scoring is declared and inspectable.
Bids can become difficult to compare across heterogeneous agents. This is the one the design answers most directly, and it’s the one that used to be answered least. The bids land in one field with one element type, not in three differently-named fields that merely happen to be described the same way in a prompt. bids is a map, of: string and confidence is a map, of: enum - so every candidate’s contribution is coerced to the same shape at the write, and the orchestrator reads a single comparable table instead of assembling one. Heterogeneous agents - different models, different costs, different latencies - all bid into the same structure. The heterogeneity is in the values; the structure is uniform, and enforced. That’s the contract-net idea made literal: the contract is the field’s type, and every candidate signs the same one.
Two honest trade-offs. First, collections are one level deep by design - of: names a scalar - so a bid can’t be a record. That’s why confidence lives in its own parallel map rather than nested inside the bid; the alternative is a blob field carrying the structure behind a claim reference. Second, this is a single-round auction: the candidates bid once, the orchestrator awards, and the work happens. A multi-round auction (iterative bidding, where candidates see the current low and re-bid) would add a bidding → award → bidding loop bounded by maxIterations. Note what re-arming that loop costs now: the bid maps have to be cleared on the loop-back edge (remove: { bids: "*" }), because a map entry that’s already there satisfies its requirement, and a round that never asks anyone is a round that silently re-uses last round’s bids.
The whole thing, one idea
| Moment | Who | What happened in context |
|---|---|---|
| task | orchestrator | task set (advertised) |
| bidding | 3 candidates (concurrent) | bids + confidence keyed by each bidder |
| award | orchestrator | winner = premium + rationale set (scored against constraints) |
| execute | premium | summoned by from: {{ context.winner }}; result set |
| deliver | orchestrator | answer set |
| done | - | terminal |
No routing table. No static assignment. No state per candidate. The task was advertised; the candidates bid into one typed, writer-keyed field; the orchestrator scored the pool against the task’s stated constraints and wrote a typed award; and the award was the routing - the winner’s name is what the execution state resolves its delegate from. The scoring function was declared in the YAML, not discovered in the trace, and the pool was declared as an enum, not as a branch per member. The assignment was a dynamic optimization - and the machine was the only thing that moved it.
Up next: 11. Event-Driven / Publish-Subscribe
Series:
- Intro
- 1. Sequential Pipeline
- 2. Parallel Fan-Out / Fan-In
- 3. Supervisor / Router
- 4. Hierarchical Manager / Worker
- 5. Peer Handoff / Swarm
- 6. Debate / Consensus
- 7. Blackboard / Shared-State
- 8. Planner / Executor
- 9. Generator / Critic / Refiner
- 10. Auction / Contract-Net
- 11. Event-Driven / Publish-Subscribe
- 12. Quorum / Voting
- 13. Dynamic Team Formation