squishling 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,91 +8,199 @@ module Squishling
8
8
  present, holds additional state about the caller. Respond only with JSON matching the required schema.
9
9
  NOTE
10
10
 
11
+ # The most of a rejected output that is forwarded to the next escalation step.
12
+ MAX_FORWARDED_CHARS = 4_000
13
+
14
+ # One escalation's request: the system prompt, the output Schema, and the first user message. The judge's
15
+ # role (:judge) skips squish_validate and names the judge in errors and logs.
16
+ Prompt = Data.define(:instructions, :schema, :input, :role)
17
+
18
+ # One of a sampling harness's two samples, between rounds: the steps it may attempt, in order.
19
+ Sample = Struct.new(:name, :steps, :chat, :rejected, :result)
20
+
11
21
  def initialize(definition, receiver, inputs)
12
22
  @definition = definition
13
23
  @receiver = receiver
14
24
  @inputs = inputs
25
+ @client = LLMClient.new(definition.label)
26
+ @output_check = OutputCheck.new(definition, receiver, inputs)
15
27
  end
16
28
 
29
+ # Runs the harness (Definition#harness) over the escalation (Definition#escalation_path).
17
30
  def call
18
- instructions = @definition.instructions(@receiver)
31
+ purpose = @definition.purpose(@receiver)
19
32
  schema = @definition.schema
20
- raise ConfigurationError, "#{@definition.label} has no instructions" if instructions.nil? || instructions.empty?
33
+ raise ConfigurationError, "#{@definition.label} has no purpose" if purpose.nil? || purpose.empty?
21
34
  raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
22
35
 
23
- chat = build_chat
24
- chat.with_instructions("#{instructions}\n\n#{INPUT_NOTE}")
25
- chat.with_schema(schema.llm_schema)
26
- apply_params(chat)
27
-
28
- response = ask(chat, JSON.generate(payload))
29
- attempts = 0
30
- loop do
31
- attempts += 1
32
- data, errors = parse(response.content)
33
- errors = schema.validate(data) if errors.empty?
34
- return schema.build(data, squished: true) if errors.empty?
35
-
36
- if attempts > Squishling.config.max_retries
37
- raise InvalidOutputError.new(errors:, raw: response.content, attempts:)
38
- end
36
+ harness = @definition.harness
37
+ path = @definition.escalation_path
38
+ body = payload
39
+ prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:,
40
+ input: JSON.generate(body), role: nil)
41
+ return escalate(path, prompt) unless harness.squishsum?
39
42
 
40
- response = ask(chat, retry_message(errors))
41
- end
43
+ # The sample steps and the judge are resolved before any sample is requested, so a missing one fails
44
+ # before costing anything.
45
+ sample_steps, rest = sampling_plan(path, harness)
46
+ judge = harness.judged? && Judge.new(definition: @definition, receiver: @receiver, client: @client, harness:,
47
+ rest:, purpose:, payload: body, escalate: method(:escalate),
48
+ log_failure: method(:log_failure))
49
+ sample(sample_steps, prompt, harness, judge)
42
50
  end
43
51
 
44
52
  private
45
53
 
46
- def build_chat
47
- RubyLLM.chat(**chat_options)
48
- rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
49
- raise ConfigurationError, "#{@definition.label}: #{e.message}"
54
+ # Splits the escalation into runs of consecutive identical steps (a step's attempts). A squishsum samples
55
+ # the first run twice; an ensemble samples the first run, then the second. Returns each sample's steps and
56
+ # the rest of the escalation, which is where a judge looks for its default step.
57
+ def sampling_plan(path, harness)
58
+ runs = path.chunk_while { |step, following| step == following }.to_a
59
+ if harness.ensemble? && runs.size < 2
60
+ raise ConfigurationError, "#{@definition.label}: the #{harness.type} harness needs a second escalation " \
61
+ "step to sample (adjacent identical steps count as one step's attempts)"
62
+ end
63
+
64
+ return [runs.first(2), runs.drop(2).flatten(1)] if harness.ensemble?
65
+
66
+ [[runs.first, runs.first], runs.drop(1).flatten(1)]
50
67
  end
