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
data/lib/smith/tool.rb
CHANGED
|
@@ -4,23 +4,68 @@ require "ruby_llm"
|
|
|
4
4
|
|
|
5
5
|
require_relative "tool/capability_builder"
|
|
6
6
|
require_relative "tool/policy"
|
|
7
|
+
require_relative "tool/call_budget"
|
|
8
|
+
require_relative "tool/call_batch"
|
|
9
|
+
require_relative "tool/legacy_call_allowance"
|
|
10
|
+
require_relative "tool/call_allowance_counter"
|
|
7
11
|
require_relative "tool/call_allowance"
|
|
12
|
+
require_relative "tool/call_reservation"
|
|
13
|
+
require_relative "tool/call_admission"
|
|
14
|
+
require_relative "tool/execution_authority"
|
|
15
|
+
require_relative "tool/execution_authorization"
|
|
16
|
+
require_relative "tool/execution_tracker"
|
|
17
|
+
require_relative "tool/execution_lifecycle"
|
|
18
|
+
require_relative "tool/invocation"
|
|
19
|
+
require_relative "tool/argument_snapshot_result"
|
|
20
|
+
require_relative "tool/argument_scalar_snapshot"
|
|
21
|
+
require_relative "tool/argument_container_reader"
|
|
22
|
+
require_relative "tool/argument_snapshot_accounting"
|
|
23
|
+
require_relative "tool/argument_snapshot_traversal"
|
|
24
|
+
require_relative "tool/argument_snapshot"
|
|
25
|
+
require_relative "tool/invocation_request"
|
|
26
|
+
require_relative "tool/invocation_sequence"
|
|
27
|
+
require_relative "tool/execution_batch_collection"
|
|
28
|
+
require_relative "tool/execution_batch_invocations"
|
|
29
|
+
require_relative "tool/execution_batch_source_metadata"
|
|
30
|
+
require_relative "tool/execution_batch_source_call"
|
|
31
|
+
require_relative "tool/execution_batch_sources"
|
|
32
|
+
require_relative "tool/execution_batch_state"
|
|
33
|
+
require_relative "tool/execution_batch"
|
|
34
|
+
require_relative "tool/execution_batch_admission"
|
|
35
|
+
require_relative "tool/execution_batch_requests"
|
|
36
|
+
require_relative "tool/execution_batch_builder"
|
|
37
|
+
require_relative "tool/execution_batch_registry"
|
|
8
38
|
require_relative "tool/budget_enforcement"
|
|
9
39
|
require_relative "tool_capture_failed"
|
|
10
40
|
require_relative "tool/capture"
|
|
11
41
|
require_relative "tool/capture_configuration"
|
|
12
42
|
require_relative "tool/compatibility"
|
|
13
43
|
require_relative "tool/scoped_context"
|
|
44
|
+
require_relative "tool/bounded_completion_state"
|
|
45
|
+
require_relative "tool/bounded_completion_guard"
|
|
46
|
+
require_relative "tool/bounded_completion_controls"
|
|
47
|
+
require_relative "tool/fail_fast_completion"
|
|
48
|
+
require_relative "tool/graceful_completion"
|
|
49
|
+
require_relative "tool/bounded_completion_context"
|
|
50
|
+
require_relative "tool/bounded_completion_installation"
|
|
51
|
+
require_relative "tool/execution_failure_handling"
|
|
52
|
+
require_relative "tool/execution_dispatch"
|
|
53
|
+
require_relative "tool/chat_execution_callbacks"
|
|
54
|
+
require_relative "tool/execution_batch_lifecycle"
|
|
14
55
|
require_relative "tool/chat_execution_context"
|
|
15
56
|
|
|
16
57
|
module Smith
|
|
17
58
|
class Tool < RubyLLM::Tool
|
|
18
59
|
include Policy
|
|
60
|
+
include ExecutionAuthorization
|
|
61
|
+
include ExecutionLifecycle
|
|
19
62
|
include BudgetEnforcement
|
|
20
63
|
include Capture
|
|
21
64
|
extend CaptureConfiguration
|
|
22
65
|
extend ScopedContext
|
|
23
66
|
|
|
67
|
+
private_constant :ExecutionAuthority
|
|
68
|
+
|
|
24
69
|
class << self
|
|
25
70
|
# Tool subclasses inherit the parent's compatible_with spec by
|
|
26
71
|
# reference (the spec is a frozen Hash; immutability makes shared
|
|
@@ -75,24 +120,32 @@ module Smith
|
|
|
75
120
|
end
|
|
76
121
|
|
|
77
122
|
def execute(**kwargs)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
check_authorization!(kwargs)
|
|
83
|
-
run_tool_guardrails!(kwargs)
|
|
84
|
-
check_tool_deadline!
|
|
85
|
-
charge_tool_call!
|
|
86
|
-
|
|
87
|
-
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
-
result = perform(**kwargs)
|
|
89
|
-
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
123
|
+
authorize_tool_execution!
|
|
124
|
+
kwargs.freeze
|
|
125
|
+
prepare_tool_execution!(kwargs)
|
|
126
|
+
result, duration = perform_with_duration(kwargs)
|
|
90
127
|
|
|
91
128
|
emit_tool_trace(kwargs, result, duration)
|
|
92
129
|
capture_result_if_configured(kwargs, result)
|
|
93
130
|
result
|
|
94
131
|
end
|
|
95
132
|
|
|
133
|
+
protected
|
|
134
|
+
|
|
135
|
+
def invocation_argument_error(arguments) = validate_keyword_arguments(normalize_args(arguments))
|
|
136
|
+
|
|
137
|
+
def execute_keyword_signature
|
|
138
|
+
parameters = method(:perform).parameters
|
|
139
|
+
required_keywords = parameters.filter_map { |kind, name| name if kind == :keyreq }
|
|
140
|
+
optional_keywords = parameters.filter_map { |kind, name| name if kind == :key }
|
|
141
|
+
accepts_extra_keywords = parameters.any? { |kind, _| kind == :keyrest }
|
|
142
|
+
accepts_positional_arguments = parameters.any? do |kind, _|
|
|
143
|
+
RubyLLM::Tool::POSITIONAL_PARAMETER_KINDS.include?(kind)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
[required_keywords, optional_keywords, accepts_extra_keywords, accepts_positional_arguments]
|
|
147
|
+
end
|
|
148
|
+
|
|
96
149
|
private
|
|
97
150
|
|
|
98
151
|
def run_before_execute_hook!(kwargs)
|
|
@@ -112,9 +165,18 @@ module Smith
|
|
|
112
165
|
end
|
|
113
166
|
|
|
114
167
|
def emit_tool_trace(kwargs, result, duration)
|
|
168
|
+
# tool_call_id is the provider's correlation id for this invocation
|
|
169
|
+
# (present only when the call came from a provider batch); it lets a
|
|
170
|
+
# host join this trace to its own per-invocation records.
|
|
115
171
|
Smith::Trace.record(
|
|
116
172
|
type: :tool_call,
|
|
117
|
-
data: {
|
|
173
|
+
data: {
|
|
174
|
+
tool: name,
|
|
175
|
+
args: kwargs,
|
|
176
|
+
result: result,
|
|
177
|
+
duration: duration,
|
|
178
|
+
tool_call_id: self.class.current_invocation&.tool_call_id
|
|
179
|
+
}.compact,
|
|
118
180
|
sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
|
|
119
181
|
)
|
|
120
182
|
end
|
|
@@ -126,6 +188,12 @@ module Smith
|
|
|
126
188
|
raise DeadlineExceeded, "wall_clock deadline exceeded during tool execution" if Time.now.utc >= deadline
|
|
127
189
|
end
|
|
128
190
|
|
|
191
|
+
def check_dispatch_deadline!
|
|
192
|
+
check_tool_deadline!
|
|
193
|
+
rescue DeadlineExceeded
|
|
194
|
+
raise ToolDispatchRejected, "tool deadline expired before dispatch"
|
|
195
|
+
end
|
|
196
|
+
|
|
129
197
|
def perform(**kwargs)
|
|
130
198
|
raise NotImplementedError, "#{self.class} must implement #perform"
|
|
131
199
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "diagnostic_text"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class ToolFailureNotificationFailed < Error
|
|
8
|
+
DETAIL_NAMES = %i[
|
|
9
|
+
dispatch_error_class dispatch_error_message notification_error_class notification_error_message
|
|
10
|
+
].freeze
|
|
11
|
+
DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
|
|
12
|
+
EXCEPTION_MESSAGE = Exception.instance_method(:message)
|
|
13
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
14
|
+
OBJECT_CLASS = Object.instance_method(:class)
|
|
15
|
+
private_constant :DETAIL_NAMES, :DETAIL_KEYS, :EXCEPTION_MESSAGE, :MODULE_NAME, :OBJECT_CLASS
|
|
16
|
+
|
|
17
|
+
attr_reader :details, :dispatch_error, :notification_error
|
|
18
|
+
|
|
19
|
+
def initialize(dispatch_error:, notification_error:)
|
|
20
|
+
@dispatch_error = dispatch_error
|
|
21
|
+
@notification_error = notification_error
|
|
22
|
+
@details = build_details
|
|
23
|
+
super(
|
|
24
|
+
"host failed to record a terminal tool outcome: " \
|
|
25
|
+
"#{@details.fetch(:notification_error_class)}: #{@details.fetch(:notification_error_message)}"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_details
|
|
30
|
+
{
|
|
31
|
+
dispatch_error_class: DiagnosticText.capture(error_class_name(dispatch_error), max_bytes: 512),
|
|
32
|
+
dispatch_error_message: DiagnosticText.capture(error_message(dispatch_error)),
|
|
33
|
+
notification_error_class: DiagnosticText.capture(error_class_name(notification_error), max_bytes: 512),
|
|
34
|
+
notification_error_message: DiagnosticText.capture(error_message(notification_error))
|
|
35
|
+
}.freeze
|
|
36
|
+
end
|
|
37
|
+
private :build_details
|
|
38
|
+
|
|
39
|
+
def self.from_details(details)
|
|
40
|
+
values = normalize_details(details)
|
|
41
|
+
new(
|
|
42
|
+
dispatch_error: restored_error(values, :dispatch_error),
|
|
43
|
+
notification_error: restored_error(values, :notification_error)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.normalize_details(details)
|
|
48
|
+
raise ArgumentError, "tool failure notification details must be a Hash" unless details.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
values = details.each_with_object({}) do |(key, value), normalized|
|
|
51
|
+
append_detail!(normalized, key, value)
|
|
52
|
+
end
|
|
53
|
+
validate_complete!(values)
|
|
54
|
+
values.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.append_detail!(normalized, key, value)
|
|
58
|
+
name = normalize_detail_name(key)
|
|
59
|
+
raise ArgumentError, "tool failure notification details contain an unknown attribute" unless name
|
|
60
|
+
raise ArgumentError, "tool failure notification details contain a duplicate attribute" if normalized.key?(name)
|
|
61
|
+
raise ArgumentError, "tool failure notification detail values must be strings" unless value.is_a?(String)
|
|
62
|
+
|
|
63
|
+
normalized[name] = value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.validate_complete!(values)
|
|
67
|
+
missing = DETAIL_NAMES - values.keys
|
|
68
|
+
raise ArgumentError, "tool failure notification details are missing required attributes" if missing.any?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.normalize_detail_name(key)
|
|
72
|
+
return key if key.is_a?(Symbol) && DETAIL_NAMES.include?(key)
|
|
73
|
+
|
|
74
|
+
DETAIL_KEYS[key] if key.is_a?(String)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.restored_error(values, prefix)
|
|
78
|
+
RuntimeError.new(
|
|
79
|
+
"#{values.fetch(:"#{prefix}_class")}: #{values.fetch(:"#{prefix}_message")}"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def error_class_name(error)
|
|
84
|
+
error_class = OBJECT_CLASS.bind_call(error)
|
|
85
|
+
MODULE_NAME.bind_call(error_class) || MODULE_NAME.bind_call(error_class.superclass) || "StandardError"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def error_message(error) = EXCEPTION_MESSAGE.bind_call(error)
|
|
89
|
+
private :error_class_name, :error_message
|
|
90
|
+
|
|
91
|
+
private_class_method :normalize_details, :append_detail!, :validate_complete!, :normalize_detail_name,
|
|
92
|
+
:restored_error
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/smith/trace/logger.rb
CHANGED
data/lib/smith/trace/memory.rb
CHANGED
|
@@ -7,26 +7,62 @@ module Smith
|
|
|
7
7
|
transition: :trace_transitions,
|
|
8
8
|
tool_call: :trace_tool_calls,
|
|
9
9
|
token_usage: :trace_token_usage,
|
|
10
|
+
provider_call: :trace_provider_calls,
|
|
10
11
|
cost: :trace_cost,
|
|
11
12
|
normalizer_decision: :trace_normalizer
|
|
12
13
|
}.freeze
|
|
13
14
|
|
|
14
15
|
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
# Generous enough that test and development runs never hit it; a bound
|
|
18
|
+
# exists at all so a long-lived process with parallel branches cannot
|
|
19
|
+
# grow this adapter without limit.
|
|
20
|
+
DEFAULT_LIMIT = 10_000
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
attr_reader :traces, :limit
|
|
23
|
+
|
|
24
|
+
def initialize(limit: DEFAULT_LIMIT)
|
|
25
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
26
|
+
raise ArgumentError, "Smith::Trace::Memory limit must be a positive integer, got #{limit.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@limit = limit
|
|
19
30
|
@traces = []
|
|
31
|
+
@dropped_count = 0
|
|
32
|
+
@mutex = Mutex.new
|
|
20
33
|
end
|
|
21
34
|
|
|
22
35
|
def record(type:, data:)
|
|
23
36
|
return unless type_enabled?(type)
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
entry = { type: type, data: filter_content(data) }
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
if @traces.length >= @limit
|
|
41
|
+
@dropped_count += 1
|
|
42
|
+
else
|
|
43
|
+
@traces << entry
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Entries rejected because the adapter was full. Zero in any healthy
|
|
49
|
+
# test run; a growing value means the limit needs raising or the
|
|
50
|
+
# process needs a clear!.
|
|
51
|
+
def dropped_count
|
|
52
|
+
@mutex.synchronize { @dropped_count }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A consistent copy for readers that may race concurrent recording;
|
|
56
|
+
# #traces stays the live array for compatibility.
|
|
57
|
+
def snapshot
|
|
58
|
+
@mutex.synchronize { @traces.dup }
|
|
26
59
|
end
|
|
27
60
|
|
|
28
61
|
def clear!
|
|
29
|
-
@
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@traces = []
|
|
64
|
+
@dropped_count = 0
|
|
65
|
+
end
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
private
|
|
@@ -7,7 +7,9 @@ module Smith
|
|
|
7
7
|
transition: :trace_transitions,
|
|
8
8
|
tool_call: :trace_tool_calls,
|
|
9
9
|
token_usage: :trace_token_usage,
|
|
10
|
-
|
|
10
|
+
provider_call: :trace_provider_calls,
|
|
11
|
+
cost: :trace_cost,
|
|
12
|
+
normalizer_decision: :trace_normalizer
|
|
11
13
|
}.freeze
|
|
12
14
|
|
|
13
15
|
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
@@ -23,18 +25,60 @@ module Smith
|
|
|
23
25
|
)
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Smith trace events describe operations that already finished, so the
|
|
29
|
+
# span is created retroactively: when the event carries a duration
|
|
30
|
+
# (:tool_call seconds, :provider_call milliseconds) the span's start is
|
|
31
|
+
# backdated by it and the span duration is real; otherwise the span is
|
|
32
|
+
# an instant. Uses only the documented opentelemetry-api surface
|
|
33
|
+
# (Tracer#start_span with start_timestamp, Span#finish with
|
|
34
|
+
# end_timestamp) so any SDK the host installs applies.
|
|
26
35
|
def record(type:, data:)
|
|
27
36
|
return unless @tracer
|
|
28
37
|
return unless type_enabled?(type)
|
|
29
38
|
|
|
30
39
|
filtered = filter_content(data)
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
finished_at = Time.now
|
|
41
|
+
span = @tracer.start_span("smith.#{type}", start_timestamp: span_start(filtered, finished_at))
|
|
42
|
+
begin
|
|
43
|
+
apply_attributes(span, filtered)
|
|
44
|
+
ensure
|
|
45
|
+
span.finish(end_timestamp: finished_at)
|
|
33
46
|
end
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
private
|
|
37
50
|
|
|
51
|
+
def span_start(data, finished_at)
|
|
52
|
+
seconds = duration_seconds(data)
|
|
53
|
+
seconds ? finished_at - seconds : finished_at
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def duration_seconds(data)
|
|
57
|
+
return data[:duration].to_f if data[:duration].is_a?(Numeric)
|
|
58
|
+
return data[:duration_ms] / 1000.0 if data[:duration_ms].is_a?(Numeric)
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def apply_attributes(span, data)
|
|
64
|
+
data.each do |key, value|
|
|
65
|
+
coerced = attribute_value(value)
|
|
66
|
+
span.set_attribute("smith.#{key}", coerced) unless coerced.nil?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# OpenTelemetry attributes accept strings, integers, floats, and
|
|
71
|
+
# booleans; numeric values keep their type instead of arriving as
|
|
72
|
+
# strings, everything else (symbols included) becomes a string, nil
|
|
73
|
+
# drops.
|
|
74
|
+
def attribute_value(value)
|
|
75
|
+
case value
|
|
76
|
+
when String, Integer, Float, true, false then value
|
|
77
|
+
when nil then nil
|
|
78
|
+
else value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
38
82
|
def type_enabled?(type)
|
|
39
83
|
config_key = CONFIG_MAP[type]
|
|
40
84
|
return true unless config_key
|
data/lib/smith/trace.rb
CHANGED
|
@@ -1,34 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "attribution"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
module Trace
|
|
5
7
|
SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
|
|
8
|
+
ADAPTER_MUTEX = Mutex.new
|
|
9
|
+
private_constant :ADAPTER_MUTEX
|
|
6
10
|
|
|
7
11
|
def self.record(type:, data:, sensitivity: :low)
|
|
8
12
|
adapter = resolve_adapter
|
|
9
13
|
return unless adapter
|
|
10
14
|
|
|
11
|
-
filtered = apply_content_policy(data, sensitivity)
|
|
15
|
+
filtered = apply_content_policy(attributed(data), sensitivity)
|
|
12
16
|
filtered = filter_fields(type, filtered)
|
|
13
17
|
adapter.record(type: type, data: filtered)
|
|
14
18
|
rescue StandardError => e
|
|
15
19
|
Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
|
|
16
20
|
end
|
|
17
21
|
|
|
22
|
+
# Ambient attribution keys are identifiers, not content: they merge in
|
|
23
|
+
# under the caller's own keys (the caller wins on conflict) and then pass
|
|
24
|
+
# through the same content policy and field allowlist as everything else,
|
|
25
|
+
# so a host's configured trace_fields contract keeps holding.
|
|
26
|
+
def self.attributed(data)
|
|
27
|
+
return data unless Smith.config.trace_attribution
|
|
28
|
+
|
|
29
|
+
fields = Attribution.current_fields
|
|
30
|
+
return data if fields.empty?
|
|
31
|
+
|
|
32
|
+
fields.merge(data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Class-configured adapters memoize one instance under a mutex so
|
|
36
|
+
# concurrent first records (every fan-out branch emits) share a single
|
|
37
|
+
# adapter instead of racing separate instances and losing entries.
|
|
18
38
|
def self.resolve_adapter
|
|
19
39
|
configured = Smith.config.trace_adapter
|
|
20
40
|
return nil unless configured
|
|
41
|
+
return configured unless configured.is_a?(Class)
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
ADAPTER_MUTEX.synchronize do
|
|
23
44
|
@adapter_instances ||= {}
|
|
24
45
|
@adapter_instances[configured] ||= configured.new
|
|
25
|
-
else
|
|
26
|
-
configured
|
|
27
46
|
end
|
|
28
47
|
end
|
|
29
48
|
|
|
30
49
|
def self.reset!
|
|
31
|
-
@adapter_instances = nil
|
|
50
|
+
ADAPTER_MUTEX.synchronize { @adapter_instances = nil }
|
|
32
51
|
end
|
|
33
52
|
|
|
34
53
|
def self.apply_content_policy(data, sensitivity)
|
data/lib/smith/version.rb
CHANGED
|
@@ -4,16 +4,17 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
6
|
AgentResult = Struct.new(
|
|
7
|
-
:content, :input_tokens, :output_tokens, :cost, :model_used,
|
|
7
|
+
:content, :input_tokens, :output_tokens, :cost, :model_used, :provider_used,
|
|
8
8
|
keyword_init: true
|
|
9
9
|
) do
|
|
10
|
-
def self.from_response(response, content, model_used: nil)
|
|
10
|
+
def self.from_response(response, content, model_used: nil, provider_used: nil)
|
|
11
11
|
new(
|
|
12
12
|
content: content,
|
|
13
13
|
input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
14
14
|
output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
|
|
15
15
|
cost: nil,
|
|
16
|
-
model_used: model_used
|
|
16
|
+
model_used: model_used,
|
|
17
|
+
provider_used: provider_used
|
|
17
18
|
)
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -4,19 +4,32 @@ require_relative "../../types"
|
|
|
4
4
|
require_relative "../../budget/decimal_context"
|
|
5
5
|
require_relative "../message_value_normalizer"
|
|
6
6
|
require_relative "../prepared_step"
|
|
7
|
+
require_relative "../usage_entry"
|
|
7
8
|
require_relative "payload"
|
|
8
9
|
|
|
9
10
|
module Smith
|
|
10
11
|
class Workflow
|
|
11
12
|
module Composite
|
|
12
|
-
|
|
13
|
+
# Length is fail-closed value validation, not logic: every key the
|
|
14
|
+
# contract admits gets a bounded value check beside the contract that
|
|
15
|
+
# admits it. Splitting the checks away from the payload they guard
|
|
16
|
+
# would trade cohesion for a metric.
|
|
17
|
+
class Effects < Payload # rubocop:disable Metrics/ClassLength
|
|
13
18
|
attr_reader :total_tokens, :total_cost
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
# Required keys are the pre-attribution UsageEntry shape, so effects
|
|
21
|
+
# produced by an older Smith (a mid-deploy branch worker or a
|
|
22
|
+
# restored checkpoint) stay valid; allowed keys are the current
|
|
23
|
+
# member set, so unknown keys still reject. The attribution members
|
|
24
|
+
# (transition, branch_key, round, attempt_id) are optional by
|
|
25
|
+
# construction.
|
|
26
|
+
USAGE_ALLOWED_ATTRIBUTES = Workflow::UsageEntry.members.map(&:to_s).freeze
|
|
27
|
+
USAGE_REQUIRED_ATTRIBUTES =
|
|
28
|
+
(USAGE_ALLOWED_ATTRIBUTES - %w[transition branch_key round attempt_id workflow]).freeze
|
|
29
|
+
TOOL_REQUIRED_ATTRIBUTES = %w[tool captured].freeze
|
|
30
|
+
TOOL_ALLOWED_ATTRIBUTES = (TOOL_REQUIRED_ATTRIBUTES + %w[tool_call_id]).freeze
|
|
31
|
+
private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES,
|
|
32
|
+
:TOOL_REQUIRED_ATTRIBUTES, :TOOL_ALLOWED_ATTRIBUTES
|
|
20
33
|
|
|
21
34
|
attribute :usage_entries, Types::Array
|
|
22
35
|
attribute :tool_results, Types::Array
|
|
@@ -25,17 +38,12 @@ module Smith
|
|
|
25
38
|
def initialize(attributes)
|
|
26
39
|
owned = self.class.normalize_attributes(attributes)
|
|
27
40
|
normalized = MessageValueNormalizer.new(owned, label: "composite effects").call
|
|
28
|
-
usage_entries =
|
|
29
|
-
|
|
30
|
-
budget_consumed = normalized.fetch("budget_consumed")
|
|
41
|
+
usage_entries, tool_results, budget_consumed =
|
|
42
|
+
normalized.values_at("usage_entries", "tool_results", "budget_consumed")
|
|
31
43
|
@total_tokens, @total_cost = validate_usage_entries!(usage_entries)
|
|
32
44
|
validate_tool_results!(tool_results)
|
|
33
45
|
validate_budget!(budget_consumed)
|
|
34
|
-
super(
|
|
35
|
-
usage_entries:,
|
|
36
|
-
tool_results:,
|
|
37
|
-
budget_consumed:
|
|
38
|
-
)
|
|
46
|
+
super(usage_entries:, tool_results:, budget_consumed:)
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
private
|
|
@@ -44,8 +52,9 @@ module Smith
|
|
|
44
52
|
raise ArgumentError, "composite usage entries must be an Array" unless entries.is_a?(Array)
|
|
45
53
|
|
|
46
54
|
entries.each do |entry|
|
|
47
|
-
|
|
55
|
+
validate_bounded_keys!(entry, USAGE_REQUIRED_ATTRIBUTES, USAGE_ALLOWED_ATTRIBUTES, "composite usage entry")
|
|
48
56
|
validate_usage_identity!(entry)
|
|
57
|
+
validate_usage_attribution!(entry)
|
|
49
58
|
validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
|
|
50
59
|
validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
|
|
51
60
|
validate_cost!(entry.fetch("cost"))
|
|
@@ -68,23 +77,40 @@ module Smith
|
|
|
68
77
|
end
|
|
69
78
|
|
|
70
79
|
def validate_usage_identity!(entry)
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
validate_uuid!(entry.fetch("usage_id"), "composite usage entry usage_id")
|
|
81
|
+
# agent_name and provider are nil-allowed; any other value
|
|
82
|
+
# (false included) must be a non-empty String.
|
|
83
|
+
%w[agent_name provider].each do |key|
|
|
84
|
+
value = entry.fetch(key)
|
|
85
|
+
validate_nonempty_string!(value, "composite usage entry #{key}") unless value.nil?
|
|
86
|
+
end
|
|
73
87
|
%w[model attempt_kind recorded_at].each do |key|
|
|
74
88
|
validate_nonempty_string!(entry.fetch(key), "composite usage entry #{key}")
|
|
75
89
|
end
|
|
76
90
|
end
|
|
77
91
|
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
# The optional attribution keys are bounded values, not just bounded
|
|
93
|
+
# keys: a present key with a wrong-typed, empty, or oversized value
|
|
94
|
+
# rejects exactly like the identity fields do. Absent keys (older
|
|
95
|
+
# producers, or nil-omitting serialization) stay valid.
|
|
96
|
+
def validate_usage_attribution!(entry)
|
|
97
|
+
%w[transition branch_key workflow].each do |key|
|
|
98
|
+
validate_bounded_string!(entry.fetch(key), "composite usage entry #{key}", 256) if entry.key?(key)
|
|
99
|
+
end
|
|
100
|
+
validate_usage_amount!(entry.fetch("round"), "round") if entry.key?("round")
|
|
101
|
+
validate_uuid!(entry.fetch("attempt_id"), "composite usage entry attempt_id") if entry.key?("attempt_id")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_uuid!(value, label)
|
|
105
|
+
return if value.is_a?(String) && PreparedStep::UUID_PATTERN.match?(value)
|
|
80
106
|
|
|
81
|
-
raise ArgumentError, "
|
|
107
|
+
raise ArgumentError, "#{label} must be a UUID"
|
|
82
108
|
end
|
|
83
109
|
|
|
84
|
-
def
|
|
85
|
-
return if
|
|
110
|
+
def validate_bounded_string!(value, label, max_length)
|
|
111
|
+
return if value.is_a?(String) && value.length.between?(1, max_length)
|
|
86
112
|
|
|
87
|
-
|
|
113
|
+
raise ArgumentError, "#{label} must be a bounded non-empty String"
|
|
88
114
|
end
|
|
89
115
|
|
|
90
116
|
def validate_nonempty_string!(value, label)
|
|
@@ -100,8 +126,7 @@ module Smith
|
|
|
100
126
|
end
|
|
101
127
|
|
|
102
128
|
def validate_cost!(cost)
|
|
103
|
-
return if cost.nil?
|
|
104
|
-
return if cost.is_a?(Numeric) && cost.finite? && cost >= 0
|
|
129
|
+
return if cost.nil? || (cost.is_a?(Numeric) && cost.finite? && cost >= 0)
|
|
105
130
|
|
|
106
131
|
raise ArgumentError, "composite usage entry cost must be a finite non-negative number or nil"
|
|
107
132
|
end
|
|
@@ -110,10 +135,15 @@ module Smith
|
|
|
110
135
|
raise ArgumentError, "composite tool results must be an Array" unless entries.is_a?(Array)
|
|
111
136
|
|
|
112
137
|
entries.each do |entry|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
validate_bounded_keys!(entry, TOOL_REQUIRED_ATTRIBUTES, TOOL_ALLOWED_ATTRIBUTES, "composite tool result")
|
|
139
|
+
validate_bounded_string!(entry.fetch("tool"), "composite tool result tool", 256)
|
|
140
|
+
|
|
141
|
+
# Present only for provider-batch invocations; the producer never
|
|
142
|
+
# writes a nil, so a present key must carry a real id. Provider
|
|
143
|
+
# tool-call ids are short strings; 1024 is far above any observed
|
|
144
|
+
# provider format while still bounding the payload.
|
|
145
|
+
if entry.key?("tool_call_id")
|
|
146
|
+
validate_bounded_string!(entry.fetch("tool_call_id"), "composite tool result tool_call_id", 1024)
|
|
117
147
|
end
|
|
118
148
|
end
|
|
119
149
|
end
|
|
@@ -128,9 +158,12 @@ module Smith
|
|
|
128
158
|
end
|
|
129
159
|
end
|
|
130
160
|
|
|
131
|
-
|
|
161
|
+
# Every required key present, no key outside the allowed set: older
|
|
162
|
+
# producers (missing optional keys) pass, unknown keys still reject.
|
|
163
|
+
# Passing the same set for both is an exact-keys check.
|
|
164
|
+
def validate_bounded_keys!(value, required, allowed, label)
|
|
132
165
|
raise ArgumentError, "#{label} must be a Hash" unless value.is_a?(Hash)
|
|
133
|
-
return if value.keys.
|
|
166
|
+
return if (required - value.keys).empty? && (value.keys - allowed).empty?
|
|
134
167
|
|
|
135
168
|
raise ArgumentError, "#{label} attributes are invalid"
|
|
136
169
|
end
|