smith-agents 0.7.0 → 0.8.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  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 +25 -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 +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +29 -197
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +15 -7
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +101 -28
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  60. data/lib/smith/tool/chat_execution_context.rb +7 -88
  61. data/lib/smith/tool/execution_authority.rb +42 -0
  62. data/lib/smith/tool/execution_authorization.rb +17 -0
  63. data/lib/smith/tool/execution_batch.rb +100 -0
  64. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  65. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  66. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  67. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  68. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  69. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  70. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  71. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  72. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  73. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  74. data/lib/smith/tool/execution_batch_state.rb +110 -0
  75. data/lib/smith/tool/execution_dispatch.rb +78 -0
  76. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  77. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  78. data/lib/smith/tool/execution_tracker.rb +20 -0
  79. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  80. data/lib/smith/tool/graceful_completion.rb +20 -0
  81. data/lib/smith/tool/invocation.rb +27 -0
  82. data/lib/smith/tool/invocation_request.rb +37 -0
  83. data/lib/smith/tool/invocation_sequence.rb +26 -0
  84. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  85. data/lib/smith/tool/scoped_context.rb +60 -8
  86. data/lib/smith/tool.rb +71 -12
  87. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  88. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  89. data/lib/smith/version.rb +2 -2
  90. data/lib/smith/workflow/agent_result.rb +4 -3
  91. data/lib/smith/workflow/artifact_integration.rb +2 -1
  92. data/lib/smith/workflow/composite/effects.rb +3 -1
  93. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  94. data/lib/smith/workflow/execution.rb +2 -9
  95. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  96. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  97. data/lib/smith/workflow/failure_record.rb +139 -0
  98. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  99. data/lib/smith/workflow/failure_record_text.rb +83 -0
  100. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  101. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  102. data/lib/smith/workflow/parallel.rb +14 -3
  103. data/lib/smith/workflow/persistence.rb +22 -19
  104. data/lib/smith/workflow/retry_execution.rb +5 -1
  105. data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
  106. data/lib/smith/workflow/transition.rb +9 -4
  107. data/lib/smith/workflow/usage_entry.rb +12 -2
  108. data/lib/smith/workflow.rb +17 -130
  109. data/lib/smith.rb +3 -0
  110. metadata +78 -11
data/lib/smith/models.rb CHANGED
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry-container"
4
- require "monitor"
4
+
5
+ require_relative "models/profile"
6
+ require_relative "models/ambiguous_profile_error"
7
+ require_relative "models/collision_error"
8
+ require_relative "models/provider_qualified_registry"
5
9
 
6
10
  module Smith
7
11
  # Capability registry for model ids. Decoupled from Smith.config.pricing
@@ -21,97 +25,47 @@ module Smith
21
25
  # 3. Safe default (no thinking, accepts temp, no routing)
22
26
  module Models
23
27
  extend Dry::Container::Mixin
24
-
25
- class CollisionError < Smith::Error; end
28
+ extend ProviderQualifiedRegistry
26
29
 
27
30
  def self.normalize_key(model_id)
28
31
  model_id.to_s
29
32
  end
30
33
 
31
- def self.find(model_id)
32
- registry_monitor.synchronize do
33
- key = normalize_key(model_id)
34
- key?(key) ? resolve(key) : nil
35
- end
36
- end
37
-
38
34
  # Application overrides first, then Inference rules, then safe default.
39
35
  def self.find_or_infer(model_id, provider: nil)
40
- find(model_id) || infer(model_id, provider: provider)
36
+ find(model_id, provider:) || infer(model_id, provider:)
41
37
  end
42
38
 
43
39
  def self.infer(model_id, provider: nil)
44
40
  inferred = Inference.profile_for(model_id) if defined?(Inference)
45
- return inferred if inferred
41
+ return inferred_profile_for_provider(inferred, provider) if inferred
46
42
 