51
68
 
52
- # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
53
- # and raises ArgumentError before any request is sent.
54
- def apply_params(chat)
55
- Params.apply(chat, @definition.params)
56
- rescue ArgumentError => e
57
- raise ConfigurationError, "#{@definition.label}: invalid params #{@definition.params.inspect} (#{e.message})"
69
+ # Makes each attempt of the path until an output passes the schema and the squish_validate check.
70
+ # Consecutive attempts on the same step continue the same conversation, so the model sees what it got wrong;
71
+ # a new step starts a fresh chat told about the last rejected output (unless the step sets
72
+ # forward_rejected: false).
73
+ def escalate(path, prompt)
74
+ chat = nil
75
+ rejected = nil # [raw content, errors] of the last invalid output
76
+ models = []
77
+
78
+ path.each.with_index(1) do |step, attempt|
79
+ models << step.model
80
+ last = attempt == path.size
81
+ if chat && step == path[attempt - 2]
82
+ message = retry_message(rejected.last)
83
+ else
84
+ chat = start_chat(step, prompt)
85
+ message = rejected && step.forward_rejected ? escalation_message(prompt.input, *rejected) : prompt.input
86
+ end
87
+
88
+ begin
89
+ response = @client.ask(chat, message, step)
90
+ rescue LLMError => e
91
+ squawk(prompt, path, attempt, nil, e)
92
+ raise if last
93
+
94
+ # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
95
+ chat = nil
96
+ log_failure(prompt.role, attempt, path, e.message)
97
+ next
98
+ end
99
+
100
+ result, errors = @output_check.call(response.content, prompt)
101
+ if errors.empty?
102
+ squawk(prompt, path, attempt, response, nil)
103
+ return result
104
+ end
105
+
106
+ error = InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models: models.dup,
107
+ source: source(prompt.role))
108
+ squawk(prompt, path, attempt, response, error)
109
+ raise error if last
110
+
111
+ rejected = [response.content, errors]
112
+ log_failure(prompt.role, attempt, path, errors.join("; "))
113
+ end
58
114
  end
59
115
 
60
- # Transient HTTP failures are already retried by RubyLLM (config.max_retries); anything that
61
- # still fails is surfaced as an LLMError rather than retried again here. A 400 means the request
62
- # we built is invalid (an unsupported param, a schema the provider rejects), so it's a setup
63
- # mistake: a fallback would otherwise hide it on every call.
64
- def ask(chat, message)
65
- chat.ask(message)
66
- rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
67
- raise ConfigurationError, "#{@definition.label}: #{e.class}: #{e.message}"
68
- rescue RubyLLM::BadRequestError => e
69
- raise ConfigurationError, "#{@definition.label}: the provider rejected the request (#{e.message})#{params_hint}"
70
- rescue RubyLLM::Error, Faraday::Error => e
71
- raise LLMError, "#{@definition.label}: #{e.class}: #{e.message}"
116
+ # Two samples, each on its own steps and retried per their attempts the same way escalate retries.
117
+ # Each round sends the pending samples' requests concurrently; everything else runs on this thread.
118
+ def sample(sample_steps, prompt, harness, judge)
119
+ samples = %w[a b].zip(sample_steps).map { |name, steps| Sample.new(name, steps) }
120
+
121
+ 1.upto(sample_steps.map(&:size).max) do |attempt|
122
+ pending = samples.reject(&:result)
123
+ break if pending.empty?
124
+
125
+ jobs = pending.map do |sample|
126
+ step = sample.steps[attempt - 1]
127
+ message = sample_message(sample, step, prompt)
128
+ -> { @client.ask(sample.chat, message, step) }
129
+ end
130
+ outcomes = LLMClient.concurrently(jobs)
131
+ # A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
132
+ fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
133
+ raise fatal if fatal
134
+
135
+ pending.zip(outcomes).each { |sample, outcome| record(sample, outcome, prompt, attempt) }
136
+ end
137
+
138
+ settle(samples, harness, judge)
72
139
  end
