squishling 0.4.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +25 -9
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +53 -18
  6. data/docs/failures.md +123 -18
  7. data/docs/harnesses.md +24 -14
  8. data/docs/measuring-tokens.md +8 -6
  9. data/docs/routing.md +5 -0
  10. data/docs/schemas.md +11 -2
  11. data/docs/testing.md +183 -0
  12. data/docs/verifying.md +84 -0
  13. data/lib/squishling/adapter/error_body.rb +47 -0
  14. data/lib/squishling/adapter.rb +275 -0
  15. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  16. data/lib/squishling/adapters/openai_options.rb +72 -0
  17. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  18. data/lib/squishling/budget.rb +71 -0
  19. data/lib/squishling/class_methods.rb +43 -7
  20. data/lib/squishling/class_registry.rb +63 -0
  21. data/lib/squishling/configuration.rb +57 -6
  22. data/lib/squishling/definition.rb +90 -16
  23. data/lib/squishling/errors.rb +92 -7
  24. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  25. data/lib/squishling/harness/option_types.rb +2 -1
  26. data/lib/squishling/harness/registry.rb +8 -36
  27. data/lib/squishling/invoker.rb +21 -9
  28. data/lib/squishling/judge.rb +16 -3
  29. data/lib/squishling/llm_client.rb +66 -132
  30. data/lib/squishling/model_path.rb +60 -13
  31. data/lib/squishling/named_registry.rb +64 -0
  32. data/lib/squishling/observer.rb +11 -2
  33. data/lib/squishling/output_check.rb +13 -9
  34. data/lib/squishling/params.rb +6 -23
  35. data/lib/squishling/railtie.rb +10 -0
  36. data/lib/squishling/result.rb +37 -16
  37. data/lib/squishling/rotation.rb +5 -0
  38. data/lib/squishling/rspec.rb +18 -0
  39. data/lib/squishling/run/messages.rb +40 -3
  40. data/lib/squishling/run/rejections.rb +43 -0
  41. data/lib/squishling/run.rb +66 -42
  42. data/lib/squishling/schema.rb +10 -9
  43. data/lib/squishling/squawk.rb +33 -3
  44. data/lib/squishling/tasks/squishling.rake +18 -0
  45. data/lib/squishling/testing/adapter.rb +194 -0
  46. data/lib/squishling/testing/checks.rb +39 -0
  47. data/lib/squishling/testing/direct.rb +41 -0
  48. data/lib/squishling/testing/errors.rb +18 -0
  49. data/lib/squishling/testing/fixtures.rb +206 -0
  50. data/lib/squishling/testing/helpers.rb +71 -0
  51. data/lib/squishling/testing/minitest.rb +32 -0
  52. data/lib/squishling/testing/observer.rb +31 -0
  53. data/lib/squishling/testing/reply.rb +60 -0
  54. data/lib/squishling/testing/request.rb +75 -0
  55. data/lib/squishling/testing/rspec.rb +30 -0
  56. data/lib/squishling/testing/script.rb +189 -0
  57. data/lib/squishling/testing/state.rb +73 -0
  58. data/lib/squishling/testing/stubs.rb +112 -0
  59. data/lib/squishling/testing.rb +126 -0
  60. data/lib/squishling/verification.rb +39 -0
  61. data/lib/squishling/verifier.rb +179 -0
  62. data/lib/squishling/version.rb +1 -1
  63. data/lib/squishling.rb +57 -4
  64. metadata +40 -21
@@ -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
@@ -49,11 +48,11 @@ module Squishling
49
48
  return if reserved.empty?
50
49
 
51
50
  raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
52
- "(controlled by Squishling/RubyLLM#{hint})"
51
+ "(controlled by Squishling and the adapter#{hint})"
53
52
  end
54
53
 
55
54
  # 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.
55
+ # A non-Hash container would replace (not merge into) the one the adapter builds, wiping the strict format.
57
56
  # Validated containers are copied and frozen so they can't be mutated after the check.
