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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +140 -0
- data/README.md +118 -31
- data/docs/configuration.md +75 -21
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +73 -9
- data/docs/harnesses.md +326 -0
- data/docs/measuring-tokens.md +140 -0
- data/docs/naming.md +43 -0
- data/docs/observers.md +80 -0
- data/docs/routing.md +36 -27
- data/docs/schemas.md +24 -5
- data/lib/squishling/appendices.rb +3 -3
- data/lib/squishling/class_methods.rb +60 -29
- data/lib/squishling/collisions.rb +54 -0
- data/lib/squishling/configuration.rb +32 -0
- data/lib/squishling/definition.rb +56 -27
- data/lib/squishling/errors.rb +22 -1
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +93 -0
- data/lib/squishling/harness/registry.rb +69 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +79 -0
- data/lib/squishling/invoker.rb +26 -188
- data/lib/squishling/judge.rb +186 -0
- data/lib/squishling/llm_client.rb +180 -0
- data/lib/squishling/model_path.rb +82 -25
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +109 -0
- data/lib/squishling/params.rb +47 -6
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/router.rb +8 -2
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/source.rb +5 -5
- data/lib/squishling/squawk.rb +52 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +56 -7
- 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
|
data/lib/squishling/source.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
# Ruby source for
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
data/lib/squishling/version.rb
CHANGED
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
|
-
|
|
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:
|
|
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/
|
|
63
|
-
# by key over them.
|
|
64
|
-
def squish!(
|
|
65
|
-
params: nil)
|
|
66
|
-
Router.hand_off(self, {
|
|
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.
|
|
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.
|
|
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.
|
|
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
|