Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7cb142c
provider, config, tui3: a fourth routing mode, simple — bare to OpenR…
santoshkumarradha Sep 13, 2026
197dcc7
tui3: the `auto` row says what the routing in force actually does
santoshkumarradha Sep 13, 2026
9a83dd3
provider: probe the simple row's pinned path — what the wire carries …
santoshkumarradha Sep 13, 2026
a613ee4
provider, config: the routing row reaches every client this process b…
santoshkumarradha Sep 13, 2026
73a8048
docs: the two change entries carry the pull request's real number, 1019
santoshkumarradha Sep 13, 2026
7389ac8
provider: under simple the talk pin rides the talk, the retirement se…
santoshkumarradha Sep 13, 2026
e1e9dd0
provider, session, tui3: news about a person's own row stops travelli…
santoshkumarradha Sep 13, 2026
5321d40
provider, tui3: a row that ends an attempt describes that attempt's b…
santoshkumarradha Sep 13, 2026
37c65c9
cmd/aforge: the headless door says who it is running for, so a person…
santoshkumarradha Sep 13, 2026
c5c1d2b
provider, config, tui3: `simple` is the routing row this build ships,…
santoshkumarradha Sep 13, 2026
63cb46c
manual, tui3: the lanes page keeps the words somebody asks the questi…
santoshkumarradha Sep 13, 2026
21f5da0
config: the routing row's hint reads straight — the shipped answer, t…
santoshkumarradha Sep 13, 2026
6a96902
provider, tui3: the suites about the ranked road say which road they …
santoshkumarradha Sep 13, 2026
30e94c8
provider: lane E's two lines in lane D's file — the simple-mode rig n…
santoshkumarradha Sep 13, 2026
8d558ce
provider: under simple with no pin a stall is reported, never rescued…
santoshkumarradha Sep 13, 2026
566312a
cmd/aforge: every door a person types says so, so `aforge do` and `af…
santoshkumarradha Sep 13, 2026
8920eab
provider: a comment names the door function by its current name
santoshkumarradha Sep 13, 2026
c74d0e1
session, cmd/aforge: a command a person typed counts as somebody watc…
santoshkumarradha Sep 13, 2026
9187ffe
provider, session, cmd/aforge: inside a command a person typed every …
santoshkumarradha Sep 13, 2026
90419e1
tui3: the model row predicts no machine where nothing chooses
santoshkumarradha Sep 13, 2026
0eff4cf
tui3: with no name and no chooser the model row speaks for no lane
santoshkumarradha Sep 13, 2026
07adda0
provider: the raced-arm ledger test says it is about the ranked road
santoshkumarradha Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/aforge/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 36 additions & 1 deletion cmd/aforge/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -130,7 +132,7 @@ func runExec(args []string) error {
defer cancel()
}
ctx = settings.Context(ctx, prompt)
execCtx := settings.ExecContext(ctx)
execCtx := typedDoorContext(settings.ExecContext(ctx))

space, err := exec.NewWorkspace(*workspace)
if err != nil {
Expand Down Expand Up @@ -451,6 +453,39 @@ func execFailureWords(runErr error) string {
// about who did the work.
const execNodeKey = "task-1"

// typedDoorContext says who the calls of a command a person typed are made for.
//
// 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 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 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.
//
// IT SAYS THE FACT TWICE BECAUSE TWO THINGS READ IT: the leaf this door runs
// 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 {
provider.SetPersonAtTheDoor(true)
return provider.WithRole(ctx, lanes.RoleLeafAttached)
}

func execTask(prompt, system, root string) exec.Task {
title, _, _ := strings.Cut(prompt, "\n")
return exec.Task{
Expand Down
42 changes: 42 additions & 0 deletions cmd/aforge/exec_role_test.go
Original file line number Diff line number Diff line change
@@ -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(typedDoorContext(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")
}
}
4 changes: 3 additions & 1 deletion cmd/aforge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions docs/CHAT-V3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/changes/unreleased/1019-simple-is-the-default.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/changes/unreleased/1019-simple-routing-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
kind: added
title: a fourth routing mode, `simple`, sends exactly what you asked for and nothing else
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."
- "`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."
- "`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)."
- "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
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.
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1019-simple-routing-picker-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: fixed
title: the model picker's `auto` row stops promising a takeover the `simple` routing never does
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."
- "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.
8 changes: 4 additions & 4 deletions internal/config/derivation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading