From 72cb60b9581ba1351ee435d0aa30cd86c8d62350 Mon Sep 17 00:00:00 2001
From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:34:39 -0400
Subject: [PATCH 1/5] seniordev: size the summary's length budget, carry state
through capacity rebuilds, pin per model
Three compaction fixes from the cybergym smoke forensics:
- The summary prompt now carries a Length-budget instruction sized from
the same watermarks the trigger used (high minus continuation headroom,
the kept tail, and a fixed baseline; clamped to a floor, absent on
unbounded windows), so a generated summary is one the rebuild can hold.
- The deterministic capacity stub no longer erases progress: it carries
the previous summary verbatim (bounded) and the changed-files list the
install loop would otherwise zero, beside the observed watermark numbers.
- Overflow pins are keyed by provider/model, not session: every session
routed to the same endpoint faces the same window, so the rejection tax
is paid once per run and a second session inherits the pin.
(cherry picked from commit d3864344172d10df7aed2316c113999749fc6667)
---
internal/seniordev/app/compaction_pin.go | 31 ++---
internal/seniordev/app/compaction_pin_test.go | 32 ++---
internal/seniordev/app/engine_backend.go | 1 +
internal/seniordev/app/engine_compaction.go | 8 +-
internal/seniordev/app/runtime.go | 11 +-
.../compaction/capacity_steering_test.go | 2 +-
.../seniordev/session/compaction/service.go | 71 +++++++++--
.../session/compaction/service_test.go | 112 +++++++++++++++++-
8 files changed, 220 insertions(+), 48 deletions(-)
diff --git a/internal/seniordev/app/compaction_pin.go b/internal/seniordev/app/compaction_pin.go
index 498a8bb43b..ba497d3bf1 100644
--- a/internal/seniordev/app/compaction_pin.go
+++ b/internal/seniordev/app/compaction_pin.go
@@ -72,31 +72,33 @@ func overflowText(err error) string {
return strings.Join(parts, "\n")
}
-// pinnedCapacityFor is the session's pinned capacity, if a rejection set one.
-func (backend *modelAPIBackend) pinnedCapacityFor(sessionID string) (float64, bool) {
+// pinnedCapacityFor is the model's pinned capacity, if a rejection set one.
+// The pin is keyed by model, not session: every session routed to the same
+// endpoint faces the same window, so the rejection tax is paid once per run.
+func (backend *modelAPIBackend) pinnedCapacityFor(providerID, modelID string) (float64, bool) {
if backend == nil {
return 0, false
}
backend.pinMu.Lock()
defer backend.pinMu.Unlock()
- value, ok := backend.pinnedCapacity[sessionID]
+ value, ok := backend.pinnedCapacity[providerID+"/"+modelID]
return value, ok
}
-// overflowConfigFor is the compaction config a session runs under: the project
-// config, with a pinned capacity folded in as a minimum.
-func (backend *modelAPIBackend) overflowConfigFor(sessionID string) (overflow.Config, error) {
+// overflowConfigFor is the compaction config a model's sessions run under: the
+// project config, with a pinned capacity folded in as a minimum.
+func (backend *modelAPIBackend) overflowConfigFor(providerID, modelID string) (overflow.Config, error) {
cfg, err := backend.config.overflowConfig()
if err != nil {
return cfg, err
}
- return backend.withPinnedCapacity(cfg, sessionID), nil
+ return backend.withPinnedCapacity(cfg, providerID, modelID), nil
}
-// withPinnedCapacity folds the session's pin into a compaction config as a
-// capacity_tokens minimum. Unpinned sessions get cfg back as is.
-func (backend *modelAPIBackend) withPinnedCapacity(cfg overflow.Config, sessionID string) overflow.Config {
- pinned, ok := backend.pinnedCapacityFor(sessionID)
+// withPinnedCapacity folds the model's pin into a compaction config as a
+// capacity_tokens minimum. Unpinned models get cfg back as is.
+func (backend *modelAPIBackend) withPinnedCapacity(cfg overflow.Config, providerID, modelID string) overflow.Config {
+ pinned, ok := backend.pinnedCapacityFor(providerID, modelID)
if !ok {
return cfg
}
@@ -164,16 +166,17 @@ func (backend *modelAPIBackend) pinCapacityOnOverflow(
if backend.pinnedCapacity == nil {
backend.pinnedCapacity = map[string]float64{}
}
- if previous, exists := backend.pinnedCapacity[sessionID]; exists && previous < pinned {
+ key := providerID + "/" + modelID
+ if previous, exists := backend.pinnedCapacity[key]; exists && previous < pinned {
pinned = previous
}
- backend.pinnedCapacity[sessionID] = pinned
+ backend.pinnedCapacity[key] = pinned
backend.pinMu.Unlock()
data["source"] = "parsed"
data["limit_tokens"] = limit
data["reservation_tokens"] = reservation
data["pinned_capacity_tokens"] = pinned
- if pinnedCfg, err := backend.overflowConfigFor(sessionID); err == nil {
+ if pinnedCfg, err := backend.overflowConfigFor(providerID, modelID); err == nil {
if _, model, projErr := (seniorDevModels{backend: backend, agent: agent}).projection(providerID, modelID); projErr == nil {
marks := overflow.Watermarks(overflow.UsableInput{Cfg: pinnedCfg, Model: model})
data["capacity_tokens"] = marks.Capacity
diff --git a/internal/seniordev/app/compaction_pin_test.go b/internal/seniordev/app/compaction_pin_test.go
index 6a9f5cb46c..de06887786 100644
--- a/internal/seniordev/app/compaction_pin_test.go
+++ b/internal/seniordev/app/compaction_pin_test.go
@@ -85,12 +85,12 @@ func TestContextOverflowPinsCapacityUnderTheWindowPolicy(t *testing.T) {
// Fixture output limit 12,000 is the reservation: 131,072 − 12,000. The
// fixture's own input window (220,000 − 12,000 = 208,000) is wider, so
// the pin is what tightens.
- pinned, ok := backend.pinnedCapacityFor("ses-pin")
+ pinned, ok := backend.pinnedCapacityFor("openrouter", "fixture/vendor-model")
if !ok || pinned != 119_072 {
t.Fatalf("pinned capacity = %v,%v want 119072", pinned, ok)
}
- if _, ok := backend.pinnedCapacityFor("ses-other"); ok {
- t.Fatal("a pin must be per session")
+ if _, ok := backend.pinnedCapacityFor("openrouter", "fixture/other-model"); ok {
+ t.Fatal("a pin must be per model, not global")
}
got := events.String()
for _, want := range []string{
@@ -103,22 +103,24 @@ func TestContextOverflowPinsCapacityUnderTheWindowPolicy(t *testing.T) {
t.Errorf("pinned event lacks %s: %s", want, got)
}
}
- // The session's compaction config now carries the pin as capacity_tokens,
- // and the project config is untouched for other sessions.
- cfg, err := backend.overflowConfigFor("ses-pin")
+ // The model's compaction config now carries the pin as capacity_tokens,
+ // so a different session on the same model inherits it, while a
+ // different model is untouched.
+ cfg, err := backend.overflowConfigFor("openrouter", "fixture/vendor-model")
if err != nil || cfg.Compaction.CapacityTokens == nil || *cfg.Compaction.CapacityTokens != 119_072 {
- t.Fatalf("session config = %+v, %v", cfg.Compaction, err)
+ t.Fatalf("model config = %+v, %v", cfg.Compaction, err)
}
- other, _ := backend.overflowConfigFor("ses-other")
+ other, _ := backend.overflowConfigFor("openrouter", "fixture/other-model")
if other.Compaction.CapacityTokens != nil {
- t.Fatalf("other session inherited the pin: %+v", other.Compaction)
+ t.Fatalf("other model inherited the pin: %+v", other.Compaction)
}
- // A later rejection naming a larger limit never raises the pin.
+ // A later rejection from another session on the same model and naming a
+ // larger limit never raises the pin.
backend.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return recordedResponse(request, 400, "application/json", `{"error":{"message":"maximum context length is 262144 tokens","code":400}}`), nil
})}
- _ = overflowStream(t, backend, "ses-pin")
- if pinned, _ := backend.pinnedCapacityFor("ses-pin"); pinned != 119_072 {
+ _ = overflowStream(t, backend, "ses-pin-2")
+ if pinned, _ := backend.pinnedCapacityFor("openrouter", "fixture/vendor-model"); pinned != 119_072 {
t.Fatalf("pin was raised to %v", pinned)
}
// And the configured event for a later turn in that session records it.
@@ -138,14 +140,14 @@ func TestContextOverflowWithoutANumberPinsNothing(t *testing.T) {
if err := overflowStream(t, backend, "ses-unparsed"); err == nil {
t.Fatal("expected the rejection to pass through")
}
- if _, ok := backend.pinnedCapacityFor("ses-unparsed"); ok {
+ if _, ok := backend.pinnedCapacityFor("openrouter", "fixture/vendor-model"); ok {
t.Fatal("an unparsed rejection must not pin")
}
got := events.String()
if !strings.Contains(got, `"status":"overflow-unpinned"`) || !strings.Contains(got, `"source":"unparsed"`) {
t.Fatalf("unparsed rejection not recorded: %s", got)
}
- cfg, _ := backend.overflowConfigFor("ses-unparsed")
+ cfg, _ := backend.overflowConfigFor("openrouter", "fixture/vendor-model")
if cfg.Compaction.CapacityTokens != nil {
t.Fatalf("config changed without a pin: %+v", cfg.Compaction)
}
@@ -157,7 +159,7 @@ func TestNonOverflowErrorsDoNotPin(t *testing.T) {
if err := overflowStream(t, backend, "ses-429"); err == nil {
t.Fatal("expected the error to pass through")
}
- if _, ok := backend.pinnedCapacityFor("ses-429"); ok || strings.Contains(events.String(), "compaction-capacity") {
+ if _, ok := backend.pinnedCapacityFor("openrouter", "fixture/vendor-model"); ok || strings.Contains(events.String(), "compaction-capacity") {
t.Fatalf("a non-overflow error pinned or emitted: %s", events.String())
}
}
diff --git a/internal/seniordev/app/engine_backend.go b/internal/seniordev/app/engine_backend.go
index dbf2176f34..f96ad01c1b 100644
--- a/internal/seniordev/app/engine_backend.go
+++ b/internal/seniordev/app/engine_backend.go
@@ -90,6 +90,7 @@ func (backend *modelAPIBackend) runEngine(
tasks := newSeniorDevCompactionController(
store, seniorDevSummaryClient{owner: client}, models, request.Workspace,
backend, system, request.Tools, request.CompactionDecisions, sessionID,
+ providerID, modelID,
)
loop := steploop.Loop{
Store: store, Client: client, Models: models,
diff --git a/internal/seniordev/app/engine_compaction.go b/internal/seniordev/app/engine_compaction.go
index 5aa9fa4643..0d2e32fd13 100644
--- a/internal/seniordev/app/engine_compaction.go
+++ b/internal/seniordev/app/engine_compaction.go
@@ -134,14 +134,16 @@ func newSeniorDevCompactionController(
tools []steploop.ToolDefinition,
decisions compaction.DecisionSink,
sessionID string,
+ providerID string,
+ modelID string,
) compaction.Controller {
service := compaction.NewService(compaction.Dependencies{
Store: store,
- // The session's config, not the project's: under the window policy
- // a context-overflow rejection may have pinned this session's
+ // The model's config, not the project's: under the window policy
+ // a context-overflow rejection may have pinned this model's
// capacity below the window (compaction_pin.go).
Config: compaction.ConfigProviderFunc(func(context.Context) (overflow.Config, error) {
- return backend.overflowConfigFor(sessionID)
+ return backend.overflowConfigFor(providerID, modelID)
}),
Agents: compaction.AgentProviderFunc(func(
context.Context, string,
diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go
index fdbd601b12..02dbadd8e0 100644
--- a/internal/seniordev/app/runtime.go
+++ b/internal/seniordev/app/runtime.go
@@ -155,7 +155,7 @@ func (runtime *runtimeAdapter) emitTurnProvenance(configured turn) {
func (runtime *runtimeAdapter) compactionProvenance(configured turn) map[string]any {
cfg, err := runtime.config.overflowConfig()
if concrete, ok := runtime.backend.(*modelAPIBackend); ok && concrete != nil && err == nil {
- cfg = concrete.withPinnedCapacity(cfg, configured.SessionID)
+ cfg = concrete.withPinnedCapacity(cfg, configured.ProviderID, configured.ModelID)
}
// Config accepts only the window policy or an empty value, so the policy
// in force is always the window.
@@ -187,7 +187,7 @@ func (runtime *runtimeAdapter) compactionProvenance(configured turn) map[string]
return record
}
marks := overflow.Watermarks(overflow.UsableInput{Cfg: cfg, Model: model})
- if pinned, ok := concrete.pinnedCapacityFor(configured.SessionID); ok {
+ if pinned, ok := concrete.pinnedCapacityFor(configured.ProviderID, configured.ModelID); ok {
record["pinned_capacity_tokens"] = pinned
}
record["model_context_tokens"] = model.Limit.Context
@@ -530,9 +530,10 @@ type modelAPIBackend struct {
// events receives the records the backend emits on its own, after
// configureTurn: the compaction-capacity pins (compaction_pin.go).
events *eventWriter
- // pinnedCapacity is the per-session capacity a context-overflow rejection
- // named (compaction_pin.go). A run is one process, so the map is the
- // whole of the state.
+ // pinnedCapacity is the per-model capacity a context-overflow rejection
+ // named (compaction_pin.go). Every session routed to the same endpoint
+ // faces the same window, so one pin serves the whole run. A run is one
+ // process, so the map is the whole of the state.
pinMu sync.Mutex
pinnedCapacity map[string]float64
}
diff --git a/internal/seniordev/session/compaction/capacity_steering_test.go b/internal/seniordev/session/compaction/capacity_steering_test.go
index 37ecc76db7..dae0a8cc60 100644
--- a/internal/seniordev/session/compaction/capacity_steering_test.go
+++ b/internal/seniordev/session/compaction/capacity_steering_test.go
@@ -63,7 +63,7 @@ func TestSteeringSurvivesRepeatedCompactionAndCapacityRebuild(t *testing.T) {
}
t.Logf("boundary %d keeps steering", i+1)
}
- if err := service.installCapacityFallback(ctx, "ses_1", nil, "original user request", 9000, overflow.CompactionWatermarks{High: 8000, Low: 6000}); err != nil {
+ if err := service.installCapacityFallback(ctx, "ses_1", nil, "original user request", 9000, overflow.CompactionWatermarks{High: 8000, Low: 6000}, nil, nil); err != nil {
t.Fatal(err)
}
fresh, _ := store.Messages(ctx, "ses_1")
diff --git a/internal/seniordev/session/compaction/service.go b/internal/seniordev/session/compaction/service.go
index bca198caee..9f720c900c 100644
--- a/internal/seniordev/session/compaction/service.go
+++ b/internal/seniordev/session/compaction/service.go
@@ -516,6 +516,16 @@ func (s *Service) Process(ctx context.Context, input ProcessInput) (steploop.Res
SerializeTranscript(cloned, ToolOutputMaxChars), SummaryTranscriptMaxChars,
)
summaryPrompt := "\n" + transcript + "\n\n\n" + pinnedPrompt
+ // The summary's length target follows the same watermarks the trigger
+ // used: a summary that cannot fit beside the fixed baseline and the kept
+ // tail is rebuilt away by enforceWatermarks, so the instruction is sized
+ // to what the rebuild can hold. Unbounded windows carry no instruction.
+ if budget, ok := s.summaryTokenBudget(ctx, cfg, originalModel, selected.Tokens); ok {
+ summaryPrompt += fmt.Sprintf(
+ "\n\nLength budget: keep the summary under %.0f tokens of prose; the rebuilt session must fit the model's window with room to continue.",
+ budget,
+ )
+ }
authoritativeTask, taskSource := s.authoritativeTask(messages)
changedFiles := s.changedFiles(ctx)
@@ -758,7 +768,7 @@ func (s *Service) Process(ctx context.Context, input ProcessInput) (steploop.Res
capacity, capacityErr := s.enforceWatermarks(
ctx, input.SessionID, beforeTokens, originalModel, cfg,
- compactionPart, taskSource,
+ compactionPart, taskSource, previousSummary, changedFiles,
)
decision.Status = capacity.Status
decision.Before, decision.After = capacity.Before, capacity.After
@@ -832,6 +842,28 @@ func minimumContinuationHeadroom(marks overflow.CompactionWatermarks) float64 {
return math.Max(1, math.Min(gap, wanted))
}
+// summaryTokenBudget sizes the length instruction carried in the summary
+// prompt. The budget is what the rebuild can hold beside the fixed baseline
+// and the kept tail: unbounded windows carry no instruction at all.
+func (s *Service) summaryTokenBudget(
+ ctx context.Context,
+ cfg overflow.Config,
+ model Model,
+ tailTokens float64,
+) (float64, bool) {
+ marks := overflow.Watermarks(
+ overflow.UsableInput{Cfg: cfg, Model: model.Overflow},
+ )
+ if marks.High <= 0 || math.IsInf(marks.High, 1) {
+ return 0, false
+ }
+ budget := marks.High - minimumContinuationHeadroom(marks) - tailTokens - 2_048
+ if budget < 256 {
+ budget = 256
+ }
+ return budget, true
+}
+
func (s *Service) enforceWatermarks(
ctx context.Context,
sessionID string,
@@ -840,6 +872,8 @@ func (s *Service) enforceWatermarks(
cfg overflow.Config,
compactionPart *msgmodel.CompactionPart,
source string,
+ previousSummary *string,
+ changedFiles []string,
) (CompactionDecision, error) {
marks := overflow.Watermarks(
overflow.UsableInput{Cfg: cfg, Model: model.Overflow},
@@ -893,6 +927,7 @@ func (s *Service) enforceWatermarks(
// deterministic capacity stub.
if err := s.installCapacityFallback(
ctx, sessionID, compactionPart, source, after, marks,
+ previousSummary, changedFiles,
); err != nil {
return decision, err
}
@@ -911,21 +946,39 @@ func (s *Service) enforceWatermarks(
return decision, ContextCapacityError{After: after, High: marks.High}
}
-func compactFallbackSummary(source string, observed float64, marks overflow.CompactionWatermarks) string {
+func compactFallbackSummary(
+ source string,
+ observed float64,
+ marks overflow.CompactionWatermarks,
+ previousSummary *string,
+ changedFiles []string,
+) string {
current := "- Continue the authoritative task pinned verbatim beside this state record."
- files := "- (none)"
- if source == ".senior-dev/spec.md" {
- files = "- .senior-dev/spec.md: authoritative task specification"
- } else if source == "" {
+ if source == "" {
current = "- Recover the original request from durable session state before editing."
}
+ // The rebuild must carry progress forward: the previous summary is
+ // quoted verbatim (bounded), and the changed-files list the install
+ // loop would otherwise zero out is written into the stub itself.
+ completed := "- No generated completion claim survived the capacity rebuild."
+ if previousSummary != nil && strings.TrimSpace(*previousSummary) != "" {
+ completed = quoteAsData(HeadTailTruncate(strings.TrimSpace(*previousSummary), 8_000))
+ }
+ files := "- (none)"
+ if len(changedFiles) > 0 {
+ lines := make([]string, 0, len(changedFiles))
+ for _, name := range changedFiles {
+ lines = append(lines, "- "+name)
+ }
+ files = strings.Join(lines, "\n")
+ }
context := fmt.Sprintf(
"- Prior projection was %.0f tokens (target %.0f; high watermark %.0f); retained history did not fit.",
observed, marks.Low, marks.High,
)
return strings.Join([]string{
"## Working State",
- "### Completed", "- No generated completion claim survived the capacity rebuild.",
+ "### Completed", completed,
"### Current", current,
"### Verification", context,
"### Next", "- Inspect the current diff and latest exact failure before editing.",
@@ -953,6 +1006,8 @@ func (s *Service) installCapacityFallback(
source string,
observed float64,
marks overflow.CompactionWatermarks,
+ previousSummary *string,
+ changedFiles []string,
) error {
if compactionPart != nil {
part := *compactionPart
@@ -971,7 +1026,7 @@ func (s *Service) installCapacityFallback(
return errors.New("compaction: completed summary missing during deterministic rebuild")
}
message := messages[prior[len(prior)-1].AssistantIndex]
- fallback := compactFallbackSummary(source, observed, marks)
+ fallback := compactFallbackSummary(source, observed, marks, previousSummary, changedFiles)
written := false
for _, raw := range message.Parts {
part, ok := raw.(msgmodel.TextPart)
diff --git a/internal/seniordev/session/compaction/service_test.go b/internal/seniordev/session/compaction/service_test.go
index 453a412859..47a572a278 100644
--- a/internal/seniordev/session/compaction/service_test.go
+++ b/internal/seniordev/session/compaction/service_test.go
@@ -414,7 +414,7 @@ func TestEnforceWatermarksRebuildsWhenFirstProjectionLacksHeadroom(t *testing.T)
})
decision, err := NewService(deps).enforceWatermarks(
context.Background(), "ses_1", 70_000, serviceModel(), watermarkTestConfig(),
- &compactionPart, "original user request",
+ &compactionPart, "original user request", nil, nil,
)
if err != nil {
t.Fatal(err)
@@ -487,9 +487,11 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
sizes = sizes[1:]
return value, nil
})
+ previous := "## Working State\n### Completed\n- Fixed src/a.ts and the build is green."
+ changed := []string{"src/a.ts", "src/b.ts"}
decision, err := NewService(deps).enforceWatermarks(
context.Background(), "ses_1", 95_000, serviceModel(), watermarkTestConfig(),
- &compactionPart, "original user request",
+ &compactionPart, "original user request", &previous, changed,
)
if err != nil {
t.Fatal(err)
@@ -503,6 +505,16 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
if generated == nil || !strings.Contains(*generated, "retained history did not fit") {
t.Fatalf("generated fallback = %v", generated)
}
+ // The stub carries state forward: the previous summary verbatim and the
+ // changed-files list, never the old erase marker.
+ for _, want := range []string{"Fixed src/a.ts and the build is green.", "- src/a.ts", "- src/b.ts"} {
+ if !strings.Contains(*generated, want) {
+ t.Fatalf("fallback stub lost %q: %v", want, generated)
+ }
+ }
+ if strings.Contains(*generated, "- (none)") {
+ t.Fatalf("fallback stub erased the files list: %v", generated)
+ }
evidenceRemoved := false
for _, raw := range fresh[2].Parts {
part, ok := raw.(msgmodel.TextPart)
@@ -955,3 +967,99 @@ func TestProcessPinsSpecOnValidSummaryWhenTheRequestIsUnrecoverable(t *testing.T
t.Fatalf("pin must be synthetic so it is not mistaken for the summary: %#v", pin)
}
}
+
+func TestSummaryTokenBudgetFollowsTheWatermarks(t *testing.T) {
+ service := NewService(baseDeps(&memoryStore{}))
+ cfg := watermarkTestConfig() // capacity 100K: high 60,000, headroom 6,000
+ budget, ok := service.summaryTokenBudget(
+ context.Background(), cfg, serviceModel(), 10_000,
+ )
+ if !ok || budget != 41_952 {
+ t.Fatalf("budget = %v,%v want 41952 (60000-6000-10000-2048)", budget, ok)
+ }
+ // A tail that alone eats the window clamps to the floor, never negative.
+ floor, ok := service.summaryTokenBudget(
+ context.Background(), cfg, serviceModel(), 200_000,
+ )
+ if !ok || floor != 256 {
+ t.Fatalf("clamped budget = %v,%v want 256", floor, ok)
+ }
+ // An unbounded window carries no instruction at all.
+ unbounded := serviceModel()
+ unbounded.Overflow.Limit.Context = 0
+ if _, ok := service.summaryTokenBudget(
+ context.Background(), overflow.Config{}, unbounded, 0,
+ ); ok {
+ t.Fatal("an unbounded window must not carry a length budget")
+ }
+}
+
+func TestSummaryPromptCarriesTheWatermarkLengthBudget(t *testing.T) {
+ messages := compactionConversation("coder")
+ store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)}
+ provider := &fakeProvider{
+ model: serviceModel(), provider: ProviderInfo{Source: "env", Options: "opts"},
+ }
+ deps := baseDeps(store)
+ deps.Provider = provider
+ deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"}
+ // Small capacity: high 60,000 against the model's own 122,880, so the
+ // budget is decided by the configured window.
+ cfg := watermarkTestConfig()
+ tail := float64(0) // zero tail budget: the older messages form the head
+ cfg.Compaction.PreserveRecentTokens = &tail
+ deps.Config = ConfigProviderFunc(func(context.Context) (overflow.Config, error) {
+ return cfg, nil
+ })
+ decisions := []CompactionDecision{}
+ deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) {
+ decisions = append(decisions, decision)
+ })
+ var prompt string
+ deps.Processors = ProcessorFactoryFunc(func(
+ _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model,
+ ) (SummaryProcessor, error) {
+ return &fakeProcessor{
+ message: assistant,
+ process: func(ctx context.Context, request SummaryRequest) (steploop.Result, error) {
+ prompt = promptOf(t, request)
+ finish := "stop"
+ assistant.Finish = &finish
+ if err := store.UpdateMessage(ctx, *assistant); err != nil {
+ return steploop.ResultStop, err
+ }
+ if err := store.UpdatePart(ctx, msgmodel.TextPart{
+ PartBase: msgmodel.PartBase{
+ ID: "summary_part", SessionID: "ses_1", MessageID: assistant.ID,
+ },
+ Text: testValidSummary("Fix src/a.ts"),
+ }); err != nil {
+ return steploop.ResultStop, err
+ }
+ return steploop.ResultContinue, nil
+ },
+ }, nil
+ })
+ service := NewService(deps)
+ result, err := service.Process(context.Background(), ProcessInput{
+ ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true,
+ })
+ if err != nil || result != steploop.ResultContinue {
+ t.Fatalf("result=%s err=%v", result, err)
+ }
+ if len(decisions) != 1 {
+ t.Fatalf("decisions = %#v", decisions)
+ }
+ want, ok := service.summaryTokenBudget(
+ context.Background(), cfg, serviceModel(), decisions[0].TailTokens,
+ )
+ if !ok {
+ t.Fatal("a bounded window must carry a length budget")
+ }
+ instruction := fmt.Sprintf(
+ "Length budget: keep the summary under %.0f tokens", want,
+ )
+ if !strings.Contains(prompt, instruction) {
+ t.Fatalf("summary prompt lacks %q: %q", instruction, prompt)
+ }
+}
From 516eb5894ed5aa95287ebe9bcd3f8a6fb9772f44 Mon Sep 17 00:00:00 2001
From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:25:54 -0400
Subject: [PATCH 2/5] seniordev: size models from codeaf's catalog offline,
refuse tiny windows, keep progress through capacity rebuilds
With models.dev out of reach, senior-dev sized every model at 16,384
tokens, though codeaf's own catalog on the same machine listed the real
window. A 1M-token model then compacted every few thousand tokens, and
113 of 115 rebuilds installed a stub that erased the session's progress.
- Sizing: models.dev first, then the catalog codeaf keeps for the
profile (config.CachedContextWindow: disk only, no key, no network),
then the 16K guess. A guessed window is now said, in the run's log and
on its page ("could not learn how much its model holds; assumed 16,384
tokens").
- A model known to hold 32,768 tokens or fewer is refused before its
first call, by name and size; a guess is never refused.
- A senior-dev shell run waits up to one catalog fetch
(catalog.FetchTimeout) for the profile's model list, so a fresh profile
has the file senior-dev reads.
- Hardening of the cherry-picked compaction fix: the summary budget
measures the system prompt, tools and pins instead of assuming 2,048,
gives the tail up before the summary, and never exceeds the
summarizer's output limit. The capacity stub is measured bare first,
then carries the NEWEST summary cut to the room left, and falls back to
the bare stub rather than ending the session when a carry does not fit.
- A 16K reproduction drives three compactions and checks that progress
survives each one; it fails on dev as it is.
Manual and change entry updated in the same change.
Co-Authored-By: Claude Opus 5.5
---
cmd/codeaf/carried.go | 25 ++-
.../1791-senior-dev-small-windows.md | 21 +++
internal/catalog/catalog.go | 6 +-
internal/config/window.go | 56 ++++++
internal/config/window_test.go | 91 +++++++++
internal/manual/chat/senior-dev.md | 41 +++-
internal/manual/chat_test.go | 3 +
internal/seniordev/actions.go | 9 +
internal/seniordev/actions_test.go | 13 ++
internal/seniordev/app/engine_client.go | 108 +++++++++--
internal/seniordev/app/run.go | 25 ++-
internal/seniordev/app/runtime.go | 6 +
internal/seniordev/app/window_check.go | 112 +++++++++++
internal/seniordev/app/window_check_test.go | 143 ++++++++++++++
.../compaction/capacity_steering_test.go | 2 +-
.../seniordev/session/compaction/service.go | 159 +++++++++++++---
.../session/compaction/service_test.go | 96 ++++++----
.../session/compaction/small_window_test.go | 176 ++++++++++++++++++
.../seniordev/session/overflow/overflow.go | 4 +
19 files changed, 994 insertions(+), 102 deletions(-)
create mode 100644 docs/changes/unreleased/1791-senior-dev-small-windows.md
create mode 100644 internal/config/window.go
create mode 100644 internal/config/window_test.go
create mode 100644 internal/seniordev/app/window_check.go
create mode 100644 internal/seniordev/app/window_check_test.go
create mode 100644 internal/seniordev/session/compaction/small_window_test.go
diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go
index 0d3b4f1e50..c18c5b67af 100644
--- a/cmd/codeaf/carried.go
+++ b/cmd/codeaf/carried.go
@@ -39,6 +39,7 @@ import (
"time"
"github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/delegate"
"github.com/Agent-Field/codeaf/internal/delegate/builtin"
@@ -130,6 +131,9 @@ type carriedRoad struct {
seat string
defaultSeat func() (string, error)
signNamed bool
+ // warmCatalog waits, within ctx, for the profile's model catalog to be
+ // resolved and written down. Nil for a road with no catalog.
+ warmCatalog func(ctx context.Context) bool
}
// carriedModels resolves a shell run's road. It is the person's own profile,
@@ -137,6 +141,11 @@ type carriedRoad struct {
// scripted road instead of a profile and a key.
var carriedModels = profileRoad
+// carriedCatalogWarm bounds how long a senior-dev shell run waits for the
+// profile's model catalog to be written before it starts: the catalog's own
+// fetch timeout, so the wait is never longer than one fetch.
+const carriedCatalogWarm = catalog.FetchTimeout
+
// profileRoad is the road through the person's profile: config.Load's
// services and keys — so a machine with no key at all is answered with the
// one sentence every command gives it — the crew's work seat if this run
@@ -172,7 +181,8 @@ func profileRoad() (carriedRoad, error) {
}
return seats.Work.Model, nil
},
- signNamed: config.AttributionModelAt(settings.ProfileDir),
+ signNamed: config.AttributionModelAt(settings.ProfileDir),
+ warmCatalog: settings.Models.Warmed,
}, nil
}
@@ -317,6 +327,19 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error {
})
defer clock.Stop()
}
+ // SENIOR-DEV SIZES ITS MODELS FROM THE CATALOG THIS PROFILE KEEPS ON DISK
+ // whenever models.dev cannot answer (internal/config's
+ // CachedContextWindow), and it cannot fetch that catalog itself: it runs
+ // with no provider key. On a profile that has never listed its models the
+ // file is written by the catalog's first resolution, so the run waits for
+ // it, at most one catalog fetch's length. A profile that already holds the
+ // list answers at once, and a fetch that cannot land leaves the run to say
+ // it guessed.
+ if inv.Program.Name == "senior-dev" && road.warmCatalog != nil {
+ warmCtx, warmed := context.WithTimeout(runCtx, carriedCatalogWarm)
+ road.warmCatalog(warmCtx)
+ warmed()
+ }
ledger := session.UsageLedgerPath()
// A SHELL RUN'S ROWS NAME THE RUN. There is no conversation and no task
// behind them, and a row that named nothing was money the spending page
diff --git a/docs/changes/unreleased/1791-senior-dev-small-windows.md b/docs/changes/unreleased/1791-senior-dev-small-windows.md
new file mode 100644
index 0000000000..3fbfa75bc4
--- /dev/null
+++ b/docs/changes/unreleased/1791-senior-dev-small-windows.md
@@ -0,0 +1,21 @@
+---
+kind: fixed
+title: senior-dev reads its window from codeaf offline, refuses tiny models, keeps progress on compaction
+pr: 1791
+surface: [engine, chat, docs]
+invalidates:
+ - "senior-dev sized every model at 16,384 tokens when models.dev could not be reached. It now reads the window from the model catalog codeaf keeps for the profile, and only guesses 16,384 when neither knows the model, saying so on its page and in its log."
+ - "senior-dev started on any model it could size. A model known to hold 32,768 tokens or fewer is now refused before its first call with `senior-dev cannot work with ( tokens): …`, and nothing is spent."
+ - "When a compacted history did not fit, senior-dev replaced the summary with a stub that kept no progress. The stub now carries as much of the newest summary as fits, and the changed-files list."
+ - "The summary length budget assumed 2,048 tokens of fixed context. It now measures the system prompt, tools and pins, and never asks for more than the summarizer's output limit."
+ - "A context-overflow pin applied to one senior-dev session. It now applies to every session on the same model for the rest of the run."
+---
+
+A CyberGym run in a container that could not reach models.dev sized a 1M-token model at
+16,384 tokens. It compacted 115 times in 25 minutes, and 113 of those compactions erased
+its progress. The do stage delivered nothing. codeaf's own catalog in that container
+already listed the real window.
+
+A senior-dev shell run now waits up to one catalog fetch (`catalog.FetchTimeout`) for the
+profile's model list before it starts, so a fresh profile writes the list senior-dev
+reads.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 74d6ca0626..a7a1469e02 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -232,6 +232,10 @@ type cache struct {
Base string `json:"base,omitempty"`
}
+// FetchTimeout bounds one listing request on the default HTTP client. A caller
+// that waits for a catalog to resolve waits no longer than this.
+const FetchTimeout = 15 * time.Second
+
// Options describes the one catalog fetch. Dir is the codeaf configuration
// directory (CODEAF_PROFILE_DIR when configured, ~/.codeaf otherwise).
type Options struct {
@@ -1197,7 +1201,7 @@ func fetch(ctx context.Context, options Options) ([]Model, error) {
}
client := options.HTTPClient
if client == nil {
- client = &http.Client{Timeout: 15 * time.Second}
+ client = &http.Client{Timeout: FetchTimeout}
}
response, err := client.Do(request)
if err != nil {
diff --git a/internal/config/window.go b/internal/config/window.go
new file mode 100644
index 0000000000..830617fb87
--- /dev/null
+++ b/internal/config/window.go
@@ -0,0 +1,56 @@
+package config
+
+import (
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/catalog"
+ "github.com/Agent-Field/codeaf/internal/env"
+ "github.com/Agent-Field/codeaf/internal/modelsource"
+)
+
+// CachedContextWindow is how many tokens a model accepts according to the
+// model catalog this profile already keeps on disk, or zero when that catalog
+// cannot say: an unknown model, a service whose list was never fetched, a
+// profile that has never listed anything.
+//
+// IT NEVER REACHES THE NETWORK AND READS NO KEY. It exists for a program codeaf
+// starts (senior-dev), which runs with every provider key taken out of its
+// environment and may sit behind a network that reaches nothing but the run's
+// own model API. That program still deserves the window codeaf itself would
+// use, and codeaf wrote it down the last time it listed the models: the
+// default service's listing in `model-catalog.json`, every other connected
+// service's in its own compartment beside it ([CatalogOptionsFor]). The model
+// is spelled the way a person writes it, and its service is found the way
+// every other door finds one ([modelsource.Set.For]).
+func CachedContextWindow(model string) int {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return 0
+ }
+ profileDir := ProfileDir()
+ values, _ := readProfileConfig(profileDir)
+ baseURL := firstNonEmpty(env.Get("CODEAF_BASE_URL"), DefaultBaseURL)
+ noKey := func(PersistedSource, modelsource.Source) string { return "" }
+ sources := sourceHomes(resolveSources("", baseURL, persistedSourcesFrom(values), noKey), profileDir)
+ service, bare := sources.For(model)
+ id := strings.TrimSpace(service.Source.ID)
+ switch {
+ case strings.EqualFold(id, modelsource.DefaultID):
+ // The default service's listing is the profile's one shared catalog,
+ // written under no source name (cmd/codeaf's sharedCatalog).
+ return catalog.Recall(catalog.Options{BaseURL: baseURL, Dir: profileDir}).ContextLength(bare)
+ case strings.EqualFold(id, "codex"):
+ // A Codex row remembered before its window was kept answers the
+ // window the fallback list names ([CodexRememberedModels]).
+ for _, row := range CodexRememberedModels(service, profileDir) {
+ if strings.EqualFold(strings.TrimSpace(row.ID), bare) {
+ return row.ContextLength
+ }
+ }
+ return 0
+ }
+ // The same compartment the connected service's listing is written to.
+ // Recall reads the file and nothing else, so the key the options carry
+ // (none here) and their client are never used.
+ return catalog.Recall(CatalogOptionsFor(service, profileDir)).ContextLength(bare)
+}
diff --git a/internal/config/window_test.go b/internal/config/window_test.go
new file mode 100644
index 0000000000..f71a1ca965
--- /dev/null
+++ b/internal/config/window_test.go
@@ -0,0 +1,91 @@
+package config
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/catalog"
+ "github.com/Agent-Field/codeaf/internal/modelsource"
+)
+
+// A PROGRAM'S WINDOW COMES FROM THE CATALOG CODEAF ALREADY KEPT. The listing
+// the default service's catalog wrote answers a model in either spelling the
+// person uses for it, a model it never listed answers zero, and nothing is
+// asked of the network on the way.
+func TestCachedContextWindowReadsTheProfilesOwnCatalog(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ calls.Add(1)
+ }))
+ defer server.Close()
+ profile := t.TempDir()
+ t.Setenv(ProfileDirEnv, profile)
+ t.Setenv("CODEAF_HOME", profile)
+ // The service's address is the stub, so a lookup that fetched would be
+ // counted.
+ t.Setenv("CODEAF_BASE_URL", server.URL+"/api/v1")
+ if err := catalog.Remember(catalog.Options{BaseURL: server.URL + "/api/v1", Dir: profile}, []catalog.Model{
+ {ID: "deepseek/deepseek-v4.1-flash", ContextLength: 1_048_576},
+ {ID: "vendor/small", ContextLength: 16_384},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for model, want := range map[string]int{
+ "deepseek/deepseek-v4.1-flash": 1_048_576,
+ "openrouter/deepseek/deepseek-v4.1-flash": 1_048_576,
+ "vendor/small": 16_384,
+ "vendor/never-listed": 0,
+ "": 0,
+ } {
+ if got := CachedContextWindow(model); got != want {
+ t.Errorf("CachedContextWindow(%q) = %d, want %d", model, got, want)
+ }
+ }
+ if calls.Load() != 0 {
+ t.Fatalf("the lookup reached the network %d times", calls.Load())
+ }
+}
+
+// A profile that never listed anything answers zero for every model, which
+// is "nobody can say" and never a small window.
+func TestCachedContextWindowOnAProfileWithNoCatalogIsZero(t *testing.T) {
+ profile := t.TempDir()
+ t.Setenv(ProfileDirEnv, profile)
+ t.Setenv("CODEAF_HOME", profile)
+ if got := CachedContextWindow("deepseek/deepseek-v4.1-flash"); got != 0 {
+ t.Fatalf("window = %d, want 0", got)
+ }
+}
+
+// A model on another connected service is answered from THAT service's own
+// compartment, found by the prefix the person writes it with, and never from
+// the default service's list.
+func TestCachedContextWindowReadsAConnectedServicesOwnCompartment(t *testing.T) {
+ profile := t.TempDir()
+ t.Setenv(ProfileDirEnv, profile)
+ t.Setenv("CODEAF_HOME", profile)
+ t.Setenv("CODEAF_BASE_URL", "")
+ if err := writeProfileValue(profile, keyModelSources, []PersistedSource{{ID: "z-ai", Written: "z-ai", Key: "zai-key-0123456789", Order: 1}}); err != nil {
+ t.Fatal(err)
+ }
+ var zai modelsource.Connected
+ for _, service := range ResolveSources(profile, "", "").All() {
+ if service.Source.ID == "z-ai" {
+ zai = service
+ }
+ }
+ if zai.Source.ID == "" {
+ t.Fatal("the z-ai service did not resolve")
+ }
+ if err := catalog.Remember(CatalogOptionsFor(zai, profile), []catalog.Model{{ID: "glm-5.1", ContextLength: 200_000}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := catalog.Remember(catalog.Options{Dir: profile}, []catalog.Model{{ID: "glm-5.1", ContextLength: 7}}); err != nil {
+ t.Fatal(err)
+ }
+ if got := CachedContextWindow("z-ai/glm-5.1"); got != 200_000 {
+ t.Fatalf("z-ai/glm-5.1 = %d, want its own service's 200000", got)
+ }
+}
diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md
index 258a386a00..519cd0aa55 100644
--- a/internal/manual/chat/senior-dev.md
+++ b/internal/manual/chat/senior-dev.md
@@ -419,8 +419,10 @@ dependencies). `SENIOR_DEV_NET=off` withholds `webfetch` and `websearch` for tha
drops the part of its instructions about installing anything, and makes its copy link
your ignored dependency folders instead of having its own.
Senior-dev also requests model sizes and capabilities from models.dev when its cached
-catalog is absent or stale. If that site cannot be reached, the run uses conservative
-model limits and still calls models through codeaf's loopback API.
+catalog is absent or stale. If that site cannot be reached, or has never listed the model,
+it reads the model's window from the model catalog codeaf keeps for your profile, which
+needs no network. Only when neither knows the model does it assume 16,384 tokens, and it
+says so. Every model call still goes through codeaf's loopback API.
## senior-dev and missing dependencies — pytest not installed, No module named, pip refuses, npm install, a virtual environment
@@ -932,10 +934,12 @@ with kimi-k2.6", or several: "with kimi-k2.6 and deepseek-v4-pro" — and senior
with exactly those, routing among them call by call when there are several; the card and
the task's first line name them. A name that fits more than one model is put to you to
settle. A model none of your connected services can serve is refused before the card, by
-name, rather than swapped for another. When a catalog was loaded, a model it cannot size cannot be used: the
-run ends before its first call with `senior-dev cannot work with : …`, and nothing
-is spent. If models.dev is unavailable and there is no cache, conservative limits let
-the run start. The models are fixed when the run starts; changing the crew later does not move
+name, rather than swapped for another. When a catalog was loaded, a model neither it nor
+codeaf's own model catalog can size cannot be used: the run ends before its first call with
+`senior-dev cannot work with : …`, and nothing is spent. A model known to hold
+32,768 tokens or fewer is refused the same way (see the section on a model too small for
+senior-dev). If models.dev is unavailable and codeaf's catalog does not know the model
+either, the run starts on an assumed 16,384 tokens and says so. The models are fixed when the run starts; changing the crew later does not move
a run already working. `/senior-dev` typed with a brief uses your crew.
**Otherwise, from the chat it uses your crew.** codeaf hands senior-dev the worker
@@ -952,6 +956,31 @@ work or a history summary.
A crew model senior-dev's model catalog cannot size is left out, and its log says so;
if that leaves no working model, it uses its own list instead.
+## senior-dev keeps compacting, or refused a model as too small — how much its model can hold, a 32K model, could not learn how much its model holds
+
+senior-dev keeps its brief, its tools, its checklist and its progress in its model's
+window, and compacts its history once it fills 60% of what the model can take in. It learns the window from models.dev
+first, then from the model catalog codeaf keeps for your profile, which it reads from
+disk with no network. A shell run waits up to 15 seconds for codeaf to list its models
+first on a profile that has never listed them.
+
+**A model known to hold 32,768 tokens or fewer is refused before its first call**, and
+nothing is spent: `senior-dev cannot work with (16,384 tokens): a run needs a
+model that holds more than 32,768 tokens to keep its brief, its tools and its progress
+in view, so nothing was started; ask for a model with a larger window`. Ask for a
+larger model.
+
+**When nothing knows the model, it assumes 16,384 tokens and runs.** Its page says
+`could not learn how much its model holds; assumed 16,384 tokens`, and its log says
+which model. A run on that guess compacts every few steps. A real window that small
+is rare, so the guess is never refused. To give it the real figure, run `codeaf models`
+once on that machine while it can reach your model service; codeaf keeps the list it
+prints, and senior-dev reads the window from it.
+
+When a compacted history still does not fit, senior-dev keeps as much of its newest
+summary as fits beside the brief and the list of changed files. It never throws its
+progress away.
+
## Which model is my senior-dev run on — the models it was launched with, the foot of its task page
Open the run's task. On a window tall enough for the task page's side column, the
diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go
index be17600c79..32590622b9 100644
--- a/internal/manual/chat_test.go
+++ b/internal/manual/chat_test.go
@@ -1076,6 +1076,9 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"who writes the commit message for senior-dev's work", "senior-dev"},
{"how much does a senior-dev run cost", "senior-dev"},
{"which model is my senior-dev run using", "senior-dev"},
+ {"why does senior-dev keep compacting every few steps", "senior-dev"},
+ {"senior-dev refused my model as too small", "senior-dev"},
+ {"senior-dev says it could not learn how much its model holds", "senior-dev"},
{"how do I read the whole brief senior-dev was given", "senior-dev"},
{"scroll senior-dev's brief", "senior-dev"},
{"senior-dev finished my new file but it is not on the branch", "senior-dev"},
diff --git a/internal/seniordev/actions.go b/internal/seniordev/actions.go
index c781e0899a..b6fbc357f9 100644
--- a/internal/seniordev/actions.go
+++ b/internal/seniordev/actions.go
@@ -275,6 +275,15 @@ func presentStage(stage, status string, facts stageFacts) (delegate.Shown, bool)
text = fmt.Sprintf("learned its model holds %s tokens", thousands(limit))
}
return delegate.Shown{Text: text}, true
+ case "compaction-capacity/guessed":
+ // Neither catalog could size the model, so the run started on the
+ // guess (app/window_check.go); a person seeing it compact every few
+ // steps is told why on the first line it writes.
+ text := "could not learn how much its model holds"
+ if limit := facts.whole("limit_tokens"); limit > 0 {
+ text += fmt.Sprintf("; assumed %s tokens", thousands(limit))
+ }
+ return delegate.Shown{Text: text}, true
case "compaction/summarized", "compaction/fallback":
shown := delegate.Shown{Text: "compacted its memory", Memory: true}
if status == "fallback" {
diff --git a/internal/seniordev/actions_test.go b/internal/seniordev/actions_test.go
index dba3eed51f..dd9258dc5d 100644
--- a/internal/seniordev/actions_test.go
+++ b/internal/seniordev/actions_test.go
@@ -208,3 +208,16 @@ func TestAMessageHandedToItsModelSaysWhoseAndWhat(t *testing.T) {
t.Fatalf("three messages read %+v", several)
}
}
+
+// A RUN ON A GUESSED WINDOW SAYS SO on its page: nothing could size its model,
+// and a run that then compacts every few steps has its reason on the first
+// line it wrote.
+func TestAGuessedWindowReadsOnTheRunsPage(t *testing.T) {
+ read := Program.Reader()
+ shown, ok := read(stage("compaction-capacity", "guessed", map[string]any{
+ "models": []string{"vendor/offline-model"}, "limit_tokens": 16384,
+ }))
+ if !ok || shown.Text != "could not learn how much its model holds; assumed 16,384 tokens" {
+ t.Fatalf("shown = %+v, %v", shown, ok)
+ }
+}
diff --git a/internal/seniordev/app/engine_client.go b/internal/seniordev/app/engine_client.go
index f67f2db209..6a4ec5e6c4 100644
--- a/internal/seniordev/app/engine_client.go
+++ b/internal/seniordev/app/engine_client.go
@@ -158,39 +158,107 @@ func (models seniorDevModels) projection(
}
func (models seniorDevModels) catalogModel(providerID, modelID string) (calc.Model, error) {
- if models.backend.catalog != nil && len(models.backend.catalog) == 0 {
- // Unknown metadata must leave enough room for the baked prompt while
- // limiting each request conservatively. codeaf's model API still prices
- // actual usage; these zero prices never enter its ledger.
+ metadata, _, err := models.sizedModel(providerID, modelID)
+ return metadata, err
+}
+
+// Where a model's limits came from ([seniorDevModels.sizedModel]).
+const (
+ // sizedByModelsDev: senior-dev's own catalog, models.dev.
+ sizedByModelsDev = "models.dev"
+ // sizedByCodeaf: the model catalog codeaf keeps for this profile, which
+ // answers when models.dev cannot be reached or has never heard of the model.
+ sizedByCodeaf = "codeaf"
+ // sizedByConfig: neither catalog knows the model, and senior-dev's own
+ // config names it; its `limit` block is the only figure there is.
+ sizedByConfig = "config"
+ // sizedByGuess: nothing knew the model, and the conservative guess below
+ // stands in for its window. A run on a guess is told so (run.go).
+ sizedByGuess = "guess"
+)
+
+// Unknown metadata must leave enough room for the baked prompt while limiting
+// each request conservatively. codeaf's model API still prices actual usage;
+// these zero prices never enter its ledger.
+const (
+ guessedContextTokens = 16_384
+ guessedOutputTokens = 2_048
+)
+
+// sizedModel is a model's metadata and where its limits came from. models.dev
+// answers first; when it cannot (the site was unreachable, or it has never
+// listed the model), the catalog codeaf keeps for this profile is asked for
+// the model's window ([modelAPIBackend.windowFor]); and only when neither
+// knows does a config-defined model run on its config, or anything else on
+// the guess.
+//
+// THE GUESS USED TO BE THE WHOLE ANSWER OFFLINE. With models.dev out of reach
+// every model was sized at 16,384 tokens, although codeaf had the real window
+// on disk the whole time: a 1M-token model compacted every few thousand
+// tokens and a CyberGym run spent 25 minutes re-reading its own brief.
+func (models seniorDevModels) sizedModel(providerID, modelID string) (calc.Model, string, error) {
+ catalog := models.backend.catalog
+ var resolveErr error
+ if len(catalog) > 0 {
+ metadata, err := catalog.Resolve(providerID, modelID)
+ if err == nil {
+ return metadata, sizedByModelsDev, nil
+ }
+ resolveErr = err
+ }
+ if window := models.backend.codeafWindow(providerID, modelID); window > 0 {
return calc.Model{
- Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
- Limit: calc.ModelLimit{Context: 16_384, Output: 2_048},
+ Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
+ Limit: calc.ModelLimit{Context: window},
+ // Unknown capabilities are permissive.
Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true},
- }, nil
+ }, sizedByCodeaf, nil
}
- if models.backend.catalog != nil {
- metadata, err := models.backend.catalog.Resolve(providerID, modelID)
- if err == nil {
- return metadata, nil
- }
+ if catalog == nil {
+ // A nil catalog is an explicit seam for injected engine tests. Every
+ // shipped CLI backend receives a loaded (possibly disabled/empty) catalog.
+ return calc.Model{
+ Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
+ // Unknown capabilities are permissive.
+ Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true},
+ }, sizedByConfig, nil
+ }
+ if len(catalog) > 0 {
if len(models.backend.config.model(providerID, modelID)) == 0 {
- return calc.Model{}, err
+ return calc.Model{}, "", resolveErr
}
- // A config-defined model absent from models.dev gets zero cost and zero
+ // A config-defined model neither catalog knows gets zero cost and zero
// context/output limits unless the config block supplies them.
return calc.Model{
Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
// Unknown capabilities are permissive.
Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true},
- }, nil
+ }, sizedByConfig, nil
}
- // A nil catalog is an explicit seam for injected engine tests. Every
- // shipped CLI backend receives a loaded (possibly disabled/empty) catalog.
+ // models.dev could not be read and codeaf's catalog does not know the
+ // model either. A config block's limits still override the guess
+ // ([seniorDevModels.projection]).
return calc.Model{
- Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
- // Unknown capabilities are permissive.
+ Cost: &calc.ModelCost{Cache: &calc.CacheCost{}},
+ Limit: calc.ModelLimit{Context: guessedContextTokens, Output: guessedOutputTokens},
Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true},
- }, nil
+ }, sizedByGuess, nil
+}
+
+// codeafWindow is the model's window in the catalog codeaf keeps for this
+// profile, zero when it cannot say or when the backend was built without a
+// lookup (an injected engine test). A model senior-dev files under codeaf's
+// model API ([normalizeModelRef]) is asked about by the id under that API, so
+// a connected service's own prefix still finds that service's list.
+func (backend *modelAPIBackend) codeafWindow(providerID, modelID string) float64 {
+ if backend == nil || backend.windowFor == nil {
+ return 0
+ }
+ ref := modelID
+ if providerID != orclient.Service {
+ ref = providerID + "/" + modelID
+ }
+ return float64(backend.windowFor(ref))
}
// normalizeModelRef files a model under the service codeaf's model API speaks
diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go
index 52c6998256..9ecbe43ca5 100644
--- a/internal/seniordev/app/run.go
+++ b/internal/seniordev/app/run.go
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/Agent-Field/codeaf/internal/buildinfo"
+ codeafconfig "github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/delegate"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/processgroup"
@@ -166,16 +167,19 @@ func runWith(ctx context.Context, host delegate.Host, options Options, notes io.
return refused("model catalog: " + err.Error())
}
client.catalog = catalog
+ // The second source of a model's window: the catalog codeaf keeps for
+ // this profile, read from disk, which knows the window of every model
+ // codeaf can list even where models.dev cannot be reached.
+ client.windowFor = codeafconfig.CachedContextWindow
model = client
+ models := seniorDevModels{backend: client}
+ // A model is known when something can size it: models.dev, codeaf's
+ // catalog or senior-dev's own config -- or, with models.dev unread,
+ // the guess, which is said at the start of the run below.
known := func(ref string) bool {
- if len(catalog) == 0 {
- return true
- }
providerID, modelID := normalizeModelRef(splitModelID(ref))
- if _, err := catalog.Resolve(providerID, modelID); err == nil {
- return true
- }
- return len(loadedConfig.model(providerID, modelID)) > 0
+ _, _, err := models.sizedModel(providerID, modelID)
+ return err == nil
}
if options.Asked {
if refusal := askedRefusal(args.High, known); refusal != "" {
@@ -189,6 +193,13 @@ func runWith(ctx context.Context, host delegate.Host, options Options, notes io.
args.High = high
}
}
+ // A MODEL TOO SMALL TO WORK IN IS REFUSED BEFORE ITS FIRST CALL, by
+ // name and size, and nothing is spent ([windowCheck]).
+ refusal, guessed := windowCheck(args, models)
+ if refusal != "" {
+ return refused(refusal)
+ }
+ sayGuessedWindows(guessed, notes, events)
}
runner := newPipeline(args, workspace, pipelineDeps{
diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go
index 02dbadd8e0..64a27d6c0b 100644
--- a/internal/seniordev/app/runtime.go
+++ b/internal/seniordev/app/runtime.go
@@ -527,6 +527,12 @@ type modelAPIBackend struct {
config *seniorDevConfig
router *adaptive.AdaptiveModelRouter
catalog modelsdev.Catalog
+ // windowFor answers a model's window from the catalog codeaf keeps for
+ // this profile (config.CachedContextWindow), zero when it cannot say. It
+ // is asked only when models.dev cannot size the model
+ // ([seniorDevModels.sizedModel]); nil leaves models.dev the only source,
+ // which is what an injected engine test wants.
+ windowFor func(model string) int
// events receives the records the backend emits on its own, after
// configureTurn: the compaction-capacity pins (compaction_pin.go).
events *eventWriter
diff --git a/internal/seniordev/app/window_check.go b/internal/seniordev/app/window_check.go
new file mode 100644
index 0000000000..96518ba2e9
--- /dev/null
+++ b/internal/seniordev/app/window_check.go
@@ -0,0 +1,112 @@
+//go:build !windows
+
+package app
+
+import (
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient"
+)
+
+// tinyWindowTokens is the largest window senior-dev refuses to start on. A
+// run keeps its brief, its tools, its checklist and its progress in its
+// model's window and compacts at 60% of it; at this size and below there is
+// no room left to work between compactions, and a run spends its whole budget
+// re-reading what it already knew. Refusing costs nothing, because nothing has
+// been spent yet.
+const tinyWindowTokens = 32_768
+
+// windowOf is how many tokens a model holds as this run will size it, and
+// where that figure came from ([seniorDevModels.sizedModel]). A config block or
+// the backend's own limit that names the context overrides any catalog, and
+// is reported as config.
+func (models seniorDevModels) windowOf(ref string) (float64, string, error) {
+ providerID, modelID := normalizeModelRef(splitModelID(ref))
+ _, source, err := models.sizedModel(providerID, modelID)
+ if err != nil {
+ return 0, "", err
+ }
+ _, metadata, err := models.projection(providerID, modelID)
+ if err != nil {
+ return 0, "", err
+ }
+ if _, ok := configNumber(objectValue(models.backend.config.model(providerID, modelID)["limit"])["context"]); ok ||
+ models.backend.contextLimit != 0 {
+ source = sizedByConfig
+ }
+ return metadata.Limit.Context, source, nil
+}
+
+// windowCheck reads the window of every model the run will call -- the
+// coder's pool, the light pool its history summaries run on, and the
+// frontier -- before anything is spent. It answers the refusal for models
+// whose window is KNOWN to be too small ([tinyWindowTokens]), and the models
+// whose window nobody knows and was guessed.
+//
+// A GUESS IS NEVER REFUSED. The guess is what senior-dev assumes when neither
+// models.dev nor codeaf's own catalog can be read, and the model behind it is
+// as likely to hold a million tokens as sixteen thousand; refusing on it would
+// stop runs that would have worked. It is said instead (run.go), so a run that
+// compacts every few steps says why at its start.
+func windowCheck(args cliArgs, models seniorDevModels) (refusal string, guessed []string) {
+ seen := map[string]bool{}
+ var tiny []string
+ for _, pool := range []string{args.High, args.Low, args.Frontier} {
+ for _, ref := range splitPool(pool) {
+ name := strings.TrimPrefix(ref, orclient.Service+"/")
+ if seen[name] {
+ continue
+ }
+ seen[name] = true
+ window, source, err := models.windowOf(ref)
+ if err != nil {
+ continue
+ }
+ switch {
+ case source == sizedByGuess:
+ guessed = append(guessed, name)
+ case window > 0 && window <= tinyWindowTokens:
+ tiny = append(tiny, fmt.Sprintf("%s (%s tokens)", name, groupedTokens(window)))
+ }
+ }
+ }
+ if len(tiny) > 0 {
+ refusal = "senior-dev cannot work with " + strings.Join(tiny, ", ") +
+ ": a run needs a model that holds more than " + groupedTokens(tinyWindowTokens) +
+ " tokens to keep its brief, its tools and its progress in view, so nothing was started; ask for a model with a larger window"
+ }
+ return refusal, guessed
+}
+
+// sayGuessedWindows tells the person, on the run's notes and on its record,
+// which models are running on the guessed window ([windowCheck]).
+func sayGuessedWindows(guessed []string, notes io.Writer, events *eventWriter) {
+ if len(guessed) == 0 {
+ return
+ }
+ _, _ = fmt.Fprintf(notes,
+ "[senior-dev] neither models.dev nor codeaf's model catalog knows how much %s can hold; assuming %s tokens, so its history will be compacted often\n",
+ strings.Join(guessed, ", "), groupedTokens(guessedContextTokens))
+ if events != nil {
+ events.stage("compaction-capacity", "guessed", map[string]any{
+ "models": guessed, "limit_tokens": float64(guessedContextTokens),
+ })
+ }
+}
+
+// groupedTokens writes a token count with thousands separators, the way every
+// count a person reads is written.
+func groupedTokens(tokens float64) string {
+ digits := strconv.FormatInt(int64(tokens), 10)
+ var out strings.Builder
+ for i, digit := range digits {
+ if i > 0 && (len(digits)-i)%3 == 0 {
+ out.WriteByte(',')
+ }
+ out.WriteRune(digit)
+ }
+ return out.String()
+}
diff --git a/internal/seniordev/app/window_check_test.go b/internal/seniordev/app/window_check_test.go
new file mode 100644
index 0000000000..4db3cab744
--- /dev/null
+++ b/internal/seniordev/app/window_check_test.go
@@ -0,0 +1,143 @@
+//go:build !windows
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/catalog"
+ "github.com/Agent-Field/codeaf/internal/delegate"
+ "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev"
+)
+
+// fixtureModelsDev is senior-dev's catalog fixture: models.dev's shape, with
+// openrouter/fixture/vendor-model at 240,000 tokens.
+func fixtureModelsDev(t *testing.T) modelsdev.Catalog {
+ t.Helper()
+ path, err := filepath.Abs("../modelsdev/testdata/catalog.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client, err := modelsdev.New(modelsdev.Options{CatalogPath: path, DisableFetch: true, CacheDir: t.TempDir()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := client.Get(context.Background())
+ if err != nil || len(loaded) == 0 {
+ t.Fatalf("fixture catalog = %v, %v", loaded, err)
+ }
+ return loaded
+}
+
+// codeafKnows is a stand-in for the catalog codeaf keeps: the windows it
+// lists, by the id codeaf spells them with.
+func codeafKnows(windows map[string]int) func(string) int {
+ return func(model string) int { return windows[model] }
+}
+
+// A MODEL'S WINDOW COMES FROM models.dev FIRST, codeaf's own catalog second,
+// and the guess last. With models.dev unreadable the model is still sized at
+// its real window when codeaf lists it, which is the whole of what the
+// CyberGym run that compacted every few thousand tokens was missing.
+func TestAModelIsSizedByModelsDevThenCodeafThenTheGuess(t *testing.T) {
+ codeaf := codeafKnows(map[string]int{
+ "deepseek/deepseek-v4.1-flash": 1_048_576,
+ "fixture/vendor-model": 999,
+ })
+ cases := []struct {
+ name string
+ catalog modelsdev.Catalog
+ windowFor func(string) int
+ provider string
+ model string
+ wantSource string
+ wantWindow float64
+ }{
+ {"models.dev answers first", fixtureModelsDev(t), codeaf, "openrouter", "fixture/vendor-model", sizedByModelsDev, 240_000},
+ {"codeaf answers what models.dev never listed", fixtureModelsDev(t), codeaf, "openrouter", "deepseek/deepseek-v4.1-flash", sizedByCodeaf, 1_048_576},
+ {"codeaf answers when models.dev is unreadable", modelsdev.Catalog{}, codeaf, "openrouter", "deepseek/deepseek-v4.1-flash", sizedByCodeaf, 1_048_576},
+ {"a provider-prefixed spelling finds codeaf's row", modelsdev.Catalog{}, codeaf, "deepseek", "deepseek-v4.1-flash", sizedByCodeaf, 1_048_576},
+ {"nothing knows it: the guess", modelsdev.Catalog{}, codeaf, "openrouter", "vendor/offline-model", sizedByGuess, guessedContextTokens},
+ {"no codeaf lookup at all: the guess", modelsdev.Catalog{}, nil, "openrouter", "deepseek/deepseek-v4.1-flash", sizedByGuess, guessedContextTokens},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ backend := newModelAPIBackend(testModelAPI, "")
+ backend.catalog = tc.catalog
+ backend.windowFor = tc.windowFor
+ models := seniorDevModels{backend: backend}
+ metadata, source, err := models.sizedModel(tc.provider, tc.model)
+ if err != nil || source != tc.wantSource || metadata.Limit.Context != tc.wantWindow {
+ t.Fatalf("sized = %v from %q (%v), want %v from %q", metadata.Limit.Context, source, err, tc.wantWindow, tc.wantSource)
+ }
+ })
+ }
+ // A loaded models.dev that does not know a model codeaf does not know
+ // either is still an unknown model, which is refused as it always was.
+ backend := newModelAPIBackend(testModelAPI, "")
+ backend.catalog = fixtureModelsDev(t)
+ backend.windowFor = codeaf
+ if _, _, err := (seniorDevModels{backend: backend}).sizedModel("openrouter", "vendor/never-heard-of"); err == nil {
+ t.Fatal("a model nothing can size was sized")
+ }
+}
+
+// A MODEL KNOWN TO BE TOO SMALL IS REFUSED BY NAME AND SIZE; a guessed window
+// is never refused, only said. 32,768 tokens is refused and one more is not.
+func TestATinyKnownWindowIsRefusedAndAGuessIsOnlySaid(t *testing.T) {
+ backend := newModelAPIBackend(testModelAPI, "")
+ backend.catalog = modelsdev.Catalog{}
+ backend.windowFor = codeafKnows(map[string]int{
+ "vendor/small": 16_384,
+ "vendor/edge": 32_768,
+ "vendor/enough": 32_769,
+ })
+ models := seniorDevModels{backend: backend}
+
+ refusal, guessed := windowCheck(cliArgs{High: "openrouter/vendor/enough", Low: "openrouter/vendor/small,openrouter/vendor/edge"}, models)
+ want := "senior-dev cannot work with vendor/small (16,384 tokens), vendor/edge (32,768 tokens): a run needs a model that holds more than 32,768 tokens"
+ if !strings.HasPrefix(refusal, want) || !strings.Contains(refusal, "nothing was started") || len(guessed) != 0 {
+ t.Fatalf("refusal = %q, guessed = %v", refusal, guessed)
+ }
+
+ refusal, guessed = windowCheck(cliArgs{High: "openrouter/vendor/enough,openrouter/vendor/offline", Low: "openrouter/vendor/offline"}, models)
+ if refusal != "" || strings.Join(guessed, ",") != "vendor/offline" {
+ t.Fatalf("refusal = %q, guessed = %v; want no refusal and the offline model named once", refusal, guessed)
+ }
+ var notes bytes.Buffer
+ sayGuessedWindows(guessed, ¬es, nil)
+ if !strings.Contains(notes.String(), "knows how much vendor/offline can hold; assuming 16,384 tokens") {
+ t.Fatalf("notes = %q", notes.String())
+ }
+}
+
+// THE REFUSAL HAPPENS BEFORE ANYTHING IS SPENT. A run asked to work on a model
+// codeaf's catalog lists at 16,384 tokens ends with the sentence that names it,
+// and never reaches its first stage, let alone a model call.
+func TestARunOnATinyModelIsRefusedBeforeItStarts(t *testing.T) {
+ profile := t.TempDir()
+ t.Setenv("CODEAF_PROFILE_DIR", profile)
+ t.Setenv("CODEAF_HOME", profile)
+ t.Setenv("CODEAF_BASE_URL", "")
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ t.Setenv("SENIOR_DEV_MODELS_PATH", "")
+ t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "1")
+ if err := catalog.Remember(catalog.Options{Dir: profile}, []catalog.Model{
+ {ID: "vendor/small", ContextLength: 16_384},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ host := &testHost{workspace: t.TempDir(), api: delegate.ModelAPI{BaseURL: "http://127.0.0.1:1/v1", Token: "run-token"}}
+ ending := Run(context.Background(), host, Options{Goal: "fix the bug", High: "openrouter/vendor/small"}, nil)
+ if ending.Status != delegate.StatusCrashed ||
+ !strings.HasPrefix(ending.Message, "senior-dev cannot work with vendor/small (16,384 tokens)") {
+ t.Fatalf("ending = %+v", ending)
+ }
+ if len(host.stages) != 0 {
+ t.Fatalf("a refused run reported stages: %v", host.stages)
+ }
+}
diff --git a/internal/seniordev/session/compaction/capacity_steering_test.go b/internal/seniordev/session/compaction/capacity_steering_test.go
index dae0a8cc60..a798880770 100644
--- a/internal/seniordev/session/compaction/capacity_steering_test.go
+++ b/internal/seniordev/session/compaction/capacity_steering_test.go
@@ -63,7 +63,7 @@ func TestSteeringSurvivesRepeatedCompactionAndCapacityRebuild(t *testing.T) {
}
t.Logf("boundary %d keeps steering", i+1)
}
- if err := service.installCapacityFallback(ctx, "ses_1", nil, "original user request", 9000, overflow.CompactionWatermarks{High: 8000, Low: 6000}, nil, nil); err != nil {
+ if err := service.installCapacityFallback(ctx, "ses_1", nil, "original user request", 9000, overflow.CompactionWatermarks{High: 8000, Low: 6000}, "", nil); err != nil {
t.Fatal(err)
}
fresh, _ := store.Messages(ctx, "ses_1")
diff --git a/internal/seniordev/session/compaction/service.go b/internal/seniordev/session/compaction/service.go
index 9f720c900c..42aa3c52b5 100644
--- a/internal/seniordev/session/compaction/service.go
+++ b/internal/seniordev/session/compaction/service.go
@@ -165,6 +165,9 @@ type CompactionDecision struct {
// stub because the summary block alone did not fit.
DroppedTail bool `json:"droppedTail"`
StubbedSummary bool `json:"stubbedSummary,omitempty"`
+ // StubCarriedChars is how much of the newest state record the capacity
+ // stub carried forward, in characters; zero when the stub carried none.
+ StubCarriedChars int `json:"stubCarriedChars,omitempty"`
// SummaryStatus is valid, normalized, fallback, no-head (nothing to
// summarize, no call made), overflow (the summary request itself exceeded
// the model), summary-error (the call failed), summary-stopped, or
@@ -516,18 +519,23 @@ func (s *Service) Process(ctx context.Context, input ProcessInput) (steploop.Res
SerializeTranscript(cloned, ToolOutputMaxChars), SummaryTranscriptMaxChars,
)
summaryPrompt := "\n" + transcript + "\n\n\n" + pinnedPrompt
+ authoritativeTask, taskSource := s.authoritativeTask(messages)
+ changedFiles := s.changedFiles(ctx)
// The summary's length target follows the same watermarks the trigger
- // used: a summary that cannot fit beside the fixed baseline and the kept
+ // used: a summary that cannot fit beside what the rebuild always carries
+ // (the system prompt, the tools and the pins written below) and the kept
// tail is rebuilt away by enforceWatermarks, so the instruction is sized
// to what the rebuild can hold. Unbounded windows carry no instruction.
- if budget, ok := s.summaryTokenBudget(ctx, cfg, originalModel, selected.Tokens); ok {
+ pins := BuildSteeringPin(s.steering()) + "\n" + BuildChangedFilesPin(changedFiles)
+ if authoritativeTask != "" {
+ pins += "\n" + BuildAuthoritativeTaskPin(authoritativeTask, taskSource)
+ }
+ if budget, ok := s.summaryTokenBudget(ctx, cfg, originalModel, model, selected.Tokens, pins); ok {
summaryPrompt += fmt.Sprintf(
"\n\nLength budget: keep the summary under %.0f tokens of prose; the rebuilt session must fit the model's window with room to continue.",
budget,
)
}
- authoritativeTask, taskSource := s.authoritativeTask(messages)
- changedFiles := s.changedFiles(ctx)
decision := CompactionDecision{
SessionID: input.SessionID,
@@ -775,6 +783,7 @@ func (s *Service) Process(ctx context.Context, input ProcessInput) (steploop.Res
decision.Capacity, decision.Low, decision.High = capacity.Capacity, capacity.Low, capacity.High
decision.DroppedTail = capacity.DroppedTail
decision.StubbedSummary = capacity.StubbedSummary
+ decision.StubCarriedChars = capacity.StubCarriedChars
if capacity.StubbedSummary {
decision.SummaryStatus = "capacity-fallback"
}
@@ -842,26 +851,57 @@ func minimumContinuationHeadroom(marks overflow.CompactionWatermarks) float64 {
return math.Max(1, math.Min(gap, wanted))
}
+const (
+ // summaryBudgetFloor is the smallest length budget a summary is ever given:
+ // below it there is no useful record to ask for, and the capacity rebuild
+ // is what answers a window that small.
+ summaryBudgetFloor = 256
+ // tailedSummaryLeast is the least room a summary must have BESIDE the kept
+ // tail for the two to be sized together. Below it the tail is the one that
+ // gives way -- enforceWatermarks drops it first -- so the summary is sized
+ // for the rebuild without it, because the progress record is worth more
+ // than the newest few messages.
+ tailedSummaryLeast = 1_024
+)
+
// summaryTokenBudget sizes the length instruction carried in the summary
-// prompt. The budget is what the rebuild can hold beside the fixed baseline
-// and the kept tail: unbounded windows carry no instruction at all.
+// prompt: what the rebuild can hold beside what it always carries and the
+// kept tail. Unbounded windows carry no instruction at all.
+//
+// THE BASELINE IS MEASURED, NEVER ASSUMED. It is the system prompt and the
+// tools as the sizer counts them, plus the pins written beside the summary
+// (pins). A fixed guess at it was what left small windows systematically
+// short: on a 16K window the real baseline was twice the guess, so every
+// summary asked for was one the rebuild then had to throw away.
+//
+// The budget never exceeds what the summarizer can say in one reply (its
+// output limit), because an instruction the model cannot obey only invites a
+// cut-off summary.
func (s *Service) summaryTokenBudget(
ctx context.Context,
cfg overflow.Config,
- model Model,
+ sizing Model,
+ summarizer Model,
tailTokens float64,
+ pins string,
) (float64, bool) {
marks := overflow.Watermarks(
- overflow.UsableInput{Cfg: cfg, Model: model.Overflow},
+ overflow.UsableInput{Cfg: cfg, Model: sizing.Overflow},
)
if marks.High <= 0 || math.IsInf(marks.High, 1) {
return 0, false
}
- budget := marks.High - minimumContinuationHeadroom(marks) - tailTokens - 2_048
- if budget < 256 {
- budget = 256
+ baseline := estimateTokens(pins)
+ if fixed, err := s.estimateContext(ctx, nil, sizing); err == nil {
+ baseline += fixed
+ }
+ room := marks.High - minimumContinuationHeadroom(marks) - baseline
+ budget := room - tailTokens
+ if budget < tailedSummaryLeast {
+ budget = room
}
- return budget, true
+ budget = math.Min(budget, overflow.MaxOutputTokens(summarizer.Overflow))
+ return math.Max(math.Floor(budget), summaryBudgetFloor), true
}
func (s *Service) enforceWatermarks(
@@ -924,21 +964,60 @@ func (s *Service) enforceWatermarks(
}
// Stage two: the summary block itself does not fit. Replace it with the
- // deterministic capacity stub.
- if err := s.installCapacityFallback(
- ctx, sessionID, compactionPart, source, after, marks,
- previousSummary, changedFiles,
- ); err != nil {
- return decision, err
+ // deterministic capacity stub, carrying as much of the newest state record
+ // as the window has room for.
+ record := s.newestSummaryText(ctx, sessionID)
+ if strings.TrimSpace(record) == "" && previousSummary != nil {
+ record = *previousSummary
+ }
+ fits := func(after float64) bool { return after < marks.High && marks.High-after >= headroom }
+ install := func(carried string) error {
+ if err := s.installCapacityFallback(
+ ctx, sessionID, compactionPart, source, decision.After, marks,
+ carried, changedFiles,
+ ); err != nil {
+ return err
+ }
+ after, err = s.projectedContextTokens(ctx, sessionID, model)
+ if err != nil {
+ return fmt.Errorf("compaction: measure deterministic rebuild: %w", err)
+ }
+ return nil
}
decision.DroppedTail = true
decision.StubbedSummary = true
- after, err = s.projectedContextTokens(ctx, sessionID, model)
- if err != nil {
- return decision, fmt.Errorf("compaction: measure deterministic rebuild: %w", err)
+ // THE BARE STUB IS THE FLOOR, and it is measured first: what the rebuild
+ // costs with no progress carried says exactly how much room is left for
+ // some. The record is then carried, cut to that room, and kept only if the
+ // rebuild still fits; a carry that does not fit falls back to the floor
+ // rather than ending the session, because a stub that fits and remembers
+ // less beats one that remembers more and stops the run.
+ if err := install(""); err != nil {
+ return decision, err
+ }
+ bare := after
+ if fits(bare) && strings.TrimSpace(record) != "" {
+ room := marks.High - headroom - bare
+ for _, share := range []float64{0.9, 0.5} {
+ chars := math.Floor(room * share * 4)
+ if chars/4 < summaryBudgetFloor {
+ break
+ }
+ carried := HeadTailTruncate(strings.TrimSpace(record), chars)
+ if err := install(carried); err != nil {
+ return decision, err
+ }
+ if fits(after) {
+ decision.StubCarriedChars = charCount(carried)
+ break
+ }
+ if err := install(""); err != nil {
+ return decision, err
+ }
+ }
}
decision.After = after
- if after < marks.High && marks.High-after >= headroom {
+ if fits(after) {
decision.Status = compactionStatusRebuilt
return decision, nil
}
@@ -946,23 +1025,43 @@ func (s *Service) enforceWatermarks(
return decision, ContextCapacityError{After: after, High: marks.High}
}
+// newestSummaryText is the state record of the session's newest completed
+// compaction -- at stage two, the one just generated -- or "" when there is
+// none. It is read before the capacity stub overwrites it, because it is the
+// freshest account of the work and the thing most worth carrying.
+func (s *Service) newestSummaryText(ctx context.Context, sessionID string) string {
+ messages, err := s.deps.Store.Messages(ctx, sessionID)
+ if err != nil {
+ return ""
+ }
+ prior := completedCompactions(messages)
+ if len(prior) == 0 || prior[len(prior)-1].Summary == nil {
+ return ""
+ }
+ return *prior[len(prior)-1].Summary
+}
+
+// compactFallbackSummary is the capacity stub. carried is the newest state
+// record already cut to the room the window has for it ("" for none); it is
+// quoted as data, so a carried heading can never pose as one of the stub's.
func compactFallbackSummary(
source string,
observed float64,
marks overflow.CompactionWatermarks,
- previousSummary *string,
+ carried string,
changedFiles []string,
) string {
current := "- Continue the authoritative task pinned verbatim beside this state record."
if source == "" {
current = "- Recover the original request from durable session state before editing."
}
- // The rebuild must carry progress forward: the previous summary is
- // quoted verbatim (bounded), and the changed-files list the install
- // loop would otherwise zero out is written into the stub itself.
+ // The rebuild must carry progress forward: the newest state record is
+ // quoted (cut to fit), and the changed-files list the install loop would
+ // otherwise zero out is written into the stub itself.
completed := "- No generated completion claim survived the capacity rebuild."
- if previousSummary != nil && strings.TrimSpace(*previousSummary) != "" {
- completed = quoteAsData(HeadTailTruncate(strings.TrimSpace(*previousSummary), 8_000))
+ if strings.TrimSpace(carried) != "" {
+ completed = "- The newest state record did not fit whole; it is carried forward as data, shortened to fit:\n" +
+ quoteAsData(carried)
}
files := "- (none)"
if len(changedFiles) > 0 {
@@ -1006,7 +1105,7 @@ func (s *Service) installCapacityFallback(
source string,
observed float64,
marks overflow.CompactionWatermarks,
- previousSummary *string,
+ carried string,
changedFiles []string,
) error {
if compactionPart != nil {
@@ -1026,7 +1125,7 @@ func (s *Service) installCapacityFallback(
return errors.New("compaction: completed summary missing during deterministic rebuild")
}
message := messages[prior[len(prior)-1].AssistantIndex]
- fallback := compactFallbackSummary(source, observed, marks, previousSummary, changedFiles)
+ fallback := compactFallbackSummary(source, observed, marks, carried, changedFiles)
written := false
for _, raw := range message.Parts {
part, ok := raw.(msgmodel.TextPart)
diff --git a/internal/seniordev/session/compaction/service_test.go b/internal/seniordev/session/compaction/service_test.go
index 47a572a278..a6c1108593 100644
--- a/internal/seniordev/session/compaction/service_test.go
+++ b/internal/seniordev/session/compaction/service_test.go
@@ -458,7 +458,7 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
summaryFlag := true
summary := testAssistant(
"as", "uc",
- textPart("as", testValidSummary("Fix src/a.ts")),
+ textPart("as", testValidSummary("Fixed src/a.ts and the build is green.")),
msgmodel.TextPart{
PartBase: msgmodel.PartBase{ID: "pin", SessionID: "ses_1", MessageID: "as"},
Text: BuildAuthoritativeTaskPin("Fix src/a.ts", "original user request"),
@@ -478,7 +478,9 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
store := &memoryStore{messages: []msgmodel.WithParts{
testUser("u0", textPart("u0", "Fix src/a.ts")), parent, summary,
}}
- sizes := []float64{90_000, 70_000, 20_000}
+ // The first, the tail-dropped and the bare-stub projections, then the
+ // stub carrying the newest record, which still fits.
+ sizes := []float64{90_000, 70_000, 20_000, 21_000}
deps := baseDeps(store)
deps.Sizer = ContextSizerFunc(func(
context.Context, []msgmodel.WithParts, Model,
@@ -487,7 +489,7 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
sizes = sizes[1:]
return value, nil
})
- previous := "## Working State\n### Completed\n- Fixed src/a.ts and the build is green."
+ previous := "## Working State\n### Completed\n- An OLDER record that must not be preferred."
changed := []string{"src/a.ts", "src/b.ts"}
decision, err := NewService(deps).enforceWatermarks(
context.Background(), "ses_1", 95_000, serviceModel(), watermarkTestConfig(),
@@ -497,7 +499,7 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
t.Fatal(err)
}
if decision.Status != compactionStatusRebuilt || !decision.DroppedTail || !decision.StubbedSummary ||
- decision.After != 20_000 || len(sizes) != 0 {
+ decision.After != 21_000 || decision.StubCarriedChars == 0 || len(sizes) != 0 {
t.Fatalf("decision = %#v; remaining sizes = %#v", decision, sizes)
}
fresh, _ := store.Messages(context.Background(), "ses_1")
@@ -505,15 +507,15 @@ func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T)
if generated == nil || !strings.Contains(*generated, "retained history did not fit") {
t.Fatalf("generated fallback = %v", generated)
}
- // The stub carries state forward: the previous summary verbatim and the
- // changed-files list, never the old erase marker.
- for _, want := range []string{"Fixed src/a.ts and the build is green.", "- src/a.ts", "- src/b.ts"} {
+ // The stub carries state forward: the NEWEST record (quoted as data) and
+ // the changed-files list, never the old erase marker.
+ for _, want := range []string{"> - Fixed src/a.ts and the build is green.", "### Files\n- src/a.ts\n- src/b.ts"} {
if !strings.Contains(*generated, want) {
- t.Fatalf("fallback stub lost %q: %v", want, generated)
+ t.Fatalf("fallback stub lost %q: %v", want, *generated)
}
}
- if strings.Contains(*generated, "- (none)") {
- t.Fatalf("fallback stub erased the files list: %v", generated)
+ if strings.Contains(*generated, "OLDER record") || strings.Contains(*generated, "No generated completion claim") {
+ t.Fatalf("fallback stub carried the wrong record: %v", *generated)
}
evidenceRemoved := false
for _, raw := range fresh[2].Parts {
@@ -968,28 +970,55 @@ func TestProcessPinsSpecOnValidSummaryWhenTheRequestIsUnrecoverable(t *testing.T
}
}
+// smallWindowConfig is a 20K capacity: high 12,000 and a continuation
+// headroom of 2,048, so the summary budget is decided by the window.
+func smallWindowConfig() overflow.Config {
+ capacity := 20_000.0
+ return overflow.Config{Compaction: &overflow.CompactionConfig{CapacityTokens: &capacity}}
+}
+
+// THE SUMMARY BUDGET IS WHAT THE REBUILD CAN HOLD, measured. The baseline is
+// the sizer's own count of the system prompt and tools plus the pins, never a
+// guess; a tail that leaves the summary too little room gives way to it; and
+// the budget never asks for more than the summarizer can say in one reply.
func TestSummaryTokenBudgetFollowsTheWatermarks(t *testing.T) {
- service := NewService(baseDeps(&memoryStore{}))
- cfg := watermarkTestConfig() // capacity 100K: high 60,000, headroom 6,000
- budget, ok := service.summaryTokenBudget(
- context.Background(), cfg, serviceModel(), 10_000,
- )
- if !ok || budget != 41_952 {
- t.Fatalf("budget = %v,%v want 41952 (60000-6000-10000-2048)", budget, ok)
- }
- // A tail that alone eats the window clamps to the floor, never negative.
- floor, ok := service.summaryTokenBudget(
- context.Background(), cfg, serviceModel(), 200_000,
- )
- if !ok || floor != 256 {
- t.Fatalf("clamped budget = %v,%v want 256", floor, ok)
+ deps := baseDeps(&memoryStore{})
+ fixed := 3_000.0
+ deps.Sizer = ContextSizerFunc(func(
+ _ context.Context, messages []msgmodel.WithParts, _ Model,
+ ) (float64, error) {
+ if len(messages) == 0 {
+ return fixed, nil
+ }
+ return 0, nil
+ })
+ service := NewService(deps)
+ pins := strings.Repeat("x", 4_000) // 1,000 tokens of pins
+ ctx := context.Background()
+ // 12,000 - 2,048 headroom - 3,000 fixed - 1,000 pins = 5,952 of room.
+ if budget, ok := service.summaryTokenBudget(ctx, smallWindowConfig(), serviceModel(), serviceModel(), 2_000, pins); !ok || budget != 3_952 {
+ t.Fatalf("budget beside a 2,000-token tail = %v,%v want 3952", budget, ok)
+ }
+ // A tail that would leave under 1,024 gives way: the summary is sized for
+ // the rebuild without it.
+ if budget, ok := service.summaryTokenBudget(ctx, smallWindowConfig(), serviceModel(), serviceModel(), 5_000, pins); !ok || budget != 5_952 {
+ t.Fatalf("budget beside a 5,000-token tail = %v,%v want 5952", budget, ok)
+ }
+ // Never more than the summarizer can say in one reply.
+ terse := serviceModel()
+ terse.Overflow.Limit.Output = 2_048
+ if budget, ok := service.summaryTokenBudget(ctx, smallWindowConfig(), serviceModel(), terse, 0, pins); !ok || budget != 2_048 {
+ t.Fatalf("budget for a 2,048-output summarizer = %v,%v want 2048", budget, ok)
+ }
+ // A baseline that eats the window clamps to the floor, never negative.
+ fixed = 50_000
+ if budget, ok := service.summaryTokenBudget(ctx, smallWindowConfig(), serviceModel(), serviceModel(), 0, pins); !ok || budget != summaryBudgetFloor {
+ t.Fatalf("clamped budget = %v,%v want %v", budget, ok, summaryBudgetFloor)
}
// An unbounded window carries no instruction at all.
unbounded := serviceModel()
unbounded.Overflow.Limit.Context = 0
- if _, ok := service.summaryTokenBudget(
- context.Background(), overflow.Config{}, unbounded, 0,
- ); ok {
+ if _, ok := service.summaryTokenBudget(ctx, overflow.Config{}, unbounded, unbounded, 0, ""); ok {
t.Fatal("an unbounded window must not carry a length budget")
}
}
@@ -1050,15 +1079,10 @@ func TestSummaryPromptCarriesTheWatermarkLengthBudget(t *testing.T) {
if len(decisions) != 1 {
t.Fatalf("decisions = %#v", decisions)
}
- want, ok := service.summaryTokenBudget(
- context.Background(), cfg, serviceModel(), decisions[0].TailTokens,
- )
- if !ok {
- t.Fatal("a bounded window must carry a length budget")
- }
- instruction := fmt.Sprintf(
- "Length budget: keep the summary under %.0f tokens", want,
- )
+ // The window here is 60,000 high against an 8,192-token summarizer, so the
+ // budget is the summarizer's own ceiling.
+ _ = decisions
+ instruction := "Length budget: keep the summary under 8192 tokens"
if !strings.Contains(prompt, instruction) {
t.Fatalf("summary prompt lacks %q: %q", instruction, prompt)
}
diff --git a/internal/seniordev/session/compaction/small_window_test.go b/internal/seniordev/session/compaction/small_window_test.go
new file mode 100644
index 0000000000..b30152a3c4
--- /dev/null
+++ b/internal/seniordev/session/compaction/small_window_test.go
@@ -0,0 +1,176 @@
+//go:build !windows
+
+package compaction
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel"
+ "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop"
+ "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow"
+)
+
+// sixteenKModel is the window senior-dev assumed for every model when its
+// catalog could not be read: 16,384 tokens of context and 2,048 of output, so
+// a high watermark of 8,601 and a rebuild that must land at or under 6,553.
+func sixteenKModel() Model {
+ model := serviceModel()
+ model.Overflow = overflow.Model{Limit: calc.ModelLimit{Context: 16_384, Output: 2_048}}
+ return model
+}
+
+// longSummary is a valid state record far larger than a 16K window can hold
+// beside its baseline, with a marker at each end so a test can see what of
+// it was carried.
+func longSummary(boundary int) string {
+ lines := []string{fmt.Sprintf("- FIRST-%d: reproduced the crash in src/parse.c", boundary)}
+ for step := 0; step < 160; step++ {
+ lines = append(lines, fmt.Sprintf("- step %d: read a file, ran a command, noted what it said about the bug", step))
+ }
+ lines = append(lines, fmt.Sprintf("- LAST-%d: the patch in src/parse.c builds and the PoC no longer crashes", boundary))
+ return strings.Join([]string{
+ "## Working State",
+ "### Completed", strings.Join(lines, "\n"),
+ "### Current", "- verify the patch",
+ "### Verification", "- the PoC ran clean once",
+ "### Next", "- write /output/fix.patch",
+ "### Files", "- src/parse.c",
+ }, "\n")
+}
+
+// THE 16K LOOP, REPRODUCED. A session on a 16K window compacts again and
+// again, and every time the generated summary is too big to fit beside the
+// four thousand tokens the system prompt and tools always cost. Before, each
+// rebuild installed a stub that said "No generated completion claim survived
+// the capacity rebuild" and the session started over from nothing, which is
+// how a CyberGym run spent 25 minutes rediscovering the same bug. Now each
+// rebuild still fits, and it carries the newest record forward, cut to the
+// room the window has.
+func TestASixteenKSessionKeepsItsProgressThroughRepeatedCapacityRebuilds(t *testing.T) {
+ ctx := context.Background()
+ messages := compactionConversation("coder")
+ store := &memoryStore{messages: messages}
+ deps := baseDeps(store)
+ deps.Provider = &fakeProvider{model: sixteenKModel()}
+ deps.ChangedFiles = func(context.Context) []string { return []string{"src/parse.c"} }
+ var service *Service
+ // The sizer counts what the real one does: the messages, plus a fixed
+ // four thousand tokens of system prompt and tools.
+ deps.Sizer = ContextSizerFunc(func(
+ _ context.Context, messages []msgmodel.WithParts, model Model,
+ ) (float64, error) {
+ tokens, err := service.Estimate(messages, model)
+ return tokens + 4_000, err
+ })
+ decisions := []CompactionDecision{}
+ deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) {
+ decisions = append(decisions, decision)
+ })
+ boundary := 0
+ deps.Processors = ProcessorFactoryFunc(func(_ context.Context, a *msgmodel.Assistant, _ string, _ Model) (SummaryProcessor, error) {
+ return &fakeProcessor{message: a, process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) {
+ finish := "stop"
+ a.Finish = &finish
+ if err := store.UpdateMessage(ctx, *a); err != nil {
+ return steploop.ResultStop, err
+ }
+ err := store.UpdatePart(ctx, textPart(a.ID, longSummary(boundary)))
+ return steploop.ResultContinue, err
+ }}, nil
+ })
+ service = NewService(deps)
+ for boundary = 1; boundary <= 3; boundary++ {
+ parent := "uc"
+ if boundary > 1 {
+ parent = fmt.Sprintf("uc%d", boundary)
+ part := msgmodel.CompactionPart{PartBase: msgmodel.PartBase{
+ ID: "pc" + parent, MessageID: parent, SessionID: "ses_1",
+ }}
+ user := testUser(parent, part)
+ if err := store.UpdateMessage(ctx, user.Info); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.UpdatePart(ctx, part); err != nil {
+ t.Fatal(err)
+ }
+ messages, _ = store.Messages(ctx, "ses_1")
+ }
+ result, err := service.Process(ctx, ProcessInput{
+ ParentID: parent, Messages: messages, SessionID: "ses_1",
+ })
+ if err != nil || result != steploop.ResultContinue {
+ t.Fatalf("boundary %d: %s %v (decisions %#v)", boundary, result, err, decisions)
+ }
+ decision := decisions[len(decisions)-1]
+ if decision.Status != compactionStatusRebuilt || !decision.StubbedSummary || decision.StubCarriedChars == 0 {
+ t.Fatalf("boundary %d: decision = %#v, want a rebuilt stub that carried the record", boundary, decision)
+ }
+ if decision.After > decision.High-minimumContinuationHeadroom(overflow.CompactionWatermarks{High: decision.High, Low: decision.Low}) {
+ t.Fatalf("boundary %d: rebuild landed at %.0f, past the window's room", boundary, decision.After)
+ }
+ fresh, _ := store.Messages(ctx, "ses_1")
+ text := generatedSummaryText(fresh[completedCompactions(fresh)[boundary-1].AssistantIndex])
+ if text == nil {
+ t.Fatalf("boundary %d: no state record", boundary)
+ }
+ if strings.Contains(*text, "No generated completion claim survived") {
+ t.Fatalf("boundary %d erased the session's progress: %s", boundary, *text)
+ }
+ for _, want := range []string{fmt.Sprintf("FIRST-%d", boundary), fmt.Sprintf("LAST-%d", boundary), "- src/parse.c"} {
+ if !strings.Contains(*text, want) {
+ t.Fatalf("boundary %d lost %q from its state record: %s", boundary, want, *text)
+ }
+ }
+ }
+}
+
+// A CARRY THAT DOES NOT FIT FALLS BACK TO THE BARE STUB, never to the end of
+// the session: a rebuild that remembers less and fits beats one that
+// remembers more and stops the run.
+func TestACarriedRecordThatDoesNotFitFallsBackToTheBareStub(t *testing.T) {
+ tail := "u0"
+ compactionPart := msgmodel.CompactionPart{
+ PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"},
+ Auto: true, TailStartID: &tail,
+ }
+ finish := "stop"
+ summaryFlag := true
+ summary := testAssistant("as", "uc", textPart("as", testValidSummary("Fixed src/a.ts")))
+ assistant := summary.Info.(msgmodel.Assistant)
+ assistant.Summary = &summaryFlag
+ assistant.Finish = &finish
+ summary.Info = assistant
+ store := &memoryStore{messages: []msgmodel.WithParts{
+ testUser("u0", textPart("u0", "Fix src/a.ts")), testUser("uc", compactionPart), summary,
+ }}
+ // First, tail-dropped, bare stub; then each carry overshoots and the bare
+ // stub is put back.
+ sizes := []float64{90_000, 70_000, 20_000, 59_000, 20_000, 58_000, 20_000}
+ deps := baseDeps(store)
+ deps.Sizer = ContextSizerFunc(func(context.Context, []msgmodel.WithParts, Model) (float64, error) {
+ value := sizes[0]
+ sizes = sizes[1:]
+ return value, nil
+ })
+ decision, err := NewService(deps).enforceWatermarks(
+ context.Background(), "ses_1", 95_000, serviceModel(), watermarkTestConfig(),
+ &compactionPart, "original user request", nil, nil,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decision.Status != compactionStatusRebuilt || decision.After != 20_000 ||
+ decision.StubCarriedChars != 0 || len(sizes) != 0 {
+ t.Fatalf("decision = %#v; remaining sizes = %#v", decision, sizes)
+ }
+ fresh, _ := store.Messages(context.Background(), "ses_1")
+ generated := generatedSummaryText(fresh[2])
+ if generated == nil || !strings.Contains(*generated, "No generated completion claim survived") ||
+ strings.Contains(*generated, "Fixed src/a.ts") {
+ t.Fatalf("a carry that did not fit was kept: %v", generated)
+ }
+}
diff --git a/internal/seniordev/session/overflow/overflow.go b/internal/seniordev/session/overflow/overflow.go
index 8b77b99ab9..65ef7efd24 100644
--- a/internal/seniordev/session/overflow/overflow.go
+++ b/internal/seniordev/session/overflow/overflow.go
@@ -35,3 +35,7 @@ func Watermarks(input UsableInput) CompactionWatermarks {
func IsOverflow(input OverflowInput) bool {
return calc.IsOverflow(input)
}
+
+// MaxOutputTokens is the most a single reply from model may hold, which is
+// also the most a generated summary can be.
+func MaxOutputTokens(model Model) float64 { return calc.MaxOutputTokens(model) }
From c5b6a2d32b15d72ce8a8edc439dec83ef2081740 Mon Sep 17 00:00:00 2001
From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:43:26 -0400
Subject: [PATCH 3/5] config: keep the window test's table formatted the same
under Go 1.26 and 1.27
The Go 1.26 gofmt CI runs breaks a map's alignment around a far shorter
key, and the empty-string case did exactly that. The blank-model case is
now asserted on its own line.
Co-Authored-By: Claude Opus 5.5
---
internal/config/window_test.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/internal/config/window_test.go b/internal/config/window_test.go
index f71a1ca965..222effe3a1 100644
--- a/internal/config/window_test.go
+++ b/internal/config/window_test.go
@@ -37,12 +37,14 @@ func TestCachedContextWindowReadsTheProfilesOwnCatalog(t *testing.T) {
"openrouter/deepseek/deepseek-v4.1-flash": 1_048_576,
"vendor/small": 16_384,
"vendor/never-listed": 0,
- "": 0,
} {
if got := CachedContextWindow(model); got != want {
t.Errorf("CachedContextWindow(%q) = %d, want %d", model, got, want)
}
}
+ if got := CachedContextWindow(" "); got != 0 {
+ t.Errorf("a blank model answered %d, want 0", got)
+ }
if calls.Load() != 0 {
t.Fatalf("the lookup reached the network %d times", calls.Load())
}
From 472fa199e92c8f2b9c4696d3dccee0a6eb97951e Mon Sep 17 00:00:00 2001
From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:50:05 -0400
Subject: [PATCH 4/5] config: write the window test's cases as a struct table
CI's gofmt agrees with
Go 1.26.5's gofmt, which CI runs, splits a map literal's alignment between
long and short keys where 1.27's does not, so the two disagreed on this
file. A slice of cases has no key column to align. Every changed Go file
is now clean under both versions.
Co-Authored-By: Claude Opus 5.5
---
internal/config/window_test.go | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/internal/config/window_test.go b/internal/config/window_test.go
index 222effe3a1..c9276dc13c 100644
--- a/internal/config/window_test.go
+++ b/internal/config/window_test.go
@@ -32,14 +32,17 @@ func TestCachedContextWindowReadsTheProfilesOwnCatalog(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
- for model, want := range map[string]int{
- "deepseek/deepseek-v4.1-flash": 1_048_576,
- "openrouter/deepseek/deepseek-v4.1-flash": 1_048_576,
- "vendor/small": 16_384,
- "vendor/never-listed": 0,
+ for _, tc := range []struct {
+ model string
+ want int
+ }{
+ {"deepseek/deepseek-v4.1-flash", 1_048_576},
+ {"openrouter/deepseek/deepseek-v4.1-flash", 1_048_576},
+ {"vendor/small", 16_384},
+ {"vendor/never-listed", 0},
} {
- if got := CachedContextWindow(model); got != want {
- t.Errorf("CachedContextWindow(%q) = %d, want %d", model, got, want)
+ if got := CachedContextWindow(tc.model); got != tc.want {
+ t.Errorf("CachedContextWindow(%q) = %d, want %d", tc.model, got, tc.want)
}
}
if got := CachedContextWindow(" "); got != 0 {
From b334655c7612e452247feb24cc55acc3a4798fa3 Mon Sep 17 00:00:00 2001
From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:40:20 -0400
Subject: [PATCH 5/5] seniordev: leave a tiny crew seat out instead of refusing
the run; say what the capacity stub keeps
Review follow-ups on #1791:
- A crew seat known to hold 32,768 tokens or fewer is now left out with
a note, as a seat nothing can size already was, and a coder's pool it
empties routes on senior-dev's own list. Only models a person asked for
are refused by name. The manual's crew passage and its new section say
the same.
- The manual no longer says senior-dev never throws its progress away:
when none of the newest summary fits, only the changed-files list
carries over.
- The shell host's catalog wait is covered: the end-to-end senior-dev
shell test checks that it runs once, before any model call, and is
bounded by catalog.FetchTimeout.
Co-Authored-By: Claude Opus 5.5
---
cmd/codeaf/carried_seniordev_test.go | 23 ++++++++-
.../1791-senior-dev-small-windows.md | 2 +-
internal/manual/chat/senior-dev.md | 26 +++++-----
internal/seniordev/app/run.go | 4 ++
internal/seniordev/app/window_check.go | 47 +++++++++++++++++--
internal/seniordev/app/window_check_test.go | 44 +++++++++++++++++
6 files changed, 128 insertions(+), 18 deletions(-)
diff --git a/cmd/codeaf/carried_seniordev_test.go b/cmd/codeaf/carried_seniordev_test.go
index 20b7159fec..6619851d78 100644
--- a/cmd/codeaf/carried_seniordev_test.go
+++ b/cmd/codeaf/carried_seniordev_test.go
@@ -16,6 +16,7 @@ import (
"time"
"github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/delegate"
"github.com/Agent-Field/codeaf/internal/delegate/builtin"
"github.com/Agent-Field/codeaf/internal/provider"
@@ -95,7 +96,23 @@ func TestSeniorDevWorksATaskThroughTheShellHostsModelAPI(t *testing.T) {
t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
model := &seniorDevModel{}
previousRoad, previousOut, previousGrace := carriedModels, carriedStdout, carriedGrace
- carriedModels = func() (carriedRoad, error) { return carriedRoad{completerFor: model.completerFor}, nil }
+ // THE PROFILE'S CATALOG IS WAITED FOR BEFORE THE PROGRAM STARTS, once and
+ // within one catalog fetch, so the window senior-dev reads from it is on
+ // disk before its first call (carried.go's carriedCatalogWarm).
+ var warms, callsAtWarm int
+ var warmBound time.Duration
+ carriedModels = func() (carriedRoad, error) {
+ return carriedRoad{completerFor: model.completerFor, warmCatalog: func(ctx context.Context) bool {
+ warms++
+ if deadline, ok := ctx.Deadline(); ok {
+ warmBound = time.Until(deadline)
+ }
+ model.mu.Lock()
+ callsAtWarm = model.calls
+ model.mu.Unlock()
+ return true
+ }}, nil
+ }
printed := &lockedBuffer{}
carriedStdout = printed
carriedGrace = 5 * time.Second
@@ -111,6 +128,10 @@ func TestSeniorDevWorksATaskThroughTheShellHostsModelAPI(t *testing.T) {
}
t.Fatalf("senior-dev's shell run left with %d:\n%s\nits stderr:\n%s", code, out, stderr)
}
+ if warms != 1 || callsAtWarm != 0 || warmBound <= 0 || warmBound > catalog.FetchTimeout {
+ t.Fatalf("catalog waited for %d times, after %d calls, bounded by %v; want once, before any call, within %v",
+ warms, callsAtWarm, warmBound, catalog.FetchTimeout)
+ }
for _, want := range []string{"senior-dev · working in " + workspace, "senior-dev finished", "senior-dev's model said: feature.txt now holds the feature", " · 300 in · 20 out · $0.0020"} {
if !strings.Contains(out, want) {
t.Fatalf("the shell run never printed %q:\n%s", want, out)
diff --git a/docs/changes/unreleased/1791-senior-dev-small-windows.md b/docs/changes/unreleased/1791-senior-dev-small-windows.md
index 3fbfa75bc4..87d3781be2 100644
--- a/docs/changes/unreleased/1791-senior-dev-small-windows.md
+++ b/docs/changes/unreleased/1791-senior-dev-small-windows.md
@@ -5,7 +5,7 @@ pr: 1791
surface: [engine, chat, docs]
invalidates:
- "senior-dev sized every model at 16,384 tokens when models.dev could not be reached. It now reads the window from the model catalog codeaf keeps for the profile, and only guesses 16,384 when neither knows the model, saying so on its page and in its log."
- - "senior-dev started on any model it could size. A model known to hold 32,768 tokens or fewer is now refused before its first call with `senior-dev cannot work with ( tokens): …`, and nothing is spent."
+ - "senior-dev started on any model it could size. A model the person asked for that is known to hold 32,768 tokens or fewer is now refused before its first call with `senior-dev cannot work with ( tokens): …`, and nothing is spent; a crew seat that small is left out with a note instead."
- "When a compacted history did not fit, senior-dev replaced the summary with a stub that kept no progress. The stub now carries as much of the newest summary as fits, and the changed-files list."
- "The summary length budget assumed 2,048 tokens of fixed context. It now measures the system prompt, tools and pins, and never asks for more than the summarizer's output limit."
- "A context-overflow pin applied to one senior-dev session. It now applies to every session on the same model for the rest of the run."
diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md
index 519cd0aa55..23d50a59cb 100644
--- a/internal/manual/chat/senior-dev.md
+++ b/internal/manual/chat/senior-dev.md
@@ -936,9 +936,9 @@ the task's first line name them. A name that fits more than one model is put to
settle. A model none of your connected services can serve is refused before the card, by
name, rather than swapped for another. When a catalog was loaded, a model neither it nor
codeaf's own model catalog can size cannot be used: the run ends before its first call with
-`senior-dev cannot work with : …`, and nothing is spent. A model known to hold
-32,768 tokens or fewer is refused the same way (see the section on a model too small for
-senior-dev). If models.dev is unavailable and codeaf's catalog does not know the model
+`senior-dev cannot work with : …`, and nothing is spent. A model you asked for
+that is known to hold 32,768 tokens or fewer is refused the same way (see the section on
+a model too small for senior-dev). If models.dev is unavailable and codeaf's catalog does not know the model
either, the run starts on an assumed 16,384 tokens and says so. The models are fixed when the run starts; changing the crew later does not move
a run already working. `/senior-dev` typed with a brief uses your crew.
@@ -953,8 +953,9 @@ planner and checker routing and `/crew`'s per-task limit apply to codeaf's own
tasks, not senior-dev's run. Change the crew and the next run follows. The
mastermind (brain) model is not used: every call senior-dev makes is either its
work or a history summary.
-A crew model senior-dev's model catalog cannot size is left out, and its log says so;
-if that leaves no working model, it uses its own list instead.
+A crew model senior-dev's model catalog cannot size is left out, and its log says so,
+and so is one known to hold 32,768 tokens or fewer; if that leaves no working model, it
+uses its own list instead.
## senior-dev keeps compacting, or refused a model as too small — how much its model can hold, a 32K model, could not learn how much its model holds
@@ -964,11 +965,12 @@ first, then from the model catalog codeaf keeps for your profile, which it reads
disk with no network. A shell run waits up to 15 seconds for codeaf to list its models
first on a profile that has never listed them.
-**A model known to hold 32,768 tokens or fewer is refused before its first call**, and
-nothing is spent: `senior-dev cannot work with (16,384 tokens): a run needs a
-model that holds more than 32,768 tokens to keep its brief, its tools and its progress
-in view, so nothing was started; ask for a model with a larger window`. Ask for a
-larger model.
+**A model you asked for that is known to hold 32,768 tokens or fewer is refused before
+its first call**, and nothing is spent: `senior-dev cannot work with (16,384
+tokens): a run needs a model that holds more than 32,768 tokens to keep its brief, its
+tools and its progress in view, so nothing was started; ask for a model with a larger
+window`. Ask for a larger model. **A crew model that small is left out instead**, with a
+note in the run's log; if that leaves no model for its work, it uses its own list.
**When nothing knows the model, it assumes 16,384 tokens and runs.** Its page says
`could not learn how much its model holds; assumed 16,384 tokens`, and its log says
@@ -978,8 +980,8 @@ once on that machine while it can reach your model service; codeaf keeps the lis
prints, and senior-dev reads the window from it.
When a compacted history still does not fit, senior-dev keeps as much of its newest
-summary as fits beside the brief and the list of changed files. It never throws its
-progress away.
+summary as fits beside the brief and the list of changed files. Only when none of the
+summary fits does the list of changed files carry over on its own.
## Which model is my senior-dev run on — the models it was launched with, the foot of its task page
diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go
index 9ecbe43ca5..4278fbfc5f 100644
--- a/internal/seniordev/app/run.go
+++ b/internal/seniordev/app/run.go
@@ -192,6 +192,10 @@ func runWith(ctx context.Context, host delegate.Host, options Options, notes io.
if options.Asked {
args.High = high
}
+ // A crew seat too small to work in is left out the way one
+ // nothing can size is; the models a person asked for are not
+ // seats, and are refused below instead ([leaveOutTinyCrewSeats]).
+ args = leaveOutTinyCrewSeats(args, options.Asked, models, notes)
}
// A MODEL TOO SMALL TO WORK IN IS REFUSED BEFORE ITS FIRST CALL, by
// name and size, and nothing is spent ([windowCheck]).
diff --git a/internal/seniordev/app/window_check.go b/internal/seniordev/app/window_check.go
index 96518ba2e9..21dd98acbb 100644
--- a/internal/seniordev/app/window_check.go
+++ b/internal/seniordev/app/window_check.go
@@ -40,6 +40,46 @@ func (models seniorDevModels) windowOf(ref string) (float64, string, error) {
return metadata.Limit.Context, source, nil
}
+// tinyWindow reports a model whose window is KNOWN to be at or under
+// [tinyWindowTokens], and that window. A guess is never tiny: it is what
+// senior-dev assumes when nothing could size the model.
+func (models seniorDevModels) tinyWindow(ref string) (float64, bool) {
+ window, source, err := models.windowOf(ref)
+ return window, err == nil && source != sizedByGuess && window > 0 && window <= tinyWindowTokens
+}
+
+// leaveOutTinyCrewSeats takes a crew seat too small to work in out of the
+// pools, with a note, exactly as [crewPools] takes out a seat nothing can
+// size, and routes on senior-dev's own list when that empties the coder's
+// pool. A crew is a standing choice nobody made for this brief, so its small
+// light seat must not refuse a run the person never named that model for.
+// keepHigh holds the coder's pool as it is: under --asked those are the
+// models the person named, and [windowCheck] refuses them by name instead.
+func leaveOutTinyCrewSeats(args cliArgs, keepHigh bool, models seniorDevModels, notes io.Writer) cliArgs {
+ keep := func(raw string) string {
+ var kept []string
+ for _, ref := range splitPool(raw) {
+ if window, tiny := models.tinyWindow(ref); tiny {
+ _, _ = fmt.Fprintf(notes,
+ "[senior-dev] the crew's %s holds only %s tokens, too few to work in; it is left out of this run\n",
+ ref, groupedTokens(window))
+ continue
+ }
+ kept = append(kept, ref)
+ }
+ return strings.Join(kept, ",")
+ }
+ args.Low, args.Frontier = keep(args.Low), keep(args.Frontier)
+ if keepHigh {
+ return args
+ }
+ if args.High = keep(args.High); args.High == "" {
+ _, _ = fmt.Fprintf(notes, "[senior-dev] none of the crew's models is large enough to work in; routing on senior-dev's own list\n")
+ args.High = DefaultHighModels
+ }
+ return args
+}
+
// windowCheck reads the window of every model the run will call -- the
// coder's pool, the light pool its history summaries run on, and the
// frontier -- before anything is spent. It answers the refusal for models
@@ -61,14 +101,13 @@ func windowCheck(args cliArgs, models seniorDevModels) (refusal string, guessed
continue
}
seen[name] = true
- window, source, err := models.windowOf(ref)
+ _, source, err := models.windowOf(ref)
if err != nil {
continue
}
- switch {
- case source == sizedByGuess:
+ if source == sizedByGuess {
guessed = append(guessed, name)
- case window > 0 && window <= tinyWindowTokens:
+ } else if window, small := models.tinyWindow(ref); small {
tiny = append(tiny, fmt.Sprintf("%s (%s tokens)", name, groupedTokens(window)))
}
}
diff --git a/internal/seniordev/app/window_check_test.go b/internal/seniordev/app/window_check_test.go
index 4db3cab744..562b563692 100644
--- a/internal/seniordev/app/window_check_test.go
+++ b/internal/seniordev/app/window_check_test.go
@@ -5,6 +5,7 @@ package app
import (
"bytes"
"context"
+ "io"
"path/filepath"
"strings"
"testing"
@@ -141,3 +142,46 @@ func TestARunOnATinyModelIsRefusedBeforeItStarts(t *testing.T) {
t.Fatalf("a refused run reported stages: %v", host.stages)
}
}
+
+// A CREW SEAT TOO SMALL TO WORK IN IS LEFT OUT, NOT REFUSED. The crew is a
+// standing choice nobody made for this brief, so its small light seat is
+// dropped with a note, as a seat nothing can size is, and a coder's pool it
+// empties routes on senior-dev's own list. Under --asked the coder's pool is
+// what the person named, so it is kept and refused by name instead.
+func TestATinyCrewSeatIsLeftOutAndAnAskedOneIsRefused(t *testing.T) {
+ backend := newModelAPIBackend(testModelAPI, "")
+ backend.catalog = modelsdev.Catalog{}
+ backend.windowFor = codeafKnows(map[string]int{
+ "vendor/hands": 262_144,
+ "vendor/small": 16_384,
+ })
+ models := seniorDevModels{backend: backend}
+
+ var notes bytes.Buffer
+ args := leaveOutTinyCrewSeats(cliArgs{
+ High: "openrouter/vendor/hands", Low: "openrouter/vendor/small", Frontier: "openrouter/vendor/small",
+ }, false, models, ¬es)
+ if args.High != "openrouter/vendor/hands" || args.Low != "" || args.Frontier != "" {
+ t.Fatalf("pools = %+v, want the small seats left out", args)
+ }
+ if !strings.Contains(notes.String(), "the crew's openrouter/vendor/small holds only 16,384 tokens, too few to work in; it is left out of this run") {
+ t.Fatalf("notes = %q", notes.String())
+ }
+ if refusal, _ := windowCheck(args, models); refusal != "" {
+ t.Fatalf("a crew run whose small seat was left out was refused: %q", refusal)
+ }
+
+ notes.Reset()
+ if args := leaveOutTinyCrewSeats(cliArgs{High: "openrouter/vendor/small"}, false, models, ¬es); args.High != DefaultHighModels ||
+ !strings.Contains(notes.String(), "routing on senior-dev's own list") {
+ t.Fatalf("a crew whose only working seat is small: High = %q, notes = %q", args.High, notes.String())
+ }
+
+ asked := leaveOutTinyCrewSeats(cliArgs{High: "openrouter/vendor/small", Low: "openrouter/vendor/small"}, true, models, io.Discard)
+ if asked.High != "openrouter/vendor/small" || asked.Low != "" {
+ t.Fatalf("asked pools = %+v, want the asked model kept and the light seat left out", asked)
+ }
+ if refusal, _ := windowCheck(asked, models); !strings.HasPrefix(refusal, "senior-dev cannot work with vendor/small (16,384 tokens)") {
+ t.Fatalf("an asked model too small to work in was not refused: %q", refusal)
+ }
+}