squishling 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -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 +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -21
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Checks squished classes without making a request: what can only otherwise fail on a method's first call (its
5
+ # output schema and typed result, its callables' keywords) and the settings the layers resolve to. Collects every
6
+ # problem rather than stopping at the first. See Squishling.verify.
7
+ class Verifier
8
+ # Checks one squished method's Definition. Each check returns messages, or raises what the call would.
9
+ class MethodCheck
10
+ CHECKS = %i[schema declarations harness callables].freeze
11
+
12
+ def initialize(definition)
13
+ @definition = definition
14
+ end
15
+
16
+ def problems
17
+ CHECKS.flat_map { |check| run(check) }
18
+ end
19
+
20
+ private
21
+
22
+ def run(check)
23
+ Array(send(check)).map { |message| problem(message) }
24
+ rescue ConfigurationError, ArgumentError => e
25
+ [problem(e.message)]
26
+ end
27
+
28
+ def problem(message)
29
+ Verification::Problem.new(label: @definition.label, message:)
30
+ end
31
+
32
+ # The output schema, its typed result (the Data class a call returns), and the JSON Schema itself.
33
+ def schema
34
+ schema = @definition.schema
35
+ messages = []
36
+ messages << "has no output_schema" unless schema
37
+ messages << "has no purpose" unless @definition.purpose_declared?
38
+ return messages unless schema
39
+
40
+ schema.result_class
41
+ messages + JSONSchemer.schema(schema.json_schema).validate_schema.map { |error| error["error"] }
42
+ end
43
+
44
+ # The escalation, generation params, and squawk this method resolves to across method, class, and config.
45
+ def declarations
46
+ steps = @definition.declared_path.first.reject { |step| step[:model].nil? }
47
+ ModelPath.from_escalation(steps, "#{@definition.label} escalation") if steps.any?
48
+ Params.normalize(@definition.params, "#{@definition.label} params")
49
+ Squawk.validate(@definition.squawk, @definition.label)
50
+ []
51
+ end
52
+
53
+ def harness
54
+ harness = @definition.harness
55
+ messages = []
56
+ unless Harness::Registry.fetch(harness.type).equal?(harness.klass)
57
+ messages << "harness #{harness.type.inspect} was unregistered or replaced after it was declared"
58
+ end
59
+ messages << "the judge needs an adapter that supports judgments" if unsupported_judgment?(harness)
60
+ messages
61
+ end
62
+
63
+ def unsupported_judgment?(harness)
64
+ judge = harness.options[:judge]
65
+ judge.is_a?(Hash) && judge[:type] == :judgment && !Squishling.config.resolved_adapter.judgments?
66
+ rescue ConfigurationError
67
+ false # no usable adapter: reported once, as the adapter's own problem
68
+ end
69
+
70
+ # Callables the method's inputs are passed to as keywords: include_when: of each step, and squish_when.
71
+ def callables
72
+ names, open = @definition.input_names
73
+ gates = @definition.declared_path.first.filter_map do |step|
74
+ [step[:include_when], "escalation step #{step[:model].inspect} include_when:"] if step[:include_when]
75
+ end
76
+ gates << [@definition.predicate, "squish_when"] if @definition.predicate
77
+ gates.flat_map { |callable, what| KeywordCheck.messages(callable, names, open).map { "#{what} #{_1}" } }
78
+ end
79
+ end
80
+
81
+ # Whether a Proc called as instance_exec(**inputs, &proc) accepts the inputs.
82
+ module KeywordCheck
83
+ module_function
84
+
85
+ # Messages for how the proc can't be called with the inputs. open: any keyword may be an input (the method
86
+ # takes **), so none can be known.
87
+ def messages(callable, inputs, open)
88
+ return ["must be a Proc, got #{callable.inspect}"] unless callable.is_a?(Proc)
89
+ return [] if open
90
+
91
+ parameters = callable.parameters
92
+ [missing_keywords(parameters, inputs), unaccepted_keywords(callable, parameters, inputs),
93
+ missing_positionals(callable, parameters, inputs)].compact
94
+ end
95
+
96
+ def missing_keywords(parameters, inputs)
97
+ missing = parameters.filter_map { |type, name| name if type == :keyreq } - inputs
98
+ return if missing.empty?
99
+
100
+ "requires #{keywords(missing)}, which #{missing.one? ? 'is not an input' : 'are not inputs'} of the method"
101
+ end
102
+
103
+ # A lambda with no positional parameters, or a proc that names keywords, raises on a keyword it doesn't take.
104
+ # Other procs and positional lambdas receive the keywords as a Hash, or ignore them.
105
+ def unaccepted_keywords(callable, parameters, inputs)
106
+ types = parameters.map(&:first)
107
+ return if types.include?(:keyrest) || !strict?(callable, types)
108
+
109
+ unaccepted = inputs - parameters.filter_map { |type, name| name if Squawk::KEYWORD_TYPES.include?(type) }
110
+ return if unaccepted.empty?
111
+
112
+ "doesn't accept #{keywords(unaccepted)}, which the method takes as input when a caller passes it (add **)"
113
+ end
114
+
115
+ # A lambda's required positional parameters get the keywords as one Hash, or nothing when there are none or
116
+ # the lambda names keywords itself.
117
+ def missing_positionals(callable, parameters, inputs)
118
+ return unless callable.lambda?
119
+
120
+ types = parameters.map(&:first)
121
+ required = types.count(:req)
122
+ given = types.intersect?(%i[key keyreq keyrest]) || inputs.empty? ? 0 : 1
123
+ return unless required > given
124
+
125
+ "takes #{required} positional argument(s), but is called with keywords only"
126
+ end
127
+
128
+ def strict?(callable, types)
129
+ keywords = types.intersect?(Squawk::KEYWORD_TYPES)
130
+ callable.lambda? ? keywords || !types.intersect?(%i[req opt rest]) : keywords
131
+ end
132
+
133
+ def keywords(names)
134
+ names.map { |name| "#{name}:" }.join(", ")
135
+ end
136
+ end
137
+
138
+ def initialize(classes)
139
+ classes.each { |klass| check_squished!(klass) }
140
+ @classes = classes
141
+ end
142
+
143
+ def call
144
+ Verification::Report.new(adapter_problems + @classes.flat_map { |klass| class_problems(klass) })
145
+ end
146
+
147
+ private
148
+
149
+ def check_squished!(klass)
150
+ return if klass.is_a?(Class) && klass.include?(Squishling)
151
+
152
+ raise ConfigurationError, "#{klass.inspect} doesn't include Squishling"
153
+ end
154
+
155
+ # The configured adapter, or RubyLLM's, must be usable.
156
+ def adapter_problems
157
+ Squishling.config.resolved_adapter
158
+ []
159
+ rescue ConfigurationError => e
160
+ [Verification::Problem.new(label: "adapter", message: e.message)]
161
+ end
162
+
163
+ def class_problems(klass)
164
+ klass.squished_methods.keys.flat_map do |name|
165
+ definition = klass.squishling_definition(name)
166
+ next [] if unused_default?(definition)
167
+
168
+ MethodCheck.new(definition).problems
169
+ end
170
+ end
171
+
172
+ # Every class has the default method (call) squished, whether or not it uses it.
173
+ def unused_default?(definition)
174
+ definition.name == ClassMethods::DEFAULT_METHOD && !definition.implemented? && definition.schema.nil?
175
+ rescue ConfigurationError, ArgumentError
176
+ false
177
+ end
178
+ end
179
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/squishling.rb CHANGED
@@ -2,29 +2,51 @@
2
2
 
