An AI workflow can produce a reasonable answer while taking the wrong execution path.
A request that needed clarification might reach a tool. An unsupported intent might fall through to a default handler. A graph might select the correct branch but execute an additional node because of an incorrect edge.
On Day 15, I explored deterministic intent routing: which workflow should receive a request?
After Day 21’s discussion of agent reliability, the next question is:
Once a request enters a workflow, what determines its next step—and how do we verify that decision?
1. From intent routing to graph routing
Intent routing usually selects a workflow from the user’s request.
Conditional graph routing selects the next node from the workflow’s current state. That state might include:
- Whether required information is missing.
- Whether a tool returned usable evidence.
- Whether an operation needs approval.
- Whether an execution budget has been exhausted.
Anthropic describes routing as directing classified inputs to specialized tasks. It also distinguishes predefined workflows from agents that dynamically control their execution. A conditional graph can implement a workflow without requiring an autonomous agent.
For this example, I’ll use three destinations:
| Condition | Destination |
|---|---|
| Required information is missing | Ask for clarification |
| Information is complete and intent is supported | Run the handler |
| Information is complete but intent is unsupported | Return a fallback |
The order matters: missing information takes priority, even when the intent is supported.
That precedence is part of the behavior we need to test.
2. Keep the routing decision small
LangGraph provides add_conditional_edges() to choose destinations using a routing function. An explicit path map connects the function’s return values to node names.
Here is a minimal example.
# routing_graph.py from typing import Literal, TypedDict from langgraph.graph import END, START, StateGraph Route = Literal["clarify", "handle", "fallback"] class State(TypedDict): intent: str missing_information: bool visited: list[str] def select_route(state: State) -> Route: if state["missing_information"]: return "clarify" if state["intent"] in {"search", "summarize"}: return "handle" return "fallback" def record_visit(state: State, node: str) -> dict: return {"visited": [*state["visited"], node]} def clarify(state: State) -> dict: return record_visit(state, "clarify") def handle(state: State) -> dict: return record_visit(state, "handle") def fallback(state: State) -> dict: return record_visit(state, "fallback") builder = StateGraph(State) builder.add_node("clarify", clarify) builder.add_node("handle", handle) builder.add_node("fallback", fallback) builder.add_conditional_edges( START, select_route, { "clarify": "clarify", "handle": "handle", "fallback": "fallback", }, ) for node in ("clarify", "handle", "fallback"): builder.add_edge(node, END) graph = builder.compile() The nodes only record their execution so we can inspect the path. A real application would replace them with clarification, retrieval, or response logic.
The router itself has no model calls or external side effects. Its decisions can be checked directly.
3. Test the decision table
Testing only a supported request leaves important behavior unchecked.
Here is a parameterized test covering every destination, both supported intents, and clarification precedence:
# test_routing_graph.py import pytest from routing_graph import graph, select_route CASES = [ pytest.param( "search", True, "clarify", id="clarification-before-search", ), pytest.param( "summarize", True, "clarify", id="clarification-before-summary", ), pytest.param( "unknown", True, "clarify", id="clarification-before-fallback", ), pytest.param( "search", False, "handle", id="complete-search", ), pytest.param( "summarize", False, "handle", id="complete-summary", ), pytest.param( "unknown", False, "fallback", id="unsupported-intent", ), ] @pytest.mark.parametrize("intent,missing,expected", CASES) def test_routing_decision(intent, missing, expected): state = { "intent": intent, "missing_information": missing, "visited": [], } assert select_route(state) == expected @pytest.mark.parametrize("intent,missing,expected", CASES) def test_graph_execution_path(intent, missing, expected): state = { "intent": intent, "missing_information": missing, "visited": [], } result = graph.invoke(state) assert result["visited"] == [expected] To run the example:
python -m pip install langgraph pytest python -m pytest -q These tests check two different things:
- Routing logic: Does the function choose the expected destination?
- Graph wiring: Does the compiled graph execute exactly that destination?
The second test matters because a correct routing function does not prove the surrounding graph is connected correctly.
4. Test what must not happen
Notice this assertion:
assert result["visited"] == [expected] It checks the complete execution trace.
An assertion such as:
assert expected in result["visited"] would also pass if an unintended node ran.
For a production workflow, the equivalent check might verify that a tool was never called when clarification was required, or that a write operation never ran without approval.
Correct execution includes the absence of forbidden actions.
5. Branch coverage has limits
These tests verify the routing policy for the supplied states.
They do not prove that an upstream classifier correctly understood the user, that missing information was detected accurately, or that the handler produced a useful answer.
Those need separate evaluations:
- Labeled requests for intent classification.
- Incomplete and ambiguous requests for clarification detection.
- Realistic tool responses for downstream behavior.
- Invalid inputs for boundary validation.
In particular, TypedDict describes the expected state for type checking; it does not validate incoming data at runtime.
A graph can follow its rules perfectly and still receive an incorrect state.
My takeaway
Conditional routing makes execution rules explicit. Testing makes those rules reviewable.
For each routing decision, I want to specify:
- The condition that selects each destination.
- Which condition wins when rules overlap.
- The nodes that must execute.
- The actions that must remain unexecuted.
A useful next experiment is to deliberately swap two destinations in the path map and check whether the graph tests catch the mistake.
When you test an AI workflow, do you verify only its answer—or also the path it took?
References
- Anthropic: Building effective agents — engineering guidance on workflows, routing, and agents.
- LangGraph: add_conditional_edges — API reference for conditional routing.
