~/satyajit

switch: the agent loop for a model that cannot write a tool call, and what it does when the model is wrong

mdjsonmcp

2026-10-31 · 13 min · agents · decision-models · evals

Why read this

Solidtop 85%

Reads all of switch's core and runs it: 71 tests pass, eleven bad deciders each end a run in a named way, and a request costs 132 to 347 tokens on the toy.

  • Facts measured from source
  • Runs on a laptop CPU
  • Explained from first principles

Agents & harnessesMITPractitioner tool

How this was scored
Is it new?
1 of 3: An incremental tweak
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
1 of 3: General advice
Will it last?
1 of 3: Relevant for months
Does it affect many?
1 of 3: A specialist community
Only here?
1 of 3: Some original analysis

Score 49 of 100, ranked 553 of 666 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

@onusoz posted switch as "a harness framework for decision models like Jev", and the README opens with the same sentence. I have written a lot on this site about decision models themselves (Jev, Agr, d3-turbo, what they cannot do) and about five ad hoc harnesses in Five Jev harnesses, for a model that cannot write. switch is the first repository I have seen that tries to be the one harness behind all of them. So this piece does the thing the others did not: read all of the core, run it, and push bad answers into it.

Labels used throughout: measured is something I ran this session. Reported is a number from the repository's own docs. Reasoned is inference from code I read.

osolmaz/switch@7c5b6b9 · snapshot 2026-10-31
tracked files
219
license
MIT
branch
main
tests
43 files
source
589.6 kB
commit date
2026-10-30
source by language
TypeScript568.1 kB(134)CSS12.8 kB(2)JavaScript8.7 kB(5)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-10-31 at 7c5b6b9 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

What switch is

switch is MIT-licensed, version 0.1.1, and needs Node 22.18 or newer. The part you import, src/, is measured at 2,134 lines of TypeScript across core, cli, models and testing. The loop is src/core/loop.ts (338 lines), the pick helper is src/core/pick.ts (127), and the model adapter for pi-ai is src/models/pi-ai.ts (101). The repository also holds three example domains (chess, wikirace, a rubric judge) and a browser viewer, which the core does not depend on.

The README states the design in one paragraph. A decision model "answers typed questions with probabilities and writes no text, so it cannot generate the tool calls that a coding agent harness such as Pi relies on. switch generates the tool calls for it in code, and the model only picks one from that finite set." That sentence is the whole idea, and the code does exactly that.

Who writes what

In a chat-model coding agent the model produces the action: a tool name and its arguments, as text, which the harness then parses. Everything that can go wrong with an action (a tool that does not exist, a malformed argument, a path the model invented) goes wrong in that text. switch moves the writing of the action out of the model. Before the model is called, your code has already decided what the legal actions are and has already filled in their arguments. The model's only contribution is a probability for each label.

Reasoned: this is why the framework fits a narrow job and fits badly a general one. A decision about "which of these 30 legal chess moves" is a menu. "Write the SQL for this report" is not, and no choice function you could write lists every query. For where decision models themselves fit, see Where to use Jev.

The loop, as written

The domain supplies a StateMachine: an initial() state, a questions(state) function, a transition(state, answers) function, and optionally a view(state, tokens, charsPerToken). A state is plain JSON. Questions are typed bool, choice or score, the same three that pi-ai's classifier interface offers, so a request written for switch also runs through the llama.cpp decision-model server or any other backend pi-ai lists.

takeSteps in loop.ts is short enough to hold in your head:

  1. Ask machine.questions(state). If the result is empty, the run ends as done.
  2. If a step limit is set and exceeded, end as step limit.
  3. Ask the model (or take the answer from a recorded trace, which is what replay does).
  4. If anything failed, end as failed with a reason string.
  5. Call machine.transition(state, answers) and record the new state. Repeat.

Measured: I ran pnpm install on the root package and vitest run. All 71 tests in 8 files pass in about 4 seconds (vitest 5.0.5, Node 22.22.0). I did not install the examples' workspaces, so their tests are not in that count.

A pick is one choice question with short labels

Most domains ask one thing per step: which action next. picking() in pick.ts builds that question. Each choice has a unique key and a unique text; duplicates throw a ChoiceError before the model is called. The model sees labels o1, o2, and so on, each next to its text. The mapping from label back to key lives in the question's data field, which sendable() strips from the request, so the model never sees keys. This matters for chess, where a move key is a long algebraic string: the model reads a short label and a short text and the domain translates back.

Labels are rebuilt on every step. After o1 meant "Read log1.txt" at step one it means "Read log2.txt" at step two. The model has no stable name for an action across steps, so the only state it can use is what the view shows it.

A worked trace

