smith-agents 0.10.0 → 0.12.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. metadata +88 -1
@@ -6,19 +6,34 @@ module Smith
6
6
  MARKER = "[smith:injected-state]"
7
7
 
8
8
  def self.inject(messages, formatter:, persisted:)
9
- content = "#{MARKER}\n#{formatter.call(persisted)}"
9
+ text = formatter.call(persisted).to_s
10
+ content = "#{MARKER}\n#{text}"
10
11
 
11
- existing_index = messages.index do |message|
12
- message_content = message[:content] || message["content"]
13
- message_content.is_a?(String) && message_content.start_with?(MARKER)
14
- end
12
+ existing_index = messages.index { |message| injected_state?(message) }
15
13
 
16
- if existing_index
14
+ if blank?(text)
15
+ without_marker(messages, existing_index)
16
+ elsif existing_index
17
17
  messages.dup.tap { |msgs| msgs[existing_index] = { role: :system, content: content } }
18
18
  else
19
19
  messages + [{ role: :system, content: content }]
20
20
  end
21
21
  end
22
+
23
+ # Only a system message carries the marker: hosts cannot admit system
24
+ # messages, so a user, assistant or tool message that starts with the
25
+ # marker is content, never Smith's injected state.
26
+ def self.injected_state?(message)
27
+ content = message[:content] || message["content"]
28
+ (message[:role] || message["role"]).to_s == "system" && content.is_a?(String) && content.start_with?(MARKER)
29
+ end
30
+
31
+ def self.blank?(text) = text.valid_encoding? && text.strip.empty?
32
+
33
+ def self.without_marker(messages, index)
34
+ index ? messages.dup.tap { |msgs| msgs.delete_at(index) } : messages
35
+ end
36
+ private_class_method :blank?, :without_marker, :injected_state?
22
37
  end
23
38
  end
24
39
  end
data/lib/smith/context.rb CHANGED
@@ -10,6 +10,7 @@ module Smith
10
10
  subclass.instance_variable_set(:@persist_mode, @persist_mode || :explicit)
11
11
  subclass.instance_variable_set(:@persist_auto_seed, (@persist_auto_seed || []).dup)
12
12
  subclass.instance_variable_set(:@inject_state, @inject_state)
13
+ subclass.instance_variable_set(:@untrusted_keys, (@untrusted_keys || []).dup)
13
14
  end
14
15
 
15
16
  def session_strategy(strategy = nil, **opts)
@@ -23,6 +24,7 @@ module Smith
23
24
  return @persist_keys || []
24
25
  end
25
26
 
27
+ validate_persist_keys!(keys)
26
28
  if also && !keys.include?(:auto)
27
29
  raise Smith::WorkflowError, ":also is only valid alongside :auto"
28
30
  end
@@ -47,10 +49,36 @@ module Smith
47
49
  @persist_mode || :explicit
48
50
  end
49
51
 
52
+ def validate_persist_keys!(keys)
53
+ invalid = keys.grep_v(Symbol)
54
+ return if invalid.empty?
55
+
56
+ raise Smith::WorkflowError, "persist keys must be Symbols, got #{invalid.map(&:inspect).join(", ")}"
57
+ end
58
+ private :validate_persist_keys!
59
+
50
60
  def persist_auto_seed
51
61
  @persist_auto_seed || []
52
62
  end
53
63
 
64
+ # Keys whose values come from outside the system (a fetched document,
65
+ # an inbound message), or that a step derived from such a value:
66
+ # `inject_state` formatters and the `seed_messages` builder never
67
+ # receive them, and an agent that names one in `inputs` is refused,
68
+ # in this workflow and in any workflow it nests.
69
+ def untrusted(*keys)
70
+ return untrusted_keys if keys.empty?
71
+
72
+ validate_persist_keys!(keys)
73
+ raise Smith::WorkflowError, "untrusted takes context keys, not :auto" if keys.include?(:auto)
74
+
75
+ @untrusted_keys = (untrusted_keys + keys).uniq
76
+ end
77
+
78
+ def untrusted_keys
79
+ @untrusted_keys || []
80
+ end
81
+
54
82
  def inject_state(&block)