47
43
  Profile.new(
48
- model_id: normalize_key(model_id),
49
- provider: provider || guess_provider(model_id),
50
- thinking_shape: nil,
51
- accepts_temperature: true,
44
+ model_id: normalize_key(model_id),
45
+ provider: provider || guess_provider(model_id),
46
+ thinking_shape: nil,
47
+ accepts_temperature: true,
52
48
  tools_with_thinking_native: false,
53
- tools_with_thinking_route: nil
49
+ tools_with_thinking_route: nil
54
50
  )
55
51
  end
56
52
 
57
- # Register a Profile. Idempotent when re-registering an identical
58
- # profile; replaces silently on Rails-reload (same model_id, possibly
59
- # different Profile object after autoload swap); raises CollisionError
60
- # on a genuinely conflicting registration.
61
- #
62
- # The stale-reload-binding pattern mirrors Smith::Agent::Registry
63
- # (agent/registry.rb:118-124) which solves the same problem for
64
- # agent classes during Rails autoreload.
65
- def self.register(profile)
66
- registry_monitor.synchronize do
67
- key = normalize_key(profile.model_id)
68
- existing = key?(key) ? resolve(key) : nil
69
-
70
- return profile if existing == profile
53
+ def self.inferred_profile_for_provider(profile, provider)
54
+ return profile unless provider && profile.provider != provider.to_sym
71
55
 
72
- if existing && stale_reload_binding?(existing, profile)
73
- # Same model_id, value-unequal Profile — Rails reload swap.
74
- # Document trade-off: a host that intentionally re-registers with
75
- # different capabilities also gets silent replacement (same
76
- # behavior Smith::Agent::Registry chose).
77
- _container.delete(key)
78
- super(key, profile)
79
- return profile
80
- end
81
-
82
- if existing
83
- raise CollisionError,
84
- "model #{key.inspect} already registered with a different profile"
85
- end
86
-
87
- super(key, profile)
88
- profile
89
- end
90
- end
91
-
92
- def self.all
93
- registry_monitor.synchronize do
94
- keys.sort.map { |k| resolve(k) }
95
- end
96
- end
97
-
98
- def self.clear!
99
- registry_monitor.synchronize { @_container&.clear }
100
- end
101
-
102
- # Eagerly initialized at module load so concurrent first-callers
103
- # cannot race the `||=` lazy-init and end up with separate Monitor
104
- # instances (which would partially defeat synchronization).
105
- @_registry_monitor = Monitor.new
106
-
107
- def self.registry_monitor
108
- @_registry_monitor
56
+ Profile.new(
57
+ **profile.to_h,
58
+ provider: provider.to_sym,
59
+ tools_with_thinking_native: false,
60
+ tools_with_thinking_route: nil
61
+ )
109
62
  end
63
+ private_class_method :inferred_profile_for_provider
110
64
 
111
65
  PROVIDER_PATTERNS = {
112
66
  anthropic: /\Aclaude/i,
113
- openai: /\A(gpt|o\d)/i,
114
- gemini: /\Agemini/i
67
+ openai: /\A(gpt|o\d)/i,
68
+ gemini: /\Agemini/i
115
69
  }.freeze
116
70
  private_constant :PROVIDER_PATTERNS
117
71
 
@@ -120,13 +74,5 @@ module Smith
120
74
  PROVIDER_PATTERNS.each { |provider, pattern| return provider if key.match?(pattern) }
121
75
  :unknown
122
76
  end
123
-
124
- # Same model_id but value-unequal Profile objects (e.g., a host
125
- # tweaked a built-in profile in config/initializers and Rails
126
- # reloaded). Replace silently rather than raise.
127
- def self.stale_reload_binding?(existing, profile)
128
- existing.model_id == profile.model_id
129
- end
130
- private_class_method :stale_reload_binding?
131
77
  end
132
78
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Smith
6
+ class PersistedFailureInvalid < Error; end
7
+ end
data/lib/smith/pricing.rb CHANGED
@@ -17,20 +17,137 @@ module Smith
17
17
  # call's input_tokens picks the rate. Used for models with
18
18
  # long-context premium pricing (Gemini 2.5 Pro: $1.25/$10 below
19
19
  # 200K input tokens, $2.50/$15 above).
