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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +25 -9
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +53 -18
- data/docs/failures.md +123 -18
- data/docs/harnesses.md +24 -14
- data/docs/measuring-tokens.md +8 -6
- data/docs/routing.md +5 -0
- data/docs/schemas.md +11 -2
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +57 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +92 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +21 -9
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +11 -2
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +6 -23
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +66 -42
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +57 -4
- 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,
|
|
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
|
-
#
|
|
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, [
|
|
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
|
-
|
|
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
|
data/lib/squishling/params.rb
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
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
|
|
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
|
|
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
|
data/lib/squishling/result.rb
CHANGED
|
@@ -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]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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)
|
data/lib/squishling/rotation.rb
CHANGED
|
@@ -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
|
-
"
|
|
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}
|
|
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
|
data/lib/squishling/run.rb
CHANGED
|
@@ -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
|
|
58
|
-
# way a judge step is resolved: it keeps its own provider, and its params merge over
|
|
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.
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
230
|
+
reask = sample.reask
|
|
231
|
+
-> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name, reask:) }
|
|
217
232
|
end
|
|
218
|
-
outcomes =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
282
|
-
model: response&.model || step.model, provider: step.provider, params: step.
|
|
283
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
data/lib/squishling/schema.rb
CHANGED
|
@@ -26,17 +26,19 @@ module Squishling
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# The payload handed to
|
|
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)
|