squishling 0.3.0 → 0.5.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -21
@@ -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
@@ -5,6 +5,7 @@ module Squishling
5
5
  # method's squish_validate check.
6
6
  class OutputCheck
7
7
  MAX_ERRORS = 20
8
+ EMPTY = "response was empty"
8
9
  UNREPRESENTABLE = "response contained a value JSON can't represent (such as Infinity or invalid UTF-8)"
9
10
 
10
11
  def initialize(definition, receiver, inputs)
@@ -13,32 +14,34 @@ module Squishling
13
14
  @inputs = inputs
14
15
  end
15
16
 
16
- # Parses and validates a response: [typed result, []] when it passes, [nil or result, errors] otherwise.
17
+ # Parses and validates a response: [typed result, []] when it passes, [nil or result, error Details] otherwise.
17
18
  # The judge's role skips squish_validate, which checks the operation's output, not a verdict.
18
19
  def call(content, prompt)
19
20
  data, errors = parse(content)
20
- errors = prompt.schema.validate(data) if errors.empty?
21
+ errors = InvalidOutputError::Detail.from(prompt.schema.validate(data), :schema) if errors.empty?
21
22
  return [nil, cap(errors)] if errors.any?
22
23
 
23
24
  result = prompt.schema.build(data, squished: true)
24
- [result, prompt.role == :judge ? [] : validator_errors(result)]
25
+ [result, prompt.role == :judge ? [] : InvalidOutputError::Detail.from(validator_errors(result), :validator)]
25
26
  end
26
27
 
27
28
  private
28
29
 
29
- # RubyLLM parses structured output itself and leaves the raw string when that fails, so
30
+ # An adapter may parse structured output itself (RubyLLM does) and leave the raw string when that fails, so
30
31
  # content is a Hash on success, or a String/nil when the model refused, was cut off, or
31
32
  # ignored the schema.
32
33
  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?)
34
+ return [nil, [parse_error(UNREPRESENTABLE)]] if content.is_a?(String) && !content.valid_encoding?
35
+ return [nil, [parse_error(EMPTY)]] if content.nil? || (content.is_a?(String) && content.strip.empty?)
35
36
 
36
37
  data = content.is_a?(String) ? JSON.parse(strip_code_fence(content)) : content
37
- representable?(data) ? [data, []] : [nil, [UNREPRESENTABLE]]
38
+ representable?(data) ? [data, []] : [nil, [parse_error(UNREPRESENTABLE)]]
38
39
  rescue JSON::ParserError => e
39
- [nil, ["response was not valid JSON#{parse_position(e)}"]]
40
+ [nil, [parse_error("response was not valid JSON#{parse_position(e)}")]]
40
41
  end
41
42
 
43
+ def parse_error(message) = InvalidOutputError::Detail.new(message, :parse)
44
+
42
45
  # JSON.parse turns an out-of-range number such as 1e400 into Infinity, and accepts invalid UTF-8 in a string.
43
46
  # Both can satisfy the schema but can't be serialized again (and the deterministic path rejects them), so
44
47
  # they are invalid output.
@@ -54,7 +57,8 @@ module Squishling
54
57
  def cap(errors)
55
58
  return errors if errors.size <= MAX_ERRORS
56
59
 
57
- errors.first(MAX_ERRORS) + ["... and #{errors.size - MAX_ERRORS} more errors"]
60
+ notice = "... and #{errors.size - MAX_ERRORS} more errors"
61
+ errors.first(MAX_ERRORS) + [InvalidOutputError::Detail.new(notice, :squishling)]
58
62
  end
59
63
 
60
64
  # The parser's message quotes a snippet of the response, which must not reach error messages or logs
@@ -5,20 +5,19 @@ module Squishling
5
5
  module Params
6
6
  THINKING_KEYS = %i[effort budget display].freeze
7
7
 
8
- # Request keys Squishling or RubyLLM own. Provider options are merged into the request last,
9
- # overriding RubyLLM's defaults, so these would silently replace the model, the conversation,
10
- # or the strict output format.
8
+ # Request keys Squishling or the adapter own. Provider params are merged into the request alongside what the
9
+ # adapter builds, so these would silently replace the model, the conversation, or the strict output format.
11
10
  RESERVED_KEYS = %i[
12
11
  model messages input inputs instructions contents system system_instruction systemInstruction cachedContent
13
12
  stream stream_options store include response_format text output_config outputConfig tools tool_choice
14
- toolConfig tool_config schema
13
+ toolConfig tool_config schema functions function_call
15
14
  ].freeze
16
15
 
17
16
  # Some protocols carry the structured-output format (or tool selection) inside a container that is
18
17
  # itself allowed, because it also holds ordinary settings (Gemini's topK, Mistral's top_p, ...). RubyLLM
