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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
- data/lib/smith/agent/chat_construction.rb +112 -0
- data/lib/smith/agent/completion.rb +46 -0
- data/lib/smith/agent/completion_usage_recording.rb +25 -0
- data/lib/smith/agent/dynamic_configuration.rb +69 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -0
- data/lib/smith/agent/invocation_preparation.rb +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -0
- data/lib/smith/agent/provider_failure_handling.rb +93 -0
- data/lib/smith/agent/provider_usage.rb +28 -0
- data/lib/smith/agent/reserved_input_bridge.rb +40 -0
- data/lib/smith/agent/usage_entry_recording.rb +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +29 -197
- data/lib/smith/context/observation_masking.rb +28 -4
- data/lib/smith/context/session.rb +7 -2
- data/lib/smith/diagnostic_text.rb +67 -0
- data/lib/smith/doctor/checks/configuration.rb +16 -3
- data/lib/smith/doctor/checks/models_registry.rb +75 -17
- data/lib/smith/errors.rb +34 -1
- data/lib/smith/models/ambiguous_profile_error.rb +9 -0
- data/lib/smith/models/collision_error.rb +9 -0
- data/lib/smith/models/inference.rb +14 -0
- data/lib/smith/models/normalizer.rb +11 -67
- data/lib/smith/models/provider_qualified_registry.rb +101 -0
- data/lib/smith/models/tool_routing.rb +140 -0
- data/lib/smith/models.rb +24 -78
- data/lib/smith/persisted_failure_invalid.rb +7 -0
- data/lib/smith/pricing.rb +138 -17
- data/lib/smith/pricing_configuration_error.rb +7 -0
- data/lib/smith/provider_permanent_failure.rb +16 -0
- data/lib/smith/tool/argument_container_reader.rb +97 -0
- data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
- data/lib/smith/tool/argument_snapshot.rb +19 -0
- data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
- data/lib/smith/tool/argument_snapshot_result.rb +15 -0
- data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
- data/lib/smith/tool/bounded_completion_context.rb +131 -0
- data/lib/smith/tool/bounded_completion_controls.rb +113 -0
- data/lib/smith/tool/bounded_completion_guard.rb +101 -0
- data/lib/smith/tool/bounded_completion_installation.rb +20 -0
- data/lib/smith/tool/bounded_completion_state.rb +57 -0
- data/lib/smith/tool/budget_enforcement.rb +15 -7
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +101 -28
- data/lib/smith/tool/call_allowance_counter.rb +43 -0
- data/lib/smith/tool/call_batch.rb +47 -0
- data/lib/smith/tool/call_budget.rb +51 -0
- data/lib/smith/tool/call_reservation.rb +42 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +7 -88
- data/lib/smith/tool/execution_authority.rb +42 -0
- data/lib/smith/tool/execution_authorization.rb +17 -0
- data/lib/smith/tool/execution_batch.rb +100 -0
- data/lib/smith/tool/execution_batch_admission.rb +56 -0
- data/lib/smith/tool/execution_batch_builder.rb +122 -0
- data/lib/smith/tool/execution_batch_collection.rb +70 -0
- data/lib/smith/tool/execution_batch_invocations.rb +28 -0
- data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
- data/lib/smith/tool/execution_batch_registry.rb +99 -0
- data/lib/smith/tool/execution_batch_requests.rb +59 -0
- data/lib/smith/tool/execution_batch_source_call.rb +36 -0
- data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
- data/lib/smith/tool/execution_batch_sources.rb +32 -0
- data/lib/smith/tool/execution_batch_state.rb +110 -0
- data/lib/smith/tool/execution_dispatch.rb +78 -0
- data/lib/smith/tool/execution_failure_handling.rb +101 -0
- data/lib/smith/tool/execution_lifecycle.rb +28 -0
- data/lib/smith/tool/execution_tracker.rb +20 -0
- data/lib/smith/tool/fail_fast_completion.rb +24 -0
- data/lib/smith/tool/graceful_completion.rb +20 -0
- data/lib/smith/tool/invocation.rb +27 -0
- data/lib/smith/tool/invocation_request.rb +37 -0
- data/lib/smith/tool/invocation_sequence.rb +26 -0
- data/lib/smith/tool/legacy_call_allowance.rb +57 -0
- data/lib/smith/tool/scoped_context.rb +60 -8
- data/lib/smith/tool.rb +71 -12
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/agent_result.rb +4 -3
- data/lib/smith/workflow/artifact_integration.rb +2 -1
- data/lib/smith/workflow/composite/effects.rb +3 -1
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/execution.rb +2 -9
- data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
- data/lib/smith/workflow/failure_reconstructor.rb +79 -0
- data/lib/smith/workflow/failure_record.rb +139 -0
- data/lib/smith/workflow/failure_record_restore.rb +145 -0
- data/lib/smith/workflow/failure_record_text.rb +83 -0
- data/lib/smith/workflow/failure_record_validator.rb +113 -0
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- 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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
27
|
-
snapshot_and_finalize(agent_class,
|
|
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
|