Context-Aware Routing
The Context-Aware Routing pattern uses a router agent to read the user's request, classify it into a category, and dispatch to the specialist whose domain matches.
Classic primitives: DefaultPattern, StringLLMCondition (LLM-evaluated routing inside the framework), or ExpressionContextCondition over a router-tool-set domain field.
Key Characteristics#
- Router agent thin. The router's only job is to classify and call the matching
classify_as_<category>tool. - Specialists answer. Each specialist is a separate agent with a domain-specific prompt; the graph routes the request to the right one via
ContextEqualson the just-set category. - Specialist's reply terminates. A
FromSpeaker(<specialist>) → TerminateTargetrule sits ABOVE theContextEqualsrules so the workflow closes after the specialist speaks (rather than looping back on the stickycategoryflag — see warning below).
Routing Mechanics#
Each classify_as_<category> tool is a plain @tool that calls set_context(session, "category", <category>). The helper emits a non-substantive EV_CONTEXT_SET envelope that lands in the WAL before the round's EV_PACKET commits, so when the router's packet folds, ContextEquals("category", X) already sees the just-set value and routes the next turn to the matching specialist.
Sticky routing
Note the transition ordering: FromSpeaker(<specialist>) → TerminateTarget rules sit ABOVE the ContextEquals rules. Without that ordering, ContextEquals("category", X) would re-match after the specialist speaks (the category var is never cleared) and the graph would loop back to the same specialist. This is the canonical "sticky routing" trap; the fix is always to list terminate / FromSpeaker rules before any sticky ContextEquals.
Agent Flow#
sequenceDiagram
participant User as user
participant Router as router
participant Billing as billing
participant Technical as technical
participant General as general
User->>Router: question
Router->>Router: classify_as_<category>(reason); set_context("category", <category>)
Note over Router: ContextEquals("category", X) routes to specialist
alt category=billing
Router->>Billing: AgentTarget(billing)
Billing->>User: answer; FromSpeaker(billing) → TerminateTarget("billing_resolved")
else category=technical
Router->>Technical: AgentTarget(technical)
Technical->>User: answer; FromSpeaker(technical) → TerminateTarget("technical_resolved")
else category=general
Router->>General: AgentTarget(general)
General->>User: answer; FromSpeaker(general) → TerminateTarget("general_resolved")
end Migration Notes#
| Classic | Beta |
|---|---|
StringLLMCondition (framework asks LLM at the transition) | Router agent's LLM turn calls classify_as_<category> tool; declarative graph reads the resulting state |
ReplyResult(context_variables={"category": "billing"}, target=AgentTarget(billing_specialist)) | await set_context(session, "category", "billing") from inside a plain @tool; ContextEquals("category", "billing") → AgentTarget(billing) graph rule |
ExpressionContextCondition(...) per category | ContextEquals("category", X) per category |
Gaps & Workarounds#
- No
LLMCondition. ClassicStringLLMConditionallowed the framework to ask an LLM "should this transition fire?" — purely declarative routing. Beta requires the router agent to make the decision inside its own LLM turn and record it via a tool call. Functionally equivalent but conceptually different: the routing intelligence lives in the agent, not the graph. Workaround: keep the router agent thin (system prompt = "classify into one of {billing, technical, general} then callclassify_as_<category>").
Code#
Tip
The router uses real Sonnet (the classification is the LLM-driven part). Each specialist also uses real Sonnet to give a domain-flavoured reply.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | |
Output#
session: 8b4d...
user: I tried to upgrade my subscription but the API is returning a 500 error. The status page says everything is green. Help?
[tool] classify_as_technical('User is reporting a 500 error from the API during a subscription upgrade — this is a technical/integration issue, not a billing dispute or general policy question.')
router:
technical: Capture the full request (endpoint, payload, response headers, request-id) and re-try the upgrade — if the 500 persists, share the request-id with us so we can trace it server-side; in the meantime, check whether the failure correlates with a specific plan, payment method, or coupon as those code paths are the most common 500 sources during upgrade.
closed: reason='technical_resolved'
final context_vars: {'category': 'technical'}