squishling 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -21
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # One squished call, as a harness sees it (see Harness::Base): the primitives that ask the model for outputs,
5
+ # and everything they do for free: model, provider, and params resolution, schema and squish_validate checks,
6
+ # retries in the same chat, escalation, squawk reports, and Squishling's own errors when attempts run out.
7
+ # Each returns typed results, the same type as the deterministic path.
8
+ #
9
+ # A stage is the attempts of one escalation step (a model with its attempts:), as an Array of
10
+ # ModelPath::Step. Primitives take stages, or anything that declares one (a model name or a step Hash).
11
+ #
12
+ # Primitives must be called from the thread that runs the harness. Only the provider requests inside them run
13
+ # on other threads; parsing, validation, and every callback (squish_validate, compare:, procs) stay on this one.
14
+ class Run
15
+ include Messages
16
+ include Rejections
17
+
18
+ private_constant :Messages, :Rejections
19
+
20
+ # One of #samples' outputs, between rounds.
21
+ Sample = Struct.new(:name, :stage, :chat, :rejected, :result, :reask)
22
+
23
+ SAMPLE_NAMES = ("a".."z").to_a.freeze
24
+ private_constant :Sample, :SAMPLE_NAMES
25
+
26
+ attr_reader :receiver, :inputs, :steps
27
+
28
+ def initialize(definition:, receiver:, inputs:, prompt:, payload:, purpose:, harness:)
29
+ @definition = definition
30
+ @receiver = receiver
31
+ @inputs = inputs
32
+ @prompt = prompt
33
+ @payload = payload
34
+ @purpose = purpose
35
+ @harness = harness
36
+ @steps = definition.escalation_path(receiver, inputs).freeze
37
+ @client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off,
38
+ budget: definition.budget)
39
+ @output_check = OutputCheck.new(definition, receiver, inputs)
40
+ @owner = Thread.current
41
+ @produced = {}.compare_by_identity
42
+ end
43
+
44
+ # Whether value is a result one of this run's primitives returned (not one built by hand).
45
+ def produced?(value)
46
+ @produced.key?(value)
47
+ end
48
+
49
+ # The squished method, as "Class#method".
50
+ def label
51
+ @definition.label
52
+ end
53
+
54
+ # The escalation's steps grouped into stages: runs of identical consecutive steps, the attempts of one model.
55
+ def stages
56
+ @stages ||= steps.slice_when { |before, after| before != after }.map(&:freeze).freeze
57
+ end
58
+
59
+ # A stage from a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:,
60
+ # retry_temperature:), the way a judge step is resolved: it keeps its own provider, and its params merge over
61
+ # the method's.
62
+ # A stage (an Array of steps) is returned as is, and a single step is a stage of one attempt.
63
+ def stage(declaration)
64
+ return checked_stage([declaration]) if declaration.is_a?(ModelPath::Step)
65
+
66
+ if declaration.is_a?(Array)
67
+ return checked_stage(declaration) if declaration.any? && declaration.all?(ModelPath::Step)
68
+
69
+ raise ConfigurationError, "#{label}: a stage is the steps of one model, got #{declaration.inspect}"
70
+ end
71
+
72
+ step = Harness::OptionTypes.check(:step, declaration, label, :"harness step")
73
+ ModelPath.steps([step], provider: nil, params: @definition.params).freeze
74
+ end
75
+
76
+ # Makes each attempt of the stages in order (the escalation's steps when none are given) until an output
77
+ # passes the schema and squish_validate. Raises BudgetExhaustedError instead of making an attempt once the
78
+ # call's token budget (see Budget) is spent. Consecutive attempts on the same step continue the same chat, so the
79
+ # model sees what it got wrong; a new step starts a fresh chat told about the last rejected output (unless
80
+ # the step sets forward_rejected: false). A retry that repeats the rejected output it was just told about gives
81
+ # up on the rest of its step's attempts. Raises InvalidOutputError, or LLMError, when the last attempt fails.
82
+ def escalate(*stages)
83
+ owner!
84
+ path = stages.empty? ? steps : stages.flat_map { |declared| stage(declared) }
85
+ escalate_with(path, @prompt)
86
+ end
87
+
88
+ # One sample per stage (the attempts of one model), each asked on its own chat, each retried per its stage's
89
+ # attempts like #escalate, with the requests of every round sent concurrently. Returns the outputs in order.
90
+ # Samples are named "a", "b", ... in squawk reports.
91
+ def samples(stages)
92
+ owner!
93
+ raise ConfigurationError, "#{label}: samples takes an Array of stages" unless stages.is_a?(Array)
94
+
95
+ stages = stages.map { |declared| stage(declared) }
96
+ raise ConfigurationError, "#{label}: samples needs at least one stage" if stages.empty?
97
+ if stages.size > SAMPLE_NAMES.size
98
+ raise ConfigurationError, "#{label}: samples takes at most #{SAMPLE_NAMES.size} stages"
99
+ end
100
+
101
+ if (mixed = stages.find { |sample_stage| sample_stage.uniq.size > 1 })
102
+ raise ConfigurationError, "#{label}: a sample is asked on one model, so its stage can't mix steps " \
103
+ "(got #{mixed.map(&:display_name).join(', ')})"
104
+ end
105
+
106
+ samples = stages.zip(SAMPLE_NAMES).map { |sample_stage, name| Sample.new(name, sample_stage) }
107
+ (1..stages.map(&:size).max).each do |attempt|
108
+ pending = samples.reject(&:result)
109
+ break if pending.empty?
110
+
111
+ check_budget!(attempts: attempt - 1, models: samples.first.stage.first(attempt - 1).map(&:model),
112
+ rejected: pending.filter_map(&:rejected).last)
113
+ round(pending, attempt)
114
+ end
115
+ samples.map(&:result)
116
+ end
117
+
118
+ # A judge that picks between two outputs (see Judge#verdict, which returns a Judge::Verdict), or rejects
119
+ # both. judge: is a model name or a Hash with model:, provider:, params:, and attempts: (as an escalation
120
+ # step, without priority:), plus type: :chat (default) or :judgment and, for :judgment, min_confidence:.
121
+ # Without one, the judge is the escalation's next stage after the first. instructions: is a String, or a
122
+ # Proc evaluated against the receiver, replacing Harness::DEFAULT_JUDGE_INSTRUCTIONS. The judge is resolved
123
+ # here, so a missing one raises ConfigurationError before any request is made.
124
+ def judge(judge: nil, instructions: nil)
125
+ owner!
126
+ judge = judge_step(judge) unless judge.nil?
127
+ Judge.new(definition: @definition, receiver: @receiver, client: @client, judge:, instructions:, path: steps,
128
+ purpose: @purpose, payload: @payload, escalate: method(:escalate_with), log_failure: method(:log_failure),
129
+ guard: method(:owner!), budget_check: method(:check_budget!), harness_type: @harness.type)
130
+ end
131
+
132
+ # Whether two outputs agree: compare (a Proc, evaluated against the receiver with the two results and the
133
+ # method's inputs, truthy for agreement) or, without one, whether they are exactly equal.
134
+ # compare is the developer's own code, so its exceptions propagate unwrapped.
135
+ def agree?(first, second, compare = nil)
136
+ owner!
137
+ return Schema.jsonify(first.to_h) == Schema.jsonify(second.to_h) unless compare
138
+
139
+ @receiver.instance_exec(first, second, **@inputs, &compare) ? true : false
140
+ end
141
+
142
+ # Logs a warning, "[Squishling] Class#method message", to Squishling.config.logger, if there is one.
143
+ def log(message)
144
+ Squishling.config.logger&.warn("[Squishling] #{label} #{message}")
145
+ end
146
+
147
+ private
148
+
149
+ # Steps made or changed by hand get the params checks a declared step does (no reserved keys).
150
+ def checked_stage(stage)
151
+ stage.each { |step| Params.normalize(step.params, "#{label} harness step params") }
152
+ stage.frozen? ? stage : stage.dup.freeze
153
+ end
154
+
155
+ # A judge declaration as the escalation step Judge takes: one step (no priority:), with its type.
156
+ def judge_step(declaration)
157
+ step = ModelPath.normalize_step(declaration, "#{label} judge", extras: Judge::STEP_KEYS)
158
+ ModelPath.single_step!(step, "#{label} judge: a judge is one step, so it")
159
+
160
+ type = step.fetch(:type, :chat)
161
+ type = :chat if type.nil?
162
+ step.merge(type: type.is_a?(String) ? type.to_sym : type)
163
+ end
164
+
165
+ def owner!
166
+ return if Thread.current.equal?(@owner)
167
+
168
+ raise ConfigurationError, "#{label}: a harness must call its run's primitives from the thread it runs on"
169
+ end
170
+
171
+ # Makes each attempt of the path until an output passes (see #escalate). The judge's prompt (role :judge)
172
+ # skips squish_validate and names the judge in errors and logs.
173
+ def escalate_with(path, prompt)
174
+ chat = nil
175
+ rejected = nil # [raw content, errors] of the last invalid output
176
+ models = []
177
+ attempt = 1
178
+
179
+ while attempt <= path.size
180
+ check_budget!(attempts: models.size, models:, rejected:)
181
+ step = path[attempt - 1]
182
+ models << step.model
183
+ last = attempt == path.size
184
+ reask = !chat.nil? && step == path[attempt - 2]
185
+ if reask
186
+ message = retry_message(rejected.last)
187
+ else
188
+ chat = start_chat(step, prompt)
189
+ message = fresh_message(step, prompt.input, rejected)
190
+ end
191
+
192
+ begin
193
+ response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role, reask:)
194
+ rescue LLMError => e
195
+ squawk(prompt, path, attempt, nil, e, reask:)
196
+ raise if last
197
+
198
+ # A failed request starts the next attempt in a fresh chat, as a new step does.
199
+ chat = nil
200
+ log_failure(prompt.role, attempt, path, e.message)
201
+ attempt += 1
202
+ next
203
+ end
204
+
205
+ result, errors = @output_check.call(response.content, prompt)
206
+ if errors.empty?
207
+ squawk(prompt, path, attempt, response, nil, reask:)
208
+ return produce(result, prompt)
209
+ end
210
+
211
+ repeated = reask && response.content == rejected.first
212
+ following = next_attempt(path, attempt, repeated)
213
+ error = invalid_output(response, errors, models.size, models.dup, prompt.role == :judge ? "Judge" : "LLM")
214
+ squawk(prompt, path, attempt, response, error, reask:, repeated:, final: following.nil?)
215
+ raise error unless following
216
+
217
+ rejected = [response.content, error.errors]
218
+ log_failure(prompt.role, attempt, path, rejection_reason(error.errors, response), following:, repeated:)
219
+ attempt = following
220
+ end
221
+ end
222
+
223
+ # One round of #samples: the pending samples' requests, sent concurrently, then each outcome recorded.
224
+ def round(pending, attempt)
225
+ jobs = pending.map do |sample|
226
+ step = sample.stage[attempt - 1]
227
+ message = sample_message(sample, step)
228
+ chat = sample.chat
229
+ attempts = sample.stage.size
230
+ reask = sample.reask
231
+ -> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name, reask:) }
232
+ end
233
+ outcomes = @client.concurrently(jobs)
234
+ # A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
235
+ fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
236
+ raise fatal if fatal
237
+
238
+ pending.zip(outcomes).each { |sample, outcome| record(sample, outcome, attempt) }
239
+ end
240
+
241
+ # The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
242
+ def sample_message(sample, step)
243
+ sample.reask = !sample.chat.nil?
244
+ return retry_message(sample.rejected.last) if sample.reask
245
+
246
+ sample.chat = start_chat(step, @prompt)
247
+ fresh_message(step, @prompt.input, sample.rejected)
248
+ end
249
+
250
+ def record(sample, (response, error), attempt)
251
+ stage = sample.stage
252
+ last = attempt == stage.size
253
+ role = "sample #{sample.name}"
254
+ if error
255
+ squawk(@prompt, stage, attempt, nil, error, sample:, reask: sample.reask)
256
+ raise error if last
257
+
258
+ sample.chat = nil
259
+ return log_failure(role, attempt, stage, error.message)
260
+ end
261
+
262
+ result, errors = @output_check.call(response.content, @prompt)
263
+ if errors.empty?
264
+ squawk(@prompt, stage, attempt, response, nil, sample:, reask: sample.reask)
265
+ return sample.result = produce(result, @prompt)
266
+ end
267
+
268
+ # A sample is one step (#samples refuses a stage that mixes them), so a repeat leaves nothing to move on to.
269
+ repeated = sample.reask && response.content == sample.rejected.first
270
+ invalid = invalid_output(response, errors, attempt, stage.first(attempt).map(&:model))
271
+ squawk(@prompt, stage, attempt, response, invalid, sample:, reask: sample.reask, repeated:,
272
+ final: last || repeated)
273
+ raise invalid if last || repeated
274
+
275
+ sample.rejected = [response.content, invalid.errors]
276
+ log_failure(role, attempt, stage, rejection_reason(invalid.errors, response))
277
+ end
278
+
279
+ # Remembers a result of the method's own output (a judge's verdict isn't one), so a harness can only return
280
+ # what a primitive produced.
281
+ def produce(result, prompt)
282
+ @produced[result] = true unless prompt.role == :judge
283
+ result
284
+ end
285
+
286
+ def start_chat(step, prompt)
287
+ @client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
288
+ end
289
+
290
+ # Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
291
+ # the error that ended it (nil when it was accepted), and what was asked. role is nil for escalation
292
+ # attempts, :sample (with the sample's name) or :judge. reask is whether the attempt retried in the same chat,
293
+ # repeated whether it sent back the reply the attempt before it was rejected for, and final whether no attempt
294
+ # follows it.
295
+ def squawk(prompt, path, attempt, response, error, sample: nil, reask: false, repeated: false,
296
+ final: attempt == path.size)
297
+ hook = @definition.squawk
298
+ return unless hook && Squawk.report?(@definition.squawk_on, attempt, error, final)
299
+
300
+ step = path[attempt - 1]
301
+ metadata = {
302
+ label:, attempt:, attempts: path.size, final:, repeated:,
303
+ model: response&.model || step.model, provider: step.provider, params: step.request_params(reask),
304
+ input: prompt.input, usage: response&.usage, finish_reason: response&.finish_reason,
305
+ role: sample ? :sample : prompt.role, sample: sample&.name
306
+ }
307
+ Squawk.call(hook, output: response&.content, metadata:, error:)
308
+ end
309
+
310
+ # following is the attempt that comes next, which is not the next one when a repeated reply skips ahead.
311
+ def log_failure(role, attempt, path, reason, following: attempt + 1, repeated: false)
312
+ step = path[attempt - 1]
313
+ next_step = path[following - 1]
314
+ action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
315
+ who = role ? "#{role} attempt" : "attempt"
316
+ reason = "it repeated the rejected reply: #{reason}" if repeated
317
+ log("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
318
+ end
319
+ end
320
+ end
@@ -26,17 +26,19 @@ module Squishling
26
26
  end
27
27
  end
28
28
 
29
- # The payload handed to RubyLLM's `with_schema`: always strict, without LOCAL_ONLY_KEYWORDS.
29
+ # The payload handed to the adapter (Adapter::Request#schema): always strict, without LOCAL_ONLY_KEYWORDS.
30
30
  attr_reader :llm_schema
31
31
  # The full JSON Schema used for validation and result typing.
32
32
  attr_reader :json_schema
33
+ # The Data class results are built as, or nil when the schema isn't an object with properties.
34
+ attr_reader :result_class
33
35
 
34
36
  def initialize(raw)
35
37
  hash = self.class.jsonify(to_hash(raw))
36
38
  wrapped = wrapped?(hash)
37
39
  body = wrapped ? hash["schema"] : hash
38
40
 
39
- # Same precedence RubyLLM uses: a top-level strict flag wins over one inside the schema.
41
+ # Same precedence RubyLLM 2.0 uses: a top-level strict flag wins over one inside the schema.
40
42
  strict = hash.key?("strict") ? hash["strict"] : body["strict"]
41
43
  raise ConfigurationError, "Squishling only supports strict output schemas (got strict: false)" if strict == false
42
44
 
@@ -48,22 +50,21 @@ module Squishling
48
50
  "strict" => true
49
51
  }.compact