55
83
  return @inject_state unless block_given?
56
84
 
@@ -9,7 +9,10 @@ module Smith
9
9
  STRING_BYTESIZE = String.instance_method(:bytesize)
10
10
  STRING_BYTESLICE = String.instance_method(:byteslice)
11
11
  MODULE_MATCH = Module.instance_method(:===)
12
- private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH
12
+ MODULE_NAME = Module.instance_method(:name)
13
+ OBJECT_CLASS = Object.instance_method(:class)
14
+ private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH, :MODULE_NAME,
15
+ :OBJECT_CLASS
13
16
 
14
17
  extend Dry::Initializer
15
18
 
@@ -18,6 +21,12 @@ module Smith
18
21
 
19
22
  def self.capture(value, max_bytes: MAX_BYTES) = new(value, max_bytes:).call
20
23
 
24
+ # An error's class name, read through the native lookups so the error
25
+ # cannot misreport it, and bounded like any diagnostic identifier.
26
+ def self.error_class_name(error)
27
+ capture(MODULE_NAME.bind_call(OBJECT_CLASS.bind_call(error)) || "anonymous_error", max_bytes: 512)
28
+ end
29
+
21
30
  def call
22
31
  validate_limit!
23
32
  text = utf8_prefix
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Smith
6
+ module Doctor
7
+ module Checks
8
+ # The artifact store: configured, buildable, usable, and as durable as
9
+ # the workflow state, since a run that waits, retries or resumes in
10
+ # another process reads its artifacts there.
11
+ module Artifacts
12
+ PROCESS_LOCAL = %w[Smith::Artifacts::Memory].freeze
13
+ COLUMNS = %w[ref data content_type execution_namespace created_at].freeze
14
+
15
+ def self.run(report)
16
+ raw = ::Smith.config.artifact_store
17
+ return report.add(**missing) if raw.nil? && !::Smith.config.test_mode
18
+
19
+ store = ::Smith.artifact_backend
20
+ report.add(name: "artifacts.store", status: :pass, message: "Artifact store: #{store_name(store)}")
21
+ check_usable(report, store)
22
+ check_durability(report, store)
23
+ check_ownership(report, store)
24
+ rescue StandardError => e
25
+ report.add(name: "artifacts.store", status: :fail, message: "Artifact store configuration is invalid",
26
+ detail: e.message)
27
+ end
28
+
29
+ def self.store_name(store) = store.is_a?(Module) ? store.name : store.class.name
30
+
31
+ def self.missing
32
+ { name: "artifacts.store", status: :warn, message: "No artifact store configured",
33
+ detail: "Set config.artifact_store to :s3 (S3, R2), :active_record, :file, or a store, with " \
34
+ "config.artifact_options; steps that store artifacts or read documents fail without one" }
35
+ end
36
+
37
+ # The table an Active Record store writes, the directory a File
38
+ # store writes, the bucket an S3 store writes.
39
+ def self.check_usable(report, store)
40
+ problem = case store
41
+ when ::Smith::Artifacts::ActiveRecordStore then table_problem(store)
42
+ when ::Smith::Artifacts::File then directory_problem(store)
43
+ when ::Smith::Artifacts::S3 then bucket_problem(store)
44
+ end
45
+ return unless problem
46
+
47
+ report.add(name: "artifacts.usable", status: :warn, message: "The artifact store cannot be used",
48
+ detail: problem)
49
+ end
50
+
51
+ def self.table_problem(store)
52
+ missing = COLUMNS - store.model.column_names
53
+ "#{store.model.name} lacks the columns #{missing.join(", ")}" if missing.any?
54
+ rescue StandardError => e
55
+ "the store's model cannot be loaded: #{e.message}"
56
+ end
57
+
58
+ def self.directory_problem(store)
59
+ dir = store.instance_variable_get(:@dir)
60
+ "the directory #{dir} is not writable" unless ::File.directory?(dir) && ::File.writable?(dir)
61
+ end
62
+
63
+ def self.bucket_problem(store)
64
+ client = store.instance_variable_get(:@client)
65
+ return unless client.respond_to?(:head_bucket)
66
+
67
+ client.head_bucket(bucket: store.instance_variable_get(:@bucket))
68
+ nil
69
+ rescue StandardError => e
70
+ "the bucket cannot be reached: #{e.class.name}"
71
+ end
72
+
73
+ def self.check_durability(report, store)
74
+ adapter = ::Smith.config.persistence_adapter
75
+ return unless PROCESS_LOCAL.include?(store.class.name) && adapter && !process_local_state?(adapter)
76
+
77
+ report.add(
78
+ name: "artifacts.durability", status: :warn,
79
+ message: "Artifacts are kept in process memory while workflow state is persisted",
80
+ detail: "A run that waits, retries or resumes in another process loses its artifacts; configure :s3, " \
81
+ ":active_record or :file"
82
+ )
83
+ end
84
+
85
+ def self.process_local_state?(adapter)
86
+ adapter.to_s == "memory" || adapter.is_a?(::Smith::PersistenceAdapters::Memory)
87
+ end
88
+
89
+ def self.check_ownership(report, store)
90
+ return if store.respond_to?(:stored_by?)
91
+
92
+ report.add(
93
+ name: "artifacts.ownership", status: :warn,
94
+ message: "The artifact store cannot tell which run stored a ref (stored_by?)",
95
+ detail: "Runs read any ref under their prefix, and content parts cannot read documents from it"
96
+ )
97
+ end
98
+ private_class_method :store_name, :missing, :check_usable, :table_problem, :directory_problem, :bucket_problem,
99
+ :check_durability, :process_local_state?, :check_ownership
100
+ end
101
+ end
102
+ end
103
+ end
@@ -6,7 +6,6 @@ module Smith
6
6
  module Configuration