3
3
  require "json"
4
4
  require "json_schemer"
5
- require "ruby_llm"
6
5
  require "schematist"
7
6
 
8
7
  require_relative "squishling/version"
9
8
  require_relative "squishling/errors"
10
9
  require_relative "squishling/configuration"
11
10
  require_relative "squishling/params"
11
+ require_relative "squishling/named_registry"
12
+ require_relative "squishling/adapter/error_body"
13
+ require_relative "squishling/adapter"
14
+ require_relative "squishling/adapters/ruby_llm"
15
+ require_relative "squishling/adapters/openai_options"
16
+ require_relative "squishling/adapters/openai_compatible"
12
17
  require_relative "squishling/squawk"
18
+ require_relative "squishling/observer"
19
+ require_relative "squishling/observers/coolhand"
13
20
  require_relative "squishling/model_path"
21
+ require_relative "squishling/rotation"
22
+ require_relative "squishling/harness/option_types"
23
+ require_relative "squishling/harness/registry"
24
+ require_relative "squishling/harness/base"
25
+ require_relative "squishling/harness/escalation"
26
+ require_relative "squishling/harness/squishsum"
27
+ require_relative "squishling/harness/judged_squishsum"
14
28
  require_relative "squishling/harness"
15
29
  require_relative "squishling/result"
