From 6abc49345cc553aa671c8d1e88a78795f9a29909 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 08:53:12 -0400 Subject: [PATCH 01/26] roles: the compaction role is deleted, because no compaction has called a model since the summariser went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoleCompaction was a priced, described, settings-visible dial wired to nothing. [Agent.compact] takes `_ context.Context` and makes no model call at all — a pass is two mechanical rearrangements of text the session already has — so the row on the Providers tab offered a person a model for a call that never happens, and TierHigh's patience was derived from a summary nobody writes. The role, its tier assignment, its description and the settings and manual text that offered it all go. The high tier keeps every tenant it actually has — the auditor, the shaper, vision, the repair, the careful part of a division — each registered from the package that makes the call. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge/chatv3_project_test.go | 4 +-- cmd/aforge/chatv3_roles_test.go | 2 +- cmd/aforge/chatv3_wiring_test.go | 4 +-- internal/e2e/families_e2e_test.go | 2 +- internal/manual/chat/commands.md | 13 +++++-- internal/roles/roles.go | 56 ++++++++++++++--------------- internal/roles/roles_test.go | 37 +++++++++++-------- internal/session/title_test.go | 6 ++-- internal/tui3/settings.go | 2 +- internal/tui3/settingsroles_test.go | 4 +-- 10 files changed, 72 insertions(+), 58 deletions(-) diff --git a/cmd/aforge/chatv3_project_test.go b/cmd/aforge/chatv3_project_test.go index 5f596ac626..cd1d0bb48e 100644 --- a/cmd/aforge/chatv3_project_test.go +++ b/cmd/aforge/chatv3_project_test.go @@ -92,9 +92,9 @@ func TestTheProjectFileBeatsTheProfileAtTheSessionConfig(t *testing.T) { if err != nil || model != "project/title" { t.Fatalf("the title role resolved to %q (%v), want the project's pin", model, err) } - model, err = roles.Resolve(cfg.RolesSource, roles.RoleCompaction, "session/model") + model, err = roles.Resolve(cfg.RolesSource, roles.RoleAuditor, "session/model") if err != nil || model != "project/capable" { - t.Fatalf("compaction resolved to %q (%v), want the project's high tier", model, err) + t.Fatalf("the auditor resolved to %q (%v), want the project's high tier", model, err) } if got, ok := roles.TierModel(cfg.RolesSource, roles.TierLow); !ok || got != "project/cheap" { t.Fatalf("the low tier is %q (ok=%v)", got, ok) diff --git a/cmd/aforge/chatv3_roles_test.go b/cmd/aforge/chatv3_roles_test.go index 9c5a5bf622..eb3bc087ce 100644 --- a/cmd/aforge/chatv3_roles_test.go +++ b/cmd/aforge/chatv3_roles_test.go @@ -68,7 +68,7 @@ func TestTheDoorsRoleLadderCarriesEveryTier(t *testing.T) { }{ {roles.RoleTitle, config.DefaultLowModel}, {roles.RoleWorker, config.DefaultWorkerModel}, - {roles.RoleCompaction, config.DefaultHighModel}, + {roles.RoleAuditor, config.DefaultHighModel}, // The mastermind's value carries a level, and Resolve hands back the id // alone — a colon in a model field is a request for a model nobody serves. {roles.RolePlanner, "z-ai/glm-5.3"}, diff --git a/cmd/aforge/chatv3_wiring_test.go b/cmd/aforge/chatv3_wiring_test.go index 80848a8ae1..1cb3c3d118 100644 --- a/cmd/aforge/chatv3_wiring_test.go +++ b/cmd/aforge/chatv3_wiring_test.go @@ -83,9 +83,9 @@ func TestTheSettingsRowsReachTheSessionConfig(t *testing.T) { if err != nil || model != "pinned/model" { t.Fatalf("the title role resolved to %q (%v), want the pin", model, err) } - model, err = roles.Resolve(cfg.RolesSource, roles.RoleCompaction, "session/model") + model, err = roles.Resolve(cfg.RolesSource, roles.RoleAuditor, "session/model") if err != nil || model != "capable/model" { - t.Fatalf("compaction resolved to %q (%v), want the high tier", model, err) + t.Fatalf("the auditor resolved to %q (%v), want the high tier", model, err) } if got, ok := roles.TierModel(cfg.RolesSource, roles.TierLow); !ok || got != "cheap/model" { t.Fatalf("the low tier is %q (ok=%v)", got, ok) diff --git a/internal/e2e/families_e2e_test.go b/internal/e2e/families_e2e_test.go index e7ed50cb56..640eb5cab9 100644 --- a/internal/e2e/families_e2e_test.go +++ b/internal/e2e/families_e2e_test.go @@ -121,7 +121,7 @@ func pinEveryTextModel(t *testing.T) { // textRoles is every role in internal/roles that answers with words. The media // four are absent for [pinEveryTextModel]'s reason. var textRoles = []roles.Role{ - roles.RoleTitle, roles.RoleCompaction, roles.RoleConsolidate, roles.RoleGuardian, + roles.RoleTitle, roles.RoleConsolidate, roles.RoleGuardian, roles.RolePlanner, roles.RoleDesigner, roles.RoleWorker, roles.RoleAuditor, roles.RoleReflex, roles.RoleRouter, roles.RoleRouterConfirm, roles.RoleMarkReader, roles.RoleHandoff, roles.RoleTaskName, roles.RoleShaper, roles.RoleIntake, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7dbb64739c..2959c40bc0 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -384,6 +384,15 @@ compact failed: `/compact` has no argument form and no alias. +**A compaction costs nothing and asks no model.** It is two mechanical passes over +the messages this session already has: tool results the model has already used +become pointers to their own bytes, and if that is not enough the oldest assistant +work is replaced by one marker line naming how much went and where it can be read. +Your own words are never folded. There is **no summariser** and there is **no +`compaction` role in settings** — there was one, and it was a priced row wired to +nothing. What the model is handed instead of a summary is the state card, which is +maintained a little at a time by the reader that runs after each turn. + ## /rewind — go back to an earlier point in the conversation `/rewind` (or `/undo`, `/back`) opens the **rewind timeline**: a fullscreen list of the @@ -1698,7 +1707,7 @@ order: 7. **reflex** — `near-free · reads every turn — memory, titles, safety` 8. **small work** — `cheap · the small calls — names, digests, the safety gate` 9. **worker** — `does the work · every task, its parts, every run node — most of the bill` -10. **careful work** — `careful · checks what must not be wrong — audits, compaction, vision` +10. **careful work** — `careful · checks what must not be wrong — audits, briefs, vision` 11. **mastermind** — `thinks · plans runs and designs harnesses — add :low, :medium or :high` 12. **pinned roles**, and hanging off it the **roles** list — one row per auxiliary call aforge makes for itself, grouped under its class. Those rows come from the running binary @@ -1760,7 +1769,7 @@ Connections tab — the foot drops `tab next place`, because the layer has taken ## The roles rows in settings — pinning a role, and del to unpin The **roles** list sits on the **Providers** tab, directly under "pinned roles". Each row is -one call aforge makes outside a turn — `title`, `compaction`, `guardian`, `auditor`, +one call aforge makes outside a turn — `title`, `guardian`, `auditor`, `planner`, `designer`, `worker`, `router`, `vision`, `reflex`, and `spellout`, which is the one of them you ask for yourself with `ctrl+r` (see the keys page) — drawn as ` `, with `pinned` after it when that role has a model of its own. diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 3489da23be..59ac63d02e 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -2,8 +2,8 @@ // call the surface makes, and one rule for which model answers it. // // An auxiliary call is any call the person did not type — the title a session -// names itself, the summary a compaction writes, the advisor's aside, a commit -// message. Each is a ROLE. Roles group under TIERS, and a tier is what the +// names itself, the judge that asks whether a turn should have been work, the +// gate that reads one tool call for safety. Each is a ROLE. Roles group under TIERS, and a tier is what the // person actually configures: "low model = X, high model = Y", set once. New // auxiliary calls register a role, inherit their tier's model, and need no // settings of their own; the person never learns a new knob per feature. @@ -37,8 +37,6 @@ type Role string const ( // RoleTitle names a session from its opening exchange. RoleTitle Role = "title" - // RoleCompaction summarizes a transcript that outgrew its window. - RoleCompaction Role = "compaction" // RoleConsolidate is the dreaming pass over what a session has remembered: // one call over fifty short lines whose whole instruction is "merge the // duplicates and drop what is superseded" (internal/session's @@ -290,7 +288,6 @@ const ( // designer that writes badly puts a wrong answer on the menu with a name on // it; a division reviewed badly hands four workers four briefs that nobody // inside them can correct. The first two were on the high tier, beside the - // compaction summary and the // auditor, which made a person choosing "the capable model" choose one // figure for two very different bills: the careful calls are many and short, // the mastermind's are few and worth thinking about. Separating them is what @@ -317,10 +314,10 @@ func known(tier Tier) bool { // DefaultAssignment is the tier each built-in role starts on. // // Titles are disposable prose: a wrong one costs a glance and is rewritten by -// the next session, so it goes to the cheap model. A compaction summary is the -// session's memory — everything before the cut is gone and only the summary -// survives it — so it goes to the capable one. The asymmetry is about what a -// bad answer destroys, not about how hard the task reads. +// the next session, so it goes to the cheap model. The planner's amendment is +// the opposite — every node the rest of a run pays for is a node it cut — so it +// goes to the most capable one there is. The asymmetry is about what a bad +// answer destroys, not about how hard the task reads. // // THE THREE RUN ROLES ARE ASSIGNED HERE rather than from the files that make // their calls, which is the arrangement guardian, vision and the auditor keep. @@ -330,14 +327,13 @@ func known(tier Tier) bool { // happens, many cheap ones that do it. Split across three files, that balance // is three unrelated lines nobody reads together. var DefaultAssignment = map[Role]Tier{ - RoleTitle: TierLow, - RoleCaption: TierLow, - RoleCompaction: TierHigh, - RolePlanner: TierMastermind, - RoleDesigner: TierMastermind, - RoleWorker: TierWorker, - RoleRouter: TierLow, - RoleReflex: TierReflex, + RoleTitle: TierLow, + RoleCaption: TierLow, + RolePlanner: TierMastermind, + RoleDesigner: TierMastermind, + RoleWorker: TierWorker, + RoleRouter: TierLow, + RoleReflex: TierReflex, } // ErrUnknownRole is returned by [Resolve] for a role that was never @@ -383,15 +379,14 @@ func TierKey(tier Tier) string { return tierPrefix + string(tier) } // A package that does pass one to [Register] overwrites its entry here, which is // the same last-one-wins rule the tier assignment keeps. var roleDescriptions = map[Role]string{ - RolePlanner: "the plan that steers an adaptive run", - RoleDesigner: "writes and reviews a harness page", - RoleAuditor: "whether finished-looking work is actually finished", - RoleCompaction: "the summary that survives a compaction", - RoleWorker: "one node of an adaptive run", - RoleTitle: "the name a session gives itself", - RoleIntake: "filling in a program's form from what was already said", - RoleGuardian: "is this one tool call plainly safe", - RoleRouter: "whether a turn should have been work", + RolePlanner: "the plan that steers an adaptive run", + RoleDesigner: "writes and reviews a harness page", + RoleAuditor: "whether finished-looking work is actually finished", + RoleWorker: "one node of an adaptive run", + RoleTitle: "the name a session gives itself", + RoleIntake: "filling in a program's form from what was already said", + RoleGuardian: "is this one tool call plainly safe", + RoleRouter: "whether a turn should have been work", // The cascade's second half, and the two calls a division makes. RoleRouterConfirm: "a second look before work starts itself", RoleMarkReader: "what is left of a long answer, and whether it has parts", @@ -610,9 +605,12 @@ func Patience(tier Tier) time.Duration { // already cost more than the answer is worth. return 45 * time.Second case TierHigh: - // Long enough for a compaction summary over a full window — the one - // call on this tier that legitimately reads a great deal before it - // writes anything. + // Long enough for the AUDITOR over a whole piece of finished-looking + // work — the one call on this tier that legitimately reads a great deal, + // and gathers evidence of its own, before it writes anything. (It was + // derived from the compaction summary until that role was deleted; the + // summariser had already stopped existing, and the figure it justified + // had outlived it.) return 5 * time.Minute case TierMastermind: // A planner reading a whole run, or a designer writing a page everybody diff --git a/internal/roles/roles_test.go b/internal/roles/roles_test.go index df0251f325..1bc52dd378 100644 --- a/internal/roles/roles_test.go +++ b/internal/roles/roles_test.go @@ -34,6 +34,11 @@ func restore(t *testing.T) { } func TestResolvePrecedence(t *testing.T) { + // THE HIGH TIER HAS NO BUILT-IN TENANT. Every careful call — the auditor, + // the shaper, vision, the repair — is registered from the package that makes + // it, so a case about that tier registers one here through the same door. + restore(t) + Register(Role("audit"), TierHigh) tests := []struct { name string src Source @@ -65,19 +70,22 @@ func TestResolvePrecedence(t *testing.T) { { // The default assignment reaching for the other tier is the whole // point of tiers: two roles, one setting each, different models. - name: "compaction takes the high tier", + // The high tier has no BUILT-IN tenant — every careful call is + // registered from the package that makes it — so the case registers + // one, which is the same door those packages use. + name: "a high-tier role takes the high tier", src: settings(map[string]string{ "tiers.low": "low-model", "tiers.high": "high-model", }), - role: RoleCompaction, + role: Role("audit"), fallback: "session-model", want: "high-model", }, { name: "session default when nothing is set", src: settings(map[string]string{}), - role: RoleCompaction, + role: RoleTitle, fallback: "session-model", want: "session-model", }, @@ -228,9 +236,9 @@ func TestResolvePrecedence(t *testing.T) { { name: "a pin resolves without any tier configured", src: settings(map[string]string{ - "roles.compaction": "pinned-model", + "roles.title": "pinned-model", }), - role: RoleCompaction, + role: RoleTitle, fallback: "", want: "pinned-model", }, @@ -344,7 +352,7 @@ func TestRegisteredIsSortedAndComplete(t *testing.T) { Register(Role("commit"), TierLow) want := []Role{ - Role("advisor"), RoleCaption, Role("commit"), RoleCompaction, + Role("advisor"), RoleCaption, Role("commit"), RoleDesigner, RolePlanner, RoleReflex, RoleRouter, RoleTitle, RoleWorker, } for range 5 { // map order varies per iteration; the answer must not @@ -364,11 +372,10 @@ func TestDefaultAssignment(t *testing.T) { // Disposable prose cheap, the session's memory capable — and the run's // balance: one careful call deciding what happens, many cheap ones doing it. for role, want := range map[Role]Tier{ - RoleTitle: TierLow, - RoleCaption: TierLow, - RoleCompaction: TierHigh, - // The two masterminds. They were on the high tier beside the compaction - // summary, which made one figure answer two different bills. + RoleTitle: TierLow, + RoleCaption: TierLow, + // The two masterminds. They were on the high tier beside the auditor, + // which made one figure answer two different bills. RolePlanner: TierMastermind, RoleDesigner: TierMastermind, RoleWorker: TierWorker, @@ -394,8 +401,8 @@ func TestReadHelpers(t *testing.T) { if model, ok := Pinned(src, RoleTitle); !ok || model != "pinned-model" { t.Fatalf("Pinned(title) = %q, %v, want pinned-model, true", model, ok) } - if _, ok := Pinned(src, RoleCompaction); ok { - t.Fatalf("Pinned(compaction) reported a pin that is not set") + if _, ok := Pinned(src, RoleWorker); ok { + t.Fatalf("Pinned(worker) reported a pin that is not set") } if model, ok := TierModel(src, TierHigh); !ok || model != "high-model" { t.Fatalf("TierModel(high) = %q, %v, want high-model, true", model, ok) @@ -406,8 +413,8 @@ func TestReadHelpers(t *testing.T) { } func TestKeys(t *testing.T) { - if got := PinKey(RoleCompaction); got != "roles.compaction" { - t.Fatalf("PinKey(compaction) = %q, want roles.compaction", got) + if got := PinKey(RoleWorker); got != "roles.worker" { + t.Fatalf("PinKey(worker) = %q, want roles.worker", got) } if got := TierKey(TierLow); got != "tiers.low" { t.Fatalf("TierKey(low) = %q, want tiers.low", got) diff --git a/internal/session/title_test.go b/internal/session/title_test.go index ad40eac90a..223519fcd4 100644 --- a/internal/session/title_test.go +++ b/internal/session/title_test.go @@ -340,9 +340,9 @@ func TestTitleModelFollowsTheRolesLadder(t *testing.T) { {"nothing set", nil, "test/model"}, {"the tier", map[string]string{roles.TierKey(roles.TierLow): "cheap/model"}, "cheap/model"}, {"the pin beats the tier", map[string]string{ - roles.TierKey(roles.TierLow): "cheap/model", - roles.PinKey(roles.RoleTitle): "pinned/model", - roles.PinKey(roles.RoleCompaction): "other/model", + roles.TierKey(roles.TierLow): "cheap/model", + roles.PinKey(roles.RoleTitle): "pinned/model", + roles.PinKey(roles.RoleAuditor): "other/model", }, "pinned/model"}, } { t.Run(testCase.name, func(t *testing.T) { diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index bf67d2e0a3..46763d38f7 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -380,7 +380,7 @@ var settingUI = map[string]settingMeta{ }, config.KeyTierHighModel: { tab: tabProviders, label: "careful work", widget: widgetSelect, - about: "careful · checks what must not be wrong — audits, compaction, vision", + about: "careful · checks what must not be wrong — audits, briefs, vision", }, // The fourth class is the one whose value may name a LEVEL as well as a // model, so it is a TEXT box and not a picker: the picker returns an id, and diff --git a/internal/tui3/settingsroles_test.go b/internal/tui3/settingsroles_test.go index 176a46b61d..bbe4b52f59 100644 --- a/internal/tui3/settingsroles_test.go +++ b/internal/tui3/settingsroles_test.go @@ -105,7 +105,7 @@ func TestTheRolesSectionSaysWhatAnswersEachRole(t *testing.T) { // The worker is the seat that does the work, and sits apart from the // small calls beside it. {roles.RoleWorker, "worker", "test/worker-model"}, - {roles.RoleCompaction, "careful work", "test/careful-model"}, + {roles.RoleAuditor, "careful work", "test/careful-model"}, {roles.RoleTitle, "small work", "test/cheap-model"}, } { row := roleItem(t, a, c.role) @@ -198,7 +198,7 @@ func TestARoleFollowsItsShippedClassAndThenTheConversation(t *testing.T) { {roles.RolePlanner, config.DefaultMastermindModel}, {roles.RoleWorker, config.DefaultWorkerModel}, {roles.RoleTitle, config.DefaultLowModel}, - {roles.RoleCompaction, config.DefaultHighModel}, + {roles.RoleAuditor, config.DefaultHighModel}, {roles.RoleReflex, config.DefaultReflexModel}, } { if got := roleItem(t, a, c.role).model; got != c.want { From 2b692e05ac860903f69a4f04c7c0b19d84f5b667 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:03:39 -0400 Subject: [PATCH 02/26] session: every request says what it is for, and the door is the only thing that spells it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NINE OF THE ELEVEN CALLERS OF THIS PACKAGE'S MODEL DOOR REACHED THE WIRE ANONYMOUSLY. The guardian, vision, the shaper, the spell-out, the subharness intake, the run planner, the harness designer, the ceiling's handoff draft and a saved program's own AI step all called completeWithModel with no provider.WithCallTag anywhere above them, so the model-call log could not say what any of them was: 2,309 of the 2,839 untagged finishes in the ten days to 2026-09-10 are this package's, and with them the answer to "what was this build spending that model on all night". The tag was a context value a caller could set or forget, and forgetting it is silent. It is an argument now — clientdoor.go's `callPurpose` — so a call with no purpose does not compile, and the door is the one thing in the package that calls provider.WithCallTag. The three callers that did stamp their own (the turn, the ask tool, the errand ladder) pass a purpose instead, which is one spelling of one fact where there were four. AND auxiliary.go's OWN CLAIM WAS THE FALSE ONE. It said callRole was "the one door all auxiliary provider calls pass through" while ten calls went round it. It is the one door every ERRAND passes through, which is a narrower and true claim, and the sentence says so and names what is legitimately not an errand — vision streams into the room in the person's own turn, the handoff draft is billed to the turn on the turn's own model, a program's step runs on no role's model at all. nohiddenwork_test.go is the law, on the laws gate: a blank purpose fails the build, a WithCallTag outside the door fails the build, and the walk fails if it ever stops finding the door. No behaviour changes. Every call goes to the same model with the same options; what is new is that its row in the log has a name on it. Co-Authored-By: Claude Opus 5 (1M context) --- internal/session/auxiliary.go | 38 +++++--- internal/session/checkpoint.go | 10 +- internal/session/checkwindow_test.go | 2 +- internal/session/clientdoor.go | 67 ++++++++++++-- internal/session/clientdoor_wire_test.go | 2 +- internal/session/guardian.go | 1 + internal/session/harness_build.go | 1 + internal/session/image.go | 3 +- internal/session/loop.go | 13 ++- internal/session/nohiddenwork_test.go | 112 +++++++++++++++++++++++ internal/session/orchestrate.go | 1 + internal/session/spellout.go | 1 + internal/session/subharness_env.go | 2 +- internal/session/subharness_intake.go | 1 + internal/session/task_shape.go | 2 +- internal/session/toolask.go | 5 +- 16 files changed, 227 insertions(+), 34 deletions(-) diff --git a/internal/session/auxiliary.go b/internal/session/auxiliary.go index ff93f447c7..22058bc2c7 100644 --- a/internal/session/auxiliary.go +++ b/internal/session/auxiliary.go @@ -123,10 +123,24 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef if err != nil { return nil, "", err } - // EVERY ERRAND ARMS ITS CUT MONEY HERE. [Agent.callRole] is the one door all - // auxiliary provider calls pass through, including calls whose own deadline - // is not derived from a turn; arming ten callers separately would leave the - // next errand able to lose the receipt this shared seam exists to keep. + // EVERY ERRAND ARMS ITS CUT MONEY HERE. [Agent.callRole] is the one door every + // ERRAND passes through, including errands whose own deadline is not derived + // from a turn; arming them separately would leave the next one able to lose + // the receipt this shared seam exists to keep. + // + // AND "ERRAND" IS NARROWER THAN "MODEL CALL", WHICH IS WHAT THIS SENTENCE USED + // TO CLAIM. It said this was the door all auxiliary provider calls pass + // through, and ten did not: the guardian, vision, the shaper, the spell-out, + // the intake, the planner, the designer, the ceiling's handoff draft, a saved + // program's own step and the standing sentinel each reached the wire by + // another road. Some of them still do, legitimately — vision STREAMS into the + // room in the person's own turn, the handoff draft is billed to the turn on + // the turn's own model, a program's step runs on the program's model and no + // role's — and an errand ladder wrapped round any of those would be the wrong + // shape twice over. What every one of them owes is the TAG, and that is the + // claim that is now true and enforced: clientdoor.go's [callPurpose] is the + // one door every REQUEST passes through, this one is the one door every + // ERRAND passes through, and neither pretends to be the other. ctx = provider.WithReconcile(ctx, a.reconciled) if len(rungs) > roleFallThroughs+1 { rungs = rungs[:roleFallThroughs+1] @@ -181,19 +195,13 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef errandCtx, endErrand := context.WithTimeout(ctx, patience) // AND THE ERRAND SAYS WHAT IT IS FOR, ON EVERY ROW IT WRITES. // - // The model-call log names a call by its tag, and a tag is either set here - // or derived from a routing slot the planning packages open — and this - // package opens none. So every errand this session makes, from every one of - // the ten callers below, landed in the log with no tag at all: 2,309 of the - // 2,839 untagged finishes in the ten days to 2026-09-10, and with them the - // answer to "what was this build spending deepseek-v4-flash on all night" - // (docs/design/recovery/census-20260910.md §8, finding 9). - // // THE ROLE IS THE TAG, because the role is what the errand IS — naming a // conversation, judging a route, writing a caption — and it is the same word // internal/lane's roles.go and the journal's own call line already use, so - // three records of one call agree about what to call it. - errandCtx = provider.WithCallTag(errandCtx, string(role)) + // three records of one call agree about what to call it. It is carried to + // the door as a [callPurpose] rather than stamped on the context here: the + // door is what every request in this package passes over, and it is the only + // thing that spells the tag (clientdoor.go says why that had to move). defer endErrand() tell := errandWatchFrom(ctx) @@ -275,7 +283,7 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef served := &provider.ServedEndpoint{} callCtx = provider.WithServedEndpoint(callCtx, served) callCtx, releaseRung := errandRungContext(callCtx, len(rungs)-attempt-1) - response, called, callErr := a.completeWithNamedModel(callCtx, messages, rung.Model, options...) + response, called, callErr := a.completeWithNamedModel(callCtx, callPurpose(role), messages, rung.Model, options...) if strings.TrimSpace(called) == "" { called = rung.Model } diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index d2411b51b9..4d2de08d63 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -4859,8 +4859,16 @@ func (a *Agent) checkpointBrief(ctx context.Context, turn *Usage, model string) // answer rather than the answer itself, so it is priced and drawn as the // errand it is: a person is reading the turn this ends, and the clock over // their answer is not this call's to move (internal/lane's roles.go). + // + // AND IT SAYS WHAT IT IS, which until now it did not: this rung reached the + // wire with no tag, so the dearest side-call of the whole ceiling road — + // a mastermind reading the turn's entire transcript — was billed to the turn + // and filed under nothing. It is its own purpose rather than the turn's, + // because it is not the turn's question: the turn asked what the person + // asked, and this asks what is left of it (clientdoor.go). response, err := a.completeWithModel( - provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary), messages, + provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary), + purposeHandoffDraft, messages, model) if err != nil || response == nil { // AND THE FAULT IS CARRIED OUT OF HERE RATHER THAN SPELLED AS SILENCE. This diff --git a/internal/session/checkwindow_test.go b/internal/session/checkwindow_test.go index f12018075a..d4cc8827f4 100644 --- a/internal/session/checkwindow_test.go +++ b/internal/session/checkwindow_test.go @@ -113,7 +113,7 @@ func TestAWindowedCallIsToldTheTimeItHasLeft(t *testing.T) { send := func(ctx context.Context) int { t.Helper() before := server.requests() - if _, err := agent.completeWithModel(provider.WithLaneChoice(ctx, lanes.Choice{Order: []string{lane}}), ask, model); err != nil { + if _, err := agent.completeWithModel(provider.WithLaneChoice(ctx, lanes.Choice{Order: []string{lane}}), purposeTurn, ask, model); err != nil { t.Fatalf("completeWithModel: %v", err) } return server.reasoningBudget(t, before) diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index aa99c122d9..8061052856 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -300,7 +300,12 @@ func (c modelRoutingCompleter) CompleteWithMessages(ctx context.Context, message if model == "" { model = c.agent.Model() } - return c.agent.completeWithModel(ctx, messages, model, options...) + // NO PURPOSE, DELIBERATELY. This is the view of a live agent that leaves the + // package — internal/reflex and the media hands reach the wire through it — + // and each of those callers names its OWN call (reflex.go's "reflex"). A + // purpose stamped here would overwrite the one they stated with a word about + // the wrapper rather than about the request. + return c.agent.completeWithModel(ctx, "", messages, model, options...) } // routedCompleter returns a completer safe to hand to a package that chooses a @@ -421,11 +426,54 @@ func (a *Agent) completerFor(model string) (Completer, string, string, error) { return inner, wire, called, nil } +// ── EVERY REQUEST SAYS WHAT IT IS FOR ─────────────────────────────────────── +// +// callPurpose is what one request this package makes is FOR, in the word the +// model-call log files it under (internal/provider's [provider.WithCallTag]). +// +// IT IS AN ARGUMENT AND NOT A CONTEXT VALUE, and that is the whole of the fix. +// The tag was a context value that a caller could set or forget, and nine of the +// eleven callers of this door forgot: the guardian, vision, the shaper, the +// spell-out, the intake, the planner, the designer, the handoff draft and a +// saved program's own step all reached the wire with no tag at all. That is +// 2,309 of the 2,839 untagged finishes in the ten days to 2026-09-10, and with +// them the answer to "what was this build spending that model on all night" +// (docs/design/recovery/census-20260910.md §8, finding 9). A tag that can be +// forgotten is a tag that will be, so it is spelled where it cannot be: a call +// with no purpose does not compile. +// +// AND IT IS STAMPED HERE AND NOWHERE ELSE. The turn, the ask tool and the +// errand ladder each used to stamp their own, which is three spellings of one +// fact; they pass a purpose now and this door is the only thing in the package +// that calls [provider.WithCallTag]. nohiddenwork_test.go is the law. +type callPurpose string + +// The purposes that are not a role's own name. A role-resolved call passes its +// role ([Agent.callRole]) and a tool ask passes the tool, so both are derived +// rather than listed; these are the three requests that are not either. +const ( + // purposeTurn is the person's own question, answered in their conversation. + purposeTurn callPurpose = "turn" + // purposeTask is the same request made inside a piece of work. + purposeTask callPurpose = "task" + // purposeSubharness is one AI step of a saved program, which runs on the + // program's own model rather than on any role's (subharness_env.go). + purposeSubharness callPurpose = "subharness" + // purposeHandoffDraft is the ceiling's draft rung: the model that has just + // spent the turn, asked on the turn's own transcript for the document a + // worker will finish from (checkpoint.go). It is not [roles.RoleHandoff], + // which is the rung BELOW it — the writer that composes a brief out of the + // digest when this one cannot be had — and naming them alike would put the + // dearest call on the road and its cheap understudy on the same row. + purposeHandoffDraft callPurpose = "handoff-draft" +) + // completeWithModel is [Agent.completerFor] joined to the one wire-model -// option. Keeping the two operations inseparable makes it impossible to change -// a slug while accidentally retaining another service's address and bearer. -func (a *Agent) completeWithModel(ctx context.Context, messages []ai.Message, model string, options ...ai.Option) (*ai.Response, error) { - response, _, err := a.completeWithNamedModel(ctx, messages, model, options...) +// option and to the one statement of what the call is for. Keeping the three +// inseparable makes it impossible to change a slug while accidentally retaining +// another service's address and bearer, or to reach the wire anonymously. +func (a *Agent) completeWithModel(ctx context.Context, purpose callPurpose, messages []ai.Message, model string, options ...ai.Option) (*ai.Response, error) { + response, _, err := a.completeWithNamedModel(ctx, purpose, messages, model, options...) return response, err } @@ -433,11 +481,18 @@ func (a *Agent) completeWithModel(ctx context.Context, messages []ai.Message, mo // resolution that chose its account. Errand receipts need that identity when a // missing default-service key moved the call onto the live seat; asking the pool // a second time afterwards could observe a different model or source set. -func (a *Agent) completeWithNamedModel(ctx context.Context, messages []ai.Message, model string, options ...ai.Option) (*ai.Response, string, error) { +func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, messages []ai.Message, model string, options ...ai.Option) (*ai.Response, string, error) { client, wire, called, err := a.completerFor(model) if err != nil { return nil, called, err } + // THE PURPOSE BECOMES THE TAG, on the one line every request in this package + // passes over. An empty one is left alone rather than written as a blank: a + // tag nobody stated is what the log already knows how to say nothing about, + // and a row reading `""` would be worse than one reading nothing. + if purpose != "" { + ctx = provider.WithCallTag(ctx, string(purpose)) + } // A CALL UNDER A TOLD WINDOW IS TOLD IT HERE, at the last moment the context // is this package's to change (callwindow.go says why it cannot be earlier). response, err := client.CompleteWithMessages(toldItsWindow(ctx), messages, append(options, ai.WithModel(wire))...) diff --git a/internal/session/clientdoor_wire_test.go b/internal/session/clientdoor_wire_test.go index 8e7673363b..8f7c0731e2 100644 --- a/internal/session/clientdoor_wire_test.go +++ b/internal/session/clientdoor_wire_test.go @@ -445,7 +445,7 @@ func TestTheCrewNeverReachesAServiceThatDoesNotServeIt(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = child.Close() }) - if _, err := child.completeWithModel(t.Context(), []ai.Message{textMessage("user", "work")}, "crew/worker"); err != nil { + if _, err := child.completeWithModel(t.Context(), purposeTask, []ai.Message{textMessage("user", "work")}, "crew/worker"); err != nil { t.Fatal(err) } drainTurn(t, agent, "chat") diff --git a/internal/session/guardian.go b/internal/session/guardian.go index 08b26c2ba9..f18ea5e580 100644 --- a/internal/session/guardian.go +++ b/internal/session/guardian.go @@ -176,6 +176,7 @@ func (a *Agent) guardianAllows(ctx context.Context, hub *eventHub, call ai.ToolC // (internal/lane's roles.go). response, err := a.completeWithModel( provider.WithRole(provider.WithoutStream(judgeCtx), lane.RoleJudge), + callPurpose(roles.RoleGuardian), []ai.Message{ textMessage("system", guardianPrompt), textMessage("user", guardianQuestion(call, decision)), diff --git a/internal/session/harness_build.go b/internal/session/harness_build.go index 6977c43530..4ddc57e2b8 100644 --- a/internal/session/harness_build.go +++ b/internal/session/harness_build.go @@ -1033,6 +1033,7 @@ func (a *Agent) harnessComplete(ctx context.Context, messages []ai.Message, mode // rather than through its token stream (internal/lane's roles.go). response, err := a.completeWithModel( provider.WithRole(streamCtx, lane.RoleDesign), + callPurpose(roles.RoleDesigner), messages, model) close(done) diff --git a/internal/session/image.go b/internal/session/image.go index 56bbbe630f..c5deceb40a 100644 --- a/internal/session/image.go +++ b/internal/session/image.go @@ -490,7 +490,8 @@ func (a *Agent) runVision(ctx context.Context, hub *eventHub, live ai.Message, s // is reading, delta by delta, above. So it is talk, and it owns the clock // for as long as it is writing (internal/lane's roles.go). response, err := a.completeWithModel( - provider.WithRole(ctx, lane.RoleTalk), []ai.Message{live}, seer) + provider.WithRole(ctx, lane.RoleTalk), callPurpose(roles.RoleVision), + []ai.Message{live}, seer) if err == nil && response != nil { a.addAuxiliaryUsage(response, seer, 1) } diff --git a/internal/session/loop.go b/internal/session/loop.go index 0636f5ce1d..e048b96a02 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1784,11 +1784,14 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // turn and a task child's turn run the identical loop, and the one thing // that tells them apart is whether this agent IS a node — so the word // follows that, and the node it is gets named beside it. + // + // THE WORD GOES TO THE DOOR AND THE NODE STAYS HERE. A purpose is what a + // request is FOR and the door is the one place it is spelled onto a call + // (clientdoor.go); which node made it is a fact only this line knows. + purpose := purposeTurn if a.config.taskID != 0 { - attemptCtx = provider.WithCallNode(provider.WithCallTag(attemptCtx, "task"), - strconv.FormatUint(a.config.taskID, 10)) - } else { - attemptCtx = provider.WithCallTag(attemptCtx, "turn") + purpose = purposeTask + attemptCtx = provider.WithCallNode(attemptCtx, strconv.FormatUint(a.config.taskID, 10)) } messages, carried := a.snapshotWithReasoning() // OLD FROZEN TOOL RESULTS ARE ALREADY CONSUMED EVIDENCE. The live @@ -1804,7 +1807,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m func(message ai.Message) string { return a.fullResultPointer(message, place) }) attemptCtx = provider.WithMessageReasoning(attemptCtx, carried) attemptCtx, generation := a.beginGeneration(attemptCtx, reached) - response, err := a.completeWithModel(attemptCtx, messages, model, + response, err := a.completeWithModel(attemptCtx, purpose, messages, model, ai.WithTools(a.beltDefinitions())) cause := a.endGeneration(generation) if errors.Is(cause, errSteerCut) { diff --git a/internal/session/nohiddenwork_test.go b/internal/session/nohiddenwork_test.go index ff47404dff..1a322e21d9 100644 --- a/internal/session/nohiddenwork_test.go +++ b/internal/session/nohiddenwork_test.go @@ -315,3 +315,115 @@ func TestTheSenseSaysWhichSenseItIsUsing(t *testing.T) { } } } + +// ── AND EVERY REQUEST SAYS WHAT IT IS FOR ─────────────────────────────────── +// +// THE STRUCTURAL HALF OF clientdoor.go's [callPurpose]. The type already makes +// an anonymous call fail to compile; what the type cannot see is a caller that +// satisfies it by passing the empty string, which is the same anonymous request +// wearing the argument. Nine of the eleven callers of this door reached the +// wire with no tag before the purpose was an argument — the guardian, vision, +// the shaper, the spell-out, the intake, the planner, the designer, the handoff +// draft and a saved program's step — and 2,309 of 2,839 untagged finishes in +// ten days were this package's. +// +// SO THE LAW IS: A PURPOSE IS A WORD, NOT A BLANK. One caller is exempt and it +// is named rather than inferred — [modelRoutingCompleter], which is the view of +// a live agent that LEAVES this package, whose callers each name their own call +// and whose tag this door must not overwrite. +// +// AND THE TAG IS SPELLED IN ONE PLACE. [provider.WithCallTag] may be called by +// the door and by nothing else in this package: three callers used to stamp +// their own, which is three spellings of one fact and the reason the other nine +// could forget it existed. +// +// IT READS THE TREE ITSELF, so it runs on the laws gate of every pull request +// (scripts/laws.sh finds it by this import). +func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { + set := token.NewFileSet() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + // theDoors are the two spellings of the one door, and theWrapper is the one + // caller that may pass an empty purpose. + theDoors := map[string]bool{"completeWithModel": true, "completeWithNamedModel": true} + const theWrapper = "modelRoutingCompleter" + purposes := 0 + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(set, name, nil, 0) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + // within is the receiver type of the function being walked, so the one + // exemption is a property of where the call is rather than a file name. + within := "" + ast.Inspect(file, func(node ast.Node) bool { + if decl, ok := node.(*ast.FuncDecl); ok { + within = receiverTypeName(decl) + } + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + if selector.Sel.Name == "WithCallTag" && name != "clientdoor.go" { + t.Errorf("%s:%d calls provider.WithCallTag. The tag is spelled by the one door "+ + "and nowhere else — pass a callPurpose to completeWithModel instead "+ + "(clientdoor.go)", name, set.Position(call.Pos()).Line) + return true + } + if !theDoors[selector.Sel.Name] || len(call.Args) < 2 { + return true + } + purposes++ + if blankPurpose(call.Args[1]) && within != theWrapper { + t.Errorf("%s:%d reaches the wire with no purpose. Every request this package "+ + "makes says what it is FOR, because a tag that can be forgotten is a tag "+ + "that will be — and the call log cannot say what a build spent its night on "+ + "(clientdoor.go's [callPurpose])", name, set.Position(call.Pos()).Line) + } + return true + }) + } + // AND THE LAW IS READING THE TREE IT THINKS IT IS. A walk that matched + // nothing would pass for ever, which is how a structural law rots. + if purposes < 8 { + t.Fatalf("only %d calls through the one door were found; the law is reading the wrong tree", purposes) + } +} + +// blankPurpose reports an argument that names no purpose: the empty literal, or +// a conversion of one. +func blankPurpose(arg ast.Expr) bool { + switch node := arg.(type) { + case *ast.BasicLit: + return node.Kind == token.STRING && (node.Value == `""` || node.Value == "``") + case *ast.CallExpr: + // callPurpose("") is the same blank wearing its type. + return len(node.Args) == 1 && blankPurpose(node.Args[0]) + } + return false +} + +// receiverTypeName is the bare type a method hangs off, and "" for a function. +func receiverTypeName(decl *ast.FuncDecl) string { + if decl.Recv == nil || len(decl.Recv.List) == 0 { + return "" + } + expr := decl.Recv.List[0].Type + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + if ident, ok := expr.(*ast.Ident); ok { + return ident.Name + } + return "" +} diff --git a/internal/session/orchestrate.go b/internal/session/orchestrate.go index f47e73a7b1..4ac832ebb6 100644 --- a/internal/session/orchestrate.go +++ b/internal/session/orchestrate.go @@ -710,6 +710,7 @@ func (p *orchestratePlanner) ask(ctx context.Context, messages []ai.Message) (st // The plan a run is steered by: nobody reads it arriving, and it has to // be right rather than soon (internal/lane's roles.go). provider.WithRole(provider.WithoutStream(ctx), lane.RoleDesign), + callPurpose(roles.RolePlanner), messages, p.call.model) if err != nil { diff --git a/internal/session/spellout.go b/internal/session/spellout.go index bd1c1b7706..bd76dcdcf1 100644 --- a/internal/session/spellout.go +++ b/internal/session/spellout.go @@ -172,6 +172,7 @@ func (a *Agent) SpellOut(ctx context.Context, draft string) string { // A side errand of the box the person is typing in, named as one so it // is priced as one and never owns the clock (internal/lane's roles.go). provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary), + callPurpose(spellOutRole), []ai.Message{ textMessage("system", spellOutSystem), // THE INSTRUCTION IS LAST, after the draft rather than above it, for diff --git a/internal/session/subharness_env.go b/internal/session/subharness_env.go index 5d57673628..2c300e4d91 100644 --- a/internal/session/subharness_env.go +++ b/internal/session/subharness_env.go @@ -203,7 +203,7 @@ func (e *subharnessEnv) AI(ctx context.Context, promptRef string, input any, opt if !opts.Schema.Empty() { options = append(options, ai.WithSchema(json.RawMessage(opts.Schema))) } - response, err := e.agent.completeWithModel(call, []ai.Message{ + response, err := e.agent.completeWithModel(call, purposeSubharness, []ai.Message{ textMessage("system", prompt), // THE INPUT GOES LAST, as its own message and as JSON. The prompt is the // asset under review and the input is the material; two messages keep diff --git a/internal/session/subharness_intake.go b/internal/session/subharness_intake.go index 987fe814db..844e727e46 100644 --- a/internal/session/subharness_intake.go +++ b/internal/session/subharness_intake.go @@ -167,6 +167,7 @@ func (a *Agent) fillSubharnessCard(card *SubharnessCard) { // (internal/lane's roles.go). response, err := a.completeWithModel( provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary), + callPurpose(roles.RoleIntake), []ai.Message{ textMessage("system", intakeSystem), textMessage("user", "The form:\n"+intakeFields(*card)+ diff --git a/internal/session/task_shape.go b/internal/session/task_shape.go index 3b88dc9b99..16d321799f 100644 --- a/internal/session/task_shape.go +++ b/internal/session/task_shape.go @@ -179,7 +179,7 @@ func (a *Agent) shapeBrief(ctx context.Context, request string) (shapedBrief, bo ctx = provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary) messages := []ai.Message{textMessage("system", shapePrompt), textMessage("user", request)} for attempt := 0; attempt < 2; attempt++ { - response, callErr := a.completeWithModel(ctx, messages, call.Model) + response, callErr := a.completeWithModel(ctx, callPurpose(roles.RoleShaper), messages, call.Model) if callErr != nil || response == nil { return shapedBrief{}, false } diff --git a/internal/session/toolask.go b/internal/session/toolask.go index 3e5ea0b331..7691b55fff 100644 --- a/internal/session/toolask.go +++ b/internal/session/toolask.go @@ -118,7 +118,6 @@ func (a *Agent) askModel(ctx context.Context, question toolAsk) (string, error) // the client bounded in total rather than on the stream client, which by // design carries no total deadline at all (internal/provider's transport.go). asked := provider.WithRole(provider.WithoutStream(ctx), lane.RoleTool) - asked = provider.WithCallTag(asked, toolCallTag(question.tool)) if a.config.taskID != 0 { asked = provider.WithCallNode(asked, strconv.FormatUint(a.config.taskID, 10)) } @@ -126,7 +125,9 @@ func (a *Agent) askModel(ctx context.Context, question toolAsk) (string, error) defer stop() // NO OUTPUT CEILING TRAVELS WITH IT ([toolAskSendsNoOutputCeiling]). - response, err := a.completeWithModel(asked, []ai.Message{{ + // THE TOOL IS THE PURPOSE, carried to the door rather than stamped here, so + // that this package has one place that spells a tag (clientdoor.go). + response, err := a.completeWithModel(asked, callPurpose(toolCallTag(question.tool)), []ai.Message{{ Role: "user", Content: []ai.ContentPart{{Type: "text", Text: question.prompt}, question.part}, }}, question.model) From fd034a56af2128a58f698ac01bec54c441460356 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:06:18 -0400 Subject: [PATCH 03/26] tests: the prefix gate weighs the belt a configured machine actually carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE GATE WAS POINTED AT A SHAPE NOBODY RUNS. fixedPrefixBudget's own doc comment has always said it bounds "the belt the shipping conversation door assembles". The fixture under it was v3ShapedAgent: a conversation with no memory store, no accounts hub, no standing items and no saved programs — eighteen tools, 39,073 bytes, eight kilobytes of headroom, green every day. A machine somebody has finished setting up carries twenty-three tools and weighs 53,025 bytes, which is 5,025 OVER the 48,000 cap the gate was enforcing. It has been over for as long as the shape existed and the gate could not see it, which is #576's shape exactly. The gate now weighs beltShapes[0] — "the shipping conversation, fully wired", the same definition prompt_belt_test.go uses, through one new helper so the two cannot drift apart again. AND THE FIGURE BECOMES A RATCHET. "The measurement plus a tenth" was right when the number came out of a diet that had just been paid for. This measurement is not the end of a diet; it is the first honest weighing of a prefix that had never been weighed, already past what the last diet aimed at, so a tenth on top would be five more kilobytes nobody chose. It is the measurement and nothing else, and anything that grows the shipped prefix now fails the build. WHAT IS OWED IS FILED, NOT FIXED. 48,000 stays as the stated target and the test prints the 5,025 shortfall on every green run. The bill is one tool: `stand` is 9,607 bytes, more than a quarter of the whole tool block and nearly twice the next heaviest. Trimming a schema is a change to what the model is told, not to a byte count, so it belongs to whoever owns the standing belt rather than here. Co-Authored-By: Claude Opus 5 (1M context) --- internal/session/belt_wiring_test.go | 24 +++++++++ internal/session/prefixbudget_test.go | 72 ++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/internal/session/belt_wiring_test.go b/internal/session/belt_wiring_test.go index b671513a7b..03557108c8 100644 --- a/internal/session/belt_wiring_test.go +++ b/internal/session/belt_wiring_test.go @@ -48,6 +48,30 @@ func v3ShapedAgent(t *testing.T) *Agent { return agent } +// shippedShapeAgent is THE BELT A CONFIGURED MACHINE ACTUALLY CARRIES, and it is +// built from [beltShapes]'s own first entry — "the shipping conversation, fully +// wired" — so that there is one definition of that shape and the prefix gate and +// the belt tests cannot come to disagree about it. +// +// IT IS NOT [v3ShapedAgent], AND THAT WAS A MEASURED HOLE. That one is a +// conversation with no memory store, no accounts hub, no standing items and no +// saved programs: eighteen tools where a machine somebody has finished setting +// up carries twenty-three. The prefix gate weighed it and reported green at +// 39,073 bytes while the shipped shape weighed 53,025 — over the cap the gate +// was enforcing, invisibly, which is #576's shape exactly (a gate that passes +// because it is pointed at something nobody runs). +func shippedShapeAgent(t *testing.T) *Agent { + t.Helper() + shape := beltShapes[0] + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + // The rendered page is what the budget weighs, so this shape renders its + // own rather than taking newTestAgent's fixed one. + config.System = "" + shape.build(t, config) + }) + return agent +} + func TestTheChatBeltCarriesTheBigHands(t *testing.T) { agent := v3ShapedAgent(t) for _, want := range []string{"build_harness", "list_harnesses", "propose_task"} { diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 450a2bf98f..3f170a8304 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -48,9 +48,52 @@ import ( // fixedPrefixBudget bounds the system prompt plus the marshalled tool block of // the belt the shipping conversation door assembles (belt_wiring_test.go's -// [v3ShapedAgent] is that shape). It is the post-diet measurement with about a -// tenth of headroom on top, which is room for a genuinely new law and not room -// for a paragraph of prose about one that is already stated. +// [shippedShapeAgent] is that shape). +// +// ── IT WEIGHED THE WRONG BELT FOR MONTHS, AND THIS IS THAT REPAIR ─────────── +// +// The sentence above has always said "the shipping conversation". The fixture +// under it was [v3ShapedAgent]: a conversation with NO memory store, NO accounts +// hub, NO standing items and NO saved programs — eighteen tools, 39,073 bytes, +// comfortably green. A machine somebody has finished setting up carries five +// more (`stand`, `search_conversations`, `remember`, and the subharness pair) +// and weighs 53,025. So the gate reported eight kilobytes of headroom on a +// prefix that was five kilobytes OVER the cap it was enforcing, and it reported +// it every day. That is #576's shape exactly: a gate pointed at something nobody +// runs passes without having tested anything. +// +// ── SO THE FIGURE IS A RATCHET NOW, AND NOT A BUDGET WITH ROOM IN IT ──────── +// +// It used to be "the post-diet measurement plus about a tenth", which was right +// when the number came out of a diet that had just been paid for: the headroom +// was room for a genuinely new law. It is wrong here, because this measurement +// is not the end of a diet — it is the first honest weighing of a prefix that +// has never been weighed, and it is already past what the last diet aimed at. A +// tenth of headroom on top of that would be five more kilobytes nobody chose. +// +// So it is the measurement and nothing else. Anything that grows the shipped +// prefix fails the build and has to be paid for out of what is already here, +// which is what a ratchet is for and what the old figure could not do. +// +// ── WHAT IS OWED, AND WHERE IT HAS TO COME FROM ───────────────────────────── +// +// THE TARGET IS STILL 48,000 and the shipped prefix is 5,025 over it. The bill +// is not spread thin — one tool is more than a quarter of the whole tool block: +// +// stand 9,607 the standing-item verb's schema +// propose_task 4,363 +// tasks 2,272 +// search_conversations 1,613 +// watch 1,396 +// +// `stand` alone is nearly twice the next heaviest and more than the 5,025 owed. +// It is not this file's to cut: what a tool's schema says is its contract with +// the model, and trimming it is a change to what the model is told rather than +// to a byte count. It belongs to whoever owns internal/session's standing belt, +// with the same discipline the 2026-09-10 diet used on `propose_task` — one +// clause per field, no rule stated twice, no em dashes — and it is filed here +// rather than done here because a gate is not the place to decide what a verb +// means. // // It is a byte count and not a token count deliberately: bytes are what this // process can measure exactly, and every tokenizer this build talks to is within @@ -318,7 +361,16 @@ import ( // `THERE IS NO PLANNER ON YOUR BELT` paragraph — and both are pinned by // TestTheBeltRoutesWideWorkToOneWorkerAndNotToAPlanner, so paying it back is a // change to that test's mind and not only to the bytes. -const fixedPrefixBudget = 48_000 +// THE MEASUREMENT, AND NOTHING ON TOP OF IT. 53,025 bytes on 2026-09-12: the +// widest page at 19,114 and the fully-wired belt's tool block at 33,911 over +// twenty-three tools. The seventy-five bytes are rounding and not headroom. +const fixedPrefixBudget = 53_100 + +// fixedPrefixTarget is where the shipped prefix has to get back to, and it is +// the figure [fixedPrefixBudget] was before anybody weighed the right belt. It +// is stated rather than enforced because a test that failed on it today would +// fail on work nobody in this file can do (see the bill above). +const fixedPrefixTarget = 48_000 // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane // G). promptprofile.go added a second shape of prefix for a model with a small @@ -436,7 +488,7 @@ func widestPage() string { // TestTheFixedPrefixStaysUnderItsBudget weighs what every request carries before // anybody has said anything. func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { - agent := v3ShapedAgent(t) + agent := shippedShapeAgent(t) definitions := agent.beltDefinitions() if len(definitions) == 0 { @@ -448,7 +500,15 @@ func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { } tools, prompt := len(block), len(widestPage()) total := tools + prompt - t.Logf("the fixed prefix is %d bytes (~%d tokens): prompt %d + tools %d", total, total/4, prompt, tools) + t.Logf("the fixed prefix is %d bytes (~%d tokens): prompt %d + tools %d over %d tools", + total, total/4, prompt, tools, len(definitions)) + // AND WHAT IS STILL OWED IS SAID ON EVERY GREEN RUN, because a debt nobody + // is reminded of is a debt that becomes the new floor. The ratchet passing + // is not the same fact as the prefix being the size it should be. + if total > fixedPrefixTarget { + t.Logf("it is %d bytes over the %d target — see this file's head for the bill "+ + "and who owes it", total-fixedPrefixTarget, fixedPrefixTarget) + } if total <= fixedPrefixBudget { return From c36ce2d283d72880d586ffed9aee49c7862dfc95 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:07:38 -0400 Subject: [PATCH 04/26] changes: the auxiliary inventory entry (#996) Co-Authored-By: Claude Opus 5 (1M context) --- .../unreleased/996-auxiliary-inventory.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/changes/unreleased/996-auxiliary-inventory.md diff --git a/docs/changes/unreleased/996-auxiliary-inventory.md b/docs/changes/unreleased/996-auxiliary-inventory.md new file mode 100644 index 0000000000..00f60db2ac --- /dev/null +++ b/docs/changes/unreleased/996-auxiliary-inventory.md @@ -0,0 +1,38 @@ +--- +kind: internal +title: every request says what it is for, a dead role goes, and the prefix gate weighs the real belt +pr: 996 +surface: [chat, engine] +invalidates: + - "`auxiliary.go` said `callRole` was \"the one door all auxiliary provider calls pass through\". It never was — ten calls went round it. It is the one door every ERRAND passes through; the one door every REQUEST passes through is `clientdoor.go`'s `completeWithModel`." + - "The call tag was a context value set with `provider.WithCallTag` wherever a caller remembered to. It is an argument now — `callPurpose`, the second parameter of `completeWithModel` — and a call with no purpose does not compile. Nothing in `internal/session` but the door may call `WithCallTag`." + - "Nine auxiliary calls reached the wire with no tag: the guardian, vision, the shaper, the spell-out, the subharness intake, the run planner, the harness designer, the ceiling's handoff draft and a saved program's AI step. Every one of them now names itself in the model-call log." + - "`roles.RoleCompaction` existed, sat on `TierHigh`, had a description and a settings row. It is deleted: no compaction has made a model call since the summariser went, and `Agent.compact` takes `_ context.Context`." + - "`TierHigh` had a built-in tenant in `DefaultAssignment`. It has none — every careful call (`auditor`, `shaper`, `vision`, `careful`, the repair) registers itself from the package that makes it." + - "`TierHigh`'s five-minute patience was justified by \"a compaction summary over a full window\". That call does not exist; the figure is derived from the auditor now." + - "The settings roles list offered a `compaction` row and the crew's careful line read \"audits, compaction, vision\". Neither does; the line reads \"audits, briefs, vision\"." + - "`fixedPrefixBudget` was 48,000 and weighed `v3ShapedAgent` — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. It weighs the fully-wired conversation (`shippedShapeAgent`, 23 tools) and is 53,100, which is that shape's measured 53,025 and no headroom." + - "The shipped prefix was believed to be inside 48,000. It is 53,025 — 5,025 over — and has been for as long as the shape existed. 48,000 is a target the test prints the shortfall against, not a cap anything passes today." +--- + +Three things this build reported about itself incorrectly. None of them changes +what any model is asked or what it answers. + +The tag is the load-bearing one. A tag that can be forgotten is a tag that will +be, and the log that was supposed to answer "what was this build spending that +model on all night" could not: 2,309 of the 2,839 untagged finishes in the ten +days to 2026-09-10 were this package's. Making the purpose an argument is the +whole fix — the type refuses an anonymous call, and `nohiddenwork_test.go` +refuses one that satisfies the type with a blank. + +The prefix gate is the same disease in a test: a gate pointed at something +nobody runs passes without having tested anything, which is #576's shape. The +5 KB it was hiding is filed rather than paid — `stand`'s schema is 9,607 bytes, +more than a quarter of the tool block, and trimming it is a change to a tool's +contract with the model rather than to a byte count. + +Two seams are reported rather than worked around: the standing sentinel builds +its own `provider.NewClient` and has no `*Agent` to reach either door through, +and four of the nine anonymous calls (guardian, shaper, spell-out, intake) are +genuine errands that could go through `callRole` — which would ask a second +model on failure, and so is a behaviour change rather than a fix. From eda3fa6e43987a6acbb0d01d2521d71c0768b390 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:09:32 -0400 Subject: [PATCH 05/26] replay: the tag law reads the door, because the door is where a tag is stated now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestEveryTagTheBuildWritesResolvesToARoleSomebodyDeclared walks the tree for provider.WithCallTag. internal/session has exactly one of those now — the relay line inside the door — so the law was reading a variable it could not resolve and finding fourteen tags for a table of thirty-seven. It reads the door's own call sites instead, which is the same thing the build does: the purpose argument is what reaches calllog.Record.Tag. The `callPurpose(...)` conversion comes off before the argument is read, a role worn as a purpose resolves to internal/roles' vocabulary (over-approximated, because this law is safe answering with more tags than one site writes and holed answering with fewer), and the door's own constants are read from their declaration the way roleWords already reads internal/roles. AND IT IMMEDIATELY FOUND THE TWO CALLS THAT HAD JUST BEEN GIVEN NAMES. The ceiling's handoff draft and a saved program's AI step now write tags, and nothing priced them: both are lane.RoleAuxiliary and both have a row. The draft rung is the dearest side-call on the ceiling road and it has been invisible to every reading of calls.jsonl until now. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge-replay/replay_test.go | 110 +++++++++++++++++++++++++++++-- cmd/aforge-replay/request.go | 10 +++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/cmd/aforge-replay/replay_test.go b/cmd/aforge-replay/replay_test.go index d072c543b7..ef230225c2 100644 --- a/cmd/aforge-replay/replay_test.go +++ b/cmd/aforge-replay/replay_test.go @@ -241,13 +241,6 @@ func TestEveryTagTheBuildWritesResolvesToARoleSomebodyDeclared(t *testing.T) { // that passes forever the moment somebody builds a tag out of a variable, which // is exactly how the log came to carry 2,309 untagged rows in the first place. var derivedTags = map[string]func(yield func(tag, where string)){ - // internal/session/auxiliary.go: every errand is tagged with its own role - // word, so the whole of internal/roles' vocabulary reaches the log. - "string(role)": func(yield func(tag, where string)) { - for _, word := range roleWords() { - yield(word, "an internal/roles constant, written by internal/session/auxiliary.go") - } - }, // internal/session/toolask.go: a tool's own call carries the tool's name // after the role word, and no walk can enumerate the belt. "toolCallTag(question.tool)": func(yield func(tag, where string)) { @@ -257,6 +250,52 @@ var derivedTags = map[string]func(yield func(tag, where string)){ // cmd/aforge/chat.go's errandContext passes the errand's own name straight // through; its call sites are walked for the literals they hand it. "task": func(yield func(tag, where string)) {}, + // internal/session/clientdoor.go is the ONE place that package writes a tag + // now, and what it writes is whatever purpose its caller handed it. It + // enumerates nothing on its own: the words come from the door's own call + // sites, which the walk below reads through the same rules. + "string(purpose)": func(yield func(tag, where string)) {}, + // internal/session/auxiliary.go hands the door the errand's own role word, so + // the whole of internal/roles' vocabulary reaches the log. The spelling is + // what is left after `callPurpose(...)` comes off the argument. + "role": func(yield func(tag, where string)) { + for _, word := range roleWords() { + yield(word, "an internal/roles constant, written by internal/session/auxiliary.go") + } + }, +} + +// purposeWords reads internal/session/clientdoor.go for the [callPurpose] +// constants, because those ARE tags the moment the door writes one. +// +// It is [roleWords] one package over and for its reason. The purposes that are +// a role's own name need no entry — they arrive through the role vocabulary — +// and these are the three or four that are not any role: the turn, a node's +// turn, a saved program's step, the ceiling's draft rung. +func purposeWords() []string { + var words []string + set := token.NewFileSet() + file, err := parser.ParseFile(set, filepath.Join(moduleRoot, "internal", "session", "clientdoor.go"), nil, 0) + if err != nil { + panic("reading internal/session/clientdoor.go: " + err.Error()) + } + ast.Inspect(file, func(node ast.Node) bool { + spec, isValue := node.(*ast.ValueSpec) + if !isValue || len(spec.Values) != 1 { + return true + } + if kind, isIdent := spec.Type.(*ast.Ident); !isIdent || kind.Name != "callPurpose" { + return true + } + if word, literal := stringLiteral(spec.Values[0]); literal && word != "" { + words = append(words, word) + } + return true + }) + if len(words) == 0 { + panic("internal/session/clientdoor.go declares no callPurpose constants; this law is reading the wrong tree") + } + return words } // tagsTheBuildWrites reads the module's sources for every tag that can reach @@ -287,6 +326,63 @@ func tagsTheBuildWrites(t *testing.T) map[string]string { return true } derived(func(tag, where string) { found[tag] = where }) + case "completeWithModel", "completeWithNamedModel", + "a.completeWithModel", "a.completeWithNamedModel", + "p.agent.completeWithModel", "e.agent.completeWithModel", + "c.agent.completeWithModel", "child.completeWithModel": + // THE DOOR IS WHERE internal/session'S TAGS ARE STATED NOW. Its + // second argument is a [session.callPurpose], and it reaches + // `calllog.Record.Tag` verbatim (clientdoor.go). Reading it here is + // what keeps this law pointed at the same thing the build is: the + // `WithCallTag` case above sees only the one relay line. + if len(call.Args) < 2 { + return true + } + purpose := call.Args[1] + // A PURPOSE MAY WEAR ITS TYPE. `callPurpose("...")` and a bare + // constant are the same statement, so the conversion comes off + // before the argument is read. + if conversion, isCall := purpose.(*ast.CallExpr); isCall && + callName(conversion.Fun) == "callPurpose" && len(conversion.Args) == 1 { + if _, literal := stringLiteral(conversion.Args[0]); !literal { + // A ROLE WORN AS A PURPOSE IS STILL A ROLE, and the + // vocabulary it can produce is internal/roles' own. It is + // over-approximated deliberately: this law asks whether + // every tag the build CAN write is named, so answering + // with more of them than one site writes is safe, where + // answering with fewer is the hole it exists to close. + if strings.Contains(source(t, conversion.Args[0]), "Role") { + for _, word := range roleWords() { + found[word] = path + ", a role worn as a purpose" + } + return true + } + } + purpose = conversion.Args[0] + } + if word, literal := stringLiteral(purpose); literal { + if word != "" { + found[word] = path + } + return true + } + // A NAMED CONSTANT IS THE ORDINARY CASE and the door declares them + // all in one block, so they are read from the declaration rather + // than listed here. + if ident, isIdent := purpose.(*ast.Ident); isIdent && strings.HasPrefix(ident.Name, "purpose") { + for _, word := range purposeWords() { + found[word] = "an internal/session/clientdoor.go callPurpose constant" + } + return true + } + spelling := source(t, purpose) + derived, known := derivedTags[spelling] + if !known { + t.Errorf("%s hands the model door a purpose spelled %q, which this law cannot "+ + "read; add it to derivedTags with what it can produce", path, spelling) + return true + } + derived(func(tag, where string) { found[tag] = where }) case "errandContext": // func errandContext(ctx, settings, task string, role lane.Role) if len(call.Args) < 3 { diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index 91ea846cd5..cb232ec855 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -257,6 +257,16 @@ func roleOf(tag string) lane.Role { var callSiteRoles = map[string]lane.Role{ // internal/session/loop.go's laneRole: a conversation's own turn is talk. "turn": lane.RoleTalk, + // internal/session/checkpoint.go's draft rung: the model that has just spent + // the turn, asked on the turn's own transcript for the document a worker will + // finish from. It is made with lane.RoleAuxiliary and billed to the turn, and + // it reached the log with NO TAG AT ALL until #996 — which made the dearest + // side-call on the ceiling road invisible to every reading of this file. + "handoff-draft": lane.RoleAuxiliary, + // internal/session/subharness_env.go: one AI step of a saved program, which + // runs on the program's own model rather than on any role's. Nobody is + // reading its stream and nobody is waiting on its first word. + "subharness": lane.RoleAuxiliary, // The same function, in a task. WHICH LEAF ROLE IT IS CANNOT BE READ FROM // THE ROW — laneRole asks `someoneIsWatching()` at the moment of the call // and nothing records the answer — so the unattended reading is taken, From c2fed7a165f0d85c8aaab3071ba8e953575a0cc8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:20:32 -0400 Subject: [PATCH 06/26] manual: the roles table stops listing a role the binary no longer registers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The models-and-cost page's roles table is described on the page itself as 'built from what is registered in the running binary, so it is the truth about this build'. It listed `compaction`, which nothing registers any more, and the crew's careful line and its account of what aforge calls on your behalf both named the summary a compaction keeps — a call that has not existed since the summariser was deleted. Co-Authored-By: Claude Opus 5 (1M context) --- internal/manual/chat/models-and-cost.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f96a1dda10..1276fbac64 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -312,7 +312,7 @@ class, then the conversation model when that worker class is blank. ## The crew — which models aforge uses on my behalf, and /crew aforge makes calls you did not type: naming a session, naming a piece of work on the roster, -the summary a compaction keeps, the safety gate, the check on finished task work, the second +the brief a task opens on, the safety gate, the check on finished task work, the second look before a task starts itself, the reading of a task's parts before they are handed out, the planner of an adaptive run and the nodes under it, the designer of a saved harness page, looking at an image. Each of those is a @@ -323,7 +323,7 @@ looking at an image. Each of those is a - **small work** — cheap · the small calls — names, digests, the safety gate. - **worker** — does the work · every task you hand off, the parts it divides into, every node of an adaptive run. Most of what a task costs is spent here. -- **careful work** — careful · checks what must not be wrong — audits, compaction, vision. +- **careful work** — careful · checks what must not be wrong — audits, briefs, vision. - **mastermind** — thinks · plans runs and designs harnesses. **All five arrive with a model already in them**, and the five together are the `balanced` @@ -413,7 +413,6 @@ under the class answering it, saying which model comes out. As shipped: | `router` | small work | whether a turn should have been work | | `consolidate` | small work | tidies what is remembered while nobody is here | | `taskname` | small work | the two or three words a task is called | -| `compaction` | careful work | the summary that survives a compaction | | `auditor` | careful work | whether finished-looking work is actually finished | | `vision` | careful work | reads images for a model that cannot see them | | `shaper` | careful work | the brief a task you started yourself is given | @@ -431,9 +430,9 @@ this build rather than a table someone kept up to date. Stop on a row and the li list is that role's own description followed by which class it follows. **What the mastermind's roles have in common is that one answer decides what all the other -calls do.** `planner` and `designer` used to sit on careful work beside the compaction -summary, which made one model id answer two unrelated bills: the careful calls are many and -short, and these are few. A planner that cuts badly spends a whole run on work nobody wanted; +calls do.** `planner` and `designer` used to sit on careful work beside the check on +finished work, which made one model id answer two unrelated bills: the careful calls are +many and short, and these are few. A planner that cuts badly spends a whole run on work nobody wanted; a designer that writes badly puts a wrong answer on the menu with a name on it; `routerconfirm` stands between a cheap model's "that should have been work" and a task starting itself, and it is asked on nothing else, so it costs a call only where something was From 32fd04b3d18a5407de4b0d91277f3c6e9179451d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:48:58 -0400 Subject: [PATCH 07/26] prefix gate: the budget is the widest machine's, not the one it was written on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first spelling of the ratchet was 53,100 — the laptop that weighed it said 53,025 — and it failed on the runner that proved it, at 53,132. The prefix is not the same size everywhere: `grep` says a longer sentence about itself where ripgrep is absent, which is 134 bytes on every request of every turn. So the gate weighs what the WIDEST machine pays. bare.WidestGrepDescription is the one place that knows which of the two sentences that is, and widestBelt swaps it in wherever the gate runs, with a law that fails if the tool it names ever leaves the belt — otherwise the budget would quietly get easier. The residual is the shelf: load_capability lists the groups THIS BUILD has, 27 bytes more on a machine that can edit video. That is filed in the file's head rather than fixed here, because naming what a build COULD have is a change to what the model is told. Co-Authored-By: Claude Opus 5 (1M context) --- internal/exec/bare/grepfindls.go | 14 +++++ internal/session/prefixbudget_test.go | 75 +++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/internal/exec/bare/grepfindls.go b/internal/exec/bare/grepfindls.go index ade4ab1bb8..72e5934fe3 100644 --- a/internal/exec/bare/grepfindls.go +++ b/internal/exec/bare/grepfindls.go @@ -77,6 +77,20 @@ func grepToolDescription(caps Caps) string { return grepFallbackDescription(caps) } +// WidestGrepDescription is the longer of the two sentences `grep` can carry — +// the one a machine with NO ripgrep is handed. +// +// A DESCRIPTION THAT DEPENDS ON THE MACHINE IS STILL A BYTE ON EVERY REQUEST, +// and the gate that bounds the fixed prefix (internal/session's +// prefixbudget_test.go) runs on machines of both kinds: the same commit weighed +// 53,025 bytes on a laptop with ripgrep and 53,132 on a runner without it, so +// the gate passed where it was written and failed where it was proved. The gate +// weighs what the WIDEST machine pays, and this is the one place that knows +// which sentence that is. +func WidestGrepDescription(caps Caps) string { + return grepFallbackDescription(caps.resolve()) +} + // grepMatch is one hit, whichever engine found it. type grepMatch struct { filePath string diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 3f170a8304..7a880f20b9 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -43,7 +43,9 @@ import ( "time" configpkg "github.com/Agent-Field/aforge-v2/internal/config" + "github.com/Agent-Field/aforge-v2/internal/exec/bare" "github.com/Agent-Field/aforge-v2/internal/subharness" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) // fixedPrefixBudget bounds the system prompt plus the marshalled tool block of @@ -75,9 +77,28 @@ import ( // prefix fails the build and has to be paid for out of what is already here, // which is what a ratchet is for and what the old figure could not do. // +// ── AND IT IS THE WIDEST MACHINE'S MEASUREMENT, NOT THIS ONE'S ────────────── +// +// The first spelling of this ratchet was 53,100 — the laptop it was written on +// weighing 53,025 — and it failed on the runner that proved it, at 53,132. The +// prefix is not the same size everywhere: `grep` says a longer sentence about +// itself where ripgrep is absent (134 bytes, and [widestBelt] now weighs that +// spelling wherever it runs), and `load_capability` lists the tool groups THIS +// BUILD has, which is 27 more bytes on a machine that can edit video than on one +// that cannot. A gate whose number depends on who runs it is a gate that passes +// where it is written and fails where it is proved, so the figure is the widest +// machine's and the swap that makes the biggest term machine-independent lives +// in [widestBelt]. +// +// The residual is the shelf, and it is owed rather than done: `load_capability` +// would have to name what this build COULD have rather than what it has, which +// is a change to what the model is told and belongs to whoever owns the shelf. +// It is bounded — one short clause per group — and it is why this number was +// measured on the machine that carries every group. +// // ── WHAT IS OWED, AND WHERE IT HAS TO COME FROM ───────────────────────────── // -// THE TARGET IS STILL 48,000 and the shipped prefix is 5,025 over it. The bill +// THE TARGET IS STILL 48,000 and the shipped prefix is 5,159 over it. The bill // is not spread thin — one tool is more than a quarter of the whole tool block: // // stand 9,607 the standing-item verb's schema @@ -86,7 +107,7 @@ import ( // search_conversations 1,613 // watch 1,396 // -// `stand` alone is nearly twice the next heaviest and more than the 5,025 owed. +// `stand` alone is nearly twice the next heaviest and more than the 5,159 owed. // It is not this file's to cut: what a tool's schema says is its contract with // the model, and trimming it is a change to what the model is told rather than // to a byte count. It belongs to whoever owns internal/session's standing belt, @@ -361,10 +382,11 @@ import ( // `THERE IS NO PLANNER ON YOUR BELT` paragraph — and both are pinned by // TestTheBeltRoutesWideWorkToOneWorkerAndNotToAPlanner, so paying it back is a // change to that test's mind and not only to the bytes. -// THE MEASUREMENT, AND NOTHING ON TOP OF IT. 53,025 bytes on 2026-09-12: the -// widest page at 19,114 and the fully-wired belt's tool block at 33,911 over -// twenty-three tools. The seventy-five bytes are rounding and not headroom. -const fixedPrefixBudget = 53_100 +// THE MEASUREMENT, AND NOTHING ON TOP OF IT. 53,159 bytes on 2026-09-12: the +// widest page at 19,114 and the fully-wired belt's tool block at 34,045 over +// twenty-three tools, weighed as the widest machine pays for it ([widestBelt]). +// There is no rounding in it and no headroom on it. +const fixedPrefixBudget = 53_159 // fixedPrefixTarget is where the shipped prefix has to get back to, and it is // the figure [fixedPrefixBudget] was before anybody weighed the right belt. It @@ -485,15 +507,48 @@ func widestPage() string { return page } +// widestBelt is the shipped belt weighed as the WIDEST MACHINE pays for it. +// +// A TOOL WHOSE DESCRIPTION DEPENDS ON WHAT IS INSTALLED still sends those bytes +// on every request, and this gate runs on machines of both kinds: `grep` says +// one sentence where ripgrep is present and a longer one where it is not +// (bare's [bare.WidestGrepDescription]), and the difference measured 107 bytes +// of prefix — enough that the same commit passed on the laptop it was written on +// and failed on the runner that proved it. So the number below is one number +// everywhere, and it is the larger one. +func widestBelt(t *testing.T, agent *Agent) []ai.ToolDefinition { + t.Helper() + definitions := append([]ai.ToolDefinition(nil), agent.beltDefinitions()...) + if len(definitions) == 0 { + t.Fatal("the belt is empty, so this test would pass on nothing") + } + widest := map[string]string{"grep": bare.WidestGrepDescription(agent.resultCaps())} + swapped := 0 + for index := range definitions { + longest, varies := widest[definitions[index].Function.Name] + if !varies { + continue + } + swapped++ + if len(longest) > len(definitions[index].Function.Description) { + definitions[index].Function.Description = longest + } + } + // AND THE SWAP CANNOT SILENTLY STOP APPLYING. A tool that leaves the belt, or + // is renamed, would take its machine-variance off this number without anybody + // noticing the budget had quietly got easier. + if swapped != len(widest) { + t.Fatalf("%d of the %d tools that vary by machine are on the belt", swapped, len(widest)) + } + return definitions +} + // TestTheFixedPrefixStaysUnderItsBudget weighs what every request carries before // anybody has said anything. func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { agent := shippedShapeAgent(t) - definitions := agent.beltDefinitions() - if len(definitions) == 0 { - t.Fatal("the belt is empty, so this test would pass on nothing") - } + definitions := widestBelt(t, agent) block, err := json.Marshal(definitions) if err != nil { t.Fatal(err) From fe36b8b60f071e871ff3e00348f9c661a9e52258 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:50:54 -0400 Subject: [PATCH 08/26] changes: the prefix figure is the widest machine's Co-Authored-By: Claude Opus 5 (1M context) --- docs/changes/unreleased/996-auxiliary-inventory.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changes/unreleased/996-auxiliary-inventory.md b/docs/changes/unreleased/996-auxiliary-inventory.md index 00f60db2ac..6d6f5dc45e 100644 --- a/docs/changes/unreleased/996-auxiliary-inventory.md +++ b/docs/changes/unreleased/996-auxiliary-inventory.md @@ -11,8 +11,9 @@ invalidates: - "`TierHigh` had a built-in tenant in `DefaultAssignment`. It has none — every careful call (`auditor`, `shaper`, `vision`, `careful`, the repair) registers itself from the package that makes it." - "`TierHigh`'s five-minute patience was justified by \"a compaction summary over a full window\". That call does not exist; the figure is derived from the auditor now." - "The settings roles list offered a `compaction` row and the crew's careful line read \"audits, compaction, vision\". Neither does; the line reads \"audits, briefs, vision\"." - - "`fixedPrefixBudget` was 48,000 and weighed `v3ShapedAgent` — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. It weighs the fully-wired conversation (`shippedShapeAgent`, 23 tools) and is 53,100, which is that shape's measured 53,025 and no headroom." - - "The shipped prefix was believed to be inside 48,000. It is 53,025 — 5,025 over — and has been for as long as the shape existed. 48,000 is a target the test prints the shortfall against, not a cap anything passes today." + - "`fixedPrefixBudget` was 48,000 and weighed `v3ShapedAgent` — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. It weighs the fully-wired conversation (`shippedShapeAgent`, 23 tools) and is 53,159, which is that shape's measurement on the widest machine and no headroom." + - "The shipped prefix was believed to be inside 48,000. It is 53,159 — 5,159 over — and has been for as long as the shape existed. 48,000 is a target the test prints the shortfall against, not a cap anything passes today." + - "The fixed prefix was believed to be the same size everywhere. It is not: `grep` says a longer sentence about itself where ripgrep is absent (134 bytes) and `load_capability` lists the tool groups this build has (27). The gate weighs the widest machine — `bare.WidestGrepDescription` is the one place that knows which spelling that is — so the number does not depend on who runs it." --- Three things this build reported about itself incorrectly. None of them changes From ceb57b1ad168e5ef60cb88f8bb63a70f05bb97ff Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:17:42 -0400 Subject: [PATCH 09/26] roles: a role that stops existing is dropped from the profiles that pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting RoleCompaction left the word in every config.json that had pinned it, and the panel re-serialises the whole `models.roles` string on any change — so the next time that person moved ANY OTHER pin the write came back `"compaction" is not a role`, and every role pin on the machine was unchangeable until somebody hand-edited config.json. The deletion had no reading for what it deleted. roles.Retired is that reading: a one-way list of the words this build used to answer to, with the line that says what happened to each. It is the only memory the build has of them, and without it a dead word is indistinguishable from a typo. ParseModelRoles drops those keys and says so once per process; writeModelRoles takes them out of the TEXT, which is what is actually stored, so a person who changes one pin stops re-saving a sentence about nothing. The test is the exact string a pre-deletion profile holds. Co-Authored-By: Claude Opus 5 (1M context) --- internal/config/selfservice_test.go | 50 +++++++++++++++++++++ internal/config/settings.go | 69 ++++++++++++++++++++++++++++- internal/roles/roles.go | 32 +++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/internal/config/selfservice_test.go b/internal/config/selfservice_test.go index 4c3184ab63..583711c0f3 100644 --- a/internal/config/selfservice_test.go +++ b/internal/config/selfservice_test.go @@ -133,6 +133,56 @@ func TestEveryRowSaysWhatItAccepts(t *testing.T) { } } +// A ROLE THIS BUILD RETIRED IS DROPPED FROM A PROFILE THAT STILL PINS IT, and +// the rest of that person's row keeps working. +// +// THE MEASURED FAILURE THIS IS WRITTEN AGAINST. `compaction` was a role for +// months: it had a tier, a description, and a row in the settings panel, and +// nothing ever called it. Deleting it took the row off the panel and left the +// word in every config.json that had pinned it. The panel re-serialises the +// WHOLE `models.roles` string on any change, so the next time that person moved +// ANY OTHER pin the write came back `"compaction" is not a role` — and every +// role pin on that machine was unchangeable until somebody opened config.json by +// hand. A deletion with no reading for what it deleted is a deletion that breaks +// the people who used the thing. +func TestAPinForARetiredRoleIsDroppedAndTheRestOfTheRowStillMoves(t *testing.T) { + profile := t.TempDir() + // The string a profile written before the role was deleted actually holds. + const before = "compaction:openai/gpt-5-mini, title:openai/gpt-5-mini, planner:deepseek/deepseek-v4-pro" + + pins, err := ParseModelRoles(before) + if err != nil { + t.Fatalf("a profile that pins a retired role could not be read at all: %v", err) + } + if _, still := pins["compaction"]; still { + t.Error("the retired role came back as a live pin") + } + for _, want := range []string{"title", "planner"} { + if pins[want] == "" { + t.Errorf("the pin for %q was lost along with the retired one: %v", want, pins) + } + } + + // AND THE NEXT CHANGE TO ANY OTHER PIN GOES THROUGH — the whole failure. + if err := writeModelRoles(profile, before); err != nil { + t.Fatalf("a row carrying a retired role was refused: %v", err) + } + // AND THE DEAD WORD IS NOT WRITTEN BACK, so the next read has nothing to + // forgive and the row stops carrying a sentence about nothing. + stored, ok := persistedString(profile, KeyModelRoles) + if !ok { + t.Fatal("the row was not stored at all") + } + if strings.Contains(stored, "compaction") { + t.Errorf("the retired role was written back: %q", stored) + } + for _, want := range []string{"title:openai/gpt-5-mini", "planner:deepseek/deepseek-v4-pro"} { + if !strings.Contains(stored, want) { + t.Errorf("the row that was kept lost %q: %q", want, stored) + } + } +} + // A ROLE NOBODY RECOGNISES FAILS SILENTLY FOREVER, which is why this row // validates the name and the model slug beside it does not. A pin written // against a misspelled role is stored, reads back exactly as typed, shows in diff --git a/internal/config/settings.go b/internal/config/settings.go index 1b0991a48a..36655d46d2 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "fmt" + "log" "os" "sort" "strconv" "strings" + "sync" "time" "github.com/Agent-Field/aforge-v2/internal/ctxbudget" @@ -3699,8 +3701,66 @@ func ModelRolesAt(profileDir string) string { // ParseModelRoles reads `title:openai/gpt-5-mini` into role → model. The value // is split at the FIRST colon only, because a model slug can carry one of its // own (`…/model:free`). +// +// A PIN FOR A ROLE THIS BUILD RETIRED IS DROPPED, NOT REFUSED (roles.Retired). +// The row is a person's own text, kept as they typed it, so it outlives the +// code that gave a word meaning — and refusing the whole row over one dead word +// would make every OTHER pin on that machine unchangeable, because the panel +// re-serialises the whole string on any change. Dropping is the reading that +// leaves the rest of their sentence working. func ParseModelRoles(raw string) (map[string]string, error) { - return parsePairs(raw, "role") + pins, err := parsePairs(raw, "role") + if err != nil { + return nil, err + } + for name := range pins { + if why, gone := roles.Retired(name); gone { + noteRetiredRole(name, why) + delete(pins, name) + } + } + return pins, nil +} + +// saidRetired is which dead role names this process has already explained, so a +// row read on every settings draw says its line ONCE rather than once a frame. +var saidRetired sync.Map + +func noteRetiredRole(name, why string) { + if _, already := saidRetired.LoadOrStore(strings.ToLower(strings.TrimSpace(name)), struct{}{}); already { + return + } + log.Printf("config: %q is no longer a role, so the model pinned to it is ignored — %s", name, why) +} + +// withoutRetiredRoles is the person's own `models.roles` text with the pins for +// retired roles taken out, and is the text itself when there are none. +// +// IT REBUILDS ONLY WHEN IT HAS TO. The row is stored verbatim so it reads back +// the way it was typed; rewriting it on every save to normalise separators +// would edit a person's sentence for no reason. When a dead word IS in there, +// the rebuild is the price of never writing it out again. +func withoutRetiredRoles(raw string) string { + kept := make([]string, 0, 8) + dropped := false + for _, item := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' + }) { + item = strings.TrimSpace(item) + if item == "" { + continue + } + name, _, _ := strings.Cut(item, ":") + if _, gone := roles.Retired(name); gone { + dropped = true + continue + } + kept = append(kept, item) + } + if !dropped { + return raw + } + return strings.Join(kept, ", ") } // ModelFallbacksAt resolves the fallback chain as the person wrote it. @@ -4033,7 +4093,12 @@ func writeModelRoles(profileDir, raw string) error { } return fmt.Errorf("%q is not a role. The roles are: %s", name, strings.Join(roleNames(), ", ")) } - return writeText(profileDir, KeyModelRoles, raw) + // AND A DEAD WORD IS NEVER WRITTEN BACK. [ParseModelRoles] has already taken + // the retired pins out of the map above, so the refusal cannot see them; this + // takes them out of the TEXT, which is what is actually stored. Without it a + // person who changes any pin keeps re-saving a word this build stopped + // answering to, forever. + return writeText(profileDir, KeyModelRoles, withoutRetiredRoles(raw)) } // roleNames is the registered roles as a sorted list of plain words, for the diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 59ac63d02e..30ea3f1545 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -477,6 +477,38 @@ func Registered() []Role { return roles } +// ── the roles this build used to have ─────────────────────────────────────── + +// retired names every role this build has deliberately stopped having, with the +// one line that says what happened to it. IT ONLY EVER GROWS. +// +// A ROLE THAT STOPS EXISTING IS STILL IN SOMEBODY'S config.json. `compaction` +// was assigned a tier, described in the settings panel and offered as a pin for +// months while nothing ever called it, so profiles carry `compaction:some/model` +// rows that were true when they were typed. Deleting the constant took the row +// off the panel and left the word in the file — and because the panel +// re-serialises the WHOLE `models.roles` string on any pin, the next change to +// ANY OTHER role was refused with `"compaction" is not a role` and every pin on +// that machine became unchangeable until somebody hand-edited config.json. +// +// So a reader DROPS what is retired rather than refusing it, and a writer never +// puts it back (internal/config's ParseModelRoles and writeModelRoles). The +// names stay here long after the code is gone, which is the whole point: this +// map is the only memory the build has of a word it used to answer to, and +// without it the word is indistinguishable from a typo. +var retired = map[string]string{ + "compaction": "a compaction has never asked a model; the role was assigned a tier and called by nothing", +} + +// Retired reports whether `name` is a role this build deliberately stopped +// having, and the line that says what happened to it. The comparison is the +// same one [Registered] readers make — lower-case, trimmed — so a row typed +// `Compaction: x` is recognised as the retired word and not as a new one. +func Retired(name string) (string, bool) { + why, ok := retired[strings.ToLower(strings.TrimSpace(name))] + return why, ok +} + // TierOf reports the tier a role resolves under, and false if the role was // never registered. func TierOf(role Role) (Tier, bool) { From 51e775baae9dba57aac6c4a8236da0392f42b7b8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:18:56 -0400 Subject: [PATCH 10/26] prose: nothing says a compaction asks a model any more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compaction is two mechanical passes over the transcript; it has never asked a model. Six comments and two manual lines still offered it as the example of a billed auxiliary errand — the pocket the title comes out of, the thing the careful tier is for, a non-streamed call like a harness node — which is a sentence about a call that does not exist, and was the figure the careful tier's own patience was derived from until this PR re-derived it. The settings hint for `careful work` no longer promises it either: what that tier actually buys is the check on finished task work, the brief a task is shaped into, and reading an image. The transcript NOTE a compaction writes is still called a summary in agent.go, because that one is real. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge/chatv3_harness.go | 6 +++--- internal/config/settings.go | 10 +++++----- internal/manual/chat/models-and-cost.md | 4 ++-- internal/session/loop.go | 6 +++--- internal/session/memory.go | 4 ++-- internal/session/task_run.go | 4 ++-- internal/session/tools_doc.go | 4 ++-- internal/session/tools_image.go | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cmd/aforge/chatv3_harness.go b/cmd/aforge/chatv3_harness.go index d3bba193cf..6c2afce94d 100644 --- a/cmd/aforge/chatv3_harness.go +++ b/cmd/aforge/chatv3_harness.go @@ -56,9 +56,9 @@ import ( // is handed to the bridge as the default every agent.loop node rides. // harnessTimeout bounds one node's completion. It matches internal/session's -// own provider timeout: a harness node is a non-streamed call like the -// compaction summary is, and the backstop is against a wedged endpoint rather -// than a limit on how long a run may take. +// own provider timeout: a harness node is a non-streamed call like the title +// call is, and the backstop is against a wedged endpoint rather than a limit on +// how long a run may take. const harnessTimeout = 10 * time.Minute // v3HarnessEntries is the registry as DETECTION reads it: one entry per saved diff --git a/internal/config/settings.go b/internal/config/settings.go index 36655d46d2..a891cf20ad 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -193,8 +193,8 @@ const ( // chosen for THINKING rather than for a price. Two roles ride it — the // planner that amends an adaptive run's plan after every node, and the // designer that writes a harness page everybody afterwards runs — and both - // were on the careful-work tier beside the compaction summary, which made one - // figure answer two unrelated bills: the careful calls are many and short, + // were on the careful-work tier beside the check on finished work, which made + // one figure answer two unrelated bills: the careful calls are many and short, // these are few and decide what all the other calls do. // // It is the one row whose value may carry a LEVEL as well as a model @@ -2181,8 +2181,8 @@ func (s *Settings) build() []Setting { }, // THE CREW, AND THEN THE FOUR CLASSES IN IT. The tiers are what a person // actually configures for the calls aforge makes on its own — the name it - // gives a session, the summary a compaction writes, the plan an adaptive - // run steers by (internal/roles). Four rows, not one per feature: a new + // gives a session, the check on work a task says is finished, the plan an + // adaptive run steers by (internal/roles). Four rows, not one per feature: a new // call joins a class and needs no row of its own. // // The crew row comes FIRST because it is the only one most people will @@ -2233,7 +2233,7 @@ func (s *Settings) build() []Setting { Key: KeyTierHighModel, Category: CategoryModels, Kind: SettingText, Label: "careful work", EmptyLabel: "follows the conversation", Hint: "the capable model for the things that must not be wrong — the check on " + - "finished task work, the summary a compaction keeps, reading an image.", + "finished task work, the brief a task is shaped into, reading an image.", read: func() string { return TierModelAt(dir, ModelTierHigh) }, write: func(raw string) error { return writeTierModel(dir, ModelTierHigh, raw) }, }, diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 1276fbac64..494714a4f6 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -472,7 +472,7 @@ only thing that moves it is `/model`, the model row in `/settings`, or naming on The crew is a different dial: the five **classes** aforge makes its own calls on — reflex, small work, worker, careful work, mastermind — used for titles, memory, the safety gate, -the work inside every task, checks on finished work, compaction summaries, adaptive-run +the work inside every task, checks on finished work, the brief a task is shaped into, adaptive-run planners and their nodes, harness pages, and looking at an image. Setting it writes all five class rows in one write, and **it is live from that moment**: the next call aforge makes on its own uses the new crew, with no relaunch and no new session. A task already @@ -633,7 +633,7 @@ for calls you did not type: | 2 | reflex | memory, titles, the safety gate — near-free, reads every turn | | 3 | small work | digests, task names, the safety gate's yes-or-no — cheap | | 4 | worker | every task you hand off, its parts, every run node — most of the bill | -| 5 | careful work | checks on finished work, compaction summaries, vision | +| 5 | careful work | checks on finished work, the brief a task is shaped into, vision | | 6 | mastermind | plans adaptive runs and designs harnesses — thinks | `/crew` shows all six and sets seats two to six in one word — `frugal`, `balanced` or diff --git a/internal/session/loop.go b/internal/session/loop.go index e048b96a02..b4d8aee3e9 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1600,9 +1600,9 @@ func (a *Agent) sealTurn(turn Usage, started time.Time, model string) Usage { // // The reasoning level is stamped HERE, on the request path and nowhere else, so // it reaches every step and every retry of the turn and reaches nothing else: -// the compaction summary and the title call are the session's own errands, not -// the person's question, and a level they asked for their conversation to be -// thought about would be an odd thing to spend on naming it. +// the title call and the memory reflex are the session's own errands, not the +// person's question, and a level they asked for their conversation to be thought +// about would be an odd thing to spend on naming it. // // The stamp is [provider.WithConfiguredReasoningEffort] — the OPERATOR-explicit // setter — because this level is exactly that: a person turned a knob. The other diff --git a/internal/session/memory.go b/internal/session/memory.go index ef83d2a51b..971f17b472 100644 --- a/internal/session/memory.go +++ b/internal/session/memory.go @@ -171,8 +171,8 @@ func (a *Agent) reflexClient() reflex.Completer { // billedCompleter is what makes a reflex call cost something a person can see. // // THE PERSON PAYS FOR IT, SO IT CANNOT BE FREE — the argument -// [Agent.addAuxiliaryUsage] states for the title and the compaction summary, -// and it bites harder here: this is the only auxiliary call made twice EVERY +// [Agent.addAuxiliaryUsage] states for the title call, and it bites harder +// here: this is the only auxiliary call made twice EVERY // turn, so a feature whose whole claim is that it is nearly free is exactly the // one that has to prove it on the bill. It is folded into the SESSION total and // never into a turn's, because no turn asked for it. diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 506ca62ff3..d828bfadd2 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -5264,8 +5264,8 @@ func (a *Agent) workTaskNode(ctx context.Context, node *TaskNode, listed *job) T } // THE NODE'S SPEND IS THE PERSON'S, so it is folded into the session's - // auxiliary usage — the pocket the title and the compaction summary come out - // of — rather than charged to whichever turn happened to propose it. It is + // auxiliary usage — the pocket the title and the memory reflex come out of — + // rather than charged to whichever turn happened to propose it. It is // kept ON THE NODE as well, in the same call, because the node outlives its // child: a surface asking a landed node what it cost has nobody else to ask // (see [TaskNode.spend]). A node that was re-modelled has TWO children, and diff --git a/internal/session/tools_doc.go b/internal/session/tools_doc.go index d62ccd311d..33b54831f7 100644 --- a/internal/session/tools_doc.go +++ b/internal/session/tools_doc.go @@ -349,8 +349,8 @@ func (a *Agent) readDocument(ctx context.Context, path, question string, offset, // Accounted BEFORE the answer is judged, and folded into the SESSION // total rather than the turn's ([Agent.addAuxiliaryUsage]): a rung that // billed for an unusable answer still billed, and no turn of the - // person's ran on that endpoint (the same treatment the title, the - // compaction summary and a generated picture get). + // person's ran on that endpoint (the same treatment the title and a + // generated picture get). if response != nil { a.addAuxiliaryUsage(&ai.Response{Usage: response.Usage}, model, 1) } diff --git a/internal/session/tools_image.go b/internal/session/tools_image.go index 08a3741954..2120e8482e 100644 --- a/internal/session/tools_image.go +++ b/internal/session/tools_image.go @@ -159,8 +159,8 @@ func (a *Agent) generateImageTool(client MediaGenerator, defaultModel string) ba } // The picture is paid for whether or not it is any good, so the // accounting lands before the write can fail. It folds into the - // SESSION total and not the turn's, exactly as the title and the - // compaction summary do: no turn asked for a picture at this price, + // SESSION total and not the turn's, exactly as the title call does: + // no turn asked for a picture at this price, // and charging one turn for it would make an ordinary question read // as the cost of a rendering (see [Agent.addAuxiliaryUsage]). a.addAuxiliaryUsage(&ai.Response{Usage: response.Usage}, model, 1) From 03091169e6d62f73378158322b0aba0025e15761 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:21:03 -0400 Subject: [PATCH 11/26] roles: a role's word lives in internal/roles, and a law says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const spellOutRole roles.Role = "spellout"` lived in internal/session. It registered, resolved and tagged its call correctly — and reached the cost report as a word nobody had written down, because roleWords parses internal/roles and nothing else. roleOf prices an unrecognised tag as a background errand with nobody waiting, so the ONE auxiliary a person sits and watches was the one counted as if it did not matter. Nothing failed; a number was quietly wrong. The word moves to roles.RoleSpellOut beside its siblings, the Register call stays at the call site, and TestEveryRoleWordIsDeclaredWhereTheVocabularyIs fails the next one. And the replay walker stops enumerating receiver spellings. callName flattened one selector level, so three of the six rows in that switch — `p.agent.completeWithModel` and friends — rendered as "" and could never match: the ladder looked complete and was half dead. methodName asks the only question the law has, which is the method's own name. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge-replay/replay_test.go | 81 ++++++++++++++++++++++++++------ internal/roles/roles.go | 21 +++++++++ internal/session/spellout.go | 17 +++---- 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/cmd/aforge-replay/replay_test.go b/cmd/aforge-replay/replay_test.go index ef230225c2..97808422d2 100644 --- a/cmd/aforge-replay/replay_test.go +++ b/cmd/aforge-replay/replay_test.go @@ -309,8 +309,8 @@ func tagsTheBuildWrites(t *testing.T) map[string]string { if !isCall { return true } - switch callName(call.Fun) { - case "WithCallTag", "provider.WithCallTag": + switch methodName(call.Fun) { + case "WithCallTag": if len(call.Args) < 2 { return true } @@ -326,10 +326,7 @@ func tagsTheBuildWrites(t *testing.T) map[string]string { return true } derived(func(tag, where string) { found[tag] = where }) - case "completeWithModel", "completeWithNamedModel", - "a.completeWithModel", "a.completeWithNamedModel", - "p.agent.completeWithModel", "e.agent.completeWithModel", - "c.agent.completeWithModel", "child.completeWithModel": + case "completeWithModel", "completeWithNamedModel": // THE DOOR IS WHERE internal/session'S TAGS ARE STATED NOW. Its // second argument is a [session.callPurpose], and it reaches // `calllog.Record.Tag` verbatim (clientdoor.go). Reading it here is @@ -343,7 +340,7 @@ func tagsTheBuildWrites(t *testing.T) map[string]string { // constant are the same statement, so the conversion comes off // before the argument is read. if conversion, isCall := purpose.(*ast.CallExpr); isCall && - callName(conversion.Fun) == "callPurpose" && len(conversion.Args) == 1 { + methodName(conversion.Fun) == "callPurpose" && len(conversion.Args) == 1 { if _, literal := stringLiteral(conversion.Args[0]); !literal { // A ROLE WORN AS A PURPOSE IS STILL A ROLE, and the // vocabulary it can produce is internal/roles' own. It is @@ -401,6 +398,52 @@ func tagsTheBuildWrites(t *testing.T) map[string]string { return found } +// TestEveryRoleWordIsDeclaredWhereTheVocabularyIs fails a role named anywhere but +// internal/roles. +// +// [roleWords] parses ONE FILE — internal/roles/roles.go — because that is where +// this build says the role vocabulary lives. A role constant declared at its own +// call site is therefore invisible to it, and to every other reader that asks +// this package what the roles ARE. +// +// THE MEASURED FAILURE. `const spellOutRole roles.Role = "spellout"` lived in +// internal/session/spellout.go. It registered correctly, resolved correctly and +// tagged its call correctly — and then reached the cost report as a word nobody +// had written down, which [roleOf] prices as a background errand with nobody +// waiting. The spell-out is the one auxiliary a person sits and watches, so the +// single call whose latency matters most was the one counted as if it did not. +// Nothing failed; a number was quietly wrong. +func TestEveryRoleWordIsDeclaredWhereTheVocabularyIs(t *testing.T) { + const home = "internal/roles/roles.go" + walkBuildSources(t, func(path string, file *ast.File) { + if path == home { + return + } + ast.Inspect(file, func(node ast.Node) bool { + spec, isValue := node.(*ast.ValueSpec) + if !isValue || spec.Type == nil { + return true + } + named, isSelector := spec.Type.(*ast.SelectorExpr) + if !isSelector || named.Sel.Name != "Role" { + return true + } + if pkg, isIdent := named.X.(*ast.Ident); !isIdent || pkg.Name != "roles" { + return true + } + for _, name := range spec.Names { + t.Errorf("%s declares the role %s. Every role's WORD belongs in %s, "+ + "beside the others: a role declared at its call site resolves and tags "+ + "correctly and is still invisible to everything that reads the "+ + "vocabulary, which prices its calls as errands nobody is waiting on. "+ + "Declare it there and keep the roles.Register call here.", + path, name.Name, home) + } + return true + }) + }) +} + // roleWords reads internal/roles for the role vocabulary itself, because that // package's constants ARE tags the moment auxiliary.go writes one. func roleWords() []string { @@ -461,17 +504,27 @@ func walkBuildSources(t *testing.T, visit func(path string, file *ast.File)) { } } -// callName renders a function expression as the source spells it, so that both -// `WithCallTag` and `provider.WithCallTag` are recognisable without resolving -// imports. -func callName(fun ast.Expr) string { +// methodName is the LAST WORD of a call expression — the function or method +// being called, whatever it was reached through. +// +// THE RECEIVER IS NOT PART OF THE QUESTION THIS LAW ASKS. "Does this call go +// through the door" has the same answer for `completeWithModel`, +// `a.completeWithModel`, `p.agent.completeWithModel` and +// `agentFor(x).completeWithModel`, and for `WithCallTag` whether or not the +// package is spelled in front of it. +// +// It used to be a list of the spellings somebody had seen, rendered by a reader +// that flattened ONE selector level — so `p.agent.completeWithModel` and +// `e.agent.completeWithModel` rendered as "" and three of the six rows in that +// list could never match anything. The ladder looked complete and was half +// dead, which is what a ladder of names always eventually is, and the only +// symptom would have been a tag this law quietly stopped knowing about. +func methodName(fun ast.Expr) string { switch shape := fun.(type) { case *ast.Ident: return shape.Name case *ast.SelectorExpr: - if pkg, isIdent := shape.X.(*ast.Ident); isIdent { - return pkg.Name + "." + shape.Sel.Name - } + return shape.Sel.Name } return "" } diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 30ea3f1545..a8b1caab15 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -212,6 +212,27 @@ const ( // internal/session/subharness_intake.go, which owns the call. RoleIntake Role = "intake" + // RoleSpellOut expands a half-written request into what it obviously meant, + // for the person to read and keep or drop. + // + // IT SITS LOW, with the namer and the sentinel rather than with the shaper. + // The shaper writes the only document an autonomous worker will ever read, so + // a vague answer there costs a whole task's spend; this one writes three lines + // a person reads on screen before deciding whether to keep them, and a weak + // answer costs one esc. A person who wants it thought about harder pins it + // (`roles.spellout: `). Registered from internal/session/spellout.go, + // which owns the call. + // + // THE WORD LIVES HERE AND NOT AT THE CALL SITE, which is the whole reason + // this constant moved. It was `const spellOutRole roles.Role = "spellout"` in + // internal/session, and every reader that asks this package what the role + // vocabulary IS — cmd/aforge-replay's roleWords, which parses this file — could + // not see it. So `spellout` reached the cost report as an unrecognised tag and + // was priced as a background errand with nobody waiting, when it is the one + // auxiliary a person sits and watches. A role declared anywhere else is a role + // that is invisible to everything that reads roles. + RoleSpellOut Role = "spellout" + // RoleDivision reviews a DIVISION as a plan. A worker halfway through its // own work has named the parts it wants to hand out, and this is the one // call that reads them TOGETHER — the evidence, the parent's own brief, and diff --git a/internal/session/spellout.go b/internal/session/spellout.go index bd76dcdcf1..bf38c5c5fc 100644 --- a/internal/session/spellout.go +++ b/internal/session/spellout.go @@ -50,17 +50,12 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" ) -// spellOutRole is the expansion, registered as a ROLE so it resolves the way -// every other auxiliary call in this build resolves: the person's pin, then the -// tier, then the conversation's own model (internal/roles). -// -// IT SITS LOW, with the namer and the sentinel rather than with the shaper. The -// shaper writes the only document an autonomous worker will ever read, so a -// vague answer there costs a whole task's spend; this one writes three lines a -// person reads on screen before deciding whether to keep them, and a weak answer -// costs one esc. A person who wants it thought about harder pins it -// (`roles.spellout: `). -const spellOutRole roles.Role = "spellout" +// spellOutRole is the expansion, registered here and DECLARED IN internal/roles +// beside every other role's word ([roles.RoleSpellOut]), so it resolves the way +// every other auxiliary call in this build resolves — the person's pin, then the +// tier, then the conversation's own model — and so that everything which reads +// the role vocabulary can see it. +const spellOutRole = roles.RoleSpellOut func init() { roles.Register(spellOutRole, roles.TierLow, "what a half-written request obviously means") From 86c62caeddfb456e22d1197051fc387d2fbd1776 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:23:50 -0400 Subject: [PATCH 12/26] prefix gate: both arms weigh the shipped shape, at the widest machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE LEAN ARM HAD THE SAME HOLE THE FULL ARM WAS REPAIRED FOR. leanShapedAgent built its own thin config — no memory store, no accounts hub, no standing items, no saved programs — so the arm that bounds the prefix for the person with the LEAST room weighed a belt without `stand` (9.6 KB on its own), `remember` or `search_conversations`, none of which leanCapabilityGroups shelves. It is the shipped shape on a small window now, which is what its own comment always claimed. And three ways the number could go back to depending on who ran it: beltShapes[0] read the fullest shape only until somebody put a row in front of it. beltShapeNamed asks for it by name. WidestGrepDescription returned the fallback sentence because that one is longer today; it now measures both and returns the longer, and widestBelt ASSIGNS rather than comparing — a swap that only fires when it makes the number bigger is a swap that stops firing silently. load_capability names the groups this BUILD has, and `edit_video` exists only where ffmpeg is on PATH — 27 bytes of prefix that depend on a program nobody installed on purpose. loadCapabilityDescription takes the members as a question now, so the gate can ask for the widest answer instead of this machine's. Both budget figures are re-measured in the next commit, on the Spark. Co-Authored-By: Claude Opus 5 (1M context) --- internal/exec/bare/grepfindls.go | 12 ++++- internal/session/belt_wiring_test.go | 27 +++++++++- internal/session/prefixbudget_test.go | 74 +++++++++++++++++--------- internal/session/tools_capabilities.go | 18 +++++-- 4 files changed, 100 insertions(+), 31 deletions(-) diff --git a/internal/exec/bare/grepfindls.go b/internal/exec/bare/grepfindls.go index 72e5934fe3..2ce8a9543a 100644 --- a/internal/exec/bare/grepfindls.go +++ b/internal/exec/bare/grepfindls.go @@ -88,7 +88,17 @@ func grepToolDescription(caps Caps) string { // weighs what the WIDEST machine pays, and this is the one place that knows // which sentence that is. func WidestGrepDescription(caps Caps) string { - return grepFallbackDescription(caps.resolve()) + caps = caps.resolve() + // THE LONGER OF THE TWO, MEASURED, and not the one that happens to be longer + // today. The fallback sentence is the longer one now; a later edit that grew + // the ripgrep sentence past it would have made a function called "widest" + // quietly return the narrower string, and the gate downstream would have gone + // back to depending on who ran it with nothing to say so. + with, without := grepDescription(caps), grepFallbackDescription(caps) + if len(with) > len(without) { + return with + } + return without } // grepMatch is one hit, whichever engine found it. diff --git a/internal/session/belt_wiring_test.go b/internal/session/belt_wiring_test.go index 03557108c8..0788c7f76b 100644 --- a/internal/session/belt_wiring_test.go +++ b/internal/session/belt_wiring_test.go @@ -60,9 +60,34 @@ func v3ShapedAgent(t *testing.T) *Agent { // 39,073 bytes while the shipped shape weighed 53,025 — over the cap the gate // was enforcing, invisibly, which is #576's shape exactly (a gate that passes // because it is pointed at something nobody runs). +// shippedBeltShape names the row of [beltShapes] that IS the shipping +// conversation, fully wired. +// +// THE INDEX IS NOT THE IDENTITY. `beltShapes[0]` read the fullest shape only for +// as long as nobody put a row in front of it — and what reads it is a budget +// whose whole job is to be the number for the SHIPPED belt, which would have +// gone on passing, quietly, against whatever shape had moved into slot zero. +// That is the same failure this fixture exists to repair, one layer down. +const shippedBeltShape = "a conversation that remembers" + +// beltShapeNamed is one row of [beltShapes] by its own name, and fails loudly +// rather than returning a zero shape that would build an agent out of nothing. +func beltShapeNamed(t *testing.T, name string) beltShape { + t.Helper() + names := make([]string, 0, len(beltShapes)) + for _, shape := range beltShapes { + if shape.name == name { + return shape + } + names = append(names, shape.name) + } + t.Fatalf("there is no belt shape called %q; the shapes are %q", name, names) + return beltShape{} +} + func shippedShapeAgent(t *testing.T) *Agent { t.Helper() - shape := beltShapes[0] + shape := beltShapeNamed(t, shippedBeltShape) agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { // The rendered page is what the budget weighs, so this shape renders its // own rather than taking newTestAgent's fixed one. diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 7a880f20b9..5636168875 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -34,7 +34,6 @@ package session // is. import ( - "context" "encoding/json" "fmt" "sort" @@ -42,9 +41,7 @@ import ( "testing" "time" - configpkg "github.com/Agent-Field/aforge-v2/internal/config" "github.com/Agent-Field/aforge-v2/internal/exec/bare" - "github.com/Agent-Field/aforge-v2/internal/subharness" "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -522,7 +519,15 @@ func widestBelt(t *testing.T, agent *Agent) []ai.ToolDefinition { if len(definitions) == 0 { t.Fatal("the belt is empty, so this test would pass on nothing") } - widest := map[string]string{"grep": bare.WidestGrepDescription(agent.resultCaps())} + widest := map[string]string{ + // `grep` says a longer sentence about itself where ripgrep is absent. + "grep": bare.WidestGrepDescription(agent.resultCaps()), + // `load_capability` NAMES THE GROUPS THIS BUILD HAS, and a group whose + // every member was gated off is not named at all — `edit_video` is built + // only where ffmpeg is on PATH, so a machine that can edit video pays for + // one more clause than one that cannot (tools_capabilities.go). + "load_capability": widestLoadCapability(), + } swapped := 0 for index := range definitions { longest, varies := widest[definitions[index].Function.Name] @@ -530,9 +535,11 @@ func widestBelt(t *testing.T, agent *Agent) []ai.ToolDefinition { continue } swapped++ - if len(longest) > len(definitions[index].Function.Description) { - definitions[index].Function.Description = longest - } + // ASSIGNED, NOT COMPARED. A swap that only fired when it made the number + // bigger would be a swap that silently stopped firing the day the other + // spelling grew — which is the same defeat, one level up. `widest` is + // widest by construction and this takes it at its word. + definitions[index].Function.Description = longest } // AND THE SWAP CANNOT SILENTLY STOP APPLYING. A tool that leaves the belt, or // is renamed, would take its machine-variance off this number without anybody @@ -543,6 +550,23 @@ func widestBelt(t *testing.T, agent *Agent) []ai.ToolDefinition { return definitions } +// widestLoadCapability is the loader's sentence with EVERY group the table +// declares and every member of each, which is what it says on a machine that has +// all the programs its tools shell out to. +// +// It is built through the product's own [loadCapabilityDescription], so the +// three sentences after the group list are stated once and cannot drift out of +// this number. +func widestLoadCapability() string { + order := make([]string, 0, len(capabilityGroups)) + members := make(map[string][]string, len(capabilityGroups)) + for _, group := range capabilityGroups { + order = append(order, group.name) + members[group.name] = group.members + } + return loadCapabilityDescription(func(group string) []string { return members[group] }, order) +} + // TestTheFixedPrefixStaysUnderItsBudget weighs what every request carries before // anybody has said anything. func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { @@ -602,24 +626,29 @@ func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { // ── the lean arm ──────────────────────────────────────────────────────────── -// leanShapedAgent is the shipping conversation door on a small window: the same -// config [v3ShapedAgent] builds, with the window a local open-weight model -// actually has. Everything else about the shape is deliberately identical, so -// the difference between the two numbers below is the profile and nothing else. +// leanShapedAgent is the SHIPPING CONVERSATION on a small window: the shape +// [shippedShapeAgent] builds, with the one thing that differs — the window a +// local open-weight model actually has — changed and nothing else. The +// difference between the two numbers below is therefore the profile, and only +// the profile. +// +// ── IT WEIGHED A THINNER AGENT THAN ANYBODY RUNS, AND THIS IS THAT REPAIR ─── +// +// It used to build its own config: no memory store, no accounts hub, no standing +// items, no saved programs. That is #576's shape again and the same one the full +// arm was repaired for in this PR — a gate pointed at something nobody runs +// passes without having tested anything. A real lean conversation carries +// `stand` (9.6 KB on its own), `remember` and `search_conversations`, and +// [Config.leanCapabilityGroups] shelves none of the three, so every one of those +// bytes is bought on every request of every turn by exactly the person whose +// window has the least room for them. func leanShapedAgent(t *testing.T) *Agent { t.Helper() + shape := beltShapeNamed(t, shippedBeltShape) agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.System = "" + shape.build(t, config) config.ContextWindow = leanWindow - config.AskConsent = true - config.BashBackgroundAfterSeconds = configpkg.DefaultBashBackgroundAfter - config.HarnessStore = subharness.At(t.TempDir()) - config.RunHarness = func(context.Context, string, string, string, func(subharness.Trail)) (string, subharness.Usage, error) { - return "", subharness.Usage{}, nil - } - config.OrchestrateRunner = func(context.Context, string, string, float64) (string, error) { - return "", nil - } }) return agent } @@ -638,10 +667,7 @@ func TestTheLeanPrefixStaysUnderItsBudget(t *testing.T) { t.Fatalf("a %d-token window did not resolve to the lean profile, so this test is weighing the wrong arm", leanWindow) } - definitions := agent.beltDefinitions() - if len(definitions) == 0 { - t.Fatal("the belt is empty, so this test would pass on nothing") - } + definitions := widestBelt(t, agent) block, err := json.Marshal(definitions) if err != nil { t.Fatal(err) diff --git a/internal/session/tools_capabilities.go b/internal/session/tools_capabilities.go index 5e738cc564..02bc618af2 100644 --- a/internal/session/tools_capabilities.go +++ b/internal/session/tools_capabilities.go @@ -267,9 +267,10 @@ func (a *Agent) shelvedTools() []bare.Tool { // meets an unfamiliar name in its own history find the group it is in. func (a *Agent) loadCapabilityTool(shelf map[string][]bare.Tool, order []string) bare.Tool { return bare.Tool{ - Name: loadCapabilityToolName, - Description: loadCapabilityDescription(shelf, order), - Schema: json.RawMessage(loadCapabilitySchema(order)), + Name: loadCapabilityToolName, + Description: loadCapabilityDescription( + func(group string) []string { return toolNames(shelf[group]) }, order), + Schema: json.RawMessage(loadCapabilitySchema(order)), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { var parsed struct { Group string `json:"group"` @@ -287,10 +288,17 @@ func (a *Agent) loadCapabilityTool(shelf map[string][]bare.Tool, order []string) // sentence in it is a law the model cannot get elsewhere: what the verb does, // WHEN the tools become usable, how long they last, and that it is not // permission. -func loadCapabilityDescription(shelf map[string][]bare.Tool, order []string) string { +// IT TAKES THE MEMBERS AS A QUESTION rather than the shelf itself, because what +// is on the shelf depends on the MACHINE — `edit_video` is built only where +// ffmpeg is on PATH, and a group whose every member was gated off is not named +// at all — while the bytes this sentence costs are spent on every request +// everywhere. The one gate that bounds the fixed prefix has to be able to ask +// for the widest answer rather than this machine's (prefixbudget_test.go's +// widestBelt), and a function is the seam that lets it. +func loadCapabilityDescription(members func(group string) []string, order []string) string { var lines strings.Builder for _, name := range order { - lines.WriteString(" " + name + ": " + strings.Join(toolNames(shelf[name]), ", ") + ".") + lines.WriteString(" " + name + ": " + strings.Join(members(name), ", ") + ".") } return "Load one additional tool group. Available groups:" + lines.String() + " Full schemas arrive on the next model request; continue in this same turn. " + From 282e93b66abb7cea001732693d98cb721c2772f9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:27:53 -0400 Subject: [PATCH 13/26] prefix: both caps are the measurement, and the three own-client roads say what they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE CAPS. Full 53,141 and lean 44,989, both weighed on the Spark against the shipped shape at the widest machine, both with no headroom, both printing the shortfall against the target they owe on every green run. PERF.md moves in this commit, which is the rule for a cap. THE ROADS. memory_consolidate.go, standing_run.go and tools_doc.go each build a provider client the one door does not make — a longer timeout, static routing, a document config — and complete on it directly. That is a real difference, so the answer is not to force them through the door; it is to make them say the word the door says. clientdoor.go's withPurpose is that, and it is still the only file in internal/session that spells provider.WithCallTag. The two errands this build runs most often with nobody there reached the call log with NO TAG AT ALL, which made them indistinguishable from a turn that had lost its name. The standing check is `standing-check` and not `sentinel`, because cmd/aforge already writes that word for the resident's quorum errand and two calls under one tag is a reading of neither. The law now pairs the halves: a file that builds its own client owes a withPurpose, and it fails if it finds fewer than three such files — a structural law that matched nothing would pass for ever. And `compaction` comes out of the replay role table: no build can write it. Co-Authored-By: Claude Opus 5 (1M context) --- PERF.md | 38 ++++++++++++++++++++ cmd/aforge-replay/request.go | 20 ++++++++++- internal/session/clientdoor.go | 45 ++++++++++++++++++++++++ internal/session/memory_consolidate.go | 8 ++++- internal/session/nohiddenwork_test.go | 31 +++++++++++++++++ internal/session/prefixbudget_test.go | 48 +++++++++++++++++++++++--- internal/session/standing_run.go | 9 ++++- internal/session/tools_doc.go | 7 +++- 8 files changed, 198 insertions(+), 8 deletions(-) diff --git a/PERF.md b/PERF.md index 4f55162ef3..d5f8eaf40a 100644 --- a/PERF.md +++ b/PERF.md @@ -1283,6 +1283,44 @@ a rule in two places is the cheapest thing in a budget to spend twice. `wide`, `model` is unchanged. Every em dash came out of every description string, small models tokenising them badly. The tool block went **23,369 → 21,808**. +**Both caps were weighing a conversation nobody has, and are re-measured +(2026-09-12, #996).** Every figure above is real and every one of them was +measured against a fixture with no memory store, no accounts hub, no standing +items and no saved programs — eighteen tools on the full arm, sixteen on the +lean one. A machine somebody has finished setting up carries five more (`stand`, +`search_conversations`, `remember` and the subharness pair), and the shipped +prefix has been **over the cap it was enforcing** for as long as that shape has +existed. A gate pointed at something nobody runs passes without having tested +anything, which is #576's shape. + +| | weighed | cap | target owed | +| --- | --- | --- | --- | +| full (`fixedPrefixBudget`) | **53,141** | 53,141 | 48,000, **5,141** over | +| lean (`leanPrefixBudget`) | **44,989** | 44,989 | 31,500, **13,489** over | + +Both caps are now the measurement with **no headroom**, because a number that +has never been honestly weighed has not earned any, and both arms build the +shipped shape by name rather than by index. `fixedPrefixTarget` and +`leanPrefixTarget` hold the figures each has to come back to, and each test +prints its shortfall on **every green run** — a ratchet passing is not the same +fact as the number being what it should be. + +The bill is not spread thin: `stand` is **9,607 bytes**, more than a quarter of +the full tool block and nearly twice the next heaviest, and on a +sixteen-thousand-token window it is roughly one token in six of everything that +person has before they have said anything. Trimming it is a change to what the +model is told, so it is owed by whoever owns the standing belt and not by this +gate. + +**And the prefix is not the same size on every machine, which is why both +figures are the widest machine's.** `grep` says a longer sentence about itself +where ripgrep is absent (**134 bytes**) and `load_capability` names the tool +groups this build has, so `edit_video` — built only where ffmpeg is on PATH — +costs another **27**. The first spelling of the full cap was 53,100, measured at +53,025 on the laptop that wrote it, and it failed CI at 53,132. `widestBelt` +weighs both varying sentences at their widest wherever it runs, so the cap no +longer depends on who runs it. + ## Following through on a completion claim A turn may decline handoff once per request when its own continuation says no diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index cb232ec855..a4682de031 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -267,6 +267,25 @@ var callSiteRoles = map[string]lane.Role{ // runs on the program's own model rather than on any role's. Nobody is // reading its stream and nobody is waiting on its first word. "subharness": lane.RoleAuxiliary, + // ── two of the three roads that carry their own provider client ───────── + // + // Each builds a client the one door does not make and completes on it + // directly (internal/session/clientdoor.go's withPurpose), and all three + // reached this file with NO TAG AT ALL until #996 — so the errand this build + // runs most often with nobody there was priced as anonymous. The third, + // `consolidate`, already had a row below because internal/roles has that + // word: the tidy-up and the role are the same errand and the same reading. + // + // internal/session/standing_run.go: one standing item's yes-or-no, on every + // check of every item forever. It is spelled `standing-check` and NOT + // `sentinel`, which is already the resident's own quorum errand above and is + // judged: this one sets lane.RoleStanding on its own context, and pricing it + // as a judge would put a wait nobody is having into the table. + "standing-check": lane.RoleStanding, + // internal/session/tools_doc.go: the model's own eyes on a document the + // `read` tool cannot open as text. A TOOL CALL INSIDE A TURN, so somebody IS + // waiting — the one of the three whose seconds are a person's. + "document": lane.RoleTalk, // The same function, in a task. WHICH LEAF ROLE IT IS CANNOT BE READ FROM // THE ROW — laneRole asks `someoneIsWatching()` at the moment of the call // and nothing records the answer — so the unattended reading is taken, @@ -314,7 +333,6 @@ var callSiteRoles = map[string]lane.Role{ "designer": lane.RoleDesign, "division": lane.RoleDesign, "title": lane.RoleAuxiliary, - "compaction": lane.RoleAuxiliary, "vision": lane.RoleAuxiliary, "imagegen": lane.RoleAuxiliary, "worker": lane.RoleAuxiliary, diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 8061052856..d8575222c8 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -466,6 +466,34 @@ const ( // digest when this one cannot be had — and naming them alike would put the // dearest call on the road and its cheap understudy on the same row. purposeHandoffDraft callPurpose = "handoff-draft" + + // ── the three roads that carry their own client ───────────────────────── + // + // These do not come through [Agent.completeWithNamedModel] at all: each + // builds a [provider.Client] of a shape the door does not make — a longer + // timeout, static routing pinned to the parent's choice, a document config + // with its own key rule — and completes on it directly. That is a real + // difference and not an oversight, so the answer is not to force them + // through the door; it is to make them say the same word the door says. + // + // THEY REACHED THE CALL LOG WITH NO TAG AT ALL UNTIL THIS PR, which meant the + // two slowest unattended errands this build makes — a memory tidy-up and a + // standing item's check, both of which run while nobody is there — were + // indistinguishable from a turn that had lost its name. They carry the most + // money per call of anything nobody is waiting for. + + // purposeConsolidate is the memory reflex's slow half: a batch of remembered + // things read together, hours after any of them was written. + purposeConsolidate callPurpose = "consolidate" + // purposeSentinel is one standing item's yes-or-no on evidence somebody else + // already gathered, run on every check of every item forever. + // The word is `standing-check` and not `sentinel`, which cmd/aforge already + // writes for the resident's quorum errand: two different calls under one tag + // is one reading of neither. + purposeSentinel callPurpose = "standing-check" + // purposeDocument is a rung of the document reader — the model's own eyes on + // a PDF the `read` tool cannot open as text. + purposeDocument callPurpose = "document" ) // completeWithModel is [Agent.completerFor] joined to the one wire-model @@ -499,6 +527,23 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, return response, called, err } +// withPurpose stamps a purpose on a context for a road that completes on a +// client of its OWN, and it is the only thing in this package besides +// [Agent.completeWithNamedModel] that may. +// +// THE DOOR IS STILL THE ONLY PLACE THAT SPELLS THE TAG. `provider.WithCallTag` +// appears twice in this file and nowhere else in internal/session — a law says +// so (nohiddenwork_test.go) — and both spellings take a [callPurpose], which is +// a closed vocabulary a reader can enumerate. What the three own-client roads +// needed was not permission to write a tag; it was a door of their own that is +// the same door. +func withPurpose(ctx context.Context, purpose callPurpose) context.Context { + if purpose == "" { + return ctx + } + return provider.WithCallTag(ctx, string(purpose)) +} + type unavailableCompleter struct{ err error } func (u unavailableCompleter) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) { diff --git a/internal/session/memory_consolidate.go b/internal/session/memory_consolidate.go index 3ac32a39c9..3e864ffe14 100644 --- a/internal/session/memory_consolidate.go +++ b/internal/session/memory_consolidate.go @@ -352,8 +352,14 @@ func consolidateAsk(ctx context.Context, client Completer, model string, batch [ // a consolidation is the memory reflex's slow half, unattended and // judged on nothing but whether its answer is usable (internal/lane's // roles.go). + // And the purpose, which is what the call log will have to answer with + // when somebody asks what the machine spent the night on. This road + // builds its own client and never passes the door, so it says the word + // the door would have said (clientdoor.go's [withPurpose]). provider.WithRole( - provider.WithRoutingIntent(provider.WithoutStream(ctx), provider.IntentBackground), + provider.WithRoutingIntent( + provider.WithoutStream(withPurpose(ctx, purposeConsolidate)), + provider.IntentBackground), lane.RoleMemory), []ai.Message{ textMessage("system", consolidatePrompt), diff --git a/internal/session/nohiddenwork_test.go b/internal/session/nohiddenwork_test.go index 1a322e21d9..bae3b9fd19 100644 --- a/internal/session/nohiddenwork_test.go +++ b/internal/session/nohiddenwork_test.go @@ -350,6 +350,14 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { theDoors := map[string]bool{"completeWithModel": true, "completeWithNamedModel": true} const theWrapper = "modelRoutingCompleter" purposes := 0 + // AND THE OTHER HALF OF THE SAME LAW. Three roads in this package build a + // [provider.Client] of their own — the memory tidy-up, a standing item's + // sentinel, the document reader — because each needs a client shape the door + // does not make. They are allowed to; what they are not allowed to do is + // reach the wire anonymously, which all three did until #996. So a file that + // builds its own client owes a [withPurpose], and this pairs the two. + ownClients := map[string]bool{} + stamped := map[string]bool{} for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { @@ -370,10 +378,20 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { if !ok { return true } + // withPurpose is a plain function and not a method, so it is read + // before the selector shape below can rule it out. + if named, isIdent := call.Fun.(*ast.Ident); isIdent && named.Name == "withPurpose" { + stamped[name] = true + return true + } selector, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true } + if selector.Sel.Name == "NewClient" && name != "clientdoor.go" { + ownClients[name] = true + return true + } if selector.Sel.Name == "WithCallTag" && name != "clientdoor.go" { t.Errorf("%s:%d calls provider.WithCallTag. The tag is spelled by the one door "+ "and nowhere else — pass a callPurpose to completeWithModel instead "+ @@ -393,11 +411,24 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { return true }) } + for file := range ownClients { + if stamped[file] { + continue + } + t.Errorf("%s builds a provider client of its own and never says what its calls are "+ + "FOR. A road that needs a client the door does not make is allowed one; a road "+ + "that reaches the wire anonymously is not, because the call log then cannot say "+ + "what this build spent its night on. Wrap the context in withPurpose "+ + "(clientdoor.go)", file) + } // AND THE LAW IS READING THE TREE IT THINKS IT IS. A walk that matched // nothing would pass for ever, which is how a structural law rots. if purposes < 8 { t.Fatalf("only %d calls through the one door were found; the law is reading the wrong tree", purposes) } + if len(ownClients) < 3 { + t.Fatalf("only %d files building their own client were found; the law is reading the wrong tree", len(ownClients)) + } } // blankPurpose reports an argument that names no purpose: the empty literal, or diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 5636168875..c61286366f 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -379,11 +379,11 @@ import ( // `THERE IS NO PLANNER ON YOUR BELT` paragraph — and both are pinned by // TestTheBeltRoutesWideWorkToOneWorkerAndNotToAPlanner, so paying it back is a // change to that test's mind and not only to the bytes. -// THE MEASUREMENT, AND NOTHING ON TOP OF IT. 53,159 bytes on 2026-09-12: the -// widest page at 19,114 and the fully-wired belt's tool block at 34,045 over +// THE MEASUREMENT, AND NOTHING ON TOP OF IT. 53,141 bytes on 2026-09-12: the +// widest page at 19,114 and the fully-wired belt's tool block at 34,027 over // twenty-three tools, weighed as the widest machine pays for it ([widestBelt]). // There is no rounding in it and no headroom on it. -const fixedPrefixBudget = 53_159 +const fixedPrefixBudget = 53_141 // fixedPrefixTarget is where the shipped prefix has to get back to, and it is // the figure [fixedPrefixBudget] was before anybody weighed the right belt. It @@ -462,7 +462,40 @@ const fixedPrefixTarget = 48_000 // THEREAFTER IT ONLY EVER RATCHETS DOWN, in the ledger discipline the full // budget above is kept under: a lane that takes bytes out lowers it in the same // commit, and nothing ever raises it again. -const leanPrefixBudget = 31_500 +// +// ── AND THEN IT TURNED OUT TO BE WEIGHING A CONVERSATION NOBODY HAS ───────── +// +// Every figure above is real and every one of them was measured against +// [leanShapedAgent], which built a conversation with NO memory store, NO +// accounts hub, NO standing items and NO saved programs — sixteen tools. A +// person on a small window who has finished setting aforge up carries `stand` +// (9,607 bytes by itself), `remember` and `search_conversations`, none of which +// [Config.leanCapabilityGroups] shelves, and their prefix is 44,989 bytes. +// +// So the arm that exists to protect the person with the LEAST room to spare was +// out by 13,489 bytes, which is more than the whole budget it was enforcing. +// That is #576's shape and it is the same hole the full arm above had, found in +// the same review: a gate pointed at something nobody runs passes without having +// tested anything, every day, in both directions. +// +// THE FIGURE IS THE MEASUREMENT NOW, with no headroom — the argument for slack +// was written when this number came off a diet that had just been paid for, and +// a number that has never been honestly weighed has not earned any. What the +// slack bought (a shared page that can still move by a couple of hundred bytes) +// is now bought by the full arm's 53,141, which both arms read the page through. +// +// WHAT IS OWED IS THE SAME BILL THE FULL ARM OWES, and it falls harder here: +// `stand` alone is 9,607 bytes on a sixteen-thousand-token window, which is +// roughly one token in six of everything that person has, spent before they have +// said anything. [leanPrefixTarget] is what this has to come back to and the +// test prints the shortfall on every green run. +const leanPrefixBudget = 44_989 + +// leanPrefixTarget is where the lean prefix has to get back to: the figure +// [leanPrefixBudget] was before anybody weighed the right belt. It is stated +// rather than enforced, for [fixedPrefixTarget]'s reason — a test that failed on +// it today would fail on work nobody in this file can do. +const leanPrefixTarget = 31_500 // leanWindow is the window the lean budget is weighed at. Sixteen thousand // tokens is the shape the profile was written for — a local open-weight model — @@ -684,6 +717,13 @@ func TestTheLeanPrefixStaysUnderItsBudget(t *testing.T) { total := tools + prompt t.Logf("the lean prefix is %d bytes (~%d tokens): prompt %d + tools %d over %d tools", total, total/4, prompt, tools, len(definitions)) + // AND THE DEBT IS SAID ON EVERY GREEN RUN, for [fixedPrefixTarget]'s reason: + // a ratchet passing is not the same fact as the number being what it should + // be, and this arm's shortfall is owed by the person with the least room. + if total > leanPrefixTarget { + t.Logf("it is %d bytes over the %d target — see [leanPrefixBudget] for the bill "+ + "and who owes it", total-leanPrefixTarget, leanPrefixTarget) + } if total <= leanPrefixBudget { return diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index a70bef4fba..17cac2c465 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -1063,8 +1063,15 @@ func NewStandingSentinel(parent Config) standing.Sentinel { // the phase clock share: a standing run has no one in front of it, so // its wait is worth nothing and its stream is nobody's to watch // (internal/lane's roles.go). + // + // And the purpose, because this road builds its own client and never + // passes the door: it is the call this build makes most often with + // nobody there, and until it said so it reached the log with no tag at + // all (clientdoor.go's [withPurpose]). callCtx := provider.WithRole( - provider.WithRoutingIntent(provider.WithoutStream(ctx), provider.IntentBackground), + provider.WithRoutingIntent( + provider.WithoutStream(withPurpose(ctx, purposeSentinel)), + provider.IntentBackground), lane.RoleStanding) if rung := effort.Resolve(effort.Scope{ Task: restoredRung(judgment.Item.Does.Effort), diff --git a/internal/session/tools_doc.go b/internal/session/tools_doc.go index 33b54831f7..92208bff19 100644 --- a/internal/session/tools_doc.go +++ b/internal/session/tools_doc.go @@ -338,7 +338,12 @@ func (a *Agent) readDocument(ctx context.Context, path, question string, offset, if rung == provider.DocumentParseNative { asked = question } - response, err := client.ParseDocument(ctx, provider.DocumentRequest{ + // THE PURPOSE, because this road builds its own client and never passes + // the door (clientdoor.go's [withPurpose]). A rung of the document reader + // is the model's own eyes on a file, billed to the session and asked for + // by a tool call rather than by a turn, and it reached the call log with + // no tag at all until it said so. + response, err := client.ParseDocument(withPurpose(ctx, purposeDocument), provider.DocumentRequest{ Model: model, Filename: filename, MediaType: entry.mediaType, From b91c566958cbe481af85d496d70314bbdfba56f7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:28:09 -0400 Subject: [PATCH 14/26] changes: the four findings the review turned up Co-Authored-By: Claude Opus 5 (1M context) --- docs/changes/unreleased/996-auxiliary-inventory.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/changes/unreleased/996-auxiliary-inventory.md b/docs/changes/unreleased/996-auxiliary-inventory.md index 6d6f5dc45e..a9df472fd5 100644 --- a/docs/changes/unreleased/996-auxiliary-inventory.md +++ b/docs/changes/unreleased/996-auxiliary-inventory.md @@ -11,8 +11,12 @@ invalidates: - "`TierHigh` had a built-in tenant in `DefaultAssignment`. It has none — every careful call (`auditor`, `shaper`, `vision`, `careful`, the repair) registers itself from the package that makes it." - "`TierHigh`'s five-minute patience was justified by \"a compaction summary over a full window\". That call does not exist; the figure is derived from the auditor now." - "The settings roles list offered a `compaction` row and the crew's careful line read \"audits, compaction, vision\". Neither does; the line reads \"audits, briefs, vision\"." - - "`fixedPrefixBudget` was 48,000 and weighed `v3ShapedAgent` — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. It weighs the fully-wired conversation (`shippedShapeAgent`, 23 tools) and is 53,159, which is that shape's measurement on the widest machine and no headroom." - - "The shipped prefix was believed to be inside 48,000. It is 53,159 — 5,159 over — and has been for as long as the shape existed. 48,000 is a target the test prints the shortfall against, not a cap anything passes today." + - "`fixedPrefixBudget` was 48,000 and weighed `v3ShapedAgent` — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. It weighs the fully-wired conversation (`shippedShapeAgent`, 23 tools) and is 53,141, which is that shape's measurement on the widest machine and no headroom." + - "The shipped prefix was believed to be inside 48,000. It is 53,141 — 5,141 over — and has been for as long as the shape existed. 48,000 is a target the test prints the shortfall against, not a cap anything passes today." + - "A profile whose `models.roles` row pinned `compaction:` was left unreadable by the role deletion: the panel drew no row for it and any other pin re-serialised the whole string through a writer that refused `\"compaction\" is not a role`, so every pin on that machine was unchangeable until config.json was hand-edited. `roles.Retired` is the reading that was missing; `ParseModelRoles` drops retired keys and `writeModelRoles` never writes one back." + - "`leanPrefixBudget` was 31,500 and weighed a lean conversation with no memory store, no accounts hub, no standing items and no saved programs — 16 tools. It weighs the shipped shape on a small window and is 44,989. The arm that protects the person with the LEAST room was out by 13,489 bytes, which is more than the budget it was enforcing." + - "`spellout` priced as an errand nobody was waiting on in every cost report, because its Role constant was declared in internal/session and `roleWords` parses internal/roles. The word is `roles.RoleSpellOut` now, and a law fails the next role declared anywhere else." + - "The memory tidy-up, the standing sentinel and the document reader reached `calls.jsonl` with no tag at all. They build their own provider client and never pass the door, so they now say the door's own word through `withPurpose` — `consolidate`, `standing-check`, `document` — and the law fails a file that builds a client and names nothing." - "The fixed prefix was believed to be the same size everywhere. It is not: `grep` says a longer sentence about itself where ripgrep is absent (134 bytes) and `load_capability` lists the tool groups this build has (27). The gate weighs the widest machine — `bare.WidestGrepDescription` is the one place that knows which spelling that is — so the number does not depend on who runs it." --- From be0d946a1bb8dd622fed6e318eb9931de2e9b91c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:30:31 -0400 Subject: [PATCH 15/26] law: the purpose is required on every own-client call, not once per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file-level pairing is satisfied by one road naming itself while the road beside it stays anonymous, which is the exact shape of the failure this law exists for: the forgettable one is always the second one. Every completion in a file that builds its own provider client must now carry a withPurpose on the context it is handed — inline, or through a local the purpose was folded into. standing_run.go writes the purpose four wrappers deep and completes on `callCtx` several lines later, so the reader follows assignments to a fixed point rather than looking only at the argument. And the second pass counts the CALLS it judged, not the files: three files that build a client and nought completions in them would pass in silence. cmd/aforge-replay's two doc comments stop citing auxiliary.go's deleted WithCallTag line and point at clientdoor.go's callPurpose, which is where the tag is spelled now. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge-replay/request.go | 21 ++-- internal/session/nohiddenwork_test.go | 149 +++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 26 deletions(-) diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index a4682de031..3001ffafe2 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -208,11 +208,12 @@ func measuredFelt(end callrows.Row, role lane.Role) float64 { // delete when the role reaches the row (#928, which carries the acceptance). // // THE RULE IS THAT AN ERRAND'S TAG IS ALREADY ITS ROLE. Every side call of a -// turn is tagged with its own role word — internal/session's auxiliary.go writes -// `WithCallTag(errandCtx, string(role))` for exactly this reason, "so three -// records of one call agree about what to call it" — which resolves `judge`, -// `memory`, `design`, `auxiliary`, `recall`, `tool`, `probe` and `standing` with -// no table at all. What is left is the handful of tags that name a CALL SITE +// turn is tagged with its own role word — internal/session's auxiliary.go hands +// `callPurpose(role)` to the one door, which spells it onto the request +// (clientdoor.go), so three records of one call agree about what to call it — +// which resolves `judge`, `memory`, `design`, `auxiliary`, `recall`, `tool`, +// `probe` and `standing` with no table at all. What is left is the handful of +// tags that name a CALL SITE // rather than a role, and each of those is one row of data below with the file // that sets it. // @@ -248,10 +249,12 @@ func roleOf(tag string) lane.Role { // // IT IS COMPLETE, AND A LAW READS THE TREE TO KEEP IT SO // (`TestEveryTagTheBuildWritesResolvesToARoleSomebodyDeclared`). Every tag this -// build can write is reachable from source: the string literals handed to -// `provider.WithCallTag`, the errand names `cmd/aforge`'s errandContext passes, -// and — because internal/session/auxiliary.go writes `string(role)` — every -// [roles.Role] constant there is. A word missing from here reads as a background +// build can write is reachable from source: the [session.callPurpose] values +// handed to internal/session's one door (clientdoor.go, which is the only file +// in that package that spells `provider.WithCallTag`), the errand names +// `cmd/aforge`'s errandContext passes, and — because auxiliary.go hands the door +// `callPurpose(role)` — every [roles.Role] constant there is. A word missing +// from here reads as a background // errand, which is quiet, plausible and moves every number in the table, so the // law fails the build rather than the report going quietly wrong. var callSiteRoles = map[string]lane.Role{ diff --git a/internal/session/nohiddenwork_test.go b/internal/session/nohiddenwork_test.go index bae3b9fd19..3a166aaa80 100644 --- a/internal/session/nohiddenwork_test.go +++ b/internal/session/nohiddenwork_test.go @@ -352,12 +352,24 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { purposes := 0 // AND THE OTHER HALF OF THE SAME LAW. Three roads in this package build a // [provider.Client] of their own — the memory tidy-up, a standing item's - // sentinel, the document reader — because each needs a client shape the door + // check, the document reader — because each needs a client shape the door // does not make. They are allowed to; what they are not allowed to do is - // reach the wire anonymously, which all three did until #996. So a file that - // builds its own client owes a [withPurpose], and this pairs the two. + // reach the wire anonymously, which all three did until #996. + // + // SO THE PURPOSE IS REQUIRED PER CALL AND NOT PER FILE. A file-level pairing + // would be satisfied by one road naming itself while the road beside it stays + // anonymous, which is the shape of the failure this law exists for: the + // forgettable one is always the second one. Every completion made in a file + // that builds its own client carries a [withPurpose] on the context it is + // handed — inline, or through a local the purpose was folded into. ownClients := map[string]bool{} - stamped := map[string]bool{} + type anonymous struct { + file string + line int + verb string + } + var unnamed []anonymous + ownCalls := 0 for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { @@ -378,12 +390,6 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { if !ok { return true } - // withPurpose is a plain function and not a method, so it is read - // before the selector shape below can rule it out. - if named, isIdent := call.Fun.(*ast.Ident); isIdent && named.Name == "withPurpose" { - stamped[name] = true - return true - } selector, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true @@ -411,15 +417,44 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { return true }) } + // SECOND PASS, over the files that build their own client. It is a second + // pass because a file has to be known to be one of those roads before its + // completions can be judged, and `provider.NewClient` may be written below + // the call it serves. for file := range ownClients { - if stamped[file] { - continue + parsed, err := parser.ParseFile(set, file, nil, 0) + if err != nil { + t.Fatalf("%s: %v", file, err) } - t.Errorf("%s builds a provider client of its own and never says what its calls are "+ - "FOR. A road that needs a client the door does not make is allowed one; a road "+ - "that reaches the wire anonymously is not, because the call log then cannot say "+ - "what this build spent its night on. Wrap the context in withPurpose "+ - "(clientdoor.go)", file) + carries := contextsCarryingAPurpose(parsed) + ast.Inspect(parsed, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !ownClientCompletions[selector.Sel.Name] || len(call.Args) == 0 { + return true + } + ownCalls++ + if !purposeReaches(call.Args[0], carries) { + unnamed = append(unnamed, anonymous{file, set.Position(call.Pos()).Line, selector.Sel.Name}) + } + return true + }) + } + for _, call := range unnamed { + t.Errorf("%s:%d calls %s on a client this package built itself, with a context that "+ + "carries no purpose. A road that needs a client the one door does not make is "+ + "allowed one; a road that reaches the wire anonymously is not, because the call "+ + "log then cannot say what this build spent its night on. Wrap the context in "+ + "withPurpose (clientdoor.go)", call.file, call.line, call.verb) + } + // AND THE SECOND PASS FOUND THE CALLS IT IS ABOUT. Three files that build a + // client and nought completions in them would pass in silence. + if ownCalls < 3 { + t.Fatalf("only %d completions were found in the %d files that build their own client; "+ + "the law is reading the wrong tree", ownCalls, len(ownClients)) } // AND THE LAW IS READING THE TREE IT THINKS IT IS. A walk that matched // nothing would pass for ever, which is how a structural law rots. @@ -431,6 +466,86 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { } } +// ownClientCompletions are the verbs that reach the wire on a client this +// package built itself. They are [provider.Client]'s own methods, so the list is +// that type's surface and not a guess: a road that completes by some other verb +// tomorrow joins it, and the count check above is what makes a missing one show +// up as a law that suddenly reads less than it did. +var ownClientCompletions = map[string]bool{ + "CompleteWithMessages": true, + "ParseDocument": true, +} + +// contextsCarryingAPurpose names the local variables in a file that were built +// from a [withPurpose] call. +// +// A PURPOSE FOLDED INTO A CONTEXT STAYS IN IT. standing_run.go writes +// `callCtx := provider.WithRole(provider.WithRoutingIntent(provider.WithoutStream( +// withPurpose(ctx, …)), …), …)` and then completes on `callCtx` several lines +// later, which is the ordinary shape — the purpose is one of several facts +// wrapped onto the same context — and a reader that looked only at the argument +// of the completion would call that road anonymous. +func contextsCarryingAPurpose(file *ast.File) map[string]bool { + carries := map[string]bool{} + // To a fixed point, because one context is often built from another. + for again := true; again; { + again = false + ast.Inspect(file, func(node ast.Node) bool { + var names []ast.Expr + var values []ast.Expr + switch shape := node.(type) { + case *ast.AssignStmt: + names, values = shape.Lhs, shape.Rhs + case *ast.ValueSpec: + values = shape.Values + for _, name := range shape.Names { + names = append(names, name) + } + default: + return true + } + if len(names) != len(values) { + return true + } + for index, value := range values { + named, isIdent := names[index].(*ast.Ident) + if !isIdent || carries[named.Name] { + continue + } + if purposeReaches(value, carries) { + carries[named.Name] = true + again = true + } + } + return true + }) + } + return carries +} + +// purposeReaches reports whether a purpose is anywhere inside an expression — +// written there, or carried in by one of the contexts `carries` names. +func purposeReaches(expr ast.Expr, carries map[string]bool) bool { + found := false + ast.Inspect(expr, func(node ast.Node) bool { + if found { + return false + } + switch shape := node.(type) { + case *ast.CallExpr: + if named, isIdent := shape.Fun.(*ast.Ident); isIdent && named.Name == "withPurpose" { + found = true + } + case *ast.Ident: + if carries[shape.Name] { + found = true + } + } + return !found + }) + return found +} + // blankPurpose reports an argument that names no purpose: the empty literal, or // a conversion of one. func blankPurpose(arg ast.Expr) bool { From 795ff54fc5e82351a1bfaa1ad0095c6e6e0d548b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:32:26 -0400 Subject: [PATCH 16/26] roles: the new law found two more of its own kind, and they are fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestEveryRoleWordIsDeclaredWhereTheVocabularyIs caught `const standingSentinelRole roles.Role = "sentinel"` in internal/session the first time it ran, which is the second instance of exactly the defect it was written for and the reason it is a law rather than a note. The word moves to roles.RoleSentinel; the Register call stays at the call site. And with `spellout` finally visible to roleWords, the tag law asked cmd/aforge-replay to say what it prices as — so the table gains the row, which is spellout.go's own lane.RoleAuxiliary and not a second reading of it. That row carries the seam it exposes: a person SITS AND WATCHES a spell-out, and an auxiliary is by definition a call nobody's seconds are being spent on. Either the lane role or the product is wrong there. The table's job is to agree with the build, so it does, and says so. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge-replay/request.go | 33 ++++++++++++++++++++------------ internal/roles/roles.go | 10 ++++++++++ internal/session/standing_run.go | 13 ++++++------- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index 3001ffafe2..2a78993415 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -337,18 +337,27 @@ var callSiteRoles = map[string]lane.Role{ "division": lane.RoleDesign, "title": lane.RoleAuxiliary, "vision": lane.RoleAuxiliary, - "imagegen": lane.RoleAuxiliary, - "worker": lane.RoleAuxiliary, - "speech": lane.RoleAuxiliary, - "video": lane.RoleAuxiliary, - "handoff": lane.RoleAuxiliary, - "taskname": lane.RoleAuxiliary, - "jobname": lane.RoleAuxiliary, - "caption": lane.RoleAuxiliary, - "shaper": lane.RoleAuxiliary, - "intake": lane.RoleAuxiliary, - "careful": lane.RoleAuxiliary, - "distill": lane.RoleAuxiliary, + // internal/session/spellout.go declares lane.RoleAuxiliary on its own + // context, so this row is that file's own reading and not a second one. + // + // IT IS WORTH A LOOK AND IT IS NOT THIS FILE'S CALL. A person is SITTING AND + // WATCHING a spell-out — it writes three lines they read before deciding + // whether to keep them — and an auxiliary is by definition a call nobody's + // seconds are being spent on. Either the lane role or the product is wrong + // there; the table's job is to agree with the build. + "spellout": lane.RoleAuxiliary, + "imagegen": lane.RoleAuxiliary, + "worker": lane.RoleAuxiliary, + "speech": lane.RoleAuxiliary, + "video": lane.RoleAuxiliary, + "handoff": lane.RoleAuxiliary, + "taskname": lane.RoleAuxiliary, + "jobname": lane.RoleAuxiliary, + "caption": lane.RoleAuxiliary, + "shaper": lane.RoleAuxiliary, + "intake": lane.RoleAuxiliary, + "careful": lane.RoleAuxiliary, + "distill": lane.RoleAuxiliary, } // requestOf turns a replayed request into the one a chooser is asked. diff --git a/internal/roles/roles.go b/internal/roles/roles.go index a8b1caab15..8507d317fb 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -212,6 +212,16 @@ const ( // internal/session/subharness_intake.go, which owns the call. RoleIntake Role = "intake" + // RoleSentinel is one standing item's cheap yes-or-no: is what this check + // found worth telling the person about. + // + // IT SITS LOW for the guardian's reason — it reads a few kilobytes and + // answers one binary question, and a wrong no costs a check that said nothing + // rather than money. It is also the call this build makes most often with + // nobody in front of it, once per check of every item forever. Registered + // from internal/session/standing_run.go, which owns the call. + RoleSentinel Role = "sentinel" + // RoleSpellOut expands a half-written request into what it obviously meant, // for the person to read and keep or drop. // diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index 17cac2c465..dad7c8b5c2 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -98,13 +98,12 @@ const ( standingOutcomeClip = 400 ) -// standingSentinelRole is the cheap yes/no judgment, registered as a ROLE so it -// resolves the way every other auxiliary call in this build resolves: the -// person's pin, then the low tier, then the conversation's own model -// (internal/roles). It sits low for the guardian's reason — it reads a few -// kilobytes and answers one binary question, and a wrong no costs a check that -// said nothing rather than money. -const standingSentinelRole roles.Role = "sentinel" +// standingSentinelRole is the cheap yes/no judgment, registered here and +// DECLARED IN internal/roles beside every other role's word ([roles.RoleSentinel], +// which carries the reasoning), so it resolves the way every other auxiliary +// call in this build resolves and so that everything which reads the role +// vocabulary can see it. +const standingSentinelRole = roles.RoleSentinel func init() { roles.Register(standingSentinelRole, roles.TierLow, "is this worth telling you about") From 6aa571f3146989cbf8296a4e129dc87c05cc8b0a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:10:47 -0400 Subject: [PATCH 17/26] roles: a dead pin is a dead pin, and three more numbers stop depending on the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE RETIRED LEDGER IS DELETED. It was a map of the words this build used to answer to, so a dropped pin could be told apart from a typo — and it could not earn its keep. Both are a pin no call will ever consult, both should stop being written back, and the only reader who can act on either is the person adding a word right now. Keeping it meant every future deletion owed it an entry, and the day somebody forgot, the old failure would come back exactly as it was. So the reading is symmetric: a pin for any word that is not a role is dropped, the writer refuses only a name being ADDED, and nothing dead is ever written back. The row DRAWS what is acted on (config.LivePinsAt) rather than the raw stored text — it used to draw `compaction: some/model` as a live pin while the list under it had no such line — and the panel says "no longer a role — that pin is ignored" to the person about to lose it. It is a sentence and not the log.Printf this had, which was the only one in internal/config and landed on stderr in every headless and --json door. AND THREE MORE MACHINE-DEPENDENT NUMBERS. The lean page interpolates the working directory, so that arm measured 45,034 on macOS against 44,989 on Linux and failed — a fixture artefact worth tens of bytes in the gate whose entire purpose is to be one number for everyone. The lean arm was also being handed the FULL shape's load_capability sentence, making its figure a hybrid of two belts that neither pays. And the replay table priced `vision` as an errand nobody waits on while image.go, its only writer, sets lane.RoleTalk with the reasoning beside it: a look at an image streams into the room during the person's own turn. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge-replay/request.go | 8 +- internal/config/selfservice_test.go | 26 +++- internal/config/settings.go | 198 ++++++++++++++++--------- internal/roles/roles.go | 44 ++---- internal/session/clientdoor.go | 76 ++++++---- internal/session/memory_consolidate.go | 2 +- internal/session/nohiddenwork_test.go | 124 +++++++--------- internal/session/prefixbudget_test.go | 57 ++++++- internal/session/recovery_law_test.go | 10 +- internal/session/spellout.go | 18 +-- internal/session/spellout_test.go | 2 +- internal/session/standing_run.go | 14 +- internal/tui3/settings.go | 8 + 13 files changed, 356 insertions(+), 231 deletions(-) diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index 2a78993415..4f5f05b974 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -336,7 +336,13 @@ var callSiteRoles = map[string]lane.Role{ "designer": lane.RoleDesign, "division": lane.RoleDesign, "title": lane.RoleAuxiliary, - "vision": lane.RoleAuxiliary, + // internal/session/image.go is the only writer of this tag and it sets + // lane.RoleTalk, deliberately and with the reasoning beside it: a look at an + // image STREAMS INTO THE ROOM the person is reading, delta by delta, during + // their own turn. This row said RoleAuxiliary — a call nobody's seconds are + // being spent on — which is the opposite of what that file says, and it moved + // every number in this table that separates a person's wait from a machine's. + "vision": lane.RoleTalk, // internal/session/spellout.go declares lane.RoleAuxiliary on its own // context, so this row is that file's own reading and not a second one. // diff --git a/internal/config/selfservice_test.go b/internal/config/selfservice_test.go index 583711c0f3..e1ac15d46e 100644 --- a/internal/config/selfservice_test.go +++ b/internal/config/selfservice_test.go @@ -133,8 +133,8 @@ func TestEveryRowSaysWhatItAccepts(t *testing.T) { } } -// A ROLE THIS BUILD RETIRED IS DROPPED FROM A PROFILE THAT STILL PINS IT, and -// the rest of that person's row keeps working. +// A PIN FOR A WORD THAT IS NOT A ROLE IS DROPPED FROM A PROFILE THAT STILL +// HOLDS IT, and the rest of that person's row keeps working. // // THE MEASURED FAILURE THIS IS WRITTEN AGAINST. `compaction` was a role for // months: it had a tier, a description, and a row in the settings panel, and @@ -145,6 +145,11 @@ func TestEveryRowSaysWhatItAccepts(t *testing.T) { // role pin on that machine was unchangeable until somebody opened config.json by // hand. A deletion with no reading for what it deleted is a deletion that breaks // the people who used the thing. +// +// THE READING DOES NOT CARE WHY THE WORD IS DEAD. A name this build retired and +// a name somebody mistyped are the same thing on the way in — a pin nothing will +// consult — so there is no ledger of retired names to keep up to date, and the +// day nobody updates it is not a day this comes back. func TestAPinForARetiredRoleIsDroppedAndTheRestOfTheRowStillMoves(t *testing.T) { profile := t.TempDir() // The string a profile written before the role was deleted actually holds. @@ -181,6 +186,23 @@ func TestAPinForARetiredRoleIsDroppedAndTheRestOfTheRowStillMoves(t *testing.T) t.Errorf("the row that was kept lost %q: %q", want, stored) } } + // AND THE PERSON IS TOLD, in a sentence and not a log line. + if note := RetiredPinNote(before); !strings.Contains(note, "compaction") { + t.Errorf("the surface is handed %q, want a line naming the pin being ignored", note) + } +} + +// AND A WORD NOBODY EVER PINNED IS STILL REFUSED WHEN IT IS BEING ADDED, which +// is the other half of the same bargain: the refusal earns its keep at the one +// moment a person can act on it, and nowhere else. +func TestANewPinForAWordThatIsNotARoleIsStillRefused(t *testing.T) { + profile := t.TempDir() + if err := writeModelRoles(profile, "title:openai/gpt-5-mini"); err != nil { + t.Fatalf("a real role name was refused: %v", err) + } + if err := writeModelRoles(profile, "title:openai/gpt-5-mini, harness_designer:some/model"); err == nil { + t.Fatal("a pin against a word that is not a role was accepted as it was added") + } } // A ROLE NOBODY RECOGNISES FAILS SILENTLY FOREVER, which is why this row diff --git a/internal/config/settings.go b/internal/config/settings.go index a891cf20ad..96779dc777 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -4,12 +4,10 @@ import ( "context" "encoding/json" "fmt" - "log" "os" "sort" "strconv" "strings" - "sync" "time" "github.com/Agent-Field/aforge-v2/internal/ctxbudget" @@ -2254,7 +2252,7 @@ func (s *Settings) build() []Setting { Label: "pinned roles", EmptyLabel: "none", Hint: "exceptions to the five rows above, one per role: `title:openai/gpt-5-mini`. " + "A role not named here follows its class.", - read: func() string { return ModelRolesAt(dir) }, + read: func() string { return LivePinsAt(dir) }, write: func(raw string) error { return writeModelRoles(dir, raw) }, }, Setting{ @@ -3698,69 +3696,96 @@ func ModelRolesAt(profileDir string) string { return "" } +// LivePinsAt is the pinned-roles row AS IT IS ACTED ON: the person's own text +// with the pins this build will never consult taken out. +// +// THE ROW DRAWS WHAT IS TRUE. [ModelRolesAt] is the raw stored string and stays +// that, because the writer needs the text to rewrite; but a row that DISPLAYED +// it would draw `compaction: some/model` as a live pin while the role list under +// it had no such line, and the person would be reading a setting that does +// nothing. What they are told instead is [RetiredPinNote], next to the row. +func LivePinsAt(profileDir string) string { + pins, dropped, err := parseModelRoles(ModelRolesAt(profileDir)) + if err != nil || len(dropped) == 0 { + return ModelRolesAt(profileDir) + } + names := make([]string, 0, len(pins)) + for name := range pins { + names = append(names, name) + } + sort.Strings(names) + kept := make([]string, 0, len(names)) + for _, name := range names { + kept = append(kept, name+":"+pins[name]) + } + return strings.Join(kept, ", ") +} + // ParseModelRoles reads `title:openai/gpt-5-mini` into role → model. The value // is split at the FIRST colon only, because a model slug can carry one of its // own (`…/model:free`). // -// A PIN FOR A ROLE THIS BUILD RETIRED IS DROPPED, NOT REFUSED (roles.Retired). -// The row is a person's own text, kept as they typed it, so it outlives the -// code that gave a word meaning — and refusing the whole row over one dead word -// would make every OTHER pin on that machine unchangeable, because the panel -// re-serialises the whole string on any change. Dropping is the reading that -// leaves the rest of their sentence working. +// A PIN FOR A WORD THAT IS NOT A ROLE IS DROPPED, NOT REFUSED — see +// [parseModelRoles], which is this and the names it dropped. func ParseModelRoles(raw string) (map[string]string, error) { - pins, err := parsePairs(raw, "role") + pins, _, err := parseModelRoles(raw) + return pins, err +} + +// parseModelRoles is [ParseModelRoles] and also SAYS WHAT IT DROPPED. +// +// A PIN FOR A WORD THAT IS NOT A ROLE IS DROPPED, NOT REFUSED, and the read does +// not care WHY it is not a role. A name this build retired and a name somebody +// mistyped are the same thing here by design: both are a pin no call will ever +// consult, and the row around them is a person's own text that outlives any +// particular build. Refusing the whole row over one dead word is what made every +// OTHER pin on that machine unchangeable when `compaction` was deleted, because +// the panel re-serialises the whole string on any change. +// +// THE DROP IS RETURNED AND NOT LOGGED. This is a pure parser on the settings +// panel's draw path, and a log line here would print on stderr in every headless +// and `--json` door, over the top of a machine-readable answer. Who should hear +// about a dead pin depends on who is asking: the panel says it to the person in +// front of it ([RetiredPinNote]), and the writer just stops writing it back. +func parseModelRoles(raw string) (pins map[string]string, dropped []string, err error) { + pins, err = parsePairs(raw, "role") if err != nil { - return nil, err + return nil, nil, err } for name := range pins { - if why, gone := roles.Retired(name); gone { - noteRetiredRole(name, why) + if !knownRole(name) { + dropped = append(dropped, name) delete(pins, name) } } - return pins, nil + sort.Strings(dropped) + return pins, dropped, nil } -// saidRetired is which dead role names this process has already explained, so a -// row read on every settings draw says its line ONCE rather than once a frame. -var saidRetired sync.Map - -func noteRetiredRole(name, why string) { - if _, already := saidRetired.LoadOrStore(strings.ToLower(strings.TrimSpace(name)), struct{}{}); already { - return +// knownRole reports a name this build actually resolves a call under. +func knownRole(name string) bool { + key := roles.RoleKey(name) + for _, role := range roles.Registered() { + if roles.RoleKey(string(role)) == key { + return true + } } - log.Printf("config: %q is no longer a role, so the model pinned to it is ignored — %s", name, why) + return false } -// withoutRetiredRoles is the person's own `models.roles` text with the pins for -// retired roles taken out, and is the text itself when there are none. -// -// IT REBUILDS ONLY WHEN IT HAS TO. The row is stored verbatim so it reads back -// the way it was typed; rewriting it on every save to normalise separators -// would edit a person's sentence for no reason. When a dead word IS in there, -// the rebuild is the price of never writing it out again. -func withoutRetiredRoles(raw string) string { - kept := make([]string, 0, 8) - dropped := false - for _, item := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ';' || r == '\n' - }) { - item = strings.TrimSpace(item) - if item == "" { - continue - } - name, _, _ := strings.Cut(item, ":") - if _, gone := roles.Retired(name); gone { - dropped = true - continue - } - kept = append(kept, item) +// RetiredPinNote is what a surface with a person in front of it says about pins +// this build will not act on, and "" when there are none. It is a sentence and +// not a log line, because the only reader who wants it is the one looking at the +// row it is about. +func RetiredPinNote(raw string) string { + _, dropped, err := parseModelRoles(raw) + if err != nil || len(dropped) == 0 { + return "" } - if !dropped { - return raw + if len(dropped) == 1 { + return fmt.Sprintf("%s is no longer a role — that pin is ignored", dropped[0]) } - return strings.Join(kept, ", ") + return fmt.Sprintf("%s are no longer roles — those pins are ignored", strings.Join(dropped, " and ")) } // ModelFallbacksAt resolves the fallback chain as the person wrote it. @@ -4031,13 +4056,7 @@ func knownToolApprovalMode(mode string) bool { // obeyed. func parsePairs(raw, subject string) (map[string]string, error) { pairs := map[string]string{} - for _, item := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ';' || r == '\n' - }) { - item = strings.TrimSpace(item) - if item == "" { - continue - } + for _, item := range pairItems(raw) { name, value, found := strings.Cut(item, ":") name = strings.TrimSpace(name) value = strings.TrimSpace(value) @@ -4052,6 +4071,26 @@ func parsePairs(raw, subject string) (map[string]string, error) { return pairs, nil } +// pairItems splits a pair row into its entries, blanks dropped and each one +// trimmed. It is where the separators a person may use are decided — comma, +// semicolon, newline — and it is one function because [parsePairs] and the +// rewriter that takes a dead role back out of a stored row +// ([withoutRetiredRoles]) have to cut the string the same way. A rewriter that +// split it differently would drop an entry the parser kept, or keep one it had +// already refused. +func pairItems(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' + }) + items := make([]string, 0, len(fields)) + for _, field := range fields { + if item := strings.TrimSpace(field); item != "" { + items = append(items, item) + } + } + return items +} + // writeToolApprovals validates the pairs, then keeps the person's own text. // Storing the text rather than the parsed map is what lets the row read back // exactly as it was typed; the map is derived on every read anyway. @@ -4079,26 +4118,45 @@ func writeToolApprovals(profileDir, raw string) error { // writing the row is guessing the name — and it guessed `harness_designer` the // first time it was asked. func writeModelRoles(profileDir, raw string) error { - pins, err := ParseModelRoles(raw) + pins, dropped, err := parseModelRoles(raw) if err != nil { return err } - known := map[string]bool{} - for _, role := range roles.Registered() { - known[string(role)] = true + // ONLY THE PIN BEING ADDED IS REFUSED. A name already in the stored row has + // been there for a while and may have been a role when it was typed — the + // person is not adding it now and cannot act on being told about it now, and + // refusing it would take the whole row with it. What a person CAN act on is + // the word they just wrote, which is where the refusal has always earned its + // keep: the settings pair guessed `harness_designer` the first time it was + // asked. + stored, _, _ := parseModelRoles(ModelRolesAt(profileDir)) + before := map[string]bool{} + for name := range stored { + before[roles.RoleKey(name)] = true + } + for _, name := range dropped { + if !before[roles.RoleKey(name)] { + return fmt.Errorf("%q is not a role. The roles are: %s", name, strings.Join(roleNames(), ", ")) + } } - for name := range pins { - if known[strings.ToLower(strings.TrimSpace(name))] { - continue + // AND A WORD THIS BUILD DOES NOT ANSWER TO IS NEVER WRITTEN BACK. The parse + // took it out of the map; this takes it out of the TEXT, which is what is + // actually stored. Without it a person who changes any pin keeps re-saving a + // word nothing will ever read, forever. + // + // THE ROW IS REBUILT ONLY WHEN IT HAS TO BE. It is stored verbatim so it reads + // back the way it was typed, and normalising a person's separators on every + // save would edit their sentence for no reason. + if len(dropped) == 0 { + return writeText(profileDir, KeyModelRoles, raw) + } + kept := make([]string, 0, len(pins)) + for _, item := range pairItems(raw) { + if name, _, _ := strings.Cut(item, ":"); knownRole(name) { + kept = append(kept, item) } - return fmt.Errorf("%q is not a role. The roles are: %s", name, strings.Join(roleNames(), ", ")) - } - // AND A DEAD WORD IS NEVER WRITTEN BACK. [ParseModelRoles] has already taken - // the retired pins out of the map above, so the refusal cannot see them; this - // takes them out of the TEXT, which is what is actually stored. Without it a - // person who changes any pin keeps re-saving a word this build stopped - // answering to, forever. - return writeText(profileDir, KeyModelRoles, withoutRetiredRoles(raw)) + } + return writeText(profileDir, KeyModelRoles, strings.Join(kept, ", ")) } // roleNames is the registered roles as a sorted list of plain words, for the diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 8507d317fb..f77f6e100b 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -508,36 +508,24 @@ func Registered() []Role { return roles } -// ── the roles this build used to have ─────────────────────────────────────── - -// retired names every role this build has deliberately stopped having, with the -// one line that says what happened to it. IT ONLY EVER GROWS. +// RoleKey is how a role name written by a person is compared: trimmed and +// lower-cased. // -// A ROLE THAT STOPS EXISTING IS STILL IN SOMEBODY'S config.json. `compaction` -// was assigned a tier, described in the settings panel and offered as a pin for -// months while nothing ever called it, so profiles carry `compaction:some/model` -// rows that were true when they were typed. Deleting the constant took the row -// off the panel and left the word in the file — and because the panel -// re-serialises the WHOLE `models.roles` string on any pin, the next change to -// ANY OTHER role was refused with `"compaction" is not a role` and every pin on -// that machine became unchangeable until somebody hand-edited config.json. +// IT IS ONE SPELLING BECAUSE A SECOND ONE EVENTUALLY DISAGREES. A row typed +// `Compaction: x` and one typed `compaction:x` are the same pin, and every +// reader that decides whether a word is a role has to answer that the same way +// or one of them will recognise a name the others do not. // -// So a reader DROPS what is retired rather than refusing it, and a writer never -// puts it back (internal/config's ParseModelRoles and writeModelRoles). The -// names stay here long after the code is gone, which is the whole point: this -// map is the only memory the build has of a word it used to answer to, and -// without it the word is indistinguishable from a typo. -var retired = map[string]string{ - "compaction": "a compaction has never asked a model; the role was assigned a tier and called by nothing", -} - -// Retired reports whether `name` is a role this build deliberately stopped -// having, and the line that says what happened to it. The comparison is the -// same one [Registered] readers make — lower-case, trimmed — so a row typed -// `Compaction: x` is recognised as the retired word and not as a new one. -func Retired(name string) (string, bool) { - why, ok := retired[strings.ToLower(strings.TrimSpace(name))] - return why, ok +// THERE IS NO LEDGER OF RETIRED ROLES, DELIBERATELY. One was written here and +// then deleted: a map of the words this build used to answer to, so a dropped +// pin could be told apart from a typo. It could not earn its keep. Both are a +// pin no call will ever consult, both should stop being written back, and the +// only reader who can act on either is the person adding a word right now — +// which is the one place internal/config still refuses (its writeModelRoles). +// Keeping the ledger meant every future deletion owed it an entry, and the day +// somebody forgot, the old failure would come back exactly as it was. +func RoleKey(name string) string { + return strings.ToLower(strings.TrimSpace(name)) } // TierOf reports the tier a role resolves under, and false if the role was diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index d8575222c8..c95388c3cc 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -300,12 +300,7 @@ func (c modelRoutingCompleter) CompleteWithMessages(ctx context.Context, message if model == "" { model = c.agent.Model() } - // NO PURPOSE, DELIBERATELY. This is the view of a live agent that leaves the - // package — internal/reflex and the media hands reach the wire through it — - // and each of those callers names its OWN call (reflex.go's "reflex"). A - // purpose stamped here would overwrite the one they stated with a word about - // the wrapper rather than about the request. - return c.agent.completeWithModel(ctx, "", messages, model, options...) + return c.agent.completeWithModel(ctx, purposeInherited, messages, model, options...) } // routedCompleter returns a completer safe to hand to a package that chooses a @@ -482,18 +477,42 @@ const ( // indistinguishable from a turn that had lost its name. They carry the most // money per call of anything nobody is waiting for. - // purposeConsolidate is the memory reflex's slow half: a batch of remembered - // things read together, hours after any of them was written. - purposeConsolidate callPurpose = "consolidate" + // EVERY WORD IN THIS BLOCK IS A WORD THAT IS NOT A ROLE. Where a purpose IS a + // role, the call site writes `callPurpose(roles.RoleX)` and no constant is + // declared here — restating a role's string would be the same word in two + // places, and the point of a small closed block is that what IS in it stands + // out. The memory tidy-up is [roles.RoleConsolidate] and says so at its own + // call; the two below are not roles at all. + // // purposeSentinel is one standing item's yes-or-no on evidence somebody else - // already gathered, run on every check of every item forever. - // The word is `standing-check` and not `sentinel`, which cmd/aforge already - // writes for the resident's quorum errand: two different calls under one tag - // is one reading of neither. + // already gathered, run on every check of every item forever. It is the ONE + // DELIBERATE DIVERGENCE: the call resolves on [roles.RoleSentinel], but the + // tag is `standing-check`, because cmd/aforge already writes `sentinel` for + // the resident's quorum errand and two different calls under one tag is one + // reading of neither. purposeSentinel callPurpose = "standing-check" // purposeDocument is a rung of the document reader — the model's own eyes on - // a PDF the `read` tool cannot open as text. + // a PDF the `read` tool cannot open as text. It is not a role because the + // reader resolves its model from the document settings and never from the + // role table (tools_doc.go). purposeDocument callPurpose = "document" + + // purposeInherited is the ONE PURPOSE THAT IS THE ABSENCE OF ONE, and it is + // named so that the absence is a statement rather than a gap. + // + // [modelRoutingCompleter] is the view of a live agent that LEAVES this + // package — internal/reflex and the media hands reach the wire through it — + // and each of those callers names its own call on the context before it gets + // here (reflex.go's "reflex"). A word stamped at this point would overwrite + // what they said with a word about the wrapper rather than about the request, + // so this one keeps whatever the caller already put there. + // + // It exists because the alternative was a law that had to KNOW about this + // call site: a walk that tracked which function it was inside, a name to + // compare against, and a reader for generic receivers — thirty lines of + // apparatus to excuse one bare `""`. A named constant says the same thing in + // the place a reader is already looking. + purposeInherited callPurpose = "" ) // completeWithModel is [Agent.completerFor] joined to the one wire-model @@ -515,28 +534,29 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, return nil, called, err } // THE PURPOSE BECOMES THE TAG, on the one line every request in this package - // passes over. An empty one is left alone rather than written as a blank: a - // tag nobody stated is what the log already knows how to say nothing about, - // and a row reading `""` would be worse than one reading nothing. - if purpose != "" { - ctx = provider.WithCallTag(ctx, string(purpose)) - } + // passes over — through [withPurpose], which is the only function in + // internal/session that spells it, here and for the three roads that carry a + // client of their own. + ctx = withPurpose(ctx, purpose) // A CALL UNDER A TOLD WINDOW IS TOLD IT HERE, at the last moment the context // is this package's to change (callwindow.go says why it cannot be earlier). response, err := client.CompleteWithMessages(toldItsWindow(ctx), messages, append(options, ai.WithModel(wire))...) return response, called, err } -// withPurpose stamps a purpose on a context for a road that completes on a -// client of its OWN, and it is the only thing in this package besides -// [Agent.completeWithNamedModel] that may. +// withPurpose stamps a purpose on a context, and it is THE ONE PLACE IN +// internal/session THAT SPELLS `provider.WithCallTag` — a law says exactly once +// (nohiddenwork_test.go). // -// THE DOOR IS STILL THE ONLY PLACE THAT SPELLS THE TAG. `provider.WithCallTag` -// appears twice in this file and nowhere else in internal/session — a law says -// so (nohiddenwork_test.go) — and both spellings take a [callPurpose], which is -// a closed vocabulary a reader can enumerate. What the three own-client roads -// needed was not permission to write a tag; it was a door of their own that is +// [Agent.completeWithNamedModel] calls it, so every request through the one door +// is stamped here; so do the three roads that complete on a client of their own, +// because each needs a client shape the door does not make. What those roads +// needed was never permission to write a tag: it was a door of their own that is // the same door. +// +// AN EMPTY PURPOSE IS LEFT ALONE rather than written as a blank. A tag nobody +// stated is what the log already knows how to say nothing about, and a row +// reading `""` would be worse than one reading nothing. func withPurpose(ctx context.Context, purpose callPurpose) context.Context { if purpose == "" { return ctx diff --git a/internal/session/memory_consolidate.go b/internal/session/memory_consolidate.go index 3e864ffe14..2b3947e4ec 100644 --- a/internal/session/memory_consolidate.go +++ b/internal/session/memory_consolidate.go @@ -358,7 +358,7 @@ func consolidateAsk(ctx context.Context, client Completer, model string, batch [ // the door would have said (clientdoor.go's [withPurpose]). provider.WithRole( provider.WithRoutingIntent( - provider.WithoutStream(withPurpose(ctx, purposeConsolidate)), + provider.WithoutStream(withPurpose(ctx, callPurpose(roles.RoleConsolidate))), provider.IntentBackground), lane.RoleMemory), []ai.Message{ diff --git a/internal/session/nohiddenwork_test.go b/internal/session/nohiddenwork_test.go index 3a166aaa80..f9ebbf8d85 100644 --- a/internal/session/nohiddenwork_test.go +++ b/internal/session/nohiddenwork_test.go @@ -340,52 +340,26 @@ func TestTheSenseSaysWhichSenseItIsUsing(t *testing.T) { // IT READS THE TREE ITSELF, so it runs on the laws gate of every pull request // (scripts/laws.sh finds it by this import). func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { - set := token.NewFileSet() - entries, err := os.ReadDir(".") - if err != nil { - t.Fatal(err) - } - // theDoors are the two spellings of the one door, and theWrapper is the one - // caller that may pass an empty purpose. + files := sessionSources(t) + // theDoors are the two spellings of the one door. There is no exemption for a + // blank purpose any more and no apparatus to grant one: the single caller that + // keeps whatever the context already carries passes [purposeInherited], which + // says so in the place a reader is already looking. theDoors := map[string]bool{"completeWithModel": true, "completeWithNamedModel": true} - const theWrapper = "modelRoutingCompleter" - purposes := 0 + purposes, tags := 0, 0 // AND THE OTHER HALF OF THE SAME LAW. Three roads in this package build a // [provider.Client] of their own — the memory tidy-up, a standing item's // check, the document reader — because each needs a client shape the door // does not make. They are allowed to; what they are not allowed to do is // reach the wire anonymously, which all three did until #996. // - // SO THE PURPOSE IS REQUIRED PER CALL AND NOT PER FILE. A file-level pairing - // would be satisfied by one road naming itself while the road beside it stays + // THE PURPOSE IS REQUIRED PER CALL AND NOT PER FILE. A file-level pairing is + // satisfied by one road naming itself while the road beside it stays // anonymous, which is the shape of the failure this law exists for: the - // forgettable one is always the second one. Every completion made in a file - // that builds its own client carries a [withPurpose] on the context it is - // handed — inline, or through a local the purpose was folded into. + // forgettable one is always the second one. ownClients := map[string]bool{} - type anonymous struct { - file string - line int - verb string - } - var unnamed []anonymous - ownCalls := 0 - for _, entry := range entries { - name := entry.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - file, err := parser.ParseFile(set, name, nil, 0) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - // within is the receiver type of the function being walked, so the one - // exemption is a property of where the call is rather than a file name. - within := "" + for name, file := range files { ast.Inspect(file, func(node ast.Node) bool { - if decl, ok := node.(*ast.FuncDecl); ok { - within = receiverTypeName(decl) - } call, ok := node.(*ast.CallExpr) if !ok { return true @@ -394,40 +368,49 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { if !ok { return true } - if selector.Sel.Name == "NewClient" && name != "clientdoor.go" { - ownClients[name] = true - return true - } - if selector.Sel.Name == "WithCallTag" && name != "clientdoor.go" { - t.Errorf("%s:%d calls provider.WithCallTag. The tag is spelled by the one door "+ - "and nowhere else — pass a callPurpose to completeWithModel instead "+ - "(clientdoor.go)", name, set.Position(call.Pos()).Line) - return true + switch selector.Sel.Name { + case "WithCallTag": + tags++ + if name != "clientdoor.go" { + t.Errorf("%s:%d calls provider.WithCallTag. The tag is spelled by withPurpose "+ + "and nowhere else — pass a callPurpose to completeWithModel instead "+ + "(clientdoor.go)", name, sessionLine(call)) + } + case "NewClient": + if name != "clientdoor.go" { + ownClients[name] = true + } } if !theDoors[selector.Sel.Name] || len(call.Args) < 2 { return true } purposes++ - if blankPurpose(call.Args[1]) && within != theWrapper { - t.Errorf("%s:%d reaches the wire with no purpose. Every request this package "+ - "makes says what it is FOR, because a tag that can be forgotten is a tag "+ - "that will be — and the call log cannot say what a build spent its night on "+ - "(clientdoor.go's [callPurpose])", name, set.Position(call.Pos()).Line) + if blankPurpose(call.Args[1]) { + t.Errorf("%s:%d reaches the wire with a purpose spelled as a bare blank. Every "+ + "request this package makes says what it is FOR, and the one call that keeps "+ + "the caller's own word says THAT, by name: pass purposeInherited "+ + "(clientdoor.go's [callPurpose])", name, sessionLine(call)) } return true }) } + // THE TAG IS SPELLED EXACTLY ONCE. Not "only in this file" — once, full stop, + // because [withPurpose] is now the single road to it and a second spelling + // beside it inside clientdoor.go would be the same drift starting over in the + // one place the law was not looking. + if tags != 1 { + t.Errorf("provider.WithCallTag is written %d times in this package; it is written once, "+ + "by withPurpose, and every other road says what it is for by handing that function "+ + "a callPurpose (clientdoor.go)", tags) + } // SECOND PASS, over the files that build their own client. It is a second // pass because a file has to be known to be one of those roads before its // completions can be judged, and `provider.NewClient` may be written below - // the call it serves. - for file := range ownClients { - parsed, err := parser.ParseFile(set, file, nil, 0) - if err != nil { - t.Fatalf("%s: %v", file, err) - } - carries := contextsCarryingAPurpose(parsed) - ast.Inspect(parsed, func(node ast.Node) bool { + // the call it serves. It reads the SAME parse, not a new one. + ownCalls := 0 + for name := range ownClients { + carries := contextsCarryingAPurpose(files[name]) + ast.Inspect(files[name], func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { return true @@ -438,31 +421,24 @@ func TestEveryRequestThroughTheDoorSaysWhatItIsFor(t *testing.T) { } ownCalls++ if !purposeReaches(call.Args[0], carries) { - unnamed = append(unnamed, anonymous{file, set.Position(call.Pos()).Line, selector.Sel.Name}) + t.Errorf("%s:%d calls %s on a client this package built itself, with a context "+ + "that carries no purpose. A road that needs a client the one door does not "+ + "make is allowed one; a road that reaches the wire anonymously is not, "+ + "because the call log then cannot say what this build spent its night on. "+ + "Wrap the context in withPurpose (clientdoor.go)", + name, sessionLine(call), selector.Sel.Name) } return true }) } - for _, call := range unnamed { - t.Errorf("%s:%d calls %s on a client this package built itself, with a context that "+ - "carries no purpose. A road that needs a client the one door does not make is "+ - "allowed one; a road that reaches the wire anonymously is not, because the call "+ - "log then cannot say what this build spent its night on. Wrap the context in "+ - "withPurpose (clientdoor.go)", call.file, call.line, call.verb) - } - // AND THE SECOND PASS FOUND THE CALLS IT IS ABOUT. Three files that build a - // client and nought completions in them would pass in silence. - if ownCalls < 3 { - t.Fatalf("only %d completions were found in the %d files that build their own client; "+ - "the law is reading the wrong tree", ownCalls, len(ownClients)) - } // AND THE LAW IS READING THE TREE IT THINKS IT IS. A walk that matched // nothing would pass for ever, which is how a structural law rots. if purposes < 8 { t.Fatalf("only %d calls through the one door were found; the law is reading the wrong tree", purposes) } - if len(ownClients) < 3 { - t.Fatalf("only %d files building their own client were found; the law is reading the wrong tree", len(ownClients)) + if ownCalls < 3 { + t.Fatalf("only %d completions were found in the %d files that build their own client; "+ + "the law is reading the wrong tree", ownCalls, len(ownClients)) } } diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index c61286366f..406eca9c69 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -537,6 +537,32 @@ func widestPage() string { return page } +// atAFixedPlace is a config whose WORKING DIRECTORY is a constant. +// +// THE PAGE INTERPOLATES WHERE YOU ARE (prompt.go's `- Working directory: %s`), +// and a fixture's workspace is a temp directory named after the test and the +// platform's temp root: `/tmp/TestX123/001` on Linux and +// `/var/folders/9k/…/T/TestX456/001` on macOS. So this arm's number moved by +// forty-five bytes between two machines and failed on the second — the same +// class of defect as `grep`'s two sentences and the capability shelf, found the +// same way, in the gate whose entire purpose is to be one number for everyone. +// +// The page also carries `- Workstation: %s/%s` from runtime.GOOS/GOARCH, which +// is a byte of difference between darwin and linux and is left alone: it is a +// FACT ABOUT THE MACHINE the model is told on purpose, it cannot be made +// constant without lying to the fixture, and a byte is inside nobody's decision. +// The working directory is not that — it is a fixture artefact, and it is tens +// of bytes. +// The full arm does not need it: [widestPage] weighs `systemPrompt` with its +// belt-fact tokens substituted and never renders the machine lines at all, so +// that number is already the same everywhere — and it is an UNDER-count of the +// shipped page by those few lines, which is recorded here rather than fixed +// because moving it would move a ratchet for a reason that is not growth. +func atAFixedPlace(config Config) Config { + config.Workspace = "/w" + return config +} + // widestBelt is the shipped belt weighed as the WIDEST MACHINE pays for it. // // A TOOL WHOSE DESCRIPTION DEPENDS ON WHAT IS INSTALLED still sends those bytes @@ -559,7 +585,12 @@ func widestBelt(t *testing.T, agent *Agent) []ai.ToolDefinition { // every member was gated off is not named at all — `edit_video` is built // only where ffmpeg is on PATH, so a machine that can edit video pays for // one more clause than one that cannot (tools_capabilities.go). - "load_capability": widestLoadCapability(), + // + // IT IS THIS SHAPE'S OWN SHELF AND NOT THE FULL ONE. A lean belt pre-arms + // `questions` instead of shelving it (promptprofile.go), so pasting the + // full shape's sentence into the lean arm would have made that number a + // hybrid of two belts — a figure neither of them pays. + "load_capability": widestLoadCapability(agent.config), } swapped := 0 for index := range definitions { @@ -583,17 +614,31 @@ func widestBelt(t *testing.T, agent *Agent) []ai.ToolDefinition { return definitions } -// widestLoadCapability is the loader's sentence with EVERY group the table -// declares and every member of each, which is what it says on a machine that has -// all the programs its tools shell out to. +// widestLoadCapability is the loader's sentence for ONE SHAPE at the widest +// machine: every group that shape shelves rather than carries, each with every +// member the table declares — which is what it says where all the programs its +// tools shell out to are installed. // // It is built through the product's own [loadCapabilityDescription], so the // three sentences after the group list are stated once and cannot drift out of // this number. -func widestLoadCapability() string { +// +// THE ONLY THING TAKEN OUT IS WHAT THIS SHAPE PRE-ARMS, because that is the one +// difference between the two belts' shelves that is a property of the SHAPE +// rather than of the machine. A group gated off by config would be +// over-counted here; that is the safe direction for a budget and it is the same +// answer everywhere, which is the whole point. +func widestLoadCapability(config Config) string { + prearmed := map[string]bool{} + for _, group := range config.prearmedGroups() { + prearmed[group] = true + } order := make([]string, 0, len(capabilityGroups)) members := make(map[string][]string, len(capabilityGroups)) for _, group := range capabilityGroups { + if prearmed[group.name] { + continue + } order = append(order, group.name) members[group.name] = group.members } @@ -712,7 +757,7 @@ func TestTheLeanPrefixStaysUnderItsBudget(t *testing.T) { if !agent.hasTool("ask") { t.Fatal("`ask` is not carried on a lean belt: a one-call-per-message model cannot load-then-ask") } - prompt := len(renderSystemAt(agent.config, time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC))) + prompt := len(renderSystemAt(atAFixedPlace(agent.config), time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC))) tools := len(block) total := tools + prompt t.Logf("the lean prefix is %d bytes (~%d tokens): prompt %d + tools %d over %d tools", diff --git a/internal/session/recovery_law_test.go b/internal/session/recovery_law_test.go index 2803768d9c..f4dc261176 100644 --- a/internal/session/recovery_law_test.go +++ b/internal/session/recovery_law_test.go @@ -188,6 +188,14 @@ func TestEveryAllowedCountIsStillThere(t *testing.T) { } } +// sessionFset is the position table every law in this package reads through, so +// a law that wants to NAME A LINE — and a failure a lane has to go and look at +// always does — can, without parsing the tree a second time of its own. +var sessionFset = token.NewFileSet() + +// sessionLine is where a node is, for a failure message. +func sessionLine(node ast.Node) int { return sessionFset.Position(node.Pos()).Line } + // sessionSources parses every non-test file of this package, keyed by base name. func sessionSources(t *testing.T) map[string]*ast.File { t.Helper() @@ -195,7 +203,7 @@ func sessionSources(t *testing.T) map[string]*ast.File { if err != nil { t.Fatal(err) } - fset := token.NewFileSet() + fset := sessionFset files := map[string]*ast.File{} for _, entry := range entries { name := entry.Name() diff --git a/internal/session/spellout.go b/internal/session/spellout.go index bf38c5c5fc..3f2a6557c9 100644 --- a/internal/session/spellout.go +++ b/internal/session/spellout.go @@ -50,15 +50,13 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" ) -// spellOutRole is the expansion, registered here and DECLARED IN internal/roles -// beside every other role's word ([roles.RoleSpellOut]), so it resolves the way -// every other auxiliary call in this build resolves — the person's pin, then the -// tier, then the conversation's own model — and so that everything which reads -// the role vocabulary can see it. -const spellOutRole = roles.RoleSpellOut - +// The expansion is [roles.RoleSpellOut] — declared there with every other role's +// word so that everything which reads the vocabulary can see it — and registered +// here, where the call is, so it resolves the way every other auxiliary call in +// this build resolves: the person's pin, then the tier, then the conversation's +// own model. func init() { - roles.Register(spellOutRole, roles.TierLow, "what a half-written request obviously means") + roles.Register(roles.RoleSpellOut, roles.TierLow, "what a half-written request obviously means") } const ( @@ -145,7 +143,7 @@ func (a *Agent) SpellOut(ctx context.Context, draft string) string { } a.mu.Lock() - call, err := roles.ResolveCall(roles.Source(a.config.RolesSource), spellOutRole, a.model) + call, err := roles.ResolveCall(roles.Source(a.config.RolesSource), roles.RoleSpellOut, a.model) closed := a.closed a.mu.Unlock() if closed || err != nil || strings.TrimSpace(call.Model) == "" { @@ -167,7 +165,7 @@ func (a *Agent) SpellOut(ctx context.Context, draft string) string { // A side errand of the box the person is typing in, named as one so it // is priced as one and never owns the clock (internal/lane's roles.go). provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary), - callPurpose(spellOutRole), + callPurpose(roles.RoleSpellOut), []ai.Message{ textMessage("system", spellOutSystem), // THE INSTRUCTION IS LAST, after the draft rather than above it, for diff --git a/internal/session/spellout_test.go b/internal/session/spellout_test.go index cfd9009b58..eb00c6f78a 100644 --- a/internal/session/spellout_test.go +++ b/internal/session/spellout_test.go @@ -74,7 +74,7 @@ func TestALongClauseIsHeldToItsCap(t *testing.T) { // call in this build resolves. A role that stopped being registered would fall // back to the conversation's own model, which is the expensive one. func TestTheExpansionIsRegisteredOnTheLowTier(t *testing.T) { - tier, ok := roles.TierOf(spellOutRole) + tier, ok := roles.TierOf(roles.RoleSpellOut) if !ok { t.Fatal("the expansion is not a registered role") } diff --git a/internal/session/standing_run.go b/internal/session/standing_run.go index dad7c8b5c2..57e6ac1da8 100644 --- a/internal/session/standing_run.go +++ b/internal/session/standing_run.go @@ -98,15 +98,11 @@ const ( standingOutcomeClip = 400 ) -// standingSentinelRole is the cheap yes/no judgment, registered here and -// DECLARED IN internal/roles beside every other role's word ([roles.RoleSentinel], -// which carries the reasoning), so it resolves the way every other auxiliary -// call in this build resolves and so that everything which reads the role -// vocabulary can see it. -const standingSentinelRole = roles.RoleSentinel - +// The cheap yes/no judgment is [roles.RoleSentinel] — declared there with every +// other role's word, which carries the reasoning — and registered here, where +// the call is. func init() { - roles.Register(standingSentinelRole, roles.TierLow, "is this worth telling you about") + roles.Register(roles.RoleSentinel, roles.TierLow, "is this worth telling you about") } // ── the live-window registry ──────────────────────────────────────────────── @@ -1024,7 +1020,7 @@ func NewStandingSentinel(parent Config) standing.Sentinel { ) return func(ctx context.Context, judgment standing.Judgment) (bool, string, float64, error) { once.Do(func() { - model, built = roles.Resolve(roles.Source(parent.RolesSource), standingSentinelRole, parent.Model) + model, built = roles.Resolve(roles.Source(parent.RolesSource), roles.RoleSentinel, parent.Model) if built != nil { return } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 46763d38f7..9e84e717b6 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1746,6 +1746,14 @@ func (a *app) applyRolePin(row config.Setting, role roles.Role, model string) { a.sheet.msg = err.Error() return } + // AND A PIN THIS BUILD NO LONGER ACTS ON IS SAID OUT LOUD to the person who + // is about to lose it. The row already DRAWS only the live pins + // (config.LivePinsAt) and this write will not put the dead one back, so + // without the line the row would quietly get shorter under their hands. It + // reads the raw stored text, which is the only place the dead word still is. + if note := config.RetiredPinNote(config.ModelRolesAt(a.profileDir)); note != "" { + a.sheet.msg = note + } if model = strings.TrimSpace(model); model == "" { delete(pins, string(role)) } else { From b7ca415d827464cef729ff23c8497542cbfbce1e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:16:12 -0400 Subject: [PATCH 18/26] prefix: one weighing, one shipped shape, and a debt that is enforced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE DEBT WAS INVISIBLE. It was a t.Logf, and nothing in the Makefile passes -v — so the one line saying the shipped prefix is thousands of bytes over what it is supposed to be was printed where no human and no CI log would show it. The cap is the TARGET plus a dated waiver now, one per arm in one place, and a waiver only ever shrinks: known-red's discipline applied to bytes. Growing the prefix takes a diff with somebody's name on it. ONE WEIGHING. The two arms were forty lines each, written twice, and the copies had already drifted — only one printed its shortfall, and it was the arm with the smaller debt. reportPrefix is called twice. ONE SHIPPED SHAPE. v3ShapedAgent WAS the second opinion: its own thin config, eighteen tools, weighed by four gates including both budgets. It is shippedShapeAgent now. beltShapes' lean row built a third config again, so the belt law and the prefix gate were measuring different lean conversations under one name; both call buildShippedConversation. ONE VOCABULARY READER. purposeWords and roleWords were the same function with a different path; they are constWordsOfType, read once, with the empty-result panic inside it. The role law catches the CONVERSION spelling too — `roleRepair = roles.Role("repair")` was invisible to it — and now also reads every roles.Register argument, which is the spelling that cannot be worked around. roles.RoleRepair is declared with the others. And the manual stops teaching a summarizer rung, its `session: summarizer returned nothing` string, and a focus appended to its instructions. None of that exists: a pass stubs and folds, asks no model, and never folds your own words. Co-Authored-By: Claude Opus 5 (1M context) --- PERF.md | 14 +- cmd/aforge-replay/replay_test.go | 160 +++++++++------- internal/manual/chat/commands.md | 12 ++ internal/manual/chat/models-and-cost.md | 55 +++--- internal/manual/chat/sessions-and-rewind.md | 2 +- internal/roles/roles.go | 9 + internal/session/belt_wiring_test.go | 38 ++-- internal/session/prefixbudget_test.go | 195 +++++++++++--------- internal/session/prompt_belt_test.go | 60 +++--- internal/session/recovery_law_test.go | 2 +- internal/session/repair_role.go | 30 ++- internal/session/task_cascade_test.go | 18 +- internal/session/taxonomy_boundary.go | 4 +- 13 files changed, 335 insertions(+), 264 deletions(-) diff --git a/PERF.md b/PERF.md index d5f8eaf40a..6c9c25565b 100644 --- a/PERF.md +++ b/PERF.md @@ -1298,12 +1298,14 @@ anything, which is #576's shape. | full (`fixedPrefixBudget`) | **53,141** | 53,141 | 48,000, **5,141** over | | lean (`leanPrefixBudget`) | **44,989** | 44,989 | 31,500, **13,489** over | -Both caps are now the measurement with **no headroom**, because a number that -has never been honestly weighed has not earned any, and both arms build the -shipped shape by name rather than by index. `fixedPrefixTarget` and -`leanPrefixTarget` hold the figures each has to come back to, and each test -prints its shortfall on **every green run** — a ratchet passing is not the same -fact as the number being what it should be. +**The cap is the target plus a dated waiver, and a waiver only ever shrinks.** +`fixedPrefixTarget` and `leanPrefixTarget` are what each arm is supposed to be; +`prefixWaivers` holds what each is over by, in one place, with the date and the +reason. That is `.github/known-red.txt`'s discipline applied to bytes: a wave +that grows the prefix raises a waiver in a diff with its name on it, and a wave +that pays some back lowers it in the same commit. It replaced a `t.Logf`, which +nothing in the Makefile passes `-v` to — the one line saying the prefix was +thousands of bytes over was printed where nobody would ever see it. The bill is not spread thin: `stand` is **9,607 bytes**, more than a quarter of the full tool block and nearly twice the next heaviest, and on a diff --git a/cmd/aforge-replay/replay_test.go b/cmd/aforge-replay/replay_test.go index 97808422d2..80b475bc55 100644 --- a/cmd/aforge-replay/replay_test.go +++ b/cmd/aforge-replay/replay_test.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "sync" "testing" "time" @@ -265,26 +266,40 @@ var derivedTags = map[string]func(yield func(tag, where string)){ }, } -// purposeWords reads internal/session/clientdoor.go for the [callPurpose] -// constants, because those ARE tags the moment the door writes one. +// purposeWords and roleWords are the two vocabularies this law joins, each read +// from the ONE FILE that declares it and each read ONCE per run. // -// It is [roleWords] one package over and for its reason. The purposes that are -// a role's own name need no entry — they arrive through the role vocabulary — -// and these are the three or four that are not any role: the turn, a node's -// turn, a saved program's step, the ceiling's draft rung. -func purposeWords() []string { - var words []string - set := token.NewFileSet() - file, err := parser.ParseFile(set, filepath.Join(moduleRoot, "internal", "session", "clientdoor.go"), nil, 0) +// They are [constWordsOfType] twice. The purposes that are a role's own name +// need no entry in either table — they arrive through the role vocabulary — and +// what is left in the door's own block is the handful that are not any role. +var purposeWords = sync.OnceValue(func() []string { + return constWordsOfType(filepath.Join("internal", "session", "clientdoor.go"), "callPurpose") +}) + +var roleWords = sync.OnceValue(func() []string { + return constWordsOfType(filepath.Join("internal", "roles", "roles.go"), "Role") +}) + +// constWordsOfType reads one file for the string constants declared with one +// named type, and PANICS ON AN EMPTY ANSWER. +// +// The panic is the point. This law joins two vocabularies by parsing the files +// that declare them, and a parse that silently found nothing would make the join +// trivially complete — every tag resolved, no row missing, the report quietly +// wrong. A file renamed out from under it has to be loud. +func constWordsOfType(relative, typeName string) []string { + path := filepath.Join(moduleRoot, relative) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) if err != nil { - panic("reading internal/session/clientdoor.go: " + err.Error()) + panic("reading " + relative + ": " + err.Error()) } + var words []string ast.Inspect(file, func(node ast.Node) bool { spec, isValue := node.(*ast.ValueSpec) if !isValue || len(spec.Values) != 1 { return true } - if kind, isIdent := spec.Type.(*ast.Ident); !isIdent || kind.Name != "callPurpose" { + if kind, isIdent := spec.Type.(*ast.Ident); !isIdent || kind.Name != typeName { return true } if word, literal := stringLiteral(spec.Values[0]); literal && word != "" { @@ -293,7 +308,7 @@ func purposeWords() []string { return true }) if len(words) == 0 { - panic("internal/session/clientdoor.go declares no callPurpose constants; this law is reading the wrong tree") + panic(relative + " declares no " + typeName + " constants; this law is reading the wrong tree") } return words } @@ -398,75 +413,92 @@ func tagsTheBuildWrites(t *testing.T) map[string]string { return found } -// TestEveryRoleWordIsDeclaredWhereTheVocabularyIs fails a role named anywhere but -// internal/roles. +// TestEveryRoleWordIsDeclaredWhereTheVocabularyIs fails a role named anywhere +// but internal/roles, in either spelling. // -// [roleWords] parses ONE FILE — internal/roles/roles.go — because that is where -// this build says the role vocabulary lives. A role constant declared at its own -// call site is therefore invisible to it, and to every other reader that asks -// this package what the roles ARE. +// [roleWords] parses ONE FILE, because that is where this build says the role +// vocabulary lives and because the tool that reads it cannot import the engine +// to ask the registry at run time — a replay reader would pull in the whole +// session package for a list of words. A role constant declared at its own call +// site is therefore invisible to it, and to every other reader that asks what +// the roles ARE. // // THE MEASURED FAILURE. `const spellOutRole roles.Role = "spellout"` lived in -// internal/session/spellout.go. It registered correctly, resolved correctly and -// tagged its call correctly — and then reached the cost report as a word nobody -// had written down, which [roleOf] prices as a background errand with nobody -// waiting. The spell-out is the one auxiliary a person sits and watches, so the -// single call whose latency matters most was the one counted as if it did not. -// Nothing failed; a number was quietly wrong. +// internal/session/spellout.go. It registered, resolved and tagged its call +// correctly — and reached the cost report as a word nobody had written down, +// which [roleOf] prices as a background errand with nobody waiting. The +// spell-out is the one auxiliary a person sits and watches, so the single call +// whose latency matters most was the one counted as if it did not. Nothing +// failed; a number was quietly wrong. `const standingSentinelRole roles.Role = +// "sentinel"` was the second, caught by this law on the day it was written. +// +// BOTH SPELLINGS, because there are two. A declared type +// (`x roles.Role = "w"`) and a conversion (`x = roles.Role("w")`) produce the +// same constant and only the first has a Type for a reader to match on; +// `roleRepair = roles.Role("repair")` was invisible to the first version of +// this law. The second half — every word handed to [roles.Register] — is the +// one that cannot be spelled around at all, because a role nothing registers +// resolves under no tier. func TestEveryRoleWordIsDeclaredWhereTheVocabularyIs(t *testing.T) { const home = "internal/roles/roles.go" + declared := map[string]bool{} + for _, word := range roleWords() { + declared[word] = true + } walkBuildSources(t, func(path string, file *ast.File) { - if path == home { - return - } ast.Inspect(file, func(node ast.Node) bool { - spec, isValue := node.(*ast.ValueSpec) - if !isValue || spec.Type == nil { - return true - } - named, isSelector := spec.Type.(*ast.SelectorExpr) - if !isSelector || named.Sel.Name != "Role" { + if call, isCall := node.(*ast.CallExpr); isCall { + if methodName(call.Fun) == "Register" && len(call.Args) > 0 { + if word, spelled := roleWordOf(call.Args[0]); spelled && !declared[word] { + t.Errorf("%s registers the role %q, which %s does not declare. A role's "+ + "WORD belongs there beside the others — spelled out, not conjured at "+ + "the call — because everything that reads the vocabulary reads that "+ + "file and a word it cannot see is priced as an errand nobody is "+ + "waiting on.", path, word, home) + } + } return true } - if pkg, isIdent := named.X.(*ast.Ident); !isIdent || pkg.Name != "roles" { + spec, isValue := node.(*ast.ValueSpec) + if !isValue || path == home { return true } - for _, name := range spec.Names { - t.Errorf("%s declares the role %s. Every role's WORD belongs in %s, "+ - "beside the others: a role declared at its call site resolves and tags "+ - "correctly and is still invisible to everything that reads the "+ - "vocabulary, which prices its calls as errands nobody is waiting on. "+ - "Declare it there and keep the roles.Register call here.", - path, name.Name, home) + for index, name := range spec.Names { + spelled := isRoleType(spec.Type) + if !spelled && index < len(spec.Values) { + _, spelled = roleWordOf(spec.Values[index]) + } + if spelled { + t.Errorf("%s declares the role %s. Every role's WORD belongs in %s, beside "+ + "the others: a role declared at its call site resolves and tags correctly "+ + "and is still invisible to everything that reads the vocabulary, which "+ + "prices its calls as errands nobody is waiting on. Declare it there and "+ + "keep the roles.Register call here.", path, name.Name, home) + } } return true }) }) } -// roleWords reads internal/roles for the role vocabulary itself, because that -// package's constants ARE tags the moment auxiliary.go writes one. -func roleWords() []string { - var words []string - set := token.NewFileSet() - file, err := parser.ParseFile(set, filepath.Join(moduleRoot, "internal", "roles", "roles.go"), nil, 0) - if err != nil { - panic("reading internal/roles: " + err.Error()) +// isRoleType reports the `roles.Role` type spelled on a declaration. +func isRoleType(expr ast.Expr) bool { + named, isSelector := expr.(*ast.SelectorExpr) + if !isSelector || named.Sel.Name != "Role" { + return false } - ast.Inspect(file, func(node ast.Node) bool { - spec, isValue := node.(*ast.ValueSpec) - if !isValue || len(spec.Values) != 1 { - return true - } - if kind, isIdent := spec.Type.(*ast.Ident); !isIdent || kind.Name != "Role" { - return true - } - if word, literal := stringLiteral(spec.Values[0]); literal && word != "" { - words = append(words, word) - } - return true - }) - return words + pkg, isIdent := named.X.(*ast.Ident) + return isIdent && pkg.Name == "roles" +} + +// roleWordOf reads a role written as a conversion — `roles.Role("repair")` — +// and answers the word inside it. +func roleWordOf(expr ast.Expr) (string, bool) { + call, isCall := expr.(*ast.CallExpr) + if !isCall || !isRoleType(call.Fun) || len(call.Args) != 1 { + return "", false + } + return stringLiteral(call.Args[0]) } // moduleRoot is this package's place in the tree, which is two directories up. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 2959c40bc0..fda8c754c1 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1713,6 +1713,18 @@ order: aforge makes for itself, grouped under its class. Those rows come from the running binary rather than the settings registry. +**A pin for a role this build no longer has is ignored, and the row stops showing it.** Roles +come and go with the calls that use them — `compaction` was one, and a compaction has not asked +a model since long before it was deleted. A pin left behind for a word like that is dropped +when the row is read, never written back, and the foot line says `compaction is no longer a +role — that pin is ignored` the next time you change any pin. **Every other pin on the row +keeps working**, which is the whole point: the row is one string holding all of them, and +refusing the lot over one dead word would leave you unable to change any of them without +editing `config.json` by hand. + +Typing a word that is not a role is still refused outright, with the real names listed — that +refusal is for the pin you are adding now, which is the one you can do something about. + The four machine rows lead because the endpoint serving your model is part of the same decision as the model, and they used to sit at the foot of the tab, forty rows below it. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 494714a4f6..fa78ab5fd0 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2199,7 +2199,7 @@ When nothing can say — a model the catalog has never carried, a machine that h reached the catalog yet — the answer is still the 128,000-token default, which is the smallest window this surface routes to and the safe direction for a guess to be wrong in. -## What happens before the conversation is summarized +## What happens before the conversation is compacted aforge does not jump straight to summarizing. There are rungs before it. @@ -2285,36 +2285,43 @@ call, nothing paraphrased. This rung is chosen only when you gave `/compact` no is a workspace, there is page budget, and the model in use can read images. Pages are 120 columns by 64 lines, greyscale, deterministic, footed ` | context page 1 of 4`, and saved under `<workspace>/.aforge-v3/frames/`. The ceiling is **8 pages**; anything past it is -summarized and appended after the pages. +folded to a marker after the pages. -**Rung 3 — the summary.** One call on the session's own model, with no tools, that must -produce six sections — `## Goal`, `## Constraints & Preferences`, `## Progress`, -`## Key Decisions`, `## Next Steps`, `## Critical Context` — and must reproduce any -unanswered question verbatim and preserve exact paths, symbols, commands and error text. +**Rung 3 — the fold.** If the transcript is still too big after stubbing, the oldest +**assistant** work is replaced by one marker line. It is not a summary: nothing is described +and nothing is decided. -## What a compaction pass keeps +``` +[folded 43 messages · grep or read ~/.aforge/v3/projects/-you-work/<session>/journal.jsonl, lines 12..40] +``` -The transcript is cut at a message boundary, walking back from the tail until the keep-recent -budget is spent. The system message is never cut. +**Your own words are never folded.** A person's messages are the one thing in a transcript +nothing else can reconstruct, so the fold walks past them and takes only the assistant's. -What is rebuilt, in order: the system message, then the page images if there are any, then -the summary note if there is one, then **the state block verbatim**, then the kept tail. The -state block — what `track` and `commit` recorded — is injected directly and is **never routed -through the summarizer**, so working state cannot be paraphrased away. +## What a compaction pass keeps -The note the model reads above a summary begins: +**A compaction asks no model, costs nothing, and takes no time you can feel.** There is no +summarizer behind it — there was one, and it was deleted. It paid a model to write prose +about the text it was about to throw away, at the worst possible moment, and the loss was +unrecoverable because the transcript the prose came from went with it. -``` -[context compacted] Everything before this point was summarized to fit the context window. This note is the record of that conversation — it is not something either of us said, and any question inside it is still open. -``` +What replaces it is two mechanical passes over messages this session already has: tool +results become pointers to their own bytes, and then the oldest assistant work becomes one +marker line naming where the whole of it can still be read. + +What the model is handed instead of a summary is the **state card** — what `track` and +`commit` recorded — which rides in the system prompt on every turn and is kept up to date +after each one. So what the conversation is about is never paraphrased, because it was never +written as prose in the first place. -A pass can decline: `session: nothing to compact` (everything already fits in the tail), -`session: a compaction pass is already running`, or `session: summarizer returned nothing`. +A pass can decline: `session: nothing to compact` (everything already fits in the tail), or +`session: a compaction pass is already running`. ## What happens when the conversation gets too long — when compaction happens by itself When the conversation gets too long to fit, nothing is lost and nothing stops: the oldest -part of it is summarized away and the recent tail is kept, which is what compaction is. +part of it is stubbed and folded down to a marker and the recent tail is kept, which is what +compaction is. **Nothing is lost is meant literally, and you can go and look.** The session file keeps every original line, and scrolling up above the boundary is given those rather than the @@ -2345,16 +2352,14 @@ While a pass runs you see `compacting ~84k tokens` (`~842` under a thousand). On ## /compact — compacting now -`/compact` summarizes the conversation on demand. It notes `compacting…` immediately and runs +`/compact` compacts the conversation on demand. It notes `compacting…` immediately and runs the pass off the loop, so the surface stays alive. **Success is silent.** There is no "done" message — a compaction that worked simply leaves the conversation shorter. A failure comes back as `compact failed: ` followed by the error. -A `/compact` given a focus always goes to the summary rung rather than page images, because a -renderer cannot be careful about anything. The focus is *appended* to the summarizer's -instructions and never replaces them, so one careless phrase cannot cost the next session its -file paths. It travels on that one request and no further. +**It costs nothing and asks no model**, so there is no reason not to run it, and no `compaction` +role in settings to point at a model for it. ## Turning automatic compaction off diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 907ab9fdae..f64d81e686 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -251,7 +251,7 @@ session: 12 is not a rewind point; the nearest is 10 conversation is being compacted. This is a refusal rather than a wait on purpose: interrupt first, rewind after, which is one keystroke. - `session: nothing to rewind` — a session nobody has spoken to yet, or one whose whole - transcript is a compaction summary. + transcript has been folded away by a compaction. - `session: agent is closed`. - The "not a rewind point" sentence names the nearest legal point. The index is never rounded to it for you. diff --git a/internal/roles/roles.go b/internal/roles/roles.go index f77f6e100b..570987ceb6 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -212,6 +212,15 @@ const ( // internal/session/subharness_intake.go, which owns the call. RoleIntake Role = "intake" + // RoleRepair is the hands that fix what the cheap hands got wrong. + // + // THE TIER IS HIGH AND NOT THE MASTERMIND'S, for [RoleCareful]'s reason: a + // repair round is many turns of ordinary work done by a model that can be + // trusted with something subtle, not one answer that decides what every other + // call does. Registered from internal/session/repair_role.go, which owns the + // call. + RoleRepair Role = "repair" + // RoleSentinel is one standing item's cheap yes-or-no: is what this check // found worth telling the person about. // diff --git a/internal/session/belt_wiring_test.go b/internal/session/belt_wiring_test.go index 0788c7f76b..690df231ad 100644 --- a/internal/session/belt_wiring_test.go +++ b/internal/session/belt_wiring_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - configpkg "github.com/Agent-Field/aforge-v2/internal/config" "github.com/Agent-Field/aforge-v2/internal/subharness" ) @@ -30,36 +29,21 @@ import ( // a registry to design into, a runner to run what is designed, an adaptive // runner, and a surface that answers questions. -// v3ShapedAgent is a conversation configured the way the interactive door -// configures one. +// v3ShapedAgent is the conversation the interactive door configures, which is +// [shippedShapeAgent] and is not a second opinion about it. +// +// IT USED TO BE A SECOND OPINION, AND THAT WAS THE MEASURED HOLE. This function +// built its own thin config — no memory store, no accounts hub, no standing +// items, no saved programs, eighteen tools — and four tests weighed the shipping +// door through it, including both prefix budgets. A machine somebody has +// finished setting up carries five more tools and fourteen kilobytes more +// prefix, so every one of those gates reported on a conversation nobody has. +// There is ONE shipped shape now and this is a name for it. func v3ShapedAgent(t *testing.T) *Agent { t.Helper() - agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { - config.AskConsent = true - config.BashBackgroundAfterSeconds = configpkg.DefaultBashBackgroundAfter - config.HarnessStore = subharness.At(t.TempDir()) - config.RunHarness = func(context.Context, string, string, string, func(subharness.Trail)) (string, subharness.Usage, error) { - return "", subharness.Usage{}, nil - } - config.OrchestrateRunner = func(context.Context, string, string, float64) (string, error) { - return "", nil - } - }) - return agent + return shippedShapeAgent(t) } -// shippedShapeAgent is THE BELT A CONFIGURED MACHINE ACTUALLY CARRIES, and it is -// built from [beltShapes]'s own first entry — "the shipping conversation, fully -// wired" — so that there is one definition of that shape and the prefix gate and -// the belt tests cannot come to disagree about it. -// -// IT IS NOT [v3ShapedAgent], AND THAT WAS A MEASURED HOLE. That one is a -// conversation with no memory store, no accounts hub, no standing items and no -// saved programs: eighteen tools where a machine somebody has finished setting -// up carries twenty-three. The prefix gate weighed it and reported green at -// 39,073 bytes while the shipped shape weighed 53,025 — over the cap the gate -// was enforcing, invisibly, which is #576's shape exactly (a gate that passes -// because it is pointed at something nobody runs). // shippedBeltShape names the row of [beltShapes] that IS the shipping // conversation, fully wired. // diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 406eca9c69..4ee6a692e0 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -95,7 +95,8 @@ import ( // // ── WHAT IS OWED, AND WHERE IT HAS TO COME FROM ───────────────────────────── // -// THE TARGET IS STILL 48,000 and the shipped prefix is 5,159 over it. The bill +// THE TARGET IS STILL [fixedPrefixTarget] and the shipped prefix is over it by the +// difference between the two constants below. The bill // is not spread thin — one tool is more than a quarter of the whole tool block: // // stand 9,607 the standing-item verb's schema @@ -104,7 +105,7 @@ import ( // search_conversations 1,613 // watch 1,396 // -// `stand` alone is nearly twice the next heaviest and more than the 5,159 owed. +// `stand` alone is nearly twice the next heaviest and more than the whole overage. // It is not this file's to cut: what a tool's schema says is its contract with // the model, and trimming it is a change to what the model is told rather than // to a byte count. It belongs to whoever owns internal/session's standing belt, @@ -383,14 +384,38 @@ import ( // widest page at 19,114 and the fully-wired belt's tool block at 34,027 over // twenty-three tools, weighed as the widest machine pays for it ([widestBelt]). // There is no rounding in it and no headroom on it. -const fixedPrefixBudget = 53_141 +const fixedPrefixBudget = fixedPrefixTarget + fixedPrefixWaiver -// fixedPrefixTarget is where the shipped prefix has to get back to, and it is -// the figure [fixedPrefixBudget] was before anybody weighed the right belt. It -// is stated rather than enforced because a test that failed on it today would -// fail on work nobody in this file can do (see the bill above). +// fixedPrefixTarget is what the shipped prefix is SUPPOSED to be: the figure the +// last diet aimed at and the one [fixedPrefixBudget] held until the right belt +// was weighed. +// +// THE CAP IS THIS PLUS A WAIVER, AND THE WAIVER ONLY SHRINKS. See +// [prefixWaivers] for what each arm owes today and why a number that is only +// printed is a number nobody ever pays. const fixedPrefixTarget = 48_000 +// prefixWaivers is what each arm is over its target by, dated, in ONE PLACE. +// +// ── THE RULE, WHICH IS `.github/known-red.txt`'S RULE ──────────────────────── +// +// A waiver only ever SHRINKS. A lane that takes bytes out lowers the figure in +// the same commit; a lane that needs more takes it out of something that is +// already being said twice. Raising one is a decision with somebody's name on it +// in a diff, which is the entire difference between a debt and a floor. +// +// 2026-09-12, #996. Both arms were weighing a conversation nobody has — no +// memory store, no accounts hub, no standing items, no saved programs — so both +// waivers are the first honest measurement of a prefix that had never been +// weighed, and neither is a wave's overspend. The bill is not spread thin: +// `stand` is 9,607 bytes, more than a quarter of the full tool block and, on a +// sixteen-thousand-token window, roughly one token in six of everything that +// person has before they have said anything. +const ( + fixedPrefixWaiver = 5_141 + leanPrefixWaiver = 13_489 +) + // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane // G). promptprofile.go added a second shape of prefix for a model with a small // window or the crew's open-weight worker seat: two sections come off the page, @@ -489,12 +514,11 @@ const fixedPrefixTarget = 48_000 // roughly one token in six of everything that person has, spent before they have // said anything. [leanPrefixTarget] is what this has to come back to and the // test prints the shortfall on every green run. -const leanPrefixBudget = 44_989 +const leanPrefixBudget = leanPrefixTarget + leanPrefixWaiver -// leanPrefixTarget is where the lean prefix has to get back to: the figure -// [leanPrefixBudget] was before anybody weighed the right belt. It is stated -// rather than enforced, for [fixedPrefixTarget]'s reason — a test that failed on -// it today would fail on work nobody in this file can do. +// leanPrefixTarget is what the lean prefix is SUPPOSED to be: the figure +// [leanPrefixBudget] held until the right belt was weighed. The cap is this plus +// [leanPrefixWaiver], which only ever shrinks. const leanPrefixTarget = 31_500 // leanWindow is the window the lean budget is weighed at. Sixteen thousand @@ -648,37 +672,69 @@ func widestLoadCapability(config Config) string { // TestTheFixedPrefixStaysUnderItsBudget weighs what every request carries before // anybody has said anything. func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { - agent := shippedShapeAgent(t) + reportPrefix(t, prefixArm{ + what: "the fixed prefix", + definitions: widestBelt(t, shippedShapeAgent(t)), + page: len(widestPage()), + budget: fixedPrefixBudget, + target: fixedPrefixTarget, + owed: "see this file's head for the bill and who owes it", + howToPay: "every byte here is sent again on every request of every turn, so take the " + + "addition back out of something that already says it rather than raising the budget", + }) +} - definitions := widestBelt(t, agent) - block, err := json.Marshal(definitions) +// prefixArm is one weighing: what to call it, what it carries, and the two +// figures it is judged against. +type prefixArm struct { + what string + definitions []ai.ToolDefinition + page int + budget int + target int + owed string + howToPay string +} + +// reportPrefix weighs one arm, says what is still owed on a green run, and names +// the bill biggest-first when the ratchet is broken. +// +// IT IS ONE FUNCTION BECAUSE THE TWO ARMS ARE ONE TEST with two sets of +// constants. They were forty lines each, written twice, and the copies had +// already drifted: only one of them printed its shortfall against the target, +// so the arm carrying the larger debt was the arm that never mentioned it. +func reportPrefix(t *testing.T, arm prefixArm) { + t.Helper() + block, err := json.Marshal(arm.definitions) if err != nil { t.Fatal(err) } - tools, prompt := len(block), len(widestPage()) - total := tools + prompt - t.Logf("the fixed prefix is %d bytes (~%d tokens): prompt %d + tools %d over %d tools", - total, total/4, prompt, tools, len(definitions)) - // AND WHAT IS STILL OWED IS SAID ON EVERY GREEN RUN, because a debt nobody - // is reminded of is a debt that becomes the new floor. The ratchet passing - // is not the same fact as the prefix being the size it should be. - if total > fixedPrefixTarget { - t.Logf("it is %d bytes over the %d target — see this file's head for the bill "+ - "and who owes it", total-fixedPrefixTarget, fixedPrefixTarget) - } - - if total <= fixedPrefixBudget { + tools := len(block) + total := tools + arm.page + t.Logf("%s is %d bytes (~%d tokens): prompt %d + tools %d over %d tools", + arm.what, total, total/4, arm.page, tools, len(arm.definitions)) + // AND THE DEBT IS ENFORCED, NOT LOGGED. It was a t.Logf, which is invisible: + // nothing in the Makefile passes -v, so the one line saying this prefix is + // thousands of bytes over what it is supposed to be was printed where no + // human and no CI log would ever show it. A debt nobody is reminded of is a + // debt that has quietly become the floor. + // + // So the TARGET is the cap, and the overage is a WAIVER: one dated figure per + // arm, in one place, which may only ever shrink ([prefixWaivers]). That is + // the known-red discipline — `.github/known-red.txt` and its ratchet — applied + // to bytes. A wave that grows the prefix has to raise a waiver, in a diff, with + // its name on it; nothing can drift. + if total <= arm.budget { return } - // THE FAILURE NAMES THE BILL, BIGGEST FIRST. A lane reading this has just // grown one of these lines and has no other way to see which. type weighed struct { name string bytes int } - heaviest := make([]weighed, 0, len(definitions)) - for _, definition := range definitions { + heaviest := make([]weighed, 0, len(arm.definitions)) + for _, definition := range arm.definitions { encoded, err := json.Marshal(definition) if err != nil { t.Fatal(err) @@ -686,20 +742,19 @@ func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { heaviest = append(heaviest, weighed{definition.Function.Name, len(encoded)}) } sort.Slice(heaviest, func(i, j int) bool { return heaviest[i].bytes > heaviest[j].bytes }) - - report := fmt.Sprintf("the fixed prefix is %d bytes (~%d tokens), over its %d budget by %d\n"+ - " prompts/system.md %6d\n"+ + report := fmt.Sprintf("%s is %d bytes (~%d tokens), over its %d budget by %d\n"+ + " the page %6d\n"+ " the tool block %6d over %d tools\n"+ "the heaviest tools:\n", - total, total/4, fixedPrefixBudget, total-fixedPrefixBudget, prompt, tools, len(definitions)) + arm.what, total, total/4, arm.budget, total-arm.budget, arm.page, tools, len(arm.definitions)) for index, tool := range heaviest { if index == 8 { break } report += fmt.Sprintf(" %-20s%6d\n", tool.name, tool.bytes) } - t.Fatalf("%severy byte here is sent again on every request of every turn, so take the "+ - "addition back out of something that already says it rather than raising the budget", report) + t.Fatalf("%sthe cap is the %d target plus a waiver of %d, and a waiver only ever shrinks "+ + "([prefixWaivers]). %s", report, arm.target, arm.budget-arm.target, arm.howToPay) } // ── the lean arm ──────────────────────────────────────────────────────────── @@ -744,63 +799,23 @@ func TestTheLeanPrefixStaysUnderItsBudget(t *testing.T) { if !agent.config.promptProfile().lean() { t.Fatalf("a %d-token window did not resolve to the lean profile, so this test is weighing the wrong arm", leanWindow) } - - definitions := widestBelt(t, agent) - block, err := json.Marshal(definitions) - if err != nil { - t.Fatal(err) - } // AND `ask` IS IN THE BLOCK, because it is pre-armed rather than shelved - // (promptprofile.go's [Config.prearmedGroups]). A lean belt that had to load - // its way to a question would be measured lighter here and be unable to ask - // one, which is the one regression this number could hide. + // ([Config.prearmedGroups]). A lean belt that had to load its way to a + // question would be measured lighter here and be unable to ask one, which is + // the one regression this number could hide. if !agent.hasTool("ask") { t.Fatal("`ask` is not carried on a lean belt: a one-call-per-message model cannot load-then-ask") } - prompt := len(renderSystemAt(atAFixedPlace(agent.config), time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC))) - tools := len(block) - total := tools + prompt - t.Logf("the lean prefix is %d bytes (~%d tokens): prompt %d + tools %d over %d tools", - total, total/4, prompt, tools, len(definitions)) - // AND THE DEBT IS SAID ON EVERY GREEN RUN, for [fixedPrefixTarget]'s reason: - // a ratchet passing is not the same fact as the number being what it should - // be, and this arm's shortfall is owed by the person with the least room. - if total > leanPrefixTarget { - t.Logf("it is %d bytes over the %d target — see [leanPrefixBudget] for the bill "+ - "and who owes it", total-leanPrefixTarget, leanPrefixTarget) - } - - if total <= leanPrefixBudget { - return - } - heaviest := make([]struct { - name string - bytes int - }, 0, len(definitions)) - for _, definition := range definitions { - encoded, err := json.Marshal(definition) - if err != nil { - t.Fatal(err) - } - heaviest = append(heaviest, struct { - name string - bytes int - }{definition.Function.Name, len(encoded)}) - } - sort.Slice(heaviest, func(i, j int) bool { return heaviest[i].bytes > heaviest[j].bytes }) - report := fmt.Sprintf("the lean prefix is %d bytes (~%d tokens), over its %d budget by %d\n"+ - " the page %6d\n"+ - " the tool block %6d over %d tools\n"+ - "the heaviest tools:\n", - total, total/4, leanPrefixBudget, total-leanPrefixBudget, prompt, tools, len(definitions)) - for index, tool := range heaviest { - if index == 8 { - break - } - report += fmt.Sprintf(" %-20s%6d\n", tool.name, tool.bytes) - } - t.Fatalf("%sthis arm's number only ever comes down: shelve the verb, cut the law that is stated twice, "+ - "or leave it — never raise the budget", report) + reportPrefix(t, prefixArm{ + what: "the lean prefix", + definitions: widestBelt(t, agent), + page: len(renderSystemAt(atAFixedPlace(agent.config), time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC))), + budget: leanPrefixBudget, + target: leanPrefixTarget, + owed: "see [leanPrefixBudget] for the bill and who owes it", + howToPay: "this arm's number only ever comes down: shelve the verb, cut the law that is " + + "stated twice, or leave it — never raise the budget", + }) } // TestAFrontierShapeIsUntouchedByTheProfile is the other half of the deal. diff --git a/internal/session/prompt_belt_test.go b/internal/session/prompt_belt_test.go index 4b0762f590..e93ee7ce6e 100644 --- a/internal/session/prompt_belt_test.go +++ b/internal/session/prompt_belt_test.go @@ -62,6 +62,32 @@ type mintedShape struct { page string } +// buildShippedConversation is the shipping conversation, fully wired, and it is +// A NAMED FUNCTION so that more than one row of [beltShapes] can be it. The lean +// row is this shape with the window changed and nothing else; it used to build a +// bare config of its own, which meant two rows of one table disagreed about what +// "the shipping door" means and only one of them was a conversation anybody has. +func buildShippedConversation(t *testing.T, config *Config) { + config.Memory = openTestBrain(t) + config.connectHub = &fakeHub{connected: true, account: "you@example.test"} + config.HarnessStore = subharness.At(t.TempDir()) + config.RunHarness = func(context.Context, string, string, string, func(subharness.Trail)) (string, subharness.Usage, error) { + return "", subharness.Usage{}, nil + } + config.OrchestrateRunner = func(context.Context, string, string, float64) (string, error) { return "", nil } + config.BashBackgroundAfterSeconds = configpkg.DefaultBashBackgroundAfter + // AND THE TWO SEAMS THAT ARE THE REST OF THE UNIVERSE. The big machines + // and `stand` are conditional on somebody being there to answer a card + // and on there being a store to arm one in (tools.go), and a universe + // built without them would be a universe that could not tell a tool + // nobody named from a tool nobody has. + config.AskConsent = true + config.Standing = &Standing{} + config.standingItems = &fakeStanding{} + config.Subharnesses = registryWith(t, &fakeGeneralist{}, &fakeRunner{manifest: theProgram()}) + config.HarnessCards = true +} + // beltShapes is every shape, and each is built the way its own door builds it — // task_run.go for a node, standing_run.go for a check — so that a door that // changes what it hands down changes this test's answer too. @@ -69,28 +95,8 @@ var beltShapes = []beltShape{{ // The shipping conversation, fully wired: a store behind memory, an // accounts hub, the harness machines. This is the fullest belt there is and // it is what the universe of tool names is built from. - name: "a conversation that remembers", - build: func(t *testing.T, config *Config) { - config.Memory = openTestBrain(t) - config.connectHub = &fakeHub{connected: true, account: "you@example.test"} - config.HarnessStore = subharness.At(t.TempDir()) - config.RunHarness = func(context.Context, string, string, string, func(subharness.Trail)) (string, subharness.Usage, error) { - return "", subharness.Usage{}, nil - } - config.OrchestrateRunner = func(context.Context, string, string, float64) (string, error) { return "", nil } - config.BashBackgroundAfterSeconds = configpkg.DefaultBashBackgroundAfter - // AND THE TWO SEAMS THAT ARE THE REST OF THE UNIVERSE. The big machines - // and `stand` are conditional on somebody being there to answer a card - // and on there being a store to arm one in (tools.go), and a universe - // built without them would be a universe that could not tell a tool - // nobody named from a tool nobody has. - config.AskConsent = true - config.Standing = &Standing{} - config.standingItems = &fakeStanding{} - config.Subharnesses = registryWith(t, &fakeGeneralist{}, &fakeRunner{manifest: theProgram()}) - config.HarnessCards = true - }, -}, { + name: "a conversation that remembers", + build: buildShippedConversation}, { // The same door with memory off, which is what --once and a session opened // against no store get: no brain, so no `remember` and no // `search_conversations`. @@ -141,7 +147,15 @@ var beltShapes = []beltShape{{ // exactly what a second partition can break. name: "a lean conversation on a small window", build: func(t *testing.T, config *Config) { - config.ContextWindow = 16_000 + // IT IS THE SHIPPED SHAPE WITH THE WINDOW CHANGED, and one row of this + // table is not allowed to disagree with another about what "the shipping + // door" means. This built a bare config until #996 — no store, no hub, no + // standing items, no saved programs — while the gate that bounds the lean + // prefix built the full one, so the two were measuring different + // conversations under the same name and only one of them was a + // conversation anybody has. + buildShippedConversation(t, config) + config.ContextWindow = leanWindow }, }, { // A standing check's probe (standing_run.go): the parent's config with the diff --git a/internal/session/recovery_law_test.go b/internal/session/recovery_law_test.go index f4dc261176..012c307f83 100644 --- a/internal/session/recovery_law_test.go +++ b/internal/session/recovery_law_test.go @@ -210,7 +210,7 @@ func sessionSources(t *testing.T) map[string]*ast.File { if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } - parsed, err := parser.ParseFile(fset, filepath.Join(".", name), nil, parser.ParseComments) + parsed, err := parser.ParseFile(fset, filepath.Join(".", name), nil, parser.ParseComments|parser.SkipObjectResolution) if err != nil { t.Fatalf("parse %s: %v", name, err) } diff --git a/internal/session/repair_role.go b/internal/session/repair_role.go index 1a44ffcffe..77b4634e7d 100644 --- a/internal/session/repair_role.go +++ b/internal/session/repair_role.go @@ -51,25 +51,23 @@ import ( "github.com/Agent-Field/aforge-v2/internal/roles" ) -// roleRepair is the hands that fix what the cheap hands got wrong. +// The hands that fix what the cheap hands got wrong are [roles.RoleRepair] — +// declared there with every other role's word, which carries the reasoning for +// the tier — and REGISTERED HERE, where the call is. // -// It is DECLARED HERE rather than in internal/roles, which is the arrangement -// that package's own header asks for: the registry is open, and a package that -// adds an auxiliary call declares its role in the file that owns the call. The -// tier is HIGH and not the mastermind's, for [roles.RoleCareful]'s reason — a -// repair round is many turns of ordinary work done by a model that can be -// trusted with something subtle, not one answer that decides what every other -// call does. -const roleRepair = roles.Role("repair") - +// THE WORD AND THE REGISTRATION ARE DIFFERENT THINGS and this file used to +// conflate them. The registry is open: a package that adds an auxiliary call +// says so at the call, which is why the Register lives here and why the +// description is passed from here — internal/roles cannot write a line for a +// role it does not know about, and a settings row with a blank under its name is +// a call somebody is paying for and cannot read. But the WORD is vocabulary, and +// everything that asks what the roles are reads one file. Spelled here as +// `roles.Role("repair")` it was invisible to all of them. func init() { - // The description is passed here because internal/roles cannot write a line - // for a role it does not know about, and a settings row with a blank under - // its name is a call somebody is paying for and cannot read. - roles.Register(roleRepair, roles.TierHigh, "the second go at work a check found gaps in") + roles.Register(roles.RoleRepair, roles.TierHigh, "the second go at work a check found gaps in") } -// repairModel is which hands one repair round runs on: [roleRepair]'s answer, +// repairModel is which hands one repair round runs on: [roles.RoleRepair]'s answer, // with the model the node is already on as the floor. // // THE FLOOR IS THE WHOLE OF THE ALL-FLASH CASE. A failure to resolve is the same @@ -95,7 +93,7 @@ func (a *Agent) repairModel(node *TaskNode) string { // never an override. return floor } - model, err := roles.Resolve(roles.Source(source), roleRepair, floor) + model, err := roles.Resolve(roles.Source(source), roles.RoleRepair, floor) if err != nil || strings.TrimSpace(model) == "" { return floor } diff --git a/internal/session/task_cascade_test.go b/internal/session/task_cascade_test.go index daf7d1060a..925756dc84 100644 --- a/internal/session/task_cascade_test.go +++ b/internal/session/task_cascade_test.go @@ -3,7 +3,7 @@ package session // THE CASCADE: WHERE A CHEAP CREW BUYS THE CAREFUL MODEL, AND WHERE IT DOES NOT. // // One escalation exists in this build (repair_role.go): work a checker sent back -// is handed to [roleRepair], which sits on the careful tier. Four things have to +// is handed to [roles.RoleRepair], which sits on the careful tier. Four things have to // hold for that to be an economy rather than a leak, and each of them is a test // here: // @@ -44,18 +44,18 @@ const carefulTier = "test/careful-model" // be paying a thinking model's price for many turns of ordinary editing, which // is the split [roles.TierMastermind] exists to keep. func TestTheRepairRoleSitsOnTheCarefulTier(t *testing.T) { - tier, ok := roles.TierOf(roleRepair) + tier, ok := roles.TierOf(roles.RoleRepair) if !ok { - t.Fatalf("%q is not a registered role, so it resolves to nothing", roleRepair) + t.Fatalf("%q is not a registered role, so it resolves to nothing", roles.RoleRepair) } if tier != roles.TierHigh { - t.Fatalf("%q resolves on %q, want the careful tier", roleRepair, tier) + t.Fatalf("%q resolves on %q, want the careful tier", roles.RoleRepair, tier) } // A ROW A PERSON READS. The settings sheet lists every registered role, and a // role with no line under its name is a call somebody pays for and cannot // read (internal/roles' Describe states the emptiness law about it). - if strings.TrimSpace(roles.Describe(roleRepair)) == "" { - t.Fatalf("%q has no description, so its settings row says only its own name", roleRepair) + if strings.TrimSpace(roles.Describe(roles.RoleRepair)) == "" { + t.Fatalf("%q has no description, so its settings row says only its own name", roles.RoleRepair) } } @@ -91,12 +91,12 @@ func TestARepairRoundResolvesThroughTheRepairRolesTier(t *testing.T) { // role alone moves the round and nothing else in the crew. pinned, _ := cascadeAgent(t, map[string]string{ - roles.TierKey(roles.TierHigh): carefulTier, - roles.PinKey(roleRepair): "vendor/mender", + roles.TierKey(roles.TierHigh): carefulTier, + roles.PinKey(roles.RoleRepair): "vendor/mender", }, taskSpec{title: "the greeting", model: "vendor/flash"}) if got := pinned.repairModel(node); got != "vendor/mender" { - t.Fatalf("a pin on %q resolved to %q", roleRepair, got) + t.Fatalf("a pin on %q resolved to %q", roles.RoleRepair, got) } } diff --git a/internal/session/taxonomy_boundary.go b/internal/session/taxonomy_boundary.go index 616e7f8ca4..8a4b967acd 100644 --- a/internal/session/taxonomy_boundary.go +++ b/internal/session/taxonomy_boundary.go @@ -618,7 +618,7 @@ func (a *Agent) readFinding(node *TaskNode, log io.Writer) taxonomy.Verdict { // under it reads exactly like a finding with none. evidence.TransportSeen = 1 } - verdict := a.classify(evidence, node.runModel(), string(roleRepair)) + verdict := a.classify(evidence, node.runModel(), string(roles.RoleRepair)) switch verdict.Action { case taxonomy.ActionEscalate: // THE TALLY IS NOT TOLD YET. A lift is recorded at the moment it is @@ -646,7 +646,7 @@ func (a *Agent) readPass(node *TaskNode, log io.Writer) taxonomy.Verdict { tally := a.tallyFor(node) evidence := tally.Evidence() evidence.Passed = true - verdict := a.classify(evidence, node.runModel(), string(roleRepair)) + verdict := a.classify(evidence, node.runModel(), string(roles.RoleRepair)) if verdict.Action == taxonomy.ActionDeescalate { tally.Deescalate() fmt.Fprintf(log, "the check passed: the work goes back to %s\n", node.runModel()) From 79546ba3add00af51abb92455200eb48bdb04b72 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:17:18 -0400 Subject: [PATCH 19/26] prefix: the lean page is weighed the same on every machine, to the byte The working directory was a fixture artefact worth tens of bytes; the Workstation line is runtime.GOOS/GOARCH and worth one, which a ratchet with no headroom fails on just as hard. Both are normalised at the one place that needs them to be, and the product still tells the model the truth about the machine it is on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- internal/session/prefixbudget_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 4ee6a692e0..f14f89429a 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -36,6 +36,7 @@ package session import ( "encoding/json" "fmt" + "runtime" "sort" "strings" "testing" @@ -582,9 +583,16 @@ func widestPage() string { // that number is already the same everywhere — and it is an UNDER-count of the // shipped page by those few lines, which is recorded here rather than fixed // because moving it would move a ratchet for a reason that is not growth. -func atAFixedPlace(config Config) Config { +func pageAsWeighed(config Config, now time.Time) int { config.Workspace = "/w" - return config + page := renderSystemAt(config, now) + // AND THE WORKSTATION LINE IS NORMALISED. `- Workstation: %s/%s` is + // runtime.GOOS/GOARCH, which is `darwin/arm64` on one machine and + // `linux/arm64` on another — ONE BYTE, and a ratchet with no headroom fails + // on one byte. It is a fact about the machine the model is told on purpose + // and cannot be made constant in the product, so it is made constant HERE, + // at the only place that needs it to be. + return len(strings.Replace(page, runtime.GOOS+"/"+runtime.GOARCH, "os/arch", 1)) } // widestBelt is the shipped belt weighed as the WIDEST MACHINE pays for it. @@ -809,7 +817,7 @@ func TestTheLeanPrefixStaysUnderItsBudget(t *testing.T) { reportPrefix(t, prefixArm{ what: "the lean prefix", definitions: widestBelt(t, agent), - page: len(renderSystemAt(atAFixedPlace(agent.config), time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC))), + page: pageAsWeighed(agent.config, time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)), budget: leanPrefixBudget, target: leanPrefixTarget, owed: "see [leanPrefixBudget] for the bill and who owes it", From eead0aed885b0525e292da06f705b626b5fe59b2 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:18:04 -0400 Subject: [PATCH 20/26] prefix: the lean waiver shrinks on its first run, and `repair` gets a price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lean figure went 44,989 → 44,920 because the page's working directory stopped being a fixture artefact and the lean arm got its OWN shelf sentence instead of the full belt's. That is sixty-nine bytes off a number that had been measured on a hybrid, and the waiver comes down with it — which is the whole behaviour the ledger exists to have. And `repair` reaches the replay table. It only became visible there when the role stopped being conjured at its call site as roles.Role("repair"): a word spelled that way is invisible to every reader of the vocabulary, which was the defect, and the law that now catches it found this the moment it could see it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- PERF.md | 2 +- cmd/aforge-replay/request.go | 9 ++++++++- internal/session/prefixbudget_test.go | 8 ++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/PERF.md b/PERF.md index 6c9c25565b..eee957c6b9 100644 --- a/PERF.md +++ b/PERF.md @@ -1296,7 +1296,7 @@ anything, which is #576's shape. | | weighed | cap | target owed | | --- | --- | --- | --- | | full (`fixedPrefixBudget`) | **53,141** | 53,141 | 48,000, **5,141** over | -| lean (`leanPrefixBudget`) | **44,989** | 44,989 | 31,500, **13,489** over | +| lean (`leanPrefixBudget`) | **44,920** | 44,920 | 31,500, **13,420** over | **The cap is the target plus a dated waiver, and a waiver only ever shrinks.** `fixedPrefixTarget` and `leanPrefixTarget` are what each arm is supposed to be; diff --git a/cmd/aforge-replay/request.go b/cmd/aforge-replay/request.go index 4f5f05b974..416df97798 100644 --- a/cmd/aforge-replay/request.go +++ b/cmd/aforge-replay/request.go @@ -335,7 +335,14 @@ var callSiteRoles = map[string]lane.Role{ "planner": lane.RoleDesign, "designer": lane.RoleDesign, "division": lane.RoleDesign, - "title": lane.RoleAuxiliary, + // internal/session/taxonomy_boundary.go hands this word to the classifier + // that reads a repair round's evidence, and internal/session/repair_role.go + // resolves the hands the round itself runs on. Both are work inside a task + // with nobody's stream open, which is what an errand IS — the table's own + // default, said out loud because this word only became visible here when the + // role stopped being conjured at its call site as roles.Role("repair"). + "repair": lane.RoleAuxiliary, + "title": lane.RoleAuxiliary, // internal/session/image.go is the only writer of this tag and it sets // lane.RoleTalk, deliberately and with the reasoning beside it: a look at an // image STREAMS INTO THE ROOM the person is reading, delta by delta, during diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index f14f89429a..1e00e34aab 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -405,7 +405,11 @@ const fixedPrefixTarget = 48_000 // already being said twice. Raising one is a decision with somebody's name on it // in a diff, which is the entire difference between a debt and a floor. // -// 2026-09-12, #996. Both arms were weighing a conversation nobody has — no +// 2026-09-12, #996. The lean figure shrank from 13,489 to 13,420 in its own +// first commit, which is the discipline working rather than an edit: fixing the +// page's working directory and giving the lean arm its OWN shelf sentence took +// sixty-nine bytes off a number that had been measured on a hybrid. Both arms +// were weighing a conversation nobody has — no // memory store, no accounts hub, no standing items, no saved programs — so both // waivers are the first honest measurement of a prefix that had never been // weighed, and neither is a wave's overspend. The bill is not spread thin: @@ -414,7 +418,7 @@ const fixedPrefixTarget = 48_000 // person has before they have said anything. const ( fixedPrefixWaiver = 5_141 - leanPrefixWaiver = 13_489 + leanPrefixWaiver = 13_420 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane From 73431dddd70196abd185cbe00a49b98f2360846d Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:18:28 -0400 Subject: [PATCH 21/26] changes: the review's findings, all of them Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/996-auxiliary-inventory.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes/unreleased/996-auxiliary-inventory.md b/docs/changes/unreleased/996-auxiliary-inventory.md index a9df472fd5..99a9044ae4 100644 --- a/docs/changes/unreleased/996-auxiliary-inventory.md +++ b/docs/changes/unreleased/996-auxiliary-inventory.md @@ -13,8 +13,13 @@ invalidates: - "The settings roles list offered a `compaction` row and the crew's careful line read \"audits, compaction, vision\". Neither does; the line reads \"audits, briefs, vision\"." - "`fixedPrefixBudget` was 48,000 and weighed `v3ShapedAgent` — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. It weighs the fully-wired conversation (`shippedShapeAgent`, 23 tools) and is 53,141, which is that shape's measurement on the widest machine and no headroom." - "The shipped prefix was believed to be inside 48,000. It is 53,141 — 5,141 over — and has been for as long as the shape existed. 48,000 is a target the test prints the shortfall against, not a cap anything passes today." - - "A profile whose `models.roles` row pinned `compaction:` was left unreadable by the role deletion: the panel drew no row for it and any other pin re-serialised the whole string through a writer that refused `\"compaction\" is not a role`, so every pin on that machine was unchangeable until config.json was hand-edited. `roles.Retired` is the reading that was missing; `ParseModelRoles` drops retired keys and `writeModelRoles` never writes one back." - - "`leanPrefixBudget` was 31,500 and weighed a lean conversation with no memory store, no accounts hub, no standing items and no saved programs — 16 tools. It weighs the shipped shape on a small window and is 44,989. The arm that protects the person with the LEAST room was out by 13,489 bytes, which is more than the budget it was enforcing." + - "`v3ShapedAgent` claimed to be the conversation the interactive door configures and built its own thin config — 18 tools, no memory store, no accounts hub, no standing items, no saved programs. Four gates weighed the shipping door through it, including both prefix budgets. It is `shippedShapeAgent` now: there is ONE shipped shape." + - "`leanPrefixBudget` and `fixedPrefixBudget` printed their overage with `t.Logf`, which nothing in the Makefile passes `-v` to — the line saying the prefix was thousands of bytes over its target was printed where nobody would see it. The cap is the target plus a dated waiver in one place (`prefixWaivers`), and a waiver only shrinks." + - "A role's word could be declared at its call site, which internal/roles' own header invited. It cannot: everything that reads the vocabulary reads one file, so `spellout`, `sentinel` and `repair` were each priced as errands nobody waits on. All three are declared in `internal/roles` and a law catches the next one in either spelling, including `roles.Role(\"x\")`." + - "The replay table priced `vision` as an auxiliary; `image.go`, its only writer, sets `lane.RoleTalk` — a look at an image streams into the room during the person's own turn." + - "The manual taught a summariser rung for `/compact`, its `session: summarizer returned nothing` refusal, and a focus appended to the summariser's instructions. There is no summariser: a pass stubs tool results and folds the oldest assistant work to one marker line, asks no model, and never folds your own words." + - "A profile whose `models.roles` row pinned `compaction:` was left unreadable by the role deletion: the panel drew no row for it and any other pin re-serialised the whole string through a writer that refused `\"compaction\" is not a role`, so every pin on that machine was unchangeable until config.json was hand-edited. The reading is symmetric now: a pin for any word that is not a role is dropped on read, never written back, and refused only when it is the pin being ADDED. The row draws what is acted on rather than the raw stored text, and the panel says `compaction is no longer a role — that pin is ignored`." + - "`leanPrefixBudget` was 31,500 and weighed a lean conversation with no memory store, no accounts hub, no standing items and no saved programs — 16 tools. It weighs the shipped shape on a small window and is 44,920. The arm that protects the person with the LEAST room was out by 13,420 bytes, which is more than the budget it was enforcing." - "`spellout` priced as an errand nobody was waiting on in every cost report, because its Role constant was declared in internal/session and `roleWords` parses internal/roles. The word is `roles.RoleSpellOut` now, and a law fails the next role declared anywhere else." - "The memory tidy-up, the standing sentinel and the document reader reached `calls.jsonl` with no tag at all. They build their own provider client and never pass the door, so they now say the door's own word through `withPurpose` — `consolidate`, `standing-check`, `document` — and the law fails a file that builds a client and names nothing." - "The fixed prefix was believed to be the same size everywhere. It is not: `grep` says a longer sentence about itself where ripgrep is absent (134 bytes) and `load_capability` lists the tool groups this build has (27). The gate weighs the widest machine — `bare.WidestGrepDescription` is the one place that knows which spelling that is — so the number does not depend on who runs it." From 49209d4a0e37b021df419dd66d2b5c0d8f3065da Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:18:52 -0400 Subject: [PATCH 22/26] prefix: the lean waiver shrinks again, to the byte the page now measures Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- PERF.md | 2 +- docs/changes/unreleased/996-auxiliary-inventory.md | 2 +- internal/session/prefixbudget_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/PERF.md b/PERF.md index eee957c6b9..f0a12d2748 100644 --- a/PERF.md +++ b/PERF.md @@ -1296,7 +1296,7 @@ anything, which is #576's shape. | | weighed | cap | target owed | | --- | --- | --- | --- | | full (`fixedPrefixBudget`) | **53,141** | 53,141 | 48,000, **5,141** over | -| lean (`leanPrefixBudget`) | **44,920** | 44,920 | 31,500, **13,420** over | +| lean (`leanPrefixBudget`) | **44,916** | 44,916 | 31,500, **13,416** over | **The cap is the target plus a dated waiver, and a waiver only ever shrinks.** `fixedPrefixTarget` and `leanPrefixTarget` are what each arm is supposed to be; diff --git a/docs/changes/unreleased/996-auxiliary-inventory.md b/docs/changes/unreleased/996-auxiliary-inventory.md index 99a9044ae4..cda2a82a63 100644 --- a/docs/changes/unreleased/996-auxiliary-inventory.md +++ b/docs/changes/unreleased/996-auxiliary-inventory.md @@ -19,7 +19,7 @@ invalidates: - "The replay table priced `vision` as an auxiliary; `image.go`, its only writer, sets `lane.RoleTalk` — a look at an image streams into the room during the person's own turn." - "The manual taught a summariser rung for `/compact`, its `session: summarizer returned nothing` refusal, and a focus appended to the summariser's instructions. There is no summariser: a pass stubs tool results and folds the oldest assistant work to one marker line, asks no model, and never folds your own words." - "A profile whose `models.roles` row pinned `compaction:` was left unreadable by the role deletion: the panel drew no row for it and any other pin re-serialised the whole string through a writer that refused `\"compaction\" is not a role`, so every pin on that machine was unchangeable until config.json was hand-edited. The reading is symmetric now: a pin for any word that is not a role is dropped on read, never written back, and refused only when it is the pin being ADDED. The row draws what is acted on rather than the raw stored text, and the panel says `compaction is no longer a role — that pin is ignored`." - - "`leanPrefixBudget` was 31,500 and weighed a lean conversation with no memory store, no accounts hub, no standing items and no saved programs — 16 tools. It weighs the shipped shape on a small window and is 44,920. The arm that protects the person with the LEAST room was out by 13,420 bytes, which is more than the budget it was enforcing." + - "`leanPrefixBudget` was 31,500 and weighed a lean conversation with no memory store, no accounts hub, no standing items and no saved programs — 16 tools. It weighs the shipped shape on a small window and is 44,916. The arm that protects the person with the LEAST room was out by 13,416 bytes, which is more than the budget it was enforcing." - "`spellout` priced as an errand nobody was waiting on in every cost report, because its Role constant was declared in internal/session and `roleWords` parses internal/roles. The word is `roles.RoleSpellOut` now, and a law fails the next role declared anywhere else." - "The memory tidy-up, the standing sentinel and the document reader reached `calls.jsonl` with no tag at all. They build their own provider client and never pass the door, so they now say the door's own word through `withPurpose` — `consolidate`, `standing-check`, `document` — and the law fails a file that builds a client and names nothing." - "The fixed prefix was believed to be the same size everywhere. It is not: `grep` says a longer sentence about itself where ripgrep is absent (134 bytes) and `load_capability` lists the tool groups this build has (27). The gate weighs the widest machine — `bare.WidestGrepDescription` is the one place that knows which spelling that is — so the number does not depend on who runs it." diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 1e00e34aab..6f37eb1459 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -418,7 +418,7 @@ const fixedPrefixTarget = 48_000 // person has before they have said anything. const ( fixedPrefixWaiver = 5_141 - leanPrefixWaiver = 13_420 + leanPrefixWaiver = 13_416 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane From 013bc6753ae452b183ea8880126d5139c3bdd98b Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:25:59 -0400 Subject: [PATCH 23/26] roles: the registry is the tier table, and the vocabulary is a different question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REGRESSION I INTRODUCED, CAUGHT BY THE SUITE. Making the read drop any word the REGISTRY does not know threw away `imagegen:paint/model` — a real pin doing real work. A role gets into the registry by registering, which is how it gets a tier, and a role with no tier never does: a painter is chosen by a pin or not at all, since a chat model in tiers.high is not a statement about painting. So the registry was never the answer to "what are the roles". roles.Vocabulary is that answer and roles.Known is the reader's door to it. The list is hand-written, which is a thing that falls behind, so a law parses the file it lives in and fails when a declared role is missing from it or a listed one is not declared. The refusal lists the vocabulary too, so a person is shown `imagegen` as a name they may pin. And the retired-pin test seeds the row the way a profile written before the deletion holds it — straight into the file, because the writer is exactly what would refuse it today and that person never typed it today either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- cmd/aforge-replay/replay_test.go | 32 ++++++++++++++++++++++ internal/config/selfservice_test.go | 29 ++++++++++++++++++++ internal/config/settings.go | 23 ++++++++-------- internal/roles/roles.go | 42 +++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 12 deletions(-) diff --git a/cmd/aforge-replay/replay_test.go b/cmd/aforge-replay/replay_test.go index 80b475bc55..fc39042b30 100644 --- a/cmd/aforge-replay/replay_test.go +++ b/cmd/aforge-replay/replay_test.go @@ -20,6 +20,7 @@ import ( "github.com/Agent-Field/aforge-v2/internal/calllog" "github.com/Agent-Field/aforge-v2/internal/callrows" "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/roles" ) // ── THE INSTRUMENT'S OWN LAW ──────────────────────────────────────────────── @@ -481,6 +482,37 @@ func TestEveryRoleWordIsDeclaredWhereTheVocabularyIs(t *testing.T) { }) } +// TestTheVocabularyListsEveryRoleTheFileDeclares keeps [roles.Vocabulary] whole. +// +// THE REGISTRY IS NOT THE VOCABULARY, and that difference cost a real setting. +// A role gets into the registry by registering, which is how it gets a TIER; a +// role with no tier never registers, and `imagegen` is one — a painter is chosen +// by a pin or not at all. Reading the registry as "what are the roles" threw a +// person's `imagegen:` pin away on the way in. +// +// So internal/roles answers both questions, and the vocabulary is a hand-written +// list, which is a thing that falls behind. This is why it cannot: the same +// parse that reads the file for [roleWords] is checked against what the package +// says it has. +func TestTheVocabularyListsEveryRoleTheFileDeclares(t *testing.T) { + listed := map[string]bool{} + for _, role := range roles.Vocabulary() { + listed[string(role)] = true + } + for _, word := range roleWords() { + if !listed[word] { + t.Errorf("internal/roles declares the role %q and roles.Vocabulary does not list it. "+ + "Every reader that asks what the roles ARE reads that list, and a word missing "+ + "from it is a pin thrown away on the way in.", word) + } + } + if len(listed) != len(roleWords()) { + t.Errorf("roles.Vocabulary lists %d roles and the file declares %d; a word in the list "+ + "that is not a constant is a word nothing can ever write", + len(listed), len(roleWords())) + } +} + // isRoleType reports the `roles.Role` type spelled on a declaration. func isRoleType(expr ast.Expr) bool { named, isSelector := expr.(*ast.SelectorExpr) diff --git a/internal/config/selfservice_test.go b/internal/config/selfservice_test.go index e1ac15d46e..3fc178ed30 100644 --- a/internal/config/selfservice_test.go +++ b/internal/config/selfservice_test.go @@ -169,6 +169,13 @@ func TestAPinForARetiredRoleIsDroppedAndTheRestOfTheRowStillMoves(t *testing.T) } // AND THE NEXT CHANGE TO ANY OTHER PIN GOES THROUGH — the whole failure. + // + // The row is seeded the way a profile written before the deletion holds it: + // straight into the file, because the writer is exactly what would refuse it + // today and the person whose machine this is never typed it today either. + if err := writeText(profile, KeyModelRoles, before); err != nil { + t.Fatalf("seeding the profile: %v", err) + } if err := writeModelRoles(profile, before); err != nil { t.Fatalf("a row carrying a retired role was refused: %v", err) } @@ -205,6 +212,28 @@ func TestANewPinForAWordThatIsNotARoleIsStillRefused(t *testing.T) { } } +// AND A ROLE WITH NO TIER IS STILL A ROLE. `imagegen` never calls +// roles.Register — a painter is chosen by a pin or not at all — so it is in the +// vocabulary and not in the registry, and a reader that asked the registry threw +// the pin away on the way in. This is the case that keeps generate_image on a +// belt somebody configured. +func TestAPinForARoleWithNoTierSurvivesTheRead(t *testing.T) { + pins, err := ParseModelRoles("imagegen:paint/model, title:openai/gpt-5-mini") + if err != nil { + t.Fatalf("the row was unreadable: %v", err) + } + if pins["imagegen"] != "paint/model" { + t.Fatalf("the painter pin read back as %q; a role with no tier is still a role", pins["imagegen"]) + } + profile := t.TempDir() + if err := writeModelRoles(profile, "imagegen:paint/model"); err != nil { + t.Fatalf("pinning a painter was refused: %v", err) + } + if stored, _ := persistedString(profile, KeyModelRoles); !strings.Contains(stored, "imagegen") { + t.Fatalf("the painter pin was not written back: %q", stored) + } +} + // A ROLE NOBODY RECOGNISES FAILS SILENTLY FOREVER, which is why this row // validates the name and the model slug beside it does not. A pin written // against a misspelled role is stored, reads back exactly as typed, shows in diff --git a/internal/config/settings.go b/internal/config/settings.go index 96779dc777..997fe4fa84 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -3762,16 +3762,15 @@ func parseModelRoles(raw string) (pins map[string]string, dropped []string, err return pins, dropped, nil } -// knownRole reports a name this build actually resolves a call under. -func knownRole(name string) bool { - key := roles.RoleKey(name) - for _, role := range roles.Registered() { - if roles.RoleKey(string(role)) == key { - return true - } - } - return false -} +// knownRole reports a name this build has a role for. +// +// IT ASKS THE VOCABULARY AND NOT THE REGISTRY, which are two different +// questions. The registry is the TIER TABLE: a role gets into it by registering, +// and a role with no tier never does. `imagegen` is exactly that — a painter is +// chosen by a pin or not at all, since a chat model in tiers.high is not a +// statement about painting — so reading the registry here threw away a person's +// `imagegen:` pin, which is a real setting doing real work. +func knownRole(name string) bool { return roles.Known(name) } // RetiredPinNote is what a surface with a person in front of it says about pins // this build will not act on, and "" when there are none. It is a sentence and @@ -4162,8 +4161,8 @@ func writeModelRoles(profileDir, raw string) error { // roleNames is the registered roles as a sorted list of plain words, for the // refusal above to name them all rather than make somebody go looking. func roleNames() []string { - names := make([]string, 0, len(roles.Registered())) - for _, role := range roles.Registered() { + names := make([]string, 0, len(roles.Vocabulary())) + for _, role := range roles.Vocabulary() { names = append(names, string(role)) } sort.Strings(names) diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 570987ceb6..4dc8344047 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -517,6 +517,48 @@ func Registered() []Role { return roles } +// Vocabulary is EVERY role this build has a word for, which is not the same set +// as [Registered]. +// +// A ROLE WITH NO TIER IS STILL A ROLE. [RoleImageGen] is the standing example: +// a painter is chosen by a pin or not at all — "a chat model in tiers.high is +// not a statement about painting" — so nothing ever calls [Register] for it and +// the registry has never heard of it. Reading the registry as the answer to +// "what are the roles" therefore throws a person's `imagegen:` pin away, which +// is a real setting doing real work. +// +// So this is the vocabulary and the registry is the tier table. They are +// different questions and this package now answers both. +// +// THE LIST IS COMPLETE BECAUSE A LAW SAYS SO, not because somebody remembers: +// cmd/aforge-replay's TestEveryRoleWordIsDeclaredWhereTheVocabularyIs already +// parses this file for every Role constant, and fails when one of them is +// missing from here. +func Vocabulary() []Role { + return append([]Role(nil), vocabulary...) +} + +// Known reports a written name as one of this build's roles, registered or not. +func Known(name string) bool { + key := RoleKey(name) + for _, role := range vocabulary { + if RoleKey(string(role)) == key { + return true + } + } + return false +} + +var vocabulary = []Role{ + RoleAuditor, RoleCaption, RoleCareful, RoleConsolidate, + RoleDesigner, RoleDivision, RoleGuardian, RoleHandoff, + RoleImageGen, RoleIntake, RoleJobName, RoleMarkReader, + RolePlanner, RoleReflex, RoleRepair, RoleRouter, + RoleRouterConfirm, RoleSentinel, RoleShaper, RoleSpeech, + RoleSpellOut, RoleTaskName, RoleTitle, RoleVideo, + RoleVision, RoleWorker, +} + // RoleKey is how a role name written by a person is compared: trimmed and // lower-cased. // From f86c199f90e6b3fb2d5bb68d51462d7e9b8ff63c Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:29:46 -0400 Subject: [PATCH 24/26] session: the two holding actions name the issue that deletes them The `standing-check` rename and [widestBelt] are both measurements around a problem rather than fixes for it, and both are deferred to a cross-package wave rather than grown here. Each now says so where a reader meets it, and names issue #1010 (spine: #928) as the place it is deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- internal/session/clientdoor.go | 6 +++++- internal/session/prefixbudget_test.go | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index c95388c3cc..8b4322fe41 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -489,7 +489,11 @@ const ( // DELIBERATE DIVERGENCE: the call resolves on [roles.RoleSentinel], but the // tag is `standing-check`, because cmd/aforge already writes `sentinel` for // the resident's quorum errand and two different calls under one tag is one - // reading of neither. + // reading of neither. THE RENAME IS A HOLDING ACTION: it moves the collision + // rather than fixing it, and it is fixed by carrying [lane.Role] on the + // calllog record beside the tag, where the two calls are told apart by what + // they ARE and both can spell `sentinel` again. That is issue #1010, whose + // spine is #928. purposeSentinel callPurpose = "standing-check" // purposeDocument is a rung of the document reader — the model's own eyes on // a PDF the `read` tool cannot open as text. It is not a role because the diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 6f37eb1459..a4634babe9 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -92,7 +92,10 @@ import ( // would have to name what this build COULD have rather than what it has, which // is a change to what the model is told and belongs to whoever owns the shelf. // It is bounded — one short clause per group — and it is why this number was -// measured on the machine that carries every group. +// measured on the machine that carries every group. It is filed as issue #1010, +// which is also where the `grep` sentence and [widestBelt] itself are deleted: +// removing the variance at its source is the only thing that makes this number +// one number for everybody. // // ── WHAT IS OWED, AND WHERE IT HAS TO COME FROM ───────────────────────────── // From 727aea5e070fb352744565ffffab02e6a1b5f110 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 11:35:35 -0400 Subject: [PATCH 25/26] config: whether a pin was already there is a question about the person's file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeModelRoles forgives a retired word it FINDS and refuses one being TYPED, and it asked parseModelRoles which of the two it was looking at. That reader drops every retired word by design, so the stored row it handed back could never contain the one word the check existed to find: a name that had sat in the file since before the deletion read as a name typed for the first time, and a machine with an old `compaction:` pin could not save a change to any other pin. The set is read off the stored TEXT now — storedRoleWords — because every reader above that line drops what it does not recognise, and the dropped word is exactly the word this question is about. The asymmetry gets its own test: the same word, forgiven when found and refused when typed. The old test only ever typed a word that was never a role, so the half that broke was the half nothing watched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- internal/config/selfservice_test.go | 34 +++++++++++++++++++++++++++++ internal/config/settings.go | 29 +++++++++++++++++++----- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/internal/config/selfservice_test.go b/internal/config/selfservice_test.go index 3fc178ed30..7aebbad227 100644 --- a/internal/config/selfservice_test.go +++ b/internal/config/selfservice_test.go @@ -212,6 +212,40 @@ func TestANewPinForAWordThatIsNotARoleIsStillRefused(t *testing.T) { } } +// AND THE SAME WORD IS REFUSED WHEN IT IS TYPED AND FORGIVEN WHEN IT IS FOUND, +// which is the whole asymmetry in one test. `compaction` sitting in a file since +// before the deletion is a sentence about a world that used to exist and the +// person cannot act on being told about it; `compaction` typed into the row just +// now is a person asking for something, who is owed an answer while they are +// still looking at the box. +// +// THE BUG THIS PINS: the check asked [parseModelRoles] whether the word was +// already stored, and that reader drops every retired word by design — so the +// stored half read as the typed half and a machine with an old pin could not +// save any change to any other pin. Nothing failed loudly; the row just would +// not move. +func TestATypedRetiredRoleIsRefusedEvenThoughAStoredOneIsForgiven(t *testing.T) { + profile := t.TempDir() + const stored = "compaction:openai/gpt-5-mini, title:openai/gpt-5-mini" + if err := writeText(profile, KeyModelRoles, stored); err != nil { + t.Fatalf("seeding the profile: %v", err) + } + + // Found in the file: forgiven, and the rest of the row moves. + if err := writeModelRoles(profile, stored); err != nil { + t.Fatalf("the stored retired pin was refused: %v", err) + } + + // Typed just now into a row that no longer has it: refused, by name. + err := writeModelRoles(profile, "title:openai/gpt-5-mini, compaction:some/model") + if err == nil { + t.Fatal("a retired role typed into the row was accepted silently") + } + if !strings.Contains(err.Error(), "compaction") { + t.Errorf("the refusal does not name the word the person typed: %v", err) + } +} + // AND A ROLE WITH NO TIER IS STILL A ROLE. `imagegen` never calls // roles.Register — a painter is chosen by a pin or not at all — so it is in the // vocabulary and not in the registry, and a reader that asked the registry threw diff --git a/internal/config/settings.go b/internal/config/settings.go index 997fe4fa84..0ec2727d59 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -4128,11 +4128,13 @@ func writeModelRoles(profileDir, raw string) error { // the word they just wrote, which is where the refusal has always earned its // keep: the settings pair guessed `harness_designer` the first time it was // asked. - stored, _, _ := parseModelRoles(ModelRolesAt(profileDir)) - before := map[string]bool{} - for name := range stored { - before[roles.RoleKey(name)] = true - } + // + // AND "ALREADY STORED" IS A QUESTION ABOUT THE PERSON'S TEXT, NOT ABOUT THE + // PARSE. Asking [parseModelRoles] was the wrong reader to ask: it drops every + // retired word by design, so the stored row it reports back can never contain + // the one word this check is looking for, and a name that had sat in the file + // for months read as a name being typed for the first time. + before := storedRoleWords(profileDir) for _, name := range dropped { if !before[roles.RoleKey(name)] { return fmt.Errorf("%q is not a role. The roles are: %s", name, strings.Join(roleNames(), ", ")) @@ -4158,6 +4160,23 @@ func writeModelRoles(profileDir, raw string) error { return writeText(profileDir, KeyModelRoles, strings.Join(kept, ", ")) } +// storedRoleWords is every role word the stored row NAMES, whether or not this +// build still answers to it — which is the set [writeModelRoles] compares a new +// row against to tell a word being added from a word that was already there. +// +// It reads the text rather than the pins on purpose. Every reader above this +// line drops what it does not recognise, and the word this set exists to hold is +// exactly the word they drop. +func storedRoleWords(profileDir string) map[string]bool { + words := map[string]bool{} + for _, item := range pairItems(ModelRolesAt(profileDir)) { + if name, _, ok := strings.Cut(item, ":"); ok { + words[roles.RoleKey(name)] = true + } + } + return words +} + // roleNames is the registered roles as a sorted list of plain words, for the // refusal above to name them all rather than make somebody go looking. func roleNames() []string { From 8a4762ca197c7fac00899c9e8ba4818d26ddbbf6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Sat, 12 Sep 2026 16:16:30 -0400 Subject: [PATCH 26/26] session: a held-reads pointer sentence pays 150 bytes on both prefix arms (#1015) --- internal/session/prefixbudget_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index c8efaf4d06..b0b3db8d09 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -419,9 +419,17 @@ const fixedPrefixTarget = 48_000 // `stand` is 9,607 bytes, more than a quarter of the full tool block and, on a // sixteen-thousand-token window, roughly one token in six of everything that // person has before they have said anything. +// +// 2026-09-12, the merge with #1015. The prompt diet's read-dedupe wave added +// one sentence to the shared BELT_FACTS — the pointer that answers +// `[already read]` from the conversation rather than fetching the file again — +// and paid exactly 150 bytes on both arms doing it. Both waivers rise by that +// figure here, in this diff, on purpose: the rule says a raise is a decision +// with a name on it, and the alternative was cutting the sentence the merge +// just bought. const ( - fixedPrefixWaiver = 5_141 - leanPrefixWaiver = 13_416 + fixedPrefixWaiver = 5_291 + leanPrefixWaiver = 13_566 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane