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
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Smith
6
+ class Agent
7
+ module InvocationPreparation
8
+ WORKFLOW_CONTINUATION_MESSAGE =
9
+ "Use the preceding assistant result as input and perform your assigned workflow step."
10
+
11
+ private_constant :WORKFLOW_CONTINUATION_MESSAGE
12
+
13
+ private
14
+
15
+ def bridge_workflow_inputs(agent_class)
16
+ return {} unless @context.is_a?(Hash)
17
+
18
+ declared = agent_class.inputs || []
19
+ user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
20
+ user_declared.to_h do |name|
21
+ [name, @context[name]]
22
+ end
23
+ end
24
+
25
+ def add_prepared_input(chat, prepared_input)
26
+ return unless prepared_input
27
+
28
+ prepared_input = provider_safe_prepared_input(prepared_input)
29
+ system_messages, other_messages = prepared_input.partition do |message|
30
+ message_role(message) == :system
31
+ end
32
+
33
+ merge_system_messages!(chat, system_messages) if system_messages.any?
34
+ other_messages.each { |message| add_message(chat, message) }
35
+ end
36
+
37
+ def provider_safe_prepared_input(prepared_input)
38
+ messages = prepared_input.to_a
39
+ return messages unless workflow_handoff?(messages)
40
+
41
+ messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
42
+ end
43
+
44
+ def workflow_handoff?(messages)
45
+ message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
46
+ return false unless message
47
+ return false unless message_role(message) == :assistant
48
+ return false unless defined?(@last_output) && !@last_output.nil?
49
+
50
+ message_content(message) == @last_output
51
+ end
52
+
53
+ def merge_system_messages!(chat, prepared_system_messages)
54
+ return append_system_messages(chat, prepared_system_messages) unless chat.respond_to?(:messages)
55
+
56
+ combined_contents = existing_system_contents(chat) + prepared_system_contents(prepared_system_messages)
57
+ return if combined_contents.empty?
58
+ return append_system_messages(chat, prepared_system_messages) unless combined_contents.all?(String)
59
+
60
+ if chat.respond_to?(:with_instructions)
61
+ chat.with_instructions(combined_contents.join("\n\n"))
62
+ else
63
+ append_system_messages(chat, prepared_system_messages)
64
+ end
65
+ end
66
+
67
+ def append_system_messages(chat, messages)
68
+ messages.each { |message| add_message(chat, message) }
69
+ end
70
+
71
+ def existing_system_contents(chat)
72
+ chat.messages.filter_map do |message|
73
+ message.content if message_role(message) == :system
74
+ end
75
+ end
76
+
77
+ def prepared_system_contents(messages)
78
+ messages.filter_map { |message| message_content(message) }
79
+ end
80
+
81
+ def add_message(chat, message)
82
+ attributes = if message.is_a?(Hash)
83
+ message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
84
+ else
85
+ message
86
+ end
87
+ attributes = provider_safe_message(attributes) if attributes.is_a?(Hash)
88
+ chat.add_message(attributes)
89
+ end
90
+
91
+ # A structured agent output recorded as a session message (StepCompletion#append_accepted_output)
92
+ # carries a Hash/Array content. RubyLLM's Message#normalize_content treats a Hash content as
93
+ # { text:, ...attachments } and opens each value as a file, so replaying a prior structured
94
+ # output to the next agent in a workflow session raises Errno::ENOENT. Serialize non-string
95
+ # content to JSON so the provider sees the prior output as text; the session store keeps the raw
96
+ # value (last_output stays structured), only this provider-facing copy is serialized. Genuine
97
+ # multimodal attachments are supplied through the provider's own with: mechanism, never as a bare
98
+ # Hash message content in a workflow session.
99
+ def provider_safe_message(attributes)
100
+ content = attributes[:content]
101
+ return attributes if content.nil? || content.is_a?(String)
102
+
103
+ attributes.merge(content: json_message_content(content))
104
+ end
105
+
106
+ def json_message_content(content)
107
+ JSON.generate(content)
108
+ rescue StandardError
109
+ content.to_s
110
+ end
111
+
112
+ def message_role(message)
113
+ message_attribute(message, :role)&.to_sym
114
+ end
115
+
116
+ def message_content(message)
117
+ message_attribute(message, :content)
118
+ end
119
+
120
+ def message_attribute(message, name)
121
+ return message.public_send(name) if message.respond_to?(name)
122
+ return message[name] if message.respond_to?(:key?) && message.key?(name)
123
+
124
+ message[name.to_s]
125
+ end
126
+ end
127
+ end
128
+ end
@@ -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,275 +20,11 @@ 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
23
+ attempt = complete_with_provider(agent_class, prepared_input, output_schema:)
24
+ snapshot_and_finalize(
25
+ agent_class, attempt.completion, attempt.model_reference, attempt_id: attempt.attempt_id
235
26
  )
