smith-agents 0.7.0 → 0.8.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  5. data/docs/PERSISTENCE.md +27 -1
  6. data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
  7. data/lib/smith/agent/chat_construction.rb +112 -0
  8. data/lib/smith/agent/completion.rb +46 -0
  9. data/lib/smith/agent/completion_usage_recording.rb +25 -0
  10. data/lib/smith/agent/dynamic_configuration.rb +69 -0
  11. data/lib/smith/agent/fallback_configuration.rb +26 -0
  12. data/lib/smith/agent/invocation_preparation.rb +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +29 -197
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +15 -7
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +101 -28
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  60. data/lib/smith/tool/chat_execution_context.rb +7 -88
  61. data/lib/smith/tool/execution_authority.rb +42 -0
  62. data/lib/smith/tool/execution_authorization.rb +17 -0
  63. data/lib/smith/tool/execution_batch.rb +100 -0
  64. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  65. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  66. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  67. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  68. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  69. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  70. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  71. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  72. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  73. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  74. data/lib/smith/tool/execution_batch_state.rb +110 -0
  75. data/lib/smith/tool/execution_dispatch.rb +78 -0
  76. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  77. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  78. data/lib/smith/tool/execution_tracker.rb +20 -0
  79. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  80. data/lib/smith/tool/graceful_completion.rb +20 -0
  81. data/lib/smith/tool/invocation.rb +27 -0
  82. data/lib/smith/tool/invocation_request.rb +37 -0
  83. data/lib/smith/tool/invocation_sequence.rb +26 -0
  84. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  85. data/lib/smith/tool/scoped_context.rb +60 -8
  86. data/lib/smith/tool.rb +71 -12
  87. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  88. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  89. data/lib/smith/version.rb +2 -2
  90. data/lib/smith/workflow/agent_result.rb +4 -3
  91. data/lib/smith/workflow/artifact_integration.rb +2 -1
  92. data/lib/smith/workflow/composite/effects.rb +3 -1
  93. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  94. data/lib/smith/workflow/execution.rb +2 -9
  95. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  96. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  97. data/lib/smith/workflow/failure_record.rb +139 -0
  98. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  99. data/lib/smith/workflow/failure_record_text.rb +83 -0
  100. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  101. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  102. data/lib/smith/workflow/parallel.rb +14 -3
  103. data/lib/smith/workflow/persistence.rb +22 -19
  104. data/lib/smith/workflow/retry_execution.rb +5 -1
  105. data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
  106. data/lib/smith/workflow/transition.rb +9 -4
  107. data/lib/smith/workflow/usage_entry.rb +12 -2
  108. data/lib/smith/workflow.rb +17 -130
  109. data/lib/smith.rb +3 -0
  110. metadata +78 -11
@@ -15,7 +15,12 @@ module Smith
15
15
  module_function
16
16
 
17
17
  def run(report)
18
- uncovered = uncovered_models
18
+ uncovered, ambiguous = classified_model_references
19
+ report_ambiguous_models(report, ambiguous)
20
+ report_model_coverage(report, uncovered)
21
+ end
22
+
23
+ def report_model_coverage(report, uncovered)
19
24
  if uncovered.empty?
