squishling 0.2.0 → 0.4.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +118 -31
  4. data/docs/configuration.md +75 -21
  5. data/docs/coolhand.md +66 -0
  6. data/docs/failures.md +73 -9
  7. data/docs/harnesses.md +326 -0
  8. data/docs/measuring-tokens.md +140 -0
  9. data/docs/naming.md +43 -0
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +36 -27
  12. data/docs/schemas.md +24 -5
  13. data/lib/squishling/appendices.rb +3 -3
  14. data/lib/squishling/class_methods.rb +60 -29
  15. data/lib/squishling/collisions.rb +54 -0
  16. data/lib/squishling/configuration.rb +32 -0
  17. data/lib/squishling/definition.rb +56 -27
  18. data/lib/squishling/errors.rb +22 -1
  19. data/lib/squishling/harness/base.rb +136 -0
  20. data/lib/squishling/harness/escalation.rb +22 -0
  21. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  22. data/lib/squishling/harness/option_types.rb +93 -0
  23. data/lib/squishling/harness/registry.rb +69 -0
  24. data/lib/squishling/harness/squishsum.rb +22 -0
  25. data/lib/squishling/harness.rb +79 -0
  26. data/lib/squishling/invoker.rb +26 -188
  27. data/lib/squishling/judge.rb +186 -0
  28. data/lib/squishling/llm_client.rb +180 -0
  29. data/lib/squishling/model_path.rb +82 -25
  30. data/lib/squishling/observer.rb +100 -0
  31. data/lib/squishling/observers/coolhand.rb +86 -0
  32. data/lib/squishling/output_check.rb +109 -0
  33. data/lib/squishling/params.rb +47 -6
  34. data/lib/squishling/rotation.rb +26 -0
  35. data/lib/squishling/router.rb +8 -2
  36. data/lib/squishling/run/messages.rb +42 -0
  37. data/lib/squishling/run.rb +296 -0
  38. data/lib/squishling/source.rb +5 -5
  39. data/lib/squishling/squawk.rb +52 -0
  40. data/lib/squishling/version.rb +1 -1
  41. data/lib/squishling.rb +56 -7
  42. metadata +25 -3
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # How a squished call uses its escalation. Declared as a type, or a Hash with the type and its options:
5
+ # harness: :squishsum
6
+ # harness: { type: :judged_squishsum, judge: "claude-opus-5-5", compare: ->(a, b, **) { a.team == b.team } }
7
+ # :escalation (the default) tries each attempt in order until an output passes. :squishsum asks the first
8
+ # escalation step twice, concurrently, and accepts the output only when both samples agree. :judged_squishsum
9
+ # hands disagreeing samples to a judge that picks one or rejects both. Your own types are registered with
10
+ # Squishling.register_harness (see Harness::Base).
11
+ class Harness
12
+ DEFAULT_JUDGE_INSTRUCTIONS = <<~TEXT.strip
13
+ You are an impartial judge. Two independent attempts at the same operation returned different outputs,
14
+ candidate "a" and candidate "b". Both already match the required output format, so judge only their
15
+ content. You are given the operation's purpose and its input. Decide which candidate correctly and
16
+ faithfully carries out the purpose for this input. If both do (for example, they differ only in
17
+ wording), choose either one, preferring "a". Choose "neither" only when both are wrong or you can't tell
18
+ whether either is correct. Never combine them or invent a third answer.
19
+ TEXT
20
+
21
+ # The type's validated options (frozen), and the Base subclass that runs it.
22
+ attr_reader :type, :klass, :options
23
+
24
+ class << self
25
+ def normalize(value, label)
26
+ case value
27
+ when Symbol, String then build(type_for(value, label), {}, label)
28
+ when Hash then from_hash(value, label)
29
+ else
30
+ raise ConfigurationError, "#{label}: harness must be one of #{types.join(', ')} or a Hash with type:, " \
31
+ "got #{value.inspect}"
32
+ end
33
+ end
34
+
35
+ # Every registered type: the built-ins, then your own in registration order.
36
+ def types
37
+ Registry.names
38
+ end
39
+
40
+ private
41
+
42
+ def from_hash(hash, label)
43
+ hash = hash.transform_keys(&:to_sym)
44
+ raise ConfigurationError, "#{label}: a harness Hash needs type:" unless hash.key?(:type)
45
+
46
+ build(type_for(hash[:type], label), hash.except(:type), label)
47
+ end
48
+
49
+ def build(type, options, label)
50
+ klass = Registry.fetch(type)
51
+ new(type:, klass:, options: klass.normalize_options(options, label))
52
+ end
53
+
54
+ def type_for(value, label)
55
+ type = value.to_s.to_sym if value.is_a?(Symbol) || value.is_a?(String)
56
+ return type if Registry.fetch(type)
57
+
58
+ raise ConfigurationError, "#{label}: unknown harness #{value.inspect} (use #{types.join(', ')})"
59
+ end
60
+ end
61
+
62
+ def initialize(type:, klass: Registry.fetch(type), options: {})
63
+ @type = type
64
+ @klass = klass
65
+ @options = options.freeze
66
+ freeze
67
+ end
68
+
69
+ DEFAULT = new(type: :escalation)
70
+
71
+ def squishsum?
72
+ %i[squishsum judged_squishsum].include?(type)
73
+ end
74
+
75
+ def judged?
76
+ type == :judged_squishsum
77
+ end
78
+ end
79
+ end
@@ -8,119 +8,45 @@ 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
+
11
18
  def initialize(definition, receiver, inputs)