7
7
  def self.run(report)
8
8
  check_logger(report)
9
- check_artifact_store(report)
10
9
  check_trace_adapter(report)
11
10
  check_pricing(report)
12
11
  end
@@ -21,16 +20,6 @@ module Smith
21
20
  )
22
21
  end
23
22
 
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
23
  def self.check_trace_adapter(report)
35
24
  configured = !::Smith.config.trace_adapter.nil?
36
25
  report.add(
@@ -31,24 +31,34 @@ module Smith
31
31
  )
32
32
  return
33
33
  else
34
+ check_ttl_support(report, adapter)
34
35
  check_persist_and_restore(report, adapter)
35
36
  check_resume_after_restore(report, adapter)
36
37
  end
37
38
 
38
- def self.check_persist_and_restore(report, adapter)
39
- workflow_class = build_probe_class
40
- payload = JSON.generate(workflow_class.new.to_state)
39
+ # ActiveRecordStore refuses every write that carries a TTL, so a
40
+ # configured global TTL would fail the first persisted run.
41
+ def self.check_ttl_support(report, adapter)
42
+ ttl = ::Smith.config.persistence_ttl
43
+ return if ttl.nil? || !adapter.is_a?(::Smith::PersistenceAdapters::ActiveRecordStore)
41
44
 
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)
45
+ report.add(
46
+ name: "durability.ttl",
47
+ status: :fail,
48
+ message: "ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil",
49
+ detail: "Smith.config.persistence_ttl is #{ttl.inspect}, so every persisted write refuses it. " \
50
+ "Set it to nil, or use an adapter that honours TTL."
51
+ )
52
+ end
46
53
 
54
+ def self.check_persist_and_restore(report, adapter)
55
+ restored = probe_round_trip(adapter)
47
56
  valid = restored.state == :idle
