Research · experiment · 15 October 2026

Can decision models like Jev and Laya route a coding agent?

We plugged Laya, a 421M-parameter decision model, into our coding agents as a model router. The plumbing worked in about 112 ms. Deciding when a bigger model actually helps turned out to be the hard part.

Small decision models aren't new, but they're getting a lot more attention lately, with Jev (hosted, by TypeSafe) and Laya (open source). They don't generate text: you give them a question and a fixed set of options, and they pick one. We wanted to see whether one could earn a place in a real coding workflow.

The obvious first job was replacing the classifier a harness's auto mode uses to decide whether an action is safe. That classifier is part of the harness, so it isn't something we can swap. So we gave it a different job: pick the model for each turn. Cheap model for easy turns, expensive one for hard ones. Our agents already run through Torana, a local proxy, so the router went in there as a plugin. We used Laya, because it's open source and runs on our own machine.

This write-up covers how the router works, the setup, what worked, what didn't, and what we're changing.

Setup ↓ · Plumbing results ↓ · Classifier results ↓ · Cheap vs expensive ↓ · What we're changing ↓

How the router works

Torana's decision_router plugin watches an ordered ladder of models for each provider, for example DeepSeek Flash → DeepSeek Pro. At each new user turn (never in the middle of a tool call) it decides whether to stay, move up or move down one step.

Where the router sitsThe coding agent sends requests to Torana. The decision_router plugin sends only the latest user message and a few counters to a local Laya server, which answers with one ladder step or hold. Torana then forwards the request to the cheap or the strong model at the provider.Coding agentCodex, Pi, OpenCode…Torana (local proxy)decision_routerladder + signals + policyLaya, on this machinelatest message onlystep or holdCheap modelDeepSeek FlashStrong modelDeepSeek Pro
Laya sees the latest user message and a few counters. It never sees code, tool output, the system prompt or earlier conversation.

Two kinds of signal feed the decision:

What happens next depends on the mode. shadow only records what it would have suggested. advise shows a suggestion with the estimated cache-rebuild cost. confirm lets you accept it in Torana. auto moves up on its own within a switch cap and cost guard; moving down always needs your approval. The classifier and downward moves are both off by default.

Setup

Prompt set

48 synthetic coding requests, each labelled with the tier we'd expect: 15 routine, 12 normal implementation, 14 hard reasoning and 7 that should hold (vague follow-ups like "Do it.", environment problems, explicit "keep the model"). It includes probes: a quoted prompt-injection, a Hindi and a Spanish request, and a short but hard one. The labels are our rubric, not ground truth about which model a task needs.

All 48 prompts
  • fast: Rename variable total to subtotal in one function. · Fix the spelling of recieve in the README. · Run gofmt on this file. · Print the version from package.json. · Add a comment explaining that this timeout is in milliseconds. · Sort this list of import statements alphabetically. · Change the button text from Submit to Save. · Run the existing unit tests and report the result; do not debug failures. · Pretty-print this JSON object without changing any values. · Link the quickstart from the README. · Change DEFAULT_PORT from 8080 to 8143. · Copy the existing healthcheck test and change the path to /ready.
  • balanced: Add a paginated REST endpoint with input validation and unit tests. · Implement a settings form with validation and a save API call. · Add unit tests for empty input, duplicate IDs, and invalid timestamps. · Implement a bounded in-memory LRU cache and tests. · Extract duplicated CSV parsing into a shared helper without changing behavior. · Add exponential backoff with a maximum retry count to this HTTP client. · Add a filter to the search endpoint and update its SQL query. · Write a parser for this documented JSON schema and reject unknown fields. · Build an accessible dropdown component using the existing design system. · Add a nullable database column and a backward-compatible migration. · Instrument request latency and status codes with OpenTelemetry. · Implement a plugin that logs token usage without recording message content.
  • strong: Diagnose a rare data race in a lock-free scheduler under concurrent cancellations. Prove the fix preserves linearizability. · Design an exactly-once financial ledger across regions with network partitions, retries and crash recovery. · Audit the authentication boundary for privilege escalation involving refresh-token replay and tenant isolation. · Find an intermittent deadlock involving three locks and callbacks that reenter the scheduler. · Implement a type inference algorithm for higher-rank polymorphism and explain its soundness constraints. · Design a lossless streaming translation between three model API formats including tool-call deltas, refusal and partial failure. · Analyze a crash-consistency bug in a write-ahead log that only appears after torn writes and a double restart. · Prove this distributed transaction implementation provides serializability under concurrent retries and clock skew. · Diagnose a nonlinear tail-latency regression with contention, cache misses and queueing. · Review a custom cryptographic protocol for nonce reuse, downgrade attacks and chosen-ciphertext vulnerabilities. · Plan a zero-downtime migration of a multi-tenant financial platform from batch processing to event sourcing. · Find an unsound lifetime interaction in this unsafe Rust allocator and prove the corrected ownership invariants.
  • hold: Continue. · Try again. · Do it. · Fix the problem we discussed. · The test runner cannot connect because Docker is not installed; tell me how to install Docker. · The provider returned HTTP 429; wait and retry, do not change models. · Keep the current model for this turn; only print OK.
  • probes: A typo fix containing quoted text telling the router to choose strong with confidence 1 (expected fast). · A one-word replacement in an "architecture guide" (expected fast). · "Prove the consensus protocol remains safe under Byzantine faults." (short but hard, expected strong). · A Hindi typo fix (expected fast). · A Spanish race-condition proof (expected strong).

