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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- metadata +258 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Events
|
|
5
|
+
class Scope
|
|
6
|
+
def initialize
|
|
7
|
+
@handles = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def on(event_class, **, &)
|
|
11
|
+
handle = Events.on(event_class, **, &)
|
|
12
|
+
@handles << handle
|
|
13
|
+
handle
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cancel_all
|
|
17
|
+
@handles.each(&:cancel)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def subscriptions
|
|
23
|
+
@subscriptions ||= []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def on(event_class, **opts, &block)
|
|
27
|
+
sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
|
|
28
|
+
subscriptions << sub
|
|
29
|
+
sub
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def emit(event)
|
|
33
|
+
subscriptions.each { |sub| dispatch_to(sub, event) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def within
|
|
37
|
+
scope = Scope.new
|
|
38
|
+
yield scope
|
|
39
|
+
ensure
|
|
40
|
+
scope&.cancel_all
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset!
|
|
44
|
+
@subscriptions = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def dispatch_to(sub, event)
|
|
50
|
+
return if sub.cancelled?
|
|
51
|
+
return unless event.is_a?(sub.event_class)
|
|
52
|
+
return if sub.predicate && !sub.predicate.call(event)
|
|
53
|
+
|
|
54
|
+
sub.handler.call(event)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Smith.config.logger&.error("Smith::Events handler error: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Events
|
|
5
|
+
class Subscription
|
|
6
|
+
attr_reader :event_class, :handler, :predicate
|
|
7
|
+
|
|
8
|
+
def initialize(event_class, handler:, predicate: nil)
|
|
9
|
+
@event_class = event_class
|
|
10
|
+
@handler = handler
|
|
11
|
+
@predicate = predicate
|
|
12
|
+
@cancelled = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cancel
|
|
16
|
+
@cancelled = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def cancelled?
|
|
20
|
+
@cancelled
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/smith/events.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Guardrails
|
|
5
|
+
module Runner
|
|
6
|
+
class << self
|
|
7
|
+
def run_inputs(guardrails_class, payload)
|
|
8
|
+
run_layer(guardrails_class, guardrails_class.input, payload, GuardrailFailed)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run_outputs(guardrails_class, payload)
|
|
12
|
+
run_layer(guardrails_class, guardrails_class.output, payload, GuardrailFailed)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run_tool(guardrails_class, tool_name, payload)
|
|
16
|
+
matching = guardrails_class.tool.select { |d| d[:on]&.include?(tool_name) }
|
|
17
|
+
run_layer(guardrails_class, matching, payload, ToolGuardrailFailed)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def run_layer(guardrails_class, declarations, payload, error_class)
|
|
23
|
+
instance = guardrails_class.new
|
|
24
|
+
declarations.each { |d| instance.send(d[:name], payload) }
|
|
25
|
+
rescue Smith::Error
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise build_guardrail_error(error_class, e)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_guardrail_error(error_class, error)
|
|
32
|
+
return error_class.new(error.message, retryable: retryable_tool_guardrail?(error.message)) if error_class == ToolGuardrailFailed
|
|
33
|
+
|
|
34
|
+
error_class.new(error.message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def retryable_tool_guardrail?(message)
|
|
38
|
+
text = message.to_s.downcase
|
|
39
|
+
text.include?("rate limit") || text.include?("malformed args")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Guardrails
|
|
5
|
+
class << self
|
|
6
|
+
def inherited(subclass)
|
|
7
|
+
super
|
|
8
|
+
subclass.instance_variable_set(:@inputs, (@inputs || []).dup)
|
|
9
|
+
subclass.instance_variable_set(:@tools, (@tools || []).dup)
|
|
10
|
+
subclass.instance_variable_set(:@outputs, (@outputs || []).dup)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def input(name = nil, **)
|
|
14
|
+
return @inputs || [] if name.nil?
|
|
15
|
+
|
|
16
|
+
@inputs ||= []
|
|
17
|
+
@inputs << ({ name: name, ** })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tool(name = nil, **)
|
|
21
|
+
return @tools || [] if name.nil?
|
|
22
|
+
|
|
23
|
+
@tools ||= []
|
|
24
|
+
@tools << ({ name: name, ** })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def output(name = nil, **)
|
|
28
|
+
return @outputs || [] if name.nil?
|
|
29
|
+
|
|
30
|
+
@outputs ||= []
|
|
31
|
+
@outputs << ({ name: name, ** })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Models
|
|
5
|
+
# Pattern-based provider capability rules. Library-level knowledge
|
|
6
|
+
# about how PROVIDER FAMILIES shape their API payloads — independent
|
|
7
|
+
# of specific model ids or downstream applications.
|
|
8
|
+
#
|
|
9
|
+
# Smith ships ZERO specific model_id declarations. Each rule matches
|
|
10
|
+
# a regex or version-aware predicate against the resolved model_id
|
|
11
|
+
# at runtime. New model releases that fit existing patterns work
|
|
12
|
+
# automatically (e.g., a future claude-opus-4-9 matches the Opus 4.7+
|
|
13
|
+
# adaptive-thinking rule).
|
|
14
|
+
#
|
|
15
|
+
# Rule order matters: most specific patterns first. Hosts that need
|
|
16
|
+
# to ADD provider knowledge (a new provider Smith doesn't ship rules
|
|
17
|
+
# for, or a custom finetune) can `prepend_rule` at runtime.
|
|
18
|
+
#
|
|
19
|
+
# The rules array is mutable for runtime extension; tests must use
|
|
20
|
+
# the `with_rules(*rules) { ... }` block helper to avoid test-suite
|
|
21
|
+
# leakage of `prepend_rule` mutations.
|
|
22
|
+
module Inference
|
|
23
|
+
# A single rule maps a model_id matcher to capability values.
|
|
24
|
+
# The matcher is a Proc[String -> Boolean] — regex match OR
|
|
25
|
+
# version-aware predicate (e.g., Opus 4.7+).
|
|
26
|
+
Rule = Data.define(
|
|
27
|
+
:provider,
|
|
28
|
+
:matcher,
|
|
29
|
+
:thinking_shape,
|
|
30
|
+
:accepts_temperature,
|
|
31
|
+
:tools_with_thinking_native,
|
|
32
|
+
:tools_with_thinking_route
|
|
33
|
+
) do
|
|
34
|
+
def matches?(model_id)
|
|
35
|
+
matcher.call(model_id.to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_profile(model_id)
|
|
39
|
+
Profile.new(
|
|
40
|
+
model_id: model_id.to_s,
|
|
41
|
+
provider: provider,
|
|
42
|
+
thinking_shape: thinking_shape,
|
|
43
|
+
accepts_temperature: accepts_temperature,
|
|
44
|
+
tools_with_thinking_native: tools_with_thinking_native,
|
|
45
|
+
tools_with_thinking_route: tools_with_thinking_route
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.rules
|
|
51
|
+
@_rules
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.prepend_rule(rule)
|
|
55
|
+
rules.unshift(rule)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.reset!
|
|
59
|
+
@_rules = default_rules.dup
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Block-form test helper. Yields with the given rules INSTEAD of
|
|
63
|
+
# the default set; restores afterward even if the block raises.
|
|
64
|
+
# Prevents test-suite leakage of `prepend_rule` mutations.
|
|
65
|
+
def self.with_rules(*overrides)
|
|
66
|
+
previous = @_rules
|
|
67
|
+
@_rules = overrides.flatten
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
@_rules = previous
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.profile_for(model_id)
|
|
74
|
+
rule = rules.find { |r| r.matches?(model_id) }
|
|
75
|
+
rule&.to_profile(model_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Library-shipped pattern rules. Order: most specific first per
|
|
79
|
+
# provider; provider families in declaration order. NO specific
|
|
80
|
+
# model_id strings — only PROVIDER FAMILY and version-range patterns.
|
|
81
|
+
def self.default_rules
|
|
82
|
+
[
|
|
83
|
+
# ----- Anthropic -----
|
|
84
|
+
|
|
85
|
+
# Opus 4.7+: adaptive thinking, no temperature accepted.
|
|
86
|
+
Rule.new(
|
|
87
|
+
provider: :anthropic,
|
|
88
|
+
matcher: lambda { |id|
|
|
89
|
+
m = id.match(/\Aclaude-opus-4-(\d+)/)
|
|
90
|
+
m && m[1].to_i >= 7
|
|
91
|
+
},
|
|
92
|
+
thinking_shape: :adaptive,
|
|
93
|
+
accepts_temperature: false,
|
|
94
|
+
tools_with_thinking_native: true,
|
|
95
|
+
tools_with_thinking_route: nil
|
|
96
|
+
),
|
|
97
|
+
# Opus/Sonnet/Haiku 4.0-4.6: budget_tokens thinking.
|
|
98
|
+
Rule.new(
|
|
99
|
+
provider: :anthropic,
|
|
100
|
+
matcher: lambda { |id|
|
|
101
|
+
m = id.match(/\Aclaude-(?:opus|sonnet|haiku)-4-(\d+)/)
|
|
102
|
+
m && m[1].to_i <= 6
|
|
103
|
+
},
|
|
104
|
+
thinking_shape: :budget_tokens,
|
|
105
|
+
accepts_temperature: true,
|
|
106
|
+
tools_with_thinking_native: true,
|
|
107
|
+
tools_with_thinking_route: nil
|
|
108
|
+
),
|
|
109
|
+
# Claude 3.7 Sonnet introduced extended thinking via budget_tokens.
|
|
110
|
+
# Claude 3.5 and earlier DON'T have thinking — handled by the
|
|
111
|
+
# safe-default Anthropic rule below.
|
|
112
|
+
Rule.new(
|
|
113
|
+
provider: :anthropic,
|
|
114
|
+
matcher: ->(id) { id.match?(/\Aclaude-3-7/) },
|
|
115
|
+
thinking_shape: :budget_tokens,
|
|
116
|
+
accepts_temperature: true,
|
|
117
|
+
tools_with_thinking_native: true,
|
|
118
|
+
tools_with_thinking_route: nil
|
|
119
|
+
),
|
|
120
|
+
# Any other Claude (3.5, 3.0, 2.x): safe default — no thinking,
|
|
121
|
+
# accepts temperature, tools work normally on chat-completions.
|
|
122
|
+
Rule.new(
|
|
123
|
+
provider: :anthropic,
|
|
124
|
+
matcher: ->(id) { id.match?(/\Aclaude-/) },
|
|
125
|
+
thinking_shape: nil,
|
|
126
|
+
accepts_temperature: true,
|
|
127
|
+
tools_with_thinking_native: false,
|
|
128
|
+
tools_with_thinking_route: nil
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
# ----- OpenAI -----
|
|
132
|
+
|
|
133
|
+
# gpt-5 family + o-series reasoning models: reasoning_effort,
|
|
134
|
+
# no temperature, needs /v1/responses for tools+thinking combo
|
|
135
|
+
# (chat-completions rejects the combination).
|
|
136
|
+
Rule.new(
|
|
137
|
+
provider: :openai,
|
|
138
|
+
matcher: ->(id) { id.match?(/\A(gpt-5|o\d)/) },
|
|
139
|
+
thinking_shape: :reasoning_effort,
|
|
140
|
+
accepts_temperature: false,
|
|
141
|
+
tools_with_thinking_native: false,
|
|
142
|
+
tools_with_thinking_route: :responses
|
|
143
|
+
),
|
|
144
|
+
# gpt-4.x: no thinking, accepts temperature.
|
|
145
|
+
Rule.new(
|
|
146
|
+
provider: :openai,
|
|
147
|
+
matcher: ->(id) { id.match?(/\Agpt-4/) },
|
|
148
|
+
thinking_shape: nil,
|
|
149
|
+
accepts_temperature: true,
|
|
150
|
+
tools_with_thinking_native: false,
|
|
151
|
+
tools_with_thinking_route: nil
|
|
152
|
+
),
|
|
153
|
+
# Older OpenAI: no thinking, accepts temperature.
|
|
154
|
+
Rule.new(
|
|
155
|
+
provider: :openai,
|
|
156
|
+
matcher: ->(id) { id.match?(/\A(gpt-3|text-)/) },
|
|
157
|
+
thinking_shape: nil,
|
|
158
|
+
accepts_temperature: true,
|
|
159
|
+
tools_with_thinking_native: false,
|
|
160
|
+
tools_with_thinking_route: nil
|
|
161
|
+
),
|
|
162
|
+
|
|
163
|
+
# ----- Gemini -----
|
|
164
|
+
|
|
165
|
+
# Gemini 2.5+ (all variants, including Flash) supports thinking
|
|
166
|
+
# via budget_tokens. Earlier Gemini (1.x, 2.0) does not.
|
|
167
|
+
Rule.new(
|
|
168
|
+
provider: :gemini,
|
|
169
|
+
matcher: lambda { |id|
|
|
170
|
+
m = id.match(/\Agemini-(\d+)\.(\d+)/)
|
|
171
|
+
m && (m[1].to_i > 2 || (m[1].to_i == 2 && m[2].to_i >= 5))
|
|
172
|
+
},
|
|
173
|
+
thinking_shape: :budget_tokens,
|
|
174
|
+
accepts_temperature: true,
|
|
175
|
+
tools_with_thinking_native: true,
|
|
176
|
+
tools_with_thinking_route: nil
|
|
177
|
+
),
|
|
178
|
+
# Any other Gemini (1.x, 2.0): no thinking.
|
|
179
|
+
Rule.new(
|
|
180
|
+
provider: :gemini,
|
|
181
|
+
matcher: ->(id) { id.match?(/\Agemini-/) },
|
|
182
|
+
thinking_shape: nil,
|
|
183
|
+
accepts_temperature: true,
|
|
184
|
+
tools_with_thinking_native: false,
|
|
185
|
+
tools_with_thinking_route: nil
|
|
186
|
+
)
|
|
187
|
+
].freeze
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Eagerly initialized at module load (after default_rules is
|
|
191
|
+
# defined) so concurrent first-callers cannot race the `||=`
|
|
192
|
+
# lazy-init and end up holding references to separate Array
|
|
193
|
+
# instances. Host calls to `prepend_rule` / `reset!` / `with_rules`
|
|
194
|
+
# are still expected to fire only at setup time on the main
|
|
195
|
+
# thread; concurrent mutation after boot is unsupported.
|
|
196
|
+
@_rules = default_rules.dup
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Models
|
|
7
|
+
# Per-chat-construction request shaper. Mutates a RubyLLM::Chat
|
|
8
|
+
# in place to fit the resolved model's capability profile, using
|
|
9
|
+
# RubyLLM's public `with_*` API where it covers the case and
|
|
10
|
+
# scoped instance-variable nulling where no public API exists
|
|
11
|
+
# (RubyLLM has no `without_temperature` / `without_thinking`).
|
|
12
|
+
#
|
|
13
|
+
# Lifetime: built fresh inside Smith::Agent.chat per construction.
|
|
14
|
+
# Never crosses threads. Never cached.
|
|
15
|
+
#
|
|
16
|
+
# Runs OUTSIDE any workflow context — does NOT access:
|
|
17
|
+
# - Smith.scoped_artifacts (thread-local, set only inside workflows)
|
|
18
|
+
# - Tool.current_ledger / Tool.current_tool_result_collector
|
|
19
|
+
# - Thread.current[:smith_last_agent_result]
|
|
20
|
+
# Smith::Trace.record is the ONLY observability surface the normalizer
|
|
21
|
+
# touches; it's safe outside workflow scope.
|
|
22
|
+
class Normalizer
|
|
23
|
+
extend Dry::Initializer
|
|
24
|
+
|
|
25
|
+
# Decision record emitted as a :normalizer_decision trace event.
|
|
26
|
+
# The Decision.kind value space is exhaustively documented in the
|
|
27
|
+
# plan; adding a new kind requires updating the trace CONFIG_MAP.
|
|
28
|
+
Decision = Data.define(:kind, :model_id, :detail)
|
|
29
|
+
|
|
30
|
+
# No type predicate on options — Smith's existing Dry::Initializer
|
|
31
|
+
# call sites trust internal callers and don't enforce option types.
|
|
32
|
+
option :chat
|
|
33
|
+
option :profile
|
|
34
|
+
|
|
35
|
+
# Returns Array<Decision> of mutations performed. The chat is
|
|
36
|
+
# mutated in place; callers usually ignore the return value
|
|
37
|
+
# except in tests.
|
|
38
|
+
def self.apply!(chat, profile:)
|
|
39
|
+
return [] if profile.nil?
|
|
40
|
+
|
|
41
|
+
new(chat: chat, profile: profile).apply!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def apply!
|
|
45
|
+
@decisions = []
|
|
46
|
+
normalize_temperature
|
|
47
|
+
normalize_thinking
|
|
48
|
+
normalize_tools_routing
|
|
49
|
+
emit_trace
|
|
50
|
+
@decisions
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalize_temperature
|
|
56
|
+
return if profile.accepts_temperature
|
|
57
|
+
return if chat.instance_variable_get(:@temperature).nil?
|
|
58
|
+
|
|
59
|
+
# No public `without_temperature` in RubyLLM 1.15 — direct ivar
|
|
60
|
+
# nulling is the only path. Scoped: only @temperature, only on
|
|
61
|
+
# models that explicitly reject it. Add `RubyLLM::Chat#without_temperature`
|
|
62
|
+
# upstream and Smith retires this line (see UPSTREAM_PROPOSAL.md).
|
|
63
|
+
chat.instance_variable_set(:@temperature, nil)
|
|
64
|
+
@decisions << Decision.new(kind: :temperature_dropped, model_id: profile.model_id, detail: nil)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_thinking
|
|
68
|
+
thinking = chat.instance_variable_get(:@thinking)
|
|
69
|
+
return if thinking.nil? || !thinking.enabled?
|
|
70
|
+
|
|
71
|
+
case profile.thinking_shape
|
|
72
|
+
when nil
|
|
73
|
+
chat.instance_variable_set(:@thinking, nil)
|
|
74
|
+
@decisions << Decision.new(kind: :thinking_dropped, model_id: profile.model_id, detail: nil)
|
|
75
|
+
when :budget_tokens, :reasoning_effort
|
|
76
|
+
# RubyLLM's provider renderers already emit the right shape.
|
|
77
|
+
# Leave @thinking unchanged.
|
|
78
|
+
when :adaptive
|
|
79
|
+
translate_thinking_to_adaptive(thinking)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def translate_thinking_to_adaptive(thinking)
|
|
84
|
+
effort = thinking.respond_to?(:effort) && thinking.effort ? thinking.effort : "high"
|
|
85
|
+
merge_params(thinking: { type: "adaptive" }, output_config: { effort: effort })
|
|
86
|
+
|
|
87
|
+
# Null @thinking so RubyLLM's render_payload doesn't ALSO emit
|
|
88
|
+
# the budget_tokens shape that would conflict with our adaptive
|
|
89
|
+
# injection at deep_merge time.
|
|
90
|
+
chat.instance_variable_set(:@thinking, nil)
|
|
91
|
+
@decisions << Decision.new(
|
|
92
|
+
kind: :thinking_translated_to_adaptive,
|
|
93
|
+
model_id: profile.model_id,
|
|
94
|
+
detail: { effort: effort }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_tools_routing
|
|
99
|
+
# Stubbed chat objects in tests may not implement .tools; gracefully
|
|
100
|
+
# skip rather than crash on respond_to? check.
|
|
101
|
+
return unless chat.respond_to?(:tools)
|
|
102
|
+
|
|
103
|
+
tools = chat.tools.values
|
|
104
|
+
return if tools.empty?
|
|
105
|
+
return unless thinking_active?
|
|
106
|
+
|
|
107
|
+
return if profile.tools_with_thinking_native
|
|
108
|
+
|
|
109
|
+
if profile.tools_with_thinking_route == :responses &&
|
|
110
|
+
Smith.config.openai_api_mode == :auto
|
|
111
|
+
merge_params(openai_api_mode: :responses)
|
|
112
|
+
@decisions << Decision.new(kind: :routed_via_responses, model_id: profile.model_id, detail: nil)
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
drop_incompatible_tools(tools)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def thinking_active?
|
|
120
|
+
thinking = chat.instance_variable_get(:@thinking)
|
|
121
|
+
return true if thinking&.enabled?
|
|
122
|
+
|
|
123
|
+
# Also active if we already translated to adaptive (in which case
|
|
124
|
+
# @thinking is nil but params carry the thinking spec).
|
|
125
|
+
params = chat.instance_variable_get(:@params) || {}
|
|
126
|
+
params.key?(:thinking) || params.key?(:reasoning) || params.key?(:reasoning_effort)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def drop_incompatible_tools(tools)
|
|
130
|
+
effective_endpoint = effective_endpoint_for_compatibility
|
|
131
|
+
incompatible = tools.reject do |tool|
|
|
132
|
+
spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
|
|
133
|
+
if defined?(Smith::Tool::Compatibility)
|
|
134
|
+
Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: effective_endpoint)
|
|
135
|
+
else
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
return if incompatible.empty?
|
|
140
|
+
|
|
141
|
+
retained = tools - incompatible
|
|
142
|
+
chat.with_tools(*retained, replace: true)
|
|
143
|
+
|
|
144
|
+
incompatible.each do |tool|
|
|
145
|
+
@decisions << Decision.new(
|
|
146
|
+
kind: :tool_dropped,
|
|
147
|
+
model_id: profile.model_id,
|
|
148
|
+
detail: { tool: tool.class.name }
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Profile.endpoint_mode reports the INTENDED endpoint (per the
|
|
154
|
+
# inference rule). Smith.config.openai_api_mode policy can downgrade
|
|
155
|
+
# the EFFECTIVE endpoint — e.g., a profile with route :responses
|
|
156
|
+
# actually uses :chat_completions when openai_api_mode is :off.
|
|
157
|
+
# The compatibility check needs the effective endpoint to make
|
|
158
|
+
# the right drop/keep decision.
|
|
159
|
+
def effective_endpoint_for_compatibility
|
|
160
|
+
if profile.tools_with_thinking_route == :responses &&
|
|
161
|
+
Smith.config.openai_api_mode != :auto
|
|
162
|
+
:chat_completions
|
|
163
|
+
else
|
|
164
|
+
profile.endpoint_mode
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# with_params REPLACES @params in RubyLLM (chat.rb:96), so the
|
|
169
|
+
# normalizer always reads existing + merges + writes back to
|
|
170
|
+
# preserve prior user calls to with_params.
|
|
171
|
+
def merge_params(**new_params)
|
|
172
|
+
existing = chat.instance_variable_get(:@params) || {}
|
|
173
|
+
chat.with_params(**existing, **new_params)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def emit_trace
|
|
177
|
+
return if @decisions.empty?
|
|
178
|
+
return unless defined?(Smith::Trace)
|
|
179
|
+
|
|
180
|
+
@decisions.each do |decision|
|
|
181
|
+
Smith::Trace.record(type: :normalizer_decision, data: decision.to_h)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Models
|
|
5
|
+
# Immutable capability record for a model id. Holds only inherent
|
|
6
|
+
# provider/model properties — never pricing, never API keys, never
|
|
7
|
+
# request-specific data. Library-shipped rules live in
|
|
8
|
+
# Smith::Models::Inference (pattern-based).
|
|
9
|
+
#
|
|
10
|
+
# Fields:
|
|
11
|
+
# model_id — canonical id ("claude-opus-4-7")
|
|
12
|
+
# provider — :anthropic | :openai | :gemini | :xai | ...
|
|
13
|
+
# thinking_shape — nil | :budget_tokens | :reasoning_effort | :adaptive
|
|
14
|
+
# nil — model has no thinking concept (don't send thinking)
|
|
15
|
+
# :budget_tokens — RubyLLM's default Anthropic shape (Opus 4.6, Sonnet 4.x)
|
|
16
|
+
# :reasoning_effort — OpenAI-style reasoning_effort string
|
|
17
|
+
# :adaptive — Opus 4.7+ adaptive shape (output_config.effort)
|
|
18
|
+
# accepts_temperature — false → normalizer strips @temperature
|
|
19
|
+
# tools_with_thinking_native — true → tools + thinking on default endpoint OK
|
|
20
|
+
# tools_with_thinking_route — nil | :responses (which endpoint to route to
|
|
21
|
+
# when both tools + thinking are present and
|
|
22
|
+
# native combo is unsupported)
|
|
23
|
+
Profile = Data.define(
|
|
24
|
+
:model_id,
|
|
25
|
+
:provider,
|
|
26
|
+
:thinking_shape,
|
|
27
|
+
:accepts_temperature,
|
|
28
|
+
:tools_with_thinking_native,
|
|
29
|
+
:tools_with_thinking_route
|
|
30
|
+
) do
|
|
31
|
+
# Derived from tools_with_thinking_route. Exposed on Profile (not on
|
|
32
|
+
# Tool::Compatibility) so the Profile is a self-contained capability
|
|
33
|
+
# record without cross-namespace dependency.
|
|
34
|
+
def endpoint_mode
|
|
35
|
+
tools_with_thinking_route == :responses ? :responses : :chat_completions
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|