20
- def self.compute_cost(model:, input_tokens:, output_tokens:)
20
+ #
21
+ # Catalog keys are normalized once per assigned catalog object:
22
+ # ["provider", "model"] / [:provider, :model] -> provider-qualified
23
+ # "provider/model" (first slash splits) -> provider-qualified
24
+ # "model" / :model (no slash) -> legacy model-only
25
+ #
26
+ # Lookup policy: a provider-qualified lookup reads only qualified
27
+ # entries and NEVER prices from a legacy model-only rate; unpriced
28
+ # usage stays visibly unpriced (nil cost). This method never raises,
29
+ # so accounting paths cannot mask a provider error with a pricing
30
+ # configuration error. The legacy-only-key policy is enforced at
31
+ # catalog admission time via `validate_catalog!`.
32
+ def self.compute_cost(model:, input_tokens:, output_tokens:, provider: nil)
21
33
  catalog = Smith.config.pricing
22
- return nil unless catalog
34
+ return nil unless catalog.is_a?(Hash)
23
35
 
24
- entry = catalog[model.to_s]
36
+ entry = lookup_entry(normalized_index(catalog), model, provider)
25
37
  return nil unless entry
26
38
 
27
- rates = resolve_rates(entry, input_tokens)
28
- return nil unless rates
39
+ input_rate, output_rate = resolve_rates(entry, input_tokens)
40
+ return nil unless input_rate
29
41
 
30
- input_rate, output_rate = rates
31
42
  (input_tokens * input_rate) + (output_tokens * output_rate)
32
43
  end
33
44
 
45
+ def self.lookup_entry(index, model, provider)
46
+ return index.fetch(:qualified)[[provider.to_s, model.to_s]] if provider
47
+
48
+ index.fetch(:legacy)[model.to_s]
49
+ end
50
+
51
+ # Admission-time catalog validation. Call when the pricing catalog
52
+ # is assigned (`config.pricing = Smith::Pricing.validate_catalog!(catalog)`)
53
+ # so misconfiguration fails at configuration time instead of inside
54
+ # an in-flight accounting path. Rejects legacy model-only keys,
55
+ # unrecognized key shapes, non-Hash entries, and keys that collide
56
+ # after normalization. Returns the catalog for assignment chaining.
57
+ def self.validate_catalog!(catalog = Smith.config.pricing)
58
+ return catalog if catalog.nil?
59
+ raise Smith::PricingConfigurationError, "pricing catalog must be a Hash" unless catalog.is_a?(Hash)
60
+
61
+ catalog.each_with_object({}) do |(key, entry), seen|
62
+ provider, model = validated_key!(key)
63
+ validate_entry!(key, entry)
64
+ reject_collision!(seen, key, provider, model)
65
+ end
66
+ catalog
67
+ end
68
+
69
+ def self.validated_key!(key)
70
+ provider, model = canonical_key(key)
71
+ raise Smith::PricingConfigurationError, "unrecognized pricing catalog key #{key.inspect}" unless model
72
+ return [provider, model] if provider
73
+
74
+ raise Smith::PricingConfigurationError, "pricing for #{model} must use a provider-qualified catalog key"
75
+ end
76
+
77
+ def self.validate_entry!(key, entry)
78
+ return if entry.is_a?(Hash)
79
+
80
+ raise Smith::PricingConfigurationError, "pricing entry for #{key.inspect} must be a Hash"
81
+ end
82
+
83
+ def self.reject_collision!(seen, key, provider, model)
84
+ previous = seen[[provider, model]]
85
+ if previous
86
+ raise Smith::PricingConfigurationError,
87
+ "pricing catalog keys #{previous.inspect} and #{key.inspect} collide as #{provider}/#{model}"
88
+ end
89
+
90
+ seen[[provider, model]] = key
91
+ end
92
+
93
+ # The catalog is treated as immutable once assigned: normalization
94
+ # runs once per catalog object and is memoized by identity, so
95
+ # replacing the catalog means assigning a new Hash.
96
+ NORMALIZATION_MUTEX = Mutex.new
97
+ private_constant :NORMALIZATION_MUTEX
98
+
99
+ def self.normalized_index(catalog)
100
+ NORMALIZATION_MUTEX.synchronize do
101
+ next @normalized_index if defined?(@normalized_source) && @normalized_source.equal?(catalog)
102
+
103
+ @normalized_index = build_normalized_index(catalog)
104
+ @normalized_source = catalog
105
+ @normalized_index
106
+ end
107
+ end
108
+
109
+ # Non-raising by design: compute_cost runs inside success and
110
+ # failure accounting, so unrecognizable keys are skipped (their
111
+ # usage stays unpriced) rather than raised. `validate_catalog!`
112
+ # is the raising surface for the same rules.
113
+ def self.build_normalized_index(catalog)
114
+ catalog.each_with_object({ qualified: {}, legacy: {} }) do |(key, entry), index|
115
+ provider, model = canonical_key(key)
116
+ next unless model
117
+
118
+ provider ? (index[:qualified][[provider, model].freeze] = entry) : (index[:legacy][model] = entry)
119
+ end
120
+ end
121
+
122
+ # Returns [provider_or_nil, model] for a recognized key, nil otherwise.
123
+ # Mirrors Smith::Agent::ModelReference: the first slash separates
124
+ # provider from model, so slashed model ids are keyed with an explicit
125
+ # provider segment ("openrouter/openai/gpt-5" or [provider, model]).
126
+ def self.canonical_key(key)
127
+ case key
128
+ when Array then array_key(key)
129
+ when String, Symbol then text_key(key.to_s)
130
+ end
131
+ end
132
+
133
+ def self.array_key(key)
134
+ provider, model = key.map { |part| scalar_key(part) } if key.length == 2
135
+ [provider, model] if provider && model
136
+ end
137
+
138
+ def self.text_key(text)
139
+ return if text.empty?
140
+ return [nil, text] unless text.include?("/")
141
+
142
+ provider, model = text.split("/", 2)
143
+ [provider, model] unless provider.empty? || model.empty?
144
+ end
145
+
146
+ def self.scalar_key(value)
147
+ text = value.to_s if value.is_a?(String) || value.is_a?(Symbol)
148
+ text unless text.nil? || text.empty?
149
+ end
150
+
34
151
  # Returns [input_rate, output_rate] or nil if no applicable rate.