16
30
  require_relative "squishling/schema"
17
31
  require_relative "squishling/source"
18
32
  require_relative "squishling/appendices"
19
33
  require_relative "squishling/definition"
34
+ require_relative "squishling/budget"
20
35
  require_relative "squishling/llm_client"
21
36
  require_relative "squishling/output_check"
37
+ require_relative "squishling/run/messages"
38
+ require_relative "squishling/run/rejections"
39
+ require_relative "squishling/run"
22
40
  require_relative "squishling/invoker"
23
41
  require_relative "squishling/judge"
24
42
  require_relative "squishling/router"
25
43
  require_relative "squishling/wrapper"
26
44
  require_relative "squishling/class_methods"
27
45
  require_relative "squishling/collisions"
46
+ require_relative "squishling/class_registry"
47
+ require_relative "squishling/verification"
48
+ require_relative "squishling/verifier"
49
+ require_relative "squishling/railtie" if defined?(Rails::Railtie)
28
50
 
29
51
  # Include Squishling in a class to make it elastic: its squished methods either run their
30
52
  # Ruby implementation or send their inputs through an LLM and return schema-validated results.
@@ -42,6 +64,68 @@ module Squishling
42
64
  @config = Configuration.new
43
65
  end
44
66
 
67
+ # Forgets the registered harnesses (the built-ins stay), e.g. between specs. Kept apart from reset_config!
68
+ # because harnesses are registered once at boot, and a suite that resets its config between examples
69
+ # shouldn't lose them.
70
+ def reset_harnesses!
71
+ Harness::Registry.reset!
72
+ end
73
+
74
+ # Registers a custom harness under a name, usable anywhere a built-in type is (harness: :name, or
75
+ # harness: { type: :name, option: value }, on a class, a method, a squish! call, or config.default_harness).
76
+ # Pass a Harness::Base subclass, or a block that becomes the body of one (see Harness::Base). Registered
77
+ # globally, so do it before the classes that use it load (an initializer). Raises ConfigurationError for a
78
+ # name that is invalid or already taken, including the built-in :escalation, :squishsum, :judged_squishsum.
79
+ def register_harness(name, klass = nil, &)
80
+ Harness::Registry.register(name, klass, &)
81
+ end
82
+
83
+ # The names of the harnesses that can be declared: the built-ins, then those registered.
84
+ def harnesses
85
+ Harness.types
86
+ end
87
+
88
+ # Registers a custom adapter class (see Adapter::Base) under a name, usable as config.adapter = :name, or
89
+ # config.adapter = { type: :name, option: value } for an adapter whose initialize takes options. To use an adapter
90
+ # without registering it, assign an instance: config.adapter = MyAdapter.new. Raises ConfigurationError for a name
91
+ # that is invalid or already taken, including the built-in :ruby_llm and :openai_compatible.
92
+ def register_adapter(name, klass)
93
+ Adapter::Registry.register(name, klass)
94
+ end
95
+
96
+ # The names of the adapters that can be configured: the built-ins, then those registered.
97
+ def adapters
98
+ Adapter::Registry.names
99
+ end
100
+
101
+ # Forgets the registered adapters (the built-ins stay), e.g. between specs.
102
+ def reset_adapters!
103
+ Adapter::Registry.reset!
104
+ end
105
+
106
+ # Every class that includes Squishling, and each subclass of one, that is loaded and hasn't been replaced by a
107
+ # reload. Classes are held weakly, so listing them doesn't keep reloaded ones alive. In Rails, eager load first
108
+ # (Rails.application.eager_load!) to include classes not yet autoloaded.
109
+ def squished_classes
110
+ ClassRegistry.classes
111
+ end
112
+
113
+ # Checks squished classes without making a request: each method's output schema and typed result, its resolved
114
+ # escalation, params, squawk and harness, and the keywords its include_when: and squish_when procs take. Checks
115
+ # the given classes, or every squished_classes when none are given. Returns a Verification::Report listing every
116
+ # problem found (ok? when none).
117
+ def verify(*classes)
118
+ Verifier.new(classes.empty? ? squished_classes : classes.flatten).call
119
+ end
120
+
121
+ # Like verify, but raises a VerificationError (a ConfigurationError) naming every problem. For CI and boot checks.
122
+ def verify!(*classes)
123
+ report = verify(*classes)
124
+ raise VerificationError, report unless report.ok?
125
+
126
+ report
127
+ end
128
+
45
129
  def included(base)