12
19
  @definition = definition
13
20
  @receiver = receiver
14
21
  @inputs = inputs
15
22
  end
16
23
 
17
- # Makes each attempt of the escalation (Definition#escalation_path) until an output passes the schema and
18
- # the squish_validate check. Consecutive attempts on the same step continue the same conversation, so the
19
- # model sees what it got wrong; a new step starts a fresh chat told about the last rejected output.
24
+ # Runs the harness (Definition#harness) over a Run of the call: the harness decides how the escalation is
25
+ # used, and what it returns is checked against the output schema's type.
20
26
  def call
21
- @instructions = @definition.instructions(@receiver)
22
- @schema = @definition.schema
23
- raise ConfigurationError, "#{@definition.label} has no instructions" if @instructions.nil? || @instructions.empty?
24
- raise ConfigurationError, "#{@definition.label} has no output_schema" unless @schema
25
-
26
- path = @definition.escalation_path
27
- input = JSON.generate(payload)
28
- chat = nil
29
- rejected = nil # [raw content, errors] of the last invalid output
30
- models = []
31
-
32
- path.each.with_index(1) do |step, attempt|
33
- models << step.model
34
- last = attempt == path.size
35
- if chat && step == path[attempt - 2]
36
- message = retry_message(rejected.last)
37
- else
38
- chat = start_chat(step)
39
- message = rejected ? escalation_message(input, *rejected) : input
40
- end
41
-
42
- begin
43
- response = ask(chat, message, step)
44
- rescue LLMError => e
45
- raise if last
46
-
47
- # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
48
- chat = nil
49
- log_failure(attempt, path, e.message)
50
- next
51
- end
52
-
53
- result, errors = check(response.content)
54
- return result if errors.empty?
27
+ purpose = @definition.purpose(@receiver)
28
+ schema = @definition.schema
29
+ raise ConfigurationError, "#{@definition.label} has no purpose" if purpose.nil? || purpose.empty?
30
+ raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
55
31
 
56
- raise InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models:) if last
57
-
58
- rejected = [response.content, errors]
59
- log_failure(attempt, path, errors.join("; "))
60
- end
32
+ harness = @definition.harness
33
+ body = payload
34
+ prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:,
35
+ input: JSON.generate(body), role: nil)
36
+ run = Run.new(definition: @definition, receiver: @receiver, inputs: @inputs, prompt:, payload: body, purpose:,
37
+ harness:)
38
+ accepted(harness.klass.new.call(run, **harness.options), run, harness)
61
39
  end
62
40
 
63
41
  private
64
42
 
65
- def start_chat(step)
66
- chat = build_chat(step)
67
- chat.with_instructions("#{@instructions}\n\n#{INPUT_NOTE}")
68
- chat.with_schema(@schema.llm_schema)
69
- apply_params(chat, step.params)
70
- chat
71
- end
72
-
73
- def build_chat(step)
74
- RubyLLM.chat(**chat_options(step))
75
- rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
76
- raise ConfigurationError, "#{@definition.label}: #{e.message}"
77
- end
43
+ # What a harness returns must be a result its run produced: validated output, typed like the deterministic
44
+ # path's. One built by hand, or a hash, would skip the schema and squish_validate.
45
+ def accepted(value, run, harness)
46
+ return value if run.produced?(value)
78
47
 
