smith-agents 0.4.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 (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. metadata +258 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ def smith_load_environment
4
+ Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
5
+ require "smith"
6
+ require "smith/doctor"
7
+ end
8
+
9
+ namespace :smith do
10
+ desc "Verify Smith integration (offline)"
11
+ task :doctor do
12
+ smith_load_environment
13
+ report = Smith::Doctor.run
14
+ exit report.exit_code unless report.passed?
15
+ end
16
+
17
+ namespace :doctor do
18
+ desc "Verify Smith integration with live provider call"
19
+ task :live do
20
+ smith_load_environment
21
+ report = Smith::Doctor.run(live: true)
22
+ exit report.exit_code unless report.passed?
23
+ end
24
+
25
+ desc "Verify Smith workflow durability"
26
+ task :durability do
27
+ smith_load_environment
28
+ report = Smith::Doctor.run(durability: true)
29
+ exit report.exit_code unless report.passed?
30
+ end
31
+ end
32
+
33
+ desc "Scaffold Smith configuration files"
34
+ task :install do
35
+ smith_load_environment
36
+ Smith::Doctor::Installer.run
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module BudgetEnforcement
6
+ private
7
+
8
+ def charge_tool_call!
9
+ allowance = self.class.current_tool_call_allowance
10
+ ledger = self.class.current_ledger
11
+ workflow_active = ledger&.limits&.key?(:tool_calls)
12
+
13
+ check_agent_tool_calls!(allowance)
14
+ commit_tool_call_charges!(ledger, allowance, workflow_active)
15
+ end
16
+
17
+ def check_agent_tool_calls!(allowance)
18
+ return unless allowance
19
+
20
+ raise BudgetExceeded, "agent tool_calls budget exceeded" if allowance[:remaining] <= 0
21
+ end
22
+
23
+ def commit_tool_call_charges!(ledger, allowance, workflow_active)
24
+ if workflow_active
25
+ ledger.reserve!(:tool_calls, 1)
26
+ ledger.reconcile!(:tool_calls, 1, 1)
27
+ end
28
+
29
+ allowance[:remaining] -= 1 if allowance
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class CapabilityBuilder
6
+ def initialize
7
+ @capabilities = {}
8
+ end
9
+
10
+ def sensitivity(value) = @capabilities[:sensitivity] = value
11
+ def privilege(value) = @capabilities[:privilege] = value
12
+ def network(value) = @capabilities[:network] = value
13
+ def approval(value) = @capabilities[:approval] = value
14
+ def data_volume(value) = @capabilities[:data_volume] = value
15
+ def to_h = @capabilities
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module Capture
6
+ private
7
+
8
+ def capture_result_if_configured(kwargs, result)
9
+ block = self.class.capture_result
10
+ return unless block
11
+
12
+ collector = self.class.current_tool_result_collector
13
+ return unless collector
14
+
15
+ captured = block.call(kwargs, result)
16
+ collector.call({ tool: name.to_s, captured: captured }) if captured
17
+ rescue StandardError => e
18
+ Smith.config.logger&.warn("[Smith] capture_result failed for #{name}: #{e.message}")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool
5
+ # Compatibility spec for a Tool class. Built by Tool.compatible_with(...)
6
+ # and consulted by Smith::Models::Normalizer when deciding whether
7
+ # to route, drop, or pass through a tool.
8
+ #
9
+ # Spec shape (frozen Hash):
10
+ # providers: Set[Symbol]?, # allowlist; nil = all allowed
11
+ # endpoints: Hash[Symbol => Set], # per-provider endpoint constraints
12
+ # except: Hash[Symbol => Set]? # exception list (overrides allow)
13
+ #
14
+ # Tools that don't declare compatible_with are universally compatible
15
+ # — Compatibility.allows?(nil, profile) returns true.
16
+ module Compatibility
17
+ module_function
18
+
19
+ # Parses the DSL invocation:
20
+ # compatible_with :anthropic
21
+ # compatible_with :anthropic, :gemini, openai: :responses
22
+ # compatible_with except: { openai: :chat_completions }
23
+ def parse(positional, except:, **provider_endpoints)
24
+ providers_arg = positional + provider_endpoints.keys
25
+ providers = if providers_arg.empty?
26
+ nil
27
+ else
28
+ providers_arg.map(&:to_sym).to_set
29
+ end
30
+ endpoints = provider_endpoints.transform_values { |v| Array(v).map(&:to_sym).to_set }
31
+ except_set = except&.transform_values { |v| Array(v).map(&:to_sym).to_set }
32
+
33
+ {
34
+ providers: providers,
35
+ endpoints: endpoints,
36
+ except: except_set
37
+ }.freeze
38
+ end
39
+
40
+ # Returns true if the (provider, endpoint) combination is allowed
41
+ # by spec. `effective_endpoint` defaults to profile.endpoint_mode
42
+ # but callers (e.g., Smith::Models::Normalizer) can override when
43
+ # user policy downgrades the endpoint — e.g., a profile with
44
+ # tools_with_thinking_route: :responses still has its tools checked
45
+ # against :chat_completions when Smith.config.openai_api_mode is
46
+ # :off (no routing).
47
+ #
48
+ # spec == nil => universally compatible (no compatible_with declared).
49
+ def allows?(spec, profile, effective_endpoint: nil)
50
+ return true if spec.nil?
51
+
52
+ provider = profile.provider
53
+ endpoint = effective_endpoint || profile.endpoint_mode
54
+
55
+ # exception list: explicit deny wins
56
+ if (excluded = spec[:except]&.[](provider)) && excluded.include?(endpoint)
57
+ return false
58
+ end
59
+
60
+ # allowlist by provider (nil = all allowed)
61
+ return false if spec[:providers] && !spec[:providers].include?(provider)
62
+
63
+ # endpoint constraint when present for the matched provider
64
+ if (allowed_endpoints = spec[:endpoints][provider])
65
+ return allowed_endpoints.include?(endpoint)
66
+ end
67
+
68
+ true
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module Policy
6
+ private
7
+
8
+ def check_privilege!(kwargs)
9
+ privilege = self.class.capabilities&.dig(:privilege)
10
+ return if privilege.nil? || privilege == :none
11
+
12
+ context = kwargs[:context] || {}
13
+ enforce_privilege!(privilege, context)
14
+ end
15
+
16
+ def enforce_privilege!(privilege, context)
17
+ require_authenticated!(context) if %i[authenticated elevated].include?(privilege)
18
+ require_elevated!(context) if privilege == :elevated
19
+ end
20
+
21
+ def require_authenticated!(context)
22
+ raise ToolPolicyDenied, "privilege requires context[:user]" unless context[:user]
23
+ end
24
+
25
+ def require_elevated!(context)
26
+ return if context[:role] == :elevated
27
+
28
+ raise ToolPolicyDenied, "privilege :elevated requires context[:role] == :elevated"
29
+ end
30
+
31
+ def check_authorization!(kwargs)
32
+ authorizer = self.class.authorize
33
+ return unless authorizer
34
+
35
+ context = kwargs[:context]
36
+ raise ToolPolicyDenied unless authorizer.call(context)
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/smith/tool.rb ADDED
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ require_relative "tool/capability_builder"
6
+ require_relative "tool/policy"
7
+ require_relative "tool/budget_enforcement"
8
+ require_relative "tool/capture"
9
+ require_relative "tool/compatibility"
10
+
11
+ module Smith
12
+ class Tool < RubyLLM::Tool
13
+ include Policy
14
+ include BudgetEnforcement
15
+ include Capture
16
+
17
+ class << self
18
+ # Tool subclasses inherit the parent's compatible_with spec by
19
+ # reference (the spec is a frozen Hash; immutability makes shared
20
+ # references safe). Subclasses can override by calling
21
+ # `compatible_with` again — assigns a NEW frozen Hash to its own
22
+ # @compatible_with_spec, leaving the parent untouched.
23
+ def inherited(subclass)
24
+ super
25
+ subclass.instance_variable_set(:@compatible_with_spec, @compatible_with_spec)
26
+ end
27
+
28
+ # Declarative compatibility DSL. Examples:
29
+ # compatible_with :anthropic, :gemini
30
+ # compatible_with :anthropic, :gemini, openai: :responses
31
+ # compatible_with except: { openai: :chat_completions }
32
+ #
33
+ # Tools that NEVER declare compatible_with are universally compatible.
34
+ # Consumed by Smith::Models::Normalizer.drop_incompatible_tools when
35
+ # the resolved model rejects the (tools + thinking) combo and no
36
+ # routing fallback (e.g., openai_api_mode :auto) is available.
37
+ def compatible_with(*providers, except: nil, **provider_endpoints)
38
+ @compatible_with_spec = Compatibility.parse(providers, except: except, **provider_endpoints)
39
+ end
40
+
41
+ attr_reader :compatible_with_spec
42
+
43
+ def current_guardrails
44
+ Thread.current[:smith_tool_guardrails]
45
+ end
46
+
47
+ def current_guardrails=(value)
48
+ Thread.current[:smith_tool_guardrails] = value
49
+ end
50
+
51
+ def current_deadline
52
+ Thread.current[:smith_tool_deadline]
53
+ end
54
+
55
+ def current_deadline=(value)
56
+ Thread.current[:smith_tool_deadline] = value
57
+ end
58
+
59
+ def current_ledger
60
+ Thread.current[:smith_tool_ledger]
61
+ end
62
+
63
+ def current_ledger=(value)
64
+ Thread.current[:smith_tool_ledger] = value
65
+ end
66
+
67
+ def current_tool_call_allowance
68
+ Thread.current[:smith_tool_call_allowance]
69
+ end
70
+
71
+ def current_tool_call_allowance=(value)
72
+ Thread.current[:smith_tool_call_allowance] = value
73
+ end
74
+
75
+ def current_tool_result_collector
76
+ Thread.current[:smith_tool_result_collector]
77
+ end
78
+
79
+ def current_tool_result_collector=(value)
80
+ Thread.current[:smith_tool_result_collector] = value
81
+ end
82
+
83
+ def category(value = nil)
84
+ return @category if value.nil?
85
+
86
+ @category = value
87
+ end
88
+
89
+ def capabilities(&)
90
+ return @capabilities unless block_given?
91
+
92
+ builder = CapabilityBuilder.new
93
+ builder.instance_eval(&)
94
+ @capabilities = builder.to_h
95
+ end
96
+
97
+ def authorize(&block)
98
+ return @authorize unless block_given?
99
+
100
+ @authorize = block
101
+ end
102
+
103
+ def before_execute(&block)
104
+ return @before_execute unless block_given?
105
+
106
+ @before_execute = block
107
+ end
108
+
109
+ def capture_result(&block)
110
+ return @capture_result unless block_given?
111
+
112
+ @capture_result = block
113
+ end
114
+ end
115
+
116
+ def execute(**kwargs)
117
+ run_before_execute_hook!(kwargs)
118
+ check_tool_deadline!
119
+ check_privilege!(kwargs)
120
+ check_authorization!(kwargs)
121
+ run_tool_guardrails!(kwargs)
122
+ check_tool_deadline!
123
+ charge_tool_call!
124
+
125
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
126
+ result = perform(**kwargs)
127
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
128
+
129
+ emit_tool_trace(kwargs, result, duration)
130
+ capture_result_if_configured(kwargs, result)
131
+ result
132
+ end
133
+
134
+ private
135
+
136
+ def run_before_execute_hook!(kwargs)
137
+ hook = self.class.before_execute
138
+ return unless hook
139
+
140
+ hook.call(self, kwargs)
141
+ end
142
+
143
+ def run_tool_guardrails!(kwargs)
144
+ guardrails_classes = self.class.current_guardrails
145
+ return unless guardrails_classes
146
+
147
+ Array(guardrails_classes).each do |guardrails_class|
148
+ Guardrails::Runner.run_tool(guardrails_class, name.to_sym, kwargs)
149
+ end
150
+ end
151
+
152
+ def emit_tool_trace(kwargs, result, duration)
153
+ Smith::Trace.record(
154
+ type: :tool_call,
155
+ data: { tool: name, args: kwargs, result: result, duration: duration },
156
+ sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
157
+ )
158
+ end
159
+
160
+ def check_tool_deadline!
161
+ deadline = self.class.current_deadline
162
+ return unless deadline
163
+
164
+ raise DeadlineExceeded, "wall_clock deadline exceeded during tool execution" if Time.now.utc >= deadline
165
+ end
166
+
167
+ def perform(**kwargs)
168
+ raise NotImplementedError, "#{self.class} must implement #perform"
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Tools
5
+ class Think < Smith::Tool
6
+ description "Think through your approach between steps. " \
7
+ "Plan what to do next, evaluate progress, and identify gaps."
8
+ category :computation
9
+
10
+ # Compatible with Anthropic (extended thinking is native), Gemini
11
+ # (thinking is the default request shape), and OpenAI BUT ONLY on
12
+ # /v1/responses — chat-completions rejects function tools combined
13
+ # with reasoning_effort for the gpt-5 family. The normalizer uses
14
+ # this spec when deciding whether to drop Think on a model whose
15
+ # profile rejects (tools + thinking) AND no routing fallback exists.
16
+ compatible_with :anthropic, :gemini, openai: :responses
17
+
18
+ param :thought, type: :string, required: true
19
+
20
+ def perform(thought:) # rubocop:disable Lint/UnusedMethodArgument
21
+ { acknowledged: true }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Tools
5
+ class UrlFetcher < Smith::Tool
6
+ description "Fetch the content of a specific URL"
7
+ category :data_access
8
+
9
+ param :url, type: :string, required: true
10
+
11
+ def perform(url:)
12
+ raise NotImplementedError, "#{self.class} requires a host-app implementation"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Tools
5
+ class WebSearch < Smith::Tool
6
+ description "Search the web for current information on a topic"
7
+ category :data_access
8
+
9
+ param :query, type: :string, required: true
10
+ param :max_results, type: :integer, required: false
11
+
12
+ def perform(query:, max_results: 5)
13
+ raise NotImplementedError, "#{self.class} requires a host-app implementation"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Tools; end
5
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Trace
5
+ class Logger
6
+ CONFIG_MAP = {
7
+ transition: :trace_transitions,
8
+ tool_call: :trace_tool_calls,
9
+ token_usage: :trace_token_usage,
10
+ cost: :trace_cost,
11
+ normalizer_decision: :trace_normalizer
12
+ }.freeze
13
+
14
+ CONTENT_KEYS = %i[content prompt response args result].freeze
15
+
16
+ def record(type:, data:)
17
+ return unless type_enabled?(type)
18
+
19
+ logger = Smith.config.logger
20
+ return unless logger
21
+
22
+ logger.info("[Smith::Trace] #{type}: #{filter_content(data).inspect}")
23
+ end
24
+
25
+ private
26
+
27
+ def type_enabled?(type)
28
+ config_key = CONFIG_MAP[type]
29
+ return true unless config_key
30
+
31
+ Smith.config.send(config_key) != false
32
+ end
33
+
34
+ def filter_content(data)
35
+ case Smith.config.trace_content
36
+ when true
37
+ data
38
+ when :redacted
39
+ data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
40
+ else
41
+ data.except(*CONTENT_KEYS)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Trace
5
+ class Memory
6
+ CONFIG_MAP = {
7
+ transition: :trace_transitions,
8
+ tool_call: :trace_tool_calls,
9
+ token_usage: :trace_token_usage,
10
+ cost: :trace_cost,
11
+ normalizer_decision: :trace_normalizer
12
+ }.freeze
13
+
14
+ CONTENT_KEYS = %i[content prompt response args result].freeze
15
+
16
+ attr_reader :traces
17
+
18
+ def initialize
19
+ @traces = []
20
+ end
21
+
22
+ def record(type:, data:)
23
+ return unless type_enabled?(type)
24
+
25
+ @traces << { type: type, data: filter_content(data) }
26
+ end
27
+
28
+ def clear!
29
+ @traces = []
30
+ end
31
+
32
+ private
33
+
34
+ def type_enabled?(type)
35
+ config_key = CONFIG_MAP[type]
36
+ return true unless config_key
37
+
38
+ Smith.config.send(config_key) != false
39
+ end
40
+
41
+ def filter_content(data)
42
+ case Smith.config.trace_content
43
+ when true
44
+ data
45
+ when :redacted
46
+ data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
47
+ else
48
+ data.except(*CONTENT_KEYS)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Trace
5
+ class OpenTelemetry
6
+ CONFIG_MAP = {
7
+ transition: :trace_transitions,
8
+ tool_call: :trace_tool_calls,
9
+ token_usage: :trace_token_usage,
10
+ cost: :trace_cost
11
+ }.freeze
12
+
13
+ CONTENT_KEYS = %i[content prompt response args result].freeze
14
+
15
+ def initialize
16
+ require "opentelemetry-api"
17
+ @tracer = ::OpenTelemetry.tracer_provider.tracer("smith", Smith::VERSION)
18
+ rescue LoadError
19
+ @tracer = nil
20
+ Smith.config.logger&.warn(
21
+ "Smith::Trace::OpenTelemetry requires the opentelemetry-api gem. " \
22
+ "Add it to your Gemfile to enable OpenTelemetry tracing."
23
+ )
24
+ end
25
+
26
+ def record(type:, data:)
27
+ return unless @tracer
28
+ return unless type_enabled?(type)
29
+
30
+ 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) }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def type_enabled?(type)
39
+ config_key = CONFIG_MAP[type]
40
+ return true unless config_key
41
+
42
+ Smith.config.send(config_key) != false
43
+ end
44
+
45
+ def filter_content(data)
46
+ case Smith.config.trace_content
47
+ when true
48
+ data
49
+ when :redacted
50
+ data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
51
+ else
52
+ data.except(*CONTENT_KEYS)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end