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
|
@@ -8,13 +8,22 @@ module Squishling
|
|
|
8
8
|
# "claude-sonnet-5-5",
|
|
9
9
|
# { model: "claude-opus-5-5", params: { thinking: { effort: :high } } }
|
|
10
10
|
# ]
|
|
11
|
-
# attempts: (default 1) retries a step;
|
|
12
|
-
#
|
|
11
|
+
# attempts: (default 1) retries a step; priority: (all steps or none, lowest first) makes the order explicit
|
|
12
|
+
# instead of positional. Steps sharing a priority are one tier: each call rotates which of them goes first
|
|
13
|
+
# (see Rotation), and the others follow before the next tier, unless a step in the tier sets rotate: false.
|
|
14
|
+
# order: is the deprecated name of priority:. forward_rejected: false starts a step from the original input
|
|
15
|
+
# alone, without the previous step's rejected output.
|
|
13
16
|
module ModelPath
|
|
14
|
-
# One attempt: the model, its provider,
|
|
15
|
-
|
|
17
|
+
# One attempt: the model, its provider, the fully resolved generation params, and whether the previous
|
|
18
|
+
# step's rejected output is shown to it.
|
|
19
|
+
Step = Data.define(:model, :provider, :params, :forward_rejected) do
|
|
20
|
+
# The model for logs and messages (nil means RubyLLM's default model).
|
|
21
|
+
def display_name
|
|
22
|
+
model || "RubyLLM default model"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
16
25
|
|
|
17
|
-
STEP_KEYS = %i[model provider params attempts order].freeze
|
|
26
|
+
STEP_KEYS = %i[model provider params attempts priority order rotate forward_rejected].freeze
|
|
18
27
|
|
|
19
28
|
module_function
|
|
20
29
|
|
|
@@ -50,42 +59,62 @@ module Squishling
|
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
# One Step per attempt. The level's provider applies to steps that don't name their own, and each
|
|
53
|
-
# step's params are merged over the level-resolved params.
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
# step's params are merged over the level-resolved params. turn is this call's place in the method's rotation
|
|
63
|
+
# (see Rotation): each tier of tied priorities starts turn steps further along.
|
|
64
|
+
def steps(path, provider:, params:, turn: 0)
|
|
65
|
+
rotated(path, turn).flat_map do |step|
|
|
56
66
|
attempt = Step.new(model: step[:model], provider: step[:provider] || provider,
|
|
57
|
-
params: Params.resolve(params, step[:params]))
|
|
67
|
+
params: Params.resolve(params, step[:params]), forward_rejected: step[:forward_rejected])
|
|
58
68
|
[attempt] * step[:attempts]
|
|
59
69
|
end
|
|
60
70
|
end
|
|
61
71
|
|
|
72
|
+
# Whether any tier of the path has several models to rotate between.
|
|
73
|
+
def rotating?(path)
|
|
74
|
+
tiers(path).any? { |tier| rotates?(tier) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Steps sorted by priority, lowest first, ties kept in the order declared.
|
|
62
78
|
def ordered(steps, label)
|
|
63
|
-
numbered = steps.count { |step| step.key?(:
|
|
64
|
-
return steps
|
|
79
|
+
numbered = steps.count { |step| step.key?(:priority) }
|
|
80
|
+
return steps if numbered.zero?
|
|
65
81
|
|
|
66
82
|
if numbered < steps.size
|
|
67
|
-
raise ConfigurationError,
|
|
83
|
+
raise ConfigurationError,
|
|
84
|
+
"#{label}: give every step a priority: or none (#{numbered} of #{steps.size} have one)"
|
|
68
85
|
end
|
|
69
86
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
steps.each_with_index.sort_by { |step, index| [step[:priority], index] }.map(&:first)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Runs of steps with the same priority. A step without one is a tier of its own.
|
|
91
|
+
def tiers(path)
|
|
92
|
+
path.slice_when { |before, after| before[:priority].nil? || before[:priority] != after[:priority] }
|
|
93
|
+
end
|
|
73
94
|
|
|
74
|
-
|
|
95
|
+
def rotates?(tier)
|
|
96
|
+
tier.size > 1 && tier.none? { |step| step[:rotate] == false }
|
|
75
97
|
end
|
|
76
98
|
|
|
77
|
-
def
|
|
99
|
+
def rotated(path, turn)
|
|
100
|
+
tiers(path).flat_map { |tier| rotates?(tier) ? tier.rotate(turn % tier.size) : tier }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# extras: names more keys a step Hash may carry for its caller (a judge's type:, say). They are kept as given,
|
|
104
|
+
# for the caller to check.
|
|
105
|
+
def normalize_step(step, label, extras: [])
|
|
78
106
|
case step
|
|
79
|
-
when String, Symbol
|
|
80
|
-
|
|
107
|
+
when String, Symbol
|
|
108
|
+
{ model: model_name(step, label), provider: nil, params: nil, attempts: 1, forward_rejected: true }
|
|
109
|
+
when Hash then normalize_hash(step, label, extras)
|
|
81
110
|
else
|
|
82
111
|
raise ConfigurationError, "#{label}: each step must be a model name or a Hash with model:, got #{step.inspect}"
|
|
83
112
|
end
|
|
84
113
|
end
|
|
85
114
|
|
|
86
|
-
def normalize_hash(step, label)
|
|
115
|
+
def normalize_hash(step, label, extras)
|
|
87
116
|
step = step.transform_keys(&:to_sym)
|
|
88
|
-
unknown = step.keys - STEP_KEYS
|
|
117
|
+
unknown = step.keys - STEP_KEYS - extras
|
|
89
118
|
raise ConfigurationError, "#{label}: unknown step option(s) #{unknown.join(', ')}" if unknown.any?
|
|
90
119
|
unless step[:model].is_a?(String) || step[:model].is_a?(Symbol)
|
|
91
120
|
raise ConfigurationError, "#{label}: a step needs a model: name, got #{step.inspect}"
|
|
@@ -96,13 +125,41 @@ module Squishling
|
|
|
96
125
|
unless attempts.is_a?(Integer) && attempts.positive?
|
|
97
126
|
raise ConfigurationError, "#{label} (#{model}): attempts: must be a positive Integer, got #{attempts.inspect}"
|
|
98
127
|
end
|
|
99
|
-
|
|
100
|
-
|
|
128
|
+
|
|
129
|
+
step = priority_alias(step, label, model)
|
|
130
|
+
if step.key?(:priority) && !step[:priority].is_a?(Integer)
|
|
131
|
+
raise ConfigurationError, "#{label} (#{model}): priority: must be an Integer, got #{step[:priority].inspect}"
|
|
132
|
+
end
|
|
133
|
+
if step.key?(:rotate) && ![true, false].include?(step[:rotate])
|
|
134
|
+
raise ConfigurationError, "#{label} (#{model}): rotate: must be true or false, got #{step[:rotate].inspect}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
forward_rejected = step.fetch(:forward_rejected, true)
|
|
138
|
+
unless [true, false].include?(forward_rejected)
|
|
139
|
+
raise ConfigurationError,
|
|
140
|
+
"#{label} (#{model}): forward_rejected: must be true or false, got #{forward_rejected.inspect}"
|
|
101
141
|
end
|
|
102
142
|
|
|
103
143
|
params = step[:params] && Params.normalize(step[:params], "#{label} (#{model}) params")
|
|
104
|
-
normalized = { model:, provider: step[:provider], params:, attempts: }
|
|
105
|
-
|
|
144
|
+
normalized = { model:, provider: step[:provider], params:, attempts:, forward_rejected: }
|
|
145
|
+
normalized.merge(step.slice(:priority, :rotate, *extras))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A step used on its own (a judge, a harness's step) can't take part in a tier, so it takes no priority: or
|
|
149
|
+
# rotate:. who names it for the message.
|
|
150
|
+
def single_step!(step, who)
|
|
151
|
+
return unless step.key?(:priority) || step.key?(:rotate)
|
|
152
|
+
|
|
153
|
+
raise ConfigurationError, "#{who} takes no priority: or rotate:"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# order: is the deprecated name of priority:.
|
|
157
|
+
def priority_alias(step, label, model)
|
|
158
|
+
return step unless step.key?(:order)
|
|
159
|
+
raise ConfigurationError, "#{label} (#{model}): give priority: or order:, not both" if step.key?(:priority)
|
|
160
|
+
|
|
161
|
+
warn "[Squishling] #{label} (#{model}): order: is deprecated, use priority:"
|
|
162
|
+
step.except(:order).merge(priority: step[:order])
|
|
106
163
|
end
|
|
107
164
|
|
|
108
165
|
def model_name(model, label)
|
|
@@ -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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Turns a model response into a typed result: parses it, validates it against the schema, and runs the
|
|
5
|
+
# method's squish_validate check.
|
|
6
|
+
class OutputCheck
|
|
7
|
+
MAX_ERRORS = 20
|
|
8
|
+
UNREPRESENTABLE = "response contained a value JSON can't represent (such as Infinity or invalid UTF-8)"
|
|
9
|
+
|
|
10
|
+
def initialize(definition, receiver, inputs)
|
|
11
|
+
@definition = definition
|
|
12
|
+
@receiver = receiver
|
|
13
|
+
@inputs = inputs
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Parses and validates a response: [typed result, []] when it passes, [nil or result, errors] otherwise.
|
|
17
|
+
# The judge's role skips squish_validate, which checks the operation's output, not a verdict.
|
|
18
|
+
def call(content, prompt)
|
|
19
|
+
data, errors = parse(content)
|
|
20
|
+
errors = prompt.schema.validate(data) if errors.empty?
|
|
21
|
+
return [nil, cap(errors)] if errors.any?
|
|
22
|
+
|
|
23
|
+
result = prompt.schema.build(data, squished: true)
|
|
24
|
+
[result, prompt.role == :judge ? [] : validator_errors(result)]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# RubyLLM parses structured output itself and leaves the raw string when that fails, so
|
|
30
|
+
# content is a Hash on success, or a String/nil when the model refused, was cut off, or
|
|
31
|
+
# ignored the schema.
|
|
32
|
+
def parse(content)
|
|
33
|
+
return [nil, [UNREPRESENTABLE]] if content.is_a?(String) && !content.valid_encoding?
|
|
34
|
+
return [nil, ["response was empty"]] if content.nil? || (content.is_a?(String) && content.strip.empty?)
|
|
35
|
+
|
|
36
|
+
data = content.is_a?(String) ? JSON.parse(strip_code_fence(content)) : content
|
|
37
|
+
representable?(data) ? [data, []] : [nil, [UNREPRESENTABLE]]
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
[nil, ["response was not valid JSON#{parse_position(e)}"]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# JSON.parse turns an out-of-range number such as 1e400 into Infinity, and accepts invalid UTF-8 in a string.
|
|
43
|
+
# Both can satisfy the schema but can't be serialized again (and the deterministic path rejects them), so
|
|
44
|
+
# they are invalid output.
|
|
45
|
+
def representable?(data)
|
|
46
|
+
JSON.generate(data)
|
|
47
|
+
true
|
|
48
|
+
rescue JSON::GeneratorError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A long output can fail the schema thousands of times over; every error goes into the retry message and
|
|
53
|
+
# the log, so only the first few are kept.
|
|
54
|
+
def cap(errors)
|
|
55
|
+
return errors if errors.size <= MAX_ERRORS
|
|
56
|
+
|
|
57
|
+
errors.first(MAX_ERRORS) + ["... and #{errors.size - MAX_ERRORS} more errors"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The parser's message quotes a snippet of the response, which must not reach error messages or logs
|
|
61
|
+
# (the raw output lives only in InvalidOutputError#raw), so only the position is kept.
|
|
62
|
+
def parse_position(error)
|
|
63
|
+
position = error.message.strip.match(/ at line (\d+) column (\d+)\z/)
|
|
64
|
+
position ? " (at line #{position[1]} column #{position[2]})" : ""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Models without native structured output sometimes wrap JSON in a markdown code fence.
|
|
68
|
+
def strip_code_fence(text)
|
|
69
|
+
text[/\A\s*```(?:json)?\s*\n(.*?)\n\s*```\s*\z/m, 1] || text
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Runs the squish_validate check, if any. Exceptions it raises are the user's own and propagate as-is.
|
|
73
|
+
def validator_errors(result)
|
|
74
|
+
validator = @definition.validator
|
|
75
|
+
return [] unless validator
|
|
76
|
+
|
|
77
|
+
value = @receiver.instance_exec(result, **@inputs, &validator)
|
|
78
|
+
case value
|
|
79
|
+
when nil, true then []
|
|
80
|
+
when false then ["the output was rejected by squish_validate"]
|
|
81
|
+
when String, Array then Array(value).flatten.compact.map(&:to_s).reject { |message| message.strip.empty? }
|
|
82
|
+
else
|
|
83
|
+
return contract_errors(value) if value.respond_to?(:success?) && value.respond_to?(:errors)
|
|
84
|
+
|
|
85
|
+
raise ConfigurationError, "#{@definition.label}: squish_validate must return nil, true, false, a String, " \
|
|
86
|
+
"an Array of Strings, or a validation result, got #{value.class}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A dry-validation style result: errors.to_h is { key => ["message", ...] }, nested for nested keys.
|
|
91
|
+
def contract_errors(value)
|
|
92
|
+
return [] if value.success?
|
|
93
|
+
|
|
94
|
+
errors = value.errors
|
|
95
|
+
errors = errors.to_h if !errors.is_a?(Array) && errors.respond_to?(:to_h)
|
|
96
|
+
messages = errors.is_a?(Hash) ? flatten_messages(errors) : Array(errors).map(&:to_s)
|
|
97
|
+
messages.empty? ? ["the output was rejected by squish_validate"] : messages
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def flatten_messages(errors, prefix = nil)
|
|
101
|
+
errors.flat_map do |key, value|
|
|
102
|
+
path = [prefix, key].compact.join(".")
|
|
103
|
+
next flatten_messages(value, path) if value.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
Array(value).map { |message| path.empty? ? message.to_s : "#{path} #{message}" }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/squishling/params.rb
CHANGED
|
@@ -11,9 +11,25 @@ module Squishling
|
|
|
11
11
|
RESERVED_KEYS = %i[
|
|
12
12
|
model messages input inputs instructions contents system system_instruction systemInstruction cachedContent
|
|
13
13
|
stream stream_options store include response_format text output_config outputConfig tools tool_choice
|
|
14
|
-
toolConfig schema
|
|
14
|
+
toolConfig tool_config schema
|
|
15
15
|
].freeze
|
|
16
16
|
|
|
17
|
+
# Some protocols carry the structured-output format (or tool selection) inside a container that is
|
|
18
|
+
# itself allowed, because it also holds ordinary settings (Gemini's topK, Mistral's top_p, ...). RubyLLM
|
|
19
|
+
# deep-merges provider options, so these nested keys would override the strict format just like the
|
|
20
|
+
# top-level ones. Re-check each protocol's render_payload (ruby_llm protocols/*/chat.rb) on every
|
|
21
|
+
# ruby_llm upgrade.
|
|
22
|
+
GENERATION_CONFIG_RESERVED_KEYS = %i[
|
|
23
|
+
responseMimeType response_mime_type responseSchema response_schema responseJsonSchema response_json_schema
|
|
24
|
+
response_format tools tool_choice toolConfig tool_config
|
|
25
|
+
].freeze
|
|
26
|
+
COMPLETION_ARGS_RESERVED_KEYS = %i[response_format tools tool_choice].freeze
|
|
27
|
+
NESTED_RESERVED_KEYS = {
|
|
28
|
+
generationConfig: GENERATION_CONFIG_RESERVED_KEYS, # Gemini
|
|
29
|
+
generation_config: GENERATION_CONFIG_RESERVED_KEYS, # Gemini Interactions
|
|
30
|
+
completion_args: COMPLETION_ARGS_RESERVED_KEYS # Mistral Conversations
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
17
33
|
module_function
|
|
18
34
|
|
|
19
35
|
# Validates and symbolizes one layer. nil values are kept so a layer can unset an inherited key.
|
|
@@ -21,15 +37,40 @@ module Squishling
|
|
|
21
37
|
raise ConfigurationError, "#{label} must be a Hash, got #{params.class}" unless params.is_a?(Hash)
|
|
22
38
|
|
|
23
39
|
params = params.transform_keys(&:to_sym)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
|
|
27
|
-
"(controlled by Squishling/RubyLLM; use model:/provider:/output_schema)"
|
|
28
|
-
end
|
|
40
|
+
reject_reserved!(params, RESERVED_KEYS, label, "; use model:/provider:/output_schema")
|
|
41
|
+
reject_nested_reserved!(params, label)
|
|
29
42
|
params[:thinking] = normalize_thinking(params[:thinking], label) unless params[:thinking].nil?
|
|
30
43
|
params.freeze
|
|
31
44
|
end
|
|
32
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
|
+
|
|
55
|
+
# Nested keys are compared as symbols so a string-keyed "responseMimeType" can't slip past the check.
|
|
56
|
+
# A non-Hash container would replace (not merge into) the one RubyLLM builds, wiping the strict format.
|
|
57
|
+
# Validated containers are copied and frozen so they can't be mutated after the check.
|
|
58
|
+
def reject_nested_reserved!(params, label)
|
|
59
|
+
nested = NESTED_RESERVED_KEYS.flat_map do |container, reserved|
|
|
60
|
+
value = params[container]
|
|
61
|
+
next [] if value.nil?
|
|
62
|
+
raise ConfigurationError, "#{label}: #{container} must be a Hash, got #{value.class}" unless value.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
params[container] = value.dup.freeze
|
|
65
|
+
(value.keys.filter_map { |key| key.to_sym if key.respond_to?(:to_sym) } & reserved)
|
|
66
|
+
.map { |key| "#{container}.#{key}" }
|
|
67
|
+
end
|
|
68
|
+
return if nested.empty?
|
|
69
|
+
|
|
70
|
+
raise ConfigurationError, "#{label}: #{nested.join(', ')} can't be set through params " \
|
|
71
|
+
"(controls the strict output format or tools; use output_schema)"
|
|
72
|
+
end
|
|
73
|
+
|
|
33
74
|
# Later layers override earlier ones key by key; nil removes the key (back to the provider default).
|
|
34
75
|
def resolve(*layers)
|
|
35
76
|
layers.compact.reduce({}) { |merged, layer| merged.merge(layer) }.compact
|
|
@@ -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
|
data/lib/squishling/router.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Squishling
|
|
|
13
13
|
class << self
|
|
14
14
|
def dispatch(receiver, name, args, kwargs, impl, wrapper:)
|
|
15
15
|
if (frame = enclosing_frame(receiver, name, wrapper))
|
|
16
|
-
return frame
|
|
16
|
+
return coerce_inner(frame, impl.call)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
definition = receiver.class.squishling_definition(name)
|
|
@@ -57,6 +57,12 @@ module Squishling
|
|
|
57
57
|
|
|
58
58
|
private
|
|
59
59
|
|
|
60
|
+
# An inner call (`super`, or the method called from squish_when or a fallback) hands its value to user code
|
|
61
|
+
# that may reshape it, so only a Hash is typed (for accessor access); the outermost return is validated in full.
|
|
62
|
+
def coerce_inner(frame, value)
|
|
63
|
+
value.is_a?(Hash) ? frame.definition.coerce(value) : value
|
|
64
|
+
end
|
|
65
|
+
|
|
60
66
|
def route_to_llm(frame, definition, reason)
|
|
61
67
|
log(definition, reason)
|
|
62
68
|
previous = frame.phase
|
|
@@ -73,7 +79,7 @@ module Squishling
|
|
|
73
79
|
|
|
74
80
|
# The call in progress that this one is part of, which runs its Ruby implementation without routing again:
|
|
75
81
|
# a subclass override's `super` (entered through an ancestor's wrapper), or a call made while deciding or on
|
|
76
|
-
# the LLM path (squish_when, squish_fallback,
|
|
82
|
+
# the LLM path (squish_when, squish_fallback, a purpose proc). Only recursion from the Ruby
|
|
77
83
|
# implementation itself is a new call, routed on its own.
|
|
78
84
|
def enclosing_frame(receiver, name, wrapper)
|
|
79
85
|
frame = frames.reverse_each.find do |candidate|
|
|
@@ -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
|