73
140
 
74
- # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
75
- # provider is named, so RubyLLM is told to assume they exist.
76
- def chat_options
77
- model = @definition.model
78
- provider = @definition.provider
79
- options = { model:, provider: }.compact
80
- options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
81
- options
141
+ # The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
142
+ def sample_message(sample, step, prompt)
143
+ return retry_message(sample.rejected.last) if sample.chat
144
+
145
+ sample.chat = start_chat(step, prompt)
146
+ sample.rejected && step.forward_rejected ? escalation_message(prompt.input, *sample.rejected) : prompt.input
82
147
  end
83
148
 
84
- def known_model?(model, provider)
85
- RubyLLM.models.find(model, provider:)
86
- true
87
- rescue RubyLLM::ModelNotFoundError
88
- false
149
+ def record(sample, (response, error), prompt, attempt)
150
+ steps = sample.steps
151
+ last = attempt == steps.size
152
+ role = "sample #{sample.name}"
153
+ if error
154
+ squawk(prompt, steps, attempt, nil, error)
155
+ raise error if last
156
+
157
+ sample.chat = nil
158
+ return log_failure(role, attempt, steps, error.message)
159
+ end
160
+
161
+ result, errors = @output_check.call(response.content, prompt)
162
+ if errors.empty?
163
+ squawk(prompt, steps, attempt, response, nil)
164
+ return sample.result = result
165
+ end
166
+
167
+ models = steps.first(attempt).map(&:model)
168
+ invalid = InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models:)
169
+ squawk(prompt, steps, attempt, response, invalid)
170
+ raise invalid if last
171
+
172
+ sample.rejected = [response.content, errors]
173
+ log_failure(role, attempt, steps, errors.join("; "))
89
174
  end
90
175
 
91
- def params_hint
92
- params = @definition.params
93
- return "" if params.empty?
176
+ # Agreeing samples are accepted; otherwise the judge, if any, picks one or the call fails.
177
+ def settle(samples, harness, judge)
178
+ first, second = samples.map(&:result)
179
+ return first if agree?(first, second, harness.compare)
180
+
181
+ models = samples.map { |sample| sample.steps.first.model }
182
+ unless judge
183
+ log_warning("samples disagreed")
184
+ raise DisagreementError.new(candidates: [first, second], models:)
185
+ end
186
+
187
+ log_warning("samples disagreed, asking the judge (#{judge.step.display_name})")
188
+ choice, reason, detail = judge.verdict(first, second)
189
+ return { a: first, b: second }.fetch(choice) unless choice == :neither
94
190
 
95
- ". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
191
+ raise DisagreementError.new(candidates: [first, second], verdict: :neither, reason:, detail:,
192
+ models: models + [judge.step.model])
193
+ end
194
+
195
+ # compare: is the developer's own code, so its exceptions propagate unwrapped.
196
+ def agree?(first, second, compare)
197
+ return Schema.jsonify(first.to_h) == Schema.jsonify(second.to_h) unless compare
198
+
199
+ @receiver.instance_exec(first, second, **@inputs, &compare) ? true : false
200
+ end
201
+
202
+ def start_chat(step, prompt)
203
+ @client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
96
204
  end
97
205
 
98
206
  # Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
@@ -126,26 +234,67 @@ module Squishling
126
234
  @receiver.instance_variable_get(:"@#{name}")
127
235
  end
128
236
 
