squishling 0.2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +118 -31
  4. data/docs/configuration.md +75 -21
  5. data/docs/coolhand.md +66 -0
  6. data/docs/failures.md +73 -9
  7. data/docs/harnesses.md +326 -0
  8. data/docs/measuring-tokens.md +140 -0
  9. data/docs/naming.md +43 -0
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +36 -27
  12. data/docs/schemas.md +24 -5
  13. data/lib/squishling/appendices.rb +3 -3
  14. data/lib/squishling/class_methods.rb +60 -29
  15. data/lib/squishling/collisions.rb +54 -0
  16. data/lib/squishling/configuration.rb +32 -0
  17. data/lib/squishling/definition.rb +56 -27
  18. data/lib/squishling/errors.rb +22 -1
  19. data/lib/squishling/harness/base.rb +136 -0
  20. data/lib/squishling/harness/escalation.rb +22 -0
  21. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  22. data/lib/squishling/harness/option_types.rb +93 -0
  23. data/lib/squishling/harness/registry.rb +69 -0
  24. data/lib/squishling/harness/squishsum.rb +22 -0
  25. data/lib/squishling/harness.rb +79 -0
  26. data/lib/squishling/invoker.rb +26 -188
  27. data/lib/squishling/judge.rb +186 -0
  28. data/lib/squishling/llm_client.rb +180 -0
  29. data/lib/squishling/model_path.rb +82 -25
  30. data/lib/squishling/observer.rb +100 -0
  31. data/lib/squishling/observers/coolhand.rb +86 -0
  32. data/lib/squishling/output_check.rb +109 -0
  33. data/lib/squishling/params.rb +47 -6
  34. data/lib/squishling/rotation.rb +26 -0
  35. data/lib/squishling/router.rb +8 -2
  36. data/lib/squishling/run/messages.rb +42 -0
  37. data/lib/squishling/run.rb +296 -0
  38. data/lib/squishling/source.rb +5 -5
  39. data/lib/squishling/squawk.rb +52 -0
  40. data/lib/squishling/version.rb +1 -1
  41. data/lib/squishling.rb +56 -7
  42. metadata +25 -3
@@ -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,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- # Ruby source for append_instructions items: a class or module body, or a single method, rendered as a
4
+ # Ruby source for append_to_purpose items: a class or module body, or a single method, rendered as a
5
5
  # fenced code block. Parsed with Prism (a default gem since Ruby 3.3), loaded the first time it's needed.
6
6
  module Source
7
7
  # owner => { method name, or nil for the module itself => rendered source }. Weak keys, so classes replaced
@@ -30,7 +30,7 @@ module Squishling
30
30
  def module_source(mod)
31
31
  constant = constant_location(mod)
32
32
  sources = constant ? class_bodies(mod, constant) : method_defs(mod)
33
- raise ConfigurationError, "append_instructions: source for #{mod.inspect} isn't available" if sources.empty?
33
+ raise ConfigurationError, "append_to_purpose: source for #{mod.inspect} isn't available" if sources.empty?
34
34
 
35
35
  [display_name(mod), sources.join("\n\n")]
36
36
  end
@@ -58,7 +58,7 @@ module Squishling
58
58
  def method_source(method)
59
59
  label = method_label(method)
60
60
  source = def_source(method)
61
- raise ConfigurationError, "append_instructions: source for #{label} isn't available" unless source
61
+ raise ConfigurationError, "append_to_purpose: source for #{label} isn't available" unless source
62
62
 
63
63
  [label, source]
64
64
  end
@@ -84,7 +84,7 @@ module Squishling
84
84
  # A squished method resolves to Squishling's wrapper; show the implementation beneath it.
85
85
  def unwrap(method)
86
86
  Wrapper.implementation(method) or
87
- raise ConfigurationError, "append_instructions: #{method.name} has no implementation to show"
87
+ raise ConfigurationError, "append_to_purpose: #{method.name} has no implementation to show"
88
88
  end
89
89
 
90
90
  # Named after the def that's shown, so an alias is labeled by its original name.
@@ -162,7 +162,7 @@ module Squishling
162
162
  text = File.read(file, encoding: Encoding::UTF_8).scrub
163
163
  [Prism.parse(text).value, text.lines]
164
164
  rescue SystemCallError => e