58
57
  def reject_nested_reserved!(params, label)
59
58
  nested = NESTED_RESERVED_KEYS.flat_map do |container, reserved|
@@ -76,22 +75,6 @@ module Squishling
76
75
  layers.compact.reduce({}) { |merged, layer| merged.merge(layer) }.compact
77
76
  end
78
77
 
79
- # Portable settings use RubyLLM's dedicated setters, which translate them for each provider;
80
- # everything else is merged into the provider request as-is.
81
- def apply(chat, params)
82
- rest = params.except(:temperature, :thinking, :max_output_tokens)
83
- chat.with_temperature(params[:temperature]) if params.key?(:temperature)
84
- chat.with_max_output_tokens(params[:max_output_tokens]) if params.key?(:max_output_tokens)
85
- apply_thinking(chat, params[:thinking]) if params.key?(:thinking)
86
- chat.with_provider_options(rest) if rest.any?
87
- chat
88
- end
89
-
90
- # true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
91
- def apply_thinking(chat, thinking)
92
- thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
93
- end
94
-
95
78
  def normalize_thinking(thinking, label)
96
79
  return thinking if [true, false].include?(thinking)
97
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)
@@ -12,6 +12,11 @@ module Squishling
12
12
  private_constant :COUNTS, :COUNTS_LOCK
13
13
 
14
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
+
15
20
  # This call's turn, counting from 0, and counts it.
16
21
  def advance(klass, name)
17
22
  COUNTS_LOCK.synchronize do
@@ -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
@@ -4,10 +4,48 @@ module Squishling
4
4
  class Run
5
5
  # The follow-up messages a Run sends a model after an output was rejected.
6
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
+
7
39
  private
8
40
 
9
41
  def retry_message(errors)
10
- "Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
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
11
49
  end
12
50
 
13
51
  # The first message to a fresh chat after an earlier model's output was rejected. The rejected output is
@@ -19,8 +57,7 @@ module Squishling
19
57
  omitted = previous.length - Invoker::MAX_FORWARDED_CHARS
20
58
  previous = "#{previous[0, Invoker::MAX_FORWARDED_CHARS]}... [truncated, #{omitted} more characters]"
21
59
  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."
60
+ "#{input}#{RETURNED}#{previous}#{REJECTED}#{errors.join("\n- ")}#{ESCALATION_TAIL}"
24
61
  end
25
62
 
26
63
  # The rejected output as text. Output that was rejected for being unrepresentable in JSON (Infinity, invalid
@@ -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
@@ -13,11 +13,12 @@ module Squishling
13
13
  # on other threads; parsing, validation, and every callback (squish_validate, compare:, procs) stay on this one.
14
14
  class Run
15
15
  include Messages
16
+ include Rejections
16
17
 
17
- private_constant :Messages
18
+ private_constant :Messages, :Rejections
18
19
 
19
20
  # One of #samples' outputs, between rounds.
20
- Sample = Struct.new(:name, :stage, :chat, :rejected, :result)
21
+ Sample = Struct.new(:name, :stage, :chat, :rejected, :result, :reask)
21
22
 
22
23
  SAMPLE_NAMES = ("a".."z").to_a.freeze
23
24
  private_constant :Sample, :SAMPLE_NAMES
@@ -32,8 +33,9 @@ module Squishling
32
33
  @payload = payload
33
34
  @purpose = purpose
34
35
  @harness = harness
35
- @steps = definition.escalation_path.freeze
36
- @client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off)
36
+ @steps = definition.escalation_path(receiver, inputs).freeze
37
+ @client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off,
38
+ budget: definition.budget)
37
39
  @output_check = OutputCheck.new(definition, receiver, inputs)
38
40
  @owner = Thread.current
39
41
  @produced = {}.compare_by_identity
@@ -54,8 +56,9 @@ module Squishling
54
56
  @stages ||= steps.slice_when { |before, after| before != after }.map(&:freeze).freeze
