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
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "provider_completion"
4
+ require_relative "usage_tracking"
5
+
3
6
  module Smith
4
7
  class Agent
5
8
  module Lifecycle
6
- WORKFLOW_CONTINUATION_MESSAGE =
7
- "Use the preceding assistant result as input and perform your assigned workflow step."
8
- private_constant :WORKFLOW_CONTINUATION_MESSAGE
9
-
10
- TRANSIENT_ERRORS = [
11
- RubyLLM::ServerError, RubyLLM::ServiceUnavailableError,
12
- RubyLLM::OverloadedError, RubyLLM::RateLimitError
13
- ].freeze
9
+ include ProviderCompletion
10
+ include UsageTracking
14
11
 
15
12
  private
16
13
 
@@ -23,274 +20,8 @@ module Smith
23
20
 
24
21
  def invoke_agent(agent_class, prepared_input, output_schema: agent_class.output_schema)
25
22
  check_deadline!
26
- response, model_used = complete_with_provider(agent_class, prepared_input, output_schema:)
27
- snapshot_and_finalize(agent_class, response, model_used)
28
- end
29
-
30
- # Returns [response, model_used] as local data — no shared mutable
31
- # state. Previously this method set `@last_attempt_model` on the
32
- # workflow instance and `snapshot_and_finalize` read it back; under
33
- # parallel fan-out, two branches sharing the workflow could race
34
- # and attribute the wrong model to the wrong response. Local data
35
- # eliminates the race entirely.
36
- def complete_with_provider(agent_class, prepared_input, output_schema:)
37
- models = build_model_chain(agent_class)
38
-
39
- models.each_with_index do |model_id, index|
40
- check_deadline! if index.positive?
41
- response = attempt_model(agent_class, prepared_input, model_id, output_schema:)
42
- return [response, model_id]
43
- rescue Smith::Error
44
- raise
45
- rescue StandardError => e
46
- account_failed_attempt(e, model_id, agent_class)
47
- raise Smith::AgentError, e.message unless fallback_eligible?(e) && index < models.length - 1
48
- end
49
- end
50
-
51
- def build_model_chain(agent_class)
52
- primary = if agent_class.respond_to?(:model_block) && agent_class.model_block
53
- resolve_dynamic_model(agent_class)
54
- else
55
- agent_class.chat_kwargs[:model]
56
- end
57
- fallbacks = agent_class.fallback_models || []
58
- [primary, *fallbacks].compact
59
- end
60
-
61
- # Evaluates a block-form `model` declaration with the workflow's
62
- # @context (Hash, defaults to {} when uninitialized). The block
63
- # must return a non-empty string model id; any other value
64
- # surfaces as Smith::AgentError so the workflow's failure handler
65
- # treats it as a step failure rather than a silent miss.
66
- def resolve_dynamic_model(agent_class)
67
- result = agent_class.model_block.call(@context || {})
68
- return result if result.is_a?(String) && !result.empty?
69
-
70
- raise Smith::AgentError,
71
- "model block for #{agent_class} must return a non-empty string; got #{result.inspect}"
72
- end
73
-
74
- def attempt_model(agent_class, prepared_input, model_id, output_schema:)
75
- chat = agent_class.chat(model: model_id, **bridge_workflow_inputs(agent_class))
76
- add_prepared_input(chat, prepared_input)
77
- chat = chat.with_schema(output_schema) if output_schema
78
- chat.complete
79
- end
80
-
81
- # Bridges declared agent `inputs` from the workflow's @context Hash
82
- # to the agent invocation kwargs, so block-form RubyLLM DSLs (tools,
83
- # instructions, params, headers, schema) can access workflow-context
84
- # data via bare method calls on `self` inside the block (RubyLLM
85
- # invokes these via `runtime.instance_exec(&block)`, exposing each
86
- # declared input as a singleton method on the runtime object).
87
- # Smith's own `model` block-form already receives @context directly
88
- # via `block.call(@context)`; this bridge gives runtime_context the
89
- # same surface for the RubyLLM-owned blocks.
90
- #
91
- # Bridges ONLY user-declared inputs — reserved names
92
- # (Smith::Agent::RESERVED_INPUT_NAMES: model_id, provider,
93
- # endpoint_mode) are auto-injected by Smith::Agent.chat from the
94
- # resolved profile, NOT from @context. The slice prevents the bridge
95
- # from accidentally passing through stale or wrong values that
96
- # happen to live in @context under those keys.
97
- #
98
- # Contract: declared inputs are ALWAYS passed (with nil when absent
99
- # from @context). The declaration is the contract — `inputs :form_kind`
100
- # promises that `form_kind` will be a callable singleton method on
101
- # the runtime regardless of whether @context happens to have a value.
102
- # This eliminates `respond_to?` defensiveness in agent blocks and
103
- # mirrors the silent-nil semantics agent authors get from `ctx[:k]`
104
- # in the model block. Non-Hash @context short-circuits.
105
- def bridge_workflow_inputs(agent_class)
106
- return {} unless @context.is_a?(Hash)
107
-
108
- declared = agent_class.inputs || []
109
- user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
110
- user_declared.to_h do |name|
111
- [name, @context[name]]
112
- end
113
- end
114
-
115
- def add_prepared_input(chat, prepared_input)
116
- return unless prepared_input
117
-
118
- prepared_input = provider_safe_prepared_input(prepared_input)
119
- system_messages, other_messages = prepared_input.partition do |message|
120
- message_role(message) == :system
121
- end
122
-
123
- merge_system_messages!(chat, system_messages) if system_messages.any?
124
- other_messages.each { |message| add_message(chat, message) }
125
- end
126
-
127
- def provider_safe_prepared_input(prepared_input)
128
- messages = prepared_input.to_a
129
- return messages unless workflow_handoff?(messages)
130
-
131
- messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
132
- end
133
-
134
- def workflow_handoff?(messages)
135
- message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
136
- return false unless message
137
- return false unless message_role(message) == :assistant
138
- return false unless defined?(@last_output) && !@last_output.nil?
139
-
140
- message_content(message) == @last_output
141
- end
142
-
143
- def merge_system_messages!(chat, prepared_system_messages)
144
- return prepared_system_messages.each { |message| add_message(chat, message) } unless chat.respond_to?(:messages)
145
-
146
- existing_system_contents = chat.messages.filter_map do |message|
147
- message.content if message_role(message) == :system
148
- end
149
- prepared_system_contents = prepared_system_messages.filter_map do |message|
150
- message_content(message)
151
- end
152
-
153
- combined_contents = existing_system_contents + prepared_system_contents
154
- return if combined_contents.empty?
155
- unless combined_contents.all?(String)
156
- return prepared_system_messages.each { |message| add_message(chat, message) }
157
- end
158
-
159
- if chat.respond_to?(:with_instructions)
160
- chat.with_instructions(combined_contents.join("\n\n"))
161
- else
162
- prepared_system_messages.each { |message| add_message(chat, message) }
163
- end
164
- end
165
-
166
- def add_message(chat, message)
167
- attributes = if message.is_a?(Hash)
168
- message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
169
- else
170
- message
171
- end
172
- chat.add_message(attributes)
173
- end
174
-
175
- def message_role(message)
176
- message_attribute(message, :role)&.to_sym
177
- end
178
-
179
- def message_content(message)
180
- message_attribute(message, :content)
181
- end
182
-
183
- def message_attribute(message, name)
184
- return message.public_send(name) if message.respond_to?(name)
185
- return message[name] if message.respond_to?(:key?) && message.key?(name)
186
-
187
- message[name.to_s]
188
- end
189
-
190
- def fallback_eligible?(error)
191
- TRANSIENT_ERRORS.any? { |klass| error.is_a?(klass) } ||
192
- error.is_a?(Faraday::TimeoutError) ||
193
- error.is_a?(Faraday::ConnectionFailed)
194
- end
195
-
196
- # `agent_class` is now a parameter (was previously implicit via
197
- # `@last_attempt_model`-only path). The caller (`complete_with_provider`)
198
- # has the local already, so no shared mutable state is needed.
199
- # Records the failed attempt's tokens via the unified `record_usage`
200
- # helper, marking the entry as `:failed_attempt`.
201
- def account_failed_attempt(error, model_id, agent_class)
202
- return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
203
-
204
- input = error.input_tokens
205
- output = error.output_tokens
206
- return unless input.is_a?(Integer) && output.is_a?(Integer)
207
-
208
- cost = Smith::Pricing.compute_cost(model: model_id, input_tokens: input, output_tokens: output)
209
- agent_result = Workflow::AgentResult.new(
210
- content: nil, input_tokens: input, output_tokens: output, cost: cost, model_used: model_id
211
- )
212
- Thread.current[:smith_failed_agent_results] ||= []
213
- Thread.current[:smith_failed_agent_results] << agent_result
214
- record_usage(agent_class, agent_result, :failed_attempt, model_id)
215
- end
216
-
217
- def snapshot_and_finalize(agent_class, response, model_used)
218
- agent_result = Workflow::AgentResult.from_response(response, response&.content, model_used: model_used)
219
- Thread.current[:smith_last_agent_result] = agent_result
220
- emit_token_usage(agent_result)
221
- compute_agent_cost(agent_result)
222
- record_usage(agent_class, agent_result, :completed_attempt, agent_result.model_used)
223
-
224
- agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
225
- raise_blank_output!(agent_class, agent_result)
226
- agent_result
227
- end
228
-
229
- def raise_blank_output!(agent_class, agent_result)
230
- return unless blank_agent_output?(agent_result.content)
231
-
232
- raise Smith::BlankAgentOutputError.new(
233
- agent_name: agent_class.register_as,
234
- model_used: agent_result.model_used
235
- )
236
- end
237
-
238
- def blank_agent_output?(content)
239
- return true if content.nil?
240
- return content.strip.empty? if content.is_a?(String)
241
-
242
- false
243
- end
244
-
245
- def emit_token_usage(agent_result)
246
- return unless agent_result.usage_known?
247
-
248
- Smith::Trace.record(
249
- type: :token_usage,
250
- data: { input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens }
251
- )
252
- end
253
-
254
- def compute_agent_cost(agent_result)
255
- return unless agent_result.usage_known?
256
-
257
- model = agent_result.model_used
258
- agent_result.cost = Smith::Pricing.compute_cost(
259
- model: model, input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens
260
- )
261
- end
262
-
263
- # Single critical section: all three of `@total_tokens`,
264
- # `@total_cost`, and `@usage_entries` update under one mutex
265
- # acquisition. Replaces the prior `accumulate_usage` which took
266
- # the mutex twice (once for tokens, once for cost) — under
267
- # parallel fan-out two branches could interleave between those
268
- # blocks, leaving totals momentarily inconsistent. Adding the
269
- # entry append in a third pass would have widened the window;
270
- # one pass closes it entirely.
271
- #
272
- # `@usage_mutex` is eagerly initialized in `Workflow#initialize`
273
- # AND `Workflow#restore_state` (since `from_state` allocates
274
- # without `initialize`), so it's always present here.
275
- def record_usage(agent_class, agent_result, attempt_kind, model_id)
276
- return unless agent_result.usage_known?
277
-
278
- entry = Workflow::UsageEntry.new(
279
- usage_id: SecureRandom.uuid,
280
- agent_name: agent_class.register_as,
281
- model: model_id,
282
- input_tokens: agent_result.input_tokens,
283
- output_tokens: agent_result.output_tokens,
284
- cost: agent_result.cost,
285
- attempt_kind: attempt_kind,
286
- recorded_at: Time.now.utc.iso8601
287
- )
288
-
289
- @usage_mutex.synchronize do
290
- @total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
291
- @total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
292
- @usage_entries << entry
293
- end
23
+ completion, model_used = complete_with_provider(agent_class, prepared_input, output_schema:)
24
+ snapshot_and_finalize(agent_class, completion, model_used)
294
25
  end