79
- # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
80
- # and raises ArgumentError before any request is sent.
81
- def apply_params(chat, params)
82
- Params.apply(chat, params)
83
- rescue ArgumentError => e
84
- raise ConfigurationError, "#{@definition.label}: invalid params #{params.inspect} (#{e.message})"
85
- end
86
-
87
- # Transient HTTP failures are already retried by RubyLLM (RubyLLM.config.max_retries); anything
88
- # that still fails is surfaced as an LLMError, which moves on to the next attempt of the
89
- # escalation (or propagates from the last one). A 400 means the request we built is invalid (an
90
- # unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
91
- # fallback would otherwise hide it on every call.
92
- def ask(chat, message, step)
93
- chat.ask(message)
94
- rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
95
- raise ConfigurationError, "#{@definition.label}: #{e.class}: #{e.message}"
96
- rescue RubyLLM::BadRequestError => e
97
- raise ConfigurationError,
98
- "#{@definition.label}: the provider rejected the request (#{e.message})#{params_hint(step.params)}"
99
- rescue RubyLLM::Error, Faraday::Error => e
100
- raise LLMError, "#{@definition.label}: #{e.class}: #{e.message}"
101
- end
102
-
103
- # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
104
- # provider is named, so RubyLLM is told to assume they exist.
105
- def chat_options(step)
106
- model = step.model
107
- provider = step.provider
108
- options = { model:, provider: }.compact
109
- options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
110
- options
111
- end
112
-
113
- def known_model?(model, provider)
114
- RubyLLM.models.find(model, provider:)
115
- true
116
- rescue RubyLLM::ModelNotFoundError
117
- false
118
- end
119
-
120
- def params_hint(params)
121
- return "" if params.empty?
122
-
123
- ". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
48
+ raise ConfigurationError, "#{@definition.label}: the #{harness.type} harness must return a result from " \
49
+ "its run (escalate, samples, or a judge's pick), got #{value.class}"
124
50
  end
125
51
 
126
52
  # Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
@@ -153,93 +79,5 @@ module Squishling
153
79
 
154
80
  @receiver.instance_variable_get(:"@#{name}")
155
81
  end
156
-
157
- # RubyLLM parses structured output itself and leaves the raw string when that fails, so
158
- # content is a Hash on success, or a String/nil when the model refused, was cut off, or
159
- # ignored the schema.
160
- def parse(content)
161
- return [nil, ["response was empty"]] if content.nil? || (content.is_a?(String) && content.strip.empty?)
162
- return [content, []] unless content.is_a?(String)
163
-
164
- [JSON.parse(strip_code_fence(content)), []]
165
- rescue JSON::ParserError => e
166
- [nil, ["response was not valid JSON (#{e.message.lines.first&.strip})"]]
167
- end
168
-
169
- # Models without native structured output sometimes wrap JSON in a markdown code fence.
170
- def strip_code_fence(text)
171
- text[/\A\s*```(?:json)?\s*\n(.*?)\n\s*```\s*\z/m, 1] || text
172
- end
173
-
174
- # Parses and validates a response: [typed result, []] when it passes, [nil or result, errors] otherwise.
175
- def check(content)
176
- data, errors = parse(content)
177
- errors = @schema.validate(data) if errors.empty?
178
- return [nil, errors] if errors.any?
179
-
180
- result = @schema.build(data, squished: true)
181
- [result, validator_errors(result)]
182
- end
183
-
184
- # Runs the squish_validate check, if any. Exceptions it raises are the user's own and propagate as-is.
185
- def validator_errors(result)
186
- validator = @definition.validator
187
- return [] unless validator
188
-
189
- value = @receiver.instance_exec(result, **@inputs, &validator)
190
- case value
191
- when nil, true then []
192
- when false then ["the output was rejected by squish_validate"]
193
- when String, Array then Array(value).flatten.compact.map(&:to_s).reject { |message| message.strip.empty? }
194
- else
195
- return contract_errors(value) if value.respond_to?(:success?) && value.respond_to?(:errors)
196
-
197
- raise ConfigurationError, "#{@definition.label}: squish_validate must return nil, true, false, a String, " \
198
- "an Array of Strings, or a validation result, got #{value.class}"
199
- end
200
- end
201
-
202
- # A dry-validation style result: errors.to_h is { key => ["message", ...] }, nested for nested keys.
203
- def contract_errors(value)
204
- return [] if value.success?
205
-
206
- errors = value.errors
207
- errors = errors.to_h if !errors.is_a?(Array) && errors.respond_to?(:to_h)
208
- messages = errors.is_a?(Hash) ? flatten_messages(errors) : Array(errors).map(&:to_s)
209
- messages.empty? ? ["the output was rejected by squish_validate"] : messages
210
- end
211
-
212
- def flatten_messages(errors, prefix = nil)
213
- errors.flat_map do |key, value|
214
- path = [prefix, key].compact.join(".")
215
- next flatten_messages(value, path) if value.is_a?(Hash)
216
-
217
- Array(value).map { |message| path.empty? ? message.to_s : "#{path} #{message}" }
218
- end
219
- end
220
-
221
- def retry_message(errors)
222
- "Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
223
- end
224
-
225
- # The first message to a fresh chat after an earlier model's output was rejected.
226
- def escalation_message(input, raw, errors)
227
- previous = raw.is_a?(String) ? raw : JSON.generate(raw)
228
- previous = "(an empty response)" if previous.strip.empty? || raw.nil?
229
- "#{input}\n\nA previous attempt at this request returned:\n#{previous}\n" \
230
- "It was rejected:\n- #{errors.join("\n- ")}\nRespond with corrected JSON only."
231
- end
232
-
233
- def log_failure(attempt, path, reason)
234
- step = path[attempt - 1]
235
- next_step = path[attempt]
236
- action = next_step == step ? "retrying" : "escalating to #{model_name(next_step)}"
237
- Squishling.config.logger&.warn("[Squishling] #{@definition.label} attempt #{attempt} of #{path.size} " \
238
- "(#{model_name(step)}) failed, #{action}: #{reason}")
239
- end
240
-
241
- def model_name(step)
242
- step.model || "RubyLLM default model"
243
- end
244
82
  end
245
83
  end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Picks between two disagreeing samples, or rejects both (see Run#judge). The judge is the given judge:
5
+ # step, or else the escalation's next stage after the first. A chat judge answers with a strict verdict
6
+ # schema; a :judgment judge (a System One decision model such as Jev) answers one choice question through
7
+ # RubyLLM.judge, and its pick counts only at or above min_confidence.
8
+ class Judge
9
+ # The judge kinds: :chat (a chat model answering VERDICT_SCHEMA) and :judgment (a System One decision model
10
+ # through RubyLLM.judge). A judge is an escalation step (see ModelPath) plus these keys: type: (default :chat)
11
+ # and, for :judgment, min_confidence: (the probability the chosen candidate needs).
12
+ TYPES = %i[chat judgment].freeze
13
+ STEP_KEYS = %i[type min_confidence].freeze
14
+ DEFAULT_MIN_CONFIDENCE = 0.8
15
+
16
+ VERDICT_SCHEMA = {
17
+ "type" => "object",
18
+ "properties" => {
19
+ "verdict" => { "type" => "string", "enum" => %w[a b neither] },
20
+ "reason" => { "type" => "string" }
21
+ },
22
+ "required" => %w[verdict reason],
23
+ "additionalProperties" => false
24
+ }.freeze
25
+
26
+ INPUT_NOTE = <<~NOTE
27
+ The input is a JSON object. "input" is what the operation received ("arguments" and, when present,
28
+ "context"); "output_schema" is the format both candidates follow; "candidates" holds "a" and "b".
29
+ Respond only with JSON matching the required schema: your verdict ("a", "b", or "neither") and a short reason.
30
+ NOTE
31
+
32
+ CHOICES = {
33
+ a: "Candidate a correctly carries out the purpose for this input (also when both do)",
34
+ b: "Candidate b correctly carries out the purpose for this input",
35
+ neither: "Neither candidate is clearly correct"
36
+ }.freeze
37
+
38
+ # What a judge decided: :a, :b, or :neither, with its reason. The detail is text Squishling wrote (nil for a
39
+ # chat judge, whose reason is model-written) and is safe for error messages.
40
+ Verdict = Data.define(:choice, :reason, :detail)
41
+
42
+ # judge: is a normalized judge step (see Harness::JudgedSquishsum) or nil for the escalation's next stage,
43
+ # instructions: a String, a Proc evaluated against the receiver, or nil for the default prompt.
44
+ def initialize(definition:, receiver:, client:, judge:, instructions:, path:, purpose:, payload:, escalate:,
45
+ log_failure:, guard:, harness_type:)
46
+ @definition = definition
47
+ @receiver = receiver
48
+ @client = client
49
+ @judge = judge
50
+ @instructions = instructions
51
+ check!
52
+ @guard = guard
53
+ @harness_type = harness_type
54
+ @purpose = purpose
55
+ @payload = payload
56
+ @escalate = escalate
57
+ @log_failure = log_failure
58
+ @steps = resolve_steps(path)
59
+ end
60
+
61
+ # The judge's first attempt, for logs and DisagreementError#models.
62
+ def step
63
+ @steps.first
64
+ end
65
+
66
+ # The Verdict for the two candidates: :a or :b for the chosen one, or :neither.
67
+ def verdict(first, second)
68
+ @guard.call
69
+ candidates = { a: Schema.jsonify(first.to_h), b: Schema.jsonify(second.to_h) }
70
+ judgment? ? judgment(candidates) : chat(candidates)
71
+ end
72
+
73
+ private
74
+
75
+ # What Run#judge was given. A harness declaration is checked earlier, with its own messages.
76
+ def check!
77
+ label = "#{@definition.label}: judge"
78
+ if @judge
79
+ type = @judge.fetch(:type)
80
+ unless TYPES.include?(type)
81
+ raise ConfigurationError, "#{label} type: must be one of #{TYPES.join(', ')}, got #{type.inspect}"
82
+ end
83
+
84
+ if type == :chat && @judge.key?(:min_confidence)
85
+ raise ConfigurationError, "#{label} min_confidence: only applies to type: :judgment judges"
86
+ end
87
+
88
+ minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
89
+ unless minimum.is_a?(Numeric) && minimum.between?(0, 1)
90
+ raise ConfigurationError, "#{label} min_confidence: must be a number from 0 to 1, got #{minimum.inspect}"
91
+ end
92
+ end
93
+ return if @instructions.nil? || @instructions.is_a?(Proc) || non_blank_string?(@instructions)
94
+
95
+ raise ConfigurationError, "#{label} instructions must be a non-blank String or a Proc"
96
+ end
97
+
98
+ def non_blank_string?(value)
99
+ value.is_a?(String) && !value.strip.empty?
100
+ end
101
+
102
+ def judgment?
103
+ @judge&.fetch(:type) == :judgment
104
+ end
105
+
106
+ # A declared judge brings its own model and provider. A chat judge gets the method's generation params
107
+ # under its own, like any escalation step; a judgment judge sends only its own params, as provider options.
108
+ def resolve_steps(path)
109
+ if (judge = @judge)
110
+ params = judge[:type] == :judgment ? {} : @definition.params
111
+ return ModelPath.steps([judge], provider: nil, params:)
112
+ end
113
+
114
+ rest = path.drop_while { |step| step == path.first }
115
+ if rest.empty?
116
+ raise ConfigurationError, "#{@definition.label}: the #{@harness_type} harness needs a judge: or a second " \
117
+ "escalation step to judge with"
118
+ end
119
+
120
+ rest.take_while { |step| step == rest.first }
121
+ end
122
+
123
+ def chat(candidates)
124
+ prompt = Invoker::Prompt.new(
125
+ instructions: "#{judge_instructions}\n\nThe operation's purpose:\n<purpose>\n#{@purpose}\n" \
126
+ "</purpose>\n\n#{INPUT_NOTE}",
127
+ schema: Schema.for(VERDICT_SCHEMA), input: JSON.generate(state(candidates)), role: :judge
128
+ )
129
+ result = @escalate.call(@steps, prompt)
130
+ Verdict.new(choice: result[:verdict].to_sym, reason: result[:reason], detail: nil)
131
+ end
132
+
133
+ def judgment(candidates)
134
+ input = { purpose: @purpose, **state(candidates) }
135
+ questions = { winner: { type: :choice, instructions: judge_instructions, options: CHOICES } }
136
+ answer = judge_with_retries(input, questions)[:winner]
137
+ unless answer.respond_to?(:choice) && answer.respond_to?(:probabilities)
138
+ raise InvalidOutputError.new(errors: ["the judgment didn't answer the winner question"], source: "Judge")
139
+ end
140
+
141
+ interpret(answer.choice.to_s.to_sym, answer.probabilities)
142
+ end
143
+
144
+ # The threshold is checked on the exact probability; it's rounded only for the reason text.
145
+ def interpret(choice, probabilities)
146
+ probability = probabilities.to_h.find { |key, _| key.to_s == choice.to_s }&.last.to_f
147
+ minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
148
+ shown = probability.round(3)
149
+ picked = %i[a b].include?(choice)
150
+ if picked && probability >= minimum
151
+ text = "chosen with probability #{shown}"
152
+ return Verdict.new(choice:, reason: text, detail: text)
153
+ end
154
+
155
+ text =
156
+ if choice == :neither then "the judge chose neither (probability #{shown})"
157
+ elsif picked then "the judge chose #{choice} with probability #{shown}, below min_confidence #{minimum}"
158
+ else "the judge returned an unrecognized choice (probability #{shown})"
159
+ end
160
+ Verdict.new(choice: :neither, reason: text, detail: text)
161
+ end
162
+
163
+ # A judgment always matches its questions, so only a failed request (LLMError) moves on to the next attempt.
164
+ def judge_with_retries(input, questions)
165
+ @steps.each.with_index(1) do |step, attempt|
166
+ return @client.observed_judgment(input, questions:, step:, attempt:, attempts: @steps.size)
167
+ rescue LLMError => e
168
+ raise if attempt == @steps.size
169
+
170
+ @log_failure.call(:judge, attempt, @steps, e.message)
171
+ end
172
+ end
173
+
174
+ def state(candidates)
175
+ { input: @payload, output_schema: @definition.schema.llm_schema["schema"], candidates: }
176
+ end
177
+
178
+ def judge_instructions
179
+ value = @instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
180
+ value = @receiver.instance_exec(&value) if value.is_a?(Proc)
181
+ return value if value.is_a?(String) && !value.strip.empty?
182
+
183
+ raise ConfigurationError, "#{@definition.label}: the judge_instructions proc must return a non-blank String"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,180 @@
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
+ # purpose, harness (the harness type), and handed_off (squish! sent the call here) are only told to the observer.
54
+ def initialize(label, purpose:, harness:, handed_off:)
55
+ @label = label
56
+ @purpose = purpose
57
+ @harness = harness
58
+ @handed_off = handed_off
59
+ end
60
+
61
+ # A fresh chat on one escalation step, with the system prompt, the strict output schema, and its params.
62
+ def chat(step, instructions:, schema:)
63
+ chat = build_chat(step)
64
+ chat.with_instructions(instructions)
65
+ chat.with_schema(schema)
66
+ apply_params(chat, step.params)
67
+ chat
68
+ end
69
+
70
+ # Transient HTTP failures are already retried by RubyLLM (RubyLLM.config.max_retries); anything
71
+ # that still fails is surfaced as an LLMError, which moves on to the next attempt of the
72
+ # escalation (or propagates from the last one). A 400 means the request we built is invalid (an
73
+ # unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
74
+ # fallback would otherwise hide it on every call.
75
+ # The request runs inside the configured observer (see Observer), on this thread: for samples, the worker's.
76
+ # role is nil for an escalation attempt, :sample (with its name) or :judge; attempt counts in attempts.
77
+ def ask(chat, message, step, attempt:, attempts:, role: nil, sample: nil)
78
+ call = call_info(step, :chat, role:, sample:, attempt:, attempts:)
79
+ Observer.around(call) { request(step) { chat.ask(message) } }
80
+ end
81
+
82
+ # A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
83
+ # RubyLLM raises ArgumentError for a judgment it can't build (e.g. a model on a provider that takes none, or
84
+ # a provider option its protocol reserves), and a bare RubyLLM::Error without an HTTP response for a provider
85
+ # that doesn't support judgments at all. An ArgumentError from anywhere else (an instrumentation subscriber
86
+ # runs inside the call) is the developer's own and propagates as-is.
87
+ def judge(input, questions:, step:)
88
+ request(step) do
89
+ RubyLLM.judge(input, questions:, provider_options: step.params, **model_options(step))
90
+ rescue RubyLLM::ModelNotFoundError => e
91
+ raise ConfigurationError, "#{@label}: #{e.message}"
92
+ rescue ArgumentError => e
93
+ raise unless raised_by_ruby_llm?(e)
94
+
95
+ raise ConfigurationError, "#{@label}: #{e.message}"
96
+ rescue RubyLLM::Error => e
97
+ raise unless e.instance_of?(RubyLLM::Error) && e.response.nil?
98
+
99
+ raise ConfigurationError, "#{@label}: #{e.message}"
100
+ end
101
+ end
102
+
103
+ # #judge inside the configured observer (see Observer), attempt counting in attempts.
104
+ def observed_judgment(input, questions:, step:, attempt:, attempts:)
105
+ call = call_info(step, :judgment, role: :judge, attempt:, attempts:)
106
+ Observer.around(call) { judge(input, questions:, step:) }
107
+ end
108
+
109
+ private
110
+
111
+ def call_info(step, request, attempt:, attempts:, role: nil, sample: nil)
112
+ Observer::Call.new(label: @label, purpose: @purpose, harness: @harness, handed_off: @handed_off, role:, sample:,
113
+ attempt:, attempts:, model: step.model, provider: step.provider, request:)
114
+ end
115
+
116
+ def raised_by_ruby_llm?(error)
117
+ error.backtrace&.first&.start_with?("#{RUBY_LLM_LIB}/") || false
118
+ end
119
+
120
+ def build_chat(step)
121
+ RubyLLM.chat(**model_options(step))
122
+ rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
123
+ raise ConfigurationError, "#{@label}: #{e.message}"
124
+ end
125
+
126
+ # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
127
+ # and raises ArgumentError before any request is sent.
128
+ def apply_params(chat, params)
129
+ Params.apply(chat, params)
130
+ rescue ArgumentError => e
131
+ raise ConfigurationError, "#{@label}: invalid params #{params.inspect} (#{e.message})"
132
+ end
133
+
134
+ # A provider's or transport's own message never goes into ours: a provider can echo request content or part of
135
+ # an API key in it, and ours reach logs and error trackers. The original stays as #cause for whoever
136
+ # chooses to read it. RubyLLM's own ConfigurationError is local (it names a missing setting), so it is kept.
137
+ def request(step)
138
+ yield
139
+ rescue RubyLLM::ConfigurationError => e
140
+ raise ConfigurationError, "#{@label}: #{e.class}: #{e.message}"
141
+ rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
142
+ raise ConfigurationError, "#{@label}: #{describe_failure(e)}"
143
+ rescue RubyLLM::BadRequestError => e
144
+ raise ConfigurationError,
145
+ "#{@label}: the provider rejected the request (#{describe_failure(e)})#{params_hint(step.params)}"
146
+ rescue RubyLLM::Error, Faraday::Error => e
147
+ raise LLMError, "#{@label}: #{describe_failure(e)}"
148
+ end
149
+
150
+ # The error's class and HTTP status, when it has one, which is all of it that is ours to repeat.
151
+ def describe_failure(error)
152
+ response = error.response
153
+ status = response.is_a?(Hash) ? response[:status] : (response.status if response.respond_to?(:status))
154
+ status ? "#{error.class}, HTTP #{status}" : error.class.to_s
155
+ end
156
+
157
+ # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
158
+ # provider is named, so RubyLLM is told to assume they exist.
159
+ def model_options(step)
160
+ model = step.model
161
+ provider = step.provider
162
+ options = { model:, provider: }.compact
163
+ options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
164
+ options
165
+ end
166
+
167
+ def known_model?(model, provider)
168
+ RubyLLM.models.find(model, provider:)
169
+ true
170
+ rescue RubyLLM::ModelNotFoundError
171
+ false
172
+ end
173
+
174
+ def params_hint(params)
175
+ return "" if params.empty?
176
+
177
+ ". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
178
+ end
179
+ end
180
+ end