35
152
  # Tiered shape is recognized by the presence of a :tiers key; flat
36
153
  # shape is the legacy default.
@@ -40,26 +157,30 @@ module Smith
40
157
  resolve_tiered(tiers, input_tokens)
41
158
  else
42
159
  flat = [entry[:input_cost_per_token], entry[:output_cost_per_token]]
43
- return nil unless flat.all? { |r| r.is_a?(Numeric) }
160
+ return nil unless flat.all?(Numeric)
44
161
 
45
162
  flat
46
163
  end
47
164
  end
48
- private_class_method :resolve_rates
49
165
 
50
166
  def self.resolve_tiered(tiers, input_tokens)
51
- tier = tiers.find do |t|
52
- max = t[:max_input_tokens] || t["max_input_tokens"]
53
- max.nil? || input_tokens <= max
54
- end
167
+ tier = tiers.find { |candidate| tier_applies?(candidate, input_tokens) }
55
168
  return nil unless tier
56
169
 
57
- input_rate = tier[:input_cost_per_token] || tier["input_cost_per_token"]
58
- output_rate = tier[:output_cost_per_token] || tier["output_cost_per_token"]
59
- return nil unless input_rate.is_a?(Numeric) && output_rate.is_a?(Numeric)
170
+ rates = [
171
+ tier[:input_cost_per_token] || tier["input_cost_per_token"],
172
+ tier[:output_cost_per_token] || tier["output_cost_per_token"]
173
+ ]
174
+ rates if rates.all?(Numeric)
175
+ end
60
176
 
61
- [input_rate, output_rate]
177
+ def self.tier_applies?(tier, input_tokens)
178
+ maximum = tier[:max_input_tokens] || tier["max_input_tokens"]
179
+ maximum.nil? || input_tokens <= maximum
62
180
  end
