diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 19cb54fe3..407037eb3 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -608,7 +608,7 @@ func doErrand(request doRequest) error { settled = router.CrewAccepted } config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.task), settled, outcome.Spend) - fmt.Fprintln(request.stderr, "crew: "+seats.Crew.Line(config.PinMark, outcome.Spend)) + fmt.Fprintln(request.stderr, "crew: "+seats.Crew.Line("", outcome.Spend)) } // THE RUN NAMES ITSELF ON EVERY PATH, including the one where nothing // worked: the id is what joins this object to the rows the model-call log diff --git a/cmd/codeaf/seats_wiring_test.go b/cmd/codeaf/seats_wiring_test.go index 4526341b5..8b237be7e 100644 --- a/cmd/codeaf/seats_wiring_test.go +++ b/cmd/codeaf/seats_wiring_test.go @@ -150,7 +150,7 @@ func TestAnErrandWithNoFlagsRunsARoutedCrewAndSaysSo(t *testing.T) { if err != nil { t.Fatalf("the errand did not settle cleanly: %v\nstderr:\n%s", err, stderr.String()) } - if !strings.Contains(stderr.String(), "crew: ") || !strings.Contains(stderr.String(), config.PinMark+" strong") { + if !strings.Contains(stderr.String(), "crew: ") || !strings.Contains(stderr.String(), "checker strong (pinned)") || strings.Contains(stderr.String(), "๐Ÿ“Œ") { t.Fatalf("the run never said its crew with the pinned checker marked:\n%s", stderr.String()) } @@ -166,6 +166,14 @@ func TestAnErrandWithNoFlagsRunsARoutedCrewAndSaysSo(t *testing.T) { if fields["check_model"] != "vendor/strong" || fields["check_model_source"] != "pinned" { t.Fatalf("--json named the checker %v (%v), want the pin", fields["check_model"], fields["check_model_source"]) } + crew, ok := fields["crew"].(map[string]any) + if !ok { + t.Fatalf("--json crew is %T", fields["crew"]) + } + checker, ok := crew["checker"].(map[string]any) + if !ok || checker["pinned"] != true { + t.Fatalf("--json checker pin is %v", crew["checker"]) + } if fields["model_source"] != "routed" { t.Fatalf("--json named the worker's rung %v, want routed", fields["model_source"]) } diff --git a/docs/HEADLESS.md b/docs/HEADLESS.md index 594cb578b..982f485dc 100644 --- a/docs/HEADLESS.md +++ b/docs/HEADLESS.md @@ -159,12 +159,12 @@ travels whole and the level is applied per call by the role ladder, exactly as it is in the chat; the slug sent to the provider is the model alone. **Every run says which rung answered**, on stderr, before anything else, and -under it the crew line โ€” the class, the worker and its route, the checker (๐Ÿ“Œ -on a pinned seat) and the estimate: +under it the crew line โ€” the class, the worker and its route, the checker +(`(pinned)` after a pinned model) and the estimate: ``` models: worker z-ai/glm-5.3-flash (routed) ยท planner z-ai/glm-5.3-flash (routed) ยท checker moonshotai/kimi-k3 (pinned) -crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker ๐Ÿ“Œ kimi-k3 ยท est $0.023 +crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker kimi-k3 (pinned) ยท est $0.023 ``` When the run ends the `crew:` line is said again with the actual beside the diff --git a/docs/LIMITS.md b/docs/LIMITS.md index a24661363..58ad6f0e0 100644 --- a/docs/LIMITS.md +++ b/docs/LIMITS.md @@ -44,7 +44,7 @@ watching, so it is the one pocket that always has a bottom. **The crew's two limits live on `/crew`, not on this tab.** The **per task** row here reads the per-task limit and opens nothing new: it is set with -`/crew cap task ` or on the panel's cap row (`per task $5 ยท daily none`). +`/crew cap task ` or on the panel's cap row (`per task $5 ยท crew daily cap none`). It is the second rail where `0` is not "no limit" โ€” a task always has a limit, so `0` and `none` are refused and an emptied box is $5 again. The crew's **daily cap** beside it is unset until somebody sets it with `/crew cap `, and diff --git a/docs/changes/unreleased/1518-crew-followups.md b/docs/changes/unreleased/1518-crew-followups.md new file mode 100644 index 000000000..be8e671aa --- /dev/null +++ b/docs/changes/unreleased/1518-crew-followups.md @@ -0,0 +1,17 @@ +--- +kind: fixed +title: crew follow-ups โ€” checker ceiling by seat, route pins beside the tier row, landing line at the end +pr: 1518 +surface: [chat, engine, docs] +invalidates: + - "The checker's spend ceiling was keyed by model and dropped whenever another seat used the same model, so a fresh profile's one-model narrow fix had none. Spend is now attributed by seat (session.SeatCompleter, marked by run.CrewFactory), and the ceiling holds whatever the other seats run." + - "At the crew's daily cap, a call to a model the catalog could not price was sent anyway. A call whose price is unknown is now refused at the cap, helpers included. A known price of nothing (a free pool, a local model, a subscription plan, via config.CrewCallPriceAt) is never stopped by a dollar line." + - "codeaf do's crew line drew a pinned seat as a pushpin emoji, and config.PinMark held it. The line now says `checker kimi-k3 (pinned)`, and PinMark is gone." + - "A `model@provider` pin was stored inside the tier row (models.tiers.worker|mastermind|high). The row now holds the model alone, and the route lives in models.crew.route.. MigrateCrew splits old rows silently, once." + - "A task's landing crew line (`$โ€ฆ (est โ€ฆ) ยท not right? /redo stronger`) rewrote the start line in place, far up the thread. It now moves to the end of the thread, beside the landing, still one line per task." + - "/crew's cap row said `per task $5 ยท daily none` beside the first-run screen's `Daily limit $500`. It now says `crew daily cap`, and names the daily limit on everything codeaf spends under it." + - "On the run road, a model named in the ask and the `task model` row never reached the router, and the crew's routed worker ran instead. Both now seat the worker as a one-task pin. Blank, the row reads `the crew's worker`, not `follows the conversation`." + - "/crew's model list drew prices as bare `$0.15/$0.50`. It now uses the model picker's `$0.15/$0.5 per M`." +--- + +Follow-ups to the Pareto crew (#1436). The manual answers three new questions: how to change a task's worker, whether code goes anywhere that logs it (including the crew's move onto free routes when every paid route is out of reach), and which model is used and what a task costs. diff --git a/internal/config/config.go b/internal/config/config.go index 68188c2d4..e825da549 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -383,6 +383,11 @@ var nonSettingProfileFields = []string{ // turns on and off. KeyCrewFreeRoutes, KeyCrewProvidersOff, + // Route pins are read beside the model-only tier rows and have no + // settings row of their own, so the unread-key notice must know them. + KeyCrewRouteWorker, + KeyCrewRoutePlanner, + KeyCrewRouteChecker, } // retiredProfileKeys are top-level config.json keys that a shipped version once diff --git a/internal/config/crew.go b/internal/config/crew.go index ec5543d53..250e491d0 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -38,12 +38,13 @@ import ( // /crew panel says what is allowed and it persists; the words in an ask say // how hard to try that one task; nothing else sticks. // -// THE PINS LIVE IN THE TIER ROWS THE CREW HAS ALWAYS LIVED IN. The worker +// THE PINNED MODELS LIVE IN THE TIER ROWS THE CREW HAS ALWAYS LIVED IN. The worker // seat is the `worker` tier row, the planner is the `mastermind` row, the // checker is the `high` row โ€” the rows the role ladder already reads, so the // auxiliary calls that ride those tiers (the brief a task is shaped into, an // image read for a model that cannot see one, the plan of an adaptive run) -// follow a pin without a second place to write it. An unwritten row is `auto`: +// follow a pin. A named provider route lives beside that row, so an older +// build reading the tier still sends a model id. An unwritten row is `auto`: // the seat is routed. The reflex and small-work rows are not crew seats and // keep their shipped defaults. @@ -70,8 +71,30 @@ const ( // (crewroute's providers.go says why), and PROFILE-ONLY for the allowed // rule's reason. KeyCrewProvidersOff = "models.crew.providers.off" + // KeyCrewRouteWorker, KeyCrewRoutePlanner and KeyCrewRouteChecker hold a + // seat's pinned ROUTE, as the whole pin `model@provider`, beside the tier + // row that holds the model alone. The route used to be written into the + // tier row itself, and a build from before routed crews reads that row as + // a model id and would send `model@provider` to the provider; kept apart, + // an older build reads a valid id and simply takes its default route. + // The whole pin is kept so a route is applied only to the model it was + // chosen for ([CrewPinAt]). PROFILE-ONLY, like the rows beside them. + KeyCrewRouteWorker = "models.crew.route.worker" + KeyCrewRoutePlanner = "models.crew.route.planner" + KeyCrewRouteChecker = "models.crew.route.checker" ) +// crewRouteKey is the row a seat's pinned route is kept in. +func crewRouteKey(seat crewroute.Seat) string { + switch seat { + case crewroute.Planner: + return KeyCrewRoutePlanner + case crewroute.Checker: + return KeyCrewRouteChecker + } + return KeyCrewRouteWorker +} + // CrewAuto is the word a seat reads when it is not pinned. const CrewAuto = "auto" @@ -131,7 +154,7 @@ type CrewPin struct { Provider string } -// String is the pin the way it is written and stored: `model[@provider]`. +// String is the pin the way a person writes and reads it: `model[@provider]`. func (p CrewPin) String() string { if p.Provider == "" { return p.Model @@ -177,6 +200,16 @@ func CrewPinAt(profileDir string, seat crewroute.Seat) (CrewPin, bool) { if auto || err != nil { return CrewPin{}, false } + // THE ROUTE IS READ FROM ITS OWN ROW, and only for the model it was pinned + // with: an older build that rewrote the tier row to another model left the + // route behind, and a route chosen for one model says nothing about another. + if pin.Provider == "" { + if route, held := persistedString(profileDir, crewRouteKey(seat)); held { + if routed, auto, err := ParseCrewPin(route); err == nil && !auto && routed.Model == pin.Model { + pin.Provider = routed.Provider + } + } + } return pin, true } @@ -217,7 +250,12 @@ func SetCrewPin(profileDir string, seat crewroute.Seat, raw string) error { if err := CrewPinAllowed(profileDir, pin); err != nil { return err } - values := map[string]any{tierKeyFor(CrewSeatTier(seat)): pin.String()} + // THE TIER ROW HOLDS THE MODEL ALONE and the route goes in its own row + // ([KeyCrewRouteWorker] says why); a pin with no route clears the old one. + values := map[string]any{tierKeyFor(CrewSeatTier(seat)): pin.Model, crewRouteKey(seat): removeProfileKey} + if pin.Provider != "" { + values[crewRouteKey(seat)] = pin.String() + } // A profile carrying retired rows is migrated in the same write. for key, value := range legacyCrewClearing(profileDir) { if _, set := values[key]; !set { @@ -231,6 +269,7 @@ func SetCrewPin(profileDir string, seat crewroute.Seat, raw string) error { func ClearCrewPin(profileDir string, seat crewroute.Seat) error { values := legacyCrewClearing(profileDir) values[tierKeyFor(CrewSeatTier(seat))] = removeProfileKey + values[crewRouteKey(seat)] = removeProfileKey return writeProfileValues(profileDir, values) } @@ -239,6 +278,7 @@ func ClearCrewPins(profileDir string) error { values := legacyCrewClearing(profileDir) for _, seat := range crewroute.Seats { values[tierKeyFor(CrewSeatTier(seat))] = removeProfileKey + values[crewRouteKey(seat)] = removeProfileKey } return writeProfileValues(profileDir, values) } @@ -995,7 +1035,7 @@ type CrewState struct { func crewStateKeys() []string { keys := []string{KeyCrewAllowed, KeyCrewCap, KeyCrewTaskCap, KeyCrewProvidersOff, KeyCrewFreeRoutes} for _, seat := range crewroute.Seats { - keys = append(keys, tierKeyFor(CrewSeatTier(seat))) + keys = append(keys, tierKeyFor(CrewSeatTier(seat)), crewRouteKey(seat)) } return keys } diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 425711363..733991454 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -1,15 +1,37 @@ package config import ( + "encoding/json" "errors" + "os" "slices" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/crewroute" ) +// rawCrewRow is one row of the profile file as an older build reads it: the +// stored string itself, not this build's reading of it. +func rawCrewRow(t *testing.T, dir, key string) string { + t.Helper() + raw, err := os.ReadFile(BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + var rows map[string]json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + t.Fatal(err) + } + var value string + if err := json.Unmarshal(rows[key], &value); err != nil { + t.Fatal(err) + } + return value +} + // crewProfile is a profile with an OpenRouter key and a small catalog, the // ordinary state a crew is routed in. Every variable a seat or a key reads // is cleared so the machine running the test cannot answer for it. @@ -143,6 +165,12 @@ func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) { if got := mustRow(t, registry(t, dir), KeyTierWorkerModel).Value(); got != "z-ai/glm-5.3-flash@openrouter" { t.Errorf("the worker row reads %q", got) } + if got := rawCrewRow(t, dir, KeyTierWorkerModel); got != "z-ai/glm-5.3-flash" { + t.Errorf("the older build's tier row reads %q", got) + } + if got := rawCrewRow(t, dir, "models.crew.route.worker"); got != "z-ai/glm-5.3-flash@openrouter" { + t.Errorf("the route row reads %q", got) + } if err := SetCrewAllowed(dir, "open"); err != nil { t.Fatal(err) } @@ -162,11 +190,115 @@ func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) { if _, ok := CrewPinAt(dir, crewroute.Worker); ok { t.Error("`auto` did not unpin the worker") } + if _, held := persistedValue(dir, "models.crew.route.worker"); held { + t.Error("unpin left the route row") + } if got := mustRow(t, registry(t, dir), KeyTierWorkerModel).Value(); got != CrewAuto { t.Errorf("an unpinned worker row reads %q, want auto", got) } } +// A ROUTE PIN THIS BUILD WROTE INTO A TIER ROW IS SPLIT ONCE, SILENTLY: the +// row keeps the model an older build can send, the route moves to its own row, +// a second start writes nothing, undo restores the route, and a route left +// behind by an older build that changed the model is not applied to the new one. +func TestRoutedPinMigrationAndStaleRoute(t *testing.T) { + dir := crewProfile(t) + if err := writeProfileValues(dir, map[string]any{KeyTierHighModel: "moonshotai/kimi-k3@openrouter"}); err != nil { + t.Fatal(err) + } + if pin, ok := CrewPinAt(dir, crewroute.Checker); !ok || pin.String() != "moonshotai/kimi-k3@openrouter" { + t.Fatalf("a pin before migration: %+v, %v", pin, ok) + } + if line, err := MigrateCrew(dir); err != nil || line != "" { + t.Fatalf("split migration: %q, %v", line, err) + } + if got := rawCrewRow(t, dir, KeyTierHighModel); got != "moonshotai/kimi-k3" { + t.Errorf("model row: %q", got) + } + if got := rawCrewRow(t, dir, "models.crew.route.checker"); got != "moonshotai/kimi-k3@openrouter" { + t.Errorf("route row: %q", got) + } + before, err := os.ReadFile(BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + oldTime := time.Unix(1, 0) + if err := os.Chtimes(BudgetConfigPath(dir), oldTime, oldTime); err != nil { + t.Fatal(err) + } + if line, err := MigrateCrew(dir); err != nil || line != "" { + t.Fatalf("second migration: %q, %v", line, err) + } + after, err := os.ReadFile(BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Error("second migration rewrote the profile") + } + info, err := os.Stat(BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(oldTime) { + t.Error("second migration touched the profile") + } + saved := CrewStateAt(dir) + if err := SetCrewPin(dir, crewroute.Checker, "moonshotai/kimi-k3"); err != nil { + t.Fatal(err) + } + if _, held := persistedValue(dir, "models.crew.route.checker"); held { + t.Error("a model-only pin left the route row") + } + if err := RestoreCrewState(dir, saved); err != nil { + t.Fatal(err) + } + if pin, ok := CrewPinAt(dir, crewroute.Checker); !ok || pin.Provider != "openrouter" { + t.Fatalf("undo did not restore the route: %+v, %v", pin, ok) + } + if err := writeProfileValues(dir, map[string]any{KeyTierHighModel: "vendor/new"}); err != nil { + t.Fatal(err) + } + if pin, ok := CrewPinAt(dir, crewroute.Checker); !ok || pin.Model != "vendor/new" || pin.Provider != "" { + t.Fatalf("a stale route followed a new model: %+v, %v", pin, ok) + } + if err := ClearCrewPins(dir); err != nil { + t.Fatal(err) + } + if _, held := persistedValue(dir, "models.crew.route.checker"); held { + t.Error("unpin all left the route row") + } +} + +// The route rows are rows this build reads, so the unread-key notice is quiet +// about them. +func TestCrewRouteRowsAreReadProfileKeys(t *testing.T) { + dir := crewProfile(t) + values := map[string]json.RawMessage{} + for _, key := range []string{"models.crew.route.worker", "models.crew.route.planner", "models.crew.route.checker"} { + values[key] = json.RawMessage(`"vendor/model@openrouter"`) + } + if unread := warnUnreadProfileKeys(dir, values); len(unread) != 0 { + t.Fatalf("route keys are reported unread: %v", unread) + } +} + +// A HEADLESS CREW LINE SAYS A PIN IN WORDS: `(pinned)`, the word the models +// line above it uses, and never a pictograph a plain terminal or a script +// cannot read. +func TestHeadlessCrewReportNamesAPinInWords(t *testing.T) { + d := crewroute.Decision{Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Send: "z-ai/glm-5.3-flash"}, + {Seat: crewroute.Planner, Model: "z-ai/glm-5.3-flash", Send: "z-ai/glm-5.3-flash"}, + {Seat: crewroute.Checker, Model: "moonshotai/kimi-k3", Send: "moonshotai/kimi-k3", Pinned: true}, + }} + report := (Seats{Crew: &d}).Report() + if !strings.Contains(report, "checker kimi-k3 (pinned)") || strings.Contains(report, "๐Ÿ“Œ") { + t.Fatalf("headless crew report: %q", report) + } +} + func TestTheAllowedRuleNarrowsTheCandidates(t *testing.T) { dir := crewProfile(t) ids := func() []string { return crewroute.Names(CrewCandidatesAt(dir)) } @@ -608,3 +740,27 @@ func TestAutoInAHelperRowReadsTheDefaultAndMigrates(t *testing.T) { t.Error("a cleared small-work row was deleted") } } + +// A PRICE OF NOTHING IS A PRICE: a free pool, and a call sent through a +// subscription plan or a model on this machine, bills nothing per token and is +// priced at nothing โ€” known โ€” while a model the catalog has no price for stays +// unknown. The spend guard stops only the unknown at a dollar line. +func TestACallThatBillsNothingIsPricedAtNothing(t *testing.T) { + dir := crewProfile(t) + if err := writeProfileValue(dir, keyModelSources, []PersistedSource{{ID: "z-ai", Written: "z-ai", Key: "zai-key-0123456789", Door: "coding-plan", Order: 1}}); err != nil { + t.Fatal(err) + } + if prompt, completion, _, ok := CrewCallPrice("z-ai/glm-5.3-flash:free"); !ok || prompt != 0 || completion != 0 { + t.Errorf("a free pool is priced %v/%v, known %v โ€” want nothing, known", prompt, completion, ok) + } + price := CrewCallPriceAt(dir) + if prompt, completion, _, ok := price("z-ai/glm-5.3-flash"); !ok || prompt != 0 || completion != 0 { + t.Errorf("a call through the coding plan is priced %v/%v, known %v โ€” want nothing, known", prompt, completion, ok) + } + if prompt, _, _, ok := price("moonshotai/kimi-k3"); !ok || prompt <= 0 { + t.Errorf("a metered catalog model is priced %v, known %v", prompt, ok) + } + if _, _, _, ok := price("vendor/nobody-prices-this"); ok { + t.Error("a model nobody prices reads as known") + } +} diff --git a/internal/config/crewhealth_test.go b/internal/config/crewhealth_test.go index 97bf2fdb3..dd2fd3da4 100644 --- a/internal/config/crewhealth_test.go +++ b/internal/config/crewhealth_test.go @@ -505,17 +505,22 @@ func TestTheSpendLinesASeatCallIsHeldTo(t *testing.T) { t.Fatal(err) } ceilings := CrewSeatCeilings(d) - if est, got := d.Seat(crewroute.Checker).EstUSD, ceilings[d.Seat(crewroute.Checker).Send]; est <= 0 || math.Abs(got-3*est) > 1e-9 { + if est, got := d.Seat(crewroute.Checker).EstUSD, ceilings[crewroute.Checker]; est <= 0 || math.Abs(got-3*est) > 1e-9 { t.Errorf("the open-ended kimi checker's ceiling is $%.3f (estimate $%.3f)", got, d.Seat(crewroute.Checker).EstUSD) } - // One model in every seat: the spend the guard keeps is the model's, so - // the checker's line would stop the worker, and there is none. + // ONE MODEL IN EVERY SEAT STILL HAS A CHECKER CEILING: it is the seat's, + // not the model's, so a fresh profile's one-model narrow fix keeps it. one := crewroute.Decision{Crew: []crewroute.Pick{ {Seat: crewroute.Worker, Model: "vendor/cheap", Send: "vendor/cheap"}, + {Seat: crewroute.Planner, Model: "vendor/cheap", Send: "vendor/cheap"}, {Seat: crewroute.Checker, Model: "vendor/cheap", Send: "vendor/cheap", EstUSD: 0.01}, }} - if got := CrewSeatCeilings(one); got != nil { - t.Errorf("a checker sharing the worker's model has a ceiling: %v", got) + if got := CrewSeatCeilings(one)[crewroute.Checker]; got != 0.05 { + t.Errorf("a shared model's checker ceiling is %v, want $0.05", got) + } + one.Crew[2].EstUSD = 0.04 + if got := CrewSeatCeilings(one)[crewroute.Checker]; math.Abs(got-0.12) > 1e-9 { + t.Errorf("a shared model's checker ceiling is %v, want $0.12", got) } } diff --git a/internal/config/crewmigrate.go b/internal/config/crewmigrate.go index 8efc5f1b4..e41cdb87d 100644 --- a/internal/config/crewmigrate.go +++ b/internal/config/crewmigrate.go @@ -79,9 +79,9 @@ func legacyCrewClearing(profileDir string) map[string]any { return values } -// MigrateCrew writes a retired crew's reading down, once, and answers the one -// line that says so โ€” empty for a profile with nothing to migrate, which is -// every profile after the first run of this build. +// MigrateCrew writes a retired crew's reading down, once, and splits any old +// model@provider tier row into a model row and a route row. The split alone +// answers no line because it changes no route the person chose. // // THE LINE IS SAID ONCE BECAUSE THE MIGRATION IS DONE ONCE: after it, the // retired rows are gone and nothing is left to say it about. It names what @@ -89,12 +89,30 @@ func legacyCrewClearing(profileDir string) map[string]any { // the half that did not. func MigrateCrew(profileDir string) (string, error) { values := legacyCrewClearing(profileDir) + retired := len(values) > 0 + // A PRIOR BUILD WROTE THE ROUTE INTO THE MODEL ROW. Split it without a + // notice because the person's pin and the route it takes have not changed. + for _, seat := range crewroute.Seats { + row := tierKeyFor(CrewSeatTier(seat)) + raw, held := persistedString(profileDir, row) + if !held { + continue + } + pin, auto, err := ParseCrewPin(raw) + if err == nil && !auto && pin.Provider != "" { + values[row] = pin.Model + values[crewRouteKey(seat)] = pin.String() + } + } if len(values) == 0 { return "", nil } if err := writeProfileValues(profileDir, values); err != nil { return "", err } + if !retired { + return "", nil + } // The seats in the order the manual and the panel list them. var kept []string pins := CrewPinsAt(profileDir) diff --git a/internal/config/crewspend.go b/internal/config/crewspend.go index 384729c4e..9f41efdd1 100644 --- a/internal/config/crewspend.go +++ b/internal/config/crewspend.go @@ -3,6 +3,9 @@ package config import ( "fmt" "math" + "strconv" + "strings" + "sync" "github.com/Agent-Field/codeaf/internal/crewroute" ) @@ -12,9 +15,14 @@ import ( // seat's own ceiling on one task. // CrewCallPrice is a model's prices per token โ€” prompt, completion, cache -// read โ€” as the catalog or the evidence table knows them. ok is false for a -// model neither prices, and for a free pool. +// read โ€” as the catalog or the evidence table knows them. ok is false only for +// a price NOBODY KNOWS: a model the catalog does not list, lists with no price, +// or lists at nothing without being a free pool. A free pool's price is known, +// and it is nothing. func CrewCallPrice(model string) (prompt, completion, cacheRead float64, ok bool) { + if crewroute.IsFree(model) { + return 0, 0, 0, true + } m, known := crewCatalogModel(model) if !known || (m.PromptPrice <= 0 && m.CompletionPrice <= 0) { return 0, 0, 0, false @@ -22,6 +30,29 @@ func CrewCallPrice(model string) (prompt, completion, cacheRead float64, ok bool return m.PromptPrice, m.CompletionPrice, m.CacheReadPrice, true } +// CrewCallPriceAt is [CrewCallPrice] on one profile's connections: a call SENT +// THROUGH A SUBSCRIPTION PLAN OR TO A MODEL ON THIS MACHINE bills nothing per +// token, so it is priced at nothing whatever the catalog lists for the model โ€” +// a z-ai coding plan answers `z-ai/โ€ฆ` ids the catalog prices as metered. The +// connections are read once, on the first call priced. +func CrewCallPriceAt(profileDir string) func(model string) (prompt, completion, cacheRead float64, ok bool) { + var once sync.Once + nothing := map[string]bool{} + return func(model string) (float64, float64, float64, bool) { + once.Do(func() { + for _, p := range CrewProvidersAt(profileDir) { + if p.Kind == crewroute.Plan || p.Kind == crewroute.Local { + nothing[strings.ToLower(strings.TrimSpace(p.Written))] = true + } + } + }) + if slash := strings.Index(model, "/"); slash > 0 && nothing[strings.ToLower(strings.TrimSpace(model[:slash]))] { + return 0, 0, 0, true + } + return CrewCallPrice(model) + } +} + // CrewSpendCap is the day's cap a crew's seat calls are held to, and the one // sentence a call it stops ends on: the crew's own daily cap // ([CrewCapAt]), or โ€” where withDaily says the run is bound by it โ€” the @@ -64,43 +95,49 @@ func CrewTaskMoney(usd float64) string { } // CrewSeatCeilings is each seat's own spend ceiling on one task, keyed by the -// ids the seat is asked for: the CHECKER'S, a few times what it is expected to +// seat rather than its model: the CHECKER'S, a few times what it is expected to // cost and never under a floor. A check is the seat whose length nothing else // bounds โ€” it reads until it is satisfied โ€” and one that ran to eleven times // its estimate on a dear model was the whole of a day's overshoot. A seat on a // route that bills nothing has no ceiling. -func CrewSeatCeilings(d crewroute.Decision) map[string]float64 { +func CrewSeatCeilings(d crewroute.Decision) map[crewroute.Seat]float64 { checker := d.Seat(crewroute.Checker) if checker.EstUSD <= 0 { return nil } - ceiling := checker.EstUSD * crewCheckCeilingTimes - if ceiling < crewCheckCeilingFloor { - ceiling = crewCheckCeilingFloor - } - // A CHECKER ON A MODEL ANOTHER SEAT ALSO SITS has no ceiling: the guard - // keeps a model's spend, not a seat's, and a crew whose three seats are one - // model would stop its WORKER at the checker's line. - for _, pick := range d.Crew { - if pick.Seat != crewroute.Checker && (pick.Send == checker.Send || pick.Model == checker.Model) { - return nil - } - } - out := map[string]float64{} - for _, id := range []string{checker.Send, checker.Model} { - if id != "" { - out[id] = ceiling - } + ceiling := checker.EstUSD * CrewCheckCeilingTimes + if ceiling < CrewCheckCeilingFloor { + ceiling = CrewCheckCeilingFloor } - return out + // THE CEILING BELONGS TO THE SEAT, NOT TO A MODEL. A fresh profile's + // narrow fix can put one model in all three seats, and a ceiling keyed by + // model had to be dropped there or it would have stopped the worker at the + // checker's line. The guard attributes each call to the seat that made it + // (internal/session's SeatCompleter), so the checker keeps its ceiling + // whatever the other seats run, and across a fallback to another model. + return map[crewroute.Seat]float64{crewroute.Checker: ceiling} } // CrewCheckCeilingAction is the sentence a check its ceiling ends says, with -// the ceiling's dollars. -const CrewCheckCeilingAction = "the check stopped at its spend ceiling of $%.2f, three times its estimate, before it finished" +// the ceiling's dollars. The multiplier is spelled from +// [CrewCheckCeilingTimes], so the sentence cannot promise a figure the guard +// does not hold. +var CrewCheckCeilingAction = "the check stopped at its spend ceiling of $%.2f, " + + crewCeilingTimesWord() + " times its estimate, before it finished" // The checker's ceiling: how many times its estimate, and the least it is. +// The manual quotes both (internal/manual's truth_test.go holds it to them). const ( - crewCheckCeilingTimes = 3.0 - crewCheckCeilingFloor = 0.05 + CrewCheckCeilingTimes = 3.0 + CrewCheckCeilingFloor = 0.05 ) + +// crewCeilingTimesWord is the multiplier as a sentence says it: a small whole +// number in words, the way prose spells a count, and anything else in digits. +func crewCeilingTimesWord() string { + words := []string{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"} + if n := int(CrewCheckCeilingTimes); float64(n) == CrewCheckCeilingTimes && n >= 0 && n < len(words) { + return words[n] + } + return strconv.FormatFloat(CrewCheckCeilingTimes, 'f', -1, 64) +} diff --git a/internal/config/seats.go b/internal/config/seats.go index b9127f2f6..d2b76f2bb 100644 --- a/internal/config/seats.go +++ b/internal/config/seats.go @@ -195,13 +195,9 @@ func (s Seats) Report() string { if s.Crew == nil { return s.Line() } - return s.Line() + "\ncrew: " + s.Crew.Line(PinMark, -1) + return s.Line() + "\ncrew: " + s.Crew.Line("", -1) } -// PinMark is the mark a pinned seat wears on a headless line. The chat -// surface draws its own, from its icon vocabulary. -const PinMark = "๐Ÿ“Œ" - // SeatFlags are the three seat flags a door took. type SeatFlags struct { Model string diff --git a/internal/config/settings.go b/internal/config/settings.go index 275306095..9291ada12 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2325,12 +2325,12 @@ func (s *Settings) build() []Setting { // are made. Setting{ Key: KeyTaskModel, Category: CategoryTasks, Kind: SettingText, - Label: "task model", EmptyLabel: "follows the conversation", - Hint: "the model a task runs on when you have not asked for another one โ€” " + - "`anthropic/claude-opus-5`. Leave it blank and a task rides the crew's " + - "worker row, and the model you are talking to when that row is blank too. " + - "You can still say which model a particular piece of work should go to, and " + - "the proposal names the one it will start on.", + Label: "task model", EmptyLabel: "the crew's worker", + Hint: "the model a task's worker runs on when you have not asked for another one โ€” " + + "`anthropic/claude-opus-5`. Leave it blank and the worker is the crew's: " + + "your /crew pin, or the model the crew picks for that task. You can still " + + "say which model a particular piece of work should go to, and the proposal " + + "names the one it will start on.", read: func() string { return TaskModelAt(dir) }, write: func(raw string) error { return writeText(dir, KeyTaskModel, raw) }, }, diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index abd0f7876..cf46aab9c 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -1534,3 +1534,17 @@ func TestHeadlessApprovalHonorsExplicitSettingsAndRejectsMalformedValues(t *test } } } + +// THE TASK MODEL ROW SAYS WHAT PICKS A TASK'S MODEL. Blank, a task's worker is +// the crew's โ€” a pin, or the model picked for that task โ€” never the model the +// conversation is on, so the row does not say it follows the conversation. +func TestTheTaskModelRowSaysTheCrewPicksWhenBlank(t *testing.T) { + dir := t.TempDir() + row := mustRow(t, registry(t, dir), KeyTaskModel) + if got := row.Value(); got != "the crew's worker" { + t.Fatalf("a blank task model row reads %q", got) + } + if !strings.Contains(row.Hint, "/crew") || strings.Contains(row.Hint, "the model you are talking to when") { + t.Fatalf("the task model row's hint: %q", row.Hint) + } +} diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 6285533fc..8a19b7c86 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -50,6 +50,9 @@ models.crew.cap models.crew.free_routes models.crew.pick models.crew.providers.off +models.crew.route.checker +models.crew.route.planner +models.crew.route.worker models.crew.source models.crew.task_cap models.fallbacks diff --git a/internal/crewroute/route.go b/internal/crewroute/route.go index 6e32f5f4d..2deb43cd9 100644 --- a/internal/crewroute/route.go +++ b/internal/crewroute/route.go @@ -1269,9 +1269,10 @@ func Gaps(candidates []Candidate) []Gap { // bugfix ยท worker glm-5.3-flash (openrouter) ยท checker glm-5.3-flash ยท $0.021 (est $0.023) // // pinMark is drawn in front of a pinned seat's model; the chat surface hands -// its own glyph and a headless door hands ๐Ÿ“Œ. actual below zero is not known -// yet, and the line then ends on the estimate alone (the emptiness law: an -// unknown is absent, never $0.00). +// its own glyph, and a headless door hands none, so the seat says `(pinned)` +// in words a script and a plain terminal both read. actual below zero is not +// known yet, and the line then ends on the estimate alone (the emptiness law: +// an unknown is absent, never $0.00). func (d Decision) Line(pinMark string, actual float64) string { var b strings.Builder if len(d.Retried) > 0 { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 4bbd96162..9f9515b28 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1843,7 +1843,7 @@ order: rather than the settings registry. **worker**, **checker** and **planner** are the crew's three seats, and on the tab they are -one row, **seats** (`auto ยท 1 pinned ยท models open ยท 3 of 4 providers ยท per task $5 ยท daily $5.00`). +one row, **seats** (`auto ยท 1 pinned ยท models open ยท 3 of 4 providers ยท per task $5 ยท crew daily cap $5.00`). `enter` on it opens the `/crew` panel, where the seats, the allowed models, the providers and the per-task and daily caps are changed; `esc` there comes back to the row. diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 1aa2256d9..7f3fddba5 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -131,14 +131,16 @@ The first control is **Daily limit**, and it opens on the amount that is actuall force โ€” `$500` on a profile that has never chosen one, or your own figure if you have. Its one line reads: -> When codeaf's spending today reaches this amount, new work waits until midnight or you +> When all codeaf spends today reaches this amount, new work waits until midnight or you > raise it. Type a number to change it โ€” the `$` is drawn for you rather than typed โ€” or type **`none`** for no limit, which is a first-class answer and makes the row read `no limit`. `?` on the row adds the part that matters when the bill arrives: *it counts spending codeaf records here. Calls already running can carry it a little past. Your provider -account has its own controls.* It is a backstop against a runaway, not a promise about +account has its own controls. Task crews also have a daily cap of their own, set in /crew.* +The two are different limits: this one covers everything codeaf spends, and `/crew`'s +**crew daily cap** covers only what task crews spend. It is a backstop against a runaway, not a promise about your whole bill. Something that is not a dollar amount is refused in the settings row's own words โ€” `that's not a dollar amount โ€” a number, or none for no limit` โ€” and the screen stays. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 6ecd6af8d..a24b5ba3d 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -525,7 +525,9 @@ can change and one line about the day. โ”‚ โ”‚ โ”‚ models โ€น all โ€บ (96) โ”‚ โ”‚ providers โœ“ openrouter โœ“ z-ai sub โœ“ ollama local โ—‹ my-vllm + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers everything codeaf โ”‚ +โ”‚ spends ยท /budget โ”‚ โ”‚ โ”‚ โ”‚ today $1.84 ยท 14 tasks โ”‚ โ•ฐโ”€ enter change ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ @@ -543,8 +545,11 @@ can change and one line about the day. its plain name, a subscription says `sub`, a model on this machine says `local` when there is room, and a custom endpoint is the name you gave it. The `+` at the end opens `/connect`. On a narrow window the chips fold to `3 of 4 on`. -- **cap** โ€” `per task $5 ยท daily none`: the most one task may spend, and the most crews - may spend in a day, `none` for no daily cap. +- **cap** โ€” `per task $5 ยท crew daily cap none`: the most one task may spend, and the most + crews may spend in a day, `none` for no crew daily cap. Under it, the daily limit on + everything codeaf spends (the first-run screen's **Daily limit**, `/budget`) is named with + its figure โ€” `the daily limit, $500, still covers everything codeaf spends ยท /budget` โ€” + because the crew's cap being `none` does not mean nothing limits the day. - **today** โ€” what crews spent today and how many tasks ran. Spend that is nothing is not drawn (`today 1 task`, never `$0.000`), and a day with nothing in it has no line. @@ -575,7 +580,8 @@ which puts things back exactly as they were. - **enter on a seat** opens that seat's list: `auto โ€” codeaf picks per task` first, then the router's suggestion marked with the star and `suggested`, then every model your - providers reach with its price in and out per million tokens and one provider. Type to + providers reach with its price in and out per million tokens, spelled the way `/model` + spells it (`$3/$15 per M`), and one provider. Type to filter; `enter` pins. **Unpinning is choosing `auto`** โ€” the list opens on it, so it is `enter enter`. `โ†’` on a model shows its routes (`any route ยท cheapest`, then each provider); `enter` on one pins the model to that provider, `โ†` folds them. @@ -683,11 +689,18 @@ to cost, which is never less than what the same model charged for its last call A call that would pass the cap is not made: the task stops on `today's crew spend has reached the daily cap of $5.00 ยท raise it with /crew cap`, with what it had done so far. A checker cut off this way ends on the same sentence. +At the cap, a call whose price codeaf does not know โ€” a model the catalog lists with no +price โ€” is not sent either, and ends on the same sentence. A call that costs nothing โ€” a +free pool, a local model, a subscription plan โ€” is never stopped by the cap, the per-task +limit or the checker's ceiling. **A checker has a ceiling of its own on each task**: three times its estimate, and never less than $0.05. A check that reaches it stops there, on `the check stopped at its spend ceiling of $0.26, three times its estimate, before it finished`, and the task ends unchecked rather than on a bill ten times its estimate. +Only the checker's own calls count toward that ceiling, whatever model the worker or +planner runs, including a crew whose three seats use one model. The ceiling follows +the checker when it moves to another model. This cap is the crew's own. The day's limit under `/settings` โ†’ Spending counts everything codeaf spends, and still applies. @@ -696,7 +709,7 @@ codeaf spends, and still applies. No task may cost more than its limit: **$5** unless you set another. `/crew cap task 10` sets it to $10; on the panel it is the first figure on the **cap** row -(`per task $5 ยท daily none`) โ€” `enter` on the row, `tab` to the per-task box, type, `enter`. +(`per task $5 ยท crew daily cap none`) โ€” `enter` on the row, `tab` to the per-task box, type, `enter`. A task always has a limit: `none` and `0` are refused, and an emptied box is $5 again. Every priced call of one task โ€” each seat's, on every model, and the helpers made for it โ€” @@ -726,7 +739,8 @@ The next task is back on the ordinary pick. ### What a task says about its crew A routed task says its crew in ONE line, under the line that says it started, and the line -is rewritten in place as the task goes. When it starts: +is rewritten in place while the task goes. When the task lands the line moves to the end of +the conversation, beside the landing, so the cost is where you are reading. When it starts: ``` task 12 crew ยท open-ended ยท worker glm-5.3-flash (openrouter) ยท planner kimi-k3 ยท checker โŒ– kimi-k3 ยท est $0.121 @@ -925,6 +939,59 @@ so the picker you opened looking for the change tells you the crew is a separate The one session with no `crew` line at all is a **remote** one opened with `--host`: that crew lives on the other machine. +## How do I change the model the task worker uses โ€” pin the worker, not /model + +A task's **worker** is the seat that does its work, and unless you choose it the crew picks +it for each task from what kind of work it is. `/model` does not move it: `/model` changes +the model you talk to, and nothing about a task. + +To choose the worker yourself: + +- **`/crew pin worker `** โ€” every task from now on, until `/crew unpin worker`. + `/crew pin worker @` pins the route too. `/crew` shows the seat. +- **the `task model` row** under `/settings` โ†’ Tasks โ€” the worker for every task that + names no other model; blank, it reads `the crew's worker`. +- **for one task** โ€” name the model in the ask ("do this on deepseek"), or from a terminal + `codeaf do --pin worker=` or `--model `. + +A model named in the ask wins over the `task model` row, and the row wins over a `/crew` +pin. The task's crew line says which worker it got. + +## Is my code sent anywhere that logs it โ€” where my prompts and code go, free routes + +What you type, and the files and command output the chat or a task reads, go to the model +provider serving each call: the model you talk to, and each task's worker, planner and +checker on the providers you connected. Whether a provider keeps or trains on it is that +provider's policy and your account's settings there. codeaf's own usage counts carry no +content (`codeaf telemetry info` says what they carry). + +**Free routes may log prompts.** A provider's free pool of a model (an OpenRouter `โ€ฆ:free` +id) may log or train on what it is sent. The crew uses free routes only when you turn +them on โ€” the `free routes` switch at the end of `/crew`'s providers list โ€” with one +exception: when every paid route a seat could use is out of reach, a seat moves onto a +free pool rather than leave the task unable to run. "Out of reach" is one of: your +OpenRouter balance is known to be low, a paid call on that provider was refused for +payment, or the provider reports its credit at zero. A seat tries at most three free pools +in one task; your allowed models (`/crew models`) still limit which ones; and the task's +crew line then says `free routes in use (may log prompts)`. A paid call answering again +puts the next task back on paid routes. + +## Which model are you using, and what does a task cost โ€” the chat model and the crew + +The model you are talking to is on the status line at the bottom, and `/model` changes it. +It answers you and runs this conversation's own tool calls. + +A task you hand off does not run on it. Each task gets a **crew** โ€” a worker, a planner and +a checker โ€” picked for each task from what kind of work it is; `/crew` shows the seats and +`/crew pin` fixes one. + +What a task costs is on its crew line. When it starts, the line gives the estimate โ€” +`task 2 crew ยท bugfix ยท worker glm-5.3-flash (openrouter) ยท checker glm-5.3-flash ยท est $0.013` +โ€” and when it lands the line moves to the end of the conversation with what it actually +cost beside the estimate. Every task is held to the per-task limit, and all crews together +to the crew daily cap when one is set (both on `/crew`'s cap row). `/cost` says what this +conversation has spent, and `/spend` the whole machine. + ## Which model does a task run on โ€” why did my task run on glm-5.3-flash and not my chat model **The crew's worker**, unless you said otherwise. The ladder, first answer wins: diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index db0283a9b..869042128 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -394,12 +394,12 @@ rung answered it โ€” and under it the class the task was read as and the estimat ``` models: worker z-ai/glm-5.3-flash (routed) ยท planner z-ai/glm-5.3-flash (routed) ยท checker moonshotai/kimi-k3 (pinned) -crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker ๐Ÿ“Œ kimi-k3 ยท est $0.023 +crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker kimi-k3 (pinned) ยท est $0.023 ``` -A seat you pinned wears `๐Ÿ“Œ`, so `checker ๐Ÿ“Œ kimi-k3` is a checker pinned with `/crew pin`. +A seat you pinned reads `checker kimi-k3 (pinned)` on the headless crew line. When the run ends, the `crew:` line is said again with what it actually cost beside the -estimate: `crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker ๐Ÿ“Œ kimi-k3 ยท $0.021 (est $0.023)`. +estimate: `crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker kimi-k3 (pinned) ยท $0.021 (est $0.023)`. **Every model flag is a one-task pin.** `--model`, `--plan-model` and `--check-model` pin the worker, planner and checker for this run and no other, and `CODEAF_MODEL`, @@ -437,7 +437,7 @@ are a person at a terminal running one thing, so they **warn and go on**: `note: today's crew spend has reached the daily cap ยท this run goes ahead; `codeaf do` would have stopped`. With `--json`, `codeaf do` carries the crew too: `class` (the kind of work the task was read -as), `crew` (each seat's `model`, `provider`, `kind`, `pinned` and `est_usd`), `est_usd` for +as), `crew` (each seat's `model`, `provider`, `kind`, `pinned` and `est_usd`; `crew..pinned` is true for a pin), `est_usd` for the whole crew beside `spend_usd`, `effort` when `--best` or `--cheap` was given, and `check_model` with `check_model_source` beside the worker's `model_source` and the planner's `plan_model_source`. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 6d629b87a..1ce672c08 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4292,7 +4292,8 @@ pinned either way. Asked in words โ€” "do this one properly, cost no object" โ€” that grooms the work sets the hand-off's `effort` field to `best` or `cheap`, and it means the same; it leaves the field out unless you said how hard to try. -**The task says its crew in one line, rewritten in place.** When it starts, it gives the +**The task says its crew in one line, rewritten in place while it runs and moved to the end +of the conversation when it lands.** When it starts, it gives the class it was read as, the models on the seats and what it is expected to cost: ``` diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index ebfe19835..170a845ed 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -282,6 +282,15 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // `no` that used to decline and now corrects, and the model shortlist, // which is a hole in a sentence walked with the arrows rather than a // row of chips answered with the digits. + // THE FRESH-INSTALL CHECK (2026-09-25) asked three things a new user asks + // in their own words, and the chat answered from the wrong pages: the + // per-task `/model` and never `/crew pin worker`; "your code goes to the + // provider and nowhere else", which leaves out the crew's fall onto free + // routes that may log prompts; and a model answer that never named the + // crew or what a task costs. + {"how do I change the model the task worker uses?", "models-and-cost"}, + {"is my code sent anywhere that logs it?", "models-and-cost"}, + {"which model are you using and what does a task cost?", "models-and-cost"}, {"how do I say no to a task it wants to start", "tasks"}, {"I typed no to the task and it started anyway", "tasks"}, {"where did the model chips on the proposal go", "tasks"}, @@ -3347,3 +3356,26 @@ func TestNoChatPageSaysAPlaceCanRefuseToOpen(t *testing.T) { } } } + +// THE FRESH-INSTALL QUESTIONS REACH THE SECTION THAT ANSWERS THEM, not only +// the right page: the worker question has to meet `/crew pin worker`, the +// privacy question the crew's fall onto free routes that may log prompts, and +// the model-and-cost question the crew picked per task and its estimate. +func TestTheFreshInstallQuestionsReachTheirAnswers(t *testing.T) { + for _, probe := range []struct{ asked, says string }{ + {"how do I change the model the task worker uses?", "/crew pin worker"}, + {"is my code sent anywhere that logs it?", "free routes in use (may log prompts)"}, + {"which model are you using and what does a task cost?", "picked for each task"}, + } { + found := false + for _, section := range Chat().Search(probe.asked, DefaultResults) { + if section.Page == "models-and-cost" && strings.Contains(section.Body, probe.says) { + found = true + break + } + } + if !found { + t.Errorf("%q does not reach the models-and-cost section that says %q", probe.asked, probe.says) + } + } +} diff --git a/internal/manual/pages/models.md b/internal/manual/pages/models.md index a7626fa0c..b8bff7ed5 100644 --- a/internal/manual/pages/models.md +++ b/internal/manual/pages/models.md @@ -97,7 +97,7 @@ and under it the crew line: ``` models: worker z-ai/glm-5.3-flash (routed) ยท planner z-ai/glm-5.3-flash (routed) ยท checker moonshotai/kimi-k3 (pinned) -crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker ๐Ÿ“Œ kimi-k3 ยท est $0.023 +crew: bugfix ยท worker glm-5.3-flash (openrouter) ยท checker kimi-k3 (pinned) ยท est $0.023 ``` `codeaf do --json` carries the same facts as `model`, `plan_model`, `check_model`, diff --git a/internal/manual/truth_test.go b/internal/manual/truth_test.go index ebf1d1921..be6d047e3 100644 --- a/internal/manual/truth_test.go +++ b/internal/manual/truth_test.go @@ -113,6 +113,23 @@ func quotedFacts(t *testing.T) []quotedFact { steps, notSteps := counted(sourceNumber(t, "../router/crew.go", "redoOffsetCeiling")) shortlist, notShortlist := counted(sourceNumber(t, "../session/taskmodel.go", "taskModelShortlist")) minutesWord, notMinutesWord := counted(int(standing.Interval / time.Minute)) + ceilingTimes, otherCeilingTimes := counted(int(config.CrewCheckCeilingTimes)) + taskDollars := config.CrewTaskMoney(config.CrewTaskCapDefault) + var otherTaskDollars []string + for amount := 1; amount <= 10; amount++ { + word := config.CrewTaskMoney(float64(amount)) + if word != taskDollars { + otherTaskDollars = append(otherTaskDollars, word) + } + } + ceilingFloor := fmt.Sprintf("$%.2f", config.CrewCheckCeilingFloor) + var otherCeilingFloors []string + for cents := 1; cents <= 10; cents++ { + word := fmt.Sprintf("$0.%02d", cents) + if word != ceilingFloor { + otherCeilingFloors = append(otherCeilingFloors, word) + } + } facts := []quotedFact{{ // THE THINKING WALK HAS ONE OWNER for both surface doors and every page @@ -173,6 +190,38 @@ func quotedFacts(t *testing.T) []quotedFact { fact: "the plugin JSON cap", owner: "skills.maxPluginJSONBytes", value: strconv.Itoa(sourceByteLimit(t, "../skills/regular.go", "maxPluginJSONBytes") / (1 << 20)), quotes: []quotedIn{{"skills-from-other-tools", "over %s MiB"}}, + }, { + fact: "the crew's default per-task limit", owner: "config.CrewTaskCapDefault", + value: taskDollars, others: otherTaskDollars, + quotes: []quotedIn{ + {"models-and-cost", "**cap** โ€” `per task %s ยท crew daily cap none`"}, + {"models-and-cost", "No task may cost more than its limit: **%s** unless you set another"}, + {"models-and-cost", "(`per task %s ยท crew daily cap none`)"}, + {"models-and-cost", "an emptied box is %s again"}, + {"models-and-cost", "this task reached its %s limit ยท raise it in /crew"}, + {"models-and-cost", "held under the %s.00 task limit"}, + {"models-and-cost", "| **per task** | `%s a task`"}, + {"models-and-cost", "## What may a task spend โ€” %s a task unless you set another"}, + {"models-and-cost", "**A task carries a dollar limit of its own: %s unless you set another.**"}, + {"models-and-cost", "`per task` row โ€” `%s a task`"}, + {"commands", "the most one task may spend โ€” %s unless set"}, + {"commands", "the most one task may spend โ€” %s unless set."}, + {"commands", "per task %s ยท crew daily cap $5.00"}, + {"running-from-the-terminal", "**Every run is held to the per-task limit**, %s unless"}, + {"running-from-the-terminal", "this task reached its %s limit ยท raise it in /crew"}, + {"tasks", "**Every task also has a money limit of its own: %s unless you set another**"}, + }, + }, { + fact: "the checker's ceiling multiplier", owner: "config.CrewCheckCeilingTimes", + value: ceilingTimes, others: otherCeilingTimes, + quotes: []quotedIn{ + {"models-and-cost", "**A checker has a ceiling of its own on each task**: %s times its estimate"}, + {"models-and-cost", "ceiling of $0.26, %s times its estimate, before it finished"}, + }, + }, { + fact: "the checker's ceiling floor", owner: "config.CrewCheckCeilingFloor", + value: ceilingFloor, others: otherCeilingFloors, + quotes: []quotedIn{{"models-and-cost", "less than %s. A check that reaches it"}}, }, { // THE CREW IS THREE SEATS, and every page that counts them counts // them from the router's own list. @@ -210,9 +259,9 @@ func quotedFacts(t *testing.T) []quotedFact { }, }, { // The mark a pinned seat wears on a headless line. - fact: "the mark a pinned seat wears headless", owner: "config.PinMark", - value: config.PinMark, - quotes: []quotedIn{{"running-from-the-terminal", "checker %s kimi-k3"}}, + fact: "the mark a pinned seat wears headless", owner: "crewroute.seatModel", + value: "(pinned)", + quotes: []quotedIn{{"running-from-the-terminal", "checker kimi-k3 %s"}}, }, { // The rule an untouched profile allows. fact: "the allowed rule nobody wrote", owner: "config.CrewAllowedAt", diff --git a/internal/run/crew.go b/internal/run/crew.go index 8827bb6ba..270b27ee2 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -157,7 +157,12 @@ func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, return seatlessWorker{tier: tier} } } - return NewBashWorker(store, workspace, model, completerFor(model)) + // EVERY CALL IS MARKED WITH THE SEAT THE TASK SITS, because this is the + // one place that knows it: the spend guard holds the checker to its own + // ceiling by seat, and a crew whose seats share one model would give it + // nothing else to tell a check's call from a worker's. + seat, _ := config.CrewTierSeat(tier) + return NewBashWorker(store, workspace, model, session.SeatCompleter(seat, completerFor(model))) } } diff --git a/internal/run/crew_context_test.go b/internal/run/crew_context_test.go new file mode 100644 index 000000000..0b8978d9d --- /dev/null +++ b/internal/run/crew_context_test.go @@ -0,0 +1,74 @@ +package run + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +// crewCallProbe is a provider that answers nothing and counts what it was asked. +type crewCallProbe struct{ calls int } + +func (p *crewCallProbe) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) { + p.calls++ + return &ai.Response{}, nil +} + +// The completer installed by CrewFactory marks each call before the guard +// sees it. Calls on a shared model reach the worker, while the checker's +// estimate crosses its own line before its first provider call. +func TestCrewFactoryCarriesTheRoleSeatToTheSpendGuard(t *testing.T) { + dir := t.TempDir() + profile := t.TempDir() + rows, _ := json.Marshal(map[string]string{ + config.KeyTierWorkerModel: "vendor/shared", + config.KeyTierHighModel: "vendor/shared", + }) + if err := os.WriteFile(config.BudgetConfigPath(profile), rows, 0o600); err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(dir, "plan.db"), "seat-test", "root", "Root", "check the seat") + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.AddMany([]plandb.TaskSpec{ + {ID: "leaf", Title: "Work", ParentID: store.RootID()}, + {ID: "review", Title: "Review", Role: plandb.RoleCheck}, + }); err != nil { + t.Fatal(err) + } + guard := &session.SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-5, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: 0.01}, + CeilingAction: "checker ceiling $%.2f", + } + probes := []*crewCallProbe{} + factory := CrewFactory(store, dir, profile, Seats{}, func(model string) session.Completer { + p := &crewCallProbe{} + probes = append(probes, p) + return guard.Wrap(model, p) + }) + call := func(id string) error { + worker := factory(*store.Task(id)).(*BashWorker) + _, err := worker.completer.CompleteWithMessages(t.Context(), []ai.Message{{Role: "user"}}) + return err + } + if err := call("leaf"); err != nil || len(probes) != 1 || probes[0].calls != 1 { + t.Fatalf("worker's shared-model call: %v, probes %+v", err, probes) + } + err = call("review") + var stopped session.ErrSpendStopped + if !errors.As(err, &stopped) || len(probes) != 2 || probes[1].calls != 0 { + t.Fatalf("checker crossed its line: %v, probes %+v", err, probes) + } +} diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index e138b2856..4744d4271 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -585,7 +585,7 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, } helper = a.helperGuard(task) var err error - if held, err = helper.before(model, messages, options); err != nil { + if held, err = helper.before(ctx, model, messages, options); err != nil { return nil, model, err } if helper != nil { @@ -607,7 +607,7 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, // 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))...) if helper != nil { - helper.after(model, response, held) + helper.after(ctx, model, response, held) crewTaskOf(ctx).addHelperSpend(response) } return response, called, err diff --git a/internal/session/crewworker_test.go b/internal/session/crewworker_test.go new file mode 100644 index 000000000..0f2307a3e --- /dev/null +++ b/internal/session/crewworker_test.go @@ -0,0 +1,51 @@ +package session + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// A TASK RUNS ON THE MODEL SOMEBODY NAMED FOR IT. The ladder the manual and +// the settings row teach โ€” a model named in the ask, then the `task model` +// row, then the crew's worker โ€” reached the proposal card and the receipt, +// but never the router: the run was seated on the routed worker whatever was +// named. A named model and the task model row now reach the router as a +// one-task pin on the worker, and a task that names nothing is routed. +func TestATaskRunsOnTheModelNamedForIt(t *testing.T) { + var asked []config.CrewAsk + route := func(ask config.CrewAsk) (crewroute.Decision, error) { + asked = append(asked, ask) + return crewroute.Decision{}, nil + } + agent := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), RouteCrew: route}, model: "somelab/the-chat-model"} + + if _, err := agent.routeTaskCrew(t.Context(), 1, "fix the parser", ""); err != nil { + t.Fatal(err) + } + if got := asked[0].Sends[crewroute.Worker]; got != "" { + t.Fatalf("a task that named nothing pinned its worker to %q", got) + } + if _, err := agent.routeTaskCrew(withCrewWish(t.Context(), crewWish{worker: "vendor/named"}), 2, "fix the parser", ""); err != nil { + t.Fatal(err) + } + if got := asked[1].Sends[crewroute.Worker]; got != "vendor/named" { + t.Fatalf("a hand-off that named vendor/named routed its worker as %q", got) + } + agent.mu.Lock() + agent.config.TaskModel = "vendor/task-row" + agent.mu.Unlock() + if _, err := agent.routeTaskCrew(t.Context(), 3, "fix the parser", ""); err != nil { + t.Fatal(err) + } + if got := asked[2].Sends[crewroute.Worker]; got != "vendor/task-row" { + t.Fatalf("the task model row routed the worker as %q", got) + } + if _, err := agent.routeTaskCrew(withCrewWish(t.Context(), crewWish{worker: "vendor/named"}), 4, "fix the parser", ""); err != nil { + t.Fatal(err) + } + if got := asked[3].Sends[crewroute.Worker]; got != "vendor/named" { + t.Fatalf("a named model lost to the task model row: %q", got) + } +} diff --git a/internal/session/seatcompleter.go b/internal/session/seatcompleter.go new file mode 100644 index 000000000..0b4effa5e --- /dev/null +++ b/internal/session/seatcompleter.go @@ -0,0 +1,62 @@ +package session + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// A CALL KNOWS WHICH SEAT MADE IT. +// +// The spend guard (spendguard.go) holds the checker to a ceiling of its own on +// each task. It used to keep that ceiling by MODEL, which works only while no +// other seat shares the checker's model โ€” and a fresh profile's narrow fix puts +// one model in all three seats, so the ceiling had to be dropped exactly where +// the crew is most uniform. The seat is known in one place, where the run +// engine seats a task by its role (internal/run's CrewFactory), so that is where +// every call of the task is marked, and the guard reads the mark rather than +// guessing the seat from the model. A fallback to another model keeps the mark, +// because the seat did not change. + +// crewSeatContextKey carries the seat a run task sits through every call it +// makes, fallbacks included. +type crewSeatContextKey struct{} + +// crewSeatOf is the seat a call was made for, empty for a call no crew seat +// made (an auxiliary call, a probe), which no seat's ceiling holds. +func crewSeatOf(ctx context.Context) crewroute.Seat { + seat, _ := ctx.Value(crewSeatContextKey{}).(crewroute.Seat) + return seat +} + +// SeatCompleter is next with every call marked as the seat's, so a guard +// attributes the call's spend to that seat even when another seat runs the +// same model or a fallback moves the seat to another. It keeps next's model +// chain, because a completer that dropped it would silently end model +// fallback for every task it wraps. +func SeatCompleter(seat crewroute.Seat, next Completer) Completer { + marked := seatCompleter{seat: seat, next: next} + if chain, ok := next.(modelChain); ok { + return seatChain{seatCompleter: marked, chain: chain} + } + return marked +} + +// seatCompleter is one seat's completer with its calls marked. +type seatCompleter struct { + seat crewroute.Seat + next Completer +} + +func (c seatCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + return c.next.CompleteWithMessages(context.WithValue(ctx, crewSeatContextKey{}, c.seat), messages, options...) +} + +// seatChain keeps the model chain of a completer that has one. +type seatChain struct { + seatCompleter + chain modelChain +} + +func (c seatChain) FallbackModels(model string) []string { return c.chain.FallbackModels(model) } diff --git a/internal/session/spendguard.go b/internal/session/spendguard.go index 8ac685005..e57f0e1ce 100644 --- a/internal/session/spendguard.go +++ b/internal/session/spendguard.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/crewroute" ) // A SEAT'S NEXT CALL IS PRICED BEFORE IT IS MADE. @@ -30,7 +31,8 @@ import ( // after, so the next estimate starts from the truth. // SpendPrice is a model's price per token: prompt, completion and cache read. -// ok is false for a model nobody prices, whose calls the guard does not stop. +// ok is false for a model whose price nobody knows; a line already reached +// still stops it. A known price of nothing is never stopped. type SpendPrice func(model string) (prompt, completion, cacheRead float64, ok bool) // SpendDay is today's spend as this process knows it: what the ledger said @@ -114,7 +116,7 @@ func (d *SpendDay) lastCost(model string) float64 { // SpendGuard holds a task's seat calls to the day's cap and to each seat's // own ceiling. The zero parts are off: no Day or no Cap is no day cap, and a -// model with no Ceilings entry has no ceiling of its own. +// seat with no SeatCeilings entry has no ceiling of its own. type SpendGuard struct { Price SpendPrice Day *SpendDay @@ -122,10 +124,10 @@ type SpendGuard struct { // stops ends on. Cap float64 CapAction string - // Ceilings are a model's own spend ceiling on this task, and + // SeatCeilings are a seat's own spend ceiling on this task, and // CeilingAction the sentence (with the ceiling's dollars) a call it stops // ends on. - Ceilings map[string]float64 + SeatCeilings map[crewroute.Seat]float64 CeilingAction string // TaskCap is the most the task may spend in dollars, across every model // this guard prices, and TaskAction the sentence a call it stops ends on. @@ -137,8 +139,9 @@ type SpendGuard struct { // the guard's own, made on its first call. Task *SpendTask - mu sync.Mutex - spent map[string]float64 + mu sync.Mutex + modelSpent map[string]float64 + seatSpent map[crewroute.Seat]*SpendTask } // SpendTask is what one task has spent and holds in flight, across every @@ -193,6 +196,19 @@ func (g *SpendGuard) tally() *SpendTask { return g.Task } +// seatTally holds one seat's spend and in-flight estimates across model changes. +func (g *SpendGuard) seatTally(seat crewroute.Seat) *SpendTask { + g.mu.Lock() + defer g.mu.Unlock() + if g.seatSpent == nil { + g.seatSpent = make(map[crewroute.Seat]*SpendTask) + } + if g.seatSpent[seat] == nil { + g.seatSpent[seat] = &SpendTask{} + } + return g.seatSpent[seat] +} + // ErrSpendStopped is a call the guard did not make. Its text is the one // sentence that says which line it met. type ErrSpendStopped struct{ Action string } @@ -217,7 +233,7 @@ func (g *SpendGuard) Wrap(model string, next Completer) Completer { // before is whether a call to model with these messages may be made, and // what it holds on the day until [SpendGuard.after] settles it. -func (g *SpendGuard) before(model string, messages []ai.Message, options []ai.Option) (float64, error) { +func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Message, options []ai.Option) (float64, error) { if g == nil { return 0, nil } @@ -227,30 +243,55 @@ func (g *SpendGuard) before(model string, messages []ai.Message, options []ai.Op prompt, completion, cacheRead, ok = g.Price(model) } if !ok { - // A call nobody prices is not estimated, but a task already at its - // limit makes no more calls of any kind. + // A CALL WHOSE PRICE NOBODY KNOWS HAS NO ESTIMATE, BUT A LINE ALREADY + // REACHED STOPS IT: a day at its cap, a task at its limit and a checker + // at its ceiling make no more such calls, on the same sentence a priced + // call ends on. Below every line it goes as it always has, and what it + // cost is counted after when the provider says. + if g.Cap > 0 && g.Day.Total() >= g.Cap { + return 0, ErrSpendStopped{Action: g.CapAction} + } if g.TaskCap > 0 && g.tally().Total() >= g.TaskCap { return 0, ErrSpendStopped{Action: g.TaskAction} } + if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 && g.seatTally(seat).Total() >= g.SeatCeilings[seat] { + ceiling := g.SeatCeilings[seat] + return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} + } + return 0, nil + } + if prompt <= 0 && completion <= 0 { + // A CALL THAT COSTS NOTHING IS NEVER STOPPED BY A DOLLAR LINE. A free + // pool, a local model and a subscription plan are priced at nothing, + // and a price of nothing is a price: no cap, limit or ceiling can be + // crossed by it, so none of them holds it. return 0, nil } g.mu.Lock() - seatSpent := g.spent[model] + modelSpent := g.modelSpent[model] g.mu.Unlock() - est := g.estimate(messages, options, prompt, completion, cacheRead, seatSpent > 0) + est := g.estimate(messages, options, prompt, completion, cacheRead, modelSpent > 0) if last := g.Day.lastCost(model); last > est { est = last } - if ceiling := g.Ceilings[model]; ceiling > 0 && seatSpent+est > ceiling { + seat := crewSeatOf(ctx) + ceiling := g.SeatCeilings[seat] + if ceiling > 0 && !g.seatTally(seat).hold(est, ceiling) { return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} } task := g.tally() if !task.hold(est, g.TaskCap) { + if ceiling > 0 { + g.seatTally(seat).settle(est, 0) + } return 0, ErrSpendStopped{Action: g.TaskAction} } held, fits := g.Day.hold(model, est, g.Cap) if !fits { task.settle(est, 0) + if ceiling > 0 { + g.seatTally(seat).settle(est, 0) + } return 0, ErrSpendStopped{Action: g.CapAction} } if g.Day == nil { @@ -279,7 +320,7 @@ func (g *SpendGuard) estimate(messages []ai.Message, options []ai.Option, prompt } // after releases what before held and records what a call to model cost. -func (g *SpendGuard) after(model string, response *ai.Response, held float64) { +func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Response, held float64) { if g == nil { return } @@ -287,6 +328,9 @@ func (g *SpendGuard) after(model string, response *ai.Response, held float64) { if response == nil || response.Usage == nil { g.Day.settle(model, held, 0) task.settle(held, 0) + if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 { + g.seatTally(seat).settle(held, 0) + } return } usd := 0.0 @@ -301,14 +345,17 @@ func (g *SpendGuard) after(model string, response *ai.Response, held float64) { } g.Day.settle(model, held, usd) task.settle(held, usd) + if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 { + g.seatTally(seat).settle(held, usd) + } if usd <= 0 { return } g.mu.Lock() - if g.spent == nil { - g.spent = map[string]float64{} + if g.modelSpent == nil { + g.modelSpent = map[string]float64{} } - g.spent[model] += usd + g.modelSpent[model] += usd g.mu.Unlock() } @@ -328,12 +375,12 @@ func (c guardedCompleter) CompleteWithMessages(ctx context.Context, messages []a if request.Model != "" { model = request.Model } - held, err := c.guard.before(model, messages, options) + held, err := c.guard.before(ctx, model, messages, options) if err != nil { return nil, err } response, err := c.next.CompleteWithMessages(ctx, messages, options...) - c.guard.after(model, response, held) + c.guard.after(ctx, model, response, held) return response, err } diff --git a/internal/session/spendguard_test.go b/internal/session/spendguard_test.go index e795e9685..961853fe0 100644 --- a/internal/session/spendguard_test.go +++ b/internal/session/spendguard_test.go @@ -37,8 +37,8 @@ func TestTheCheckerStopsAtItsCeilingAndTheDayAtItsCap(t *testing.T) { messages := []ai.Message{textMessage("user", strings.Repeat("the diff and the tests ", 800))} checker := &spendingCompleter{usd: 0.06} guard := &SpendGuard{Price: kimiPrice, Day: NewSpendDay(0), Cap: 1, CapAction: "today's spending limit of $1.00 is reached ยท raise it with /budget", - Ceilings: map[string]float64{"moonshotai/kimi-k3": 0.0882 * 3}, CeilingAction: "the check stopped at its spend ceiling of $%.2f"} - seat := guard.Wrap("moonshotai/kimi-k3", checker) + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: 0.0882 * 3}, CeilingAction: "the check stopped at its spend ceiling of $%.2f"} + seat := SeatCompleter(crewroute.Checker, guard.Wrap("moonshotai/kimi-k3", checker)) var stopped ErrSpendStopped for i := 0; i < 20; i++ { if _, err := seat.CompleteWithMessages(t.Context(), messages, ai.WithMaxTokens(2000)); err != nil { @@ -105,9 +105,9 @@ func TestATaskStopsAtItsLimitAcrossEveryModel(t *testing.T) { // The checker's own ceiling still binds inside the task's limit. ceilinged := &SpendGuard{Price: kimiPrice, Day: NewSpendDay(0), TaskCap: 5, TaskAction: config.CrewTaskCapAction(5), - Ceilings: map[string]float64{"moonshotai/kimi-k3": 0.5}, CeilingAction: "the check stopped at its spend ceiling of $%.2f"} + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: 0.5}, CeilingAction: "the check stopped at its spend ceiling of $%.2f"} check := &spendingCompleter{usd: 0.4} - seat := ceilinged.Wrap("moonshotai/kimi-k3", check) + seat := SeatCompleter(crewroute.Checker, ceilinged.Wrap("moonshotai/kimi-k3", check)) var err error for i := 0; i < 5 && err == nil; i++ { _, err = seat.CompleteWithMessages(t.Context(), messages, ai.WithMaxTokens(2000)) @@ -144,3 +144,111 @@ func TestTheTaskLimitIsOnEveryGuard(t *testing.T) { t.Fatalf("a helper for no task is held to a task limit of %v", loose.TaskCap) } } + +// A SHARED MODEL DOES NOT SHARE A SEAT'S CEILING. The checker also keeps its +// tally when its next call goes through another model on the fallback ladder. +func TestSharedModelCheckerCeilingFollowsTheSeat(t *testing.T) { + d := crewroute.Decision{Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "vendor/cheap", Send: "vendor/cheap"}, + {Seat: crewroute.Planner, Model: "vendor/cheap", Send: "vendor/cheap"}, + {Seat: crewroute.Checker, Model: "vendor/cheap", Send: "vendor/cheap", EstUSD: 0.01}, + }} + guard := CrewSpendGuard(t.TempDir(), d, false) + guard.Price = func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true } + worker := &spendingCompleter{usd: 0.02} + check := &spendingCompleter{usd: 0.02} + workCall := SeatCompleter(crewroute.Worker, guard.Wrap("vendor/cheap", worker)) + checkCall := SeatCompleter(crewroute.Checker, guard.Wrap("vendor/cheap", check)) + messages := []ai.Message{textMessage("user", "check")} + for i := 0; i < 4; i++ { + if _, err := workCall.CompleteWithMessages(t.Context(), messages, ai.WithMaxTokens(20000)); err != nil { + t.Fatalf("worker call %d: %v", i, err) + } + } + for i := 0; i < 2; i++ { + if _, err := checkCall.CompleteWithMessages(t.Context(), messages, ai.WithMaxTokens(20000)); err != nil { + t.Fatalf("checker call %d: %v", i, err) + } + } + fallback := SeatCompleter(crewroute.Checker, guard.Wrap("vendor/fallback", check)) + _, err := fallback.CompleteWithMessages(t.Context(), messages, ai.WithMaxTokens(20000)) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.Action != "the check stopped at its spend ceiling of $0.05, three times its estimate, before it finished" || check.calls != 2 || worker.calls != 4 { + t.Fatalf("fallback after a shared model: %v, %d checker and %d worker calls", err, check.calls, worker.calls) + } + // A call without a seat still has the task limit, but not this ceiling. + if _, err := guard.Wrap("vendor/cheap", worker).CompleteWithMessages(t.Context(), messages, ai.WithMaxTokens(20000)); err != nil { + t.Fatalf("a call without a crew seat: %v", err) + } +} + +// THE SEAT MARK KEEPS THE MODEL CHAIN: a wrapper that dropped it would end +// model fallback for every run task it wraps. +func TestSeatCompleterKeepsTheModelFallbackChain(t *testing.T) { + next := chained([]string{"vendor/fallback"}) + marked := SeatCompleter(crewroute.Checker, (&SpendGuard{}).Wrap("vendor/first", next)) + chain, ok := marked.(modelChain) + if !ok || len(chain.FallbackModels("vendor/first")) != 1 || chain.FallbackModels("vendor/first")[0] != "vendor/fallback" { + t.Fatalf("the seat wrapper dropped the fallback chain: %T", marked) + } +} + +// AT THE DAILY CAP A CALL NOBODY PRICES IS NOT SENT EITHER, on the same +// sentence a priced one ends on; below the cap it goes as it always did, and +// with no cap nothing stops it. +func TestUnpricedCallAtDailyCapIsNotSent(t *testing.T) { + for _, tc := range []struct { + day, cap float64 + sent bool + }{ + {0.5, 0.5, false}, {0.6, 0.5, false}, {0.4, 0.5, true}, {0.5, 0, true}, + } { + guard := &SpendGuard{Price: func(string) (float64, float64, float64, bool) { return 0, 0, 0, false }, + Day: NewSpendDay(tc.day), Cap: tc.cap, CapAction: "daily cap"} + calls := &spendingCompleter{usd: 0.01} + _, err := guard.Wrap("local/unpriced", calls).CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if tc.sent && (err != nil || calls.calls != 1) || !tc.sent && (!errors.As(err, &stopped) || stopped.Action != "daily cap" || calls.calls != 0) { + t.Errorf("day=%v cap=%v: %v after %d calls", tc.day, tc.cap, err, calls.calls) + } + } +} + +// A HELPER IS HELD THE SAME WAY: the guard a conversation's auxiliary calls +// go through refuses an unpriced call once the day is at the crew's cap. +func TestHelperGuardRefusesUnpricedCallAtDailyCap(t *testing.T) { + dir := t.TempDir() + if err := config.SetCrewCap(dir, "0.5"); err != nil { + t.Fatal(err) + } + a := &Agent{config: Config{ProfileDir: dir, RouteCrew: func(config.CrewAsk) (crewroute.Decision, error) { return crewroute.Decision{}, nil }}} + a.crewDayOnce.Do(func() { a.crewDayHeld = NewSpendDay(0.5) }) + guard := a.helperGuard(nil) + guard.Price = func(string) (float64, float64, float64, bool) { return 0, 0, 0, false } + calls := &spendingCompleter{usd: 0.01} + _, err := guard.Wrap("local/unpriced", calls).CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.Action != guard.CapAction || calls.calls != 0 { + t.Fatalf("helper at cap: %v after %d calls", err, calls.calls) + } +} + +// A CALL THAT COSTS NOTHING IS NEVER STOPPED BY A DOLLAR LINE: a free pool, a +// local model and a subscription plan are priced at nothing โ€” a price that is +// KNOWN โ€” so a day past its cap, a task at its limit and a checker at its +// ceiling all let it through. Only a call whose price nobody knows is held at +// a line it cannot be estimated against. +func TestACallThatCostsNothingPassesEveryDollarLine(t *testing.T) { + free := func(string) (float64, float64, float64, bool) { return 0, 0, 0, true } + task := &SpendTask{} + task.settle(0, 6) + guard := &SpendGuard{Price: free, Day: NewSpendDay(0.6), Cap: 0.5, CapAction: "daily cap", + TaskCap: 5, TaskAction: "task limit", Task: task, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: 0.05}, CeilingAction: "ceiling $%.2f"} + guard.seatTally(crewroute.Checker).settle(0, 0.06) + calls := &spendingCompleter{} + seat := SeatCompleter(crewroute.Checker, guard.Wrap("ollama/gemma4:12b", calls)) + if _, err := seat.CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "hi")}, ai.WithMaxTokens(2000)); err != nil || calls.calls != 1 { + t.Fatalf("a call that costs nothing past every line: %v after %d calls", err, calls.calls) + } +} diff --git a/internal/session/task.go b/internal/session/task.go index ef1635ce9..c0290a73b 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -851,7 +851,7 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // before it: in a batch committed at one moment none of the hand-offs // could see a live run beforehand, and the one that opened the run is // decided under the start lock ([Agent.startOrJoinTaskRun]). - joined, err := a.startOrJoinTaskRun(withCrewWish(context.WithoutCancel(ctx), crewWish{effort: spec.crewEffort}), p.id, spec.title, description, spec.dependsOn, p.stand, question) + joined, err := a.startOrJoinTaskRun(withCrewWish(context.WithoutCancel(ctx), crewWish{effort: spec.crewEffort, worker: namedModel(spec)}), p.id, spec.title, description, spec.dependsOn, p.stand, question) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, nil } @@ -871,6 +871,15 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { return taskReceipt(p.id, spec, state, p.stand, elsewhere), false, nil } +// namedModel is the model a hand-off named for its work, and nothing when it +// named none โ€” a task that names nothing is the crew's to seat. +func namedModel(spec taskSpec) string { + if spec.modelWord == "" { + return "" + } + return spec.model +} + // taskReceipt is what an admitted proposal hands back to the model. // // THE MODEL IS NAMED BACK ONLY WHEN IT WAS ASKED FOR. A word resolves to an id diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index 01487d521..65fb34a4f 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -49,13 +49,16 @@ type crewWish struct { // again is a redo of a crew that never started: the next-best models at // the same cost, because nothing ran to be too weak. again *crewroute.Decision + // worker is a model the hand-off named for this task, which seats the + // worker as a one-task pin ([Agent.routeTaskCrew]). + worker string } type crewWishKey struct{} // withCrewWish hands a start door the crew wish; an empty wish is no value. func withCrewWish(ctx context.Context, wish crewWish) context.Context { - if wish.effort == "" && wish.stronger == nil && wish.again == nil { + if wish.effort == "" && wish.stronger == nil && wish.again == nil && wish.worker == "" { return ctx } return context.WithValue(ctx, crewWishKey{}, wish) @@ -175,11 +178,20 @@ func (a *Agent) routeTaskCrew(ctx context.Context, row uint64, title, brief stri } wish := crewWishOf(ctx) repo := canonicalPath(a.config.Workspace) - decision, err := route(config.CrewAsk{ + ask := config.CrewAsk{ Task: crewroute.Task{Text: strings.TrimSpace(title + "\n\n" + brief)}, Effort: wish.effort, Stronger: wish.stronger, Again: wish.again, Repo: repo, ChatModel: a.Model(), - }) + } + // A MODEL NAMED FOR THE TASK SEATS ITS WORKER, as a one-task pin: the one + // the hand-off named, else the `task model` row. That is the ladder the + // proposal card, the receipt and the manual state ([Agent.defaultTaskModel] + // minus its last two rungs, which are the crew's own answer), and it used + // to stop at the card while the router seated whatever it picked. + if worker := a.namedTaskWorker(wish.worker); worker != "" { + ask.Sends = map[crewroute.Seat]string{crewroute.Worker: worker} + } + decision, err := route(ask) if errors.Is(err, config.ErrCrewAtCap) { spent, capUSD := config.CrewHistory(a.config.ProfileDir).SpentUSD, config.CrewCapAt(a.config.ProfileDir) return nil, fmt.Errorf("today's crew spend (%s) has reached the daily cap of %s ยท raise it or turn it off with /crew cap, or wait until midnight", @@ -204,6 +216,26 @@ func (a *Agent) routeTaskCrew(ctx context.Context, row uint64, title, brief stri return crew, nil } +// namedTaskWorker is the model somebody named for a task's worker: named, when +// the hand-off named one, else the `task model` row resolved the way a +// proposal resolves it, else nothing โ€” and nothing is a worker the router +// picks. +func (a *Agent) namedTaskWorker(named string) string { + if named = strings.TrimSpace(named); named != "" { + return named + } + a.mu.Lock() + configured := strings.TrimSpace(a.config.TaskModel) + a.mu.Unlock() + if configured == "" { + return "" + } + if candidates := matchTaskModel(configured, a.taskModelList()); len(candidates) == 1 { + return candidates[0] + } + return configured +} + // settleTaskCrew writes a task's crew outcome once: accepted when the work // came home, not kept otherwise. A redo overwrites it later with its own row, // and the log reads the last row for a call as the truth. @@ -351,7 +383,7 @@ func (c crewSeatCompleter) CompleteWithMessages(ctx context.Context, messages [] } continue } - held, err := crew.guard.before(current, messages, options) + held, err := crew.guard.before(ctx, current, messages, options) if err != nil { // THE CALL THAT WOULD CROSS THE LINE IS NOT MADE, and the line // says which line it met โ€” never "/redo stronger". @@ -360,7 +392,7 @@ func (c crewSeatCompleter) CompleteWithMessages(ctx context.Context, messages [] // A SEAT NEVER WAITS OUT A LIMIT: a 429 goes back at once, and the // seat moves to its next route or model ([provider.WithoutPatientRateLimits]). response, err := c.agent.completeWithModel(provider.WithoutPatientRateLimits(asCrewSeatCall(ctx)), purposeInherited, messages, current, options...) - crew.guard.after(current, response, held) + crew.guard.after(ctx, current, response, held) if err == nil { c.agent.crewAnswered(c.run, current) return response, nil @@ -524,8 +556,8 @@ func crewSpendGuard(profileDir string, d crewroute.Decision, withDaily bool) *Sp capUSD, action := config.CrewSpendCap(profileDir, withDaily) taskCap, taskAction := config.CrewTaskSpendCap(profileDir) guard := &SpendGuard{ - Price: config.CrewCallPrice, Cap: capUSD, CapAction: action, - Ceilings: config.CrewSeatCeilings(d), CeilingAction: config.CrewCheckCeilingAction, + Price: config.CrewCallPriceAt(profileDir), Cap: capUSD, CapAction: action, + SeatCeilings: config.CrewSeatCeilings(d), CeilingAction: config.CrewCheckCeilingAction, TaskCap: taskCap, TaskAction: taskAction, Task: &SpendTask{}, } if capUSD > 0 { @@ -552,7 +584,7 @@ func (a *Agent) helperGuard(crew *taskCrew) *SpendGuard { if a.config.RouteCrew == nil { return nil } - guard := &SpendGuard{Price: config.CrewCallPrice, Day: a.crewDay()} + guard := &SpendGuard{Price: config.CrewCallPriceAt(a.config.ProfileDir), Day: a.crewDay()} if capUSD, action := config.CrewSpendCap(a.config.ProfileDir, false); capUSD > 0 { guard.Cap, guard.CapAction = capUSD, action } @@ -645,7 +677,7 @@ func CrewSpendGuard(profileDir string, d crewroute.Decision, withDaily bool) *Sp // the per-task limit alone. func TaskSpendGuard(profileDir string) *SpendGuard { taskCap, taskAction := config.CrewTaskSpendCap(profileDir) - return &SpendGuard{Price: config.CrewCallPrice, TaskCap: taskCap, TaskAction: taskAction, Task: &SpendTask{}} + return &SpendGuard{Price: config.CrewCallPriceAt(profileDir), TaskCap: taskCap, TaskAction: taskAction, Task: &SpendTask{}} } // spentTodayOnLedger is today's spend as the usage ledger has it; nothing diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 446d79aaa..c620d2cca 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -164,15 +164,15 @@ func (a *app) crewCap(rest string) int { return a.crewTaskCap(strings.TrimSpace(figure)) } if rest == "" { - a.note("daily cap ยท " + a.crewCapWords()) + a.note(crewDailyCapWord + " ยท " + a.crewCapWords()) return -1 } if err := config.SetCrewCap(a.profileDir, rest); err != nil { - a.note("could not set the daily cap ยท " + err.Error()) + a.note("could not set the " + crewDailyCapWord + " ยท " + err.Error()) return -1 } a.crewApplied() - a.note("daily cap ยท " + a.crewCapWords()) + a.note(crewDailyCapWord + " ยท " + a.crewCapWords()) return crewCap } @@ -276,9 +276,10 @@ func crewPinMark(linear bool) string { } // sayTaskCrew keeps a routed task's crew line in the thread: ONE line, said -// when the task starts with the estimate, and REWRITTEN IN PLACE as the task -// goes โ€” a seat that failed to start and moved to its fallback, and at the end -// what it cost beside the estimate and the one door to asking again harder. +// when the task starts with the estimate, REWRITTEN IN PLACE while the task +// goes โ€” a seat that failed to start and moved to its fallback โ€” and MOVED TO +// THE END when it lands, with what it cost beside the estimate and the one +// door to asking again harder. // Two lines for one crew read as two crews; the second said nothing the first // could not carry. func (a *app) sayTaskCrew(notice session.TaskNotice) { @@ -329,7 +330,16 @@ func (a *app) sayTaskCrew(notice session.TaskNotice) { if text == said.text { return } - if said.text == "" || !a.feed.renote(said.text, text, facts) { + switch { + case said.text == "": + a.noteFacts(text, facts...) + case said.landed: + // THE LANDING IS SAID WHERE THE TASK LANDS. A task runs for minutes + // while the conversation goes on, and its start line is far up the + // thread by then; rewritten there, the actual and `/redo stronger` + // were drawn where nobody was looking. The one line moves to the end. + a.feed.moveNote(said.text, text, facts) + case !a.feed.renote(said.text, text, facts): a.noteFacts(text, facts...) } said.text, said.facts = text, facts diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index e4b6c7226..ba5136521 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -301,3 +301,36 @@ func TestCrewMoneyDrawsNoZero(t *testing.T) { t.Errorf("a day that spent reads %q", got) } } + +// THE LANDING LINE IS SAID WHERE THE TASK LANDS. A task runs for minutes while +// the conversation goes on, so by the time it lands its start line is far up +// the thread; rewriting that line in place drew the actual and `/redo +// stronger` where nobody was looking (the fresh-install check saw a 22-minute +// task land with no crew line in view). The landing moves the one line to the +// end of the thread โ€” still one line per task, never two. +func TestTheLandingCrewLineIsSaidWhereTheTaskLands(t *testing.T) { + a, _ := sheetApp(t) + crew := &crewroute.Decision{Class: crewroute.Bugfix, EstUSD: 0.013, Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}, + {Seat: crewroute.Checker, Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}, + }} + a.sayTaskCrew(session.TaskNotice{ID: 2, State: session.TaskRunning, Crew: crew}) + for _, line := range []string{"what does this repo do?", "is my code sent anywhere that logs it?"} { + a.feed.said(entry{kind: entryUser, text: line, turn: a.feed.turn}) + a.noteFacts("an answer to " + line) + } + a.sayTaskCrew(session.TaskNotice{ID: 2, State: session.TaskDone, Crew: crew, CostUSD: 0.004, Merge: "kept", Branch: "task/fix"}) + last := a.entries[len(a.entries)-1] + if last.kind != entryNote || !strings.Contains(last.text, "task 2 crew ยท ") || !strings.Contains(last.text, "$0.004 (est $0.013) ยท not right? /redo stronger") { + t.Fatalf("the thread ends on %q, not the landing crew line", last.text) + } + count := 0 + for _, e := range a.entries { + if strings.Contains(e.text, "task 2 crew") { + count++ + } + } + if count != 1 { + t.Fatalf("the task's crew is said %d times, want once", count) + } +} diff --git a/internal/tui3/crewpanel.go b/internal/tui3/crewpanel.go index 5023893b2..32992cef2 100644 --- a/internal/tui3/crewpanel.go +++ b/internal/tui3/crewpanel.go @@ -1,7 +1,6 @@ package tui3 import ( - "math" "sort" "strconv" "strings" @@ -187,6 +186,9 @@ type crewPanel struct { suggest map[crewroute.Seat]string rule crewroute.Allowed capUSD float64 + // dayUSD is the daily limit on everything codeaf spends (/budget), read + // beside the crew's own cap so the panel can say both apply; zero is none. + dayUSD float64 taskUSD float64 free bool providers []config.CrewProvider @@ -327,6 +329,7 @@ func (p *crewPanel) read(dir string) { p.pins = config.CrewPinsAt(dir) p.rule = config.CrewAllowedAt(dir) p.capUSD = config.CrewCapAt(dir) + p.dayUSD, _ = config.DailyBudgetUSDAt(dir) p.taskUSD = config.CrewTaskCapAt(dir) p.free = config.CrewFreeRoutesAt(dir) p.providers = config.CrewProvidersAt(dir) @@ -1603,6 +1606,12 @@ func (a *app) crewMainRows(width, hover int) ([]string, []int) { capValue += " " + a.pal.add(a.crewMark(tokens.GSettled)) } add(a.crewRowLine(a.crewLabel("cap", p.cursor == crewCap)+capValue, p.cursor == crewCap, hover == len(rows), false, width), crewCap) + if day := a.crewDayLimitLine(); day != "" { + // WRAPPED, NEVER CLIPPED: a narrow frame keeps the whole sentence. + for _, line := range wrap(day, max(1, width-12)) { + add(a.pal.dim(" "+line), -1) + } + } if day := a.crewTodayWord(); day != "" { add("", -1) add(a.pal.dim(fit(" "+day, width)), -1) @@ -1756,13 +1765,29 @@ func (a *app) crewCapValue() string { } if p.edit != nil && p.edit.stop == crewCap { if p.edit.price == 1 { - return a.pal.dim("per task ") + hole(p.edit.box.String()) + a.pal.dim(" ยท daily ") + daily + + return a.pal.dim("per task ") + hole(p.edit.box.String()) + a.pal.dim(" ยท "+crewDailyCapWord+" ") + daily + a.pal.dim(" ยท empty is "+config.CrewTaskMoney(config.CrewTaskCapDefault)+" ยท tab daily") } - return a.pal.dim("per task ") + task + a.pal.dim(" ยท daily ") + hole(p.edit.box.String()) + + return a.pal.dim("per task ") + task + a.pal.dim(" ยท "+crewDailyCapWord+" ") + hole(p.edit.box.String()) + a.pal.dim(" ยท empty is none ยท tab per task") } - return a.pal.dim("per task ") + task + a.pal.dim(" ยท daily ") + daily + return a.pal.dim("per task ") + task + a.pal.dim(" ยท "+crewDailyCapWord+" ") + daily +} + +// crewDailyCapWord is the crew's own daily cap as every crew surface names it. +// TWO DAILY LIMITS ARE NAMED APART: the first-run screen's `Daily limit` is the +// day's limit on everything codeaf spends (/budget), and a cap row that said +// only `daily none` beside it read as "nothing limits the day". +const crewDailyCapWord = "crew daily cap" + +// crewDayLimitLine is the line under the cap row that says the daily limit on +// everything codeaf spends still applies, with its figure โ€” nothing when there +// is no daily limit (the emptiness law). +func (a *app) crewDayLimitLine() string { + if a.crewUI.dayUSD <= 0 { + return "" + } + return "the daily limit, " + config.CrewTaskMoney(a.crewUI.dayUSD) + ", still covers everything codeaf spends ยท /budget" } // crewChipRoom is the cells a providers row keeps free past its chips, for @@ -2174,7 +2199,10 @@ func (a *app) crewOfferText(row crewPickRow, current bool, width int) string { if current { text += " " + a.crewMark(tokens.GPinned) } - facts := []string{crewPerM(offer.Model)} + var facts []string + if price := crewPerM(offer.Model); price != "" { + facts = append(facts, price) + } if len(offer.Routes) > 0 { provider := offer.Routes[0].Provider if extra := len(offer.Routes) - 1; extra > 0 { @@ -2204,20 +2232,13 @@ func (a *app) crewRouteText(offer config.CrewOffer, route int, pin config.CrewPi return a.pal.ink(r.Provider) + a.pal.dim(" ยท "+string(r.Kind)) } -// crewPerM is a model's price as the list shows it: dollars per million -// tokens in and out, cents at most. +// crewPerM is a model's price as the list shows it: the model picker's own +// words ([priceWord]), `$0.08/$0.16 per M`, dollars per million tokens in and +// out. It once drew the two figures bare, which a person had no way to read as +// a unit, and a second spelling of one price is a second thing to keep true. +// A model with no published price says nothing (the emptiness law). func crewPerM(m crewroute.Model) string { - return "$" + crewCents(m.PromptPrice*1e6) + "/$" + crewCents(m.CompletionPrice*1e6) -} - -// crewCents spells a price to the cent: whole dollars bare, anything else to -// two places, so a column of prices reads as money ($0.50, not $0.5). -func crewCents(usd float64) string { - cents := math.Round(usd * 100) - if math.Mod(cents, 100) == 0 { - return strconv.FormatFloat(cents/100, 'f', 0, 64) - } - return strconv.FormatFloat(cents/100, 'f', 2, 64) + return priceWord(m.PromptPrice, m.CompletionPrice) } // crewCheckRows is the checklist's rows: a tick where the rule admits the @@ -2237,7 +2258,10 @@ func (a *app) crewCheckRows(width, hover int) ([]string, []int) { if p.ticked(line) { mark = a.pal.ink(a.crewMark(tokens.GSettled)) + " " } - text := mark + a.pal.ink(line.offer.Model.ID) + a.pal.dim(" "+crewPerM(line.offer.Model)) + text := mark + a.pal.ink(line.offer.Model.ID) + if price := crewPerM(line.offer.Model); price != "" { + text += a.pal.dim(" " + price) + } if !line.offer.Served { text += a.pal.dim(" ยท " + crewProviderOff) } diff --git a/internal/tui3/crewpanel_test.go b/internal/tui3/crewpanel_test.go index be47fe259..faa6d2c6d 100644 --- a/internal/tui3/crewpanel_test.go +++ b/internal/tui3/crewpanel_test.go @@ -207,7 +207,7 @@ func TestCrewJourneyCap(t *testing.T) { t.Fatalf("setting the cap took %d steps", j.count) } t.Logf("set cap: %d steps", j.count) - if row := crewLineWith(t, crewScreen(a), "cap"); !strings.Contains(row, "per task $5 ยท daily $5.00") { + if row := crewLineWith(t, crewScreen(a), "cap"); !strings.Contains(row, "per task $5 ยท crew daily cap $5.00") { t.Fatalf("the cap row reads %q", row) } @@ -237,7 +237,7 @@ func TestCrewJourneyTaskCap(t *testing.T) { j := &crewJourney{t: t, a: a} j.open() j.keys("down", "down", "down", "down", "down") - if row := crewLineWith(t, crewScreen(a), "cap"); !strings.Contains(row, "per task $5 ยท daily none") { + if row := crewLineWith(t, crewScreen(a), "cap"); !strings.Contains(row, "per task $5 ยท crew daily cap none") { t.Fatalf("the cap row reads %q", row) } j.keys("enter", "tab") @@ -253,7 +253,7 @@ func TestCrewJourneyTaskCap(t *testing.T) { if got := config.CrewCapAt(dir); got != 0 { t.Fatalf("setting the per-task limit moved the daily cap to %v", got) } - if row := crewLineWith(t, crewScreen(a), "cap"); !strings.Contains(row, "per task $12 ยท daily none") { + if row := crewLineWith(t, crewScreen(a), "cap"); !strings.Contains(row, "per task $12 ยท crew daily cap none") { t.Fatalf("the cap row reads %q", row) } j.keys("enter", "tab", "backspace", "backspace", "enter") @@ -320,7 +320,7 @@ func TestCrewSeatListShapeAndRoutes(t *testing.T) { if !strings.Contains(lines[2], a.icon(tokens.GRecommended)) || !strings.Contains(lines[2], "suggested") { t.Fatalf("the suggestion is not second and marked:\n%s", strings.Join(lines, "\n")) } - if row := crewLineWith(t, strings.Join(lines, "\n"), "kimi-k3"); !strings.Contains(row, "$3/$15") || !strings.Contains(row, "openrouter") { + if row := crewLineWith(t, strings.Join(lines, "\n"), "kimi-k3"); !strings.Contains(row, "$3/$15 per M") || !strings.Contains(row, "openrouter") { t.Fatalf("a row does not say its price and provider: %q", row) } j.typed("kimi") @@ -646,3 +646,23 @@ func TestTheCrewPanelDrawsNoPrivateUseMarks(t *testing.T) { t.Errorf("the panel does not draw the plain pin and tick:\n%s", screen) } } + +// TWO DAILY LIMITS, NAMED APART. The first-run screen offers a `Daily limit` +// on everything codeaf spends; the crew has a daily cap of its own. A panel +// that said `daily none` beside a day limited at $500 read as "no limit on the +// day", so the cap row says whose cap it is and the panel says the daily limit +// still applies, with its figure. +func TestCrewPanelNamesTheCrewCapApartFromTheDailyLimit(t *testing.T) { + a, dir := crewLab(t) + if err := config.WriteDailyBudgetUSD(dir, 500); err != nil { + t.Fatal(err) + } + (&crewJourney{t: t, a: a}).open() + screen := crewScreen(a) + if row := crewLineWith(t, screen, "cap"); !strings.Contains(row, "crew daily cap none") { + t.Fatalf("the cap row does not name the crew's cap: %q", row) + } + if !strings.Contains(screen, "the daily limit, $500, still covers everything codeaf spends") { + t.Fatalf("the panel does not name the day's limit:\n%s", screen) + } +} diff --git a/internal/tui3/crewsnap_test.go b/internal/tui3/crewsnap_test.go index 045bdaffb..1be50a008 100644 --- a/internal/tui3/crewsnap_test.go +++ b/internal/tui3/crewsnap_test.go @@ -45,7 +45,8 @@ func TestCrewSnapshotPanel(t *testing.T) { โ”‚ โ”‚ โ”‚ models โ€น all โ€บ (4) โ”‚ โ”‚ providers {tick} openrouter + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers everything codeaf spends ยท /budget โ”‚ โ•ฐโ”€ enter change ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -56,10 +57,10 @@ func TestCrewSnapshotSeatList(t *testing.T) { crewSnap(t, a, "seat list", ` โ•ญโ”€ crew ยท worker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ esc โ”€โ•ฎ โ”‚โ€บ auto โ€” codeaf picks per task โ”‚ -โ”‚ {star} z-ai/glm-5.3-flash $0.15/$0.50 ยท openrouter ยท suggested โ”‚ -โ”‚ anthropic/claude-opus-5 $5/$25 ยท openrouter โ”‚ -โ”‚ deepseek/deepseek-v4-flash $0.08/$0.16 ยท openrouter โ”‚ -โ”‚ moonshotai/kimi-k3 $3/$15 ยท openrouter โ”‚ +โ”‚ {star} z-ai/glm-5.3-flash $0.15/$0.5 per M ยท openrouter ยท suggested โ”‚ +โ”‚ anthropic/claude-opus-5 $5/$25 per M ยท openrouter โ”‚ +โ”‚ deepseek/deepseek-v4-flash $0.082/$0.16 per M ยท openrouter โ”‚ +โ”‚ moonshotai/kimi-k3 $3/$15 per M ยท openrouter โ”‚ โ•ฐโ”€ type to filter ยท enter pick ยท โ†’ routes ยท esc back โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -74,7 +75,7 @@ func TestCrewSnapshotRefusal(t *testing.T) { drive(t, a, key("enter")) crewSnap(t, a, "refusal", ` โ•ญโ”€ crew ยท worker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ esc โ”€โ•ฎ -โ”‚โ€บ anthropic/claude-opus-5 $5/$25 ยท openrouter ยท not allowed โ”‚ +โ”‚โ€บ anthropic/claude-opus-5 $5/$25 per M ยท openrouter ยท not allowed โ”‚ โ”‚ {fail} claude-opus-5 is not in your allowed models (open) โ€” enter to allow it โ”‚ โ•ฐโ”€ type to filter ยท enter pick ยท โ†’ routes ยท esc back โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -92,7 +93,8 @@ func TestCrewSnapshotPriceBeingTyped(t *testing.T) { โ”‚ โ”‚ โ”‚โ€บ models โ€น price โ€บ โ‰ค $[ 0.5 ] in / $[ 5 ] out (2) {tick} โ”‚ โ”‚ providers {tick} openrouter + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers everything codeaf spends ยท /budget โ”‚ โ•ฐโ”€ enter change ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -105,10 +107,10 @@ func TestCrewSnapshotChecklist(t *testing.T) { drive(t, a, key("down"), key("down"), key("down"), key("right"), key("enter")) crewSnap(t, a, "checklist", ` โ•ญโ”€ crew ยท allowed models ยท 2 of 4 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ esc โ”€โ•ฎ -โ”‚โ€บ {tick} z-ai/glm-5.3-flash $0.15/$0.50 โ”‚ -โ”‚ moonshotai/kimi-k3 $3/$15 โ”‚ -โ”‚ {tick} deepseek/deepseek-v4-flash $0.08/$0.16 โ”‚ -โ”‚ anthropic/claude-opus-5 $5/$25 โ”‚ +โ”‚โ€บ {tick} z-ai/glm-5.3-flash $0.15/$0.5 per M โ”‚ +โ”‚ moonshotai/kimi-k3 $3/$15 per M โ”‚ +โ”‚ {tick} deepseek/deepseek-v4-flash $0.082/$0.16 per M โ”‚ +โ”‚ anthropic/claude-opus-5 $5/$25 per M โ”‚ โ•ฐโ”€ type to filter ยท space or enter tick ยท esc back โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -127,7 +129,9 @@ func TestCrewSnapshotNarrow(t *testing.T) { โ”‚ โ”‚ โ”‚ models โ€น all โ€บ (4) โ”‚ โ”‚ providers {tick} openrouter + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers โ”‚ +โ”‚ everything codeaf spends ยท /budget โ”‚ โ•ฐโ”€ enter change ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -143,7 +147,8 @@ func TestCrewSnapshotNoProviders(t *testing.T) { โ”‚ โ”‚ โ”‚ models โ€น all โ€บ (0) โ”‚ โ”‚ providers + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers everything codeaf spends ยท /budget โ”‚ โ”‚ no providers connected โ€” /connect adds one โ”‚ โ•ฐโ”€ enter change ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } @@ -160,7 +165,8 @@ func TestCrewSnapshotUndoOffer(t *testing.T) { โ”‚ โ”‚ โ”‚โ€บ models โ€น open โ€บ (3) {tick} โ”‚ โ”‚ providers {tick} openrouter + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers everything codeaf spends ยท /budget โ”‚ โ•ฐโ”€ enter change ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ z undo โ”€โ•ฏ`) } @@ -178,7 +184,8 @@ func TestCrewSnapshotProvidersRow(t *testing.T) { โ”‚ โ”‚ โ”‚ models โ€น all โ€บ (4) โ”‚ โ”‚โ€บ providers {tick} openrouter {tick} z-ai sub {off} ollama local {tick} my-vllm + โ”‚ -โ”‚ cap per task $5 ยท daily none โ”‚ +โ”‚ cap per task $5 ยท crew daily cap none โ”‚ +โ”‚ the daily limit, $500, still covers everything codeaf spends ยท /budget โ”‚ โ•ฐโ”€ enter change ยท space toggle ยท esc close ยท ? keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ`) } diff --git a/internal/tui3/feed.go b/internal/tui3/feed.go index 98279fb1e..aa0d281c8 100644 --- a/internal/tui3/feed.go +++ b/internal/tui3/feed.go @@ -1346,6 +1346,28 @@ func (f *feed) renote(old, text string, facts []string) bool { return false } +// moveNote takes the newest note saying old out of its place and says text as +// a new note at the end of the thread: a line that is one fact still settling +// while it runs, but whose last word is news when it arrives (a task's crew +// line on landing, crew.go). A note that is not the newest entry is left as an +// empty, stale block rather than cut out, the way [feed.dropLive] leaves one, +// because a later entry may hold its index. +func (f *feed) moveNote(old, text string, facts []string) { + for i := len(f.entries) - 1; i >= 0; i-- { + e := &f.entries[i] + if e.kind != entryNote || e.text != old { + continue + } + if i == len(f.entries)-1 { + f.entries = f.entries[:i] + } else { + f.entries[i] = entry{kind: entryAssistant, turn: e.turn, stale: true} + } + break + } + f.noteWritten(text, false, facts) +} + // โ”€โ”€ AN ATTEMPT THAT NEVER HAPPENED โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // retry is a cut request being asked again (internal/provider's streamguard.go). diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index ab8dbe29c..2364dd104 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -103,7 +103,7 @@ const controlLabelWidth = 19 // row where it can. Everything else this screen could say about a control is // behind `?` on that control. const ( - controlLimitWord = "When " + product + "'s spending today reaches this amount, " + + controlLimitWord = "When all " + product + " spends today reaches this amount, " + "new work waits until midnight or you raise it." controlModelWord = "The model you talk to in this conversation." ) @@ -117,7 +117,8 @@ const ( // the product making a promise it cannot keep with somebody else's money. const ( controlLimitDetail = "It counts spending " + product + " records here. Calls already " + - "running can carry it a little past. Your provider account has its own controls." + "running can carry it a little past. Your provider account has its own controls. " + + "Task crews also have a daily cap of their own, set in /crew." controlModelDetail = "It also handles this conversation's tool use. Changing it here is " + "the same choice /model makes, and it is kept for the next launch." + " Tasks get their own crew, picked per task ยท /crew shows it." diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 191a82c23..6d13726e8 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -355,8 +355,8 @@ var settingUI = map[string]settingMeta{ // offers a blank line is asking a person to be the catalog. config.KeyTaskModel: { tab: tabTasks, label: "task model", widget: widgetSelect, - about: "the model a task runs on when you have not asked for another. " + - "Blank runs it on the model you are talking to.", + about: "the model a task's worker runs on when you have not asked for another. " + + "Blank leaves it to the crew: your /crew pin, or the model picked for each task.", }, // THE CREW'S THREE SEATS ARE REGISTRY ROWS AND ARE NOT DRAWN AS THREE ROWS. // Each is empty for AUTO โ€” codeaf routes that seat per task โ€” and a model id @@ -2027,7 +2027,7 @@ func (s *sheet) crewDoorItem(terms []fuzzy.Term) sheetItem { } value += " ยท per task " + config.CrewTaskMoney(config.CrewTaskCapAt(s.profileDir)) if capUSD := config.CrewCapAt(s.profileDir); capUSD > 0 { - value += " ยท daily " + crewroute.Money(capUSD) + value += " ยท " + crewDailyCapWord + " " + crewroute.Money(capUSD) } return sheetItem{ crewDoor: true, crewValue: value, hitAt: hitAt, hitLen: hitLen,