295
26
  end
296
27
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "ruby_llm"
5
+
6
+ require_relative "../types"
7
+
8
+ module Smith
9
+ class Agent < RubyLLM::Agent
10
+ class ModelReference < Dry::Struct
11
+ ModelId = Types::String.constructor do |value|
12
+ normalized = value.to_s.dup
13
+ raise ArgumentError, "model id must not be blank" if normalized.empty?
14
+
15
+ normalized.freeze
16
+ end
17
+ Provider = Types::Symbol.optional.constructor { |value| value&.to_sym }
18
+
19
+ private_constant :ModelId, :Provider
20
+
21
+ attribute :model_id, ModelId
22
+ attribute :provider, Provider
23
+
24
+ def initialize(attributes)
25
+ super
26
+ freeze
27
+ end
28
+
29
+ def self.coerce(value, provider: nil)
30
+ return value if value.is_a?(self)
31
+ return from_hash(value) if value.is_a?(Hash)
32
+ return parse(value) if provider.nil? && value.is_a?(String) && value.include?("/")
33
+
34
+ new(model_id: value, provider:)
35
+ end
36
+
37
+ def self.from_hash(value)
38
+ attributes = value.transform_keys(&:to_sym)
39
+ new(model_id: attributes[:model_id] || attributes[:model], provider: attributes[:provider])
40
+ end
41
+ private_class_method :from_hash
42
+
43
+ # Inverse of #to_s: the first slash separates provider from model,
44
+ # so a reference whose model id itself contains slashes round-trips
45
+ # ("openrouter/openai/gpt-5" -> provider :openrouter,
46
+ # model "openai/gpt-5"). An explicit `provider:` keyword keeps the
47
+ # string literal and is never re-split.
48
+ def self.parse(value)
49
+ provider, model_id = value.split("/", 2)
50
+ raise ArgumentError, "provider segment in #{value.inspect} must not be blank" if provider.empty?
51
+
52
+ new(model_id:, provider:)
53
+ end
54
+
55
+ def key
56
+ [provider, model_id].freeze
57
+ end
58
+
59
+ # True when the two references can address the same physical model:
60
+ # an unqualified reference delegates provider selection to RubyLLM,
61
+ # so it can resolve to any provider serving the same model id.
62
+ def same_candidate?(other)
63
+ model_id == other.model_id &&
64
+ (provider.nil? || other.provider.nil? || provider == other.provider)
65
+ end
66
+
67
+ def chat_options
68
+ { model: model_id, provider: }.compact.freeze
69
+ end
70
+
71
+ def to_s
72
+ provider ? "#{provider}/#{model_id}" : model_id
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "../types"
6
+ require_relative "model_reference"
7
+
8
+ module Smith
9
+ class Agent
10
+ class ProviderAttempt < Dry::Struct
11
+ attribute :completion, Types::Any.optional
12
+ attribute :model_reference, Types.Instance(ModelReference)
13
+ attribute :error, Types.Instance(StandardError).optional
14
+
15
+ def self.success(completion:, model_reference:)
16
+ new(completion:, model_reference:, error: nil)
17
+ end
18
+
19
+ def self.failure(error:, model_reference:)
20
+ new(completion: nil, model_reference:, error:)
21
+ end
22
+
23
+ def success?
24
+ error.nil?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ class ProviderCandidateSequence
6
+ include Enumerable
7
+
8
+ def initialize(references)
9
+ @references = references.freeze
10
+ @remaining_by_provider = references.each_with_object(Hash.new(0)) do |reference, counts|
11
+ counts[reference.provider] += 1
12
+ end
13
+ @unavailable_providers = {}
14
+ @available_count = references.length
15
+ end
16
+
17
+ # Returns self so an exhausted sequence can never be mistaken for
18
+ # an attempt result by a caller that forgets to fail closed.
19
+ def each
20
+ return enum_for(:each) unless block_given?
21
+
22
+ references.each_with_index do |reference, index|
23
+ consume(reference)
24
+ yield reference, index unless unavailable?(reference.provider)
25
+ end
26
+ self
27
+ end
28
+
29
+ def suppress(provider)
30
+ return unless provider
31
+ return if unavailable?(provider)
32
+
33
+ unavailable_providers[provider] = true
34
+ @available_count -= remaining_by_provider.fetch(provider, 0)
35
+ end
36
+
37
+ def fallback_available?
38
+ @available_count.positive?
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :references, :remaining_by_provider, :unavailable_providers
44
+
45
+ def consume(reference)
46
+ remaining_by_provider[reference.provider] -= 1
47
+ @available_count -= 1 unless unavailable?(reference.provider)
48
+ end
49
+
50
+ def unavailable?(provider)
51
+ unavailable_providers.key?(provider)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "provider_failure_handling"
4
+ require_relative "invocation_preparation"
5
+ require_relative "provider_attempt"
6
+ require_relative "provider_candidate_sequence"
7
+
8
+ module Smith
9
+ class Agent
10
+ module ProviderCompletion
11
+ include ProviderFailureHandling
12
+ include InvocationPreparation
13
+
14
+ private
15
+
16
+ def complete_with_provider(agent_class, prepared_input, output_schema:)
17
+ candidates = ProviderCandidateSequence.new(build_model_chain(agent_class))
18
+ candidates.each do |model_reference, index|
19
+ check_deadline! if index.positive?
20
+ attempt = attempt_model(agent_class, prepared_input, model_reference, output_schema:)
21
+ return [attempt.completion, attempt.model_reference] if attempt.success?
22
+
23
+ candidates.suppress(account_failed_provider(attempt, model_reference))
24
+ handle_provider_failure!(
25
+ attempt.error, attempt.model_reference, agent_class,
26
+ fallback_available: candidates.fallback_available?
27
+ )
28
+ end
29
+
30
+ # Fail closed: an empty candidate chain (agent without a model)
31
+ # or any exhausted sequence must surface a typed diagnostic, not
32
+ # the sequence itself destructured into nils.
33
+ raise Smith::AgentError,
34
+ "no executable model candidate for #{agent_class}; declare a model or fallback_models"
35
+ end
36
+
37
+ # Suppression keys on the provider that actually rejected the
38
+ # account; when the chat is unobservable the attempted reference's
39
+ # declared provider is the best available attribution, so a dead
40
+ # account is not billed a second same-provider attempt.
41
+ def account_failed_provider(attempt, attempted_reference)
42
+ return unless provider_account_failure?(attempt.error)
43
+
44
+ attempt.model_reference.provider || attempted_reference.provider
45
+ end
46
+
47
+ def build_model_chain(agent_class)
48
+ references = [primary_model_reference(agent_class), *fallback_model_references(agent_class)].compact
49
+ references.each_with_object([]) do |reference, chain|
50
+ chain << reference unless chain.any? { |kept| kept.same_candidate?(reference) }
51
+ end
52
+ end
53
+
54
+ def primary_model_reference(agent_class)
55
+ return resolve_dynamic_model(agent_class) if agent_class.model_block
56
+
57
+ model_id = agent_class.chat_kwargs[:model]
58
+ return unless model_id
59
+
60
+ # Static declarations stay literal: RubyLLM owns the meaning of
61
+ # the declared id, so a slashed id ("openai/gpt-5") is not split
62
+ # into provider/model here the way ModelReference.coerce parses
63
+ # host-supplied strings.
64
+ ModelReference.new(model_id: model_id, provider: agent_class.chat_kwargs[:provider])
65
+ end
66
+
67
+ def fallback_model_references(agent_class)
68
+ Array(agent_class.fallback_models).map { |model| ModelReference.coerce(model) }
69
+ end
70
+
71
+ def resolve_dynamic_model(agent_class)
72
+ result = agent_class.model_block.call(@context || {})
73
+ reference = ModelReference.coerce(result)
74
+ return reference if reference.provider
75
+
76
+ raise Smith::AgentError,
77
+ "model block for #{agent_class} must return a provider-qualified model reference; got #{result.inspect}"
78
+ rescue ArgumentError, Dry::Struct::Error => e
79
+ raise Smith::AgentError, "invalid model block result for #{agent_class}: #{e.message}"
80
+ end
81
+
82
+ def attempt_model(agent_class, prepared_input, model_reference, output_schema:)
83
+ chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
84
+ message_count = chat_message_count(chat)
85
+ observed_reference = observed_model_reference(chat, fallback: model_reference)
86
+ completion = Completion.from_messages(response: chat.complete, messages: new_chat_messages(chat, message_count))
87
+
88
+ ProviderAttempt.success(completion:, model_reference: observed_reference)
89
+ rescue StandardError => e
90
+ observed_reference ||= observed_model_reference(chat, fallback: model_reference)
91
+ account_completed_prefix(agent_class, observed_reference, new_chat_messages(chat, message_count))
92
+ raise unless provider_failure?(e)
93
+
94
+ ProviderAttempt.failure(error: e, model_reference: observed_reference)
95
+ end
96
+
97
+ def observed_model_reference(chat, fallback:)
98
+ model = observable_model(chat)
99
+ return fallback unless model.respond_to?(:id) && !model.id.to_s.empty?
100
+
101
+ ModelReference.coerce(model.id, provider: observed_provider(model, fallback))
102
+ rescue StandardError
103
+ fallback
104
+ end
105
+
106
+ def observable_model(chat)
107
+ resolved_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
108
+ resolved_chat.model if resolved_chat.respond_to?(:model)
109
+ end
110
+
111
+ def observed_provider(model, fallback)
112
+ model.respond_to?(:provider) && !model.provider.to_s.empty? ? model.provider : fallback.provider
113
+ end
114
+
115
+ def prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
116
+ chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
117
+ add_prepared_input(chat, prepared_input)
118
+ output_schema ? chat.with_schema(output_schema) : chat
119
+ end
120
+
121
+ def chat_message_count(chat)
122
+ chat.respond_to?(:messages) ? chat.messages.length : nil
123
+ end
124
+
125
+ def new_chat_messages(chat, message_count)
126
+ message_count ? chat.messages.drop(message_count) : []
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module ProviderFailureHandling
6
+ TRANSIENT_ERRORS = [
7
+ RubyLLM::ServerError,
8
+ RubyLLM::ServiceUnavailableError,
9
+ RubyLLM::OverloadedError,
10
+ RubyLLM::RateLimitError
11
+ ].freeze
12
+ MODEL_UNAVAILABLE_STATUSES = [404, 410].freeze
13
+ PROVIDER_ACCOUNT_ERRORS = [
14
+ RubyLLM::UnauthorizedError,
15
+ RubyLLM::PaymentRequiredError
16
+ ].freeze
17
+ MODEL_PERMISSION_ERRORS = [RubyLLM::ForbiddenError].freeze
18
+
19
+ private_constant :TRANSIENT_ERRORS, :MODEL_UNAVAILABLE_STATUSES,
20
+ :PROVIDER_ACCOUNT_ERRORS, :MODEL_PERMISSION_ERRORS
21
+
22
+ private
23
+
24
+ def handle_provider_failure!(error, model_reference, agent_class, fallback_available:)
25
+ account_failed_attempt(error, model_reference, agent_class)
26
+ if completed_tool_calls?
27
+ raise Smith::ToolOutcomeUncertain.new(
28
+ "provider failed after tool execution began; retry or fallback could replay an uncertain outcome"
29
+ ), cause: error
30
+ end
31
+ return if fallback_eligible?(error) && fallback_available
32
+
33
+ raise terminal_provider_error(error, model_reference), cause: error
34
+ end
35
+
36
+ def provider_failure?(error)
37
+ error.is_a?(RubyLLM::Error) ||
38
+ (defined?(RubyLLM::ModelNotFoundError) && error.is_a?(RubyLLM::ModelNotFoundError)) ||
39
+ error.is_a?(Faraday::TimeoutError) ||
40
+ error.is_a?(Faraday::ConnectionFailed)
41
+ end
42
+
43
+ def provider_account_failure?(error)
44
+ PROVIDER_ACCOUNT_ERRORS.any? { |error_class| error.is_a?(error_class) }
45
+ end
46
+
47
+ def completed_tool_calls?
48
+ Tool.current_tool_execution_tracker&.started?
49
+ end
50
+
51
+ def fallback_eligible?(error)
52
+ transient_failure?(error) ||
53
+ transport_failure?(error) ||
54
+ provider_account_failure?(error) ||
55
+ model_permission_failure?(error) ||
56
+ model_unavailable?(error)
57
+ end
58
+
59
+ def transient_failure?(error)
60
+ TRANSIENT_ERRORS.any? { |error_class| error.is_a?(error_class) }
61
+ end
62
+
63
+ def transport_failure?(error)
64
+ error.is_a?(Faraday::TimeoutError) || error.is_a?(Faraday::ConnectionFailed)
65
+ end
66
+
67
+ def model_permission_failure?(error)
68
+ MODEL_PERMISSION_ERRORS.any? { |error_class| error.is_a?(error_class) }
69
+ end
70
+
71
+ def model_unavailable?(error)
72
+ return true if defined?(RubyLLM::ModelNotFoundError) && error.is_a?(RubyLLM::ModelNotFoundError)
73
+ return false unless error.respond_to?(:response)
74
+
75
+ response = error.response
76
+ response.respond_to?(:status) && MODEL_UNAVAILABLE_STATUSES.include?(response.status.to_i)
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ def terminal_provider_error(error, model_reference)
82
+ return Smith::AgentError.new(error.message) if transient_failure?(error) || transport_failure?(error)
83
+
84
+ Smith::ProviderPermanentFailure.new(
85
+ error.message,
86
+ provider: model_reference.provider,
87
+ model_id: model_reference.model_id,
88
+ source_error_class: error.class.name
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end