squishling 0.4.0 → 0.5.1
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 +149 -0
- data/README.md +26 -10
- data/docs/adapters.md +257 -0
- data/docs/configuration.md +54 -19
- data/docs/failures.md +170 -22
- data/docs/harnesses.md +39 -15
- data/docs/measuring-tokens.md +12 -6
- data/docs/observers.md +3 -3
- data/docs/routing.md +14 -3
- data/docs/schemas.md +24 -2
- data/docs/testing.md +187 -0
- data/docs/verifying.md +86 -0
- data/lib/squishling/adapter/error_body.rb +54 -0
- data/lib/squishling/adapter.rb +328 -0
- data/lib/squishling/adapters/openai_compatible.rb +311 -0
- data/lib/squishling/adapters/openai_options.rb +114 -0
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +229 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +58 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +94 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +44 -14
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +13 -4
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +7 -24
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +50 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +60 -53
- data/lib/squishling/schema.rb +50 -9
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +37 -4
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +74 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling/wrapper.rb +19 -1
- data/lib/squishling.rb +60 -4
- metadata +43 -21
data/lib/squishling/run.rb
CHANGED
|
@@ -13,11 +13,13 @@ module Squishling
|
|
|
13
13
|
# on other threads; parsing, validation, and every callback (squish_validate, compare:, procs) stay on this one.
|
|
14
14
|
class Run
|
|
15
15
|
include Messages
|
|
16
|
+
include Rejections
|
|
17
|
+
include Reports
|
|
16
18
|
|
|
17
|
-
private_constant :Messages
|
|
19
|
+
private_constant :Messages, :Rejections, :Reports
|
|
18
20
|
|
|
19
21
|
# One of #samples' outputs, between rounds.
|
|
20
|
-
Sample = Struct.new(:name, :stage, :chat, :rejected, :result)
|
|
22
|
+
Sample = Struct.new(:name, :stage, :chat, :rejected, :result, :reask)
|
|
21
23
|
|
|
22
24
|
SAMPLE_NAMES = ("a".."z").to_a.freeze
|
|
23
25
|
private_constant :Sample, :SAMPLE_NAMES
|
|
@@ -32,14 +34,17 @@ module Squishling
|
|
|
32
34
|
@payload = payload
|
|
33
35
|
@purpose = purpose
|
|
34
36
|
@harness = harness
|
|
35
|
-
@steps = definition.escalation_path.freeze
|
|
36
|
-
@client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off
|
|
37
|
+
@steps = definition.escalation_path(receiver, inputs).freeze
|
|
38
|
+
@client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off,
|
|
39
|
+
budget: definition.budget)
|
|
37
40
|
@output_check = OutputCheck.new(definition, receiver, inputs)
|
|
38
41
|
@owner = Thread.current
|
|
39
42
|
@produced = {}.compare_by_identity
|
|
40
43
|
end
|
|
41
44
|
|
|
42
|
-
# Whether value is a result one of this run's primitives returned (not one built by hand).
|
|
45
|
+
# Whether value is a result one of this run's primitives returned (not one built by hand). By identity, so it
|
|
46
|
+
# tells results that are objects (the typed Data of an object schema) apart; a schema that isn't an object
|
|
47
|
+
# returns the bare JSON value, and an immediate such as 1 or true is the same object in every run.
|
|
43
48
|
def produced?(value)
|
|
44
49
|
@produced.key?(value)
|
|
45
50
|
end
|
|
@@ -54,8 +59,9 @@ module Squishling
|
|
|
54
59
|
@stages ||= steps.slice_when { |before, after| before != after }.map(&:freeze).freeze
|
|
55
60
|
end
|
|
56
61
|
|
|
57
|
-
# A stage from a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected
|
|
58
|
-
# way a judge step is resolved: it keeps its own provider, and its params merge over
|
|
62
|
+
# A stage from a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:,
|
|
63
|
+
# retry_temperature:), the way a judge step is resolved: it keeps its own provider, and its params merge over
|
|
64
|
+
# the method's.
|
|
59
65
|
# A stage (an Array of steps) is returned as is, and a single step is a stage of one attempt.
|
|
60
66
|
def stage(declaration)
|
|
61
67
|
return checked_stage([declaration]) if declaration.is_a?(ModelPath::Step)
|
|
@@ -71,9 +77,11 @@ module Squishling
|
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
# Makes each attempt of the stages in order (the escalation's steps when none are given) until an output
|
|
74
|
-
# passes the schema and squish_validate.
|
|
80
|
+
# passes the schema and squish_validate. Raises BudgetExhaustedError instead of making an attempt once the
|
|
81
|
+
# call's token budget (see Budget) is spent. Consecutive attempts on the same step continue the same chat, so the
|
|
75
82
|
# model sees what it got wrong; a new step starts a fresh chat told about the last rejected output (unless
|
|
76
|
-
# the step sets forward_rejected: false).
|
|
83
|
+
# the step sets forward_rejected: false). A retry that repeats the rejected output it was just told about gives
|
|
84
|
+
# up on the rest of its step's attempts. Raises InvalidOutputError, or LLMError, when the last attempt fails.
|
|
77
85
|
def escalate(*stages)
|
|
78
86
|
owner!
|
|
79
87
|
path = stages.empty? ? steps : stages.flat_map { |declared| stage(declared) }
|
|
@@ -103,6 +111,8 @@ module Squishling
|
|
|
103
111
|
pending = samples.reject(&:result)
|
|
104
112
|
break if pending.empty?
|
|
105
113
|
|
|
114
|
+
check_budget!(attempts: attempt - 1, models: samples.first.stage.first(attempt - 1).map(&:model),
|
|
115
|
+
rejected: pending.filter_map(&:rejected).last)
|
|
106
116
|
round(pending, attempt)
|
|
107
117
|
end
|
|
108
118
|
samples.map(&:result)
|
|
@@ -119,7 +129,7 @@ module Squishling
|
|
|
119
129
|
judge = judge_step(judge) unless judge.nil?
|
|
120
130
|
Judge.new(definition: @definition, receiver: @receiver, client: @client, judge:, instructions:, path: steps,
|
|
121
131
|
purpose: @purpose, payload: @payload, escalate: method(:escalate_with), log_failure: method(:log_failure),
|
|
122
|
-
guard: method(:owner!), harness_type: @harness.type)
|
|
132
|
+
guard: method(:owner!), budget_check: method(:check_budget!), harness_type: @harness.type)
|
|
123
133
|
end
|
|
124
134
|
|
|
125
135
|
# Whether two outputs agree: compare (a Proc, evaluated against the receiver with the two results and the
|
|
@@ -167,42 +177,49 @@ module Squishling
|
|
|
167
177
|
chat = nil
|
|
168
178
|
rejected = nil # [raw content, errors] of the last invalid output
|
|
169
179
|
models = []
|
|
180
|
+
attempt = 1
|
|
170
181
|
|
|
171
|
-
|
|
182
|
+
while attempt <= path.size
|
|
183
|
+
check_budget!(attempts: models.size, models:, rejected:)
|
|
184
|
+
step = path[attempt - 1]
|
|
172
185
|
models << step.model
|
|
173
186
|
last = attempt == path.size
|
|
174
|
-
|
|
187
|
+
reask = !chat.nil? && step == path[attempt - 2]
|
|
188
|
+
if reask
|
|
175
189
|
message = retry_message(rejected.last)
|
|
176
190
|
else
|
|
177
191
|
chat = start_chat(step, prompt)
|
|
178
|
-
message =
|
|
192
|
+
message = fresh_message(step, prompt.input, rejected)
|
|
179
193
|
end
|
|
180
194
|
|
|
181
195
|
begin
|
|
182
|
-
response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role)
|
|
196
|
+
response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role, reask:)
|
|
183
197
|
rescue LLMError => e
|
|
184
|
-
squawk(prompt, path, attempt, nil, e)
|
|
198
|
+
squawk(prompt, path, attempt, nil, e, reask:)
|
|
185
199
|
raise if last
|
|
186
200
|
|
|
187
|
-
#
|
|
201
|
+
# A failed request starts the next attempt in a fresh chat, as a new step does.
|
|
188
202
|
chat = nil
|
|
189
203
|
log_failure(prompt.role, attempt, path, e.message)
|
|
204
|
+
attempt += 1
|
|
190
205
|
next
|
|
191
206
|
end
|
|
192
207
|
|
|
193
208
|
result, errors = @output_check.call(response.content, prompt)
|
|
194
209
|
if errors.empty?
|
|
195
|
-
squawk(prompt, path, attempt, response, nil)
|
|
210
|
+
squawk(prompt, path, attempt, response, nil, reask:)
|
|
196
211
|
return produce(result, prompt)
|
|
197
212
|
end
|
|
198
213
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
repeated = reask && response.content == rejected.first
|
|
215
|
+
following = next_attempt(path, attempt, repeated)
|
|
216
|
+
error = invalid_output(response, errors, models.size, models.dup, prompt.role == :judge ? "Judge" : "LLM")
|
|
217
|
+
squawk(prompt, path, attempt, response, error, reask:, repeated:, final: following.nil?)
|
|
218
|
+
raise error unless following
|
|
203
219
|
|
|
204
|
-
rejected = [response.content, errors]
|
|
205
|
-
log_failure(prompt.role, attempt, path, errors
|
|
220
|
+
rejected = [response.content, error.errors]
|
|
221
|
+
log_failure(prompt.role, attempt, path, rejection_reason(error.errors, response), following:, repeated:)
|
|
222
|
+
attempt = following
|
|
206
223
|
end
|
|
207
224
|
end
|
|
208
225
|
|
|
@@ -213,9 +230,10 @@ module Squishling
|
|
|
213
230
|
message = sample_message(sample, step)
|
|
214
231
|
chat = sample.chat
|
|
215
232
|
attempts = sample.stage.size
|
|
216
|
-
|
|
233
|
+
reask = sample.reask
|
|
234
|
+
-> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name, reask:) }
|
|
217
235
|
end
|
|
218
|
-
outcomes =
|
|
236
|
+
outcomes = @client.concurrently(jobs)
|
|
219
237
|
# A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
|
|
220
238
|
fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
|
|
221
239
|
raise fatal if fatal
|
|
@@ -225,10 +243,11 @@ module Squishling
|
|
|
225
243
|
|
|
226
244
|
# The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
|
|
227
245
|
def sample_message(sample, step)
|
|
228
|
-
|
|
246
|
+
sample.reask = !sample.chat.nil?
|
|
247
|
+
return retry_message(sample.rejected.last) if sample.reask
|
|
229
248
|
|
|
230
249
|
sample.chat = start_chat(step, @prompt)
|
|
231
|
-
|
|
250
|
+
fresh_message(step, @prompt.input, sample.rejected)
|
|
232
251
|
end
|
|
233
252
|
|
|
234
253
|
def record(sample, (response, error), attempt)
|
|
@@ -236,7 +255,7 @@ module Squishling
|
|
|
236
255
|
last = attempt == stage.size
|
|
237
256
|
role = "sample #{sample.name}"
|
|
238
257
|
if error
|
|
239
|
-
squawk(@prompt, stage, attempt, nil, error, sample:)
|
|
258
|
+
squawk(@prompt, stage, attempt, nil, error, sample:, reask: sample.reask)
|
|
240
259
|
raise error if last
|
|
241
260
|
|
|
242
261
|
sample.chat = nil
|
|
@@ -245,17 +264,19 @@ module Squishling
|
|
|
245
264
|
|
|
246
265
|
result, errors = @output_check.call(response.content, @prompt)
|
|
247
266
|
if errors.empty?
|
|
248
|
-
squawk(@prompt, stage, attempt, response, nil, sample:)
|
|
267
|
+
squawk(@prompt, stage, attempt, response, nil, sample:, reask: sample.reask)
|
|
249
268
|
return sample.result = produce(result, @prompt)
|
|
250
269
|
end
|
|
251
270
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
271
|
+
# A sample is one step (#samples refuses a stage that mixes them), so a repeat leaves nothing to move on to.
|
|
272
|
+
repeated = sample.reask && response.content == sample.rejected.first
|
|
273
|
+
invalid = invalid_output(response, errors, attempt, stage.first(attempt).map(&:model))
|
|
274
|
+
squawk(@prompt, stage, attempt, response, invalid, sample:, reask: sample.reask, repeated:,
|
|
275
|
+
final: last || repeated)
|
|
276
|
+
raise invalid if last || repeated
|
|
256
277
|
|
|
257
|
-
sample.rejected = [response.content, errors]
|
|
258
|
-
log_failure(role, attempt, stage, errors
|
|
278
|
+
sample.rejected = [response.content, invalid.errors]
|
|
279
|
+
log_failure(role, attempt, stage, rejection_reason(invalid.errors, response))
|
|
259
280
|
end
|
|
260
281
|
|
|
261
282
|
# Remembers a result of the method's own output (a judge's verdict isn't one), so a harness can only return
|
|
@@ -269,27 +290,13 @@ module Squishling
|
|
|
269
290
|
@client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
|
|
270
291
|
end
|
|
271
292
|
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
# attempts, :sample (with the sample's name) or :judge.
|
|
275
|
-
def squawk(prompt, path, attempt, response, error, sample: nil)
|
|
276
|
-
hook = @definition.squawk
|
|
277
|
-
return unless hook
|
|
278
|
-
|
|
279
|
-
step = path[attempt - 1]
|
|
280
|
-
metadata = {
|
|
281
|
-
label:, attempt:, attempts: path.size, final: attempt == path.size,
|
|
282
|
-
model: response&.model || step.model, provider: step.provider, params: step.params, input: prompt.input,
|
|
283
|
-
usage: response&.tokens&.to_h, role: sample ? :sample : prompt.role, sample: sample&.name
|
|
284
|
-
}
|
|
285
|
-
Squawk.call(hook, output: response&.content, metadata:, error:)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def log_failure(role, attempt, path, reason)
|
|
293
|
+
# following is the attempt that comes next, which is not the next one when a repeated reply skips ahead.
|
|
294
|
+
def log_failure(role, attempt, path, reason, following: attempt + 1, repeated: false)
|
|
289
295
|
step = path[attempt - 1]
|
|
290
|
-
next_step = path[
|
|
296
|
+
next_step = path[following - 1]
|
|
291
297
|
action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
|
|
292
298
|
who = role ? "#{role} attempt" : "attempt"
|
|
299
|
+
reason = "it repeated the rejected reply: #{reason}" if repeated
|
|
293
300
|
log("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
|
|
294
301
|
end
|
|
295
302
|
end
|
data/lib/squishling/schema.rb
CHANGED
|
@@ -4,8 +4,15 @@ module Squishling
|
|
|
4
4
|
# Normalizes the supported output-schema forms into a JSON Schema, validates data against it,
|
|
5
5
|
# and builds typed results.
|
|
6
6
|
class Schema
|
|
7
|
-
|
|
7
|
+
# Held weakly by the raw schema (a Schematist class, a Hash), so classes replaced by code reloading can be
|
|
8
|
+
# collected along with their schemas. A Hash compares by content, which yields the same schema.
|
|
9
|
+
CACHE = ObjectSpace::WeakKeyMap.new
|
|
8
10
|
CACHE_LOCK = Mutex.new
|
|
11
|
+
# What building a validator raises for a $ref (or $schema) it can't resolve. json_schemer's default resolver
|
|
12
|
+
# refuses remote URIs, so none is fetched.
|
|
13
|
+
REF_ERRORS = [
|
|
14
|
+
JSONSchemer::UnknownRef, JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer, URI::InvalidURIError
|
|
15
|
+
].freeze
|
|
9
16
|
|
|
10
17
|
# Conditional keywords (Schematist's `given` and `dependent`) that providers' strict modes don't support.
|
|
11
18
|
# They're validated locally and kept out of the schema sent to the LLM.
|
|
@@ -26,17 +33,19 @@ module Squishling
|
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
# The payload handed to
|
|
36
|
+
# The payload handed to the adapter (Adapter::Request#schema): always strict, without LOCAL_ONLY_KEYWORDS.
|
|
30
37
|
attr_reader :llm_schema
|
|
31
38
|
# The full JSON Schema used for validation and result typing.
|
|
32
39
|
attr_reader :json_schema
|
|
40
|
+
# The Data class results are built as, or nil when the schema isn't an object with properties.
|
|
41
|
+
attr_reader :result_class
|
|
33
42
|
|
|
34
43
|
def initialize(raw)
|
|
35
44
|
hash = self.class.jsonify(to_hash(raw))
|
|
36
45
|
wrapped = wrapped?(hash)
|
|
37
46
|
body = wrapped ? hash["schema"] : hash
|
|
38
47
|
|
|
39
|
-
# Same precedence RubyLLM uses: a top-level strict flag wins over one inside the schema.
|
|
48
|
+
# Same precedence RubyLLM 2.0 uses: a top-level strict flag wins over one inside the schema.
|
|
40
49
|
strict = hash.key?("strict") ? hash["strict"] : body["strict"]
|
|
41
50
|
raise ConfigurationError, "Squishling only supports strict output schemas (got strict: false)" if strict == false
|
|
42
51
|
|
|
@@ -47,24 +56,56 @@ module Squishling
|
|
|
47
56
|
"schema" => provider_schema(@json_schema),
|
|
48
57
|
"strict" => true
|
|
49
58
|
}.compact
|
|
50
|
-
@validator =
|
|
59
|
+
@validator = validator_for(@json_schema)
|
|
60
|
+
# Built now so a property name the result can't take fails here, not on the first response.
|
|
61
|
+
@result_class = Result.build(@json_schema)
|
|
51
62
|
end
|
|
52
63
|
|
|
53
64
|
def validate(data)
|
|
54
|
-
@validator.validate(data).map { |error| error["error"] }
|
|
65
|
+
errors = @validator.validate(data).map { |error| error["error"] }
|
|
66
|
+
# A raw Hash schema's type can be a union that includes "object" (a bare `properties` is refused at build);
|
|
67
|
+
# the typed result is built from a Hash, so anything else is invalid output rather than a NoMethodError.
|
|
68
|
+
errors.empty? && result_class && !data.is_a?(Hash) ? ["root must be an object"] : errors
|
|
55
69
|
end
|
|
56
70
|
|
|
57
71
|
def build(data, squished:)
|
|
58
72
|
result_class ? result_class.from_h(data, squished:) : data
|
|
59
73
|
end
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
return @result_class if defined?(@result_class)
|
|
75
|
+
private
|
|
63
76
|
|
|
64
|
-
|
|
77
|
+
# The json_schemer validator, with every $ref resolved now, so one that points nowhere (a remote URI, a missing
|
|
78
|
+
# $defs entry) fails at build and in Squishling.verify, not on the first response.
|
|
79
|
+
def validator_for(json_schema)
|
|
80
|
+
validator = JSONSchemer.schema(json_schema)
|
|
81
|
+
resolve_refs!(validator, {}.compare_by_identity)
|
|
82
|
+
validator
|
|
83
|
+
rescue *REF_ERRORS => e
|
|
84
|
+
raise ConfigurationError, "output_schema has a $ref that can't be resolved (#{e.message}); " \
|
|
85
|
+
"only references within the schema are supported"
|
|
65
86
|
end
|
|
66
87
|
|
|
67
|
-
|
|
88
|
+
# Walks the parsed schema (json_schemer's Schema#parsed and Keyword#ref_schema) and into each target a $ref
|
|
89
|
+
# resolves to, so a reference inside a subschema kept under any keyword is checked too. seen stops cycles.
|
|
90
|
+
def resolve_refs!(schema, seen)
|
|
91
|
+
return if seen.key?(schema)
|
|
92
|
+
|
|
93
|
+
seen[schema] = true
|
|
94
|
+
schema.parsed.each_value do |keyword|
|
|
95
|
+
resolve_refs!(keyword.ref_schema, seen) if keyword.respond_to?(:ref_schema)
|
|
96
|
+
subschemas(keyword.parsed).each { |subschema| resolve_refs!(subschema, seen) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The Schema objects directly under a parsed keyword: itself, in a list, or in a map of names.
|
|
101
|
+
def subschemas(parsed)
|
|
102
|
+
case parsed
|
|
103
|
+
when JSONSchemer::Schema then [parsed]
|
|
104
|
+
when Array then parsed.grep(JSONSchemer::Schema)
|
|
105
|
+
when Hash then parsed.values.grep(JSONSchemer::Schema)
|
|
106
|
+
else []
|
|
107
|
+
end
|
|
108
|
+
end
|
|
68
109
|
|
|
69
110
|
def to_hash(raw)
|
|
70
111
|
case raw
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Holds a credential (an API key, or the headers that may carry one) so a serializer that walks an object's instance
|
|
5
|
+
# variables can't write it out: to_s, inspect, to_yaml, as_json (so ActiveSupport's instance_values walk too) and
|
|
6
|
+
# to_json all show "[REDACTED]", and Marshal refuses. The value sits in a closure rather than an instance variable;
|
|
7
|
+
# #reveal is the only way to read it, and it is for the code that sends the request. Code running in the process can
|
|
8
|
+
# still call #reveal; this guards against accidental serialization (a logged config, an error tracker, a cache
|
|
9
|
+
# dump), not against a hostile caller.
|
|
10
|
+
class Secret
|
|
11
|
+
REDACTED = "[REDACTED]"
|
|
12
|
+
|
|
13
|
+
def initialize(value)
|
|
14
|
+
@reveal = -> { value }
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reveal
|
|
19
|
+
@reveal.call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
REDACTED
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
alias_method :to_s, :inspect
|
|
27
|
+
|
|
28
|
+
def encode_with(coder)
|
|
29
|
+
coder.scalar = REDACTED
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def as_json(*)
|
|
33
|
+
REDACTED
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_json(*)
|
|
37
|
+
REDACTED.to_json
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def marshal_dump
|
|
41
|
+
raise TypeError, "Squishling::Secret can't be dumped: it holds a credential"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_constant :Secret
|
|
45
|
+
end
|
data/lib/squishling/squawk.rb
CHANGED
|
@@ -1,24 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
# The opt-in observability hook: a callable run after every LLM attempt
|
|
5
|
-
# it can be sent somewhere (an error tracker, a
|
|
6
|
-
# to leave the process; nothing runs unless one
|
|
4
|
+
# The opt-in observability hook: a callable run after every LLM attempt (or only the rejected and retried
|
|
5
|
+
# ones, see squawk_on:) with the attempt's raw output, so it can be sent somewhere (an error tracker, a
|
|
6
|
+
# tracing tool). It's the one sanctioned way for model output to leave the process; nothing runs unless one
|
|
7
|
+
# is configured. A call that a token budget or a disagreement stops gets one more report, with the error and no
|
|
8
|
+
# output (metadata's stopped:), since neither has an attempt of its own.
|
|
7
9
|
module Squawk
|
|
8
10
|
KEYWORD_TYPES = %i[key keyreq].freeze
|
|
9
11
|
POSITIONAL_TYPES = %i[req opt].freeze
|
|
10
12
|
KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
|
|
11
13
|
KEYWORDS = %i[output metadata error].freeze
|
|
14
|
+
# The squawk_on: conditions, each answering "does the hook run for this attempt?" from the attempt's 1-based
|
|
15
|
+
# number (the one its metadata carries), its error (nil when accepted), and whether it is the last one. A stopped
|
|
16
|
+
# call's report has an error and is final, so it passes every condition whatever its attempt number (0 or more).
|
|
17
|
+
CONDITIONS = {
|
|
18
|
+
all: ->(_attempt, _error, _final) { true },
|
|
19
|
+
rejections: ->(_attempt, error, _final) { !error.nil? },
|
|
20
|
+
retries: ->(attempt, error, _final) { !error.nil? || attempt > 1 },
|
|
21
|
+
final: ->(_attempt, _error, final) { final }
|
|
22
|
+
}.freeze
|
|
23
|
+
ON = CONDITIONS.keys.freeze
|
|
12
24
|
|
|
13
25
|
module_function
|
|
14
26
|
|
|
27
|
+
# Which attempts reach the hook (squawk_on:): one condition or a list of them, or nil to inherit. :all is
|
|
28
|
+
# every attempt, :rejections only the ones that errored, :retries those plus any accepted attempt after the
|
|
29
|
+
# first, and :final the last attempt the escalation allows (metadata's final:), however it ended; an earlier
|
|
30
|
+
# accepted attempt is not final. The report of a stopped call is final and has an error, so every
|
|
31
|
+
# condition lets it through. A list runs the hook when any of them holds.
|
|
32
|
+
def validate_on(value, where)
|
|
33
|
+
return value if value.nil?
|
|
34
|
+
|
|
35
|
+
conditions = value.is_a?(Array) ? value : [value]
|
|
36
|
+
unless conditions.any? && conditions.all? { |condition| CONDITIONS.key?(condition) }
|
|
37
|
+
raise ConfigurationError,
|
|
38
|
+
"#{where} squawk_on must be one or a list of #{ON.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
value.is_a?(Array) ? value.dup.freeze : value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def report?(on, attempt, error, final)
|
|
45
|
+
Array(on).any? { |condition| CONDITIONS.fetch(condition).call(attempt, error, final) }
|
|
46
|
+
end
|
|
47
|
+
|
|
15
48
|
# The hook itself, or nil/false to inherit/silence. Anything else must respond to call and take keywords
|
|
16
49
|
# only, which is checked here so a mismatch fails at setup, not inside the first attempt.
|
|
17
50
|
def validate(hook, where)
|
|
18
51
|
return hook if hook.nil? || hook == false
|
|
19
52
|
|
|
20
53
|
unless hook.respond_to?(:call)
|
|
21
|
-
raise ConfigurationError, "#{where} squawk must respond to call (or be false), got #{hook
|
|
54
|
+
raise ConfigurationError, "#{where} squawk must respond to call (or be false), got #{Appendices.describe(hook)}"
|
|
22
55
|
end
|
|
23
56
|
if positional_only?(parameters(hook))
|
|
24
57
|
raise ConfigurationError,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Outside Rails, load it from your Rakefile: load "squishling/tasks/squishling.rake"
|
|
4
|
+
unless Rake::Task.task_defined?("squishling:verify")
|
|
5
|
+
namespace :squishling do
|
|
6
|
+
desc "Verify every squished class without making a request (see Squishling.verify)"
|
|
7
|
+
task :verify do
|
|
8
|
+
# Only loaded classes are known, so load them all first. Rails defines :environment after loading its
|
|
9
|
+
# railties' tasks, so it can't be a prerequisite declared here.
|
|
10
|
+
Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
|
|
11
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
12
|
+
|
|
13
|
+
report = Squishling.verify
|
|
14
|
+
puts report
|
|
15
|
+
abort unless report.ok?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# A Squishling adapter that answers from a script instead of a provider (see Script). Install it with
|
|
6
|
+
# Testing.install. It records every request (#requests) and groups them into the chats they belong to
|
|
7
|
+
# (#conversations), and it is safe to ask from the worker threads a harness's samples run on: everything shared
|
|
8
|
+
# is taken under one lock, and replies are chosen by (step, sample, attempt), never by arrival order.
|
|
9
|
+
class Adapter < Squishling::Adapter::Base
|
|
10
|
+
# One chat: its sample (nil unless a harness sample's) and the turns exchanged so far.
|
|
11
|
+
Conversation = Struct.new(:index, :sample, :transcript)
|
|
12
|
+
|
|
13
|
+
attr_reader :script
|
|
14
|
+
|
|
15
|
+
def initialize(script = Script.new)
|
|
16
|
+
super()
|
|
17
|
+
@script = script
|
|
18
|
+
@lock = Mutex.new
|
|
19
|
+
@requests = []
|
|
20
|
+
@conversations = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def judgments?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every request so far, in the order they arrived (Testing::Request).
|
|
28
|
+
def requests
|
|
29
|
+
@lock.synchronize { @requests.dup }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The requests of each chat, in order: a retry on the same step shares a chat, a new step starts one.
|
|
33
|
+
def conversations
|
|
34
|
+
requests.select(&:conversation).group_by(&:conversation).sort.map(&:last)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def requests_for(role: nil, sample: nil)
|
|
38
|
+
requests.select do |request|
|
|
39
|
+
(role.nil? || request.role == role) && (sample.nil? || request.sample == sample.to_s)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The models the requests went to, in order (nil for a step that names none).
|
|
44
|
+
def models
|
|
45
|
+
requests.map(&:model)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The first request's arguments: what left the process.
|
|
49
|
+
def arguments
|
|
50
|
+
requests.first&.arguments
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def complete(request)
|
|
54
|
+
outcome = @lock.synchronize { answer(request) }
|
|
55
|
+
raise outcome if outcome.is_a?(Exception)
|
|
56
|
+
|
|
57
|
+
outcome
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def judge(input, questions:, step:, label:)
|
|
61
|
+
outcome = @lock.synchronize { judged(input, questions, step, label) }
|
|
62
|
+
raise outcome if outcome.is_a?(Exception)
|
|
63
|
+
|
|
64
|
+
outcome
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def answer(request)
|
|
70
|
+
call = call_of(request)
|
|
71
|
+
outcome = outcome_for(:chat, call, request.label, request.model)
|
|
72
|
+
conversation = conversation_for(request, call[:sample])
|
|
73
|
+
conversation.transcript.push(*turns(request, outcome)) unless outcome.is_a?(Exception)
|
|
74
|
+
record(request, call, outcome, conversation.index)
|
|
75
|
+
outcome
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def judged(input, _questions, step, label)
|
|
79
|
+
call = { role: :judge, sample: nil, attempt: Observer.current&.attempt, attempts: Observer.current&.attempts,
|
|
80
|
+
model: step.model, provider: step.provider }
|
|
81
|
+
outcome = outcome_for(:judgment, call, label, step.model)
|
|
82
|
+
payload = Schema.jsonify(input)
|
|
83
|
+
@requests << Request.new(label:, type: :judgment, role: :judge, sample: nil, attempt: call[:attempt],
|
|
84
|
+
attempts: call[:attempts], model: step.model, provider: step.provider, params: step.params,
|
|
85
|
+
system_prompt: nil, output_schema: nil, messages: [], conversation: nil, payload:, reply: outcome)
|
|
86
|
+
outcome
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def outcome_for(kind, call, label, model)
|
|
90
|
+
rule = @script.rule_for(kind, call)
|
|
91
|
+
item = rule ? rule.take : Rule::EXHAUSTED
|
|
92
|
+
return unscripted(kind, call, label, model, rule) if item.equal?(Rule::EXHAUSTED)
|
|
93
|
+
|
|
94
|
+
resolve(item, label)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve(item, label)
|
|
98
|
+
case item
|
|
99
|
+
when Failure then Failures.build(item.intent, label)
|
|
100
|
+
when Exception, Judgment then item
|
|
101
|
+
when Class then item <= Exception ? item.new : response(item)
|
|
102
|
+
when Reply then Squishling::Adapter::Response.new(content: item.content, model: item.model, tokens: item.usage)
|
|
103
|
+
else response(item)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def response(content)
|
|
108
|
+
Squishling::Adapter::Response.new(content:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def unscripted(kind, call, label, model, rule)
|
|
112
|
+
aimed = call.compact.except(:attempts).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
|
|
113
|
+
why = if rule
|
|
114
|
+
"its script #{rule.empty? ? 'has no replies' : 'has no answers left (once)'}"
|
|
115
|
+
else
|
|
116
|
+
"no script covers it"
|
|
117
|
+
end
|
|
118
|
+
UnscriptedRequestError.new("#{label}: unscripted #{kind} request to #{model.inspect} (#{aimed}): #{why}. " \
|
|
119
|
+
"Scripted: #{@script.describe}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The role, sample, and attempt of the request: from the observer that wraps it, or, when the adapter was
|
|
123
|
+
# installed without one, what the request itself shows.
|
|
124
|
+
def call_of(request)
|
|
125
|
+
observed = Observer.current
|
|
126
|
+
base = { model: request.model, provider: request.provider }
|
|
127
|
+
if observed
|
|
128
|
+
return base.merge(role: observed.role, sample: observed.sample, attempt: observed.attempt,
|
|
129
|
+
attempts: observed.attempts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
base.merge(role: judge_schema?(request.schema) ? :judge : nil, sample: nil, attempt: nil, attempts: nil)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def judge_schema?(schema)
|
|
136
|
+
schema["schema"] == Schema.for(Judge::VERDICT_SCHEMA).llm_schema["schema"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A request that continues a chat sends the chat's transcript back, so it finds its conversation by it.
|
|
140
|
+
def conversation_for(request, sample)
|
|
141
|
+
history = request.messages[0...-1]
|
|
142
|
+
found = history.empty? ? nil : @conversations.reverse.find { |c| c.sample == sample && c.transcript == history }
|
|
143
|
+
found || Conversation.new(@conversations.size, sample, []).tap { |chat| @conversations << chat }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def turns(request, response)
|
|
147
|
+
[{ role: :user, content: request.messages.last.fetch(:content) }, { role: :assistant, content: response.text }]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def record(request, call, outcome, conversation)
|
|
151
|
+
@requests << Request.new(label: request.label, type: :chat, role: call[:role], sample: call[:sample]&.to_s,
|
|
152
|
+
attempt: call[:attempt], attempts: call[:attempts], model: request.model, provider: request.provider,
|
|
153
|
+
params: request.params, system_prompt: request.instructions, output_schema: request.schema["schema"],
|
|
154
|
+
messages: request.messages, conversation:, payload: Request.payload_of(request.messages), reply: outcome)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The adapter behind Testing.forbid_provider_calls!: any request that reaches it raises ProviderCallError, which
|
|
159
|
+
# is not a Squishling::Error, so a squish_fallback can't absorb it. It remembers the calls that were attempted,
|
|
160
|
+
# for a test that rescued the error.
|
|
161
|
+
class ForbiddenAdapter < Squishling::Adapter::Base
|
|
162
|
+
def initialize
|
|
163
|
+
super
|
|
164
|
+
@lock = Mutex.new
|
|
165
|
+
@attempts = []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The labels ("Klass#method") of the calls that reached it.
|
|
169
|
+
def attempts
|
|
170
|
+
@lock.synchronize { @attempts.dup }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def judgments?
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def complete(request)
|
|
178
|
+
refuse(request.label)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def judge(_input, label:, **)
|
|
182
|
+
refuse(label)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def refuse(label)
|
|
188
|
+
@lock.synchronize { @attempts << label }
|
|
189
|
+
raise ProviderCallError, "#{label} reached the provider, but no LLM call was expected. Script it with " \
|
|
190
|
+
"Squishling::Testing.install, or fix the code under test"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|