smith-agents 0.7.0 → 0.10.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +456 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +27 -6
  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 +43 -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 +128 -0
  13. data/lib/smith/agent/lifecycle.rb +8 -275
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +34 -0
  16. data/lib/smith/agent/provider_call_timing.rb +74 -0
  17. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  18. data/lib/smith/agent/provider_completion.rb +148 -0
  19. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  20. data/lib/smith/agent/provider_usage.rb +28 -0
  21. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  22. data/lib/smith/agent/usage_entry_recording.rb +66 -0
  23. data/lib/smith/agent/usage_traces.rb +49 -0
  24. data/lib/smith/agent/usage_tracking.rb +116 -0
  25. data/lib/smith/agent.rb +29 -197
  26. data/lib/smith/attribution.rb +115 -0
  27. data/lib/smith/context/observation_masking.rb +28 -4
  28. data/lib/smith/context/session.rb +7 -2
  29. data/lib/smith/diagnostic_text.rb +67 -0
  30. data/lib/smith/doctor/checks/configuration.rb +16 -3
  31. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  32. data/lib/smith/errors.rb +34 -1
  33. data/lib/smith/event.rb +10 -2
  34. data/lib/smith/events/bus.rb +63 -5
  35. data/lib/smith/events/step_completed.rb +3 -0
  36. data/lib/smith/events/step_failed.rb +25 -0
  37. data/lib/smith/events/subscription.rb +8 -0
  38. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  39. data/lib/smith/models/collision_error.rb +9 -0
  40. data/lib/smith/models/inference.rb +14 -0
  41. data/lib/smith/models/normalizer.rb +11 -67
  42. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  43. data/lib/smith/models/tool_routing.rb +140 -0
  44. data/lib/smith/models.rb +24 -78
  45. data/lib/smith/persisted_failure_invalid.rb +7 -0
  46. data/lib/smith/pricing.rb +138 -17
  47. data/lib/smith/pricing_configuration_error.rb +7 -0
  48. data/lib/smith/provider_permanent_failure.rb +16 -0
  49. data/lib/smith/tool/argument_container_reader.rb +97 -0
  50. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  51. data/lib/smith/tool/argument_snapshot.rb +19 -0
  52. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  53. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  54. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  55. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  56. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  57. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  58. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  59. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  60. data/lib/smith/tool/budget_enforcement.rb +15 -7
  61. data/lib/smith/tool/call_admission.rb +44 -0
  62. data/lib/smith/tool/call_allowance.rb +101 -28
  63. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  64. data/lib/smith/tool/call_batch.rb +47 -0
  65. data/lib/smith/tool/call_budget.rb +51 -0
  66. data/lib/smith/tool/call_reservation.rb +42 -0
  67. data/lib/smith/tool/capture.rb +9 -1
  68. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  69. data/lib/smith/tool/chat_execution_context.rb +7 -88
  70. data/lib/smith/tool/execution_authority.rb +42 -0
  71. data/lib/smith/tool/execution_authorization.rb +17 -0
  72. data/lib/smith/tool/execution_batch.rb +100 -0
  73. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  74. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  75. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  76. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  77. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  78. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  79. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  80. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  81. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  82. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  83. data/lib/smith/tool/execution_batch_state.rb +110 -0
  84. data/lib/smith/tool/execution_dispatch.rb +78 -0
  85. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  86. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  87. data/lib/smith/tool/execution_tracker.rb +20 -0
  88. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  89. data/lib/smith/tool/graceful_completion.rb +20 -0
  90. data/lib/smith/tool/invocation.rb +27 -0
  91. data/lib/smith/tool/invocation_request.rb +37 -0
  92. data/lib/smith/tool/invocation_sequence.rb +26 -0
  93. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  94. data/lib/smith/tool/scoped_context.rb +60 -8
  95. data/lib/smith/tool.rb +81 -13
  96. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  97. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  98. data/lib/smith/trace/logger.rb +1 -0
  99. data/lib/smith/trace/memory.rb +40 -4
  100. data/lib/smith/trace/open_telemetry.rb +47 -3
  101. data/lib/smith/trace.rb +24 -5
  102. data/lib/smith/version.rb +2 -2
  103. data/lib/smith/workflow/agent_result.rb +4 -3
  104. data/lib/smith/workflow/artifact_integration.rb +2 -1
  105. data/lib/smith/workflow/composite/effects.rb +64 -31
  106. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  107. data/lib/smith/workflow/deterministic_execution.rb +2 -1
  108. data/lib/smith/workflow/deterministic_step.rb +14 -1
  109. data/lib/smith/workflow/dsl.rb +25 -1
  110. data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
  111. data/lib/smith/workflow/event_integration.rb +48 -1
  112. data/lib/smith/workflow/execution.rb +10 -9
  113. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  114. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  115. data/lib/smith/workflow/failure_record.rb +139 -0
  116. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  117. data/lib/smith/workflow/failure_record_text.rb +83 -0
  118. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  119. data/lib/smith/workflow/fanout_execution.rb +1 -1
  120. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  121. data/lib/smith/workflow/guardrail_integration.rb +25 -0
  122. data/lib/smith/workflow/parallel.rb +14 -3
  123. data/lib/smith/workflow/parallel_execution.rb +6 -0
  124. data/lib/smith/workflow/persistence.rb +55 -20
  125. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  126. data/lib/smith/workflow/retry_execution.rb +5 -1
  127. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  128. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  129. data/lib/smith/workflow/step_completion.rb +9 -0
  130. data/lib/smith/workflow/step_context.rb +46 -0
  131. data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
  132. data/lib/smith/workflow/transition.rb +9 -4
  133. data/lib/smith/workflow/usage_entry.rb +43 -5
  134. data/lib/smith/workflow.rb +43 -131
  135. data/lib/smith.rb +10 -2
  136. metadata +82 -11
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
data/lib/smith/event.rb CHANGED
@@ -3,9 +3,17 @@
3
3
  require "dry-struct"