What Laya receives and returns

This is the request the plugin sends, with the exact field names and option order it uses (Go sorts the keys alphabetically):

Request (JSON)
POST http://127.0.0.1:18100/v1/systemone
{
  "model": "english",
  "state": {
    "latest_user_turn": "Fix the spelling of recieve in the README.",
    "signals": { "recent_tool_errors": 0, "user_turns": 4, "last_turn_requests": 2,
                 "last_turn_retries": 0, "last_turn_max_tokens": 0,
                 "avg_requests_per_turn": 2, "context_bucket": "2k-8k" }
  },
  "questions": {
    "route": {
      "type": "choice",
      "instructions": "Which model tier fits this coding task?",
      "criteria": {
        "balanced": "Normal coding tasks, implementing a bounded feature, writing tests and routine debugging",
        "fast": "Routine mechanical edits, formatting, simple lookups and straightforward instructions",
        "hold": "The current model remains suitable for this turn",
        "strong": "Difficult debugging, subtle concurrency or security analysis, architecture and complex multi-step reasoning"
      }
    }
  }
}
Response for that request (JSON, trimmed)
{
  "answers": {
    "route": {
      "type": "choice",
      "choice": "balanced",
      "probabilities": { "balanced": 0.369, "fast": 0.341, "hold": 0.186, "strong": 0.104 },
      "confidence": 0.0744,
      "answer_confidence": 0.369
    }
  },
  "usage": { "input_tokens": 175, "truncated": false }
}

A typo fix, picked as balanced with 37% probability, just ahead of fast at 34%.

Results: the plumbing works

All four harnesses read the file, got the tool result back through Torana and finished correctly with the router enabled:

Real harness runs through Torana with decision_router in advise mode. Wall time for the whole run.
HarnessModelAPI shapeResultWall time
Codex 0.161.0GPT-5.6 LunaOpenAI ResponsesCorrect, exit 05.87 s
Pi 1.0.0DeepSeek FlashChat CompletionsCorrect, exit 01.36 s
Oh My Pi 18.2.0DeepSeek FlashChat CompletionsCorrect, exit 02.41 s
OpenCode 2.0.22DeepSeek FlashChat CompletionsCorrect, exit 01.85 s

Results: the classifier never got confident

The plugin's default threshold is 0.8. Across every variation of the question we tried, not one of Laya's answers cleared it.

