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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +26 -10
  4. data/docs/adapters.md +257 -0
  5. data/docs/configuration.md +54 -19
  6. data/docs/failures.md +170 -22
  7. data/docs/harnesses.md +39 -15
  8. data/docs/measuring-tokens.md +12 -6
  9. data/docs/observers.md +3 -3
  10. data/docs/routing.md +14 -3
  11. data/docs/schemas.md +24 -2
  12. data/docs/testing.md +187 -0
  13. data/docs/verifying.md +86 -0
  14. data/lib/squishling/adapter/error_body.rb +54 -0
  15. data/lib/squishling/adapter.rb +328 -0
  16. data/lib/squishling/adapters/openai_compatible.rb +311 -0
  17. data/lib/squishling/adapters/openai_options.rb +114 -0
  18. data/lib/squishling/adapters/openai_transport.rb +123 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +229 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +58 -6
  24. data/lib/squishling/definition.rb +90 -16
  25. data/lib/squishling/errors.rb +94 -7
  26. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  27. data/lib/squishling/harness/option_types.rb +2 -1
  28. data/lib/squishling/harness/registry.rb +8 -36
  29. data/lib/squishling/invoker.rb +44 -14
  30. data/lib/squishling/judge.rb +16 -3
  31. data/lib/squishling/llm_client.rb +66 -132
  32. data/lib/squishling/model_path.rb +60 -13
  33. data/lib/squishling/named_registry.rb +64 -0
  34. data/lib/squishling/observer.rb +13 -4
  35. data/lib/squishling/output_check.rb +13 -9
  36. data/lib/squishling/params.rb +7 -24
  37. data/lib/squishling/railtie.rb +10 -0
  38. data/lib/squishling/result.rb +50 -16
  39. data/lib/squishling/rotation.rb +5 -0
  40. data/lib/squishling/router.rb +3 -2
  41. data/lib/squishling/rspec.rb +18 -0
  42. data/lib/squishling/run/messages.rb +40 -3
  43. data/lib/squishling/run/rejections.rb +43 -0
  44. data/lib/squishling/run/reports.rb +63 -0
  45. data/lib/squishling/run.rb +60 -53
  46. data/lib/squishling/schema.rb +50 -9
  47. data/lib/squishling/secret.rb +45 -0
  48. data/lib/squishling/squawk.rb +37 -4
  49. data/lib/squishling/tasks/squishling.rake +18 -0
  50. data/lib/squishling/testing/adapter.rb +194 -0
  51. data/lib/squishling/testing/checks.rb +39 -0
  52. data/lib/squishling/testing/direct.rb +41 -0
  53. data/lib/squishling/testing/errors.rb +18 -0
  54. data/lib/squishling/testing/fixtures.rb +206 -0
  55. data/lib/squishling/testing/helpers.rb +71 -0
  56. data/lib/squishling/testing/minitest.rb +32 -0
  57. data/lib/squishling/testing/observer.rb +31 -0
  58. data/lib/squishling/testing/reply.rb +60 -0
  59. data/lib/squishling/testing/request.rb +75 -0
  60. data/lib/squishling/testing/rspec.rb +30 -0
  61. data/lib/squishling/testing/script.rb +189 -0
  62. data/lib/squishling/testing/state.rb +74 -0
  63. data/lib/squishling/testing/stubs.rb +112 -0
  64. data/lib/squishling/testing.rb +126 -0
  65. data/lib/squishling/verification.rb +39 -0
  66. data/lib/squishling/verifier.rb +179 -0
  67. data/lib/squishling/version.rb +1 -1
  68. data/lib/squishling/wrapper.rb +19 -1
  69. data/lib/squishling.rb +60 -4
  70. metadata +43 -21
@@ -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 RubyLLM with structured output.
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
- input: JSON.generate(body), role: nil)
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
- def describe(value)
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
@@ -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.judge, and its pick counts only at or above min_confidence.
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 RubyLLM.judge). A judge is an escalation step (see ModelPath) plus these keys: type: (default :chat)
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
- # RubyLLM requests for one squished method, with RubyLLM's errors mapped onto Squishling's taxonomy.
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
- # Where RubyLLM's own code lives, to tell its ArgumentErrors from the developer's (see #judge).
7
- RUBY_LLM_LIB = File.dirname(RubyLLM.method(:judge).source_location.first)
8
- private_constant :RUBY_LLM_LIB
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 (RubyLLM's usage owner) is inherited by the threads, and RubyLLM's instrumentation context is
13
- # carried over the way RubyLLM's own concurrent tool calls do it. Unlike those, the threads don't enter the
14
- # Rails executor: the caller already holds it, and a second share can deadlock against a pending code reload.
15
- # If the caller is interrupted while waiting (a timeout, Thread#raise), the requests still running are
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 = instrumentation_context
36
+ context = @adapter.capture_context if @adapter.respond_to?(:capture_context)
21
37
  threads = jobs.map do |job|
22
- thread = Thread.new { with_instrumentation_context(context) { capture(job) } }
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
- def self.capture(job)
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
- chat = build_chat(step)
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
- # Transient HTTP failures are already retried by RubyLLM (RubyLLM.config.max_retries); anything
71
- # that still fails is surfaced as an LLMError, which moves on to the next attempt of the
72
- # escalation (or propagates from the last one). A 400 means the request we built is invalid (an
73
- # unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
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
- def ask(chat, message, step, attempt:, attempts:, role: nil, sample: nil)
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) { request(step) { chat.ask(message) } }
61
+ Observer.around(call) { complete(chat, message, step, reask) }
80
62
  end
81
63
 
82
- # A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
83
- # RubyLLM raises ArgumentError for a judgment it can't build (e.g. a model on a provider that takes none, or
84
- # a provider option its protocol reserves), and a bare RubyLLM::Error without an HTTP response for a provider
85
- # that doesn't support judgments at all. An ArgumentError from anywhere else (an instrumentation subscriber
86
- # runs inside the call) is the developer's own and propagates as-is.
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
- request(step) do
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 call_info(step, request, attempt:, attempts:, role: nil, sample: nil)
112
- Observer::Call.new(label: @label, purpose: @purpose, harness: @harness, handed_off: @handed_off, role:, sample:,
113
- attempt:, attempts:, model: step.model, provider: step.provider, request:)
114
- end
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
- # A provider's or transport's own message never goes into ours: a provider can echo request content or part of
135
- # an API key in it, and ours reach logs and error trackers. The original stays as #cause for whoever
136
- # chooses to read it. RubyLLM's own ConfigurationError is local (it names a missing setting), so it is kept.
137
- def request(step)
138
- yield
139
- rescue RubyLLM::ConfigurationError => e
140
- raise ConfigurationError, "#{@label}: #{e.class}: #{e.message}"
141
- rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
142
- raise ConfigurationError, "#{@label}: #{describe_failure(e)}"
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
- # The error's class and HTTP status, when it has one, which is all of it that is ours to repeat.
151
- def describe_failure(error)
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
- # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
158
- # provider is named, so RubyLLM is told to assume they exist.
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
- def known_model?(model, provider)
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 params_hint(params)
175
- return "" if params.empty?
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, and whether the previous
18
- # step's rejected output is shown to it.
19
- Step = Data.define(:model, :provider, :params, :forward_rejected) do
20
- # The model for logs and messages (nil means RubyLLM's default model).
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 || "RubyLLM default 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].freeze
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, so it takes no priority: or
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: or rotate:"
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
@@ -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.inspect}"
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.inspect}"
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&.input, output_tokens: tokens&.output,
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
- private_class_method :usage
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