4
4
  require "securerandom"
5
5
 
6
+ require_relative "attribution"
7
+
6
8
  module Smith
7
9
  class Event < Dry::Struct
8
- attribute(:execution_id, Types::String.default { SecureRandom.uuid })
9
- attribute(:trace_id, Types::String.default { SecureRandom.uuid })
10
+ # Both ids default to the ambient Smith::Attribution execution key, so
11
+ # events emitted under one persisted run (or one host-seeded
12
+ # Attribution scope) share one identity a host can group by. Outside
13
+ # any scope, each event falls back to its own random UUID, so
14
+ # non-persisted, unseeded runs have no shared event identity. Callers
15
+ # may always pass explicit values.
16
+ attribute(:execution_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
17
+ attribute(:trace_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
10
18
  end
11
19
  end
@@ -18,19 +18,43 @@ module Smith
18
18
  end
19
19
  end
20
20
 
21
+ REGISTRY_MUTEX = Mutex.new
22
+ private_constant :REGISTRY_MUTEX
23
+
21
24
  class << self
25
+ # Registration-ordered snapshot of the live subscriptions. Cancelled
26
+ # subscriptions are detached from the registry, so this reflects only
27
+ # what will actually receive events.
22
28
  def subscriptions
23
- @subscriptions ||= []
29
+ snapshot = REGISTRY_MUTEX.synchronize { registry.values.flatten }
30
+ snapshot.sort_by!(&:sequence_number)
24
31
  end
25
32
 
26
33
  def on(event_class, **opts, &block)
27
34
  sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
28
- subscriptions << sub
35
+ REGISTRY_MUTEX.synchronize do
36
+ @sequence = (@sequence || 0) + 1
37
+ sub.sequence_number = @sequence
38
+ (registry[event_class] ||= []) << sub
39
+ end
29
40
  sub
30
41
  end
31
42
 
43
+ # Removes a subscription from the registry. Called by
44
+ # Subscription#cancel; safe to call more than once.
45
+ def detach(subscription)
46
+ REGISTRY_MUTEX.synchronize do
47
+ bucket = registry[subscription.event_class]
48
+ next unless bucket
49
+
50
+ bucket.delete(subscription)
51
+ registry.delete(subscription.event_class) if bucket.empty?
52
+ end
53
+ nil
54
+ end
55
+
32
56
  def emit(event)
33
- subscriptions.each { |sub| dispatch_to(sub, event) }
57
+ matching_subscriptions(event).each { |sub| dispatch_to(sub, event) }
34
58
  end
35
59
 
36
60
  def within
@@ -41,14 +65,48 @@ module Smith
41
65
  end
42
66
 
43
67
  def reset!
44
- @subscriptions = []
68
+ REGISTRY_MUTEX.synchronize do
69
+ @registry = {}
70
+ @sequence = 0
71
+ end
45
72
  end
46
73
 
47
74
  private
48
75
 
76
+ def registry
77
+ @registry ||= {}
78
+ end
79
+
80
+ # Subscriptions live in per-class buckets so one emit touches only the
81
+ # buckets for the event's ancestors instead of scanning every
82
+ # subscription; `is_a?` dispatch semantics are preserved exactly
83
+ # because a subscription matches iff its registered class or module is
84
+ # among the event class's ancestors. The merged candidates are ordered
85
+ # by registration sequence, keeping subscription-order dispatch.
86
+ # Handlers run outside the registry lock so a handler may subscribe or
87
+ # cancel without deadlocking.
88
+ def matching_subscriptions(event)
89
+ # singleton_class.ancestors, not class.ancestors: it additionally
90
+ # covers modules mixed into the event instance via extend, which
91
+ # `is_a?` matched before the bucketed registry existed. Immediates
92
+ # (Integer, Symbol, Float) have no singleton class; they also cannot
93
+ # be extended, so their class ancestors are the complete `is_a?` set.
94
+ ancestors = begin
95
+ event.singleton_class.ancestors
96
+ rescue TypeError
97
+ event.class.ancestors
98
+ end
99
+ candidates = REGISTRY_MUTEX.synchronize do
100
+ ancestors.each_with_object([]) do |ancestor, found|
101
+ bucket = registry[ancestor]
102
+ found.concat(bucket) if bucket
103
+ end
104
+ end
105
+ candidates.sort_by!(&:sequence_number)
106
+ end
107
+
49
108
  def dispatch_to(sub, event)
50
109
  return if sub.cancelled?
51
- return unless event.is_a?(sub.event_class)
52
110
  return if sub.predicate && !sub.predicate.call(event)
53
111
 
54
112
  sub.handler.call(event)
@@ -6,6 +6,9 @@ module Smith
6
6
  attribute :transition, Types::Strict::Symbol
7
7
  attribute :from, Types::Strict::Symbol.optional
8
8
  attribute :to, Types::Strict::Symbol
9
+ # The emitting workflow's class name; distinguishes nested-child steps
10
+ # from parent steps under the shared root execution identity.
11
+ attribute :workflow, Types::Strict::String.optional.default(nil)
9
12
  end
10
13
  end
11
14
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events
5
+ # Emitted from both step-failure paths (step-body failure and an
6
+ # unresolved transition routed to :fail), closing the success-only
7
+ # observation gap for failures that reach step handling. Terminal errors
8
+ # raised outside it (an unresolved transition with no :fail transition,
9
+ # transition-budget exhaustion, origin validation) still re-raise
10
+ # without a StepFailed. Carries bounded classification only, never raw
11
+ # error messages; `error_family` uses FailureRecord's taxonomy
12
+ # ("agent_error", "deadline_exceeded", ..., "other") and `retryable` is
13
+ # nil when the error does not declare retryability.
14
+ class StepFailed < Smith::Event
15
+ attribute :transition, Types::Strict::Symbol
16
+ attribute :from, Types::Strict::Symbol.optional
17
+ attribute :to, Types::Strict::Symbol.optional
18
+ attribute :error_class, Types::Strict::String
19
+ attribute :error_family, Types::Strict::String
20
+ attribute :retryable, Types::Strict::Bool.optional
21
+ # See StepCompleted#workflow.
22
+ attribute :workflow, Types::Strict::String.optional.default(nil)
23
+ end
24
+ end
25
+ end
@@ -4,6 +4,9 @@ module Smith
4
4
  module Events
5
5
  class Subscription
6
6
  attr_reader :event_class, :handler, :predicate
7
+ # Internal registration order, assigned by Events.on; dispatch and the
8
+ # subscriptions snapshot sort by it.
9
+ attr_accessor :sequence_number
7
10
 
8
11
  def initialize(event_class, handler:, predicate: nil)
9
12
  @event_class = event_class
@@ -12,8 +15,13 @@ module Smith
12
15
  @cancelled = false
13
16
  end
14
17
 
18
+ # Cancelling flags the subscription and detaches it from the registry
19
+ # (so it cannot leak). The flag check against an in-flight emit
20
+ # snapshot is best-effort: a dispatch already past the check may still
21
+ # deliver once after cancel returns.
15
22
  def cancel
16
23
  @cancelled = true
24
+ Events.detach(self)
17
25
  end
18
26
 
19
27
  def cancelled?
@@ -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