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
@@ -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; order: (all steps or none, unique, lowest first) makes the order
12
- # explicit instead of positional.
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, and the fully resolved generation params.
15
- Step = Data.define(:model, :provider, :params)
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
- def steps(path, provider:, params:)
55
- path.flat_map do |step|
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?(:order) }
64
- return steps.map { |step| step.except(:order) } if numbered.zero?
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, "#{label}: give every step an order: or none (#{numbered} of #{steps.size} have one)"
83
+ raise ConfigurationError,
84
+ "#{label}: give every step a priority: or none (#{numbered} of #{steps.size} have one)"
68
85
  end
69
86
 
70
- orders = steps.map { |step| step[:order] }
71
- duplicates = orders.tally.select { |_, count| count > 1 }.keys
72
- raise ConfigurationError, "#{label}: duplicate order: #{duplicates.join(', ')}" if duplicates.any?
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
- steps.sort_by { |step| step[:order] }.map { |step| step.except(:order) }
95
+ def rotates?(tier)
96
+ tier.size > 1 && tier.none? { |step| step[:rotate] == false }
75
97
  end
76
98
 
77
- def normalize_step(step, label)
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 then { model: model_name(step, label), provider: nil, params: nil, attempts: 1 }
80
- when Hash then normalize_hash(step, label)
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
- if step.key?(:order) && !step[:order].is_a?(Integer)
100
- raise ConfigurationError, "#{label} (#{model}): order: must be an Integer, got #{step[:order].inspect}"
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
- step.key?(:order) ? normalized.merge(order: step[:order]) : normalized
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
@@ -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
- reserved = params.keys & RESERVED_KEYS
25
- if reserved.any?
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
@@ -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.definition.coerce(impl.call)
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, an instructions proc). Only recursion from the Ruby
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