docs/architecture/routing-system.md
Back to README
In PicoClaw, the runtime "routing system" is not just one decision. It is the combined pipeline that decides:
This document covers the runtime path in pkg/routing and its integration in pkg/agent.
It does not describe the launcher's HTTP ServeMux routes or the frontend's TanStack Router files under web/.
| Layer | Files | Responsibility |
|---|---|---|
| Agent dispatch | pkg/routing/route.go, pkg/routing/agent_id.go | Choose the target agent for the inbound message. |
| Session policy selection | pkg/routing/route.go | Decide which dimensions should define session isolation for that routed turn. |
| Model routing | pkg/routing/router.go, pkg/routing/features.go, pkg/routing/classifier.go | Choose between the primary model and a configured light model based on message complexity. |
| Runtime integration | pkg/agent/registry.go, pkg/agent/agent_message.go, pkg/agent/turn_coord.go | Apply the route result, allocate session scope, and select model candidates before provider execution. |
The normal path for a user message is:
InboundMessage
-> NormalizeInboundContext
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> ensureSessionMetadata(...)
-> Router.SelectModel(...)
-> provider execution
The first half answers "who should handle this message and what session does it belong to". The second half answers "which model tier should that agent use for this turn".
routing.RouteResolver turns a normalized bus.InboundContext into a ResolvedRoute:
type ResolvedRoute struct {
AgentID string
Channel string
AccountID string
SessionPolicy SessionPolicy
MatchedBy string
}
MatchedBy is a debugging aid.
Typical values are:
defaultdispatch.ruledispatch.rule:<rule-name>Before matching rules, the resolver builds a normalized dispatchView.
Each field is normalized to the exact shape expected by rule matching.
| Selector field | Runtime shape |
|---|---|
channel | lowercased channel name |
account | normalized account ID |
space | <space_type>:<space_id> |
chat | <chat_type>:<chat_id> |
topic | topic:<topic_id> |
sender | lowercased canonical sender ID |
mentioned | boolean copied from inbound context |
This means dispatch rules must match the normalized shape, for example:
{
"agents": {
"dispatch": {
"rules": [
{
"name": "support-group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-100123"
}
},
{
"name": "slack-mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
ResolveRoute(...) follows this sequence:
channel and account.session.identity_links from config.agents.dispatch.rules in order.Important consequences:
identity_linksIf no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order:
default: trueagents.listmainBoth agent IDs and account IDs are normalized through the helpers in pkg/routing/agent_id.go.
Agent dispatch does not directly build a session key.
Instead it emits a SessionPolicy:
type SessionPolicy struct {
Dimensions []string
IdentityLinks map[string][]string
}
The dimensions come from:
session.dimensionsdispatch_rule.session_dimensions when the matching rule overrides themOnly these dimension names survive normalization:
spacechattopicsenderInvalid or duplicated entries are silently dropped.
pkg/session/AllocateRouteSession(...) then turns that policy into:
SessionScopeSo the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage".
session.identity_links is shared between dispatch and session allocation.
That is intentional: a sender canonicalized for routing should also map to the same session identity.
Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions.
The second routing stage decides whether a turn can use a cheaper or faster light model.
Config shape:
{
"routing": {
"enabled": true,
"light_model": "gemini-2.0-flash",
"threshold": 0.35
}
}
pkg/routing.Router compares the current turn against structural features and returns:
If the score is below the threshold, the light model wins. Otherwise the agent's primary model is used. At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set.
ExtractFeatures(...) computes a language-agnostic feature vector:
| Feature | Meaning |
|---|---|
TokenEstimate | Approximate token count; CJK runes count more accurately than a flat rune split. |
CodeBlockCount | Number of fenced code blocks in the current message. |
RecentToolCalls | Tool-call count across the last six history entries. |
ConversationDepth | Total history length. |
HasAttachments | Detects embedded media or common media URL/file extensions. |
This is intentionally structural rather than keyword-based, so the router behaves the same across languages.
The current classifier is RuleClassifier.
It uses a weighted sum capped to [0, 1].
| Signal | Score |
|---|---|
| attachments present | 1.00 |
token estimate > 200 | 0.35 |
token estimate > 50 | 0.15 |
| code block present | 0.40 |
recent tool calls > 3 | 0.25 |
recent tool calls 1..3 | 0.10 |
conversation depth > 10 | 0.10 |
The default threshold is 0.35.
That makes the following behavior intentional:
Agent dispatch and model routing happen in different places:
pkg/agent/registry.go owns RouteResolverpkg/agent/agent_message.go resolves the route and allocates session scopepkg/agent/turn_coord.go:selectCandidates calls agent.Router.SelectModel(...)When the light model is selected, the agent loop swaps to agent.LightCandidates.
When it is not selected, execution stays on the agent's primary provider candidate set.
One nuance sits just outside pkg/routing but matters for the full routing story.
After a route is allocated, pkg/agent/agent_utils.go:resolveScopeKey preserves an explicit incoming session key when the caller already supplied:
agent:... keyThat makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key.
The repository also contains two unrelated route systems:
web/backend/api/router.goweb/frontend/src/routes/Those are launcher implementation details. They are separate from the runtime routing system described here.
pkg/routing/route.gopkg/routing/router.gopkg/routing/classifier.gopkg/routing/features.gopkg/routing/agent_id.gopkg/session/allocator.gopkg/agent/registry.gopkg/agent/agent_message.gopkg/agent/turn_coord.go