19
18
  # deep-merges provider options, so these nested keys would override the strict format just like the
20
19
  # top-level ones. Re-check each protocol's render_payload (ruby_llm protocols/*/chat.rb) on every
21
- # ruby_llm upgrade.
20
+ # ruby_llm upgrade (the RubyLLM adapter is where they matter).
22
21
  GENERATION_CONFIG_RESERVED_KEYS = %i[
23
22
  responseMimeType response_mime_type responseSchema response_schema responseJsonSchema response_json_schema
24
23
  response_format tools tool_choice toolConfig tool_config
@@ -37,18 +36,23 @@ module Squishling
37
36
  raise ConfigurationError, "#{label} must be a Hash, got #{params.class}" unless params.is_a?(Hash)
38
37
 
39
38
  params = params.transform_keys(&:to_sym)
40
- reserved = params.keys & RESERVED_KEYS
41
- if reserved.any?
42
- raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
43
- "(controlled by Squishling/RubyLLM; use model:/provider:/output_schema)"
44
- end
39
+ reject_reserved!(params, RESERVED_KEYS, label, "; use model:/provider:/output_schema")
45
40
  reject_nested_reserved!(params, label)
46
41
  params[:thinking] = normalize_thinking(params[:thinking], label) unless params[:thinking].nil?
47
42
  params.freeze
48
43
  end
49
44
 
45
+ # Raises when params sets any of the keys Squishling owns. hint is appended inside the parenthetical.
46
+ def reject_reserved!(params, keys, label, hint = "")
47
+ reserved = params.keys & keys
48
+ return if reserved.empty?
49
+
50
+ raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
51
+ "(controlled by Squishling and the adapter#{hint})"
52
+ end
53
+
50
54
  # Nested keys are compared as symbols so a string-keyed "responseMimeType" can't slip past the check.
51
- # A non-Hash container would replace (not merge into) the one RubyLLM builds, wiping the strict format.
55
+ # A non-Hash container would replace (not merge into) the one the adapter builds, wiping the strict format.
52
56
  # Validated containers are copied and frozen so they can't be mutated after the check.
53
57
  def reject_nested_reserved!(params, label)
54
58
  nested = NESTED_RESERVED_KEYS.flat_map do |container, reserved|
@@ -71,22 +75,6 @@ module Squishling
71
75
  layers.compact.reduce({}) { |merged, layer| merged.merge(layer) }.compact
72
76
  end
73
77
 
74
- # Portable settings use RubyLLM's dedicated setters, which translate them for each provider;
75
- # everything else is merged into the provider request as-is.
76
- def apply(chat, params)
77
- rest = params.except(:temperature, :thinking, :max_output_tokens)
78
- chat.with_temperature(params[:temperature]) if params.key?(:temperature)
79
- chat.with_max_output_tokens(params[:max_output_tokens]) if params.key?(:max_output_tokens)
80
- apply_thinking(chat, params[:thinking]) if params.key?(:thinking)
81
- chat.with_provider_options(rest) if rest.any?
82
- chat
83
- end
84
-
85
- # true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
86
- def apply_thinking(chat, thinking)
87
- thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
88
- end
89
-
90
78
  def normalize_thinking(thinking, label)
91
79
  return thinking if [true, false].include?(thinking)
92
80
 
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Adds the squishling:verify rake task to a Rails app (loaded only when Rails is).
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ load File.expand_path("tasks/squishling.rake", __dir__)
8
+ end
9
+ end
10
+ end
@@ -4,6 +4,17 @@ module Squishling
4
4
  # Builds Data classes from a JSON Schema so both the LLM and deterministic paths return
5
5
  # the same typed object.
6
6
  module Result
7
+ # Property names a generated class can't take: a Data member of the same name replaces the method, which the
8
+ # result (or Squishling) needs. result_spec.rb probes every Object, Kernel, and Data method, so a method a newer
9
+ # Ruby adds can't slip through unreserved.
10
+ RESERVED_NAMES = %i[
11
+ squished? squishling_squished []
12
+ class is_a? kind_of? inspect to_s to_h hash == eql? members deconstruct_keys with dup
13
+ send public_send
14
+ __send__ object_id __id__
15
+ initialize initialize_copy initialize_dup initialize_clone respond_to_missing?
16
+ ].freeze
17
+
7
18
  # Mixed into every generated result class.
8
19
  module Instance
9
20
  def initialize(squishling_squished: false, **attrs)
@@ -28,15 +39,9 @@ module Squishling
28
39
  module ClassMethods
29
40
  def from_h(hash, squished: false)
30
41
  hash = hash.transform_keys(&:to_sym)
31
- attrs = members.to_h { |member| [member, squishling_fields[member].call(hash[member], squished)] }
42
+ attrs = members.to_h { |member| [member, @squishling_fields[member].call(hash[member], squished)] }
32
43
  new(squishling_squished: squished, **attrs)
33
44
  end
34
-
35
- private
36
-
37
- def squishling_fields
38
- @squishling_fields ||= @squishling_builder.fields_for(@squishling_schema)
39
- end
40
45
  end
41
46
 
42
47
  class << self
@@ -67,21 +72,37 @@ module Squishling
67
72
  schema = resolve(schema)
68
73
  return nil unless schema.is_a?(Hash) && schema["properties"].is_a?(Hash)
69
74
 
70
- @classes[schema] ||= begin
71
- klass = Data.define(*schema["properties"].keys.map(&:to_sym))
72
- klass.include(Instance)
73
- klass.extend(ClassMethods)
74
- klass.instance_variable_set(:@squishling_builder, self)
75
- klass.instance_variable_set(:@squishling_schema, schema)
76
- klass
77
- end
75
+ return @classes[schema] if @classes.key?(schema)
76
+
77
+ klass = @classes[schema] = define_class(schema)
78
+ # Memoized first so recursive schemas stop; typing the fields builds nested classes now, so a bad
79
+ # property name in any object the result types (nested, array items, resolved $refs) raises at schema build.
80
+ klass.instance_variable_set(:@squishling_fields, fields_for(schema))
81
+ klass
78
82
  end
79
83
 
84
+ private
85
+
80
86
  def fields_for(schema)
81
87
  schema["properties"].to_h { |key, property| [key.to_sym, converter_for(property)] }
82
88
  end
83
89
 
84
- private
90
+ def define_class(schema)
91
+ klass = Data.define(*property_names(schema))
92
+ klass.include(Instance)
93
+ klass.extend(ClassMethods)
94
+ klass
95
+ end
96
+
97
+ def property_names(schema)
98
+ names = schema["properties"].keys.map(&:to_sym)
99
+ clashes = names & RESERVED_NAMES
100
+ return names if clashes.empty?
101
+
102
+ raise ConfigurationError,
103
+ "output_schema property names clash with methods the result needs: #{clashes.join(', ')}. " \
104
+ "Rename the field(s) (e.g. `class` to `ticket_class`)"
105
+ end
85
106
 
86
107
  def converter_for(property)
87
108
  property = resolve(property)
@@ -0,0 +1,31 @@
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
+ # The turn the next call will get, without counting it.
16
+ def peek(klass, name)
17
+ COUNTS_LOCK.synchronize { (COUNTS[klass] || {}).fetch(name, 0) }
18
+ end
19
+
20
+ # This call's turn, counting from 0, and counts it.
21
+ def advance(klass, name)
22
+ COUNTS_LOCK.synchronize do
23
+ counts = (COUNTS[klass] ||= {})
24
+ turn = counts.fetch(name, 0)
25
+ counts[name] = turn + 1
26
+ turn
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+ require "squishling"
5
+
6
+ # be_squishling_valid: passes when Squishling.verify finds no problems, and fails with its report.
7
+ # expect(Squishling).to be_squishling_valid # every squished class (eager load first in Rails)
8
+ # expect(InvoiceParser).to be_squishling_valid
9
+ # expect([InvoiceParser, Triage]).to be_squishling_valid
10
+ RSpec::Matchers.define :be_squishling_valid do
11
+ match do |actual|
12
+ @report = actual.equal?(Squishling) ? Squishling.verify : Squishling.verify(actual)
13
+ @report.ok?
14
+ end
15
+
16
+ failure_message { @report.to_s }
17
+ failure_message_when_negated { "expected Squishling.verify to find problems, but it found none" }
18
+ end
@@ -0,0 +1,79 @@
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
+ RETRY_HEAD = "Your previous response was rejected:\n- "
8
+ RETRY_TAIL = "\nRespond again with corrected JSON only."
9
+ RETURNED = "\n\nA previous attempt at this request returned:\n"
10
+ REJECTED = "\nIt was rejected:\n- "
11
+ ESCALATION_TAIL = "\nRespond with corrected JSON only."
12
+ private_constant :RETRY_HEAD, :RETRY_TAIL, :RETURNED, :REJECTED, :ESCALATION_TAIL
13
+
14
+ # What an escalation message carried: the original input, the (capped) rejected output text, and the errors.
15
+ Escalation = Data.define(:input, :rejected, :errors)
16
+
17
+ RETRY_PATTERN = /\A#{Regexp.escape(RETRY_HEAD)}(.*)#{Regexp.escape(RETRY_TAIL)}\z/m
18
+ ESCALATION_PATTERN = Regexp.new(
19
+ "\\A(.*?)#{Regexp.escape(RETURNED)}(.*)#{Regexp.escape(REJECTED)}(.*)#{Regexp.escape(ESCALATION_TAIL)}\\z",
20
+ Regexp::MULTILINE
21
+ )
22
+ private_constant :RETRY_PATTERN, :ESCALATION_PATTERN
23
+
24
+ class << self
25
+ # The errors a retry message carries, or nil when text isn't one. The inverse of the message a Run sends, kept
26
+ # here so the two can't drift apart (the test helpers read what was sent through it). Errors are told apart
27
+ # by splitting on "\n- ", so one that itself contains that sequence reads back as several.
28
+ def parse_retry(text)
29
+ text.match(RETRY_PATTERN)&.then { |match| match[1].split("\n- ") }
30
+ end
31
+
32
+ # What an escalation message carried, or nil when text isn't one.
33
+ def parse_escalation(text)
34
+ match = text.match(ESCALATION_PATTERN)
35
+ match && Escalation.new(input: match[1], rejected: match[2], errors: match[3].split("\n- "))
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def retry_message(errors)
42
+ "#{RETRY_HEAD}#{errors.join("\n- ")}#{RETRY_TAIL}"
43
+ end
44
+
45
+ # The first message to a chat: the input, along with the output rejected before it (if any) unless the step
46
+ # opts out of seeing it.
47
+ def fresh_message(step, input, rejected)
48
+ rejected && step.forward_rejected ? escalation_message(input, *rejected) : input
49
+ end
50
+
51
+ # The first message to a fresh chat after an earlier model's output was rejected. The rejected output is
52
+ # model-generated and may cross providers, so it is capped at Invoker::MAX_FORWARDED_CHARS.
53
+ def escalation_message(input, raw, errors)
54
+ previous = rejected_text(raw)
55
+ previous = "(an empty response)" if previous.strip.empty? || raw.nil?
56
+ if previous.length > Invoker::MAX_FORWARDED_CHARS
57
+ omitted = previous.length - Invoker::MAX_FORWARDED_CHARS
58
+ previous = "#{previous[0, Invoker::MAX_FORWARDED_CHARS]}... [truncated, #{omitted} more characters]"
59
+ end
60
+ "#{input}#{RETURNED}#{previous}#{REJECTED}#{errors.join("\n- ")}#{ESCALATION_TAIL}"
61
+ end
62
+
63
+ # The rejected output as text. Output that was rejected for being unrepresentable in JSON (Infinity, invalid
64
+ # UTF-8) can't be serialized or measured as is, so it is scrubbed or replaced rather than raising.
65
+ def rejected_text(raw)
66
+ return utf8(raw).scrub("?") if raw.is_a?(String)
67
+
68
+ JSON.generate(raw)
69
+ rescue JSON::JSONError
70
+ "(a response JSON can't represent)"
71
+ end
72
+
73
+ # A binary-tagged string counts every byte as valid, so it is read as UTF-8 before scrubbing.
74
+ def utf8(text)
75
+ text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Run
5
+ # What a Run does with a reply it rejects: the attempt that follows, the error it raises, the reason it logs, and
6
+ # the token budget that can end the call before another request. A model that sends back the reply it was just told
7
+ # was invalid, in the same chat, would do it again, so a repeated reply gives up on the rest of its step's attempts.
8
+ module Rejections
9
+ private
10
+
11
+ # The number of the attempt after attempt in path, or nil when none is left.
12
+ def next_attempt(path, attempt, repeated)
13
+ return (attempt + 1 if attempt < path.size) unless repeated
14
+
15
+ skipped = path.drop(attempt).index { |step| step != path[attempt - 1] }
16
+ attempt + skipped + 1 if skipped
17
+ end
18
+
19
+ def invalid_output(response, errors, attempt, models, source = "LLM")
20
+ InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models:, source:,
21
+ finish_reason: response.finish_reason)
22
+ end
23
+
24
+ # Why an attempt was rejected, for the log: the output check's errors, plus a note when the reply was cut off.
25
+ def rejection_reason(errors, response)
26
+ reason = errors.join("; ")
27
+ response.truncated? ? "#{reason} (#{InvalidOutputError::CUTOFF_NOTE})" : reason
28
+ end
29
+
30
+ # Raises BudgetExhaustedError when the call has spent its token budget (see Budget), before another request.
31
+ # attempts is how many were made so far, models the ones tried, rejected the last [raw content, errors].
32
+ def check_budget!(attempts: 0, models: nil, rejected: nil)
33
+ exhausted = @client.exhausted_budget
34
+ return unless exhausted
35
+
36
+ error = BudgetExhaustedError.new(**exhausted, errors: rejected&.last || [], raw: rejected&.first,
37
+ attempts: (attempts if attempts.positive?), models: (models.dup if models&.any?))
38
+ log("stopped, #{error.errors.first}")
39
+ raise error
40
+ end
41
+ end
42
+ end
43
+ end