Skip to content
Closed
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
42 changes: 31 additions & 11 deletions cmd/aforge/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// session's model changes under /model (see [v3SeesImages]). It reads
// the SHELF, so a model picked out of a list somebody refreshed a moment
// ago is answered from that list rather than refused as unknown.
SupportsImages: v3SeesImages(proc.Shelf),
SeesImages: v3SeesImages(proc.Shelf),
// The published answer to "may this call carry this knob", which the
// adapter asks before it lets an optional field travel. It was wired to
// nothing on this path, so a reasoning level set with ctrl+t or
Expand Down Expand Up @@ -2127,13 +2127,28 @@ func v3AnswersText(outputs []string) bool {
// The file is read at most once per session: it is the same rows for the whole
// warming window, and re-reading it per message would put I/O on the message
// path to learn nothing new.
func v3SeesImages(models v3Catalog) func(string) bool {
// AND IT SAYS WHETHER IT KNOWS, which is the shape the facts beside it use
// ([session.Config.SeesImages]). The difference decides what happens to a
// person's screenshot: until it existed a cold catalog said `cannot see` in the
// same breath a warm one said `sees`, for one id, one conversation, a minute
// apart.
//
// THREE ANSWERS OUT OF ONE ROW, and the third is not the same as the second. A
// list that names `image` SEES. A non-empty list that does not name it is a row
// that published its modalities and left image out, which is a model this door
// KNOWS is blind. An EMPTY list is a row that published nothing at all — and
// that is ignorance, not a no. The silence law (docs/MULTIMODAL.md Decision 6)
// governs what is SENT, and the send gate honours it by refusing anything but a
// positive `sees`; grading that silence as a known `cannot see` also handed it
// to the guard that hides pictures, which is the opposite direction and the one
// that used to be irreversible.
func v3SeesImages(models v3Catalog) func(string) (bool, bool) {
var once sync.Once
var cached []tui3.Model
return func(model string) bool {
return func(model string) (sees, known bool) {
model = strings.TrimSpace(model)
if model == "" {
return false
return false, false
}
if models != nil {
if rows := models.ModelsNow(); len(rows) > 0 {
Expand All @@ -2143,9 +2158,11 @@ func v3SeesImages(models v3Catalog) func(string) bool {
}
}
// The catalog HAS answered and does not carry this id — a
// hand-typed slug, a model this router never listed. That is a
// no on the same terms as an unpublished modality.
return false
// hand-typed slug, a model this router never listed. Nothing has
// been READ about this model, so nothing is known about it: it is
// refused a picture like any unknown, and its pictures are shown
// like any unknown's.
return false, false
}
}
once.Do(func() { cached = tui3.CachedModels() })
Expand All @@ -2154,7 +2171,7 @@ func v3SeesImages(models v3Catalog) func(string) bool {
return v3ReadsImages(row.Input)
}
}
return false
return false, false
}
}

Expand All @@ -2171,13 +2188,16 @@ func v3SeesImages(models v3Catalog) func(string) bool {
// with a base64 photo in it, sent to a model that cannot read one, comes back as
// a provider error about a content part, while an unknown modality is a refusal
// the person can act on ("switch to a model with vision").
func v3ReadsImages(inputs []string) bool {
func v3ReadsImages(inputs []string) (sees, known bool) {
for _, modality := range inputs {
if strings.EqualFold(strings.TrimSpace(modality), "image") {
return true
return true, true
}
}
return false
// A ROW THAT PUBLISHED NOTHING HAS SAID NOTHING. An empty list is the shape a
// router uses for "no architecture block on this model", and reading it as a
// published `text only` was this door grading its own ignorance as a fact.
return false, len(inputs) > 0
}

// v3NearestModels is the last resort of the endpoint-refusal chain: when the
Expand Down
94 changes: 66 additions & 28 deletions cmd/aforge/chatv3_images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,47 @@ func TestTheVisionGateAnswersFromTheCatalogsInputModalities(t *testing.T) {
models := fakeV3Catalog{rows: []catalog.Model{
{ID: "vendor/sees", InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}},
{ID: "vendor/reads", InputModalities: []string{"text"}, OutputModalities: []string{"text"}},
// A row cached before modalities were recorded. Silence is not consent.
// A row cached before modalities were recorded. Silence is not consent —
// and it is not a verdict either.
{ID: "vendor/quiet", OutputModalities: []string{"text"}},
}}

cfg := session.Config{Model: "vendor/sees", SupportsImages: v3SeesImages(models)}
if !cfg.SupportsImages("vendor/sees") {
t.Fatal("a model that publishes image input was gated out")
cfg := session.Config{Model: "vendor/sees", SeesImages: v3SeesImages(models)}
if got := sightWord(cfg.SeesImages("vendor/sees")); got != "sees" {
t.Fatalf("a model that publishes image input reads as %v", got)
}
// Case is not a difference: it is the same id either way.
if !cfg.SupportsImages("Vendor/Sees") {
t.Fatal("the gate answered case rather than identity")
if got := sightWord(cfg.SeesImages("Vendor/Sees")); got != "sees" {
t.Fatalf("the gate answered case rather than identity: %v", got)
}
for _, model := range []string{"vendor/reads", "vendor/quiet", "vendor/unheard-of", ""} {
if cfg.SupportsImages(model) {
t.Fatalf("%q was told it can read images", model)
// A ROW THAT PUBLISHED ITS MODALITIES IS A VERDICT. `vendor/reads` says text
// and stops: this door has read that and knows the model is blind.
if got := sightWord(cfg.SeesImages("vendor/reads")); got != "cannot see" {
t.Fatalf("a row that published text-only input answers %v", got)
}
// AND EVERYTHING ELSE IS IGNORANCE, INCLUDING A ROW THAT SAID NOTHING.
// `vendor/quiet` published no input modalities at all, which is the shape a
// router uses for a model with no architecture block — not a claim that it
// cannot see. Grading that silence as a verdict was this door calling its own
// ignorance a fact, and it was the irreversible one of the two answers for as
// long as the guard that read it rewrote the transcript (internal/session's
// blindswap.go). The SEND gate still refuses all three of these, which is the
// silence law; it refuses them for not being a positive yes.
for _, model := range []string{"vendor/quiet", "vendor/unheard-of", ""} {
if got := sightWord(cfg.SeesImages(model)); got != "nobody has said" {
t.Fatalf("%q, which nobody has published anything about, answers %v rather than ignorance", model, got)
}
}

// A catalog still warming, with no cache on disk either, answers no — and
// answers it per call, so the same session says yes once the rows land
// rather than being pinned at boot.
// A catalog still warming, with no cache on disk either, knows NOTHING — and
// answers per call, so the same session says yes once the rows land rather
// than being pinned at boot.
warming := fakeV3Catalog{}
if v3SeesImages(warming)("vendor/sees") {
t.Fatal("a warming catalog vouched for a model it has not read")
if got := sightWord(v3SeesImages(warming)("vendor/sees")); got != "nobody has said" {
t.Fatalf("a warming catalog answered %v for a model it has not read", got)
}
if v3SeesImages(nil)("vendor/sees") {
t.Fatal("no catalog at all vouched for a model")
if got := sightWord(v3SeesImages(nil)("vendor/sees")); got != "nobody has said" {
t.Fatalf("no catalog at all answered %v for a model", got)
}
}

Expand All @@ -69,33 +83,57 @@ func TestTheVisionGateReadsTheDiskCacheWhileTheCatalogIsWarming(t *testing.T) {
}

gate := v3SeesImages(fakeV3Catalog{})
if !gate("vendor/sees") {
t.Fatal("the cache's own witness that a model can see was ignored")
if got := sightWord(gate("vendor/sees")); got != "sees" {
t.Fatalf("the cache's own witness that a model can see reads as %v", got)
}
for _, model := range []string{"vendor/reads", "vendor/painter", "vendor/unheard-of"} {
if gate(model) {
t.Fatalf("%q was told it can read images off the cache", model)
for _, model := range []string{"vendor/reads", "vendor/painter"} {
if got := sightWord(gate(model)); got != "cannot see" {
t.Fatalf("%q, whose cached row this door has read, answers %v", model, got)
}
}
// AND A MODEL THE CACHE HAS NEVER HEARD OF IS IGNORANCE, not a verdict.
if got := sightWord(gate("vendor/unheard-of")); got != "nobody has said" {
t.Fatalf("a model the cache does not carry answers %v rather than ignorance", got)
}

// AND A CATALOG THAT HAS ANSWERED WINS. The cache is the warming rung and
// never a second opinion: a row the live catalog carries is answered from
// the live catalog, whatever a stale file says.
live := v3SeesImages(fakeV3Catalog{rows: []catalog.Model{
{ID: "vendor/sees", InputModalities: []string{"text"}, OutputModalities: []string{"text"}},
}})
if live("vendor/sees") {
t.Fatal("a stale cache overruled the catalog that had already answered")
if got := sightWord(live("vendor/sees")); got != "cannot see" {
t.Fatalf("a stale cache overruled the catalog that had already answered: %v", got)
}
}

// The modality test itself, spelled out: the direction that matters is INPUT,
// and an output-image row is a painter rather than a model that can see.
// an output-image row is a painter rather than a model that can see, and a row
// that listed nothing has said nothing.
func TestTheImageModalityTestIsAboutWhatGoesIn(t *testing.T) {
if !v3ReadsImages([]string{"text", "IMAGE"}) {
t.Fatal("a published image modality was missed on case")
if got := sightWord(v3ReadsImages([]string{"text", "IMAGE"})); got != "sees" {
t.Fatalf("a published image modality was missed on case: %v", got)
}
if got := sightWord(v3ReadsImages([]string{"text", "audio"})); got != "cannot see" {
t.Fatalf("a row that listed its input and left image out answers %v", got)
}
if v3ReadsImages(nil) || v3ReadsImages([]string{"text", "audio"}) {
t.Fatal("a row with no image input was read as one that has it")
// AN EMPTY LIST IS THE THIRD ANSWER. It is a row with no architecture block,
// and it is the difference between refusing to send a picture — which costs a
// turn — and hiding one, which used to cost the picture.
if got := sightWord(v3ReadsImages(nil)); got != "nobody has said" {
t.Fatalf("a row that published no modalities answers %v rather than ignorance", got)
}
}

// sightWord is an oracle's two answers as one, so a table can say what it
// expected in the words the design uses.
func sightWord(sees, known bool) string {
switch {
case !known:
return "nobody has said"
case sees:
return "sees"
default:
return "cannot see"
}
}
4 changes: 2 additions & 2 deletions cmd/aforge/chatv3_modelshelf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ func TestTheShelfTakesTodaysListAndKeepsYesterdaysOnFailure(t *testing.T) {
if err != nil || len(rows) != 2 || at.IsZero() {
t.Fatalf("a landed refresh answered %d rows at %v, error %v", len(rows), at, err)
}
if !v3SeesImages(live)("vendor/shipped-this-morning") {
t.Fatal("the vision gate does not know the model the refresh brought")
if got := sightWord(v3SeesImages(live)("vendor/shipped-this-morning")); got != "sees" {
t.Fatalf("the vision gate reads the model the refresh brought as %v", got)
}
cached := tui3.CachedModels()
if len(cached) != 2 || cached[1].ID != "vendor/shipped-this-morning" {
Expand Down
27 changes: 27 additions & 0 deletions docs/changes/unreleased/993-chrome-names-the-wire-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
kind: fixed
title: the model on the seam is the model the engine is on, and the rung beside it says which ladder it is
pr: 993
surface: [chat, engine]
invalidates:
- "The foot's model cell showed the model you last picked. No longer true: while a turn is running it names the model that turn is actually on, and only an idle conversation shows the dial. The door is `internal/tui3.app.wireModel`, which reads `session.Agent.TurnModel()` — `a.riding`, the engine's own record of what the step in flight is talking to, asked directly and NEVER inferred from a news-desk entry, because a phase entry is deleted at the end of every request and every tool batch and a reading taken from its presence went empty between two steps of one turn. `TestTheSeamNamesTheModelTheTurnIsOnAndNotTheDial` is the law."
- "Only the seam's model cell read the model in flight. No longer true: every cell drawn beside it does — the `via` rider and its sighting lookups, the `waiting for <model>` word, the `@lane` pin, the `/status` model and lane rows (one reading now, not two), and the press targets behind the chip (`/model`, `/lane`, the picker's fold). `internal/tui3/wiremodel_law_test.go` is now a package-wide ratchet: every function that reads `a.model` is named in `dialReaders` with the reason it means the dial, and a new reader that is not named fails the build."
- "Over `--host` the surface had no way to know the model a far turn was on. No longer true: `session.Facts.TurnModel` crosses on the photograph the thinking rung already rides, `remote.Welcome.TurnModel` states whether the far engine keeps the fact, and the wire version is 17. Against a version-16 engine the seam draws the dial alone and `/model` says nothing about a turn in flight — a capability that cannot work is absent, not guessed."
- "The thinking rung on the seam was resolved for the dial. No longer true: it resolves for the model the cell names (`session.Agent.ResolvedEffortFor`, mirrored on `remote.Agent` and carried in `Facts.Thinking`). Reasoning levels live per model id, so a rung resolved for the dial drew one model's name beside another model's level, and a fallback hop moved the turn's rung without moving the drawn one."
- "`session.Config.SupportsImages func(string) bool` is gone, replaced by `SeesImages func(string) ModelSight` with three answers — `SightUnknown`, `SightBlind`, `SightSees`. Sending pictures needs a positive `sees`; taking them out of a live transcript needs a positive `blind`. As one bool the two shared the reading 'unknown counts as blind', which let a failed catalog fetch strip a conversation's pictures for good."
- "The blind-image scrub ran in `SetModel`, on the pick. No longer true: it rides `Agent.ridesOnLocked`, the one door the model the work is talking to changes through — a turn opening, a rescue's hop, or a person's word taken at the request boundary — and only when the model actually changed (`Agent.scrubbedFor`). A pick is a preference that may never be acted on, and since #935 a pick can reach the REQUEST in flight, which a scrub keyed to the picker missed by one request. `TestAPickThatCutsTheRequestTakesThePicturesWithIt` asserts it on the wire; `TestAFlappingOracleCannotScrubAConversationThatNeverSwapped` and `TestASwapOntoAnUnknownModelKeepsThePictures` guard the other two edges."
- "A vision turn sealed itself with `Agent.Model()`, the dial, while the looking model had run it. No longer true: the seal takes the seer, which is the model named in the reply's own `[vision: <model>]` prefix. `TestTheWorkInFlightIsSealedUnderItsOwnModel` is the law."
- "`session.Agent`'s phase news carried `a.model`, the dial. No longer true: every stage a turn posts — running, checking, tidying, taking stock — is stamped with the model the work is on (`Agent.newsModel`, which is `ridingNowLocked`), so a pick that has not reached the work yet changes none of them."
- "`/model` mid-turn said nothing about the turn in flight, only about running tasks. No longer true: the note reads `model · <new> — <old> is answering now, and the next request takes it`, derived from what the engine reports it is on, never from a surface flag, and in the same words a task room uses for the same fact."
- "`auto` beside the model meant the thinking rung, with nothing on the cell saying so — and `auto` is also the lane dial's word (`/model auto`, the `lane` row in `/settings`), which is why it was read as \"lane: auto\" twice. Now spelled `⠿ thinking auto`: the cell names its own ladder at every rung (`⠿ thinking high`, `~ thinking low` on a plain terminal), not only at the word that collided."
- "`a.riding` was written in three places. No longer true: `Agent.ridesOnLocked` is the only writer, because publishing the model the work is talking to and making the transcript's pictures safe for it are one event — a writer that set the field without it would move the work onto a blind model with the base64 still in the messages. `TestOnlyTheTurnsOwnDoorsLatchItsModel` pins the writer and the four doors that reach it."
---
One rule closes all of it: THE CHROME NAMES THE MODEL THE ENGINE IS ON. A
person sent a message at 22:48:15, opened the picker at 22:48:18 and chose
kimi-k3 at 22:48:21; the turn had latched glm-5.3-flash and ran there for
seventeen minutes and forty-one calls — the answer was arriving, so by
`internal/session/steer.go`'s law the pick rode the next request the work made. The seam read `moonshotai/kimi-k3 · auto · via wafer` throughout —
the model cell from the picker's answer, the `via` cell from the endpoint of
the request in flight — and neither cell was wrong on its own terms. There is
one reading behind both cells now, and no second road: the model the turn is on
travels on the phase news that already crosses a `--host` connection.
5 changes: 3 additions & 2 deletions internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,8 +894,9 @@ on the machine you are already pinned to, where the same key takes the pin off a

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 —
notes `model · <model>`, and writes the choice into your profile, so the next `aforge`
opens on it. Over `--host` the switch takes for the session and is not written down: the
notes `model · <model>` — and, when a turn is still running, `model · <model> — this turn
finishes on <the model it started on>` — and writes the choice into your profile, so the
next `aforge` opens on it. Over `--host` the switch takes for the session and is not written down: the
model a remote session opens on is that machine's to resolve.

## What the model picker lists, and what it will not do
Expand Down
Loading
Loading