smith-agents 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. metadata +258 -0
@@ -0,0 +1,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