squishling 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # The around-style hook for tools that must see an LLM request while it is made (a tracing span, a Coolhand
5
+ # declared workload), which squawk's after-the-fact report can't do. An observer is any object responding to
6
+ # around(call), which yields once to make the request. See docs/observers.md.
7
+ #
8
+ # Observers get call metadata (Call) and, back from the yield, the request's token Usage: never the model's
9
+ # output or the method's inputs.
10
+ module Observer
11
+ # What an observer is told about one provider request. handed_off is true when squish! sent the call to the
12
+ # LLM (typically from a rescue) and false when squish_when or a missing implementation did. role is nil for an
13
+ # escalation attempt, :sample (with sample "a", "b", ...) or :judge, like squawk's metadata. attempt and
14
+ # attempts count the (expanded) escalation path the request belongs to. request is :chat, or :judgment for a
15
+ # System One judge.
16
+ Call = Data.define(:label, :purpose, :harness, :handed_off, :role, :sample, :attempt, :attempts, :model, :provider,
17
+ :request)
18
+
19
+ # What an observer's block returns: the request's token usage and the model that answered, never the content.
20
+ # A count the provider didn't report is nil.
21
+ Usage = Data.define(:input_tokens, :output_tokens, :model)
22
+
23
+ BUILT_IN = %i[coolhand].freeze
24
+
25
+ module_function
26
+
27
+ # The observer itself, nil (auto-detect), or false (none). A built-in's name (:coolhand) or anything that
28
+ # responds to around is accepted, checked here so a mistake fails at setup, not inside the first request.
29
+ def validate(observer, where)
30
+ return observer if observer.nil? || observer == false
31
+
32
+ if observer.is_a?(Symbol)
33
+ return observer if BUILT_IN.include?(observer)
34
+
35
+ raise ConfigurationError, "#{where} observer must be one of #{BUILT_IN.map(&:inspect).join(', ')}, " \
36
+ "an object that responds to around, or false, got #{observer.inspect}"
37
+ end
38
+ return observer if observer.respond_to?(:around)
39
+
40
+ raise ConfigurationError, "#{where} observer must respond to around(call) { ... } (or be false), " \
41
+ "got #{observer.inspect}"
42
+ end
43
+
44
+ # The observer that wraps a request: the configured one, the Coolhand observer when nothing is configured and
45
+ # Coolhand is loaded and set up, or nil.
46
+ def resolve(configured)
47
+ case configured
48
+ when false then nil
49
+ when nil then Observers::Coolhand.new if Observers::Coolhand.available?
50
+ when Symbol then Observers::Coolhand.new
51
+ else configured
52
+ end
53
+ end
54
+
55
+ # Runs the request inside the resolved observer, if there is one. The block runs on the calling thread, so
56
+ # for samples it is the worker thread that makes the request. The observer gets the request's Usage back from
57
+ # its yield, not the response, so the model's output can't leave the process through it, and what it returns
58
+ # is ignored, and so is any rescue of the request's error: the error is raised again. An observer that never
59
+ # yields would make the request vanish, and one that yields twice would send it twice: both are setup mistakes.
60
+ def around(call)
61
+ observer = resolve(Squishling.config.observer)
62
+ return yield unless observer
63
+
64
+ yielded = false
65
+ response = nil
66
+ failure = nil
67
+ observer.around(call) do
68
+ if yielded
69
+ failure = ConfigurationError.new("#{call.label}: #{observer.class}#around must yield only once")
70
+ raise failure
71
+ end
72
+
73
+ yielded = true
74
+ begin
75
+ response = yield
76
+ rescue StandardError => e
77
+ failure = e
78
+ raise
79
+ end
80
+ usage(response)
81
+ end
82
+ unless yielded
83
+ raise ConfigurationError,
84
+ "#{call.label}: the #{observer.class} observer's around must yield to make the request"
85
+ end
86
+
87
+ raise failure if failure # an observer can't swallow the request's error: Squishling's taxonomy holds
88
+
89
+ response
90
+ end
91
+
92
+ def usage(response)
93
+ tokens = response.respond_to?(:tokens) ? response.tokens : nil
94
+ model = response.respond_to?(:model) ? response.model : nil
95
+ Usage.new(input_tokens: tokens&.input, output_tokens: tokens&.output,
96
+ model: model.respond_to?(:id) ? model.id : model)
97
+ end
98
+ private_class_method :usage
99
+ end
100
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Observers
5
+ # Declares each request's workload and template to Coolhand (Coolhand.with_context), so Coolhand files the
6
+ # logged call under them instead of matching it to a template. The workload is the squished method
7
+ # ("Class#method", described as that, plus "(rescue)" when squish! handed the call over); the template is the
8
+ # call's place in it: "Class#method/squish:L2" (an escalation attempt), "/sample:a", or "/judge". The model
9
+ # isn't in the key: it's on the log, and the place is the stable identity.
10
+ # Used automatically when the coolhand gem is loaded and configured (see Observer.resolve). See docs/coolhand.md.
11
+ class Coolhand
12
+ REPORTER = "squishling"
13
+
14
+ # Coolhand rejects keys longer than this.
15
+ MAX_KEY_LENGTH = 255
16
+
17
+ # Whether Coolhand is loaded, new enough to declare workloads, and set up to send logs.
18
+ def self.available?
19
+ return false unless defined?(::Coolhand) && ::Coolhand.respond_to?(:with_context)
20
+
21
+ configuration = ::Coolhand.respond_to?(:configuration) && ::Coolhand.configuration
22
+ return false unless configuration
23
+
24
+ configuration.enabled != false && !configuration.api_key.to_s.strip.empty?
25
+ rescue StandardError
26
+ false # nobody asked for this observer, so a Coolhand that can't say whether it's set up isn't ours to break
27
+ end
28
+
29
+ def self.load_library
30
+ require "coolhand"
31
+ end
32
+ private_class_method :load_library
33
+
34
+ # Raises ConfigurationError when the coolhand gem is missing or too old to declare workloads.
35
+ def initialize
36
+ self.class.send(:load_library) unless defined?(::Coolhand)
37
+ return if ::Coolhand.respond_to?(:with_context)
38
+
39
+ raise ConfigurationError, "observer :coolhand needs a coolhand gem version with Coolhand.with_context " \
40
+ "(declared workloads); upgrade the coolhand gem"
41
+ rescue LoadError
42
+ raise ConfigurationError, "observer :coolhand needs the coolhand gem; add `gem \"coolhand\"` to your Gemfile"
43
+ end
44
+
45
+ def around(call, &)
46
+ workload = call.label
47
+ template = "#{workload}/#{role_key(call)}"
48
+ return yield unless declarable?(workload) && declarable?(template)
49
+
50
+ ::Coolhand.with_context(
51
+ reporter: REPORTER,
52
+ workload: { key: workload, name: workload, description: description(call) },
53
+ template: { key: template, name: "#{workload} · #{role_name(call)}" },
54
+ &
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ # The squished method, marked "(rescue)" when squish! handed it to the LLM. Not the purpose text, which can be
61
+ # a proc that differs per call.
62
+ def description(call)
63
+ call.handed_off ? "#{call.label} (rescue)" : call.label
64
+ end
65
+
66
+ # Coolhand rejects, and drops the whole log for, a key over the limit, with surrounding whitespace, or with
67
+ # a leading, trailing or doubled "/".
68
+ def declarable?(key)
69
+ key.length <= MAX_KEY_LENGTH && key == key.strip && !key.start_with?("/") && !key.end_with?("/") &&
70
+ !key.include?("//")
71
+ end
72
+
73
+ def role_key(call)
74
+ case call.role
75
+ when :sample then "sample:#{call.sample}"
76
+ when :judge then "judge"
77
+ else "squish:L#{call.attempt}"
78
+ end
79
+ end
80
+
81
+ def role_name(call)
82
+ role_key(call).tr(":", " ")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -37,16 +37,21 @@ module Squishling
37
37
  raise ConfigurationError, "#{label} must be a Hash, got #{params.class}" unless params.is_a?(Hash)
38
38
 
39
39
  params = params.transform_keys(&:to_sym)
40
- reserved = params.keys & RESERVED_KEYS
41
- if reserved.any?
42
- raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
43
- "(controlled by Squishling/RubyLLM; use model:/provider:/output_schema)"
44
- end
40
+ reject_reserved!(params, RESERVED_KEYS, label, "; use model:/provider:/output_schema")
45
41
  reject_nested_reserved!(params, label)
46
42
  params[:thinking] = normalize_thinking(params[:thinking], label) unless params[:thinking].nil?
47
43
  params.freeze
48
44
  end
49
45
 
46
+ # Raises when params sets any of the keys Squishling owns. hint is appended inside the parenthetical.
47
+ def reject_reserved!(params, keys, label, hint = "")
48
+ reserved = params.keys & keys
49
+ return if reserved.empty?
50
+
51
+ raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
52
+ "(controlled by Squishling/RubyLLM#{hint})"
53
+ end
54
+
50
55
  # Nested keys are compared as symbols so a string-keyed "responseMimeType" can't slip past the check.
51
56
  # A non-Hash container would replace (not merge into) the one RubyLLM builds, wiping the strict format.
52
57
  # Validated containers are copied and frozen so they can't be mutated after the check.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # How many times each squished method has been called with models to rotate between (see ModelPath: steps tied on
5
+ # priority). The count is the call's turn: each tier of tied models starts that many steps further along, so
6
+ # successive calls give every model an even share of the first attempts. Counted per class and method, across
7
+ # threads.
8
+ module Rotation
9
+ # class => { method name => calls so far }. Weak keys, so classes replaced by code reloading can be collected.
10
+ COUNTS = ObjectSpace::WeakKeyMap.new
11
+ COUNTS_LOCK = Mutex.new
12
+ private_constant :COUNTS, :COUNTS_LOCK
13
+
14
+ class << self
15
+ # This call's turn, counting from 0, and counts it.
16
+ def advance(klass, name)
17
+ COUNTS_LOCK.synchronize do
18
+ counts = (COUNTS[klass] ||= {})
19
+ turn = counts.fetch(name, 0)
20
+ counts[name] = turn + 1
21
+ turn
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Run
5
+ # The follow-up messages a Run sends a model after an output was rejected.
6
+ module Messages
7
+ private
8
+
9
+ def retry_message(errors)
10
+ "Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
11
+ end
12
+
13
+ # The first message to a fresh chat after an earlier model's output was rejected. The rejected output is
14
+ # model-generated and may cross providers, so it is capped at Invoker::MAX_FORWARDED_CHARS.
15
+ def escalation_message(input, raw, errors)
16
+ previous = rejected_text(raw)
17
+ previous = "(an empty response)" if previous.strip.empty? || raw.nil?
18
+ if previous.length > Invoker::MAX_FORWARDED_CHARS
19
+ omitted = previous.length - Invoker::MAX_FORWARDED_CHARS
20
+ previous = "#{previous[0, Invoker::MAX_FORWARDED_CHARS]}... [truncated, #{omitted} more characters]"
21
+ end
22
+ "#{input}\n\nA previous attempt at this request returned:\n#{previous}\n" \
23
+ "It was rejected:\n- #{errors.join("\n- ")}\nRespond with corrected JSON only."
24
+ end
25
+
26
+ # The rejected output as text. Output that was rejected for being unrepresentable in JSON (Infinity, invalid
27
+ # UTF-8) can't be serialized or measured as is, so it is scrubbed or replaced rather than raising.
28
+ def rejected_text(raw)
29
+ return utf8(raw).scrub("?") if raw.is_a?(String)
30
+
31
+ JSON.generate(raw)
32
+ rescue JSON::JSONError
33
+ "(a response JSON can't represent)"
34
+ end
35
+
36
+ # A binary-tagged string counts every byte as valid, so it is read as UTF-8 before scrubbing.
37
+ def utf8(text)
38
+ text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,296 @@
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
+
17
+ private_constant :Messages
18
+
19
+ # One of #samples' outputs, between rounds.
20
+ Sample = Struct.new(:name, :stage, :chat, :rejected, :result)
21
+
22
+ SAMPLE_NAMES = ("a".."z").to_a.freeze
23
+ private_constant :Sample, :SAMPLE_NAMES
24
+
25
+ attr_reader :receiver, :inputs, :steps
26
+
27
+ def initialize(definition:, receiver:, inputs:, prompt:, payload:, purpose:, harness:)
28
+ @definition = definition
29
+ @receiver = receiver
30
+ @inputs = inputs
31
+ @prompt = prompt
32
+ @payload = payload
33
+ @purpose = purpose
34
+ @harness = harness
35
+ @steps = definition.escalation_path.freeze
36
+ @client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off)
37
+ @output_check = OutputCheck.new(definition, receiver, inputs)
38
+ @owner = Thread.current
39
+ @produced = {}.compare_by_identity
40
+ end
41
+
42
+ # Whether value is a result one of this run's primitives returned (not one built by hand).
43
+ def produced?(value)
44
+ @produced.key?(value)
45
+ end
46
+
47
+ # The squished method, as "Class#method".
48
+ def label
49
+ @definition.label
50
+ end
51
+
52
+ # The escalation's steps grouped into stages: runs of identical consecutive steps, the attempts of one model.
53
+ def stages
54
+ @stages ||= steps.slice_when { |before, after| before != after }.map(&:freeze).freeze
55
+ end
56
+
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.
59
+ # A stage (an Array of steps) is returned as is, and a single step is a stage of one attempt.
60
+ def stage(declaration)
61
+ return checked_stage([declaration]) if declaration.is_a?(ModelPath::Step)
62
+
63
+ if declaration.is_a?(Array)
64
+ return checked_stage(declaration) if declaration.any? && declaration.all?(ModelPath::Step)
65
+
66
+ raise ConfigurationError, "#{label}: a stage is the steps of one model, got #{declaration.inspect}"
67
+ end
68
+
69
+ step = Harness::OptionTypes.check(:step, declaration, label, :"harness step")
70
+ ModelPath.steps([step], provider: nil, params: @definition.params).freeze
71
+ end
72
+
73
+ # 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
75
+ # 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.
77
+ def escalate(*stages)
78
+ owner!
79
+ path = stages.empty? ? steps : stages.flat_map { |declared| stage(declared) }
80
+ escalate_with(path, @prompt)
81
+ end
82
+
83
+ # One sample per stage (the attempts of one model), each asked on its own chat, each retried per its stage's
84
+ # attempts like #escalate, with the requests of every round sent concurrently. Returns the outputs in order.
85
+ # Samples are named "a", "b", ... in squawk reports.
86
+ def samples(stages)
87
+ owner!
88
+ raise ConfigurationError, "#{label}: samples takes an Array of stages" unless stages.is_a?(Array)
89
+
90
+ stages = stages.map { |declared| stage(declared) }
91
+ raise ConfigurationError, "#{label}: samples needs at least one stage" if stages.empty?
92
+ if stages.size > SAMPLE_NAMES.size
93
+ raise ConfigurationError, "#{label}: samples takes at most #{SAMPLE_NAMES.size} stages"
94
+ end
95
+
96
+ if (mixed = stages.find { |sample_stage| sample_stage.uniq.size > 1 })
97
+ raise ConfigurationError, "#{label}: a sample is asked on one model, so its stage can't mix steps " \
98
+ "(got #{mixed.map(&:display_name).join(', ')})"
99
+ end
100
+
101
+ samples = stages.zip(SAMPLE_NAMES).map { |sample_stage, name| Sample.new(name, sample_stage) }
102
+ (1..stages.map(&:size).max).each do |attempt|
103
+ pending = samples.reject(&:result)
104
+ break if pending.empty?
105
+
106
+ round(pending, attempt)
107
+ end
108
+ samples.map(&:result)
109
+ end
110
+
111
+ # A judge that picks between two outputs (see Judge#verdict, which returns a Judge::Verdict), or rejects
112
+ # both. judge: is a model name or a Hash with model:, provider:, params:, and attempts: (as an escalation
113
+ # step, without priority:), plus type: :chat (default) or :judgment and, for :judgment, min_confidence:.
114
+ # Without one, the judge is the escalation's next stage after the first. instructions: is a String, or a
115
+ # Proc evaluated against the receiver, replacing Harness::DEFAULT_JUDGE_INSTRUCTIONS. The judge is resolved
116
+ # here, so a missing one raises ConfigurationError before any request is made.
117
+ def judge(judge: nil, instructions: nil)
118
+ owner!
119
+ judge = judge_step(judge) unless judge.nil?
120
+ Judge.new(definition: @definition, receiver: @receiver, client: @client, judge:, instructions:, path: steps,
121
+ purpose: @purpose, payload: @payload, escalate: method(:escalate_with), log_failure: method(:log_failure),
122
+ guard: method(:owner!), harness_type: @harness.type)
123
+ end
124
+
125
+ # Whether two outputs agree: compare (a Proc, evaluated against the receiver with the two results and the
126
+ # method's inputs, truthy for agreement) or, without one, whether they are exactly equal.
127
+ # compare is the developer's own code, so its exceptions propagate unwrapped.
128
+ def agree?(first, second, compare = nil)
129
+ owner!
130
+ return Schema.jsonify(first.to_h) == Schema.jsonify(second.to_h) unless compare
131
+
132
+ @receiver.instance_exec(first, second, **@inputs, &compare) ? true : false
133
+ end
134
+
135
+ # Logs a warning, "[Squishling] Class#method message", to Squishling.config.logger, if there is one.
136
+ def log(message)
137
+ Squishling.config.logger&.warn("[Squishling] #{label} #{message}")
138
+ end
139
+
140
+ private
141
+
142
+ # Steps made or changed by hand get the params checks a declared step does (no reserved keys).
143
+ def checked_stage(stage)
144
+ stage.each { |step| Params.normalize(step.params, "#{label} harness step params") }
145
+ stage.frozen? ? stage : stage.dup.freeze
146
+ end
147
+
148
+ # A judge declaration as the escalation step Judge takes: one step (no priority:), with its type.
149
+ def judge_step(declaration)
150
+ step = ModelPath.normalize_step(declaration, "#{label} judge", extras: Judge::STEP_KEYS)
151
+ ModelPath.single_step!(step, "#{label} judge: a judge is one step, so it")
152
+
153
+ type = step.fetch(:type, :chat)
154
+ type = :chat if type.nil?
155
+ step.merge(type: type.is_a?(String) ? type.to_sym : type)
156
+ end
157
+
158
+ def owner!
159
+ return if Thread.current.equal?(@owner)
160
+
161
+ raise ConfigurationError, "#{label}: a harness must call its run's primitives from the thread it runs on"
162
+ end
163
+
164
+ # Makes each attempt of the path until an output passes (see #escalate). The judge's prompt (role :judge)
165
+ # skips squish_validate and names the judge in errors and logs.
166
+ def escalate_with(path, prompt)
167
+ chat = nil
168
+ rejected = nil # [raw content, errors] of the last invalid output
169
+ models = []
170
+
171
+ path.each.with_index(1) do |step, attempt|
172
+ models << step.model
173
+ last = attempt == path.size
174
+ if chat && step == path[attempt - 2]
175
+ message = retry_message(rejected.last)
176
+ else
177
+ chat = start_chat(step, prompt)
178
+ message = rejected && step.forward_rejected ? escalation_message(prompt.input, *rejected) : prompt.input
179
+ end
180
+
181
+ begin
182
+ response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role)
183
+ rescue LLMError => e
184
+ squawk(prompt, path, attempt, nil, e)
185
+ raise if last
186
+
187
+ # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
188
+ chat = nil
189
+ log_failure(prompt.role, attempt, path, e.message)
190
+ next
191
+ end
192
+
193
+ result, errors = @output_check.call(response.content, prompt)
194
+ if errors.empty?
195
+ squawk(prompt, path, attempt, response, nil)
196
+ return produce(result, prompt)
197
+ end
198
+
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
203
+
204
+ rejected = [response.content, errors]
205
+ log_failure(prompt.role, attempt, path, errors.join("; "))
206
+ end
207
+ end
208
+
209
+ # One round of #samples: the pending samples' requests, sent concurrently, then each outcome recorded.
210
+ def round(pending, attempt)
211
+ jobs = pending.map do |sample|
212
+ step = sample.stage[attempt - 1]
213
+ message = sample_message(sample, step)
214
+ chat = sample.chat
215
+ attempts = sample.stage.size
216
+ -> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name) }
217
+ end
218
+ outcomes = LLMClient.concurrently(jobs)
219
+ # A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
220
+ fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
221
+ raise fatal if fatal
222
+
223
+ pending.zip(outcomes).each { |sample, outcome| record(sample, outcome, attempt) }
224
+ end
225
+
226
+ # The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
227
+ def sample_message(sample, step)
228
+ return retry_message(sample.rejected.last) if sample.chat
229
+
230
+ sample.chat = start_chat(step, @prompt)
231
+ sample.rejected && step.forward_rejected ? escalation_message(@prompt.input, *sample.rejected) : @prompt.input
232
+ end
233
+
234
+ def record(sample, (response, error), attempt)
235
+ stage = sample.stage
236
+ last = attempt == stage.size
237
+ role = "sample #{sample.name}"
238
+ if error
239
+ squawk(@prompt, stage, attempt, nil, error, sample:)
240
+ raise error if last
241
+
242
+ sample.chat = nil
243
+ return log_failure(role, attempt, stage, error.message)
244
+ end
245
+
246
+ result, errors = @output_check.call(response.content, @prompt)
247
+ if errors.empty?
248
+ squawk(@prompt, stage, attempt, response, nil, sample:)
249
+ return sample.result = produce(result, @prompt)
250
+ end
251
+
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
256
+
257
+ sample.rejected = [response.content, errors]
258
+ log_failure(role, attempt, stage, errors.join("; "))
259
+ end
260
+
261
+ # Remembers a result of the method's own output (a judge's verdict isn't one), so a harness can only return
262
+ # what a primitive produced.
263
+ def produce(result, prompt)
264
+ @produced[result] = true unless prompt.role == :judge
265
+ result
266
+ end
267
+
268
+ def start_chat(step, prompt)
269
+ @client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
270
+ end
271
+
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)
289
+ step = path[attempt - 1]
290
+ next_step = path[attempt]
291
+ action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
292
+ who = role ? "#{role} attempt" : "attempt"
293
+ log("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
294
+ end
295
+ end
296
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/squishling.rb CHANGED
@@ -10,7 +10,16 @@ require_relative "squishling/errors"
10
10
  require_relative "squishling/configuration"
11
11
  require_relative "squishling/params"
12
12
  require_relative "squishling/squawk"
13
+ require_relative "squishling/observer"
14
+ require_relative "squishling/observers/coolhand"
13
15
  require_relative "squishling/model_path"
16
+ require_relative "squishling/rotation"
17
+ require_relative "squishling/harness/option_types"
18
+ require_relative "squishling/harness/registry"
19
+ require_relative "squishling/harness/base"
20
+ require_relative "squishling/harness/escalation"
21
+ require_relative "squishling/harness/squishsum"
22
+ require_relative "squishling/harness/judged_squishsum"
14
23
  require_relative "squishling/harness"
15
24
  require_relative "squishling/result"
16
25
  require_relative "squishling/schema"
@@ -19,6 +28,8 @@ require_relative "squishling/appendices"
19
28
  require_relative "squishling/definition"
20
29
  require_relative "squishling/llm_client"
21
30
  require_relative "squishling/output_check"
31
+ require_relative "squishling/run/messages"
32
+ require_relative "squishling/run"
22
33
  require_relative "squishling/invoker"
23
34
  require_relative "squishling/judge"
24
35
  require_relative "squishling/router"
@@ -42,6 +53,27 @@ module Squishling
42
53
  @config = Configuration.new
43
54
  end
44
55
 
56
+ # Forgets the registered harnesses (the built-ins stay), e.g. between specs. Kept apart from reset_config!
57
+ # because harnesses are registered once at boot, and a suite that resets its config between examples
58
+ # shouldn't lose them.
59
+ def reset_harnesses!
60
+ Harness::Registry.reset!
61
+ end
62
+
63
+ # Registers a custom harness under a name, usable anywhere a built-in type is (harness: :name, or
64
+ # harness: { type: :name, option: value }, on a class, a method, a squish! call, or config.default_harness).
65
+ # Pass a Harness::Base subclass, or a block that becomes the body of one (see Harness::Base). Registered
66
+ # globally, so do it before the classes that use it load (an initializer). Raises ConfigurationError for a
67
+ # name that is invalid or already taken, including the built-in :escalation, :squishsum, :judged_squishsum.
68
+ def register_harness(name, klass = nil, &)
69
+ Harness::Registry.register(name, klass, &)
70
+ end
71
+
72
+ # The names of the harnesses that can be declared: the built-ins, then those registered.
73
+ def harnesses
74
+ Harness.types
75
+ end
76
+
45
77
  def included(base)
46
78
  raise ConfigurationError, "Squishling can only be included in a class" unless base.is_a?(Class)
47
79