Edges That Decide at Runtime

TL;DR

  • Part 3’s fan-out gave all 43 files a node; its own trace shows 31 of 43 found nothing to do. This post adds the sweeper’s first conditional edge: one code sweep classifies, and only the 12 dirty files get fix and verify nodes.
  • Both arms ran fresh in the same environment. Un-routed: 86 agent calls, 2,419,777 tokens, 456.1s. Routed: 24 calls plus a 1.98s router sweep, 684,636 tokens, 211.8s. Identical independently verified end state, n=1 per arm.
  • The test that generalizes: judgment at the node, determinism at the edge. This run’s classifier was shellcheck itself, so it could not misroute on this job. The moment classification needs judgment, the classifier is a node, and it needs a contract and a verifier.

📊 Result, up front. Same 43 real files, same fix and verify prompts, same session, same environment. The only difference is one edge that decides at runtime. Both arms ended 43/43 independently verified clean.

Arm U (un-routed)Arm R (routed)
Agent calls8624 + one 1.98s code sweep
Tokens2,419,777684,636
Wall clock456.1s211.8s
End state43/43 verified clean43/43 verified clean

Part 7 closed on a promise: the next measured problem ships next in this series. Here it is: the graph pays for nodes the input never needed. This is a post about conditional agent routing, measured rather than asserted. One classification step ahead of the fan-out, both arms measured fresh, and the router’s own cost charged against its saving.

Prerequisites:

  • Finished Part 3 (the 43-file fan-out): the run whose trace showed 31 of 43 nodes with nothing to do.
  • Finished Part 4 (the reduce step): the arrange/decide test Step 5 extends to classification.

Step 1: The loop had one path, and the fan-out kept it

The loop spec ran the identical step for every file, every round, even files anyone could see were clean (loop anatomy). One path; no edge ever decided anything. Part 3 promoted the loop to a fan-out and kept that blindness at width: all 43 files got a node, unconditionally.

The fan-out’s own published trace names the price: 31 of 43 fix nodes found 0 findings and returned without editing anything, at full node price each (graph-fan-out-one-agent-per-file). Every part since made nodes better (contract, verifier, isolation) or scheduled them better (barrier vs pipeline). None ever asked whether a node should run at all. The dispatch line makes the blindness visible:

// Part 3's shape: every file gets a fix node, unconditionally
const results = await parallel(files.map((f) => () => agent(fixPrompt(f))));

Verify: count the returns in your fan-out where the agent reported nothing to do. If most nodes are provable no-ops, this edge applies to you.

Step 2: Judgment at the node, determinism at the edge

A router is a classification step that decides what each input needs, followed by plain code that picks the branch. Same classification in, same branch out, every time. There’s no emergent “the agent decided to skip the audit step,” because the routing itself never touches a model. That’s the one concept: judgment at the node, determinism at the edge.

