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,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
module PersistenceRegistry
|
|
7
|
+
def self.check(report, mode)
|
|
8
|
+
case mode
|
|
9
|
+
when :database
|
|
10
|
+
check_database_registry(report)
|
|
11
|
+
when :bundled
|
|
12
|
+
report.add(name: "persistence.model_registry_mode", status: :pass,
|
|
13
|
+
message: "Model registry mode: bundled (explicit)")
|
|
14
|
+
else
|
|
15
|
+
report.add(
|
|
16
|
+
name: "persistence.model_registry_mode", status: :pass,
|
|
17
|
+
message: "Model registry mode: bundled fallback (default)",
|
|
18
|
+
detail: "Set config.ruby_llm_model_registry = :database if DB-backed registry is required"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.check_database_registry(report)
|
|
24
|
+
registry_class = resolve_registry_class
|
|
25
|
+
unless registry_class
|
|
26
|
+
report.add(name: "persistence.model_registry_mode", status: :fail,
|
|
27
|
+
message: "DB-backed model registry required but registry class not resolvable",
|
|
28
|
+
detail: "RubyLLM.config.model_registry_class could not be resolved to a constant")
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
unless ar_backed?(registry_class)
|
|
33
|
+
report.add(name: "persistence.model_registry_mode", status: :fail,
|
|
34
|
+
message: "DB-backed model registry required but class is not ActiveRecord-backed",
|
|
35
|
+
detail: "#{registry_class.name} does not inherit from ActiveRecord::Base")
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
verify_registry_table_and_records(report, registry_class)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.resolve_registry_class
|
|
43
|
+
raw = ::RubyLLM.config.model_registry_class
|
|
44
|
+
return nil unless raw
|
|
45
|
+
|
|
46
|
+
klass = raw.is_a?(String) ? Object.const_get(raw) : raw
|
|
47
|
+
klass.is_a?(Class) ? klass : nil
|
|
48
|
+
rescue NameError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.ar_backed?(klass)
|
|
53
|
+
klass.ancestors.any? { |a| a.name&.include?("ActiveRecord::Base") }
|
|
54
|
+
rescue StandardError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.verify_registry_table_and_records(report, registry_class)
|
|
59
|
+
unless registry_class.table_exists?
|
|
60
|
+
report.add(name: "persistence.model_registry_mode", status: :fail,
|
|
61
|
+
message: "DB-backed model registry table missing",
|
|
62
|
+
detail: "#{registry_class.table_name} does not exist")
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
count = registry_class.count
|
|
67
|
+
if count.positive?
|
|
68
|
+
report.add(name: "persistence.model_registry_mode", status: :pass,
|
|
69
|
+
message: "DB-backed model registry operational (#{count} records)")
|
|
70
|
+
else
|
|
71
|
+
report.add(name: "persistence.model_registry_mode", status: :fail,
|
|
72
|
+
message: "DB-backed model registry table exists but is empty",
|
|
73
|
+
detail: "Run ruby_llm model sync or seeding task")
|
|
74
|
+
end
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
report.add(name: "persistence.model_registry_mode", status: :fail,
|
|
77
|
+
message: "DB-backed model registry query failed", detail: e.message)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
module Rails
|
|
7
|
+
def self.run(report)
|
|
8
|
+
unless defined?(::Rails)
|
|
9
|
+
report.add(name: "rails.detected", status: :skip, message: "Rails not detected")
|
|
10
|
+
return
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
check_application(report)
|
|
14
|
+
check_smith_config(report)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.check_application(report)
|
|
18
|
+
present = defined?(::Rails.application) && !::Rails.application.nil?
|
|
19
|
+
report.add(
|
|
20
|
+
name: "rails.application",
|
|
21
|
+
status: present ? :pass : :fail,
|
|
22
|
+
message: present ? "Rails application present" : "Rails application not found",
|
|
23
|
+
detail: present ? nil : "Rails is loaded but no application is defined"
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.check_smith_config(report)
|
|
28
|
+
accessible = ::Smith.respond_to?(:config) && !::Smith.config.nil?
|
|
29
|
+
report.add(
|
|
30
|
+
name: "rails.smith_config",
|
|
31
|
+
status: accessible ? :pass : :warn,
|
|
32
|
+
message: accessible ? "Smith config accessible from Rails" : "Smith config not accessible",
|
|
33
|
+
detail: accessible ? nil : "Ensure config/initializers/smith.rb exists"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Doctor
|
|
7
|
+
module Checks
|
|
8
|
+
module Serialization
|
|
9
|
+
def self.run(report)
|
|
10
|
+
check_to_state(report)
|
|
11
|
+
check_json_roundtrip(report)
|
|
12
|
+
check_from_state(report)
|
|
13
|
+
check_resume(report)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.check_to_state(report)
|
|
17
|
+
state = build_probe_class.new.to_state
|
|
18
|
+
valid = state.is_a?(Hash) && state.key?(:state) && state.key?(:class)
|
|
19
|
+
report.add(
|
|
20
|
+
name: "serialization.to_state",
|
|
21
|
+
status: valid ? :pass : :fail,
|
|
22
|
+
message: valid ? "to_state produces valid Hash" : "to_state output is malformed"
|
|
23
|
+
)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
report.add(name: "serialization.to_state", status: :fail, message: "to_state failed", detail: e.message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.check_json_roundtrip(report)
|
|
29
|
+
state = build_probe_class.new.to_state
|
|
30
|
+
parsed = JSON.parse(JSON.generate(state))
|
|
31
|
+
valid = parsed.is_a?(Hash) && parsed.key?("state")
|
|
32
|
+
report.add(
|
|
33
|
+
name: "serialization.json_roundtrip",
|
|
34
|
+
status: valid ? :pass : :fail,
|
|
35
|
+
message: valid ? "JSON round-trip preserves state" : "JSON round-trip corrupts state"
|
|
36
|
+
)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
report.add(name: "serialization.json_roundtrip", status: :fail, message: "JSON round-trip failed",
|
|
39
|
+
detail: e.message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.check_from_state(report)
|
|
43
|
+
klass = build_probe_class
|
|
44
|
+
parsed = JSON.parse(JSON.generate(klass.new.to_state))
|
|
45
|
+
restored = klass.from_state(parsed)
|
|
46
|
+
valid = restored.state == :idle
|
|
47
|
+
report.add(
|
|
48
|
+
name: "serialization.from_state",
|
|
49
|
+
status: valid ? :pass : :fail,
|
|
50
|
+
message: valid ? "from_state restores workflow" : "from_state produced invalid state"
|
|
51
|
+
)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
report.add(name: "serialization.from_state", status: :fail, message: "from_state failed", detail: e.message)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.check_resume(report)
|
|
57
|
+
result = build_probe_class.new.run!
|
|
58
|
+
valid = result.state == :done
|
|
59
|
+
report.add(
|
|
60
|
+
name: "serialization.resume",
|
|
61
|
+
status: valid ? :pass : :fail,
|
|
62
|
+
message: valid ? "Workflow completes after restore" : "Workflow did not reach terminal state"
|
|
63
|
+
)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
report.add(name: "serialization.resume", status: :fail, message: "Resume failed", detail: e.message)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.build_probe_class
|
|
69
|
+
Class.new(::Smith::Workflow) do
|
|
70
|
+
initial_state :idle
|
|
71
|
+
state :done
|
|
72
|
+
transition :finish, from: :idle, to: :done
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Doctor
|
|
7
|
+
class Installer
|
|
8
|
+
def self.run(io: $stdout)
|
|
9
|
+
new(io:).install
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(io:)
|
|
13
|
+
@io = io
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def install
|
|
17
|
+
write_config_file
|
|
18
|
+
print_next_steps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def write_config_file
|
|
24
|
+
path = target_path
|
|
25
|
+
if File.exist?(path)
|
|
26
|
+
@io.puts " exists #{path}"
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
31
|
+
File.write(path, rails? ? rails_template : plain_template)
|
|
32
|
+
@io.puts " create #{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def target_path
|
|
36
|
+
rails? ? "config/initializers/smith.rb" : "config/smith.rb"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rails?
|
|
40
|
+
defined?(::Rails::Railtie)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rails_template
|
|
44
|
+
template_path = File.expand_path("../../generators/smith/install/templates/smith.rb.tt", __dir__)
|
|
45
|
+
File.read(template_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def plain_template
|
|
49
|
+
<<~RUBY
|
|
50
|
+
# frozen_string_literal: true
|
|
51
|
+
|
|
52
|
+
require "logger"
|
|
53
|
+
require "smith"
|
|
54
|
+
|
|
55
|
+
Smith.configure do |config|
|
|
56
|
+
config.logger = Logger.new($stdout)
|
|
57
|
+
# Host durability verification / persistence adapter options:
|
|
58
|
+
# config.persistence_adapter = :cache_store
|
|
59
|
+
# config.persistence_options = {
|
|
60
|
+
# store: SomeCacheStore.new,
|
|
61
|
+
# namespace: "smith"
|
|
62
|
+
# }
|
|
63
|
+
#
|
|
64
|
+
# config.persistence_adapter = :redis
|
|
65
|
+
# config.persistence_options = {
|
|
66
|
+
# redis: Redis.new(url: ENV.fetch("REDIS_URL")),
|
|
67
|
+
# namespace: "smith"
|
|
68
|
+
# }
|
|
69
|
+
#
|
|
70
|
+
# config.persistence_adapter = :active_record
|
|
71
|
+
# config.persistence_options = {
|
|
72
|
+
# model: WorkflowState,
|
|
73
|
+
# key_column: :key,
|
|
74
|
+
# payload_column: :payload
|
|
75
|
+
# }
|
|
76
|
+
#
|
|
77
|
+
# Custom adapters are also supported if they implement:
|
|
78
|
+
# store(key, payload)
|
|
79
|
+
# fetch(key)
|
|
80
|
+
# delete(key)
|
|
81
|
+
config.artifact_store = Smith::Artifacts::Memory.new
|
|
82
|
+
config.trace_adapter = Smith::Trace::Memory.new
|
|
83
|
+
end
|
|
84
|
+
RUBY
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def print_next_steps
|
|
88
|
+
@io.puts ""
|
|
89
|
+
if rails?
|
|
90
|
+
@io.puts "Smith installed. Next steps:"
|
|
91
|
+
@io.puts " 1. Configure RubyLLM in config/initializers/ruby_llm.rb"
|
|
92
|
+
@io.puts " 2. Run: bin/rails smith:doctor"
|
|
93
|
+
else
|
|
94
|
+
@io.puts "Smith configured. Next steps:"
|
|
95
|
+
@io.puts " 1. Configure RubyLLM for your provider"
|
|
96
|
+
@io.puts " 2. Run: smith doctor"
|
|
97
|
+
end
|
|
98
|
+
@io.puts " 3. Define your first agent and workflow"
|
|
99
|
+
@io.puts ""
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
class Printer
|
|
6
|
+
CLEAR = "\e[0m"
|
|
7
|
+
BOLD = "\e[1m"
|
|
8
|
+
RED = "\e[31m"
|
|
9
|
+
GREEN = "\e[32m"
|
|
10
|
+
YELLOW = "\e[33m"
|
|
11
|
+
CYAN = "\e[36m"
|
|
12
|
+
WHITE = "\e[37m"
|
|
13
|
+
|
|
14
|
+
ICONS = { pass: "\u2713", fail: "\u2717", warn: "!", skip: "-" }.freeze
|
|
15
|
+
COLORS = { pass: GREEN, fail: RED, warn: YELLOW, skip: CYAN }.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(report, io: $stdout)
|
|
18
|
+
@report = report
|
|
19
|
+
@io = io
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def print
|
|
23
|
+
@io.puts colorize("\nSmith Doctor\n", BOLD, WHITE)
|
|
24
|
+
print_grouped_checks
|
|
25
|
+
print_summary
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def print_grouped_checks
|
|
31
|
+
@report.grouped.each do |group, checks|
|
|
32
|
+
@io.puts colorize(" #{format_group(group)}", BOLD, WHITE)
|
|
33
|
+
checks.each { |c| print_check(c) }
|
|
34
|
+
@io.puts
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print_check(check)
|
|
39
|
+
icon = ICONS.fetch(check.status)
|
|
40
|
+
color = COLORS.fetch(check.status)
|
|
41
|
+
@io.puts " #{colorize(icon, color)} #{check.message}"
|
|
42
|
+
@io.puts " #{colorize(check.detail, CYAN)}" if check.detail
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def print_summary
|
|
46
|
+
color = @report.passed? ? GREEN : RED
|
|
47
|
+
@io.puts colorize(" #{@report.summary}", BOLD, color)
|
|
48
|
+
@io.puts
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def format_group(group)
|
|
52
|
+
group.split("_").map(&:capitalize).join(" ")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def colorize(text, *codes)
|
|
56
|
+
return text.to_s unless @io.respond_to?(:tty?) && @io.tty? && ENV["NO_COLOR"].nil?
|
|
57
|
+
|
|
58
|
+
"#{codes.join}#{text}#{CLEAR}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
class Report
|
|
6
|
+
attr_reader :checks
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@checks = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add(name:, status:, message:, detail: nil)
|
|
13
|
+
@checks << Check.new(name:, status:, message:, detail:)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def passed?
|
|
17
|
+
checks.none?(&:fail?)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exit_code
|
|
21
|
+
passed? ? 0 : 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def grouped
|
|
25
|
+
checks.group_by { |c| c.name.split(".").first }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def summary
|
|
29
|
+
counts = checks.group_by(&:status).transform_values(&:size)
|
|
30
|
+
parts = []
|
|
31
|
+
parts << "#{counts.fetch(:pass, 0)} passed"
|
|
32
|
+
parts << "#{counts[:warn]} warnings" if counts[:warn]
|
|
33
|
+
parts << "#{counts[:fail]} failed" if counts[:fail]
|
|
34
|
+
parts << "#{counts[:skip]} skipped" if counts[:skip]
|
|
35
|
+
parts.join(", ")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/smith/doctor.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "doctor/check"
|
|
4
|
+
require_relative "doctor/report"
|
|
5
|
+
require_relative "doctor/printer"
|
|
6
|
+
require_relative "doctor/installer"
|
|
7
|
+
require_relative "doctor/checks/baseline"
|
|
8
|
+
require_relative "doctor/checks/configuration"
|
|
9
|
+
require_relative "doctor/checks/rails"
|
|
10
|
+
require_relative "doctor/checks/persistence"
|
|
11
|
+
require_relative "doctor/checks/persistence_registry"
|
|
12
|
+
require_relative "doctor/checks/serialization"
|
|
13
|
+
require_relative "doctor/checks/durability"
|
|
14
|
+
require_relative "doctor/checks/live"
|
|
15
|
+
require_relative "doctor/checks/models_registry"
|
|
16
|
+
require_relative "doctor/checks/openai_api_mode"
|
|
17
|
+
require_relative "doctor/checks/persistence_capabilities"
|
|
18
|
+
|
|
19
|
+
module Smith
|
|
20
|
+
module Doctor
|
|
21
|
+
def self.run(profile: :auto, live: false, durability: false, io: $stdout)
|
|
22
|
+
report = Report.new
|
|
23
|
+
|
|
24
|
+
Checks::Baseline.run(report)
|
|
25
|
+
Checks::Configuration.run(report)
|
|
26
|
+
Checks::ModelsRegistry.run(report)
|
|
27
|
+
Checks::OpenaiApiMode.run(report)
|
|
28
|
+
Checks::Rails.run(report) if detect_rails?
|
|
29
|
+
Checks::Persistence.run(report) if persistence_profile?(profile)
|
|
30
|
+
Checks::PersistenceCapabilities.run(report) if persistence_profile?(profile)
|
|
31
|
+
if durability || durability_profile?(profile)
|
|
32
|
+
Checks::Serialization.run(report)
|
|
33
|
+
Checks::Durability.run(report)
|
|
34
|
+
end
|
|
35
|
+
Checks::Live.run(report) if live
|
|
36
|
+
|
|
37
|
+
Printer.new(report, io:).print
|
|
38
|
+
report
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.detect_rails?
|
|
42
|
+
defined?(::Rails::Railtie)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.persistence_profile?(profile)
|
|
46
|
+
profile == :rails_persistence
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.durability_profile?(profile)
|
|
50
|
+
profile == :durable
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/smith/errors.rb
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
# Classification surface for host retry policies. Smith owns the
|
|
5
|
+
# answer to "should the workflow attempt be retried?" so consumers
|
|
6
|
+
# don't reimplement the case statement in every Execution / Job.
|
|
7
|
+
module Errors
|
|
8
|
+
# Returns true when the host should retry the workflow attempt.
|
|
9
|
+
# AgentError + DeadlineExceeded are always retryable.
|
|
10
|
+
# DeterministicStepFailure + ToolGuardrailFailed honor their
|
|
11
|
+
# `retryable` attribute (opt-in at the raise site).
|
|
12
|
+
# All other Smith errors and non-Smith errors return false.
|
|
13
|
+
def self.retryable?(error)
|
|
14
|
+
return false if error.nil?
|
|
15
|
+
|
|
16
|
+
case error
|
|
17
|
+
when Smith::DeterministicStepFailure, Smith::ToolGuardrailFailed
|
|
18
|
+
error.retryable == true
|
|
19
|
+
when Smith::AgentError, Smith::DeadlineExceeded
|
|
20
|
+
true
|
|
21
|
+
else
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Always-retryable error classes for explicit ActiveJob retry_on
|
|
27
|
+
# allow-lists. Excludes the retryable-bearing families because
|
|
28
|
+
# their retryability is per-raise, not per-class.
|
|
29
|
+
def self.retryable_classes
|
|
30
|
+
[Smith::AgentError, Smith::DeadlineExceeded].freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class BudgetExceeded < Error; end
|
|
35
|
+
class DeadlineExceeded < Error; end
|
|
36
|
+
class MaxTransitionsExceeded < Error; end
|
|
37
|
+
class GuardrailFailed < Error; end
|
|
38
|
+
class ToolGuardrailFailed < Error
|
|
39
|
+
attr_reader :retryable
|
|
40
|
+
|
|
41
|
+
def initialize(message, retryable: nil)
|
|
42
|
+
@retryable = retryable
|
|
43
|
+
super(message)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
class ToolPolicyDenied < Error; end
|
|
47
|
+
class AgentError < Error; end
|
|
48
|
+
class BlankAgentOutputError < AgentError
|
|
49
|
+
attr_reader :agent_name, :model_used
|
|
50
|
+
|
|
51
|
+
def initialize(agent_name:, model_used:)
|
|
52
|
+
@agent_name = agent_name
|
|
53
|
+
@model_used = model_used
|
|
54
|
+
|
|
55
|
+
detail = +"agent"
|
|
56
|
+
detail << " :#{agent_name}" if agent_name
|
|
57
|
+
detail << " returned blank output"
|
|
58
|
+
detail << " from model #{model_used}" if model_used
|
|
59
|
+
|
|
60
|
+
super(detail)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
class WorkflowError < Error; end
|
|
64
|
+
|
|
65
|
+
class DeterministicStepFailure < WorkflowError
|
|
66
|
+
attr_reader :retryable, :kind, :details
|
|
67
|
+
|
|
68
|
+
def initialize(message, retryable: nil, kind: nil, details: nil)
|
|
69
|
+
@retryable = retryable
|
|
70
|
+
@kind = kind
|
|
71
|
+
@details = details
|
|
72
|
+
super(message)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class UnresolvedTransitionError < WorkflowError
|
|
77
|
+
attr_reader :requested_name, :workflow_class, :origin_state
|
|
78
|
+
|
|
79
|
+
def initialize(requested_name, workflow_class, origin_state)
|
|
80
|
+
@requested_name = requested_name
|
|
81
|
+
@workflow_class = workflow_class
|
|
82
|
+
@origin_state = origin_state
|
|
83
|
+
super("unresolved transition :#{requested_name} in #{workflow_class} from state :#{origin_state}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class SerializationError < Error; end
|
|
88
|
+
class AgentRegistryError < Error; end
|
|
89
|
+
|
|
90
|
+
# Raised after persistence retry attempts are exhausted. Wraps the
|
|
91
|
+
# underlying I/O cause (Redis connection error, AR connection error,
|
|
92
|
+
# cache backend error) so hosts can distinguish a true I/O failure
|
|
93
|
+
# from a programmatic error.
|
|
94
|
+
class PersistenceIOError < Error
|
|
95
|
+
attr_reader :operation, :cause
|
|
96
|
+
|
|
97
|
+
def initialize(operation:, cause:)
|
|
98
|
+
@operation = operation
|
|
99
|
+
@cause = cause
|
|
100
|
+
super("persistence I/O error during #{operation}: #{cause.class}: #{cause.message}")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Raised when an adapter's optimistic-lock check detects a concurrent
|
|
105
|
+
# write: another process modified the key between this process's
|
|
106
|
+
# restore and persist. Hosts can rescue + restore + retry, or fail
|
|
107
|
+
# the workflow run with explicit conflict semantics.
|
|
108
|
+
class PersistenceVersionConflict < Error
|
|
109
|
+
attr_reader :key, :expected, :actual
|
|
110
|
+
|
|
111
|
+
def initialize(key:, expected:, actual:)
|
|
112
|
+
@key = key
|
|
113
|
+
@expected = expected
|
|
114
|
+
@actual = actual
|
|
115
|
+
super("persistence version conflict for #{key.inspect}: expected v#{expected}, got #{actual.inspect}")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Raised when restore detects that the workflow's seed_messages
|
|
120
|
+
# builder now produces a different digest than what was persisted
|
|
121
|
+
# (i.e., the system prompt or seed template changed in code after this
|
|
122
|
+
# workflow was already running). Only fires when the workflow opts into
|
|
123
|
+
# `seed_validation :strict`; the default `:off` skips validation and
|
|
124
|
+
# `:warn` logs without raising.
|
|
125
|
+
class SeedMismatch < Error
|
|
126
|
+
attr_reader :workflow, :stored_digest, :current_digest
|
|
127
|
+
|
|
128
|
+
def initialize(workflow:, stored_digest:, current_digest:)
|
|
129
|
+
@workflow = workflow
|
|
130
|
+
@stored_digest = stored_digest
|
|
131
|
+
@current_digest = current_digest
|
|
132
|
+
super(
|
|
133
|
+
"seed_messages drift detected for #{workflow}: stored digest #{stored_digest.inspect}, " \
|
|
134
|
+
"current digest #{current_digest.inspect}. The seed_messages block changed after this " \
|
|
135
|
+
"workflow was persisted. Restoring this state would mix old + new prompt context."
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Raised on restore when the persisted payload has the
|
|
141
|
+
# step_in_progress marker set AND the workflow class opted into
|
|
142
|
+
# `idempotency_mode :strict`. Signals that a previous worker crashed
|
|
143
|
+
# between `persist!` (before advance) and `persist!` (after advance);
|
|
144
|
+
# the step's effects are unknown, so blindly re-running could
|
|
145
|
+
# double-execute non-idempotent agent calls or tools.
|
|
146
|
+
class StepInProgressOnRestore < Error
|
|
147
|
+
attr_reader :workflow, :persistence_key
|
|
148
|
+
|
|
149
|
+
def initialize(workflow:, persistence_key:)
|
|
150
|
+
@workflow = workflow
|
|
151
|
+
@persistence_key = persistence_key
|
|
152
|
+
super(
|
|
153
|
+
"step in progress on restore for #{workflow} key=#{persistence_key.inspect}: " \
|
|
154
|
+
"a previous worker crashed mid-step. Hosts using idempotency_mode :strict must " \
|
|
155
|
+
"decide whether to clear the persisted state (idempotent re-run unsafe) or " \
|
|
156
|
+
"switch to :lax (assume re-run is safe)."
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Raised when restoring a persisted payload whose schema_version does
|
|
162
|
+
# not match the workflow's current persistence_schema_version AND no
|
|
163
|
+
# migration block is registered to bridge the gap. Hosts fix this by
|
|
164
|
+
# adding `migrate_from(stored) do |payload| ... end` to the workflow
|
|
165
|
+
# class, or by bumping persistence_schema_version to match the stored
|
|
166
|
+
# version. Downgrades (stored > current) always raise; Smith has no
|
|
167
|
+
# rollback semantics.
|
|
168
|
+
class PersistenceSchemaMismatch < Error
|
|
169
|
+
attr_reader :workflow, :stored, :current
|
|
170
|
+
|
|
171
|
+
def initialize(workflow:, stored:, current:)
|
|
172
|
+
@workflow = workflow
|
|
173
|
+
@stored = stored
|
|
174
|
+
@current = current
|
|
175
|
+
super(format_message(workflow, stored, current))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def format_message(workflow, stored, current)
|
|
181
|
+
base = "schema mismatch restoring #{workflow}: stored v#{stored}, current v#{current}."
|
|
182
|
+
if stored > current
|
|
183
|
+
base + " Downgrade is not supported (stored state is ahead of the current code). " \
|
|
184
|
+
"Bump persistence_schema_version to at least #{stored} or roll the code forward."
|
|
185
|
+
else
|
|
186
|
+
base + " Declare `migrate_from(#{stored})` to bridge the gap, or bump persistence_schema_version " \
|
|
187
|
+
"back to #{stored} if this version was rolled out by mistake."
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/smith/event.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Event < Dry::Struct
|
|
8
|
+
attribute(:execution_id, Types::String.default { SecureRandom.uuid })
|
|
9
|
+
attribute(:trace_id, Types::String.default { SecureRandom.uuid })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
File without changes
|