236
27
  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
294
- end
295
28
  end
296
29
  end
297
30
  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,34 @@
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
+ # One attempt = one chat completion (including any provider tool loop).
15
+ # `attempt_id` joins the attempt's usage entries to its single measured
16
+ # duration; `duration_ms` is nil when the attempt failed before the
17
+ # completion call started.
18
+ attribute :attempt_id, Types::String.optional.default(nil)
19
+ attribute :duration_ms, Types::Integer.optional.default(nil)
20
+
21
+ def self.success(completion:, model_reference:, attempt_id: nil, duration_ms: nil)
22
+ new(completion:, model_reference:, error: nil, attempt_id:, duration_ms:)
23
+ end
24
+
25
+ def self.failure(error:, model_reference:, attempt_id: nil, duration_ms: nil)
26
+ new(completion: nil, model_reference:, error:, attempt_id:, duration_ms:)
27
+ end
28
+
29
+ def success?
30
+ error.nil?
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # Monotonic timing and trace emission for one provider attempt: the
6
+ # attempt's single measured duration wraps the whole chat completion
7
+ # (including any provider-side tool loop). Per-network-round timing
8
+ # belongs to the host's RubyLLM notification subscriptions, not Smith.
9
+ module ProviderCallTiming
10
+ class Timer
11
+ def self.start
12
+ new
13
+ end
14
+
15
+ def initialize
16
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ @stopped_at = nil
18
+ end
19
+
20
+ # Idempotent: the first stop wins, so a rescue-path stop after a
21
+ # success-path stop cannot stretch the measurement.
22
+ def stop
23
+ @stopped_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ self
25
+ end
26
+
27
+ def elapsed_ms
28
+ ending = @stopped_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ ((ending - @started_at) * 1000).round
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # One `:provider_call` trace per attempt: success, provider failure, or
36
+ # aborted (a non-provider error that re-raises). Usage entries from the
37
+ # same attempt share its attempt_id; join there instead of summing
38
+ # durations across entries. duration_ms is present only when the timed
39
+ # provider call actually started: a failure before dispatch (model
40
+ # resolution, chat construction) emits its attempt without a duration.
41
+ def record_provider_call_trace(attempt, attempt_index, aborted: false)
42
+ Smith::Trace.record(
43
+ type: :provider_call,
44
+ data: {
45
+ model: attempt.model_reference.model_id,
46
+ provider: attempt.model_reference.provider,
47
+ duration_ms: attempt.duration_ms,
48
+ attempt_id: attempt.attempt_id,
49
+ attempt_index: attempt_index,
50
+ outcome: provider_call_outcome(attempt, aborted)
51
+ }.compact
52
+ )
53
+ end
54
+
55
+ def provider_call_outcome(attempt, aborted)
56
+ return :aborted if aborted
57
+ return :success if attempt.success?
58
+
59
+ :failure
60
+ end
61
+
62
+ # Aborted (non-provider) attempts emit a :provider_call too, so the
63
+ # prefix-accounted usage entries stamped with this attempt_id always
64
+ # have their join target; the error then propagates unchanged.
65
+ def failed_provider_attempt(error, observed_reference, attempt_id, timer, attempt_index)
66
+ attempt = ProviderAttempt.failure(
67
+ error: error, model_reference: observed_reference, attempt_id:, duration_ms: timer&.elapsed_ms
68
+ )
69
+ record_provider_call_trace(attempt, attempt_index, aborted: !provider_failure?(error))
70
+ attempt
71
+ end
72
+ end
73
+ end
74
+ 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