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,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
@@ -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
@@ -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
@@ -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