返回 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 // MaxOutputBytes aborts the stream once exceeded. Zero uses DefaultMaxOutputBytes.
52 MaxOutputBytes int
53 // MaxSystemBytes is the hard cap on the fixed system policy. Zero uses DefaultMaxSystemBytes.
54 MaxSystemBytes int
55 // MaxTotalBytes is the hard cap on system + evidence. Zero uses DefaultMaxTotalBytes.
56 MaxTotalBytes int
57 }
58
59 // Call runs one bounded no-tool request: system policy + a single user evidence
60 // message, temperature 0, capped completion, and streamed output collected up to
61 // MaxOutputBytes. It returns the raw response text (the caller parses its own
62 // JSON contract). Usage is emitted to Sink under UsageSource when both are set.
63 func Call(ctx context.Context, cfg Config, system, evidence string) (string, error) {
64 if nilutil.IsNil(cfg.Provider) {
65 return "", fmt.Errorf("bounded reviewer provider unavailable")
66 }
67 if nilutil.IsNil(ctx) {
68 ctx = context.Background()
69 }
70 timeout := cfg.Timeout
71 if timeout <= 0 {
72 timeout = DefaultTimeout
73 }
74 callCtx, cancel := context.WithTimeout(ctx, timeout)
75 defer cancel()
76 callCtx = provider.WithRequestAttemptCounter(callCtx)
77
78 maxTokens := cfg.MaxTokens
79 if maxTokens <= 0 {
80 maxTokens = DefaultMaxTokens
81 }
82 maxOutputBytes := cfg.MaxOutputBytes
83 if maxOutputBytes <= 0 {
84 maxOutputBytes = DefaultMaxOutputBytes
85 }
86 maxSystemBytes := cfg.MaxSystemBytes
87 if maxSystemBytes <= 0 {
88 maxSystemBytes = DefaultMaxSystemBytes
89 }
90 maxTotalBytes := cfg.MaxTotalBytes
91 if maxTotalBytes <= 0 {
92 maxTotalBytes = DefaultMaxTotalBytes
93 }
94 if len(system) > maxSystemBytes {
95 // Should never happen; keep fail-closed if a policy grows past budget.
96 return "", fmt.Errorf("bounded reviewer system policy exceeds %d bytes", maxSystemBytes)
97 }
98 if len(system)+len(evidence) > maxTotalBytes {
99 // Must not mid-clip JSON. Evidence is field-budgeted by the caller;
100 // remaining overflow can only come from policy growth — fail closed.
101 return "", fmt.Errorf("bounded reviewer request exceeds %d bytes", maxTotalBytes)
102 }
103
104 req := provider.Request{
105 Messages: []provider.Message{
106 {Role: provider.RoleSystem, Content: system},
107 {Role: provider.RoleUser, Content: evidence},
108 },
109 // No tools.
110 Temperature: provider.TemperaturePtr(0),
111 MaxTokens: maxTokens,
112 }
113
114 var usage *provider.Usage
115 defer func() {
116 usage = provider.UsageWithRequestAttemptCount(callCtx, usage)
117 if usage != nil && cfg.UsageSource != "" && cfg.Sink != nil {
118 cfg.Sink.Emit(event.Event{
119 Kind: event.Usage,
120 ModelRef: cfg.ModelRef,
121 Usage: usage,
122 Pricing: cfg.Pricing,
123 UsageSource: cfg.UsageSource,
124 Source: cfg.UsageSource,
125 })
126 }
127 }()
128
129 ch, err := cfg.Provider.Stream(callCtx, req)
130 if err != nil {
131 return "", err
132 }
133
134 var text strings.Builder
135 for chunk := range ch {
136 switch chunk.Type {
137 case provider.ChunkText:
138 text.WriteString(chunk.Text)
139 if text.Len() > maxOutputBytes {
140 cancel()
141 return "", fmt.Errorf("bounded reviewer output exceeded %d bytes", maxOutputBytes)
142 }
143 case provider.ChunkUsage:
144 if chunk.Usage != nil {
145 u := *chunk.Usage
146 usage = &u
147 }
148 case provider.ChunkError:
149 if chunk.Err != nil {
150 return "", chunk.Err
151 }
152 return "", fmt.Errorf("bounded reviewer stream error")
153 }
154 }
155 if callCtx.Err() != nil && text.Len() == 0 {
156 return "", callCtx.Err()
157 }
158 return text.String(), nil
159 }
160
160 lines GO