How the Turn Graph Routes Between Nodes
When you send a message to Ivy, it enters a turn graph — a six-node state machine that cycles through preparation, LLM calls, tool execution, and reconciliation until the task is done or the budget is exhausted. But how does it decide what happens next?
The Graph Topology
The graph is built in AgentGraphFactory.ts. Six nodes, linear flow with two branch points:
prepare → llmCall → parse → execute → reconcile → [conditional]
↓ (budget exceeded) ↓
finish finish (if outcome set)
prepare (if continuing)
Every node receives the same shared mutable state — an AgentRunState object — and modifies it in place. The state carries the conversation, budget counters, tool execution results, events, and the crucial outcome field.
The Two Conditional Edges
Routing decisions live in exactly two places:
1. prepare → llmCall or finish
The prepare node is the graph entry point. Its job: drain the steering queue into messages, check budget, and route. The routing logic is a simple conditional in the graph definition:
graph.addConditionalEdge('prepare', (state) => {
if (state.outcome !== null) {
return 'finish';
}
return 'llmCall';
});
But prepare also sets the outcome. Before returning, it checks four budget dimensions:
- Turns:
turnsUsed >= maxTurns - Tokens:
tokensUsed >= maxTokens - Cost:
costUsed >= costCap - Time: elapsed milliseconds exceeding
timeoutMs
If any dimension exceeds its limit, prepare sets state.outcome = 'aborted' and emits an agent_end event. The conditional edge then routes to finish.
2. reconcile → finish or prepare
The reconcile node sits at the end of the turn cycle. Its job: decide whether to loop or finish. The conditional edge:
graph.addConditionalEdge('reconcile', (state) => {
if (state.outcome !== null) {
return 'finish';
}
return 'prepare';
});
Like prepare, reconcile sets outcome based on what happened. The decision tree:
- Outcome already set? → finish (budget was exceeded in prepare)
- No tool calls?
- If the model produced text →
outcome = 'answered', finish - If the response was empty → increment error cascade, nudge and loop (up to 3 times, then
'failed')
- If the model produced text →
- Repeated failure? →
outcome = 'failed', finish - Abort signal? →
outcome = 'aborted', finish - Otherwise → clear
currentToolCalls, loop to prepare
The repeated failure detection is particularly elegant: it looks at the last two turns, collects failed tool signatures (name + JSON.stringify(args)), and checks for intersection. If the same tool call with the same arguments failed in both turns, it stops — the model is stuck.
What This Means
The turn graph doesn't "route" — it sets state and lets conditional edges read that state. The routing logic is:
- Before LLM: Does budget allow continuing?
- After execution: Are we done? Did the model finish? Is it stuck?
Everything else — the message assembly, tool execution, parse logic — is just moving data through the state object. The graph structure ensures the right checks happen at the right times, but the decisions themselves are local to each node.
Dive Deeper
- How the System Prompt is Built — what goes into the LLM call
projects/ivy/src/runtime/AgentGraphFactory.ts— graph definitionprojects/ivy/src/runtime/nodes/prepare.ts— budget checksprojects/ivy/src/runtime/nodes/reconcile.ts— loop vs finish logicprojects/ivy/test/phase2/turn-graph.test.ts— test coverage