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,148 @@
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_call_timing"
7
+ require_relative "provider_candidate_sequence"
8
+
9
+ module Smith
10
+ class Agent
11
+ module ProviderCompletion
12
+ include ProviderFailureHandling
13
+ include InvocationPreparation
14
+ include ProviderCallTiming
15
+
16
+ private
17
+
18
+ def complete_with_provider(agent_class, prepared_input, output_schema:)
19
+ candidates = ProviderCandidateSequence.new(build_model_chain(agent_class))
20
+ candidates.each do |model_reference, index|
21
+ check_deadline! if index.positive?
22
+ attempt = attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index: index)
23
+ return attempt if attempt.success?
24
+
25
+ candidates.suppress(account_failed_provider(attempt, model_reference))
26
+ handle_provider_failure!(
27
+ attempt.error, attempt.model_reference, agent_class,
28
+ fallback_available: candidates.fallback_available?,
29
+ attempt_id: attempt.attempt_id
30
+ )
31
+ end
32
+
33
+ # Fail closed: an empty candidate chain (agent without a model)
34
+ # or any exhausted sequence must surface a typed diagnostic, not
35
+ # the sequence itself destructured into nils.
36
+ raise Smith::AgentError,
37
+ "no executable model candidate for #{agent_class}; declare a model or fallback_models"
38
+ end
39
+
40
+ # Suppression keys on the provider that actually rejected the
41
+ # account; when the chat is unobservable the attempted reference's
42
+ # declared provider is the best available attribution, so a dead
43
+ # account is not billed a second same-provider attempt.
44
+ def account_failed_provider(attempt, attempted_reference)
45
+ return unless provider_account_failure?(attempt.error)
46
+
47
+ attempt.model_reference.provider || attempted_reference.provider
48
+ end
49
+
50
+ def build_model_chain(agent_class)
51
+ references = [primary_model_reference(agent_class), *fallback_model_references(agent_class)].compact
52
+ references.each_with_object([]) do |reference, chain|
53
+ chain << reference unless chain.any? { |kept| kept.same_candidate?(reference) }
54
+ end
55
+ end
56
+
57
+ def primary_model_reference(agent_class)
58
+ return resolve_dynamic_model(agent_class) if agent_class.model_block
59
+
60
+ model_id = agent_class.chat_kwargs[:model]
61
+ return unless model_id
62
+
63
+ # Static declarations stay literal: RubyLLM owns the meaning of
64
+ # the declared id, so a slashed id ("openai/gpt-5") is not split
65
+ # into provider/model here the way ModelReference.coerce parses
66
+ # host-supplied strings.
67
+ ModelReference.new(model_id: model_id, provider: agent_class.chat_kwargs[:provider])
68
+ end
69
+
70
+ def fallback_model_references(agent_class)
71
+ Array(agent_class.fallback_models).map { |model| ModelReference.coerce(model) }
72
+ end
73
+
74
+ def resolve_dynamic_model(agent_class)
75
+ result = agent_class.model_block.call(@context || {})
76
+ reference = ModelReference.coerce(result)
77
+ return reference if reference.provider
78
+
79
+ raise Smith::AgentError,
80
+ "model block for #{agent_class} must return a provider-qualified model reference; got #{result.inspect}"
81
+ rescue ArgumentError, Dry::Struct::Error => e
82
+ raise Smith::AgentError, "invalid model block result for #{agent_class}: #{e.message}"
83
+ end
84
+
85
+ # rubocop:disable Metrics/AbcSize -- one provider attempt is a single
86
+ # cohesive lifecycle (identity, prepared chat, observed model, timed
87
+ # completion, prefix accounting on failure); splitting it would scatter
88
+ # the rescue-path accounting away from what it accounts for.
89
+ def attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index:)
90
+ attempt_id = SecureRandom.uuid
91
+ chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
92
+ message_count = chat_message_count(chat)
93
+ observed_reference = observed_model_reference(chat, fallback: model_reference)
94
+ timer = ProviderCallTiming::Timer.start
95
+ response = chat.complete
96
+ timer.stop
97
+ completion = Completion.from_messages(response: response, messages: new_chat_messages(chat, message_count))
98
+
99
+ attempt = ProviderAttempt.success(
100
+ completion:, model_reference: observed_reference, attempt_id:, duration_ms: timer.elapsed_ms
101
+ )
102
+ record_provider_call_trace(attempt, attempt_index)
103
+ attempt
104
+ rescue StandardError => e
105
+ timer&.stop
106
+ observed_reference ||= observed_model_reference(chat, fallback: model_reference)
107
+ account_completed_prefix(agent_class, observed_reference, new_chat_messages(chat, message_count), attempt_id:)
108
+ attempt = failed_provider_attempt(e, observed_reference, attempt_id, timer, attempt_index)
109
+ raise unless provider_failure?(e)
110
+
111
+ attempt
112
+ end
113
+ # rubocop:enable Metrics/AbcSize
114
+
115
+ def observed_model_reference(chat, fallback:)
116
+ model = observable_model(chat)
117
+ return fallback unless model.respond_to?(:id) && !model.id.to_s.empty?
118
+
119
+ ModelReference.coerce(model.id, provider: observed_provider(model, fallback))
120
+ rescue StandardError
121
+ fallback
122
+ end
123
+
124
+ def observable_model(chat)
125
+ resolved_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
126
+ resolved_chat.model if resolved_chat.respond_to?(:model)
127
+ end
128
+
129
+ def observed_provider(model, fallback)
130
+ model.respond_to?(:provider) && !model.provider.to_s.empty? ? model.provider : fallback.provider
131
+ end
132
+
133
+ def prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
134
+ chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
135
+ add_prepared_input(chat, prepared_input)
136
+ output_schema ? chat.with_schema(output_schema) : chat
137
+ end
138
+
139
+ def chat_message_count(chat)
140
+ chat.respond_to?(:messages) ? chat.messages.length : nil
141
+ end
142
+
143
+ def new_chat_messages(chat, message_count)
144
+ message_count ? chat.messages.drop(message_count) : []
145
+ end
146
+ end
147
+ end
148
+ 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:, attempt_id: nil)
25
+ account_failed_attempt(error, model_reference, agent_class, attempt_id:)
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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "../types"
6
+
7
+ module Smith
8
+ class Agent
9
+ class ProviderUsage < Dry::Struct
10
+ attribute :input_tokens, Types::Integer
11
+ attribute :output_tokens, Types::Integer
12
+
13
+ def self.from_message(message)
14
+ return unless message.respond_to?(:input_tokens) && message.respond_to?(:output_tokens)
15
+
16
+ input_tokens = message.input_tokens
17
+ output_tokens = message.output_tokens
18
+ return unless input_tokens.is_a?(Integer) && output_tokens.is_a?(Integer)
19
+
20
+ new(input_tokens:, output_tokens:)
21
+ end
22
+
23
+ def total_tokens
24
+ input_tokens + output_tokens
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module ReservedInputBridge
6
+ private
7
+
8
+ def partition_inputs(kwargs)
9
+ input_values, chat_options = super
10
+ provider = input_values[:provider]
11
+ chat_options[:provider] = provider if provider && provider.to_sym != :unknown
12
+ [input_values, chat_options]
13
+ end
14
+
15
+ def apply_configuration(chat_object, input_values:, persist_instructions:)
16
+ super(
17
+ chat_object,
18
+ input_values: reserved_inputs_for(chat_object, input_values),
19
+ persist_instructions:
20
+ )
21
+ end
22
+
23
+ def reserved_inputs_for(chat_object, input_values)
24
+ llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
25
+ model = llm_chat.model if llm_chat.respond_to?(:model)
26
+ profile = resolve_profile(
27
+ model&.id || input_values[:model_id],
28
+ provider: actual_provider(llm_chat) || input_values[:provider]
29
+ )
30
+ return input_values unless profile
31
+
32
+ input_values.merge(
33
+ model_id: profile.model_id,
34
+ provider: profile.provider,
35
+ endpoint_mode: profile.endpoint_mode
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module UsageEntryRecording
6
+ private
7
+
8
+ def record_usage(agent_class, agent_result, attempt_kind, model_reference, attempt_id: nil)
9
+ return unless agent_result.usage_known?
10
+
11
+ model_reference = coerce_model_reference(model_reference)
12
+ entry = build_usage_entry(agent_class, agent_result, attempt_kind, model_reference, attempt_id:)
13
+ accumulate_usage(agent_result, entry)
14
+ end
15
+
16
+ # Attribution (transition, branch key, optimizer round) is read from the
17
+ # ambient context of the recording thread, which is the thread that ran
18
+ # the provider call: a fan-out branch records under its own overlay.
19
+ # transition and branch_key are recorded as Symbols even when a host
20
+ # seeded Strings through Attribution.with, because from_h symbolizes
21
+ # them on restore: recording the same way keeps a restored entry equal
22
+ # to the recorded one.
23
+ def build_usage_entry(agent_class, agent_result, attempt_kind, model_reference, attempt_id: nil)
24
+ Workflow::UsageEntry.new(
25
+ usage_id: SecureRandom.uuid,
26
+ agent_name: agent_class.register_as,
27
+ model: model_reference.model_id,
28
+ provider: model_reference.provider,
29
+ input_tokens: agent_result.input_tokens,
30
+ output_tokens: agent_result.output_tokens,
31
+ cost: agent_result.cost,
32
+ attempt_kind: attempt_kind,
33
+ recorded_at: Time.now.utc.iso8601(6),
34
+ attempt_id: attempt_id,
35
+ **ambient_attribution_fields
36
+ )
37
+ end
38
+
39
+ def ambient_attribution_fields
40
+ attribution = Smith::Attribution.ambient
41
+ {
42
+ transition: symbolized_attribution(attribution.transition),
43
+ branch_key: symbolized_attribution(attribution.branch_key),
44
+ round: attribution.round,
45
+ workflow: attribution.workflow
46
+ }
47
+ end
48
+
49
+ def symbolized_attribution(value)
50
+ value.is_a?(String) ? value.to_sym : value
51
+ end
52
+
53
+ def accumulate_usage(agent_result, entry)
54
+ @usage_mutex.synchronize do
55
+ @total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
56
+ @total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
57
+ @usage_entries << entry
58
+ end
59
+ end
60
+
61
+ def coerce_model_reference(value)
62
+ ModelReference.coerce(value)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # Trace emission for a completed agent invocation's usage facts, kept
6
+ # apart from the accounting itself (UsageTracking) so recording rows and
7
+ # emitting observability stay separate concerns.
8
+ module UsageTraces
9
+ private
10
+
11
+ def emit_token_usage(agent_result)
12
+ return unless agent_result.usage_known?
13
+
14
+ Smith::Trace.record(
15
+ type: :token_usage,
16
+ data: {
17
+ input_tokens: agent_result.input_tokens,
18
+ output_tokens: agent_result.output_tokens,
19
+ model: agent_result.model_used,
20
+ provider: agent_result.provider_used
21
+ }.compact
22
+ )
23
+ end
24
+
25
+ # One :cost trace per completed agent invocation. The cost is the sum
26
+ # of the invocation's per-response entry costs (what tiered catalogs
27
+ # actually bill), never the aggregate token totals priced as one call.
28
+ # Token counts remain the invocation aggregates. Billed failed and
29
+ # partial attempts appear only in usage entries, so summing :cost
30
+ # traces is not a spend total. Unpriced usage emits nothing, and the
31
+ # caller gates out partially metered or partially priced invocations
32
+ # so an incomplete figure is never presented as the invocation cost.
33
+ def emit_cost_trace(agent_result, invocation_cost)
34
+ return if invocation_cost.nil?
35
+
36
+ Smith::Trace.record(
37
+ type: :cost,
38
+ data: {
39
+ cost: invocation_cost,
40
+ model: agent_result.model_used,
41
+ provider: agent_result.provider_used,
42
+ input_tokens: agent_result.input_tokens,
43
+ output_tokens: agent_result.output_tokens
44
+ }.compact
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "completion_usage_recording"
4
+ require_relative "usage_entry_recording"
5
+ require_relative "usage_traces"
6
+
7
+ module Smith
8
+ class Agent
9
+ module UsageTracking
10
+ include CompletionUsageRecording
11
+ include UsageEntryRecording
12
+ include UsageTraces
13
+
14
+ private
15
+
16
+ def account_failed_attempt(error, model_reference, agent_class, attempt_id: nil)
17
+ return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
18
+
19
+ input = error.input_tokens
20
+ output = error.output_tokens
21
+ return unless input.is_a?(Integer) && output.is_a?(Integer)
22
+
23
+ record_failed_usage(agent_class, model_reference, input, output, attempt_id:)
24
+ end
25
+
26
+ def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil)
27
+ completion = Completion.from_messages(response: nil, messages: messages)
28
+ record_completion_usage(agent_class, completion, :partial_attempt, model_reference, attempt_id:)
29
+ end
30
+
31
+ def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_id: nil)
32
+ model_reference = coerce_model_reference(model_reference)
33
+ cost = Smith::Pricing.compute_cost(
34
+ model: model_reference.model_id,
35
+ provider: model_reference.provider,
36
+ input_tokens:,
37
+ output_tokens:
38
+ )
39
+ agent_result = Workflow::AgentResult.new(
40
+ content: nil,
41
+ input_tokens:,
42
+ output_tokens:,
43
+ cost: cost,
44
+ model_used: model_reference.model_id,
45
+ provider_used: model_reference.provider
46
+ )
47
+ Thread.current[:smith_failed_agent_results] ||= []
48
+ Thread.current[:smith_failed_agent_results] << agent_result
49
+ record_usage(agent_class, agent_result, :failed_attempt, model_reference, attempt_id:)
50
+ end
51
+
52
+ def snapshot_and_finalize(agent_class, completion, model_reference, attempt_id: nil)
53
+ model_reference = coerce_model_reference(model_reference)
54
+ agent_result = Workflow::AgentResult.new(
55
+ content: completion.content,
56
+ input_tokens: completion.input_tokens,
57
+ output_tokens: completion.output_tokens,
58
+ cost: nil,
59
+ model_used: model_reference.model_id,
60
+ provider_used: model_reference.provider
61
+ )
62
+ Thread.current[:smith_last_agent_result] = agent_result
63
+ emit_token_usage(agent_result)
64
+ account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
65
+
66
+ agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
67
+ raise_blank_output!(agent_class, agent_result)
68
+ agent_result
69
+ end
70
+
71
+ def raise_blank_output!(agent_class, agent_result)
72
+ return unless blank_agent_output?(agent_result.content)
73
+
74
+ raise Smith::BlankAgentOutputError.new(
75
+ agent_name: agent_class.register_as,
76
+ model_used: agent_result.model_used
77
+ )
78
+ end
79
+
80
+ def blank_agent_output?(content)
81
+ return true if content.nil?
82
+ return content.strip.empty? if content.is_a?(String)
83
+
84
+ false
85
+ end
86
+
87
+ # Records one usage entry per provider response and settles the
88
+ # invocation's cost from the recorded per-response sum: that sum is
89
+ # what tiered catalogs bill, so it becomes agent_result.cost (which
90
+ # budget settlement and result surfaces read) and the recorded entries,
91
+ # the budget ledger, and the :cost trace all agree. The trace emits
92
+ # only for fully metered, fully priced invocations, so a partial
93
+ # figure is never presented as the invocation cost; the partial sum
94
+ # still settles the budget because it is what was verifiably billed.
95
+ def account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
96
+ invocation_cost, fully_priced = record_completion_usage(
97
+ agent_class, completion, :completed_attempt, model_reference, attempt_id:
98
+ )
99
+ agent_result.cost = invocation_cost
100
+ emit_cost_trace(agent_result, invocation_cost) if fully_priced
101
+ end
102
+
103
+ def compute_agent_cost(agent_result)
104
+ return unless agent_result.usage_known?
105
+
106
+ model = agent_result.model_used
107
+ agent_result.cost = Smith::Pricing.compute_cost(
108
+ model: model,
109
+ provider: agent_result.provider_used,
110
+ input_tokens: agent_result.input_tokens,
111
+ output_tokens: agent_result.output_tokens
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end