129
- # RubyLLM parses structured output itself and leaves the raw string when that fails, so
130
- # content is a Hash on success, or a String/nil when the model refused, was cut off, or
131
- # ignored the schema.
132
- def parse(content)
133
- return [nil, ["response was empty"]] if content.nil? || (content.is_a?(String) && content.strip.empty?)
134
- return [content, []] unless content.is_a?(String)
237
+ def retry_message(errors)
238
+ "Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
239
+ end
240
+
241
+ # The first message to a fresh chat after an earlier model's output was rejected. The rejected output is
242
+ # model-generated and may cross providers, so it is capped at MAX_FORWARDED_CHARS.
243
+ def escalation_message(input, raw, errors)
244
+ previous = rejected_text(raw)
245
+ previous = "(an empty response)" if previous.strip.empty? || raw.nil?
246
+ if previous.length > MAX_FORWARDED_CHARS
247
+ omitted = previous.length - MAX_FORWARDED_CHARS
248
+ previous = "#{previous[0, MAX_FORWARDED_CHARS]}... [truncated, #{omitted} more characters]"
249
+ end
250
+ "#{input}\n\nA previous attempt at this request returned:\n#{previous}\n" \
251
+ "It was rejected:\n- #{errors.join("\n- ")}\nRespond with corrected JSON only."
252
+ end
135
253
 
136
- [JSON.parse(strip_code_fence(content)), []]
137
- rescue JSON::ParserError => e
138
- [nil, ["response was not valid JSON (#{e.message.lines.first&.strip})"]]
254
+ # The rejected output as text. Output that was rejected for being unrepresentable in JSON (Infinity, invalid
255
+ # UTF-8) can't be serialized or measured as is, so it is scrubbed or replaced rather than raising.
256
+ def rejected_text(raw)
257
+ return utf8(raw).scrub("?") if raw.is_a?(String)
258
+
259
+ JSON.generate(raw)
260
+ rescue JSON::JSONError
261
+ "(a response JSON can't represent)"
139
262
  end
140
263
 
141
- # Models without native structured output sometimes wrap JSON in a markdown code fence.
142
- def strip_code_fence(text)
143
- text[/\A\s*```(?:json)?\s*\n(.*?)\n\s*```\s*\z/m, 1] || text
264
+ # A binary-tagged string counts every byte as valid, so it is read as UTF-8 before scrubbing.
265
+ def utf8(text)
266
+ text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text
144
267
  end
145
268
 
