squishling 0.4.0 → 0.5.1
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 +149 -0
- data/README.md +26 -10
- data/docs/adapters.md +257 -0
- data/docs/configuration.md +54 -19
- data/docs/failures.md +170 -22
- data/docs/harnesses.md +39 -15
- data/docs/measuring-tokens.md +12 -6
- data/docs/observers.md +3 -3
- data/docs/routing.md +14 -3
- data/docs/schemas.md +24 -2
- data/docs/testing.md +187 -0
- data/docs/verifying.md +86 -0
- data/lib/squishling/adapter/error_body.rb +54 -0
- data/lib/squishling/adapter.rb +328 -0
- data/lib/squishling/adapters/openai_compatible.rb +311 -0
- data/lib/squishling/adapters/openai_options.rb +114 -0
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +229 -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 +58 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +94 -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 +44 -14
- 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 +13 -4
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +7 -24
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +50 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/router.rb +3 -2
- 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/reports.rb +63 -0
- data/lib/squishling/run.rb +60 -53
- data/lib/squishling/schema.rb +50 -9
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +37 -4
- 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 +74 -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/wrapper.rb +19 -1
- data/lib/squishling.rb +60 -4
- metadata +43 -21
data/lib/squishling/invoker.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
# The elastic path: sends a squished method's inputs through
|
|
4
|
+
# The elastic path: sends a squished method's inputs through the configured adapter with structured output.
|
|
5
5
|
class Invoker
|
|
6
6
|
INPUT_NOTE = <<~NOTE
|
|
7
7
|
The input is a JSON object. "arguments" holds the values passed to this operation; "context", when
|
|
@@ -21,25 +21,45 @@ module Squishling
|
|
|
21
21
|
@inputs = inputs
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Everything a Run needs of the call: the prompt, the payload it was built from, and the purpose text.
|
|
25
|
+
Built = Data.define(:prompt, :payload, :purpose)
|
|
26
|
+
|
|
24
27
|
# Runs the harness (Definition#harness) over a Run of the call: the harness decides how the escalation is
|
|
25
|
-
# used, and what it returns is checked against the output schema's type.
|
|
28
|
+
# used, and what it returns is checked against the output schema's type. A call that ends on the token budget
|
|
29
|
+
# or a disagreement is reported to squawk once here, as the stop no attempt of its own reported.
|
|
26
30
|
def call
|
|
31
|
+
built = build
|
|
32
|
+
harness = @definition.harness
|
|
33
|
+
run = Run.new(definition: @definition, receiver: @receiver, inputs: @inputs, prompt: built.prompt,
|
|
34
|
+
payload: built.payload, purpose: built.purpose, harness:)
|
|
35
|
+
accepted(call_harness(harness, run), run, harness)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The first request the call would make (system prompt, output schema, user message), without making it.
|
|
39
|
+
def prompt
|
|
40
|
+
build.prompt
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def call_harness(harness, run)
|
|
46
|
+
harness.klass.new.call(run, **harness.options)
|
|
47
|
+
rescue BudgetExhaustedError, DisagreementError => e
|
|
48
|
+
run.report_stop(e)
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build
|
|
27
53
|
purpose = @definition.purpose(@receiver)
|
|
28
54
|
schema = @definition.schema
|
|
29
55
|
raise ConfigurationError, "#{@definition.label} has no purpose" if purpose.nil? || purpose.empty?
|
|
30
56
|
raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
|
|
31
57
|
|
|
32
|
-
harness = @definition.harness
|
|
33
58
|
body = payload
|
|
34
|
-
prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:,
|
|
35
|
-
|
|
36
|
-
run = Run.new(definition: @definition, receiver: @receiver, inputs: @inputs, prompt:, payload: body, purpose:,
|
|
37
|
-
harness:)
|
|
38
|
-
accepted(harness.klass.new.call(run, **harness.options), run, harness)
|
|
59
|
+
prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:, input: JSON.generate(body), role: nil)
|
|
60
|
+
Built.new(prompt:, payload: body, purpose:)
|
|
39
61
|
end
|
|
40
62
|
|
|
41
|
-
private
|
|
42
|
-
|
|
43
63
|
# What a harness returns must be a result its run produced: validated output, typed like the deterministic
|
|
44
64
|
# path's. One built by hand, or a hash, would skip the schema and squish_validate.
|
|
45
65
|
def accepted(value, run, harness)
|
|
@@ -58,16 +78,26 @@ module Squishling
|
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
# Exceptions are sent as their class and message. Plain JSON would send only the message, and with
|
|
61
|
-
# json/add/exception loaded it would also send the backtrace, which exposes file paths.
|
|
62
|
-
|
|
81
|
+
# json/add/exception loaded it would also send the backtrace, which exposes file paths. path is the Hashes and
|
|
82
|
+
# Arrays being walked: one that contains itself is a ConfigurationError rather than a SystemStackError.
|
|
83
|
+
def describe(value, path = [])
|
|
63
84
|
case value
|
|
64
85
|
when Exception then { class: exception_class_name(value.class), message: value.message }
|
|
65
|
-
when Hash then value.transform_values { |item| describe(item) }
|
|
66
|
-
when Array then value.map { |item| describe(item) }
|
|
86
|
+
when Hash then within(value, path) { |inner| value.transform_values { |item| describe(item, inner) } }
|
|
87
|
+
when Array then within(value, path) { |inner| value.map { |item| describe(item, inner) } }
|
|
67
88
|
else value
|
|
68
89
|
end
|
|
69
90
|
end
|
|
70
91
|
|
|
92
|
+
def within(container, path)
|
|
93
|
+
if path.any? { |ancestor| ancestor.equal?(container) }
|
|
94
|
+
raise ConfigurationError, "#{@definition.label}: an argument or context value contains itself, " \
|
|
95
|
+
"so it can't be sent as JSON"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
yield path + [container]
|
|
99
|
+
end
|
|
100
|
+
|
|
71
101
|
# An anonymous error class (Class.new(StandardError)) is named after its closest named ancestor.
|
|
72
102
|
def exception_class_name(klass)
|
|
73
103
|
klass = klass.superclass until klass.name
|
data/lib/squishling/judge.rb
CHANGED
|
@@ -4,10 +4,12 @@ module Squishling
|
|
|
4
4
|
# Picks between two disagreeing samples, or rejects both (see Run#judge). The judge is the given judge:
|
|
5
5
|
# step, or else the escalation's next stage after the first. A chat judge answers with a strict verdict
|
|
6
6
|
# schema; a :judgment judge (a System One decision model such as Jev) answers one choice question through
|
|
7
|
-
# RubyLLM
|
|
7
|
+
# the adapter's judgments (RubyLLM's, with the RubyLLM adapter), and its pick counts only at or above
|
|
8
|
+
# min_confidence.
|
|
8
9
|
class Judge
|
|
9
10
|
# The judge kinds: :chat (a chat model answering VERDICT_SCHEMA) and :judgment (a System One decision model
|
|
10
|
-
# through
|
|
11
|
+
# through the adapter's judgments). A judge is an escalation step (see ModelPath) plus these keys:
|
|
12
|
+
# type: (default :chat)
|
|
11
13
|
# and, for :judgment, min_confidence: (the probability the chosen candidate needs).
|
|
12
14
|
TYPES = %i[chat judgment].freeze
|
|
13
15
|
STEP_KEYS = %i[type min_confidence].freeze
|
|
@@ -42,14 +44,16 @@ module Squishling
|
|
|
42
44
|
# judge: is a normalized judge step (see Harness::JudgedSquishsum) or nil for the escalation's next stage,
|
|
43
45
|
# instructions: a String, a Proc evaluated against the receiver, or nil for the default prompt.
|
|
44
46
|
def initialize(definition:, receiver:, client:, judge:, instructions:, path:, purpose:, payload:, escalate:,
|
|
45
|
-
log_failure:, guard:, harness_type:)
|
|
47
|
+
log_failure:, guard:, budget_check:, harness_type:)
|
|
46
48
|
@definition = definition
|
|
47
49
|
@receiver = receiver
|
|
48
50
|
@client = client
|
|
49
51
|
@judge = judge
|
|
50
52
|
@instructions = instructions
|
|
51
53
|
check!
|
|
54
|
+
check_judgments!
|
|
52
55
|
@guard = guard
|
|
56
|
+
@budget_check = budget_check
|
|
53
57
|
@harness_type = harness_type
|
|
54
58
|
@purpose = purpose
|
|
55
59
|
@payload = payload
|
|
@@ -95,6 +99,14 @@ module Squishling
|
|
|
95
99
|
raise ConfigurationError, "#{label} instructions must be a non-blank String or a Proc"
|
|
96
100
|
end
|
|
97
101
|
|
|
102
|
+
# Raised before any request, like the rest of the judge's checks.
|
|
103
|
+
def check_judgments!
|
|
104
|
+
return if !judgment? || @client.judgments?
|
|
105
|
+
|
|
106
|
+
raise ConfigurationError, "#{@definition.label}: judge type: :judgment needs an adapter that supports " \
|
|
107
|
+
"judgments (the configured adapter doesn't); use type: :chat or the :ruby_llm adapter"
|
|
108
|
+
end
|
|
109
|
+
|
|
98
110
|
def non_blank_string?(value)
|
|
99
111
|
value.is_a?(String) && !value.strip.empty?
|
|
100
112
|
end
|
|
@@ -163,6 +175,7 @@ module Squishling
|
|
|
163
175
|
# A judgment always matches its questions, so only a failed request (LLMError) moves on to the next attempt.
|
|
164
176
|
def judge_with_retries(input, questions)
|
|
165
177
|
@steps.each.with_index(1) do |step, attempt|
|
|
178
|
+
@budget_check.call(attempts: attempt - 1)
|
|
166
179
|
return @client.observed_judgment(input, questions:, step:, attempt:, attempts: @steps.size)
|
|
167
180
|
rescue LLMError => e
|
|
168
181
|
raise if attempt == @steps.size
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
#
|
|
4
|
+
# The requests for one squished method, made through the configured adapter (see Adapter). Squishling owns the
|
|
5
|
+
# conversation: each Conversation holds the system prompt, the output schema, and the messages so far, and every
|
|
6
|
+
# request sends all of it to the adapter.
|
|
5
7
|
class LLMClient
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
# One chat on one escalation step. messages are { role:, content: } Hashes, grown by #ask.
|
|
9
|
+
Conversation = Struct.new(:step, :instructions, :schema, :messages)
|
|
10
|
+
|
|
11
|
+
# purpose, harness (the harness type), and handed_off (squish! sent the call here) are only told to the observer.
|
|
12
|
+
# budget (see Budget) caps the tokens the requests of this client may use between them.
|
|
13
|
+
def initialize(label, purpose:, harness:, handed_off:, budget: {})
|
|
14
|
+
@label = label
|
|
15
|
+
@purpose = purpose
|
|
16
|
+
@harness = harness
|
|
17
|
+
@handed_off = handed_off
|
|
18
|
+
@adapter = Squishling.config.resolved_adapter
|
|
19
|
+
@tally = Budget::Tally.new(budget)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The budget limit this client's requests have reached ({ limit:, max:, used: }), or nil. Checked before each
|
|
23
|
+
# request, so one already in flight is never cut short.
|
|
24
|
+
def exhausted_budget
|
|
25
|
+
@tally.exhausted
|
|
26
|
+
end
|
|
9
27
|
|
|
10
28
|
# Runs each job on its own thread and returns, in order, each job's [value, exception]. Only provider
|
|
11
29
|
# requests run here: parsing, validation, and the developer's callbacks stay on the caller's thread. Fiber
|
|
12
|
-
# storage
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
# stopped rather than left running unobserved.
|
|
17
|
-
def self.concurrently(jobs)
|
|
30
|
+
# storage is inherited by the threads, and the adapter carries over whatever request context it keeps (see
|
|
31
|
+
# Adapter::Base#capture_context). If the caller is interrupted while waiting (a timeout, Thread#raise), the
|
|
32
|
+
# requests still running are stopped rather than left running unobserved.
|
|
33
|
+
def concurrently(jobs)
|
|
18
34
|
return jobs.map { |job| capture(job) } if jobs.size < 2
|
|
19
35
|
|
|
20
|
-
context =
|
|
36
|
+
context = @adapter.capture_context if @adapter.respond_to?(:capture_context)
|
|
21
37
|
threads = jobs.map do |job|
|
|
22
|
-
thread = Thread.new {
|
|
38
|
+
thread = Thread.new { with_context(context) { capture(job) } }
|
|
23
39
|
thread.report_on_exception = false
|
|
24
40
|
thread
|
|
25
41
|
end
|
|
@@ -28,76 +44,32 @@ module Squishling
|
|
|
28
44
|
threads&.each { |thread| thread.kill.join if thread.alive? }
|
|
29
45
|
end
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
[job.call, nil]
|
|
33
|
-
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised on the caller's thread
|
|
34
|
-
[nil, e]
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def self.instrumentation_context
|
|
38
|
-
instrumentation = defined?(RubyLLM::Support::Instrumentation) && RubyLLM::Support::Instrumentation
|
|
39
|
-
return unless instrumentation.respond_to?(:current_workflow) && instrumentation.respond_to?(:capture_context)
|
|
40
|
-
|
|
41
|
-
[instrumentation.current_workflow, instrumentation.capture_context]
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def self.with_instrumentation_context(context, &)
|
|
45
|
-
return yield unless context
|
|
46
|
-
|
|
47
|
-
instrumentation = RubyLLM::Support::Instrumentation
|
|
48
|
-
instrumentation.with_workflow(context.first) { instrumentation.with_context(context.last, &) }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private_class_method :capture, :instrumentation_context, :with_instrumentation_context
|
|
52
|
-
|
|
53
|
-
# purpose, harness (the harness type), and handed_off (squish! sent the call here) are only told to the observer.
|
|
54
|
-
def initialize(label, purpose:, harness:, handed_off:)
|
|
55
|
-
@label = label
|
|
56
|
-
@purpose = purpose
|
|
57
|
-
@harness = harness
|
|
58
|
-
@handed_off = handed_off
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# A fresh chat on one escalation step, with the system prompt, the strict output schema, and its params.
|
|
47
|
+
# A fresh conversation on one escalation step, with the system prompt and the strict output schema.
|
|
62
48
|
def chat(step, instructions:, schema:)
|
|
63
|
-
|
|
64
|
-
chat.with_instructions(instructions)
|
|
65
|
-
chat.with_schema(schema)
|
|
66
|
-
apply_params(chat, step.params)
|
|
67
|
-
chat
|
|
49
|
+
Conversation.new(step, instructions, schema, [])
|
|
68
50
|
end
|
|
69
51
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# fallback would otherwise hide it on every call.
|
|
52
|
+
# One request in the conversation: the message goes in, and the adapter's answer is kept in it so the next
|
|
53
|
+
# message continues from there. A request that fails leaves the conversation as it was.
|
|
54
|
+
# The adapter raises ConfigurationError for setup mistakes (never retried) and LLMError for failures worth another
|
|
55
|
+
# attempt, which moves on to the next attempt of the escalation (or propagates from the last one).
|
|
75
56
|
# The request runs inside the configured observer (see Observer), on this thread: for samples, the worker's.
|
|
76
57
|
# role is nil for an escalation attempt, :sample (with its name) or :judge; attempt counts in attempts.
|
|
77
|
-
|
|
58
|
+
# reask is whether this message is a retry in the same chat (see ModelPath::Step#request_params).
|
|
59
|
+
def ask(chat, message, step, attempt:, attempts:, role: nil, sample: nil, reask: false)
|
|
78
60
|
call = call_info(step, :chat, role:, sample:, attempt:, attempts:)
|
|
79
|
-
Observer.around(call) {
|
|
61
|
+
Observer.around(call) { complete(chat, message, step, reask) }
|
|
80
62
|
end
|
|
81
63
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
64
|
+
# Whether the adapter can make System One judgments (judges with type: :judgment).
|
|
65
|
+
def judgments?
|
|
66
|
+
@adapter.respond_to?(:judgments?) && @adapter.respond_to?(:judge) && @adapter.judgments?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A System One judgment on one step, through the adapter.
|
|
70
|
+
# Judge#check_judgments! has already refused an adapter that can't.
|
|
87
71
|
def judge(input, questions:, step:)
|
|
88
|
-
|
|
89
|
-
RubyLLM.judge(input, questions:, provider_options: step.params, **model_options(step))
|
|
90
|
-
rescue RubyLLM::ModelNotFoundError => e
|
|
91
|
-
raise ConfigurationError, "#{@label}: #{e.message}"
|
|
92
|
-
rescue ArgumentError => e
|
|
93
|
-
raise unless raised_by_ruby_llm?(e)
|
|
94
|
-
|
|
95
|
-
raise ConfigurationError, "#{@label}: #{e.message}"
|
|
96
|
-
rescue RubyLLM::Error => e
|
|
97
|
-
raise unless e.instance_of?(RubyLLM::Error) && e.response.nil?
|
|
98
|
-
|
|
99
|
-
raise ConfigurationError, "#{@label}: #{e.message}"
|
|
100
|
-
end
|
|
72
|
+
@tally.add(@adapter.judge(input, questions:, step:, label: @label))
|
|
101
73
|
end
|
|
102
74
|
|
|
103
75
|
# #judge inside the configured observer (see Observer), attempt counting in attempts.
|
|
@@ -108,73 +80,35 @@ module Squishling
|
|
|
108
80
|
|
|
109
81
|
private
|
|
110
82
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def raised_by_ruby_llm?(error)
|
|
117
|
-
error.backtrace&.first&.start_with?("#{RUBY_LLM_LIB}/") || false
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def build_chat(step)
|
|
121
|
-
RubyLLM.chat(**model_options(step))
|
|
122
|
-
rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
|
|
123
|
-
raise ConfigurationError, "#{@label}: #{e.message}"
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
|
|
127
|
-
# and raises ArgumentError before any request is sent.
|
|
128
|
-
def apply_params(chat, params)
|
|
129
|
-
Params.apply(chat, params)
|
|
130
|
-
rescue ArgumentError => e
|
|
131
|
-
raise ConfigurationError, "#{@label}: invalid params #{params.inspect} (#{e.message})"
|
|
83
|
+
def capture(job)
|
|
84
|
+
[job.call, nil]
|
|
85
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised on the caller's thread
|
|
86
|
+
[nil, e]
|
|
132
87
|
end
|
|
133
88
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
rescue RubyLLM::BadRequestError => e
|
|
144
|
-
raise ConfigurationError,
|
|
145
|
-
"#{@label}: the provider rejected the request (#{describe_failure(e)})#{params_hint(step.params)}"
|
|
146
|
-
rescue RubyLLM::Error, Faraday::Error => e
|
|
147
|
-
raise LLMError, "#{@label}: #{describe_failure(e)}"
|
|
148
|
-
end
|
|
89
|
+
def complete(chat, message, step, reask)
|
|
90
|
+
messages = chat.messages + [{ role: :user, content: message }]
|
|
91
|
+
request = Adapter::Request.new(label: @label, model: step.model, provider: step.provider,
|
|
92
|
+
instructions: chat.instructions, messages:, schema: chat.schema, params: step.request_params(reask))
|
|
93
|
+
response = @adapter.complete(request)
|
|
94
|
+
unless response.is_a?(Adapter::Response)
|
|
95
|
+
raise ConfigurationError, "#{@label}: the #{@adapter.class} adapter must return a " \
|
|
96
|
+
"Squishling::Adapter::Response from #complete, got #{response.class}"
|
|
97
|
+
end
|
|
149
98
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
response = error.response
|
|
153
|
-
status = response.is_a?(Hash) ? response[:status] : (response.status if response.respond_to?(:status))
|
|
154
|
-
status ? "#{error.class}, HTTP #{status}" : error.class.to_s
|
|
99
|
+
chat.messages = messages + [{ role: :assistant, content: response.text }]
|
|
100
|
+
@tally.add(response)
|
|
155
101
|
end
|
|
156
102
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def model_options(step)
|
|
160
|
-
model = step.model
|
|
161
|
-
provider = step.provider
|
|
162
|
-
options = { model:, provider: }.compact
|
|
163
|
-
options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
|
|
164
|
-
options
|
|
165
|
-
end
|
|
103
|
+
def with_context(context, &)
|
|
104
|
+
return yield unless @adapter.respond_to?(:with_context)
|
|
166
105
|
|
|
167
|
-
|
|
168
|
-
RubyLLM.models.find(model, provider:)
|
|
169
|
-
true
|
|
170
|
-
rescue RubyLLM::ModelNotFoundError
|
|
171
|
-
false
|
|
106
|
+
@adapter.with_context(context, &)
|
|
172
107
|
end
|
|
173
108
|
|
|
174
|
-
def
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
|
|
109
|
+
def call_info(step, request, attempt:, attempts:, role: nil, sample: nil)
|
|
110
|
+
Observer::Call.new(label: @label, purpose: @purpose, harness: @harness, handed_off: @handed_off, role:, sample:,
|
|
111
|
+
attempt:, attempts:, model: step.model, provider: step.provider, request:)
|
|
178
112
|
end
|
|
179
113
|
end
|
|
180
114
|
end
|
|
@@ -12,18 +12,31 @@ module Squishling
|
|
|
12
12
|
# instead of positional. Steps sharing a priority are one tier: each call rotates which of them goes first
|
|
13
13
|
# (see Rotation), and the others follow before the next tier, unless a step in the tier sets rotate: false.
|
|
14
14
|
# order: is the deprecated name of priority:. forward_rejected: false starts a step from the original input
|
|
15
|
-
# alone, without the previous step's rejected output.
|
|
15
|
+
# alone, without the previous step's rejected output. include_when: (a Proc, called like squish_when with the
|
|
16
|
+
# method's inputs as keywords, on the instance) drops the step from any call where it is falsy.
|
|
17
|
+
# retry_temperature: (a Numeric) is the temperature for the step's same-chat retries after the first attempt.
|
|
16
18
|
module ModelPath
|
|
17
|
-
# One attempt: the model, its provider, the fully resolved generation params,
|
|
18
|
-
# step's rejected output is shown to it.
|
|
19
|
-
Step = Data.define(:model, :provider, :params, :forward_rejected) do
|
|
20
|
-
|
|
19
|
+
# One attempt: the model, its provider, the fully resolved generation params, whether the previous
|
|
20
|
+
# step's rejected output is shown to it, and the temperature its retries in the same chat use, if any.
|
|
21
|
+
Step = Data.define(:model, :provider, :params, :forward_rejected, :retry_temperature) do
|
|
22
|
+
def initialize(model:, provider:, params:, forward_rejected:, retry_temperature: nil)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The params for a request: on a retry in the same chat (reask), the step's retry_temperature replaces the
|
|
27
|
+
# temperature, if it sets one.
|
|
28
|
+
def request_params(reask)
|
|
29
|
+
reask && retry_temperature ? Params.resolve(params, temperature: retry_temperature) : params
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The model for logs and messages (nil means the adapter's default model).
|
|
21
33
|
def display_name
|
|
22
|
-
model || "
|
|
34
|
+
model || "the adapter's default model"
|
|
23
35
|
end
|
|
24
36
|
end
|
|
25
37
|
|
|
26
|
-
STEP_KEYS = %i[model provider params attempts priority order rotate forward_rejected
|
|
38
|
+
STEP_KEYS = %i[model provider params attempts priority order rotate forward_rejected include_when
|
|
39
|
+
retry_temperature].freeze
|
|
27
40
|
|
|
28
41
|
module_function
|
|
29
42
|
|
|
@@ -64,11 +77,24 @@ module Squishling
|
|
|
64
77
|
def steps(path, provider:, params:, turn: 0)
|
|
65
78
|
rotated(path, turn).flat_map do |step|
|
|
66
79
|
attempt = Step.new(model: step[:model], provider: step[:provider] || provider,
|
|
67
|
-
params: Params.resolve(params, step[:params]), forward_rejected: step[:forward_rejected]
|
|
80
|
+
params: Params.resolve(params, step[:params]), forward_rejected: step[:forward_rejected],
|
|
81
|
+
retry_temperature: step[:retry_temperature])
|
|
68
82
|
[attempt] * step[:attempts]
|
|
69
83
|
end
|
|
70
84
|
end
|
|
71
85
|
|
|
86
|
+
# The steps this call may use: those without an include_when:, and those whose include_when: is truthy for the
|
|
87
|
+
# call's inputs (evaluated against the receiver, like squish_when). Priority order is kept.
|
|
88
|
+
def eligible(path, receiver, inputs, label)
|
|
89
|
+
kept = path.select do |step|
|
|
90
|
+
gate = step[:include_when]
|
|
91
|
+
gate.nil? || receiver.instance_exec(**inputs, &gate)
|
|
92
|
+
end
|
|
93
|
+
raise ConfigurationError, "#{label}: include_when: excluded every escalation step" if kept.empty?
|
|
94
|
+
|
|
95
|
+
kept
|
|
96
|
+
end
|
|
97
|
+
|
|
72
98
|
# Whether any tier of the path has several models to rotate between.
|
|
73
99
|
def rotating?(path)
|
|
74
100
|
tiers(path).any? { |tier| rotates?(tier) }
|
|
@@ -134,6 +160,8 @@ module Squishling
|
|
|
134
160
|
raise ConfigurationError, "#{label} (#{model}): rotate: must be true or false, got #{step[:rotate].inspect}"
|
|
135
161
|
end
|
|
136
162
|
|
|
163
|
+
check_include_when!(step, label, model)
|
|
164
|
+
check_retry_temperature!(step, label, model)
|
|
137
165
|
forward_rejected = step.fetch(:forward_rejected, true)
|
|
138
166
|
unless [true, false].include?(forward_rejected)
|
|
139
167
|
raise ConfigurationError,
|
|
@@ -142,15 +170,34 @@ module Squishling
|
|
|
142
170
|
|
|
143
171
|
params = step[:params] && Params.normalize(step[:params], "#{label} (#{model}) params")
|
|
144
172
|
normalized = { model:, provider: step[:provider], params:, attempts:, forward_rejected: }
|
|
145
|
-
normalized.merge(step.slice(:priority, :rotate, *extras))
|
|
173
|
+
normalized.merge(step.slice(:priority, :rotate, :include_when, :retry_temperature, *extras))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def check_include_when!(step, label, model)
|
|
177
|
+
return if !step.key?(:include_when) || step[:include_when].is_a?(Proc)
|
|
178
|
+
|
|
179
|
+
raise ConfigurationError,
|
|
180
|
+
"#{label} (#{model}): include_when: must be a Proc, got #{step[:include_when].inspect}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def valid_temperature?(temperature)
|
|
184
|
+
temperature.is_a?(Numeric) && temperature.real? && temperature.finite? && !temperature.negative?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def check_retry_temperature!(step, label, model)
|
|
188
|
+
temperature = step[:retry_temperature]
|
|
189
|
+
return if !step.key?(:retry_temperature) || valid_temperature?(temperature)
|
|
190
|
+
|
|
191
|
+
raise ConfigurationError,
|
|
192
|
+
"#{label} (#{model}): retry_temperature: must be a finite Numeric of 0 or more, got #{temperature.inspect}"
|
|
146
193
|
end
|
|
147
194
|
|
|
148
|
-
# A step used on its own (a judge, a harness's step) can't take part in a tier
|
|
149
|
-
# rotate:. who names it for the message.
|
|
195
|
+
# A step used on its own (a judge, a harness's step) can't take part in a tier or be left out of a call, so it
|
|
196
|
+
# takes no priority:, rotate:, or include_when:. who names it for the message.
|
|
150
197
|
def single_step!(step, who)
|
|
151
|
-
return unless step.key?(:priority) || step.key?(:rotate)
|
|
198
|
+
return unless step.key?(:priority) || step.key?(:rotate) || step.key?(:include_when)
|
|
152
199
|
|
|
153
|
-
raise ConfigurationError, "#{who} takes no priority
|
|
200
|
+
raise ConfigurationError, "#{who} takes no priority:, rotate:, or include_when:"
|
|
154
201
|
end
|
|
155
202
|
|
|
156
203
|
# order: is the deprecated name of priority:.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Classes by name, for the things that can be registered (harnesses, adapters): the built-ins, registered as they
|
|
5
|
+
# load, and the developer's own. Shared across threads, so every read and write is under the lock.
|
|
6
|
+
class NamedRegistry
|
|
7
|
+
NAME = /\A[a-z][a-z0-9_]*\z/
|
|
8
|
+
|
|
9
|
+
# kind names what is registered ("harness"), for messages.
|
|
10
|
+
def initialize(kind)
|
|
11
|
+
@kind = kind
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
@built_in = {}
|
|
14
|
+
@custom = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register_built_in(name, klass)
|
|
18
|
+
@lock.synchronize { @built_in[name] = klass }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The name as a Symbol. Raises ConfigurationError when it isn't a Symbol or String of lowercase letters, digits
|
|
22
|
+
# and underscores, starting with a letter.
|
|
23
|
+
def checked_name(name)
|
|
24
|
+
unless name.is_a?(Symbol) || name.is_a?(String)
|
|
25
|
+
raise ConfigurationError, "#{@kind} name must be a Symbol or String, got #{name.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
name = name.to_sym
|
|
29
|
+
return name if NAME.match?(name)
|
|
30
|
+
|
|
31
|
+
raise ConfigurationError,
|
|
32
|
+
"#{@kind} name #{name.inspect} must be lowercase letters, digits, and underscores, starting with a letter"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Registers klass under name (already checked), unless the name is taken.
|
|
36
|
+
def add(name, klass)
|
|
37
|
+
@lock.synchronize do
|
|
38
|
+
raise ConfigurationError, "#{@kind} #{name.inspect} is already registered" if taken?(name)
|
|
39
|
+
|
|
40
|
+
@custom[name] = klass
|
|
41
|
+
end
|
|
42
|
+
klass
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(name)
|
|
46
|
+
@lock.synchronize { @built_in[name] || @custom[name] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def names
|
|
50
|
+
@lock.synchronize { @built_in.keys + @custom.keys }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Forgets the registered classes; the built-ins stay.
|
|
54
|
+
def reset!
|
|
55
|
+
@lock.synchronize { @custom.clear }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def taken?(name)
|
|
61
|
+
@built_in.key?(name) || @custom.key?(name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/squishling/observer.rb
CHANGED
|
@@ -33,12 +33,12 @@ module Squishling
|
|
|
33
33
|
return observer if BUILT_IN.include?(observer)
|
|
34
34
|
|
|
35
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
|
|
36
|
+
"an object that responds to around, or false, got #{Appendices.describe(observer)}"
|
|
37
37
|
end
|
|
38
38
|
return observer if observer.respond_to?(:around)
|
|
39
39
|
|
|
40
40
|
raise ConfigurationError, "#{where} observer must respond to around(call) { ... } (or be false), " \
|
|
41
|
-
"got #{observer
|
|
41
|
+
"got #{Appendices.describe(observer)}"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# The observer that wraps a request: the configured one, the Coolhand observer when nothing is configured and
|
|
@@ -89,12 +89,21 @@ module Squishling
|
|
|
89
89
|
response
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# An Adapter::Response carries its token counts in a Hash; a judgment (see Adapter::Base#judge) may carry them in
|
|
93
|
+
# an object with input and output instead. Shared with Budget::Tally; not part of the public API.
|
|
92
94
|
def usage(response)
|
|
93
95
|
tokens = response.respond_to?(:tokens) ? response.tokens : nil
|
|
94
96
|
model = response.respond_to?(:model) ? response.model : nil
|
|
95
|
-
Usage.new(input_tokens: tokens
|
|
97
|
+
Usage.new(input_tokens: token_count(tokens, :input_tokens, :input),
|
|
98
|
+
output_tokens: token_count(tokens, :output_tokens, :output),
|
|
96
99
|
model: model.respond_to?(:id) ? model.id : model)
|
|
97
100
|
end
|
|
98
|
-
|
|
101
|
+
|
|
102
|
+
def token_count(tokens, key, reader)
|
|
103
|
+
return tokens[key] if tokens.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
tokens.respond_to?(reader) ? tokens.public_send(reader) : nil
|
|
106
|
+
end
|
|
107
|
+
private_class_method :token_count
|
|
99
108
|
end
|
|
100
109
|
end
|