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
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
- ensure_capture_ready!
79
- run_before_execute_hook!(kwargs)
80
- check_tool_deadline!
81
- check_privilege!(kwargs)
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: { tool: name, args: kwargs, result: result, duration: duration },
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Smith
6
+ class ToolExecutionNotAdmitted < Error; end
7
+ 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
@@ -7,6 +7,7 @@ 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
@@ -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
- attr_reader :traces
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
- def initialize
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
- @traces << { type: type, data: filter_content(data) }
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
- @traces = []
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
- cost: :trace_cost
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
- @tracer.in_span("smith.#{type}") do |span|
32
- filtered.each { |key, value| span.set_attribute("smith.#{key}", value.to_s) }
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
- if configured.is_a?(Class)
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.7.0"
5
- EXECUTION_SEMANTICS_VERSION = "3"
4
+ VERSION = "0.10.0"
5
+ EXECUTION_SEMANTICS_VERSION = "5"
6
6
  end
@@ -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
 
@@ -33,7 +33,8 @@ module Smith
33
33
  Context::Session.new(
34
34
  messages: messages,
35
35
  context_manager: manager,
36
- persisted_context: @context
36
+ persisted_context: @context,
37
+ seed_message_count: @seed_message_count || 0
37
38
  )
38
39
  end
39
40
  end
@@ -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
- class Effects < Payload
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
- USAGE_ATTRIBUTES = %w[
16
- usage_id agent_name model input_tokens output_tokens cost attempt_kind recorded_at
17
- ].freeze
18
- TOOL_ATTRIBUTES = %w[tool captured].freeze
19
- private_constant :USAGE_ATTRIBUTES, :TOOL_ATTRIBUTES
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 = normalized.fetch("usage_entries")
29
- tool_results = normalized.fetch("tool_results")
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
- validate_exact_keys!(entry, USAGE_ATTRIBUTES, "composite usage entry")
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
- validate_usage_id!(entry.fetch("usage_id"))
72
- validate_agent_name!(entry.fetch("agent_name"))
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
- def validate_usage_id!(usage_id)
79
- return if usage_id.is_a?(String) && PreparedStep::UUID_PATTERN.match?(usage_id)
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, "composite usage entry usage_id must be a UUID"
107
+ raise ArgumentError, "#{label} must be a UUID"
82
108
  end
83
109
 
84
- def validate_agent_name!(agent_name)
85
- return if agent_name.nil?
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
- validate_nonempty_string!(agent_name, "composite usage entry agent_name")
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
- validate_exact_keys!(entry, TOOL_ATTRIBUTES, "composite tool result")
114
- tool = entry.fetch("tool")
115
- unless tool.is_a?(String) && tool.length.between?(1, 256)
116
- raise ArgumentError, "composite tool result tool must be a bounded non-empty String"
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
- def validate_exact_keys!(value, expected, label)
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.sort == expected.sort
166
+ return if (required - value.keys).empty? && (value.keys - allowed).empty?
134
167
 
135
168
  raise ArgumentError, "#{label} attributes are invalid"
136
169
  end