48
57
  report.add(
49
58
  name: "durability.persist_restore",
50
59
  status: valid ? :pass : :fail,
51
- message: valid ? "Host persistence round-trip works" : "Host persistence round-trip failed"
60
+ message: valid ? "Host persistence round-trip works" : "Host persistence round-trip failed",
61
+ detail: "Written as runs write, with #{probe_write(adapter)}"
52
62
  )
53
63
  rescue StandardError => e
54
64
  report.add(
@@ -58,15 +68,7 @@ module Smith
58
68
  end
59
69
 
60
70
  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!
71
+ result = probe_round_trip(adapter).run!
70
72
  valid = result.state == :done
71
73
 
72
74
  report.add(
@@ -79,6 +81,22 @@ module Smith
79
81
  message: "Resume after restore failed", detail: e.message)
80
82
  end
81
83
 
84
+ # A probe run stored as runs are (Workflow#persist!: store_versioned
85
+ # where the adapter has it, and persistence_max_bytes) and restored.
86
+ def self.probe_round_trip(adapter)
87
+ workflow_class = build_probe_class
88
+ adapter.delete(PROBE_KEY)
89
+ workflow_class.new.persist!(PROBE_KEY, adapter:)
90
+ workflow_class.restore(PROBE_KEY, adapter:)
91
+ ensure
92
+ adapter.delete(PROBE_KEY)
93
+ end
94
+
95
+ def self.probe_write(adapter)
96
+ versioned = ::Smith::PersistenceAdapters.supports?(adapter, :store_versioned)
97
+ versioned ? "store_versioned" : "plain store (the adapter has no store_versioned)"
98
+ end
99
+
82
100
  def self.build_probe_class
83
101
  Class.new(::Smith::Workflow) do
84
102
  initial_state :idle
@@ -17,7 +17,10 @@ module Smith
17
17
  OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
18
18
 
19
19
  def run(report)
20
- adapter = resolve_adapter
20
+ adapter = Smith.persistence_adapter
21
+ rescue StandardError => e
22
+ report_invalid_adapter(report, e)
23
+ else
21
24
  return report_missing_adapter(report) if adapter.nil?
22
25
 
23
26
  missing = OPTIONAL_CAPABILITIES.reject { |cap| capability_available?(adapter, cap) }
@@ -26,12 +29,6 @@ module Smith
26
29
  report_missing_capabilities(report, adapter, missing)
27
30
  end
28
31
 
29
- def resolve_adapter
30
- Smith.persistence_adapter
31
- rescue StandardError
32
- nil
33
- end
34
-
35
32
  def capability_available?(adapter, capability)
36
33
  return false unless Smith::PersistenceAdapters.supports?(adapter, capability)
37
34
  return true unless capability == :persistence_identity
@@ -42,6 +39,15 @@ module Smith
42
39
  false
43
40
  end
44
41
 
42
+ def report_invalid_adapter(report, error)
43
+ report.add(
44
+ name: "persistence.capabilities",
45
+ status: :fail,
46
+ message: "Persistence adapter configuration is invalid",
47
+ detail: error.message
48
+ )
49
+ end
50
+
45
51
  def report_missing_adapter(report)
46
52
  report.add(
47
53
  name: "persistence.capabilities",
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Doctor
5
+ module Checks
6
+ # Whether an ActiveRecord persistence adapter's model can hold runs:
7
+ # its locking column, a text payload, and a string key the database
8
+ # keeps unique. It reads schema metadata only, and skips when the
9
+ # database cannot be reached (the default doctor run is offline).
10
+ # Other adapters, and an adapter configuration that does not resolve
11
+ # (Durability reports it), are not checked.
12
+ module StateModel
13
+ NAME = "persistence.state_model"
14
+
15
+ module_function
16
+
17
+ def run(report)
18
+ adapter = active_record_adapter
19
+ return unless adapter
20
+
21
+ adapter.check_schema!
22
+ report.add(name: NAME, status: :pass,
23
+ message: "The workflow state model has a lock_version, a text payload and a unique key")
24
+ report_encryption(report) if adapter.encrypted_payload?
25
+ rescue StandardError => e
26
+ report_failure(report, e)
27
+ end
28
+
29
+ def report_failure(report, error)
30
+ if unreachable?(error)
31
+ report.add(name: NAME, status: :skip, detail: error.class.name,
32
+ message: "The workflow state model was not checked: the database could not be reached")
33
+ else
34
+ report.add(name: NAME, status: :fail, message: "The workflow state model cannot hold runs",
35
+ detail: error.message)
36
+ end
37
+ end
38
+
39
+ def report_encryption(report)
40
+ report.add(name: "#{NAME}.encryption", status: :warn,
41
+ message: "The workflow state payload is encrypted",
42
+ detail: "Runs store and read it, but split-step exact writes (replace_exact) compare " \
43
+ "payloads in the database and cannot use an encrypted one")
44
+ end
45
+
46
+ def unreachable?(error)
47
+ classes = PersistenceAdapters::ActiveRecordConnectionErrors.classes
48
+ classes << ::ActiveRecord::NoDatabaseError if defined?(::ActiveRecord::NoDatabaseError)
49
+ classes.any? { error.is_a?(_1) }
50
+ end
51
+
52
+ def active_record_adapter
53
+ return unless ::Smith.config.persistence_adapter
54
+
55
+ adapter = ::Smith.persistence_adapter
56
+ adapter if adapter.is_a?(::Smith::PersistenceAdapters::ActiveRecordStore)
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
3
4
  require "fileutils"
4
5
 
5
6
  module Smith
@@ -41,8 +42,9 @@ module Smith
41
42
  end
42
43
 
43
44
  def rails_template
45
+ # The generator's initializer, as `rails g smith:install --skip-state-model` writes it.
44
46
  template_path = File.expand_path("../../generators/smith/install/templates/smith.rb.tt", __dir__)
45
- File.read(template_path)
47
+ ERB.new(File.read(template_path), trim_mode: "-").result_with_hash(options: { skip_state_model: true })
46
48
  end
47
49
 
48
50
  def plain_template
@@ -78,7 +80,12 @@ module Smith
78
80
  # store(key, payload)
79
81
  # fetch(key)
80
82
  # delete(key)
81
- config.artifact_store = Smith::Artifacts::Memory.new
83
+ #
84
+ # Artifacts (large outputs, attached documents) need a store that
85
+ # outlives the process: :s3 (S3, R2) or :active_record, with
86
+ # config.artifact_options; :memory keeps them in this process.
87
+ # config.artifact_store = :s3
88
+ # config.artifact_options = { bucket: "smith-artifacts", region: "us-east-1" }
82
89
  config.trace_adapter = Smith::Trace::Memory.new
83
90
  end
84
91
  RUBY
data/lib/smith/doctor.rb CHANGED
@@ -15,6 +15,8 @@ require_relative "doctor/checks/live"
15
15
  require_relative "doctor/checks/models_registry"
16
16
  require_relative "doctor/checks/openai_api_mode"
17
17
  require_relative "doctor/checks/persistence_capabilities"
18
+ require_relative "doctor/checks/artifacts"
19
+ require_relative "doctor/checks/state_model"
18
20
 
19
21
  module Smith
20
22
  module Doctor
@@ -23,6 +25,8 @@ module Smith
23
25
 
24
26
  Checks::Baseline.run(report)
25
27
  Checks::Configuration.run(report)
28
+ Checks::Artifacts.run(report)
29
+ Checks::StateModel.run(report)
26
30
  Checks::ModelsRegistry.run(report)
27
31
  Checks::OpenaiApiMode.run(report)
28
32
  Checks::Rails.run(report) if detect_rails?