146
- def retry_message(errors)
147
- "Your previous response did not match the required schema:\n- #{errors.join("\n- ")}\n" \
148
- "Respond again with corrected JSON only."
269
+ # Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
270
+ # the error that ended it (nil when it was accepted), and what was asked.
271
+ def squawk(prompt, path, attempt, response, error)
272
+ hook = @definition.squawk
273
+ return unless hook
274
+
275
+ step = path[attempt - 1]
276
+ metadata = {
277
+ label: @definition.label, attempt:, attempts: path.size, final: attempt == path.size,
278
+ model: response&.model || step.model, provider: step.provider, params: step.params, input: prompt.input,
279
+ usage: response&.tokens&.to_h
280
+ }
281
+ Squawk.call(hook, output: response&.content, metadata:, error:)
282
+ end
283
+
284
+ def source(role)
285
+ role == :judge ? "Judge" : "LLM"
286
+ end
287
+
288
+ def log_failure(role, attempt, path, reason)
289
+ step = path[attempt - 1]
290
+ next_step = path[attempt]
291
+ action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
292
+ who = role ? "#{role} attempt" : "attempt"
293
+ log_warning("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
294
+ end
295
+
296
+ def log_warning(message)
297
+ Squishling.config.logger&.warn("[Squishling] #{@definition.label} #{message}")
149
298
  end
150
299
  end
151
300
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Picks between a judged harness's two disagreeing samples (judged_squishsum or judged_ensemble), or rejects
5
+ # both. The judge is the harness's judge: step, or else the escalation's next step after the ones the samples
6
+ # ran on. A chat judge answers with a strict verdict schema; a :judgment judge (a System One decision model such
7
+ # as Jev) answers one choice question through RubyLLM.judge, and its pick counts only at or above
8
+ # min_confidence.
9
+ class Judge
10
+ VERDICT_SCHEMA = {
11
+ "type" => "object",
12
+ "properties" => {
13
+ "verdict" => { "type" => "string", "enum" => %w[a b neither] },
14
+ "reason" => { "type" => "string" }
15
+ },
16
+ "required" => %w[verdict reason],
17
+ "additionalProperties" => false
18
+ }.freeze
19
+
20
+ INPUT_NOTE = <<~NOTE
21
+ The input is a JSON object. "input" is what the operation received ("arguments" and, when present,
22
+ "context"); "output_schema" is the format both candidates follow; "candidates" holds "a" and "b".
23
+ Respond only with JSON matching the required schema: your verdict ("a", "b", or "neither") and a short reason.
24
+ NOTE
25
+
26
+ CHOICES = {
27
+ a: "Candidate a correctly carries out the purpose for this input (also when both do)",
28
+ b: "Candidate b correctly carries out the purpose for this input",
29
+ neither: "Neither candidate is clearly correct"
30
+ }.freeze
31
+
32
+ def initialize(definition:, receiver:, client:, harness:, rest:, purpose:, payload:, escalate:, log_failure:)
33
+ @definition = definition
34
+ @receiver = receiver
35
+ @client = client
36
+ @harness = harness
37
+ @purpose = purpose
38
+ @payload = payload
39
+ @escalate = escalate
40
+ @log_failure = log_failure
41
+ @steps = resolve_steps(rest)
42
+ end
43
+
44
+ # The judge's first attempt, for logs and DisagreementError#models.
45
+ def step
46
+ @steps.first
47
+ end
48
+
49
+ # [:a | :b, reason, detail] for the chosen candidate, or [:neither, reason, detail]. The detail is text
50
+ # Squishling wrote (nil for a chat judge, whose reason is model-written) and is safe for error messages.
51
+ def verdict(first, second)
52
+ candidates = { a: Schema.jsonify(first.to_h), b: Schema.jsonify(second.to_h) }
53
+ judgment? ? judgment(candidates) : chat(candidates)
54
+ end
55
+
56
+ private
57
+
58
+ def judgment?
59
+ @harness.judge&.fetch(:type) == :judgment
60
+ end
61
+
62
+ # A declared judge brings its own model and provider. A chat judge gets the method's generation params
63
+ # under its own, like any escalation step; a judgment judge sends only its own params, as provider options.
64
+ def resolve_steps(rest)
65
+ if (judge = @harness.judge)
66
+ params = judge[:type] == :judgment ? {} : @definition.params
67
+ return ModelPath.steps([judge], provider: nil, params:)
68
+ end
69
+
70
+ if rest.empty?
71
+ ordinal = @harness.ensemble? ? "third" : "second"
72
+ raise ConfigurationError, "#{@definition.label}: the #{@harness.type} harness needs a judge: or a " \
73
+ "#{ordinal} escalation step to judge with"
74
+ end
75
+
76
+ rest.take_while { |step| step == rest.first }
77
+ end
78
+
79
+ def chat(candidates)
80
+ prompt = Invoker::Prompt.new(
81
+ instructions: "#{judge_instructions}\n\nThe operation's purpose:\n<purpose>\n#{@purpose}\n" \
82
+ "</purpose>\n\n#{INPUT_NOTE}",
83
+ schema: Schema.for(VERDICT_SCHEMA), input: JSON.generate(state(candidates)), role: :judge
84
+ )
85
+ result = @escalate.call(@steps, prompt)
86
+ [result[:verdict].to_sym, result[:reason], nil]
87
+ end
88
+
89
+ def judgment(candidates)
90
+ input = { purpose: @purpose, **state(candidates) }
91
+ questions = { winner: { type: :choice, instructions: judge_instructions, options: CHOICES } }
92
+ answer = judge_with_retries(input, questions)[:winner]
93
+ unless answer.respond_to?(:choice) && answer.respond_to?(:probabilities)
94
+ raise InvalidOutputError.new(errors: ["the judgment didn't answer the winner question"], source: "Judge")
95
+ end
96
+
97
+ interpret(answer.choice.to_s.to_sym, answer.probabilities)
98
+ end
99
+
100
+ # The threshold is checked on the exact probability; it's rounded only for the reason text.
101
+ def interpret(choice, probabilities)
102
+ probability = probabilities.to_h.find { |key, _| key.to_s == choice.to_s }&.last.to_f
103
+ minimum = @harness.judge[:min_confidence]
104
+ shown = probability.round(3)
105
+ picked = %i[a b].include?(choice)
106
+ if picked && probability >= minimum
107
+ text = "chosen with probability #{shown}"
108
+ return [choice, text, text]
109
+ end
110
+
111
+ text =
112
+ if choice == :neither then "the judge chose neither (probability #{shown})"
113
+ elsif picked then "the judge chose #{choice} with probability #{shown}, below min_confidence #{minimum}"
114
+ else "the judge returned an unrecognized choice (probability #{shown})"
115
+ end
116
+ [:neither, text, text]
117
+ end
118
+
119
+ # A judgment always matches its questions, so only a failed request (LLMError) moves on to the next attempt.
120
+ def judge_with_retries(input, questions)
121
+ @steps.each.with_index(1) do |step, attempt|
122
+ return @client.judge(input, questions:, step:)
123
+ rescue LLMError => e
124
+ raise if attempt == @steps.size
125
+
126
+ @log_failure.call(:judge, attempt, @steps, e.message)
127
+ end
128
+ end
129
+
130
+ def state(candidates)
131
+ { input: @payload, output_schema: @definition.schema.llm_schema["schema"], candidates: }
132
+ end
133
+
134
+ def judge_instructions
135
+ value = @harness.judge_instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
136
+ value = @receiver.instance_exec(&value) if value.is_a?(Proc)
137
+ return value if value.is_a?(String) && !value.strip.empty?
138
+
139
+ raise ConfigurationError, "#{@definition.label}: the judge_instructions proc must return a non-blank String"
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # RubyLLM requests for one squished method, with RubyLLM's errors mapped onto Squishling's taxonomy.
5
+ class LLMClient
6
+ # Where RubyLLM's own code lives, to tell its ArgumentErrors from the developer's (see #judge).
7
+ RUBY_LLM_LIB = File.dirname(RubyLLM.method(:judge).source_location.first)
8
+ private_constant :RUBY_LLM_LIB
9
+
10
+ # Runs each job on its own thread and returns, in order, each job's [value, exception]. Only provider
11
+ # requests run here: parsing, validation, and the developer's callbacks stay on the caller's thread. Fiber
12
+ # storage (RubyLLM's usage owner) is inherited by the threads, and RubyLLM's instrumentation context is
13
+ # carried over the way RubyLLM's own concurrent tool calls do it. Unlike those, the threads don't enter the
14
+ # Rails executor: the caller already holds it, and a second share can deadlock against a pending code reload.
15
+ # If the caller is interrupted while waiting (a timeout, Thread#raise), the requests still running are
16
+ # stopped rather than left running unobserved.
17
+ def self.concurrently(jobs)
18
+ return jobs.map { |job| capture(job) } if jobs.size < 2
19
+
20
+ context = instrumentation_context
21
+ threads = jobs.map do |job|
22
+ thread = Thread.new { with_instrumentation_context(context) { capture(job) } }
23
+ thread.report_on_exception = false
24
+ thread
25
+ end
26
+ threads.map(&:value)
27
+ ensure
28
+ threads&.each { |thread| thread.kill.join if thread.alive? }
29
+ end
30
+
31
+ def self.capture(job)
32
+ [job.call, nil]
33
+ rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised on the caller's thread
34
+ [nil, e]
35
+ end
36
+
37
+ def self.instrumentation_context
38
+ instrumentation = defined?(RubyLLM::Support::Instrumentation) && RubyLLM::Support::Instrumentation
39
+ return unless instrumentation.respond_to?(:current_workflow) && instrumentation.respond_to?(:capture_context)
40
+
41
+ [instrumentation.current_workflow, instrumentation.capture_context]
42
+ end
43
+
44
+ def self.with_instrumentation_context(context, &)
45
+ return yield unless context
46
+
47
+ instrumentation = RubyLLM::Support::Instrumentation
48
+ instrumentation.with_workflow(context.first) { instrumentation.with_context(context.last, &) }
49
+ end
50
+
51
+ private_class_method :capture, :instrumentation_context, :with_instrumentation_context
52
+
53
+ def initialize(label)
54
+ @label = label
55
+ end
56
+
57
+ # A fresh chat on one escalation step, with the system prompt, the strict output schema, and its params.
58
+ def chat(step, instructions:, schema:)
59
+ chat = build_chat(step)
60
+ chat.with_instructions(instructions)
61
+ chat.with_schema(schema)
62
+ apply_params(chat, step.params)
63
+ chat
64
+ end
65
+
66
+ # Transient HTTP failures are already retried by RubyLLM (RubyLLM.config.max_retries); anything
67
+ # that still fails is surfaced as an LLMError, which moves on to the next attempt of the
68
+ # escalation (or propagates from the last one). A 400 means the request we built is invalid (an
69
+ # unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
70
+ # fallback would otherwise hide it on every call.
71
+ def ask(chat, message, step)
72
+ request(step) { chat.ask(message) }
73
+ end
74
+
75
+ # A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
76
+ # RubyLLM raises ArgumentError for a judgment it can't build (e.g. a model on a provider that takes none, or
77
+ # a provider option its protocol reserves), and a bare RubyLLM::Error without an HTTP response for a provider
78
+ # that doesn't support judgments at all. An ArgumentError from anywhere else (an instrumentation subscriber
79
+ # runs inside the call) is the developer's own and propagates as-is.
80
+ def judge(input, questions:, step:)
81
+ request(step) do
82
+ RubyLLM.judge(input, questions:, provider_options: step.params, **model_options(step))
83
+ rescue RubyLLM::ModelNotFoundError => e
84
+ raise ConfigurationError, "#{@label}: #{e.message}"
85
+ rescue ArgumentError => e
86
+ raise unless raised_by_ruby_llm?(e)
87
+
88
+ raise ConfigurationError, "#{@label}: #{e.message}"
89
+ rescue RubyLLM::Error => e
90
+ raise unless e.instance_of?(RubyLLM::Error) && e.response.nil?
91
+
92
+ raise ConfigurationError, "#{@label}: #{e.message}"
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def raised_by_ruby_llm?(error)
99
+ error.backtrace&.first&.start_with?("#{RUBY_LLM_LIB}/") || false
100
+ end
101
+
102
+ def build_chat(step)
103
+ RubyLLM.chat(**model_options(step))
104
+ rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
105
+ raise ConfigurationError, "#{@label}: #{e.message}"
106
+ end
107
+
108
+ # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
109
+ # and raises ArgumentError before any request is sent.
110
+ def apply_params(chat, params)
111
+ Params.apply(chat, params)
112
+ rescue ArgumentError => e
113
+ raise ConfigurationError, "#{@label}: invalid params #{params.inspect} (#{e.message})"
114
+ end
115
+
116
+ def request(step)
117
+ yield
118
+ rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
119
+ raise ConfigurationError, "#{@label}: #{e.class}: #{e.message}"
120
+ rescue RubyLLM::BadRequestError => e
121
+ raise ConfigurationError,
122
+ "#{@label}: the provider rejected the request (#{e.message})#{params_hint(step.params)}"
123
+ rescue RubyLLM::Error, Faraday::Error => e
124
+ raise LLMError, "#{@label}: #{e.class}: #{e.message}"
125
+ end
126
+
127
+ # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
128
+ # provider is named, so RubyLLM is told to assume they exist.
129
+ def model_options(step)
130
+ model = step.model
131
+ provider = step.provider
132
+ options = { model:, provider: }.compact
133
+ options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
134
+ options
135
+ end
136
+
137
+ def known_model?(model, provider)
138
+ RubyLLM.models.find(model, provider:)
139
+ true
140
+ rescue RubyLLM::ModelNotFoundError
141
+ false
142
+ end
143
+
144
+ def params_hint(params)
145
+ return "" if params.empty?
146
+
147
+ ". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
148
+ end
149
+ end
150
+ end