165
- raise ConfigurationError, "append_instructions: can't read #{file} (#{e.class})"
165
+ raise ConfigurationError, "append_to_purpose: can't read #{file} (#{e.class})"
166
166
  end
167
167
 
168
168
  # Give the node's first line its source line's indentation (it may start mid-line, as in
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
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.
7
+ module Squawk
8
+ KEYWORD_TYPES = %i[key keyreq].freeze
9
+ POSITIONAL_TYPES = %i[req opt].freeze
10
+ KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
11
+ KEYWORDS = %i[output metadata error].freeze
12
+
13
+ module_function
14
+
15
+ # The hook itself, or nil/false to inherit/silence. Anything else must respond to call and take keywords
16
+ # only, which is checked here so a mismatch fails at setup, not inside the first attempt.
17
+ def validate(hook, where)
18
+ return hook if hook.nil? || hook == false
19
+
20
+ unless hook.respond_to?(:call)
21
+ raise ConfigurationError, "#{where} squawk must respond to call (or be false), got #{hook.inspect}"
22
+ end
23
+ if positional_only?(parameters(hook))
24
+ raise ConfigurationError,
25
+ "#{where} squawk is called with keywords (output:, metadata:, error:), not positional arguments"
26
+ end
27
+ unknown = parameters(hook).filter_map { |type, name| name if type == :keyreq && !KEYWORDS.include?(name) }
28
+ raise ConfigurationError, "#{where} squawk requires unknown keyword(s) #{unknown.join(', ')}" if unknown.any?
29
+
30
+ hook
31
+ end
32
+
33
+ # Calls the hook with the keywords it declares, or all of them when it takes **, so new metadata fields
34
+ # never break an existing lambda. Exceptions it raises are the user's own and propagate as-is.
35
+ def call(hook, **payload)
36
+ parameters = parameters(hook)
37
+ payload = payload.slice(*parameters.filter_map { |type, name| name if KEYWORD_TYPES.include?(type) }) \
38
+ unless parameters.any? { |type, _| type == :keyrest }
39
+ hook.call(**payload)
40
+ end
41
+
42
+ # Positional parameters, or a bare splat, would receive the keywords as a Hash (or not at all).
43
+ def positional_only?(parameters)
44
+ types = parameters.map(&:first)
45
+ types.intersect?(POSITIONAL_TYPES) || (types.include?(:rest) && !types.intersect?(KEYWORD_CAPTURE_TYPES))
46
+ end
47
+
48
+ def parameters(hook)
49
+ (hook.is_a?(Proc) || hook.is_a?(Method) ? hook : hook.method(:call)).parameters
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/squishling.rb CHANGED
@@ -9,16 +9,33 @@ require_relative "squishling/version"
9
9
  require_relative "squishling/errors"
10
10
  require_relative "squishling/configuration"
11
11
  require_relative "squishling/params"
12
+ require_relative "squishling/squawk"
13
+ require_relative "squishling/observer"
14
+ require_relative "squishling/observers/coolhand"
12
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"
23
+ require_relative "squishling/harness"
13
24
  require_relative "squishling/result"
14
25
  require_relative "squishling/schema"
15
26
  require_relative "squishling/source"
16
27
  require_relative "squishling/appendices"
17
28
  require_relative "squishling/definition"
29
+ require_relative "squishling/llm_client"
30
+ require_relative "squishling/output_check"
31
+ require_relative "squishling/run/messages"
32
+ require_relative "squishling/run"
18
33
  require_relative "squishling/invoker"
34
+ require_relative "squishling/judge"
19
35
  require_relative "squishling/router"
20
36
  require_relative "squishling/wrapper"
21
37
  require_relative "squishling/class_methods"
38
+ require_relative "squishling/collisions"
22
39
 
23
40
  # Include Squishling in a class to make it elastic: its squished methods either run their
24
41
  # Ruby implementation or send their inputs through an LLM and return schema-validated results.
@@ -36,10 +53,35 @@ module Squishling
36
53
  @config = Configuration.new
37
54
  end
38
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
+
39
77
  def included(base)
40
78
  raise ConfigurationError, "Squishling can only be included in a class" unless base.is_a?(Class)
41
79
 
80
+ # A redundant include in a subclass overrides nothing new: the superclass's own methods already won.
81
+ Collisions.check!(base) unless base.superclass&.include?(Squishling)
42
82
  base.extend(ClassMethods)
