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