50
52
  @validator = JSONSchemer.schema(@json_schema)
53
+ # Built now so a property name the result can't take fails here, not on the first response.
54
+ @result_class = Result.build(@json_schema)
51
55
  end
52
56
 
53
57
  def validate(data)
54
- @validator.validate(data).map { |error| error["error"] }
58
+ errors = @validator.validate(data).map { |error| error["error"] }
59
+ # A raw Hash schema can leave out type: "object"; the typed result is built from a Hash, so anything else is
60
+ # invalid output rather than a NoMethodError out of the builder.
61
+ errors.empty? && result_class && !data.is_a?(Hash) ? ["root must be an object"] : errors
55
62
  end
56
63
 
57
64
  def build(data, squished:)
58
65
  result_class ? result_class.from_h(data, squished:) : data
59
66
  end
60
67
 
61
- def result_class
62
- return @result_class if defined?(@result_class)
63
-
64
- @result_class = Result.build(@json_schema)
65
- end
66
-
67
68
  private
68
69
 
69
70
  def to_hash(raw)
@@ -1,17 +1,47 @@
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 with the attempt's raw output, so
5
- # it can be sent somewhere (an error tracker, a tracing tool). It's the one sanctioned way for model output
6
- # to leave the process; nothing runs unless one is configured.
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.
7
8
  module Squawk