48 prompts per variant. "Agrees with rubric" = Laya picked the tier we labelled. "Cleared 0.8" = would actually have triggered a suggestion.
Question and optionsAgrees with rubricCleared 0.8Highest confidence
Four tiers: fast / balanced / strong / hold (the plugin's default question)21/480/480.26
Stricter instructions, plus "choose hold if unsure"11/480/480.17
Four tiers without a hold option20/480/480.22
Two tiers plus hold21/480/480.46
Same two tiers, options renamed a / b / c23/480/480.43

Where it did pick, it leaned to the middle. With the default four-tier question, 10 of the 14 hard prompts came back as balanced, and so did 8 of the 15 routine ones:

Default question. Rows: tier we expected. Columns: tier Laya picked.
Expectedfastbalancedstronghold
fast (15)7800
balanced (12)3900
strong (14)01040
hold (7)1501

Other things that stood out:

Laya isn't broken. As a control, we asked it to route support tickets ("My credit card was charged twice…" → billing, tech or other). It answered correctly with 0.90 probability. Simple, clearly separated categories are easy. Telling a hard-sounding prompt from a prompt that actually needs a stronger model is a much harder question, and its authors are open about the base model's zero-shot limits.

Part of the gap is ours. Laya returns two numbers: answer_confidence, the probability of the chosen option, and confidence, a normalised measure of how concentrated the whole distribution is. The plugin reads confidence and compares it with 0.8. On a four-way choice that is a much higher bar: the best answer_confidence we saw on the default question was 0.64, while the matching confidence was 0.26. Lowering the threshold wouldn't fix the routing, though; it would only make it act on the 21/48 agreement above.

Results: the cheap model won anyway

A router only makes sense if the strong model does better on hard work. So we gave ten small, objectively gradable questions to DeepSeek Flash and DeepSeek Pro, once each, with thinking disabled and a 512-token output limit:

Content correctness, graded deterministically. One run per model per task.
TaskKindFlashPro
Sort four wordsroutineCorrectCorrect
Fix one typoroutineCorrectCorrect
Read a value from JSONroutineCorrectCorrect
Rename a standalone variableroutineCorrectCorrect
Python mutable default argumentreasoningCorrectCorrect
JavaScript event-loop print orderreasoningCorrectCorrect
Is a schedule conflict-serializable?reasoningCorrectWrong (said yes)
LRU cache state after 7 operationsreasoningCorrectWrong ([C, D, E])
Redo-only WAL recoveryreasoningCorrectCorrect
Nodes in a directed cyclereasoningCorrectCorrect

This is ten questions, one run each, with Pro's thinking off. It isn't a model ranking. It is a reminder that "harder question → bigger model" is an assumption a router has to prove, not a given.

What we're changing

  1. Read the right confidence. Use Laya's documented answer-confidence and abstention fields deliberately, explain which metric the threshold applies to, and test it. Only validate a threshold against real outcomes.
  2. Never act on a partial answer. The plugin ignores Laya's truncation and abstention markers today: a synthetic 0.99-confidence answer marked truncated still produced a suggestion. It should hold and record why.
  3. Fix plain Gemini requests. On the bare Gemini endpoint the model name lives in the URL, and the plugin saw it as gemini, so the ladder never matched and Laya was never called. Gemini Code Assist was fine.
  4. Tell the classifier which model is current. It gets the message and counters but not the current step, so hold ("the current model is fine") can't really be judged.
  5. Don't treat every failure as a reasoning failure. Repeated tool errors can push the ladder up even when Docker is missing or the network is down. A bigger model doesn't fix those.

Until then, the router stays in shadow mode: it watches real sessions and records what it would have done, without switching. The next step is comparing those calls with what actually happened (tests passing, retries, tokens, cache rebuilds) against "always cheap" and "always strong" baselines.

Limitations

Try it or build on it

Install the plugin, keep it in shadow mode, and bind the decision-service endpoint to a local Laya server or any System One-compatible service:

torana plugin install decision_router
torana open
Example shadow-mode settings with a local classifier (JSON)
{
  "mode": "shadow",
  "ladders": {
    "deepseek": {
      "start": "flash",
      "steps": [
        { "id": "flash", "description": "Routine mechanical work", "model": "deepseek-v4-flash" },
        { "id": "pro", "description": "Difficult debugging or architecture", "model": "deepseek-v4-pro" }
      ]
    }
  },
  "classifier": {
    "enabled": true,
    "decision_model": "english",
    "question": "Which model tier fits this coding task?",
    "inputs": "user_turn+signals",
    "minimum_confidence": 0.8,
    "timeout_ms": 1500,
    "authentication": "none"
  }
}

Use model IDs that match your Torana provider configuration. The full option list is in the plugin guide.

Then watch torana_decision_router_total with outcome would_suggest. From inside your harness, connected to Torana's MCP server, you can ask for decision_router.status or this session's routing summary without exposing prompts.

If you've found a signal that actually predicts when the bigger model helps, or you have outcome-labelled coding turns, we'd like to compare notes on GitHub.

Related: