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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +140 -0
- data/README.md +118 -31
- data/docs/configuration.md +75 -21
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +73 -9
- data/docs/harnesses.md +326 -0
- data/docs/measuring-tokens.md +140 -0
- data/docs/naming.md +43 -0
- data/docs/observers.md +80 -0
- data/docs/routing.md +36 -27
- data/docs/schemas.md +24 -5
- data/lib/squishling/appendices.rb +3 -3
- data/lib/squishling/class_methods.rb +60 -29
- data/lib/squishling/collisions.rb +54 -0
- data/lib/squishling/configuration.rb +32 -0
- data/lib/squishling/definition.rb +56 -27
- data/lib/squishling/errors.rb +22 -1
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +93 -0
- data/lib/squishling/harness/registry.rb +69 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +79 -0
- data/lib/squishling/invoker.rb +26 -188
- data/lib/squishling/judge.rb +186 -0
- data/lib/squishling/llm_client.rb +180 -0
- data/lib/squishling/model_path.rb +82 -25
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +109 -0
- data/lib/squishling/params.rb +47 -6
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/router.rb +8 -2
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/source.rb +5 -5
- data/lib/squishling/squawk.rb +52 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +56 -7
- 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
|
data/lib/squishling/invoker.rb
CHANGED
|
@@ -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
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
raise ConfigurationError, "#{@definition.label} has no
|
|
24
|
-
raise ConfigurationError, "#{@definition.label} has no output_schema" unless
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|