From 30be1f6ff609defe0ff332e0181f9c29051f305c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:28:19 -0400 Subject: [PATCH 01/15] crew: the checker's spend ceiling holds whatever model the other seats run The guard kept spend per model and keyed the checker's ceiling by model id, so CrewSeatCeilings dropped the ceiling whenever another seat shared the checker's model. A fresh profile's narrow fix puts one model in all three seats, so the checker ran with no ceiling at all. Spend is now attributed by seat. The run engine's crew factory is the one place that knows which seat a task sits, so it marks every call of the task with that seat (session.SeatCompleter, which keeps the model chain), and the guard holds the checker's own calls to max(3x its estimate, $0.05) whatever the worker and planner run. Worker and planner calls on the same model are neither counted toward it nor stopped by it, and the ceiling follows the checker onto a fallback model. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/config/crewhealth_test.go | 15 +++-- internal/config/crewspend.go | 26 +++------ internal/manual/chat/models-and-cost.md | 3 + internal/run/crew.go | 7 ++- internal/run/crew_context_test.go | 74 +++++++++++++++++++++++++ internal/session/clientdoor.go | 4 +- internal/session/seatcompleter.go | 62 +++++++++++++++++++++ internal/session/spendguard.go | 66 ++++++++++++++++------ internal/session/spendguard_test.go | 56 +++++++++++++++++-- internal/session/taskcrew.go | 6 +- 10 files changed, 271 insertions(+), 48 deletions(-) create mode 100644 internal/run/crew_context_test.go create mode 100644 internal/session/seatcompleter.go diff --git a/internal/config/crewhealth_test.go b/internal/config/crewhealth_test.go index 97bf2fdb33..dd2fd3da41 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/crewspend.go b/internal/config/crewspend.go index 384729c4e6..9be10bad06 100644 --- a/internal/config/crewspend.go +++ b/internal/config/crewspend.go @@ -64,12 +64,12 @@ 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 @@ -78,21 +78,13 @@ func CrewSeatCeilings(d crewroute.Decision) map[string]float64 { 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 - } - } - 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 diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0d16fe18a7..04192e1e47 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -688,6 +688,9 @@ way ends on the same sentence. 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. diff --git a/internal/run/crew.go b/internal/run/crew.go index be4cd7ac6e..4c90685ad9 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -136,7 +136,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 0000000000..0b8978d9db --- /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 e138b28563..4744d4271d 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/seatcompleter.go b/internal/session/seatcompleter.go new file mode 100644 index 0000000000..0b4effa5e2 --- /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 8ac685005d..b6101396d6 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. @@ -114,7 +115,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 +123,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 +138,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 +195,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 +232,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 } @@ -228,29 +243,42 @@ func (g *SpendGuard) before(model string, messages []ai.Message, options []ai.Op } if !ok { // A call nobody prices is not estimated, but a task already at its - // limit makes no more calls of any kind. + // limit, or a checker already at its ceiling, makes no more calls of + // any kind. 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 } 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 +307,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 +315,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 +332,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 +362,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 e795e96851..dc3be229f0 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,51 @@ 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) + } +} diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index 01487d5210..930a4032fc 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -351,7 +351,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 +360,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 @@ -525,7 +525,7 @@ func crewSpendGuard(profileDir string, d crewroute.Decision, withDaily bool) *Sp taskCap, taskAction := config.CrewTaskSpendCap(profileDir) guard := &SpendGuard{ Price: config.CrewCallPrice, Cap: capUSD, CapAction: action, - Ceilings: config.CrewSeatCeilings(d), CeilingAction: config.CrewCheckCeilingAction, + SeatCeilings: config.CrewSeatCeilings(d), CeilingAction: config.CrewCheckCeilingAction, TaskCap: taskCap, TaskAction: taskAction, Task: &SpendTask{}, } if capUSD > 0 { From 90a5136f4d3df854bc2b94cd2b67a23dd69bd845 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:28:24 -0400 Subject: [PATCH 02/15] crew: at the daily cap a call the guard cannot price is not sent either A call to a model the catalog could not price skipped the daily-cap check before it was sent, and was counted afterwards only when the provider reported a cost, so a task at the cap kept calling any unpriced model. Now, when the day's guarded spend is already at or over the cap, such a call is refused before it is sent, on the same sentence a priced call ends on. That covers a model with no catalog price, a free pool and a local model, the same way the task limit already stopped calls of every kind. Helper calls go through the same check. Below the cap nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/models-and-cost.md | 2 ++ internal/session/spendguard.go | 14 ++++++--- internal/session/spendguard_test.go | 40 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 04192e1e47..d3a38453ab 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -683,6 +683,8 @@ 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 codeaf cannot price — a model with no catalog price, a free pool, +or a local model — is not sent either, and ends on the same sentence. **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 diff --git a/internal/session/spendguard.go b/internal/session/spendguard.go index b6101396d6..0e372a4843 100644 --- a/internal/session/spendguard.go +++ b/internal/session/spendguard.go @@ -31,7 +31,7 @@ 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 nobody prices; a cap already reached still stops it. 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 @@ -242,9 +242,15 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes prompt, completion, cacheRead, ok = g.Price(model) } if !ok { - // A call nobody prices is not estimated, but a task already at its - // limit, or a checker already at its ceiling, makes no more calls of - // any kind. + // A CALL NOBODY PRICES 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 calls of any kind — a model with no catalog + // price, a free pool and a local model included — 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} } diff --git a/internal/session/spendguard_test.go b/internal/session/spendguard_test.go index dc3be229f0..38d94c7a60 100644 --- a/internal/session/spendguard_test.go +++ b/internal/session/spendguard_test.go @@ -192,3 +192,43 @@ func TestSeatCompleterKeepsTheModelFallbackChain(t *testing.T) { 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) + } +} From 8a18a13c42d860bc48ca91fa5e3207b76f1ba6a9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:29:05 -0400 Subject: [PATCH 03/15] crew: a headless crew line says (pinned) instead of a literal emoji codeaf do's crew lines, at the start and at the end of a run, drew a pinned seat with a literal pushpin emoji, the one pictograph on a surface scripts and plain terminals read, and outside the icon vocabulary. They now say `checker kimi-k3 (pinned)`, the word the models line above them already uses, and config.PinMark is gone. The -json output already carries each seat's `pinned`, and a test now asserts it. The chat's own pin mark is unchanged. The terminal page, docs/HEADLESS.md and the resident models page show the new spelling. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/do.go | 2 +- cmd/codeaf/seats_wiring_test.go | 10 +++++++++- docs/HEADLESS.md | 6 +++--- internal/config/crew_test.go | 15 +++++++++++++++ internal/config/seats.go | 6 +----- internal/crewroute/route.go | 7 ++++--- internal/manual/chat/running-from-the-terminal.md | 8 ++++---- internal/manual/pages/models.md | 2 +- internal/manual/truth_test.go | 6 +++--- 9 files changed, 41 insertions(+), 21 deletions(-) diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 19cb54fe3f..407037eb3d 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 4526341b5b..8b237be7e4 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 594cb578b4..982f485dc0 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/internal/config/crew_test.go b/internal/config/crew_test.go index 425711363d..9ee255f3fd 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -167,6 +167,21 @@ func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) { } } +// 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)) } diff --git a/internal/config/seats.go b/internal/config/seats.go index b9127f2f60..d2b76f2bb8 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/crewroute/route.go b/internal/crewroute/route.go index 6e32f5f4d6..2deb43cd93 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/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index db0283a9b4..869042128a 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/pages/models.md b/internal/manual/pages/models.md index a7626fa0ca..b8bff7ed54 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 ebf1d19216..1df6917a9f 100644 --- a/internal/manual/truth_test.go +++ b/internal/manual/truth_test.go @@ -210,9 +210,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", From 03320996c8a23a07bb4008a77199e57dfeff2075 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:29:15 -0400 Subject: [PATCH 04/15] manual: the crew's per-task limit and the checker's ceiling come from the code The $5 per-task default, the checker ceiling's three times its estimate and its $0.05 floor were copied into the chat manual as literals, and the ceiling's refusal sentence spelled "three" by hand. A changed constant would have left every page, and the sentence, quoting the old figure. The truth table in internal/manual now holds a row for each figure, interpolated from config.CrewTaskCapDefault, config.CrewCheckCeilingTimes and config.CrewCheckCeilingFloor (exported for it) and quoting every sentence that states them, so a moved constant fails naming the page. The refusal sentence spells the multiplier from the same constant. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/config/crewspend.go | 29 ++++++++++++++++----- internal/manual/truth_test.go | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/internal/config/crewspend.go b/internal/config/crewspend.go index 9be10bad06..060ea78c09 100644 --- a/internal/config/crewspend.go +++ b/internal/config/crewspend.go @@ -3,6 +3,7 @@ package config import ( "fmt" "math" + "strconv" "github.com/Agent-Field/codeaf/internal/crewroute" ) @@ -74,9 +75,9 @@ func CrewSeatCeilings(d crewroute.Decision) map[crewroute.Seat]float64 { if checker.EstUSD <= 0 { return nil } - ceiling := checker.EstUSD * crewCheckCeilingTimes - if ceiling < crewCheckCeilingFloor { - ceiling = crewCheckCeilingFloor + ceiling := checker.EstUSD * CrewCheckCeilingTimes + if ceiling < CrewCheckCeilingFloor { + ceiling = CrewCheckCeilingFloor } // 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 @@ -88,11 +89,25 @@ func CrewSeatCeilings(d crewroute.Decision) map[crewroute.Seat]float64 { } // 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/manual/truth_test.go b/internal/manual/truth_test.go index 1df6917a9f..7c98f32df8 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 · daily none`"}, + {"models-and-cost", "No task may cost more than its limit: **%s** unless you set another"}, + {"models-and-cost", "(`per task %s · daily 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 · daily $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. From 5f3c092aca33cbf29b1a6ce85bac8dd374edc7da Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:29:44 -0400 Subject: [PATCH 05/15] crew: a route pin lives in its own row, so an older codeaf never sends model@provider A `model@provider` pin was written into the seat's tier row (models.tiers.worker, mastermind or high). dev before #1436 and stable v0.4.x read that row verbatim as a model id, so a person who switched back would have had `model@provider` sent to the provider. The tier row now holds the model alone, and the route is kept as the whole pin in models.crew.route., a profile-only row older builds do not read. This build reads the pin back unchanged. MigrateCrew splits a row this build wrote before, once and without a line, since nothing the person chose changed. A route is applied only to the model it was pinned with, so a row an older build rewrote is not paired with a stale route. Unpinning removes both rows, and the panel's undo carries the route rows too. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/config/config.go | 5 + internal/config/crew.go | 50 +++++++- internal/config/crew_test.go | 117 +++++++++++++++++++ internal/config/crewmigrate.go | 24 +++- internal/config/testdata/profile-keys.ledger | 3 + 5 files changed, 191 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 68188c2d42..e825da549e 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 ec5543d530..250e491d0f 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 9ee255f3fd..3952cc8dd2 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,100 @@ 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. diff --git a/internal/config/crewmigrate.go b/internal/config/crewmigrate.go index 8efc5f1b40..e41cdb87d9 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/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 6285533fc3..8a19b7c865 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 From af3ead9393bcbc71623da4e14e1c4b561ee99a9e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:52:38 -0400 Subject: [PATCH 06/15] crew: a call that costs nothing is never stopped by a dollar line The previous commit refused every call the guard could not price once the day was at its cap, and "could not price" included free pools and local models, whose price is known: nothing. A dollar cap has no business stopping a call that costs nothing. A free pool, and a call sent through a subscription plan or to a model on this machine (config.CrewCallPriceAt reads the profile's connections), is now priced at nothing, a known price, and passes the daily cap, the task limit and the checker's ceiling. Only a call whose price nobody knows, a model the catalog lists with no price, is refused at a line already reached. The manual sentence says the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/config/crew_test.go | 24 +++++++++++++++++ internal/config/crewspend.go | 34 +++++++++++++++++++++++-- internal/manual/chat/models-and-cost.md | 6 +++-- internal/session/spendguard.go | 21 ++++++++++----- internal/session/spendguard_test.go | 20 +++++++++++++++ internal/session/taskcrew.go | 6 ++--- 6 files changed, 97 insertions(+), 14 deletions(-) diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 3952cc8dd2..733991454a 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -740,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/crewspend.go b/internal/config/crewspend.go index 060ea78c09..9f41efdd16 100644 --- a/internal/config/crewspend.go +++ b/internal/config/crewspend.go @@ -4,6 +4,8 @@ import ( "fmt" "math" "strconv" + "strings" + "sync" "github.com/Agent-Field/codeaf/internal/crewroute" ) @@ -13,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 @@ -23,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 diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index d3a38453ab..79544350fb 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -683,8 +683,10 @@ 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 codeaf cannot price — a model with no catalog price, a free pool, -or a local model — is not sent either, and 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 diff --git a/internal/session/spendguard.go b/internal/session/spendguard.go index 0e372a4843..e57f0e1ce4 100644 --- a/internal/session/spendguard.go +++ b/internal/session/spendguard.go @@ -31,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; a cap already reached still stops it. +// 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 @@ -242,12 +243,11 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes prompt, completion, cacheRead, ok = g.Price(model) } if !ok { - // A CALL NOBODY PRICES 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 calls of any kind — a model with no catalog - // price, a free pool and a local model included — 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. + // 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} } @@ -260,6 +260,13 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes } 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() modelSpent := g.modelSpent[model] g.mu.Unlock() diff --git a/internal/session/spendguard_test.go b/internal/session/spendguard_test.go index 38d94c7a60..961853fe0f 100644 --- a/internal/session/spendguard_test.go +++ b/internal/session/spendguard_test.go @@ -232,3 +232,23 @@ func TestHelperGuardRefusesUnpricedCallAtDailyCap(t *testing.T) { 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/taskcrew.go b/internal/session/taskcrew.go index 930a4032fc..103f919086 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -524,7 +524,7 @@ 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, + Price: config.CrewCallPriceAt(profileDir), Cap: capUSD, CapAction: action, SeatCeilings: config.CrewSeatCeilings(d), CeilingAction: config.CrewCheckCeilingAction, TaskCap: taskCap, TaskAction: taskAction, Task: &SpendTask{}, } @@ -552,7 +552,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 +645,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 From 3b3c6a9cd1abffec18d723f284e8c8a1175675bf Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:55:37 -0400 Subject: [PATCH 07/15] tui3: a task's landing crew line is said where the task lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A routed task's crew line was said when the task started and rewritten in place when it landed. The landing notice does carry the crew and the cost (session's beltRunNotice, kept by publishRunRow), and sayTaskCrew did rewrite the line, but by then the start line was far up the thread. The fresh-install check watched a 22-minute task land `done · branch kept` with no crew line anywhere in view, because the actual and `not right? /redo stronger` had been drawn in scrollback nobody was reading. The line is still rewritten in place while the task runs, for a seat that moved to its fallback. On landing it now moves to the end of the thread, beside the landing (feed.moveNote), and stays one line per task. The models and tasks pages say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/models-and-cost.md | 3 ++- internal/manual/chat/tasks.md | 3 ++- internal/tui3/crew.go | 18 +++++++++++--- internal/tui3/crew_test.go | 33 +++++++++++++++++++++++++ internal/tui3/feed.go | 22 +++++++++++++++++ 5 files changed, 73 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 79544350fb..1cb81bac4a 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -733,7 +733,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 diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 6d629b87a7..1ce672c082 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/tui3/crew.go b/internal/tui3/crew.go index 446d79aaa6..b7b0b49689 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -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 e4b6c72262..ba51365216 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/feed.go b/internal/tui3/feed.go index 98279fb1eb..aa0d281c8a 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). From 2858622a4ec85c1db7175f3ad597c47114587d0d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:02:40 -0400 Subject: [PATCH 08/15] tui3: /crew's model list says its prices are per million tokens The /crew seat list and the allowed-models checklist drew each model's price as two bare figures, `$0.15/$0.50`, with nothing saying what they were, and in a spelling of their own. They now use the model picker's own words (priceWord), `$0.15/$0.5 per M`, dollars per million tokens in and out, so one price has one spelling. A model with no published price says nothing, as the picker's rows do. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/models-and-cost.md | 3 ++- internal/tui3/crewpanel.go | 30 ++++++++++++------------- internal/tui3/crewpanel_test.go | 2 +- internal/tui3/crewsnap_test.go | 18 +++++++-------- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 1cb81bac4a..5ab83cada1 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -575,7 +575,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. diff --git a/internal/tui3/crewpanel.go b/internal/tui3/crewpanel.go index 5023893b2c..47132b7853 100644 --- a/internal/tui3/crewpanel.go +++ b/internal/tui3/crewpanel.go @@ -1,7 +1,6 @@ package tui3 import ( - "math" "sort" "strconv" "strings" @@ -2174,7 +2173,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 +2206,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 +2232,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 be47fe2596..9684bcaee5 100644 --- a/internal/tui3/crewpanel_test.go +++ b/internal/tui3/crewpanel_test.go @@ -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") diff --git a/internal/tui3/crewsnap_test.go b/internal/tui3/crewsnap_test.go index 045bdaffbe..a7f0a7f360 100644 --- a/internal/tui3/crewsnap_test.go +++ b/internal/tui3/crewsnap_test.go @@ -56,10 +56,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 +74,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 ──────────────────────────────────────────────╯`) } @@ -105,10 +105,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 ────────────────────────────────────────────────╯`) } From 87de6f5ed66227949a6b6f50cfac558b677de03c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:22:26 -0400 Subject: [PATCH 09/15] crew: the crew's daily cap is named apart from the daily limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-run screen offers a `Daily limit` ($500 on a new profile), the day's limit on everything codeaf spends, while /crew's cap row said `per task $5 · daily none`. Side by side, a new user read `none` as "nothing limits the day". The crew's cap is now called the crew daily cap everywhere a person reads it: the panel's cap row, the /crew cap notes and the settings seats row. The panel names the daily limit under it with its figure (`the daily limit, $500, still covers everything codeaf spends · /budget`), wrapped and never clipped on a narrow frame. The first-run line says the daily limit covers everything codeaf spends, and its `?` detail points at the crew's own cap. The two limits stay separate. The manual, the truth table and docs/LIMITS.md follow. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/LIMITS.md | 2 +- internal/manual/chat/commands.md | 2 +- internal/manual/chat/getting-started.md | 8 ++++--- internal/manual/chat/models-and-cost.md | 13 ++++++---- internal/manual/truth_test.go | 6 ++--- internal/tui3/crew.go | 6 ++--- internal/tui3/crewpanel.go | 32 ++++++++++++++++++++++--- internal/tui3/crewpanel_test.go | 26 +++++++++++++++++--- internal/tui3/crewsnap_test.go | 19 ++++++++++----- internal/tui3/onboarding.go | 5 ++-- internal/tui3/settings.go | 2 +- 11 files changed, 91 insertions(+), 30 deletions(-) diff --git a/docs/LIMITS.md b/docs/LIMITS.md index a246613637..58ad6f0e0e 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/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 4bbd96162c..9f9515b289 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 1aa2256d96..631c90d061 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 -> raise it. +> When everything 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 5ab83cada1..3f98f7b7d9 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. @@ -704,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 — diff --git a/internal/manual/truth_test.go b/internal/manual/truth_test.go index 7c98f32df8..be6d047e34 100644 --- a/internal/manual/truth_test.go +++ b/internal/manual/truth_test.go @@ -194,9 +194,9 @@ func quotedFacts(t *testing.T) []quotedFact { fact: "the crew's default per-task limit", owner: "config.CrewTaskCapDefault", value: taskDollars, others: otherTaskDollars, quotes: []quotedIn{ - {"models-and-cost", "**cap** — `per task %s · daily none`"}, + {"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 · daily none`)"}, + {"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"}, @@ -206,7 +206,7 @@ func quotedFacts(t *testing.T) []quotedFact { {"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 · daily $5.00"}, + {"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**"}, diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index b7b0b49689..c620d2cca6 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 } diff --git a/internal/tui3/crewpanel.go b/internal/tui3/crewpanel.go index 47132b7853..32992cef24 100644 --- a/internal/tui3/crewpanel.go +++ b/internal/tui3/crewpanel.go @@ -186,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 @@ -326,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) @@ -1602,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) @@ -1755,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 diff --git a/internal/tui3/crewpanel_test.go b/internal/tui3/crewpanel_test.go index 9684bcaee5..faa6d2c6d1 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") @@ -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 a7f0a7f360..1be50a0083 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 ──────────────────────────────────────────────────────────────╯`) } @@ -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 ──────────────────────────────────────────────────────────────╯`) } @@ -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/onboarding.go b/internal/tui3/onboarding.go index ab8dbe29cd..f11e88a570 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 everything " + 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 191a82c23b..bb2d8a6b16 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -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, From e1ba17c9fb1843347cdaccc5c161e592c0da0719 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:22:31 -0400 Subject: [PATCH 10/15] crew: a model named for a task, or the task model row, seats its worker Settings said the task model row "follows the conversation", while the manual, the row's hint, the proposal card and the receipt all taught one ladder: a model named in the ask, then the task model row, then the crew's worker. On the default task road (the run engine) neither the named model nor the row reached the router. The run was seated on the routed worker whatever was named, and the receipt still said `task N started on `. The named model, or the task model row when nothing was named, now reaches the router as a one-task pin on the worker. A task that names nothing is routed as before. The row now reads `the crew's worker` when blank, and its hint says the crew picks it. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/config/settings.go | 12 +++---- internal/config/settings_test.go | 14 ++++++++ internal/session/crewworker_test.go | 51 +++++++++++++++++++++++++++++ internal/session/task.go | 11 ++++++- internal/session/taskcrew.go | 38 +++++++++++++++++++-- internal/tui3/settings.go | 4 +-- 6 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 internal/session/crewworker_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index 2753060953..9291ada122 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 abd0f78761..cf46aab9c0 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/session/crewworker_test.go b/internal/session/crewworker_test.go new file mode 100644 index 0000000000..0f2307a3ec --- /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/task.go b/internal/session/task.go index ef1635ce93..c0290a73b0 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 103f919086..65fb34a4f5 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. diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index bb2d8a6b16..6d13726e85 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 From aa2b0441cd979d9d008e88124fa67e2af7c9d784 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:26:41 -0400 Subject: [PATCH 11/15] manual: the chat answers the worker, privacy and cost questions a new user asks The fresh-install check asked the chat three things in a new user's own words, and its manual answered from the wrong places. "how do I change the model the task worker uses?" got the per-task /model and never /crew pin worker. "is my code sent anywhere that logs it?" got "your code travels to the model provider, and nowhere else", which leaves out the crew's move onto free routes that may log prompts. "which model are you using and what does a task cost?" never named the crew or a task's estimate. Three models-and-cost sections now answer them under headings in the asker's words. Pinning the worker gives the ladder. Where content goes describes the free-route move as the code does it: when free routes are switched on, or when every paid route is out of reach (a low OpenRouter balance, a payment refusal, credit reported at zero), at most three pools per task, within the allowed models, with the crew line's notice. The chat model and the crew section covers the estimate and the landed cost. Each question is a probe that must reach the section that says the answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/models-and-cost.md | 53 +++++++++++++++++++++++++ internal/manual/chat_test.go | 32 +++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 3f98f7b7d9..b0333936c0 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -939,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_test.go b/internal/manual/chat_test.go index 5f4cc9971a..67e3b564e6 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -271,6 +271,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"}, @@ -3331,3 +3340,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) + } + } +} From eb6862bf710da71979b6009d4041f0825d2ef09e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 18:11:55 -0400 Subject: [PATCH 12/15] changes: the crew follow-ups entry (#1518) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1518-crew-followups.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/changes/unreleased/1518-crew-followups.md diff --git a/docs/changes/unreleased/1518-crew-followups.md b/docs/changes/unreleased/1518-crew-followups.md new file mode 100644 index 0000000000..fcfa12bd5d --- /dev/null +++ b/docs/changes/unreleased/1518-crew-followups.md @@ -0,0 +1,17 @@ +--- +kind: fixed +title: the checker's ceiling holds on a one-model crew, the crew names its cap apart from the daily limit, and a task's landing line is where you are reading +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. From cc3899a54c3ed891aab937a541c7bf709a47faf5 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 18:12:04 -0400 Subject: [PATCH 13/15] changes: keep the crew follow-ups title to one line Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1518-crew-followups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1518-crew-followups.md b/docs/changes/unreleased/1518-crew-followups.md index fcfa12bd5d..55466af02f 100644 --- a/docs/changes/unreleased/1518-crew-followups.md +++ b/docs/changes/unreleased/1518-crew-followups.md @@ -1,6 +1,6 @@ --- kind: fixed -title: the checker's ceiling holds on a one-model crew, the crew names its cap apart from the daily limit, and a task's landing line is where you are reading +title: crew follow-ups — seat-kept checker ceiling, route pins beside the tier row, landing line at the end pr: 1518 surface: [chat, engine, docs] invalidates: From da0ab243572a7d7bc0ec42f5700fcc5611cc96d5 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 18:12:14 -0400 Subject: [PATCH 14/15] changes: the crew follow-ups title under a hundred characters Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1518-crew-followups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1518-crew-followups.md b/docs/changes/unreleased/1518-crew-followups.md index 55466af02f..be8e671aa7 100644 --- a/docs/changes/unreleased/1518-crew-followups.md +++ b/docs/changes/unreleased/1518-crew-followups.md @@ -1,6 +1,6 @@ --- kind: fixed -title: crew follow-ups — seat-kept checker ceiling, route pins beside the tier row, landing line at the end +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: From 014e3512770d7799d721b90271f44a61fc4c5267 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 18:25:56 -0400 Subject: [PATCH 15/15] tui3: the first-run daily limit line keeps to two rows "When everything codeaf spends today" pushed the line onto a third row beside the example panel, and the forty-column controls test caught it. "When all codeaf spends today" says the same in the two rows the screen gives it. The getting-started page quotes the new line. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/getting-started.md | 4 ++-- internal/tui3/onboarding.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 631c90d061..7f3fddba5e 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -131,8 +131,8 @@ 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 everything codeaf spends today reaches this amount, new work waits until midnight or -> you raise it. +> 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`. diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index f11e88a570..2364dd1042 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 everything " + product + " spends 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." )