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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Runs the parts of a squished method that don't need a model, for unit tests: the output checks, the fallback,
6
+ # and the prompt. Each takes the method's arguments as args: and keywords (as the method is called), and a
7
+ # receiver: for a class whose constructor takes arguments.
8
+ module Direct
9
+ class << self
10
+ # The messages squish_validate and the output schema reject this reply with ([] when it would be accepted).
11
+ def validate(klass, reply, method: :call, receiver: nil, args: [], **kwargs)
12
+ definition = klass.squishling_definition(method)
13
+ schema = definition.schema or raise SetupError, "#{definition.label} has no output_schema"
14
+ inputs = definition.bind_arguments(args, kwargs)
15
+ prompt = Invoker::Prompt.new(instructions: nil, schema:, input: nil, role: nil)
16
+ OutputCheck.new(definition, receiver || klass.new, inputs).call(reply, prompt).last.map(&:message)
17
+ end
18
+
19
+ # What squish_fallback returns for this error, typed like any result of the method.
20
+ def fallback_for(klass, error, method: :call, receiver: nil, args: [], **kwargs)
21
+ definition = klass.squishling_definition(method)
22
+ handler = definition.fallback or raise SetupError, "#{definition.label} has no squish_fallback"
23
+ inputs = definition.bind_arguments(args, kwargs)
24
+ definition.coerce((receiver || klass.new).instance_exec(error, **inputs, &handler))
25
+ end
26
+
27
+ # The first request a call would make, without making it: { system_prompt:, user_message:, output_schema:,
28
+ # step: } (the step is the ModelPath::Step with the model, provider and params; rotation isn't advanced).
29
+ def preview(klass, method: :call, receiver: nil, args: [], **kwargs)
30
+ definition = klass.squishling_definition(method)
31
+ receiver ||= klass.new
32
+ inputs = definition.bind_arguments(args, kwargs)
33
+ prompt = Invoker.new(definition, receiver, inputs).prompt
34
+ { system_prompt: prompt.instructions, user_message: prompt.input,
35
+ output_schema: prompt.schema.llm_schema["schema"],
36
+ step: definition.escalation_path(receiver, inputs, advance: false).first }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # What the test helpers raise. None of these is a Squishling::Error, so squish_fallback, which absorbs
6
+ # InvalidOutputError and LLMError, can never hide a test setup mistake.
7
+ class Error < StandardError; end
8
+
9
+ # A request reached the test adapter and no script covers it.
10
+ class UnscriptedRequestError < Error; end
11
+
12
+ # A request reached the provider seam while provider calls were forbidden (see Testing.forbid_provider_calls!).
13
+ class ProviderCallError < Error; end
14
+
15
+ # A stub, a fixture, or a direct check was asked for something the method's schema doesn't allow.
16
+ class SetupError < Error; end
17
+ end
18
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Replies derived from an output schema, so a test that doesn't care about the content stays valid when the schema
6
+ # changes, and a test of "out of range gets re-asked" doesn't hand-write a bad payload. Replies are Hashes with
7
+ # string keys, as JSON would carry them, and are always checked against the schema before they are returned.
8
+ module Fixtures
9
+ FORMATS = {
10
+ "date-time" => "2024-01-01T00:00:00Z", "date" => "2024-01-01", "time" => "00:00:00Z",
11
+ "email" => "user@example.com", "uri" => "https://example.com", "uuid" => "00000000-0000-4000-8000-000000000000"
12
+ }.freeze
13
+ MAX_DEPTH = 8
14
+ WRONG_VALUES = ["not valid", -1_000_000_007, [], {}, true, nil].freeze
15
+
16
+ class << self
17
+ # A reply that satisfies the schema of a squished class's method, a Squishling::Schema, or a schema Hash.
18
+ # overrides replace top-level attributes.
19
+ def valid_reply_for(source, method: :call, **overrides)
20
+ schema = schema_of(source, method)
21
+ reply = Generator.new(schema.json_schema).value
22
+ reply = reply.merge(overrides.transform_keys(&:to_s)) if reply.is_a?(Hash)
23
+ verify!(schema, reply, "valid")
24
+ end
25
+
26
+ # A valid reply with one top-level attribute (violating:) broken: out of its enum or range, the wrong type,
27
+ # null, or missing. how: (:enum, :range, :type, :null, :missing) picks the break; by default the first that
28
+ # the schema rejects. Overrides are applied to the valid reply first.
29
+ def invalid_reply_for(source, violating:, how: nil, method: :call, **overrides)
30
+ schema = schema_of(source, method)
31
+ base = valid_reply_for(schema, **overrides)
32
+ key = violating.to_s
33
+ raise SetupError, "the schema has no property #{key.inspect} to violate" unless schema.json_schema.fetch(
34
+ "properties", {}
35
+ ).key?(key)
36
+
37
+ candidates = breaks(schema.json_schema, key, how)
38
+ broken = candidates.lazy.map { |value| mutate(base, key, value) }.find { |reply| schema.validate(reply).any? }
39
+ broken or raise SetupError,
40
+ "couldn't find a value of #{key.inspect} that the schema rejects#{" (how: #{how.inspect})" if how}"
41
+ end
42
+
43
+ private
44
+
45
+ def schema_of(source, method)
46
+ case source
47
+ when Schema then source
48
+ when Class then source.squishling_definition(method).schema or raise SetupError,
49
+ "#{source} has no output_schema"
50
+ else Schema.for(source)
51
+ end
52
+ end
53
+
54
+ def verify!(schema, reply, kind)
55
+ errors = schema.validate(reply)
56
+ return reply if errors.empty?
57
+
58
+ raise SetupError,
59
+ "couldn't build a #{kind} reply (#{errors.join('; ')}); pass the attributes it needs as overrides"
60
+ end
61
+
62
+ def mutate(base, key, value)
63
+ value.equal?(:missing) ? base.except(key) : base.merge(key => value)
64
+ end
65
+
66
+ def breaks(json_schema, key, how)
67
+ node = Generator.new(json_schema).node_for(json_schema.fetch("properties").fetch(key))
68
+ all = { enum: enum_breaks(node), range: range_breaks(node), type: WRONG_VALUES.compact, null: [nil],
69
+ missing: [:missing] }
70
+ return all.fetch(how) { raise ArgumentError, "how: is one of #{all.keys.map(&:inspect).join(', ')}" } if how
71
+
72
+ all.values.flatten(1)
73
+ end
74
+
75
+ def enum_breaks(node)
76
+ values = node["enum"] ? ["not in the enum"] : []
77
+ node.key?("const") ? values + ["not the const"] : values
78
+ end
79
+
80
+ def range_breaks(node)
81
+ bounds = node.slice("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minLength", "maxLength",
82
+ "minItems", "maxItems")
83
+ bounds.flat_map { |name, limit| beyond(name, limit) }
84
+ end
85
+
86
+ def beyond(name, limit)
87
+ case name
88
+ when "minimum", "exclusiveMinimum" then [limit - 1]
89
+ when "maximum", "exclusiveMaximum" then [limit + 1]
90
+ when "minLength" then ["x" * [limit - 1, 0].max]
91
+ when "maxLength" then ["x" * (limit + 1)]
92
+ when "minItems" then [[nil] * [limit - 1, 0].max]
93
+ when "maxItems" then [[nil] * (limit + 1)]
94
+ end
95
+ end
96
+ end
97
+
98
+ # Builds one value that satisfies a JSON schema (see Fixtures).
99
+ class Generator
100
+ def initialize(root)
101
+ @root = root
102
+ @expanding = []
103
+ end
104
+
105
+ # The value for a node. A $ref being expanded is remembered, so a recursive schema (a node whose child is
106
+ # the same node or null) ends at the null instead of nesting forever.
107
+ def value(node = @root, depth = 0)
108
+ ref = node["$ref"]
109
+ @expanding.push(ref) if ref
110
+ generate(node_for(node), depth)
111
+ ensure
112
+ @expanding.pop if ref
113
+ end
114
+
115
+ # The schema node with a root-relative $ref followed.
116
+ def node_for(node)
117
+ ref = node["$ref"]
118
+ return node unless ref
119
+
120
+ path = ref.delete_prefix("#").split("/").reject(&:empty?)
121
+ node.except("$ref").merge(path.reduce(@root) do |memo, part|
122
+ memo.fetch(part.gsub("~1", "/").gsub("~0", "~"))
123
+ end)
124
+ end
125
+
126
+ private
127
+
128
+ def generate(node, depth)
129
+ return node["const"] if node.key?("const")
130
+ return node["enum"].first if node["enum"]
131
+ return from_branch(node, depth) if node["anyOf"] || node["oneOf"]
132
+ return from_all_of(node, depth) if node["allOf"]
133
+
134
+ typed(node, depth)
135
+ end
136
+
137
+ def from_branch(node, depth)
138
+ branches = node["anyOf"] || node["oneOf"]
139
+ open = branches.reject { |branch| branch["$ref"] && @expanding.include?(branch["$ref"]) }
140
+ chosen = open.find { |branch| branch["type"] != "null" } || open.first || branches.first
141
+ value(chosen, depth + 1)
142
+ end
143
+
144
+ def from_all_of(node, depth)
145
+ parts = node["allOf"].map { |part| value(part, depth + 1) }.grep(Hash)
146
+ parts.reduce({}) { |memo, part| memo.merge(part) }
147
+ end
148
+
149
+ def typed(node, depth)
150
+ case primary_type(node)
151
+ when "object" then object(node, depth)
152
+ when "array" then array(node, depth)
153
+ when "string" then string(node)
154
+ when "integer", "number" then number(node)
155
+ when "boolean" then true
156
+ end
157
+ end
158
+
159
+ def primary_type(node)
160
+ types = Array(node["type"])
161
+ types.find { |type| type != "null" } || (types.first if types.any?) || inferred_type(node)
162
+ end
163
+
164
+ def inferred_type(node)
165
+ return "object" if node["properties"]
166
+
167
+ "array" if node["items"]
168
+ end
169
+
170
+ def object(node, depth)
171
+ return {} if depth > MAX_DEPTH
172
+
173
+ (node["properties"] || {}).transform_values { |property| value(property, depth + 1) }
174
+ end
175
+
176
+ def array(node, depth)
177
+ return [] if depth > MAX_DEPTH || !node["items"]
178
+
179
+ Array.new(node["minItems"].to_i) { value(node["items"], depth + 1) }
180
+ end
181
+
182
+ def string(node)
183
+ text = FORMATS.fetch(node["format"], "example")
184
+ text = text.ljust(node["minLength"], "x") if node["minLength"]
185
+ node["maxLength"] ? text[0, node["maxLength"]] : text
186
+ end
187
+
188
+ def number(node)
189
+ number = clamp_up(0, node["minimum"], node["exclusiveMinimum"])
190
+ number = clamp_down(number, node["maximum"], node["exclusiveMaximum"])
191
+ primary_type(node) == "integer" ? number.ceil : number
192
+ end
193
+
194
+ def clamp_up(number, minimum, exclusive)
195
+ number = minimum if minimum && number < minimum
196
+ exclusive && number <= exclusive ? exclusive + 1 : number
197
+ end
198
+
199
+ def clamp_down(number, maximum, exclusive)
200
+ number = maximum if maximum && number > maximum
201
+ exclusive && number >= exclusive ? exclusive - 1 : number
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The test-facing helpers, shared by the Minitest and RSpec mixins. A mixin supplies #squishling_failure(message),
6
+ # which fails the current test.
7
+ module Helpers
8
+ # Installs a scripted adapter and returns it. The block scripts it (see Script); the adapter's #requests,
9
+ # #conversations and #arguments show what was sent.
10
+ def fake_squishling(&)
11
+ Testing.install(&)
12
+ end
13
+
14
+ # Any LLM request raises until a test installs a fake, naming the squished method that made it.
15
+ def forbid_provider_calls!
16
+ Testing.forbid_provider_calls!
17
+ end
18
+
19
+ def assert_sent_arguments(source, expected, request: 0)
20
+ failure = Checks.sent_arguments(source, expected, request:)
21
+ squishling_failure(failure) if failure
22
+ end
23
+
24
+ def assert_llm_calls(source, expected)
25
+ failure = Checks.call_count(source, expected)
26
+ squishling_failure(failure) if failure
27
+ end
28
+
29
+ def assert_no_llm_call(&)
30
+ failure = Checks.no_llm_call(&)
31
+ squishling_failure(failure) if failure
32
+ end
33
+
34
+ def stub_squishling_result(klass, attrs = nil, **, &)
35
+ Testing.stub_result(klass, attrs, **, &)
36
+ end
37
+
38
+ def stub_squishling_error(klass, kind, **, &)
39
+ Testing.stub_error(klass, kind, **, &)
40
+ end
41
+
42
+ def squishling_validate(klass, reply, **inputs)
43
+ Testing.validate(klass, reply, **inputs)
44
+ end
45
+
46
+ def squishling_fallback_for(klass, error, **inputs)
47
+ Testing.fallback_for(klass, error, **inputs)
48
+ end
49
+
50
+ def squishling_preview(klass, **inputs)
51
+ Testing.preview(klass, **inputs)
52
+ end
53
+
54
+ def valid_reply_for(source, **overrides)
55
+ Testing.valid_reply_for(source, **overrides)
56
+ end
57
+
58
+ def invalid_reply_for(source, **)
59
+ Testing.invalid_reply_for(source, **)
60
+ end
61
+
62
+ def capture_squawk
63
+ Testing.capture_squawk
64
+ end
65
+
66
+ def with_squawk(capture = capture_squawk, &)
67
+ Testing.with_squawk(capture, &)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The helpers for Minitest: include it in a test case (or ActiveSupport::TestCase). What it installs (the
6
+ # adapter, observer, and stubs) is put back after each test.
7
+ #
8
+ # class RegexInferrerTest < Minitest::Test
9
+ # include Squishling::Testing::Minitest
10
+ #
11
+ # def test_retries_a_bad_regex
12
+ # fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
13
+ # assert_equal "Run.*", RegexInferrer.call(sample_turns: turns).regex
14
+ # assert_sent_arguments fake, { sample_turns: turns }
15
+ # end
16
+ # end
17
+ module Minitest
18
+ include Helpers
19
+
20
+ def after_teardown
21
+ Testing.reset!
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ def squishling_failure(message)
28
+ flunk message
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Tells the test adapter which request it is answering. An Adapter::Request carries no role, sample, or attempt,
6
+ # but the Observer::Call that wraps it does, on the thread that makes the request: this observer keeps it where
7
+ # Adapter#complete can read it, and passes the request on to any observer the app configured.
8
+ class Observer
9
+ KEY = :squishling_testing_call
10
+
11
+ # The call being made on this thread, or nil outside a request.
12
+ def self.current
13
+ Thread.current[KEY]
14
+ end
15
+
16
+ attr_reader :previous
17
+
18
+ def initialize(previous = nil)
19
+ @previous = previous
20
+ end
21
+
22
+ def around(call, &)
23
+ outer = Thread.current[KEY]
24
+ Thread.current[KEY] = call
25
+ @previous ? @previous.around(call, &) : yield
26
+ ensure
27
+ Thread.current[KEY] = outer
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # A scripted answer with more than content: the model id the provider reports and its token counts, so the
6
+ # squawk and observer usage paths are covered. usage is a Hash such as { input_tokens: 12, output_tokens: 30 }.
7
+ class Reply
8
+ attr_reader :content, :model, :usage
9
+
10
+ def initialize(content = nil, model: nil, usage: nil)
11
+ @content = content
12
+ @model = model
13
+ @usage = usage
14
+ end
15
+ end
16
+
17
+ # A scripted provider failure, named by what went wrong (see Failures).
18
+ Failure = Data.define(:intent)
19
+
20
+ # The answer to the "winner" question of a System One judgment.
21
+ Answer = Struct.new(:choice, :probabilities)
22
+
23
+ # What Adapter#judge returns: answers by question name, like RubyLLM's Judgment.
24
+ Judgment = Struct.new(:answers) do
25
+ def [](name)
26
+ answers[name]
27
+ end
28
+ end
29
+
30
+ # The provider failures a script can name by intent rather than by the error class of any one SDK. Each becomes
31
+ # the Squishling error an adapter raises for it: LLMError for what is worth another attempt, ConfigurationError
32
+ # for what is not. Messages carry the failure and its status, like the real adapters' do.
33
+ module Failures
34
+ # intent => [error class, what happened, HTTP status or nil]
35
+ INTENTS = {
36
+ rate_limit: [LLMError, "rate limited", 429],
37
+ server_error: [LLMError, "server error", 500],
38
+ overloaded: [LLMError, "provider overloaded", 529],
39
+ timeout: [LLMError, "request timed out", nil],
40
+ context_length: [LLMError, "context length exceeded", 400],
41
+ bad_request: [ConfigurationError, "bad request", 400],
42
+ unauthorized: [ConfigurationError, "unauthorized", 401],
43
+ forbidden: [ConfigurationError, "forbidden", 403]
44
+ }.freeze
45
+
46
+ module_function
47
+
48
+ def check!(intent)
49
+ return intent if INTENTS.key?(intent)
50
+
51
+ raise ArgumentError, "unknown failure #{intent.inspect} (one of #{INTENTS.keys.map(&:inspect).join(', ')})"
52
+ end
53
+
54
+ def build(intent, label)
55
+ klass, text, status = INTENTS.fetch(intent)
56
+ klass.new("#{label}: #{text}#{" (HTTP #{status})" if status}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Run's follow-up messages (a private constant of Run): the helpers read what was sent through their parsers.
6
+ MESSAGES = Run.const_get(:Messages)
7
+ private_constant :MESSAGES
8
+
9
+ # One provider request as the test adapter saw it: what left the process, and what came back.
10
+ #
11
+ # arguments and context are the method's arguments and the squish_context / squish! context values the model was
12
+ # sent. For a judge they are those of the call being judged, and candidates holds the two samples. messages is
13
+ # the conversation as sent (empty for a System One judgment, whose payload is the input). conversation is the
14
+ # index of the chat in Adapter#conversations (nil for a judgment): a retry is the same conversation, an
15
+ # escalation a new one. reply is the Adapter::Response, or the error raised instead.
16
+ Request = Data.define(:label, :type, :role, :sample, :attempt, :attempts, :model, :provider, :params,
17
+ :system_prompt, :output_schema, :messages, :conversation, :payload, :reply) do
18
+ def arguments
19
+ input["arguments"]
20
+ end
21
+
22
+ def context
23
+ input["context"]
24
+ end
25
+
26
+ def candidates
27
+ payload["candidates"]
28
+ end
29
+
30
+ # The validation messages this request carried back to the model: a retry in the same conversation, or an
31
+ # escalation to the next step. Empty for a first attempt.
32
+ def errors
33
+ (retry_errors || escalation&.errors) || []
34
+ end
35
+
36
+ # The rejected output forwarded from the previous step (text, capped), or nil when none was.
37
+ def forwarded_rejected
38
+ escalation&.rejected
39
+ end
40
+
41
+ def failed?
42
+ reply.is_a?(Exception)
43
+ end
44
+
45
+ def judge?
46
+ role == :judge
47
+ end
48
+
49
+ # The JSON payload of the first user message, parsed (an escalation message wraps it in its own text).
50
+ def self.payload_of(messages)
51
+ text = messages.first.fetch(:content)
52
+ JSON.parse(MESSAGES.parse_escalation(text)&.input || text)
53
+ rescue JSON::ParserError
54
+ {}
55
+ end
56
+
57
+ private
58
+
59
+ # The method's arguments and context: the payload itself, or inside a judge's "input".
60
+ def input
61
+ judge? ? payload.fetch("input", {}) : payload
62
+ end
63
+
64
+ def retry_errors
65
+ text = messages.last&.fetch(:content, nil)
66
+ messages.size > 1 && text ? MESSAGES.parse_retry(text) : nil
67
+ end
68
+
69
+ def escalation
70
+ text = messages.first&.fetch(:content, nil)
71
+ text && MESSAGES.parse_escalation(text)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The helpers for RSpec (no rspec-mocks needed): `config.include Squishling::Testing::RSpec`, or include it in a
6
+ # group. What it installs (the adapter, observer, and stubs) is put back after each example.
7
+ #
8
+ # RSpec.describe RegexInferrer do
9
+ # it "retries a bad regex" do
10
+ # fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
11
+ # expect(described_class.call(sample_turns: turns).regex).to eq("Run.*")
12
+ # assert_sent_arguments fake, { sample_turns: turns }
13
+ # end
14
+ # end
15
+ module RSpec
16
+ include Helpers
17
+
18
+ def self.included(group)
19
+ super
20
+ group.after { Testing.reset! }
21
+ end
22
+
23
+ private
24
+
25
+ def squishling_failure(message)
26
+ raise ::RSpec::Expectations::ExpectationNotMetError, message
27
+ end
28
+ end
29
+ end
30
+ end