20
25
  report.add(
21
26
  name: "models.coverage",
@@ -36,23 +41,65 @@ module Smith
36
41
  end
37
42
  end
38
43
 
44
+ # An unqualified agent model id registered under more than one
45
+ # provider cannot resolve to a single profile: chat construction
46
+ # fails closed with AmbiguousProfileError. Doctor must report that
47
+ # configuration instead of crashing on it.
48
+ def report_ambiguous_models(report, ambiguous)
49
+ return if ambiguous.empty?
50
+
51
+ descriptions = ambiguous.map { |reference| ambiguous_reference_description(reference) }
52
+ report.add(
53
+ name: "models.ambiguity",
54
+ status: :fail,
55
+ message: "#{ambiguous.size} agent model id(s) match profiles from multiple registered providers",
56
+ detail: "Ambiguous: #{descriptions.join("; ")}. Chat construction fails closed for " \
57
+ "these agents until each declares an explicit provider (for example " \
58
+ "model \"gpt-5\", provider: :openai) selecting exactly one registered profile."
59
+ )
60
+ end
61
+
62
+ def ambiguous_reference_description(reference)
63
+ "#{reference.model_id} (providers: #{registered_providers_for(reference).join(", ")})"
64
+ end
65
+
66
+ def registered_providers_for(reference)
67
+ Smith::Models.all
68
+ .select { |profile| profile.model_id == reference.model_id }
69
+ .map { |profile| profile.provider.to_s }
70
+ end
71
+
39
72
  # Walk Smith::Agent::Registry. For each agent, extract every static
40
73
  # model id Smith can know at boot: the primary `model "..."` value and
41
74
  # any static fallback models. Block-form primary models are skipped
42
75
  # because they resolve per-attempt, but their static fallbacks still
43
76
  # need coverage checks.
44
77
  # Check whether find_or_infer returns a custom (non-default)
45
- # Profile meaning either an explicit override or an inference
46
- # rule matched.
47
- def uncovered_models
48
- return [] unless defined?(Smith::Agent::Registry)
78
+ # Profile, meaning either an explicit override or an inference
79
+ # rule matched. Returns [uncovered, ambiguous] reference lists.
80
+ def classified_model_references
81
+ return [[], []] unless defined?(Smith::Agent::Registry)
49
82
 
50
- static_model_ids.uniq.reject { |model_id| covered_model?(model_id) }
83
+ uncovered = []
84
+ ambiguous = []
85
+ static_model_references.uniq(&:key).each do |reference|
86
+ case model_coverage(reference)
87
+ when :uncovered then uncovered << reference
88
+ when :ambiguous then ambiguous << reference
89
+ end
90
+ end
91
+ [uncovered, ambiguous]
92
+ end
93
+
94
+ def model_coverage(reference)
95
+ covered_model?(reference) ? :covered : :uncovered
96
+ rescue Smith::Models::AmbiguousProfileError
97
+ :ambiguous
51
98
  end
52
99
 
53
- def static_model_ids
54
- Smith::Agent::Registry.each.with_object([]) do |(_key, agent), ids|
55
- ids.concat(static_model_ids_for(agent)) if inspectable_agent?(agent)
100
+ def static_model_references
101
+ Smith::Agent::Registry.each.with_object([]) do |(_key, agent), references|
102
+ references.concat(static_model_references_for(agent)) if inspectable_agent?(agent)
56
103
  end
57
104
  end
58
105
 
@@ -60,16 +107,27 @@ module Smith
60
107
  agent.is_a?(Class) && agent.respond_to?(:chat_kwargs)
61
108
  end
62
109
 
63
- def static_model_ids_for(agent)
64
- [
65
- agent.chat_kwargs[:model],
66
- *(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil)
67
- ].compact
110
+ def static_model_references_for(agent)
111
+ primary = agent.chat_kwargs[:model]
112
+ references = Array(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil).dup
113
+ if primary
114
+ references.unshift(
115
+ Smith::Agent::ModelReference.coerce(primary, provider: agent.chat_kwargs[:provider])
116
+ )
117
+ end
118
+ references
119
+ end
120
+
121
+ def covered_model?(reference)
122
+ Smith::Models.find(reference.model_id, provider: reference.provider) ||
123
+ inferred_profile_matches?(reference)
68
124
  end
69
125
 
70
- def covered_model?(model_id)
71
- Smith::Models.find(model_id) ||
72
- (defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
126
+ def inferred_profile_matches?(reference)
127
+ return false unless defined?(Smith::Models::Inference)
128
+
129
+ profile = Smith::Models::Inference.profile_for(reference.model_id)
130
+ profile && (reference.provider.nil? || profile.provider.to_sym == reference.provider)
73
131
  end
74
132
  end
75
133
  end
data/lib/smith/errors.rb CHANGED
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "error"
4
+ require_relative "persisted_failure_invalid"
5
+ require_relative "pricing_configuration_error"
6
+ require_relative "provider_permanent_failure"
7
+ require_relative "tool_capture_failed"
8
+ require_relative "tool_execution_not_admitted"
9
+ require_relative "tool_failure_notification_failed"
4
10
 
5
11
  module Smith
6
12
  # Classification surface for host retry policies. Smith owns the
7
13
  # answer to "should the workflow attempt be retried?" so consumers
8
14
  # don't reimplement the case statement in every Execution / Job.
9
15
  module Errors
16
+ MODULE_MATCH = Module.instance_method(:===)
17
+ private_constant :MODULE_MATCH
18
+
10
19
  # Returns true when the host should retry the workflow attempt.
11
20
  # AgentError + DeadlineExceeded are always retryable.
12
21
  # DeterministicStepFailure + ToolGuardrailFailed honor their
@@ -16,7 +25,7 @@ module Smith
16
25
  return false if error.nil?
17
26
 
18
27
  composite_failure = defined?(Smith::Workflow::Composite::BranchFailure) &&
19
- error.is_a?(Smith::Workflow::Composite::BranchFailure)
28
+ MODULE_MATCH.bind_call(Smith::Workflow::Composite::BranchFailure, error)
20
29
  return false if composite_failure
21
30
 
22
31
  case error
@@ -29,6 +38,27 @@ module Smith
29
38
  end
30
39
  end
31
40
 
41
+ def self.retry_forbidden?(error)
42
+ return false if error.nil?
43
+
44
+ retry_forbidden_classes.any? { |error_class| MODULE_MATCH.bind_call(error_class, error) }
45
+ end
46
+
47
+ def self.retry_forbidden_class?(error_class)
48
+ retry_forbidden_classes.any? { |forbidden| error_class <= forbidden }
49
+ end
50
+
51
+ def self.retry_forbidden_classes
52
+ @retry_forbidden_classes ||= [
53
+ Smith::ToolCaptureFailed,
54
+ Smith::ToolOutcomeUncertain,
55
+ Smith::ToolExecutionNotAdmitted,
56
+ Smith::ToolFailureNotificationFailed,
57
+ Smith::BoundedCompletionError,
58
+ Smith::PersistedFailureInvalid
59
+ ].freeze
60
+ end
61
+
32
62
  # Always-retryable error classes for explicit ActiveJob retry_on
33
63
  # allow-lists. Excludes the retryable-bearing families because
34
64
  # their retryability is per-raise, not per-class.
@@ -52,6 +82,9 @@ module Smith
52
82
  end
53
83
 
54
84
  class ToolPolicyDenied < Error; end
85
+ class ToolDispatchRejected < Error; end
86
+ class BoundedCompletionError < Error; end
87
+ class ToolOutcomeUncertain < Error; end
55
88
  class AgentError < Error; end
56
89
 
57
90
  class BlankAgentOutputError < AgentError
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+
5
+ module Smith
6
+ module Models
7
+ class AmbiguousProfileError < Smith::Error; end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+
5
+ module Smith
6
+ module Models
7
+ class CollisionError < Smith::Error; end
8
+ end
9
+ end
@@ -82,6 +82,20 @@ module Smith
82
82
  [
83
83
  # ----- Anthropic -----
84
84
 
85
+ # Claude 5+ families (Fable/Mythos/Opus/Sonnet/Haiku): adaptive
86
+ # thinking, no temperature accepted (the API rejects an explicit
87
+ # temperature with a 400).
88
+ Rule.new(
89
+ provider: :anthropic,
90
+ matcher: lambda { |id|
91
+ m = id.match(/\Aclaude-(?:fable|mythos|opus|sonnet|haiku)-(\d+)/)
92
+ m && m[1].to_i >= 5
93
+ },
94
+ thinking_shape: :adaptive,
95
+ accepts_temperature: false,
96
+ tools_with_thinking_native: true,
97
+ tools_with_thinking_route: nil
98
+ ),
85
99
  # Opus 4.7+: adaptive thinking, no temperature accepted.
86
100
  Rule.new(
87
101
  provider: :anthropic,
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "dry-initializer"
4
4
 
5
+ require_relative "tool_routing"
6
+
5
7
  module Smith
6
8
  module Models
7
9
  # Per-chat-construction request shaper. Mutates a RubyLLM::Chat
@@ -96,73 +98,11 @@ module Smith
96
98
  end
97
99
 
98
100
  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
101
+ ToolRouting.new(
102
+ chat: chat,
103
+ profile: profile,
104
+ decision_recorder: method(:record_decision)
105
+ ).call
166
106
  end
167
107
 
168
108
  # with_params REPLACES @params in RubyLLM (chat.rb:96), so the
@@ -173,6 +113,10 @@ module Smith
173
113
  chat.with_params(**existing, **new_params)
174
114
  end
175
115
 
116
+ def record_decision(kind, model_id, detail)
117
+ @decisions << Decision.new(kind: kind, model_id: model_id, detail: detail)
118
+ end
119
+
176
120
  def emit_trace
177
121
  return if @decisions.empty?
178
122
  return unless defined?(Smith::Trace)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Smith
6
+ module Models
7
+ module ProviderQualifiedRegistry
8
+ def self.extended(registry)
9
+ registry.instance_variable_set(:@registry_monitor, Monitor.new)
10
+ registry.instance_variable_set(:@registry_index, {})
11
+ end
12
+
13
+ def find(model_id, provider: nil)
14
+ registry_monitor.synchronize do
15
+ registered_keys = registered_keys_for(model_id, provider:)
16
+ return nil if registered_keys.empty?
17
+
18
+ reject_ambiguous_lookup!(model_id, registered_keys)
19
+ resolve(registered_keys.first)
20
+ end
21
+ end
22
+
23
+ def register(profile)
24
+ registry_monitor.synchronize do
25
+ profile = normalized_profile(profile)
26
+ key = registry_key(profile.model_id, profile.provider)
27
+ existing = key?(key) ? resolve(key) : nil
28
+
29
+ return profile if existing == profile
30
+
31
+ raise_collision!(key, existing, profile) if existing
32
+
33
+ super(key, profile)
34
+ index_profile(profile, key)
35
+ profile
36
+ end
37
+ end
38
+
39
+ def all
40
+ registry_monitor.synchronize do
41
+ keys.map { |key| resolve(key) }.sort_by do |profile|
42
+ [profile.model_id, profile.provider.to_s]
43
+ end
44
+ end
45
+ end
46
+
47
+ def clear!
48
+ registry_monitor.synchronize do
49
+ @_container&.clear
50
+ @registry_index = {}
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :registry_monitor, :registry_index
57
+
58
+ def registered_keys_for(model_id, provider:)
59
+ normalized_id = normalize_key(model_id)
60
+ return registry_index.fetch(normalized_id, []) unless provider
61
+
62
+ key = registry_key(normalized_id, provider)
63
+ key?(key) ? [key] : []
64
+ end
65
+
66
+ def registry_key(model_id, provider)
67
+ [provider.to_sym, normalize_key(model_id)]
68
+ end
69
+
70
+ def reject_ambiguous_lookup!(model_id, registered_keys)
71
+ return unless registered_keys.length > 1
72
+
73
+ providers = registered_keys.map { |key| resolve(key).provider.to_s }.sort
74
+ raise AmbiguousProfileError,
75
+ "model #{normalize_key(model_id).inspect} has profiles for multiple providers: " \
76
+ "#{providers.join(", ")}; pass provider:"
77
+ end
78
+
79
+ def index_profile(profile, key)
80
+ model_id = normalize_key(profile.model_id)
81
+ registry_index[model_id] = [*registry_index.fetch(model_id, []), key].uniq.freeze
82
+ end
83
+
84
+ def normalized_profile(profile)
85
+ Profile.new(
86
+ **profile.to_h,
87
+ model_id: normalize_key(profile.model_id).freeze,
88
+ provider: profile.provider.to_sym
89
+ )
90
+ rescue NoMethodError, TypeError
91
+ raise ArgumentError, "model profile must expose model_id, provider, and profile attributes"
92
+ end
93
+
94
+ def raise_collision!(key, existing, replacement)
95
+ raise CollisionError,
96
+ "model profile collision for #{key.inspect}: existing #{existing.inspect}, " \
97
+ "replacement #{replacement.inspect}"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ module Models
7
+ class ToolRouting
8
+ TOOL_ENDPOINTS = %i[chat_completions responses].freeze
9
+
10
+ private_constant :TOOL_ENDPOINTS
11
+
12
+ extend Dry::Initializer
13
+
14
+ option :chat
15
+ option :profile
16
+ option :decision_recorder
17
+
18
+ def call
19
+ return unless chat.respond_to?(:tools)
20
+
21
+ tools = chat.tools.values
22
+ return if tools.empty?
23
+
24
+ route_tools_with_thinking if thinking_active?
25
+ route_tools_for_compatibility(tools)
26
+ drop_incompatible_tools(tools)
27
+ end
28
+
29
+ private
30
+
31
+ def route_tools_with_thinking
32
+ return unless profile.provider == :openai
33
+ return if profile.tools_with_thinking_native
34
+ return unless profile.tools_with_thinking_route == :responses
35
+ return unless Smith.config.openai_api_mode == :auto
36
+
37
+ route_via_responses
38
+ end
39
+
40
+ def route_tools_for_compatibility(tools)
41
+ return unless profile.provider == :openai
42
+ return unless Smith.config.openai_api_mode == :auto
43
+ return if responses_routed?
44
+
45
+ forced_endpoint = forced_tool_endpoint(tools)
46
+ return if forced_endpoint == :chat_completions
47
+ return unless forced_endpoint == :responses ||
48
+ compatible_tool_count(tools, :responses) > compatible_tool_count(tools, :chat_completions)
49
+
50
+ route_via_responses
51
+ end
52
+
53
+ def route_via_responses
54
+ return if responses_routed?
55
+
56
+ merge_params(openai_api_mode: :responses)
57
+ record(:routed_via_responses)
58
+ end
59
+
60
+ def drop_incompatible_tools(tools)
61
+ endpoint = responses_routed? ? :responses : :chat_completions
62
+ incompatible = tools.reject { |tool| tool_compatible_with?(tool, endpoint) }
63
+ return if incompatible.empty?
64
+
65
+ remaining = tools - incompatible
66
+ forced_choice = reset_invalid_tool_choice(remaining)
67
+ chat.with_tools(*remaining, replace: true)
68
+ incompatible.each_with_index do |tool, index|
69
+ record(:tool_dropped, dropped_tool_detail(tool, forced_choice, first: index.zero?))
70
+ end
71
+ end
72
+
73
+ def dropped_tool_detail(tool, forced_choice, first:)
74
+ detail = { tool: tool.class.name }
75
+ if forced_choice && (tool.name.to_sym == forced_choice.to_sym || (forced_choice == :required && first))
76
+ detail[:forced_choice_reset] = forced_choice
77
+ end
78
+ detail
79
+ end
80
+
81
+ def forced_tool_endpoint(tools)
82
+ choice = chat.tool_prefs[:choice]&.to_sym
83
+ return if %i[auto none required].include?(choice)
84
+
85
+ tool = tools.find { |candidate| candidate.name.to_sym == choice }
86
+ return unless tool
87
+
88
+ compatible_endpoints = TOOL_ENDPOINTS.select { |endpoint| tool_compatible_with?(tool, endpoint) }
89
+ compatible_endpoints.first if compatible_endpoints.one?
90
+ end
91
+
92
+ def reset_invalid_tool_choice(remaining)
93
+ choice = chat.tool_prefs[:choice]
94
+ return unless choice
95
+ return if %i[auto none].include?(choice)
96
+ return if choice == :required && remaining.any?
97
+ return if remaining.any? { |tool| tool.name.to_sym == choice.to_sym }
98
+
99
+ chat.tool_prefs[:choice] = nil
100
+ choice
101
+ end
102
+
103
+ def compatible_tool_count(tools, endpoint)
104
+ tools.count { |tool| tool_compatible_with?(tool, endpoint) }
105
+ end
106
+
107
+ def tool_compatible_with?(tool, endpoint)
108
+ return true unless defined?(Smith::Tool::Compatibility)
109
+
110
+ spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
111
+ Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: endpoint)
112
+ end
113
+
114
+ def thinking_active?
115
+ thinking = chat.instance_variable_get(:@thinking)
116
+ return true if thinking&.enabled?
117
+
118
+ current_params.key?(:thinking) || current_params.key?(:reasoning) ||
119
+ current_params.key?(:reasoning_effort)
120
+ end
121
+
122
+ def responses_routed?
123
+ mode = current_params[:openai_api_mode] || current_params["openai_api_mode"]
124
+ mode.to_s == "responses"
125
+ end
126
+
127
+ def current_params
128
+ chat.instance_variable_get(:@params) || {}
129
+ end
130
+
131
+ def merge_params(**new_params)
132
+ chat.with_params(**current_params, **new_params)
133
+ end
134
+
135
+ def record(kind, detail = nil)
136
+ decision_recorder.call(kind, profile.model_id, detail)
137
+ end
138
+ end
139
+ end
140
+ end