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.
data/docs/harnesses.md CHANGED
@@ -1,4 +1,4 @@
1
- # Harnesses: escalation, squishsum, and ensemble
1
+ # Harnesses: escalation, squishsum, and your own
2
2
 
3
3
  A squished call's **harness** decides how it uses its [escalation](configuration.md#models-and-escalation):
4
4
 
@@ -7,13 +7,12 @@ A squished call's **harness** decides how it uses its [escalation](configuration
7
7
  | `:escalation` (default) | Tries each attempt in order until an output passes the schema and `squish_validate`. | 1 or more |
8
8
  | `:squishsum` | Asks the escalation's **first step** twice, concurrently. Identical outputs are accepted; different ones raise `Squishling::DisagreementError`. | 2 or more |
9
9
  | `:judged_squishsum` | Like `:squishsum`, but when the two outputs differ, a **judge** picks one or rejects both. | 2 or more, plus 1 or more judge requests when they differ |
10
- | `:ensemble` | Asks the escalation's **first and second steps** once each, concurrently, so two different models are compared. Identical outputs are accepted; different ones raise `DisagreementError`. | 2 or more |
11
- | `:judged_ensemble` | Like `:ensemble`, but when the two outputs differ, a **judge** picks one or rejects both. | 2 or more, plus 1 or more judge requests when they differ |
10
+
11
+ Need a different trade-off, such as two models plus a tiebreaker or a majority vote? Register your own with
12
+ [`Squishling.register_harness`](#custom-harnesses).
12
13
 
13
14
  Use a squishsum harness when a confidently wrong answer costs more than a second request: one sample can make a
14
- mistake, but two independent samples rarely make the same one. Use an [ensemble](#ensembles) when the two samples
15
- should come from different models: a model rarely disagrees with itself at low temperature, but a cheap and a
16
- stronger model often disagree on exactly the cases the cheap one gets confidently wrong.
15
+ mistake, but two independent samples rarely make the same one.
17
16
 
18
17
  ```ruby
19
18
  class TicketTriager
@@ -47,15 +46,14 @@ The first level that declares one wins: `squish!`, then the method, then the cla
47
46
 
48
47
  | Option | Harnesses | Description |
49
48
  |---|---|---|
50
- | `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`, `:ensemble`, or `:judged_ensemble` (required in the Hash form). |
51
- | `compare:` | squishsum and ensemble ones | `->(a, b, **inputs) { ... }`, evaluated against the instance with the two typed results and the method's inputs. Truthy means the samples agree. Default: the two outputs are exactly equal. |
52
- | `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled ones. |
49
+ | `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`, or a [registered](#custom-harnesses) name (required in the Hash form). |
50
+ | `compare:` | squishsum ones | `->(a, b, **inputs) { ... }`, evaluated against the instance with the two typed results and the method's inputs. Truthy means the samples agree. Default: the two outputs are exactly equal. |
51
+ | `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled one. |
53
52
  | `judge_instructions:` | judged ones | A String, or a Proc evaluated against the instance, that replaces the [default judge prompt](#the-default-judge-prompt). |
54
53
 
55
54
  ## Samples
56
55
 
57
- Under `:squishsum`, both samples run on the escalation's first step, with its model, provider, and params
58
- (an [ensemble](#ensembles) runs them on its first and second steps):
56
+ Both samples run on the escalation's first step, with its model, provider, and params:
59
57
 
60
58
  - **They're independent.** Each sample has its own chat, and the two requests are sent concurrently on separate
61
59
  threads. Only the provider requests run on those threads. Parsing, schema validation, `squish_validate`,
@@ -63,9 +61,10 @@ Under `:squishsum`, both samples run on the escalation's first step, with its mo
63
61
  RubyLLM instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for `chat.ruby_llm`) do see
64
62
  each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
65
63
  - **Each one is retried like an escalation step.** A sample with invalid output is re-asked in its own chat, up to
66
- their step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
64
+ the first step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
67
65
  chat. A sample still failing after its attempts fails the call with `InvalidOutputError` or `LLMError`, as
68
- usual. Under `:squishsum`, later escalation steps are never used for samples (see [Ensembles](#ensembles)).
66
+ usual. Later escalation steps are never used for samples (a judge may use the next one, see
67
+ [The judge](#the-judge)).
69
68
  - **Agreement is exact by default.** Free-text fields seldom match word for word, so pass `compare:` to decide
70
69
  which fields must match:
71
70
 
@@ -75,32 +74,6 @@ Under `:squishsum`, both samples run on the escalation's first step, with its mo
75
74
 
76
75
  When the samples agree, the first one is returned.
77
76
 
78
- ## Ensembles
79
-
80
- `:ensemble` and `:judged_ensemble` sample two *different* steps instead of one step twice. Sample `a` is the
81
- escalation's first step and sample `b` its second, each with its own model, provider, and params:
82
-
83
- ```ruby
84
- class TicketTriager
85
- include Squishling
86
-
87
- squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"],
88
- harness: :judged_ensemble # a = Haiku, b = Sonnet, Opus judges when they differ
89
- # purpose, output_schema, ...
90
- end
91
- ```
92
-
93
- - A step's `attempts:` are that sample's retries (here Haiku gets two attempts, Sonnet one), exactly as in
94
- [Samples](#samples). Steps after the second are never sampled.
95
- - Everything else matches squishsum: independence and concurrency, `compare:`, `DisagreementError`, and the
96
- judge. `DisagreementError#models` names the model behind each sample.
97
- - The judge defaults to the escalation's **third** step. With only two steps, a `:judged_ensemble` needs `judge:`.
98
- An ensemble with fewer than two steps, or a judged one with neither a third step nor `judge:`, raises
99
- `ConfigurationError` before any request.
100
- - A global `default_harness = :ensemble` therefore needs every class to declare at least two steps, and a
101
- `squish!(model: ...)` override (one step) can't be combined with an ensemble harness.
102
- - Steps are told apart by value, so two adjacent steps with the same model, provider, and params count as one step.
103
-
104
77
  ## The judge
105
78
 
106
79
  With a judged harness, two valid samples that disagree go to a judge. The judge picks candidate `a` or `b`,
@@ -110,9 +83,9 @@ whose typed result is returned (`squished?` is `true`), or it picks `neither`, w
110
83
  The judge is:
111
84
 
112
85
  1. the `judge:` step, if declared: a model name, or a Hash with `model:`, `provider:`, `params:`, and `attempts:`
113
- (as in an escalation step, without `order:`), plus `type:`; or
114
- 2. the escalation's next step after the sampled ones (the second under `:judged_squishsum`, the third under
115
- `:judged_ensemble`), with its attempts.
86
+ (as in an escalation step, without `priority:` or `rotate:`), plus `type:`; or
87
+ 2. the escalation's next step after the sampled one, with its attempts. Steps tied on `priority:` rotate between calls
88
+ (see [Rotating models](configuration.md#rotating-models-within-a-priority)), so which model that is can vary.
116
89
 
117
90
  A judged harness with neither raises `ConfigurationError` before any sample is requested. A `judge:`
118
91
  step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
@@ -196,3 +169,158 @@ end
196
169
  Other failures are unchanged. A sample or judge that never produces valid output raises `InvalidOutputError`, a
197
170
  failed request on the last attempt raises `LLMError`, and setup mistakes (including a provider 400 on either
198
171
  sample) raise `ConfigurationError`, which is never sent to the fallback.
172
+
173
+ ## Custom harnesses
174
+
175
+ A harness is a class that decides how to use the escalation: which samples to ask for, how to compare them, who
176
+ breaks a tie. Register one under a name and it works anywhere a built-in type does: `squishling harness: :name`,
177
+ `squish :method, harness: { type: :name, ... }`, `squish!(harness: ...)`, and `config.default_harness`. It gets
178
+ everything the built-ins get: model, provider, and params resolution, schema validation and
179
+ [`squish_validate`](failures.md#output-checks-squish_validate), retries and escalation,
180
+ [`squawk`](failures.md#observing-every-attempt-squawk) reports with token usage, concurrent samples, and the
181
+ [failure behavior](#when-a-custom-harness-fails) (`squish_fallback`, `DisagreementError`).
182
+
183
+ ### Worked example: two models and a tiebreaker
184
+
185
+ Ask two cheap models. If they agree, done. If they don't, ask a third:
186
+
187
+ ```ruby
188
+ class Tiebreaker < Squishling::Harness::Base
189
+ option :tiebreaker, :step, required: true # a model name, or a step Hash
190
+ option :compare, :proc # optional, like squishsum's compare:
191
+
192
+ def call(run, tiebreaker:, compare: nil)
193
+ raise Squishling::ConfigurationError, "#{run.label}: needs two models" if run.stages.size < 2
194
+
195
+ first, second = run.samples(run.stages.first(2)) # the escalation's first two steps, asked concurrently
196
+ return first if run.agree?(first, second, compare)
197
+
198
+ run.escalate(tiebreaker) # they differ: a third opinion
199
+ end
200
+ end
201
+
202
+ Squishling.register_harness(:tiebreaker, Tiebreaker) # e.g. in an initializer
203
+
204
+ class TicketTriager
205
+ include Squishling
206
+
207
+ squishling escalation: ["claude-haiku-4-5", "claude-sonnet-5-5"],
208
+ harness: { type: :tiebreaker, tiebreaker: "claude-opus-5-5",
209
+ compare: ->(a, b, **) { a.priority == b.priority } }
210
+ purpose "Assign a priority and a team."
211
+ output_schema do
212
+ string :priority, enum: %w[low medium high]
213
+ string :team
214
+ end
215
+ end
216
+ ```
217
+
218
+ `TicketTriager.call(...)` sends the ticket to Haiku and Sonnet at the same time. A pair that agrees on `priority` returns
219
+ Haiku's result; otherwise Opus decides. Either way you get the same typed, validated result as every other path.
220
+ `run.stages` groups identical neighbouring steps into one stage (they are one model's attempts), so this harness needs
221
+ two different models in the escalation.
222
+
223
+ For small harnesses, pass a block instead of a class. It becomes the body of a `Squishling::Harness::Base` subclass:
224
+
225
+ ```ruby
226
+ Squishling.register_harness(:best_of_three) do
227
+ def call(run)
228
+ results = run.samples([run.stages.first] * 3)
229
+ results.max_by { |result| results.count { |other| run.agree?(result, other) } } # the most-agreed-with sample
230
+ end
231
+ end
232
+ ```
233
+
234
+ ### Registering
235
+
236
+ `Squishling.register_harness(name, klass = nil, &block)` takes a `Squishling::Harness::Base` subclass or a block, and
237
+ returns the class. `Squishling.harnesses` lists every name, built-ins first.
238
+
239
+ - The registry is global, and a harness is looked up when it is **declared**, so an unknown name fails up front.
240
+ Register in an initializer, before the classes that use the name load.
241
+ - A name is lowercase letters, digits, and underscores. Registering a name twice, or one of `:escalation`,
242
+ `:squishsum`, `:judged_squishsum`, raises `ConfigurationError`.
243
+ - A class must define `call` taking the run as its first argument and each declared option as a keyword (a keyword
244
+ for an option that may be left out needs a default), and be creatable without arguments. This is checked at
245
+ registration.
246
+ - Subclass `Squishling::Harness::Base` directly. The built-in harness classes are implementation, not an extension
247
+ point; write against `Run` as they do.
248
+ - `Squishling.reset_harnesses!` forgets the registered harnesses (the built-ins stay). It is meant for specs.
249
+ `Squishling.reset_config!` leaves them, since they're registered once at boot.
250
+
251
+ ### Options
252
+
253
+ Declare each option with `option name, type, required: false`, before registering the class. They are checked when
254
+ the harness is declared, before any request is made, and an unknown, missing, or mistyped option raises
255
+ `ConfigurationError`. A `nil` counts as not given, and `call` receives only the options that were given.
256
+
257
+ | Type | Accepts | `call` receives |
258
+ |---|---|---|
259
+ | `:step` | A model name, or a Hash with `model:`, `provider:`, `params:`, `attempts:`, `forward_rejected:` | A normalized, frozen step; pass it to `run.escalate`, or in the Array given to `run.samples` |
260
+ | `:proc` | A Proc | The Proc |
261
+ | `:string`, `:symbol`, `:integer`, `:boolean` | A String, a Symbol (or String, converted), an Integer, `true` or `false` | The value |
262
+
263
+ An option with a shape of its own takes a callable as its type instead. It gets the value, the declaration site (a
264
+ label for error messages), and the option's name, and returns the normalized value, or raises `ConfigurationError`. The
265
+ Hashes, Arrays, and Strings in the result are copied and frozen:
266
+
267
+ ```ruby
268
+ option :word, ->(value, label, name) do
269
+ raise Squishling::ConfigurationError, "#{label}: #{name}: must be a String" unless value.is_a?(String)
270
+
271
+ value.upcase
272
+ end
273
+ ```
274
+
275
+ To check options against each other, define `def self.validate_options!(options, label)` and raise
276
+ `Squishling::ConfigurationError` (include the `label`) to reject.
277
+
278
+ ### The run
279
+
280
+ `call(run, **options)` gets a `Squishling::Run`: one squished call, with these primitives. Each returns typed
281
+ results, the same type the deterministic path returns.
282
+
283
+ | Method | What it does |
284
+ |---|---|
285
+ | `run.receiver`, `run.inputs`, `run.label` | The instance, the method's inputs, and `"Class#method"` |
286
+ | `run.steps` | The escalation, one step per attempt |
287
+ | `run.stages` | The escalation grouped by model: `[[haiku, haiku], [sonnet]]` for `[{ model: haiku, attempts: 2 }, sonnet]`. A stage is the attempts of one model |
288
+ | `run.stage(declaration)` | A stage from a model name or a step Hash (a single step is a stage of one attempt). It keeps its own provider, and its `params:` merge over the method's, as a [judge](#the-judge) step does |
289
+ | `run.escalate(*stages)` | Makes each attempt in order until an output passes the schema and `squish_validate`, in the same chat after invalid output and in a fresh one (told about the rejected output) on a new step. With no argument, it runs the escalation: exactly what `:escalation` does |
290
+ | `run.samples(stages)` | Takes an Array of stages (so `run.samples([stage, stage])`, not `run.samples(stage)`; a bare step counts as a stage of one attempt): one sample per stage, each in its own chat and retried like an escalation step, with the requests of every round sent concurrently. Returns the outputs in order. Each stage must be one model's attempts: mixed steps raise `ConfigurationError`. Samples are named `"a"`, `"b"`, ... (up to 26) |
291
+ | `run.judge(judge: nil, instructions: nil)` | Resolves a [judge](#the-judge) now (so a missing one raises `ConfigurationError` before any request) and returns it. `judge:` is a model name, or a [judge Hash](#the-judge) (`model:`, `provider:`, `params:`, `attempts:`, `type:`, `min_confidence:`; no `priority:` or `rotate:`), and `instructions:` a non-blank String or a Proc. `judge.verdict(first, second)` returns a `Squishling::Judge::Verdict` with `choice` (`:a`, `:b`, or `:neither`), `reason`, and `detail`, and `judge.step` is its first attempt. A chat judge's `reason` is model-written, so use `detail` (Squishling's own text, `nil` for a chat judge), not `reason`, in messages and logs. Without `judge:`, the judge is the escalation's second stage |
292
+ | `run.agree?(first, second, compare = nil)` | `compare` (evaluated against the instance with both results and the method's inputs) or, without one, exact equality |
293
+ | `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
294
+
295
+ `call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
296
+ result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
297
+
298
+ ### When a custom harness fails
299
+
300
+ A harness fails the way the built-ins do, so `squish_fallback` and the error classes behave predictably:
301
+
302
+ - **Attempts run out.** `run.escalate` and `run.samples` raise `InvalidOutputError` (invalid output after the last
303
+ attempt) or `LLMError` (a failed request on the last attempt) themselves, with the same attributes as always.
304
+ - **Your rule rejects.** Raise one of those errors yourself. `Squishling::DisagreementError.new(candidates:,
305
+ models:)` is the one for samples that don't agree, and `Squishling::InvalidOutputError.new(errors: [...], source: "Harness")` for
306
+ anything else. Both go to [`squish_fallback`](failures.md#fallbacks).
307
+ - **Setup mistakes** raise `ConfigurationError`, which is never retried and never sent to the fallback.
308
+ - **Your own exceptions** (from `call`, `compare:`, or `squish_validate`) propagate unchanged. Squishling doesn't wrap or
309
+ swallow them.
310
+
311
+ Keep model output out of the messages you raise and the lines you `run.log`: it can quote the input. The output is
312
+ available to your code as the results and in `InvalidOutputError#raw`, and to `squawk` if you opt in.
313
+
314
+ ### Reporting to `squawk`
315
+
316
+ Every attempt a primitive makes is reported to the [`squawk`](failures.md#observing-every-attempt-squawk) hook, with
317
+ usage, so [token measuring](measuring-tokens.md) works unchanged. `metadata[:role]` says what the attempt was for:
318
+ `nil` for an escalation attempt, `:sample` (with `metadata[:sample]` set to `"a"`, `"b"`, ...), or `:judge`. `attempt`
319
+ and `attempts` count within that sample, judge, or escalation.
320
+
321
+ ### Threads
322
+
323
+ Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
324
+ `squish_validate`, and `compare:` all run on the calling thread, and the routing state stays fiber-local. Call a run's
325
+ primitives from the thread `call` runs on: from another one they raise `ConfigurationError`. Don't keep unsynchronized
326
+ state at class level in a harness, since one instance is created per call but the class is shared.
@@ -10,7 +10,7 @@ first tells you when the second is worth paying.
10
10
  method (`label`, e.g. `"ShipmentUpdate#call"`), the model and provider, which attempt it was, and the token usage
11
11
  RubyLLM reported. That is enough to attribute spend to a squishling method without any other tooling.
12
12
  - Cost in dollars comes from RubyLLM's model pricing. Multiply the reported tokens by your model's price, or read it
13
- from RubyLLM directly (see [the instrumenter](#lower-level-rubyllms-instrumenter)).
13
+ from RubyLLM directly (see [OpenTelemetry](#opentelemetry), or RubyLLM's `config.instrumenter`).
14
14
 
15
15
  ## Deciding when to write the code
16
16
 
@@ -31,8 +31,8 @@ stays small, the debt you haven't taken on is cheaper than the code.
31
31
  |---|---|---|
32
32
  | [Coolhand Labs](#coolhand-labs) | Picking up your squishling calls automatically and measuring their cost and accuracy | The `coolhand` gem and an initializer |
33
33
  | [Custom: the `squawk` hook](#custom-the-squawk-hook) | Logging or metrics in your own stack, with no new dependency | A lambda and one config line |
34
- | [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | The OpenTelemetry SDK, an exporter, and one line |
35
- | [LangSmith](#langsmith) | Browsing traces and token usage in LangSmith | Through OpenTelemetry |
34
+ | [Your own observer](#your-own-observer) | Wrapping each request in your tracing or metrics tool, knowing which method, level, and sample it was | An object with `around(call)` |
35
+ | [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | RubyLLM's tracer and an exporter |
36
36
 
37
37
  ### Coolhand Labs
38
38
 
@@ -51,7 +51,8 @@ end
51
51
  ```
52
52
 
53
53
  Get an API key at [coolhandlabs.com](https://coolhandlabs.com/). To keep the data in your own infrastructure, point
54
- `config.base_url` at a Coolhand-compatible endpoint of your own.
54
+ `config.base_url` at a Coolhand-compatible endpoint of your own. Squishling also tells Coolhand which workload and
55
+ template each call belongs to; see [Coolhand Labs](coolhand.md).
55
56
 
56
57
  ### Custom: the `squawk` hook
57
58
 
@@ -73,43 +74,39 @@ Each line carries the `"Class#method"` that made the call, so grouping by label
73
74
  that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
74
75
  been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
75
76
  [Observing every attempt](failures.md#observing-every-attempt-squawk) for every keyword and the rules for exceptions.
77
+ Under a [harness](harnesses.md), including a [custom one](harnesses.md#custom-harnesses), every sample and judge
78
+ request reaches the hook too. `metadata[:role]` (`nil`, `:sample`, or `:judge`) and `metadata[:sample]` (`"a"`, `"b"`, ...)
79
+ split the spend by what each request was for.
76
80
 
77
- ### Lower level: RubyLLM's instrumenter
81
+ ### Your own observer
78
82
 
79
- To meter every RubyLLM request in your app, not only Squishling's, use RubyLLM's own instrumentation. It reports a
80
- `usage.ruby_llm` event for each provider request, with `model`, `provider`, `status`, `tokens` (`input`, `output`,
81
- and more) and `cost`. Give it an object that responds to `instrument(name, payload)` and optionally takes a block. In
82
- Rails, `ActiveSupport::Notifications` is used automatically.
83
+ An [observer](observers.md) wraps each provider request and gets the request's token usage back from `yield`, so it
84
+ can meter spend knowing which method, escalation level, sample, or judge made the request. Any object that responds to
85
+ `around(call)` is one:
83
86
 
84
87
  ```ruby
85
- class TokenMeter
86
- def instrument(name, payload = {})
87
- if name == "usage.ruby_llm"
88
- Rails.logger.info("llm model=#{payload[:model]} workflow=#{payload[:workflow_name]} " \
89
- "in=#{payload[:tokens].input} out=#{payload[:tokens].output}")
90
- end
91
- block_given? ? yield : nil
88
+ class SpendObserver
89
+ def around(call)
90
+ usage = yield # input_tokens, output_tokens (nil if unreported), and model; never the content
91
+ tags = ["label:#{call.label}", "role:#{call.role || 'squish'}", "model:#{usage.model}"]
92
+ Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags:)
93
+ Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags:)
92
94
  end
93
95
  end
94
96
 
95
- RubyLLM.configure { |config| config.instrumenter = TokenMeter.new }
97
+ Squishling.configure { |config| config.observer = SpendObserver.new }
96
98
  ```
97
99
 
98
- These events don't know which squishling made the request. To attribute the spend, wrap the call in a RubyLLM
99
- workflow, and every event emitted inside the block carries its name, id and any `metadata:` you pass. (The
100
- `squawk` hook above needs no wrapping.)
101
-
102
- ```ruby
103
- RubyLLM.workflow("ShipmentUpdate", metadata: { carrier: "acme-freight" }) do
104
- ShipmentUpdate.call(carrier: "acme-freight", payload: body)
105
- end
106
- ```
100
+ An observer sees usage, never the model's output or your inputs. See [Observers](observers.md) for the `call` fields
101
+ and the rules.
107
102
 
108
103
  ### OpenTelemetry
109
104
 
110
105
  RubyLLM 2.1+ ships its own OpenTelemetry tracer. Each request becomes a span with the model, request settings and
111
106
  token usage (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`), following the OpenTelemetry GenAI
112
- conventions. It does not record message text. Your app owns the SDK and exporters.
107
+ conventions. It does not record message text. Your app owns the SDK and exporters, and the spans work with any OTLP
108
+ backend, [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
109
+ included.
113
110
 
114
111
  ```ruby
115
112
  # Gemfile
@@ -121,16 +118,9 @@ OpenTelemetry::SDK.configure # reads the standard OTEL_* environment vari
121
118
  RubyLLM::OpenTelemetry.enable # starts tracing RubyLLM requests
122
119
  ```
123
120
 
124
- Spans don't say which squishling made the request, so pair this with a `RubyLLM.workflow` (see
125
- [above](#lower-level-rubyllms-instrumenter)) or group by model and prompt in your backend.
126
-
127
- ### LangSmith
128
-
129
- LangSmith accepts OpenTelemetry traces, so the OpenTelemetry setup above covers it: point the OTLP exporter at
130
- LangSmith. See LangSmith's
131
- [OpenTelemetry docs](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
132
- for the endpoint and headers. A community Ruby gem, [`langsmith-sdk`](https://github.com/felipekb/langsmith-ruby-sdk),
133
- also exists; it isn't published by LangChain.
121
+ Spans don't say which squishling made the request. To attribute them, wrap the call in `RubyLLM.workflow("Name")`, or
122
+ use an [observer](observers.md) that adds the label to the current span. RubyLLM's `config.instrumenter` also reports
123
+ a `usage.ruby_llm` event per request, with tokens and cost, if you would rather meter than trace.
134
124
 
135
125
  ## Privacy
136
126
 
@@ -144,5 +134,7 @@ append. Tools that sit on the request path are a separate route out of your proc
144
134
  send them. That is the sanctioned route for model output; see
145
135
  [Observing every attempt](failures.md#observing-every-attempt-squawk). The examples above log only metadata.
146
136
  - **OpenTelemetry** with RubyLLM's tracer records usage and request settings, not message text.
137
+ - **An observer** sees timing, token usage, and the call's metadata (method, model, level), never the model's output or
138
+ your inputs.
147
139
 
148
140
  Check what your tool stores before pointing it at inputs that contain personal data.
data/docs/observers.md ADDED
@@ -0,0 +1,80 @@
1
+ # Observers
2
+
3
+ An observer wraps every provider request Squishling makes, so another tool can see it while it happens: open a
4
+ tracing span, tag the request, set a thread-local. It is the around-style counterpart to [`squawk`](failures.md#observing-every-attempt-squawk),
5
+ which reports each attempt only after it is over.
6
+
7
+ Squishling ships one, for [Coolhand Labs](coolhand.md), and uses it automatically when the `coolhand` gem is configured.
8
+ Any object that responds to `around(call)` is an observer:
9
+
10
+ ```ruby
11
+ class TracingObserver
12
+ def around(call)
13
+ Tracer.in_span("squishling #{call.label}", attributes: { "model" => call.model, "role" => call.role.to_s }) do
14
+ yield
15
+ end
16
+ end
17
+ end
18
+
19
+ Squishling.configure { |config| config.observer = TracingObserver.new }
20
+ ```
21
+
22
+ `around` must yield exactly once. The block makes the request, so the observer runs around it, on the thread that
23
+ makes it: for the concurrent samples of the squishsum harnesses, that is a worker thread, so thread-locals set by the
24
+ observer are visible to the HTTP call. What `around` returns is ignored. One that never yields, or yields twice,
25
+ raises `ConfigurationError`.
26
+
27
+ ## What `yield` returns
28
+
29
+ The block returns a `Squishling::Observer::Usage`: the request's `input_tokens` and `output_tokens` (nil when the
30
+ provider didn't report them) and the `model` that answered. That is enough to meter spend:
31
+
32
+ ```ruby
33
+ class SpendObserver
34
+ def around(call)
35
+ usage = yield
36
+ Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags: ["label:#{call.label}"])
37
+ Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags: ["label:#{call.label}"])
38
+ end
39
+ end
40
+ ```
41
+
42
+ Usage never includes the model's output; Squishling returns the real response to its own caller, whatever `around`
43
+ returns. If the request fails, the error is raised through `yield`, so a `begin/ensure` in `around` still runs.
44
+
45
+ ## `config.observer`
46
+
47
+ | Value | Meaning |
48
+ |---|---|
49
+ | `nil` (default) | The Coolhand observer when `coolhand` is loaded and configured, otherwise none |
50
+ | `:coolhand` | The Coolhand observer, always. Raises `ConfigurationError` if the gem is missing or too old |
51
+ | any object responding to `around` | That observer |
52
+ | `false` | None, even when Coolhand is configured |
53
+
54
+ Anything else raises `ConfigurationError` when you assign it.
55
+
56
+ ## The `call`
57
+
58
+ `around` receives a `Squishling::Observer::Call` (a `Data`) describing the request:
59
+
60
+ | Field | |
61
+ |---|---|
62
+ | `label` | `"Class#method"` |
63
+ | `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved) |
64
+ | `handed_off` | `true` when a `squish!` call sent the call to the LLM (typically from a `rescue`), `false` when `squish_when` or a missing implementation did |
65
+ | `harness` | The harness type (`:escalation`, `:squishsum`, `:judged_squishsum`, or a [custom one](harnesses.md#custom-harnesses)) |
66
+ | `role` | `nil` for an escalation attempt, `:sample` or `:judge`, as in `squawk`'s `metadata` |
67
+ | `sample` | `"a"`, `"b"`, ... for a sample, else `nil` |
68
+ | `attempt`, `attempts` | This request's 1-based position in the steps it was asked on, and how many there are: the escalation's expanded steps (or, for a custom harness, those passed to `run.escalate`), the sample's stage, or the judge's steps |
69
+ | `model`, `provider` | The step's model and provider (`provider` is `nil` unless declared) |
70
+ | `request` | `:chat`, or `:judgment` for a System One judge |
71
+
72
+ It carries metadata only. The method's inputs, its context, and the model's output never reach an observer: it gets
73
+ the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
74
+
75
+ ## Errors
76
+
77
+ An observer is your code: an exception it raises propagates unchanged, and is never retried, escalated, or handed to
78
+ `squish_fallback`. Squishling maps a failed request onto `LLMError` or `ConfigurationError` inside the block, so `around` sees those and
79
+ can rescue them or let them pass. If `around` rescues one and returns normally, Squishling raises it again: an
80
+ observer can watch a failure, not swallow it.
data/docs/routing.md CHANGED
@@ -90,7 +90,7 @@ end
90
90
  - `append_to_purpose:` (added to the class's, see [Appending to the purpose](#appending-to-the-purpose))
91
91
  - `output_schema:` (or a schema block)
92
92
  - `model:` or `escalation:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
93
- - `harness:` (see [Harnesses](harnesses.md))
93
+ - `harness:` (see [Harnesses](harnesses.md), including your own)
94
94
  - `when:`, a predicate proc
95
95
  - `validate:` and `fallback:` (see [Failure handling](failures.md))
96
96
 
@@ -210,7 +210,7 @@ Source is read with Ruby's own parser (Prism) the first time it's needed and cac
210
210
  "context": { "customer_tier": "enterprise", "product": "API" } }
211
211
  ```
212
212
 
213
- - **Squishsum and ensemble harnesses:** both samples get exactly this input, and a [judge](harnesses.md#the-judge) gets
213
+ - **Squishsum harnesses:** both samples get exactly this input, and a [judge](harnesses.md#the-judge) gets
214
214
  the same purpose and input plus both samples' outputs.
215
215
  - **Retries and escalation:** another attempt of the same step gets the validation errors in the same conversation. A
216
216
  later [escalation](configuration.md#models-and-escalation) step, which may be a different provider (say, local
data/docs/schemas.md CHANGED
@@ -30,6 +30,23 @@ output_schema(
30
30
  )
31
31
  ```
32
32
 
33
+ ## Describing fields
34
+
35
+ Every DSL type, and `optional`, takes a `description:`. It is sent to the provider as guidance for that field
36
+ and isn't enforced; validation is by type and shape only.
37
+
38
+ **Best practice:** put per-field guidance here rather than in the `purpose`. The description travels with the
39
+ field in the schema the model fills in, so the instruction sits right where the value goes and next to the type
40
+ or `enum` it explains. The purpose can then stay short and about the goal. For rules that must hold, use an
41
+ `enum` or `squish_validate`; a description is a request, not a guarantee.
42
+
43
+ ```ruby
44
+ output_schema do
45
+ string :language, description: "Lowercase language name, e.g. 'ruby'."
46
+ optional(:regex, description: "The proposed regex source, without delimiters or flags.") { string }
47
+ end
48
+ ```
49
+
33
50
  ## Strict schemas only
34
51
 
35
52
  Squishling only supports **strict** output schemas: it always asks RubyLLM for strict structured output, and a
@@ -70,7 +87,7 @@ field nullable with the DSL's `optional`. The key stays required, but its value
70
87
  ```ruby
71
88
  output_schema do
72
89
  array :symptoms, of: :string # always a list; [] means "none"
73
- optional :allergies do # [] means "none", nil means "not mentioned"
90
+ optional :allergies, description: "[] if the record says none; null if it doesn't mention allergies" do
74
91
  array of: :string
75
92
  end
76
93
  optional :vitals do # a typed Data object, or nil
@@ -84,8 +101,8 @@ end
84
101
  `optional` produces `anyOf: [<schema>, {type: "null"}]`, which is strict-compatible. Squishling types the non-null
85
102
  branch as usual: `vitals` is a `Data` object or `nil`, and a list of objects is a list of `Data` objects. In a raw
86
103
  JSON Schema, `type: ["array", "null"]` works too. A union with more than one non-null branch is ambiguous, so its
87
- values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in your
88
- purpose.
104
+ values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in the field's
105
+ `description:`, as `allergies` does above.
89
106
 
90
107
  ## Contracts beyond the schema
91
108
 
@@ -111,7 +128,8 @@ Providers' strict modes don't support conditional keywords (`if`/`then`/`else` f
111
128
  `dependentSchemas` from `dependent`), so Squishling keeps them out of the schema it sends and enforces them itself.
112
129
  Output that breaks a rule is rejected like any other invalid output: the call moves on to the next attempt in its
113
130
  [escalation](configuration.md#models-and-escalation) with the errors. The model doesn't see these rules
114
- up front, so state them in your purpose too.
131
+ up front, so state them in the `description:` of the field they govern (here `status`) or in your purpose. A
132
+ description declared inside a `given` block is local-only too, so it is never sent.
115
133
 
116
134
  For anything a schema can't express (sums, lookups against your data), use
117
135
  [`squish_validate`](failures.md#output-checks-squish_validate).
@@ -24,8 +24,8 @@ module Squishling
24
24
  attr_reader :default_params
25
25
 
26
26
  # How every squishling class that doesn't declare its own harness uses its escalation (see Harness):
27
- # :escalation (the default), :squishsum, :judged_squishsum, :ensemble, :judged_ensemble, or a Hash with
28
- # type: and its options.
27
+ # :escalation (the default), :squishsum, :judged_squishsum, a harness you registered, or a Hash with type: and
28
+ # its options.
29
29
  attr_reader :default_harness
30
30
 
31
31
  # Optional Logger for routing and escalation decisions.
@@ -35,7 +35,13 @@ module Squishling
35
35
  # ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
36
36
  attr_reader :squawk
37
37
 
38
+ # Optional around-style hook wrapped around every provider request (see Observer): an object responding to
39
+ # around(call) { ... }, or :coolhand. nil (the default) uses the Coolhand observer when the coolhand gem is
40
+ # loaded and configured, and nothing otherwise; false never wraps.
41
+ attr_reader :observer
42
+
38
43
  def initialize
44
+ @observer = nil
39
45
  @default_model = nil
40
46
  @default_escalation = nil
41
47
  @default_model_path = nil
@@ -62,6 +68,13 @@ module Squishling
62
68
  @squawk = Squawk.validate(hook, "Squishling.configure")
63
69
  end
64
70
 
71
+ def observer=(observer)
72
+ Observer.validate(observer, "Squishling.configure")
73
+ # A named observer is built now, so a missing gem fails at setup rather than on the first request.
74
+ Observer.resolve(observer) if observer.is_a?(Symbol)
75
+ @observer = observer
76
+ end
77
+
65
78
  def default_params=(params)
66
79
  @default_params = Params.normalize(params, "default_params")
67
80
  end
@@ -7,11 +7,11 @@ module Squishling
7
7
  # Matches the keys a squish! context: Hash may use.
8
8
  NAME_KEY = ->(key) { key.is_a?(String) || key.is_a?(Symbol) }
9
9
 
10
- attr_reader :klass, :name, :call_context
10
+ attr_reader :klass, :name, :call_context, :handed_off
11
11
 
12
12
  def initialize(klass:, name:, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil,
13
13
  provider: nil, params: nil, harness: nil, predicate: nil, fallback: nil, validator: nil, squawk: nil,
14
- call_context: {})
14
+ call_context: {}, handed_off: false)
15
15
  @klass = klass
16
16
  @name = name
17
17
  @purpose = purpose
@@ -26,6 +26,7 @@ module Squishling
26
26
  @validator = validator
27
27
  @squawk = squawk
28
28
  @call_context = call_context
29
+ @handed_off = handed_off
29
30
  end
30
31
 
31
32
  # This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
@@ -50,7 +51,8 @@ module Squishling
50
51
  # merge, not Params.resolve: a nil at the method level must still unset the class's key.
51
52
  params: call_params ? (@params || {}).merge(call_params) : @params,
52
53
  harness: call_harness || @harness,
53
- call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
54
+ call_context: @call_context.merge((context || {}).transform_keys(&:to_sym)),
55
+ handed_off: true # only squish! builds a definition this way; it is not part of a declared one
54
56
  )
55
57
  end
56
58
 
@@ -78,13 +80,15 @@ module Squishling
78
80
 
79
81
  # One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
80
82
  # or escalation, or a single attempt on RubyLLM's default model. A provider travels with the model declared
81
- # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider.
83
+ # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider. Models
84
+ # tied on priority are rotated by this method's call count (see Rotation).
82
85
  def escalation_path
83
86
  config = Squishling.config
84
87
  entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
85
88
  [config.default_model_path, config.default_provider]].find(&:first)
86
89
  entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
87
- ModelPath.steps(entries, provider:, params:)
90
+ turn = ModelPath.rotating?(entries) ? Rotation.advance(klass, name) : 0
91
+ ModelPath.steps(entries, provider:, params:, turn:)
88
92
  end
89
93
 
90
94
  # How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
@@ -23,11 +23,11 @@ module Squishling
23
23
  end
24
24
  end
25
25
 
26
- # A sampling harness's samples (squishsum or ensemble) were valid but didn't agree, and either no judge was declared
27
- # (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates holds the two
28
- # typed results, so a squish_fallback can still return one of them, and raw both as Hashes. models names the
29
- # model behind each role (sample a, sample b, then the judge if one ran), not one entry per attempt; attempts
30
- # is nil.
26
+ # A harness's samples (the squishsum harnesses', or a custom one's) were valid but didn't agree, and either no
27
+ # judge was declared (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates
28
+ # holds the typed results, so a squish_fallback can still return one of them, and raw all of them as Hashes.
29
+ # models names the model behind each role (sample a, sample b, then the judge if one ran), not one entry per
30
+ # attempt; attempts is nil.
31
31
  class DisagreementError < InvalidOutputError
32
32
  attr_reader :candidates, :verdict, :reason
33
33