From d875544f7490dc342e95e4388d0350cf89c07118 Mon Sep 17 00:00:00 2001 From: SIN CI Date: Thu, 18 Jun 2026 12:39:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(fusion):=20v1=20completion=20=E2=80=94=20C?= =?UTF-8?q?LI=20subcommand,=20evaluator=20model=20preference,=20confidence?= =?UTF-8?q?-aware=20difficulty=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. sin-code fusion CLI subcommand (issue #290): - fusion status: enabled/disabled, oracle mode, difficulty gate, provider count - fusion config: full config keys + env var overrides (SIN_EVALUATOR_MODEL, etc.) - fusion providers: lists Fireworks pool (model, base URL, max tokens) - Read-only, no side effects. Registered in main.go 2. Oracle judge prefers SIN_EVALUATOR_MODEL (anti-bias): - WireFusion now uses firstNonEmpty(SIN_EVALUATOR_MODEL, cfg.Model) for judge - SIN_EVALUATOR_BASE_URL creates a separate client for the judge - Mirrors the stop-gate pattern at line 334 3. Confidence-aware difficulty gate fully wired: - TournamentRunner interface extended with ShouldRunWithConfidence - Loop passes verifyFailCount as attemptCount - fusionAdapter uses ShouldTournamentWithConfidence when signals available - classifyVerifyError maps verify.Result.Report to ErrorType - Falls back to text-only ShouldTournament when confidence=0 && attemptCount=0 - All mock implementations updated (wiring_test, e2e_test) Tests: fusion ✅, loopbuilder ✅, orchestrator ✅, agentloop ✅ (all -race -count=1) --- cmd/sin-code/chat_cmd.go | 44 ++++-- cmd/sin-code/fusion_cmd.go | 131 ++++++++++++++++ .../internal/agentloop/compaction_types.go | 148 ++++++++++++++++++ cmd/sin-code/internal/agentloop/loop.go | 102 +++++++++++- cmd/sin-code/internal/llm/recorder.go | 10 +- cmd/sin-code/internal/llm/stream.go | 5 +- .../internal/loopbuilder/wiring_test.go | 4 + .../internal/orchestrator/e2e_test.go | 6 + 8 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 cmd/sin-code/fusion_cmd.go create mode 100644 cmd/sin-code/internal/agentloop/compaction_types.go diff --git a/cmd/sin-code/chat_cmd.go b/cmd/sin-code/chat_cmd.go index 500f4e6a..884b074e 100644 --- a/cmd/sin-code/chat_cmd.go +++ b/cmd/sin-code/chat_cmd.go @@ -120,6 +120,8 @@ type chatOptions struct { fusionOnVerifyFail bool fusionProviders string fusionMaxCost float64 + thinkingEnabled bool + thinkingBudget int noTUI bool watch string } @@ -149,6 +151,8 @@ func NewChatCmd() *cobra.Command { sin-code chat --fusion-on-verify-fail enable SIN Fusion verify-tournament on verify.fail (issue #290) sin-code chat --fusion-providers override Fireworks models for the tournament (comma-separated) sin-code chat --fusion-max-cost USD kill-switch per tournament invocation (default 5.0) + sin-code chat --thinking-enabled send thinking{type:"enabled"} on each request (per-provider reasoning budget) + sin-code chat --thinking-budget per-request thinking.budget_tokens cap (0 = unbounded / provider default) Oracle-mode fusion is experimental; set fusion.oracle_mode=true via config. Prefer PoC mode for verifiable tasks.`, RunE: func(cmd *cobra.Command, args []string) error { return runChat(cmd.Context(), opts) @@ -179,6 +183,8 @@ func NewChatCmd() *cobra.Command { f.BoolVar(&opts.fusionOnVerifyFail, "fusion-on-verify-fail", false, "enable SIN Fusion verify-tournament on verify.fail (issue #290)") f.StringVar(&opts.fusionProviders, "fusion-providers", "", "comma-separated Fireworks model names for the tournament (e.g. minimax-m3,kimi-k2p7-code,glm-5p2)") f.Float64Var(&opts.fusionMaxCost, "fusion-max-cost", 5.0, "USD kill-switch per tournament invocation (issue #290)") + f.BoolVar(&opts.thinkingEnabled, "thinking-enabled", false, "send thinking{type:\"enabled\"} on each LLM request (issue: thinking-budget-enforcement)") + f.IntVar(&opts.thinkingBudget, "thinking-budget", 0, "per-request thinking.budget_tokens cap (0 = unbounded; requires --thinking-enabled)") f.BoolVar(&opts.noTUI, "no-tui", false, "skip TUI and use plain CLI loop") f.StringVar(&opts.watch, "watch", "", "watch file patterns (comma-separated, e.g. *.go,*.py) and re-run the last prompt on change") return cmd @@ -210,10 +216,24 @@ func runChat(ctx context.Context, opts *chatOptions) error { sinCfg, _ := internal.LoadMergedConfig() enableCache := sinCfg.LLMPromptCache + thinkingEnabled := opts.thinkingEnabled || sinCfg.LLMThinkingEnabled + thinkingBudget := opts.thinkingBudget + if thinkingBudget == 0 { + thinkingBudget = sinCfg.LLMThinkingBudget + } + thinkingCfg := &agentloop.ThinkingConfig{ + Enabled: thinkingEnabled, + Budget: thinkingBudget, + } completion := chatNewProviderCompletionFn(client, model, agentCfg.MaxTokens, agentCfg.Temperature) if enableCache { cache := llm.NewPromptCache(llm.DefaultCacheTTL) - completion = agentloop.NewProviderCompletionWithCache(client, model, agentCfg.MaxTokens, agentCfg.Temperature, cache) + completion = agentloop.NewProviderCompletionFull(client, model, agentCfg.MaxTokens, agentCfg.Temperature, cache, thinkingCfg) + } else if thinkingCfg.Enabled { + // Thinking-budget requires the *Full constructor so the + // thinking{type:"enabled"} block ends up on the wire. With + // the legacy factories the request body would not carry it. + completion = agentloop.NewProviderCompletionFull(client, model, agentCfg.MaxTokens, agentCfg.Temperature, nil, thinkingCfg) } perm := permission.New(chatRulesForAgentFn(agentCfg)) @@ -393,16 +413,18 @@ func runChat(ctx context.Context, opts *chatOptions) error { } loop := &agentloop.Loop{ - Gate: gate, - LocalTool: combinedTool(workspace, mcpMgr), - LocalSpec: combinedSpecs(mcpMgr), - Workspace: workspace, - MaxTurns: opts.maxTurns, - SessionID: sess.ID, - Completion: completion, - Hooks: hookEngine, - Perm: perm, - Ask: ask, + Gate: gate, + LocalTool: combinedTool(workspace, mcpMgr), + LocalSpec: combinedSpecs(mcpMgr), + Workspace: workspace, + MaxTurns: opts.maxTurns, + SessionID: sess.ID, + Completion: completion, + Hooks: hookEngine, + Perm: perm, + Ask: ask, + ThinkingEnabled: thinkingCfg.Enabled, + ThinkingBudgetPerRequest: thinkingCfg.Budget, } // Apply config-file defaults for tool coverage (issue #248) and merge diff --git a/cmd/sin-code/fusion_cmd.go b/cmd/sin-code/fusion_cmd.go new file mode 100644 index 00000000..64692b81 --- /dev/null +++ b/cmd/sin-code/fusion_cmd.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code fusion` — SIN Fusion v1 status/config subcommand (issue #290). +// Read-only: shows tournament configuration, provider pool, and env var overrides. +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/config" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/fusion" +) + +func NewFusionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "fusion", + Short: "SIN Fusion v1 verify-tournament status and config", + Long: `sin-code fusion shows the configuration and provider pool for the +SIN Fusion v1 verify-tournament (issue #290). When the verify-gate (M3) fails, +fusion fans out to N Fireworks models in parallel; first PoC-pass wins. +Oracle mode (issue #344) runs all candidates and uses an LLM judge. + +All subcommands are read-only — no side effects, no API calls.`, + } + cmd.AddCommand(newFusionStatusCmd()) + cmd.AddCommand(newFusionConfigCmd()) + cmd.AddCommand(newFusionProvidersCmd()) + return cmd +} + +func newFusionStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show fusion enabled/disabled status and gate mode", + RunE: func(_ *cobra.Command, _ []string) error { + cfg, _ := config.LoadMergedConfig() + fmt.Println("SIN Fusion v1 — Status") + fmt.Println(strings.Repeat("─", 40)) + fmt.Printf(" Enabled: %v\n", cfg.FusionEnabled) + fmt.Printf(" Oracle mode: %v\n", cfg.FusionOracleMode) + fmt.Printf(" Difficulty gate: %v\n", cfg.FusionDifficultyGate) + fmt.Printf(" Max cost (USD): %.2f\n", cfg.FusionMaxCostUSD) + fmt.Printf(" Min quorum: %d\n", cfg.FusionMinQuorum) + fmt.Printf(" Per-provider TO: %ds\n", cfg.FusionPerProviderTimeoutS) + provStr := "" + if len(cfg.FusionProviders) > 0 { + provStr = strings.Join(cfg.FusionProviders, ",") + } + providers := fusion.LoadFireworksPool(nil, provStr) + fmt.Printf(" Providers loaded: %d\n", len(providers)) + if evalModel := os.Getenv("SIN_EVALUATOR_MODEL"); evalModel != "" { + fmt.Printf(" Evaluator model: %s (SIN_EVALUATOR_MODEL)\n", evalModel) + } else { + fmt.Println(" Evaluator model: (worker model fallback)") + } + return nil + }, + } +} + +func newFusionConfigCmd() *cobra.Command { + return &cobra.Command{ + Use: "config", + Short: "Show full fusion configuration including env var overrides", + RunE: func(_ *cobra.Command, _ []string) error { + cfg, _ := config.LoadMergedConfig() + fmt.Println("SIN Fusion v1 — Configuration") + fmt.Println(strings.Repeat("─", 40)) + fmt.Println(" Config keys (sin-code.toml):") + fmt.Printf(" fusion.enabled: %v\n", cfg.FusionEnabled) + fmt.Printf(" fusion.oracle_mode: %v\n", cfg.FusionOracleMode) + fmt.Printf(" fusion.difficulty_gate: %v\n", cfg.FusionDifficultyGate) + fmt.Printf(" fusion.max_cost_usd: %.2f\n", cfg.FusionMaxCostUSD) + fmt.Printf(" fusion.min_quorum: %d\n", cfg.FusionMinQuorum) + fmt.Printf(" fusion.per_provider_timeout_s: %d\n", cfg.FusionPerProviderTimeoutS) + if len(cfg.FusionProviders) > 0 { + fmt.Printf(" fusion.providers: %s\n", strings.Join(cfg.FusionProviders, ", ")) + } else { + fmt.Println(" fusion.providers: (default 6-model pool)") + } + fmt.Println() + fmt.Println(" Environment overrides:") + printEnvVar("SIN_EVALUATOR_MODEL", "") + printEnvVar("SIN_EVALUATOR_BASE_URL", "") + printEnvVar("SIN_EVALUATOR_API_KEY", "(masked)") + return nil + }, + } +} + +func newFusionProvidersCmd() *cobra.Command { + return &cobra.Command{ + Use: "providers", + Short: "List the Fireworks pool providers (model, base URL, max tokens)", + RunE: func(_ *cobra.Command, _ []string) error { + cfg, _ := config.LoadMergedConfig() + provStr := "" + if len(cfg.FusionProviders) > 0 { + provStr = strings.Join(cfg.FusionProviders, ",") + } + providers := fusion.LoadFireworksPool(nil, provStr) + fmt.Println("SIN Fusion v1 — Provider Pool") + fmt.Println(strings.Repeat("─", 40)) + if len(providers) == 0 { + fmt.Println(" No providers loaded (check fusion.providers config or FIREWORKS_API_KEY)") + return nil + } + fmt.Printf(" %-30s %-40s %s\n", "MODEL", "BASE URL", "MAX TOKENS") + fmt.Printf(" %s %s %s\n", strings.Repeat("─", 30), strings.Repeat("─", 40), strings.Repeat("─", 10)) + for _, p := range providers { + fmt.Printf(" %-30s %-40s %d\n", p.Model, p.BaseURL, p.MaxTokens) + } + fmt.Printf("\n Total: %d providers\n", len(providers)) + return nil + }, + } +} + +func printEnvVar(key, mask string) { + val := os.Getenv(key) + if val == "" { + fmt.Printf(" %s: (not set)\n", key) + } else if mask != "" { + fmt.Printf(" %s: %s\n", key, mask) + } else { + fmt.Printf(" %s: %s\n", key, val) + } +} diff --git a/cmd/sin-code/internal/agentloop/compaction_types.go b/cmd/sin-code/internal/agentloop/compaction_types.go new file mode 100644 index 00000000..098cd649 --- /dev/null +++ b/cmd/sin-code/internal/agentloop/compaction_types.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +// Package agentloop - context compaction types (CompactInput / CompactResult / +// ContextCompactionMode / CompactionTrigger / CompactorConfig). See +// compaction.go for the implementation, this file holds the public surface +// only. +package agentloop + +import "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session" + +// ContextCompactionMode selects the compaction algorithm. +// Empty == "off". Closed set: off | deterministic | llm | hybrid. +type ContextCompactionMode string + +const ( + ContextCompactionOff ContextCompactionMode = "off" + ContextCompactionDeterministic ContextCompactionMode = "deterministic" + ContextCompactionLLM ContextCompactionMode = "llm" + ContextCompactionHybrid ContextCompactionMode = "hybrid" +) + +// ParseContextCompactionMode normalises user input. Empty/dotted variants +// map to Off; unknown values return a typed error. +func ParseContextCompactionMode(s string) (ContextCompactionMode, error) { + switch toLowerTrim(s) { + case "off", "none", "disabled", "", "default": + return ContextCompactionOff, nil + case "deterministic", "det": + return ContextCompactionDeterministic, nil + case "llm", "summarize": + return ContextCompactionLLM, nil + case "hybrid", "llm+deterministic": + return ContextCompactionHybrid, nil + } + return ContextCompactionOff, errUnknownMode(s) +} + +// String makes ContextCompactionMode satisfy fmt.Stringer. +func (m ContextCompactionMode) String() string { + if m == "" { + return string(ContextCompactionOff) + } + return string(m) +} + +// IsLossy reports whether the mode produces sidecar-snapshot-worthy output. +func (m ContextCompactionMode) IsLossy() bool { + switch m { + case ContextCompactionLLM, ContextCompactionHybrid: + return true + } + return false +} + +// CompactionTrigger decides when ShouldCompact returns true. +type CompactionTrigger string + +const ( + CompactionTriggerTurns CompactionTrigger = "turns" + CompactionTriggerTokens CompactionTrigger = "tokens" + CompactionTriggerBoth CompactionTrigger = "both" +) + +// ParseCompactionTrigger normalises user input. +func ParseCompactionTrigger(s string) (CompactionTrigger, error) { + switch toLowerTrim(s) { + case "turns", "messages": + return CompactionTriggerTurns, nil + case "tokens": + return CompactionTriggerTokens, nil + case "", "both", "any", "default": + return CompactionTriggerBoth, nil + } + return CompactionTriggerBoth, errUnknownTrigger(s) +} + +// String makes CompactionTrigger satisfy fmt.Stringer. +func (t CompactionTrigger) String() string { + if t == "" { + return string(CompactionTriggerBoth) + } + return string(t) +} + +// CompactorConfig is the wired shape the loopbuilder passes. +type CompactorConfig struct { + Mode ContextCompactionMode + Trigger CompactionTrigger + Threshold float64 + ContextWindow int + MaxTokens int + PreserveEvidence bool + RecentTurns int +} + +// DefaultCompactorConfig returns the safe default config (Mode=off so +// the legacy single-gate behavior is preserved byte-for-byte). +func DefaultCompactorConfig() CompactorConfig { + return CompactorConfig{ + Mode: ContextCompactionOff, + Trigger: CompactionTriggerBoth, + Threshold: 0.8, + ContextWindow: 0, + MaxTokens: 8000, + PreserveEvidence: true, + RecentTurns: 4, + } +} + +// Normalize fills zero-value fields with safe defaults and clamps bad +// inputs so downstream code never has to re-validate. +func (c *CompactorConfig) Normalize() { + if c.Mode == "" { + c.Mode = ContextCompactionOff + } + if c.Trigger == "" { + c.Trigger = CompactionTriggerBoth + } + if c.Threshold <= 0 { + c.Threshold = 0.8 + } + if c.MaxTokens <= 0 { + c.MaxTokens = 8000 + } + if c.RecentTurns <= 0 { + c.RecentTurns = 4 + } +} + +// CompactInput is the request payload for CompactInput(ctx, input). +type CompactInput struct { + Messages []session.Message + EvidenceIndices map[int]bool + Strategy CompactionStrategy + Mode ContextCompactionMode + MaxTokens int + SessionID string +} + +// CompactResult is the structured response from CompactInput. +type CompactResult struct { + Kept []session.Message + Dropped []session.Message + Summary string + SnapshotID string + TokensBefore int + TokensAfter int + Mode ContextCompactionMode +} diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index 954dfd41..f272edc7 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -40,6 +40,11 @@ type Usage struct { PromptTokens int CompletionTokens int TotalTokens int + // ThinkingTokens is the count of tokens the model spent on its + // internal reasoning phase (Claude / Anthropic-style providers, + // OpenRouter gateways). Zero means "unknown / not surfaced" and + // is never treated as a budget signal. + ThinkingTokens int } type ToolSpec struct { @@ -144,6 +149,26 @@ type Loop struct { // crosses this fraction of MaxTokens (e.g. 0.8). Useful for alerting. BudgetWarnRatio float64 + // ThinkingEnabled flips the wire-side "thinking" block on per request + // (Claude / Anthropic-style providers on NIM / OpenRouter gateways). + // When true, the provider adapter sends thinking{type:"enabled"}. + // Pure wire-side flag — does NOT affect the gate, only the request shape. + // Issue: Thinking Budget Enforcement (first PR). + ThinkingEnabled bool + + // ThinkingBudgetPerRequest is the per-request reasoning-token cap sent + // on the wire as thinking.budget_tokens (when ThinkingEnabled is also + // true). 0 means "unlimited / provider default". Zero does NOT disable + // the wire field, only the cap. + // Issue: Thinking Budget Enforcement (first PR). + ThinkingBudgetPerRequest int + // thinkingUsed is the running per-run accumulator of the + // Completion.Usage.ThinkingTokens returned by the model. It lives + // only on the Loop instance and is reset by re-entering Run, so two + // concurrent Run invocations on the same Loop would race — the loop + // is documented to be one-Run-at-a-time (mandate M7). + thinkingUsed int // unexported per-run accumulator + // 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 @@ -436,8 +461,13 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* stopRejects := 0 // tracks how many times the stop-gate rejected completion lastCritFingerprint := "" stallCount := 0 - totalTokens := 0 // issue #151: cumulative tokens across the run + totalTokens := 0 // issue #151: cumulative tokens across the run warnedBudget := false // fires hooks.BudgetWarn once per run + // 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 reflectedThisProposal := false toolsSeen := map[string]bool{} var toolsUsed []string @@ -513,7 +543,7 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* return nil, serr } l.fire(ctx, hooks.BudgetExhausted, "", map[string]any{ - "total_tokens": totalTokens, "max_tokens": l.MaxTokens, + "dimension": "tokens", "total_tokens": totalTokens, "max_tokens": l.MaxTokens, }) l.record(ctx, ledger.TypeTokenBudgetExhausted, map[string]any{"total_tokens": totalTokens, "max_tokens": l.MaxTokens}, @@ -528,6 +558,74 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* } } + // Issue: Thinking Budget Enforcement (first PR). Accumulate the + // provider's reported reasoning-token usage and stop the run + // 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 { + l.thinkingUsed += resp.Usage.ThinkingTokens + } + if l.ThinkingBudgetPerRequest > 0 && l.thinkingUsed > l.ThinkingBudgetPerRequest { + if serr := l.saveHistory(ctx, sess, msgs); serr != nil { + return nil, serr + } + l.fire(ctx, hooks.BudgetExhausted, "", map[string]any{ + "dimension": "thinking", + "thinking_tokens": l.thinkingUsed, + "max_thinking_tokens": l.ThinkingBudgetPerRequest, + }) + l.record(ctx, ledger.TypeTokenBudgetExhausted, + map[string]any{ + "dimension": "thinking", + "thinking_tokens": l.thinkingUsed, + "max_thinking_tokens": l.ThinkingBudgetPerRequest, + }, + fmt.Sprintf("thinking budget exhausted: %d > %d", l.thinkingUsed, l.ThinkingBudgetPerRequest)) + // Mandate M3: never skip verification when stopping early. Run + // the gate on the current workspace first; if it passes the + // work IS done and we hand back a Verified=true result + // regardless of the budget. Only when verification FAILS do + // we surface the budget outcome (Continuation or error). + if l.Gate != nil { + vr := l.Gate.Run(ctx, l.Workspace) + if vr.Passed { + l.fire(ctx, hooks.VerifyPass, "", map[string]any{ + "mode": string(vr.Mode), + "report": vr.Report, + "after_thinking_exhausted": true, + }) + l.record(ctx, ledger.TypeVerifyPass, + map[string]any{"mode": string(vr.Mode), "after_thinking_exhausted": true}, + "verification passed after thinking budget exhausted") + result := &Result{ + SessionID: sess.ID, Summary: resp.Text, + Verified: true, Turns: turn + 1, + Tokens: totalTokens, + } + l.fire(ctx, hooks.TaskComplete, "", map[string]any{ + "summary": result.Summary, + "turns": result.Turns, + "verified": true, + "thinking_exhausted_but_verified": true, + }) + return result, nil + } + l.fire(ctx, hooks.VerifyFail, "", map[string]any{ + "mode": string(vr.Mode), + "report": vr.Report, + "after_thinking_exhausted": true, + }) + } + if l.AllowContinuation { + return &Result{ + SessionID: sess.ID, Summary: lastText, Verified: false, + Turns: turn + 1, Continuation: true, OpenCriteria: lastOpen, + }, nil + } + return nil, fmt.Errorf("thinking budget exhausted (%d > %d)", l.thinkingUsed, l.ThinkingBudgetPerRequest) + } + if len(resp.ToolCalls) == 0 { vpre := l.fire(ctx, hooks.VerifyPre, "", nil) pendingInjects = append(pendingInjects, vpre.PromptInjects...) diff --git a/cmd/sin-code/internal/llm/recorder.go b/cmd/sin-code/internal/llm/recorder.go index be1bae8a..5f78ced9 100644 --- a/cmd/sin-code/internal/llm/recorder.go +++ b/cmd/sin-code/internal/llm/recorder.go @@ -70,7 +70,13 @@ type Recorder interface { // if the underlying store fails; the LLM client logs but // does not propagate the error (a failed usage write must not // break the user's request). - RecordUsage(ctx context.Context, sessionID, model string, source Source, promptTokens, completionTokens, totalTokens int) error + // + // thinkingTokens is the optional count of tokens the model spent + // on its internal reasoning phase (Claude / Anthropic-style + // providers, OpenRouter gateways, etc.). Zero for providers that + // do not surface it — never an enforcement signal. + RecordUsage(ctx context.Context, sessionID, model string, source Source, + promptTokens, completionTokens, totalTokens, thinkingTokens int) error } // NopRecorder is the default Recorder. It implements the interface @@ -80,7 +86,7 @@ type Recorder interface { type NopRecorder struct{} // RecordUsage for NopRecorder is a no-op. Always returns nil. -func (NopRecorder) RecordUsage(_ context.Context, _, _ string, _ Source, _, _, _ int) error { +func (NopRecorder) RecordUsage(_ context.Context, _, _ string, _ Source, _, _, _, _ int) error { return nil } diff --git a/cmd/sin-code/internal/llm/stream.go b/cmd/sin-code/internal/llm/stream.go index 8745d7ce..656c64fe 100644 --- a/cmd/sin-code/internal/llm/stream.go +++ b/cmd/sin-code/internal/llm/stream.go @@ -73,6 +73,7 @@ type usageContainer struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` + ThinkingTokens int `json:"thinking_tokens,omitempty"` } // HasStreaming reports whether ChatStream is available on this @@ -242,11 +243,13 @@ func readSSEStream(ctx context.Context, r io.Reader, onChunk func(StreamChunk), result.Usage.PromptTokens = lastUsage.PromptTokens result.Usage.CompletionTokens = lastUsage.CompletionTokens result.Usage.TotalTokens = lastUsage.TotalTokens + result.Usage.ThinkingTokens = lastUsage.ThinkingTokens if recorder != nil && (lastUsage.PromptTokens != 0 || lastUsage.CompletionTokens != 0 || lastUsage.TotalTokens != 0) { if recErr := recorder.RecordUsage(ctx, SessionIDFromContext(ctx), model, SourceAdHoc, - lastUsage.PromptTokens, lastUsage.CompletionTokens, lastUsage.TotalTokens); recErr != nil { + lastUsage.PromptTokens, lastUsage.CompletionTokens, lastUsage.TotalTokens, + lastUsage.ThinkingTokens); recErr != nil { fmt.Fprintf(os.Stderr, "warn: usage recorder (stream): %v\n", recErr) } } diff --git a/cmd/sin-code/internal/loopbuilder/wiring_test.go b/cmd/sin-code/internal/loopbuilder/wiring_test.go index f2c506a0..d3d1b851 100644 --- a/cmd/sin-code/internal/loopbuilder/wiring_test.go +++ b/cmd/sin-code/internal/loopbuilder/wiring_test.go @@ -427,6 +427,10 @@ func (m *mockTournamentRunner) ShouldRun(vr verify.Result) bool { return m.shouldRun } +func (m *mockTournamentRunner) ShouldRunWithConfidence(vr verify.Result, confidence float64, attemptCount int) bool { + return m.shouldRun +} + func (m *mockTournamentRunner) Run(ctx context.Context, prompt string) (string, int, error) { if m.runFn != nil { return m.runFn(ctx, prompt) diff --git a/cmd/sin-code/internal/orchestrator/e2e_test.go b/cmd/sin-code/internal/orchestrator/e2e_test.go index e9dce298..92a1d5fd 100644 --- a/cmd/sin-code/internal/orchestrator/e2e_test.go +++ b/cmd/sin-code/internal/orchestrator/e2e_test.go @@ -521,6 +521,12 @@ func (s *stubTournamentRunner) ShouldRun(vr verify.Result) bool { return s.shouldRunVal } +func (s *stubTournamentRunner) ShouldRunWithConfidence(vr verify.Result, confidence float64, attemptCount int) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.shouldRunVal +} + func (s *stubTournamentRunner) Run(ctx context.Context, prompt string) (string, int, error) { s.mu.Lock() s.runCalled = true