83
+ # `result` is a convenience alias for squishling_result; a class that already has a `result` keeps it.
84
+ base.include(ResultAlias) unless base.method_defined?(:result) || base.private_method_defined?(:result)
43
85
  base.send(:squishling_install_wrapper)
44
86
  end
45
87
  end
@@ -53,16 +95,23 @@ module Squishling
53
95
  frame.definition.build_result(attrs || kwargs, squished: false)
54
96
  end
55
97
 
56
- alias_method :result, :squishling_result
98
+ # Included separately, and only when the class has no `result` of its own, so `result` never shadows an
99
+ # inherited one. A module (not an alias on the class) keeps it out of the class's own source.
100
+ module ResultAlias
101
+ def result(...)
102
+ squishling_result(...)
103
+ end
104
+ end
57
105
 
58
106
  # Hand the squished method currently executing to the LLM, e.g. from a `rescue` when the Ruby path can't
59
107
  # handle this input. Returns the typed result (squished? true, or false when the declared fallback supplied
60
- # it); return it from the method. The overrides apply to this call only: append_instructions adds to (or,
108
+ # it); return it from the method. The overrides apply to this call only: append_to_purpose adds to (or,
61
109
  # with false, replaces) the declared sections, context is sent alongside the declared squish_context,
62
- # model/escalation (one or the other)/provider/instructions replace the declared ones, and params merge key
63
- # by key over them.
64
- def squish!(append_instructions: nil, context: nil, instructions: nil, model: nil, escalation: nil, provider: nil,
65
- params: nil)
66
- Router.hand_off(self, { append_instructions:, context:, instructions:, model:, escalation:, provider:, params: })
110
+ # model/escalation (one or the other)/provider/purpose/harness replace the declared ones, and params merge
111
+ # key by key over them.
112
+ def squish!(append_to_purpose: nil, context: nil, purpose: nil, model: nil, escalation: nil, provider: nil,
113
+ params: nil, harness: nil)
114
+ Router.hand_off(self, { append_to_purpose:, context:, purpose:, model:, escalation:, provider:, params:,
115
+ harness: })
67
116
  end
68
117
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: squishling
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '2.0'
32
+ version: '2.1'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '2.0'
39
+ version: '2.1'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: schematist
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -71,22 +71,44 @@ files:
71
71
  - Rakefile
72
72
  - SECURITY.md
73
73
  - docs/configuration.md
74
+ - docs/coolhand.md
74
75
  - docs/failures.md
76
+ - docs/harnesses.md
77
+ - docs/measuring-tokens.md
78
+ - docs/naming.md
79
+ - docs/observers.md
75
80
  - docs/routing.md
76
81
  - docs/schemas.md
77
82
  - lib/squishling.rb
78
83
  - lib/squishling/appendices.rb
79
84
  - lib/squishling/class_methods.rb
85
+ - lib/squishling/collisions.rb
80
86
  - lib/squishling/configuration.rb
81
87
  - lib/squishling/definition.rb
82
88
  - lib/squishling/errors.rb
89
+ - lib/squishling/harness.rb
90
+ - lib/squishling/harness/base.rb
91
+ - lib/squishling/harness/escalation.rb
92
+ - lib/squishling/harness/judged_squishsum.rb
93
+ - lib/squishling/harness/option_types.rb
94
+ - lib/squishling/harness/registry.rb
95
+ - lib/squishling/harness/squishsum.rb
83
96
  - lib/squishling/invoker.rb
97
+ - lib/squishling/judge.rb
98
+ - lib/squishling/llm_client.rb
84
99
  - lib/squishling/model_path.rb
100
+ - lib/squishling/observer.rb
101
+ - lib/squishling/observers/coolhand.rb
102
+ - lib/squishling/output_check.rb
85
103
  - lib/squishling/params.rb
86
104
  - lib/squishling/result.rb
105
+ - lib/squishling/rotation.rb
87
106
  - lib/squishling/router.rb
107
+ - lib/squishling/run.rb
108
+ - lib/squishling/run/messages.rb
88
109
  - lib/squishling/schema.rb
89
110
  - lib/squishling/source.rb
111
+ - lib/squishling/squawk.rb
90
112
  - lib/squishling/version.rb
91
113
  - lib/squishling/wrapper.rb
92
114
  homepage: https://github.com/Coolhand-Labs/squishling