squishling 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +38 -6
- data/docs/configuration.md +36 -6
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +9 -5
- data/docs/harnesses.md +170 -42
- data/docs/measuring-tokens.md +29 -37
- data/docs/observers.md +80 -0
- data/docs/routing.md +2 -2
- data/docs/schemas.md +22 -4
- data/lib/squishling/configuration.rb +15 -2
- data/lib/squishling/definition.rb +9 -5
- data/lib/squishling/errors.rb +5 -5
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +93 -0
- data/lib/squishling/harness/registry.rb +69 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +25 -99
- data/lib/squishling/invoker.rb +11 -228
- data/lib/squishling/judge.rb +66 -22
- data/lib/squishling/llm_client.rb +36 -6
- data/lib/squishling/model_path.rb +64 -21
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/params.rb +10 -5
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +32 -0
- metadata +14 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# The around-style hook for tools that must see an LLM request while it is made (a tracing span, a Coolhand
|
|
5
|
+
# declared workload), which squawk's after-the-fact report can't do. An observer is any object responding to
|
|
6
|
+
# around(call), which yields once to make the request. See docs/observers.md.
|
|
7
|
+
#
|
|
8
|
+
# Observers get call metadata (Call) and, back from the yield, the request's token Usage: never the model's
|
|
9
|
+
# output or the method's inputs.
|
|
10
|
+
module Observer
|
|
11
|
+
# What an observer is told about one provider request. handed_off is true when squish! sent the call to the
|
|
12
|
+
# LLM (typically from a rescue) and false when squish_when or a missing implementation did. role is nil for an
|
|
13
|
+
# escalation attempt, :sample (with sample "a", "b", ...) or :judge, like squawk's metadata. attempt and
|
|
14
|
+
# attempts count the (expanded) escalation path the request belongs to. request is :chat, or :judgment for a
|
|
15
|
+
# System One judge.
|
|
16
|
+
Call = Data.define(:label, :purpose, :harness, :handed_off, :role, :sample, :attempt, :attempts, :model, :provider,
|
|
17
|
+
:request)
|
|
18
|
+
|
|
19
|
+
# What an observer's block returns: the request's token usage and the model that answered, never the content.
|
|
20
|
+
# A count the provider didn't report is nil.
|
|
21
|
+
Usage = Data.define(:input_tokens, :output_tokens, :model)
|
|
22
|
+
|
|
23
|
+
BUILT_IN = %i[coolhand].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# The observer itself, nil (auto-detect), or false (none). A built-in's name (:coolhand) or anything that
|
|
28
|
+
# responds to around is accepted, checked here so a mistake fails at setup, not inside the first request.
|
|
29
|
+
def validate(observer, where)
|
|
30
|
+
return observer if observer.nil? || observer == false
|
|
31
|
+
|
|
32
|
+
if observer.is_a?(Symbol)
|
|
33
|
+
return observer if BUILT_IN.include?(observer)
|
|
34
|
+
|
|
35
|
+
raise ConfigurationError, "#{where} observer must be one of #{BUILT_IN.map(&:inspect).join(', ')}, " \
|
|
36
|
+
"an object that responds to around, or false, got #{observer.inspect}"
|
|
37
|
+
end
|
|
38
|
+
return observer if observer.respond_to?(:around)
|
|
39
|
+
|
|
40
|
+
raise ConfigurationError, "#{where} observer must respond to around(call) { ... } (or be false), " \
|
|
41
|
+
"got #{observer.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The observer that wraps a request: the configured one, the Coolhand observer when nothing is configured and
|
|
45
|
+
# Coolhand is loaded and set up, or nil.
|
|
46
|
+
def resolve(configured)
|
|
47
|
+
case configured
|
|
48
|
+
when false then nil
|
|
49
|
+
when nil then Observers::Coolhand.new if Observers::Coolhand.available?
|
|
50
|
+
when Symbol then Observers::Coolhand.new
|
|
51
|
+
else configured
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Runs the request inside the resolved observer, if there is one. The block runs on the calling thread, so
|
|
56
|
+
# for samples it is the worker thread that makes the request. The observer gets the request's Usage back from
|
|
57
|
+
# its yield, not the response, so the model's output can't leave the process through it, and what it returns
|
|
58
|
+
# is ignored, and so is any rescue of the request's error: the error is raised again. An observer that never
|
|
59
|
+
# yields would make the request vanish, and one that yields twice would send it twice: both are setup mistakes.
|
|
60
|
+
def around(call)
|
|
61
|
+
observer = resolve(Squishling.config.observer)
|
|
62
|
+
return yield unless observer
|
|
63
|
+
|
|
64
|
+
yielded = false
|
|
65
|
+
response = nil
|
|
66
|
+
failure = nil
|
|
67
|
+
observer.around(call) do
|
|
68
|
+
if yielded
|
|
69
|
+
failure = ConfigurationError.new("#{call.label}: #{observer.class}#around must yield only once")
|
|
70
|
+
raise failure
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
yielded = true
|
|
74
|
+
begin
|
|
75
|
+
response = yield
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
failure = e
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
usage(response)
|
|
81
|
+
end
|
|
82
|
+
unless yielded
|
|
83
|
+
raise ConfigurationError,
|
|
84
|
+
"#{call.label}: the #{observer.class} observer's around must yield to make the request"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise failure if failure # an observer can't swallow the request's error: Squishling's taxonomy holds
|
|
88
|
+
|
|
89
|
+
response
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def usage(response)
|
|
93
|
+
tokens = response.respond_to?(:tokens) ? response.tokens : nil
|
|
94
|
+
model = response.respond_to?(:model) ? response.model : nil
|
|
95
|
+
Usage.new(input_tokens: tokens&.input, output_tokens: tokens&.output,
|
|
96
|
+
model: model.respond_to?(:id) ? model.id : model)
|
|
97
|
+
end
|
|
98
|
+
private_class_method :usage
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Observers
|
|
5
|
+
# Declares each request's workload and template to Coolhand (Coolhand.with_context), so Coolhand files the
|
|
6
|
+
# logged call under them instead of matching it to a template. The workload is the squished method
|
|
7
|
+
# ("Class#method", described as that, plus "(rescue)" when squish! handed the call over); the template is the
|
|
8
|
+
# call's place in it: "Class#method/squish:L2" (an escalation attempt), "/sample:a", or "/judge". The model
|
|
9
|
+
# isn't in the key: it's on the log, and the place is the stable identity.
|
|
10
|
+
# Used automatically when the coolhand gem is loaded and configured (see Observer.resolve). See docs/coolhand.md.
|
|
11
|
+
class Coolhand
|
|
12
|
+
REPORTER = "squishling"
|
|
13
|
+
|
|
14
|
+
# Coolhand rejects keys longer than this.
|
|
15
|
+
MAX_KEY_LENGTH = 255
|
|
16
|
+
|
|
17
|
+
# Whether Coolhand is loaded, new enough to declare workloads, and set up to send logs.
|
|
18
|
+
def self.available?
|
|
19
|
+
return false unless defined?(::Coolhand) && ::Coolhand.respond_to?(:with_context)
|
|
20
|
+
|
|
21
|
+
configuration = ::Coolhand.respond_to?(:configuration) && ::Coolhand.configuration
|
|
22
|
+
return false unless configuration
|
|
23
|
+
|
|
24
|
+
configuration.enabled != false && !configuration.api_key.to_s.strip.empty?
|
|
25
|
+
rescue StandardError
|
|
26
|
+
false # nobody asked for this observer, so a Coolhand that can't say whether it's set up isn't ours to break
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.load_library
|
|
30
|
+
require "coolhand"
|
|
31
|
+
end
|
|
32
|
+
private_class_method :load_library
|
|
33
|
+
|
|
34
|
+
# Raises ConfigurationError when the coolhand gem is missing or too old to declare workloads.
|
|
35
|
+
def initialize
|
|
36
|
+
self.class.send(:load_library) unless defined?(::Coolhand)
|
|
37
|
+
return if ::Coolhand.respond_to?(:with_context)
|
|
38
|
+
|
|
39
|
+
raise ConfigurationError, "observer :coolhand needs a coolhand gem version with Coolhand.with_context " \
|
|
40
|
+
"(declared workloads); upgrade the coolhand gem"
|
|
41
|
+
rescue LoadError
|
|
42
|
+
raise ConfigurationError, "observer :coolhand needs the coolhand gem; add `gem \"coolhand\"` to your Gemfile"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def around(call, &)
|
|
46
|
+
workload = call.label
|
|
47
|
+
template = "#{workload}/#{role_key(call)}"
|
|
48
|
+
return yield unless declarable?(workload) && declarable?(template)
|
|
49
|
+
|
|
50
|
+
::Coolhand.with_context(
|
|
51
|
+
reporter: REPORTER,
|
|
52
|
+
workload: { key: workload, name: workload, description: description(call) },
|
|
53
|
+
template: { key: template, name: "#{workload} · #{role_name(call)}" },
|
|
54
|
+
&
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# The squished method, marked "(rescue)" when squish! handed it to the LLM. Not the purpose text, which can be
|
|
61
|
+
# a proc that differs per call.
|
|
62
|
+
def description(call)
|
|
63
|
+
call.handed_off ? "#{call.label} (rescue)" : call.label
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Coolhand rejects, and drops the whole log for, a key over the limit, with surrounding whitespace, or with
|
|
67
|
+
# a leading, trailing or doubled "/".
|
|
68
|
+
def declarable?(key)
|
|
69
|
+
key.length <= MAX_KEY_LENGTH && key == key.strip && !key.start_with?("/") && !key.end_with?("/") &&
|
|
70
|
+
!key.include?("//")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def role_key(call)
|
|
74
|
+
case call.role
|
|
75
|
+
when :sample then "sample:#{call.sample}"
|
|
76
|
+
when :judge then "judge"
|
|
77
|
+
else "squish:L#{call.attempt}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def role_name(call)
|
|
82
|
+
role_key(call).tr(":", " ")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/squishling/params.rb
CHANGED
|
@@ -37,16 +37,21 @@ module Squishling
|
|
|
37
37
|
raise ConfigurationError, "#{label} must be a Hash, got #{params.class}" unless params.is_a?(Hash)
|
|
38
38
|
|
|
39
39
|
params = params.transform_keys(&:to_sym)
|
|
40
|
-
|
|
41
|
-
if reserved.any?
|
|
42
|
-
raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
|
|
43
|
-
"(controlled by Squishling/RubyLLM; use model:/provider:/output_schema)"
|
|
44
|
-
end
|
|
40
|
+
reject_reserved!(params, RESERVED_KEYS, label, "; use model:/provider:/output_schema")
|
|
45
41
|
reject_nested_reserved!(params, label)
|
|
46
42
|
params[:thinking] = normalize_thinking(params[:thinking], label) unless params[:thinking].nil?
|
|
47
43
|
params.freeze
|
|
48
44
|
end
|
|
49
45
|
|
|
46
|
+
# Raises when params sets any of the keys Squishling owns. hint is appended inside the parenthetical.
|
|
47
|
+
def reject_reserved!(params, keys, label, hint = "")
|
|
48
|
+
reserved = params.keys & keys
|
|
49
|
+
return if reserved.empty?
|
|
50
|
+
|
|
51
|
+
raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
|
|
52
|
+
"(controlled by Squishling/RubyLLM#{hint})"
|
|
53
|
+
end
|
|
54
|
+
|
|
50
55
|
# Nested keys are compared as symbols so a string-keyed "responseMimeType" can't slip past the check.
|
|
51
56
|
# A non-Hash container would replace (not merge into) the one RubyLLM builds, wiping the strict format.
|
|
52
57
|
# Validated containers are copied and frozen so they can't be mutated after the check.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# How many times each squished method has been called with models to rotate between (see ModelPath: steps tied on
|
|
5
|
+
# priority). The count is the call's turn: each tier of tied models starts that many steps further along, so
|
|
6
|
+
# successive calls give every model an even share of the first attempts. Counted per class and method, across
|
|
7
|
+
# threads.
|
|
8
|
+
module Rotation
|
|
9
|
+
# class => { method name => calls so far }. Weak keys, so classes replaced by code reloading can be collected.
|
|
10
|
+
COUNTS = ObjectSpace::WeakKeyMap.new
|
|
11
|
+
COUNTS_LOCK = Mutex.new
|
|
12
|
+
private_constant :COUNTS, :COUNTS_LOCK
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# This call's turn, counting from 0, and counts it.
|
|
16
|
+
def advance(klass, name)
|
|
17
|
+
COUNTS_LOCK.synchronize do
|
|
18
|
+
counts = (COUNTS[klass] ||= {})
|
|
19
|
+
turn = counts.fetch(name, 0)
|
|
20
|
+
counts[name] = turn + 1
|
|
21
|
+
turn
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Run
|
|
5
|
+
# The follow-up messages a Run sends a model after an output was rejected.
|
|
6
|
+
module Messages
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def retry_message(errors)
|
|
10
|
+
"Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The first message to a fresh chat after an earlier model's output was rejected. The rejected output is
|
|
14
|
+
# model-generated and may cross providers, so it is capped at Invoker::MAX_FORWARDED_CHARS.
|
|
15
|
+
def escalation_message(input, raw, errors)
|
|
16
|
+
previous = rejected_text(raw)
|
|
17
|
+
previous = "(an empty response)" if previous.strip.empty? || raw.nil?
|
|
18
|
+
if previous.length > Invoker::MAX_FORWARDED_CHARS
|
|
19
|
+
omitted = previous.length - Invoker::MAX_FORWARDED_CHARS
|
|
20
|
+
previous = "#{previous[0, Invoker::MAX_FORWARDED_CHARS]}... [truncated, #{omitted} more characters]"
|
|
21
|
+
end
|
|
22
|
+
"#{input}\n\nA previous attempt at this request returned:\n#{previous}\n" \
|
|
23
|
+
"It was rejected:\n- #{errors.join("\n- ")}\nRespond with corrected JSON only."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The rejected output as text. Output that was rejected for being unrepresentable in JSON (Infinity, invalid
|
|
27
|
+
# UTF-8) can't be serialized or measured as is, so it is scrubbed or replaced rather than raising.
|
|
28
|
+
def rejected_text(raw)
|
|
29
|
+
return utf8(raw).scrub("?") if raw.is_a?(String)
|
|
30
|
+
|
|
31
|
+
JSON.generate(raw)
|
|
32
|
+
rescue JSON::JSONError
|
|
33
|
+
"(a response JSON can't represent)"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A binary-tagged string counts every byte as valid, so it is read as UTF-8 before scrubbing.
|
|
37
|
+
def utf8(text)
|
|
38
|
+
text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# One squished call, as a harness sees it (see Harness::Base): the primitives that ask the model for outputs,
|
|
5
|
+
# and everything they do for free: model, provider, and params resolution, schema and squish_validate checks,
|
|
6
|
+
# retries in the same chat, escalation, squawk reports, and Squishling's own errors when attempts run out.
|
|
7
|
+
# Each returns typed results, the same type as the deterministic path.
|
|
8
|
+
#
|
|
9
|
+
# A stage is the attempts of one escalation step (a model with its attempts:), as an Array of
|
|
10
|
+
# ModelPath::Step. Primitives take stages, or anything that declares one (a model name or a step Hash).
|
|
11
|
+
#
|
|
12
|
+
# Primitives must be called from the thread that runs the harness. Only the provider requests inside them run
|
|
13
|
+
# on other threads; parsing, validation, and every callback (squish_validate, compare:, procs) stay on this one.
|
|
14
|
+
class Run
|
|
15
|
+
include Messages
|
|
16
|
+
|
|
17
|
+
private_constant :Messages
|
|
18
|
+
|
|
19
|
+
# One of #samples' outputs, between rounds.
|
|
20
|
+
Sample = Struct.new(:name, :stage, :chat, :rejected, :result)
|
|
21
|
+
|
|
22
|
+
SAMPLE_NAMES = ("a".."z").to_a.freeze
|
|
23
|
+
private_constant :Sample, :SAMPLE_NAMES
|
|
24
|
+
|
|
25
|
+
attr_reader :receiver, :inputs, :steps
|
|
26
|
+
|
|
27
|
+
def initialize(definition:, receiver:, inputs:, prompt:, payload:, purpose:, harness:)
|
|
28
|
+
@definition = definition
|
|
29
|
+
@receiver = receiver
|
|
30
|
+
@inputs = inputs
|
|
31
|
+
@prompt = prompt
|
|
32
|
+
@payload = payload
|
|
33
|
+
@purpose = purpose
|
|
34
|
+
@harness = harness
|
|
35
|
+
@steps = definition.escalation_path.freeze
|
|
36
|
+
@client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off)
|
|
37
|
+
@output_check = OutputCheck.new(definition, receiver, inputs)
|
|
38
|
+
@owner = Thread.current
|
|
39
|
+
@produced = {}.compare_by_identity
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether value is a result one of this run's primitives returned (not one built by hand).
|
|
43
|
+
def produced?(value)
|
|
44
|
+
@produced.key?(value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The squished method, as "Class#method".
|
|
48
|
+
def label
|
|
49
|
+
@definition.label
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The escalation's steps grouped into stages: runs of identical consecutive steps, the attempts of one model.
|
|
53
|
+
def stages
|
|
54
|
+
@stages ||= steps.slice_when { |before, after| before != after }.map(&:freeze).freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
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 (an Array of steps) is returned as is, and a single step is a stage of one attempt.
|
|
60
|
+
def stage(declaration)
|
|
61
|
+
return checked_stage([declaration]) if declaration.is_a?(ModelPath::Step)
|
|
62
|
+
|
|
63
|
+
if declaration.is_a?(Array)
|
|
64
|
+
return checked_stage(declaration) if declaration.any? && declaration.all?(ModelPath::Step)
|
|
65
|
+
|
|
66
|
+
raise ConfigurationError, "#{label}: a stage is the steps of one model, got #{declaration.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
step = Harness::OptionTypes.check(:step, declaration, label, :"harness step")
|
|
70
|
+
ModelPath.steps([step], provider: nil, params: @definition.params).freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 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
|
|
75
|
+
# 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.
|
|
77
|
+
def escalate(*stages)
|
|
78
|
+
owner!
|
|
79
|
+
path = stages.empty? ? steps : stages.flat_map { |declared| stage(declared) }
|
|
80
|
+
escalate_with(path, @prompt)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# One sample per stage (the attempts of one model), each asked on its own chat, each retried per its stage's
|
|
84
|
+
# attempts like #escalate, with the requests of every round sent concurrently. Returns the outputs in order.
|
|
85
|
+
# Samples are named "a", "b", ... in squawk reports.
|
|
86
|
+
def samples(stages)
|
|
87
|
+
owner!
|
|
88
|
+
raise ConfigurationError, "#{label}: samples takes an Array of stages" unless stages.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
stages = stages.map { |declared| stage(declared) }
|
|
91
|
+
raise ConfigurationError, "#{label}: samples needs at least one stage" if stages.empty?
|
|
92
|
+
if stages.size > SAMPLE_NAMES.size
|
|
93
|
+
raise ConfigurationError, "#{label}: samples takes at most #{SAMPLE_NAMES.size} stages"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if (mixed = stages.find { |sample_stage| sample_stage.uniq.size > 1 })
|
|
97
|
+
raise ConfigurationError, "#{label}: a sample is asked on one model, so its stage can't mix steps " \
|
|
98
|
+
"(got #{mixed.map(&:display_name).join(', ')})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
samples = stages.zip(SAMPLE_NAMES).map { |sample_stage, name| Sample.new(name, sample_stage) }
|
|
102
|
+
(1..stages.map(&:size).max).each do |attempt|
|
|
103
|
+
pending = samples.reject(&:result)
|
|
104
|
+
break if pending.empty?
|
|
105
|
+
|
|
106
|
+
round(pending, attempt)
|
|
107
|
+
end
|
|
108
|
+
samples.map(&:result)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A judge that picks between two outputs (see Judge#verdict, which returns a Judge::Verdict), or rejects
|
|
112
|
+
# both. judge: is a model name or a Hash with model:, provider:, params:, and attempts: (as an escalation
|
|
113
|
+
# step, without priority:), plus type: :chat (default) or :judgment and, for :judgment, min_confidence:.
|
|
114
|
+
# Without one, the judge is the escalation's next stage after the first. instructions: is a String, or a
|
|
115
|
+
# Proc evaluated against the receiver, replacing Harness::DEFAULT_JUDGE_INSTRUCTIONS. The judge is resolved
|
|
116
|
+
# here, so a missing one raises ConfigurationError before any request is made.
|
|
117
|
+
def judge(judge: nil, instructions: nil)
|
|
118
|
+
owner!
|
|
119
|
+
judge = judge_step(judge) unless judge.nil?
|
|
120
|
+
Judge.new(definition: @definition, receiver: @receiver, client: @client, judge:, instructions:, path: steps,
|
|
121
|
+
purpose: @purpose, payload: @payload, escalate: method(:escalate_with), log_failure: method(:log_failure),
|
|
122
|
+
guard: method(:owner!), harness_type: @harness.type)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Whether two outputs agree: compare (a Proc, evaluated against the receiver with the two results and the
|
|
126
|
+
# method's inputs, truthy for agreement) or, without one, whether they are exactly equal.
|
|
127
|
+
# compare is the developer's own code, so its exceptions propagate unwrapped.
|
|
128
|
+
def agree?(first, second, compare = nil)
|
|
129
|
+
owner!
|
|
130
|
+
return Schema.jsonify(first.to_h) == Schema.jsonify(second.to_h) unless compare
|
|
131
|
+
|
|
132
|
+
@receiver.instance_exec(first, second, **@inputs, &compare) ? true : false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Logs a warning, "[Squishling] Class#method message", to Squishling.config.logger, if there is one.
|
|
136
|
+
def log(message)
|
|
137
|
+
Squishling.config.logger&.warn("[Squishling] #{label} #{message}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Steps made or changed by hand get the params checks a declared step does (no reserved keys).
|
|
143
|
+
def checked_stage(stage)
|
|
144
|
+
stage.each { |step| Params.normalize(step.params, "#{label} harness step params") }
|
|
145
|
+
stage.frozen? ? stage : stage.dup.freeze
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A judge declaration as the escalation step Judge takes: one step (no priority:), with its type.
|
|
149
|
+
def judge_step(declaration)
|
|
150
|
+
step = ModelPath.normalize_step(declaration, "#{label} judge", extras: Judge::STEP_KEYS)
|
|
151
|
+
ModelPath.single_step!(step, "#{label} judge: a judge is one step, so it")
|
|
152
|
+
|
|
153
|
+
type = step.fetch(:type, :chat)
|
|
154
|
+
type = :chat if type.nil?
|
|
155
|
+
step.merge(type: type.is_a?(String) ? type.to_sym : type)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def owner!
|
|
159
|
+
return if Thread.current.equal?(@owner)
|
|
160
|
+
|
|
161
|
+
raise ConfigurationError, "#{label}: a harness must call its run's primitives from the thread it runs on"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Makes each attempt of the path until an output passes (see #escalate). The judge's prompt (role :judge)
|
|
165
|
+
# skips squish_validate and names the judge in errors and logs.
|
|
166
|
+
def escalate_with(path, prompt)
|
|
167
|
+
chat = nil
|
|
168
|
+
rejected = nil # [raw content, errors] of the last invalid output
|
|
169
|
+
models = []
|
|
170
|
+
|
|
171
|
+
path.each.with_index(1) do |step, attempt|
|
|
172
|
+
models << step.model
|
|
173
|
+
last = attempt == path.size
|
|
174
|
+
if chat && step == path[attempt - 2]
|
|
175
|
+
message = retry_message(rejected.last)
|
|
176
|
+
else
|
|
177
|
+
chat = start_chat(step, prompt)
|
|
178
|
+
message = rejected && step.forward_rejected ? escalation_message(prompt.input, *rejected) : prompt.input
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
begin
|
|
182
|
+
response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role)
|
|
183
|
+
rescue LLMError => e
|
|
184
|
+
squawk(prompt, path, attempt, nil, e)
|
|
185
|
+
raise if last
|
|
186
|
+
|
|
187
|
+
# The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
|
|
188
|
+
chat = nil
|
|
189
|
+
log_failure(prompt.role, attempt, path, e.message)
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
result, errors = @output_check.call(response.content, prompt)
|
|
194
|
+
if errors.empty?
|
|
195
|
+
squawk(prompt, path, attempt, response, nil)
|
|
196
|
+
return produce(result, prompt)
|
|
197
|
+
end
|
|
198
|
+
|
|
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
|
|
203
|
+
|
|
204
|
+
rejected = [response.content, errors]
|
|
205
|
+
log_failure(prompt.role, attempt, path, errors.join("; "))
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# One round of #samples: the pending samples' requests, sent concurrently, then each outcome recorded.
|
|
210
|
+
def round(pending, attempt)
|
|
211
|
+
jobs = pending.map do |sample|
|
|
212
|
+
step = sample.stage[attempt - 1]
|
|
213
|
+
message = sample_message(sample, step)
|
|
214
|
+
chat = sample.chat
|
|
215
|
+
attempts = sample.stage.size
|
|
216
|
+
-> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name) }
|
|
217
|
+
end
|
|
218
|
+
outcomes = LLMClient.concurrently(jobs)
|
|
219
|
+
# A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
|
|
220
|
+
fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
|
|
221
|
+
raise fatal if fatal
|
|
222
|
+
|
|
223
|
+
pending.zip(outcomes).each { |sample, outcome| record(sample, outcome, attempt) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
|
|
227
|
+
def sample_message(sample, step)
|
|
228
|
+
return retry_message(sample.rejected.last) if sample.chat
|
|
229
|
+
|
|
230
|
+
sample.chat = start_chat(step, @prompt)
|
|
231
|
+
sample.rejected && step.forward_rejected ? escalation_message(@prompt.input, *sample.rejected) : @prompt.input
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def record(sample, (response, error), attempt)
|
|
235
|
+
stage = sample.stage
|
|
236
|
+
last = attempt == stage.size
|
|
237
|
+
role = "sample #{sample.name}"
|
|
238
|
+
if error
|
|
239
|
+
squawk(@prompt, stage, attempt, nil, error, sample:)
|
|
240
|
+
raise error if last
|
|
241
|
+
|
|
242
|
+
sample.chat = nil
|
|
243
|
+
return log_failure(role, attempt, stage, error.message)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
result, errors = @output_check.call(response.content, @prompt)
|
|
247
|
+
if errors.empty?
|
|
248
|
+
squawk(@prompt, stage, attempt, response, nil, sample:)
|
|
249
|
+
return sample.result = produce(result, @prompt)
|
|
250
|
+
end
|
|
251
|
+
|
|
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
|
|
256
|
+
|
|
257
|
+
sample.rejected = [response.content, errors]
|
|
258
|
+
log_failure(role, attempt, stage, errors.join("; "))
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Remembers a result of the method's own output (a judge's verdict isn't one), so a harness can only return
|
|
262
|
+
# what a primitive produced.
|
|
263
|
+
def produce(result, prompt)
|
|
264
|
+
@produced[result] = true unless prompt.role == :judge
|
|
265
|
+
result
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def start_chat(step, prompt)
|
|
269
|
+
@client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
|
|
273
|
+
# 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)
|
|
276
|
+
hook = @definition.squawk
|
|
277
|
+
return unless hook
|
|
278
|
+
|
|
279
|
+
step = path[attempt - 1]
|
|
280
|
+
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
|
|
284
|
+
}
|
|
285
|
+
Squawk.call(hook, output: response&.content, metadata:, error:)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def log_failure(role, attempt, path, reason)
|
|
289
|
+
step = path[attempt - 1]
|
|
290
|
+
next_step = path[attempt]
|
|
291
|
+
action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
|
|
292
|
+
who = role ? "#{role} attempt" : "attempt"
|
|
293
|
+
log("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
data/lib/squishling/version.rb
CHANGED
data/lib/squishling.rb
CHANGED
|
@@ -10,7 +10,16 @@ require_relative "squishling/errors"
|
|
|
10
10
|
require_relative "squishling/configuration"
|
|
11
11
|
require_relative "squishling/params"
|
|
12
12
|
require_relative "squishling/squawk"
|
|
13
|
+
require_relative "squishling/observer"
|
|
14
|
+
require_relative "squishling/observers/coolhand"
|
|
13
15
|
require_relative "squishling/model_path"
|
|
16
|
+
require_relative "squishling/rotation"
|
|
17
|
+
require_relative "squishling/harness/option_types"
|
|
18
|
+
require_relative "squishling/harness/registry"
|
|
19
|
+
require_relative "squishling/harness/base"
|
|
20
|
+
require_relative "squishling/harness/escalation"
|
|
21
|
+
require_relative "squishling/harness/squishsum"
|
|
22
|
+
require_relative "squishling/harness/judged_squishsum"
|
|
14
23
|
require_relative "squishling/harness"
|
|
15
24
|
require_relative "squishling/result"
|
|
16
25
|
require_relative "squishling/schema"
|
|
@@ -19,6 +28,8 @@ require_relative "squishling/appendices"
|
|
|
19
28
|
require_relative "squishling/definition"
|
|
20
29
|
require_relative "squishling/llm_client"
|
|
21
30
|
require_relative "squishling/output_check"
|
|
31
|
+
require_relative "squishling/run/messages"
|
|
32
|
+
require_relative "squishling/run"
|
|
22
33
|
require_relative "squishling/invoker"
|
|
23
34
|
require_relative "squishling/judge"
|
|
24
35
|
require_relative "squishling/router"
|
|
@@ -42,6 +53,27 @@ module Squishling
|
|
|
42
53
|
@config = Configuration.new
|
|
43
54
|
end
|
|
44
55
|
|
|
56
|
+
# Forgets the registered harnesses (the built-ins stay), e.g. between specs. Kept apart from reset_config!
|
|
57
|
+
# because harnesses are registered once at boot, and a suite that resets its config between examples
|
|
58
|
+
# shouldn't lose them.
|
|
59
|
+
def reset_harnesses!
|
|
60
|
+
Harness::Registry.reset!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Registers a custom harness under a name, usable anywhere a built-in type is (harness: :name, or
|
|
64
|
+
# harness: { type: :name, option: value }, on a class, a method, a squish! call, or config.default_harness).
|
|
65
|
+
# Pass a Harness::Base subclass, or a block that becomes the body of one (see Harness::Base). Registered
|
|
66
|
+
# globally, so do it before the classes that use it load (an initializer). Raises ConfigurationError for a
|
|
67
|
+
# name that is invalid or already taken, including the built-in :escalation, :squishsum, :judged_squishsum.
|
|
68
|
+
def register_harness(name, klass = nil, &)
|
|
69
|
+
Harness::Registry.register(name, klass, &)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The names of the harnesses that can be declared: the built-ins, then those registered.
|
|
73
|
+
def harnesses
|
|
74
|
+
Harness.types
|
|
75
|
+
end
|
|
76
|
+
|
|
45
77
|
def included(base)
|
|
46
78
|
raise ConfigurationError, "Squishling can only be included in a class" unless base.is_a?(Class)
|
|
47
79
|
|