8
9
  KEYWORD_TYPES = %i[key keyreq].freeze
9
10
  POSITIONAL_TYPES = %i[req opt].freeze
10
11
  KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
11
12
  KEYWORDS = %i[output metadata error].freeze
13
+ # The squawk_on: conditions, each answering "does the hook run for this attempt?" from the attempt's 1-based
14
+ # number (the one its metadata carries), its error (nil when accepted), and whether it is the last one.
15
+ CONDITIONS = {
16
+ all: ->(_attempt, _error, _final) { true },
17
+ rejections: ->(_attempt, error, _final) { !error.nil? },
18
+ retries: ->(attempt, error, _final) { !error.nil? || attempt > 1 },
19
+ final: ->(_attempt, _error, final) { final }
20
+ }.freeze
21
+ ON = CONDITIONS.keys.freeze
12
22
 
13
23
  module_function
14
24
 
25
+ # Which attempts reach the hook (squawk_on:): one condition or a list of them, or nil to inherit. :all is
26
+ # every attempt, :rejections only the ones that errored, :retries those plus any accepted attempt after the
27
+ # first, and :final the last attempt the escalation allows (metadata's final:), however it ended; an earlier
28
+ # accepted attempt is not final. A list runs the hook when any of them holds.
29
+ def validate_on(value, where)
30
+ return value if value.nil?
31
+
32
+ conditions = value.is_a?(Array) ? value : [value]
33
+ unless conditions.any? && conditions.all? { |condition| CONDITIONS.key?(condition) }
34
+ raise ConfigurationError,
35
+ "#{where} squawk_on must be one or a list of #{ON.map(&:inspect).join(', ')}, got #{value.inspect}"
36
+ end
37
+
38
+ value.is_a?(Array) ? value.dup.freeze : value
39
+ end
40
+
41
+ def report?(on, attempt, error, final)
42
+ Array(on).any? { |condition| CONDITIONS.fetch(condition).call(attempt, error, final) }
43
+ end
44
+
15
45
  # The hook itself, or nil/false to inherit/silence. Anything else must respond to call and take keywords
