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,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require "set"
7
+ require "time"
8
+
9
+ module Smith
10
+ class Workflow
11
+ include DSL
12
+ include Persistence
13
+ include Durability
14
+ include GuardrailIntegration
15
+ include BudgetIntegration
16
+ include EventIntegration
17
+ include ArtifactIntegration
18
+ include DataVolumePolicy
19
+ include DeadlineEnforcement
20
+ include Execution
21
+
22
+ DEFAULT_MAX_TRANSITIONS = 100
23
+
24
+ # `keyword_init: true` is mandatory: `build_run_result` constructs
25
+ # the result via keyword arguments. Plain Ruby Structs treat the
26
+ # kwargs hash as the first positional field, silently leaving the
27
+ # remaining fields nil — verified empirically. The `keyword_init`
28
+ # flag routes kwargs to the right fields. `usage_entries` is the
29
+ # 10th field, added in this slice for hadithi billing.
30
+ RunResult = Struct.new(:state, :output, :steps, :total_cost, :total_tokens, :context, :session_messages,
31
+ :tool_results, :outcome, :usage_entries, keyword_init: true) do
32
+ def done?
33
+ state == :done
34
+ end
35
+
36
+ def failed?
37
+ state == :failed
38
+ end
39
+
40
+ def terminal_output
41
+ output
42
+ end
43
+
44
+ def outcome_kind
45
+ outcome&.dig(:kind)
46
+ end
47
+
48
+ def outcome_payload
49
+ outcome&.dig(:payload)
50
+ end
51
+
52
+ def last_error
53
+ steps.reverse.map { |step| step[:error] }.compact.first
54
+ end
55
+
56
+ def failed_transition
57
+ failure_detail&.fetch(:transition)
58
+ end
59
+
60
+ def failure_detail
61
+ failed_step = steps.reverse.find { |step| step[:error] }
62
+ return nil unless failed_step
63
+
64
+ {
65
+ transition: failed_step[:transition],
66
+ from: failed_step[:from],
67
+ to: failed_step[:to],
68
+ error: failed_step[:error]
69
+ }
70
+ end
71
+ end
72
+
73
+ # keyword_init: true so adding new fields in future schema versions
74
+ # remains backward-compatible: `from_response` and `from_h` can fill
75
+ # extra fields without breaking existing call sites that pass the
76
+ # historical positional args. Reading old persisted records into a
77
+ # newer Struct shape: from_h slices to current members (unknown keys
78
+ # silently dropped; missing keys default to nil).
79
+ AgentResult = Struct.new(
80
+ :content, :input_tokens, :output_tokens, :cost, :model_used,
81
+ keyword_init: true
82
+ ) do
83
+ def self.from_response(response, content, model_used: nil)
84
+ new(
85
+ content: content,
86
+ input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
87
+ output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
88
+ cost: nil,
89
+ model_used: model_used
90
+ )
91
+ end
92
+
93
+ def usage_known?
94
+ !input_tokens.nil? && !output_tokens.nil?
95
+ end
96
+ end
97
+
98
+ # One row per agent provider call. `usage_id` is a UUID generated
99
+ # at recording time and stable across persist/restore — hadithi
100
+ # uses it as the idempotency anchor on `usage_events.smith_usage_id`.
101
+ # Includes `to_h`/`from_h` for JSON serialization (plain Struct
102
+ # JSON-encodes to `"#<struct ...>"` — useless).
103
+ #
104
+ # keyword_init: true gives forward/backward compatibility:
105
+ # - Adding a new field: old persisted hashes restore cleanly (new
106
+ # field defaults to nil).
107
+ # - Reading new persisted hashes with an older Smith version: from_h
108
+ # slices to known members (unknown keys silently dropped).
109
+ UsageEntry = Struct.new(
110
+ :usage_id,
111
+ :agent_name,
112
+ :model,
113
+ :input_tokens,
114
+ :output_tokens,
115
+ :cost,
116
+ :attempt_kind,
117
+ :recorded_at,
118
+ keyword_init: true
119
+ ) do
120
+ def self.from_h(hash)
121
+ sym = hash.transform_keys(&:to_sym)
122
+ filtered = sym.slice(*members)
123
+ # Symbolize :agent_name and :attempt_kind for backward compat
124
+ # with callers that consume the field as Symbol.
125
+ filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
126
+ filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
127
+ new(**filtered)
128
+ end
129
+ end
130
+
131
+ # Reconstruct Smith error classes from `@last_failed_step` snapshots.
132
+ # Order matters: more-specific subclasses first, so a real DSF doesn't
133
+ # get caught by the WorkflowError handler. Each lambda preserves the
134
+ # billing-critical attributes (`retryable`, `kind`, `details`) by
135
+ # routing through the original constructor — Smith's retryable errors
136
+ # expose `attr_reader :retryable` only, with no setter, so kwargs
137
+ # MUST flow through `initialize`.
138
+ KNOWN_RECONSTRUCTORS = {
139
+ "Smith::ToolGuardrailFailed" => ->(s) {
140
+ Smith::ToolGuardrailFailed.new(s[:error_message], retryable: s[:error_retryable])
141
+ },
142
+ "Smith::DeterministicStepFailure" => ->(s) {
143
+ Smith::DeterministicStepFailure.new(
144
+ s[:error_message],
145
+ retryable: s[:error_retryable],
146
+ kind: s[:error_kind],
147
+ details: s[:error_details]
148
+ )
149
+ },
150
+ "Smith::AgentError" => ->(s) { Smith::AgentError.new(s[:error_message]) },
151
+ "Smith::DeadlineExceeded" => ->(s) { Smith::DeadlineExceeded.new(s[:error_message]) },
152
+ "Smith::WorkflowError" => ->(s) { Smith::WorkflowError.new(s[:error_message]) },
153
+ # Smith errors with non-message constructors map to compatible
154
+ # superclass — message preserved, original metadata (agent_name,
155
+ # model_used, requested_name, workflow_class, origin_state) lossy
156
+ # but `is_a?` classification round-trips via the superclass.
157
+ "Smith::BlankAgentOutputError" => ->(s) { Smith::AgentError.new(s[:error_message]) },
158
+ "Smith::UnresolvedTransitionError" => ->(s) { Smith::WorkflowError.new(s[:error_message]) }
159
+ }.freeze
160
+ private_constant :KNOWN_RECONSTRUCTORS
161
+
162
+ # Families whose retryable/kind/details attributes are billing-critical.
163
+ # For these, the reconstruction path bypasses `const_get(...).new(message)`
164
+ # (which would succeed for unknown subclasses with message-only
165
+ # constructors but discard the kwargs) and uses the family fallback
166
+ # directly so the parent-class constructor preserves the attrs.
167
+ RETRYABLE_BEARING_FAMILIES = %w[deterministic_step_failure tool_guardrail_failed].freeze
168
+ private_constant :RETRYABLE_BEARING_FAMILIES
169
+
170
+ # keyword_init: true for forward/backward compat (see UsageEntry).
171
+ BranchEnv = Struct.new(
172
+ :prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
173
+ keyword_init: true
174
+ ) do
175
+ def setup_thread
176
+ Smith::Tool.current_guardrails = guardrail_sources
177
+ Smith::Tool.current_deadline = deadline
178
+ Smith.scoped_artifacts = scoped_store
179
+ end
180
+
181
+ def teardown_thread
182
+ Smith::Tool.current_guardrails = nil
183
+ Smith::Tool.current_deadline = nil
184
+ Smith.scoped_artifacts = nil
185
+ end
186
+ end
187
+
188
+ attr_reader :state, :last_prepared_input, :session_messages, :ledger
189
+
190
+ def initialize(context: {}, ledger: nil, created_at: nil)
191
+ @state = self.class.initial_state
192
+ @context = context
193
+ @step_count = 0
194
+ @next_transition_name = nil
195
+ @ledger = ledger || build_ledger
196
+ @created_at = created_at || Time.now.utc.iso8601
197
+ @updated_at = @created_at
198
+ @total_cost = 0.0
199
+ @total_tokens = 0
200
+ @outcome = nil
201
+ # Eager init for usage tracking. Both `@usage_mutex` (lazy
202
+ # init at the call site would race across parallel fan-out
203
+ # branches) and the durable per-call/output/failure fields
204
+ # must be present before any agent recording fires.
205
+ # `restore_state` mirrors these inits because `from_state` uses
206
+ # `allocate` and bypasses `initialize` — see persistence.rb.
207
+ @usage_entries = []
208
+ @usage_mutex = Mutex.new
209
+ @last_output = nil
210
+ @last_failed_step = nil
211
+ # Optimistic-locking version. Incremented on each persist!; restored
212
+ # from the persisted payload. Adapters that support store_versioned
213
+ # raise Smith::PersistenceVersionConflict when expected_version
214
+ # doesn't match the stored payload's version (i.e., a concurrent
215
+ # write occurred between this process's restore and persist).
216
+ @persistence_version = 0
217
+ # Digest of the seed_messages produced at construction time.
218
+ # Compared on restore against the live builder's output when
219
+ # seed_validation is :warn or :strict; nil when no seed builder
220
+ # ran or its output was empty.
221
+ @seed_digest = nil
222
+ # Idempotency marker stamped between persist-before-advance and
223
+ # persist-after-advance under idempotency_mode :strict; restored
224
+ # workflows with the marker set raise
225
+ # Smith::StepInProgressOnRestore. Lax mode leaves it false.
226
+ @step_in_progress = false
227
+ # Set of context keys recorded via deterministic step write_context
228
+ # writes. Used by persist :auto Context mode to compute the
229
+ # persisted-context slice. Seeded from the Context class's
230
+ # also: declaration so explicit input keys round-trip.
231
+ @persisted_keys = ::Set.new(initial_persist_auto_seed)
232
+ @persisted_keys_mutex = Mutex.new
233
+ initialize_tool_result_state
234
+ seed_initial_session_messages
235
+ end
236
+
237
+ def persisted_keys
238
+ @persisted_keys.dup.freeze
239
+ end
240
+
241
+ def advance!
242
+ max = self.class.max_transitions || DEFAULT_MAX_TRANSITIONS
243
+ raise MaxTransitionsExceeded if @step_count >= max
244
+
245
+ transition = resolve_transition
246
+ return if transition.nil?
247
+
248
+ step_result = execute_step(transition)
249
+ @step_count += 1
250
+ @updated_at = Time.now.utc.iso8601
251
+ record_step_snapshot(step_result)
252
+ step_result
253
+ rescue UnresolvedTransitionError => e
254
+ origin_state = @state
255
+ @outcome = nil
256
+ raise unless route_to_fail_state!
257
+
258
+ step_result = { transition: e.requested_name, from: origin_state, to: @state, error: e }
259
+ record_step_snapshot(step_result)
260
+ step_result
261
+ end
262
+
263
+ def run!
264
+ steps = []
265
+ until terminal?
266
+ step = advance!
267
+ steps << step if step
268
+ end
269
+ build_run_result(steps)
270
+ end
271
+
272
+ def terminal?
273
+ self.class.transitions_from(@state).empty? && @next_transition_name.nil?
274
+ end
275
+
276
+ def done?
277
+ @state == :done
278
+ end
279
+
280
+ def failed?
281
+ @state == :failed
282
+ end
283
+
284
+ def record_persisted_key!(key)
285
+ @persisted_keys_mutex.synchronize do
286
+ @persisted_keys << key.to_sym
287
+ end
288
+ end
289
+
290
+ private
291
+
292
+ def initial_persist_auto_seed
293
+ manager = self.class.context_manager
294
+ return [] unless manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
295
+
296
+ manager.persist_auto_seed.map(&:to_sym)
297
+ end
298
+
299
+ # Centralized capture for both `advance!` paths — the normal
300
+ # `execute_step` return AND the `UnresolvedTransitionError` rescue
301
+ # path. Without centralization, the rescue path's step would never
302
+ # populate `@last_failed_step`, and an unresolved-transition
303
+ # terminal failure restored after persist would have nil last_error.
304
+ #
305
+ # On a successful step (no :error key, possibly with :output): clear
306
+ # `@last_failed_step` (a workflow that handled a failure and reached
307
+ # :done shouldn't synthesize a stale error on terminal restore) and
308
+ # capture the latest non-nil `:output` into `@last_output` (last
309
+ # non-nil wins; matches `RunResult#output`'s `compact.first` shape).
310
+ def record_step_snapshot(step_result)
311
+ return unless step_result
312
+
313
+ if step_result[:error]
314
+ err = step_result[:error]
315
+ # error_family preserves classification across reconstruction
316
+ # even when the exact class can't be rebuilt. Order matters:
317
+ # specific subclasses first (DSF before WorkflowError, etc.)
318
+ # so a real DSF doesn't get classified as workflow_error.
319
+ error_family = case err
320
+ when Smith::DeterministicStepFailure then "deterministic_step_failure"
321
+ when Smith::ToolGuardrailFailed then "tool_guardrail_failed"
322
+ when Smith::DeadlineExceeded then "deadline_exceeded"
323
+ when Smith::AgentError then "agent_error"
324
+ when Smith::WorkflowError then "workflow_error"
325
+ else "other"
326
+ end
327
+ @last_failed_step = {
328
+ transition: step_result[:transition],
329
+ from: step_result[:from],
330
+ to: step_result[:to],
331
+ error_class: err.class.name,
332
+ error_family: error_family,
333
+ error_message: err.message,
334
+ error_retryable: err.respond_to?(:retryable) ? err.retryable : nil,
335
+ error_kind: err.respond_to?(:kind) ? err.kind : nil,
336
+ error_details: err.respond_to?(:details) ? err.details : nil
337
+ }
338
+ else
339
+ # Successful step: clear any prior failed-step snapshot
340
+ # (workflow handled the failure and continued) and capture
341
+ # the output if non-nil (preserves `false` as a valid output;
342
+ # matches `RunResult#output`'s `.compact.first` semantics).
343
+ @last_failed_step = nil
344
+ @last_output = step_result[:output] if step_result.key?(:output) && !step_result[:output].nil?
345
+ end
346
+ end
347
+
348
+ def build_ledger
349
+ config = self.class.budget
350
+ return nil unless config
351
+
352
+ Budget::Ledger.new(limits: config)
353
+ end
354
+
355
+ def route_to_fail_state!
356
+ fail_transition = self.class.find_transition(:fail)
357
+ return false unless fail_transition
358
+
359
+ @state = fail_transition.to
360
+ true
361
+ end
362
+
363
+ def resolve_transition
364
+ if @next_transition_name
365
+ name = @next_transition_name
366
+ @next_transition_name = nil
367
+ self.class.find_transition(name) ||
368
+ raise(UnresolvedTransitionError.new(name, self.class, @state))
369
+ else
370
+ self.class.transitions_from(@state).first
371
+ end
372
+ end
373
+
374
+ def build_run_result(steps)
375
+ # `output` derivation matches existing semantics on fresh runs
376
+ # (last non-nil step output via `compact.first`). Terminal-restore
377
+ # path (steps.empty?) falls back to `@last_output` so the durable
378
+ # output survives persist/restore. Gate on `steps.empty?` to avoid
379
+ # leaking a stale `@last_output` into a fresh run that produced
380
+ # nil output.
381
+ output = steps.reverse.map { |step| step[:output] }.compact.first
382
+ output = @last_output if output.nil? && steps.empty?
383
+
384
+ # On terminal-restore of a failed workflow with empty steps,
385
+ # synthesize a single-step array from `@last_failed_step` so
386
+ # `RunResult#last_error` and `#failure_detail` work exactly as
387
+ # they do on fresh-run failures. Gate on `failed?` so a `:done`
388
+ # terminal state never produces a synthetic error even if the
389
+ # snapshot wasn't cleared.
390
+ effective_steps = if steps.empty? && failed? && @last_failed_step
391
+ [reconstruct_failed_step]
392
+ else
393
+ steps
394
+ end
395
+
396
+ RunResult.new(
397
+ state: @state,
398
+ output: output,
399
+ steps: effective_steps,
400
+ total_cost: @total_cost,
401
+ total_tokens: @total_tokens,
402
+ context: snapshot_context,
403
+ session_messages: snapshot_session_messages,
404
+ tool_results: snapshot_tool_results,
405
+ outcome: snapshot_outcome,
406
+ usage_entries: snapshot_usage_entries
407
+ )
408
+ end
409
+
410
+ def reconstruct_failed_step
411
+ snap = @last_failed_step
412
+ builder = KNOWN_RECONSTRUCTORS[snap[:error_class]]
413
+ error = if builder
414
+ builder.call(snap)
415
+ elsif RETRYABLE_BEARING_FAMILIES.include?(snap[:error_family])
416
+ # Skip const_get for retryable-bearing families. An unknown
417
+ # subclass with a message-only constructor would const_get
418
+ # successfully but discard the snapshot's `retryable`/`kind`/
419
+ # `details` (defaults to nil), and hadithi's `retryable?`
420
+ # check would misclassify a retryable failure as terminal.
421
+ # Family fallback rebuilds the parent class with kwargs intact.
422
+ family_fallback(snap)
423
+ else
424
+ # Unknown subclass without retryable-bearing semantics. Try
425
+ # the exact class for shape preservation; fall back via family
426
+ # if the constructor doesn't accept message-only args (or the
427
+ # class doesn't exist).
428
+ begin
429
+ Kernel.const_get(snap[:error_class]).new(snap[:error_message])
430
+ rescue NameError, ArgumentError
431
+ family_fallback(snap)
432
+ end
433
+ end
434
+
435
+ # Symbol coercion on the way out: live steps carry these as
436
+ # symbols; JSON round-trip stringifies them; coerce back to
437
+ # match fresh-run shape exactly.
438
+ {
439
+ transition: snap[:transition]&.to_sym,
440
+ from: snap[:from]&.to_sym,
441
+ to: snap[:to]&.to_sym,
442
+ error: error
443
+ }
444
+ end
445
+
446
+ def family_fallback(snap)
447
+ case snap[:error_family]
448
+ when "deterministic_step_failure"
449
+ Smith::DeterministicStepFailure.new(
450
+ snap[:error_message],
451
+ retryable: snap[:error_retryable],
452
+ kind: snap[:error_kind],
453
+ details: snap[:error_details]
454
+ )
455
+ when "tool_guardrail_failed"
456
+ Smith::ToolGuardrailFailed.new(snap[:error_message], retryable: snap[:error_retryable])
457
+ when "deadline_exceeded" then Smith::DeadlineExceeded.new(snap[:error_message])
458
+ when "agent_error" then Smith::AgentError.new(snap[:error_message])
459
+ when "workflow_error" then Smith::WorkflowError.new(snap[:error_message])
460
+ else RuntimeError.new(snap[:error_message])
461
+ end
462
+ end
463
+
464
+ def seed_initial_session_messages
465
+ messages = compute_seed_messages
466
+ return if messages.nil?
467
+
468
+ @session_messages = messages
469
+ @seed_digest = compute_seed_digest(messages)
470
+ end
471
+
472
+ # Fresh evaluation of the seed builder against the workflow's
473
+ # current @context. Returns nil when no builder is defined so
474
+ # callers (initialize, validate_seed_digest!) can distinguish
475
+ # "no builder" from "builder returned empty".
476
+ def compute_seed_messages
477
+ builder = self.class.seed_messages
478
+ return nil unless builder
479
+
480
+ seeded = builder.arity == 1 ? builder.call(@context) : builder.call
481
+ normalize_seed_messages(seeded)
482
+ end
483
+
484
+ def compute_seed_digest(messages)
485
+ return nil if messages.nil? || messages.empty?
486
+
487
+ Digest::SHA256.hexdigest(JSON.generate(messages))
488
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
489
+ raise WorkflowError,
490
+ "seed_messages content must be valid UTF-8 (Smith hashes via JSON.generate " \
491
+ "for drift detection): #{e.message}"
492
+ end
493
+
494
+ def normalize_seed_messages(seeded)
495
+ return [] if seeded.nil?
496
+ return [seeded] if seeded.is_a?(Hash)
497
+ return seeded.to_a if seeded.respond_to?(:to_a)
498
+
499
+ raise WorkflowError, "seed_messages must return a message Hash or an Array of message Hashes"
500
+ end
501
+
502
+ def snapshot_context
503
+ snapshot_value(@context)
504
+ end
505
+
506
+ def snapshot_session_messages
507
+ snapshot_value(@session_messages || [])
508
+ end
509
+
510
+ def snapshot_tool_results
511
+ snapshot_value(@tool_results || [])
512
+ end
513
+
514
+ def snapshot_outcome
515
+ snapshot_value(@outcome)
516
+ end
517
+
518
+ # Defensive deep copy via `from_h(snapshot_value(to_h))` round-trip.
519
+ # `Struct#dup` is shallow — it shares mutable string fields between
520
+ # the original and the duplicate. Smith's existing snapshot helpers
521
+ # (`snapshot_context`, etc.) also use this round-trip pattern; the
522
+ # billing-facing RunResult must not alias mutable workflow state.
523
+ # Same rule applies to nested-workflow rollup (see
524
+ # `nested_execution.rb`).
525
+ def snapshot_usage_entries
526
+ @usage_entries.map { |entry| Workflow::UsageEntry.from_h(snapshot_value(entry.to_h)) }
527
+ end
528
+
529
+ def tool_result_collector
530
+ ->(entry) { @tool_results_mutex.synchronize { @tool_results << entry } }
531
+ end
532
+
533
+ def initialize_tool_result_state
534
+ @tool_results = []
535
+ @tool_results_mutex = Mutex.new
536
+ end
537
+
538
+ def snapshot_value(value)
539
+ case value
540
+ when Hash
541
+ value.each_with_object({}) do |(key, nested), copy|
542
+ copy[snapshot_value(key)] = snapshot_value(nested)
543
+ end
544
+ when Array
545
+ value.map { |nested| snapshot_value(nested) }
546
+ when String
547
+ value.dup
548
+ else
549
+ value.dup
550
+ end
551
+ rescue TypeError
552
+ value
553
+ end
554
+ end
555
+ end