| 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 |