55
57
  end
56
58
 
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 from a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:,
60
+ # retry_temperature:), the way a judge step is resolved: it keeps its own provider, and its params merge over
61
+ # the method's.
59
62
  # A stage (an Array of steps) is returned as is, and a single step is a stage of one attempt.
60
63
  def stage(declaration)
61
64
  return checked_stage([declaration]) if declaration.is_a?(ModelPath::Step)
@@ -71,9 +74,11 @@ module Squishling
71
74
  end
72
75
 
73
76
  # 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
77
+ # passes the schema and squish_validate. Raises BudgetExhaustedError instead of making an attempt once the
78
+ # call's token budget (see Budget) is spent. Consecutive attempts on the same step continue the same chat, so the
75
79
  # 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.
80
+ # the step sets forward_rejected: false). A retry that repeats the rejected output it was just told about gives
81
+ # up on the rest of its step's attempts. Raises InvalidOutputError, or LLMError, when the last attempt fails.
77
82
  def escalate(*stages)
78
83
  owner!
79
84
  path = stages.empty? ? steps : stages.flat_map { |declared| stage(declared) }
@@ -103,6 +108,8 @@ module Squishling
103
108
  pending = samples.reject(&:result)
104
109
  break if pending.empty?
105
110
 
111
+ check_budget!(attempts: attempt - 1, models: samples.first.stage.first(attempt - 1).map(&:model),
112
+ rejected: pending.filter_map(&:rejected).last)
106
113
  round(pending, attempt)
107
114
  end
108
115
  samples.map(&:result)
@@ -119,7 +126,7 @@ module Squishling
119
126
  judge = judge_step(judge) unless judge.nil?
120
127
  Judge.new(definition: @definition, receiver: @receiver, client: @client, judge:, instructions:, path: steps,
121
128
  purpose: @purpose, payload: @payload, escalate: method(:escalate_with), log_failure: method(:log_failure),
122
- guard: method(:owner!), harness_type: @harness.type)
129
+ guard: method(:owner!), budget_check: method(:check_budget!), harness_type: @harness.type)
123
130
  end
124
131
 
125
132
  # Whether two outputs agree: compare (a Proc, evaluated against the receiver with the two results and the
@@ -167,42 +174,49 @@ module Squishling
167
174
  chat = nil
168
175
  rejected = nil # [raw content, errors] of the last invalid output
169
176
  models = []
177
+ attempt = 1
170
178
 
171
- path.each.with_index(1) do |step, attempt|
179
+ while attempt <= path.size
180
+ check_budget!(attempts: models.size, models:, rejected:)
181
+ step = path[attempt - 1]
172
182
  models << step.model
173
183
  last = attempt == path.size
174
- if chat && step == path[attempt - 2]
184
+ reask = !chat.nil? && step == path[attempt - 2]
185
+ if reask
175
186
  message = retry_message(rejected.last)
176
187
  else
177
188
  chat = start_chat(step, prompt)
178
- message = rejected && step.forward_rejected ? escalation_message(prompt.input, *rejected) : prompt.input
189
+ message = fresh_message(step, prompt.input, rejected)
179
190
  end
180
191
 
181
192
  begin
182
- response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role)
193
+ response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role, reask:)
183
194
  rescue LLMError => e
184
- squawk(prompt, path, attempt, nil, e)
195
+ squawk(prompt, path, attempt, nil, e, reask:)
185
196
  raise if last
186
197
 
187
- # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
198
+ # A failed request starts the next attempt in a fresh chat, as a new step does.
188
199
  chat = nil
189
200
  log_failure(prompt.role, attempt, path, e.message)
201
+ attempt += 1
190
202
  next
191
203
  end
192
204
 
193
205
  result, errors = @output_check.call(response.content, prompt)
194
206
  if errors.empty?
195
- squawk(prompt, path, attempt, response, nil)
207
+ squawk(prompt, path, attempt, response, nil, reask:)
196
208
  return produce(result, prompt)