un-routed: 43 files -> 43 fix -> 43 verify (86 calls, 31 provable no-ops)
routed: 43 files -> sweep (code, 1.98s, 0 tokens)
|- 31 clean -> terminate at the edge
`- 12 dirty -> 12 fix -> 12 verify (24 calls)

The honest twist, stated early: on this job the classifier is shellcheck itself. Code, not an agent, the cheapest possible router, and it cannot misroute here because the classifier is the ground-truth tool. The general shape, an agent classifying on fuzzy criteria like small/clean vs large/complex, is the variant Step 5 prices.

The literature asserts both directions without measuring either. Patronus’s agent-routing guide warns that “Incorrect agents also consume additional input and output tokens, thereby increasing the overall cost,” with zero measurements. Callsphere’s tutorial on LangGraph conditional routing is pure add_conditional_edges() mechanics, no cost data at all. Nobody publishes both bills for the same job.

Verify: name your own graph’s classifier. If its output is something you’d trust as ground truth anyway (a linter, a type checker, a test suite), your router is code. If it needs judgment, see Step 5.

Step 3: Build both arms, same files, same prompts, one edge apart

The design in one breath: Arm U reproduces Part 3’s Snapshot-B shape, 43 fix agents dispatched at once, then 43 verify agents. Arm R runs one harness-timed code sweep first, and only the finding-carriers get fix and verify with identical prompts. I ran both arms fresh, same session, same container, per Part 3’s like-for-like precedent.

Environment, pinned once: a 2-CPU cloud container, ShellCheck 0.11.0, corpus fingerprint verified identical to repo HEAD 776206f6. The concurrency cap observed from dispatch timestamps was 2 in both arms, where Part 3’s environment observed 16, so no wall clock here compares to that post’s. A live pre-run sweep re-verified the split: 35 findings across 12 files, 31 clean.

The contracts are inherited. The fix node returns Part 2’s four-field {file, before, after, exitCode} object (graph-node-contract-schema); the verify node is a fresh-context observer returning {file, findingsNow, exitCode, bashOk}, no edits. The router is a few lines:

Terminal window
# The router: one sweep, harness-timed, then a code partition
shellcheck -f json1 $(git ls-files '*.sh') > sweep.json # 1.98s, exit 1, 0 tokens
jq -r '[.comments[].file] | unique | .[]' sweep.json # 12 dirty files
# 0-finding files terminate at the edge; only these 12 get fix -> verify
Arm UArm R
Routernoneone code sweep, 1.98s, 0 tokens
Fix nodes4312
Verify nodes4312
Promptsidenticalidentical

The routed set came from the sweep’s output alone. Ground truth after each arm is harness-run, never self-report; Step 4 shows the three checks.

Verify: fingerprint your corpus before each arm and re-run the classification live. This run re-verified 35/12/31 at HEAD before any agent was dispatched.

Step 4: What conditional agent routing saved here

Routing cut agent calls from 86 to 24 (72.1% fewer), tokens from 2,419,777 to 684,636 (71.7% fewer, 1,735,141 saved), and wall clock from 456.1s to 211.8s (53.6% less, 2.15x), for the identical independently verified end state. The router’s own price, charged inside Arm R’s total, was 1.98s, 0.9% of the arm.

Arm U (un-routed)Arm R (routed)
Agent calls86 (43 fix + 43 verify)24 (12 fix + 12 verify)
Routernone1 code sweep, 1.98s, 0 tokens
Wall clock456.1s211.8s (209.8s agents + 1.98s router)
Total subagent tokens2,419,777684,636
Independent sweep after0 findings, 43/43 bash -n OK0 findings, 43/43 bash -n OK
Files changed vs seedexactly the 12 dirty filesexactly the 12 routed files

n=1 per arm, same as every run in this series. No node failed in either arm (0 errors across 110 agent calls), and every fix agent’s self-reported before summed to 35 both times, matching ground truth; treat that as an observation, not a trend.

In Arm R, the routed set, the set of files the agents actually changed, and the independently verified dirty set are the same 12 files. The 31 skipped files are byte-identical to the seed because no agent was ever pointed at them, a structural guarantee rather than a behavioral one.

💰 The saving, raw. 62 agent calls, 1,735,141 tokens, and 244.3 seconds bought by one conditional edge, with the router’s own 1.98s already charged inside Arm R’s total. Same verified end state on both bills. No dollar conversion here; pricing the fleet is Part 11’s job.

The instructive wrinkle is that the three savings are three different numbers. Calls and tokens fell about 72%, wall clock only 53.6%. Calls and tokens scale with how many nodes run; wall clock scales with what sits on the critical path under the scheduler’s cap. The dirty files dominate that path in both arms:

nmean
Arm U fix, dirty files1224.4s
Arm U fix, clean files (found 0, edited nothing)317.7s
Arm U verify (all 43)438.5s
Arm R fix1225.9s
Arm R verify128.3s

The 62 dropped calls were mostly cheap no-ops (7.7s fix and 8.5s verify means), so dropping them buys proportionally less wall time than it buys calls. Under this run’s cap of 2, those no-ops genuinely occupied dispatch slots, so the wall-clock saving was still large. Under a wider cap, more would absorb into idle slots and the wall-clock saving would likely shrink; the call and token savings can’t be absorbed by any scheduler. That last step is reasoning; the cap-2 numbers are what this run measured.

One honest disclosure: scripts/run-content-series.sh was the slowest fix node in both arms (47.8s in U, 75.2s in R), and when I diffed the two arms’ repairs of it, they differed: a no-op variable reference plus one disable directive in U, four disable directives in R. Both pass shellcheck and bash -n: equally clean, without being byte-identical.

The post-arm ground truth is three harness-run checks, and they’re pasteable:

Terminal window
# Ground truth after each arm: never a self-report
shellcheck -f json1 $(git ls-files '*.sh') | jq '.comments | length' # expect: 0
git ls-files '*.sh' | xargs -n1 bash -n && echo "all parse" # expect: all parse
for f in $(git ls-files '*.sh'); do cmp -s seed/$f $f || echo "$f"; done # expect: the 12 routed files, no others

Verify: run those three checks against your own arm. The changed-file list must equal the routed set exactly.

Step 5: When classification becomes a node

The router is a cost-control primitive, not a correctness-control one. Arm U was equally correct; it just paid 62 extra agent calls, 31 of them provable no-ops, for the privilege. What routing bought was the removal of work the input never needed, at a charged price of 1.98s.

Classification sits exactly on Part 4’s arrange/decide line (graph-reduce-is-free). Routing on a lint exit code is arrange-work: code, deterministic, free in tokens. Routing on judgment is decide-work, which makes the classifier a node, and then it inherits everything nodes need: a contract (Part 2) and, because misrouting silently skips work, verification with no stake in the answer (Part 6).

ClassificationArrange or decideWhat it is
Lint exit code (this run)arrangea code edge: 1.98s, 0 tokens, cannot misroute on this job
Path glob, file extensionarrangea code edge
”Is this diff risky enough for a full audit?“decidea node: contract required, verifier required
”Small and clean, or large and complex?“decidea node: contract required, verifier required

Whichever side classification lands on, the branch pick itself stays code. That’s the determinism half of the concept, and it’s one line:

// The edge: same classification in, same branch out, every time
const branch = (file) => dirtySet.has(file) ? 'fix' : 'terminate';

The misrouting asymmetry is the sharp edge. Routing a clean file to audit wastes one bounded node, about 7.7s and a schema-validated nothing-happened return on this corpus. Routing a dirty file to skip silently ships a defect, and that cost is unbounded. FutureAGI’s conditional-routing glossary names this failure precisely, “The request returns 200, but the wrong model answered,” and supplies no measurement of either side. This run prices the bounded side; the unbounded side is why a judging classifier gets a fresh-context verifier.

Verify: apply the Part 4 test to your own classifier. Would two careful engineers always produce the same routing decision from the same input? Yes means it’s an edge and code. Any no means you’ve found a node; give it a contract and a verifier before you trust its skips.

Try It Now

Reproduce the router on your own corpus in under a minute. Every expected value below is this run’s harness-verified ground truth.

Terminal window
# 1. Pin the classifier version this run used
shellcheck --version | grep 0.11.0 # expect: version: 0.11.0
# 2. Run the router sweep and read its exit code
shellcheck -f json1 $(git ls-files '*.sh') > sweep.json; echo "exit: $?"
# expect on this corpus: exit: 1
jq '.comments | length' sweep.json # expect: 35
# 3. Partition: count the routed (dirty) set
jq '[.comments[].file] | unique | length' sweep.json # expect: 12
# 4. Count what terminates at the edge
echo $(( $(git ls-files '*.sh' | wc -l) - 12 )) # expect: 31

Wrap time around the sweep to price your own router; this run measured 1.98s over 43 files.

FAQ

Q: Should the router in an agent graph be an agent or code?

A: Code, whenever the classification is arrange-work. This run’s router was one shellcheck sweep: 1.98s, 0 tokens, and it could not misroute because the classifier is the ground-truth tool. Use an agent only when the classification needs judgment, and then it’s a node with a contract and a verifier.

Q: Did conditional agent routing change the result?

A: No. Both arms ended 43/43 independently verified clean. Routing controlled cost while correctness stayed flat, measured here as 72.1% fewer agent calls for the identical end state.

Q: What did routing actually save on this run?

A: 86 agent calls down to 24 plus one 1.98s code sweep, 2,419,777 tokens down to 684,636 (71.7% fewer), and 456.1s down to 211.8s (53.6% less). One 43-file corpus, one environment, n=1 per arm.

Q: What happens when the router misroutes?

A: On this job it can’t: the classifier is the ground-truth tool, and the routed set matched the independently verified dirty set exactly. In the general case, an agent classifying on fuzzy criteria, the asymmetry is the point. A clean file routed to audit wastes one bounded node; a dirty file routed to skip ships a defect silently, which is why a judging classifier needs verification.

Close

You now have the measured pair: one job run un-routed at 86 agent calls and routed at 24 plus a 1.98s sweep, ending in the identical independently verified clean state. The test that generalizes it: judgment at the node, determinism at the edge. Find the branch in your graph that runs for every input when only a fraction needs it; that’s where this edge goes.

Three pitfalls. Don’t read “cannot misroute” as a router property; it held because this classifier is the ground-truth tool. Don’t collapse the three savings into one number; the gap between them is the scheduler’s cap. And don’t compare wall clocks across environments; run both arms fresh in one.

The next measured problem ships next in this series.

{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Edges That Decide at Runtime",
"author": { "@type": "Organization", "name": "ShipWithAI" },
"datePublished": "2026-08-19",
"description": "Conditional agent routing, measured: the same 43-file lint sweep un-routed (86 agent calls) and routed (24 plus one 1.98s sweep). Same clean end state.",
"image": "/images/blog/graph-router-conditional-edges-cover.png",
"articleSection": "tutorial",
"keywords": "claude-code, automation, ai, tutorial, english",
"mainEntityOfPage": "https://shipwithai.io/blog/graph-router-conditional-edges/"
}
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Edges That Decide at Runtime",
"description": "Conditional agent routing, measured: the same 43-file lint sweep un-routed (86 agent calls) and routed (24 plus one 1.98s sweep). Same clean end state.",
"image": "/images/blog/graph-router-conditional-edges-cover.png",
"step": [
{
"@type": "HowToStep",
"name": "Step 1: The loop had one path, and the fan-out kept it",
"text": "The loop spec ran the identical step for every file, every round, even files anyone could see were clean. One path; no edge ever decided anything."
},
{
"@type": "HowToStep",
"name": "Step 2: Judgment at the node, determinism at the edge",
"text": "A router is a classification step that decides what each input needs, followed by plain code that picks the branch. Same classification in, same branch out, every time."
},
{
"@type": "HowToStep",
"name": "Step 3: Build both arms, same files, same prompts, one edge apart",
"text": "The design in one breath: Arm U reproduces Part 3's Snapshot-B shape, 43 fix agents dispatched at once, then 43 verify agents."
},
{
"@type": "HowToStep",
"name": "Step 4: What conditional agent routing saved here",
"text": "Routing cut agent calls from 86 to 24 (72.1% fewer), tokens from 2,419,777 to 684,636 (71.7% fewer, 1,735,141 saved), and wall clock from 456.1s to 211.8s (53.6% less, 2.15x), for the identical..."
},
{
"@type": "HowToStep",
"name": "Step 5: When classification becomes a node",
"text": "The router is a cost-control primitive, not a correctness-control one. Arm U was equally correct; it just paid 62 extra agent calls, 31 of them provable no-ops, for the privilege."
},
{
"@type": "HowToStep",
"name": "Close",
"text": "You now have the measured pair: one job run un-routed at 86 agent calls and routed at 24 plus a 1.98s sweep, ending in the identical independently verified clean state."
}
]
}
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Should the router in an agent graph be an agent or code?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Code, whenever the classification is arrange-work. This run's router was one shellcheck sweep: 1.98s, 0 tokens, and it could not misroute because the classifier is the ground-truth tool. Use an agent only when the classification needs judgment, and then it's a node with a contract and a verifier."
}
},
{
"@type": "Question",
"name": "Did conditional agent routing change the result?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Both arms ended 43/43 independently verified clean. Routing controlled cost while correctness stayed flat, measured here as 72.1% fewer agent calls for the identical end state."
}
},
{
"@type": "Question",
"name": "What did routing actually save on this run?",
"acceptedAnswer": {
"@type": "Answer",
"text": "86 agent calls down to 24 plus one 1.98s code sweep, 2,419,777 tokens down to 684,636 (71.7% fewer), and 456.1s down to 211.8s (53.6% less). One 43-file corpus, one environment, n=1 per arm."
}
},
{
"@type": "Question",
"name": "What happens when the router misroutes?",
"acceptedAnswer": {
"@type": "Answer",
"text": "On this job it can't: the classifier is the ground-truth tool, and the routed set matched the independently verified dirty set exactly. In the general case, an agent classifying on fuzzy criteria, the asymmetry is the point. A clean file routed to audit wastes one bounded node..."
}
}
]
}
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://shipwithai.io/blog/" },
{ "@type": "ListItem", "position": 2, "name": "Edges That Decide at Runtime", "item": "https://shipwithai.io/blog/graph-router-conditional-edges/" }
]
}