Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 24 additions & 14 deletions cmd/aforge/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"github.com/Agent-Field/aforge-v2/internal/home"
"github.com/Agent-Field/aforge-v2/internal/leave"
"github.com/Agent-Field/aforge-v2/internal/openrouterauth"
"github.com/Agent-Field/aforge-v2/internal/provider"
"github.com/Agent-Field/aforge-v2/internal/roles"
"github.com/Agent-Field/aforge-v2/internal/search"
"github.com/Agent-Field/aforge-v2/internal/session"
Expand Down Expand Up @@ -239,8 +238,13 @@ func openChatV3(name string, args []string, pickSession bool) error {
if workspace, take := v3HostRoad(v3HostChoice{
noHost: *noHost,
once: strings.TrimSpace(*once) != "",
debug: *debug,
setup: !v3MachineIsSetUp(),
// THE SWITCH AND NOT THE FLAG. --debug has already turned the record on
// above, and so has AFORGE_DEBUG in the shell that started this process
// (internal/trace's init) — both are this process being told to record,
// and both must keep the calls in this process to have anything to
// record ([v3HostChoice.debug]).
debug: trace.Enabled(),
setup: !v3MachineIsSetUp(),
}); take {
err := openChatV3Local(localLaunch{
workspace: workspace,
Expand Down Expand Up @@ -1434,17 +1438,23 @@ func applyV3Governance(cfg session.Config, profileDir string, yolo, oneModel boo
// process, built at the door and assigned by the launch
// (chatv3_process.go's [v3Process.Conns]), because two managers on one store
// are two caches with no way to tell each other that a token has moved.
// How this session chooses among the endpoints serving its model — and it is
// read as the CHOICE rather than as the resolved default, so an unwritten row
// arrives here empty. The adapter needs to be able to tell "nobody said" from
// "somebody said latency": with nothing said it routes a person's own turn by
// speed and a task node or an errand by price, and with a word written that
// word wins outright (internal/provider's velocity.go). The parse is still
// total, so a word this build does not know leaves the row unset rather than
// taking routing away.
if word := config.RoutingChoiceAt(profileDir); word != "" {
cfg.Routing, _ = provider.ParseRoutingStrategy(word)
}
// HOW THIS SESSION CHOOSES AMONG THE ENDPOINTS SERVING ITS MODEL IS NOT SET
// HERE, and that absence is the point. This door used to read the routing row
// and write it onto the config, which the session then handed to every client
// it built as an answer of its own ([provider.StaticRouting]) — and an answer
// handed down WINS over the row this process installs, which is what a caller
// holding a row this process did not install needs it to do. So the
// conversation's clients were pinned to the word that was on disk at
// launch: a person who cycled `routing` in the settings panel watched the row
// change, watched the `lane` row go on explaining itself in the old word, and
// got the old routing on every request until they relaunched (issue #1022).
//
// The row is installed process-wide one line below, where every client that
// was handed nothing reads it — including this session's, now — and the panel
// re-installs it the moment it is written (internal/config's
// InstallRoutingRow). A caller that really does have a row of its own still
// hands one down and still wins; this door does not, because the row it would
// hand down is the very row it is installing.
// AND WHICH MACHINE BEHIND THAT MODEL, which is the row beside routing and a
// different question: routing says what a request PREFERS, and this says
// which endpoint it actually goes to (internal/config's lane keys). The
Expand Down
14 changes: 11 additions & 3 deletions cmd/aforge/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,9 +512,17 @@ type v3HostChoice struct {
// surprise — but it joins one that is already there, so a scripted message
// lands in the conversation a person is actually in.
once bool
// debug is --debug: the model-call record is written by the process that
// makes the calls, and over a socket that process is the host, which was
// never told to record. So the flag keeps the launch here, where it is real.
// debug is THE RECORD'S SWITCH and not the flag alone ([trace.Enabled]): the
// model-call record is written by the process that makes the calls, and over
// a socket that process is the host, which was never told to record. So a
// run that is recording keeps its launch here, where the calls are real.
//
// IT IS THE SWITCH BECAUSE THE SWITCH HAS THREE DOORS AND THE FLAG IS ONE.
// Reading the flag alone meant `AFORGE_DEBUG=1 aforge` took the host road,
// and four launches wrote a folder holding run.json and not one request body
// — the header written by this process, the calls made by another — while
// `aforge chat --debug` wrote them all (issue #1022). The manual advertises
// the environment pin for exactly this, so it reaches the same door.
debug bool
// setup says this machine may still have to be set up — no key it can find —
// and setting one up is a conversation with the person at this terminal
Expand Down
86 changes: 86 additions & 0 deletions cmd/aforge/recordroad_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"go/ast"
"go/parser"
"go/printer"
"go/token"
"strings"
"testing"
)

// ── A RUN THAT IS RECORDING MAKES ITS OWN CALLS ─────────────────────────────
//
// THE MEASURED FAILURE (issue #1022). `AFORGE_DEBUG=1 aforge` wrote a folder
// holding `run.json` and not one request body, four launches running: the header
// is written by the process at the door and the calls were made by this
// workspace's session host, which was never told to record. `aforge chat
// --debug` wrote them all, because the host rule read the FLAG while the record
// has three doors and the flag is one of them.
//
// The rule below is the switch and not the flag, and this is the law that keeps
// it so: a shape test, because there is no way to reach the wiring through the
// door without opening a terminal, and because a flag re-read here is exactly
// how it was wrong.

// TestTheHostRoadIsDecidedByTheRecordsSwitchAndNotOneOfItsDoors reads the chat
// door's own source: the `debug` field of the host choice it builds must be the
// switch ([trace.Enabled]), never the flag variable it parsed a moment earlier.
func TestTheHostRoadIsDecidedByTheRecordsSwitchAndNotOneOfItsDoors(t *testing.T) {
fset := token.NewFileSet()
parsed, err := parser.ParseFile(fset, "chatv3.go", nil, 0)
if err != nil {
t.Fatalf("read chatv3.go: %v", err)
}

found := false
ast.Inspect(parsed, func(node ast.Node) bool {
literal, ok := node.(*ast.CompositeLit)
if !ok {
return true
}
if name, _ := literal.Type.(*ast.Ident); name == nil || name.Name != "v3HostChoice" {
return true
}
for _, element := range literal.Elts {
pair, ok := element.(*ast.KeyValueExpr)
if !ok {
continue
}
if key, _ := pair.Key.(*ast.Ident); key == nil || key.Name != "debug" {
continue
}
found = true
var written strings.Builder
if err := printer.Fprint(&written, fset, pair.Value); err != nil {
t.Fatalf("print the debug field: %v", err)
}
if got := written.String(); got != "trace.Enabled()" {
t.Fatalf(
"the chat door decides the host road from %s at %s; it must read the record's own switch, "+
"trace.Enabled(), so the environment pin keeps the calls in this process exactly as --debug does",
got, fset.Position(pair.Value.Pos()),
)
}
}
return true
})
if !found {
t.Fatal("chatv3.go builds no v3HostChoice with a debug field; this law has stopped watching the chat door")
}
}

// AND THE RULE ITSELF REFUSES THE HOST ROAD WHILE THE RECORD IS ON, whichever
// door turned it on — the other half of the same promise, asserted on the rule
// rather than on the source.
func TestARecordingLaunchKeepsItsCallsInThisProcess(t *testing.T) {
shortStateRoot(t)
workspace := t.TempDir()
answeringHost(t, workspace)
if !v3TakeHostRoad(workspace, v3HostChoice{}) {
t.Fatal("a host was answering and an ordinary launch did not take it, so this test proves nothing")
}
if v3TakeHostRoad(workspace, v3HostChoice{debug: true}) {
t.Fatal("a recording launch handed its calls to a host that was never told to record")
}
}
59 changes: 59 additions & 0 deletions cmd/aforge/routinglive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"testing"

"github.com/Agent-Field/aforge-v2/internal/config"
"github.com/Agent-Field/aforge-v2/internal/provider"
"github.com/Agent-Field/aforge-v2/internal/session"
)

// ── THE ROUTING ROW IS INSTALLED, NEVER HANDED DOWN ─────────────────────────
//
// THE MEASURED FAILURE (issue #1022). This door read the routing row and wrote
// it onto the session's config, which the session handed to every client it
// built as an answer OF ITS OWN — and an answer handed down beats the row this
// process installs, which is what a caller holding a row this process did not
// install needs it to do. So the conversation's clients were pinned to the word
// that was on disk at launch: somebody cycled `routing` in the settings panel,
// watched the row change, and went on being routed by the old word until they
// relaunched.
//
// The fix is an absence, and an absence is the one thing a reader cannot see, so
// it is asserted here: the door installs the row and hands down nothing.
func TestTheProfileDoorInstallsTheRoutingRowAndHandsDownNone(t *testing.T) {
before := provider.RoutingNow()
t.Cleanup(func() { provider.InstallRouting(before) })
t.Setenv("AFORGE_HOME", t.TempDir())
dir := t.TempDir()
row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyRouting)
if !ok || row.Apply(config.RoutingLatency) != nil {
t.Fatal("could not write the routing row")
}

built, err := applyV3Governance(session.Config{Workspace: t.TempDir()}, dir, false, false)
if err != nil {
t.Fatalf("the door refused a plain profile: %v", err)
}
if built.Routing != "" {
t.Fatalf("the door handed the session its own routing answer (%q), which no later write can overrule", built.Routing)
}
if got := provider.RoutingNow(); got != provider.RoutingLatency {
t.Fatalf("the door installed routing %q, want the row the person wrote", got)
}

// AND THE ROW MOVES UNDER THE SESSION THAT WAS BUILT, which is the whole
// point of installing it: the panel re-installs on the write
// ([config.InstallRoutingRow]) and the next request is answered by the new
// word with nothing relaunched.
if row.Apply(config.RoutingSimple) != nil {
t.Fatal("could not cycle the routing row")
}
config.InstallRoutingRow(dir)
if got := provider.RoutingNow(); got != provider.RoutingSimple {
t.Fatalf("after the write the transport routes by %q, want simple", got)
}
if built.Routing != "" {
t.Fatal("the session built a moment ago acquired a routing row of its own")
}
}
20 changes: 20 additions & 0 deletions docs/changes/unreleased/1025-pin-chip-live-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
kind: fixed
title: the chip names a machine only while the wire would demand it, and a routing change lands now
pr: 1025
surface: [chat, engine, docs]
invalidates:
- "The chip on the model word, the settings panel's `lane` tail, the picker fold's mark and a picker row's `via` were drawn from the PROFILE ROW (`config.LanePinned` / `config.LaneAt`) while the wire asked the transport (`provider.PinnedFor`). The two disagree the moment the wire retires a pairing, and on 2026-09-13 that left `moonshotai/kimi-k3@morph` on the screen over three turns another machine answered (#1022). Every one of them now reads one door — `provider.PinNow`, which answers the machine a request will demand AND the machine a person's row still names that nothing is asking for — so a retired pairing shows `auto`, the model row's tail reads `auto (morph cannot serve this model)`, and the fold marks `auto`. The `lane` row itself is untouched and still reads `pinned: morph`, because pinning again puts it straight back."
- "`app.pinnedNow` keyed `provider.PinnedFor` on the surface's own spelling of the model while retirements are keyed on the ledger's. The question now goes through the door that folds (`lanePinFor`), so there is no key built on the surface side to disagree with."
- "A `routing` change landed on the NEXT SESSION. `internal/manual/chat/models-and-cost.md` said so in those words. It lands on your next message: the chat door no longer writes the row onto `session.Config.Routing` — a handed-down answer beats the row `provider.InstallRouting` puts in place, which is why the conversation's clients stayed pinned to the word that was on disk at launch — it installs the row and hands down nothing, and the settings panel re-installs it (`config.InstallRoutingRow`) and re-reads `app.routing` and `sheet.routing` in the same keystroke. The field stays the way a caller HANDS a row down — a child built from a parent's own config rather than from a profile — and such a caller still wins over the installed row."
- "`session.Config.Routing` was filled in by every local launch. It is now EMPTY for a launch from a profile, and empty means the session reads the live installed row. Anything on this side that gated on the field — the lane beat's `routing: off` check — asks `Config.routingInForce()`, which is the same rule the adapter applies (`Client.routingChoice`)."
- "`AFORGE_DEBUG=1 aforge` wrote a run folder holding `run.json` and no request bodies at all, while `aforge chat --debug` wrote them: the host rule read the FLAG (`debug: *debug`) and the record has three doors, so the environment pin took the session-host road and the host — never told to record — made the calls. It reads the switch (`trace.Enabled()`), so a recording launch keeps its conversation in this terminal whichever way it was asked for, exactly as `--no-host` does."
- "The lane fold had no gesture for going back to `auto` — it was a walk up past every machine in the list, with another model's row, where `enter` switches the model, one press further. `enter` on the machine already in force now takes the pin off, and the hint slot reads `enter unpin · ← back · esc` while the cursor is on it. On a machine the wire has stood down, `enter` still PINS: that is a person saying try again, which is the sentence the retirement makes to them. The `auto` row is unchanged and still the explicit way to say it."
- "Typing in the filter box while a fold was open filtered MODELS and closed the fold — typing `morph` inside a fold matched `morph/morph-v3-large` in the catalog and shut the machines the person was reading. While a fold is open the box matches that model's machines first, through the same matcher the model list uses, and falls through to the models only when none of them match."
---

The two halves of item one are one defect: a surface may name a machine only
while a request would demand it, and it may only ask that question through the
door that folds the model's name the way the transport does. Everything else
here is the same shape one layer out — a row a person watched themselves change
that then did nothing to the very next request.
20 changes: 11 additions & 9 deletions internal/config/derivation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,17 @@ var settingReaders = map[string]string{
// turn end, beside the mouse and the gate posture — and becomes what the
// transcript draws of the clock (internal/tui3's timestamps.go).
KeyTimestamps: "TimestampsAt",
// The routing row is read by the v3 door and becomes session.Config.Routing,
// 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 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 routing row is INSTALLED rather than handed down — `InstallRoutingRow`
// puts it on the process-wide knob every client that was handed nothing
// reads (internal/provider's velocity.go), and the v3 door deliberately
// hands the session no answer of its own so that a row written mid-session
// reaches the very next request (#1022). So the reader named here is the
// installer, which is what is really called from outside this package — by
// the launch, and again by the settings panel on the keystroke that writes
// the row. `RoutingChoiceAt` is the choice reader it is built on and
// `RoutingAt` is the surface's resolved read of the same row; both stay what
// they were.
KeyRouting: "InstallRoutingRow",
// The fallback chain is read by the v3 door and becomes
// session.Config.ModelFallbacks, which the adapter walks when no endpoint
// serving the session's model will accept the request at all
Expand Down
11 changes: 10 additions & 1 deletion internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -848,11 +848,20 @@ func LanePinAt(profileDir, slot string) provider.LanePin {
// and every client falls to the shipped row together
// ([provider.DefaultRouting]).
func InstallLaneRows(profileDir string) {
provider.InstallRouting(installedRoutingFor(profileDir))
InstallRoutingRow(profileDir)
provider.SetLanePin(LanePinAt(profileDir, LaneSlotTalk))
provider.SetLaneGuard(LaneGuardAt(profileDir))
}

// InstallRoutingRow hands the routing row ALONE to the transport, and it is the
// half of [InstallLaneRows] the settings panel calls by itself: somebody cycles
// `routing`, the row is written, and the very next request has to go out under
// it. The lane rows beside it are untouched because nothing about them changed —
// re-stating a pin here would be a resolver's write nobody asked for.
func InstallRoutingRow(profileDir string) {
provider.InstallRouting(installedRoutingFor(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
Expand Down
3 changes: 2 additions & 1 deletion internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,8 @@ filter · ↑↓ · → lanes · ctrl+t effort · ctrl+r refresh · enter · esc
```

and the hint slot above the box follows the cursor: `→ lanes · enter switch · esc` on a
model, `enter choose · ← back · esc` inside its lanes.
model, `enter choose · ← back · esc` inside its lanes — and `enter unpin · ← back · esc`
on the machine you are already pinned to, where the same key takes the pin off again.

Choosing a model sets it on the agent, teaches the surface its context window and tells
the session — compaction fires at a fraction of that window, so this is not decoration —
Expand Down
7 changes: 7 additions & 0 deletions internal/manual/chat/debug-record.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ AFORGE_DEBUG=1 aforge for one shell, one run
`AFORGE_CALL_LOG_BODIES=1` — the older word, if it is the one in your shell history —
means the same thing now.

**A recording launch keeps its conversation in this terminal.** The record is written by
the process that makes the calls, and ordinarily `aforge` hands your conversation to this
folder's background session host — which was never told to record, and would leave you a
folder holding `run.json` and no request bodies at all. So a run that is recording takes
the in-process road, by the pin exactly as by the flag. The conversation ends with the
terminal, the way `--no-host` does.

**It does not change what the run does.** Nothing is asked differently, nothing is slower,
no model is told anything new. With it off, a run costs one check and writes nothing at
all; that is why it is safe to leave the flag out and reach for it only on the day you
Expand Down
Loading
Loading