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,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Context
|
|
5
|
+
class << self
|
|
6
|
+
def inherited(subclass)
|
|
7
|
+
super
|
|
8
|
+
subclass.instance_variable_set(:@session_strategy, @session_strategy)
|
|
9
|
+
subclass.instance_variable_set(:@persist_keys, (@persist_keys || []).dup)
|
|
10
|
+
subclass.instance_variable_set(:@persist_mode, @persist_mode || :explicit)
|
|
11
|
+
subclass.instance_variable_set(:@persist_auto_seed, (@persist_auto_seed || []).dup)
|
|
12
|
+
subclass.instance_variable_set(:@inject_state, @inject_state)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def session_strategy(strategy = nil, **opts)
|
|
16
|
+
return @session_strategy if strategy.nil?
|
|
17
|
+
|
|
18
|
+
@session_strategy = { strategy: strategy, **opts }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def persist(*keys, also: nil)
|
|
22
|
+
if keys.empty? && also.nil?
|
|
23
|
+
return @persist_keys || []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if also && !keys.include?(:auto)
|
|
27
|
+
raise Smith::WorkflowError, ":also is only valid alongside :auto"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if keys.include?(:auto)
|
|
31
|
+
other_keys = keys.reject { |k| k == :auto }
|
|
32
|
+
unless other_keys.empty?
|
|
33
|
+
raise Smith::WorkflowError, "persist :auto must be the sole positional argument; got #{other_keys.inspect}"
|
|
34
|
+
end
|
|
35
|
+
@persist_mode = :auto
|
|
36
|
+
@persist_keys = []
|
|
37
|
+
@persist_auto_seed = Array(also).map(&:to_sym)
|
|
38
|
+
return @persist_auto_seed.dup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@persist_mode = :explicit
|
|
42
|
+
@persist_keys ||= []
|
|
43
|
+
@persist_keys.concat(keys)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def persist_mode
|
|
47
|
+
@persist_mode || :explicit
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def persist_auto_seed
|
|
51
|
+
@persist_auto_seed || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inject_state(&block)
|
|
55
|
+
return @inject_state unless block_given?
|
|
56
|
+
|
|
57
|
+
@inject_state = block
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
Check = Data.define(:name, :status, :message, :detail) do
|
|
6
|
+
def pass? = status == :pass
|
|
7
|
+
def fail? = status == :fail
|
|
8
|
+
def warn? = status == :warn
|
|
9
|
+
def skip? = status == :skip
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
module Baseline
|
|
7
|
+
def self.run(report)
|
|
8
|
+
check_smith_loads(report)
|
|
9
|
+
check_ruby_version(report)
|
|
10
|
+
check_ruby_llm_loads(report)
|
|
11
|
+
check_concurrent_loads(report)
|
|
12
|
+
check_configure(report)
|
|
13
|
+
check_minimal_workflow(report)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.check_smith_loads(report)
|
|
17
|
+
report.add(
|
|
18
|
+
name: "baseline.smith_loads",
|
|
19
|
+
status: defined?(::Smith) ? :pass : :fail,
|
|
20
|
+
message: defined?(::Smith) ? "smith loads" : "smith failed to load",
|
|
21
|
+
detail: defined?(::Smith) ? nil : "Ensure smith is in your Gemfile and bundle install has been run"
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.check_ruby_version(report)
|
|
26
|
+
satisfied = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2.0")
|
|
27
|
+
report.add(
|
|
28
|
+
name: "baseline.ruby_version",
|
|
29
|
+
status: satisfied ? :pass : :fail,
|
|
30
|
+
message: satisfied ? "Ruby #{RUBY_VERSION}" : "Ruby #{RUBY_VERSION} is below minimum 3.2.0",
|
|
31
|
+
detail: satisfied ? nil : "Smith requires Ruby >= 3.2.0"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.check_ruby_llm_loads(report)
|
|
36
|
+
loaded = defined?(::RubyLLM)
|
|
37
|
+
report.add(
|
|
38
|
+
name: "baseline.ruby_llm_loads",
|
|
39
|
+
status: loaded ? :pass : :fail,
|
|
40
|
+
message: loaded ? "ruby_llm loads" : "ruby_llm failed to load",
|
|
41
|
+
detail: loaded ? nil : "Ensure ruby_llm is in your Gemfile"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.check_concurrent_loads(report)
|
|
46
|
+
loaded = defined?(::Concurrent)
|
|
47
|
+
report.add(
|
|
48
|
+
name: "baseline.concurrent_loads",
|
|
49
|
+
status: loaded ? :pass : :fail,
|
|
50
|
+
message: loaded ? "concurrent-ruby loads" : "concurrent-ruby failed to load",
|
|
51
|
+
detail: loaded ? nil : "Ensure concurrent-ruby is in your Gemfile"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.check_configure(report)
|
|
56
|
+
callable = ::Smith.respond_to?(:configure) && ::Smith.respond_to?(:config)
|
|
57
|
+
report.add(
|
|
58
|
+
name: "baseline.configure",
|
|
59
|
+
status: callable ? :pass : :fail,
|
|
60
|
+
message: callable ? "Smith.configure callable" : "Smith.configure not available"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.check_minimal_workflow(report)
|
|
65
|
+
workflow_class = Class.new(::Smith::Workflow) do
|
|
66
|
+
initial_state :idle
|
|
67
|
+
state :done
|
|
68
|
+
transition :check, from: :idle, to: :done
|
|
69
|
+
end
|
|
70
|
+
booted = workflow_class.new.state == :idle
|
|
71
|
+
|
|
72
|
+
report.add(
|
|
73
|
+
name: "baseline.minimal_workflow",
|
|
74
|
+
status: booted ? :pass : :fail,
|
|
75
|
+
message: booted ? "Minimal workflow boots" : "Minimal workflow failed to initialize"
|
|
76
|
+
)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
report.add(name: "baseline.minimal_workflow", status: :fail, message: "Minimal workflow failed",
|
|
79
|
+
detail: e.message)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
module Configuration
|
|
7
|
+
def self.run(report)
|
|
8
|
+
check_logger(report)
|
|
9
|
+
check_artifact_store(report)
|
|
10
|
+
check_trace_adapter(report)
|
|
11
|
+
check_pricing(report)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.check_logger(report)
|
|
15
|
+
configured = !::Smith.config.logger.nil?
|
|
16
|
+
report.add(
|
|
17
|
+
name: "config.logger",
|
|
18
|
+
status: configured ? :pass : :warn,
|
|
19
|
+
message: configured ? "Logger configured" : "No logger configured",
|
|
20
|
+
detail: configured ? nil : "Set config.logger for Smith runtime logging"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.check_artifact_store(report)
|
|
25
|
+
configured = !::Smith.config.artifact_store.nil?
|
|
26
|
+
report.add(
|
|
27
|
+
name: "config.artifact_store",
|
|
28
|
+
status: configured ? :pass : :warn,
|
|
29
|
+
message: configured ? "Artifact store configured" : "No artifact store configured",
|
|
30
|
+
detail: configured ? nil : "Large outputs will use in-memory default"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.check_trace_adapter(report)
|
|
35
|
+
configured = !::Smith.config.trace_adapter.nil?
|
|
36
|
+
report.add(
|
|
37
|
+
name: "config.trace_adapter",
|
|
38
|
+
status: configured ? :pass : :warn,
|
|
39
|
+
message: configured ? "Trace adapter configured" : "No trace adapter configured",
|
|
40
|
+
detail: configured ? nil : "Traces will be discarded"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.check_pricing(report)
|
|
45
|
+
configured = ::Smith.config.pricing.is_a?(Hash) && !::Smith.config.pricing.empty?
|
|
46
|
+
report.add(
|
|
47
|
+
name: "config.pricing",
|
|
48
|
+
status: configured ? :pass : :warn,
|
|
49
|
+
message: configured ? "Pricing configured" : "No pricing configured",
|
|
50
|
+
detail: configured ? nil : "RunResult.total_cost will be 0.0"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Doctor
|
|
7
|
+
module Checks
|
|
8
|
+
module Durability
|
|
9
|
+
PROBE_KEY = "smith_doctor_probe"
|
|
10
|
+
|
|
11
|
+
def self.run(report)
|
|
12
|
+
raw_adapter = ::Smith.config.persistence_adapter
|
|
13
|
+
unless raw_adapter
|
|
14
|
+
report.add(
|
|
15
|
+
name: "durability.adapter",
|
|
16
|
+
status: :warn,
|
|
17
|
+
message: "No persistence adapter configured",
|
|
18
|
+
detail: "Set config.persistence_adapter to :rails_cache, :active_record, :redis, :cache_store, or a custom adapter"
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
adapter = ::Smith.persistence_adapter
|
|
24
|
+
add_backend_warning(report, adapter)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
report.add(
|
|
27
|
+
name: "durability.adapter",
|
|
28
|
+
status: :fail,
|
|
29
|
+
message: "Persistence adapter configuration is invalid",
|
|
30
|
+
detail: e.message
|
|
31
|
+
)
|
|
32
|
+
return
|
|
33
|
+
else
|
|
34
|
+
check_persist_and_restore(report, adapter)
|
|
35
|
+
check_resume_after_restore(report, adapter)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.check_persist_and_restore(report, adapter)
|
|
39
|
+
workflow_class = build_probe_class
|
|
40
|
+
payload = JSON.generate(workflow_class.new.to_state)
|
|
41
|
+
|
|
42
|
+
adapter.store(PROBE_KEY, payload)
|
|
43
|
+
restored_payload = adapter.fetch(PROBE_KEY)
|
|
44
|
+
restored = workflow_class.from_state(JSON.parse(restored_payload))
|
|
45
|
+
adapter.delete(PROBE_KEY)
|
|
46
|
+
|
|
47
|
+
valid = restored.state == :idle
|
|
48
|
+
report.add(
|
|
49
|
+
name: "durability.persist_restore",
|
|
50
|
+
status: valid ? :pass : :fail,
|
|
51
|
+
message: valid ? "Host persistence round-trip works" : "Host persistence round-trip failed"
|
|
52
|
+
)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
report.add(
|
|
55
|
+
name: "durability.persist_restore", status: :fail,
|
|
56
|
+
message: "Host persistence round-trip failed", detail: e.message
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.check_resume_after_restore(report, adapter)
|
|
61
|
+
workflow_class = build_probe_class
|
|
62
|
+
payload = JSON.generate(workflow_class.new.to_state)
|
|
63
|
+
|
|
64
|
+
adapter.store(PROBE_KEY, payload)
|
|
65
|
+
restored_payload = adapter.fetch(PROBE_KEY)
|
|
66
|
+
restored = workflow_class.from_state(JSON.parse(restored_payload))
|
|
67
|
+
adapter.delete(PROBE_KEY)
|
|
68
|
+
|
|
69
|
+
result = restored.run!
|
|
70
|
+
valid = result.state == :done
|
|
71
|
+
|
|
72
|
+
report.add(
|
|
73
|
+
name: "durability.resume_after_restore",
|
|
74
|
+
status: valid ? :pass : :fail,
|
|
75
|
+
message: valid ? "Restored workflow resumes correctly" : "Restored workflow did not reach terminal state"
|
|
76
|
+
)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
report.add(name: "durability.resume_after_restore", status: :fail,
|
|
79
|
+
message: "Resume after restore failed", detail: e.message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.build_probe_class
|
|
83
|
+
Class.new(::Smith::Workflow) do
|
|
84
|
+
initial_state :idle
|
|
85
|
+
state :done
|
|
86
|
+
transition :finish, from: :idle, to: :done
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.add_backend_warning(report, adapter)
|
|
91
|
+
warning = adapter.respond_to?(:durability_warning) ? adapter.durability_warning : nil
|
|
92
|
+
return unless warning
|
|
93
|
+
|
|
94
|
+
report.add(
|
|
95
|
+
name: "durability.backend",
|
|
96
|
+
status: :warn,
|
|
97
|
+
message: warning
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
module Live
|
|
7
|
+
def self.run(report)
|
|
8
|
+
check_provider_config(report)
|
|
9
|
+
check_model_call(report)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.check_provider_config(report)
|
|
13
|
+
configured = ruby_llm_configured?
|
|
14
|
+
report.add(
|
|
15
|
+
name: "live.provider_config",
|
|
16
|
+
status: configured ? :pass : :warn,
|
|
17
|
+
message: configured ? "RubyLLM provider configured" : "No RubyLLM provider credentials detected",
|
|
18
|
+
detail: configured ? nil : "Configure RubyLLM or set a provider API key env var"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.check_model_call(report)
|
|
23
|
+
attempt_model_call(report)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.attempt_model_call(report)
|
|
27
|
+
response = ::RubyLLM.chat.ask("Respond with exactly: ok")
|
|
28
|
+
has_content = response.respond_to?(:content) && !response.content.nil?
|
|
29
|
+
report.add(
|
|
30
|
+
name: "live.model_call",
|
|
31
|
+
status: has_content ? :pass : :fail,
|
|
32
|
+
message: has_content ? "Live model call succeeded" : "Live model call returned no content"
|
|
33
|
+
)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
report.add(name: "live.model_call", status: :fail, message: "Live model call failed", detail: e.message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.ruby_llm_configured?
|
|
39
|
+
config = ::RubyLLM.config
|
|
40
|
+
return true if present?(config.openai_api_key)
|
|
41
|
+
return true if present?(config.anthropic_api_key)
|
|
42
|
+
return true if present?(config.gemini_api_key)
|
|
43
|
+
|
|
44
|
+
false
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.present?(value)
|
|
50
|
+
value.is_a?(String) && !value.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates that every registered agent's resolved model has either:
|
|
7
|
+
# (a) an explicit application-side Smith::Models.register override, OR
|
|
8
|
+
# (b) a matching Smith::Models::Inference rule (library-shipped).
|
|
9
|
+
#
|
|
10
|
+
# If neither, the model gets safe defaults (no thinking, accepts temp,
|
|
11
|
+
# no tool routing) which may silently degrade behavior. Reports the
|
|
12
|
+
# uncovered models so hosts know to register overrides or rely on
|
|
13
|
+
# the safe defaults knowingly.
|
|
14
|
+
module ModelsRegistry
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(report)
|
|
18
|
+
uncovered = uncovered_models
|
|
19
|
+
if uncovered.empty?
|
|
20
|
+
report.add(
|
|
21
|
+
name: "models.coverage",
|
|
22
|
+
status: :pass,
|
|
23
|
+
message: "All registered agents have model profiles or matching inference rules"
|
|
24
|
+
)
|
|
25
|
+
else
|
|
26
|
+
report.add(
|
|
27
|
+
name: "models.coverage",
|
|
28
|
+
status: :warn,
|
|
29
|
+
message: "#{uncovered.size} agent model(s) without explicit profile or matching inference rule",
|
|
30
|
+
detail: "Uncovered: #{uncovered.join(", ")}. These models will get safe defaults " \
|
|
31
|
+
"(no thinking, accepts temperature, no tool routing). Either register an " \
|
|
32
|
+
"explicit Smith::Models::Profile via Smith::Models.register, OR add an " \
|
|
33
|
+
"Inference rule via Smith::Models::Inference.prepend_rule if the model " \
|
|
34
|
+
"fits an existing provider pattern."
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Walk Smith::Agent::Registry. For each agent, extract the model
|
|
40
|
+
# id from chat_kwargs (static `model "..."` form). Block-form
|
|
41
|
+
# `model do |ctx| ... end` agents are skipped because their
|
|
42
|
+
# model is resolved per-attempt and can't be enumerated at boot.
|
|
43
|
+
# Check whether find_or_infer returns a custom (non-default)
|
|
44
|
+
# Profile — meaning either an explicit override or an inference
|
|
45
|
+
# rule matched.
|
|
46
|
+
def uncovered_models
|
|
47
|
+
return [] unless defined?(Smith::Agent::Registry)
|
|
48
|
+
|
|
49
|
+
model_ids = []
|
|
50
|
+
Smith::Agent::Registry.each do |_key, agent|
|
|
51
|
+
next unless agent.is_a?(Class)
|
|
52
|
+
next unless agent.respond_to?(:chat_kwargs)
|
|
53
|
+
|
|
54
|
+
id = agent.chat_kwargs[:model]
|
|
55
|
+
model_ids << id if id
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
model_ids.uniq.reject do |model_id|
|
|
59
|
+
Smith::Models.find(model_id) ||
|
|
60
|
+
(defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates that Smith.config.openai_api_mode is one of the allowed
|
|
7
|
+
# values (:off | :auto). Also surfaces whether the
|
|
8
|
+
# Smith::Providers::OpenAI::Responses adapter is loaded when mode
|
|
9
|
+
# is :auto — if not, gpt-5 family + tools + thinking would raise
|
|
10
|
+
# NotImplementedError at runtime.
|
|
11
|
+
module OpenaiApiMode
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def run(report)
|
|
15
|
+
mode = Smith.config.openai_api_mode
|
|
16
|
+
unless %i[off auto].include?(mode)
|
|
17
|
+
report.add(
|
|
18
|
+
name: "config.openai_api_mode",
|
|
19
|
+
status: :fail,
|
|
20
|
+
message: "openai_api_mode = #{mode.inspect} (invalid)",
|
|
21
|
+
detail: "Must be :off or :auto"
|
|
22
|
+
)
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if mode == :auto && !responses_adapter_loaded?
|
|
27
|
+
report.add(
|
|
28
|
+
name: "config.openai_api_mode",
|
|
29
|
+
status: :warn,
|
|
30
|
+
message: "openai_api_mode = :auto but Smith::Providers::OpenAI::Responses is not vendored",
|
|
31
|
+
detail: "When (gpt-5 family + tools + thinking) is detected, the routing path " \
|
|
32
|
+
"raises NotImplementedError. Either: (a) set openai_api_mode = :off to fall " \
|
|
33
|
+
"back to graceful tool-dropping, or (b) vendor the Responses adapter (PR #770 " \
|
|
34
|
+
"on crmne/ruby_llm tracks the upstream effort)."
|
|
35
|
+
)
|
|
36
|
+
else
|
|
37
|
+
report.add(
|
|
38
|
+
name: "config.openai_api_mode",
|
|
39
|
+
status: :pass,
|
|
40
|
+
message: "openai_api_mode = #{mode.inspect}"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def responses_adapter_loaded?
|
|
46
|
+
defined?(Smith::Providers::OpenAI::Responses)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "persistence_registry"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Doctor
|
|
7
|
+
module Checks
|
|
8
|
+
module Persistence
|
|
9
|
+
def self.run(report)
|
|
10
|
+
check_active_record(report)
|
|
11
|
+
check_db_connection(report)
|
|
12
|
+
check_ruby_llm_persistence(report)
|
|
13
|
+
check_schema_presence(report)
|
|
14
|
+
PersistenceRegistry.check(report, ::Smith.config.ruby_llm_model_registry)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.check_active_record(report)
|
|
18
|
+
loaded = defined?(::ActiveRecord::Base)
|
|
19
|
+
report.add(
|
|
20
|
+
name: "persistence.active_record",
|
|
21
|
+
status: loaded ? :pass : :fail,
|
|
22
|
+
message: loaded ? "ActiveRecord available" : "ActiveRecord not available"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.check_db_connection(report)
|
|
27
|
+
unless defined?(::ActiveRecord::Base)
|
|
28
|
+
report.add(name: "persistence.db_connection", status: :skip, message: "DB check skipped — no ActiveRecord")
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
verify_active_connection(report)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.check_ruby_llm_persistence(report)
|
|
36
|
+
detected = ruby_llm_persistence_detected?
|
|
37
|
+
report.add(
|
|
38
|
+
name: "persistence.ruby_llm_surface",
|
|
39
|
+
status: detected ? :pass : :warn,
|
|
40
|
+
message: detected ? "RubyLLM persistence surface detected" : "RubyLLM persistence surface not detected",
|
|
41
|
+
detail: detected ? nil : "RubyLLM may be running in memory-only mode"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.check_schema_presence(report)
|
|
46
|
+
unless defined?(::ActiveRecord::Base)
|
|
47
|
+
report.add(
|
|
48
|
+
name: "persistence.schema_presence", status: :skip, message: "Schema check skipped — no ActiveRecord"
|
|
49
|
+
)
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
inspect_tables(report)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.check_model_registry_mode(report)
|
|
57
|
+
PersistenceRegistry.check(report, ::Smith.config.ruby_llm_model_registry)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.verify_active_connection(report)
|
|
61
|
+
active = ::ActiveRecord::Base.connection.active?
|
|
62
|
+
report.add(
|
|
63
|
+
name: "persistence.db_connection",
|
|
64
|
+
status: active ? :pass : :fail,
|
|
65
|
+
message: active ? "Database connection active" : "Database connection failed"
|
|
66
|
+
)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
report.add(name: "persistence.db_connection", status: :fail, message: "Database connection failed",
|
|
69
|
+
detail: e.message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.ruby_llm_persistence_detected?
|
|
73
|
+
return false unless defined?(::RubyLLM::Chat)
|
|
74
|
+
|
|
75
|
+
::RubyLLM::Chat.ancestors.any? { |a| a.name&.include?("ActiveRecord") }
|
|
76
|
+
rescue StandardError
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.inspect_tables(report)
|
|
81
|
+
tables = ::ActiveRecord::Base.connection.tables
|
|
82
|
+
known = tables.select { |t| %w[chats messages tool_calls].include?(t) }
|
|
83
|
+
|
|
84
|
+
if known.any?
|
|
85
|
+
report.add(name: "persistence.schema_presence", status: :pass,
|
|
86
|
+
message: "RubyLLM tables found: #{known.join(", ")}")
|
|
87
|
+
else
|
|
88
|
+
report.add(name: "persistence.schema_presence", status: :warn,
|
|
89
|
+
message: "No RubyLLM persistence tables detected (heuristic)",
|
|
90
|
+
detail: "Expected tables like chats, messages, or tool_calls")
|
|
91
|
+
end
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
report.add(name: "persistence.schema_presence", status: :warn,
|
|
94
|
+
message: "Schema check inconclusive", detail: e.message)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
# Reports which optional capabilities the configured persistence
|
|
7
|
+
# adapter supports. Hosts who depend on optimistic locking but
|
|
8
|
+
# configure a cache-backed adapter (CacheStore / RailsCache) need
|
|
9
|
+
# to know up front that store_versioned silently falls back to
|
|
10
|
+
# plain store. Workflow#persist! warns once per adapter class
|
|
11
|
+
# per Smith boot, but doctor surfaces it eagerly.
|
|
12
|
+
module PersistenceCapabilities
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
OPTIONAL_CAPABILITIES = %i[store_versioned].freeze
|
|
16
|
+
|
|
17
|
+
def run(report)
|
|
18
|
+
adapter = resolve_adapter
|
|
19
|
+
if adapter.nil?
|
|
20
|
+
report.add(
|
|
21
|
+
name: "persistence.capabilities",
|
|
22
|
+
status: :warn,
|
|
23
|
+
message: "No persistence adapter configured",
|
|
24
|
+
detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
|
|
25
|
+
"Hosts using durable workflows must set persistence_adapter."
|
|
26
|
+
)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
missing = OPTIONAL_CAPABILITIES.reject { |cap| Smith::PersistenceAdapters.supports?(adapter, cap) }
|
|
31
|
+
|
|
32
|
+
if missing.empty?
|
|
33
|
+
report.add(
|
|
34
|
+
name: "persistence.capabilities",
|
|
35
|
+
status: :pass,
|
|
36
|
+
message: "#{adapter.class.name} supports all optional persistence capabilities",
|
|
37
|
+
detail: "Supported: #{OPTIONAL_CAPABILITIES.join(', ')}"
|
|
38
|
+
)
|
|
39
|
+
else
|
|
40
|
+
report.add(
|
|
41
|
+
name: "persistence.capabilities",
|
|
42
|
+
status: :warn,
|
|
43
|
+
message: "#{adapter.class.name} missing optional capabilities: #{missing.join(', ')}",
|
|
44
|
+
detail: "Workflows using these capabilities fall back to non-versioned writes " \
|
|
45
|
+
"with a one-time warning per adapter class. Switch to RedisStore, " \
|
|
46
|
+
"ActiveRecordStore (with lock_version column), or the Memory adapter " \
|
|
47
|
+
"for full coverage."
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_adapter
|
|
53
|
+
Smith.persistence_adapter
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|