46
130
  raise ConfigurationError, "Squishling can only be included in a class" unless base.is_a?(Class)
47
131
 
@@ -76,10 +160,11 @@ module Squishling
76
160
  # it); return it from the method. The overrides apply to this call only: append_to_purpose adds to (or,
77
161
  # with false, replaces) the declared sections, context is sent alongside the declared squish_context,
78
162
  # model/escalation (one or the other)/provider/purpose/harness replace the declared ones, and params merge
79
- # key by key over them.
163
+ # key by key over them. max_total_output_tokens/max_total_input_tokens cap what the call may spend across all its
164
+ # requests (false lifts a declared cap).
80
165
  def squish!(append_to_purpose: nil, context: nil, purpose: nil, model: nil, escalation: nil, provider: nil,
81
- params: nil, harness: nil)
166
+ params: nil, harness: nil, max_total_output_tokens: nil, max_total_input_tokens: nil)
82
167
  Router.hand_off(self, { append_to_purpose:, context:, purpose:, model:, escalation:, provider:, params:,
83
- harness: })
168
+ harness:, max_total_output_tokens:, max_total_input_tokens: })
84
169
  end
85
170
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: squishling
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.0'
26
- - !ruby/object:Gem::Dependency
27
- name: ruby_llm
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '2.1'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '2.1'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: schematist
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -53,12 +39,13 @@ dependencies:
53
39
  version: '1.1'
54
40
  description: 'Squishling makes Ruby classes elastic. Add a squishling to a class and
55
41
  it can send inputs straight to an LLM (OpenAI, Anthropic Claude, Google Gemini,
56
- or any provider RubyLLM supports) and return strict-schema-validated, typed results:
57
- the same type your Ruby code returns. Use it to gracefully maintain fickle integrations,
58
- build interfaces for unknown data formats, and rescue production errors by handing
59
- the failing call to the LLM with the source class as context. Write Ruby only for
60
- the paths where token costs justify maintaining it. Per-input routing, validated
61
- retries, and fallbacks for your LLM calls included.'
42
+ or any provider RubyLLM supports, or any OpenAI-compatible endpoint, or your own
43
+ adapter) and return strict-schema-validated, typed results: the same type your Ruby
44
+ code returns. Use it to gracefully maintain fickle integrations, build interfaces
45
+ for unknown data formats, and rescue production errors by handing the failing call
46
+ to the LLM with the source class as context. Write Ruby only for the paths where
47
+ token costs justify maintaining it. Per-input routing, validated retries, and fallbacks
48
+ for your LLM calls included.'
62
49
  email:
63
50
  - mc@coolhandlabs.com
64
51
  executables: []
@@ -70,32 +57,77 @@ files:
70
57
  - README.md
71
58
  - Rakefile
72
59
  - SECURITY.md
60
+ - docs/adapters.md
73
61
  - docs/configuration.md
62
+ - docs/coolhand.md
74
63
  - docs/failures.md
75
64
  - docs/harnesses.md
76
65
  - docs/measuring-tokens.md
77
66
  - docs/naming.md
67
+ - docs/observers.md
78
68
  - docs/routing.md
79
69
  - docs/schemas.md
70
+ - docs/testing.md
71
+ - docs/verifying.md
80
72
  - lib/squishling.rb
73
+ - lib/squishling/adapter.rb
74
+ - lib/squishling/adapter/error_body.rb
75
+ - lib/squishling/adapters/openai_compatible.rb
76
+ - lib/squishling/adapters/openai_options.rb
77
+ - lib/squishling/adapters/ruby_llm.rb
81
78
  - lib/squishling/appendices.rb
79
+ - lib/squishling/budget.rb
82
80
  - lib/squishling/class_methods.rb
81
+ - lib/squishling/class_registry.rb
83
82
  - lib/squishling/collisions.rb
84
83
  - lib/squishling/configuration.rb
85
84
  - lib/squishling/definition.rb
86
85
  - lib/squishling/errors.rb
87
86
  - lib/squishling/harness.rb
87
+ - lib/squishling/harness/base.rb
88
+ - lib/squishling/harness/escalation.rb
89
+ - lib/squishling/harness/judged_squishsum.rb
90
+ - lib/squishling/harness/option_types.rb
91
+ - lib/squishling/harness/registry.rb
92
+ - lib/squishling/harness/squishsum.rb
88
93
  - lib/squishling/invoker.rb
89
94
  - lib/squishling/judge.rb
90
95
  - lib/squishling/llm_client.rb
91
96
  - lib/squishling/model_path.rb
97
+ - lib/squishling/named_registry.rb
98
+ - lib/squishling/observer.rb
99
+ - lib/squishling/observers/coolhand.rb
92
100
  - lib/squishling/output_check.rb
93
101
  - lib/squishling/params.rb
102
+ - lib/squishling/railtie.rb
94
103
  - lib/squishling/result.rb
104
+ - lib/squishling/rotation.rb
95
105
  - lib/squishling/router.rb
106
+ - lib/squishling/rspec.rb
107
+ - lib/squishling/run.rb
108
+ - lib/squishling/run/messages.rb
109
+ - lib/squishling/run/rejections.rb
96
110
  - lib/squishling/schema.rb
97
111
  - lib/squishling/source.rb
98
112
  - lib/squishling/squawk.rb
113
+ - lib/squishling/tasks/squishling.rake
114
+ - lib/squishling/testing.rb
115
+ - lib/squishling/testing/adapter.rb
116
+ - lib/squishling/testing/checks.rb
117
+ - lib/squishling/testing/direct.rb
118
+ - lib/squishling/testing/errors.rb
119
+ - lib/squishling/testing/fixtures.rb
120
+ - lib/squishling/testing/helpers.rb
121
+ - lib/squishling/testing/minitest.rb
122
+ - lib/squishling/testing/observer.rb
123
+ - lib/squishling/testing/reply.rb
124
+ - lib/squishling/testing/request.rb
125
+ - lib/squishling/testing/rspec.rb
126
+ - lib/squishling/testing/script.rb
127
+ - lib/squishling/testing/state.rb
128
+ - lib/squishling/testing/stubs.rb
129
+ - lib/squishling/verification.rb
130
+ - lib/squishling/verifier.rb
99
131
  - lib/squishling/version.rb
100
132
  - lib/squishling/wrapper.rb
101
133
  homepage: https://github.com/Coolhand-Labs/squishling