16
46
  # only, which is checked here so a mismatch fails at setup, not inside the first attempt.
17
47
  def validate(hook, where)
@@ -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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The assertions, independent of any test framework: each returns a failure message, or nil when it holds. The
6
+ # Minitest and RSpec mixins turn a message into a failure in their own way.
7
+ module Checks
8
+ module_function
9
+
10
+ # The requests of a fake adapter (or an Array of them) sent exactly these arguments, as JSON would carry them.
11
+ def sent_arguments(source, expected, request: 0)
12
+ sent = requests_of(source)[request]
13
+ return "expected request ##{request} to have been sent, but only #{requests_of(source).size} were" unless sent
14
+
15
+ wanted = Schema.jsonify(expected)
16
+ return nil if sent.arguments == wanted
17
+
18
+ "expected the model to be sent arguments #{wanted.inspect}, but it was sent #{sent.arguments.inspect}"
19
+ end
20
+
21
+ def call_count(source, expected)
22
+ actual = requests_of(source).size
23
+ actual == expected ? nil : "expected #{expected} LLM call(s), but there were #{actual}"
24
+ end
25
+
26
+ # Runs the block with provider calls forbidden: a failure message when it tried to make one.
27
+ def no_llm_call(&)
28
+ attempts = Testing.no_llm_call(&)
29
+ return nil if attempts.empty?
30
+
31
+ "expected no LLM call, but #{attempts.uniq.join(', ')} reached the provider (#{attempts.size} request(s))"
32
+ end
33
+
34
+ def requests_of(source)
35
+ source.respond_to?(:requests) ? source.requests : Array(source)
36
+ end
37
+ end
38
+ end
39
+ end