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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +26 -10
  4. data/docs/adapters.md +257 -0
  5. data/docs/configuration.md +54 -19
  6. data/docs/failures.md +170 -22
  7. data/docs/harnesses.md +39 -15
  8. data/docs/measuring-tokens.md +12 -6
  9. data/docs/observers.md +3 -3
  10. data/docs/routing.md +14 -3
  11. data/docs/schemas.md +24 -2
  12. data/docs/testing.md +187 -0
  13. data/docs/verifying.md +86 -0
  14. data/lib/squishling/adapter/error_body.rb +54 -0
  15. data/lib/squishling/adapter.rb +328 -0
  16. data/lib/squishling/adapters/openai_compatible.rb +311 -0
  17. data/lib/squishling/adapters/openai_options.rb +114 -0
  18. data/lib/squishling/adapters/openai_transport.rb +123 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +229 -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 +58 -6
  24. data/lib/squishling/definition.rb +90 -16
  25. data/lib/squishling/errors.rb +94 -7
  26. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  27. data/lib/squishling/harness/option_types.rb +2 -1
  28. data/lib/squishling/harness/registry.rb +8 -36
  29. data/lib/squishling/invoker.rb +44 -14
  30. data/lib/squishling/judge.rb +16 -3
  31. data/lib/squishling/llm_client.rb +66 -132
  32. data/lib/squishling/model_path.rb +60 -13
  33. data/lib/squishling/named_registry.rb +64 -0
  34. data/lib/squishling/observer.rb +13 -4
  35. data/lib/squishling/output_check.rb +13 -9
  36. data/lib/squishling/params.rb +7 -24
  37. data/lib/squishling/railtie.rb +10 -0
  38. data/lib/squishling/result.rb +50 -16
  39. data/lib/squishling/rotation.rb +5 -0
  40. data/lib/squishling/router.rb +3 -2
  41. data/lib/squishling/rspec.rb +18 -0
  42. data/lib/squishling/run/messages.rb +40 -3
  43. data/lib/squishling/run/rejections.rb +43 -0
  44. data/lib/squishling/run/reports.rb +63 -0
  45. data/lib/squishling/run.rb +60 -53
  46. data/lib/squishling/schema.rb +50 -9
  47. data/lib/squishling/secret.rb +45 -0
  48. data/lib/squishling/squawk.rb +37 -4
  49. data/lib/squishling/tasks/squishling.rake +18 -0
  50. data/lib/squishling/testing/adapter.rb +194 -0
  51. data/lib/squishling/testing/checks.rb +39 -0
  52. data/lib/squishling/testing/direct.rb +41 -0
  53. data/lib/squishling/testing/errors.rb +18 -0
  54. data/lib/squishling/testing/fixtures.rb +206 -0
  55. data/lib/squishling/testing/helpers.rb +71 -0
  56. data/lib/squishling/testing/minitest.rb +32 -0
  57. data/lib/squishling/testing/observer.rb +31 -0
  58. data/lib/squishling/testing/reply.rb +60 -0
  59. data/lib/squishling/testing/request.rb +75 -0
  60. data/lib/squishling/testing/rspec.rb +30 -0
  61. data/lib/squishling/testing/script.rb +189 -0
  62. data/lib/squishling/testing/state.rb +74 -0
  63. data/lib/squishling/testing/stubs.rb +112 -0
  64. data/lib/squishling/testing.rb +126 -0
  65. data/lib/squishling/verification.rb +39 -0
  66. data/lib/squishling/verifier.rb +179 -0
  67. data/lib/squishling/version.rb +1 -1
  68. data/lib/squishling/wrapper.rb +19 -1
  69. data/lib/squishling.rb +60 -4
  70. metadata +43 -21
@@ -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:), the
58
- # way a judge step is resolved: it keeps its own provider, and its params merge over the method's.
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. Consecutive attempts on the same step continue the same chat, so the
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). Raises InvalidOutputError, or LLMError, when the last attempt fails.
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
- path.each.with_index(1) do |step, attempt|
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
- if chat && step == path[attempt - 2]
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 = rejected && step.forward_rejected ? escalation_message(prompt.input, *rejected) : prompt.input
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
- # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
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
- error = InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models: models.dup,
200
- source: prompt.role == :judge ? "Judge" : "LLM")
201
- squawk(prompt, path, attempt, response, error)
202
- raise error if last
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.join("; "))
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
- -> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name) }
233
+ reask = sample.reask
234
+ -> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name, reask:) }
217
235
  end
218
- outcomes = LLMClient.concurrently(jobs)
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
- return retry_message(sample.rejected.last) if sample.chat
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
- sample.rejected && step.forward_rejected ? escalation_message(@prompt.input, *sample.rejected) : @prompt.input
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
- invalid = InvalidOutputError.new(errors:, raw: response.content, attempts: attempt,
253
- models: stage.first(attempt).map(&:model))
254
- squawk(@prompt, stage, attempt, response, invalid, sample:)
255
- raise invalid if last
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.join("; "))
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
- # Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
273
- # the error that ended it (nil when it was accepted), and what was asked. role is nil for escalation
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[attempt]
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
@@ -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
- CACHE = {}.compare_by_identity
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 RubyLLM's `with_schema`: always strict, without LOCAL_ONLY_KEYWORDS.
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 = JSONSchemer.schema(@json_schema)
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
- def result_class
62
- return @result_class if defined?(@result_class)
75
+ private
63
76
 
64
- @result_class = Result.build(@json_schema)
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
- private
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
@@ -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 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. 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.inspect}"
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