squishling 0.1.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.
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # A squished method's settings, with per-method overrides falling back to class-level defaults.
5
+ # `for_call` layers one call's `squish!` overrides on top.
6
+ class Definition
7
+ # Matches the keys a squish! context: Hash may use.
8
+ NAME_KEY = ->(key) { key.is_a?(String) || key.is_a?(Symbol) }
9
+
10
+ attr_reader :klass, :name, :call_context
11
+
12
+ def initialize(klass:, name:, instructions: nil, append_instructions: nil, output_schema: nil, model: nil,
13
+ provider: nil, params: nil, predicate: nil, fallback: nil, call_context: {})
14
+ @klass = klass
15
+ @name = name
16
+ @instructions = instructions
17
+ @append_instructions = append_instructions
18
+ @output_schema = output_schema
19
+ @model = model
20
+ @provider = provider
21
+ @params = params
22
+ @predicate = predicate
23
+ @fallback = fallback
24
+ @call_context = call_context
25
+ end
26
+
27
+ # This definition with one call's overrides on top. The output schema and predicate can't be overridden:
28
+ # the call must still return the method's result type.
29
+ def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil, provider: nil, params: nil)
30
+ raise ConfigurationError, "#{label}: squish! provider: needs a model:" if provider && !model
31
+ unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
32
+ raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
33
+ end
34
+
35
+ appended = Appendices.normalize(append_instructions, "#{label} squish!") unless append_instructions.nil?
36
+ call_params = params && Params.normalize(params, "#{label} squish! params")
37
+ self.class.new(
38
+ klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
39
+ instructions: instructions || @instructions,
40
+ append_instructions: [*@append_instructions, *appended],
41
+ model: model || @model, provider: model ? provider : @provider,
42
+ # merge, not Params.resolve: a nil at the method level must still unset the class's key.
43
+ params: call_params ? (@params || {}).merge(call_params) : @params,
44
+ call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
45
+ )
46
+ end
47
+
48
+ def label
49
+ "#{klass}##{name}"
50
+ end
51
+
52
+ # The system prompt: the instructions, then each append_instructions section.
53
+ def instructions(receiver)
54
+ value = @instructions || klass.instructions
55
+ value = receiver.instance_exec(&value) if value.is_a?(Proc)
56
+ return value if value.nil? || value.empty?
57
+
58
+ [value, *Appendices.render(append_instructions, receiver, label)].join("\n\n")
59
+ end
60
+
61
+ def append_instructions
62
+ Appendices.resolve(klass.squishling_append_instructions + (@append_instructions || []))
63
+ end
64
+
65
+ def schema
66
+ raw = @output_schema || klass.output_schema
67
+ raw && Schema.for(raw)
68
+ end
69
+
70
+ def model
71
+ model_and_provider.first
72
+ end
73
+
74
+ def provider
75
+ model_and_provider.last
76
+ end
77
+
78
+ # A provider travels with the model declared at the same level (method, class, or config),
79
+ # so a per-method Anthropic model never inherits a class-level OpenAI provider.
80
+ def model_and_provider
81
+ config = Squishling.config
82
+ [[@model, @provider], [klass.squishling_model, klass.squishling_provider],
83
+ [config.default_model, config.default_provider]].find(&:first) || [nil, nil]
84
+ end
85
+
86
+ # Generation params: config defaults, overridden key by key by the class, then by the method.
87
+ def params
88
+ Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
89
+ end
90
+
91
+ def context_names
92
+ klass.squishling_context_names
93
+ end
94
+
95
+ def squish?(receiver, inputs)
96
+ predicate = @predicate || klass.squishling_predicate
97
+ return false unless predicate
98
+
99
+ receiver.instance_exec(**inputs, &predicate) ? true : false
100
+ end
101
+
102
+ # Runs the elastic path. When the LLM fails (InvalidOutputError or LLMError) and a fallback is
103
+ # declared, the fallback's return value is used instead, coerced like a deterministic return.
104
+ def invoke_llm(receiver, inputs)
105
+ Invoker.new(self, receiver, inputs).call
106
+ rescue InvalidOutputError, LLMError => e
107
+ handler = @fallback || klass.squishling_fallback
108
+ raise unless handler
109
+
110
+ Squishling.config.logger&.warn("[Squishling] #{label} LLM failed, using fallback: #{e.message}")
111
+ coerce(receiver.instance_exec(e, **inputs, &handler))
112
+ end
113
+
114
+ # Deterministic return values: hashes are validated and turned into the typed result;
115
+ # anything else (including an already-built result) passes through.
116
+ def coerce(value)
117
+ value.is_a?(Hash) && schema ? build_result(value, squished: false) : value
118
+ end
119
+
120
+ def build_result(attrs, squished:)
121
+ raise ConfigurationError, "#{label} has no output_schema" unless schema
122
+ return attrs if attrs.is_a?(Result::Instance)
123
+
124
+ data = Schema.jsonify(attrs)
125
+ errors = schema.validate(data)
126
+ raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
127
+
128
+ schema.build(data, squished:)
129
+ end
130
+
131
+ # Map positional and keyword arguments onto the original method's parameter names.
132
+ def bind_arguments(args, kwargs)
133
+ positional = args.dup
134
+ bound = {}
135
+
136
+ parameters.each do |type, param|
137
+ case type
138
+ when :req, :opt
139
+ bound[param] = positional.shift unless positional.empty? || param.nil?
140
+ when :rest
141
+ bound[param.nil? || param == :* ? :args : param] = positional.shift(positional.size)
142
+ end
143
+ end
144
+ positional.each_with_index { |value, index| bound[:"arg#{index}"] = value }
145
+
146
+ bound.merge(kwargs)
147
+ end
148
+
149
+ private
150
+
151
+ # The implementation's parameters, beneath the prepended wrappers.
152
+ def parameters
153
+ Wrapper.implementation(klass.instance_method(name))&.parameters || []
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Error < StandardError; end
5
+
6
+ # A programming or setup mistake (missing instructions/schema, non-strict schema, unknown model,
7
+ # missing API key). Never retried and never passed to squish_fallback.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Output that didn't match the schema: from the LLM after all retries, or from the deterministic path.
11
+ class InvalidOutputError < Error
12
+ attr_reader :errors, :raw, :attempts
13
+
14
+ def initialize(errors:, raw: nil, source: "LLM", attempts: nil)
15
+ @errors = errors
16
+ @raw = raw
17
+ @attempts = attempts
18
+ tries = attempts ? " after #{attempts} attempt#{'s' unless attempts == 1}" : ""
19
+ super("#{source} output did not match the output schema#{tries}: #{errors.join('; ')}")
20
+ end
21
+ end
22
+
23
+ # The LLM call itself failed (rate limit, server error, timeout, connection) after RubyLLM's own
24
+ # HTTP retries. The original exception is available as #cause.
25
+ class LLMError < Error; end
26
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # The elastic path: sends a squished method's inputs through RubyLLM with structured output.
5
+ class Invoker
6
+ INPUT_NOTE = <<~NOTE
7
+ The input is a JSON object. "arguments" holds the values passed to this operation; "context", when
8
+ present, holds additional state about the caller. Respond only with JSON matching the required schema.
9
+ NOTE
10
+
11
+ def initialize(definition, receiver, inputs)
12
+ @definition = definition
13
+ @receiver = receiver
14
+ @inputs = inputs
15
+ end
16
+
17
+ def call
18
+ instructions = @definition.instructions(@receiver)
19
+ schema = @definition.schema
20
+ raise ConfigurationError, "#{@definition.label} has no instructions" if instructions.nil? || instructions.empty?
21
+ raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
22
+
23
+ chat = build_chat
24
+ chat.with_instructions("#{instructions}\n\n#{INPUT_NOTE}")
25
+ chat.with_schema(schema.llm_schema)
26
+ apply_params(chat)
27
+
28
+ response = ask(chat, JSON.generate(payload))
29
+ attempts = 0
30
+ loop do
31
+ attempts += 1
32
+ data, errors = parse(response.content)
33
+ errors = schema.validate(data) if errors.empty?
34
+ return schema.build(data, squished: true) if errors.empty?
35
+
36
+ if attempts > Squishling.config.max_retries
37
+ raise InvalidOutputError.new(errors:, raw: response.content, attempts:)
38
+ end
39
+
40
+ response = ask(chat, retry_message(errors))
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def build_chat
47
+ RubyLLM.chat(**chat_options)
48
+ rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
49
+ raise ConfigurationError, "#{@definition.label}: #{e.message}"
50
+ end
51
+
52
+ # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
53
+ # and raises ArgumentError before any request is sent.
54
+ def apply_params(chat)
55
+ Params.apply(chat, @definition.params)
56
+ rescue ArgumentError => e
57
+ raise ConfigurationError, "#{@definition.label}: invalid params #{@definition.params.inspect} (#{e.message})"
58
+ end
59
+
60
+ # Transient HTTP failures are already retried by RubyLLM (config.max_retries); anything that
61
+ # still fails is surfaced as an LLMError rather than retried again here. A 400 means the request
62
+ # we built is invalid (an unsupported param, a schema the provider rejects), so it's a setup
63
+ # mistake: a fallback would otherwise hide it on every call.
64
+ def ask(chat, message)
65
+ chat.ask(message)
66
+ rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
67
+ raise ConfigurationError, "#{@definition.label}: #{e.class}: #{e.message}"
68
+ rescue RubyLLM::BadRequestError => e
69
+ raise ConfigurationError, "#{@definition.label}: the provider rejected the request (#{e.message})#{params_hint}"
70
+ rescue RubyLLM::Error, Faraday::Error => e
71
+ raise LLMError, "#{@definition.label}: #{e.class}: #{e.message}"
72
+ end
73
+
74
+ # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
75
+ # provider is named, so RubyLLM is told to assume they exist.
76
+ def chat_options
77
+ model = @definition.model
78
+ provider = @definition.provider
79
+ options = { model:, provider: }.compact
80
+ options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
81
+ options
82
+ end
83
+
84
+ def known_model?(model, provider)
85
+ RubyLLM.models.find(model, provider:)
86
+ true
87
+ rescue RubyLLM::ModelNotFoundError
88
+ false
89
+ end
90
+
91
+ def params_hint
92
+ params = @definition.params
93
+ return "" if params.empty?
94
+
95
+ ". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
96
+ end
97
+
98
+ # Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
99
+ def payload
100
+ body = { arguments: Schema.jsonify(describe(@inputs)) }
101
+ context = @definition.context_names.to_h { |name| [name, context_value(name)] }.merge(@definition.call_context)
102
+ body[:context] = Schema.jsonify(describe(context)) if context.any?
103
+ body
104
+ end
105
+
106
+ # Exceptions are sent as their class and message. Plain JSON would send only the message, and with
107
+ # json/add/exception loaded it would also send the backtrace, which exposes file paths.
108
+ def describe(value)
109
+ case value
110
+ when Exception then { class: exception_class_name(value.class), message: value.message }
111
+ when Hash then value.transform_values { |item| describe(item) }
112
+ when Array then value.map { |item| describe(item) }
113
+ else value
114
+ end
115
+ end
116
+
117
+ # An anonymous error class (Class.new(StandardError)) is named after its closest named ancestor.
118
+ def exception_class_name(klass)
119
+ klass = klass.superclass until klass.name
120
+ klass.name
121
+ end
122
+
123
+ def context_value(name)
124
+ return @receiver.send(name) if @receiver.respond_to?(name, true)
125
+
126
+ @receiver.instance_variable_get(:"@#{name}")
127
+ end
128
+
129
+ # RubyLLM parses structured output itself and leaves the raw string when that fails, so
130
+ # content is a Hash on success, or a String/nil when the model refused, was cut off, or
131
+ # ignored the schema.
132
+ def parse(content)
133
+ return [nil, ["response was empty"]] if content.nil? || (content.is_a?(String) && content.strip.empty?)
134
+ return [content, []] unless content.is_a?(String)
135
+
136
+ [JSON.parse(strip_code_fence(content)), []]
137
+ rescue JSON::ParserError => e
138
+ [nil, ["response was not valid JSON (#{e.message.lines.first&.strip})"]]
139
+ end
140
+
141
+ # Models without native structured output sometimes wrap JSON in a markdown code fence.
142
+ def strip_code_fence(text)
143
+ text[/\A\s*```(?:json)?\s*\n(.*?)\n\s*```\s*\z/m, 1] || text
144
+ end
145
+
146
+ def retry_message(errors)
147
+ "Your previous response did not match the required schema:\n- #{errors.join("\n- ")}\n" \
148
+ "Respond again with corrected JSON only."
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Generation params (temperature, thinking, max_output_tokens, top_p, ...) layered config -> class -> method.
5
+ module Params
6
+ THINKING_KEYS = %i[effort budget display].freeze
7
+
8
+ # Request keys Squishling or RubyLLM own. Provider options are merged into the request last,
9
+ # overriding RubyLLM's defaults, so these would silently replace the model, the conversation,
10
+ # or the strict output format.
11
+ RESERVED_KEYS = %i[
12
+ model messages input instructions contents system system_instruction stream stream_options store include
13
+ response_format text output_config tools tool_choice schema
14
+ ].freeze
15
+
16
+ module_function
17
+
18
+ # Validates and symbolizes one layer. nil values are kept so a layer can unset an inherited key.
19
+ def normalize(params, label)
20
+ raise ConfigurationError, "#{label} must be a Hash, got #{params.class}" unless params.is_a?(Hash)
21
+
22
+ params = params.transform_keys(&:to_sym)
23
+ reserved = params.keys & RESERVED_KEYS
24
+ if reserved.any?
25
+ raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
26
+ "(controlled by Squishling/RubyLLM; use model:/provider:/output_schema)"
27
+ end
28
+ params[:thinking] = normalize_thinking(params[:thinking], label) unless params[:thinking].nil?
29
+ params.freeze
30
+ end
31
+
32
+ # Later layers override earlier ones key by key; nil removes the key (back to the provider default).
33
+ def resolve(*layers)
34
+ layers.compact.reduce({}) { |merged, layer| merged.merge(layer) }.compact
35
+ end
36
+
37
+ # Portable settings use RubyLLM's dedicated setters, which translate them for each provider;
38
+ # everything else is merged into the provider request as-is.
39
+ def apply(chat, params)
40
+ rest = params.except(:temperature, :thinking, :max_output_tokens)
41
+ chat.with_temperature(params[:temperature]) if params.key?(:temperature)
42
+ chat.with_max_output_tokens(params[:max_output_tokens]) if params.key?(:max_output_tokens)
43
+ apply_thinking(chat, params[:thinking]) if params.key?(:thinking)
44
+ chat.with_provider_options(rest) if rest.any?
45
+ chat
46
+ end
47
+
48
+ # true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
49
+ def apply_thinking(chat, thinking)
50
+ thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
51
+ end
52
+
53
+ def normalize_thinking(thinking, label)
54
+ return thinking if [true, false].include?(thinking)
55
+
56
+ thinking = thinking.transform_keys(&:to_sym) if thinking.is_a?(Hash)
57
+ unless thinking.is_a?(Hash) && thinking.any? && (thinking.keys - THINKING_KEYS).empty? && !thinking.value?(nil)
58
+ raise ConfigurationError,
59
+ "#{label}: thinking must be true, false, or a Hash with :effort, :budget and/or :display, " \
60
+ "got #{thinking.inspect}"
61
+ end
62
+
63
+ thinking
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Builds Data classes from a JSON Schema so both the LLM and deterministic paths return
5
+ # the same typed object.
6
+ module Result
7
+ # Mixed into every generated result class.
8
+ module Instance
9
+ def initialize(squishling_squished: false, **attrs)
10
+ @squished = squishling_squished
11
+ super(**attrs)
12
+ end
13
+
14
+ # True when this result came from the LLM rather than Ruby code.
15
+ def squished?
16
+ @squished
17
+ end
18
+
19
+ def [](key)
20
+ members.include?(key.to_sym) ? public_send(key.to_sym) : nil
21
+ end
22
+
23
+ def to_h
24
+ super.transform_values { |value| Result.deep_to_h(value) }
25
+ end
26
+ end
27
+
28
+ module ClassMethods
29
+ def from_h(hash, squished: false)
30
+ hash = hash.transform_keys(&:to_sym)
31
+ attrs = members.to_h { |member| [member, squishling_fields[member].call(hash[member], squished)] }
32
+ new(squishling_squished: squished, **attrs)
33
+ end
34
+
35
+ private
36
+
37
+ def squishling_fields
38
+ @squishling_fields ||= @squishling_builder.fields_for(@squishling_schema)
39
+ end
40
+ end
41
+
42
+ class << self
43
+ def build(json_schema)
44
+ Builder.new(json_schema).class_for(json_schema)
45
+ end
46
+
47
+ def deep_to_h(value)
48
+ case value
49
+ when Instance then value.to_h
50
+ when Array then value.map { |item| deep_to_h(item) }
51
+ else value
52
+ end
53
+ end
54
+ end
55
+
56
+ # Resolves $refs and builds (memoized, so recursive schemas work) one Data class per object schema.
57
+ class Builder
58
+ IDENTITY = ->(value, _squished) { value.is_a?(Hash) ? value.transform_keys(&:to_sym) : value }
59
+
60
+ def initialize(root)
61
+ @root = root
62
+ @classes = {}.compare_by_identity
63
+ end
64
+
65
+ # Returns a Data class, or nil when the schema isn't an object with properties.
66
+ def class_for(schema)
67
+ schema = resolve(schema)
68
+ return nil unless schema.is_a?(Hash) && schema["properties"].is_a?(Hash)
69
+
70
+ @classes[schema] ||= begin
71
+ klass = Data.define(*schema["properties"].keys.map(&:to_sym))
72
+ klass.include(Instance)
73
+ klass.extend(ClassMethods)
74
+ klass.instance_variable_set(:@squishling_builder, self)
75
+ klass.instance_variable_set(:@squishling_schema, schema)
76
+ klass
77
+ end
78
+ end
79
+
80
+ def fields_for(schema)
81
+ schema["properties"].to_h { |key, property| [key.to_sym, converter_for(property)] }
82
+ end
83
+
84
+ private
85
+
86
+ def converter_for(property)
87
+ property = resolve(property)
88
+ return IDENTITY unless property.is_a?(Hash)
89
+
90
+ # `optional` (anyOf [schema, null]) is typed as its schema; nil passes through every converter.
91
+ if (branch = nullable_branch(property))
92
+ converter_for(branch)
93
+ elsif (klass = class_for(property))
94
+ ->(value, squished) { value.is_a?(Hash) ? klass.from_h(value, squished:) : value }
95
+ elsif property["items"]
96
+ item = converter_for(property["items"])
97
+ ->(value, squished) { value.is_a?(Array) ? value.map { |v| item.call(v, squished) } : value }
98
+ else
99
+ IDENTITY
100
+ end
101
+ end
102
+
103
+ # The single non-null branch of an anyOf/oneOf. Unions with several non-null branches are
104
+ # ambiguous, so their values are left untyped.
105
+ def nullable_branch(property)
106
+ union = property["anyOf"] || property["oneOf"]
107
+ return unless union.is_a?(Array)
108
+
109
+ branches = union.reject { |branch| resolve(branch).is_a?(Hash) && resolve(branch)["type"] == "null" }
110
+ branches.first if branches.size == 1
111
+ end
112
+
113
+ def resolve(schema)
114
+ return schema unless schema.is_a?(Hash) && schema["$ref"].is_a?(String)
115
+ return schema unless schema["$ref"].start_with?("#/")
116
+
117
+ path = schema["$ref"].delete_prefix("#/").split("/")
118
+ @root.dig(*path) || schema
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Decides, per call, whether a squished method runs its Ruby implementation or the LLM.
5
+ module Router
6
+ # One squished call in progress. `phase` is :routing (deciding, e.g. running squish_when), :ruby (running
7
+ # the implementation), or :llm (on the LLM path, including any fallback); `escalated` records that squish!
8
+ # already sent it to the LLM.
9
+ Frame = Struct.new(:receiver, :definition, :wrapper, :inputs, :phase, :escalated)
10
+
11
+ FRAMES_KEY = :__squishling_frames__
12
+
13
+ class << self
14
+ def dispatch(receiver, name, args, kwargs, impl, wrapper:)
15
+ if (frame = enclosing_frame(receiver, name, wrapper))
16
+ return frame.definition.coerce(impl.call)
17
+ end
18
+
19
+ definition = receiver.class.squishling_definition(name)
20
+ frame = Frame.new(receiver, definition, wrapper, definition.bind_arguments(args, kwargs), :routing, false)
21
+ frames.push(frame)
22
+ begin
23
+ return route_to_llm(frame, definition, "predicate") if definition.squish?(receiver, frame.inputs)
24
+
25
+ begin
26
+ frame.phase = :ruby
27
+ value = impl.call
28
+ rescue NotImplementedError
29
+ # After squish!, the error came from the LLM path (a fallback, a proc), not a missing implementation.
30
+ raise if frame.escalated
31
+
32
+ return route_to_llm(frame, definition, "not implemented")
33
+ end
34
+
35
+ definition.coerce(value)
36
+ ensure
37
+ frames.pop
38
+ end
39
+ end
40
+
41
+ def current_frame(receiver)
42
+ frames.reverse_each.find { |frame| frame.receiver.equal?(receiver) }
43
+ end
44
+
45
+ # squish!: hand the call in progress on this receiver to the LLM, with this call's overrides.
46
+ def escalate(receiver, overrides)
47
+ frame = current_frame(receiver)
48
+ raise Error, "#{receiver.class}#squish! called outside a squished method" unless frame
49
+ unless frame.phase == :ruby
50
+ raise Error, "#{frame.definition.label}: squish! can only be called from the method's Ruby implementation, " \
51
+ "not from squish_when or while already on the LLM path (e.g. from squish_fallback)"
52
+ end
53
+
54
+ frame.escalated = true
55
+ route_to_llm(frame, frame.definition.for_call(**overrides), "squish!")
56
+ end
57
+
58
+ private
59
+
60
+ def route_to_llm(frame, definition, reason)
61
+ log(definition, reason)
62
+ previous = frame.phase
63
+ frame.phase = :llm
64
+ definition.invoke_llm(frame.receiver, frame.inputs)
65
+ ensure
66
+ frame.phase = previous
67
+ end
68
+
69
+ # Thread#[] is fiber-local, so concurrent fibers don't share frames.
70
+ def frames
71
+ Thread.current[FRAMES_KEY] ||= []
72
+ end
73
+
74
+ # The call in progress that this one is part of, which runs its Ruby implementation without routing again:
75
+ # a subclass override's `super` (entered through an ancestor's wrapper), or a call made while deciding or on
76
+ # the LLM path (squish_when, squish_fallback, an instructions proc). Only recursion from the Ruby
77
+ # implementation itself is a new call, routed on its own.
78
+ def enclosing_frame(receiver, name, wrapper)
79
+ frame = frames.reverse_each.find do |candidate|
80
+ candidate.receiver.equal?(receiver) && candidate.definition.name == name
81
+ end
82
+ frame unless frame.nil? || (frame.wrapper.equal?(wrapper) && frame.phase == :ruby)
83
+ end
84
+
85
+ def log(definition, reason)
86
+ Squishling.config.logger&.debug("[Squishling] #{definition.label} -> LLM (#{reason})")
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Normalizes the supported output-schema forms into a JSON Schema, validates data against it,
5
+ # and builds typed results.
6
+ class Schema
7
+ CACHE = {}.compare_by_identity
8
+ CACHE_LOCK = Mutex.new
9
+
10
+ class << self
11
+ def for(raw)
12
+ CACHE_LOCK.synchronize { CACHE[raw] ||= new(raw) }
13
+ end
14
+
15
+ # Deep-convert to plain JSON types (string keys), as the LLM would return them.
16
+ def jsonify(value)
17
+ JSON.parse(JSON.generate(value))
18
+ end
19
+ end
20
+
21
+ # The payload handed to RubyLLM's `with_schema`: always strict.
22
+ attr_reader :llm_schema
23
+ # The bare JSON Schema used for validation and result typing.
24
+ attr_reader :json_schema
25
+
26
+ def initialize(raw)
27
+ hash = self.class.jsonify(to_hash(raw))
28
+ wrapped = wrapped?(hash)
29
+ body = wrapped ? hash["schema"] : hash
30
+
31
+ # Same precedence RubyLLM uses: a top-level strict flag wins over one inside the schema.
32
+ strict = hash.key?("strict") ? hash["strict"] : body["strict"]
33
+ raise ConfigurationError, "Squishling only supports strict output schemas (got strict: false)" if strict == false
34
+
35
+ @json_schema = body.except("strict")
36
+ @llm_schema = {
37
+ "name" => hash["name"] || body["title"],
38
+ "description" => hash["description"] || body["description"],
39
+ "schema" => @json_schema,
40
+ "strict" => true
41
+ }.compact
42
+ @validator = JSONSchemer.schema(@json_schema)
43
+ end
44
+
45
+ def validate(data)
46
+ @validator.validate(data).map { |error| error["error"] }
47
+ end
48
+
49
+ def build(data, squished:)
50
+ result_class ? result_class.from_h(data, squished:) : data
51
+ end
52
+
53
+ def result_class
54
+ return @result_class if defined?(@result_class)
55
+
56
+ @result_class = Result.build(@json_schema)
57
+ end
58
+
59
+ private
60
+
61
+ def to_hash(raw)
62
+ case raw
63
+ when Hash then raw
64
+ when Class
65
+ raise ConfigurationError, "#{raw} is not a Schematist::Schema" unless raw <= Schematist::Schema
66
+
67
+ raw.new.to_json_schema
68
+ else
69
+ return raw.to_json_schema if raw.respond_to?(:to_json_schema)
70
+
71
+ raise ConfigurationError, "Unsupported output_schema: #{raw.inspect}"
72
+ end
73
+ end
74
+
75
+ # Some schema objects emit a { name:, description:, schema: {...} } wrapper instead of a bare schema.
76
+ def wrapped?(hash)
77
+ hash["schema"].is_a?(Hash) && !hash.key?("type") && !hash.key?("properties")
78
+ end
79
+ end
80
+ end