63
- private_class_method :resolve_tiered
181
+
182
+ private_class_method :lookup_entry, :validated_key!, :validate_entry!, :reject_collision!,
183
+ :normalized_index, :build_normalized_index, :canonical_key, :array_key,
184
+ :text_key, :scalar_key, :resolve_rates, :resolve_tiered, :tier_applies?
64
185
  end
65
186
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Smith
6
+ class PricingConfigurationError < Error; end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Smith
6
+ class ProviderPermanentFailure < Error
7
+ attr_reader :provider, :model_id, :source_error_class
8
+
9
+ def initialize(message, provider:, model_id:, source_error_class:)
10
+ @provider = provider&.to_sym
11
+ @model_id = model_id.to_s.dup.freeze
12
+ @source_error_class = source_error_class.to_s.dup.freeze
13
+ super(message)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ArgumentContainerReader
8
+ MODULE_MATCH = Module.instance_method(:===)
9
+ ARRAY_LENGTH = Array.instance_method(:length)
10
+ ARRAY_AREF = Array.instance_method(:[])
11
+ ARRAY_INITIALIZE_COPY = Array.instance_method(:initialize_copy)
12
+ HASH_LENGTH = Hash.instance_method(:length)
13
+ HASH_KEYS = Hash.instance_method(:keys)
14
+ HASH_FETCH = Hash.instance_method(:fetch)
15
+ HASH_INITIALIZE_COPY = Hash.instance_method(:initialize_copy)
16
+ SYMBOL_TO_S = Symbol.instance_method(:to_s)
17
+ private_constant :MODULE_MATCH, :ARRAY_LENGTH, :ARRAY_AREF, :ARRAY_INITIALIZE_COPY,
18
+ :HASH_LENGTH, :HASH_KEYS, :HASH_FETCH, :HASH_INITIALIZE_COPY, :SYMBOL_TO_S
19
+
20
+ extend Dry::Initializer
21
+
22
+ option :scalar_snapshot
23
+ option :byte_count
24
+
25
+ def type(value)
26
+ return :hash if MODULE_MATCH.bind_call(Hash, value)
27
+
28
+ :array if MODULE_MATCH.bind_call(Array, value)
29
+ end
30
+
31
+ def size(source, type)
32
+ type == :hash ? HASH_LENGTH.bind_call(source) : ARRAY_LENGTH.bind_call(source)
33
+ end
34
+
35
+ def snapshot(source, type)
36
+ target = type == :hash ? Hash.allocate : Array.allocate
37
+ initializer = type == :hash ? HASH_INITIALIZE_COPY : ARRAY_INITIALIZE_COPY
38
+ initializer.bind_call(target, source)
39
+ target.freeze
40
+ end
41
+
42
+ def target(type, size) = type == :hash ? {} : Array.new(size)
43
+
44
+ def append_children(pending:, source:, target:, depth:, metrics:)
45
+ if type(source) == :hash
46
+ append_hash_children(pending, source, target, depth, metrics)
47
+ else
48
+ append_array_children(pending, source, target, depth, metrics)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def append_array_children(pending, source, target, depth, metrics)
55
+ (ARRAY_LENGTH.bind_call(source) - 1).downto(0) do |index|
56
+ child = ARRAY_AREF.bind_call(source, index)
57
+ pending << [:visit, child, target, index, depth + 1, metrics]
58
+ end
59
+ end
60
+
61
+ def append_hash_children(pending, source, target, depth, metrics)
62
+ expected_size = HASH_LENGTH.bind_call(source)
63
+ keys = HASH_KEYS.bind_call(source)
64
+ unless ARRAY_LENGTH.bind_call(keys) == expected_size
65
+ raise Error, "tool argument object changed while it was being captured"
66
+ end
67
+
68
+ capture_hash_pairs(source, keys, expected_size, metrics).reverse_each do |key, child|
69
+ pending << [:visit, child, target, key, depth + 1, metrics]
70
+ end
71
+ rescue KeyError
72
+ raise Error, "tool argument object changed while it was being captured"
73
+ end
74
+
75
+ def capture_hash_pairs(source, keys, expected_size, metrics)
76
+ pairs = Array.new(expected_size)
77
+ canonical_keys = {}
78
+ keys.each_with_index do |source_key, index|
79
+ copied_key = copy_hash_key(source_key, canonical_keys, metrics)
80
+ pairs[index] = [copied_key, HASH_FETCH.bind_call(source, source_key)]
81
+ end
82
+ pairs
83
+ end
84
+
85
+ def copy_hash_key(key, canonical_keys, metrics)
86
+ previous_bytes = byte_count.call
87
+ copied_key = scalar_snapshot.copy_key(key)
88
+ canonical = MODULE_MATCH.bind_call(String, copied_key) ? copied_key : SYMBOL_TO_S.bind_call(copied_key)
89
+ raise Error, "tool arguments contain duplicate canonical Hash keys" if canonical_keys.key?(canonical)
90
+
91
+ canonical_keys[canonical] = true
92
+ metrics[:byte_count] += byte_count.call - previous_bytes
93
+ copied_key
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ArgumentScalarSnapshot
8
+ STRING_BYTESIZE = String.instance_method(:bytesize)
9
+ STRING_ENCODE = String.instance_method(:encode)
10
+ STRING_VALID_ENCODING = String.instance_method(:valid_encoding?)
11
+ MODULE_MATCH = Module.instance_method(:===)
12
+ DECIMAL_DIGIT_LOWER_BOUND_NUMERATOR = 301
13
+ DECIMAL_DIGIT_LOWER_BOUND_DENOMINATOR = 1_000
14
+
15
+ private_constant :STRING_BYTESIZE, :STRING_ENCODE, :STRING_VALID_ENCODING, :MODULE_MATCH,
16
+ :DECIMAL_DIGIT_LOWER_BOUND_NUMERATOR, :DECIMAL_DIGIT_LOWER_BOUND_DENOMINATOR
17
+
18
+ extend Dry::Initializer
19
+
20
+ option :byte_counter
21
+ option :byte_validator
22
+
23
+ def copy(value)
24
+ case value
25
+ when String then copy_string(value)
26
+ when Integer then copy_integer(value)
27
+ when TrueClass, FalseClass, NilClass then count(value)
28
+ when Float then copy_float(value)
29
+ else
30
+ raise Error, "tool arguments must contain JSON-compatible values"
31
+ end
32
+ end
33
+
34
+ def copy_key(key)
35
+ raise Error, "tool argument object keys must be strings or symbols" unless string?(key) || symbol?(key)
36
+
37
+ normalized = if string?(key)
38
+ normalize_utf8(key)
39
+ else
40
+ normalize_utf8(Symbol.instance_method(:to_s).bind_call(key))
41
+ end
42
+ byte_counter.call(normalized.bytesize)
43
+ string?(key) ? normalized : key
44
+ end
45
+
46
+ private
47
+
48
+ def copy_string(value)
49
+ normalized = normalize_utf8(value)
50
+ byte_counter.call(normalized.bytesize)
51
+ normalized
52
+ end
53
+
54
+ def copy_float(value)
55
+ raise Error, "tool arguments must contain finite numbers" unless value.finite?
56
+
57
+ count(value)
58
+ end
59
+
60
+ def copy_integer(value)
61
+ byte_validator.call(minimum_integer_bytes(value))
62
+ count(value)
63
+ end
64
+
65
+ def count(value)
66
+ byte_counter.call(value.to_s.bytesize)
67
+ value
68
+ end
69
+
70
+ def minimum_integer_bytes(value)
71
+ bits = value.bit_length
72
+ digits = if bits.zero?
73
+ 1
74
+ else
75
+ (((bits - 1) * DECIMAL_DIGIT_LOWER_BOUND_NUMERATOR) /
76
+ DECIMAL_DIGIT_LOWER_BOUND_DENOMINATOR) + 1
77
+ end
78
+ value.negative? ? digits + 1 : digits
79
+ end
80
+
81
+ def normalize_utf8(value)
82
+ byte_validator.call(STRING_BYTESIZE.bind_call(value))
83
+ raise Encoding::InvalidByteSequenceError unless STRING_VALID_ENCODING.bind_call(value)
84
+
85
+ encoded = STRING_ENCODE.bind_call(value, Encoding::UTF_8)
86
+ normalized = String.new(encoded).freeze
87
+ raise Encoding::InvalidByteSequenceError unless STRING_VALID_ENCODING.bind_call(normalized)
88
+
89
+ normalized
90
+ rescue EncodingError
91
+ raise Error, "tool arguments must contain valid UTF-8 strings"
92
+ end
93
+
94
+ def string?(value) = MODULE_MATCH.bind_call(String, value)
95
+
96
+ def symbol?(value) = MODULE_MATCH.bind_call(Symbol, value)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ArgumentSnapshot
8
+ MAX_DEPTH = ArgumentSnapshotTraversal::MAX_DEPTH
9
+ MAX_NODES = ArgumentSnapshotTraversal::MAX_NODES
10
+ MAX_BYTES = ArgumentSnapshotTraversal::MAX_BYTES
11
+
12
+ extend Dry::Initializer
13
+
14
+ param :value
15
+
16
+ def call = ArgumentSnapshotTraversal.new(value).call
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ArgumentSnapshotAccounting
8
+ extend Dry::Initializer
9
+
10
+ option :max_depth
11
+ option :max_nodes
12
+ option :max_bytes
13
+
14
+ attr_reader :node_count, :byte_count
15
+
16
+ def initialize(...)
17
+ super
18
+ @node_count = 0
19
+ @allocated_node_count = 1
20
+ @byte_count = 0
21
+ end
22
+
23
+ def visit!(depth)
24
+ raise Error, "tool arguments exceed #{max_depth} levels" if depth > max_depth
25
+
26
+ add_nodes!(1)
27
+ end
28
+
29
+ def reserve_children!(count)
30
+ @allocated_node_count += count
31
+ raise Error, "tool arguments exceed #{max_nodes} values" if @allocated_node_count > max_nodes
32
+ end
33
+
34
+ def add_bytes!(bytes)
35
+ @byte_count += bytes
36
+ raise Error, "tool arguments exceed #{max_bytes} bytes" if @byte_count > max_bytes
37
+ end
38
+
39
+ def validate_bytes!(bytes)
40
+ raise Error, "tool arguments exceed #{max_bytes} bytes" if @byte_count + bytes > max_bytes
41
+ end
42
+
43
+ def account_reused!(metrics, depth)
44
+ raise Error, "tool arguments exceed #{max_depth} levels" if depth + metrics.fetch(:max_depth) > max_depth
45
+
46
+ add_nodes!(metrics.fetch(:node_count) - 1)
47
+ add_bytes!(metrics.fetch(:byte_count))
48
+ end
49
+
50
+ def propagate!(parent_metrics, child_metrics)
51
+ return unless parent_metrics
52
+
53
+ parent_metrics[:node_count] += child_metrics.fetch(:node_count)
54
+ parent_metrics[:byte_count] += child_metrics.fetch(:byte_count)
55
+ parent_metrics[:max_depth] = [
56
+ parent_metrics[:max_depth],
57
+ child_metrics.fetch(:max_depth) + 1
58
+ ].max
59
+ end
60
+
61
+ private
62
+
63
+ def add_nodes!(count)
64
+ @node_count += count
65
+ raise Error, "tool arguments exceed #{max_nodes} values" if @node_count > max_nodes
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "../types"
6
+
7
+ module Smith
8
+ class Tool < RubyLLM::Tool
9
+ class ArgumentSnapshotResult < Dry::Struct
10
+ attribute :value, Types::Hash
11
+ attribute :node_count, Types::Integer.constrained(gteq: 1)
12
+ attribute :byte_count, Types::Integer.constrained(gteq: 0)
13
+ end
14
+ end
15
+ end