197
209
  end
198
210
 
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
211
+ repeated = reask && response.content == rejected.first
212
+ following = next_attempt(path, attempt, repeated)
213
+ error = invalid_output(response, errors, models.size, models.dup, prompt.role == :judge ? "Judge" : "LLM")
214
+ squawk(prompt, path, attempt, response, error, reask:, repeated:, final: following.nil?)
215
+ raise error unless following
203
216
 
204
- rejected = [response.content, errors]
205
- log_failure(prompt.role, attempt, path, errors.join("; "))
217
+ rejected = [response.content, error.errors]
218
+ log_failure(prompt.role, attempt, path, rejection_reason(error.errors, response), following:, repeated:)
219
+ attempt = following
206
220
  end
207
221
  end
208
222
 
@@ -213,9 +227,10 @@ module Squishling
213
227
  message = sample_message(sample, step)
214
228
  chat = sample.chat
215
229
  attempts = sample.stage.size
216
- -> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name) }
230
+ reask = sample.reask
231
+ -> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name, reask:) }
217
232
  end
218
- outcomes = LLMClient.concurrently(jobs)
233
+ outcomes = @client.concurrently(jobs)
219
234
  # A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
220
235
  fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
221
236
  raise fatal if fatal
@@ -225,10 +240,11 @@ module Squishling
225
240
 
226
241
  # The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
227
242
  def sample_message(sample, step)
228
- return retry_message(sample.rejected.last) if sample.chat
243
+ sample.reask = !sample.chat.nil?
244
+ return retry_message(sample.rejected.last) if sample.reask
229
245
 
230
246
  sample.chat = start_chat(step, @prompt)
231
- sample.rejected && step.forward_rejected ? escalation_message(@prompt.input, *sample.rejected) : @prompt.input
247
+ fresh_message(step, @prompt.input, sample.rejected)
232
248
  end
233
249
 
234
250
  def record(sample, (response, error), attempt)
@@ -236,7 +252,7 @@ module Squishling
236
252
  last = attempt == stage.size
237
253
  role = "sample #{sample.name}"
238
254
  if error
239
- squawk(@prompt, stage, attempt, nil, error, sample:)
255
+ squawk(@prompt, stage, attempt, nil, error, sample:, reask: sample.reask)
240
256
  raise error if last
241
257
 
242
258
  sample.chat = nil
@@ -245,17 +261,19 @@ module Squishling
245
261
 
246
262
  result, errors = @output_check.call(response.content, @prompt)
247
263
  if errors.empty?
248
- squawk(@prompt, stage, attempt, response, nil, sample:)
264
+ squawk(@prompt, stage, attempt, response, nil, sample:, reask: sample.reask)
249
265
  return sample.result = produce(result, @prompt)
250
266
  end
251
267
 
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
268
+ # A sample is one step (#samples refuses a stage that mixes them), so a repeat leaves nothing to move on to.
269
+ repeated = sample.reask && response.content == sample.rejected.first
270
+ invalid = invalid_output(response, errors, attempt, stage.first(attempt).map(&:model))
271
+ squawk(@prompt, stage, attempt, response, invalid, sample:, reask: sample.reask, repeated:,
272
+ final: last || repeated)
273
+ raise invalid if last || repeated
256
274
 
257
- sample.rejected = [response.content, errors]
258
- log_failure(role, attempt, stage, errors.join("; "))
275
+ sample.rejected = [response.content, invalid.errors]
276
+ log_failure(role, attempt, stage, rejection_reason(invalid.errors, response))
259
277
  end
260
278
 
261
279
  # Remembers a result of the method's own output (a judge's verdict isn't one), so a harness can only return
@@ -271,25 +289,31 @@ module Squishling
271
289
 
272
290
  # Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
273
291
  # 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)
