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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +456 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +27 -6
- 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 +43 -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 +128 -0
- data/lib/smith/agent/lifecycle.rb +8 -275
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +34 -0
- data/lib/smith/agent/provider_call_timing.rb +74 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +148 -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 +66 -0
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +116 -0
- data/lib/smith/agent.rb +29 -197
- data/lib/smith/attribution.rb +115 -0
- 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/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- 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/capture.rb +9 -1
- 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 +81 -13
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- 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 +64 -31
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/deterministic_execution.rb +2 -1
- data/lib/smith/workflow/deterministic_step.rb +14 -1
- data/lib/smith/workflow/dsl.rb +25 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +10 -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/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/guardrail_integration.rb +25 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +55 -20
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +9 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +43 -5
- data/lib/smith/workflow.rb +43 -131
- data/lib/smith.rb +10 -2
- 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
|