I took the README's ticket-triage domain verbatim (read a log, or stop, then choose a team), gave it eight synthetic 200-character logs, and ran it against a scripted decider that reads three logs, picks stop, then answers "infra". Step through what the loop did:

Measured: five steps, ended done, 18 trace entries, 15.9 ms of loop time for the whole run. The estimated request grew from 132 to 347 tokens as the view accumulated logs, then dropped to 314 on the final question, whose menu has three options instead of six. The estimate is switch's own: estimateTokens is the JSON length of the request divided by three. It is a safety number, not a tokenizer.

What a request costs

Two things set the size of a request: the view (your state, shaped by your view function) and the menu (the options in the pick).

Measured: with the view holding no logs, a six-option menu estimated at 111 tokens and a twenty-option menu at 216. That is a slope of 7.5 tokens an option over a base of about 66. Two points on a domain with short option texts is a fit, not a law; the widget lets you slide it but it will undersell a domain whose options are paragraphs.

Measured: with 50 logs, the first request showed 20 of 51 options. The default limit is set by the domain; the README's triage uses 20 and the legal judge, per its docs, uses 30. A cut keeps the choices of lowest priority and stop is given priority -1, so the way out of the loop is never the thing that gets cut. A cut option is not lost, since reading one changes the state and the next menu is drawn from what remains. In my run, an always-first-label decider read 20 logs in 20 steps and hit the domain's step limit, never seeing logs 21 to 50 in a single menu.

Reported: the repository's first legal-judge run (docs/2026-10-25-first-legal-judge-run.md) made 319 calls with a median of 0.29 s each and 3.2 million input tokens in total, so about 10,000 input tokens a call. It reports the cost at another provider's listed Jev price of $0.042 per million input tokens as about $0.13 for 100 answers. I did not rerun this; it needs the TypeSafe API and the private answers.

Measured: loop overhead. I ran 500 steps of the triage domain with a fake decider that answers instantly, over 2,000 logs, and the whole run took 1,849 ms, 3.7 ms a step. That includes JSON-stringifying the request for the size estimate and storing every full state in the trace, and the state grows by one log each step, so this is an upper bound for a domain with a flat state. Next to a 0.29 s median model call, the framework is about 1% of a step.

When the decision model is wrong

This is the part I cared about. In a chat harness a bad action is text, and the harness spends most of its code deciding what to do with text that does not parse. In switch the model's output space is already a closed set, so there are only a few ways for it to be wrong. I wrote eleven deciders, each returning something specific, and ran each against the triage domain.

Six of the eleven runs ended failed with a named reason; the rest finished, hit the step cap, or threw. What the loop does, by row (measured, with the code that does it read):

Reasoned: the failure surface is much smaller than in a chat harness, and what remains is infrastructure failure (the API, the window) rather than model failure. The one thing the framework cannot detect is a valid wrong answer. An always-last decider stops at once and picks the last team, and the loop is content with that.

Traces, replay and forks

Every run writes a trace: each state, each question set, each answer with its probabilities. replay runs a case again from a trace without asking the model, and --fork n repeats the first n steps and lets a model carry on. The loop code shows how: when a replay is active, answersAt takes the recorded step and fitted() checks it against the questions the domain asks now, matching a pick's answer by choice key rather than by label, so a menu reordered since the recording still resolves. If the domain changed its definition, the run notes it and carries on.

Reasoned: replay plus fork is the most useful property for someone building a domain. You can change a choice function, replay yesterday's trace to see where the new menu differs, and only pay for the model from the fork point on. I read this path and the replay tests pass; I did not run a replay on a real trace.

Against a coding-agent harness

A coding agent like Codex or Pi hands the model a transcript and a set of tools, lets it write a tool call, runs it, and appends the result. The loop is open-ended and the model does the planning in text. switch has the same shape (observe, choose, act, repeat) but a different split of labour:

What I did not run

What to take from it

If your task is a finite menu repeated over a state (route a ticket, move in a game, choose the next document to read), switch is a clean place to start: about 2,100 lines, a loop you can read in an afternoon, a trace and a replay that make debugging a domain tractable, and failure modes that stop loudly instead of continuing wrongly. If the task needs the model to write something, none of it applies.

The number I would carry away is the overhead one. The framework costs about 3.7 ms a step in my measurement, and the model call costs roughly eighty times that in the repository's own report. The interesting engineering is all in your choice function and your view, which is where the repository's docs spend their effort too.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "switch: the agent loop for a model that cannot write a tool call, and what it does when the model is wrong", ai.thesatyajit.com, October 2026.

bibtex
@misc{ghana2026switchdecisionharness,
  author = {Satyajit Ghana},
  title  = {switch: the agent loop for a model that cannot write a tool call, and what it does when the model is wrong},
  url    = {https://ai.thesatyajit.com/articles/switch-decision-harness},
  year   = {2026}
}
share