Status: v3.4 — proposed; LLM_ROUTER slot added (Wave 19, opt-in) Last updated: 2026-06-04 Owner: your name
v3.4 update (2026-06-04 — default router model + REPL visibility): First field test against the originally-shipped
openrouter:qwen/qwen-plusdefault returnedno_tool_call— qwen-plus generated prose instead of callingchoose_model. Default switched toanthropic:claude-haiku-4-5(reliable forced tool-use; ~$0.001 per meta-call vs qwen-plus’s measured $0.004 at 150-token max output). The §11.10 open question (“genuinely best cost-effective router model”) stays open — haiku is the safe-bet default, not the optimal one.TurnResultgains an additiveroute_chainfield (defaulted empty tuple) so themetis devresult tag can echo the LLM_ROUTER slot’s per-turn verdict + meta-cost without callers having to query the trace store. The echo is quiet when the slot was a no-op (disabled / worker re-entry / not pre-computed).v3.4 update (2026-06-05 — router prompt hardening + failure renames): Live testing showed
haiku-4-5as router answering trivial information- seeking prompts (e.g. “what’s today’s date”) directly to the user instead of callingchoose_model— role-confusion despite the explicit system-prompt instruction. Two changes: (1) the router system prompt is rewritten to repeatedly assert “you are NOT the assistant, the planner answers the user, your reply is the tool call”; the user message is wrapped in--- TASK FOR PLANNER ---/--- END TASK ---markers so the LLM treats it as routable data, not as a question to itself. (2) Theno_tool_callfailure-reason constant is renamed tono_model_chosenfor clarity in trace queries; the REPL renders it as “didn’t pick a model (router replied with text instead of a tool call)”. A new_humanize_router_reasonmapping incli/chat.pytranslates other internal reasons (budget_exhausted,timeout,invalid_model_id: …, etc.) into human-readable forms for the result-tag echo; the raw constants stay in the trace store so analytics queries are unaffected.v3.4 update (2026-06-04 — timeout classification + default raise): Live testing surfaced
network_error: CancelledErroras a recurring “failure” mode that was actually a misclassified timeout. The OpenRouter and OpenAI adapters wrapasyncio.CancelledErroras their own typedAdapterCancelledError(provider-adapter-contract §6.1) — when ourasyncio.wait_forfires its timeout, the cancellation propagates INTO the adapter and re-emerges as the wrapped error, sowait_fordoesn’t recognize it and skips its TimeoutError conversion. Router now catchesAdapterCancelledErrorexplicitly and classifies it asfailure_reason="timeout". Defaulttimeout_secondsraised8.0 → 20.0— OpenRouter routinely dispatched Qwen meta-calls to upstream providers with 10–18s first-token latency, blowing the 8s cap repeatedly.v3.4 update (2026-06-04 — router-call events + response inspection): §4.6.7 spec drift closed. The v3.4 first cut declared that router meta-calls would land as
llm.call_completedevents stampedActor.ROUTERbut the implementation never emitted them — only the per-slotmeta_cost_usdfield onPolicyEvaluationwas wired. A liveno_tool_callfailure on 2026-06-04 made the gap concrete: the response content was discarded, so operators had no way to see what the router actually wrote.LLMRouternow takes an optionalbus: EventBusand emits pairedllm.call_started+llm.call_completedfor every meta-call attempt that reaches the adapter.LLMCallCompletedgains an additive optionalresponse_text_preview: str | Nonefield (truncated to 500 chars; populated by the router on no-tool-call failures so the prose is persisted;Nonefor planner-loop emitters and tool-only router responses). Dashboard / query example added to §4.6.7.v3.4 update (2026-06-04 — router-prompt quality fix): Same-day field test showed the router picking
claude-sonnet-4-6for a one-wordtestprompt at $0.045 per turn, whenhaiku-4-5was the obvious right answer. Two prompt bugs identified per §5.6.2 history note: (1) the candidate catalog carried capability tags +fast/balanced/deeptask-profile labels but no concrete per-MTok prices, so the LLM had nothing to anchor “cheapest” against; (2) the guidance closed with “when in doubt, pick a balanced mid-tier candidate” — the wrong default for short or ambiguous prompts, which are exactly the case where “doubt” applies. Fix: catalog lines now carryin $X/MTok, out $Y/MTokfrom the activePriceTable; the guidance flips to “default to the cheapest candidate for short, ambiguous, conversational, or low-stakes prompts” with narrow escalation criteria. §5.6.2 is rewritten with the new prompt shape + the dated history note explaining the failure.v3.4 changes (2026-06-03 — Wave 19, LLM router): New chain slot
LLM_ROUTERinserted at position 5, betweenPATTERN_RECOMMENDATIONand the renumberedDELEGATE_REQUEST(now position 6). When enabled, the slot asks a small auxiliary LLM (“router model”; defaultanthropic:claude-haiku-4-5as of 2026-06-04; wasopenrouter:qwen/qwen-pluson the v3.4 first cut — see header update above) to pick the model for the turn from the registered, capability-valid, available candidates. The router returns its pick via a singlechoose_model(model_id, reason)tool call; the pick passes through the standard §4.4 validation gate. Off by default (chain shape unchanged for existing deployments — slot 5 reportsnot_applicable, reason="llm_router disabled"). Enabled per workspace via the newllm_router:block inrouting.yaml(§5.6) or interactively with/router llm on|off|model <id>|statusinmetis dev. The slot shares the evaluator’sBudgetTrackerprimitive with independent per-session / per-day caps; over-budget →not_applicable, reason="budget_exhausted". Failure modes (timeout, invalid model id, network error, no candidates) all degrade tonot_applicablewith the reason recorded onroute.decided.chain. In worker re-entry the slot defers withreason="delegate_request_in_flight"so the planner’s explicittier=choice is never second-guessed (matches PATTERN slot 4 behavior under delegation, seedelegation.md §11). Catalog eventroute.decidedis extended additively —PolicyEvaluationgainsmeta_cost_usd/meta_tokens_input/meta_tokens_outputfields (Nonefor every slot other thanllm_router); theRoutingPolicyNameliteral gains"llm_router". Slot 4 still uses the Wave-15 pattern store. Renumbering shiftsDELEGATE_REQUEST5→6,WORKSPACE_DEFAULT6→7,GLOBAL_DEFAULT7→8 — slot names (the keys the implementation uses) are unchanged, only the numeric ordinals shift.v3.3 changes (2026-05-20 — implementation sync): Spec re-synced to the shipped routing engine. Slots 4 (
PATTERN_RECOMMENDATION) and 5 (DELEGATE_REQUEST) are wired, not stubs — §4.1/§4.2 corrected (slot 5 is always present in the chain, reportingnot_applicableoutside a delegation re-entry; it is not “skipped”).team_budget_remaining_ltpredicate added to the closed set (§5.3). The v1-stub status ofcost_today_exceeds_usd,skills_matching_message_includes, andfile_extensions_in_contextis now stated explicitly (§5.3–§5.4) — these are accepted at load but never match until their backing infra lands. §6 trimmed to the routing-owned surface: thedelegate()tool contract is now canonical indelegation.md, and §6.1–§6.8 are cross-reference stubs (section numbers preserved so existing references resolve). Auxiliary-event implementation status annotated (§7.3). Stalemetis-coresource path and the~/.yourtool/config-dir placeholder corrected.v3.2 changes: Cancellation event sequence cross-reference updated to point at streaming-protocol’s three-case model (§3.4).
v3.1 changes: Auxiliary event
pattern.override_acceptedrenamed toroute.overriddento align with event-bus-and-trace-catalog.md §6.5b (preserves the one-route.decided-per-turn invariant). Delegation phase asymmetry documented (§6 preamble): the chain slot exists from Phase 1 as a stub; thedelegate()tool itself ships in Phase 4.v3 changes: Provider availability state machine made consistent (§4.5). Capability validation extended to tools, system prompt, structured output (§4.4). Predicate snapshot points pinned (§5.3).
skills_loaded_includesrenamed toskills_matching_message_includes(§5.3). Cost-efficiency divide-by-zero defined (§5.5).insufficient_contextschema specified (§6.6). Worker memory/skill/visibility rules added (§6.2.1). Tier upgrade exhaustion behavior stated (§6.9). Workspace tiers require all three slots (§5.8). Mid-turn multiple swaps last-write-wins (§3.3). Various nits.Throughout: the per-workspace routing config lives at
<workspace>/.metis/routing.yaml. Older drafts of this spec used a~/.yourtool/placeholder; remaining~/.metis/-style paths denote the user-global config directory.
This document specifies the routing engine: the component that decides, for every turn in a session, which model handles it. The engine composes three modes — manual selection by the user, configured rules from a yaml policy, and agent-decided delegation — into a single ordered policy chain. It also defines the contract for delegate(), the tool by which a planner model invokes a sub-agent on a different tier.
Routing is the user-visible feature most likely to feel either magical or untrustworthy. A wrong choice wastes money or produces bad output; a silent override of user intent destroys trust. This spec aims to make every decision explainable, every override visible, and every failure mode predictable.
This spec depends on canonical-message-format.md for Message, ToolDefinition, and AdapterCapabilities.
A turn begins when a USER message is added to the session and ends when the session manager observes an assistant message with stop_reason: end_turn (no further tool calls pending), or the user cancels.
Within a turn there can be many LLM calls and tool invocations:
USER message ─┐
├─ LLM call #1 → ASSISTANT (text + tool_use)
│ └─ tool dispatch → TOOL message
├─ LLM call #2 → ASSISTANT (text + tool_use)
│ └─ tool dispatch → TOOL message
├─ LLM call #3 → ASSISTANT (text, end_turn) ◄── turn ends
│
(next USER message starts the next turn)
All LLM calls within one turn are part of the same turn_id.
The model chosen at turn start owns the entire turn. All LLM calls within the turn use that model, including tool-loop continuations. Re-routing happens only at turn boundaries.
Rationale:
estimated_input_tokens grew with tool results.The single exception is delegation: when a model calls delegate(), the worker runs in a separate session with its own routing decision (per §6). The parent turn’s lock is unaffected; it resumes on the parent model when delegate() returns.
/model swapsIf the user runs /model <id> while a turn is in flight, the swap is queued and applies to the next turn. The TUI surfaces this:
Model swap pending: anthropic:claude-opus-4-7. Applies to next turn.
This is a deliberate UX choice — the alternative (cancel the in-flight turn and restart on the new model) is more disruptive than waiting one turn boundary.
If the user runs multiple /model commands during a single turn, last-write-wins: only the most recent pending swap takes effect at the next turn boundary. Earlier pending swaps are silently superseded; the TUI banner updates to reflect the latest target.
User-cancellation (Ctrl-C) ends the turn early with status: cancelled. The next USER message starts a new turn and goes through fresh routing. Any queued model swap takes effect at that boundary.
The exact event sequence emitted on cancellation depends on where in the turn lifecycle the cancel arrives — see streaming-protocol.md §6.2 for the three cases (cancel during LLM streaming, cancel during tool dispatch, cancel at the seam). The routing engine itself does not emit cancellation events; it simply stops dispatching new LLM calls or tool calls for the cancelled turn and lets the session manager and adapters emit the canonical sequence.
For each turn, at turn start, the engine runs policies in fixed order. The first policy returning a non-None, validated RoutingDecision wins:
1. PER_MESSAGE_OVERRIDE — explicit @model syntax in the user's message
2. MANUAL_STICKY — session.active_model set explicitly via /model
3. CONFIGURED_RULES — first-match-wins over the rules list
4. PATTERN_RECOMMENDATION — pattern store result with confidence ≥ threshold
5. LLM_ROUTER — auxiliary LLM picks model from validated candidates; opt-in, see §4.6
6. DELEGATE_REQUEST — resolved tier model on a delegation re-entry; see §6
7. WORKSPACE_DEFAULT — workspace-scoped default
8. GLOBAL_DEFAULT — hardcoded fallback
All eight slots are evaluated in this fixed order on every turn — the chain
shape does not change between turn types. Slot 5 (LLM_ROUTER) is opt-in
per workspace (§5.6); when disabled or absent it reports not_applicable,
reason="llm_router disabled" and the chain proceeds. Slot 6
(DELEGATE_REQUEST) only proposes a candidate inside a worker session’s
re-entry into the chain (§6.9); on a normal top-level turn it reports
not_applicable (it is not skipped). Likewise slots 3, 4, and 5 propose a
candidate only when a rule matches / the pattern store returns a confident
recommendation / the router LLM returns a validated pick, and report
not_applicable otherwise. The route.decided.chain trace (§7) is the
prefix of slots evaluated up to and including the winner, so a turn won by
slot 3 records three entries and a turn won by slot 8 records all eight.
User intent dominates. (1) is the most local user signal — “just this message.” (2) is the session-level user signal. (3) is pre-declared user policy. (4) is system inference from accumulated empirical data, ranked below user-set things by design. (5) is system inference from an inference-time LLM, ranked below (4) because patterns are cheaper, faster, deterministic for a given fingerprint, and grounded in observed outcomes — the LLM router is the inference-time fallback when patterns are cold or below the confidence gate. (6) handles delegation. (7) and (8) are floors.
This order is deliberate and stable. Reordering it (e.g., putting pattern recommendations above rules, or putting the LLM router above patterns) would let probabilistic inference silently override user-set or empirically-validated choices — the failure mode that destroys trust.
MANUAL_STICKY is opt-inMANUAL_STICKY returns None unless the user has explicitly set a sticky model via /model <id> in the current session. A fresh session has no sticky and falls through to rules.
Setting a sticky is opt-out from rule-based routing for that session. Rationale: a user typing /model haiku is signaling “I want Haiku, ignore my rules.” That signal should be honored.
To return to rule-based routing within a session, the user runs /model - (clears sticky).
Every policy that returns a candidate model passes through validation before becoming the winner:
def validate(model: str, context: TurnContext) -> ValidationResult:
caps = adapter_registry.capabilities(model)
# Availability
if not adapter_registry.is_configured(model):
REJECT(reason="not_configured") # missing API key, etc.
if not adapter_registry.is_available(model):
REJECT(reason="provider_unavailable") # see §4.5
# Capability — gated on whether the turn actually needs the capability
if context.has_images and not caps.supports_images:
REJECT(reason="no_vision_support")
if context.estimated_input_tokens > caps.max_context_tokens:
REJECT(reason="exceeds_context_window")
if context.has_tool_definitions and not caps.supports_tools:
REJECT(reason="no_tool_support")
if context.has_system_prompt and not caps.supports_system_prompt:
REJECT(reason="no_system_prompt_support")
if context.requires_structured_output and not caps.supports_structured_output:
REJECT(reason="no_structured_output_support")
return OK
A rejected candidate causes the policy to be treated as if it returned None; the chain continues. Each rejection is recorded in the canonical route.decided event (§7) as part of the policy’s evaluation.
The capability gates follow the “only require what we’ll use” principle: a turn with no images doesn’t need supports_images; a worker with no output_schema doesn’t need supports_structured_output. This avoids spurious rejections that would force fallthrough when the model would actually have worked.
supports_thinking is not validated. Models that don’t support thinking simply have thinking blocks dropped at adapter serialization time (per §7.3 of the canonical format spec).
This relies on
AdapterCapabilities(defined incanonical-message-format.md§7.2) declaringsupports_tools,supports_system_prompt, andsupports_structured_output. Those fields have shipped on the canonical type. Entries that don’t declare them default totrueforsupports_toolsandsupports_system_prompt(the common case for both Anthropic and OpenAI) andfalseforsupports_structured_output. The engine’s_validate()(routing/engine.py) reads them viaModelRegistry.capabilities_for().
Availability is tracked at two granularities, both maintained by the adapter registry:
Each scope has the same three states:
provider_unavailable. Auto-clears as defined below.| Failure pattern | Scope marked Unavailable | Rationale |
|---|---|---|
≥5 consecutive failures on one (provider, model) within 2 minutes |
That (provider, model) |
Single-model issue (rate limit, deprecation). |
| ≥3 distinct models from one provider hit Unavailable within 2 minutes | The whole provider | Pattern points to a provider-wide issue. |
| Any auth error (401, 403) on any model from a provider | The whole provider | Misconfigured key affects everything. |
| ≥2 DNS / network errors reaching a provider’s host within 30 seconds | The whole provider | Sustained connectivity loss, not model-specific. |
| A single transient DNS / network error reaching a provider’s host | None (counts toward the per-(provider, model) 5-strike threshold below) |
One-off SSL renegotiation or TCP RST is not an outage. |
| Bounded exponential backoff exhausted inside a single adapter call | No state change | Per-call transient handling; not a signal of sustained outage. |
A successful call against a (provider, model) clears that scope’s Unavailable state immediately. A successful call against any model from a provider clears the provider-wide Unavailable state and the sliding NETWORK-failure window.
Why NETWORK is not immediate (refined 2026-05-16): an earlier revision blacked the whole provider out on a single NETWORK error, on the theory that DNS / connectivity issues affect every model identically. In practice transient SSL handshake errors (ssl.SSLError: SSLV3_ALERT_BAD_RECORD_MAC, httpx.ConnectError mid-TLS-renegotiation, one-off TCP RST) reach the adapter as ErrorClass.NETWORK but represent a single failed connection rather than a sustained provider-side outage. The 5-minute auto-clear made one transient hiccup look like a 5-minute provider blackout. The 2-within-30-seconds threshold filters the one-off from the real outage: a genuine DNS / regional-network failure will produce a second NETWORK error well inside 30 seconds, while a one-off TLS glitch resolves on the next call.
The thresholds (_NETWORK_PROVIDER_ESCALATION_THRESHOLD, _NETWORK_PROVIDER_ESCALATION_WINDOW_SECONDS) live in availability.py as module constants; AUTH still escalates immediately because a misconfigured key cannot be a one-off.
Without successful calls, Unavailable states auto-clear after 5 minutes of no attempts. This prevents the system from being permanently locked out of a provider that recovered while idle.
When a policy’s chosen model is m from provider p:
p is provider-wide Unavailable → reject with provider_unavailable (provider-wide).(p, m) is Unavailable → reject with provider_unavailable (model-specific).Both rejection cases use the same validation_failure value (provider_unavailable); the route.decided event’s reason field disambiguates (“anthropic:claude-opus-4-7 model-specific outage” vs. “all anthropic models temporarily unavailable”).
When a model-specific Unavailable causes fallthrough:
anthropic:claude-opus-4-7 currently unavailable. Routing fell through to anthropic:claude-sonnet-4-6.
When a provider-wide Unavailable causes fallthrough:
anthropic provider currently unavailable. Routing fell through to openai:gpt-5 (workspace default).
Banners clear when the corresponding state returns to Healthy.
LLM_ROUTER slotThe LLM_ROUTER slot (chain position 5) asks a small auxiliary LLM to pick the model for the turn from the set of registered, capability-valid, available candidates. It is off by default, opt-in per workspace via the llm_router: block in routing.yaml (§5.6) or interactively via /router llm on in metis dev.
When disabled, the slot returns not_applicable, reason="llm_router disabled" and the chain proceeds to slot 6.
Slot 4 (PATTERN_RECOMMENDATION) returns confident picks only after the pattern store has accumulated enough verdicts in the K-NN cluster around the current turn’s fingerprint. In a fresh workspace, or on a workload the store hasn’t seen, slot 4 returns not_applicable and routing falls through to the workspace / global default. The default is by construction a single fixed model — it ignores per-turn task character.
LLM_ROUTER is the inference-time complement to the pattern store: when accumulated data is absent, an LLM that has been trained on text describing many models’ strengths can produce a per-turn pick that is at least correlated with task character. It does not replace patterns — once the pattern store has data, slot 4 wins first (cheaper, deterministic, grounded in observed outcomes). The LLM router fires on the cold-start tail.
The router LLM is called once per turn (when enabled) with:
choose_model exactly once; a catalog of every candidate model with model_id, capability summary (vision / tools / system / structured output / context window), and per-MTok price tier (fast / mid / deep).choose_model(model_id: string, reason: string). Tool-use is required (forced via tool_choice: choose_model on providers that support it; JSON-mode fallback otherwise).The router LLM does NOT see the workspace contents, MEMORY.md, prior turn assistant text, or any other context. This is by design — the meta-call must stay cheap and bounded, and we want to avoid leaking conversation content into the meta-decision.
The candidate set is built from the model registry filtered by §4.4 (capability + per-(provider, model) availability). A candidate that fails validation is omitted from the prompt entirely — the router can’t choose a model that wouldn’t pass validation anyway.
The model id returned by the router LLM passes through the standard §4.4 validation gate. If validation rejects the pick (capability mismatch, provider Unavailable, model not registered), the slot reports rejected with the validation failure recorded, and the chain falls through to slot 6 — exactly like a rejected pattern recommendation or rule.
If the router returns a model_id that is not in the candidate set (e.g. hallucinated, or the model was removed between the prompt and the tool call), the slot reports not_applicable, reason="invalid_model_id: <name>" and the chain proceeds.
When the engine is invoked for a worker session (the planner emitted delegate(tier=...)), the LLM_ROUTER slot defers with verdict: not_applicable, reason: "delegate_request_in_flight". The planner’s explicit tier= choice must not be second-guessed by an LLM. This matches the slot 4 (pattern) behavior under delegation — see delegation.md §11.
The slot shares the BudgetTracker primitive from the evaluator (evaluator.md §4.3) with independent caps. Defaults:
per_session_budget_usd: 0.10per_day_budget_usd: 1.00Both are configurable in the llm_router: block (§5.6). When the meta-call would push the running total past either cap, the slot reports not_applicable, reason="budget_exhausted" and the chain proceeds without making the LLM call. The evaluator and router each see their own running totals — sharing the primitive means we don’t ship two implementations of token-budget bookkeeping, not that the two budgets sum into one cap.
All failure modes degrade to not_applicable with a documented reason, so the chain always reaches a definite winner from slots 6-8. Failures are recorded on route.decided.chain so dashboards can attribute them.
| Failure | reason recorded |
|---|---|
| LLM call timed out (default 8s) | timeout |
| LLM call raised a network / 5xx error | network_error: <error_class> |
| LLM returned a model id not in the candidate set | invalid_model_id: <name> |
LLM returned no choose_model call |
no_model_chosen |
| Provider rejected the request (e.g. AUTH) | provider_error: <error_class> |
| Budget exhausted (per-session or per-day) | budget_exhausted |
| No candidate models passed §4.4 validation | no_candidates |
llm_router: block missing or enabled: false |
llm_router disabled |
| Worker re-entry | delegate_request_in_flight |
The slot never raises an exception that escapes the engine. The chain is allowed to fall through; routing’s no-model-available hard failure (§4.8) still requires every slot to come up empty.
The meta-call lands in the trace store as paired llm.call_started + llm.call_completed events stamped with the Actor.ROUTER actor (events/envelope.py), so dashboards can attribute meta-spend separately from the planner’s spend. The cost is also surfaced on the LLM_ROUTER slot’s PolicyEvaluation via three additive fields (meta_cost_usd, meta_tokens_input, meta_tokens_output), None for every other slot. The slot’s meta-cost does not count against the session’s turn.completed.usage.cost_usd (which measures only planner-side LLM tokens, consistent with the worker convention in delegation.md §8).
For debuggability the llm.call_completed payload includes an additive response_text_preview: str | None field — the first ~500 characters of any TextBlock content the router emitted, or None when the response was tool-only. The motivating case: a no_model_chosen failure (the router writes prose instead of calling choose_model) previously discarded the response, leaving operators blind to why the router failed. With the preview persisted, a SQLite query like
SELECT
json_extract(payload_json, '$.model') AS router_model,
json_extract(payload_json, '$.response_text_preview') AS prose
FROM events
WHERE type = 'llm.call_completed'
AND actor = 'router'
AND json_extract(payload_json, '$.produced_tool_calls') = 0
ORDER BY id DESC LIMIT 10;
…surfaces every recent router-side no_model_chosen failure with the model’s actual text. Truncation cap (~500 chars upstream) keeps event rows from bloating; redaction-time treatment for the field follows redaction.md USER_CONTROLLED text-strip rules.
The meta-call’s route.decided invariant still holds: routing produces exactly one route.decided event per turn. The router’s llm.call_started / llm.call_completed are separate events, ordered before route.decided because the router’s response is read first.
The router’s system prompt is stable for the lifetime of a workspace’s candidate set (changes only when the registry changes or llm_router.model changes). Provider-side prompt caching (Anthropic cache_control, OpenAI implicit, OpenRouter where supported) applies automatically via the existing context-assembler pathway. The router does NOT maintain its own decision cache in v1 — the same user prompt going through the router twice will make two meta-calls. Adding a per-workspace decision cache is a deferred follow-on (§11.x).
delegate() — it picks a single model that handles the turn. Routing-level delegation remains the planner’s job via the delegate() tool (delegation.md)./model haiku set sticks with haiku; a user typing @sonnet in a message wins; a YAML rule that matches wins. The router fires only when accumulated user intent and empirical data are both silent.Rules do not carry fallback lists (fallback: [model_a, model_b]). All fallback is handled by chain fallthrough.
Rationale: per-rule fallback breaks the predictability invariant. If rule_A has fallbacks [opus, sonnet, haiku] and rule_B has different fallbacks, debugging “why this model?” becomes a search through multiple lists. Chain fallthrough keeps the explanation linear: each policy gets one shot, and the chain is short.
If a user wants a specific fallback order for a workspace, they encode it as additional rules:
rules:
- name: "deep for architecture"
when: {message_matches: "architecture"}
use: anthropic:claude-opus-4-7
- name: "deep for architecture (sonnet fallback)"
when: {message_matches: "architecture"}
use: anthropic:claude-sonnet-4-6
The second rule fires only if the first’s model is unavailable (validation rejects it, chain continues, second rule’s predicate matches).
If every policy in the chain returns None or fails validation, the engine raises a hard error to the session manager. The TUI surfaces:
No model available for this turn.
Tried: anthropic:claude-opus-4-7 (unavailable), anthropic:claude-sonnet-4-6 (unavailable)
Run /model <id> to choose explicitly, or /rules check.
The turn is not started. The user must intervene (set a sticky, fix config, wait for provider recovery). Silently using a model the user didn’t authorize is never acceptable.
The configured policy file is read fresh at the start of every turn. Cost: a yaml parse and validation pass, ~1ms for a typical file. The router caches the parsed structure keyed by file mtime to avoid re-parsing when nothing changed.
If the file is invalid (yaml syntax error, unknown predicate, unknown model), the router uses the last-known-good version and surfaces this in the TUI. Users diagnose with /rules check.
The per-workspace routing config lives at <workspace>/.metis/routing.yaml.
It is read fresh at the start of every turn (§4.9); a missing file is
equivalent to an empty policy (the chain falls straight through to the
defaults).
# <workspace>/.metis/routing.yaml
schema_version: 1
global_default: anthropic:claude-sonnet-4-6
# Tier mapping for delegation (§6.10)
tiers:
fast: anthropic:claude-haiku-4-5
balanced: anthropic:claude-sonnet-4-6
deep: anthropic:claude-opus-4-7
# Pattern store weighting (§5.5) + optional v2 hybrid-fingerprint opt-in
pattern:
cost_weight: 0.05 # 0.0 = pure quality, 1.0 = pure cost (default 0.05)
min_confidence: 0.05 # default 0.05 — scaled to match cost_weight=0.05 (see §5.5)
min_sample_size: 5
# fingerprint_version: v2 # opt in to the hybrid embedding fingerprint
# embedding_provider: openai:text-embedding-3-small # required when v2
# embedding_alpha: 0.6 # cosine/jaccard blend (pattern-store.md §16)
# LLM-router slot (§4.6 + §5.6). Off by default. When enabled, slot 5
# asks the configured router model to pick the turn's model from the
# candidate set. Falls through on any failure mode.
llm_router:
enabled: false # default false; chain shape unchanged when disabled
model: anthropic:claude-haiku-4-5 # safe-bet default (reliable forced tool-use); cheaper models TBD per §5.6 research note
per_session_budget_usd: 0.10 # shared BudgetTracker primitive with evaluator (independent caps)
per_day_budget_usd: 1.00
timeout_seconds: 20 # meta-call wall-clock cap (raised from 8s on 2026-06-04)
# tool_choice: required # always forced; not user-tunable in v1
rules:
- name: "fast for commits"
when:
message_matches: "^/commit|write.*commit message"
use: anthropic:claude-haiku-4-5
- name: "deep for architecture"
when:
any_of:
- message_matches: "(architecture|design review|security review)"
- skills_matching_message_includes: "system_design"
use: anthropic:claude-opus-4-7
- name: "long context"
when:
estimated_input_tokens_gt: 80000
use: anthropic:claude-opus-4-7
- name: "budget circuit breaker"
when:
cost_today_exceeds_usd: 5.00
use: anthropic:claude-haiku-4-5
workspaces:
~/code/myproject:
default: openai:gpt-5
pattern:
cost_weight: 0.7 # this is the "ship reliable" workspace
tiers:
fast: openai:gpt-5-mini
balanced: openai:gpt-5
deep: openai:gpt-5 # no deeper option configured; deep == balanced here
rules:
- name: "this project uses gpt for SQL"
when:
file_extensions_in_context: [".sql"]
use: openai:gpt-5
Within rules, top-to-bottom, first match wins. Each rule has a unique name (used for tracing and the /rules UI). Rules without name get a synthetic name rule_<index>.
Within workspaces.{path}.rules, same semantics, but workspace rules run before global rules. Workspace default and pattern config replace the corresponding global section for that workspace (full replacement, not merge — v1 simplification).
Workspace tiers must define all three slots (fast, balanced, deep) or be omitted entirely. A partial workspace tier map is rejected at validation time. Rationale: an all-or-nothing tier map makes a misconfiguration a config-time error rather than a non-obvious runtime no_model_available_for_tier. If a workspace truly only wants to override one tier, it must restate the others (typically by copying the global mapping). Note: as of v1 the routing.yaml tiers: block is parsed and validated but not consumed by delegation — tier resolution reads the model registry instead. See §6.10.
Closed set. Adding a predicate is a deliberate spec change.
Predicates evaluate against state captured at routing time, which is the start of a turn. Each predicate has a defined input source:
| Predicate | Reads from |
|---|---|
message_matches, message_contains_any |
The new USER message of the current turn. |
estimated_input_tokens_* |
The active adapter’s estimate_input_tokens() against the messages that would be sent if the candidate model were chosen, including the assembled system prompt and tool definitions. |
has_images |
The new USER message of the current turn (image blocks). |
has_tool_calls_in_history |
The session’s canonical message store (any prior ASSISTANT message with tool_use blocks). |
skills_matching_message_includes |
The skill-description index, matched against the new USER message. The index is built at session start from frontmatter descriptions only — no skill bodies are loaded for this. |
file_extensions_in_context |
File extensions appearing in any tool input or tool result of the current session (the file paths the agent has actually touched). User-message text is not scanned — too noisy. At the first turn this is always empty. |
workspace_path_matches |
The session’s workspace path, set at session creation. |
time_of_day_between |
Wall clock at routing time, in the user’s local timezone. |
cost_today_exceeds_usd |
The accumulated cost across the user’s sessions since UTC midnight. |
team_budget_remaining_lt |
The team’s remaining monthly budget (cap minus month-to-date spend) at turn start, in USD. Set by the gateway harness on team-bound requests; None — and the predicate False — on the agent path or when no team-level cap is configured (multi-user.md §6.1). |
| Predicate | Type | Description |
|---|---|---|
message_matches |
regex | Matches the user’s turn message (the new USER message). |
message_contains_any |
[string] | Any substring (case-insensitive) appears in the message. |
estimated_input_tokens_gt |
int | estimate_input_tokens() exceeds threshold. |
estimated_input_tokens_lt |
int | estimate_input_tokens() is under threshold. |
has_images |
bool | Any image block in this turn’s user message. |
has_tool_calls_in_history |
bool | Any prior assistant message has tool_use blocks. |
skills_matching_message_includes |
[string] | Skill name(s) whose description match the user message above threshold. |
file_extensions_in_context |
[string] | File types touched by tools in this session (case-insensitive). |
workspace_path_matches |
regex | Workspace’s absolute path matches. |
time_of_day_between |
[HH:MM,HH:MM] | Local time falls in window. Wraps midnight: [22:00, 06:00]. |
cost_today_exceeds_usd |
float | Sum of today’s session costs (UTC midnight). |
team_budget_remaining_lt |
float | Team’s remaining monthly budget is below the threshold, in USD (multi-user.md §6.1). |
any_of |
[predicate] | Logical OR. |
all_of |
[predicate] | Logical AND. |
not |
predicate | Logical NOT. |
A when block with multiple top-level keys is implicitly all_of.
v1 implementation status. Three predicates are accepted at load time so policy can be written forward-compatibly, but their backing infrastructure is not yet wired — they evaluate to
Falseon every turn, so a rule whosewhenblock depends on one of them never matches:skills_matching_message_includes(no skill-description index is built at routing time),file_extensions_in_context(no tool-touched-file tracker), andcost_today_exceeds_usd(no daily-cost accumulator — see §5.4).team_budget_remaining_ltis not in this category: it matches whenever the gateway supplies the team-budget headroom, and isFalseonly when there is genuinely no team binding (the agent path, pre-multi-user keys).
Note on
skills_matching_message_includes: prior drafts of this spec called this predicateskills_loaded_includes. That name was misleading because skills are loaded by the context assembler, which runs after routing — at routing time no skills are “loaded” yet. The current name reflects what’s actually checked: a fast match against the skill description index.
cost_today_exceeds_usd is a first-class predicate. When it fires, the TUI surfaces:
Daily budget $5.00 exceeded ($5.42 today). Routing per "budget circuit breaker" rule.
Banner clears at UTC midnight reset.
v1 status. The predicate is part of the closed set and is accepted by the policy loader, but the daily-cost accumulator that would feed it is not wired yet (§5.3.2 “v1 implementation status”). Until it lands the predicate evaluates to
False, so acost_today_exceeds_usdrule never fires and the banner above never appears. The behavior described here is the intended contract for when the accumulator ships.
Status. Slot 4 (
PATTERN_RECOMMENDATION) is wired end-to-end: the engine consults the per-workspacePatternStorewhen apattern_store_resolverandfingerprint_inputs_builderare injected, and emitspattern.matchedon a win. The store mechanics — fingerprinting, K-NN retrieval, the v2 hybrid embedding path — are specified inpattern-store.md; this section covers only how the routing chain consumes a recommendation.
The pattern policy queries the pattern store for the K nearest fingerprints (default K=10) to the current turn’s fingerprint. Among the K neighbors, it groups by outcome.primary_model. For each model M in the cluster, it computes:
normalized_success_M = sample-size-weighted mean(success_score) for neighbors
with primary_model = M, computed as
Σ(success_score_i × sample_size_i) / Σ(sample_size_i)
(already in 0..1; a neighbor row with 50 contributing
sessions weights 50× a single-shot row, so well-evidenced
outcomes dominate noisy one-offs)
if max_avg_cost_in_cluster == min_avg_cost_in_cluster:
normalized_cost_efficiency_M = 0 for all models in the cluster
else:
normalized_cost_efficiency_M = (max_avg_cost_in_cluster - avg_cost_M)
/ (max_avg_cost_in_cluster - min_avg_cost_in_cluster)
(0..1; cheapest gets 1.0, most expensive gets 0.0)
score_M = (1 - cost_weight) × normalized_success_M
+ cost_weight × normalized_cost_efficiency_M
The degenerate case (all candidate models in the cluster have identical average cost) zeroes out the cost-efficiency term entirely, making the score reduce to (1 - cost_weight) × normalized_success_M — i.e., the decision falls to pure quality. This is the right behavior: there is no cost differentiation to weight.
cost_weight is configurable per workspace (default 0.05, lowered from 0.1 on 2026-05-15 per the §A3-rev5 benchmark finding, which itself succeeded the 0.3 → 0.1 migration on 2026-05-14 — see “Default rationale” below). cost_weight = 0 means “pure quality, ignore cost”; cost_weight = 1 means “pure cost, ignore quality”; values in between blend.
The model with the highest aggregate score is the recommendation. The runner-up appears in alternatives.
confidence = (top_score - runner_up_score) / top_score
If top_score == 0, confidence is 0. The pattern policy returns None if confidence < pattern.min_confidence or sample_size < pattern.min_sample_size. Both are configurable.
Default rationale. The cost_weight default of 0.05 (was 0.1 from 2026-05-14, was 0.3 prior to 2026-05-14) is itself a value judgment — but a documented one. A user prototyping wants higher; a user shipping production code may want lower (closer to pure quality). The point is that the tradeoff is visible, not hidden, and the user can see and override it.
The default was lowered from 0.3 → 0.1 after the §A3-rev benchmark run (see benchmarks/RESULTS.md). At 0.3 the cost-efficiency term required a success delta of ~0.43 to flip the chooser when the cheapest model also scored 1.0 on cost_efficiency — larger than the 0.15–0.30 cluster-level quality deltas the LLM judge produced in real data. The result was slot 4 picking the cheaper model on every routed turn regardless of evidence. At 0.1 a quality delta of ~0.143 is enough to invert the ranking, which the observed deltas do clear. The scoring formula is unchanged — only the default of the blend constant moved. Workspaces that depended on the prior cost-bias must restate cost_weight: 0.3 in their routing.yaml.
The default was lowered again from 0.1 → 0.05 on 2026-05-15 after the §A3-rev5 benchmark run. §A3-rev5 reproduced a separate failure mode: with the v2 HYBRID fingerprint wiring landed (Wave 11) and the §A3-rev3 defaults in place, slot 4 still picked haiku on all 17 routed Pass C turns — including regex-with-edge-cases where haiku rubric-fails on the hard “16 edge case tests” turn (q=0.19) and sonnet rubric-passes (q=1.00). Diagnosis: cost_efficiency normalizes per cluster to [0.0, 1.0], so at cost_weight=0.1 whichever model is cheapest gets a flat +0.10 floor on its score regardless of cluster geometry. On the §A3-rev5 regex cluster (haiku q=0.91 vs sonnet q=1.00, cost_haiku ≪ cost_sonnet) this floor swamped the 0.09 quality delta and slot 4 picked haiku at conf=0.011 — gated off, falling to slot 7. Direct simulation against the a3rev5-patterns.db snapshot under cw=0.05 showed 6 sonnet picks pass the min_confidence=0.05 gate where cw=0.10 produced 0; haiku-correct decisions on workloads with genuine quality dominance (multi-file-refactor q=0.79 vs 0.67; multi-turn-refactor q=1.00 vs 0.95) still pick haiku at high confidence. The scoring formula is unchanged — only the default of the blend constant moved. Workspaces that depended on the 0.1 cost bias restate cost_weight: 0.1 in their routing.yaml. (Per-prompt sub-cluster partitioning was considered as an alternative wedge but found unnecessary: the K-NN already pulls 9 of 10 same-workload neighbors per cluster on §A3-rev5 data.)
The min_confidence default was lowered from 0.3 → 0.05 in the 2026-05-14 wave after the §A3-rev2 benchmark run. The two knobs are coupled: confidence is (top_score - runner_up_score) / top_score, and score itself is (1 - cost_weight) * success + cost_weight * cost_efficiency. Under the legacy cost_weight=0.3, the cost-efficiency term alone — independent of any quality delta — produced ~0.35 confidence on tied-quality clusters where the two models had different costs, so min_confidence=0.3 acted as a noise gate without suppressing genuine signal. Under cost_weight=0.1 the same tied-quality clusters produce ~0.10 confidence, and the legacy 0.3 gate suppresses real cluster inversions: §A3-rev2 Pass C turn 2 on write-a-doc-from-notes aggregated sonnet=0.900 ahead of haiku=0.842 (the first cluster-level inversion in any A3 series) with confidence 0.064, and slot 4 emitted not_applicable. At 0.05 the gate scales down with the cost-weight reduction so real inversions can fire; cluster-empty / zero-score / fewer-than-K-cluster cases still gate off in aggregation.py. Workspaces that depended on the prior tighter gate restate min_confidence: 0.3 in their routing.yaml. The 2026-05-15 cost_weight 0.1 → 0.05 migration leaves min_confidence=0.05 unchanged: under the new cost_weight=0.05 the cost-floor effect on confidence drops further (~0.05 max contribution from cost_efficiency saturation alone), and the §A3-rev2 inversion-friendly ratio still clears the 0.05 gate.
llm_router: config blockWorkspace-scoped (top-level llm_router: applies globally; workspaces.<path>.llm_router: overrides per workspace, matching the §5.5 pattern: pattern). All fields optional; absent block ≡ enabled: false.
llm_router:
enabled: true
model: anthropic:claude-haiku-4-5
per_session_budget_usd: 0.10
per_day_budget_usd: 1.00
timeout_seconds: 20
| Field | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Master switch. False → slot 5 returns not_applicable, reason="llm_router disabled". |
model |
str | anthropic:claude-haiku-4-5 |
Router model id (registered in ModelRegistry, capability-validated like any other model). |
per_session_budget_usd |
float | 0.10 |
Per-session cap; shares BudgetTracker primitive with evaluator (independent caps). |
per_day_budget_usd |
float | 1.00 |
Per-day cap; ditto. |
timeout_seconds |
float | 20.0 |
Wall-clock cap on the meta-call. Timeout (including adapter-wrapped CancelledError) → not_applicable, reason="timeout". Raised from 8.0 on 2026-06-04 — OpenRouter routing of Qwen models to various upstream providers regularly took 10–18s. |
anthropic:claude-haiku-4-5 is the v1 default — chosen for reliable forced tool-use (the slot requires the router to call choose_model exactly once, and haiku honors that consistently). This default is not load-bearing — the cost-effective router model question is genuinely open and should be benchmarked against the standard suite once we have signal on router-pick quality across haiku-4-5, gpt-4o-mini, qwen3-coder, and other sub-$1/MTok candidates. The router model selection benchmark is a deferred follow-on (§11.10).
2026-06-04 history. First field test (one turn against the v3.4-first-cut openrouter:qwen/qwen-plus default) returned no_tool_call: qwen-plus emitted 150 output tokens of prose explaining its pick instead of calling the tool, the slot reported not_applicable, reason="no_tool_call" and the chain fell through to the workspace default ($0.0042 wasted per turn on the meta-call). The default switched to haiku-4-5 the same day. Cheaper candidates (qwen3 family, gpt-4o-mini) remain viable if tool-use is verified per-model first.
Workspaces with no openrouter API key configured should set model: explicitly to an anthropic: or openai: candidate that resolves under their existing credentials. The metis auth doctor output lists which providers have credentials.
The router is called with one tool:
{
"name": "choose_model",
"description": "Pick the model best suited to handle the user task. Return one of the registered candidates.",
"input_schema": {
"type": "object",
"required": ["model_id", "reason"],
"properties": {
"model_id": {"type": "string", "enum": ["<candidate_1>", "<candidate_2>", ...]},
"reason": {"type": "string", "maxLength": 200}
}
}
}
The model_id enum is constructed from the candidate set at call time (post §4.4 validation). The router cannot return a model not on the enum; if a provider’s tool-use implementation doesn’t enforce enums strictly, an off-enum response triggers invalid_model_id per §4.6.6.
The system prompt is built from a stable template + the candidate catalog and is constant across consecutive turns in a workspace, so provider-side prompt caching applies (Anthropic cache_control, OpenAI implicit, OpenRouter where the upstream supports it per provider-adapter-contract.md §4.5).
Each catalog line carries: model_id, capability summary (tools / vision / thinking), context-window size, and per-MTok input + output rates from the active PriceTable. The price is concrete dollar amounts, not a relative tier label — without absolute numbers the router LLM can’t anchor “cheapest” against the alternatives. A candidate whose model is not in the PriceTable is rendered with a price unknown marker rather than being dropped.
- anthropic:claude-haiku-4-5 [fast; tools; 200k ctx; in $0.8/MTok, out $4/MTok]
- anthropic:claude-sonnet-4-6 [balanced; tools; vision; thinking; 200k ctx; in $3/MTok, out $15/MTok]
- anthropic:claude-opus-4-7 [deep; tools; vision; thinking; 200k ctx; in $15/MTok, out $75/MTok]
The guidance block at the bottom of the system prompt explicitly biases the router toward the cheapest viable candidate and enumerates the (narrow) escalation criteria. The phrasing is load-bearing: an earlier draft ended with “when in doubt, pick a balanced mid-tier candidate” which qwen-plus interpreted as license to pick claude-sonnet-4-6 for a one-word prompt (test) on 2026-06-04, paying a ~$0.045 turn cost when haiku would have produced the same response for under $0.005. The shipped guidance instead reads:
Bias hard toward the cheapest viable model.
- Default to the cheapest candidate for short, ambiguous, conversational, or low-stakes prompts.
- Escalate to a mid-tier model only when the task explicitly calls for multi-step reasoning, code synthesis across multiple files, careful refactoring, or non-trivial debugging.
- Escalate to the deep tier only for architecture design, security review, multi-document synthesis, or tasks that explicitly request extended reasoning. “test” / “hi” / “continue” / single-sentence questions are NOT in this category.
- A 4× more expensive model that gives a 5% better answer on a trivial task is the wrong pick.
The catalog-line price + biased-cheap guidance combination is the v3.4 fix for the 2026-06-04 sonnet-for-test failure; the §11.10 “genuinely best router model” open question is independent of this fix.
The llm_router: block is validated at load time:
enabled is a bool.model is a non-empty string. The model id is NOT required to be currently registered (the registry can change at runtime); resolution happens per turn, and an unregistered model results in no_candidates or a validation rejection.per_session_budget_usd and per_day_budget_usd are non-negative floats. 0.0 is allowed (disables the router via budget exhaustion on every turn).timeout_seconds is a positive float; values < 1.0 are clamped to 1.0 with a routing.policy_invalid-style WARN.A malformed llm_router: block is treated as if absent (slot reports not_applicable, reason="llm_router disabled"), and a routing.policy_invalid event is emitted with the validation errors. The rest of the policy file continues to load (the rule slot stays functional).
When both a rule and a pattern recommendation are available, the rule wins (per §4.1). The pattern recommendation is not discarded — it’s recorded in the route.decided event as a deferred policy with its own evaluation.
Pattern-disagreement surfacing — not yet implemented. The opt-in feature
described in the rest of this section is specified but unbuilt as of Wave 16:
the pattern_disagreement config block is not parsed by the policy loader,
the /route override and /route ignore commands do not exist, and the
route.overridden / pattern.override_dismissed events are not in the
catalog. The design is retained here as the intended contract.
An opt-in feature surfaces high-confidence pattern disagreement to the user:
pattern_disagreement:
surface: true # default false
min_confidence: 0.85
min_sample_size: 20
When enabled and a pattern recommendation disagrees with the chosen rule above the thresholds, the TUI shows:
→ Routing to anthropic:claude-haiku-4-5 per rule "fast for commits"
Pattern store suggests: anthropic:claude-sonnet-4-6 (confidence 0.87, 23 similar tasks)
/route override to use Sonnet for this turn
/route ignore to dismiss
The user’s choice is itself an event (route.overridden for accept, pattern.override_dismissed for ignore), feeding back into pattern learning. See event-bus-and-trace-catalog.md §6.5b for payloads.
At load time, the router validates:
schema_version matches a supported version.use and default references a model in the adapter registry.tiers blocks define all three slots (fast, balanced, deep) or are absent. Partial maps are rejected.name is duplicated (synthetic names excepted).pattern.cost_weight is in [0.0, 1.0]; min_confidence in [0.0, 1.0]; min_sample_size ≥ 1.pattern.fingerprint_version is v1 or v2; pattern.embedding_alpha is in [0.0, 1.0]; fingerprint_version: v2 requires pattern.embedding_provider to be set (the v2 hybrid fingerprint — pattern-store.md §16).A failure in any of these causes the file to be rejected as a whole — last-known-good is used. /rules check prints validation errors.
By design, rules cannot:
These constraints are how the engine stays predictable. Users wanting richer logic write a skill, not a rule.
delegate() contractStatus (Wave 16). The
delegate()tool shipped in Wave 10 (delegation v1 MVP).delegation.mdis the canonical contract for the tool signature, the worker session lifecycle, context handoff (ContextSpec), the worker system prompt, return values, failure modes, and cost attribution. This section is trimmed to the surface the routing engine owns: theDELEGATE_REQUESTchain slot, worker re-entry into the policy chain (§6.9), capability validation of the resolved tier model, and tier definitions (§6.10).§6.1–§6.8 are retained as cross-reference stubs. Their section numbers are cited from
delegation.md,tool-dispatcher.md, andevent-bus-and-trace-catalog.md, so the numbering is kept stable even though the canonical text now lives indelegation.md.The routing chain’s
DELEGATE_REQUESTslot (§4.1, position 6 as of v3.4; position 5 in v3.3 and earlier — see §4 changelog header for the renumbering rationale) existed from Phase 1 as a stub returningnot_applicable; delegation v1 filled in the stub. The slot still reportsnot_applicableon every non-worker turn.
→ Canonical: delegation.md §4.1. The
delegate(tier, task, context, output_schema?, allowed_tools?, max_tokens?)
tool is registered for a planner session whose active model has
can_delegate: true in the model registry (§6.8). It is never registered for
worker sessions — no recursive delegation in v1.
→ Canonical: delegation.md §5. A worker is a fresh
Session (is_worker=true, with parent_session_id / parent_tool_use_id
set, workspace inherited) running the same turn-locked loop (§3.2) as any
other session.
→ Canonical: delegation.md §5.4–§5.6 and the isolation
summary in delegation.md §10. Workers are read-only against memory (the
memory_* tools are not registered for worker sessions), skills (load only,
no authoring), and routing.yaml.
→ Canonical: delegation.md §5 and delegation.md §14.9.
Worker sessions are hidden from /history by default and surface as cost
rollups under the parent session.
→ Canonical: delegation.md §4. ContextSpec is minimal
(task brief only) or explicit (planner-specified include references).
There is no auto mode in v1 — the planner decides what the worker needs.
→ Canonical: delegation.md §4. minimal for
self-contained mechanical sub-tasks; explicit when the worker needs curated
prior context. When unsure, default to explicit.
→ Canonical: delegation.md §5.7. The worker runs under a
sub-agent system prompt instructing it to stay focused, be terse, fetch
missing information via tools, and return rather than ask the user for
clarification.
→ Canonical: delegation.md §4.3–§4.4. DelegateResult
carries success / output / error / a usage rollup / worker_session_id.
The one failure mode the routing path itself produces is
no_model_available_for_tier (§6.9); the rest (worker_error,
max_tokens_exceeded, insufficient_context, output_schema_validation_failed,
cancelled_by_user, timeout) originate inside the worker session or its
spawn wrapper.
insufficient_context schema→ Canonical: delegation.md §4.4. A worker that cannot
proceed without more information returns a structured
InsufficientContextRequest rather than free text, so the planner can
programmatically retry with targeted references.
→ Canonical: delegation.md §8. Worker token usage is
attributed to the worker session and rolls up under the planner via the
analytics projection (/analytics/cost?group_by=parent_session /
group_by=is_worker). The planner’s own turn.completed.usage.cost_usd
measures planner tokens only; worker cost lives on delegate.completed.
can_delegate flag→ Canonical: delegation.md §4.2. ModelEntry.can_delegate
(routing/registry.py)
gates whether the delegate tool is registered when that model is the active
planner model. Default false; a worker session never sees the tool
regardless of the flag.
This is the canonical definition of the DELEGATE_REQUEST slot, cross-referenced
by delegation.md §7.
When a planner emits a delegate(tier=...) call,
SessionManager.spawn_worker
(sessions/manager.py)
resolves tier to a concrete model via the model registry’s delegation_tier
field (ModelRegistry.model_for_tier, §6.10). If no registered model carries
that tier, spawn_worker short-circuits with no_model_available_for_tier
and no worker session is created — this case never reaches the routing engine.
Otherwise a worker Session is created, and its first turn enters the policy
chain with the resolved model carried on TurnContext.worker_tier_model. The
chain runs end-to-end (§6.9.1); slots 1–5 typically report not_applicable
(slot 5 LLM_ROUTER defers with reason="delegate_request_in_flight" per
§4.6.4), and slot 6 (DELEGATE_REQUEST) proposes the resolved tier model as
its candidate.
The resolved tier model is validated like any other candidate (§4.4 —
capability and availability). If it passes, slot 6 wins. If it fails — e.g.
the worker’s task carries images and the resolved fast model is text-only,
or the model’s provider is Unavailable — slot 6 is rejected and the chain
falls through to WORKSPACE_DEFAULT / GLOBAL_DEFAULT, exactly as for
any other rejected candidate. There is no automatic fast → balanced →
deep upgrade inside the engine in v1 (earlier drafts of this spec described
one; it was never implemented). A user wanting capability-driven escalation
encodes it as a configured rule (§4.6) or relies on the workspace default.
The worker’s turn is itself turn-locked (§3.2): the model chosen at the worker’s turn start owns all of the worker’s LLM calls and tool cycles.
The policy chain runs end-to-end for worker sessions, including policies that cannot apply to workers by construction (PER_MESSAGE_OVERRIDE — workers don’t have user messages with @ prefixes; MANUAL_STICKY — workers have no user /model command; CONFIGURED_RULES — can match against the worker’s task brief and would fire if the user wrote rules targeting it, though this is rare).
The alternative — skipping inapplicable policies in worker mode — produces a cleaner trace but adds a special case to the routing pipeline. A reviewer might prefer either; this spec keeps the chain uniform for predictability. The route.decided event traces for workers will show not_applicable verdicts on the user-facing policies, which is the trade-off.
tier is a coarse abstraction over concrete models — fast / balanced /
deep:
| Tier | Intent |
|---|---|
fast |
Cheap, low-latency. Used for mechanical sub-tasks. |
balanced |
Capable enough for most coding work. Default for medium tasks. |
deep |
The most capable available. Used for planning and reasoning. |
In the shipped implementation, tier resolution is a model-registry
lookup. Each ModelEntry may declare a delegation_tier
(routing/registry.py),
and ModelRegistry.model_for_tier(tier) returns the first registered model
whose delegation_tier matches. A tier with no registered model causes
delegate(tier=X) to return no_model_available_for_tier (§6.9).
Spec/impl note. The
tiers:block inrouting.yaml(§5.1, §5.2) is parsed and validated intoRoutingPolicy.tiers/WorkspaceScope.tiers(aTierMap) but is not yet consumed by delegation —spawn_workerreads the model registry, not the routing policy. Per-workspace tier overrides written inrouting.yamltherefore do not take effect in v1; the block is retained as forward-compatible config. Reconciling the two tier sources — havingspawn_workerconsult the workspace-scopedTierMap, or dropping therouting.yamlblock in favor of registry config — is tracked as an open question (§11).
route.decided eventEvery turn produces exactly one route.decided event, emitted at turn start after the chain runs. This is the source of truth for “why this model?”
class RouteDecidedEvent:
# Standard event envelope (see event-bus spec)
type: Literal["route.decided"]
timestamp: datetime
session_id: str
turn_id: str
# Routing-specific payload
chain: list[PolicyEvaluation] # one entry per policy in chain order
winner_index: int # index into chain
chosen_model: str
elapsed_ms: float
class PolicyEvaluation:
policy: str # per_message_override | manual_sticky | rule
# | pattern | delegate_request
# | workspace_default | global_default
# (lowercase snake_case wire literals —
# RoutingPolicyName in events/payloads.py)
verdict: Verdict # not_applicable | deferred | rejected | chose
candidate_model: str | None # what this policy proposed (None if not_applicable)
reason: str # human-readable
# Mode-specific extras
rule_name: str | None # for the rule slot
confidence: float | None # for the pattern slot
pattern_alternatives: list[PatternAlternative] | None # for the pattern slot
# Validation outcome (if candidate_model was set)
validation_failure: str | None # "no_vision_support" | "exceeds_context_window"
# | "no_tool_support" | "no_system_prompt_support"
# | "no_structured_output_support"
# | "provider_unavailable" | "not_configured"
# | None if validation passed
class PatternAlternative:
model: str
score: float # the aggregate score from §5.5
sample_size: int # how many neighbors used this model
class Verdict(StrEnum):
NOT_APPLICABLE = "not_applicable" # policy didn't have anything to say (e.g. no override token)
DEFERRED = "deferred" # policy had a candidate but a higher-priority policy won
REJECTED = "rejected" # candidate failed validation, chain continued
CHOSE = "chose" # this policy won
deferredis currently never emitted. The engine evaluates policies in order and stops at the first winner — lower-priority policies are not evaluated and do not appear inchain. Sochainis always the prefix of slots up to and including thechoseentry, and a policy outranked by an earlier winner simply never runs. Thedeferredverdict is reserved for the opt-in pattern-disagreement feature (§5.7), which would deliberately evaluate and record the pattern slot even when a rule wins; until that ships, every chain entry isnot_applicable,rejected, orchose.
Earlier drafts had separate events for routing.constraint_failure, routing.rule_skipped, routing.policy_invalid. Folding them into a single route.decided event means:
routing.policy_invalid (the rule file failed to load) remains a separate session-level event since it’s not turn-scoped.
These remain separate events because they describe distinct user actions or worker lifecycle. The Status column reflects the Wave-16 implementation:
| Event type | When | Status |
|---|---|---|
route.overridden |
User chose /route override (turn re-dispatched on pattern’s choice). |
Not implemented — depends on the §5.7 pattern-disagreement feature; no payload in the catalog. |
pattern.override_dismissed |
User chose /route ignore (turn proceeds with original choice). |
Not implemented — see §5.7. |
delegate.started |
A delegate tool call began. Includes worker_session_id. |
Shipped (Wave 10). |
delegate.completed |
The worker session ended. | Shipped (Wave 10). |
delegate.failed |
The worker session failed; includes failure mode (§6.6). | Shipped (Wave 10). |
routing.policy_invalid |
The rule file failed to load; last-known-good in use. | Shipped; audit-flagged (AUDIT_EVENT_TYPES). |
routing.provider_unavailable |
Provider state transitioned to Unavailable. | Payload defined in the catalog; emission not wired — the availability state machine (availability.py) has no event-bus handle in v1. |
routing.provider_recovered |
Provider state transitioned back to Healthy. | Payload defined in the catalog; emission not wired (as above). |
All events conform to the schema defined in event-bus-and-trace-catalog.md.
The “why this model?” view is a single record render:
Turn 01HZ_xyz · session sess_42 · 2026-05-08T14:23:11Z
Chose: anthropic:claude-sonnet-4-6 (workspace default)
Chain:
[1] per_message_override not_applicable no @model token in message
[2] manual_sticky not_applicable no sticky model set
[3] rule rejected rule "deep for architecture" matched →
anthropic:claude-opus-4-7 (provider_unavailable)
[4] pattern not_applicable no high-confidence recommendation
[5] llm_router not_applicable llm_router disabled
[6] delegate_request not_applicable not a delegation re-entry
[7] workspace_default chose anthropic:claude-sonnet-4-6
The chain stops at the first chose entry (here slot 7), so global_default
(slot 8) does not appear. The TUI’s /model show command prints the same
trace inline.
session.active_model = "anthropic:claude-sonnet-4-6" (user ran /model sonnet)
user: "Refactor this function."
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY chose → anthropic:claude-sonnet-4-6 (validates)
session.active_model = None (no /model run)
rules: [{name: "fast for commits", when: {message_matches: "^/commit"}, use: haiku}]
user: "/commit fix the auth bug"
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES chose → anthropic:claude-haiku-4-5 (rule "fast for commits")
session.active_model = "anthropic:claude-sonnet-4-6"
user: "@haiku what's a quick name for this variable?"
Chain:
PER_MESSAGE_OVERRIDE chose → anthropic:claude-haiku-4-5 (override "@haiku")
session.active_model is unchanged after this turn.
session.active_model = None
rules: []
pattern store: returns sonnet, confidence 0.78, sample 12
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES not_applicable (no rules match)
PATTERN_RECOMMENDATION chose → anthropic:claude-sonnet-4-6 (confidence 0.78, 12 samples)
session.active_model = None
rules: []
pattern store: empty (cold start)
llm_router: enabled=true, model=anthropic:claude-haiku-4-5
User: "Refactor the get_user helper to accept a Session argument; preserve the call sites."
Router meta-call:
→ input: system prompt + candidate catalog + user message
→ choose_model(model_id="anthropic:claude-sonnet-4-6",
reason="multi-file refactor with contract preservation needs balanced tier")
→ meta_cost_usd=$0.00021, meta_tokens_input=1840, meta_tokens_output=72
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES not_applicable (no rules match)
PATTERN_RECOMMENDATION not_applicable (no high-confidence recommendation, sample size 0)
LLM_ROUTER chose → anthropic:claude-sonnet-4-6 (meta_cost $0.00021)
session.active_model = None
rules: []
pattern store: empty
llm_router: enabled=true, model=anthropic:claude-haiku-4-5, timeout_seconds=20
workspace_default: anthropic:claude-haiku-4-5
(router meta-call times out at 8.0s)
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES not_applicable
PATTERN_RECOMMENDATION not_applicable
LLM_ROUTER not_applicable (timeout)
DELEGATE_REQUEST not_applicable (not a delegation re-entry)
WORKSPACE_DEFAULT chose → anthropic:claude-haiku-4-5
rules: [{name: "deep for architecture", when: {message_matches: "architecture"}, use: opus}]
workspace_default: anthropic:claude-sonnet-4-6
provider state: (anthropic, claude-opus-4-7) — Unavailable
anthropic provider-wide — Healthy
(anthropic, claude-sonnet-4-6) — Healthy
user: "Walk me through the architecture of this codebase"
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES rejected → opus (provider_unavailable, model-specific)
PATTERN_RECOMMENDATION not_applicable (insufficient samples)
WORKSPACE_DEFAULT chose → anthropic:claude-sonnet-4-6 (validates)
TUI banner:
anthropic:claude-opus-4-7 currently unavailable. Routing fell through to anthropic:claude-sonnet-4-6.
rules: [{name: "default override", when: {}, use: opus}]
workspace_default: anthropic:claude-sonnet-4-6
global_default: anthropic:claude-haiku-4-5
provider state: anthropic provider-wide — Unavailable (auth error triggered escalation)
no other providers configured
Chain:
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES rejected → opus (provider_unavailable, provider-wide)
PATTERN_RECOMMENDATION not_applicable
WORKSPACE_DEFAULT rejected → sonnet (provider_unavailable, provider-wide)
GLOBAL_DEFAULT rejected → haiku (provider_unavailable, provider-wide)
Hard failure. Turn does not start.
TUI:
No model available for this turn.
anthropic provider currently unavailable.
Tried: opus, sonnet, haiku — all on anthropic.
Run /model <id> to choose a model from a configured provider, or wait for recovery.
(The TUI message dynamically lists alternative configured providers; if none exist, it omits the suggestion.)
turn: estimated_input_tokens = 90000, has_images = true
rules: [{name: "long context", when: {estimated_input_tokens_gt: 80000}, use: haiku}]
(haiku has 200k context but no vision support)
workspace_default: opus
Chain:
CONFIGURED_RULES rejected → haiku (no_vision_support)
(rule matched but candidate failed validation)
WORKSPACE_DEFAULT chose → opus (supports images, 200k context)
Not yet implemented. This example illustrates the opt-in pattern-disagreement feature (§5.7), which is specified but unbuilt as of Wave 16. With the feature off — the current behavior — the chain short-circuits when the rule wins (slot 3
chose), the pattern slot is never evaluated, and noroute.decidedentry, TUI prompt, ordeferredverdict is produced. The example is retained as the intended contract.
session.active_model = None
rules: [{name: "fast for commits", when: {message_matches: "^/commit"}, use: haiku}]
pattern store: sonnet at confidence 0.87, sample 23
pattern_disagreement.surface = true
user: "/commit fix the auth bug"
Chain:
CONFIGURED_RULES chose → anthropic:claude-haiku-4-5 (rule "fast for commits")
PATTERN_RECOMMENDATION deferred → anthropic:claude-sonnet-4-6
(confidence 0.87, 23 samples — outranked by rule)
TUI:
→ Routing to anthropic:claude-haiku-4-5 per rule "fast for commits"
Pattern store suggests anthropic:claude-sonnet-4-6 (confidence 0.87, 23 tasks)
/route override to use Sonnet for this turn
/route ignore to dismiss
Turn start at T=0:
user: "Read README.md and summarize"
Chain: CONFIGURED_RULES → sonnet (rule "balanced for reads")
Lock: sonnet for entire turn.
T=1.2s: sonnet emits tool_use (read_file)
T=1.5s: tool dispatcher returns TOOL message with file content
T=1.6s: LLM call #2 (still sonnet, lock holds): emits tool_use (read_file for table of contents)
T=2.0s: TOOL message
T=2.1s: LLM call #3 (still sonnet): emits final summary, stop_reason=end_turn
Turn ends.
route.decided event has chain trace from T=0.
No re-routing happens between LLM calls #1, #2, #3.
/model swap is queuedTurn N in flight. User runs `/model opus` while sonnet is mid-tool-loop.
Server response (TUI banner): "Model swap pending: anthropic:claude-opus-4-7. Applies to next turn."
Turn N completes on sonnet (lock holds).
Turn N+1 starts:
Chain: MANUAL_STICKY → opus (newly set)
Active planner model: anthropic:claude-opus-4-7
Planner emits: delegate(tier="fast", task="rename `foo` to `bar` in src/", context={"mode": "minimal"})
Worker session creation triggers routing:
Chain (in worker context):
PER_MESSAGE_OVERRIDE not_applicable
MANUAL_STICKY not_applicable
CONFIGURED_RULES not_applicable (rules evaluated against worker's "user message" = task brief)
PATTERN_RECOMMENDATION not_applicable (insufficient context)
DELEGATE_REQUEST chose → anthropic:claude-haiku-4-5 (tier=fast resolves)
Worker runs to completion on haiku. Returns to planner via delegate tool result.
Planner's lock on opus is preserved through the delegation.
rules:
- name: "deep for architecture"
when: {message_matches: "architecture"}
use: opus
- name: "budget cap"
when: {cost_today_exceeds_usd: 5.00}
use: haiku
cost_today = $5.42
user: "Walk me through the architecture..."
Chain:
CONFIGURED_RULES chose → opus (rule "deep for architecture" matched first)
This is wrong if the user wants the budget cap to win. Reorder:
rules:
- name: "budget cap"
when: {cost_today_exceeds_usd: 5.00}
use: haiku
- name: "deep for architecture"
when: {message_matches: "architecture"}
use: opus
Now:
CONFIGURED_RULES chose → haiku (rule "budget cap" matched first)
/rules check can detect obvious shadowing (an earlier rule’s predicates strictly subsume a later rule’s), but rule ordering remains the user’s contract.
| Command | Effect |
|---|---|
/model <id> |
Set session sticky model (opt-out from rules). |
/model - |
Clear sticky; next turn uses default policy chain. |
/model show |
Print active model and the last turn’s full chain trace. |
/route override |
(When pattern-disagreement is surfaced) use the pattern’s choice. |
/route ignore |
(When pattern-disagreement is surfaced) dismiss the suggestion. |
/rules check |
Validate the routing.yaml file; print errors or “ok”. |
/rules show |
Print the active rule list (post-validation, with synthetic names). |
/rules reload |
Force re-read of routing.yaml (normally automatic). |
/router llm on\|off |
Enable/disable the LLM_ROUTER slot for the active workspace (persists to routing.yaml). |
/router llm model <id> |
Set the router model id (persists to routing.yaml). |
/router llm status |
Print the current llm_router: config + running budget totals. |
/cost |
Print this session’s cost broken down by model and role. |
Implementation note.
/route overrideand/route ignoredepend on the pattern-disagreement surfacing feature (§5.7) and are not implemented as of Wave 16. The shipped CLI surface is/model,/cost,/models,/helpplus the per-message@aliasoverride; seeAGENTS.mdfor the authoritative list./router llm on|off|model|statusships in v3.4 (Wave 19) alongside theLLM_ROUTERslot itself.
A user message starting with @<alias> (e.g., @haiku, @opus, @sonnet) is parsed as a per-message override. The token is stripped before sending to the model. Aliases are resolved against the alias table; unknown aliases produce an inline error and the turn does not start.
Aliases live in the model registry config (alongside tier and can_delegate per §6.8). Each model entry can declare any number of aliases:
models:
anthropic:claude-haiku-4-5:
tier: fast
can_delegate: false
aliases: [haiku, fast]
anthropic:claude-sonnet-4-6:
tier: balanced
can_delegate: true
aliases: [sonnet, balanced]
anthropic:claude-opus-4-7:
tier: deep
can_delegate: true
aliases: [opus, deep]
Aliases must be unique across the registry; duplicates are rejected at validation.
The override syntax must be at the start of the message and followed by whitespace. Email me @haiku tomorrow is not an override.
Edge case: a user wanting to ask the agent about the literal string @haiku at message start can prefix with a backslash (\@haiku). The backslash is stripped; no override is applied.
Note. Tests 12–14, 22, 24, and 26 exercise the
delegate()contract, which is now owned bydelegation.md— its test plan is authoritative for those. Tests 13 and 22 describe an automaticfast → balanced → deepcapability upgrade that was never implemented (§6.9): in the shipped engine a worker tier model that fails validation falls through the chain rather than being upgraded.
/model swap is queued, not applied.route.decided records the rejection.provider_unavailable./route override and /route ignore.delegate(tier=...); verify the worker uses the resolved model.delegate tool registered.usage.cost_usd.route.decided completeness. Every turn emits exactly one route.decided event with chain length equal to the number of policies that actually ran.supports_tools=false. Verify rejection with no_tool_support and chain continues.anthropic provider is marked Unavailable on the next routing decision.delegate returns no_model_available_for_tier.fast is rejected by /rules check.memory_add; the tool is not registered; the call fails as “unknown tool” or equivalent.insufficient_context shape. Worker returns a structured InsufficientContextRequest; the planner’s delegate.failed event payload validates against the schema./model swaps. User runs /model A then /model B within one turn; verify only B is applied at the next turn boundary, and the banner reflects B.Worth investing in for two specific properties:
any_of/all_of/not over the predicate set always evaluate to a bool, never raise.Tracked here, deferred to later revisions:
RoutingPolicy.workspace_for() matches the session’s absolute workspace path exactly; substring / prefix / closest-ancestor matching is a deliberate non-feature. Nested-workspace and symlink resolution deferred.delegate(tier="fast") could consult patterns to pick among fast-tier models. v1: configured fast model is used./rules check shadow detection. v1 prints rules; v2 may detect when one rule strictly shadows another and warn.routing.yaml tiers: block (§5.1, §5.2) is parsed and validated but not consumed — delegation resolves tiers via the model registry’s delegation_tier field (§6.10). v2 should either wire SessionManager.spawn_worker to consult the workspace-scoped TierMap, or drop the routing.yaml block in favor of registry config. Until then, per-workspace tier overrides written in routing.yaml have no effect.anthropic:claude-haiku-4-5 (safe-bet for reliable forced tool-use as of 2026-06-04); the genuinely best model for the LLM_ROUTER meta-call is open. Cheaper candidates (qwen3-coder, gpt-4o-mini, deepseek-v3, and others sub-$1/MTok) need tool-use reliability verified before they can replace haiku as default. Benchmark against the standard suite (cost per router decision, pick quality vs. workspace-default baseline, tool-call hit rate).delegate() — it picks a single concrete model. Allowing the router to return delegate(tier=…) would let it split tasks across tiers but breaks the §4.6.4 “router does not second-guess explicit delegate calls” invariant on worker re-entry. Deferred.| Date | Decision | Rationale |
|---|---|---|
| 2026-05-08 | Turn-locked model; re-routing only at turn boundaries | Cost predictability; behavioral predictability; reasoning continuity within a turn. |
| 2026-05-08 | Mid-turn /model swap is queued, not applied |
Less disruptive than cancelling the in-flight turn. |
| 2026-05-08 | First-match-wins for rules; user orders the list | Predictable. Specificity ordering is a debugging nightmare. |
| 2026-05-08 | User-set policy beats pattern recommendations by default | Trust. Silent override of user rules destroys confidence in the engine. |
| 2026-05-08 | Pattern disagreement surfaced as suggestion (Phase 3 opt-in) | Lets the system improve on user rules without overriding them. |
| 2026-05-08 | Capability validation per-policy, fall through on failure | Routing is resilient: a bad rule choice doesn’t kill the turn. |
| 2026-05-08 | Provider availability tracked at adapter; routing rejects Unavailable | Outages produce graceful fallthrough, not turn failures. |
| 2026-05-08 | No per-rule fallback lists; chain fallthrough is the answer | Per-rule fallback breaks linearity of “why this model?” debugging. |
| 2026-05-08 | Hard failure when no policy succeeds; surface to user | Never silently use a model the user didn’t authorize. |
| 2026-05-08 | Closed predicate set, no DSL | Predicates are contracts; users wanting more write skills, not rules. |
| 2026-05-08 | cost_today_exceeds_usd first-class predicate |
Budget enforcement is the most common reason users add rules. |
| 2026-05-08 | Delegation context: minimal and explicit only; no auto |
Forces planners to think about handoff; produces more reliable results than guessing. |
| 2026-05-08 | Workers cannot delegate | Prevents fan-out cost explosions and recursion. |
| 2026-05-08 | Tiers as abstraction over concrete models | Decouples planner reasoning from provider choices. |
| 2026-05-08 | Hot reload on every turn | Cheap; eliminates “I edited the file but nothing changed” frustration. |
| 2026-05-08 | Pattern recommendations require min sample size and min confidence | Cold start safety; prevents routing on noise. |
| 2026-05-08 | cost_weight configurable per workspace |
Cost/quality tradeoff differs by workflow; defaulting to a hidden tradeoff is wrong. |
| 2026-05-08 | One canonical route.decided event per turn |
Single record answers “why this model?” without joins; full chain trace is atomic. |
| 2026-05-08 | Availability tracked at (provider, model) with provider-wide promotion |
Single-model outages don’t blackout a provider; auth/DNS errors do. |
| 2026-05-08 | Capability validation extended to tools, system prompt, structured output | Required for Ollama and other limited models; “only require what we’ll use” prevents spurious rejections. |
| 2026-05-08 | Predicate skills_loaded_includes renamed to skills_matching_message_includes |
Routing runs before context assembly; “loaded” was a lie. |
| 2026-05-08 | Workspace tiers must define all three slots or be absent |
Partial tier maps cause runtime no_model_available_for_tier for non-obvious reasons; fail at config time. |
| 2026-05-08 | Workers are read-only against memory and skill state | Planner has the broader context; sub-tasks shouldn’t mutate durable state. |
| 2026-05-08 | Worker sessions hidden from /history by default |
A planner can spawn many workers; flat listing clutters the user’s view. |
| 2026-05-08 | insufficient_context returns a structured InsufficientContextRequest |
Planner can programmatically retry with targeted references rather than re-prompting itself. |
| 2026-05-08 | Cost-efficiency degenerate case (all costs equal) zeros the term | Decision falls cleanly to pure quality when there’s no cost differentiation. |
| 2026-05-14 | cost_weight default lowered from 0.3 → 0.1 |
§A3-rev showed 0.3 required a ~0.43 quality delta to flip the chooser, swamping the 0.15–0.30 cluster-level deltas the LLM judge actually produces. 0.1 needs ~0.143, which observed deltas clear. |
| 2026-05-15 | cost_weight default lowered from 0.1 → 0.05 |
§A3-rev5 showed cost_efficiency normalizes per cluster to [0.0, 1.0], so cw=0.1 added a flat +0.10 floor to whichever model was cheapest, swamping the 0.05–0.09 quality deltas observed on regex-with-edge-cases (haiku 0.91 / sonnet 1.00) and fix-a-bug-small. cw=0.05 halves the floor; direct simulation showed 6 sonnet picks pass the gate where cw=0.1 produced 0; haiku-correct decisions on workloads with q-delta ≥0.1 still pick haiku. |
| 2026-05-08 | Tier upgrade exhausts at deep; no escape above it |
Explicit failure mode rather than implicit infinite loop. |
| 2026-05-08 | Pattern override emits route.overridden, not pattern.override_accepted |
Aligns with event-bus catalog; preserves one-route.decided-per-turn invariant. |
| 2026-05-08 | Delegation slot in Phase 1; delegate() tool in Phase 4 |
Chain shape is fixed; fills stub later rather than refactoring the pipeline. |
| 2026-05-20 | Spec re-synced to the Wave-16 implementation (v3.3) | Draft v3.2 had drifted from shipped code: slots 4/5 are wired, team_budget_remaining_lt was added to the closed predicate set, and §6 was superseded by delegation.md. |
| 2026-05-20 | §6 trimmed; delegation.md is the canonical delegate() contract |
Delegation v1 shipped Wave 10 with its own spec; routing-engine.md §6 keeps only the routing-owned surface (slot 5, tier resolution), §6.1–§6.8 as cross-reference stubs. |
| 2026-05-20 | Automatic fast → balanced → deep tier upgrade dropped from the spec |
Never implemented. The shipped engine falls a rejected worker-tier candidate through the chain like any other rejection (§6.9). Supersedes the 2026-05-08 “Tier upgrade exhausts at deep” row. |
| 2026-06-03 | LLM_ROUTER slot added at position 5; opt-in per workspace | Pattern store covers the warm-cluster case; defaults are coarse. An auxiliary LLM that sees the user prompt + candidate catalog is the cold-start complement. Off by default preserves byte-identical behavior for existing deployments. |
| 2026-06-03 | LLM_ROUTER ranked below PATTERN (slot 5 below slot 4) | Patterns are cheaper, faster, deterministic per fingerprint, and grounded in observed outcomes. The router fires when patterns are silent. Reversing the order would let probabilistic inference override empirical data. |
| 2026-06-03 | LLM_ROUTER defers in worker re-entry | Planner’s explicit tier= choice must not be second-guessed; matches slot 4 pattern behavior under delegation (delegation.md §11). |
| 2026-06-03 | LLM_ROUTER all failures degrade to not_applicable |
Routing must remain resilient; a flaky router model cannot fail turns. Hard failure still requires every slot to come up empty (§4.8). |
| 2026-06-03 | LLM_ROUTER shares BudgetTracker primitive with evaluator | Avoids shipping two implementations of token-budget bookkeeping. Caps are independent — the two budgets don’t sum into one shared bucket. |
| 2026-06-03 | LLM_ROUTER returns picks via choose_model(model_id, reason) tool |
Forces a typed response; model_id enum is built from the candidate set at call time so the router cannot hallucinate a non-existent candidate (provider tool-use enum enforcement permitting). |
canonical-message-format.md — Message, ToolDefinition, AdapterCapabilities.event-bus-and-trace-catalog.md — payload shape for route.decided and routing auxiliaries.delegation.md — canonical delegate() tool contract, worker session lifecycle, slot-5 re-entry.pattern-store.md — task fingerprint design, K-NN aggregation, and the v2 hybrid embedding fingerprint that backs slot 4.streaming-protocol.md — turn lifecycle events; the three cancellation cases (§3.4).multi-user.md — data source for the team_budget_remaining_lt predicate (§5.3).