smith-agents 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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. metadata +258 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events
5
+ class Scope
6
+ def initialize
7
+ @handles = []
8
+ end
9
+
10
+ def on(event_class, **, &)
11
+ handle = Events.on(event_class, **, &)
12
+ @handles << handle
13
+ handle
14
+ end
15
+
16
+ def cancel_all
17
+ @handles.each(&:cancel)
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def subscriptions
23
+ @subscriptions ||= []
24
+ end
25
+
26
+ def on(event_class, **opts, &block)
27
+ sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
28
+ subscriptions << sub
29
+ sub
30
+ end
31
+
32
+ def emit(event)
33
+ subscriptions.each { |sub| dispatch_to(sub, event) }
34
+ end
35
+
36
+ def within
37
+ scope = Scope.new
38
+ yield scope
39
+ ensure
40
+ scope&.cancel_all
41
+ end
42
+
43
+ def reset!
44
+ @subscriptions = []
45
+ end
46
+
47
+ private
48
+
49
+ def dispatch_to(sub, event)
50
+ return if sub.cancelled?
51
+ return unless event.is_a?(sub.event_class)
52
+ return if sub.predicate && !sub.predicate.call(event)
53
+
54
+ sub.handler.call(event)
55
+ rescue StandardError => e
56
+ Smith.config.logger&.error("Smith::Events handler error: #{e.message}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events
5
+ class StepCompleted < Smith::Event
6
+ attribute :transition, Types::Strict::Symbol
7
+ attribute :from, Types::Strict::Symbol.optional
8
+ attribute :to, Types::Strict::Symbol
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events
5
+ class Subscription
6
+ attr_reader :event_class, :handler, :predicate
7
+
8
+ def initialize(event_class, handler:, predicate: nil)
9
+ @event_class = event_class
10
+ @handler = handler
11
+ @predicate = predicate
12
+ @cancelled = false
13
+ end
14
+
15
+ def cancel
16
+ @cancelled = true
17
+ end
18
+
19
+ def cancelled?
20
+ @cancelled
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events; end
5
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Guardrails
5
+ module Runner
6
+ class << self
7
+ def run_inputs(guardrails_class, payload)
8
+ run_layer(guardrails_class, guardrails_class.input, payload, GuardrailFailed)
9
+ end
10
+
11
+ def run_outputs(guardrails_class, payload)
12
+ run_layer(guardrails_class, guardrails_class.output, payload, GuardrailFailed)
13
+ end
14
+
15
+ def run_tool(guardrails_class, tool_name, payload)
16
+ matching = guardrails_class.tool.select { |d| d[:on]&.include?(tool_name) }
17
+ run_layer(guardrails_class, matching, payload, ToolGuardrailFailed)
18
+ end
19
+
20
+ private
21
+
22
+ def run_layer(guardrails_class, declarations, payload, error_class)
23
+ instance = guardrails_class.new
24
+ declarations.each { |d| instance.send(d[:name], payload) }
25
+ rescue Smith::Error
26
+ raise
27
+ rescue StandardError => e
28
+ raise build_guardrail_error(error_class, e)
29
+ end
30
+
31
+ def build_guardrail_error(error_class, error)
32
+ return error_class.new(error.message, retryable: retryable_tool_guardrail?(error.message)) if error_class == ToolGuardrailFailed
33
+
34
+ error_class.new(error.message)
35
+ end
36
+
37
+ def retryable_tool_guardrail?(message)
38
+ text = message.to_s.downcase
39
+ text.include?("rate limit") || text.include?("malformed args")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Guardrails
5
+ class UrlVerifier; end
6
+ end
7
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Guardrails
5
+ class << self
6
+ def inherited(subclass)
7
+ super
8
+ subclass.instance_variable_set(:@inputs, (@inputs || []).dup)
9
+ subclass.instance_variable_set(:@tools, (@tools || []).dup)
10
+ subclass.instance_variable_set(:@outputs, (@outputs || []).dup)
11
+ end
12
+
13
+ def input(name = nil, **)
14
+ return @inputs || [] if name.nil?
15
+
16
+ @inputs ||= []
17
+ @inputs << ({ name: name, ** })
18
+ end
19
+
20
+ def tool(name = nil, **)
21
+ return @tools || [] if name.nil?
22
+
23
+ @tools ||= []
24
+ @tools << ({ name: name, ** })
25
+ end
26
+
27
+ def output(name = nil, **)
28
+ return @outputs || [] if name.nil?
29
+
30
+ @outputs ||= []
31
+ @outputs << ({ name: name, ** })
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Models
5
+ # Pattern-based provider capability rules. Library-level knowledge
6
+ # about how PROVIDER FAMILIES shape their API payloads — independent
7
+ # of specific model ids or downstream applications.
8
+ #
9
+ # Smith ships ZERO specific model_id declarations. Each rule matches
10
+ # a regex or version-aware predicate against the resolved model_id
11
+ # at runtime. New model releases that fit existing patterns work
12
+ # automatically (e.g., a future claude-opus-4-9 matches the Opus 4.7+
13
+ # adaptive-thinking rule).
14
+ #
15
+ # Rule order matters: most specific patterns first. Hosts that need
16
+ # to ADD provider knowledge (a new provider Smith doesn't ship rules
17
+ # for, or a custom finetune) can `prepend_rule` at runtime.
18
+ #
19
+ # The rules array is mutable for runtime extension; tests must use
20
+ # the `with_rules(*rules) { ... }` block helper to avoid test-suite
21
+ # leakage of `prepend_rule` mutations.
22
+ module Inference
23
+ # A single rule maps a model_id matcher to capability values.
24
+ # The matcher is a Proc[String -> Boolean] — regex match OR
25
+ # version-aware predicate (e.g., Opus 4.7+).
26
+ Rule = Data.define(
27
+ :provider,
28
+ :matcher,
29
+ :thinking_shape,
30
+ :accepts_temperature,
31
+ :tools_with_thinking_native,
32
+ :tools_with_thinking_route
33
+ ) do
34
+ def matches?(model_id)
35
+ matcher.call(model_id.to_s)
36
+ end
37
+
38
+ def to_profile(model_id)
39
+ Profile.new(
40
+ model_id: model_id.to_s,
41
+ provider: provider,
42
+ thinking_shape: thinking_shape,
43
+ accepts_temperature: accepts_temperature,
44
+ tools_with_thinking_native: tools_with_thinking_native,
45
+ tools_with_thinking_route: tools_with_thinking_route
46
+ )
47
+ end
48
+ end
49
+
50
+ def self.rules
51
+ @_rules
52
+ end
53
+
54
+ def self.prepend_rule(rule)
55
+ rules.unshift(rule)
56
+ end
57
+
58
+ def self.reset!
59
+ @_rules = default_rules.dup
60
+ end
61
+
62
+ # Block-form test helper. Yields with the given rules INSTEAD of
63
+ # the default set; restores afterward even if the block raises.
64
+ # Prevents test-suite leakage of `prepend_rule` mutations.
65
+ def self.with_rules(*overrides)
66
+ previous = @_rules
67
+ @_rules = overrides.flatten
68
+ yield
69
+ ensure
70
+ @_rules = previous
71
+ end
72
+
73
+ def self.profile_for(model_id)
74
+ rule = rules.find { |r| r.matches?(model_id) }
75
+ rule&.to_profile(model_id)
76
+ end
77
+
78
+ # Library-shipped pattern rules. Order: most specific first per
79
+ # provider; provider families in declaration order. NO specific
80
+ # model_id strings — only PROVIDER FAMILY and version-range patterns.
81
+ def self.default_rules
82
+ [
83
+ # ----- Anthropic -----
84
+
85
+ # Opus 4.7+: adaptive thinking, no temperature accepted.
86
+ Rule.new(
87
+ provider: :anthropic,
88
+ matcher: lambda { |id|
89
+ m = id.match(/\Aclaude-opus-4-(\d+)/)
90
+ m && m[1].to_i >= 7
91
+ },
92
+ thinking_shape: :adaptive,
93
+ accepts_temperature: false,
94
+ tools_with_thinking_native: true,
95
+ tools_with_thinking_route: nil
96
+ ),
97
+ # Opus/Sonnet/Haiku 4.0-4.6: budget_tokens thinking.
98
+ Rule.new(
99
+ provider: :anthropic,
100
+ matcher: lambda { |id|
101
+ m = id.match(/\Aclaude-(?:opus|sonnet|haiku)-4-(\d+)/)
102
+ m && m[1].to_i <= 6
103
+ },
104
+ thinking_shape: :budget_tokens,
105
+ accepts_temperature: true,
106
+ tools_with_thinking_native: true,
107
+ tools_with_thinking_route: nil
108
+ ),
109
+ # Claude 3.7 Sonnet introduced extended thinking via budget_tokens.
110
+ # Claude 3.5 and earlier DON'T have thinking — handled by the
111
+ # safe-default Anthropic rule below.
112
+ Rule.new(
113
+ provider: :anthropic,
114
+ matcher: ->(id) { id.match?(/\Aclaude-3-7/) },
115
+ thinking_shape: :budget_tokens,
116
+ accepts_temperature: true,
117
+ tools_with_thinking_native: true,
118
+ tools_with_thinking_route: nil
119
+ ),
120
+ # Any other Claude (3.5, 3.0, 2.x): safe default — no thinking,
121
+ # accepts temperature, tools work normally on chat-completions.
122
+ Rule.new(
123
+ provider: :anthropic,
124
+ matcher: ->(id) { id.match?(/\Aclaude-/) },
125
+ thinking_shape: nil,
126
+ accepts_temperature: true,
127
+ tools_with_thinking_native: false,
128
+ tools_with_thinking_route: nil
129
+ ),
130
+
131
+ # ----- OpenAI -----
132
+
133
+ # gpt-5 family + o-series reasoning models: reasoning_effort,
134
+ # no temperature, needs /v1/responses for tools+thinking combo
135
+ # (chat-completions rejects the combination).
136
+ Rule.new(
137
+ provider: :openai,
138
+ matcher: ->(id) { id.match?(/\A(gpt-5|o\d)/) },
139
+ thinking_shape: :reasoning_effort,
140
+ accepts_temperature: false,
141
+ tools_with_thinking_native: false,
142
+ tools_with_thinking_route: :responses
143
+ ),
144
+ # gpt-4.x: no thinking, accepts temperature.
145
+ Rule.new(
146
+ provider: :openai,
147
+ matcher: ->(id) { id.match?(/\Agpt-4/) },
148
+ thinking_shape: nil,
149
+ accepts_temperature: true,
150
+ tools_with_thinking_native: false,
151
+ tools_with_thinking_route: nil
152
+ ),
153
+ # Older OpenAI: no thinking, accepts temperature.
154
+ Rule.new(
155
+ provider: :openai,
156
+ matcher: ->(id) { id.match?(/\A(gpt-3|text-)/) },
157
+ thinking_shape: nil,
158
+ accepts_temperature: true,
159
+ tools_with_thinking_native: false,
160
+ tools_with_thinking_route: nil
161
+ ),
162
+
163
+ # ----- Gemini -----
164
+
165
+ # Gemini 2.5+ (all variants, including Flash) supports thinking
166
+ # via budget_tokens. Earlier Gemini (1.x, 2.0) does not.
167
+ Rule.new(
168
+ provider: :gemini,
169
+ matcher: lambda { |id|
170
+ m = id.match(/\Agemini-(\d+)\.(\d+)/)
171
+ m && (m[1].to_i > 2 || (m[1].to_i == 2 && m[2].to_i >= 5))
172
+ },
173
+ thinking_shape: :budget_tokens,
174
+ accepts_temperature: true,
175
+ tools_with_thinking_native: true,
176
+ tools_with_thinking_route: nil
177
+ ),
178
+ # Any other Gemini (1.x, 2.0): no thinking.
179
+ Rule.new(
180
+ provider: :gemini,
181
+ matcher: ->(id) { id.match?(/\Agemini-/) },
182
+ thinking_shape: nil,
183
+ accepts_temperature: true,
184
+ tools_with_thinking_native: false,
185
+ tools_with_thinking_route: nil
186
+ )
187
+ ].freeze
188
+ end
189
+
190
+ # Eagerly initialized at module load (after default_rules is
191
+ # defined) so concurrent first-callers cannot race the `||=`
192
+ # lazy-init and end up holding references to separate Array
193
+ # instances. Host calls to `prepend_rule` / `reset!` / `with_rules`
194
+ # are still expected to fire only at setup time on the main
195
+ # thread; concurrent mutation after boot is unsupported.
196
+ @_rules = default_rules.dup
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ module Models
7
+ # Per-chat-construction request shaper. Mutates a RubyLLM::Chat
8
+ # in place to fit the resolved model's capability profile, using
9
+ # RubyLLM's public `with_*` API where it covers the case and
10
+ # scoped instance-variable nulling where no public API exists
11
+ # (RubyLLM has no `without_temperature` / `without_thinking`).
12
+ #
13
+ # Lifetime: built fresh inside Smith::Agent.chat per construction.
14
+ # Never crosses threads. Never cached.
15
+ #
16
+ # Runs OUTSIDE any workflow context — does NOT access:
17
+ # - Smith.scoped_artifacts (thread-local, set only inside workflows)
18
+ # - Tool.current_ledger / Tool.current_tool_result_collector
19
+ # - Thread.current[:smith_last_agent_result]
20
+ # Smith::Trace.record is the ONLY observability surface the normalizer
21
+ # touches; it's safe outside workflow scope.
22
+ class Normalizer
23
+ extend Dry::Initializer
24
+
25
+ # Decision record emitted as a :normalizer_decision trace event.
26
+ # The Decision.kind value space is exhaustively documented in the
27
+ # plan; adding a new kind requires updating the trace CONFIG_MAP.
28
+ Decision = Data.define(:kind, :model_id, :detail)
29
+
30
+ # No type predicate on options — Smith's existing Dry::Initializer
31
+ # call sites trust internal callers and don't enforce option types.
32
+ option :chat
33
+ option :profile
34
+
35
+ # Returns Array<Decision> of mutations performed. The chat is
36
+ # mutated in place; callers usually ignore the return value
37
+ # except in tests.
38
+ def self.apply!(chat, profile:)
39
+ return [] if profile.nil?
40
+
41
+ new(chat: chat, profile: profile).apply!
42
+ end
43
+
44
+ def apply!
45
+ @decisions = []
46
+ normalize_temperature
47
+ normalize_thinking
48
+ normalize_tools_routing
49
+ emit_trace
50
+ @decisions
51
+ end
52
+
53
+ private
54
+
55
+ def normalize_temperature
56
+ return if profile.accepts_temperature
57
+ return if chat.instance_variable_get(:@temperature).nil?
58
+
59
+ # No public `without_temperature` in RubyLLM 1.15 — direct ivar
60
+ # nulling is the only path. Scoped: only @temperature, only on
61
+ # models that explicitly reject it. Add `RubyLLM::Chat#without_temperature`
62
+ # upstream and Smith retires this line (see UPSTREAM_PROPOSAL.md).
63
+ chat.instance_variable_set(:@temperature, nil)
64
+ @decisions << Decision.new(kind: :temperature_dropped, model_id: profile.model_id, detail: nil)
65
+ end
66
+
67
+ def normalize_thinking
68
+ thinking = chat.instance_variable_get(:@thinking)
69
+ return if thinking.nil? || !thinking.enabled?
70
+
71
+ case profile.thinking_shape
72
+ when nil
73
+ chat.instance_variable_set(:@thinking, nil)
74
+ @decisions << Decision.new(kind: :thinking_dropped, model_id: profile.model_id, detail: nil)
75
+ when :budget_tokens, :reasoning_effort
76
+ # RubyLLM's provider renderers already emit the right shape.
77
+ # Leave @thinking unchanged.
78
+ when :adaptive
79
+ translate_thinking_to_adaptive(thinking)
80
+ end
81
+ end
82
+
83
+ def translate_thinking_to_adaptive(thinking)
84
+ effort = thinking.respond_to?(:effort) && thinking.effort ? thinking.effort : "high"
85
+ merge_params(thinking: { type: "adaptive" }, output_config: { effort: effort })
86
+
87
+ # Null @thinking so RubyLLM's render_payload doesn't ALSO emit
88
+ # the budget_tokens shape that would conflict with our adaptive
89
+ # injection at deep_merge time.
90
+ chat.instance_variable_set(:@thinking, nil)
91
+ @decisions << Decision.new(
92
+ kind: :thinking_translated_to_adaptive,
93
+ model_id: profile.model_id,
94
+ detail: { effort: effort }
95
+ )
96
+ end
97
+
98
+ def normalize_tools_routing
99
+ # Stubbed chat objects in tests may not implement .tools; gracefully
100
+ # skip rather than crash on respond_to? check.
101
+ return unless chat.respond_to?(:tools)
102
+
103
+ tools = chat.tools.values
104
+ return if tools.empty?
105
+ return unless thinking_active?
106
+
107
+ return if profile.tools_with_thinking_native
108
+
109
+ if profile.tools_with_thinking_route == :responses &&
110
+ Smith.config.openai_api_mode == :auto
111
+ merge_params(openai_api_mode: :responses)
112
+ @decisions << Decision.new(kind: :routed_via_responses, model_id: profile.model_id, detail: nil)
113
+ return
114
+ end
115
+
116
+ drop_incompatible_tools(tools)
117
+ end
118
+
119
+ def thinking_active?
120
+ thinking = chat.instance_variable_get(:@thinking)
121
+ return true if thinking&.enabled?
122
+
123
+ # Also active if we already translated to adaptive (in which case
124
+ # @thinking is nil but params carry the thinking spec).
125
+ params = chat.instance_variable_get(:@params) || {}
126
+ params.key?(:thinking) || params.key?(:reasoning) || params.key?(:reasoning_effort)
127
+ end
128
+
129
+ def drop_incompatible_tools(tools)
130
+ effective_endpoint = effective_endpoint_for_compatibility
131
+ incompatible = tools.reject do |tool|
132
+ spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
133
+ if defined?(Smith::Tool::Compatibility)
134
+ Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: effective_endpoint)
135
+ else
136
+ true
137
+ end
138
+ end
139
+ return if incompatible.empty?
140
+
141
+ retained = tools - incompatible
142
+ chat.with_tools(*retained, replace: true)
143
+
144
+ incompatible.each do |tool|
145
+ @decisions << Decision.new(
146
+ kind: :tool_dropped,
147
+ model_id: profile.model_id,
148
+ detail: { tool: tool.class.name }
149
+ )
150
+ end
151
+ end
152
+
153
+ # Profile.endpoint_mode reports the INTENDED endpoint (per the
154
+ # inference rule). Smith.config.openai_api_mode policy can downgrade
155
+ # the EFFECTIVE endpoint — e.g., a profile with route :responses
156
+ # actually uses :chat_completions when openai_api_mode is :off.
157
+ # The compatibility check needs the effective endpoint to make
158
+ # the right drop/keep decision.
159
+ def effective_endpoint_for_compatibility
160
+ if profile.tools_with_thinking_route == :responses &&
161
+ Smith.config.openai_api_mode != :auto
162
+ :chat_completions
163
+ else
164
+ profile.endpoint_mode
165
+ end
166
+ end
167
+
168
+ # with_params REPLACES @params in RubyLLM (chat.rb:96), so the
169
+ # normalizer always reads existing + merges + writes back to
170
+ # preserve prior user calls to with_params.
171
+ def merge_params(**new_params)
172
+ existing = chat.instance_variable_get(:@params) || {}
173
+ chat.with_params(**existing, **new_params)
174
+ end
175
+
176
+ def emit_trace
177
+ return if @decisions.empty?
178
+ return unless defined?(Smith::Trace)
179
+
180
+ @decisions.each do |decision|
181
+ Smith::Trace.record(type: :normalizer_decision, data: decision.to_h)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Models
5
+ # Immutable capability record for a model id. Holds only inherent
6
+ # provider/model properties — never pricing, never API keys, never
7
+ # request-specific data. Library-shipped rules live in
8
+ # Smith::Models::Inference (pattern-based).
9
+ #
10
+ # Fields:
11
+ # model_id — canonical id ("claude-opus-4-7")
12
+ # provider — :anthropic | :openai | :gemini | :xai | ...
13
+ # thinking_shape — nil | :budget_tokens | :reasoning_effort | :adaptive
14
+ # nil — model has no thinking concept (don't send thinking)
15
+ # :budget_tokens — RubyLLM's default Anthropic shape (Opus 4.6, Sonnet 4.x)
16
+ # :reasoning_effort — OpenAI-style reasoning_effort string
17
+ # :adaptive — Opus 4.7+ adaptive shape (output_config.effort)
18
+ # accepts_temperature — false → normalizer strips @temperature
19
+ # tools_with_thinking_native — true → tools + thinking on default endpoint OK
20
+ # tools_with_thinking_route — nil | :responses (which endpoint to route to
21
+ # when both tools + thinking are present and
22
+ # native combo is unsupported)
23
+ Profile = Data.define(
24
+ :model_id,
25
+ :provider,
26
+ :thinking_shape,
27
+ :accepts_temperature,
28
+ :tools_with_thinking_native,
29
+ :tools_with_thinking_route
30
+ ) do
31
+ # Derived from tools_with_thinking_route. Exposed on Profile (not on
32
+ # Tool::Compatibility) so the Profile is a self-contained capability
33
+ # record without cross-namespace dependency.
34
+ def endpoint_mode
35
+ tools_with_thinking_route == :responses ? :responses : :chat_completions
36
+ end
37
+ end
38
+ end
39
+ end