From 7cb142c210b540493cd599b90fa2fd670d75d217 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:10:26 -0400 Subject: [PATCH 01/22] =?UTF-8?q?provider,=20config,=20tui3:=20a=20fourth?= =?UTF-8?q?=20routing=20mode,=20simple=20=E2=80=94=20bare=20to=20OpenRoute?= =?UTF-8?q?r=20unpinned,=20exactly=20the=20named=20provider=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `routing: simple` the chooser, the routing gate, the hedge arms, the probes and silent pin retirement all stay out of the request path. No pin sends no provider object at all; a pin sends {only:[X], allow_fallbacks:false} and nothing else. The machinery is disconnected, not deleted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1016-simple-routing-mode.md | 16 +++++++ internal/config/settings.go | 16 +++++-- internal/manual/chat/commands.md | 7 ++- internal/manual/chat/lanes.md | 46 +++++++++++++++++-- internal/manual/chat/models-and-cost.md | 19 ++++---- internal/manual/chat/screen.md | 2 +- internal/provider/lanes.go | 21 +++++++++ internal/provider/probe.go | 7 +++ internal/provider/simplemode_verify_test.go | 41 +++++++++++++++++ internal/provider/velocity.go | 31 ++++++++++++- internal/tui3/settings.go | 6 ++- 11 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 docs/changes/unreleased/1016-simple-routing-mode.md create mode 100644 internal/provider/simplemode_verify_test.go diff --git a/docs/changes/unreleased/1016-simple-routing-mode.md b/docs/changes/unreleased/1016-simple-routing-mode.md new file mode 100644 index 0000000000..c5dcc6dcbb --- /dev/null +++ b/docs/changes/unreleased/1016-simple-routing-mode.md @@ -0,0 +1,16 @@ +--- +kind: added +title: a fourth routing mode, `simple`, sends exactly what you asked for and nothing else +pr: 1016 +surface: [engine, chat, docs] +invalidates: + - "The `routing` row had three answers — `latency`, `price`, `off` — and the settings hint, the lanes page and the Providers-tab list all said so. It has four: `simple` sits between `price` and `off` in the cycle, and with no lane pinned the request carries NO provider object at all, so OpenRouter's own default routing answers and aforge does no measuring, no hedging and no re-ranking of its own." + - "`off` was the only way to send no preference, and it costs the lane sheet, the pin rows and the speed guard because nothing is measured. `simple` keeps the pin: a lane a person named goes out as a strict demand (`only`, fallbacks off) with nothing else on the request, and a pin written `borrow when slow` collapses to strict because no rescue runs for it to borrow." + - "The lanes page's machinery — the takeover, the closed set, the refusal walk, the probe — read as what aforge does while it is choosing. It is what `latency` and `price` do; under `simple` the routing gate, the belief chooser, the hedge and the probes stay compiled in but are disconnected from the request path, and none of it runs." +--- + +The provider-choosing stack is not deleted, it is disconnected: every layer still +compiles and still answers the other three modes, and under `simple` the row a person +wrote is the whole algorithm — no pin means no provider object and the router's own +default, a pin means exactly that machine. Pin retirement on a terminal refusal is +unchanged, and pinning again after one puts the pin straight back. diff --git a/internal/config/settings.go b/internal/config/settings.go index 47c0768c1a..23d0182a4e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -711,7 +711,7 @@ var BackgroundModes = []string{BackgroundOn, BackgroundOff} // DefaultBackground is on. const DefaultBackground = BackgroundOn -// The routing row's three answers. They are spelled here rather than imported +// The routing row's four answers. They are spelled here rather than imported // from internal/provider for the reason [DocumentEngines] is: a settings key's // vocabulary is a string on disk, and it must not change because a package // renamed a constant. @@ -720,6 +720,10 @@ const ( RoutingLatency = "latency" // RoutingPrice asks for the cheapest one that can serve the request. RoutingPrice = "price" + // RoutingSimple sends no preference of ours at all: when no lane is + // pinned the router's own default answers, and a pinned lane is the + // whole request. + RoutingSimple = "simple" // RoutingOff sends no preference at all, and stops measuring with it. RoutingOff = "off" ) @@ -727,7 +731,7 @@ const ( // RoutingModes lists them, latency first — which is also the default: a chat // session is a person waiting, and the endpoint that answers soonest is the one // they are asking for. -var RoutingModes = []string{RoutingLatency, RoutingPrice, RoutingOff} +var RoutingModes = []string{RoutingLatency, RoutingPrice, RoutingSimple, RoutingOff} // DefaultRouting is latency. const DefaultRouting = RoutingLatency @@ -1930,9 +1934,11 @@ func (s *Settings) build() []Setting { "cheapest for work you are not waiting on: task workers, judges, titles, the " + "memory pass. Choosing here overrides that everywhere: latency asks for the " + "fastest one for everything and times every answer, demoting an endpoint that " + - "keeps being slow; price asks for the cheapest for everything; off asks for " + - "nothing and measures nothing — and with nothing measured there is no lane " + - "to choose, no sheet of them to open and no speed guard. A change lands on " + + "keeps being slow; price asks for the cheapest for everything; simple sends " + + "no preference of ours at all — with no lane pinned the router's own default " + + "answers, and a pinned lane is the whole request; off asks for nothing and " + + "measures nothing — and with nothing measured there is no lane to choose, " + + "no sheet of them to open and no speed guard. A change lands on " + "the next session.", read: func() string { return RoutingAt(dir) }, write: func(raw string) error { return writeChoice(dir, KeyRouting, raw, RoutingModes) }, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 79894361f0..08485d54d3 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1697,8 +1697,11 @@ order: what has been measured of each, and enter on one pins it. 3. **speed guard** — whether an answer slow to start is asked of the next-best machine as well. -4. **routing** — what every request prefers among the endpoints: `latency`, `price`, `off`. - With `off` nothing is measured, so the two rows above it have no machine to name. +4. **routing** — what every request prefers among the endpoints: `latency`, `price`, + `simple`, `off`. With `off` nothing is measured, so the two rows above it have no + machine to name; `simple` sends no preference of aforge's own either, but a lane you + pinned still goes out — the pin is the whole request, and with no pin the router's + own default answers. 5. **prompt profile** — how much aforge tells the model before you type: `auto`, `lean`, `full`. Another cycle row. `auto` reads the model's context window and goes lean under 32,000 tokens (see *Models, context, and what it costs*). diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index 921f14b1b9..18442042d5 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -77,7 +77,12 @@ rather have the router's price balance than be rescued from its bad minute. The rest of this page — the closed set, the refusal walk, the probe — describes what happens while aforge is choosing: during a takeover, and whenever you have -pinned a lane yourself. +pinned a lane yourself. All of it is written about the routing the row ships +with. With `routing` at `simple` none of it runs — no takeover, no ranking, no +measuring — because that mode sends exactly what you asked for and nothing +else; *How do I stop aforge choosing the provider itself* below has it. With +the row at `off` the choosing stops too, and the last section says what that +leaves standing. **A run started from a terminal is routed on the same terms.** `aforge do`, `aforge run` and `aforge plan run` open no conversation and draw no status line, and they used to @@ -843,6 +848,37 @@ A directly connected service is simpler: it has one lane, so there is nothing to between and no lane sheet to open. That is not a fault. The service name carried by the model id is already the whole route. +## How do I stop aforge choosing the provider itself — the simple routing mode, OpenRouter's default routing, and what my pinned lane still sends + +The `routing` row (`/settings` → **Providers**) has a fourth answer, **`simple`**, +for exactly this. Under it aforge keeps no opinion of its own about the machines +behind your model, and sends none: + +- **No lane pinned** — the request carries no routing preference at all: no sort + word, no price ceiling, no machines named or excluded. OpenRouter's own default + routing picks the endpoint, exactly as it would for a request aforge had never + touched. There is no measuring, no second request hedged alongside yours, not + even the one-token measurement sent while you type — and no takeover when + answers come back refused: the bad-minute rescue this page describes does not + run here, and neither does its cost. +- **A lane pinned** (`/model @deepseek`, or enter on the **lane** row) — the + request demands exactly that one machine: `only`, fallbacks off, and nothing + else rides along. Your word is the whole request. A pin written `borrow when + slow` changes nothing here — there is no rescue running for it to borrow. + +What does not change: the machine that answered is still named on the status +line, and the `switch to auto?` question a slow pinned lane asks still has +somewhere to send you. A pin the router itself refuses — the machine saying it +cannot serve that model at all — is still retired for that model, with the same +one-sentence note, and pinning again puts it straight back on the very next +request. + +`simple` is not `off`. `off` stops the measuring, and with nothing measured +there is no lane to choose, no sheet of machines to open and no speed guard. +`simple` leaves the pin standing: the one instruction you gave is the only one +sent. Left alone the row is still `latency`, and everything the rest of this +page describes is what it does. + ## Turning lane routing off Set routing off (`/settings`, or the `routing` row) and aforge sends every @@ -851,15 +887,17 @@ ceiling on how long a silence runs before *something* is said about it is not steering, it is the promise this surface makes — but it stops choosing endpoints for you, stops sending second requests, and stops spending anything on speed. -**The row has three answers, not two, and the third is not off.** Left alone, aforge asks +**The row has four answers, and the two quiet ones are not the same nothing.** Left alone, aforge asks for the fastest machine on the turns you are waiting through, and on the work you are not watching it still weighs speed, at a quarter of that weight — a task ends when its slowest call ends, and a machine that refuses four requests in five costs five sends for one answer, so its seconds are never free. That is the split the rest of this page describes. Writing a word in the row overrides that everywhere: `latency` asks for the fastest one on every call, background work included; `price` ranks on price alone on every call, your own turns included, which -is you saying that speed is not worth money anywhere; and `off` is the paragraph above. -`price` still measures machines and still chooses between them. `off` stops the choosing. +is you saying that speed is not worth money anywhere; `simple` sends no preference of +aforge's own at all — your pin, if you made one, is the whole request (the section above); +and `off` is the paragraph above. +`price` still measures machines and still chooses between them. `simple` and `off` stop the choosing. **`off` does not stop the remembering, and that is deliberate.** aforge still writes down which machine answered and which one refused, because that is what lets a request that diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 299d6a1d62..95ba33ec23 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1188,10 +1188,10 @@ number of tries (see *How long aforge keeps trying*). **Sometimes it moves after two attempts instead of three.** Three attempts are worth making only when they can reach *different* endpoints. If the stream died before naming -which endpoint served it, or you have set `routing` to `off` on the **Providers** tab, then +which endpoint served it, or you have set `routing` to `off` or `simple` on the **Providers** tab, then nothing is being routed around and the next attempt lands in exactly the same place — so aforge stops asking and moves to the next model a try earlier. Setting `routing` to `off` -switches off **endpoint** steering; it does not switch off moving to another model. +or `simple` switches off **endpoint** steering; it does not switch off moving to another model. ## Was I charged for a reply that got cut off — money on a stream that was cut, stopped, or lost the race @@ -1727,7 +1727,7 @@ or `off`, and the default is **on**. Off means you see whatever arrives, and kee you stop. You can also just ask aforge to turn it off; it is not one of the rows it refuses. The two clocks in the section above have no switch — a request that produced nothing at all has failed by any -reading. Setting `routing` to `off` on the same tab stops aforge steering between endpoints +reading. Setting `routing` to `off` or `simple` on the same tab stops aforge steering between endpoints at all, and with it stops any of this being recorded. ## I stopped a reply and the text is gone — where the reply went after I hit esc, and why pressing escape on a broken reply deletes it @@ -2609,7 +2609,7 @@ Where a model publishes no price, no cap is sent at all rather than one guessed **One thing about background work is not quite "speed is worth nothing".** Work you are not watching still asks the router for the cheapest endpoint — that part is unchanged — but among the machines behind that model, aforge will pay a little for a quicker one **while your window is open**, because you are there to read what the work lands. With no window open on this machine it will not: the cheapest machine wins outright, however slowly it writes. Nothing about this is a setting; it follows whether you are here. -Setting **routing** yourself overrides all of that everywhere: `latency` asks for the fastest endpoint (still under the price cap) for every call including background work, `price` asks for the cheapest for every call including your own turns, and `off` sends no preference and stops timing endpoints. A change lands on the next session. +Setting **routing** yourself overrides all of that everywhere: `latency` asks for the fastest endpoint (still under the price cap) for every call including background work, `price` asks for the cheapest for every call including your own turns, `simple` sends no preference of aforge's own at all — with no lane pinned the router's own default routing answers, and a pinned lane is demanded exactly (`only`, no fallbacks) with nothing else on the request — and `off` sends no preference and stops timing endpoints. A change lands on the next session. **You can also name the endpoint yourself.** routing says what a request prefers; the **lane** row above it, and `→` on a row in the model picker, say which provider requests from your home actually go to — see "choose a provider" above. @@ -2877,7 +2877,7 @@ token out, twice. **How often.** At most one pair every **twenty seconds** per model, however fast you type — so a long message buys one, not one per keystroke. None at all when the **speed -guard** is off, when `routing` is `off`, when the lane row says `openrouter`, when the +guard** is off, when `routing` is `off` or `simple`, when the lane row says `openrouter`, when the pool is already backing off a rate limit, when aforge is still recovering a dropped connection, or when **nobody is waiting on that model** — a task working on its own and an errand buy none, because the measurement exists to shorten a wait somebody is sitting @@ -2916,7 +2916,7 @@ probes. That file is the record of what was sent. **How to make it zero.** Settings → Providers → **speed guard**, off. The same row governs asking a second machine when an answer is slow to start, so turning it off stops both. -`routing off` and a lane row set to `openrouter` also stop it. +`routing off` or `routing simple`, and a lane row set to `openrouter`, also stop it. ## The lane row in settings — auto, pinned, pinned but borrowable, openrouter @@ -2944,7 +2944,10 @@ The **your model** row says which lane is answering it beside the model id — ` (cloudflare now)` while the choice is aforge's, `pinned: cloudflare` once it is yours. `lane` and `routing` are different questions: routing is what every request **prefers** (fastest, cheapest, or nothing at all), and lane is which endpoint requests from your home -actually land on. +actually land on. Under `simple` routing the borrow rung is moot: the pin goes out +strictly — that one machine, `only`, fallbacks off, nothing else on the request — because +simple runs no choosing of its own for a slow answer to borrow. The `switch to auto?` +question a slow pinned lane raises still has somewhere to send you. ## Why does the same conversation suddenly cost more? Keeping the prompt cache warm @@ -2963,7 +2966,7 @@ The same stable identity also travels in OpenRouter's session header so a succes **Answering a question does not cost the cache.** What sits in front of every message — the instructions, the folders you attached, your standing orders, the newest few decisions — is re-sent unchanged on every request, and one changed byte in it re-prices the whole conversation at full price. Answering a question used to change it, so every `allow once` on a tool bought that re-send on the very next message. It does not any more: the decision is written to the record on disk, the model reads the answer in the result that comes back to it, and the copy in front of the conversation is brought up to date only when something else there moves anyway — a folder attached, a standing order agreed. -Each of your conversations keeps its own endpoint, and so does each worker on a task, because each of them is sending a different transcript. Background work is kept warm the same way: its first request still asks for the cheapest endpoint, and after that it comes back to whichever one answered. Setting **routing** to `off` turns this off with everything else. +Each of your conversations keeps its own endpoint, and so does each worker on a task, because each of them is sending a different transcript. Background work is kept warm the same way: its first request still asks for the cheapest endpoint, and after that it comes back to whichever one answered. Setting **routing** to `off` or `simple` turns this off with everything else. ## A model that cannot stop thinking — what turning thinking off does on it, and why some models think at "max" by default diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index f94ef937fb..6dde6bd68b 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -3449,7 +3449,7 @@ are the provider's own `Retry-After` rather than anything aforge chose. **No arrow is drawn unless both halves are real** — a moment, and something that really happens at it. So there is no countdown when there is no other machine to go to: a home -whose lane row pins one machine, `routing` set to `off`, an endpoint that is not a router, +whose lane row pins one machine, `routing` set to `off` or `simple`, an endpoint that is not a router, or a model with only one machine behind it. You still get the phase and the count-up, which are true, and no promise, which is the point — aforge would rather show you nothing than a countdown that expires and does nothing. diff --git a/internal/provider/lanes.go b/internal/provider/lanes.go index dc5ebc49e9..0ef0fb2dc6 100644 --- a/internal/provider/lanes.go +++ b/internal/provider/lanes.go @@ -506,6 +506,27 @@ func (c *Client) drawLaneChoice(knobs callKnobs, model string, request *ai.Reque if strategy == RoutingOff { return lanes.Choice{}, false } + // SIMPLE ROUTING ASKS THE ROW AND NOTHING ELSE. A pin — strict or + // borrowable, the difference is a rescue this mode does not run — is the + // one instruction that reaches the wire: the demand for the machine it + // names, with the frontier still drawn so a stall has somewhere to offer + // to go. Every other reading of the world (the routing gate, the belief's + // own ranking, the account's exclusions) stays computed by the packages + // below and is simply never asked, because under this row the person is + // the algorithm. No pin — or one the wire has retired for this model — + // means no choice at all: the request goes out with no provider object + // and the router's own default answers, and with no choice on the context + // nothing downstream hedges either ([Client.raceFor] reads the same + // absence). + if strategy == RoutingSimple { + named := pin.pinned() + if named == "" || retired { + return lanes.Choice{}, false + } + ask := c.laneRequest(model, knobs, request, c.laneValueOfTime(knobs)) + choice := lanes.Default().Chooser().Choose(ask) + return lanes.Choice{Only: []string{named}, Frontier: choice.Frontier, Pinned: true}, true + } // `auto` IS OPENROUTER'S ROAD UNTIL THE ROUTER LETS GO OF IT (routefirst.go). // A gate per model counts the refusals and the answers that came back // unusable; while it says the router is serving this model, there is no diff --git a/internal/provider/probe.go b/internal/provider/probe.go index 163578fe9b..13a1ccf91e 100644 --- a/internal/provider/probe.go +++ b/internal/provider/probe.go @@ -242,6 +242,13 @@ func (c *Client) ProbeLanes(ctx context.Context, model string) { if c == nil || !c.carriesPreferences() || c.connectionWaiting() { return } + // SIMPLE ROUTING BUYS NO MEASUREMENTS. A probe exists to teach the belief + // which machine to choose, and under this row choosing is the person's + // row and nothing else — so a probe would spend real money on an answer + // no request will ever read. + if c.routing() == RoutingSimple { + return + } model = strings.TrimSpace(model) if model == "" { return diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go new file mode 100644 index 0000000000..f5bd6746ea --- /dev/null +++ b/internal/provider/simplemode_verify_test.go @@ -0,0 +1,41 @@ +package provider + +import ( + "strings" + "testing" +) + +// THROWAWAY VERIFICATION for the simple routing mode: what the wire carries +// when the row reads `simple`. Kept as a law because the mode's whole promise +// is the shape of the request — bare without a pin, the pin's demand and +// nothing else with one. +func TestSimpleModeSendsOnlyThePersonsPin(t *testing.T) { + server := prefRig(t) + client := plainBase(t, server.URL(), server) + client.config.Routing = StaticRouting(RoutingSimple) + + // No pin: the request carries no provider object at all, and nobody + // hedges — one ask per turn. + if _, err := client.CompleteWithMessages(talking(), userMessages("hello")); err != nil { + t.Fatalf("unpinned simple turn failed: %v", err) + } + // A pin: the demand is the whole object. + pinned(t, LanePin{Lane: "Harbor"}) + if _, err := client.CompleteWithMessages(talking(), userMessages("again")); err != nil { + t.Fatalf("pinned simple turn failed: %v", err) + } + + asks := server.Asks() + if len(asks) != 2 { + t.Fatalf("two turns produced %d asks, want exactly 2 (no hedging)", len(asks)) + } + bare := asks[0] + if bare.Sort != "" || bare.Only != nil || bare.Order != nil || bare.Ignore != nil || bare.MaxPrice != nil || bare.NoFallbacks { + t.Fatalf("simple with no pin carried %+v, want a bare request", bare) + } + demand := asks[1] + if len(demand.Only) != 1 || !strings.EqualFold(demand.Only[0], "Harbor") || + !demand.NoFallbacks || demand.Sort != "" || demand.MaxPrice != nil || demand.Order != nil { + t.Fatalf("simple with a pin carried %+v, want only Harbor and fallbacks off", demand) + } +} diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index 7fa08dc936..ca5a36e687 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -57,6 +57,14 @@ const ( // it: an operator who has not asked to be routed has not asked to be // measured either, and a demotion nobody can act on is only overhead. RoutingOff RoutingStrategy = "off" + // RoutingSimple sends exactly what the person asked for and nothing else. + // No lane pinned: the request carries NO provider object at all and the + // router's own default routing answers — no belief, no sort word, no price + // ceiling, no hedge. A lane pinned: the request carries that one demand + // (`provider.only`, fallbacks off) and nothing with it. Every other layer + // of the routing stack stays compiled in but is disconnected from the + // request path, so the row a person wrote is the whole algorithm. + RoutingSimple RoutingStrategy = "simple" ) // sortWord is the strategy as the router spells it, empty when no preference @@ -65,7 +73,9 @@ func (s RoutingStrategy) sortWord() string { switch s { case RoutingPrice: return "price" - case RoutingOff: + case RoutingOff, RoutingSimple: + // Simple never asks by sort word: its only preference is a person's + // own pin, which is a demand and not a ranking. return "" default: return "latency" @@ -146,6 +156,8 @@ func ParseRoutingStrategy(word string) (RoutingStrategy, bool) { return RoutingPrice, true case string(RoutingOff): return RoutingOff, true + case string(RoutingSimple): + return RoutingSimple, true default: return RoutingLatency, false } @@ -355,6 +367,23 @@ func (c *Client) providerPreferences(model string, knobs callKnobs) *providerPre return nil } strategy := c.routingFor(knobs.intent) + // SIMPLE ROUTING SENDS THE PERSON'S OWN INSTRUCTION AND NOTHING ELSE. + // With no pin in force there is nothing to ask for: the request goes out + // with no provider object at all and the router's own default routing + // answers, which is the whole of what the row promises. With a pin the + // one demand the choice carries is the whole object — `only` plus + // fallbacks off, applied by [Client.applyLaneChoice] — and the sort word, + // the price ceiling, the ledger's order and the cache pin all stay off + // the wire, because each of them is a second guess beside the one + // machine somebody named. + if strategy == RoutingSimple { + if knobs.laneChoice == nil || len(knobs.laneChoice.Only) == 0 { + return nil + } + prefs := &providerPrefs{} + c.applyLaneChoice(prefs, model, knobs, "") + return prefs + } word := strategy.sortWord() if word == "" { return nil diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 9e84e717b6..8411aadaa1 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -682,8 +682,10 @@ var settingUI = map[string]settingMeta{ config.KeyRouting: { tab: tabProviders, label: "routing", widget: widgetCycle, about: "one model is served by many endpoints. latency asks for the fastest and " + - "demotes one that keeps being slow; price asks for the cheapest; off asks for " + - "nothing, measures nothing, and leaves the two rows above it with no machine to name.", + "demotes one that keeps being slow; price asks for the cheapest; simple sends no " + + "preference of ours — no pinned lane means the router's own default answers, and " + + "a pinned lane is the whole request; off asks for nothing, measures nothing, " + + "and leaves the two rows above it with no machine to name.", }, // AND UNDER IT, THE MACHINE ITSELF. routing is about what every request // prefers; this is about which endpoint your conversation actually lands on. From 197dcc77b37cc5440facd889ab8bb5f215b01b42 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:20:20 -0400 Subject: [PATCH 02/22] tui3: the `auto` row says what the routing in force actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model picker's lane fold told every person the same sentence — `router routes; aforge takes over if answers turn bad` — and named the machine the next turn would land on beside it. Under the `simple` routing row both are false: an unpinned request carries no provider object at all, OpenRouter's own default routing answers it, and the chooser, the gate, the hedge and the probes are all disconnected from the request path. There is no takeover and nothing here can honestly predict who answers. The surface could not have said otherwise, because it kept one boolean reading of that row — `routingOff` — from when the row had three answers and aforge chose under two of them. It keeps the row itself now, and one door turns that row into what `auto` may claim: the sentence, whether a machine may be named beside it, and whether a `no rescue` chip means anything. The picker's row, the settings sheet's `lane` explanation and the `your model` row's `auto (cloudflare now)` tail all read that one door, so the panel and the list cannot say different things about one routing row. The stall offer is untouched and stays honest under `simple`: `coreweave is slow · switch to auto? (y)` asks whether to let go of the pin for that one answer, which is a question and never a promise, and the branch keeps the frontier drawn so a `y` still has somewhere to go. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../1016-simple-routing-picker-row.md | 15 ++++ internal/manual/chat/models-and-cost.md | 17 +++- internal/tui3/app.go | 23 ++++-- internal/tui3/lanes.go | 18 ++++- internal/tui3/lanewalk_test.go | 56 +++++++++++++ internal/tui3/palette.go | 81 +++++++++++++++++-- internal/tui3/settings.go | 31 ++++++- 7 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 docs/changes/unreleased/1016-simple-routing-picker-row.md diff --git a/docs/changes/unreleased/1016-simple-routing-picker-row.md b/docs/changes/unreleased/1016-simple-routing-picker-row.md new file mode 100644 index 0000000000..f70d0eb3c1 --- /dev/null +++ b/docs/changes/unreleased/1016-simple-routing-picker-row.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: the model picker's `auto` row stops promising a takeover the `simple` routing never does +pr: 1016 +surface: [chat, docs] +invalidates: + - "The `auto` row in the model picker's lane fold read `router routes; aforge takes over if answers turn bad` under every routing row. Under `routing: simple` it reads `openrouter's own routing; aforge stays out`, because that mode disconnects the chooser, the gate, the hedge and the probes — nothing on aforge's side takes anything over." + - "That row also named the machine the next turn would land on (`— cloudflare now`), and the settings panel's `your model` row carried the same prediction as `auto (cloudflare now)`. Under `simple` neither is drawn: an unpinned request carries no provider object at all, so the machine that answers is OpenRouter's choice and no prediction here can be honest. The `no rescue` chip is gone there too — there is no rescue under any setting of the speed guard." + - "The settings sheet's `lane` row explained itself with a sentence written beside the row (`auto picks the fastest one each answer`). It has no sentence of its own any more: the panel and the picker both read one door, so the two cannot say different things about one routing row." +--- + +The surface kept a single boolean reading of a row that now has four answers — `routingOff` +— which is why one sentence could be false under the fourth. It keeps the row itself +instead, and everything that says what `auto` DOES asks the one function that turns that +row into a promise. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 95ba33ec23..36e5030cd5 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2707,6 +2707,16 @@ measured it opens all the same, onto the only two honest answers: `auto` and `op From the keyboard alone: `/model @cloudflare` pins, `/model auto` un-pins. +**Under `routing: simple` the `auto` row says something else, because it does something +else.** It reads `openrouter's own routing; aforge stays out`, and it names no machine +beside it — under that row nothing on aforge's side chooses, so there is no machine it +could honestly say the next turn will land on, and no `no rescue` note either, because +there is no rescue running under any setting of the speed guard. The fold still opens and +`enter` still pins: a pin is the one instruction that mode sends. The **lane** row in +`/settings` is explained the same way, and the `auto (cloudflare now)` tail on the **your +model** row is gone with it — it was a prediction, and under `simple` nobody here is +predicting. + **A model nobody has measured opens onto its two answers and no machines.** `→` shows `auto` and `openrouter`, and in the machines' place one line — `no machine has been measured for this model yet — machines show up after its first answer` @@ -2947,7 +2957,12 @@ The **your model** row says which lane is answering it beside the model id — ` actually land on. Under `simple` routing the borrow rung is moot: the pin goes out strictly — that one machine, `only`, fallbacks off, nothing else on the request — because simple runs no choosing of its own for a slow answer to borrow. The `switch to auto?` -question a slow pinned lane raises still has somewhere to send you. +question a slow pinned lane raises still has somewhere to send you — it asks whether to +let go of the pin for that one answer, and asking is all it ever does. The `auto` row of +the table above is the other rung that reads differently there: under `simple` nothing +takes over, so the row says `openrouter's own routing; aforge stays out` in the fold and +the **your model** row drops its `auto (cloudflare now)` tail rather than name a machine +nobody chose. ## Why does the same conversation suddenly cost more? Keeping the prompt cache warm diff --git a/internal/tui3/app.go b/internal/tui3/app.go index bc7c3d4991..afe94aafea 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2263,13 +2263,22 @@ type app struct { // settings should not open one. profileDir string settings *config.Settings - // routingOff is whether this session was launched with the routing row at - // `off`, which sends no lane choice at all and measures nothing - // (internal/provider's lanes.go). It is read ONCE, here, because that is + // routing is the routing row this session was launched under, in the words + // the row itself is written in — `latency`, `price`, `simple` or `off` + // (internal/config's settings.go). It is read ONCE, here, because that is // when the session reads it — the row lands on the next session — and the - // chrome that asks it does so on every frame. Under it there is no fold to - // open ([app.armLanes]) and no pin on the model's name ([app.pinnedNow]). - routingOff bool + // chrome that asks it does so on every frame. + // + // THE WHOLE ROW IS KEPT AND NOT ONE READING OF IT. This was a `routingOff + // bool`, which answered the only question the surface had while the row had + // three answers and aforge chose under two of them. `simple` is a fourth, + // and under it aforge does not choose at all — so a row that says what auto + // does has to be told which routing it is describing ([laneAutoSaid]), and a + // second boolean beside the first would be two readings of one row, drifting + // the first time either was fixed. Under `off` there is no fold to open + // ([app.armLanes]) and no pin on the model's name ([app.pinnedNow]), which + // is [app.routingOff] asking this field. + routing string // crew is the profile's crew as this surface last read it, so the status // line can name it without reading four settings rows off the disk on every // frame (crew.go's [app.crewReading]). @@ -2638,7 +2647,7 @@ func newApp(ctx context.Context, opts Options) *app { artifacts: opts.ArtifactsIndex, ctxWindow: opts.ContextWindow, profileDir: opts.ProfileDir, - routingOff: config.RoutingAt(opts.ProfileDir) == config.RoutingOff, + routing: config.RoutingAt(opts.ProfileDir), oneModel: opts.OneModel, settings: opts.Settings, saveApproval: opts.SaveApproval, diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 7405f45b38..f9d4507413 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -609,7 +609,12 @@ func laneSlotForRow(key string) string { // nothing, so a fold would offer machines no request asks for and promise // measurements that never come. func (a *app) armLanes(p *picker, slot string) { - if slot == "" || a.routingOff { + // THE ROUTING ROW RIDES ALONG EVEN WHERE IT CLOSES THE FOLD, because it is + // the one fact here that is about what aforge PROMISES rather than about + // what it has measured, and the row that makes that promise reads it + // ([laneAutoSaid]). + p.routing = a.routing + if slot == "" || a.routingOff() { return } p.laneSlot = slot @@ -618,6 +623,15 @@ func (a *app) armLanes(p *picker, slot string) { p.ascii = a.pal.ascii } +// routingOff is whether this session's routing row is `off` — the one answer +// that sends no lane choice at all and measures nothing (internal/provider's +// lanes.go), which leaves nothing for a fold to draw and nothing to pin. +// +// It asks [app.routing] rather than keeping a boolean of its own: the row has +// four answers now, and each place that cares cares about a different one of +// them. +func (a *app) routingOff() bool { return a.routing == config.RoutingOff } + // applyLaneChoice is enter on a row INSIDE an open fold: the lane the cursor is // on, written for the model that fold belongs to. // @@ -1197,7 +1211,7 @@ const laneAtSign = "@" // lane at all, [app.routingOff]), and over a connection, where the pin in force // is the far machine's and this process cannot see it. func (a *app) pinnedNow() string { - if a.hosted() || a.routingOff || a.model == "" || a.modelIsDirect(a.model) { + if a.hosted() || a.routingOff() || a.model == "" || a.modelIsDirect(a.model) { return "" } return strings.ToLower(provider.PinnedFor(a.model)) diff --git a/internal/tui3/lanewalk_test.go b/internal/tui3/lanewalk_test.go index 36b7cde6f7..de67e0df8d 100644 --- a/internal/tui3/lanewalk_test.go +++ b/internal/tui3/lanewalk_test.go @@ -218,6 +218,62 @@ func TestRoutingOffOpensNoFoldAndWritesNoPin(t *testing.T) { } } +// AND UNDER ROUTING `simple` THE FOLD OPENS AND THE `auto` ROW STOPS PROMISING +// A TAKEOVER. That row sends no preference of aforge's own: with nothing pinned +// the request goes out bare and OpenRouter's own routing answers, so nothing on +// this side chooses a machine, predicts which one the next turn lands on, or +// rescues an answer that turns slow — and the sentence a person reads on the row +// where they decide whether to leave the choosing alone has to say so. +func TestUnderSimpleRoutingTheAutoRowPromisesNoTakeover(t *testing.T) { + laneLab(t, threeLanes()) + dir := t.TempDir() + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyRouting) + if !ok || row.Apply(config.RoutingSimple) != nil { + t.Fatal("could not write the routing row") + } + t.Setenv("AFORGE_HOME", t.TempDir()) + a := newApp(t.Context(), Options{Agent: &fakeAgent{model: flash}, Workspace: "/tmp/lab", ProfileDir: dir}) + a.models = func() []Model { return laneCatalog } + a.width, a.height = 120, 24 + + typeLine(t, a, "/model") + drive(t, a, key("right")) + if a.pick.unfold != flash { + t.Fatalf("under routing simple → left the fold at %q", a.pick.unfold) + } + screen := plain(frame(a)) + if !strings.Contains(screen, "openrouter's own routing; aforge stays out") { + t.Fatalf("the auto row does not say who is choosing under simple:\n%s", screen) + } + // THE TWO CLAIMS THAT ONLY A CHOOSER CAN MAKE ARE GONE WITH IT: the machine + // the next turn would go to, which under this row nobody on this side picks, + // and the takeover the sentence used to promise. + for _, gone := range []string{"aforge takes over", "cloudflare now"} { + if strings.Contains(screen, gone) { + t.Fatalf("under routing simple the fold still says %q:\n%s", gone, screen) + } + } + // AND THE SETTINGS PANEL EXPLAINS THE SAME ROW THE SAME WAY, because both + // read the one door (palette.go's [laneAutoSaid]). + drive(t, a, key("esc")) + a.openSettings() + meta, found := a.sheet.metaFor(mustRow(t, a, config.LaneSettingKey(talkSlot))) + if !found || !strings.Contains(meta.about, "openrouter's own routing answers") { + t.Fatalf("the lane row is explained as %q", meta.about) + } +} + +// mustRow is one registry row by key, for a test that is about what the panel +// SAYS about it rather than about finding it. +func mustRow(t *testing.T, a *app, key string) config.Setting { + t.Helper() + row, ok := a.sheet.registry.Row(key) + if !ok { + t.Fatalf("the registry has no row %q", key) + } + return row +} + // DEFECT 5. A BELIEF WHOSE CONFIDENCE HAS DECAYED HAS NO TAIL. A day without a // sighting doubles the spread a hundred and forty-four times, and the p99 of // that is +Inf — which a row once printed as `tail 9223372036854775807s`. diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 153cd3704c..0cbf4da78c 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -98,6 +98,13 @@ type picker struct { // because it is a fact about what `auto` PROMISES, and this list is where a // person decides whether to leave the choosing to it. guard bool + // routing is the routing row this session was launched under ([app.routing]), + // and it rides here for the same reason the guard does: under `simple` aforge + // makes no choice of its own at all, so what the `auto` row may honestly say + // it does is a question only this row answers ([laneAutoSaid]). A snapshot, + // like the rest of them — the row lands on the next session and cannot move + // while a modal list owns the keyboard. + routing string // ascii is whether this terminal was refused box drawing, so the two marks // on the fold's own rows have a plain spelling. It is a snapshot like the // rest of them, and the list is closed long before a terminal could change @@ -1445,17 +1452,25 @@ func (p *picker) entryText(at int, width int, level func(string) string) (string // FOR is a thing you read once; which machine it would send you to now // is the thing you came back to look at, so the short spelling keeps the // name and drops the explanation around it. - sentence := laneAutoNote + // + // AND WHAT IT MAY CLAIM AT ALL IS THE ROUTING ROW'S TO SAY, asked once + // ([laneAutoSaid]) rather than read off the mode here: under `simple` + // nothing on this side chooses, so there is no machine to name and no + // rescue to promise. + said := laneAutoSaid(p.routing) + sentence := said.note short := "" - if p.auto != "" { + if said.chooses && p.auto != "" { sentence += " — " + strings.ToLower(p.auto) + " now" short = strings.ToLower(p.auto) + " now" } fields := []rowField{rowSay(sentence, short), rowSay("recommended")} // AND WHAT AUTO WILL NOT DO, said where the choice is made. With the // speed guard off, a lane that turns slow mid-answer is one you wait - // out; that is a fact about this row and it belongs on it. - if !p.guard { + // out; that is a fact about this row and it belongs on it. Where the + // routing runs no rescue in the first place the sentence above has + // already said so, and a chip repeating it is the same fact twice. + if said.chooses && !p.guard { fields = append(fields, rowSay("no rescue")) } return rowHalves(rowPlan{primary: " " + p.mark(true) + " auto", fields: fields}, width, 0) @@ -1490,10 +1505,60 @@ func (p *picker) mark(filled bool) string { return tokens.GlyphStepPending } -// laneAutoNote is what the auto row says it does. It is a sentence and not a -// word because it is the row a person will land on first and the one they will -// leave alone: what it is FOR has to be on it. -const laneAutoNote = "router routes; aforge takes over if answers turn bad" +// laneAutoSay is what the `auto` row may honestly claim, as the routing row in +// force decides it: the sentence saying what leaving the choosing alone DOES, +// and whether aforge is the one doing any of the choosing. +type laneAutoSay struct { + // note is the sentence on the row. It is a sentence and not a word because + // it is the row a person will land on first and the one they will leave + // alone: what it is FOR has to be on it. + note string + // about is the same promise as the settings sheet's `lane` row explains it + // ([settingUI], read through [sheet.metaFor]). It is a second wording and + // not a second decision: the sheet's row is a paragraph about what the four + // answers to "which machine" mean and the picker's row is a label inside + // the list, and the two would say different things about `simple` the first + // time either was written without the other. + about string + // chooses is whether aforge chooses anything under this routing row. It + // gates the two claims that are only true when it does: the name of the + // machine the next turn would go to — which is the CHOOSER'S answer + // (lanes.go's [laneAuto]), and a prediction nobody makes where no chooser + // runs — and the `no rescue` chip, which is a fact about a speed guard that + // has nothing to guard. + chooses bool +} + +// laneAutoSaid reads the routing row and answers it once, so that what the row +// promises is decided in ONE place rather than at each thing the row draws. +// +// THE SENTENCE IS A PROMISE AND A PROMISE HAS TO BE KEPT UNDER EVERY ROW. Under +// `latency` and `price` aforge does take over when the router's answers turn +// bad, and the row has said so since it was written. Under `simple` it does +// not: the request goes out with no preference of aforge's own on it and +// OpenRouter's own default routing answers, which is exactly the row a person +// chose in order to be left alone — so the row that still said "aforge takes +// over" would be the surface promising machinery the mode disconnected. +// +// An unknown word — a row read before this surface armed anything — is the +// shipped routing, which is what [config.DefaultRouting] is. +func laneAutoSaid(routing string) laneAutoSay { + if routing == config.RoutingSimple { + return laneAutoSay{ + note: "openrouter's own routing; aforge stays out", + about: "which machine behind your model answers you. routing is simple, so auto " + + "sends no choice of ours at all and openrouter's own routing answers; a lane " + + "you pin is the whole request. enter opens them all with what has been " + + "measured of each.", + } + } + return laneAutoSay{ + note: "router routes; aforge takes over if answers turn bad", + about: "which machine behind your model answers you. auto picks the fastest one " + + "each answer; enter opens them all with what has been measured of each.", + chooses: true, + } +} // laneUnmeasured is the one line a fold draws in the machines' place when // nothing behind the model has been measured. It is a sentence a person would diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 8411aadaa1..d7bc9a9680 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -689,10 +689,13 @@ var settingUI = map[string]settingMeta{ }, // AND UNDER IT, THE MACHINE ITSELF. routing is about what every request // prefers; this is about which endpoint your conversation actually lands on. + // ITS EXPLANATION IS NOT WRITTEN HERE. What `auto` does is the routing row's + // answer and not this row's, so the words come from the one place that knows + // them ([laneAutoSaid], filled in by [sheet.metaFor]) — the same door the + // picker's own `auto` row reads. A sentence spelled here as well would be + // this panel promising a takeover on a routing that runs none. config.LaneSettingKey(talkSlot): { tab: tabProviders, label: "lane", widget: widgetLane, - about: "which machine behind your model answers you. auto picks the fastest one " + - "each answer; enter opens them all with what has been measured of each.", }, config.KeyLaneGuard: { tab: tabProviders, label: "speed guard", widget: widgetToggle, @@ -900,6 +903,12 @@ type sheet struct { // defaults is every row's reading on a profile nobody has touched, so a row // that differs from it can be marked. See [settingDefaults]. defaults map[string]string + // routing is the routing row this session was launched under ([app.routing]), + // taken once for the same reason [sheet.sessionModel] is: it is what the + // SESSION is routing by, the row itself lands on the next session, and a + // panel that re-read it off the disk would explain the `lane` row by a word + // nothing is acting on yet. + routing string // sessionModel is the model this conversation is on — the FLOOR of every // role's ladder ([roles.Resolve]), and therefore what a role row resolves to // when nothing above it is set. It is taken once, when the panel opens: the @@ -1194,6 +1203,7 @@ func (a *app) raiseSettings() { sources: a.sources, defaults: settingDefaults(), sessionModel: a.model, + routing: a.routing, today: a.todayReading(), } a.sheet.rows = a.sheet.registry.Rows() @@ -1379,6 +1389,14 @@ func (s *sheet) metaFor(row config.Setting) (settingMeta, bool) { if ok && row.Key == config.KeySearchProvider { meta.about = config.SearchProviderHintAt(s.profileDir) } + // AND THE `lane` ROW IS EXPLAINED BY THE ROUTING IN FORCE, because `auto` is + // a different promise under `simple` than under the row aforge ships with — + // the same one door the picker's own `auto` row reads (palette.go's + // [laneAutoSaid]), so the panel and the list cannot say different things + // about one routing. + if ok && row.Key == config.LaneSettingKey(talkSlot) { + meta.about = laneAutoSaid(s.routing).about + } return meta, ok } @@ -2915,6 +2933,15 @@ func (s *sheet) laneWord(item sheetItem) string { return word } } + // AND NAMING THE MACHINE `auto` WOULD USE IS A CLAIM ONLY A CHOOSER CAN + // MAKE. Under `simple` routing an unpinned request carries no preference of + // ours at all and OpenRouter's own routing answers it, so the best lane this + // process believes in is a machine nothing asked for — the same reading the + // picker's `auto` row makes (palette.go's [laneAutoSaid]), and the emptiness + // law closes the rest: no prediction, no word. + if !laneAutoSaid(s.routing).chooses { + return "" + } if best, ok := bestLane(laneViews(s.sessionModel, timeNow())); ok { return "auto (" + strings.ToLower(best.Name) + " now)" } From 9a83dd38299a3088f43b36ad8c3cf303d6d31063 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:16:57 -0400 Subject: [PATCH 03/22] =?UTF-8?q?provider:=20probe=20the=20simple=20row's?= =?UTF-8?q?=20pinned=20path=20=E2=80=94=20what=20the=20wire=20carries=20wh?= =?UTF-8?q?en=20the=20machine=20is=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP diagnostic commit so the two acceptances can be run on the test box. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/provider/simplemode_verify_test.go | 112 ++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index f5bd6746ea..4d053d19c6 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -3,6 +3,8 @@ package provider import ( "strings" "testing" + + lanes "github.com/Agent-Field/aforge-v2/internal/lane" ) // THROWAWAY VERIFICATION for the simple routing mode: what the wire carries @@ -39,3 +41,113 @@ func TestSimpleModeSendsOnlyThePersonsPin(t *testing.T) { t.Fatalf("simple with a pin carried %+v, want only Harbor and fallbacks off", demand) } } + +// ── A PIN THE WIRE REFUSES, UNDER `simple` ────────────────────────────────── +// +// The loose end the mode shipped with. `simple` is the row that promises the +// wire carries the person's own instruction and nothing else, and the danger in +// that promise is the silent half: a demand that never goes out leaves the +// chrome saying `@deepseek` over a request the router answered from wherever it +// liked, which is the exact complaint the mode was written to close. +// +// So the pin is SENT, once, even when this process already believes the +// account's own settings exclude the machine — the wire is the only authority +// on that, the belief is a day old by design (internal/lane's account.go), and +// one refused round trip a run is a smaller price than a promise quietly +// broken. When the refusal comes back the pin is retired through the SAME door +// every other mode retires one through, so the person reads the one sentence +// this build has for it and the chrome stops naming the machine in the same +// breath. +func TestSimpleRoutingSendsThePinOnceThenRetiresItOutLoud(t *testing.T) { + rig := newLaneRig(t, "simple/pin-refused", retiredLanes()...) + forgotten(t) + rig.client.config.Routing = StaticRouting(RoutingSimple) + pinned(t, LanePin{Lane: "Ghost"}) + notes := ¬iceLog{} + turn := WithStreamObserver(talking(), notes.observe) + + // 1. THE DEMAND GOES OUT. Under `simple` the pin is the whole request. + if _, err := rig.client.CompleteWithMessages(turn, userMessages("hello")); err != nil { + t.Fatalf("the turn died on a refusal the widened retry was supposed to absorb: %v", err) + } + asks := rig.server.Asks() + if len(asks) == 0 { + t.Fatal("nothing reached the router") + } + if !demandedOnly(asks[0], "Ghost") { + t.Fatalf("the first ask carried %+v, want the one machine the person pinned", asks[0]) + } + before := len(asks) + + // 2. AND THE PERSON IS TOLD, in the sentence this build already has. + said := notes.retired() + if len(said) != 1 { + t.Fatalf("the conversation was told %d times, want exactly once: %q", len(said), said) + } + if want := RetiredPinLine("Ghost"); said[0] != want { + t.Fatalf("the conversation reads %q, want %q", said[0], want) + } + + // 3. AND THE CHROME STOPS NAMING THE MACHINE IN THE SAME BREATH. This is + // the whole defect: a status line still reading `@ghost` over a bare + // request is the chrome promising a machine the wire was never asked for. + if still := PinnedFor(rig.model); still != "" { + t.Fatalf("the chrome would still say @%s after the wire retired the pin", still) + } + + // 4. AND THE NEXT REQUEST GOES BARE rather than buying the same 404 again. + if _, err := rig.client.CompleteWithMessages(turn, userMessages("again")); err != nil { + t.Fatalf("the second turn failed: %v", err) + } + asks = rig.server.Asks() + if len(asks) <= before { + t.Fatal("the second turn sent nothing") + } + for _, later := range asks[before:] { + if len(later.Only) != 0 || later.Sort != "" || len(later.Order) != 0 || later.MaxPrice != nil { + t.Fatalf("a request after the retirement carried %+v, want a bare request", later) + } + } + if said := notes.retired(); len(said) != 1 { + t.Fatalf("the conversation was told %d times over two turns: %q", len(said), said) + } +} + +// AND A PIN THIS PROCESS ALREADY BELIEVES THE ACCOUNT EXCLUDES IS STILL SENT. +// +// The store is a belief carried between processes — `account-exclusions.json`, +// written a day at a time — and under every other row it is allowed to retire a +// strict pin before the call (lanes.go). Under `simple` it may not: the row's +// whole promise is that what a person wrote is what goes out, and a pin retired +// on a saved belief is a pin the person would watch fail with no request ever +// made. One refused round trip a run is what honesty costs here, and the +// sentence they read afterwards is the same one. +func TestSimpleRoutingStillAsksForAPinTheSavedExclusionsCover(t *testing.T) { + rig := newLaneRig(t, "simple/pin-excluded", retiredLanes()...) + forgotten(t) + rig.client.config.Routing = StaticRouting(RoutingSimple) + // The state the live run started in: a previous process wrote the machine + // out of every model's serving set on the router's own sentence. + lanes.ExcludeForAccount("Ghost", "the account's own settings exclude it") + t.Cleanup(func() { lanes.ClearAccountExclusion("Ghost") }) + if !lanes.AccountExcludes("Ghost") { + t.Fatal("the fixture did not write the exclusion it is about") + } + pinned(t, LanePin{Lane: "Ghost"}) + notes := ¬iceLog{} + turn := WithStreamObserver(talking(), notes.observe) + + if _, err := rig.client.CompleteWithMessages(turn, userMessages("hello")); err != nil { + t.Fatalf("the turn failed: %v", err) + } + asks := rig.server.Asks() + if len(asks) == 0 || !demandedOnly(asks[0], "Ghost") { + t.Fatalf("the first ask carried %+v, want the pin sent to the one authority on it", asks) + } + if said := notes.retired(); len(said) != 1 { + t.Fatalf("the conversation was told %d times about its own row, want exactly once: %q", len(said), said) + } + if still := PinnedFor(rig.model); still != "" { + t.Fatalf("the chrome would still say @%s over a request that no longer asks for it", still) + } +} From a613ee40e4845148f8fc02828e0dc771d911ee8a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:21:50 -0400 Subject: [PATCH 04/22] provider, config: the routing row reaches every client this process builds, and under simple a pin is refused by the wire rather than by a memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loose end the simple row shipped with was not in the row. It was in who answers it. internal/session hands its own clients the routing answer (clientdoor.go, standing_run.go, memory_consolidate.go); every OTHER client in the binary is assembled through config.Config.ClientConfig, which has never carried one — the harness and the subharness (cmd/aforge), read_document and view_image (internal/config), and each member of a /model panel (internal/router). All of them ran latency whatever a person had written. Under simple that was silent and it was expensive. A client on the ranked road reaches the account-exclusion block in drawLaneChoice, which stands a strict pin down on a belief SAVED FROM AN EARLIER PROCESS (~/.aforge/v3/account-exclusions.json) before any wire is asked. The stand-down is process-wide and its sentence is parked on a call nobody is reading, so the conversation's own next turn — running simple, drawing correctly — found the pin already retired, went out with no provider object, and was answered by whichever machine the router liked while the status line still read @deepseek. That is the complaint the mode was written to close, arriving through a different door. So the row is installed once, by the door that already installs the lane rows a person wrote, and every client handed nothing reads it (provider.InstallRouting, config.InstallLaneRows). A client handed an answer keeps its own: an engine host and a task child carry their parent's row explicitly, and a process-wide fallback may not overrule a caller that spoke. THE RULING ON THE SAVED EXCLUSION IS (i), the pinned ask still goes out. The store is a belief a day long and the wire is the only authority on it; refusing before the call would be a person watching their pin fail with no request ever made. So under simple the pin is demanded once, OpenRouter answers its account-exclusion 404, and the pin is retired through the EXISTING visible door — retirePinnedLane's one sentence in the conversation and @X coming off the model word in the same breath. One refused round trip a window is what the row's promise costs, and it buys a sentence a person can act on instead of a request that quietly went somewhere else. Four acceptances, all against a real refusal on a real wire rather than against an object about to be encoded: the pin is sent once and retired out loud and the next ask is bare; a pin the saved exclusions already cover is still sent; a client handed no row answers the installed one while a client handed one keeps its own; and the same draw that the ranked road stands down is left standing under simple. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1016-simple-routing-mode.md | 2 + internal/config/settings.go | 35 +++++- internal/manual/chat/lanes.md | 30 +++++ internal/provider/simplemode_verify_test.go | 105 ++++++++++++++++++ internal/provider/velocity.go | 70 ++++++++++-- 5 files changed, 230 insertions(+), 12 deletions(-) diff --git a/docs/changes/unreleased/1016-simple-routing-mode.md b/docs/changes/unreleased/1016-simple-routing-mode.md index c5dcc6dcbb..d0668f0efa 100644 --- a/docs/changes/unreleased/1016-simple-routing-mode.md +++ b/docs/changes/unreleased/1016-simple-routing-mode.md @@ -6,6 +6,8 @@ surface: [engine, chat, docs] invalidates: - "The `routing` row had three answers — `latency`, `price`, `off` — and the settings hint, the lanes page and the Providers-tab list all said so. It has four: `simple` sits between `price` and `off` in the cycle, and with no lane pinned the request carries NO provider object at all, so OpenRouter's own default routing answers and aforge does no measuring, no hedging and no re-ranking of its own." - "`off` was the only way to send no preference, and it costs the lane sheet, the pin rows and the speed guard because nothing is measured. `simple` keeps the pin: a lane a person named goes out as a strict demand (`only`, fallbacks off) with nothing else on the request, and a pin written `borrow when slow` collapses to strict because no rescue runs for it to borrow." + - "The routing row was believed to reach every request this process makes. It did not: `internal/session` handed its own clients the answer and every other client in the binary — the harness, the subharness, `read_document`, `view_image`, a `/model` panel's members — was assembled through `config.Config.ClientConfig`, which carried no routing answer at all, so each of them ran `latency` whatever a person had written. Under `simple` that was silent and costly: a client on the ranked road may stand a strict pin down on the SAVED account-exclusion belief before any wire is asked, the stand-down is process-wide, and the conversation's own next turn then went out bare while the status line still read `@deepseek`. `config.InstallLaneRows` now installs the row beside the lane rows (`provider.InstallRouting`), and a client handed nothing answers it; a client handed an answer still keeps its own." + - "A strict pin on a machine `~/.aforge/v3/account-exclusions.json` covers was stood down before the call in every mode. Under `simple` it is not: the pin goes on the wire once, OpenRouter refuses it, and the pin is retired through the one existing door — the `X cannot serve this model; routing on auto for this model until you pin again` note in the conversation, and `@X` coming off the model word in the same breath. One refused round trip a window is what the row's promise costs." - "The lanes page's machinery — the takeover, the closed set, the refusal walk, the probe — read as what aforge does while it is choosing. It is what `latency` and `price` do; under `simple` the routing gate, the belief chooser, the hedge and the probes stay compiled in but are disconnected from the request path, and none of it runs." --- diff --git a/internal/config/settings.go b/internal/config/settings.go index 23d0182a4e..7f59f9b9a0 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -820,15 +820,42 @@ func LanePinAt(profileDir, slot string) provider.LanePin { return provider.LanePin{} } -// InstallLaneRows hands this profile's lane rows to the process-wide knobs the -// transport reads them from. It uses the RESOLVER'S entrance so loading a row -// already in force never forgets a retirement the wire earned; only a person's -// own act belongs at [provider.RepinLane]. +// InstallLaneRows hands this profile's routing posture to the process-wide +// knobs the transport reads it from. It uses the RESOLVER'S entrance so loading +// a row already in force never forgets a retirement the wire earned; only a +// person's own act belongs at [provider.RepinLane]. +// +// THE ROUTING ROW IS ONE OF THEM, and it is here rather than only on the +// session's own config because of the clients nobody hands one to. The harness, +// the subharness, `read_document`, `view_image` and a panel's members are all +// assembled through [Config.ClientConfig], which carries no routing answer — +// so before this line they ran on the default whatever a person had written, +// and under `simple` one of them could retire a person's own pin, process-wide, +// before any wire was asked (internal/provider's velocity.go says what that +// cost). It is the CHOICE and not the resolved default, so an unwritten row +// installs nothing and the per-request default still moves with who is waiting. func InstallLaneRows(profileDir string) { + provider.InstallRouting(installedRoutingFor(profileDir)) provider.SetLanePin(LanePinAt(profileDir, LaneSlotTalk)) provider.SetLaneGuard(LaneGuardAt(profileDir)) } +// installedRoutingFor is the routing row as the transport's own vocabulary, and +// the EMPTY strategy when a person has written nothing readable. The parse is +// total, so a word this build does not know installs nothing rather than taking +// a person's routing somewhere they did not ask for. +func installedRoutingFor(profileDir string) provider.RoutingStrategy { + word := RoutingChoiceAt(profileDir) + if word == "" { + return "" + } + strategy, known := provider.ParseRoutingStrategy(word) + if !known { + return "" + } + return strategy +} + // SetLane writes one slot's lane. An empty word clears the row back to auto, // which is the same thing said two ways and both of them arrive here. func SetLane(profileDir, slot, value string) error { diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index 18442042d5..dd57502314 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -805,6 +805,14 @@ costs one refused round trip, once. A strict pin on it is stood down on every model with the usual `cannot serve this model` line. Nothing about its speed is written; an answer from it, or pinning it again, takes it back at once. +**Under `routing: simple` that memory never stands your pin down by itself.** +The row promises that what you wrote is what goes on the wire, so the pin is +sent — once — and OpenRouter is left to be the one that says no. You pay the +refused round trip again on the first turn of a new window, and you get the +`cannot serve this model` line in the conversation, in the same breath as the +`@machine` coming off the model on the status line. That is the trade: a +sentence you can act on instead of a request that quietly went somewhere else. + ## Lanes on a custom base URL, a proxy, a mirror, or a self-hosted router — `AFORGE_BASE_URL` Lanes are not tied to the OpenRouter hostname. Point aforge at any base with @@ -879,6 +887,28 @@ there is no lane to choose, no sheet of machines to open and no speed guard. sent. Left alone the row is still `latency`, and everything the rest of this page describes is what it does. +## Does simple routing cover everything, or only my own messages — harness runs, reading a document, looking at an image + +All of it. The `routing` row is about **this session**, not about one request +road, so every part of a conversation that opens its own connection answers the +same word: your turns, the work a task node sends, a **subharness** run, the +model that reads a document for you, the one that looks at an image, and each +member of a `/model` panel. + +That was not always true. Until 2026-09-13 those extra roads were built without +the row and ran `latency` whatever you had written — which was quiet and wrong +in one specific way. A road on `latency` is allowed to stand a pin down on +aforge's own saved belief that your account cannot reach the machine, and that +stand-down covers the whole window: your very next message, on `simple`, doing +nothing wrong, went out with no machine demanded while the status line still +read `@deepseek`. The row reaching every road is what closes it. + +If you want to check: pin a machine, set `routing` to `simple`, and send a +message. Either the answer comes from the machine you named, or you get the +`cannot serve this model` sentence and the `@machine` disappears from the model +word. There is no third outcome — a bare request under a pin that is still +being drawn is the bug above, and it is worth reporting. + ## Turning lane routing off Set routing off (`/settings`, or the `routing` row) and aforge sends every diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index 4d053d19c6..fafd4dc704 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -5,6 +5,7 @@ import ( "testing" lanes "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) // THROWAWAY VERIFICATION for the simple routing mode: what the wire carries @@ -151,3 +152,107 @@ func TestSimpleRoutingStillAsksForAPinTheSavedExclusionsCover(t *testing.T) { t.Fatalf("the chrome would still say @%s over a request that no longer asks for it", still) } } + +// installedRow states the routing row this process's unhanded clients answer to +// for the length of one test and puts it back afterwards — the knob is +// process-wide (velocity.go), so a test that left one set would route every +// test after it. +func installedRow(t *testing.T, strategy RoutingStrategy) { + t.Helper() + before, held := installedRoutingChoice() + if !held { + before = "" + } + InstallRouting(strategy) + t.Cleanup(func() { InstallRouting(before) }) +} + +// ── THE ROW REACHES THE CLIENTS NOBODY HANDS ONE TO ───────────────────────── +// +// THE MEASURED FAILURE (2026-09-13). `internal/session` hands its own clients +// the row; every other client in the binary is assembled through +// [config.Config.ClientConfig], which carried no routing answer at all — the +// harness, the subharness, `read_document`, `view_image`, a panel's members. +// Each ran the ranked road whatever a person had written, and the ranked road +// may stand a strict pin down on a SAVED belief before any wire is asked +// (lanes.go's account-exclusion block). The retirement is process-wide, so a +// conversation running `simple` and doing everything right found its own pin +// already retired, went out bare, and left the chrome naming a machine the +// request had never asked for. +func TestAClientHandedNoRowAnswersTheOneThisProcessInstalled(t *testing.T) { + server := prefRig(t) + client := plainBase(t, server.URL(), server) + installedRow(t, RoutingSimple) + + // A client handed nothing reads the installed row. + client.config.Routing = nil + if strategy := client.routingFor(IntentInteractive); strategy != RoutingSimple { + t.Fatalf("a client handed no row routes on %q, want the row this process installed", strategy) + } + // And so does one handed a source that says nothing, which is how a session + // with an unwritten row hands its answer down. + client.config.Routing = StaticRouting("") + if strategy := client.routingFor(IntentInteractive); strategy != RoutingSimple { + t.Fatalf("a client handed an empty source routes on %q, want the installed row", strategy) + } + // A CALLER THAT SPOKE STILL WINS. An engine host and a task child carry + // their parent's row explicitly, and a process-wide fallback may not + // overrule somebody who said a word. + client.config.Routing = StaticRouting(RoutingPrice) + if strategy := client.routingFor(IntentInteractive); strategy != RoutingPrice { + t.Fatalf("a client handed price routes on %q, want the answer it was handed", strategy) + } + // And with nothing installed and nothing handed down, the default still + // moves with who is waiting, which is what an unwritten row has always done. + InstallRouting("") + client.config.Routing = nil + if strategy := client.routingFor(IntentBackground); strategy != RoutingPrice { + t.Fatalf("an unwritten row routes a background call on %q, want price", strategy) + } + if strategy := client.routingFor(IntentInteractive); strategy != RoutingLatency { + t.Fatalf("an unwritten row routes a person's turn on %q, want latency", strategy) + } +} + +// AND UNDER `simple` NOTHING STANDS A PIN DOWN ON A SAVED BELIEF. +// +// This is the defect above stated as the behaviour that closes it: with the row +// installed, the client that used to run the ranked road runs `simple` like +// every other, so the machine a person named is demanded and the wire is left +// to be the authority on whether the account can reach it. The contrast is the +// same draw with no row installed, which is the ranked road doing exactly what +// it is supposed to do. +func TestSimpleRoutingLeavesAPinTheRankedRoadWouldHaveStoodDown(t *testing.T) { + rig := newLaneRig(t, "simple/pin-not-stood-down", retiredLanes()...) + forgotten(t) + lanes.ExcludeForAccount("Ghost", "the account's own settings exclude it") + t.Cleanup(func() { lanes.ClearAccountExclusion("Ghost") }) + pinned(t, LanePin{Lane: "Ghost"}) + request := &ai.Request{Model: rig.model, Messages: userMessages("hello")} + + // THE RANKED ROAD, WHICH IS THE CONTRAST. Nothing installed, nothing handed + // down: the saved exclusion stands the pin down before the wire is asked. + installedRow(t, "") + rig.client.config.Routing = nil + if choice, made := rig.client.drawLaneChoice(callKnobs{}, rig.model, request); made && len(choice.Only) > 0 { + t.Fatalf("the ranked road demanded %v for a machine the account excludes", choice.Only) + } + if !pinRetired("Ghost", rig.model) { + t.Fatal("the ranked road did not stand the pin down, so this test is not about the contrast it names") + } + + // AND `simple`, INSTALLED THE WAY THE SURFACE INSTALLS IT. The same client, + // handed nothing, now demands the machine the person wrote. + forgetRetiredPins() + installedRow(t, RoutingSimple) + choice, made := rig.client.drawLaneChoice(callKnobs{}, rig.model, request) + if !made || len(choice.Only) != 1 || !strings.EqualFold(choice.Only[0], "Ghost") { + t.Fatalf("under simple the draw came out %+v, want the one machine the person pinned", choice) + } + if pinRetired("Ghost", rig.model) { + t.Fatal("under simple a saved belief stood the person's pin down with no wire asked") + } + if PinnedFor(rig.model) == "" { + t.Fatal("the chrome would say nothing over a request that demands the machine") + } +} diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index ca5a36e687..1716a903c9 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -5,6 +5,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" lanes "github.com/Agent-Field/aforge-v2/internal/lane" @@ -185,6 +186,55 @@ func (s staticRouting) RoutingStrategy() RoutingStrategy { return RoutingStrateg // see [Client.routingFor]. func StaticRouting(strategy RoutingStrategy) RoutingSource { return staticRouting(strategy) } +// ── THE ROW EVERY CLIENT IN THIS PROCESS ANSWERS TO ───────────────────────── +// +// [RoutingSource] is how a caller HANDS an answer down, and every client that +// is handed one keeps it. The problem is the clients nobody hands one to, and +// there are several: the harness and the subharness build their own adapters +// (cmd/aforge), `read_document` and `view_image` build theirs +// (internal/config), and a panel builds one per member (internal/router). Each +// of those was assembled through [config.Config.ClientConfig], which has never +// carried a routing answer at all — so whatever a person wrote in the routing +// row, those adapters ran on the default. +// +// THAT IS A BREACH OF THE ROW AND, UNDER `simple`, A SILENT ONE. A client on +// the default takes the ranked road, and the ranked road may retire A PERSON'S +// PIN before any wire is asked — a machine the saved account exclusions cover +// is stood down where it is drawn (lanes.go). The retirement is process-wide, +// so the conversation's own next turn — running `simple`, doing everything +// right — found the pin already retired and went out bare, with the sentence +// parked on a call nobody was reading and the chrome still naming the machine. +// +// So the row is installed ONCE, by the same door that installs the lane rows a +// person wrote (internal/config's InstallLaneRows), and every client that was +// handed nothing reads it. It is a process-wide knob for [lanePin]'s reason +// said again: the row is about a SESSION and not about an adapter, and two +// clients in one process holding two answers to "what did they ask for" is the +// defect above. A HANDED-DOWN ANSWER STILL WINS — the engine host and a task +// child carry their parent's row explicitly, and a process-wide fallback may +// not overrule a caller that spoke. +var installedRouting atomic.Value + +// InstallRouting states the routing row this process's clients answer to, for +// every client that is handed no [RoutingSource] of its own. The empty strategy +// is NOBODY HAVING WRITTEN ONE and puts the knob back to exactly that, which is +// what an unwritten row resolves to — the default then moves with who is +// waiting ([Client.routingFor]) exactly as it always has. +func InstallRouting(strategy RoutingStrategy) { + installedRouting.Store(strategy) +} + +// installedRoutingChoice is the installed row and whether one was installed at +// all, in the shape [Client.routingChoice] answers in. +func installedRoutingChoice() (RoutingStrategy, bool) { + held, ok := installedRouting.Load().(RoutingStrategy) + if !ok || strings.TrimSpace(string(held)) == "" { + return RoutingLatency, false + } + parsed, _ := ParseRoutingStrategy(string(held)) + return parsed, true +} + // routingChoice is the strategy A PERSON CHOSE, and whether one was chosen at // all. An empty source, an empty word, or no source is "nobody said" — which is // a different fact from "somebody said latency", and the whole of what lets the @@ -196,15 +246,19 @@ func (c *Client) routingChoice() (RoutingStrategy, bool) { // object and hand router vocabulary to an endpoint that has no lanes. return RoutingOff, true } - if c.config.Routing == nil { - return RoutingLatency, false - } - strategy := c.config.Routing.RoutingStrategy() - if strings.TrimSpace(string(strategy)) == "" { - return RoutingLatency, false + // A CALLER THAT SPOKE IS ANSWERED FIRST, and a caller that did not falls to + // the row this process installed ([InstallRouting]). The two arms below are + // one question asked of two places: a nil source and a source holding the + // empty strategy are both "this caller said nothing", and neither is a + // refusal. + if c.config.Routing != nil { + strategy := c.config.Routing.RoutingStrategy() + if strings.TrimSpace(string(strategy)) != "" { + parsed, _ := ParseRoutingStrategy(string(strategy)) + return parsed, true + } } - parsed, _ := ParseRoutingStrategy(string(strategy)) - return parsed, true + return installedRoutingChoice() } // routing resolves the strategy for this client with nothing said about who is From 73a80489f2935d67345fc327ad37a88caea07984 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:28:01 -0400 Subject: [PATCH 05/22] docs: the two change entries carry the pull request's real number, 1019 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- ...{1016-simple-routing-mode.md => 1019-simple-routing-mode.md} | 2 +- ...-routing-picker-row.md => 1019-simple-routing-picker-row.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/changes/unreleased/{1016-simple-routing-mode.md => 1019-simple-routing-mode.md} (99%) rename docs/changes/unreleased/{1016-simple-routing-picker-row.md => 1019-simple-routing-picker-row.md} (99%) diff --git a/docs/changes/unreleased/1016-simple-routing-mode.md b/docs/changes/unreleased/1019-simple-routing-mode.md similarity index 99% rename from docs/changes/unreleased/1016-simple-routing-mode.md rename to docs/changes/unreleased/1019-simple-routing-mode.md index d0668f0efa..6ee98b6f29 100644 --- a/docs/changes/unreleased/1016-simple-routing-mode.md +++ b/docs/changes/unreleased/1019-simple-routing-mode.md @@ -1,7 +1,7 @@ --- kind: added title: a fourth routing mode, `simple`, sends exactly what you asked for and nothing else -pr: 1016 +pr: 1019 surface: [engine, chat, docs] invalidates: - "The `routing` row had three answers — `latency`, `price`, `off` — and the settings hint, the lanes page and the Providers-tab list all said so. It has four: `simple` sits between `price` and `off` in the cycle, and with no lane pinned the request carries NO provider object at all, so OpenRouter's own default routing answers and aforge does no measuring, no hedging and no re-ranking of its own." diff --git a/docs/changes/unreleased/1016-simple-routing-picker-row.md b/docs/changes/unreleased/1019-simple-routing-picker-row.md similarity index 99% rename from docs/changes/unreleased/1016-simple-routing-picker-row.md rename to docs/changes/unreleased/1019-simple-routing-picker-row.md index f70d0eb3c1..c6a485b775 100644 --- a/docs/changes/unreleased/1016-simple-routing-picker-row.md +++ b/docs/changes/unreleased/1019-simple-routing-picker-row.md @@ -1,7 +1,7 @@ --- kind: fixed title: the model picker's `auto` row stops promising a takeover the `simple` routing never does -pr: 1016 +pr: 1019 surface: [chat, docs] invalidates: - "The `auto` row in the model picker's lane fold read `router routes; aforge takes over if answers turn bad` under every routing row. Under `routing: simple` it reads `openrouter's own routing; aforge stays out`, because that mode disconnects the chooser, the gate, the hedge and the probes — nothing on aforge's side takes anything over." From 7389ac897fc5b40411a7914e95226ad7837fc42e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 16:55:14 -0400 Subject: [PATCH 06/22] provider: under simple the talk pin rides the talk, the retirement sentence is said on the next body, and a widened row stops naming the machine it withdrew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects a real drive against the live router found in the simple row, all of them about the same promise: that a person watching their own pin be refused is told so, once, and pays for it once. THE SENTENCE WAS QUEUED BEHIND A DOOR THE TURN NEVER OPENED AGAIN. The three parked lines a person is owed — a retired pin, a base that will not carry a preference, a takeover — were said at Client.sendShaped, which is entered ONCE PER CALL. A line learned from a call's OWN refusal therefore had nowhere to go: the widened retry a retired pin earns leaves through Client.attemptShaped and every rung of the relaxation ladder likewise, and none of them come back through the shaped door. Measured on 2026-09-13: the pin was demanded, refused, retired, @deepseek came off the model word, another machine answered the turn, and nothing anywhere said why. They are now said at Client.send, which is the door every BODY passes through — the grain the promise was written at. THE ROW GOVERNS THE CALLS IT IS NAMED FOR. The row is lane.talk and the slot is the whole scope, and until now the demand rode every call of a turn. One measured turn paid three separate 404s: its own on the chat model, the naming errand on the reflex tier's model, and the memory reflex on a third model the pinned machine does not serve at all — a retirement is written per pairing, so each new model an errand reached for was a fresh refused round trip, bought for calls nobody is reading on machines nobody pinned. The scope is read from the role the caller already stamped (internal/lane's roles.go), which is the same reading that decides whether a person is TOLD when the wire refuses the pin — one predicate, so the machine somebody is asked for and the sentence they get when it is refused cannot belong to two different sets of calls. AND THE WIDENED RETRY'S ROW DESCRIBED THE REQUEST BEFORE IT. The bare retry of a retired pin carried no provider key at all and was logged `"lane":"DeepSeek","served":"DeepSeek","relaxed":["provider.require_parameters"]` — the machine that had just refused it, and a field a simple request never sends. The knobs travel by value through the widen, so the choice that named the machine is still sitting on them after the encoder has stopped sending it. A row now names a lane only while the body still asks for one, `served` is no longer back-filled from a demand that has been withdrawn, and the widening rung names the fields it really took off this object rather than the one word the table could hold. The proof is at the session layer as well as the transport's, because the transport already had a passing test and what it could not see was the rest of the road: the turn's observer, the loop that reads the stream, the events a surface is handed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1019-simple-routing-mode.md | 3 + internal/manual/chat/lanes.md | 46 ++++-- internal/provider/calllog.go | 60 ++++++- internal/provider/client.go | 23 +-- internal/provider/dispatch.go | 20 +++ internal/provider/endpoints.go | 58 ++++++- internal/provider/lanes.go | 23 ++- internal/provider/simplemode_verify_test.go | 103 +++++++++++- internal/session/retiredpin_wire_test.go | 149 ++++++++++++++++++ 9 files changed, 450 insertions(+), 35 deletions(-) create mode 100644 internal/session/retiredpin_wire_test.go diff --git a/docs/changes/unreleased/1019-simple-routing-mode.md b/docs/changes/unreleased/1019-simple-routing-mode.md index 6ee98b6f29..d8bde1bfb7 100644 --- a/docs/changes/unreleased/1019-simple-routing-mode.md +++ b/docs/changes/unreleased/1019-simple-routing-mode.md @@ -8,6 +8,9 @@ invalidates: - "`off` was the only way to send no preference, and it costs the lane sheet, the pin rows and the speed guard because nothing is measured. `simple` keeps the pin: a lane a person named goes out as a strict demand (`only`, fallbacks off) with nothing else on the request, and a pin written `borrow when slow` collapses to strict because no rescue runs for it to borrow." - "The routing row was believed to reach every request this process makes. It did not: `internal/session` handed its own clients the answer and every other client in the binary — the harness, the subharness, `read_document`, `view_image`, a `/model` panel's members — was assembled through `config.Config.ClientConfig`, which carried no routing answer at all, so each of them ran `latency` whatever a person had written. Under `simple` that was silent and costly: a client on the ranked road may stand a strict pin down on the SAVED account-exclusion belief before any wire is asked, the stand-down is process-wide, and the conversation's own next turn then went out bare while the status line still read `@deepseek`. `config.InstallLaneRows` now installs the row beside the lane rows (`provider.InstallRouting`), and a client handed nothing answers it; a client handed an answer still keeps its own." - "A strict pin on a machine `~/.aforge/v3/account-exclusions.json` covers was stood down before the call in every mode. Under `simple` it is not: the pin goes on the wire once, OpenRouter refuses it, and the pin is retired through the one existing door — the `X cannot serve this model; routing on auto for this model until you pin again` note in the conversation, and `@X` coming off the model word in the same breath. One refused round trip a window is what the row's promise costs." + - "A pin under `simple` was believed to ride the person's own turn. It rode EVERY call of the turn: one measured turn paid three 404s — the turn on the chat model, the naming errand on the reflex tier's model, and the memory reflex on a third model the pinned machine does not serve at all — because a retirement is written per machine-and-model pairing and each errand reached for a model of its own. The row is `lane.talk` and the slot is now its whole scope: under `simple` the demand rides the calls a person is reading (their turn, and a task room somebody is watching) and every errand beside one — title, memory reflex, route question, a hand asking about a document, a subharness node — goes out bare. One turn, one refusal, one sentence." + - "The retirement sentence was believed to reach the conversation on 'the very next request'. The door that said it was entered ONCE PER CALL (`Client.sendShaped`), and the widened retry a retired pin earns and every rung of the relaxation ladder go out through a lower door — so a line learned from a call's OWN refusal was queued behind a door the rest of that turn never opened again. Measured 2026-09-13: the pin was retired, `@deepseek` came off the model word, another machine answered, and nothing said why. The three parked sentences are now said at the door every BODY passes through (`Client.send`)." + - "A widened retry's model-call row described the request before it. The bare retry of a retired pin carried no `provider` key at all and was logged `lane: DeepSeek, served: DeepSeek, relaxed: [provider.require_parameters]` — the machine that had just refused it, and a field a `simple` request never sends. A row now names a lane only while the body still asks for one, `served` is no longer back-filled from a demand that has been withdrawn, and the widening rung names the fields it really took off this object (`provider.only` first)." - "The lanes page's machinery — the takeover, the closed set, the refusal walk, the probe — read as what aforge does while it is choosing. It is what `latency` and `price` do; under `simple` the routing gate, the belief chooser, the hedge and the probes stay compiled in but are disconnected from the request path, and none of it runs." --- diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index dd57502314..e507b45e87 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -866,13 +866,14 @@ behind your model, and sends none: word, no price ceiling, no machines named or excluded. OpenRouter's own default routing picks the endpoint, exactly as it would for a request aforge had never touched. There is no measuring, no second request hedged alongside yours, not - even the one-token measurement sent while you type — and no takeover when - answers come back refused: the bad-minute rescue this page describes does not - run here, and neither does its cost. -- **A lane pinned** (`/model @deepseek`, or enter on the **lane** row) — the - request demands exactly that one machine: `only`, fallbacks off, and nothing - else rides along. Your word is the whole request. A pin written `borrow when - slow` changes nothing here — there is no rescue running for it to borrow. + even the one-token measurement sent while you type, and no takeover when + answers come back refused. +- **A lane pinned** (`/model @deepseek`, or enter on the **lane** row) — your + turn demands exactly that one machine: `only`, fallbacks off, and nothing else + rides along. Your word is the whole request. A pin written `borrow when slow` + changes nothing here — there is no rescue running for it to borrow. The row is + named `lane.talk` and that is its scope: the errands that run beside a turn go + out bare (the next section). What does not change: the machine that answered is still named on the status line, and the `switch to auto?` question a slow pinned lane asks still has @@ -889,11 +890,12 @@ page describes is what it does. ## Does simple routing cover everything, or only my own messages — harness runs, reading a document, looking at an image -All of it. The `routing` row is about **this session**, not about one request -road, so every part of a conversation that opens its own connection answers the -same word: your turns, the work a task node sends, a **subharness** run, the -model that reads a document for you, the one that looks at an image, and each -member of a `/model` panel. +The **row** does. It is about **this session**, not one request road, so every +part of a conversation that opens its own connection answers the same word: your +turns, a task node's work, a **subharness** run, the model that reads a document +for you, the one that looks at an image, each member of a `/model` panel. None +of them measures, ranks or hedges under `simple`. The **pin** is narrower — the +next section says how. That was not always true. Until 2026-09-13 those extra roads were built without the row and ran `latency` whatever you had written — which was quiet and wrong @@ -909,6 +911,26 @@ message. Either the answer comes from the machine you named, or you get the word. There is no third outcome — a bare request under a pin that is still being drawn is the bug above, and it is worth reporting. +## Does my pinned machine apply to the title, the memory reflex and a subharness too, or only to what I type + +**Only to the calls you are reading.** Under `routing: simple` the pinned +machine is demanded on your own turn, and on a task room you are sitting in +front of. The errands that run beside a turn send no machine name at all: a +conversation's two-word title, the memory reflex, the question that routes your +message, a hand asking a model about a document, a subharness node. The row is +spelled `lane.talk` and the slot is its whole scope. + +That is what one refusal costs. A pin the router refuses is retired **per +machine and model** — one refused round trip, once — but an errand runs on a +model of its own, and before 2026-09-13 a single turn bought three of them: +yours, the title's and the reflex's, on three different models, each with its +own 404 and none of them a machine you had asked for. One turn, one refusal, +one sentence. + +Under `latency` and `price` nothing changes: there is no demand to scope, +because a pin on those roads is drawn against everything the belief knows about +the machines behind each model. + ## Turning lane routing off Set routing off (`/settings`, or the `routing` row) and aforge sends every diff --git a/internal/provider/calllog.go b/internal/provider/calllog.go index 2c5d62c605..e41cc32687 100644 --- a/internal/provider/calllog.go +++ b/internal/provider/calllog.go @@ -388,7 +388,7 @@ func (c *Client) record(facts recordFacts) { Tools: len(facts.request.Tools), Stream: facts.stream, Attempt: facts.attempt, - Relaxed: relaxNames(facts.knobs.relaxed), + Relaxed: c.relaxNames(model, facts.knobs), Status: facts.status, Millis: logNow().Sub(facts.began).Milliseconds(), Learned: facts.learned, @@ -436,7 +436,7 @@ func (c *Client) record(facts recordFacts) { // THIS attempt, so that the row of a rescue says what the rescue did and // the row of the request it rescued says what happened to that one. if wait, watched := streamWatchFrom(facts.ctx).facts(); watched { - record.Lane = wait.lane + record.Lane = recordedLane(facts.knobs, wait.lane) record.HazardCeilingMs = wait.deadline.Milliseconds() // WHAT WAS PLANNED AND WHAT HAPPENED ARE TWO FIELDS, AND THE ROW MAY // CARRY BOTH. The ceiling above is when the watch was going to start @@ -604,11 +604,23 @@ func recordedServed(facts recordFacts) string { return soleDemandedLane(facts.knobs) } -// soleDemandedLane is the one machine this request was pinned to, and "" when -// it was free to be routed anywhere. A rescue demands the single arm it walked -// to (hedge.go's [hedgePreference]) and a person's pin is one machine +// soleDemandedLane is the one machine THIS ATTEMPT'S BODY was pinned to, and "" +// when it was free to be routed anywhere. A rescue demands the single arm it +// walked to (hedge.go's [hedgePreference]) and a person's pin is one machine // (lanes.go), so either is a commitment the router cannot answer around. +// +// AND AN ATTEMPT THAT HAS WITHDRAWN THE DEMAND HAS NO COMMITMENT LEFT, which is +// the half this used to get wrong. The knobs travel by value through the widen +// and the ladder, so the choice that named the machine is still sitting on them +// after the encoder has stopped sending it ([relaxedPreferences] takes `only` +// off) — and the bare retry of a retired pin was logged `served: DeepSeek` over +// a body with no `provider` key at all, which is the one machine that certainly +// did not answer it. The demand is read the way the encoder reads it or not at +// all. func soleDemandedLane(knobs callKnobs) string { + if !knobs.carriesTheDemand() { + return "" + } if lane := strings.TrimSpace(knobs.hedgeLane); lane != "" { return lane } @@ -618,6 +630,25 @@ func soleDemandedLane(knobs callKnobs) string { return "" } +// recordedLane is the machine THIS ATTEMPT'S preference asked for: the head of +// the order it sent, or the one machine it demanded. +// +// THE WATCH KNOWS WHAT THE CALL CHOSE AND NOT WHAT EACH ATTEMPT SENT +// (waitreport.go's askedLane), and the two part company exactly once: on the +// rung that widens. It takes `only` and `allow_fallbacks` off and leaves +// `order` standing, so a lane the choice merely RANKED is still this body's ask +// and a lane it DEMANDED is not — and a pinned request's bare retry was +// reported under the name of the machine whose refusal it was recovering from. +func recordedLane(knobs callKnobs, asked string) string { + if asked == "" || knobs.carriesTheDemand() { + return asked + } + if knobs.laneChoice != nil && len(knobs.laneChoice.Order) > 0 && !knobs.noProvider { + return asked + } + return "" +} + // builtString reads a builder that may never have been made. The streamed // path only builds one while a record is open, and the emptiness law is the // same in a struct literal as it is on a screen: nothing recorded is nothing @@ -726,12 +757,25 @@ func (c *Client) emptyAtCeiling(request *ai.Request, response *ai.Response) bool // words (endpoints.go's relaxRungs). It is the same table the retry line a // person watches is built from, so the log and the surface can never call the // same rung two things. -func relaxNames(set relaxSet) []string { +// +// AND A RUNG THAT TAKES OFF MORE THAN ONE FIELD NAMES THE ONES THIS BODY HAD +// (endpoints.go's [relaxStep.took]). The row is read to find out what changed +// about a request, so a rung's general word is only worth writing where it is +// also the particular truth — and the widening rung's was not, on every pinned +// request there has ever been. +func (c *Client) relaxNames(model string, knobs callKnobs) []string { var names []string for _, rung := range relaxRungs { - if set.has(rung.bit) { - names = append(names, rung.name) + if !knobs.relaxed.has(rung.bit) { + continue + } + if rung.took != nil { + if took := c.widenedNames(model, knobs); len(took) > 0 { + names = append(names, took...) + continue + } } + names = append(names, rung.name) } return names } diff --git a/internal/provider/client.go b/internal/provider/client.go index bad5424b75..3f53592e78 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -453,6 +453,18 @@ type callKnobs struct { trace *callTrace } +// carriesTheDemand reports whether the body THIS attempt encodes still asks for +// one named machine — whether `provider.only` is going out at all. +// +// IT IS ASKED OF THE KNOBS AND ANSWERED FROM THE TWO THINGS THAT TAKE THE FIELD +// OFF, so that the log and the encoder cannot disagree about a request that has +// already travelled: [relaxEndpointFilter] is the rung that drops every +// membership restriction (velocity.go's [relaxedPreferences]) and [noProvider] +// is the one encode that sends no preference object at all. +func (k callKnobs) carriesTheDemand() bool { + return !k.noProvider && !k.relaxed.has(relaxEndpointFilter) +} + func knobsFrom(ctx context.Context) callKnobs { knobs := callKnobs{ cacheKey: CacheKeyFrom(ctx), @@ -508,17 +520,6 @@ func (c *Client) modelFor(request *ai.Request) string { // way out rather than at each return so that no future rung can be added past // it and quietly keep a dead pin. func (c *Client) sendShaped(ctx context.Context, request *ai.Request, knobs callKnobs, stream bool) (*http.Response, error) { - // AND ANYTHING A PERSON IS STILL OWED IS SAID HERE, on the first request of - // theirs that goes out after it was learned (lanepin.go's - // [tellRetiredPins]). It is one nil check on a call nobody is reading and - // on every call after the sentence has been said. - tellRetiredPins(ctx) - tellUncarriedPins(ctx) - // AND THE TAKEOVER SAYS ITSELF THROUGH THE SAME DOOR (routefirst.go). One - // more parked sentence, one more nil check; the three share the two - // conditions above because they are the same promise about three different - // rows. - tellTakeover(ctx) // AND THE MODEL IS WRITTEN DOWN BEFORE THE REQUEST LEAVES. This is the one // door every send passes through, and since the adapter stopped changing the // model (endpoints.go) every attempt below this line is on the model named diff --git a/internal/provider/dispatch.go b/internal/provider/dispatch.go index 1a87e720d4..e6c67c74ca 100644 --- a/internal/provider/dispatch.go +++ b/internal/provider/dispatch.go @@ -240,6 +240,26 @@ func (c *Client) dispatchPlan(ctx context.Context, request *ai.Request, knobs ca // empty document. AND ITS BYTES ARE REBUILT WITH IT — see THE BODY IS WRITTEN // PER ATTEMPT above. func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, body []byte, stream bool) (*http.Response, error) { + // ANYTHING A PERSON IS STILL OWED IS SAID HERE, ON THE VERY NEXT BODY THAT + // GOES OUT ON A STREAM THEY ARE READING (lanepin.go's [tellRetiredPins], + // prefcarry.go's [tellUncarriedPins], routefirst.go's [tellTakeover]). Each + // is one nil check on a call nobody is reading and on every call after its + // sentence has been said. + // + // IT IS HERE AND NOT AT THE SHAPED DOOR ABOVE IT, and the move is the fix. + // [Client.sendShaped] is entered once per call, so a fact the call learned + // FROM ITS OWN REFUSAL had already gone past the only place that says it: + // the widened retry a retired pin earns goes out through + // [Client.attemptShaped], and every rung of the relaxation ladder likewise, + // and none of them come back through the shaped door. Measured on + // 2026-09-13: a turn demanded a machine the account excludes, collected the + // router's refusal, retired the person's pin, and answered from another + // machine — and the sentence saying so was queued behind a door the rest of + // that turn never opened again. This is the door every BODY passes through, + // which is the grain the promise was written at. + tellRetiredPins(ctx) + tellUncarriedPins(ctx) + tellTakeover(ctx) var lastErr error // The wait is sized for the reply the request PERMITS — the caller's answer // plus the thinking pass's room — and not for the caller's figure alone. diff --git a/internal/provider/endpoints.go b/internal/provider/endpoints.go index c9b757772a..e54e728456 100644 --- a/internal/provider/endpoints.go +++ b/internal/provider/endpoints.go @@ -465,6 +465,19 @@ type relaxStep struct { label string // name is the same fact in the terminal error's list of what was stripped. name string + // took is what this rung really took off ONE body, for the rung that takes + // off more than one field. Nil is a rung whose `name` is the whole answer. + // + // IT EXISTS BECAUSE ONE WORD WAS A LIE ON HALF THE ROWS. The first rung + // drops every membership restriction at once, and the table could hold only + // one name for it — so a widened retry always claimed to have dropped + // `provider.require_parameters`, including the retry of a request whose + // whole problem was `provider.only`, and including a `simple` request that + // had never sent `require_parameters` at all. The measured row (2026-09-13, + // a retired pin's bare retry): `"relaxed":["provider.require_parameters"]` + // over a body carrying no `provider` key, under a `provider` object that had + // been `{"only":["DeepSeek"],"allow_fallbacks":false}` and nothing else. + took func(*providerPrefs) []string } // relaxRungs is every rung there is, in the order they are climbed, and it is @@ -474,7 +487,7 @@ type relaxStep struct { // (calllog.go's relaxNames). A rung named separately in each would have been // two names for one thing the first time either was reworded. var relaxRungs = []relaxStep{ - {bit: relaxEndpointFilter, label: "relaxed the endpoint filter", name: "provider.require_parameters"}, + {bit: relaxEndpointFilter, label: "relaxed the endpoint filter", name: "provider.require_parameters", took: widenedOff}, {bit: relaxPriceCeiling, label: "dropped the price ceiling", name: "provider.max_price"}, {bit: relaxReasoning, label: "removed reasoning", name: "reasoning"}, {bit: relaxMaxTokens, label: "removed max_tokens", name: "max_tokens"}, @@ -483,6 +496,49 @@ var relaxRungs = []relaxStep{ {bit: relaxTools, label: "removed tools", name: "tools"}, } +// widenedOff is what the first rung really takes off ONE preference object: +// every membership restriction it was carrying, in the order +// [relaxedPreferences] removes them and spelled the way the wire spells them. +// +// A FIELD THE OBJECT WAS NOT CARRYING IS NOT NAMED, which is the whole point — +// the row says what changed about THIS request rather than what the rung is +// capable of changing. An object that had nothing to widen names nothing, and +// the caller falls back on the rung's own word so a row is never left silent +// about a rung it climbed. +func widenedOff(prefs *providerPrefs) []string { + if prefs == nil { + return nil + } + var names []string + // THE DEMAND IS NAMED FIRST BECAUSE IT IS THE ONE THAT MATTERED. `only` + // plus `allow_fallbacks: false` is the narrowest filter this process sends, + // and a refusal earned under it is the router saying the set is empty + // (velocity.go's [relaxedPreferences] says the same thing from the other + // side). + if len(prefs.Only) > 0 { + names = append(names, "provider.only") + } + if prefs.RequireParameters != nil { + names = append(names, "provider.require_parameters") + } + if len(prefs.Ignore) > 0 { + names = append(names, "provider.ignore") + } + return names +} + +// widenedNames is [widenedOff] asked about the object THIS CALL would have sent +// before the ladder touched it, which is the two halves the rung strips — +// what the ledger and the row composed, plus a rescue's own demand — and +// deliberately not [Client.wirePreferences], whose job includes teaching the +// ledger and which no log line may run. +func (c *Client) widenedNames(model string, knobs callKnobs) []string { + unwidened := knobs + unwidened.relaxed &^= relaxEndpointFilter + unwidened.noProvider = false + return widenedOff(hedgePreference(c.providerPreferences(model, unwidened), unwidened)) +} + // rung is one row of the table above. A bit with no row is a programming error // rather than a runtime one, and it returns a step that names nothing. func rung(bit relaxSet) relaxStep { diff --git a/internal/provider/lanes.go b/internal/provider/lanes.go index 0ef0fb2dc6..51cce84b58 100644 --- a/internal/provider/lanes.go +++ b/internal/provider/lanes.go @@ -520,7 +520,28 @@ func (c *Client) drawLaneChoice(knobs callKnobs, model string, request *ai.Reque // absence). if strategy == RoutingSimple { named := pin.pinned() - if named == "" || retired { + // AND THE ROW GOVERNS THE CALLS IT IS NAMED FOR. The row is `lane.talk` + // (internal/config's LaneSlotTalk) and the slot is the whole scope: the + // person's own turn is the talk, and the errands that run beside one — + // the title, the memory reflex, the route question, a hand asking about + // a document, a subharness node — are not. + // + // THE MEASURED COST OF NOT SAYING SO (2026-09-13). One turn under + // `simple`, pinned to a machine the account excludes, demanded that + // machine on all three of its calls and paid three separate 404s: the + // turn on the chat model, the title on the reflex tier's own model, and + // the memory reflex on a third model the pinned machine does not serve + // at all. The retirement is written per PAIRING, so each new model is a + // fresh round trip — and two of the three were errands nobody asked for + // on machines nobody pinned. + // + // THE SCOPE IS READ FROM THE ROLE THE CALLER ALREADY STAMPED + // (internal/lane's roles.go), which is the same reading that decides + // whether a person is told when the wire refuses the pin (lanepin.go's + // tellRetiredPins). One predicate, so the machine a person is asked for + // and the sentence they get when it is refused can never belong to two + // different sets of calls. + if named == "" || retired || !knobs.role.Visible() { return lanes.Choice{}, false } ask := c.laneRequest(model, knobs, request, c.laneValueOfTime(knobs)) diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index fafd4dc704..acbede7e03 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -1,9 +1,13 @@ package provider import ( + "net/http" + "slices" "strings" "testing" + "github.com/Agent-Field/aforge-v2/internal/calllog" + lanes "github.com/Agent-Field/aforge-v2/internal/lane" "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -234,7 +238,7 @@ func TestSimpleRoutingLeavesAPinTheRankedRoadWouldHaveStoodDown(t *testing.T) { // down: the saved exclusion stands the pin down before the wire is asked. installedRow(t, "") rig.client.config.Routing = nil - if choice, made := rig.client.drawLaneChoice(callKnobs{}, rig.model, request); made && len(choice.Only) > 0 { + if choice, made := rig.client.drawLaneChoice(turnKnobs(), rig.model, request); made && len(choice.Only) > 0 { t.Fatalf("the ranked road demanded %v for a machine the account excludes", choice.Only) } if !pinRetired("Ghost", rig.model) { @@ -245,7 +249,7 @@ func TestSimpleRoutingLeavesAPinTheRankedRoadWouldHaveStoodDown(t *testing.T) { // handed nothing, now demands the machine the person wrote. forgetRetiredPins() installedRow(t, RoutingSimple) - choice, made := rig.client.drawLaneChoice(callKnobs{}, rig.model, request) + choice, made := rig.client.drawLaneChoice(turnKnobs(), rig.model, request) if !made || len(choice.Only) != 1 || !strings.EqualFold(choice.Only[0], "Ghost") { t.Fatalf("under simple the draw came out %+v, want the one machine the person pinned", choice) } @@ -256,3 +260,98 @@ func TestSimpleRoutingLeavesAPinTheRankedRoadWouldHaveStoodDown(t *testing.T) { t.Fatal("the chrome would say nothing over a request that demands the machine") } } + +// turnKnobs is one call made for the person's own turn, which is the scope the +// `lane.talk` row governs. It is spelled here rather than [callKnobs] with its +// zero role because a zero role reads as a hidden background errand +// (internal/lane's RoleUnknown) and a draw asked about one of those is a draw +// asked the other question. +func turnKnobs() callKnobs { return callKnobs{role: lanes.RoleTalk} } + +// ── THE ROW GOVERNS THE CALLS IT IS NAMED FOR ─────────────────────────────── +// +// THE MEASURED FAILURE (2026-09-13, a real drive). One turn under `simple`, +// pinned to a machine the account excludes, paid THREE 404s: the turn on the +// chat model, the naming errand on the reflex tier's own model, and the memory +// reflex on a third model the pinned machine does not serve at all. A pairing +// is retired once, so each new model an errand reaches for is a fresh refused +// round trip — bought for calls nobody is reading, on machines nobody pinned. +// +// The row is `lane.talk` and the slot is the whole scope: the person's turn +// carries the demand and the errands beside it go bare. +func TestUnderSimpleOnlyThePersonsOwnTurnCarriesTheTalkPin(t *testing.T) { + rig := newLaneRig(t, "simple/errands-go-bare", retiredLanes()...) + forgotten(t) + rig.client.config.Routing = StaticRouting(RoutingSimple) + pinned(t, LanePin{Lane: "Ghost"}) + request := &ai.Request{Model: rig.model, Messages: userMessages("hello")} + + choice, made := rig.client.drawLaneChoice(turnKnobs(), rig.model, request) + if !made || len(choice.Only) != 1 || !strings.EqualFold(choice.Only[0], "Ghost") { + t.Fatalf("the person's own turn drew %+v, want the one machine they pinned", choice) + } + // EVERY ERRAND THE TURN RUNS BESIDE ITSELF. The roles are internal/lane's + // own words for them, and the point of listing all four is that the scope is + // read from the table rather than from a list kept here: whatever else is + // added to that table arrives already answered. + for _, errand := range []lanes.Role{ + lanes.RoleAuxiliary, lanes.RoleMemory, lanes.RoleRecall, + lanes.RoleTool, lanes.RoleJudge, lanes.RoleLeafUnattended, lanes.RoleUnknown, + } { + knobs := callKnobs{role: errand} + if choice, made := rig.client.drawLaneChoice(knobs, rig.model, request); made { + t.Fatalf("a %q errand drew %+v, want no preference at all", errand, choice) + } + } +} + +// ── THE ROW SAYS WHAT THE RETRY REALLY CARRIED ────────────────────────────── +// +// THE MEASURED ROW (2026-09-13). A pin was refused, retired, and the widened +// retry went out with no `provider` key at all — and its start row read +// `"lane":"DeepSeek","served":"DeepSeek","relaxed":["provider.require_parameters"]`. +// Every word of that is about the request before it. The lane and the served +// machine name the one machine that had just refused to answer; the relaxed +// list names a field a `simple` request never sends, while the field that +// actually came off was the demand. +func TestTheWidenedRetryOfARetiredPinIsLoggedAsTheBareRequestItIs(t *testing.T) { + read := loggingTo(t) + rig := newLaneRig(t, "simple/retry-row", retiredLanes()...) + forgotten(t) + rig.client.config.Routing = StaticRouting(RoutingSimple) + pinned(t, LanePin{Lane: "Ghost"}) + + if _, err := rig.client.CompleteWithMessages(talking(), userMessages("hello")); err != nil { + t.Fatalf("the turn died on a refusal the widened retry was supposed to absorb: %v", err) + } + + var demand, widened *calllog.Record + for _, record := range read() { + row := record + switch { + case row.Status == http.StatusNotFound && demand == nil: + demand = &row + case demand != nil && row.Relaxed != nil && widened == nil: + widened = &row + } + } + if demand == nil || widened == nil { + t.Fatalf("the log holds no refused ask and widened retry to compare: %+v", read()) + } + // THE REFUSED ASK IS UNCHANGED. It really did demand the machine, so it + // really does name it — this half is the control on the other. + if !strings.EqualFold(demand.Lane, "Ghost") { + t.Fatalf("the refused ask names lane %q, want the machine it demanded", demand.Lane) + } + if widened.Lane != "" || widened.Served != "" && strings.EqualFold(widened.Served, "Ghost") { + t.Fatalf("the bare retry is logged as lane %q served %q, want a row that names no machine it did not ask for", + widened.Lane, widened.Served) + } + if !slices.Contains(widened.Relaxed, "provider.only") { + t.Fatalf("the bare retry says it relaxed %v, want the demand it actually withdrew", widened.Relaxed) + } + if slices.Contains(widened.Relaxed, "provider.require_parameters") { + t.Fatalf("the bare retry says it dropped %v, and a simple request never sent require_parameters", + widened.Relaxed) + } +} diff --git a/internal/session/retiredpin_wire_test.go b/internal/session/retiredpin_wire_test.go new file mode 100644 index 0000000000..761a538f45 --- /dev/null +++ b/internal/session/retiredpin_wire_test.go @@ -0,0 +1,149 @@ +package session + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/home" + lanes "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/lane/lanestub" + "github.com/Agent-Field/aforge-v2/internal/modelsource" + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +// ── A PIN THE WIRE REFUSES IS RETIRED WHERE THE PERSON CAN SEE IT ─────────── +// +// THE MEASURED FAILURE (2026-09-13, a real drive against the live router). A +// conversation on `routing: simple`, pinned to a machine the account's own +// settings exclude, sent the demand, collected the router's 404, retired the +// pin and answered from another machine — and said nothing. The status line +// dropped `@deepseek` between one turn and the next and no sentence anywhere +// explained why, which is the exact complaint the mode exists to close. +// +// THE PROOF IS AT THIS LAYER AND NOT AT THE TRANSPORT'S, because the transport +// already had a test of its own that passed. What it could not see is the rest +// of the road the sentence has to travel: the turn's own observer, the loop +// that reads the stream, the events a surface is handed. A fact that is emitted +// into a channel nobody in the product is listening on is a fact nobody is +// told. +func TestAPinTheWireRefusesTellsTheConversationSo(t *testing.T) { + // NO TEST WRITES THE REAL HOME: the lane registry's store lives under it. + t.Setenv(home.EnvVar, t.TempDir()) + const model = "stub/talk" + // THE PINNED MACHINE IS ONE THE ROUTER DOES NOT SERVE FOR THIS MODEL, which + // is the measured shape: the router publishes its serving set, the machine a + // person pinned is not in it, and the demand earns `…but your request's + // provider.only preference permits only: deepseek`. + server := lanestub.New(model, lanestub.Lane{Name: "StreamLake", Profile: lanestub.Profile{Tokens: 3}}) + t.Cleanup(server.Close) + // The stub publishes an endpoints page and names its lane on every answer, + // so it really is a router and saying so is stating a fact about the + // fixture (internal/provider's hedge_test.go makes the same statement). + lanes.HeardPrefsCarried(server.URL()) + + provider.RepinLane(provider.LanePin{Lane: "DeepSeek"}) + t.Cleanup(func() { provider.RepinLane(provider.LanePin{}) }) + + agent, err := New(Config{ + Workspace: t.TempDir(), + Model: model, + Routing: provider.RoutingSimple, + Sources: modelsource.NewSet(modelsource.Connected{ + Source: modelsource.DefaultSource(server.URL()), + Key: "test-key", + Address: server.URL(), + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + + said := turnLines(t, agent, "say only the word ok") + want := provider.RetiredPinLine("DeepSeek") + if !saidLine(said, want) { + t.Fatalf("the pin was retired without telling anybody.\nwant a line reading %q\ngot %q", want, said) + } + // AND THE DEMAND REALLY WENT OUT FIRST. Without this the test would pass on + // a build that never asked for the machine at all, which is the other way + // of breaking the same promise. + if asked := server.Requests("DeepSeek"); asked != 1 { + t.Fatalf("DeepSeek was demanded %d times, want exactly the one refusal that taught us", asked) + } + if served := server.Served(); len(served) == 0 { + t.Fatal("nothing answered the turn, so the widened retry never landed") + } +} + +// AND THE ERRANDS BESIDE THE TURN NEVER PAY FOR IT. The row is `lane.talk` and +// the slot is its whole scope (internal/provider's lanes.go): one refused round +// trip a pairing belongs to the person's own turn, and a title, a memory reflex +// or a route question demanding somebody's pinned machine on a model of its own +// is a second and a third 404 nobody asked for. Measured the same afternoon: +// one turn, three demands, three refusals, two of them errands. +func TestAnErrandBesideTheTurnDoesNotCarryTheTalkPin(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + const model = "stub/talk" + server := lanestub.New(model, lanestub.Lane{Name: "StreamLake", Profile: lanestub.Profile{Tokens: 3}}) + t.Cleanup(server.Close) + lanes.HeardPrefsCarried(server.URL()) + provider.RepinLane(provider.LanePin{Lane: "DeepSeek"}) + t.Cleanup(func() { provider.RepinLane(provider.LanePin{}) }) + + agent, err := New(Config{ + Workspace: t.TempDir(), + Model: model, + Routing: provider.RoutingSimple, + Sources: modelsource.NewSet(modelsource.Connected{ + Source: modelsource.DefaultSource(server.URL()), + Key: "test-key", + Address: server.URL(), + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + + for _, ask := range server.Asks() { + if len(ask.Only) > 0 { + t.Fatalf("a request demanded %v before the person had asked anything", ask.Only) + } + } + turnLines(t, agent, "say only the word ok") + if asked := server.Requests("DeepSeek"); asked != 1 { + t.Fatalf("DeepSeek was demanded %d times over one turn, want the turn's own single ask", asked) + } +} + +// turnLines runs one turn and hands back every line the person would have read +// about it — the notices the surface draws as well as the answer. +func turnLines(t *testing.T, agent *Agent, text string) []string { + t.Helper() + ctx, cancel := deadline(20 * time.Second) + defer cancel() + events, err := agent.Submit(ctx, text) + if err != nil { + t.Fatalf("Submit(%q): %v", text, err) + } + var said []string + for event := range events { + switch event.Kind { + case EventError: + t.Fatalf("turn errored: %v", event.Err) + case EventNotice: + said = append(said, event.Text) + } + } + return said +} + +func saidLine(said []string, want string) bool { + for _, line := range said { + if strings.Contains(line, want) { + return true + } + } + return false +} From e1e9dd0946c9e6ec9bf8265c0706e9de5a90ff49 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:03:33 -0400 Subject: [PATCH 07/22] provider, session, tui3: news about a person's own row stops travelling as machinery narration, and the work chip stops swallowing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE SENTENCE WAS EMITTED ALL ALONG AND DIED AT THE SURFACE. The retirement line went out on provider.StreamNotice — the kind the adapter uses for `Retry 1/3: removed max_tokens` — so internal/session drew it as an EventNotice and internal/tui3 wrote it as an ordinary note, between the question and the answer, where deriveWorkfolds swallows it. The measured drive (2026-09-13): the pin was demanded, refused, retired, `@deepseek` came off the model word, another machine answered the turn, and `▸ worked 1.6s · thought 0.2s · ctrl+e` sat where the explanation should have been. The session-level test in this commit passes on the branch this fix sits on top of, which is how that was established rather than guessed. A NOTICE AND THIS ARE TWO DIFFERENT SENTENCES. A notice is the adapter saying what it did to a REQUEST to get it accepted, it is over when the answer lands, and a surface is right to fold it in with the rest of the machinery. This is the adapter saying that a ROW THE PERSON WROTE is no longer being sent, and there is nothing to fold it into: it is the only account they will get of why the machine they named stopped appearing, and it asks them to do something about it. So it is its own kind the whole way down — StreamRowNews, EventRowNews, and a note carrying entry.told, which the work chip may not hide. The line saying a base will not carry a lane choice at all travels the same road for the same reason. THE FOLD ALREADY KEPT THIS LAW AND COULD ONLY ASK IT BY LOOKING AT A NOTE'S FIRST WORD. An interrupt's own lines have always been held out of a chip because they are the rows addressed TO the person; the flag is that law said precisely, and the prefix reading stays beside it until its own producers carry the flag. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1019-simple-routing-mode.md | 3 +- internal/manual/chat/lanes.md | 3 ++ internal/provider/endpoints.go | 9 ++++- internal/provider/lanepin.go | 2 +- internal/provider/lanepinretire_test.go | 7 +++- internal/provider/prefcarry.go | 7 +++- internal/provider/stream.go | 22 +++++++++++ internal/session/loop.go | 9 +++++ internal/session/retiredpin_wire_test.go | 25 +++++++++---- internal/session/session.go | 20 ++++++++++ internal/tui3/app.go | 14 +++++++ internal/tui3/feed.go | 18 +++++++++ internal/tui3/workfold.go | 8 +++- internal/tui3/workfold_test.go | 37 +++++++++++++++++++ 14 files changed, 170 insertions(+), 14 deletions(-) diff --git a/docs/changes/unreleased/1019-simple-routing-mode.md b/docs/changes/unreleased/1019-simple-routing-mode.md index d8bde1bfb7..5a6c355cd2 100644 --- a/docs/changes/unreleased/1019-simple-routing-mode.md +++ b/docs/changes/unreleased/1019-simple-routing-mode.md @@ -9,7 +9,8 @@ invalidates: - "The routing row was believed to reach every request this process makes. It did not: `internal/session` handed its own clients the answer and every other client in the binary — the harness, the subharness, `read_document`, `view_image`, a `/model` panel's members — was assembled through `config.Config.ClientConfig`, which carried no routing answer at all, so each of them ran `latency` whatever a person had written. Under `simple` that was silent and costly: a client on the ranked road may stand a strict pin down on the SAVED account-exclusion belief before any wire is asked, the stand-down is process-wide, and the conversation's own next turn then went out bare while the status line still read `@deepseek`. `config.InstallLaneRows` now installs the row beside the lane rows (`provider.InstallRouting`), and a client handed nothing answers it; a client handed an answer still keeps its own." - "A strict pin on a machine `~/.aforge/v3/account-exclusions.json` covers was stood down before the call in every mode. Under `simple` it is not: the pin goes on the wire once, OpenRouter refuses it, and the pin is retired through the one existing door — the `X cannot serve this model; routing on auto for this model until you pin again` note in the conversation, and `@X` coming off the model word in the same breath. One refused round trip a window is what the row's promise costs." - "A pin under `simple` was believed to ride the person's own turn. It rode EVERY call of the turn: one measured turn paid three 404s — the turn on the chat model, the naming errand on the reflex tier's model, and the memory reflex on a third model the pinned machine does not serve at all — because a retirement is written per machine-and-model pairing and each errand reached for a model of its own. The row is `lane.talk` and the slot is now its whole scope: under `simple` the demand rides the calls a person is reading (their turn, and a task room somebody is watching) and every errand beside one — title, memory reflex, route question, a hand asking about a document, a subharness node — goes out bare. One turn, one refusal, one sentence." - - "The retirement sentence was believed to reach the conversation on 'the very next request'. The door that said it was entered ONCE PER CALL (`Client.sendShaped`), and the widened retry a retired pin earns and every rung of the relaxation ladder go out through a lower door — so a line learned from a call's OWN refusal was queued behind a door the rest of that turn never opened again. Measured 2026-09-13: the pin was retired, `@deepseek` came off the model word, another machine answered, and nothing said why. The three parked sentences are now said at the door every BODY passes through (`Client.send`)." + - "The retirement sentence was believed to reach the person. It reached the engine's event stream and died at the surface: it travelled as a `StreamNotice` — the kind the adapter uses for `Retry 1/3: removed max_tokens` — so the chat drew it as ordinary work narration and the turn's work chip swallowed it whole. Measured 2026-09-13: the pin was retired, `@deepseek` came off the model word, another machine answered the turn, and `▸ worked 1.6s · thought 0.2s · ctrl+e` sat where the explanation should have been. News about a row a person WROTE is now its own kind end to end — `provider.StreamRowNews`, `session.EventRowNews`, a note the work chip may not hide — and the same is true of the line saying a base will not carry a lane choice at all." + - "The three parked sentences a person is owed were said at `Client.sendShaped`, which is entered once per CALL — so a line learned from a call's own refusal had nowhere to go until the next call, because the widened retry a retired pin earns and every rung of the relaxation ladder leave through a lower door. They are now said at `Client.send`, the door every BODY passes through." - "A widened retry's model-call row described the request before it. The bare retry of a retired pin carried no `provider` key at all and was logged `lane: DeepSeek, served: DeepSeek, relaxed: [provider.require_parameters]` — the machine that had just refused it, and a field a `simple` request never sends. A row now names a lane only while the body still asks for one, `served` is no longer back-filled from a demand that has been withdrawn, and the widening rung names the fields it really took off this object (`provider.only` first)." - "The lanes page's machinery — the takeover, the closed set, the refusal walk, the probe — read as what aforge does while it is choosing. It is what `latency` and `price` do; under `simple` the routing gate, the belief chooser, the hedge and the probes stay compiled in but are disconnected from the request path, and none of it runs." --- diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index e507b45e87..c0e351e5ff 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -270,6 +270,9 @@ What that means, exactly: demanded at all — routed the way `auto` routes; - **the request that collected the refusal is widened and sent again**, once, so your answer still arrives. If that is refused too the turn ends, and says so; +- **the line stays in the conversation.** It is not one of the dim retry notes + the work chip collapses when an answer lands, so it is still on the screen + after the turn finishes; - **your settings row is not touched.** It still reads `pinned: coreweave`; - **every other model still goes to that machine.** The refusal was about one pairing; diff --git a/internal/provider/endpoints.go b/internal/provider/endpoints.go index e54e728456..6754226ed6 100644 --- a/internal/provider/endpoints.go +++ b/internal/provider/endpoints.go @@ -531,7 +531,14 @@ func widenedOff(prefs *providerPrefs) []string { // before the ladder touched it, which is the two halves the rung strips — // what the ledger and the row composed, plus a rescue's own demand — and // deliberately not [Client.wirePreferences], whose job includes teaching the -// ledger and which no log line may run. +// ledger ([velocityLedger.keepTheSetServable]) and which no log line may run. +// +// WHAT IS LEFT IS IDEMPOTENT, which is the whole reason a row may ask it. The +// one thing [Client.providerPreferences] changes is an affinity pin whose +// endpoint the veto list now covers (affinity.go's [Client.heldEndpoint]), and +// the encode of this same attempt asked the same question of the same veto list +// before the body went out — so the pin is already released and asking again +// answers rather than acts. func (c *Client) widenedNames(model string, knobs callKnobs) []string { unwidened := knobs unwidened.relaxed &^= relaxEndpointFilter diff --git a/internal/provider/lanepin.go b/internal/provider/lanepin.go index 1edb169977..60c295ef2e 100644 --- a/internal/provider/lanepin.go +++ b/internal/provider/lanepin.go @@ -414,7 +414,7 @@ func tellRetiredPins(ctx context.Context) { return } for _, line := range takeRetiredPins() { - Emit(ctx, StreamNotice, line) + Emit(ctx, StreamRowNews, line) } } diff --git a/internal/provider/lanepinretire_test.go b/internal/provider/lanepinretire_test.go index b828f4057f..a3ad3b2fb3 100644 --- a/internal/provider/lanepinretire_test.go +++ b/internal/provider/lanepinretire_test.go @@ -57,7 +57,12 @@ type noticeLog struct { } func (l *noticeLog) observe(event StreamEvent) { - if event.Kind != StreamNotice { + // BOTH KINDS, because what this log is for is what a person READ. A notice + // is the adapter reshaping their request and row news is a row of theirs + // that stopped being sent (stream.go says why they are two kinds), and a + // test that watched only one would pass a build that had quietly moved the + // sentence onto the other. + if event.Kind != StreamNotice && event.Kind != StreamRowNews { return } l.mu.Lock() diff --git a/internal/provider/prefcarry.go b/internal/provider/prefcarry.go index 2eea416957..b3ada3edb7 100644 --- a/internal/provider/prefcarry.go +++ b/internal/provider/prefcarry.go @@ -264,12 +264,17 @@ func parkUncarriedPin(base string) { // does nothing at all on one they are not. The two conditions are // [tellRetiredPins]'s, for its reasons: there has to be a stream to say it on, // and the errand this call belongs to has to be one a person is watching. +// +// AND IT TRAVELS AS [StreamRowNews] FOR THAT FUNCTION'S REASON TOO: this is a +// sentence about a row the person WROTE and not about the shape of a request, +// and a surface that folded it in with the machinery would leave them with a +// pin that silently stopped being sent. func tellUncarriedPins(ctx context.Context) { if ctx == nil || streamObserverFrom(ctx) == nil || !RoleFrom(ctx).Visible() { return } for _, line := range takeUncarriedPins() { - Emit(ctx, StreamNotice, line) + Emit(ctx, StreamRowNews, line) } } diff --git a/internal/provider/stream.go b/internal/provider/stream.go index 62cabf7052..53a972c2f8 100644 --- a/internal/provider/stream.go +++ b/internal/provider/stream.go @@ -107,6 +107,28 @@ const ( // had actually been shown. Everything the consumer drew or buffered for this // response is void, and the response the call returns is the replacement's. StreamReplaced + // StreamRowNews carries one line about A PERSON'S OWN ROW in Delta — a pin + // the wire has refused and this build has stopped sending (lanepin.go), a + // base that has said it will not carry one at all (prefcarry.go). + // + // IT IS [StreamNotice]'s SIBLING AND DELIBERATELY NOT StreamNotice ITSELF, + // for the reason [StreamReplaced] is not: a notice is the adapter saying + // what it did to the person's REQUEST to get it accepted, and a surface is + // free to fold that away with the rest of the machinery once the answer has + // landed. This is the adapter saying that a SETTING they wrote is no longer + // being sent, and there is nothing to fold it into — it is the only account + // they will get of why the machine they named stopped appearing. + // + // THE MEASURED FAILURE (2026-09-13). The retirement sentence went out on + // StreamNotice, arrived as a note, and was swallowed whole by the chat's + // work chip: one drive, `@deepseek` gone from the model word, another + // machine answering, and `▸ worked 1.6s · thought 0.2s · ctrl+e` where the + // explanation should have been. + // + // It is raised BEFORE the stream opens, exactly as StreamNotice is, so a + // surface may receive one on a turn that goes on to produce no + // StreamStarted at all. + StreamRowNews ) // StreamEvent carries provider text as it arrives. Delta is populated only diff --git a/internal/session/loop.go b/internal/session/loop.go index 511b9cebe6..9290b67b90 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -545,6 +545,15 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // it reaches the transcript, and the person sees which attempt they // are on and what was taken off to get there. hub.send(Event{Kind: EventNotice, Text: event.Delta}) + case provider.StreamRowNews: + // A ROW THE PERSON WROTE IS NO LONGER BEING SENT, and this is the + // only place they are told (internal/provider's lanepin.go). It is + // its own kind rather than a notice because a notice is narration + // about one request's shape and this is news about a setting: a + // surface is free to fold the first away once the answer lands and + // must not fold this one, which is exactly what happened to it + // while the two shared a channel. + hub.send(Event{Kind: EventRowNews, Text: event.Delta}) case provider.StreamReplaced: // A rescue on another machine is this step being asked again, so what // the dead machine streamed is void exactly as a cut attempt's is. The diff --git a/internal/session/retiredpin_wire_test.go b/internal/session/retiredpin_wire_test.go index 761a538f45..4d3bc5e504 100644 --- a/internal/session/retiredpin_wire_test.go +++ b/internal/session/retiredpin_wire_test.go @@ -60,10 +60,16 @@ func TestAPinTheWireRefusesTellsTheConversationSo(t *testing.T) { } t.Cleanup(func() { _ = agent.Close() }) - said := turnLines(t, agent, "say only the word ok") + said, other := turnLines(t, agent, "say only the word ok") want := provider.RetiredPinLine("DeepSeek") + // AND IT ARRIVES AS NEWS ABOUT THE PERSON'S OWN ROW rather than as one more + // line about the request's shape, which is the difference a surface acts on: + // a notice is folded into the work chip once the answer lands and this is + // the only account they get of why the machine they named disappeared + // (session.go's [EventRowNews]). if !saidLine(said, want) { - t.Fatalf("the pin was retired without telling anybody.\nwant a line reading %q\ngot %q", want, said) + t.Fatalf("the pin was retired without telling anybody.\nwant a line reading %q\ngot row news %q and notices %q", + want, said, other) } // AND THE DEMAND REALLY WENT OUT FIRST. Without this the test would pass on // a build that never asked for the machine at all, which is the other way @@ -117,9 +123,11 @@ func TestAnErrandBesideTheTurnDoesNotCarryTheTalkPin(t *testing.T) { } } -// turnLines runs one turn and hands back every line the person would have read -// about it — the notices the surface draws as well as the answer. -func turnLines(t *testing.T, agent *Agent, text string) []string { +// turnLines runs one turn and hands back the two kinds of line a person reads +// about it, apart: the news about their own rows, and the notices the adapter +// writes about the request's shape. Two slices rather than one because which +// channel a sentence arrived on is the thing under test. +func turnLines(t *testing.T, agent *Agent, text string) (rowNews, notices []string) { t.Helper() ctx, cancel := deadline(20 * time.Second) defer cancel() @@ -127,16 +135,17 @@ func turnLines(t *testing.T, agent *Agent, text string) []string { if err != nil { t.Fatalf("Submit(%q): %v", text, err) } - var said []string for event := range events { switch event.Kind { case EventError: t.Fatalf("turn errored: %v", event.Err) + case EventRowNews: + rowNews = append(rowNews, event.Text) case EventNotice: - said = append(said, event.Text) + notices = append(notices, event.Text) } } - return said + return rowNews, notices } func saidLine(said []string, want string) bool { diff --git a/internal/session/session.go b/internal/session/session.go index f94deb07f0..d9673491a5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -605,6 +605,26 @@ const ( // opposite of that: it is the one thing about a question that stays true // afterwards. EventQuestionAnswered + // EventRowNews carries one line in Text about A ROW THE PERSON WROTE that + // this build has stopped acting on — a pinned machine the router refuses to + // serve a model from, a base that will not carry a lane choice at all + // (internal/provider's lanepin.go and prefcarry.go). + // + // IT IS NOT [EventNotice] AND THE DIFFERENCE IS WHO THE SENTENCE IS FOR. A + // notice is the adapter saying what it did to a request to get it accepted, + // and it is over once the answer lands — a surface may fold it away with + // the rest of the machinery. This is the only account a person will get of + // why the machine they named has stopped appearing, and there is nothing to + // fold it into: it asks them to do something (pin again, or leave it on + // auto). Measured on 2026-09-13, riding the wrong kind: the pin was + // retired, `@deepseek` came off the model word, another machine answered, + // and the chat's work chip had swallowed the sentence that said so. + // + // IT IS LAST IN THIS BLOCK AND EVERY NEW KIND BELONGS HERE, because a kind + // is an integer on the remote wire (internal/remote's EventWire): one added + // in the middle renumbers every kind under it, and a window and an engine + // on two builds would then disagree about what each other's events were. + EventRowNews ) // TaskReplyTag is the task identity a surface places beside the answer its diff --git a/internal/tui3/app.go b/internal/tui3/app.go index afe94aafea..6437567c4b 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -260,6 +260,20 @@ type entry struct { // asks for it). It is false on every other note, which is nearly all of them. block bool + // told says this note is ADDRESSED TO THE PERSON rather than narration + // about the machinery, so the work chip may not swallow it (workfold.go's + // [deriveWorkfolds]). + // + // THE LAW THE CHIP ALREADY KEEPS, SAID PRECISELY. A chip hides what the + // turn DID between a question and its answer, and a line asking the person + // to do something is not that — the interrupt's own lines have always been + // held out of one for exactly this reason. Until this field the only way to + // ask was to look at the note's first word, so a sentence about a person's + // own pin being refused was folded away whole: measured on 2026-09-13, + // `@deepseek` gone from the model word and `▸ worked 1.6s · ctrl+e` where + // the explanation should have been (session's EventRowNews). + told bool + // context is the NAMED WORKING CONTEXT this turn was routed into, in the // engine's own person-facing words (session's TaskNotice.Context) — and empty // for every ordinary turn, which is nearly all of them. It is set on the diff --git a/internal/tui3/feed.go b/internal/tui3/feed.go index d397e8f38f..aaa1fb40a6 100644 --- a/internal/tui3/feed.go +++ b/internal/tui3/feed.go @@ -290,6 +290,14 @@ func (f *feed) ingestStream(ev session.Event, lump bool) { // reason: it is already being handled, the person only needs to see it. f.note(ev.Text) + case session.EventRowNews: + // A ROW THE PERSON WROTE IS NO LONGER BEING SENT — their pinned machine + // refused this model, the base will not carry a lane choice at all. The + // same dim one-liner, and for the opposite reason: nothing is being + // handled, and this sentence is the whole of what they are told. So it + // is held out of the work chip, which had been swallowing it. + f.toldNote(ev.Text) + case session.EventRetrying: f.retry(ev) @@ -1272,6 +1280,16 @@ func (f *feed) reserveResponseContinuation() { // deliberate command with silence. func (f *feed) note(text string) { f.noteWritten(text, false, nil) } +// toldNote is a note ADDRESSED TO THE PERSON: same dim line, same door, and the +// work chip may not swallow it ([entry.told]). Its one caller today is the news +// that a row they wrote has stopped being sent (session's EventRowNews). +func (f *feed) toldNote(text string) { + f.noteWritten(text, false, nil) + if n := len(f.entries); n > 0 && f.entries[n-1].kind == entryNote { + f.entries[n-1].told = true + } +} + // noteWritten is the one body behind [feed.note] and the chat's two richer doors // ([app.noteFacts], [app.noteBlock]), so the repeat rule, the fact list and the // block flag cannot disagree about what a note is. diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index 08567ab976..73ebbdec3b 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -242,6 +242,12 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { hi++ } answer := -1 + // A NOTE ADDRESSED TO THE PERSON IS NEVER FOLDED AWAY, and the loop + // below asks that two ways because the flag is the law and the first + // word is the older reading of it ([entry.told]). The line that earned + // the flag is a row of theirs that stopped being sent — a pinned + // machine the router refuses — and a chip that hid it left them with a + // pin that disappeared and no sentence anywhere saying why. blocked, stopped := false, false for i := lo; i < hi; i++ { if es[i].kind == entryAssistant && strings.TrimSpace(es[i].text) != "" { @@ -251,7 +257,7 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { stopped = true } if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding || - (es[i].kind == entryNote && strings.HasPrefix(es[i].text, "cancel")) { + (es[i].kind == entryNote && (es[i].told || strings.HasPrefix(es[i].text, "cancel"))) { blocked = true } // A SEAM IS NEVER FOLDED AWAY. A chip hides the machinery between a diff --git a/internal/tui3/workfold_test.go b/internal/tui3/workfold_test.go index f7c823c98f..cc1efa387b 100644 --- a/internal/tui3/workfold_test.go +++ b/internal/tui3/workfold_test.go @@ -174,3 +174,40 @@ func TestOneTurnCountsItsToolCallsInOneWord(t *testing.T) { t.Fatalf("fourteen calls are spelled %q, want %q", got, "14 tool calls") } } + +// TestWorkfoldNeverHidesNewsAboutAPersonsOwnRow is the fold's own law said +// about the line that broke it. +// +// THE MEASURED FAILURE (2026-09-13). A pinned machine was refused by the +// router, the pin was retired, `@deepseek` came off the model word — and the +// one sentence explaining it was written as an ordinary note, drawn between the +// question and the answer, and swallowed whole by `▸ worked 1.6s · thought +// 0.2s · ctrl+e`. A chip hides what the turn DID; it may not hide what the turn +// has to TELL somebody (session's EventRowNews, [entry.told]). +func TestWorkfoldNeverHidesNewsAboutAPersonsOwnRow(t *testing.T) { + base := time.Unix(100, 0) + said := "deepseek cannot serve this model; routing on auto for this model until you pin again" + entries := []entry{ + {kind: entryUser, text: "say only the word ok", turn: 1, began: base}, + {kind: entryNote, text: said, turn: 1, told: true}, + {kind: entryThinking, text: "checking", turn: 1, began: base, ended: base.Add(6 * time.Second), settled: true}, + {kind: entryTool, tool: "read", turn: 1, status: toolOK, began: base.Add(6 * time.Second), ended: base.Add(8 * time.Second)}, + {kind: entryAssistant, text: "ok", turn: 1, settled: true}, + } + if got := deriveWorkfolds(entries, 0); len(got) != 0 { + t.Fatalf("the chip swallowed a sentence addressed to the person: %#v", got) + } + a := newTestApp(&fakeAgent{model: "m"}) + a.entries, a.workMode = entries, config.WorkFold + a.touch() + if got := strings.Join(plainRows(a), "\n"); !strings.Contains(got, said) { + t.Fatalf("the retirement sentence is not on the screen:\n%s", got) + } + // AND AN ORDINARY NOTE IS STILL WORK. Without this the test would pass on a + // build that had simply stopped folding, which hides nothing and says + // nothing either. + entries[1].told = false + if got := deriveWorkfolds(entries, 0); len(got) != 1 { + t.Fatalf("an ordinary note stopped the chip forming at all: %#v", got) + } +} From 5321d402b3afe90042aa6c93d8c8e71ea7ef1c68 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:11:40 -0400 Subject: [PATCH 08/22] provider, tui3: a row that ends an attempt describes that attempt's body, and the new kind takes its seat at both pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE START ROW WAS HONEST AND THE ROW THAT ENDED IT WAS NOT. The widen and the relaxation ladder hand a COPY of the knobs down, so the door that records the answer is still holding the caller's unrelaxed original — and the bare retry of a retired pin was written `"lane":"DeepSeek"` on the way out of a body with no `provider` key. The trace already keeps each attempt's own knobs for exactly this reason; a row that ends an attempt now reads them. AND THE SALIENCE TABLE HAS HEARD OF THE NEW KIND. internal/tui3 fails the build on an event kind no page has considered, which is the gate working: a pin of the WINDOW'S that has stopped being sent is news a node's room owes its reader too, because the node is running on that row as well. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/provider/calllog.go | 40 ++++++++++++++++--- internal/provider/simplemode_verify_test.go | 43 +++++++++++++-------- internal/tui3/salience_test.go | 6 +++ internal/tui3/workfold_test.go | 5 ++- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/internal/provider/calllog.go b/internal/provider/calllog.go index e41cc32687..b030ca534d 100644 --- a/internal/provider/calllog.go +++ b/internal/provider/calllog.go @@ -363,6 +363,9 @@ func (c *Client) record(facts recordFacts) { // is what the headless waiting line reads, and the file's own switch is // read where the file is written. model := c.modelFor(facts.request) + // THE BODY THIS ROW IS ABOUT, which on a row that ENDS an attempt is not the + // caller's current one (bodyKnobs). + body := bodyKnobs(facts) record := calllog.Record{ Time: logNow().Format("2006-01-02T15:04:05.000Z07:00"), // The run this call belongs to, read off the context the caller @@ -375,7 +378,7 @@ func (c *Client) record(facts recordFacts) { Tag: callTag(facts.ctx), Node: callNode(facts.ctx), Model: model, - Served: recordedServed(facts), + Served: recordedServed(facts, body), Effort: c.recordedEffort(model, facts.knobs), EffortPin: c.recordedEffortPin(model, facts.knobs), // The ceiling that TRAVELLED, from the one function that works it out @@ -388,7 +391,7 @@ func (c *Client) record(facts recordFacts) { Tools: len(facts.request.Tools), Stream: facts.stream, Attempt: facts.attempt, - Relaxed: c.relaxNames(model, facts.knobs), + Relaxed: c.relaxNames(model, body), Status: facts.status, Millis: logNow().Sub(facts.began).Milliseconds(), Learned: facts.learned, @@ -436,7 +439,7 @@ func (c *Client) record(facts recordFacts) { // THIS attempt, so that the row of a rescue says what the rescue did and // the row of the request it rescued says what happened to that one. if wait, watched := streamWatchFrom(facts.ctx).facts(); watched { - record.Lane = recordedLane(facts.knobs, wait.lane) + record.Lane = recordedLane(body, wait.lane) record.HazardCeilingMs = wait.deadline.Milliseconds() // WHAT WAS PLANNED AND WHAT HAPPENED ARE TWO FIELDS, AND THE ROW MAY // CARRY BOTH. The ceiling above is when the watch was going to start @@ -597,11 +600,38 @@ func firstTokenAfter(began, first time.Time) time.Duration { // another machine's refusal (docs/design/recovery/DESIGN.md §1's third // reading). Filling an empty `served` with `lane` would make that disagreement // permanently invisible, so it is never done. -func recordedServed(facts recordFacts) string { +func recordedServed(facts recordFacts, body callKnobs) string { if served := strings.TrimSpace(facts.served); served != "" { return served } - return soleDemandedLane(facts.knobs) + return soleDemandedLane(body) +} + +// bodyKnobs is the knobs describing the request THIS ROW IS ABOUT. +// +// A ROW THAT ENDS AN ATTEMPT IS ABOUT THAT ATTEMPT'S BODY AND NOT THE CALLER'S +// CURRENT ONE. The widen and the ladder relax a COPY of the knobs and hand it +// down, so the door that records the answer is still holding the unrelaxed +// original — and a row built from that said the bare retry of a retired pin had +// gone to the very machine whose refusal it was recovering from +// (`"lane":"DeepSeek"` over a body with no `provider` key, measured +// 2026-09-13). The trace already keeps each attempt's own knobs for exactly +// this reason ([openAttempt]); this is the second reader of them. +// +// THE THREE CASES ARE ONE RULE. A start row is written before the attempt is +// tracked, so nothing is open and the caller's knobs are the body's. An +// ordinary end row is written while its own attempt is still open, so the open +// one is this row's. And the unconditional closing row ([Client.closeOpenAttempt]) +// has already taken the attempt off the trace and is CARRYING its knobs, so the +// caller's are the body's again. +func bodyKnobs(facts recordFacts) callKnobs { + if facts.phase == calllog.PhaseStart { + return facts.knobs + } + if trace := facts.knobs.trace; trace != nil && trace.open != nil { + return trace.open.knobs + } + return facts.knobs } // soleDemandedLane is the one machine THIS ATTEMPT'S BODY was pinned to, and "" diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index acbede7e03..000bc311b2 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -325,33 +325,44 @@ func TestTheWidenedRetryOfARetiredPinIsLoggedAsTheBareRequestItIs(t *testing.T) t.Fatalf("the turn died on a refusal the widened retry was supposed to absorb: %v", err) } - var demand, widened *calllog.Record - for _, record := range read() { + rows := read() + var demand *calllog.Record + var widened []calllog.Record + for _, record := range rows { row := record switch { case row.Status == http.StatusNotFound && demand == nil: demand = &row - case demand != nil && row.Relaxed != nil && widened == nil: - widened = &row + case demand != nil && row.Relaxed != nil: + widened = append(widened, row) } } - if demand == nil || widened == nil { - t.Fatalf("the log holds no refused ask and widened retry to compare: %+v", read()) + // BOTH ROWS OF THE RETRY, and both of them on purpose: the start row and + // the row that ends it are written from different hands, and until this + // wave the second was built from the knobs the CALLER was still holding + // rather than from the body that travelled (calllog.go's bodyKnobs). + if demand == nil || len(widened) < 2 { + t.Fatalf("the log holds no refused ask and no pair of widened rows to compare: %+v", rows) } // THE REFUSED ASK IS UNCHANGED. It really did demand the machine, so it // really does name it — this half is the control on the other. if !strings.EqualFold(demand.Lane, "Ghost") { t.Fatalf("the refused ask names lane %q, want the machine it demanded", demand.Lane) } - if widened.Lane != "" || widened.Served != "" && strings.EqualFold(widened.Served, "Ghost") { - t.Fatalf("the bare retry is logged as lane %q served %q, want a row that names no machine it did not ask for", - widened.Lane, widened.Served) - } - if !slices.Contains(widened.Relaxed, "provider.only") { - t.Fatalf("the bare retry says it relaxed %v, want the demand it actually withdrew", widened.Relaxed) - } - if slices.Contains(widened.Relaxed, "provider.require_parameters") { - t.Fatalf("the bare retry says it dropped %v, and a simple request never sent require_parameters", - widened.Relaxed) + for _, row := range widened { + if row.Lane != "" { + t.Fatalf("a row about the bare retry names lane %q, want no machine: the body asked for none", row.Lane) + } + if strings.EqualFold(row.Served, "Ghost") { + t.Fatalf("a row about the bare retry says %q served it, and %q had refused the request before it", + row.Served, row.Served) + } + if !slices.Contains(row.Relaxed, "provider.only") { + t.Fatalf("the bare retry says it relaxed %v, want the demand it actually withdrew", row.Relaxed) + } + if slices.Contains(row.Relaxed, "provider.require_parameters") { + t.Fatalf("the bare retry says it dropped %v, and a simple request never sent require_parameters", + row.Relaxed) + } } } diff --git a/internal/tui3/salience_test.go b/internal/tui3/salience_test.go index 083bed41fb..d9ec359c93 100644 --- a/internal/tui3/salience_test.go +++ b/internal/tui3/salience_test.go @@ -171,6 +171,12 @@ var salienceTable = []salienceCase{ ev: session.Event{Kind: session.EventQuestionAnswered, ID: 1, Question: &session.Question{ ID: 1, Kind: session.QuestionConsent, Head: "needs your ok to run bash", }, Answer: &session.Answer{Kind: session.QuestionConsent, ID: 1, Key: "1"}}}, + // A ROW THE PERSON WROTE IS NO LONGER BEING SENT, and a node's room draws it + // for the reason the lens exists: the pin is the WINDOW'S row and the node + // is running on it too, so somebody watching a node's work is owed the same + // sentence about why the machine they named stopped answering. + {name: "EventRowNews", ev: session.Event{Kind: session.EventRowNews, + Text: "deepseek cannot serve this model; routing on auto for this model until you pin again"}}, } // errSalience is the engine failing, in the one shape both pumps read. diff --git a/internal/tui3/workfold_test.go b/internal/tui3/workfold_test.go index cc1efa387b..d1fd24e79a 100644 --- a/internal/tui3/workfold_test.go +++ b/internal/tui3/workfold_test.go @@ -200,7 +200,10 @@ func TestWorkfoldNeverHidesNewsAboutAPersonsOwnRow(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.entries, a.workMode = entries, config.WorkFold a.touch() - if got := strings.Join(plainRows(a), "\n"); !strings.Contains(got, said) { + // THE SENTENCE WRAPS AT THIS WIDTH, so the assertion is on the half that + // carries the fact rather than on the whole line: what is under test is + // whether the chip HID it, and a wrapped line is on the screen. + if got := strings.Join(plainRows(a), "\n"); !strings.Contains(got, "deepseek cannot serve this model") { t.Fatalf("the retirement sentence is not on the screen:\n%s", got) } // AND AN ORDINARY NOTE IS STILL WORK. Without this the test would pass on a From 37c65c9cb5fd2b7e4e380b6267de8f581354f264 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:29:50 -0400 Subject: [PATCH 09/22] cmd/aforge: the headless door says who it is running for, so a person's pin travels with `aforge exec` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aforge exec` named no role at all. Every call it made therefore read as lane.RoleUnknown — the conservative default, a hidden background errand — and once the `simple` row scoped a pin to the calls a person is reading, a pinned `aforge exec` put `provider: null` on the wire while the row still named a machine. Measured 2026-09-13 on the live router: request body `provider= null`, one 200 from StreamLake, and no sentence anywhere, which is the same silent shape this branch exists to close arriving through a different door. A PERSON TYPED THE COMMAND AND IS WAITING FOR ITS ANSWER, so the door says so: lane.RoleLeafAttached, which is the leaf with somebody in front of it. It is a leaf and not the talk because this command is one leaf's work — no turn loop, no room, no transcript — and it is attached because the wait is theirs. The role also carries a person's patience and prices a second of their wait as a person's, on every routing row rather than only under `simple`. AND THE DOOR IS THE MARK. There is no env var or flag to key on because nothing in this binary launches `aforge exec` as a child: every spawner builds its leaves in process — subharness.go's buildLinear, chatv3_subharness.go's registry, and the subharness's own asks in subharness_env.go, which already names lane.RoleLeafUnattended itself — and not one of them reaches runExec. So arriving at that function IS the fact that a person typed the command, and a spawned node keeps the unattended reading and goes bare. No predicate was added to lanes.go: knobs.role.Visible() remains the one scoping, and the provider test now drives both sides of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/exec.go | 30 ++++++++++++- cmd/aforge/exec_role_test.go | 42 +++++++++++++++++++ .../unreleased/1019-simple-routing-mode.md | 1 + internal/manual/chat/lanes.md | 11 ++--- internal/provider/simplemode_verify_test.go | 13 ++++-- 5 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 cmd/aforge/exec_role_test.go diff --git a/cmd/aforge/exec.go b/cmd/aforge/exec.go index ed3f2f28bc..da6ebff8e6 100644 --- a/cmd/aforge/exec.go +++ b/cmd/aforge/exec.go @@ -16,6 +16,8 @@ import ( "github.com/Agent-Field/aforge-v2/internal/config" "github.com/Agent-Field/aforge-v2/internal/ctxbudget" "github.com/Agent-Field/aforge-v2/internal/exec" + lanes "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/provider" "github.com/Agent-Field/aforge-v2/internal/trace" ) @@ -130,7 +132,7 @@ func runExec(args []string) error { defer cancel() } ctx = settings.Context(ctx, prompt) - execCtx := settings.ExecContext(ctx) + execCtx := execCallContext(settings.ExecContext(ctx)) space, err := exec.NewWorkspace(*workspace) if err != nil { @@ -451,6 +453,32 @@ func execFailureWords(runErr error) string { // about who did the work. const execNodeKey = "task-1" +// execCallContext says who the calls of this command are made for. +// +// A PERSON TYPED `aforge exec` AND IS WAITING FOR ITS ANSWER. That is the whole +// claim, and the role table is where it has consequences: what a second of the +// wait is worth, how long the run waits on a silent machine before something is +// done about it, and — since the `simple` routing row — whether the machine +// they pinned is demanded at all (internal/provider's lanes.go). Until this +// line the door named no role, so every call it made read as +// [lane.RoleUnknown] — a hidden background errand — and a pinned `aforge exec` +// under `simple` went out with no machine named while the row still said one. +// +// IT IS A LEAF AND NOT THE TALK, because this command is one leaf's work and +// not a conversation: there is no turn loop, no room and no transcript here, +// and [lane.RoleLeafAttached] is exactly "that leaf, with somebody in front of +// it". +// +// AND THE DOOR IS THE MARK. Nothing in this binary launches `aforge exec` as a +// child — every spawner builds its leaves in process (subharness.go's +// buildLinear, chatv3_subharness.go's registry, the subharness's own asks in +// subharness_env.go, which names [lane.RoleLeafUnattended] itself) and none of +// them reaches this function. So there is no env var or flag to key on and +// none is invented: arriving here IS the fact that a person typed the command. +func execCallContext(ctx context.Context) context.Context { + return provider.WithRole(ctx, lanes.RoleLeafAttached) +} + func execTask(prompt, system, root string) exec.Task { title, _, _ := strings.Cut(prompt, "\n") return exec.Task{ diff --git a/cmd/aforge/exec_role_test.go b/cmd/aforge/exec_role_test.go new file mode 100644 index 0000000000..a8c21bdd7b --- /dev/null +++ b/cmd/aforge/exec_role_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "testing" + + lanes "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +// TestTheExecDoorRunsAsALeafSomebodyIsWatching is the door's half of the +// `lane.talk` scope. +// +// THE MEASURED FAILURE (2026-09-13). With `routing: simple` and a machine +// pinned, `aforge exec --model … --debug` put a body on the wire carrying +// `provider: null`: the pin a person had written was not demanded, and nothing +// said so. The cause was here — this command named no role at all, so every +// call it made read as [lane.RoleUnknown], which is a hidden background errand, +// and the routing row's demand rides only the calls a person is reading. +// +// The other half is internal/provider's: a visible role draws the pin under +// `simple` and an unattended one does not +// (TestUnderSimpleOnlyThePersonsOwnTurnCarriesTheTalkPin). +func TestTheExecDoorRunsAsALeafSomebodyIsWatching(t *testing.T) { + role := provider.RoleFrom(execCallContext(context.Background())) + if role != lanes.RoleLeafAttached { + t.Fatalf("`aforge exec` runs as %q, want the leaf somebody is watching", role) + } + // THE CLAIM IS WHAT THE TABLE MAKES OF IT, and the table is the authority: + // a role is only the person's own turn to everything downstream if it reads + // as visible there (internal/lane's roles.go). + if !role.Visible() { + t.Fatalf("%q is not a role anybody is reading, so a pinned run would still go out bare", role) + } + // AND A NODE A SPAWNER LAUNCHED IS STILL NOT. The subharness asks its own + // questions under this role (subharness_env.go) and it must stay the + // conservative reading: nobody is waiting on one, so it buys no speed with + // somebody's money and demands no machine of its own. + if lanes.RoleLeafUnattended.Visible() { + t.Fatal("an unattended leaf now reads as watched, which puts every spawned node back on the person's pin") + } +} diff --git a/docs/changes/unreleased/1019-simple-routing-mode.md b/docs/changes/unreleased/1019-simple-routing-mode.md index 5a6c355cd2..697081eb1f 100644 --- a/docs/changes/unreleased/1019-simple-routing-mode.md +++ b/docs/changes/unreleased/1019-simple-routing-mode.md @@ -9,6 +9,7 @@ invalidates: - "The routing row was believed to reach every request this process makes. It did not: `internal/session` handed its own clients the answer and every other client in the binary — the harness, the subharness, `read_document`, `view_image`, a `/model` panel's members — was assembled through `config.Config.ClientConfig`, which carried no routing answer at all, so each of them ran `latency` whatever a person had written. Under `simple` that was silent and costly: a client on the ranked road may stand a strict pin down on the SAVED account-exclusion belief before any wire is asked, the stand-down is process-wide, and the conversation's own next turn then went out bare while the status line still read `@deepseek`. `config.InstallLaneRows` now installs the row beside the lane rows (`provider.InstallRouting`), and a client handed nothing answers it; a client handed an answer still keeps its own." - "A strict pin on a machine `~/.aforge/v3/account-exclusions.json` covers was stood down before the call in every mode. Under `simple` it is not: the pin goes on the wire once, OpenRouter refuses it, and the pin is retired through the one existing door — the `X cannot serve this model; routing on auto for this model until you pin again` note in the conversation, and `@X` coming off the model word in the same breath. One refused round trip a window is what the row's promise costs." - "A pin under `simple` was believed to ride the person's own turn. It rode EVERY call of the turn: one measured turn paid three 404s — the turn on the chat model, the naming errand on the reflex tier's model, and the memory reflex on a third model the pinned machine does not serve at all — because a retirement is written per machine-and-model pairing and each errand reached for a model of its own. The row is `lane.talk` and the slot is now its whole scope: under `simple` the demand rides the calls a person is reading (their turn, and a task room somebody is watching) and every errand beside one — title, memory reflex, route question, a hand asking about a document, a subharness node — goes out bare. One turn, one refusal, one sentence." + - "`aforge exec` named no role at all, so every call the headless door made read as a hidden background errand — and once the `simple` row scoped the pin to the calls a person is reading, a pinned `aforge exec` went out with `provider: null` while the row still named a machine. The door now says what it is: a leaf somebody is watching (`lane.RoleLeafAttached`). Nothing in the binary launches `aforge exec` as a child — every spawner builds its leaves in process — so reaching that door is itself the fact that a person typed the command, and a subharness node keeps `lane.RoleLeafUnattended` and goes bare. It also means the headless run now waits with a person's patience and prices a second of that wait as a person's, on every routing row rather than only under `simple`." - "The retirement sentence was believed to reach the person. It reached the engine's event stream and died at the surface: it travelled as a `StreamNotice` — the kind the adapter uses for `Retry 1/3: removed max_tokens` — so the chat drew it as ordinary work narration and the turn's work chip swallowed it whole. Measured 2026-09-13: the pin was retired, `@deepseek` came off the model word, another machine answered the turn, and `▸ worked 1.6s · thought 0.2s · ctrl+e` sat where the explanation should have been. News about a row a person WROTE is now its own kind end to end — `provider.StreamRowNews`, `session.EventRowNews`, a note the work chip may not hide — and the same is true of the line saying a base will not carry a lane choice at all." - "The three parked sentences a person is owed were said at `Client.sendShaped`, which is entered once per CALL — so a line learned from a call's own refusal had nowhere to go until the next call, because the widened retry a retired pin earns and every rung of the relaxation ladder leave through a lower door. They are now said at `Client.send`, the door every BODY passes through." - "A widened retry's model-call row described the request before it. The bare retry of a retired pin carried no `provider` key at all and was logged `lane: DeepSeek, served: DeepSeek, relaxed: [provider.require_parameters]` — the machine that had just refused it, and a field a `simple` request never sends. A row now names a lane only while the body still asks for one, `served` is no longer back-filled from a demand that has been withdrawn, and the widening rung names the fields it really took off this object (`provider.only` first)." diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index c0e351e5ff..fa4110d53d 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -917,11 +917,12 @@ being drawn is the bug above, and it is worth reporting. ## Does my pinned machine apply to the title, the memory reflex and a subharness too, or only to what I type **Only to the calls you are reading.** Under `routing: simple` the pinned -machine is demanded on your own turn, and on a task room you are sitting in -front of. The errands that run beside a turn send no machine name at all: a -conversation's two-word title, the memory reflex, the question that routes your -message, a hand asking a model about a document, a subharness node. The row is -spelled `lane.talk` and the slot is its whole scope. +machine is demanded on your own turn, on a task room you are sitting in front +of, and on a headless `aforge exec` you typed — all three are you, waiting. The +errands that run beside a turn send no machine name at all: a conversation's +two-word title, the memory reflex, the question that routes your message, a +hand asking a model about a document, a subharness node. The row is spelled +`lane.talk` and the slot is its whole scope. That is what one refusal costs. A pin the router refuses is retired **per machine and model** — one refused round trip, once — but an errand runs on a diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index 000bc311b2..53ea86c9ce 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -286,9 +286,16 @@ func TestUnderSimpleOnlyThePersonsOwnTurnCarriesTheTalkPin(t *testing.T) { pinned(t, LanePin{Lane: "Ghost"}) request := &ai.Request{Model: rig.model, Messages: userMessages("hello")} - choice, made := rig.client.drawLaneChoice(turnKnobs(), rig.model, request) - if !made || len(choice.Only) != 1 || !strings.EqualFold(choice.Only[0], "Ghost") { - t.Fatalf("the person's own turn drew %+v, want the one machine they pinned", choice) + // EVERY ROLE A PERSON IS READING CARRIES THE DEMAND: the conversation's own + // turn, a task room somebody is sitting in front of, and the headless + // command they typed (cmd/aforge's execCallContext, which names the second + // of these). + for _, watched := range []lanes.Role{lanes.RoleTalk, lanes.RoleLeafAttached} { + knobs := callKnobs{role: watched} + choice, made := rig.client.drawLaneChoice(knobs, rig.model, request) + if !made || len(choice.Only) != 1 || !strings.EqualFold(choice.Only[0], "Ghost") { + t.Fatalf("a %q call drew %+v, want the one machine the person pinned", watched, choice) + } } // EVERY ERRAND THE TURN RUNS BESIDE ITSELF. The roles are internal/lane's // own words for them, and the point of listing all four is that the scope is From c5c1d2b3dd33b1a05864fb606fb17bfdd64ed070 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:36:48 -0400 Subject: [PATCH 10/22] provider, config, tui3: `simple` is the routing row this build ships, and the picker, the wire and the record say one thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's ruling is that what the picker shows, what gets selected and what the call log records must be exactly what went on the wire. The provider-selection algorithm was the one decision in a turn nobody could watch being made: a ranking this process keeps, a price ceiling, refusals learned from earlier answers, a pin stood down against a belief saved from an earlier run — and, underneath all of it, a default that read WHO WAS WAITING and asked the router to sort by speed for a person's own turn and by price for an errand. So the algorithm becomes opt in. `config.DefaultRouting` and the new `provider.DefaultRouting` are both `simple`: with no lane pinned the request carries no provider object at all and the router's own default routing answers it, and with a lane pinned that pin is the whole request. `latency` and `price` are untouched and one word away on the same row, which now cycles with the shipped answer first. The per-intent split in `Client.routingFor` is deleted. The intent is still stamped by a dozen call sites and still read — for what a wait is worth to the lane chooser, and for how much of an answer anybody is reading — but it no longer chooses a road, and the comments in internal/session that said an errand "routes by price rather than by speed" said something this build no longer does. An unknown or garbled word falls to the shipped row rather than to `latency`, in one place now: `config.routingWritten` is the vocabulary check and `config.RoutingWord` resolves a row already in hand, which is the door the picker's `auto` sentence asks so an empty word cannot draw a takeover that does not run. The manual moves with it — the routing section, the sample sheet and fold, the one-token measurement, the warm-cache section, the Providers list and the lanes page's framing all said the shipped row was `latency` or described machinery as though it ran by default. The provider suites are about the ranked road and stay that way: one fixture, `rankedRoad`, gives a client built out of a bare Config the `latency` row, and the four rigs that build their own clients call it. The new law is the wire's own answer — a client nobody has routed, with nothing installed, sends no provider object, and a profile nobody has written is in force as `simple`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- docs/CHAT-V3.md | 8 +- .../unreleased/1019-simple-is-the-default.md | 21 +++ internal/config/derivation_test.go | 8 +- internal/config/routingdefault_test.go | 51 +++++++ internal/config/settings.go | 110 +++++++++------ internal/config/settings_test.go | 8 +- internal/manual/chat/commands.md | 11 +- internal/manual/chat/lanes.md | 32 ++--- internal/manual/chat/models-and-cost.md | 91 ++++++++----- internal/provider/ceiling_policy_test.go | 6 +- internal/provider/endpoints_test.go | 2 + internal/provider/provider_test.go | 18 +++ internal/provider/refusal_class_test.go | 2 + internal/provider/roles.go | 11 +- internal/provider/routingdefault_test.go | 102 ++++++++++++++ internal/provider/velocity.go | 125 +++++++++++------- internal/session/auxiliary.go | 12 +- internal/session/loop.go | 10 +- internal/session/memory_consolidate.go | 6 +- internal/session/session.go | 10 +- internal/session/standing_run.go | 4 +- internal/tui3/lanes_test.go | 12 ++ internal/tui3/palette.go | 10 +- internal/tui3/render.go | 8 +- internal/tui3/settings.go | 11 +- 25 files changed, 500 insertions(+), 189 deletions(-) create mode 100644 docs/changes/unreleased/1019-simple-is-the-default.md create mode 100644 internal/config/routingdefault_test.go create mode 100644 internal/provider/routingdefault_test.go diff --git a/docs/CHAT-V3.md b/docs/CHAT-V3.md index 27a563ee18..7ad7df361e 100644 --- a/docs/CHAT-V3.md +++ b/docs/CHAT-V3.md @@ -1137,9 +1137,11 @@ true, require_parameters: true}` — the router picks the currently-fastest endpoint, fallbacks keep every preference advisory (a slow answer beats no answer), and `require_parameters` stops a request carrying the reasoning knob from landing on an endpoint that would silently drop it. The `routing` settings -row is the only dial: `latency` (default) · `price` · `off`, and `off` is total -— no preference object, and no measurement either, because a session that asked -for no routing asked for no ledger. +row is the only dial: `simple` (default) · `latency` · `price` · `off`. The +object above is what `latency` and `price` send; `simple` sends no preference of +ours at all — the router's own default routing answers, and a lane you pinned is +the whole request — and `off` is total, no preference object and no measurement +either, because a session that asked for no routing asked for no ledger. **The check is a ledger, and it names no vendor.** Every completion is timed — TTFT from the send to the first token (reasoning counts; it is the endpoint diff --git a/docs/changes/unreleased/1019-simple-is-the-default.md b/docs/changes/unreleased/1019-simple-is-the-default.md new file mode 100644 index 0000000000..2cd4e75e09 --- /dev/null +++ b/docs/changes/unreleased/1019-simple-is-the-default.md @@ -0,0 +1,21 @@ +--- +kind: changed +title: '`simple` is the routing row this build ships with, and it is the whole algorithm' +pr: 1019 +surface: [engine, chat, docs] +invalidates: + - "`latency` was the default, and an unwritten `routing` row meant the request asked the router to sort by speed under a price ceiling. The default is `simple`: with no lane pinned the request carries NO provider object at all and the router's own default routing answers it, and with a lane pinned that pin is the whole request (`only`, fallbacks off). `latency` and `price` are unchanged and one word away on the same row." + - "The row's cycle read `latency`, `price`, `simple`, `off`. It reads `simple`, `latency`, `price`, `off` — the default first, as every cycle row in the sheet is ordered. `config.RoutingModes`, the settings hint, the Providers-tab list in the manual and the `/settings` panel's own sentence all moved with it." + - "A call with nobody's row written was routed BY WHO WAS WAITING: a person's own turn asked the router for the fastest endpoint and an errand nobody was watching asked for the cheapest, decided per request inside the adapter. That split is gone. Every call falls to one answer, `provider.DefaultRouting`, and the routing intent — still stamped by a dozen call sites — is now read only for what a wait is worth to the lane chooser and for how much of an answer anybody is reading. Comments in `internal/session` that said an errand `routes by price rather than by speed` said something this build no longer does." + - "The one-token measurement bought while you type, and the endpoint the adapter asks for again to keep a prompt cache warm, ran on a home nobody had configured. Neither does now: both are `latency` and `price` machinery, and under the shipped row the request carries no preference of ours to carry them. The lane sheet the router publishes is still fetched — it is free, and it is what fills the machines a person picks a pin from — so `/model`'s fold still opens with numbers on it." + - "An unreadable or unknown `routing` word fell back to `latency`, in `config.RoutingAt` and again in `provider.ParseRoutingStrategy`. Both now fall back to the shipped row, and the vocabulary check itself lives in one place (`config.routingWritten`, with `config.RoutingWord` for a row already in hand). `internal/tui3`'s `auto` row asks that door too, so an empty or unknown word draws the `openrouter's own routing; aforge stays out` sentence rather than promising a takeover that does not run." +--- + +The owner's ruling is that what the picker shows, what is selected and what the call +log records must be the same thing that went on the wire, and the provider-selection +algorithm was the one decision in a turn nobody could watch being made. So the +algorithm becomes opt in rather than opt out. Nothing is deleted: `latency` still +sorts by speed under its price ceiling, times every answer and demotes a machine that +keeps being slow, and `price` still ranks on price alone — a person who wants either +writes it on the `routing` row and gets all of it back, probes and rescue and warm +cache included. diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 79135be179..b043dacc5d 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -179,10 +179,10 @@ var settingReaders = map[string]string{ // which the adapter turns into the preference object on every request // (internal/provider's velocity.go). It names the accessor the door touches, // which is the CHOICE reader rather than the resolved one: the door has to be - // able to hand down "nobody wrote a word", because that is what lets the - // adapter route a person's own turn by speed and an errand by price while an - // actual word still wins. RoutingAt is the settings sheet's read of the same - // row and stays what it always was. + // able to hand down "nobody wrote a word", because that is what lets a client + // handed nothing fall to the row this process installed while an actual word + // still wins. RoutingAt is the settings sheet's read of the same row and + // stays what it always was. KeyRouting: "RoutingChoiceAt", // The fallback chain is read by the v3 door and becomes // session.Config.ModelFallbacks, which the adapter walks when no endpoint diff --git a/internal/config/routingdefault_test.go b/internal/config/routingdefault_test.go new file mode 100644 index 0000000000..d29be17ec3 --- /dev/null +++ b/internal/config/routingdefault_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "testing" + + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +// THE SHIPPED ROUTING ROW, PINNED WHERE IT IS SPELLED TWICE. +// +// The word on disk is this package's ([DefaultRouting]) and the word on the +// wire is the transport's ([provider.DefaultRouting]), deliberately separate so +// a settings key's vocabulary cannot change because a package renamed a +// constant. Two spellings of one fact drift, so this holds them together: a +// profile nobody has written is in force as `simple`, nobody has chosen +// anything there, and the transport ships the same answer. +func TestAnUnwrittenProfileShipsSimpleRouting(t *testing.T) { + dir := t.TempDir() + if got := RoutingAt(dir); got != RoutingSimple { + t.Fatalf("an unwritten profile routes as %q, want %q", got, RoutingSimple) + } + if got := RoutingChoiceAt(dir); got != "" { + t.Fatalf("an unwritten profile reads as the choice %q, want nobody having chosen", got) + } + if DefaultRouting != RoutingSimple { + t.Fatalf("the shipped row is %q, want %q", DefaultRouting, RoutingSimple) + } + if string(provider.DefaultRouting) != DefaultRouting { + t.Fatalf("the wire ships %q and the settings row ships %q; one fact, two spellings, and they have drifted", + provider.DefaultRouting, DefaultRouting) + } + // AND THE DEFAULT LEADS THE CYCLE, which is what every other choice row in + // the sheet does: the row a person walks starts from what they already have. + if len(RoutingModes) == 0 || RoutingModes[0] != DefaultRouting { + t.Fatalf("the routing cycle is %v, want the shipped row first", RoutingModes) + } +} + +// AND A WORD ALREADY IN HAND RESOLVES THROUGH THE SAME DOOR. A surface holding +// the row — the picker's `auto` sentence is the one that matters — must not +// decide for itself what an empty or unknown word means. +func TestARoutingWordInHandResolvesToTheShippedRow(t *testing.T) { + for _, word := range []string{"", " ", "sideways", "LATENCY?"} { + if got := RoutingWord(word); got != DefaultRouting { + t.Fatalf("%q is in force as %q, want the shipped %q", word, got, DefaultRouting) + } + } + if got := RoutingWord(" Latency "); got != RoutingLatency { + t.Fatalf("a written word in force reads %q, want %q", got, RoutingLatency) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 7f59f9b9a0..97ca8c5c36 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -728,13 +728,25 @@ const ( RoutingOff = "off" ) -// RoutingModes lists them, latency first — which is also the default: a chat -// session is a person waiting, and the endpoint that answers soonest is the one -// they are asking for. -var RoutingModes = []string{RoutingLatency, RoutingPrice, RoutingSimple, RoutingOff} +// RoutingModes lists them, simple first — which is also the default, and the +// order the row cycles in. +var RoutingModes = []string{RoutingSimple, RoutingLatency, RoutingPrice, RoutingOff} -// DefaultRouting is latency. -const DefaultRouting = RoutingLatency +// DefaultRouting is simple: what a person asked for is what goes on the wire, +// and nothing else does. +// +// IT WAS `latency` UNTIL THIS BUILD, and the reason it moved is that the +// choosing was not visible. Sorting by speed brings a whole apparatus with it — +// a ranking this process keeps, a price ceiling, refusals learned from earlier +// answers, a pin retired against a belief saved from an earlier run — and each +// of those is a decision nobody watched being made. What the picker showed, what +// was chosen, and what the record said were three answers to one question. Under +// this row they are one answer: with no lane pinned the request carries no +// preference at all and the router's own default routing answers it, and with a +// lane pinned that pin is the whole request. `latency` and `price` are both +// still here for somebody who wants the apparatus, one word away +// (internal/provider's velocity.go). +const DefaultRouting = RoutingSimple // ── WHICH MACHINE, NOT WHICH MODEL ────────────────────────────────────────── // @@ -830,10 +842,11 @@ func LanePinAt(profileDir, slot string) provider.LanePin { // the subharness, `read_document`, `view_image` and a panel's members are all // assembled through [Config.ClientConfig], which carries no routing answer — // so before this line they ran on the default whatever a person had written, -// and under `simple` one of them could retire a person's own pin, process-wide, -// before any wire was asked (internal/provider's velocity.go says what that -// cost). It is the CHOICE and not the resolved default, so an unwritten row -// installs nothing and the per-request default still moves with who is waiting. +// and one of them could retire a person's own pin, process-wide, before any +// wire was asked (internal/provider's velocity.go says what that cost). It is +// the CHOICE and not the resolved default, so an unwritten row installs nothing +// and every client falls to the shipped row together +// ([provider.DefaultRouting]). func InstallLaneRows(profileDir string) { provider.InstallRouting(installedRoutingFor(profileDir)) provider.SetLanePin(LanePinAt(profileDir, LaneSlotTalk)) @@ -1955,15 +1968,13 @@ func (s *Settings) build() []Setting { Key: KeyRouting, Category: CategoryModels, Kind: SettingChoice, Label: "routing", Choices: RoutingModes, Hint: "one model id is served by many endpoints, and they answer at very " + - "different speeds AND very different prices. Left alone, aforge asks for the " + - "fastest endpoint for your own turns — capped at a quarter over the model's " + - "list price, because no endpoint is worth four times that — and asks for the " + - "cheapest for work you are not waiting on: task workers, judges, titles, the " + - "memory pass. Choosing here overrides that everywhere: latency asks for the " + - "fastest one for everything and times every answer, demoting an endpoint that " + - "keeps being slow; price asks for the cheapest for everything; simple sends " + - "no preference of ours at all — with no lane pinned the router's own default " + - "answers, and a pinned lane is the whole request; off asks for nothing and " + + "different speeds AND very different prices. Left alone — simple — aforge " + + "sends no preference of its own at all: with no lane pinned the router's own " + + "default routing answers, and a lane you pinned is the whole request, that " + + "machine and no fallbacks. The other three ask for something: latency asks " + + "for the fastest endpoint for every call, capped at a quarter over the " + + "model's list price, and times every answer, demoting one that keeps being " + + "slow; price asks for the cheapest for every call; off asks for nothing and " + "measures nothing — and with nothing measured there is no lane to choose, " + "no sheet of them to open and no speed guard. A change lands on " + "the next session.", @@ -3454,41 +3465,56 @@ func IconsAt(profileDir string) string { return IconsAuto } -// RoutingAt resolves the routing row to its word, default latency. An -// unreadable or unknown word falls back to the default rather than to off: a -// garbled row must not quietly stop a session chasing the fastest endpoint. -func RoutingAt(profileDir string) string { - if value, ok := persistedString(profileDir, KeyRouting); ok { - value = strings.TrimSpace(strings.ToLower(value)) - for _, mode := range RoutingModes { - if value == mode { - return value - } +// routingWritten is the routing word in raw when this build knows it, and empty +// for anything else — unwritten, blank, garbled, or a word a later build +// spelled. It is the one place the row's vocabulary is checked, so that "what +// did they write?" and "what is in force?" cannot come apart. +func routingWritten(raw string) string { + raw = strings.TrimSpace(strings.ToLower(raw)) + for _, mode := range RoutingModes { + if raw == mode { + return raw } } + return "" +} + +// RoutingWord is what an already-read routing row is IN FORCE as: the word when +// somebody wrote one this build knows, and [DefaultRouting] otherwise. +// +// A surface that holds the row in hand asks this rather than deciding for +// itself what an unknown word means, so the shipped row is stated once +// (internal/tui3's palette.go reads it for the sentence on the `auto` row). +func RoutingWord(raw string) string { + if written := routingWritten(raw); written != "" { + return written + } return DefaultRouting } +// RoutingAt resolves the routing row to the word in force, [DefaultRouting] +// when nobody wrote one. An unreadable or unknown word falls back to the +// default rather than to off: a garbled row must not quietly stop a session +// sending what it would otherwise send, and off is a real answer somebody +// chooses rather than one they arrive at by accident. +func RoutingAt(profileDir string) string { + value, _ := persistedString(profileDir, KeyRouting) + return RoutingWord(value) +} + // RoutingChoiceAt is the routing row A PERSON ACTUALLY WROTE, empty when they // have written nothing readable. // // It is the same read as [RoutingAt] without the fallback, and the two are both // needed because they answer different questions. A settings sheet asks "what -// is in force?" and must be told latency, which is what an unset row does. The -// adapter asks "did somebody CHOOSE?", and it must be able to hear no — that is -// the whole of what lets it route a person's own turn by speed and an errand -// nobody is waiting on by price, while an explicit word still wins over both +// is in force?" and must be told the shipped row, which is what an unset row +// does. The adapter asks "did somebody CHOOSE?", and it must be able to hear no +// — which is what lets a client that was handed nothing fall to the row this +// process installed, while an explicit word still wins over both // (internal/provider's velocity.go). func RoutingChoiceAt(profileDir string) string { - if value, ok := persistedString(profileDir, KeyRouting); ok { - value = strings.TrimSpace(strings.ToLower(value)) - for _, mode := range RoutingModes { - if value == mode { - return value - } - } - } - return "" + value, _ := persistedString(profileDir, KeyRouting) + return routingWritten(value) } // TaskAuditAt resolves the audit row to its word, default on. diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index b81c00a291..aedd717fe2 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -1013,10 +1013,10 @@ func TestAProfileStillHoldingTheRetiredWordReadsAsSized(t *testing.T) { } // THE ROW HAS TO BE ABLE TO SAY "NOBODY ANSWERED". The settings sheet asks what -// is in force and is owed the default; the adapter asks whether a person CHOSE, -// and the difference is the whole of what lets it route a person's own turn on -// speed and a task node or an errand on price while a written word still wins -// (internal/provider's velocity.go). +// is in force and is owed the shipped row; the adapter asks whether a person +// CHOSE, and the difference is what lets a client handed nothing fall to the row +// this process installed while a written word still wins (internal/provider's +// velocity.go). func TestTheRoutingRowReadsAsAChoiceAndAsAnAnswer(t *testing.T) { dir := t.TempDir() if got := RoutingAt(dir); got != DefaultRouting { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 08485d54d3..a732741907 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1697,11 +1697,12 @@ order: what has been measured of each, and enter on one pins it. 3. **speed guard** — whether an answer slow to start is asked of the next-best machine as well. -4. **routing** — what every request prefers among the endpoints: `latency`, `price`, - `simple`, `off`. With `off` nothing is measured, so the two rows above it have no - machine to name; `simple` sends no preference of aforge's own either, but a lane you - pinned still goes out — the pin is the whole request, and with no pin the router's - own default answers. +4. **routing** — what every request prefers among the endpoints, and it cycles + `simple`, `latency`, `price`, `off`. **`simple` is what it ships as**: aforge sends no + preference of its own, a lane you pinned goes out as the whole request, and with no pin + the router's own default routing answers. `latency` asks for the fastest endpoint and + `price` for the cheapest, on every call. With `off` nothing is measured, so the two rows + above it have no machine to name. 5. **prompt profile** — how much aforge tells the model before you type: `auto`, `lean`, `full`. Another cycle row. `auto` reads the model's context window and goes lean under 32,000 tokens (see *Models, context, and what it costs*). diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index fa4110d53d..cac717facb 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -77,10 +77,10 @@ rather have the router's price balance than be rescued from its bad minute. The rest of this page — the closed set, the refusal walk, the probe — describes what happens while aforge is choosing: during a takeover, and whenever you have -pinned a lane yourself. All of it is written about the routing the row ships -with. With `routing` at `simple` none of it runs — no takeover, no ranking, no -measuring — because that mode sends exactly what you asked for and nothing -else; *How do I stop aforge choosing the provider itself* below has it. With +pinned a lane yourself. All of it is written about `routing` at `latency` or +`price`. With the row at `simple` — which is what it ships as — none of it runs: +no takeover, no ranking, no measuring, because that row sends exactly what you +asked for and nothing else; *How do I stop aforge choosing the provider itself* below has it. With the row at `off` the choosing stops too, and the last section says what that leaves standing. @@ -888,8 +888,10 @@ request. `simple` is not `off`. `off` stops the measuring, and with nothing measured there is no lane to choose, no sheet of machines to open and no speed guard. `simple` leaves the pin standing: the one instruction you gave is the only one -sent. Left alone the row is still `latency`, and everything the rest of this -page describes is what it does. +sent. **`simple` is also what the row ships as**, so this is what a home nobody +has changed does; everything else this page describes — the ranking, the +takeover, the rescue, the measuring — is what `latency` and `price` do, one word +away on the same row. ## Does simple routing cover everything, or only my own messages — harness runs, reading a document, looking at an image @@ -943,16 +945,16 @@ ceiling on how long a silence runs before *something* is said about it is not steering, it is the promise this surface makes — but it stops choosing endpoints for you, stops sending second requests, and stops spending anything on speed. -**The row has four answers, and the two quiet ones are not the same nothing.** Left alone, aforge asks -for the fastest machine on the turns you are waiting through, and on the work you are not -watching it still weighs speed, at a quarter of that weight — a task ends when its slowest -call ends, and a machine that refuses four requests in five costs five sends for one answer, -so its seconds are never free. That is the split the rest of this page describes. Writing a word in the row -overrides that everywhere: `latency` asks for the fastest one on every call, background +**The row has four answers, and the two quiet ones are not the same nothing.** Left alone +it reads `simple`, which sends no preference of aforge's own at all — your pin, if you made +one, is the whole request (the section above). Writing another word in the row turns the +choosing on everywhere: `latency` asks for the fastest machine on every call, background work included; `price` ranks on price alone on every call, your own turns included, which -is you saying that speed is not worth money anywhere; `simple` sends no preference of -aforge's own at all — your pin, if you made one, is the whole request (the section above); -and `off` is the paragraph above. +is you saying that speed is not worth money anywhere; and `off` is the paragraph above. +Under `latency` and `price` the work you are not watching still weighs speed, at a quarter +of the weight your own turns give it — a task ends when its slowest call ends, and a +machine that refuses four requests in five costs five sends for one answer, so its seconds +are never free. That is the split the rest of this page describes. `price` still measures machines and still chooses between them. `simple` and `off` stop the choosing. **`off` does not stop the remembering, and that is deliberate.** aforge still writes down diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 36e5030cd5..04d8779f8b 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2603,15 +2603,24 @@ colour. One model id is served by many endpoints, and they differ in two ways at once: how fast they answer, and what they charge. The published list price beside a model is the model's own figure — no endpoint is obliged to match it, and the fastest one often does not. -So, with the **routing** row on the Providers tab left alone, aforge asks for two different things depending on who is waiting. **Your own turns** ask for the fastest endpoint, capped at **a quarter over the model's published list price**: an endpoint 25% dearer buys a head start you can feel, and one four times dearer buys nothing you would notice on a five-minute task. **Work you are not waiting on** — task workers, a divided part, the check on a piece of work, the model that names a task or a conversation, the memory pass — asks for the cheapest endpoint instead, because speed is worth nothing to a call nobody is watching. +**Left alone, aforge asks for nothing.** The **routing** row on the Providers tab ships as `simple`, and `simple` means the request carries no preference of aforge's own: with no lane pinned there is no `provider` object on it at all, and OpenRouter's own default routing picks the endpoint. Pin a lane and that pin is the whole request — that machine, `only`, no fallbacks, and nothing else added to it. Nothing is ranked, nothing is capped, nothing is retired behind your back, and what the picker shows, what is chosen and what the record says are the same thing. -Where a model publishes no price, no cap is sent at all rather than one guessed from something else. If the router refuses a request, aforge first widens the endpoint set while keeping the cap. Only if that wider request is refused too does aforge lift the cap rather than fail the turn. Each change has its own attempt line. +It has not always been this way: until this build the shipped row was `latency`, and aforge asked for the fastest endpoint on your own turns and the cheapest on work you were not waiting on. That choosing was invisible — the one decision in a turn you could not see being made — so it is now something you turn on rather than something you turn off. -**One thing about background work is not quite "speed is worth nothing".** Work you are not watching still asks the router for the cheapest endpoint — that part is unchanged — but among the machines behind that model, aforge will pay a little for a quicker one **while your window is open**, because you are there to read what the work lands. With no window open on this machine it will not: the cheapest machine wins outright, however slowly it writes. Nothing about this is a setting; it follows whether you are here. +Setting **routing** yourself is how you turn it on, and it applies everywhere: -Setting **routing** yourself overrides all of that everywhere: `latency` asks for the fastest endpoint (still under the price cap) for every call including background work, `price` asks for the cheapest for every call including your own turns, `simple` sends no preference of aforge's own at all — with no lane pinned the router's own default routing answers, and a pinned lane is demanded exactly (`only`, no fallbacks) with nothing else on the request — and `off` sends no preference and stops timing endpoints. A change lands on the next session. +- **`latency`** asks for the fastest endpoint on every call, capped at **a quarter over the model's published list price**: an endpoint 25% dearer buys a head start you can feel, and one four times dearer buys nothing you would notice on a five-minute task. It also times every answer and demotes an endpoint that keeps being slow. +- **`price`** asks for the cheapest endpoint on every call, including your own turns. +- **`simple`** is the shipped row described above. +- **`off`** sends no preference and stops timing endpoints altogether. -**You can also name the endpoint yourself.** routing says what a request prefers; the **lane** row above it, and `→` on a row in the model picker, say which provider requests from your home actually go to — see "choose a provider" above. +A change lands on the next session. + +Under `latency` or `price`, where a model publishes no price, no cap is sent at all rather than one guessed from something else. If the router refuses a request, aforge first widens the endpoint set while keeping the cap. Only if that wider request is refused too does aforge lift the cap rather than fail the turn. Each change has its own attempt line. + +**Under `price`, one thing is not quite "speed is worth nothing".** Work you are not watching asks the router for the cheapest endpoint — but among the machines behind that model, aforge will pay a little for a quicker one **while your window is open**, because you are there to read what the work lands. With no window open on this machine it will not: the cheapest machine wins outright, however slowly it writes. Nothing about this is a setting; it follows whether you are here. + +**You can also name the endpoint yourself, under any row.** routing says what a request prefers; the **lane** row above it, and `→` on a row in the model picker, say which provider requests from your home actually go to — see "choose a provider" above. A pin is the one instruction `simple` sends. With `routing: off` there is nothing measured, so there is no lane to choose, no sheet of them to open under a model row, and no speed guard. @@ -2658,15 +2667,17 @@ that order** — **lane**, **speed guard**, **routing** — because the machine your model is part of the same decision as the model: ``` - your model deepseek/deepseek-v4-flash · auto (cloudflare now) + your model deepseek/deepseek-v4-flash lane auto speed guard on - routing latency + routing simple ``` -The tail on the model row is the machine: `auto (cloudflare now)` when the choosing is -left to aforge, `pinned: cloudflare` when it is not, `openrouter` when you have asked for -no endpoint at all. A session that has measured nothing shows the model id alone. +The tail on the model row is the machine: `pinned: cloudflare` once you have pinned one, +`openrouter` when you have asked for no endpoint at all, and `auto (cloudflare now)` — a +prediction of where the next turn would land — only under `latency` or `price`, where +aforge is the one choosing. Under the shipped `simple` row nobody here is predicting, so +there is no tail, and a session that has measured nothing shows the model id alone too. In the model picker — `/model`, or `enter` on that **your model** row — press `→` or `tab` on a row and the model's lanes open underneath it, with the cursor already on the @@ -2674,13 +2685,17 @@ lane in force (`auto` when nothing is pinned): ``` deepseek-v4-flash via cloudflare · ▲0.8s · $0.09/$0.18 per M · 1M · 58t/s - ● auto router routes; aforge takes over if answers turn bad — cloudflare now · recommended + ● auto openrouter's own routing; aforge stays out cloudflare 0.8s · 58 t/s · $1.3/M · no tools · 100% · ▁▂▁▃▁▂ coreweave 0.4s · 24 t/s · $0.28/M · tail 12s · 99% · ▁▁▇▁▂▁ deepinfra 0.8s · 27 t/s · $0.18/M · out ≤ 65k · 99% ○ openrouter let the router balance on price ``` +That is the `auto` row under the shipped `simple` row. Set **routing** to `latency` or +`price` and it reads `router routes; aforge takes over if answers turn bad — cloudflare +now · recommended` instead, because there it does. + Each lane row reads, in order: its name, the wait before the first word, how fast it writes, what a million output tokens cost there, one short note about what is wrong with it, how much of the last five minutes it was answering, and a sparkline of **your own** @@ -2707,15 +2722,16 @@ measured it opens all the same, onto the only two honest answers: `auto` and `op From the keyboard alone: `/model @cloudflare` pins, `/model auto` un-pins. -**Under `routing: simple` the `auto` row says something else, because it does something -else.** It reads `openrouter's own routing; aforge stays out`, and it names no machine -beside it — under that row nothing on aforge's side chooses, so there is no machine it -could honestly say the next turn will land on, and no `no rescue` note either, because -there is no rescue running under any setting of the speed guard. The fold still opens and -`enter` still pins: a pin is the one instruction that mode sends. The **lane** row in -`/settings` is explained the same way, and the `auto (cloudflare now)` tail on the **your -model** row is gone with it — it was a prediction, and under `simple` nobody here is -predicting. +**Under `routing: simple` — the row aforge ships with — the `auto` row says something +else, because it does something else.** It reads `openrouter's own routing; aforge stays +out`, and it names no machine beside it: under that row nothing on aforge's side chooses, +so there is no machine it could honestly say the next turn will land on, and no `no +rescue` note either, because there is no rescue running under any setting of the speed +guard. The fold still opens and `enter` still pins: a pin is the one instruction that row +sends. The **lane** row in `/settings` is explained the same way, and the `auto (cloudflare +now)` tail on the **your model** row is gone with it — it was a prediction, and under +`simple` nobody here is predicting. Choose `latency` or `price` and the takeover sentence, +the named machine and the `no rescue` note all come back. **A model nobody has measured opens onto its two answers and no machines.** `→` shows `auto` and `openrouter`, and in the machines' place one line — @@ -2867,6 +2883,11 @@ It hedges **at most one extra call** per answer and stays under **a tenth** of w session spends. It does nothing under `routing: price` — nobody is buying seconds there — and nothing while an answer is already flowing normally. +**Under the shipped `routing` row it buys no measurement.** `simple` sends what you asked +for and nothing else, so the one-token measurement in the next section is not bought at +all — nothing on aforge's side is choosing a machine for it to inform. Set **routing** to +`latency` or `price` and it is bought again. + Turn it off if you are paying for every token and never mind waiting. With it off, the `auto` row in the model picker says `no rescue`, so you can see the promise it is making — and the measurement described in the next section stops being bought as well. The two @@ -2886,8 +2907,10 @@ answer's first word is not also paying for a handshake. token out, twice. **How often.** At most one pair every **twenty seconds** per model, however fast you -type — so a long message buys one, not one per keystroke. None at all when the **speed -guard** is off, when `routing` is `off` or `simple`, when the lane row says `openrouter`, when the +type — so a long message buys one, not one per keystroke. **None at all under the shipped +`routing` row**: `simple` buys no measurements, because nothing on aforge's side is +choosing a machine for them to inform. Also none when the **speed +guard** is off, when `routing` is `off`, when the lane row says `openrouter`, when the pool is already backing off a rate limit, when aforge is still recovering a dropped connection, or when **nobody is waiting on that model** — a task working on its own and an errand buy none, because the measurement exists to shorten a wait somebody is sitting @@ -2924,16 +2947,19 @@ run in the background, and a task working on its own buys none. `~/.aforge/v3/lanes.log`, one line of JSON each, with the ones bought this way marked as probes. That file is the record of what was sent. -**How to make it zero.** Settings → Providers → **speed guard**, off. The same row governs -asking a second machine when an answer is slow to start, so turning it off stops both. -`routing off` or `routing simple`, and a lane row set to `openrouter`, also stop it. +**How to make it zero.** It is already zero on a home where nobody has touched +**routing**: the shipped row is `simple` and it buys none of these. If you have set +`latency` or `price` and want it back to zero: Settings → Providers → **speed guard**, +off. The same row governs asking a second machine when an answer is slow to start, so +turning it off stops both. `routing simple`, `routing off`, and a lane row set to +`openrouter`, also stop it. ## The lane row in settings — auto, pinned, pinned but borrowable, openrouter Settings → Providers has two rows under **routing**: ``` - your model deepseek-v4-flash · auto (cloudflare now) + your model deepseek-v4-flash lane auto speed guard on ``` @@ -2950,11 +2976,12 @@ Settings → Providers has two rows under **routing**: The pinned rungs are missing until aforge has measured something — there is no honest lane to name yet, so the walk is `auto` ↔ `openrouter`. -The **your model** row says which lane is answering it beside the model id — `auto -(cloudflare now)` while the choice is aforge's, `pinned: cloudflare` once it is yours. +The **your model** row says which lane is answering it beside the model id — `pinned: +cloudflare` once the choice is yours, and `auto (cloudflare now)` while it is aforge's, +which under the shipped `simple` row it never is. `lane` and `routing` are different questions: routing is what every request **prefers** (fastest, cheapest, or nothing at all), and lane is which endpoint requests from your home -actually land on. Under `simple` routing the borrow rung is moot: the pin goes out +actually land on. Under `simple` routing — the shipped row — the borrow rung is moot: the pin goes out strictly — that one machine, `only`, fallbacks off, nothing else on the request — because simple runs no choosing of its own for a slow answer to borrow. The `switch to auto?` question a slow pinned lane raises still has somewhere to send you — it asks whether to @@ -2968,7 +2995,9 @@ nobody chose. Every request in a conversation re-sends the whole conversation. What keeps that from costing a fortune is the **prompt cache**: the endpoint that answered you a moment ago still has those tokens, and re-reading them costs a fraction of sending them fresh. The catch is that the cache sits on **one machine**. An endpoint that has never seen your conversation charges full price for all of it — measured on a real run, the same 94,000-token context cost **4.7 times more** on a cold endpoint than on the warm one, and that alone is where a quarter of the requests in that run ate half its money. -So aforge remembers which endpoint answered your last request and **asks for that same endpoint first on the next one**. It is a preference, not a demand: if that endpoint is busy or gone, the request still goes through somewhere else rather than failing. Nothing extra is sent and nothing is probed to work this out — it is the name that came back on the last answer. +**This is something aforge does under `routing: latency` and `routing: price`, and not under the shipped `simple` row.** Under `simple` the request carries no preference of aforge's own at all, and asking for last time's endpoint is a preference — so keeping the cache warm is the router's business there, as the rest of the choosing is. The row is one word away if you want it: `/settings` → Providers → **routing**. + +Under those two rows, aforge remembers which endpoint answered your last request and **asks for that same endpoint first on the next one**. It is a preference, not a demand: if that endpoint is busy or gone, the request still goes through somewhere else rather than failing. Nothing extra is sent and nothing is probed to work this out — it is the name that came back on the last answer. It moves off that endpoint when the endpoint stops earning it: @@ -2981,7 +3010,7 @@ The same stable identity also travels in OpenRouter's session header so a succes **Answering a question does not cost the cache.** What sits in front of every message — the instructions, the folders you attached, your standing orders, the newest few decisions — is re-sent unchanged on every request, and one changed byte in it re-prices the whole conversation at full price. Answering a question used to change it, so every `allow once` on a tool bought that re-send on the very next message. It does not any more: the decision is written to the record on disk, the model reads the answer in the result that comes back to it, and the copy in front of the conversation is brought up to date only when something else there moves anyway — a folder attached, a standing order agreed. -Each of your conversations keeps its own endpoint, and so does each worker on a task, because each of them is sending a different transcript. Background work is kept warm the same way: its first request still asks for the cheapest endpoint, and after that it comes back to whichever one answered. Setting **routing** to `off` or `simple` turns this off with everything else. +Each of your conversations keeps its own endpoint, and so does each worker on a task, because each of them is sending a different transcript. Background work is kept warm the same way: its first request asks by whatever the row asks for, and after that it comes back to whichever one answered. `routing` at `simple` — the shipped row — or at `off` sends none of it. ## A model that cannot stop thinking — what turning thinking off does on it, and why some models think at "max" by default diff --git a/internal/provider/ceiling_policy_test.go b/internal/provider/ceiling_policy_test.go index e9574d0a88..b85a93d27a 100644 --- a/internal/provider/ceiling_policy_test.go +++ b/internal/provider/ceiling_policy_test.go @@ -37,12 +37,14 @@ func TestADataPolicyRefusalDropsTheCeilingAndTeachesTheLedger(t *testing.T) { _, _ = writer.Write([]byte(`{"model":"sim/model","choices":[{"index":0,"finish_reason":"stop",` + `"message":{"role":"assistant","content":"ok"}}]}`)) }) - config := Config{ + // The ceiling rides the latency ask and only the latency ask, so this rig + // says which road it is about ([rankedRoad]). + config := rankedRoad(Config{ APIKey: "test-key", BaseURL: "https://openrouter.ai/api/v1", HTTPClient: handlerClient(handler), Model: "sim/model", // A known list price is what puts a ceiling on the wire at all. ModelPrice: func(string) (float64, float64, bool) { return 0.66e-6, 1.98e-6, true }, - } + }) client, err := NewClient(config) if err != nil { t.Fatal(err) diff --git a/internal/provider/endpoints_test.go b/internal/provider/endpoints_test.go index 4c9ffa7199..eca7f5dce3 100644 --- a/internal/provider/endpoints_test.go +++ b/internal/provider/endpoints_test.go @@ -23,6 +23,8 @@ const refusalBody = `{"error":{"message":"No endpoints found that can handle the // provider.test would be testing a shorter ladder than the product climbs. func refusingClient(t *testing.T, accept func(map[string]any) bool, config Config) (*Client, *capture) { t.Helper() + // The ladder these tests climb is the ranked road's ([rankedRoad]). + config = rankedRoad(config) forgetLanes(t) recorded := &capture{} handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 61b3812eec..d6b79df01f 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -84,8 +84,26 @@ func (c *capture) body(index int) map[string]any { return c.bodies[index] } +// rankedRoad is the routing row a fixture about the RANKED ROAD wants, written +// in one place for every fixture that builds a client out of a bare [Config]. +// +// The shipped row sends no preference of ours at all ([DefaultRouting]), so a +// client handed nothing puts no `provider` object on the wire — which is what +// TestAClientNobodyHasRoutedSendsNoProviderObject exists to hold. Everything +// else in this package's suites is about the sort word, the ledger's order, the +// price ceiling, the hedge and the cache pin, and all of those are `latency` and +// `price` machinery. So a fixture that says nothing gets the road it is about, +// and a test that means the shipped row says so itself. +func rankedRoad(config Config) Config { + if config.Routing == nil { + config.Routing = StaticRouting(RoutingLatency) + } + return config +} + func newTestClient(t *testing.T, config Config) (*Client, *capture) { t.Helper() + config = rankedRoad(config) forgetLanes(t) recorded := &capture{} handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/provider/refusal_class_test.go b/internal/provider/refusal_class_test.go index e061137217..44119a815f 100644 --- a/internal/provider/refusal_class_test.go +++ b/internal/provider/refusal_class_test.go @@ -65,6 +65,8 @@ func classClient(t *testing.T, handler http.Handler, config Config) *Client { // recovery into a walk to the other lane before the ladder's first rung, // which is a different call shape than the one these tests are counting. forgetLanes(t) + // These are the ranked road's refusals ([rankedRoad]). + config = rankedRoad(config) config.APIKey = "test-key" if config.BaseURL == "" { config.BaseURL = "https://openrouter.ai/api/v1" diff --git a/internal/provider/roles.go b/internal/provider/roles.go index f89b405427..a1c0e74924 100644 --- a/internal/provider/roles.go +++ b/internal/provider/roles.go @@ -44,10 +44,13 @@ func RoleFrom(ctx context.Context) lanes.Role { // roleIntent is the old two-valued knob, derived from the role rather than set // beside it. // -// [RoutingIntent] is kept because it is what `provider.sort` is built from and -// what a dozen call sites still say, but it is now a READING of the role rather -// than a second opinion about the same fact. Where both are present the role -// wins: it is the more specific claim, and it is the one the table can explain. +// [RoutingIntent] is kept because a dozen call sites still say it and because +// what it answers is still asked — what a wait is worth (lanes.go) and how much +// of an answer anybody is reading (workload.go). It no longer picks a road: +// that is the routing row's and nobody else's (velocity.go's [DefaultRouting]). +// It is now a READING of the role rather than a second opinion about the same +// fact, and where both are present the role wins: it is the more specific +// claim, and it is the one the table can explain. func roleIntent(ctx context.Context) (RoutingIntent, bool) { role := RoleFrom(ctx) if !role.Known() { diff --git a/internal/provider/routingdefault_test.go b/internal/provider/routingdefault_test.go new file mode 100644 index 0000000000..7adcc9683c --- /dev/null +++ b/internal/provider/routingdefault_test.go @@ -0,0 +1,102 @@ +package provider + +import ( + "strings" + "testing" +) + +// nothingInstalled puts this process back to nobody having written a routing +// row, and restores whatever was there afterwards — the knob is process-wide +// ([InstallRouting]), so a test that left one set would route every test after +// it. +func nothingInstalled(t *testing.T) { + t.Helper() + before, ok := installedRoutingChoice() + InstallRouting("") + t.Cleanup(func() { + if !ok { + InstallRouting("") + return + } + InstallRouting(before) + }) +} + +// THE SHIPPED ROW IS THE WHOLE ALGORITHM, and this is the law that says so from +// the only place it can be checked honestly: the wire. +// +// A client nobody has routed — no [RoutingSource] handed to it, nothing +// installed by [InstallRouting] — puts NO provider object on the request at +// all. Not a sort word, not the ledger's order, not a price ceiling, not the +// endpoint that answered last time. Whatever this process believes about the +// machines behind the model, none of it is asked for, and OpenRouter's own +// default routing answers. +// +// It is written against a client built by hand rather than through +// [rankedRoad], because every other fixture in this package says which road it +// is about and this one is about saying nothing at all. +func TestAClientNobodyHasRoutedSendsNoProviderObject(t *testing.T) { + server := prefRig(t) + nothingInstalled(t) + client, err := NewClient(Config{APIKey: "test-key", BaseURL: server.URL(), Model: plainModel}) + if err != nil { + t.Fatal(err) + } + client.velocity = newVelocityLedger() + + if got := client.routing(); got != DefaultRouting { + t.Fatalf("a client handed no routing answers to %q, want the shipped %q", got, DefaultRouting) + } + if _, err := client.CompleteWithMessages(talking(), userMessages("hello")); err != nil { + t.Fatalf("the unrouted turn failed: %v", err) + } + asks := server.Asks() + if len(asks) != 1 { + t.Fatalf("one turn produced %d asks, want exactly one — nothing hedges under the shipped row", len(asks)) + } + bare := asks[0] + if bare.Sort != "" || bare.Only != nil || bare.Order != nil || bare.Ignore != nil || + bare.MaxPrice != nil || bare.NoFallbacks { + t.Fatalf("a client nobody routed carried %+v, want a bare request", bare) + } +} + +// AND A PERSON'S OWN PIN IS STILL THE WHOLE REQUEST THERE. The shipped row is +// not "send nothing"; it is "send what they asked for and nothing else", and +// with nobody having written a routing row the pin is the only thing anybody +// asked for. +func TestTheShippedRowStillSendsThePinAndNothingElse(t *testing.T) { + server := prefRig(t) + nothingInstalled(t) + client, err := NewClient(Config{APIKey: "test-key", BaseURL: server.URL(), Model: plainModel}) + if err != nil { + t.Fatal(err) + } + client.velocity = newVelocityLedger() + pinned(t, LanePin{Lane: "Harbor"}) + + if _, err := client.CompleteWithMessages(talking(), userMessages("hello")); err != nil { + t.Fatalf("the pinned turn failed: %v", err) + } + asks := server.Asks() + if len(asks) != 1 { + t.Fatalf("one turn produced %d asks, want exactly one", len(asks)) + } + demand := asks[0] + if len(demand.Only) != 1 || !strings.EqualFold(demand.Only[0], "Harbor") || !demand.NoFallbacks || + demand.Sort != "" || demand.MaxPrice != nil || demand.Order != nil { + t.Fatalf("the pinned ask carried %+v, want only Harbor and fallbacks off", demand) + } +} + +// AN UNKNOWN WORD IS THE SHIPPED ROW AND NOT A ROAD NOBODY ASKED FOR. A row a +// later build spells, or a typo, must not put a session on the ranked road — +// which is what a fallback of `latency` did while `latency` was the default. +func TestAnUnreadableRoutingWordFallsToTheShippedRow(t *testing.T) { + if got, known := ParseRoutingStrategy("quickest"); known || got != DefaultRouting { + t.Fatalf("an unknown word parsed to %q (known=%v), want the shipped %q", got, known, DefaultRouting) + } + if got, known := ParseRoutingStrategy(""); known || got != DefaultRouting { + t.Fatalf("an empty word parsed to %q (known=%v), want the shipped %q", got, known, DefaultRouting) + } +} diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index 1716a903c9..a096c094f7 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -47,10 +47,9 @@ type RoutingStrategy string const ( // RoutingLatency asks for the currently-fastest endpoint UNDER A PRICE - // CEILING (see latencyPriceCeiling). It is what a call with nobody's row - // written and a person waiting on it gets, because a chat session is a - // person waiting — and the ceiling is there because being served fastest was - // never worth being charged anything. + // CEILING (see latencyPriceCeiling). It is opt in — somebody writes the row + // — and the ceiling is there because being served fastest was never worth + // being charged anything. RoutingLatency RoutingStrategy = "latency" // RoutingPrice asks for the cheapest endpoint that can serve the request. RoutingPrice RoutingStrategy = "price" @@ -58,7 +57,8 @@ const ( // it: an operator who has not asked to be routed has not asked to be // measured either, and a demotion nobody can act on is only overhead. RoutingOff RoutingStrategy = "off" - // RoutingSimple sends exactly what the person asked for and nothing else. + // RoutingSimple sends exactly what the person asked for and nothing else, + // and IT IS THE ROW THIS BUILD SHIPS ([DefaultRouting]). // No lane pinned: the request carries NO provider object at all and the // router's own default routing answers — no belief, no sort word, no price // ceiling, no hedge. A lane pinned: the request carries that one demand @@ -68,6 +68,21 @@ const ( RoutingSimple RoutingStrategy = "simple" ) +// DefaultRouting is what a client asks for when nobody has chosen: no caller +// handed it a row and this process installed none ([InstallRouting]). +// +// IT IS THE SAME ANSWER FOR EVERY CALL, and that is the point. The old default +// read who was waiting and asked the router to sort by speed for a person's own +// turn and by price for an errand — a decision made per request, out of sight, +// that the picker and the record could then disagree with. Under this one the +// wire carries the person's own row and nothing this build inferred, so what is +// shown, what is chosen and what is written down are the same fact. +// +// It is spelled again in internal/config ([config.DefaultRouting]) because that +// package owns the word on disk and this one owns the word on the wire; a test +// there holds the two together. +const DefaultRouting = RoutingSimple + // sortWord is the strategy as the router spells it, empty when no preference // object should be sent at all. func (s RoutingStrategy) sortWord() string { @@ -85,14 +100,16 @@ func (s RoutingStrategy) sortWord() string { // ── WHO IS WAITING ────────────────────────────────────────────────────────── // -// Sorting by latency is a decision about a PERSON, not about a model: it is -// worth something only when somebody is sitting there watching the answer -// arrive. A task worker, a divided part, an auditor, a judge, a title, a memory -// pass — nobody is waiting on any of those, and pinning the fastest endpoint -// for them buys nothing and pays whatever that endpoint charges. +// Speed is worth something only when somebody is sitting there watching the +// answer arrive. A task worker, a divided part, an auditor, a judge, a title, a +// memory pass — nobody is waiting on any of those, and a second saved on one of +// them is a second nobody spends. // -// So the request carries who is waiting, and this file is the one place that -// reads it. +// So the request carries who is waiting. IT NO LONGER PICKS A SORT WORD: the +// routing row is a person's answer and this build does not write one for them +// ([DefaultRouting]). What still reads it is what a WAIT IS WORTH — the value of +// time the chooser is given when somebody did choose the ranked road (lanes.go), +// and how much of an answer is being read as it arrives (workload.go). // RoutingIntent says whether a person is waiting on this call. type RoutingIntent int @@ -147,8 +164,9 @@ func routingIntentFrom(ctx context.Context) RoutingIntent { } // ParseRoutingStrategy reads a settings word. An unrecognized word is NOT an -// error and NOT off: it falls back to the default, because a typo in a config -// row must not silently take routing away from a session that asked for it. +// error and NOT off: it falls back to [DefaultRouting], because a typo in a +// config row must not silently put a session on a road nobody asked for, and +// off is a road somebody chooses rather than one they arrive at by accident. func ParseRoutingStrategy(word string) (RoutingStrategy, bool) { switch strings.ToLower(strings.TrimSpace(word)) { case string(RoutingLatency): @@ -160,7 +178,7 @@ func ParseRoutingStrategy(word string) (RoutingStrategy, bool) { case string(RoutingSimple): return RoutingSimple, true default: - return RoutingLatency, false + return DefaultRouting, false } } @@ -182,8 +200,8 @@ func (s staticRouting) RoutingStrategy() RoutingStrategy { return RoutingStrateg // StaticRouting is one already-resolved answer as a source. The empty strategy // is NOBODY HAVING CHOSEN rather than a refusal, so a caller that has nothing -// to say leaves the adapter to decide per request from who is waiting on it — -// see [Client.routingFor]. +// to say falls to the row this process installed and, with none installed, to +// [DefaultRouting] — see [Client.routingChoice]. func StaticRouting(strategy RoutingStrategy) RoutingSource { return staticRouting(strategy) } // ── THE ROW EVERY CLIENT IN THIS PROCESS ANSWERS TO ───────────────────────── @@ -197,13 +215,16 @@ func StaticRouting(strategy RoutingStrategy) RoutingSource { return staticRoutin // carried a routing answer at all — so whatever a person wrote in the routing // row, those adapters ran on the default. // -// THAT IS A BREACH OF THE ROW AND, UNDER `simple`, A SILENT ONE. A client on -// the default takes the ranked road, and the ranked road may retire A PERSON'S -// PIN before any wire is asked — a machine the saved account exclusions cover -// is stood down where it is drawn (lanes.go). The retirement is process-wide, -// so the conversation's own next turn — running `simple`, doing everything -// right — found the pin already retired and went out bare, with the sentence -// parked on a call nobody was reading and the chrome still naming the machine. +// THAT IS A BREACH OF THE ROW, AND IT WAS A SILENT ONE. Those adapters ran the +// ranked road while the default was `latency`, and the ranked road may retire A +// PERSON'S PIN before any wire is asked — a machine the saved account exclusions +// cover is stood down where it is drawn (lanes.go). The retirement is +// process-wide, so the conversation's own next turn — running `simple`, doing +// everything right — found the pin already retired and went out bare, with the +// sentence parked on a call nobody was reading and the chrome still naming the +// machine. The default has moved since ([DefaultRouting]) and the breach has +// not: a person who writes `latency` or `price` is owed it in every adapter +// this process builds, exactly as one who leaves the row alone is. // // So the row is installed ONCE, by the same door that installs the lane rows a // person wrote (internal/config's InstallLaneRows), and every client that was @@ -218,18 +239,21 @@ var installedRouting atomic.Value // InstallRouting states the routing row this process's clients answer to, for // every client that is handed no [RoutingSource] of its own. The empty strategy // is NOBODY HAVING WRITTEN ONE and puts the knob back to exactly that, which is -// what an unwritten row resolves to — the default then moves with who is -// waiting ([Client.routingFor]) exactly as it always has. +// what an unwritten row resolves to — [DefaultRouting], the same answer for +// every call. func InstallRouting(strategy RoutingStrategy) { installedRouting.Store(strategy) } // installedRoutingChoice is the installed row and whether one was installed at -// all, in the shape [Client.routingChoice] answers in. +// all, in the shape [Client.routingChoice] answers in. With none installed the +// strategy is [DefaultRouting] and the answer to "did somebody choose?" is no, +// which are two different facts and both wanted: a gate reads the first and the +// request path reads the second. func installedRoutingChoice() (RoutingStrategy, bool) { held, ok := installedRouting.Load().(RoutingStrategy) if !ok || strings.TrimSpace(string(held)) == "" { - return RoutingLatency, false + return DefaultRouting, false } parsed, _ := ParseRoutingStrategy(string(held)) return parsed, true @@ -237,13 +261,14 @@ func installedRoutingChoice() (RoutingStrategy, bool) { // routingChoice is the strategy A PERSON CHOSE, and whether one was chosen at // all. An empty source, an empty word, or no source is "nobody said" — which is -// a different fact from "somebody said latency", and the whole of what lets the -// default below depend on who is waiting while an explicit row still wins. +// a different fact from "somebody said simple", even where the two resolve to +// the same road, and it is what lets a client handed nothing fall to the row +// this process installed while a caller that spoke keeps its own answer. func (c *Client) routingChoice() (RoutingStrategy, bool) { if c.config.Direct { // A connected direct service has one road. Treating the absence of a - // person's router setting as latency routing would synthesize a provider - // object and hand router vocabulary to an endpoint that has no lanes. + // person's router setting as any kind of routing would hand router + // vocabulary to an endpoint that has no lanes to rank. return RoutingOff, true } // A CALLER THAT SPOKE IS ANSWERED FIRST, and a caller that did not falls to @@ -261,9 +286,10 @@ func (c *Client) routingChoice() (RoutingStrategy, bool) { return installedRoutingChoice() } -// routing resolves the strategy for this client with nothing said about who is -// waiting. It is what the ledger's own gates read — they only ever ask whether -// routing is off — and it keeps the old answer: no row means latency. +// routing resolves the strategy for this client. It is what the machinery's own +// gates read — whether to measure, to probe, to hedge, to hold a cache pin — +// and it is the same answer [Client.routingFor] gives the request beside it, so +// a gate cannot be open on a road no request is taking. func (c *Client) routing() RoutingStrategy { strategy, _ := c.routingChoice() return strategy @@ -271,17 +297,21 @@ func (c *Client) routing() RoutingStrategy { // routingFor resolves the strategy one request will actually ask for. // -// THE PERSON'S ROW WINS OUTRIGHT. Everything below it is the DEFAULT moving -// with who is waiting: the conversation's own turn chases speed, and a call -// nobody is sitting in front of chases price. -func (c *Client) routingFor(intent RoutingIntent) RoutingStrategy { - if chosen, ok := c.routingChoice(); ok { - return chosen - } - if intent == IntentBackground { - return RoutingPrice - } - return RoutingLatency +// THE PERSON'S ROW WINS OUTRIGHT, and where nobody wrote one there is nothing +// underneath it to infer: every call falls to [DefaultRouting]. It used to read +// who was waiting here and ask for speed or for price accordingly, which was a +// routing decision this build made on a person's behalf and then had to keep +// explaining — the one thing a person could not see in the picker, the status +// line or the record. +// +// SO THE INTENT IS TAKEN AND NOT READ. It is still carried on every call and +// still read a line later for what a wait is worth (lanes.go) and for how much +// of an answer is being read as it arrives (workload.go); it is named here so +// the two call sites that ask this question go on saying which call they are +// asking about, and so that a row which one day wants to know again has the +// fact in its hand rather than a parameter to thread back through. +func (c *Client) routingFor(RoutingIntent) RoutingStrategy { + return c.routing() } // providerPrefs is the routing preference object. @@ -474,7 +504,8 @@ func (c *Client) providerPreferences(model string, knobs callKnobs) *providerPre // prefix, and a cold prefix cost 4.7× a warm one at identical token counts — // more than any endpoint's tariff differs from another's, so the errand // nobody is waiting on wants its cache back exactly as much as the person - // does. Its first request, having nothing pinned, still asks by price. + // does. Its first request, having nothing pinned, asks by the row's own + // sort word like any other. // // It is a preference and never a demand: `allow_fallbacks` stays true above, // so an endpoint that is busy, gone, or over the ceiling simply does not diff --git a/internal/session/auxiliary.go b/internal/session/auxiliary.go index 22058bc2c7..b11acafe9e 100644 --- a/internal/session/auxiliary.go +++ b/internal/session/auxiliary.go @@ -235,10 +235,12 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef // above about what bounds a rung. // // And IntentBackground for the other half of the same sentence. Nobody - // asked for it and nobody is waiting on it, so the fastest endpoint is - // worth nothing here and its price is worth everything — every errand in - // this package routes by price rather than by speed - // (internal/provider's velocity.go). This is the one place that says so, + // asked for it and nobody is waiting on it, so a second saved here buys + // nothing — which is what the lane chooser prices, and what an answer + // nobody reads is worth (internal/provider's workload.go). It does not + // choose a road: which road every call takes is the routing row's answer + // and this build no longer writes one for anybody (velocity.go's + // [provider.DefaultRouting]). This is the one place that says it, // because this is the one place an errand is made. // // AND THE ROLE ITSELF, WHICH IS THE SENTENCE ABOVE SAID PROPERLY. @@ -249,7 +251,7 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef // errand that answered while a person was waiting on their own slow // answer used to take the status line away from it, which was half of // the reported defect the clock exists for. The intent stays beside it - // because `provider.sort` is still built from it, and it is now a + // because what a wait is worth is still read off it, and it is now a // reading of the role rather than a second opinion about it. callCtx := provider.WithRole( provider.WithRoutingIntent(provider.WithoutStream(errandCtx), provider.IntentBackground), diff --git a/internal/session/loop.go b/internal/session/loop.go index 9290b67b90..9ac482d9af 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -643,16 +643,16 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // fact free to disagree, the λ saying a watched node's seconds belong to // somebody and the intent saying they belong to nobody. They are one stamp // now: the turn names its ROLE and internal/lane's table answers both, so an - // unwatched node still asks for the cheap endpoint and a node whose run - // somebody is sitting in front of asks for the soonest one. + // unwatched node's seconds are worth nothing and the seconds of a node whose + // run somebody is sitting in front of are worth what theirs are. // // AND ALL OF IT SAID ONCE, AS A ROLE. internal/lane's roles.go holds the // table — what a second of this errand's wait is worth, what quality bar it // needs, how many calls it will make, and whether a person is reading THIS // stream — and the role is the only thing a call site names. The intent - // below is kept beside it because `provider.sort` is still built from it and - // a dozen other packages still set it, but it is now a READING of the role - // rather than a second opinion about the same fact (internal/provider's + // below is kept beside it because what a wait is worth is still read off it + // and a dozen other packages still set it, but it is now a READING of the + // role rather than a second opinion about the same fact (internal/provider's // roles.go). ctx = provider.WithRole(ctx, a.laneRole()) // AND WHOSE ERRAND IT IS, beside what kind of errand it is, for the reason diff --git a/internal/session/memory_consolidate.go b/internal/session/memory_consolidate.go index 2b3947e4ec..dd580574b2 100644 --- a/internal/session/memory_consolidate.go +++ b/internal/session/memory_consolidate.go @@ -345,9 +345,9 @@ func consolidateAsk(ctx context.Context, client Completer, model string, batch [ response, err := client.CompleteWithMessages( // WithoutStream for the sentinel's reason: nobody is watching this, and // a stream would be typing JSON into a room that is not open. - // IntentBackground is the same fact aimed at the router: a pass that runs - // six hours from now is not waiting on the fastest endpoint, it is - // waiting on the cheapest one. + // IntentBackground is the same fact said where the lane chooser reads it: + // a pass that runs six hours from now has nobody waiting on it, so a + // second of its wait is worth nothing. // And the role, which is the same fact said where the table can price it: // a consolidation is the memory reflex's slow half, unattended and // judged on nothing but whether its answer is usable (internal/lane's diff --git a/internal/session/session.go b/internal/session/session.go index d9673491a5..a6e33844e8 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1112,9 +1112,9 @@ type Config struct { // Routing is how this session asks the router to choose among the endpoints // serving its model, and whether it times them at all (internal/provider's - // velocity.go). EMPTY IS LATENCY, the default the settings row carries, so a - // caller that says nothing still gets the fastest endpoint the router can - // find and still measures what it actually got. + // velocity.go). EMPTY IS NOBODY HAVING CHOSEN: the session falls to the row + // this process installed and, with none installed, to the shipped row + // ([provider.DefaultRouting]), which sends no preference of ours at all. Routing provider.RoutingStrategy // CompactEnabled gates automatic compaction. Manual compaction via the @@ -1367,8 +1367,8 @@ type Config struct { // ModelPrice is a model's own published list price, per token in US dollars, // and whether anybody published one (internal/catalog's PriceNow). The - // adapter bounds a latency-sorted request against it, so this session asks - // for the fastest endpoint that is not also charging several times what the + // adapter bounds a latency-sorted request against it, so a session whose + // routing row asks for speed is not also charging several times what the // model itself costs. // // NIL IS "NO PRICE IS KNOWN", which sends no ceiling and routes exactly as an diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index 57e6ac1da8..657f607387 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -1051,8 +1051,8 @@ func NewStandingSentinel(parent Config) standing.Sentinel { // every time and the item's own choice would do nothing (provider's // requestedEffort). // - // IntentBackground says the same thing to the router — nobody is - // waiting, so route on price rather than on speed. + // IntentBackground says the same thing where the lane chooser reads it — + // nobody is waiting, so a second of this wait is worth nothing. // // And the role says both of those once, in the vocabulary the router and // the phase clock share: a standing run has no one in front of it, so diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index a96877e623..b30c5143ef 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -99,10 +99,20 @@ var laneCatalog = []Model{ {ID: "moonshotai/kimi-k3"}, } +// laneApp is a picker over this lab's ledger, ON THE RANKED ROAD. +// +// The shipped routing row is `simple` ([config.DefaultRouting]) and under it +// nothing on aforge's side chooses a machine — no takeover sentence, no +// `recommended`, no name beside `auto` (palette.go's [laneAutoSaid]). The folds +// these tests read are about the road where aforge does choose, so the row is +// written here once rather than at each of them; the test that is about the +// shipped row writes `simple` into a profile of its own +// (TestUnderSimpleRoutingTheAutoRowPromisesNoTakeover). func laneApp(t *testing.T) *app { t.Helper() a := pickerApp(t, &fakeAgent{model: flash}, laneCatalog) a.profileDir = t.TempDir() + a.routing = config.RoutingLatency return a } @@ -482,6 +492,8 @@ func TestAHostedSurfacePinsNothing(t *testing.T) { func laneSheet(t *testing.T) (*app, string) { t.Helper() a, dir := sheetApp(t) + // On the ranked road, for [laneApp]'s reason. + a.routing = config.RoutingLatency a.width = 120 a.model = flash a.sheet.sessionModel = flash diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 0cbf4da78c..8b6cdf45b1 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1540,10 +1540,14 @@ type laneAutoSay struct { // chose in order to be left alone — so the row that still said "aforge takes // over" would be the surface promising machinery the mode disconnected. // -// An unknown word — a row read before this surface armed anything — is the -// shipped routing, which is what [config.DefaultRouting] is. +// An unknown word — an empty one, or a row read before this surface armed +// anything — is the shipped routing, and the sentence follows it there rather +// than keeping a favourite of its own: [config.RoutingWord] is the one place +// that says what an unwritten row is in force as, and the shipped row is +// `simple`, so a surface that fell through to the takeover sentence would be +// promising the machinery the shipped row disconnects. func laneAutoSaid(routing string) laneAutoSay { - if routing == config.RoutingSimple { + if config.RoutingWord(routing) == config.RoutingSimple { return laneAutoSay{ note: "openrouter's own routing; aforge stays out", about: "which machine behind your model answers you. routing is simple, so auto " + diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 69934da9da..a93c6779e4 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -2361,10 +2361,10 @@ const servedWindow = 10 * time.Minute // deepseek-v4-flash · via deepinfra · 92 tok/s // // A model id is an address, not a machine. One id is fanned over many endpoints -// that answer at very different speeds for the same price, and the adapter both -// asks for the fastest of them and times what it got (internal/provider's -// velocity.go). The id alone therefore names a decision the session did not -// make — this is the part of it that is a fact. +// that answer at very different speeds for the same price, and which of them +// answered is decided by the router unless somebody's routing row says otherwise +// (internal/provider's velocity.go). The id alone therefore names a decision the +// session did not make — this is the part of it that is a fact. // // It is drawn only when the server's name is not already the model's own: an // endpoint that IS the vendor adds nothing to "deepseek-v4-flash", and a line diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d7bc9a9680..2826ae0058 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -681,11 +681,12 @@ var settingUI = map[string]settingMeta{ // differences the session pays attention to. config.KeyRouting: { tab: tabProviders, label: "routing", widget: widgetCycle, - about: "one model is served by many endpoints. latency asks for the fastest and " + - "demotes one that keeps being slow; price asks for the cheapest; simple sends no " + - "preference of ours — no pinned lane means the router's own default answers, and " + - "a pinned lane is the whole request; off asks for nothing, measures nothing, " + - "and leaves the two rows above it with no machine to name.", + about: "one model is served by many endpoints. simple is the one it ships with and " + + "sends no preference of ours — no pinned lane means the router's own default " + + "answers, and a pinned lane is the whole request; latency asks for the fastest " + + "and demotes one that keeps being slow; price asks for the cheapest; off asks " + + "for nothing, measures nothing, and leaves the two rows above it with no " + + "machine to name.", }, // AND UNDER IT, THE MACHINE ITSELF. routing is about what every request // prefers; this is about which endpoint your conversation actually lands on. From 63cb46c0579ca3526363d50fdb34faec00e3b267 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:39:16 -0400 Subject: [PATCH 11/22] manual, tui3: the lanes page keeps the words somebody asks the question in, and the sheet's prediction says which road it is about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of the default moving, both caught by a gate. "does aforge do pick the fastest endpoint too" stopped reaching the lanes page. Rewriting the four answers there took `pick` and `fastest` out of the paragraph a person lands on, and retrieval is the feature: the sentence now says what is true — left alone aforge picks no machine at all, no fastest endpoint and no cheapest one — in the words the asker used. And the settings model row's `auto (cloudflare now)` tail is a PREDICTION, drawn only where aforge is the one choosing, so the test about it says which road it is about rather than leaning on a default that has moved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/manual/chat/lanes.md | 7 ++++--- internal/tui3/lanes_test.go | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index cac717facb..f1e93d3303 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -946,9 +946,10 @@ steering, it is the promise this surface makes — but it stops choosing endpoin for you, stops sending second requests, and stops spending anything on speed. **The row has four answers, and the two quiet ones are not the same nothing.** Left alone -it reads `simple`, which sends no preference of aforge's own at all — your pin, if you made -one, is the whole request (the section above). Writing another word in the row turns the -choosing on everywhere: `latency` asks for the fastest machine on every call, background +it reads `simple`, and aforge does not pick a machine for you at all — it asks for no +fastest endpoint and no cheapest one, sends no preference of its own, and your pin, if you +made one, is the whole request (the section above). Writing another word in the row turns +the choosing on everywhere: `latency` picks the fastest endpoint on every call, background work included; `price` ranks on price alone on every call, your own turns included, which is you saying that speed is not worth money anywhere; and `off` is the paragraph above. Under `latency` and `price` the work you are not watching still weighs speed, at a quarter diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index b30c5143ef..4c50eccf5f 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -773,6 +773,9 @@ func TestAMediaSlotPickerHasNoLanes(t *testing.T) { func TestTheModelRowInSettingsNamesTheLane(t *testing.T) { laneLab(t, threeLanes()) a, _ := sheetApp(t) + // The tail is a PREDICTION, so it is drawn only where aforge is the one + // choosing — the ranked road, for [laneApp]'s reason. + a.routing = config.RoutingLatency a.model = flash a.sheet.sessionModel = flash a.openSettings() From 21f5da0703c5a0d9201c6eccbbcbcdab5172b6fd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:39:56 -0400 Subject: [PATCH 12/22] =?UTF-8?q?config:=20the=20routing=20row's=20hint=20?= =?UTF-8?q?reads=20straight=20=E2=80=94=20the=20shipped=20answer,=20then?= =?UTF-8?q?=20what=20choosing=20another=20word=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/config/settings.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 97ca8c5c36..9f0264be10 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1971,7 +1971,8 @@ func (s *Settings) build() []Setting { "different speeds AND very different prices. Left alone — simple — aforge " + "sends no preference of its own at all: with no lane pinned the router's own " + "default routing answers, and a lane you pinned is the whole request, that " + - "machine and no fallbacks. The other three ask for something: latency asks " + + "machine and no fallbacks. Choosing another word here changes that " + + "everywhere: latency asks " + "for the fastest endpoint for every call, capped at a quarter over the " + "model's list price, and times every answer, demoting one that keeps being " + "slow; price asks for the cheapest for every call; off asks for nothing and " + From 6a9690290ffddf51e13f836c25feb78969f52cb8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:54:01 -0400 Subject: [PATCH 13/22] provider, tui3: the suites about the ranked road say which road they are about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client built out of a bare Config used to be on the ranked road by default, so two dozen fixtures proved the hedge, the walk, the ceiling, the ledger's order, the cache pin and the fold's predicted machine without ever naming it. The row they were leaning on has moved, and none of those tests is about the row — so the lane rig, the pool client, the endpoint-filter rig, the account-exclusion rig and the picker's width lab say `latency` once, through the one fixture (`rankedRoad`), rather than at each of their call sites. Three tests were about the default itself and are flipped rather than propped up. `ParseRoutingStrategy`'s unknown word falls to the shipped row. The pair that held the per-intent default — an errand sorted by price, a person's turn by speed — is replaced by one test that says what is true now: who is waiting no longer picks a road, and a written `price` row is the same row for both of them. The warm-cache test that used an errand's intent to reach the price sort asks for the price row instead, and says why it was written the other way. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/provider/affinity_test.go | 22 ++++-- internal/provider/endpoints_test.go | 4 +- internal/provider/hedge_test.go | 8 +- internal/provider/neverrepeat_test.go | 4 +- internal/provider/recovery_test.go | 2 +- internal/provider/routing_fastlanes_test.go | 2 +- internal/provider/routing_price_test.go | 81 ++++++++++++--------- internal/provider/routingdefault_test.go | 12 --- internal/provider/velocity_test.go | 14 ++-- internal/tui3/rowfit_test.go | 7 ++ 10 files changed, 88 insertions(+), 68 deletions(-) diff --git a/internal/provider/affinity_test.go b/internal/provider/affinity_test.go index 7d62b0ebc0..a698e48716 100644 --- a/internal/provider/affinity_test.go +++ b/internal/provider/affinity_test.go @@ -260,7 +260,7 @@ func TestAZeroCacheReadOnAShortPromptKeepsThePin(t *testing.T) { // The pin never buys speed or cache at any price: the same ceiling the latency // ask has always carried rides on every pinned request too. func TestAPinnedRequestStillCarriesThePriceCeiling(t *testing.T) { - client, recorded := pinningClient(t, nil, 0.0000004, 0.0000016, true, + client, recorded := pinningClient(t, StaticRouting(RoutingLatency), 0.0000004, 0.0000016, true, answering(ok(answerFrom("quicksilver", 90000, 89000, 0)))) ctx := lineage("conversation-1") for _, prompt := range []string{"hello", "again"} { @@ -287,7 +287,7 @@ func TestAnEndpointThatChargedAboveTheCeilingLosesThePin(t *testing.T) { // $0.40/M in, $1.60/M out, so 90000 prompt and 2 completion tokens are // allowed 90000/1e6 * 0.4 * 1.25 = $0.045 at the ceiling. The answer charges // well over it. - client, recorded := pinningClient(t, nil, 0.0000004, 0.0000016, true, + client, recorded := pinningClient(t, StaticRouting(RoutingLatency), 0.0000004, 0.0000016, true, answering(ok(answerFrom("quicksilver", 90000, 89000, 0.2)))) ctx := lineage("conversation-1") for _, prompt := range []string{"hello", "again"} { @@ -301,7 +301,7 @@ func TestAnEndpointThatChargedAboveTheCeilingLosesThePin(t *testing.T) { } func TestAnEndpointChargingUnderTheCeilingKeepsThePin(t *testing.T) { - client, recorded := pinningClient(t, nil, 0.0000004, 0.0000016, true, + client, recorded := pinningClient(t, StaticRouting(RoutingLatency), 0.0000004, 0.0000016, true, answering(ok(answerFrom("quicksilver", 90000, 89000, 0.01)))) ctx := lineage("conversation-1") for _, prompt := range []string{"hello", "again"} { @@ -316,11 +316,17 @@ func TestAnEndpointChargingUnderTheCeilingKeepsThePin(t *testing.T) { // ── WHO ELSE IT APPLIES TO ────────────────────────────────────────────────── -// A worker nobody is waiting on asks for the cheapest endpoint on its first -// request and then comes back to it, because a cold prefix costs more than any -// two endpoints' tariffs differ by. -func TestACallNobodyIsWaitingOnPinsOnceItIsWarm(t *testing.T) { - client, recorded := pinningClient(t, nil, 0.0000004, 0.0000016, true, +// A SESSION ON THE PRICE ROW IS PINNED TOO. It asks for the cheapest endpoint on +// its first request and then comes back to the one that answered, because a cold +// prefix costs more than any two endpoints' tariffs differ by — and that is as +// true of the cheapest road as of the fastest one. +// +// It was written as "a worker nobody is waiting on", because who was waiting is +// what used to put a call on the price road. Nothing does that by itself now +// (velocity.go's [DefaultRouting]), so the row says it, and the errand's intent +// stays on the context because that is what a real errand carries. +func TestAPriceSortedCallPinsOnceItIsWarm(t *testing.T) { + client, recorded := pinningClient(t, StaticRouting(RoutingPrice), 0.0000004, 0.0000016, true, answering(ok(answerFrom("quicksilver", 90000, 89000, 0)))) ctx := WithRoutingIntent(lineage("worker-1"), IntentBackground) for _, prompt := range []string{"hello", "again"} { diff --git a/internal/provider/endpoints_test.go b/internal/provider/endpoints_test.go index eca7f5dce3..e327f8a876 100644 --- a/internal/provider/endpoints_test.go +++ b/internal/provider/endpoints_test.go @@ -144,8 +144,8 @@ func TestAnAllProvidersIgnoredRefusalClimbsTheLadder(t *testing.T) { _, _ = writer.Write([]byte(`{"model":"sim/model","choices":[{"index":0,"finish_reason":"stop",` + `"message":{"role":"assistant","content":"ok"}}]}`)) }) - config := Config{APIKey: "test-key", BaseURL: "https://openrouter.ai/api/v1", - HTTPClient: handlerClient(handler), Model: "sim/model"} + config := rankedRoad(Config{APIKey: "test-key", BaseURL: "https://openrouter.ai/api/v1", + HTTPClient: handlerClient(handler), Model: "sim/model"}) client, err := NewClient(config) if err != nil { t.Fatal(err) diff --git a/internal/provider/hedge_test.go b/internal/provider/hedge_test.go index 4e3c172ecc..e5f6576868 100644 --- a/internal/provider/hedge_test.go +++ b/internal/provider/hedge_test.go @@ -132,9 +132,13 @@ func newLaneRigWithPrice( server := lanestub.New(model, lanesOffered...) t.Cleanup(server.Close) - client, err := NewClient(Config{ + // AND THE RIG IS ON THE RANKED ROAD ([rankedRoad]). Every hedge, walk, + // ceiling and veto proved through this rig is `latency` machinery; the row + // this build ships sends no preference at all, and a rig that said nothing + // would be proving all of it against a road none of it runs on. + client, err := NewClient(rankedRoad(Config{ APIKey: "test-key", BaseURL: server.URL(), Model: model, ModelPrice: modelPrice, - }) + })) if err != nil { t.Fatal(err) } diff --git a/internal/provider/neverrepeat_test.go b/internal/provider/neverrepeat_test.go index a6f36a1289..5ecca9e3b1 100644 --- a/internal/provider/neverrepeat_test.go +++ b/internal/provider/neverrepeat_test.go @@ -160,8 +160,8 @@ func (h *poolHandler) wire(attempt int) wirePrefs { func poolClient(t *testing.T, name string, handler http.Handler) *Client { t.Helper() t.Setenv(home.EnvVar, t.TempDir()) - client, err := NewClient(Config{APIKey: "k", BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/" + name, HTTPClient: handlerClient(handler)}) + client, err := NewClient(rankedRoad(Config{APIKey: "k", BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/" + name, HTTPClient: handlerClient(handler)})) if err != nil { t.Fatal(err) } diff --git a/internal/provider/recovery_test.go b/internal/provider/recovery_test.go index 198fdb39e1..cd506c2631 100644 --- a/internal/provider/recovery_test.go +++ b/internal/provider/recovery_test.go @@ -235,7 +235,7 @@ func TestAnAccountExclusionIsLearnedOnceForEveryModel(t *testing.T) { // home is the same, so the file this process wrote is what the next one // reads when it wires its sheet. lanes.ForgetAccountExclusionsInMemory() - again, err := NewClient(Config{APIKey: "test-key", BaseURL: rig.server.URL(), Model: modelB}) + again, err := NewClient(rankedRoad(Config{APIKey: "test-key", BaseURL: rig.server.URL(), Model: modelB})) if err != nil { t.Fatal(err) } diff --git a/internal/provider/routing_fastlanes_test.go b/internal/provider/routing_fastlanes_test.go index fe9e280d0a..e5e56bcdbf 100644 --- a/internal/provider/routing_fastlanes_test.go +++ b/internal/provider/routing_fastlanes_test.go @@ -18,7 +18,7 @@ import ( // that one: 404, and the two fastest lanes for the model were reachable only // through a rescue arm. func TestTheBeliefsOrderRidesUnderACeilingItFitsUnder(t *testing.T) { - client, recorded := pricedClient(t, nil, 0.0000002, 0.0000006, true) + client, recorded := pricedClient(t, StaticRouting(RoutingLatency), 0.0000002, 0.0000006, true) lanes.HeardPrefsCarried(client.config.BaseURL) const model = "vendor/fast-model" primed(t, model, diff --git a/internal/provider/routing_price_test.go b/internal/provider/routing_price_test.go index 1a0ea4fd20..788b002407 100644 --- a/internal/provider/routing_price_test.go +++ b/internal/provider/routing_price_test.go @@ -12,10 +12,15 @@ import ( // // A cost autopsy over 44 bench cells found this adapter paying ~3.5× the list // price of the very models it named, because `sort: latency` asked for the -// fastest endpoint and named no ceiling at all. These tests pin the two halves -// of the answer: a latency ask carries a ceiling derived from the model's own -// published price, and a call nobody is waiting on does not ask for latency in -// the first place. +// fastest endpoint and named no ceiling at all. The answer was a ceiling derived +// from the model's own published price, and these tests pin it to the ask it +// rides on. +// +// THE OTHER HALF OF THAT ANSWER IS GONE, and the tests below say so: a call +// nobody was waiting on used to be routed by price WITHOUT ANYBODY ASKING, and +// a person's own turn by speed. The road is nobody's to guess now — a client +// that was handed no row takes [DefaultRouting] whoever is waiting on it +// (velocity.go), and `latency` and `price` are words somebody writes. // pricedClient is a router-shaped client that knows what its model lists at. // The prices are per token in US dollars, as the catalog publishes them. @@ -59,7 +64,7 @@ func ceilingOn(t *testing.T, recorded *capture, index int) map[string]any { func TestALatencyAskCarriesACeilingDerivedFromTheModelsOwnListPrice(t *testing.T) { // $0.40/M in and $1.60/M out, spelled per token the way the catalog does. - client, recorded := pricedClient(t, nil, 0.0000004, 0.0000016, true) + client, recorded := pricedClient(t, StaticRouting(RoutingLatency), 0.0000004, 0.0000016, true) if _, err := client.CompleteWithMessages(context.Background(), userMessages("hello")); err != nil { t.Fatal(err) } @@ -87,7 +92,7 @@ func TestALatencyAskCarriesACeilingDerivedFromTheModelsOwnListPrice(t *testing.T // because a ceiling invented from nothing would refuse endpoints on a number // that does not exist. func TestNoPublishedPriceSendsNoCeiling(t *testing.T) { - client, recorded := pricedClient(t, nil, 0, 0, false) + client, recorded := pricedClient(t, StaticRouting(RoutingLatency), 0, 0, false) if _, err := client.CompleteWithMessages(context.Background(), userMessages("hello")); err != nil { t.Fatal(err) } @@ -112,38 +117,46 @@ func TestAnUnwiredPriceSeamSendsNoCeiling(t *testing.T) { } } -// ── WHO IS WAITING ────────────────────────────────────────────────────────── +// ── WHO IS WAITING NO LONGER CHOOSES THE ROAD ─────────────────────────────── -func TestACallNobodyIsWaitingOnRoutesByPrice(t *testing.T) { - client, recorded := pricedClient(t, nil, 0.0000004, 0.0000016, true) - ctx := WithRoutingIntent(context.Background(), IntentBackground) - if _, err := client.CompleteWithMessages(ctx, userMessages("name this")); err != nil { - t.Fatal(err) - } - prefs := prefsOn(t, recorded, 0) - if prefs == nil || prefs["sort"] != "price" { - t.Fatalf("preferences = %v, want an errand routed on price", prefs) - } - // And no ceiling with it: sorting by price is already asking for the - // cheapest thing available, and a ceiling could only take endpoints away. - if _, carried := prefs["max_price"]; carried { - t.Fatalf("a price-sorted request carried a ceiling: %v", prefs["max_price"]) - } -} - -func TestTheConversationsOwnTurnStillRoutesByLatency(t *testing.T) { - client, recorded := pricedClient(t, nil, 0.0000004, 0.0000016, true) - ctx := WithRoutingIntent(context.Background(), IntentInteractive) - if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { - t.Fatal(err) +// The two tests this replaces were TestACallNobodyIsWaitingOnRoutesByPrice and +// TestTheConversationsOwnTurnStillRoutesByLatency, and between them they were +// the per-request default: an errand sorted by price and a person's own turn by +// speed, neither of them anybody's instruction. What is left is one answer for +// both, and it is the row somebody wrote or the row this build ships. +func TestWhoIsWaitingNoLongerPicksTheRoad(t *testing.T) { + for _, intent := range []RoutingIntent{IntentBackground, IntentInteractive} { + client, recorded := pricedClient(t, nil, 0.0000004, 0.0000016, true) + ctx := WithRoutingIntent(context.Background(), intent) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + if prefs := prefsOn(t, recorded, 0); prefs != nil { + t.Fatalf("a client nobody routed sent %v for intent %v, want nothing at all", prefs, intent) + } } - if prefs := prefsOn(t, recorded, 0); prefs == nil || prefs["sort"] != "latency" { - t.Fatalf("preferences = %v, want the person's own turn still chasing speed", prefs) + // AND A WRITTEN ROW IS THE SAME ROW FOR BOTH OF THEM. `price` was what an + // errand used to get for free; it is now what asking for it gets, and a + // person's own turn gets it too. + for _, intent := range []RoutingIntent{IntentBackground, IntentInteractive} { + client, recorded := pricedClient(t, StaticRouting(RoutingPrice), 0.0000004, 0.0000016, true) + ctx := WithRoutingIntent(context.Background(), intent) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + prefs := prefsOn(t, recorded, 0) + if prefs == nil || prefs["sort"] != "price" { + t.Fatalf("preferences = %v for intent %v, want the written price sort", prefs, intent) + } + // And no ceiling with it: sorting by price is already asking for the + // cheapest thing available, and a ceiling could only take endpoints away. + if _, carried := prefs["max_price"]; carried { + t.Fatalf("a price-sorted request carried a ceiling: %v", prefs["max_price"]) + } } } -// A ROW A PERSON WROTE WINS OVER BOTH. The default moves with who is waiting; -// an explicit choice does not move at all. +// A ROW A PERSON WROTE IS THE WHOLE ANSWER, whoever is waiting on the call. func TestAnExplicitRoutingRowOverridesTheIntent(t *testing.T) { client, recorded := pricedClient(t, StaticRouting(RoutingLatency), 0.0000004, 0.0000016, true) ctx := WithRoutingIntent(context.Background(), IntentBackground) @@ -184,7 +197,7 @@ func TestRoutingOffStaysOffForAnErrand(t *testing.T) { // first — so sending it beside a price sort would silently undo the sort. The // refusals still ride: those are about endpoints that will not answer at all. func TestAPriceSortedRequestCarriesTheRefusalsAndNoSpeedRanking(t *testing.T) { - client, recorded := pricedClient(t, nil, 0.0000004, 0.0000016, true) + client, recorded := pricedClient(t, StaticRouting(RoutingPrice), 0.0000004, 0.0000016, true) const model = "vendor/fast-model" client.velocity.brisk(model, "quicksilver") for i := 0; i < ignoreAfter; i++ { diff --git a/internal/provider/routingdefault_test.go b/internal/provider/routingdefault_test.go index 7adcc9683c..013110fbf9 100644 --- a/internal/provider/routingdefault_test.go +++ b/internal/provider/routingdefault_test.go @@ -88,15 +88,3 @@ func TestTheShippedRowStillSendsThePinAndNothingElse(t *testing.T) { t.Fatalf("the pinned ask carried %+v, want only Harbor and fallbacks off", demand) } } - -// AN UNKNOWN WORD IS THE SHIPPED ROW AND NOT A ROAD NOBODY ASKED FOR. A row a -// later build spells, or a typo, must not put a session on the ranked road — -// which is what a fallback of `latency` did while `latency` was the default. -func TestAnUnreadableRoutingWordFallsToTheShippedRow(t *testing.T) { - if got, known := ParseRoutingStrategy("quickest"); known || got != DefaultRouting { - t.Fatalf("an unknown word parsed to %q (known=%v), want the shipped %q", got, known, DefaultRouting) - } - if got, known := ParseRoutingStrategy(""); known || got != DefaultRouting { - t.Fatalf("an empty word parsed to %q (known=%v), want the shipped %q", got, known, DefaultRouting) - } -} diff --git a/internal/provider/velocity_test.go b/internal/provider/velocity_test.go index 409c9fc52d..ecc2931e8f 100644 --- a/internal/provider/velocity_test.go +++ b/internal/provider/velocity_test.go @@ -958,12 +958,14 @@ func TestParseRoutingStrategyFallsBackToTheDefault(t *testing.T) { t.Fatalf("ParseRoutingStrategy(%q) = %v, %v, want %v, true", word, got, ok, want) } } - // A word this build does not know must not take routing away. - if got, ok := ParseRoutingStrategy("fastest-please"); got != RoutingLatency || ok { - t.Fatalf("ParseRoutingStrategy(unknown) = %v, %v, want latency, false", got, ok) - } - if got, _ := ParseRoutingStrategy(""); got != RoutingLatency { - t.Fatalf("ParseRoutingStrategy(empty) = %v, want the default", got) + // A word this build does not know is the shipped row and not a road nobody + // asked for — which is what a fallback of `latency` was once `latency` had + // stopped being the default. + if got, ok := ParseRoutingStrategy("fastest-please"); got != DefaultRouting || ok { + t.Fatalf("ParseRoutingStrategy(unknown) = %v, %v, want %v, false", got, ok, DefaultRouting) + } + if got, _ := ParseRoutingStrategy(""); got != DefaultRouting { + t.Fatalf("ParseRoutingStrategy(empty) = %v, want the shipped %v", got, DefaultRouting) } } diff --git a/internal/tui3/rowfit_test.go b/internal/tui3/rowfit_test.go index c8ab822c97..03da3b384e 100644 --- a/internal/tui3/rowfit_test.go +++ b/internal/tui3/rowfit_test.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/x/ansi" + "github.com/Agent-Field/aforge-v2/internal/config" "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" ) @@ -30,6 +31,12 @@ func rowfitPicker(t *testing.T) *app { laneLab(t, threeLanes()) a := pickerApp(t, &fakeAgent{model: flash}, rowfitCatalog) a.profileDir = t.TempDir() + // ON THE RANKED ROAD, and before the picker is armed, because the row is + // copied onto the list when it opens (lanes.go's [app.armLanes]). The fold + // these widths are measured against is the one where aforge names the + // machine it would send you to, and the shipped row names none + // (palette.go's [laneAutoSaid]). + a.routing = config.RoutingLatency typeLine(t, a, "/model") a.pick.pin = "CoreWeave" return a From 30e94c8490f16ac5435beb8d032043cd2c7a6f93 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 17:54:21 -0400 Subject: [PATCH 14/22] =?UTF-8?q?provider:=20lane=20E's=20two=20lines=20in?= =?UTF-8?q?=20lane=20D's=20file=20=E2=80=94=20the=20simple-mode=20rig=20na?= =?UTF-8?q?mes=20the=20ranked=20road?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEPARABLE ON PURPOSE. simplemode_verify_test.go belongs to the lane working on the routing wire beside this one, and this commit exists so the branch it is on is green; drop it if that lane has already said the same thing. Both changes are the same fact. A client handed nothing no longer routes by who is waiting, so the assertion that it answers price for an errand and latency for a turn becomes one assertion that it answers the shipped row for both. And the contrast arm of the pin test — "the ranked road, which is what stands a pin down on a saved belief" — installs `latency` by name instead of installing nothing, because installing nothing is `simple` now and `simple` is the arm below it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/provider/simplemode_verify_test.go | 24 ++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index 53ea86c9ce..06d5029fc9 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -206,15 +206,18 @@ func TestAClientHandedNoRowAnswersTheOneThisProcessInstalled(t *testing.T) { if strategy := client.routingFor(IntentInteractive); strategy != RoutingPrice { t.Fatalf("a client handed price routes on %q, want the answer it was handed", strategy) } - // And with nothing installed and nothing handed down, the default still - // moves with who is waiting, which is what an unwritten row has always done. + // And with nothing installed and nothing handed down, every call takes the + // row this build ships, whoever is waiting on it. It used to move with who + // was waiting — price for an errand, latency for a person's own turn — and + // that guess is what the shipped row replaces (velocity.go's + // [DefaultRouting]). InstallRouting("") client.config.Routing = nil - if strategy := client.routingFor(IntentBackground); strategy != RoutingPrice { - t.Fatalf("an unwritten row routes a background call on %q, want price", strategy) - } - if strategy := client.routingFor(IntentInteractive); strategy != RoutingLatency { - t.Fatalf("an unwritten row routes a person's turn on %q, want latency", strategy) + for _, intent := range []RoutingIntent{IntentBackground, IntentInteractive} { + if strategy := client.routingFor(intent); strategy != DefaultRouting { + t.Fatalf("an unwritten row routes intent %v on %q, want the shipped %q", + intent, strategy, DefaultRouting) + } } } @@ -234,9 +237,10 @@ func TestSimpleRoutingLeavesAPinTheRankedRoadWouldHaveStoodDown(t *testing.T) { pinned(t, LanePin{Lane: "Ghost"}) request := &ai.Request{Model: rig.model, Messages: userMessages("hello")} - // THE RANKED ROAD, WHICH IS THE CONTRAST. Nothing installed, nothing handed - // down: the saved exclusion stands the pin down before the wire is asked. - installedRow(t, "") + // THE RANKED ROAD, WHICH IS THE CONTRAST. It is NAMED rather than left to a + // default: `latency` is the road where the saved exclusion stands the pin + // down before the wire is asked, and the row nobody writes is `simple` now. + installedRow(t, RoutingLatency) rig.client.config.Routing = nil if choice, made := rig.client.drawLaneChoice(turnKnobs(), rig.model, request); made && len(choice.Only) > 0 { t.Fatalf("the ranked road demanded %v for a machine the account excludes", choice.Only) From 8d558ce2104dbb16ea7644ed79fab19d980947ee Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:08:38 -0400 Subject: [PATCH 15/22] provider: under simple with no pin a stall is reported, never rescued, and the road is asked once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's whole promise is that nothing on this side names a machine. The hedge already honoured that — an empty choice races no arm — but a stall is a different door: the controller rescues off the wait plan's alternatives, and with the chooser holding nothing those were filled off the lane sheet, so the speed guard could send a second request demanding a lane this process picked while the first was still streaming. Now the plan has no alternatives under `simple` without a pin. With a pin they stay, because there the act is the `switch to auto?` question and a question has to name where a `y` would go. routingFor collapses into routing. Since `simple` became the shipped row the intent no longer picks the road, so the function that took it had one line left and its parameter was documented as unread; two call sites now ask the one question by its one name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1019-simple-routing-mode.md | 1 + internal/provider/lanes.go | 4 +- internal/provider/simplemode_verify_test.go | 44 +++++++++++++++---- internal/provider/velocity.go | 35 ++++++--------- internal/provider/waitplan.go | 10 ++++- 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/docs/changes/unreleased/1019-simple-routing-mode.md b/docs/changes/unreleased/1019-simple-routing-mode.md index 697081eb1f..5ae9c4f4a7 100644 --- a/docs/changes/unreleased/1019-simple-routing-mode.md +++ b/docs/changes/unreleased/1019-simple-routing-mode.md @@ -14,6 +14,7 @@ invalidates: - "The three parked sentences a person is owed were said at `Client.sendShaped`, which is entered once per CALL — so a line learned from a call's own refusal had nowhere to go until the next call, because the widened retry a retired pin earns and every rung of the relaxation ladder leave through a lower door. They are now said at `Client.send`, the door every BODY passes through." - "A widened retry's model-call row described the request before it. The bare retry of a retired pin carried no `provider` key at all and was logged `lane: DeepSeek, served: DeepSeek, relaxed: [provider.require_parameters]` — the machine that had just refused it, and a field a `simple` request never sends. A row now names a lane only while the body still asks for one, `served` is no longer back-filled from a demand that has been withdrawn, and the widening rung names the fields it really took off this object (`provider.only` first)." - "The lanes page's machinery — the takeover, the closed set, the refusal walk, the probe — read as what aforge does while it is choosing. It is what `latency` and `price` do; under `simple` the routing gate, the belief chooser, the hedge and the probes stay compiled in but are disconnected from the request path, and none of it runs." + - "A stall under `simple` with no pin could still be rescued: the wait plan filled its alternatives off the lane sheet when the chooser held nothing, so the speed guard could send a second request demanding a machine aforge picked. Under `simple` with no pin the plan has no alternatives and a stall is reported, never rescued; with a pin the alternatives stay because the act there is the `switch to auto?` question." --- The provider-choosing stack is not deleted, it is disconnected: every layer still diff --git a/internal/provider/lanes.go b/internal/provider/lanes.go index 51cce84b58..7d4a9bebd0 100644 --- a/internal/provider/lanes.go +++ b/internal/provider/lanes.go @@ -140,7 +140,7 @@ func callHorizonFrom(ctx context.Context) int { // // THE ROW A PERSON WROTE AND THE DEFAULT DERIVED FROM WHO IS WAITING ARE NOT // THE SAME FACT, and reading them through one value silently made λ a dead -// letter for every background call. [Client.routingFor] answers `price` for an +// letter for every background call. The default used to answer `price` for an // unattended call because nobody said otherwise; taking that as "a person said // speed is worthless" then discarded the call site's own λ, so a task node that // declared its wait was worth something was routed as though it had declared @@ -502,7 +502,7 @@ func (c *Client) drawLaneChoice(knobs callKnobs, model string, request *ai.Reque if pin.OpenRouter { return lanes.Choice{}, false } - strategy := c.routingFor(knobs.intent) + strategy := c.routing() if strategy == RoutingOff { return lanes.Choice{}, false } diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index 06d5029fc9..f5f3c341af 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -1,6 +1,7 @@ package provider import ( + "context" "net/http" "slices" "strings" @@ -190,20 +191,20 @@ func TestAClientHandedNoRowAnswersTheOneThisProcessInstalled(t *testing.T) { // A client handed nothing reads the installed row. client.config.Routing = nil - if strategy := client.routingFor(IntentInteractive); strategy != RoutingSimple { + if strategy := client.routing(); strategy != RoutingSimple { t.Fatalf("a client handed no row routes on %q, want the row this process installed", strategy) } // And so does one handed a source that says nothing, which is how a session // with an unwritten row hands its answer down. client.config.Routing = StaticRouting("") - if strategy := client.routingFor(IntentInteractive); strategy != RoutingSimple { + if strategy := client.routing(); strategy != RoutingSimple { t.Fatalf("a client handed an empty source routes on %q, want the installed row", strategy) } // A CALLER THAT SPOKE STILL WINS. An engine host and a task child carry // their parent's row explicitly, and a process-wide fallback may not // overrule somebody who said a word. client.config.Routing = StaticRouting(RoutingPrice) - if strategy := client.routingFor(IntentInteractive); strategy != RoutingPrice { + if strategy := client.routing(); strategy != RoutingPrice { t.Fatalf("a client handed price routes on %q, want the answer it was handed", strategy) } // And with nothing installed and nothing handed down, every call takes the @@ -213,11 +214,38 @@ func TestAClientHandedNoRowAnswersTheOneThisProcessInstalled(t *testing.T) { // [DefaultRouting]). InstallRouting("") client.config.Routing = nil - for _, intent := range []RoutingIntent{IntentBackground, IntentInteractive} { - if strategy := client.routingFor(intent); strategy != DefaultRouting { - t.Fatalf("an unwritten row routes intent %v on %q, want the shipped %q", - intent, strategy, DefaultRouting) - } + if strategy := client.routing(); strategy != DefaultRouting { + t.Fatalf("an unwritten row routes on %q, want the shipped %q", strategy, DefaultRouting) + } +} + +// ── NO PIN, NO SECOND REQUEST ─────────────────────────────────────────────── +// +// The hedge reads the choice and the choice is empty, so no arm races the +// first request. But a STALL is a different door: the controller rescues off +// the plan's alternatives, and with the chooser holding nothing those came off +// the lane sheet (waitplan.go's sheetAlts) — a second request demanding a +// machine this process picked, under the one row that promises it picks none. +// With a pin the alternatives stay, because the act there is the offer and a +// question has to name where a `y` would go. +func TestSimpleWithNoPinPlansNoSecondRequest(t *testing.T) { + client, _, model := stubbedRouter(t) + client.config.Routing = StaticRouting(RoutingSimple) + t.Cleanup(func() { lanes.Default().Reset() }) + if err := lanes.Default().Sheet().Refresh(context.Background(), model); err != nil { + t.Fatalf("filling the sheet: %v", err) + } + if rows := lanes.Default().Sheet().Rows(model); len(rows) < 2 { + t.Fatalf("the stub gave %d sheet rows, want at least two for a rescue to have somewhere to go", len(rows)) + } + + bare := client.planFor(context.Background(), lanes.Choice{}, model, 0) + if len(bare.Alts) != 0 { + t.Fatalf("simple with no pin planned a rescue to %v, want nowhere", bare.Alts) + } + asked := client.planFor(context.Background(), pinnedChoice(model, "quicksilver", "brass"), model, 0) + if !asked.Pinned || len(asked.Alts) == 0 { + t.Fatalf("simple with a pin planned %+v, want the offer to have somewhere to go", asked) } } diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index a096c094f7..fff6a29b98 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -288,32 +288,23 @@ func (c *Client) routingChoice() (RoutingStrategy, bool) { // routing resolves the strategy for this client. It is what the machinery's own // gates read — whether to measure, to probe, to hedge, to hold a cache pin — -// and it is the same answer [Client.routingFor] gives the request beside it, so -// a gate cannot be open on a road no request is taking. +// and it is the one answer the request beside them asks for too, so a gate +// cannot be open on a road no request is taking. +// +// THE PERSON'S ROW WINS OUTRIGHT, and where nobody wrote one there is nothing +// underneath it to infer: every call falls to [DefaultRouting]. Until +// 2026-09-13 a second reading beside this one asked who was waiting and chose +// speed or price accordingly, which was a routing decision this build made on +// a person's behalf and then had to keep explaining — the one thing a person +// could not see in the picker, the status line or the record. The intent is +// still carried on every call for what a wait is worth (lanes.go) and for how +// much of an answer is being read as it arrives (workload.go); it no longer +// picks the road. func (c *Client) routing() RoutingStrategy { strategy, _ := c.routingChoice() return strategy } -// routingFor resolves the strategy one request will actually ask for. -// -// THE PERSON'S ROW WINS OUTRIGHT, and where nobody wrote one there is nothing -// underneath it to infer: every call falls to [DefaultRouting]. It used to read -// who was waiting here and ask for speed or for price accordingly, which was a -// routing decision this build made on a person's behalf and then had to keep -// explaining — the one thing a person could not see in the picker, the status -// line or the record. -// -// SO THE INTENT IS TAKEN AND NOT READ. It is still carried on every call and -// still read a line later for what a wait is worth (lanes.go) and for how much -// of an answer is being read as it arrives (workload.go); it is named here so -// the two call sites that ask this question go on saying which call they are -// asking about, and so that a row which one day wants to know again has the -// fact in its hand rather than a parameter to thread back through. -func (c *Client) routingFor(RoutingIntent) RoutingStrategy { - return c.routing() -} - // providerPrefs is the routing preference object. // // Sort is the standing ask. Order and Ignore are the ledger's two verdicts, and @@ -450,7 +441,7 @@ func (c *Client) providerPreferences(model string, knobs callKnobs) *providerPre if !c.prefsProven() && c.pinnedLaneFor(model) == "" { return nil } - strategy := c.routingFor(knobs.intent) + strategy := c.routing() // SIMPLE ROUTING SENDS THE PERSON'S OWN INSTRUCTION AND NOTHING ELSE. // With no pin in force there is nothing to ask for: the request goes out // with no provider object at all and the router's own default routing diff --git a/internal/provider/waitplan.go b/internal/provider/waitplan.go index 607e49de78..5cacaf4a85 100644 --- a/internal/provider/waitplan.go +++ b/internal/provider/waitplan.go @@ -79,7 +79,15 @@ func (c *Client) planFor(ctx context.Context, choice lanes.Choice, model string, // A DECISION SITE (#433). An alternative is a machine a SECOND REQUEST // would demand with `provider.only`, so "could not be demanded" is the // base's own answer about carrying a preference and not its hostname. - if !c.carriesPreferences() || c.routing() == RoutingOff { + // + // AND UNDER `simple` WITH NO PIN THERE IS NOWHERE TO GO EITHER. The row's + // whole promise is that nothing on this side names a machine, and a rescue + // is exactly that — a second request demanding a lane this process chose + // off the sheet, sent while the first is still streaming. So the frontier + // is empty and a stall is reported rather than rescued. With a pin the + // alternatives stay, because there the act is the offer — `switch to + // auto?` — and a question has to name where a `y` would go (offer.go). + if !c.carriesPreferences() || c.routing() == RoutingOff || (c.routing() == RoutingSimple && !choice.Pinned) { plan.Alts = nil } else if len(plan.Alts) == 0 { // THE CHOOSER RETURNS THE ZERO CHOICE WHEN IT HOLDS FEWER THAN TWO From 566312a496ea694245d5e73ea857bc46bb86796f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:12:11 -0400 Subject: [PATCH 16/22] cmd/aforge: every door a person types says so, so `aforge do` and `aforge plan new` carry the pin the way `aforge exec` does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `simple` the talk pin rides only the calls somebody is reading, and the stamp that says a person is at the door had been put on `aforge exec` alone. cmd/aforge's lanepin_doors_test names three doors whose first request must carry the pin, and the other two went bare once `simple` became the row this build ships — green on each lane, red where they met. The one function that states the fact is now named for what it is about and used at all three doors. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/do.go | 5 ++++- cmd/aforge/exec.go | 28 ++++++++++++++-------------- cmd/aforge/exec_role_test.go | 2 +- cmd/aforge/main.go | 4 +++- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/cmd/aforge/do.go b/cmd/aforge/do.go index 8de5b45190..705ea4f0ea 100644 --- a/cmd/aforge/do.go +++ b/cmd/aforge/do.go @@ -588,7 +588,10 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom // nothing deletes the store either. signalled, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignals() - ctx, cancel := context.WithTimeout(signalled, request.timeout) + // A PERSON TYPED THIS, so its calls are made for somebody who is reading + // them (exec.go's [typedDoorContext]): the talk pin rides them and a + // refused pin is said to the one who is waiting. + ctx, cancel := context.WithTimeout(typedDoorContext(signalled), request.timeout) defer cancel() watcher := &settlementWatch{ graph: graph, session: session, commandSeq: command.Seq, diff --git a/cmd/aforge/exec.go b/cmd/aforge/exec.go index da6ebff8e6..15d4b31895 100644 --- a/cmd/aforge/exec.go +++ b/cmd/aforge/exec.go @@ -132,7 +132,7 @@ func runExec(args []string) error { defer cancel() } ctx = settings.Context(ctx, prompt) - execCtx := execCallContext(settings.ExecContext(ctx)) + execCtx := typedDoorContext(settings.ExecContext(ctx)) space, err := exec.NewWorkspace(*workspace) if err != nil { @@ -453,29 +453,29 @@ func execFailureWords(runErr error) string { // about who did the work. const execNodeKey = "task-1" -// execCallContext says who the calls of this command are made for. +// typedDoorContext says who the calls of a command a person typed are made for. // -// A PERSON TYPED `aforge exec` AND IS WAITING FOR ITS ANSWER. That is the whole -// claim, and the role table is where it has consequences: what a second of the -// wait is worth, how long the run waits on a silent machine before something is -// done about it, and — since the `simple` routing row — whether the machine -// they pinned is demanded at all (internal/provider's lanes.go). Until this -// line the door named no role, so every call it made read as -// [lane.RoleUnknown] — a hidden background errand — and a pinned `aforge exec` -// under `simple` went out with no machine named while the row still said one. +// THE MEASURED FAILURE (2026-09-13). Under `simple` the talk pin rides only the +// calls somebody is reading (internal/provider's drawLaneChoice asks the role's +// Visible), and a headless door that stamped nothing ran its leaf as +// [lane.RoleUnknown] — a hidden background errand — so a pinned `aforge exec` +// went out with no machine named while the row still said one. `aforge do` and +// `aforge plan new` are the same shape: one command, one person waiting on it, +// and cmd/aforge's lanepin_doors_test names all three as the doors whose first +// request must carry the pin. // -// IT IS A LEAF AND NOT THE TALK, because this command is one leaf's work and +// IT IS A LEAF AND NOT THE TALK, because each of these is one leaf's work and // not a conversation: there is no turn loop, no room and no transcript here, // and [lane.RoleLeafAttached] is exactly "that leaf, with somebody in front of // it". // -// AND THE DOOR IS THE MARK. Nothing in this binary launches `aforge exec` as a -// child — every spawner builds its leaves in process (subharness.go's +// AND THE DOOR IS THE MARK. Nothing in this binary launches these commands as +// a child — every spawner builds its leaves in process (subharness.go's // buildLinear, chatv3_subharness.go's registry, the subharness's own asks in // subharness_env.go, which names [lane.RoleLeafUnattended] itself) and none of // them reaches this function. So there is no env var or flag to key on and // none is invented: arriving here IS the fact that a person typed the command. -func execCallContext(ctx context.Context) context.Context { +func typedDoorContext(ctx context.Context) context.Context { return provider.WithRole(ctx, lanes.RoleLeafAttached) } diff --git a/cmd/aforge/exec_role_test.go b/cmd/aforge/exec_role_test.go index a8c21bdd7b..e511b6a4a1 100644 --- a/cmd/aforge/exec_role_test.go +++ b/cmd/aforge/exec_role_test.go @@ -22,7 +22,7 @@ import ( // `simple` and an unattended one does not // (TestUnderSimpleOnlyThePersonsOwnTurnCarriesTheTalkPin). func TestTheExecDoorRunsAsALeafSomebodyIsWatching(t *testing.T) { - role := provider.RoleFrom(execCallContext(context.Background())) + role := provider.RoleFrom(typedDoorContext(context.Background())) if role != lanes.RoleLeafAttached { t.Fatalf("`aforge exec` runs as %q, want the leaf somebody is watching", role) } diff --git a/cmd/aforge/main.go b/cmd/aforge/main.go index f0b2725917..3cdf5d406c 100644 --- a/cmd/aforge/main.go +++ b/cmd/aforge/main.go @@ -626,7 +626,9 @@ func runPlanNew(name string, args []string) error { return err } defer closePlanner() - ctx := settings.Context(context.Background(), goal) + // A PERSON TYPED THIS (exec.go's [typedDoorContext]), so the plan's own + // calls carry the talk pin the way `aforge exec`'s do. + ctx := typedDoorContext(settings.Context(context.Background(), goal)) // The ruler in force comes from measured work when there is any; the // built-in prior is only the starting point. From 8920eabd535d6e3c2c7538ecbda0d6250d25a9bb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:12:26 -0400 Subject: [PATCH 17/22] provider: a comment names the door function by its current name Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/provider/simplemode_verify_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index f5f3c341af..a24e89ecf7 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -320,7 +320,7 @@ func TestUnderSimpleOnlyThePersonsOwnTurnCarriesTheTalkPin(t *testing.T) { // EVERY ROLE A PERSON IS READING CARRIES THE DEMAND: the conversation's own // turn, a task room somebody is sitting in front of, and the headless - // command they typed (cmd/aforge's execCallContext, which names the second + // commands they type (cmd/aforge's typedDoorContext, which names the second // of these). for _, watched := range []lanes.Role{lanes.RoleTalk, lanes.RoleLeafAttached} { knobs := callKnobs{role: watched} From c74d0e1e6e51b7d642e7678a33a154dc280c595d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:16:48 -0400 Subject: [PATCH 18/22] session, cmd/aforge: a command a person typed counts as somebody watching, so `aforge do`'s nodes carry the pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aforge do` runs its work through the session's own executor, and each node takes its role from whether a person is in front of the process — a reading the live-conversation map makes, and a headless command opens no conversation, so its nodes ran as unattended and under `simple` the person's own pin never rode their calls. The door's context stamp could not reach them, because the loop stamps the role itself. So the fact is said once more where those nodes can read it: the typed-door function latches that a person is here, and the one reading of "attended" this build makes answers true for the command they are waiting on as well as for a room they opened. The lane prober reads the same latch, which is also right — a person waiting on `aforge do` under the ranked road is a person a probe is for. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/exec.go | 7 ++++++ internal/session/personatdoor_test.go | 32 ++++++++++++++++++++++++ internal/session/standing_run.go | 35 +++++++++++++++++++++------ 3 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 internal/session/personatdoor_test.go diff --git a/cmd/aforge/exec.go b/cmd/aforge/exec.go index 15d4b31895..36858d9944 100644 --- a/cmd/aforge/exec.go +++ b/cmd/aforge/exec.go @@ -18,6 +18,7 @@ import ( "github.com/Agent-Field/aforge-v2/internal/exec" lanes "github.com/Agent-Field/aforge-v2/internal/lane" "github.com/Agent-Field/aforge-v2/internal/provider" + "github.com/Agent-Field/aforge-v2/internal/session" "github.com/Agent-Field/aforge-v2/internal/trace" ) @@ -475,7 +476,13 @@ const execNodeKey = "task-1" // subharness_env.go, which names [lane.RoleLeafUnattended] itself) and none of // them reaches this function. So there is no env var or flag to key on and // none is invented: arriving here IS the fact that a person typed the command. +// +// IT SAYS THE FACT TWICE BECAUSE TWO THINGS READ IT: the leaf this door runs +// itself takes the role off its context, and the nodes `aforge do` runs through +// the session's own executor take theirs from whether a person is in front of +// the process (internal/session's someoneIsWatching), which no context reaches. func typedDoorContext(ctx context.Context) context.Context { + session.APersonIsHere() return provider.WithRole(ctx, lanes.RoleLeafAttached) } diff --git a/internal/session/personatdoor_test.go b/internal/session/personatdoor_test.go new file mode 100644 index 0000000000..05cb887989 --- /dev/null +++ b/internal/session/personatdoor_test.go @@ -0,0 +1,32 @@ +package session + +import ( + "testing" + + "github.com/Agent-Field/aforge-v2/internal/lane" +) + +// A node run by a command a person typed is a node somebody is watching. The +// live-conversation map cannot say so — a headless command opens no +// conversation — and before the door's latch `aforge do` ran its nodes as +// unattended, so under `simple` the person's own pin never rode their calls. +func TestANodeRunByATypedCommandCountsAsWatched(t *testing.T) { + before := personAtTheDoor.Load() + t.Cleanup(func() { personAtTheDoor.Store(before) }) + personAtTheDoor.Store(false) + + node := &Agent{config: Config{InTask: true}} + if someoneIsWatching() { + t.Skip("another conversation is registered in this process; the map already answers") + } + if got := node.laneRole(); got != lane.RoleLeafUnattended { + t.Fatalf("a node with nobody at the door ran as %q, want %q", got, lane.RoleLeafUnattended) + } + APersonIsHere() + if got := node.laneRole(); got != lane.RoleLeafAttached { + t.Fatalf("a node run by a typed command ran as %q, want %q", got, lane.RoleLeafAttached) + } + if !node.laneRole().Visible() { + t.Fatal("the role a typed command's node runs as must be one a person is reading") + } +} diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index 657f607387..2e44cc88a6 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -70,6 +70,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/Agent-Field/aforge-v2/internal/effort" @@ -158,15 +159,32 @@ func forgetLiveSession(agent *Agent) { liveSessionsMu.Unlock() } -// someoneIsWatching reports whether this process holds a conversation somebody -// is sitting in front of. +// personAtTheDoor is whether this process was started by a command a person +// typed and is waiting on — `aforge do`, `aforge exec`, `aforge plan new` — as +// distinct from a conversation they opened. It is a latch and not a counter +// because the fact it records cannot come and go: the process IS that command +// for as long as it runs. cmd/aforge's typedDoorContext is the one door that +// sets it. +var personAtTheDoor atomic.Bool + +// APersonIsHere states that somebody typed the command this process is running +// and is reading what it does. It is what lets the nodes a headless command +// runs count as watched — the talk pin rides their calls under `simple`, and +// their seconds are worth a person's — without the command having to open a +// conversation it does not have. +func APersonIsHere() { personAtTheDoor.Store(true) } + +// someoneIsWatching reports whether a person is in front of this process: a +// conversation they opened, or a command they typed and are waiting on. // // IT IS THE ONE READING OF "ATTENDED" THIS BUILD CAN HONESTLY MAKE, and it is -// this map because of what the map already refuses: a task node's own agent and -// an errand's pane both decline to register ([registerLiveSession]), so an -// entry here is a room with a person in it and nothing else is. A headless run -// opens no conversation and answers false, which is the correct reading of a -// process nobody is watching. +// this map plus the door's latch because of what the map already refuses: a +// task node's own agent and an errand's pane both decline to register +// ([registerLiveSession]), so an entry here is a room with a person in it and +// nothing else is. A headless run opens no conversation; it answers true only +// when the door said a person typed it ([APersonIsHere]), which is the +// difference between `aforge do` at somebody's terminal and a node a spawner +// built with nobody there. // // WHAT IT IS FOR. λ — what a second of waiting is worth — is zero for work // nobody is waiting on, and that is a true statement about a run whose owner @@ -176,6 +194,9 @@ func forgetLiveSession(agent *Agent) { // they are looking at this particular node, which is a distinction no plan // graph in this build can yet draw. func someoneIsWatching() bool { + if personAtTheDoor.Load() { + return true + } liveSessionsMu.Lock() defer liveSessionsMu.Unlock() return len(liveSessions) > 0 From 9187ffe70e12cf9faf7121a356f276ba26dec4f0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:21:01 -0400 Subject: [PATCH 19/22] provider, session, cmd/aforge: inside a command a person typed every call is theirs, and one predicate says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aforge do`'s first request is its planning pass, in a role nobody reads, and the role-scoped pin left it bare while the person who typed the command sat waiting on it. There is no errand beside a typed command — the command is the errand — so the latch that says a person typed this moves beside the pin in internal/provider, and readByAPerson is the one predicate that decides both whether the talk pin rides a call under `simple` and whether a refused pin is said to them now rather than parked: a role they are reading, or any role at all inside a command they typed. The session's reading of "attended" asks the same latch, so a typed command's nodes count as watched as well. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/exec.go | 10 +++---- internal/provider/lanepin.go | 31 ++++++++++++++++++++- internal/provider/lanes.go | 15 ++++++---- internal/provider/simplemode_verify_test.go | 22 +++++++++++++++ internal/session/personatdoor_test.go | 9 +++--- internal/session/standing_run.go | 24 +++------------- 6 files changed, 75 insertions(+), 36 deletions(-) diff --git a/cmd/aforge/exec.go b/cmd/aforge/exec.go index 36858d9944..f34fff125a 100644 --- a/cmd/aforge/exec.go +++ b/cmd/aforge/exec.go @@ -18,7 +18,6 @@ import ( "github.com/Agent-Field/aforge-v2/internal/exec" lanes "github.com/Agent-Field/aforge-v2/internal/lane" "github.com/Agent-Field/aforge-v2/internal/provider" - "github.com/Agent-Field/aforge-v2/internal/session" "github.com/Agent-Field/aforge-v2/internal/trace" ) @@ -478,11 +477,12 @@ const execNodeKey = "task-1" // none is invented: arriving here IS the fact that a person typed the command. // // IT SAYS THE FACT TWICE BECAUSE TWO THINGS READ IT: the leaf this door runs -// itself takes the role off its context, and the nodes `aforge do` runs through -// the session's own executor take theirs from whether a person is in front of -// the process (internal/session's someoneIsWatching), which no context reaches. +// itself takes the role off its context, and everything `aforge do` runs +// through the session's own executor — its planning pass, its nodes — takes +// the fact from the process-wide latch (internal/provider's readByAPerson and +// internal/session's someoneIsWatching), which no context reaches. func typedDoorContext(ctx context.Context) context.Context { - session.APersonIsHere() + provider.SetPersonAtTheDoor(true) return provider.WithRole(ctx, lanes.RoleLeafAttached) } diff --git a/internal/provider/lanepin.go b/internal/provider/lanepin.go index 60c295ef2e..24efc1feb7 100644 --- a/internal/provider/lanepin.go +++ b/internal/provider/lanepin.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "sync/atomic" lanes "github.com/Agent-Field/aforge-v2/internal/lane" ) @@ -146,6 +147,34 @@ func CurrentLanePin() LanePin { // after coreweave refused the model would be the chrome promising a machine the // next request does not ask for. It is memory only and cheap when nothing is // pinned, because the chrome asks it on every frame. +// personAtTheDoor is whether this process was started by a command a person +// typed and is waiting on — `aforge do`, `aforge exec`, `aforge plan new` — as +// distinct from a conversation they opened. Its whole content is that EVERY +// call of such a process is the person's own work: there is no errand beside a +// typed command, because the command is the errand. cmd/aforge's +// typedDoorContext is the one door that sets it. +var personAtTheDoor atomic.Bool + +// SetPersonAtTheDoor states whether somebody typed the command this process is +// running. It is what lets a headless command's calls — its planning pass and +// the nodes the session's executor runs for it, whose roles are not ones a +// person reads — carry the talk pin under `simple` and count as watched +// (internal/session's someoneIsWatching), without the command opening a +// conversation it does not have. Tests hand it false again. +func SetPersonAtTheDoor(here bool) { personAtTheDoor.Store(here) } + +// PersonAtTheDoor reports whether a typed command owns this process. +func PersonAtTheDoor() bool { return personAtTheDoor.Load() } + +// readByAPerson is whether a call in this role is one a person is waiting on: +// a role they are reading, or any role at all inside a command they typed. IT +// IS THE ONE PREDICATE that decides both whether the talk pin rides a call +// under `simple` (lanes.go) and whether a refused pin is said to them now +// rather than parked ([tellRetiredPins]), so the machine a person is asked for +// and the sentence they get when it is refused can never belong to two +// different sets of calls. +func readByAPerson(role lanes.Role) bool { return role.Visible() || PersonAtTheDoor() } + func PinnedFor(model string) string { if CurrentLanePin().pinned() == "" || !BaseTakesLaneChoice() { return "" @@ -410,7 +439,7 @@ func retirePinnedLane(ctx context.Context, model string, refusal laneRefusal) bo // never said. So the sentence waits, and the very next request of the // conversation carries it (client.go's [Client.sendShaped]). func tellRetiredPins(ctx context.Context) { - if ctx == nil || streamObserverFrom(ctx) == nil || !RoleFrom(ctx).Visible() { + if ctx == nil || streamObserverFrom(ctx) == nil || !readByAPerson(RoleFrom(ctx)) { return } for _, line := range takeRetiredPins() { diff --git a/internal/provider/lanes.go b/internal/provider/lanes.go index 7d4a9bebd0..7a8d3f131d 100644 --- a/internal/provider/lanes.go +++ b/internal/provider/lanes.go @@ -536,12 +536,15 @@ func (c *Client) drawLaneChoice(knobs callKnobs, model string, request *ai.Reque // on machines nobody pinned. // // THE SCOPE IS READ FROM THE ROLE THE CALLER ALREADY STAMPED - // (internal/lane's roles.go), which is the same reading that decides - // whether a person is told when the wire refuses the pin (lanepin.go's - // tellRetiredPins). One predicate, so the machine a person is asked for - // and the sentence they get when it is refused can never belong to two - // different sets of calls. - if named == "" || retired || !knobs.role.Visible() { + // (internal/lane's roles.go) through lanepin.go's readByAPerson, the + // same reading that decides whether a person is told when the wire + // refuses the pin (tellRetiredPins). One predicate, so the machine a + // person is asked for and the sentence they get when it is refused can + // never belong to two different sets of calls. Inside a command a + // person typed every call is theirs — the planning pass of `aforge do` + // runs in a role nobody reads and is still the thing they are waiting + // on — and the same predicate says so. + if named == "" || retired || !readByAPerson(knobs.role) { return lanes.Choice{}, false } ask := c.laneRequest(model, knobs, request, c.laneValueOfTime(knobs)) diff --git a/internal/provider/simplemode_verify_test.go b/internal/provider/simplemode_verify_test.go index a24e89ecf7..c2574783a1 100644 --- a/internal/provider/simplemode_verify_test.go +++ b/internal/provider/simplemode_verify_test.go @@ -405,3 +405,25 @@ func TestTheWidenedRetryOfARetiredPinIsLoggedAsTheBareRequestItIs(t *testing.T) } } } + +// AND INSIDE A COMMAND A PERSON TYPED, EVERY CALL IS THEIRS. `aforge do`'s first +// request is its planning pass, in a role nobody reads, and it is still the +// thing the person at the terminal is waiting on — cmd/aforge's +// lanepin_doors_test holds that door to the pin on its first request. +func TestInsideATypedCommandEveryRoleCarriesTheTalkPin(t *testing.T) { + client, _, model := stubbedRouter(t) + client.config.Routing = StaticRouting(RoutingSimple) + pinned(t, LanePin{Lane: "brass"}) + before := PersonAtTheDoor() + t.Cleanup(func() { SetPersonAtTheDoor(before) }) + + SetPersonAtTheDoor(false) + if choice, drawn := client.drawLaneChoice(callKnobs{role: lanes.RoleDesign}, model, &ai.Request{Model: model, Messages: userMessages("hello")}); drawn { + t.Fatalf("a design pass in a conversation drew %+v, want nothing", choice) + } + SetPersonAtTheDoor(true) + choice, drawn := client.drawLaneChoice(callKnobs{role: lanes.RoleDesign}, model, &ai.Request{Model: model, Messages: userMessages("hello")}) + if !drawn || len(choice.Only) != 1 || !strings.EqualFold(choice.Only[0], "brass") || !choice.Pinned { + t.Fatalf("a design pass inside a typed command drew %+v, want the pin", choice) + } +} diff --git a/internal/session/personatdoor_test.go b/internal/session/personatdoor_test.go index 05cb887989..e53966eeac 100644 --- a/internal/session/personatdoor_test.go +++ b/internal/session/personatdoor_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/provider" ) // A node run by a command a person typed is a node somebody is watching. The @@ -11,9 +12,9 @@ import ( // conversation — and before the door's latch `aforge do` ran its nodes as // unattended, so under `simple` the person's own pin never rode their calls. func TestANodeRunByATypedCommandCountsAsWatched(t *testing.T) { - before := personAtTheDoor.Load() - t.Cleanup(func() { personAtTheDoor.Store(before) }) - personAtTheDoor.Store(false) + before := provider.PersonAtTheDoor() + t.Cleanup(func() { provider.SetPersonAtTheDoor(before) }) + provider.SetPersonAtTheDoor(false) node := &Agent{config: Config{InTask: true}} if someoneIsWatching() { @@ -22,7 +23,7 @@ func TestANodeRunByATypedCommandCountsAsWatched(t *testing.T) { if got := node.laneRole(); got != lane.RoleLeafUnattended { t.Fatalf("a node with nobody at the door ran as %q, want %q", got, lane.RoleLeafUnattended) } - APersonIsHere() + provider.SetPersonAtTheDoor(true) if got := node.laneRole(); got != lane.RoleLeafAttached { t.Fatalf("a node run by a typed command ran as %q, want %q", got, lane.RoleLeafAttached) } diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index 2e44cc88a6..cd9dcb9c6e 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -70,7 +70,6 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" "github.com/Agent-Field/aforge-v2/internal/effort" @@ -159,21 +158,6 @@ func forgetLiveSession(agent *Agent) { liveSessionsMu.Unlock() } -// personAtTheDoor is whether this process was started by a command a person -// typed and is waiting on — `aforge do`, `aforge exec`, `aforge plan new` — as -// distinct from a conversation they opened. It is a latch and not a counter -// because the fact it records cannot come and go: the process IS that command -// for as long as it runs. cmd/aforge's typedDoorContext is the one door that -// sets it. -var personAtTheDoor atomic.Bool - -// APersonIsHere states that somebody typed the command this process is running -// and is reading what it does. It is what lets the nodes a headless command -// runs count as watched — the talk pin rides their calls under `simple`, and -// their seconds are worth a person's — without the command having to open a -// conversation it does not have. -func APersonIsHere() { personAtTheDoor.Store(true) } - // someoneIsWatching reports whether a person is in front of this process: a // conversation they opened, or a command they typed and are waiting on. // @@ -182,9 +166,9 @@ func APersonIsHere() { personAtTheDoor.Store(true) } // task node's own agent and an errand's pane both decline to register // ([registerLiveSession]), so an entry here is a room with a person in it and // nothing else is. A headless run opens no conversation; it answers true only -// when the door said a person typed it ([APersonIsHere]), which is the -// difference between `aforge do` at somebody's terminal and a node a spawner -// built with nobody there. +// when the door said a person typed it (internal/provider's +// [provider.SetPersonAtTheDoor]), which is the difference between `aforge do` +// at somebody's terminal and a node a spawner built with nobody there. // // WHAT IT IS FOR. λ — what a second of waiting is worth — is zero for work // nobody is waiting on, and that is a true statement about a run whose owner @@ -194,7 +178,7 @@ func APersonIsHere() { personAtTheDoor.Store(true) } // they are looking at this particular node, which is a distinction no plan // graph in this build can yet draw. func someoneIsWatching() bool { - if personAtTheDoor.Load() { + if provider.PersonAtTheDoor() { return true } liveSessionsMu.Lock() From 90419e1af0b773725b7fc40311795c866d059543 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:22:42 -0400 Subject: [PATCH 20/22] tui3: the model row predicts no machine where nothing chooses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `via` on a picker's model row is a claim about the next request — the machine the chooser would send it to — and under `simple` the next request names nobody. The acceptance drive on 2026-09-13 caught the row saying `via modal` with the belief's numbers beside it while the status line and the call log both said Sail Research had answered: the surface predicting a choice nobody was making. The prediction now asks the one door that says what `auto` may claim under a row before it names anything, and the ranked road keeps its prediction because there the chooser really is about to choose. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/lanes.go | 16 ++++++++++++++-- internal/tui3/lanewalk_test.go | 20 ++++++++++++++++++++ internal/tui3/models.go | 14 +++++++++----- internal/tui3/palette.go | 4 ++-- internal/tui3/rowfit_test.go | 2 +- 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index f9d4507413..5d0093a322 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -537,7 +537,7 @@ func (a *app) laneNow(model string, views []laneView) (string, bool) { if name, pinned := config.LanePinned(a.profileDir, laneSlotFor(model)); pinned { return name, true } - name := laneAuto(model, views, a.now()) + name := laneAuto(a.routing, model, views, a.now()) return name, name != "" } @@ -546,7 +546,19 @@ func (a *app) laneNow(model string, views []laneView) (string, bool) { // It is a free function because a picker row is drawn from places that hold no // session (the settings panel's slot rows, the composer's), and a row that said // nothing there would be the same list telling two stories. -func laneAuto(model string, views []laneView, now time.Time) string { +// +// AND IT ANSWERS NOTHING WHERE NOTHING ON THIS SIDE CHOOSES. The name it gives +// is a prediction — the machine the chooser would send the next turn to — and +// under a routing row that runs no chooser (`simple`, the row this build +// ships) there is no such machine: the request goes out with no preference +// and the router answers from wherever it likes. A `via modal` on the model +// row while the status line and the record said Sail Research was the surface +// predicting a choice nobody was making (2026-09-13). The one door that says +// what `auto` may claim under a row ([laneAutoSaid]) is asked first. +func laneAuto(routing, model string, views []laneView, now time.Time) string { + if !laneAutoSaid(routing).chooses { + return "" + } // THE QUESTION IS THE TURN'S OWN, and it is asked through the transport's // spelling of it ([provider.LaneTalkAsk]) rather than one written here. A // request built on this side with λ left at zero asks "which is CHEAPEST", diff --git a/internal/tui3/lanewalk_test.go b/internal/tui3/lanewalk_test.go index de67e0df8d..770bd0243f 100644 --- a/internal/tui3/lanewalk_test.go +++ b/internal/tui3/lanewalk_test.go @@ -324,3 +324,23 @@ func TestClearingTheFilterReturnsToTheModelInUse(t *testing.T) { t.Fatalf("ctrl+u left the cursor on %q, want the model in use", chosen.ID) } } + +// THE MODEL ROW PREDICTS NO MACHINE WHERE NOTHING CHOOSES. The `via` on a +// picker row is a claim about the next request, and under `simple` the next +// request names nobody: a row that still said `via modal` while the status +// line and the record said Sail Research was the surface predicting a choice +// nobody was making (the 2026-09-13 acceptance drive). The ranked road keeps +// its prediction, because there the chooser really is about to make one. +func TestUnderSimpleRoutingTheModelRowPredictsNoMachine(t *testing.T) { + views := []laneView{{Name: "modal", Known: true, TTFT: 0.8, Rate: 90}} + now := time.Now() + if got := laneAuto(config.RoutingSimple, "vendor/quiet", views, now); got != "" { + t.Fatalf("under simple the model row would say via %q, want no machine", got) + } + if got := laneAuto(config.RoutingLatency, "vendor/quiet", views, now); got != "modal" { + t.Fatalf("under latency the model row names %q, want the machine the belief holds", got) + } + if note := rowAll(modelFields(Model{ID: "vendor/quiet", ContextLength: 128_000}, "", config.RoutingSimple)); strings.Contains(note, "via ") { + t.Fatalf("the simple model row still carries a prediction: %q", note) + } +} diff --git a/internal/tui3/models.go b/internal/tui3/models.go index 31e2d8bbf0..2031f9fa16 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -2,6 +2,7 @@ package tui3 import ( "encoding/json" + "github.com/Agent-Field/aforge-v2/internal/config" "math" "os" "path/filepath" @@ -698,12 +699,15 @@ func contextWord(tokens int) string { // // It is [rowAll] over [modelFields], and every narrower frame is the same // fields through the same fitter with an edge on it (rowfit.go). -func modelNote(model Model) string { return modelNoteVia(model, "") } +func modelNote(model Model) string { return modelNoteVia(model, "", config.RoutingLatency) } // modelNoteVia is that tail with the caller's own knowledge of which machine // this model is pinned to — empty when it is not pinned or when the caller has -// no profile to ask. -func modelNoteVia(model Model, pin string) string { return rowAll(modelFields(model, pin)) } +// no profile to ask — and of the routing row in force, which decides whether a +// machine may be predicted at all ([laneAuto]). +func modelNoteVia(model Model, pin, routing string) string { + return rowAll(modelFields(model, pin, routing)) +} // ── THE PICKER ROW'S DATA HIERARCHY ───────────────────────────────────────── // @@ -744,7 +748,7 @@ func modelNoteVia(model Model, pin string) string { return rowAll(modelFields(mo // row, and this row already has one mark to explain ([laneUpMark]). A field // that is last to be drawn is a field that should be said in words or not at // all. -func modelFields(model Model, pin string) []rowField { +func modelFields(model Model, pin, routing string) []rowField { if model.Direct { return []rowField{ {}, {}, priceField(model.PromptPrice, model.CompletionPrice), @@ -762,7 +766,7 @@ func modelFields(model Model, pin string) []rowField { views := laneViews(model.ID, now) via := pin if via == "" { - via = laneAuto(model.ID, views, now) + via = laneAuto(routing, model.ID, views, now) } // THE NUMBERS BELONG TO THE LANE THE ROW NAMES ([laneShown] states why), // and they are three fields rather than one phrase now: the lane a person diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 8b6cdf45b1..dc0c7640e5 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -551,7 +551,7 @@ func (p *picker) unfoldAt(at int, first string, now time.Time) bool { views = lifted } } - p.unfold, p.lanes, p.first, p.auto = model.ID, views, first, laneAuto(model.ID, views, now) + p.unfold, p.lanes, p.first, p.auto = model.ID, views, first, laneAuto(p.routing, model.ID, views, now) return true } @@ -1642,7 +1642,7 @@ func (p *picker) rowFields(model Model, pin string) []rowField { if fields, ok := p.held[model.ID]; ok { return fields } - fields := modelFields(model, pin) + fields := modelFields(model, pin, p.routing) p.held[model.ID] = fields return fields } diff --git a/internal/tui3/rowfit_test.go b/internal/tui3/rowfit_test.go index 03da3b384e..ec1bec0604 100644 --- a/internal/tui3/rowfit_test.go +++ b/internal/tui3/rowfit_test.go @@ -202,7 +202,7 @@ func TestAnUnknownFactDrawsNothingAndFreesItsSpace(t *testing.T) { if got := rowTail(fields, 60); got != "coreweave · 1M · elo 1290" { t.Fatalf("the unknown fields left a hole: %q", got) } - if got := rowAll(modelFields(Model{ID: "vendor/quiet"}, "")); got != "" { + if got := rowAll(modelFields(Model{ID: "vendor/quiet"}, "", config.RoutingLatency)); got != "" { t.Fatalf("a model nobody published anything about says %q", got) } if got := rowAll([]rowField{rowField{}, rowField{}}); got != "" { From 0eff4cf63531e9de9a447478773c7bd2d86f5b11 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:30:12 -0400 Subject: [PATCH 21/22] tui3: with no name and no chooser the model row speaks for no lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit took the predicted machine's name off the model row under `simple` and left its numbers: `▲1.0s · 30t/s` were parasail's, drawn because the lane a row speaks for fell through to the best-known one when nothing was named, and that fallthrough is the chooser's own claim about where the next turn goes. Under a routing row that runs no chooser it is nobody's, so the row speaks for no lane and draws no wait and no rate — the emptiness law — while a pinned or named machine still speaks for itself. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/lanes.go | 17 ++++++++++++++--- internal/tui3/lanes_test.go | 2 +- internal/tui3/lanewalk_test.go | 11 +++++++++++ internal/tui3/models.go | 2 +- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 5d0093a322..dbca18cc65 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -692,8 +692,8 @@ func laneRateWord(rate float64) string { // became a row saying one machine's speed under another machine's name the // moment the chooser landed. A row like that is worse than a blank one: it is a // measurement attributed to a machine that did not make it. -func laneSpeedWord(views []laneView, now string) string { - best, ok := laneShown(views, now) +func laneSpeedWord(routing string, views []laneView, now string) string { + best, ok := laneShown(routing, views, now) if !ok { return "" } @@ -725,10 +725,21 @@ func laneSpeedWord(views []laneView, now string) string { // file's own sort put first and then wrote the chooser's name after them, which // was invisible while the two agreed and became a measurement attributed to a // machine that did not make it the moment they stopped. -func laneShown(views []laneView, now string) (laneView, bool) { +// +// AND WITH NO NAME AND NO CHOOSER THERE IS NO LANE TO SPEAK FOR. The best-known +// fallthrough is the chooser's claim — "this is where auto would send you" — +// and under a routing row that runs no chooser (`simple`, the row this build +// ships) it is nobody's: the row drew `▲1.0s · 30t/s`, which were parasail's +// numbers with parasail's name taken off (the 2026-09-13 acceptance drive), a +// measurement the next request would not be routed by. A pinned or named +// machine still speaks, because there the numbers are its own. +func laneShown(routing string, views []laneView, now string) (laneView, bool) { if best, ok := laneExactly(views, now); ok { return best, true } + if !laneAutoSaid(routing).chooses { + return laneView{}, false + } return bestLane(views) } diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index 4c50eccf5f..9c66a65a9e 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -911,7 +911,7 @@ func TestAFirstTokenWaitIsAlwaysSaidInSeconds(t *testing.T) { } head, tail := laneRowText(views[0], 80) for what, text := range map[string]string{ - "the model row": laneSpeedWord(views, ""), + "the model row": laneSpeedWord(config.RoutingLatency, views, ""), "the lane row": head + " " + tail, "the why line": laneWhy(views[0]), } { diff --git a/internal/tui3/lanewalk_test.go b/internal/tui3/lanewalk_test.go index 770bd0243f..9f3dd9e808 100644 --- a/internal/tui3/lanewalk_test.go +++ b/internal/tui3/lanewalk_test.go @@ -343,4 +343,15 @@ func TestUnderSimpleRoutingTheModelRowPredictsNoMachine(t *testing.T) { if note := rowAll(modelFields(Model{ID: "vendor/quiet", ContextLength: 128_000}, "", config.RoutingSimple)); strings.Contains(note, "via ") { t.Fatalf("the simple model row still carries a prediction: %q", note) } + // AND NOT THE PREDICTED MACHINE'S NUMBERS EITHER: with no name and no + // chooser the row speaks for no lane, so it draws no wait and no rate. + if shown, ok := laneShown(config.RoutingSimple, views, ""); ok { + t.Fatalf("under simple the model row speaks for %q, want no lane", shown.Name) + } + if shown, ok := laneShown(config.RoutingSimple, views, "modal"); !ok || shown.Name != "modal" { + t.Fatal("a machine the row names still speaks for itself under simple") + } + if shown, ok := laneShown(config.RoutingLatency, views, ""); !ok || shown.Name != "modal" { + t.Fatal("under latency the best-known lane still speaks for the row") + } } diff --git a/internal/tui3/models.go b/internal/tui3/models.go index 2031f9fa16..6f3a051ba8 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -772,7 +772,7 @@ func modelFields(model Model, pin, routing string) []rowField { // and they are three fields rather than one phrase now: the lane a person // is served by outranks every number, and the throughput sits five rungs // under the wait it used to be glued to. - best, known := laneShown(views, via) + best, known := laneShown(routing, views, via) first, rate := rowField{}, rowField{} if known { if word := laneSecondsWord(best.TTFT); word != "" { From 07adda0051d6d97e1ef6ec28fb5dc63d15780b57 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 18:34:34 -0400 Subject: [PATCH 22/22] provider: the raced-arm ledger test says it is about the ranked road A stall is raced only where the routing row runs a chooser, and this fixture built its client with no row, which since `simple` became the shipped row means no rescue and no race. It names the ranked road the way its siblings do. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/provider/aliasledger_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/provider/aliasledger_test.go b/internal/provider/aliasledger_test.go index 81d1b9855f..79fa5ab7aa 100644 --- a/internal/provider/aliasledger_test.go +++ b/internal/provider/aliasledger_test.go @@ -295,7 +295,10 @@ func TestARacedArmIsFiledUnderTheOneNameTheLedgerKeys(t *testing.T) { return model }) - client, err := NewClient(Config{APIKey: "test-key", BaseURL: rig.server.URL(), Model: alias}) + // THIS IS A TEST ABOUT THE RANKED ROAD — a stall is raced only where the + // routing row runs a chooser — so the client says which road it is on + // rather than inheriting the row this build ships ([rankedRoad]). + client, err := NewClient(rankedRoad(Config{APIKey: "test-key", BaseURL: rig.server.URL(), Model: alias})) if err != nil { t.Fatal(err) }