Skip to content

Commit ba31e14

Browse files
committed
feat: add compact window/ratio settings and unify /model persistence
1 parent 7a5016d commit ba31e14

8 files changed

Lines changed: 92 additions & 254 deletions

File tree

internal/agent/session_runtime.go

Lines changed: 11 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ func (s *Session) SetModel(prov, model string) error {
485485
})
486486

487487
s.reclampThinking()
488+
s.updateContextFromRegistry(prov, model)
488489

489490
return nil
490491
}
@@ -500,115 +501,6 @@ func (s *Session) providerType(prov string) (string, error) {
500501
return config.ResolveProviderType(prov, "")
501502
}
502503

503-
func (s *Session) ResolveAndSetModel(pattern string) (resolved string, err error) {
504-
defer func() {
505-
if err == nil {
506-
prov, model := s.Provider(), s.ModelName()
507-
if e := config.PatchGlobalSettings(config.Settings{
508-
Provider: &prov,
509-
Model: &model,
510-
}); e != nil {
511-
fmt.Fprintf(os.Stderr, "warning: persist model setting: %v\n", e)
512-
}
513-
}
514-
}()
515-
516-
// Extract :thinking suffix (e.g. "model:high").
517-
thinkingLevel := agentcore.ThinkingLevel("")
518-
if idx := strings.LastIndex(pattern, ":"); idx > 0 {
519-
suffix := pattern[idx+1:]
520-
if provider.IsValidThinkingLevel(suffix) {
521-
thinkingLevel = agentcore.ThinkingLevel(suffix)
522-
pattern = pattern[:idx]
523-
}
524-
}
525-
526-
// Snapshot configured providers.
527-
s.mu.Lock()
528-
provSnapshot := make(map[string]config.ProviderConfig, len(s.providers))
529-
maps.Copy(provSnapshot, s.providers)
530-
s.mu.Unlock()
531-
532-
// Explicit provider/model or provider:model.
533-
// Only split when the left side is a configured provider key so that
534-
// model IDs containing slashes (e.g. OpenRouter's "openai/gpt-5") still
535-
// work as explicit model IDs under the chosen provider.
536-
if prov, model, ok := splitExplicitModelPattern(pattern, provSnapshot); ok {
537-
if err := s.SetModel(prov, model); err != nil {
538-
return "", err
539-
}
540-
s.updateContextFromRegistry(prov, model)
541-
if thinkingLevel != "" {
542-
s.SetThinkingLevel(thinkingLevel)
543-
}
544-
return explicitModelID(prov, model), nil
545-
}
546-
547-
// Search across all configured providers' models lists.
548-
type match struct {
549-
provider string
550-
model string
551-
}
552-
var matches []match
553-
for provName, pc := range provSnapshot {
554-
for _, m := range pc.Models {
555-
if strings.EqualFold(m, pattern) {
556-
matches = append(matches, match{provider: provName, model: m})
557-
}
558-
}
559-
}
560-
if len(matches) == 1 {
561-
m := matches[0]
562-
if err := s.SetModel(m.provider, m.model); err != nil {
563-
return "", err
564-
}
565-
s.updateContextFromRegistry(m.provider, m.model)
566-
if thinkingLevel != "" {
567-
s.SetThinkingLevel(thinkingLevel)
568-
}
569-
return m.model, nil
570-
}
571-
if len(matches) > 1 {
572-
provs := make([]string, len(matches))
573-
for i, m := range matches {
574-
provs[i] = config.FormatModelID(m.provider, m.model)
575-
}
576-
return "", fmt.Errorf("model %q is ambiguous, found in: %s; use provider/model format", pattern, strings.Join(provs, ", "))
577-
}
578-
579-
// Fallback: try current provider with the pattern as model name.
580-
curProv := s.Provider()
581-
if err := s.SetModel(curProv, pattern); err != nil {
582-
return "", fmt.Errorf("model %q not found in any configured provider", pattern)
583-
}
584-
s.updateContextFromRegistry(curProv, pattern)
585-
if thinkingLevel != "" {
586-
s.SetThinkingLevel(thinkingLevel)
587-
}
588-
return pattern, nil
589-
}
590-
591-
func splitExplicitModelPattern(pattern string, providers map[string]config.ProviderConfig) (providerKey, model string, ok bool) {
592-
if prov, model, ok := strings.Cut(pattern, "/"); ok {
593-
if _, exists := providers[prov]; exists && model != "" {
594-
return prov, model, true
595-
}
596-
}
597-
if prov, model, ok := strings.Cut(pattern, ":"); ok {
598-
if _, exists := providers[prov]; exists && model != "" {
599-
return prov, model, true
600-
}
601-
}
602-
return "", "", false
603-
}
604-
605-
func explicitModelID(providerKey, model string) string {
606-
if providerKey == "" {
607-
return model
608-
}
609-
return providerKey + "/" + model
610-
}
611-
612504
// updateContextFromRegistry updates context window from registry metadata if available.
613505
// It tries provider-qualified lookup first (e.g. "anthropic/claude-sonnet-4-5"),
614506
// then falls back to bare modelID for custom providers not in the registry.
@@ -633,13 +525,23 @@ func (s *Session) updateContextFromRegistry(providerKey, modelID string) {
633525
}
634526