292
+ # attempts, :sample (with the sample's name) or :judge. reask is whether the attempt retried in the same chat,
293
+ # repeated whether it sent back the reply the attempt before it was rejected for, and final whether no attempt
294
+ # follows it.
295
+ def squawk(prompt, path, attempt, response, error, sample: nil, reask: false, repeated: false,
296
+ final: attempt == path.size)
276
297
  hook = @definition.squawk
277
- return unless hook
298
+ return unless hook && Squawk.report?(@definition.squawk_on, attempt, error, final)
278
299
 
279
300
  step = path[attempt - 1]
280
301
  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
302
+ label:, attempt:, attempts: path.size, final:, repeated:,
303
+ model: response&.model || step.model, provider: step.provider, params: step.request_params(reask),
304
+ input: prompt.input, usage: response&.usage, finish_reason: response&.finish_reason,
305
+ role: sample ? :sample : prompt.role, sample: sample&.name
284
306
  }
285
307
  Squawk.call(hook, output: response&.content, metadata:, error:)
286
308
  end
287
309
 
288
- def log_failure(role, attempt, path, reason)
310
+ # following is the attempt that comes next, which is not the next one when a repeated reply skips ahead.
311
+ def log_failure(role, attempt, path, reason, following: attempt + 1, repeated: false)
289
312
  step = path[attempt - 1]
290
- next_step = path[attempt]
313
+ next_step = path[following - 1]
291
314
  action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
292
315
  who = role ? "#{role} attempt" : "attempt"
316
+ reason = "it repeated the rejected reply: #{reason}" if repeated
293
317
  log("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
294
318
  end
295
319
  end
@@ -26,17 +26,19 @@ module Squishling
26
26
  end
27
27
  end
28
28
 
29
- # The payload handed to RubyLLM's `with_schema`: always strict, without LOCAL_ONLY_KEYWORDS.
29
+ # The payload handed to the adapter (Adapter::Request#schema): always strict, without LOCAL_ONLY_KEYWORDS.
30
30
  attr_reader :llm_schema
31
31
  # The full JSON Schema used for validation and result typing.
32
32
  attr_reader :json_schema
33
+ # The Data class results are built as, or nil when the schema isn't an object with properties.
34
+ attr_reader :result_class
33
35
 
34
36
  def initialize(raw)
35
37
  hash = self.class.jsonify(to_hash(raw))
36
38
  wrapped = wrapped?(hash)
37
39
  body = wrapped ? hash["schema"] : hash
38
40
 
39
- # Same precedence RubyLLM uses: a top-level strict flag wins over one inside the schema.
41
+ # Same precedence RubyLLM 2.0 uses: a top-level strict flag wins over one inside the schema.
40
42
  strict = hash.key?("strict") ? hash["strict"] : body["strict"]
41
43
  raise ConfigurationError, "Squishling only supports strict output schemas (got strict: false)" if strict == false
42
44
 
@@ -48,22 +50,21 @@ module Squishling
48
50
  "strict" => true
49
51
  }.compact
50
52
  @validator = JSONSchemer.schema(@json_schema)
53
+ # Built now so a property name the result can't take fails here, not on the first response.
54
+ @result_class = Result.build(@json_schema)
51
55
  end
52
56
 
53
57
  def validate(data)
54
- @validator.validate(data).map { |error| error["error"] }
58
+ errors = @validator.validate(data).map { |error| error["error"] }
59
+ # A raw Hash schema can leave out type: "object"; the typed result is built from a Hash, so anything else is
60
+ # invalid output rather than a NoMethodError out of the builder.
61
+ errors.empty? && result_class && !data.is_a?(Hash) ? ["root must be an object"] : errors
55
62
  end
56
63
 
57
64
  def build(data, squished:)
58
65
  result_class ? result_class.from_h(data, squished:) : data
59
66
  end
60
67
 
61
- def result_class
62
- return @result_class if defined?(@result_class)
63
-
64
- @result_class = Result.build(@json_schema)
65
- end
66
-
67
68
  private
68
69
 
69
70
  def to_hash(raw)