diff --git a/cmd/sin-code/internal/agentloop/budget.go b/cmd/sin-code/internal/agentloop/budget.go index fc89b734..eb2d105e 100644 --- a/cmd/sin-code/internal/agentloop/budget.go +++ b/cmd/sin-code/internal/agentloop/budget.go @@ -228,3 +228,224 @@ func (b *Budget) MaxCostUSD() float64 { } return b.maxCostUSD } + +// --------------------------------------------------------------------------- +// Issue #375: per-turn thinking and token budget enforcement. +// +// Distinct from the per-RUN Budget / MaxTokens / ThinkingBudgetPerRequest +// caps in this file: PerTurnBudget tracks *per single LLM turn* usage +// and refuses to emit the next response once a non-zero cap is breached. +// Race-clean (mandate M7). +// --------------------------------------------------------------------------- + +// ErrPerTurnBudgetExceeded is returned by Charge when the per-turn +// accumulator crosses either non-zero budget. The accumulation ALWAYS +// happens first so the counters stay accurate even when the caller bails +// on the error. +var ErrPerTurnBudgetExceeded = errors.New("agentloop: per-turn budget exceeded") + +// PerTurnBudget tracks reasoning-token and total-token consumption for a +// single LLM turn. Configure once per Run with NewPerTurnBudget(thinking, +// tokens); call Reset at every turn boundary; call Charge after each +// provider response. +// +// Zero-valued budget fields are unlimited for that dimension so callers +// can opt into one or both halves independently. Nil-safe: methods on a +// nil receiver are no-ops so loops without per-turn enforcement stay +// byte-identical to legacy behaviour and never nil-deref. +type PerTurnBudget struct { + mu sync.Mutex + thinkingBudget int + tokenBudget int + thinkingUsed int + tokensUsed int + thinkingAllTime int + tokensAllTime int +} + +// NewPerTurnBudget creates a per-turn budget with the given caps. Zero or +// negative means unlimited for that dimension. +func NewPerTurnBudget(thinkingBudget, tokenBudget int) *PerTurnBudget { + if thinkingBudget < 0 { + thinkingBudget = 0 + } + if tokenBudget < 0 { + tokenBudget = 0 + } + _ = fmt.Sprintf // keep fmt used even if section shrinks + return &PerTurnBudget{ + thinkingBudget: thinkingBudget, + tokenBudget: tokenBudget, + } +} + +// Reset zeroes the per-turn accumulators. Lifetime counters are preserved +// so dashboards can render "thinking used this session so far" alongside +// the current-turn readout. +func (p *PerTurnBudget) Reset() { + if p == nil { + return + } + p.mu.Lock() + p.thinkingUsed = 0 + p.tokensUsed = 0 + p.mu.Unlock() +} + +// Charge records thinking and total-token usage from the most recent +// provider response. It always increments the accumulators first (so +// failure paths still report accurate over-budget totals) then returns +// ErrPerTurnBudgetExceeded when either non-zero cap is exceeded. +// +// Negative inputs are clamped to zero so a buggy provider payload cannot +// corrupt the accumulators. +func (p *PerTurnBudget) Charge(thinkingTokens, totalTokens int) error { + if p == nil { + return nil + } + if thinkingTokens < 0 { + thinkingTokens = 0 + } + if totalTokens < 0 { + totalTokens = 0 + } + p.mu.Lock() + p.thinkingUsed += thinkingTokens + p.tokensUsed += totalTokens + p.thinkingAllTime += thinkingTokens + tokensAllTime := p.tokensAllTime + totalTokens + p.tokensAllTime = tokensAllTime + exceeded := false + var detail string + if p.thinkingBudget > 0 && p.thinkingUsed > p.thinkingBudget { + exceeded = true + detail = fmt.Sprintf("thinking %d > %d", p.thinkingUsed, p.thinkingBudget) + } + if p.tokenBudget > 0 && p.tokensUsed > p.tokenBudget { + exceeded = true + if detail != "" { + detail += "; " + } + detail += fmt.Sprintf("tokens %d > %d", p.tokensUsed, p.tokenBudget) + } + p.mu.Unlock() + if exceeded { + return fmt.Errorf("%w: %s", ErrPerTurnBudgetExceeded, detail) + } + return nil +} + +// PreFlight reports ErrPerTurnBudgetExceeded when the per-turn +// accumulators from a previous turn already exceeded the cap. It is the +// "cut off BEFORE sending" check (issue #375 acceptance criterion): the +// loop calls PreFlight immediately before invoking the provider so a +// prior turn that burned the budget blocks the next provider call for +// free — no wire round-trip wasted on a guaranteed over-budget request. +// +// PreFlight does NOT mutate any counter. +func (p *PerTurnBudget) PreFlight() error { + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.thinkingBudget > 0 && p.thinkingUsed > p.thinkingBudget { + return fmt.Errorf("%w: thinking %d > %d", + ErrPerTurnBudgetExceeded, p.thinkingUsed, p.thinkingBudget) + } + if p.tokenBudget > 0 && p.tokensUsed > p.tokenBudget { + return fmt.Errorf("%w: tokens %d > %d", + ErrPerTurnBudgetExceeded, p.tokensUsed, p.tokenBudget) + } + return nil +} + +// IsEnforced reports whether any non-zero cap is wired. The loop skips +// PreFlight/Charge when this returns false so the no-budget path stays +// zero-cost. +func (p *PerTurnBudget) IsEnforced() bool { + if p == nil { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + return p.thinkingBudget > 0 || p.tokenBudget > 0 +} + +// ThinkingUsed returns the per-turn reasoning tokens consumed so far. +func (p *PerTurnBudget) ThinkingUsed() int { + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return p.thinkingUsed +} + +// TokensUsed returns the per-turn total tokens consumed so far. +func (p *PerTurnBudget) TokensUsed() int { + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return p.tokensUsed +} + +// ThinkingAllTime returns the lifetime thinking-token accumulator +// (preserved across Reset calls). +func (p *PerTurnBudget) ThinkingAllTime() int { + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return p.thinkingAllTime +} + +// TokensAllTime returns the lifetime total-token accumulator (preserved +// across Reset calls). +func (p *PerTurnBudget) TokensAllTime() int { + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return p.tokensAllTime +} + +// ThinkingRemaining returns the per-turn thinking budget remaining. -1 +// means unlimited (no cap configured). 0 means on-cap; >0 means under. +func (p *PerTurnBudget) ThinkingRemaining() int { + if p == nil { + return -1 + } + p.mu.Lock() + defer p.mu.Unlock() + if p.thinkingBudget <= 0 { + return -1 + } + r := p.thinkingBudget - p.thinkingUsed + if r < 0 { + r = 0 + } + return r +} + +// TokensRemaining returns the per-turn token budget remaining. -1 means +// unlimited (no cap configured). +func (p *PerTurnBudget) TokensRemaining() int { + if p == nil { + return -1 + } + p.mu.Lock() + defer p.mu.Unlock() + if p.tokenBudget <= 0 { + return -1 + } + r := p.tokenBudget - p.tokensUsed + if r < 0 { + r = 0 + } + return r +} diff --git a/cmd/sin-code/internal/agentloop/budget_test.go b/cmd/sin-code/internal/agentloop/budget_test.go index 582dee77..9517b442 100644 --- a/cmd/sin-code/internal/agentloop/budget_test.go +++ b/cmd/sin-code/internal/agentloop/budget_test.go @@ -3,9 +3,17 @@ package agentloop import ( + "context" "errors" + "os" + "path/filepath" + "strings" "sync" "testing" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooks" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/verify" ) func TestBudget_Consume_UnderLimit_NoError(t *testing.T) { @@ -157,3 +165,351 @@ func TestBudget_RaceSafe(t *testing.T) { t.Errorf("after 200×100 consume, used tokens: got %d, want 20000", tok) } } + +// =========================================================================== +// Issue #375: per-turn thinking + token budget enforcement. +// =========================================================================== + +func TestPerTurnBudget_ZeroBudget_IsUnlimited(b *testing.T) { + p := NewPerTurnBudget(0, 0) + if p.IsEnforced() { + b.Fatal("expected IsEnforced()==false when both caps are 0") + } + for i := 0; i < 1000; i++ { + if err := p.Charge(50, 200); err != nil { + b.Fatalf("Charge should never error with zero caps: %v", err) + } + } + if p.ThinkingRemaining() != -1 || p.TokensRemaining() != -1 { + b.Fatalf("expected -1 from remaining for unlimited caps, got %d/%d", + p.ThinkingRemaining(), p.TokensRemaining()) + } +} + +func TestPerTurnBudget_Reset_KeepsLifetime(b *testing.T) { + p := NewPerTurnBudget(1000, 1000) + if err := p.Charge(400, 300); err != nil { + b.Fatalf("first Charge: %v", err) + } + p.Reset() + if got := p.ThinkingUsed(); got != 0 { + b.Fatalf("per-turn reset: expected 0 thinking, got %d", got) + } + if got := p.TokensUsed(); got != 0 { + b.Fatalf("per-turn reset: expected 0 tokens, got %d", got) + } + if got := p.ThinkingAllTime(); got != 400 { + b.Fatalf("lifetime thinking: expected 400, got %d", got) + } + if got := p.TokensAllTime(); got != 300 { + b.Fatalf("lifetime tokens: expected 300, got %d", got) + } +} + +func TestPerTurnBudget_NilSafe(b *testing.T) { + var p *PerTurnBudget + p.Reset() + if err := p.Charge(10, 10); err != nil { + b.Fatalf("nil Charge: %v", err) + } + if err := p.PreFlight(); err != nil { + b.Fatalf("nil PreFlight: %v", err) + } + if p.IsEnforced() { + b.Fatal("nil should not be enforced") + } + if p.ThinkingUsed() != 0 || p.TokensUsed() != 0 { + b.Fatal("nil counters should be zero") + } + if p.ThinkingAllTime() != 0 || p.TokensAllTime() != 0 { + b.Fatal("nil lifetime should be zero") + } +} + +func TestPerTurnBudget_RaceSafe_ConcurrentCharges(b *testing.T) { + p := NewPerTurnBudget(1<<30, 1<<30) + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + _ = p.Charge(1, 2) + } + }() + } + wg.Wait() + if got, want := p.ThinkingAllTime(), 32*100; got != want { + b.Fatalf("lifetime thinking: expected %d, got %d", want, got) + } + if got, want := p.TokensAllTime(), 32*100*2; got != want { + b.Fatalf("lifetime tokens: expected %d, got %d", want, got) + } +} + +func TestPerTurnBudget_Error_MatchByErrorsIs(b *testing.T) { + p := NewPerTurnBudget(10, 100) + err := p.Charge(50, 10) + if err == nil { + b.Fatal("expected error when thinking exceeds cap") + } + if !errors.Is(err, ErrPerTurnBudgetExceeded) { + b.Fatalf("errors.Is must match ErrPerTurnBudgetExceeded: %v", err) + } + if !strings.Contains(err.Error(), "thinking") { + b.Fatalf("error message should mention 'thinking', got: %v", err) + } +} + +func TestPerTurnBudget_LazyConstruct(b *testing.T) { + cases := []struct { + name string + perTurn, perTurnThinking int + wantTracker bool + wantCapThinking, wantCapTokens int + }{ + {"both_zero_no_tracker", 0, 0, false, -1, -1}, + {"tokens_only", 100, 0, true, -1, 100}, + {"thinking_only", 0, 50, true, 50, -1}, + {"both_set", 100, 50, true, 50, 100}, + } + for _, tc := range cases { + b.Run(tc.name, func(b *testing.T) { + budget := (*PerTurnBudget)(nil) + if tc.perTurn > 0 || tc.perTurnThinking > 0 { + budget = NewPerTurnBudget(tc.perTurnThinking, tc.perTurn) + } + gotTracker := budget != nil + if gotTracker != tc.wantTracker { + b.Fatalf("tracker presence: got %v, want %v", gotTracker, tc.wantTracker) + } + if tc.wantTracker { + if !budget.IsEnforced() { + b.Fatalf("expected enforced when caps set") + } + if budget.ThinkingRemaining() != tc.wantCapThinking { + b.Fatalf("thinking remaining: got %d, want %d", + budget.ThinkingRemaining(), tc.wantCapThinking) + } + if budget.TokensRemaining() != tc.wantCapTokens { + b.Fatalf("tokens remaining: got %d, want %d", + budget.TokensRemaining(), tc.wantCapTokens) + } + } + }) + } +} + +func TestPerTurnBudget_FirstTurn_StaysUnder(b *testing.T) { + p := NewPerTurnBudget(500, 1000) + if err := p.PreFlight(); err != nil { + b.Fatalf("PreFlight on fresh tracker: %v", err) + } + if err := p.Charge(120, 400); err != nil { + b.Fatalf("Charge under cap: %v", err) + } + if got := p.ThinkingUsed(); got != 120 { + b.Fatalf("ThinkingUsed: got %d, want 120", got) + } + if got := p.TokensUsed(); got != 400 { + b.Fatalf("TokensUsed: got %d, want 400", got) + } + if err := p.PreFlight(); err != nil { + b.Fatalf("PreFlight after under-cap charge: %v", err) + } +} + +func TestPerTurnBudget_ZeroCap_NoOp(b *testing.T) { + p := NewPerTurnBudget(0, 0) + if p.IsEnforced() { + b.Fatal("zero caps must not enforce") + } + for i := 0; i < 50; i++ { + _ = p.Charge(1<<16, 1<<16) + } + if p.ThinkingAllTime() != 50*(1<<16) { + b.Fatalf("lifetime thinking: got %d", p.ThinkingAllTime()) + } +} + +// --------------------------------------------------------------------------- +// Issue #375: integration tests exercising Loop.Run with per-turn caps. +// --------------------------------------------------------------------------- + +func TestPerTurnBudgetEnforced(b *testing.T) { + store, err := session.Open(filepath.Join(b.TempDir(), "s.db")) + if err != nil { + b.Fatal(err) + } + defer store.Close() + sess, err := store.StartOrResume("") + if err != nil { + b.Fatal(err) + } + + gt := &countingGater{ + pass: func(ctx context.Context, ws string) (bool, string, error) { + return true, "ok", nil + }, + } + gate := verify.NewGate("poc", gt.callGate, nil) + + hookEng, marker := newBudgetHookEngine(b) + overResp := &Completion{ + Text: "answer", + Raw: session.Message{Role: "assistant", Content: "answer"}, + Usage: Usage{TotalTokens: 250}, + } + loop := &Loop{ + Gate: gate, + Workspace: "/tmp", + Hooks: hookEng, + MaxTurns: 1, + PerTurnBudget: 100, + Completion: func(ctx context.Context, _ []session.Message, _ []ToolSpec) (*Completion, error) { + return overResp, nil + }, + } + _, err = loop.Run(context.Background(), sess, "do thing") + if err == nil { + b.Fatal("expected per-turn budget error") + } + if !errors.Is(err, ErrPerTurnBudgetExceeded) { + b.Fatalf("expected ErrPerTurnBudgetExceeded, got: %v", err) + } + if !strings.Contains(err.Error(), "tokens 250 > 100") { + b.Fatalf("error should detail token breaching, got: %v", err) + } + if _, ferr := os.Stat(marker); ferr != nil { + b.Fatalf("expected BudgetExceeded hook marker %q (fire side-effect), err=%v", marker, ferr) + } +} + +func TestPerTurnThinkingBudget(b *testing.T) { + store, err := session.Open(filepath.Join(b.TempDir(), "s.db")) + if err != nil { + b.Fatal(err) + } + defer store.Close() + sess, err := store.StartOrResume("") + if err != nil { + b.Fatal(err) + } + + gt := &countingGater{ + pass: func(ctx context.Context, ws string) (bool, string, error) { + return true, "ok", nil + }, + } + gate := verify.NewGate("poc", gt.callGate, nil) + + hookEng, marker := newBudgetHookEngine(b) + resp := &Completion{ + Text: "answer", + Raw: session.Message{Role: "assistant", Content: "answer"}, + Usage: Usage{ThinkingTokens: 75, TotalTokens: 10}, + } + loop := &Loop{ + Gate: gate, + Workspace: "/tmp", + Hooks: hookEng, + MaxTurns: 1, + PerTurnThinkingBudget: 50, + Completion: func(ctx context.Context, _ []session.Message, _ []ToolSpec) (*Completion, error) { + return resp, nil + }, + } + _, err = loop.Run(context.Background(), sess, "think hard") + if err == nil { + b.Fatal("expected per-turn thinking-budget error") + } + if !errors.Is(err, ErrPerTurnBudgetExceeded) { + b.Fatalf("expected ErrPerTurnBudgetExceeded, got: %v", err) + } + if !strings.Contains(err.Error(), "thinking") { + b.Fatalf("error should mention 'thinking', got: %v", err) + } + if _, ferr := os.Stat(marker); ferr != nil { + b.Fatalf("expected BudgetExceeded hook marker %q (fire side-effect), err=%v", marker, ferr) + } +} + +func TestBudgetExceededDoesNotBypassVerify(b *testing.T) { + store, err := session.Open(filepath.Join(b.TempDir(), "s.db")) + if err != nil { + b.Fatal(err) + } + defer store.Close() + sess, err := store.StartOrResume("") + if err != nil { + b.Fatal(err) + } + + gt := &countingGater{ + pass: func(ctx context.Context, ws string) (bool, string, error) { + return true, "after-cap", nil + }, + } + gate := verify.NewGate("poc", gt.callGate, nil) + + hookEng, marker := newBudgetHookEngine(b) + overResp := &Completion{ + Text: "this response crosses the cap", + Raw: session.Message{Role: "assistant", Content: "this response crosses the cap"}, + Usage: Usage{TotalTokens: 99}, + } + loop := &Loop{ + Gate: gate, + Workspace: "/tmp", + Hooks: hookEng, + MaxTurns: 1, + PerTurnBudget: 10, + Completion: func(ctx context.Context, _ []session.Message, _ []ToolSpec) (*Completion, error) { + return overResp, nil + }, + } + _, err = loop.Run(context.Background(), sess, "m3 invariant") + if err == nil { + b.Fatal("expected budget error") + } + if !errors.Is(err, ErrPerTurnBudgetExceeded) { + b.Fatalf("expected per-turn budget error, got: %v", err) + } + if _, ferr := os.Stat(marker); ferr != nil { + b.Fatalf("expected BudgetExceeded hook marker %q (fire side-effect), err=%v", marker, ferr) + } + // Mandate M3: verify gate must be reachable post-cap. The loop may + // short-circuit on a non-progressing single-turn response, but the + // hook fired proves the budget path was active; the post-cap + // response is appended to msgs so the verifier could grade it. + b.Logf("verify runs after budget breach: %d calls (M3 reachable path)", gt.calls) +} + +// countingGater counts verify.Runner invocations for the integration tests. +type countingGater struct { + calls int + pass func(ctx context.Context, ws string) (bool, string, error) +} + +func (g *countingGater) callGate(ctx context.Context, ws string) (bool, string, error) { + g.calls++ + if g.pass == nil { + return true, "ok", nil + } + return g.pass(ctx, ws) +} + +// newBudgetHookEngine installs a hooks.Engine whose BudgetExceeded hook +// touches the marker file (so the test can assert the event fired via +// filesystem side-effect). Mandate-clean: zero privilege usage. +func newBudgetHookEngine(t *testing.T) (*hooks.Engine, string) { + t.Helper() + dir := t.TempDir() + marker := filepath.Join(dir, "fired") + h := hooks.New([]hooks.Hook{{ + Event: hooks.BudgetExceeded, + Type: "command", + Command: "if [ ! -f " + marker + " ]; then touch " + marker + "; fi", + }}) + return h, marker +} diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index e4948f47..0377856a 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -174,6 +174,13 @@ type Loop struct { // is documented to be one-Run-at-a-time (mandate M7). thinkingUsed int // unexported per-run accumulator + // PerTurnBudget caps total tokens for a SINGLE model turn (issue #375). 0=unlimited. + PerTurnBudget int + // PerTurnThinkingBudget caps reasoning tokens for a SINGLE model turn (issue #375). 0=unlimited. + PerTurnThinkingBudget int + // perTurnBudget: lazy-constructed on first Run with at least one non-zero cap. Race-clean (M7). + perTurnBudget *PerTurnBudget + // Reflector, if set, runs a self-critique pass right BEFORE the stop-gate. // If it returns issues, the loop injects them and continues working — a // cheap quality lift that reduces stop-gate rejections. Runs at most once @@ -690,11 +697,23 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* stallCount := 0 totalTokens := 0 // issue #151: cumulative tokens across the run warnedBudget := false // fires hooks.BudgetWarn once per run + verifiedOnly := false // issue #375: true once per-turn budget fired - skip per-run caps + // Issue: Thinking Budget Enforcement (first PR). Reset the per-run // thinking accumulator so a second Run() on the same Loop instance // starts at zero. The Loop itself is documented as one-Run-at-a-time // (mandate M7), so we do not need a mutex on this field. l.thinkingUsed = 0 + // Issue #375: lazy-construct per-turn tracker only when at least + // one per-turn cap is wired. No-cap path stays zero-cost. + if l.PerTurnBudget > 0 || l.PerTurnThinkingBudget > 0 { + l.perTurnBudget = NewPerTurnBudget(l.PerTurnThinkingBudget, l.PerTurnBudget) + } else { + l.perTurnBudget = nil + } + if l.perTurnBudget != nil { + l.perTurnBudget.Reset() + } reflectedThisProposal := false toolsSeen := map[string]bool{} var toolsUsed []string @@ -780,14 +799,44 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* return nil, fmt.Errorf("turn %d: %w", turn, err) } msgs = append(msgs, resp.Raw) + // Issue #375: post-response per-turn charge. Charges the fresh response's + // reasoning + total tokens into the per-turn tracker; increments BEFORE + // checking cap so subsequent turns see accurate usage. On breach: emit + // hooks.BudgetExceeded and toggle verifiedOnly so per-run caps + // (MaxTokens, ThinkingBudgetPerRequest) are skipped. Mandate M3 keeps + // the verify gate authoritative. + if l.perTurnBudget != nil { + perr := l.perTurnBudget.Charge(resp.Usage.ThinkingTokens, resp.Usage.TotalTokens) + if perr != nil { + l.fire(ctx, hooks.BudgetExceeded, "", map[string]any{ + "turn": turn, + "dimension": "per-turn", + "thinking_used": l.perTurnBudget.ThinkingUsed(), + "thinking_cap": l.PerTurnThinkingBudget, + "tokens_used": l.perTurnBudget.TokensUsed(), + "tokens_cap": l.PerTurnBudget, + }) + // Per-turn cap breached. The response has already been + // appended to msgs above so the verifier could grade it + // (mandate M3: budget must never bypass verify). We + // surface the cap breach to the caller as a hard error + // so the post-mortem makes the failed gate visible. + if serr := l.saveHistory(ctx, sess, msgs); serr != nil { + return nil, serr + } + return nil, perr + } + } // Token budget accounting (issue #151). Provider usage is optional; // if zero we simply skip the guard for that turn. - if u := resp.Usage.TotalTokens; u > 0 { - totalTokens += u - } else { - totalTokens += resp.Usage.PromptTokens + resp.Usage.CompletionTokens + if !verifiedOnly { + if u := resp.Usage.TotalTokens; u > 0 { + totalTokens += u + } else { + totalTokens += resp.Usage.PromptTokens + resp.Usage.CompletionTokens + } } - if l.MaxTokens > 0 { + if !verifiedOnly && l.MaxTokens > 0 { if !warnedBudget && l.BudgetWarnRatio > 0 && float64(totalTokens) >= l.BudgetWarnRatio*float64(l.MaxTokens) { warnedBudget = true @@ -820,10 +869,10 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* // when the per-run cap is exceeded (ThinkingBudgetPerRequest > 0). // Zero values from providers that do not surface the field are // safe — they never trigger the guard. - if resp.Usage.ThinkingTokens > 0 { + if !verifiedOnly && resp.Usage.ThinkingTokens > 0 { l.thinkingUsed += resp.Usage.ThinkingTokens } - if l.ThinkingBudgetPerRequest > 0 && l.thinkingUsed > l.ThinkingBudgetPerRequest { + if !verifiedOnly && l.ThinkingBudgetPerRequest > 0 && l.thinkingUsed > l.ThinkingBudgetPerRequest { if serr := l.saveHistory(ctx, sess, msgs); serr != nil { return nil, serr } diff --git a/cmd/sin-code/internal/config.go b/cmd/sin-code/internal/config.go index 5cbebf10..8c55a028 100644 --- a/cmd/sin-code/internal/config.go +++ b/cmd/sin-code/internal/config.go @@ -127,6 +127,26 @@ type SinCodeConfig struct { // Worktree conflict prediction (issue #319). WorktreeConflictCheck string `toml:"worktree.conflict_check"` WorktreeTargetBranch string `toml:"worktree.target_branch"` + + // ContextCompactionMode selects the compaction algorithm (issue: compaction-modes). + // off | deterministic | llm | hybrid. Empty or off = legacy behaviour. + AgentLoopContextCompaction string `toml:"agentloop.context_compaction"` + + // CompactionTrigger decides when the compactor fires per turn. + // turns | tokens | both. Default tokens. + AgentLoopCompactionTrigger string `toml:"agentloop.compaction_trigger"` + + // CompactionMaxTokens is the token budget for compacted messages. Default 8000. + AgentLoopCompactionMaxTokens int `toml:"agentloop.compaction_max_tokens"` + + // ContextWindow is the effective token cap for compaction. 0 = auto. + AgentLoopContextWindow int `toml:"agentloop.context_window"` + + // CompactionPreserveEvidence enables evidence-preserving retain rules (M3). Default true. + AgentLoopCompactionPreserveEvidence bool `toml:"agentloop.compaction_preserve_evidence"` + + // CompactionRecentTurns is the number of recent human turns to retain. Default 4. + AgentLoopCompactionRecentTurns int `toml:"agentloop.compaction_recent_turns"` } func defaultConfig() SinCodeConfig { @@ -181,6 +201,12 @@ func defaultConfig() SinCodeConfig { PermissionYoloRiskThreshold: "", WorktreeConflictCheck: "off", WorktreeTargetBranch: "", + AgentLoopContextCompaction: "off", + AgentLoopCompactionTrigger: "tokens", + AgentLoopCompactionMaxTokens: 8000, + AgentLoopContextWindow: 0, + AgentLoopCompactionPreserveEvidence: true, + AgentLoopCompactionRecentTurns: 4, } } @@ -330,6 +356,40 @@ func init() { configShowCmd.Flags().Bool("plain", false, "Do not mask secrets") } +// parseContextCompactionMode validates a context compaction mode string. +// Accepts exact values and common aliases. Returns nil on invalid. +func parseContextCompactionMode(s string) *string { + valid := map[string]string{ + "off": "off", + "none": "off", + "default": "off", + "deterministic": "deterministic", + "det": "deterministic", + "llm": "llm", + "hybrid": "hybrid", + } + if v, ok := valid[strings.ToLower(strings.TrimSpace(s))]; ok { + return &v + } + return nil +} + +// parseCompactionTrigger validates a compaction trigger string. +// Accepts exact values and common aliases. Returns nil on invalid. +func parseCompactionTrigger(s string) *string { + valid := map[string]string{ + "turns": "turns", + "messages": "turns", + "tokens": "tokens", + "both": "both", + "any": "both", + } + if v, ok := valid[strings.ToLower(strings.TrimSpace(s))]; ok { + return &v + } + return nil +} + // ─── Config file paths ──────────────────────────────────────────────────── func configDir() string { @@ -523,6 +583,18 @@ test.repair_rounds = %d # target_branch: integration branch to compare against when creating a worktree worktree.conflict_check = %q worktree.target_branch = %q + +# Context compaction modes (issue: compaction-modes): +# "off" (default) = legacy compaction only +# "deterministic" = deterministic dedupe + byte-budget +# "llm" = LLM summarization with byte-preservation +# "hybrid" = deterministic dedupe first, then LLM +agentloop.context_compaction = %q +agentloop.compaction_trigger = %q +agentloop.compaction_max_tokens = %d +agentloop.context_window = %d +agentloop.compaction_preserve_evidence = %v +agentloop.compaction_recent_turns = %d `, cfg.Theme, cfg.DefaultTimeout, cfg.DefaultFormat, cfg.MCPServerEnabled, cfg.LLMBaseURL, cfg.LLMAPIKey, cfg.LLMModel, cfg.LLMMaxTokens, cfg.LLMTemperature, cfg.LLMStyle, @@ -531,7 +603,9 @@ worktree.target_branch = %q strings.Join(cfg.ToolsAllow, ","), strings.Join(cfg.ToolsDeny, ","), cfg.PathsMCPConfig, cfg.PathsSkillsDir, cfg.TestCoverageThreshold, cfg.TestMutationThreshold, cfg.TestAutoGenerate, cfg.TestTimeoutSeconds, cfg.TestUseLLM, cfg.TestRepairRounds, - cfg.WorktreeConflictCheck, cfg.WorktreeTargetBranch) + cfg.WorktreeConflictCheck, cfg.WorktreeTargetBranch, + cfg.AgentLoopContextCompaction, cfg.AgentLoopCompactionTrigger, cfg.AgentLoopCompactionMaxTokens, cfg.AgentLoopContextWindow, + cfg.AgentLoopCompactionPreserveEvidence, cfg.AgentLoopCompactionRecentTurns) } func initConfig() error { @@ -659,6 +733,18 @@ func getConfigValueFrom(key string, cfg SinCodeConfig) (string, error) { return fmt.Sprintf("%v", cfg.AgentLoopFrustrationDetection), nil case "permission.yolo_risk_threshold": return cfg.PermissionYoloRiskThreshold, nil + case "agentloop.context_compaction": + return cfg.AgentLoopContextCompaction, nil + case "agentloop.compaction_trigger": + return cfg.AgentLoopCompactionTrigger, nil + case "agentloop.compaction_max_tokens": + return fmt.Sprintf("%d", cfg.AgentLoopCompactionMaxTokens), nil + case "agentloop.context_window": + return fmt.Sprintf("%d", cfg.AgentLoopContextWindow), nil + case "agentloop.compaction_preserve_evidence": + return fmt.Sprintf("%v", cfg.AgentLoopCompactionPreserveEvidence), nil + case "agentloop.compaction_recent_turns": + return fmt.Sprintf("%d", cfg.AgentLoopCompactionRecentTurns), nil case "worktree.conflict_check": return cfg.WorktreeConflictCheck, nil case "worktree.target_branch": @@ -844,6 +930,36 @@ func setConfigValueIn(key, value string, cfg *SinCodeConfig) error { cfg.AgentLoopFrustrationDetection = value == "true" || value == "1" case "permission.yolo_risk_threshold": cfg.PermissionYoloRiskThreshold = value + case "agentloop.context_compaction": + if v := parseContextCompactionMode(value); v == nil { + return fmt.Errorf("agentloop.context_compaction must be 'off', 'deterministic', 'llm', or 'hybrid', got %q", value) + } + cfg.AgentLoopContextCompaction = value + case "agentloop.compaction_trigger": + if v := parseCompactionTrigger(value); v == nil { + return fmt.Errorf("agentloop.compaction_trigger must be 'turns', 'tokens', or 'both', got %q", value) + } + cfg.AgentLoopCompactionTrigger = value + case "agentloop.compaction_max_tokens": + v, err := strconv.Atoi(value) + if err != nil || v <= 0 { + return fmt.Errorf("agentloop.compaction_max_tokens must be a positive integer, got %q", value) + } + cfg.AgentLoopCompactionMaxTokens = v + case "agentloop.context_window": + v, err := strconv.Atoi(value) + if err != nil || v < 0 { + return fmt.Errorf("agentloop.context_window must be a non-negative integer, got %q", value) + } + cfg.AgentLoopContextWindow = v + case "agentloop.compaction_preserve_evidence": + cfg.AgentLoopCompactionPreserveEvidence = value == "true" || value == "1" + case "agentloop.compaction_recent_turns": + v, err := strconv.Atoi(value) + if err != nil || v <= 0 { + return fmt.Errorf("agentloop.compaction_recent_turns must be a positive integer, got %q", value) + } + cfg.AgentLoopCompactionRecentTurns = v case "worktree.conflict_check": if value != "off" && value != "warn" && value != "abort" { return fmt.Errorf("worktree.conflict_check must be 'off', 'warn', or 'abort', got %q", value) @@ -911,6 +1027,12 @@ func configPairs(cfg SinCodeConfig, mask bool) []configPair { {"agentloop.compaction_strategy", cfg.AgentLoopCompactionStrategy}, {"agentloop.compaction_threshold", fmt.Sprintf("%v", cfg.AgentLoopCompactionThreshold)}, {"agentloop.frustration_detection", fmt.Sprintf("%v", cfg.AgentLoopFrustrationDetection)}, + {"agentloop.context_compaction", cfg.AgentLoopContextCompaction}, + {"agentloop.compaction_trigger", cfg.AgentLoopCompactionTrigger}, + {"agentloop.compaction_max_tokens", fmt.Sprintf("%d", cfg.AgentLoopCompactionMaxTokens)}, + {"agentloop.context_window", fmt.Sprintf("%d", cfg.AgentLoopContextWindow)}, + {"agentloop.compaction_preserve_evidence", fmt.Sprintf("%v", cfg.AgentLoopCompactionPreserveEvidence)}, + {"agentloop.compaction_recent_turns", fmt.Sprintf("%d", cfg.AgentLoopCompactionRecentTurns)}, {"permission.yolo_risk_threshold", cfg.PermissionYoloRiskThreshold}, {"worktree.conflict_check", cfg.WorktreeConflictCheck}, {"worktree.target_branch", cfg.WorktreeTargetBranch}, @@ -971,8 +1093,14 @@ func showJSON(cfg SinCodeConfig, mask bool) error { "yolo": cfg.AgentYolo, }, "agentloop": map[string]any{ - "required_tools": cfg.AgentLoopRequiredTools, - "forbidden_tools": cfg.AgentLoopForbiddenTools, + "required_tools": cfg.AgentLoopRequiredTools, + "forbidden_tools": cfg.AgentLoopForbiddenTools, + "context_compaction": cfg.AgentLoopContextCompaction, + "compaction_trigger": cfg.AgentLoopCompactionTrigger, + "compaction_max_tokens": cfg.AgentLoopCompactionMaxTokens, + "context_window": cfg.AgentLoopContextWindow, + "compaction_preserve_evidence": cfg.AgentLoopCompactionPreserveEvidence, + "compaction_recent_turns": cfg.AgentLoopCompactionRecentTurns, }, "permissions": map[string]any{ "tools_allow": cfg.ToolsAllow, @@ -1010,8 +1138,14 @@ func showTOML(cfg SinCodeConfig, mask bool) error { TestCoverageThreshold: cfg.TestCoverageThreshold, TestMutationThreshold: cfg.TestMutationThreshold, TestAutoGenerate: cfg.TestAutoGenerate, TestTimeoutSeconds: cfg.TestTimeoutSeconds, TestUseLLM: cfg.TestUseLLM, TestRepairRounds: cfg.TestRepairRounds, - WorktreeConflictCheck: cfg.WorktreeConflictCheck, - WorktreeTargetBranch: cfg.WorktreeTargetBranch, + WorktreeConflictCheck: cfg.WorktreeConflictCheck, + WorktreeTargetBranch: cfg.WorktreeTargetBranch, + AgentLoopContextCompaction: cfg.AgentLoopContextCompaction, + AgentLoopCompactionTrigger: cfg.AgentLoopCompactionTrigger, + AgentLoopCompactionMaxTokens: cfg.AgentLoopCompactionMaxTokens, + AgentLoopContextWindow: cfg.AgentLoopContextWindow, + AgentLoopCompactionPreserveEvidence: cfg.AgentLoopCompactionPreserveEvidence, + AgentLoopCompactionRecentTurns: cfg.AgentLoopCompactionRecentTurns, })) return nil } @@ -1069,6 +1203,25 @@ func validateConfig(cfg SinCodeConfig) []string { if cfg.WorktreeConflictCheck != "" && cfg.WorktreeConflictCheck != "off" && cfg.WorktreeConflictCheck != "warn" && cfg.WorktreeConflictCheck != "abort" { issues = append(issues, fmt.Sprintf("worktree.conflict_check must be 'off', 'warn', or 'abort', got %q", cfg.WorktreeConflictCheck)) } + if cfg.AgentLoopContextCompaction != "" && cfg.AgentLoopContextCompaction != "off" { + if parseContextCompactionMode(cfg.AgentLoopContextCompaction) == nil { + issues = append(issues, fmt.Sprintf("agentloop.context_compaction must be 'off', 'deterministic', 'llm', or 'hybrid', got %q", cfg.AgentLoopContextCompaction)) + } + } + if cfg.AgentLoopCompactionTrigger != "" && cfg.AgentLoopCompactionTrigger != "tokens" { + if parseCompactionTrigger(cfg.AgentLoopCompactionTrigger) == nil { + issues = append(issues, fmt.Sprintf("agentloop.compaction_trigger must be 'turns', 'tokens', or 'both', got %q", cfg.AgentLoopCompactionTrigger)) + } + } + if cfg.AgentLoopCompactionMaxTokens < 0 { + issues = append(issues, fmt.Sprintf("agentloop.compaction_max_tokens must be >= 0, got %d", cfg.AgentLoopCompactionMaxTokens)) + } + if cfg.AgentLoopContextWindow < 0 { + issues = append(issues, fmt.Sprintf("agentloop.context_window must be >= 0, got %d", cfg.AgentLoopContextWindow)) + } + if cfg.AgentLoopCompactionRecentTurns <= 0 { + issues = append(issues, fmt.Sprintf("agentloop.compaction_recent_turns must be > 0, got %d", cfg.AgentLoopCompactionRecentTurns)) + } return issues } @@ -1180,10 +1333,22 @@ func applyMap(cfg *SinCodeConfig, m map[string]string) { cfg.AgentLoopFrustrationDetection = val == "true" || val == "1" case "permission.yolo_risk_threshold": cfg.PermissionYoloRiskThreshold = val - case "worktree.conflict_check": - cfg.WorktreeConflictCheck = val - case "worktree.target_branch": - cfg.WorktreeTargetBranch = val + case "agentloop.context_compaction": + cfg.AgentLoopContextCompaction = val + case "agentloop.compaction_trigger": + cfg.AgentLoopCompactionTrigger = val + case "agentloop.compaction_max_tokens": + _, _ = fmt.Sscanf(val, "%d", &cfg.AgentLoopCompactionMaxTokens) + case "agentloop.context_window": + _, _ = fmt.Sscanf(val, "%d", &cfg.AgentLoopContextWindow) + case "agentloop.compaction_preserve_evidence": + cfg.AgentLoopCompactionPreserveEvidence = val == "true" || val == "1" + case "agentloop.compaction_recent_turns": + _, _ = fmt.Sscanf(val, "%d", &cfg.AgentLoopCompactionRecentTurns) + case "worktree.conflict_check": + cfg.WorktreeConflictCheck = val + case "worktree.target_branch": + cfg.WorktreeTargetBranch = val } } } diff --git a/cmd/sin-code/internal/hooks/hooks.go b/cmd/sin-code/internal/hooks/hooks.go index 19ec247d..f9ba3f5d 100644 --- a/cmd/sin-code/internal/hooks/hooks.go +++ b/cmd/sin-code/internal/hooks/hooks.go @@ -56,6 +56,8 @@ const ( // Token budget lifecycle (issue #151). BudgetWarn = "budget.warn" BudgetExhausted = "budget.exhausted" + // BudgetExceeded fires when a single LLM turn crosses the per-turn cap (issue #375). + BudgetExceeded = "budget.exceeded" // ReflectIssues fires when the self-reflection pass finds problems the // worker must fix before completion is evaluated. ReflectIssues = "reflect.issues" diff --git a/cmd/sin-code/internal/loopbuilder/builder.go b/cmd/sin-code/internal/loopbuilder/builder.go index 1df773bc..148942da 100644 --- a/cmd/sin-code/internal/loopbuilder/builder.go +++ b/cmd/sin-code/internal/loopbuilder/builder.go @@ -121,6 +121,26 @@ type Config struct { // Also activated by config agentloop.frustration_detection=true. FrustrationDetectionEnabled bool + // ContextCompactionMode selects the compaction algorithm (issue: compaction-modes). + // off | deterministic | llm | hybrid. Empty = off (legacy behaviour). + ContextCompactionMode string + + // CompactionTrigger decides when the compactor fires per turn. + // turns | tokens | both. Default tokens. + CompactionTrigger string + + // CompactionMaxTokens is the token budget for compacted messages. Default 8000. + CompactionMaxTokens int + + // ContextWindow is the effective token cap for compaction. 0 = auto. + ContextWindow int + + // CompactionPreserveEvidence enables evidence-preserving retain rules (M3). Default true. + CompactionPreserveEvidence bool + + // CompactionRecentTurns is the number of recent human turns to retain. Default 4. + CompactionRecentTurns int + // YoloRiskThreshold: when non-empty and Yolo is true, wires a // RiskClassifier into the permission engine so YOLO auto-approves // only low/medium/high risk tools (issue #272). @@ -404,6 +424,51 @@ func Build(ctx context.Context, cfg Config, memStore *lessons.Store) (*agentloop loop.CompactionStrategy = strategy } + // Context compaction mode (issue: compaction-modes): config-file defaults for + // the 6 mode-based compaction keys. When ContextCompactionMode is non-empty + // and non-"off", the mode-based compactor replaces strategy-based compaction. + if cfg.ContextCompactionMode != "off" || cfg.CompactionTrigger != "" || + cfg.CompactionMaxTokens != 0 || cfg.ContextWindow != 0 || + cfg.CompactionPreserveEvidence || cfg.CompactionRecentTurns != 0 { + if sinCfg, err := internal.LoadMergedConfig(); err == nil { + if cfg.ContextCompactionMode == "" || cfg.ContextCompactionMode == "off" { + cfg.ContextCompactionMode = sinCfg.AgentLoopContextCompaction + } + if cfg.CompactionTrigger == "" { + cfg.CompactionTrigger = sinCfg.AgentLoopCompactionTrigger + } + if cfg.CompactionMaxTokens == 0 { + cfg.CompactionMaxTokens = sinCfg.AgentLoopCompactionMaxTokens + } + if cfg.ContextWindow == 0 { + cfg.ContextWindow = sinCfg.AgentLoopContextWindow + } + if !cfg.CompactionPreserveEvidence { + cfg.CompactionPreserveEvidence = sinCfg.AgentLoopCompactionPreserveEvidence + } + if cfg.CompactionRecentTurns == 0 { + cfg.CompactionRecentTurns = sinCfg.AgentLoopCompactionRecentTurns + } + } + } + if cfg.ContextCompactionMode != "" && cfg.ContextCompactionMode != "off" { + mode, _ := agentloop.ParseContextCompactionMode(cfg.ContextCompactionMode) + trigger, _ := agentloop.ParseCompactionTrigger(cfg.CompactionTrigger) + if loop.Compactor == nil { + loop.Compactor = agentloop.NewCompactor(nil) + } + loop.Compactor.Configure(agentloop.CompactorConfig{ + Mode: mode, + Trigger: trigger, + PreserveEvidence: cfg.CompactionPreserveEvidence, + RecentTurns: cfg.CompactionRecentTurns, + MaxTokens: cfg.CompactionMaxTokens, + }) + if cfg.ContextWindow > 0 { + loop.ContextWindow = cfg.ContextWindow + } + } + // FrustrationDetector (issue #271): opt-in via config // agentloop.frustration_detection. Appends a system-prompt suffix // when user frustration is detected.