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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- 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 +25 -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 +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -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 +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +29 -197
- 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/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/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 +71 -12
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- 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 +3 -1
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/execution.rb +2 -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/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- 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
|
-
|
|
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:
|
|
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:
|
|
49
|
-
provider:
|
|
50
|
-
thinking_shape:
|
|
51
|
-
accepts_temperature:
|
|
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:
|
|
49
|
+
tools_with_thinking_route: nil
|
|
54
50
|
)
|
|
55
51
|
end
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
114
|
-
gemini:
|
|
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
|
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
|
-
|
|
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
|
|
36
|
+
entry = lookup_entry(normalized_index(catalog), model, provider)
|
|
25
37
|
return nil unless entry
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
return nil unless
|
|
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?
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|