635527
func (s *Session) applyContextWindow(window int) {
528+
// Re-apply user-configured compaction caps to the new model window so that
529+
// mid-session model switches honor compact_window / compact_ratio.
530+
if cap := s.settings.CompactWindow; cap > 0 && cap < window {
531+
window = cap
532+
}
533+
reserve := 0
534+
if r := s.settings.CompactRatio; r > 0 && r < 1 {
535+
reserve = window - int(float64(window)*r)
536+
}
636537
s.agent.SetContextWindow(window)
637538
s.mu.Lock()
638539
s.settings.ContextWindow = window
639540
cm := s.contextManager
640541
s.mu.Unlock()
641542
if engine, ok := cm.(*agentctx.ContextEngine); ok {
642543
engine.SetContextWindow(window)
544+
engine.SetReserveTokens(reserve)
643545
}
644546
}
645547

internal/agent/session_test.go

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -547,96 +547,6 @@ func TestSetModelDoesNotRewriteGlobalSettings(t *testing.T) {
547547
}
548548
}
549549

550-
func TestResolveAndSetModelSupportsExplicitProviderSyntax(t *testing.T) {
551-
t.Parallel()
552-
553-
cases := []struct {
554-
name string
555-
pattern string
556-
wantResolved string
557-
wantProvider string
558-
wantModel string
559-
wantThinking string
560-
}{
561-
{
562-
name: "provider slash model preserves provider-qualified return",
563-
pattern: "openrouter/openai/gpt-5",
564-
wantResolved: "openrouter/openai/gpt-5",
565-
wantProvider: "openrouter",
566-
wantModel: "openai/gpt-5",
567-
wantThinking: "low",
568-
},
569-
{
570-
name: "provider colon model normalizes to slash form",
571-
pattern: "anthropic:claude-sonnet-4-5",
572-
wantResolved: "anthropic/claude-sonnet-4-5",
573-
wantProvider: "anthropic",
574-
wantModel: "claude-sonnet-4-5",
575-
wantThinking: "low",
576-
},
577-
{
578-
name: "provider colon model with thinking suffix",
579-
pattern: "anthropic:claude-sonnet-4-5:high",
580-
wantResolved: "anthropic/claude-sonnet-4-5",
581-
wantProvider: "anthropic",
582-
wantModel: "claude-sonnet-4-5",
583-
wantThinking: "high",
584-
},
585-
}
586-
587-
for _, tc := range cases {
588-
t.Run(tc.name, func(t *testing.T) {
589-
t.Parallel()
590-
591-
dir := t.TempDir()
592-
s := NewSession(SessionConfig{
593-
Agent: agentcore.NewAgent(agentcore.WithModel(&stubChatModel{})),
594-
Settings: config.Resolved{
595-
Provider: "openai",
596-
Model: "gpt-4.1",
597-
ThinkingLevel: "low",
598-
Providers: map[string]config.ProviderConfig{
599-
"openai": {
600-
APIKey: "openai-key",
601-
Models: []string{"gpt-4.1", "gpt-5"},
602-
},
603-
"anthropic": {
604-
APIKey: "anthropic-key",
605-
Models: []string{"claude-sonnet-4-5"},
606-
},
607-
"openrouter": {
608-
APIKey: "openrouter-key",
609-
Models: []string{"openai/gpt-5"},
610-
},
611-
},
612-
},
613-
Cwd: dir,
614-
CreateModel: func(_ string, model string, _ string, _ string) (agentcore.ChatModel, error) {
615-
return &namedChatModel{name: model}, nil
616-
},
617-
})
618-
t.Cleanup(s.Close)
619-
620-
resolved, err := s.ResolveAndSetModel(tc.pattern)
621-
if err != nil {
622-
t.Fatalf("ResolveAndSetModel(%q) error: %v", tc.pattern, err)
623-
}
624-
if resolved != tc.wantResolved {
625-
t.Fatalf("resolved = %q, want %q", resolved, tc.wantResolved)
626-
}
627-
if got := s.Provider(); got != tc.wantProvider {
628-
t.Fatalf("provider = %q, want %q", got, tc.wantProvider)
629-
}
630-
if got := s.ModelName(); got != tc.wantModel {
631-
t.Fatalf("model = %q, want %q", got, tc.wantModel)
632-
}
633-
if got := s.Settings().ThinkingLevel; got != tc.wantThinking {
634-
t.Fatalf("thinking = %q, want %q", got, tc.wantThinking)
635-
}
636-
})
637-
}
638-
}
639-
640550
func TestResolveCredentialsPerProvider(t *testing.T) {
641551
t.Parallel()
642552

internal/bootstrap/assemble_runtime.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ func assembleRuntime(input *resolvedInput, services *bootServices, assembly *ses
2828
baseTools = append(baseTools, assembly.baseTools...)
2929
baseTools = append(baseTools, taskTools...)
3030

31-
contextEngine, summaryCompact := buildContextEngine(assembly.chatModel, assembly.settings.ContextWindow)
31+
reserveTokens := 0 // 0 = engine default (fixed buffer)
32+
if r := assembly.settings.CompactRatio; r > 0 && r < 1 {
33+
reserveTokens = assembly.settings.ContextWindow - int(float64(assembly.settings.ContextWindow)*r)
34+
}
35+
contextEngine, summaryCompact := buildContextEngine(assembly.chatModel, assembly.settings.ContextWindow, reserveTokens)
3236
agentCore, err := buildAgent(assembly, services, contextEngine, taskRT, tools)
3337
if err != nil {
3438
return nil, err
@@ -69,7 +73,7 @@ func assembleRuntime(input *resolvedInput, services *bootServices, assembly *ses
6973
}, nil
7074
}
7175

72-
func buildContextEngine(chatModel agentcore.ChatModel, contextWindow int) (*agentctx.ContextEngine, *agentctx.FullSummaryStrategy) {
76+
func buildContextEngine(chatModel agentcore.ChatModel, contextWindow, reserveTokens int) (*agentctx.ContextEngine, *agentctx.FullSummaryStrategy) {
7377
toolCompact := agentctx.NewToolResultMicrocompact(agentctx.ToolResultMicrocompactConfig{
7478
Classifier: agent.CodebotToolClassifier,
7579
KeepRecent: 5,
@@ -80,6 +84,7 @@ func buildContextEngine(chatModel agentcore.ChatModel, contextWindow int) (*agen
8084
})
8185
engine := agentctx.NewEngine(agentctx.EngineConfig{
8286
ContextWindow: contextWindow,
87+
ReserveTokens: reserveTokens,
8388
Strategies: []agentctx.Strategy{
8489
toolCompact,
8590
trimCompact,

internal/bootstrap/assemble_session.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ func resolveActiveModel(input *resolvedInput) (config.Resolved, string, agentcor
9494
} else if settings.ContextWindow <= 0 {
9595
settings.ContextWindow = 128000
9696
}
97+
// Apply user cap: effective = min(detected, CompactWindow). Never raise above
98+
// the model's real window — that would trigger API errors.
99+
if cap := settings.CompactWindow; cap > 0 && cap < settings.ContextWindow {
100+
settings.ContextWindow = cap
101+
}
97102
settings.Provider = activeProvider
98103
settings.Model = activeModel
99104

internal/config/settings.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ type Settings struct {
9393

9494
MaxTurns *int `json:"max_turns,omitempty"`
9595

96+
// CompactWindow caps the effective context window used for compaction.
97+
// Effective = min(model's detected window, CompactWindow). 0 = disabled.
98+
CompactWindow *int `json:"compact_window,omitempty"`
99+
// CompactRatio triggers compaction when usage >= effective * ratio.
100+
// Range (0, 1). 0 = engine default (fixed headroom buffer).
101+
CompactRatio *float64 `json:"compact_ratio,omitempty"`
102+
96103
SearchProvider *string `json:"search_provider,omitempty"`
97104
SearchAPIKey *string `json:"search_api_key,omitempty"`
98105

@@ -116,7 +123,9 @@ type Resolved struct {
116123
SmallModel string // sub-agent model; equals Model when not configured
117124
Providers map[string]ProviderConfig // per-provider credentials
118125

119-
ContextWindow int // auto-detected from model registry at boot
126+
ContextWindow int // effective window after applying CompactWindow cap
127+
CompactWindow int // user-configured cap on effective window; 0 = disabled
128+
CompactRatio float64 // usage ratio that triggers compaction; 0 = engine default
120129
ThinkingLevel string
121130
MaxTurns int
122131
SearchProvider string
@@ -215,6 +224,15 @@ func (s Settings) Resolve() Resolved {
215224
if s.MaxTurns != nil {
216225
r.MaxTurns = *s.MaxTurns
217226
}
227+
if s.CompactWindow != nil && *s.CompactWindow > 0 {
228+
r.CompactWindow = *s.CompactWindow
229+
}
230+
if s.CompactRatio != nil {
231+
ratio := *s.CompactRatio
232+
if ratio > 0 && ratio < 1 {
233+
r.CompactRatio = ratio
234+
}
235+
}
218236
if s.SearchProvider != nil {
219237
r.SearchProvider = *s.SearchProvider
220238
}
@@ -241,6 +259,15 @@ func ProjectConfigExists(cwd string) bool {
241259
return err == nil
242260
}
243261

262+
// ProjectSettingsDefinesModel reports whether the project settings file
263+
// exists and explicitly sets provider or model. Callers use this to decide
264+
// whether /model persistence should target the project file (so the choice
265+
// sticks across restarts) or the global file.
266+
func ProjectSettingsDefinesModel(cwd string) bool {
267+
s := loadSettingsFile(SettingsPath(cwd))
268+
return s.Provider != nil || s.Model != nil
269+
}
270+
244271
// GlobalSettingsPath returns ~/.codebot/settings.json.
245272
func GlobalSettingsPath() string {
246273
return filepath.Join(UserConfigDir(), "settings.json")
@@ -390,6 +417,12 @@ func mergeSettings(base, override Settings) Settings {
390417
if override.MaxTurns != nil {
391418
base.MaxTurns = override.MaxTurns
392419
}
420+
if override.CompactWindow != nil {
421+
base.CompactWindow = override.CompactWindow
422+
}
423+
if override.CompactRatio != nil {
424+
base.CompactRatio = override.CompactRatio
425+
}
393426
if override.SearchProvider != nil {
394427
base.SearchProvider = override.SearchProvider
395428
}

0 commit comments

Comments
 (0)