返回 DeepSeek-Reasonix
bounded.go
根目录 / internal / boundedllm / bounded.go
1 // Package boundedllm provides the shared bounded no-tool provider call
2 // infrastructure used by independent host reviewers (the Auto Guard recovery
3 // reviewer and the Goal evaluator). Each reviewer is deliberately isolated from
4 // the main conversation: no tools, no session history, no compaction — a single
5 // temperature-0 request with hard time/output budgets whose usage is attributed
6 // to the reviewer's own source, never the main session's prompt cache.
7 package boundedllm
8
9 import (
10 "context"
11 "fmt"
12 "strings"
13 "time"
14
15 "reasonix/internal/event"
16 "reasonix/internal/nilutil"
17 "reasonix/internal/provider"
18 )
19
20 const (
21 // DefaultTimeout bounds one reviewer request.
22 DefaultTimeout = 30 * time.Second
23 // DefaultMaxTokens caps the model's completion length.
24 DefaultMaxTokens = 256
25 // DefaultMaxOutputBytes aborts the stream if the provider ignores MaxTokens.
26 DefaultMaxOutputBytes = 4 * 1024
27 // DefaultMaxSystemBytes caps the fixed system policy.
28 DefaultMaxSystemBytes = 2 * 1024
29 // DefaultMaxTotalBytes caps system + evidence together; each caller budgets
30 // its own evidence below this.
31 DefaultMaxTotalBytes = 8 * 1024
32 )
33
34 // Config carries one bounded reviewer call's policy and accounting hooks.
35 type Config struct {
36 // Provider is the model endpoint. Required.
37 Provider provider.Provider
38 // Pricing is used only for usage cost display; nil omits cost.
39 Pricing *provider.Pricing
40 // ModelRef is the canonical "provider/model" label on emitted usage events.
41 ModelRef string
42 // Sink receives the billable Usage event; nil disables emission.
43 Sink event.Sink
44 // UsageSource labels the emitted usage (e.g. event.UsageSourceGoalEvaluator).
45 // Empty means no Usage event is emitted.
46 UsageSource string
47 // Timeout bounds the whole call. Zero uses DefaultTimeout.
48 Timeout time.Duration
49 // MaxTokens caps the completion. Zero uses DefaultMaxTokens.
50 MaxTokens int
51 // EffortOverride optionally requests a lower or higher reasoning depth for
52 // this independent call. Provider adapters reject unsupported values.
53 EffortOverride string
54 // MaxOutputBytes aborts the stream once exceeded. Zero uses DefaultMaxOutputBytes.
55 MaxOutputBytes int
56 // MaxSystemBytes is the hard cap on the fixed system policy. Zero uses DefaultMaxSystemBytes.
57 MaxSystemBytes int
58 // MaxTotalBytes is the hard cap on system + evidence. Zero uses DefaultMaxTotalBytes.
59 MaxTotalBytes int
60 }
61
62 // Call runs one bounded no-tool request: system policy + a single user evidence
63 // message, temperature 0, capped completion, and streamed output collected up to
64 // MaxOutputBytes. It returns the raw response text (the caller parses its own
65 // JSON contract). Usage is emitted to Sink under UsageSource when both are set.
66 func Call(ctx context.Context, cfg Config, system, evidence string) (string, error) {
67 if nilutil.IsNil(cfg.Provider) {
68 return "", fmt.Errorf("bounded reviewer provider unavailable")
69 }
70 if nilutil.IsNil(ctx) {
71 ctx = context.Background()
72 }
73 timeout := cfg.Timeout
74 if timeout <= 0 {
75 timeout = DefaultTimeout
76 }
77 callCtx, cancel := context.WithTimeout(ctx, timeout)
78 defer cancel()
79 callCtx = provider.WithRequestAttemptCounter(callCtx)
80
81 maxTokens := cfg.MaxTokens
82 if maxTokens <= 0 {
83 maxTokens = DefaultMaxTokens
84 }
85 maxOutputBytes := cfg.MaxOutputBytes
86 if maxOutputBytes <= 0 {
87 maxOutputBytes = DefaultMaxOutputBytes
88 }
89 maxSystemBytes := cfg.MaxSystemBytes
90 if maxSystemBytes <= 0 {
91 maxSystemBytes = DefaultMaxSystemBytes
92 }
93 maxTotalBytes := cfg.MaxTotalBytes
94 if maxTotalBytes <= 0 {
95 maxTotalBytes = DefaultMaxTotalBytes
96 }
97 if len(system) > maxSystemBytes {
98 // Should never happen; keep fail-closed if a policy grows past budget.
99 return "", fmt.Errorf("bounded reviewer system policy exceeds %d bytes", maxSystemBytes)
100 }
101 if len(system)+len(evidence) > maxTotalBytes {
102 // Must not mid-clip JSON. Evidence is field-budgeted by the caller;
103 // remaining overflow can only come from policy growth — fail closed.
104 return "", fmt.Errorf("bounded reviewer request exceeds %d bytes", maxTotalBytes)
105 }
106
107 req := provider.Request{
108 Messages: []provider.Message{
109 {Role: provider.RoleSystem, Content: system},
110 {Role: provider.RoleUser, Content: evidence},
111 },
112 // No tools.
113 Temperature: provider.TemperaturePtr(0),
114 MaxTokens: maxTokens,
115 EffortOverride: cfg.EffortOverride,
116 }
117
118 var usage *provider.Usage
119 defer func() {
120 usage = provider.UsageWithRequestAttemptCount(callCtx, usage)
121 if usage != nil && cfg.UsageSource != "" && cfg.Sink != nil {
122 cfg.Sink.Emit(event.Event{
123 Kind: event.Usage,
124 ModelRef: cfg.ModelRef,
125 Usage: usage,
126 Pricing: cfg.Pricing,
127 UsageSource: cfg.UsageSource,
128 Source: cfg.UsageSource,
129 })
130 }
131 }()
132
133 ch, err := provider.StreamForModel(callCtx, cfg.Provider, req, cfg.ModelRef)
134 if err != nil {
135 return "", err
136 }
137
138 var text strings.Builder
139 for chunk := range ch {
140 switch chunk.Type {
141 case provider.ChunkText:
142 text.WriteString(chunk.Text)
143 if text.Len() > maxOutputBytes {
144 cancel()
145 return "", fmt.Errorf("bounded reviewer output exceeded %d bytes", maxOutputBytes)
146 }
147 case provider.ChunkUsage:
148 if chunk.Usage != nil {
149 u := *chunk.Usage
150 usage = &u
151 }
152 case provider.ChunkError:
153 if chunk.Err != nil {
154 return "", chunk.Err
155 }
156 return "", fmt.Errorf("bounded reviewer stream error")
157 }
158 }
159 if callCtx.Err() != nil && text.Len() == 0 {
160 return "", callCtx.Err()
161 }
162 return text.String(), nil
163 }
164
164 lines GO