squishling 0.3.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 +58 -1
- data/README.md +38 -6
- data/docs/configuration.md +36 -6
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +9 -5
- data/docs/harnesses.md +170 -42
- data/docs/measuring-tokens.md +29 -37
- data/docs/observers.md +80 -0
- data/docs/routing.md +2 -2
- data/docs/schemas.md +22 -4
- data/lib/squishling/configuration.rb +15 -2
- data/lib/squishling/definition.rb +9 -5
- data/lib/squishling/errors.rb +5 -5
- 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 +25 -99
- data/lib/squishling/invoker.rb +11 -228
- data/lib/squishling/judge.rb +66 -22
- data/lib/squishling/llm_client.rb +36 -6
- data/lib/squishling/model_path.rb +64 -21
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/params.rb +10 -5
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +32 -0
- metadata +14 -1
data/lib/squishling/invoker.rb
CHANGED
|
@@ -15,18 +15,14 @@ module Squishling
|
|
|
15
15
|
# role (:judge) skips squish_validate and names the judge in errors and logs.
|
|
16
16
|
Prompt = Data.define(:instructions, :schema, :input, :role)
|
|
17
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
|
-
|
|
21
18
|
def initialize(definition, receiver, inputs)
|
|
22
19
|
@definition = definition
|
|
23
20
|
@receiver = receiver
|
|
24
21
|
@inputs = inputs
|
|
25
|
-
@client = LLMClient.new(definition.label)
|
|
26
|
-
@output_check = OutputCheck.new(definition, receiver, inputs)
|
|
27
22
|
end
|
|
28
23
|
|
|
29
|
-
# Runs the harness (Definition#harness) over the escalation
|
|
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.
|
|
30
26
|
def call
|
|
31
27
|
purpose = @definition.purpose(@receiver)
|
|
32
28
|
schema = @definition.schema
|
|
@@ -34,173 +30,23 @@ module Squishling
|
|
|
34
30
|
raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
|
|
35
31
|
|
|
36
32
|
harness = @definition.harness
|
|
37
|
-
path = @definition.escalation_path
|
|
38
33
|
body = payload
|
|
39
34
|
prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:,
|
|
40
35
|
input: JSON.generate(body), role: nil)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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)
|
|
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)
|
|
50
39
|
end
|
|
51
40
|
|
|
52
41
|
private
|
|
53
42
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
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)]
|
|
67
|
-
end
|
|
68
|
-
|
|
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
|
|
114
|
-
end
|
|
115
|
-
|
|
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)
|
|
139
|
-
end
|
|
140
|
-
|
|
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
|
|
147
|
-
end
|
|
148
|
-
|
|
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
|
|
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)
|
|
171
47
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
end
|
|
175
|
-
|
|
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
|
|
190
|
-
|
|
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)
|
|
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}"
|
|
204
50
|
end
|
|
205
51
|
|
|
206
52
|
# Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
|
|
@@ -233,68 +79,5 @@ module Squishling
|
|
|
233
79
|
|
|
234
80
|
@receiver.instance_variable_get(:"@#{name}")
|
|
235
81
|
end
|
|
236
|
-
|
|
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
|
|
253
|
-
|
|
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)"
|
|
262
|
-
end
|
|
263
|
-
|
|
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
|
|
267
|
-
end
|
|
268
|
-
|
|
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}")
|
|
298
|
-
end
|
|
299
82
|
end
|
|
300
83
|
end
|
data/lib/squishling/judge.rb
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
# Picks between
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# min_confidence.
|
|
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.
|
|
9
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
|
+
|
|
10
16
|
VERDICT_SCHEMA = {
|
|
11
17
|
"type" => "object",
|
|
12
18
|
"properties" => {
|
|
@@ -29,16 +35,27 @@ module Squishling
|
|
|
29
35
|
neither: "Neither candidate is clearly correct"
|
|
30
36
|
}.freeze
|
|
31
37
|
|
|
32
|
-
|
|
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:)
|
|
33
46
|
@definition = definition
|
|
34
47
|
@receiver = receiver
|
|
35
48
|
@client = client
|
|
36
|
-
@
|
|
49
|
+
@judge = judge
|
|
50
|
+
@instructions = instructions
|
|
51
|
+
check!
|
|
52
|
+
@guard = guard
|
|
53
|
+
@harness_type = harness_type
|
|
37
54
|
@purpose = purpose
|
|
38
55
|
@payload = payload
|
|
39
56
|
@escalate = escalate
|
|
40
57
|
@log_failure = log_failure
|
|
41
|
-
@steps = resolve_steps(
|
|
58
|
+
@steps = resolve_steps(path)
|
|
42
59
|
end
|
|
43
60
|
|
|
44
61
|
# The judge's first attempt, for logs and DisagreementError#models.
|
|
@@ -46,31 +63,58 @@ module Squishling
|
|
|
46
63
|
@steps.first
|
|
47
64
|
end
|
|
48
65
|
|
|
49
|
-
#
|
|
50
|
-
# Squishling wrote (nil for a chat judge, whose reason is model-written) and is safe for error messages.
|
|
66
|
+
# The Verdict for the two candidates: :a or :b for the chosen one, or :neither.
|
|
51
67
|
def verdict(first, second)
|
|
68
|
+
@guard.call
|
|
52
69
|
candidates = { a: Schema.jsonify(first.to_h), b: Schema.jsonify(second.to_h) }
|
|
53
70
|
judgment? ? judgment(candidates) : chat(candidates)
|
|
54
71
|
end
|
|
55
72
|
|
|
56
73
|
private
|
|
57
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
|
+
|
|
58
102
|
def judgment?
|
|
59
|
-
@
|
|
103
|
+
@judge&.fetch(:type) == :judgment
|
|
60
104
|
end
|
|
61
105
|
|
|
62
106
|
# A declared judge brings its own model and provider. A chat judge gets the method's generation params
|
|
63
107
|
# under its own, like any escalation step; a judgment judge sends only its own params, as provider options.
|
|
64
|
-
def resolve_steps(
|
|
65
|
-
if (judge = @
|
|
108
|
+
def resolve_steps(path)
|
|
109
|
+
if (judge = @judge)
|
|
66
110
|
params = judge[:type] == :judgment ? {} : @definition.params
|
|
67
111
|
return ModelPath.steps([judge], provider: nil, params:)
|
|
68
112
|
end
|
|
69
113
|
|
|
114
|
+
rest = path.drop_while { |step| step == path.first }
|
|
70
115
|
if rest.empty?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"#{ordinal} escalation step to judge with"
|
|
116
|
+
raise ConfigurationError, "#{@definition.label}: the #{@harness_type} harness needs a judge: or a second " \
|
|
117
|
+
"escalation step to judge with"
|
|
74
118
|
end
|
|
75
119
|
|
|
76
120
|
rest.take_while { |step| step == rest.first }
|
|
@@ -83,7 +127,7 @@ module Squishling
|
|
|
83
127
|
schema: Schema.for(VERDICT_SCHEMA), input: JSON.generate(state(candidates)), role: :judge
|
|
84
128
|
)
|
|
85
129
|
result = @escalate.call(@steps, prompt)
|
|
86
|
-
|
|
130
|
+
Verdict.new(choice: result[:verdict].to_sym, reason: result[:reason], detail: nil)
|
|
87
131
|
end
|
|
88
132
|
|
|
89
133
|
def judgment(candidates)
|
|
@@ -100,12 +144,12 @@ module Squishling
|
|
|
100
144
|
# The threshold is checked on the exact probability; it's rounded only for the reason text.
|
|
101
145
|
def interpret(choice, probabilities)
|
|
102
146
|
probability = probabilities.to_h.find { |key, _| key.to_s == choice.to_s }&.last.to_f
|
|
103
|
-
minimum = @
|
|
147
|
+
minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
|
|
104
148
|
shown = probability.round(3)
|
|
105
149
|
picked = %i[a b].include?(choice)
|
|
106
150
|
if picked && probability >= minimum
|
|
107
151
|
text = "chosen with probability #{shown}"
|
|
108
|
-
return
|
|
152
|
+
return Verdict.new(choice:, reason: text, detail: text)
|
|
109
153
|
end
|
|
110
154
|
|
|
111
155
|
text =
|
|
@@ -113,13 +157,13 @@ module Squishling
|
|
|
113
157
|
elsif picked then "the judge chose #{choice} with probability #{shown}, below min_confidence #{minimum}"
|
|
114
158
|
else "the judge returned an unrecognized choice (probability #{shown})"
|
|
115
159
|
end
|
|
116
|
-
|
|
160
|
+
Verdict.new(choice: :neither, reason: text, detail: text)
|
|
117
161
|
end
|
|
118
162
|
|
|
119
163
|
# A judgment always matches its questions, so only a failed request (LLMError) moves on to the next attempt.
|
|
120
164
|
def judge_with_retries(input, questions)
|
|
121
165
|
@steps.each.with_index(1) do |step, attempt|
|
|
122
|
-
return @client.
|
|
166
|
+
return @client.observed_judgment(input, questions:, step:, attempt:, attempts: @steps.size)
|
|
123
167
|
rescue LLMError => e
|
|
124
168
|
raise if attempt == @steps.size
|
|
125
169
|
|
|
@@ -132,7 +176,7 @@ module Squishling
|
|
|
132
176
|
end
|
|
133
177
|
|
|
134
178
|
def judge_instructions
|
|
135
|
-
value = @
|
|
179
|
+
value = @instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
|
|
136
180
|
value = @receiver.instance_exec(&value) if value.is_a?(Proc)
|
|
137
181
|
return value if value.is_a?(String) && !value.strip.empty?
|
|
138
182
|
|
|
@@ -50,8 +50,12 @@ module Squishling
|
|
|
50
50
|
|
|
51
51
|
private_class_method :capture, :instrumentation_context, :with_instrumentation_context
|
|
52
52
|
|
|
53
|
-
|
|
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:)
|
|
54
55
|
@label = label
|
|
56
|
+
@purpose = purpose
|
|
57
|
+
@harness = harness
|
|
58
|
+
@handed_off = handed_off
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
# A fresh chat on one escalation step, with the system prompt, the strict output schema, and its params.
|
|
@@ -68,8 +72,11 @@ module Squishling
|
|
|
68
72
|
# escalation (or propagates from the last one). A 400 means the request we built is invalid (an
|
|
69
73
|
# unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
|
|
70
74
|
# fallback would otherwise hide it on every call.
|
|
71
|
-
|
|
72
|
-
|
|
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) } }
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
# A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
|
|
@@ -93,8 +100,19 @@ module Squishling
|
|
|
93
100
|
end
|
|
94
101
|
end
|
|
95
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
|
+
|
|
96
109
|
private
|
|
97
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
|
+
|
|
98
116
|
def raised_by_ruby_llm?(error)
|
|
99
117
|
error.backtrace&.first&.start_with?("#{RUBY_LLM_LIB}/") || false
|
|
100
118
|
end
|
|
@@ -113,15 +131,27 @@ module Squishling
|
|
|
113
131
|
raise ConfigurationError, "#{@label}: invalid params #{params.inspect} (#{e.message})"
|
|
114
132
|
end
|
|
115
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.
|
|
116
137
|
def request(step)
|
|
117
138
|
yield
|
|
118
|
-
rescue RubyLLM::ConfigurationError
|
|
139
|
+
rescue RubyLLM::ConfigurationError => e
|
|
119
140
|
raise ConfigurationError, "#{@label}: #{e.class}: #{e.message}"
|
|
141
|
+
rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
|
|
142
|
+
raise ConfigurationError, "#{@label}: #{describe_failure(e)}"
|
|
120
143
|
rescue RubyLLM::BadRequestError => e
|
|
121
144
|
raise ConfigurationError,
|
|
122
|
-
"#{@label}: the provider rejected the request (#{e
|
|
145
|
+
"#{@label}: the provider rejected the request (#{describe_failure(e)})#{params_hint(step.params)}"
|
|
123
146
|
rescue RubyLLM::Error, Faraday::Error => e
|
|
124
|
-
raise LLMError, "#{@label}: #{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
|
|
125
155
|
end
|
|
126
156
|
|
|
127
157
|
# Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
|
|
@@ -8,9 +8,11 @@ module Squishling
|
|
|
8
8
|
# "claude-sonnet-5-5",
|
|
9
9
|
# { model: "claude-opus-5-5", params: { thinking: { effort: :high } } }
|
|
10
10
|
# ]
|
|
11
|
-
# attempts: (default 1) retries a step;
|
|
12
|
-
#
|
|
13
|
-
# the
|
|
11
|
+
# attempts: (default 1) retries a step; priority: (all steps or none, lowest first) makes the order explicit
|
|
12
|
+
# instead of positional. Steps sharing a priority are one tier: each call rotates which of them goes first
|
|
13
|
+
# (see Rotation), and the others follow before the next tier, unless a step in the tier sets rotate: false.
|
|
14
|
+
# order: is the deprecated name of priority:. forward_rejected: false starts a step from the original input
|
|
15
|
+
# alone, without the previous step's rejected output.
|
|
14
16
|
module ModelPath
|
|
15
17
|
# One attempt: the model, its provider, the fully resolved generation params, and whether the previous
|
|
16
18
|
# step's rejected output is shown to it.
|
|
@@ -21,7 +23,7 @@ module Squishling
|
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
STEP_KEYS = %i[model provider params attempts order forward_rejected].freeze
|
|
26
|
+
STEP_KEYS = %i[model provider params attempts priority order rotate forward_rejected].freeze
|
|
25
27
|
|
|
26
28
|
module_function
|
|
27
29
|
|
|
@@ -57,43 +59,62 @@ module Squishling
|
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
# One Step per attempt. The level's provider applies to steps that don't name their own, and each
|
|
60
|
-
# step's params are merged over the level-resolved params.
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
# step's params are merged over the level-resolved params. turn is this call's place in the method's rotation
|
|
63
|
+
# (see Rotation): each tier of tied priorities starts turn steps further along.
|
|
64
|
+
def steps(path, provider:, params:, turn: 0)
|
|
65
|
+
rotated(path, turn).flat_map do |step|
|
|
63
66
|
attempt = Step.new(model: step[:model], provider: step[:provider] || provider,
|
|
64
67
|
params: Params.resolve(params, step[:params]), forward_rejected: step[:forward_rejected])
|
|
65
68
|
[attempt] * step[:attempts]
|
|
66
69
|
end
|
|
67
70
|
end
|
|
68
71
|
|
|
72
|
+
# Whether any tier of the path has several models to rotate between.
|
|
73
|
+
def rotating?(path)
|
|
74
|
+
tiers(path).any? { |tier| rotates?(tier) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Steps sorted by priority, lowest first, ties kept in the order declared.
|
|
69
78
|
def ordered(steps, label)
|
|
70
|
-
numbered = steps.count { |step| step.key?(:
|
|
71
|
-
return steps
|
|
79
|
+
numbered = steps.count { |step| step.key?(:priority) }
|
|
80
|
+
return steps if numbered.zero?
|
|
72
81
|
|
|
73
82
|
if numbered < steps.size
|
|
74
|
-
raise ConfigurationError,
|
|
83
|
+
raise ConfigurationError,
|
|
84
|
+
"#{label}: give every step a priority: or none (#{numbered} of #{steps.size} have one)"
|
|
75
85
|
end
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
steps.each_with_index.sort_by { |step, index| [step[:priority], index] }.map(&:first)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Runs of steps with the same priority. A step without one is a tier of its own.
|
|
91
|
+
def tiers(path)
|
|
92
|
+
path.slice_when { |before, after| before[:priority].nil? || before[:priority] != after[:priority] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rotates?(tier)
|
|
96
|
+
tier.size > 1 && tier.none? { |step| step[:rotate] == false }
|
|
97
|
+
end
|
|
80
98
|
|
|
81
|
-
|
|
99
|
+
def rotated(path, turn)
|
|
100
|
+
tiers(path).flat_map { |tier| rotates?(tier) ? tier.rotate(turn % tier.size) : tier }
|
|
82
101
|
end
|
|
83
102
|
|
|
84
|
-
|
|
103
|
+
# extras: names more keys a step Hash may carry for its caller (a judge's type:, say). They are kept as given,
|
|
104
|
+
# for the caller to check.
|
|
105
|
+
def normalize_step(step, label, extras: [])
|
|
85
106
|
case step
|
|
86
107
|
when String, Symbol
|
|
87
108
|
{ model: model_name(step, label), provider: nil, params: nil, attempts: 1, forward_rejected: true }
|
|
88
|
-
when Hash then normalize_hash(step, label)
|
|
109
|
+
when Hash then normalize_hash(step, label, extras)
|
|
89
110
|
else
|
|
90
111
|
raise ConfigurationError, "#{label}: each step must be a model name or a Hash with model:, got #{step.inspect}"
|
|
91
112
|
end
|
|
92
113
|
end
|
|
93
114
|
|
|
94
|
-
def normalize_hash(step, label)
|
|
115
|
+
def normalize_hash(step, label, extras)
|
|
95
116
|
step = step.transform_keys(&:to_sym)
|
|
96
|
-
unknown = step.keys - STEP_KEYS
|
|
117
|
+
unknown = step.keys - STEP_KEYS - extras
|
|
97
118
|
raise ConfigurationError, "#{label}: unknown step option(s) #{unknown.join(', ')}" if unknown.any?
|
|
98
119
|
unless step[:model].is_a?(String) || step[:model].is_a?(Symbol)
|
|
99
120
|
raise ConfigurationError, "#{label}: a step needs a model: name, got #{step.inspect}"
|
|
@@ -104,8 +125,13 @@ module Squishling
|
|
|
104
125
|
unless attempts.is_a?(Integer) && attempts.positive?
|
|
105
126
|
raise ConfigurationError, "#{label} (#{model}): attempts: must be a positive Integer, got #{attempts.inspect}"
|
|
106
127
|
end
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
|
|
129
|
+
step = priority_alias(step, label, model)
|
|
130
|
+
if step.key?(:priority) && !step[:priority].is_a?(Integer)
|
|
131
|
+
raise ConfigurationError, "#{label} (#{model}): priority: must be an Integer, got #{step[:priority].inspect}"
|
|
132
|
+
end
|
|
133
|
+
if step.key?(:rotate) && ![true, false].include?(step[:rotate])
|
|
134
|
+
raise ConfigurationError, "#{label} (#{model}): rotate: must be true or false, got #{step[:rotate].inspect}"
|
|
109
135
|
end
|
|
110
136
|
|
|
111
137
|
forward_rejected = step.fetch(:forward_rejected, true)
|
|
@@ -116,7 +142,24 @@ module Squishling
|
|
|
116
142
|
|
|
117
143
|
params = step[:params] && Params.normalize(step[:params], "#{label} (#{model}) params")
|
|
118
144
|
normalized = { model:, provider: step[:provider], params:, attempts:, forward_rejected: }
|
|
119
|
-
|
|
145
|
+
normalized.merge(step.slice(:priority, :rotate, *extras))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A step used on its own (a judge, a harness's step) can't take part in a tier, so it takes no priority: or
|
|
149
|
+
# rotate:. who names it for the message.
|
|
150
|
+
def single_step!(step, who)
|
|
151
|
+
return unless step.key?(:priority) || step.key?(:rotate)
|
|
152
|
+
|
|
153
|
+
raise ConfigurationError, "#{who} takes no priority: or rotate:"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# order: is the deprecated name of priority:.
|
|
157
|
+
def priority_alias(step, label, model)
|
|
158
|
+
return step unless step.key?(:order)
|
|
159
|
+
raise ConfigurationError, "#{label} (#{model}): give priority: or order:, not both" if step.key?(:priority)
|
|
160
|
+
|
|
161
|
+
warn "[Squishling] #{label} (#{model}): order: is deprecated, use priority:"
|
|
162
|
+
step.except(:order).merge(priority: step[:order])
|
|
120
163
|
end
|
|
121
164
|
|
|
122
165
|
def model_name(model, label)
|