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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module OrchestratorWorker
9
+ OrchestrationState = Struct.new(
10
+ :config, :prepared_input, :orchestrator_class, :worker_class, :worker_results
11
+ ) do
12
+ def initialize(config, prepared_input)
13
+ super(config, prepared_input, nil, nil, nil)
14
+ end
15
+ end
16
+
17
+ WorkerExecution = Struct.new(:execution_id, :task, :output) do
18
+ def self.run(worker_class, task, schema, budget_runner)
19
+ new(SecureRandom.uuid, task, budget_runner.call(worker_class, task, schema))
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def dispatch_step(transition, prepared_input: nil)
26
+ if transition.parallel? then execute_parallel_step(transition, prepared_input: prepared_input)
27
+ elsif transition.nested? then execute_nested_workflow(transition)
28
+ elsif transition.optimized? then execute_optimization_step(transition, prepared_input: prepared_input)
29
+ elsif transition.orchestrated? then execute_orchestration_step(transition, prepared_input: prepared_input)
30
+ elsif transition.deterministic? then execute_deterministic_step(transition)
31
+ else execute_serial_step(transition, prepared_input: prepared_input)
32
+ end
33
+ end
34
+
35
+ def execute_orchestration_step(transition, prepared_input: nil)
36
+ state = OrchestrationState.new(transition.orchestrator_config, prepared_input)
37
+ state.orchestrator_class = Agent::Registry.fetch!(
38
+ state.config[:orchestrator],
39
+ workflow_class: self.class,
40
+ transition_name: transition.name,
41
+ role: :orchestrator
42
+ )
43
+ state.worker_class = Agent::Registry.fetch!(
44
+ state.config[:worker],
45
+ workflow_class: self.class,
46
+ transition_name: transition.name,
47
+ role: :worker
48
+ )
49
+ run_orchestration_loop(state)
50
+ end
51
+
52
+ def run_orchestration_loop(state)
53
+ state.config[:max_delegation_rounds].times do |round|
54
+ result = run_orchestration_round(state, round)
55
+ return result if result
56
+ end
57
+
58
+ raise WorkflowError,
59
+ "orchestration exhausted #{state.config[:max_delegation_rounds]} rounds without final output"
60
+ end
61
+
62
+ def run_orchestration_round(state, round)
63
+ decision = call_orchestrator(state, round)
64
+ validate_orchestrator_decision!(decision)
65
+
66
+ return validated_final(decision[:final], state.config) if decision.key?(:final)
67
+ raise WorkflowError, "orchestrator stopped: #{decision[:stop]}" if decision.key?(:stop)
68
+
69
+ validate_tasks!(decision[:tasks], state.config)
70
+ state.worker_results = execute_workers(state, decision[:tasks])
71
+ nil
72
+ end
73
+
74
+ def call_orchestrator(state, round)
75
+ input = prepare_orchestrator_input(state.prepared_input, round, state.worker_results)
76
+ invoke_agent_with_budget(state.orchestrator_class, input)
77
+ end
78
+
79
+ def execute_workers(state, tasks)
80
+ runner = method(:run_worker_with_schema)
81
+ tasks.map do |task|
82
+ validate_task!(task, state.config[:task_schema])
83
+ execution = WorkerExecution.run(state.worker_class, task, state.config[:worker_output_schema], runner)
84
+ validate_worker_output!(execution.output, state.config[:worker_output_schema])
85
+ { execution_id: execution.execution_id, task: execution.task, output: execution.output }
86
+ end
87
+ end
88
+
89
+ def run_worker_with_schema(worker_class, task, worker_output_schema)
90
+ input = [{ role: :user, content: task.to_json }]
91
+ original_schema = worker_class.output_schema
92
+ worker_class.output_schema(worker_output_schema)
93
+ invoke_agent_with_budget(worker_class, input)
94
+ ensure
95
+ worker_class.output_schema(original_schema)
96
+ end
97
+
98
+ def prepare_orchestrator_input(prepared_input, round, worker_results)
99
+ return prepared_input if round.zero?
100
+
101
+ (prepared_input&.dup || []).push(
102
+ {
103
+ role: :user,
104
+ content: "[smith:orchestration-round] #{round + 1}\n[smith:worker-results]\n#{worker_results.to_json}"
105
+ }
106
+ )
107
+ end
108
+
109
+ def validate_orchestrator_decision!(output)
110
+ raise WorkflowError, "orchestrator output must be a Hash" unless output.is_a?(Hash)
111
+ return if %i[tasks final stop].one? { |k| output.key?(k) }
112
+
113
+ raise WorkflowError, "orchestrator must emit exactly one of :tasks, :final, or :stop"
114
+ end
115
+
116
+ def validate_tasks!(tasks, config)
117
+ raise WorkflowError, "orchestrator :tasks must be an Array" unless tasks.is_a?(Array)
118
+ return unless tasks.length > config[:max_workers]
119
+
120
+ raise WorkflowError, "orchestrator tasks (#{tasks.length}) exceeds max_workers (#{config[:max_workers]})"
121
+ end
122
+
123
+ def validate_task!(task, schema)
124
+ check_schema_keys!(task, schema, "worker task")
125
+ end
126
+
127
+ def validate_worker_output!(output, schema)
128
+ check_schema_keys!(output, schema, "worker output")
129
+ end
130
+
131
+ def validated_final(final, config)
132
+ check_schema_keys!(final, config[:final_output_schema], "final output")
133
+ final
134
+ end
135
+
136
+ def check_schema_keys!(data, schema, label)
137
+ raise WorkflowError, "#{label} must be a Hash" unless data.is_a?(Hash)
138
+ return unless schema.respond_to?(:required_keys)
139
+
140
+ missing = schema.required_keys.reject { |k| data.key?(k) }
141
+ raise WorkflowError, "#{label} missing required keys: #{missing.join(", ")}" unless missing.empty?
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Parallel
8
+ CancellationSignal = Struct.new(:cancelled, :mutex) do
9
+ def initialize
10
+ super(false, Mutex.new)
11
+ end
12
+
13
+ def cancel!
14
+ mutex.synchronize { self.cancelled = true }
15
+ end
16
+
17
+ def cancelled?
18
+ mutex.synchronize { cancelled }
19
+ end
20
+ end
21
+
22
+ def self.resolve_branch_count(transition, context)
23
+ count = transition.agent_opts[:count]
24
+ count.respond_to?(:call) ? count.call(context) : (count || 1)
25
+ end
26
+
27
+ def self.execute(branches:)
28
+ signal = CancellationSignal.new
29
+
30
+ futures = branches.map do |branch|
31
+ Concurrent::Promises.future(branch, signal) do |b, s|
32
+ b.call(s)
33
+ rescue StandardError
34
+ s.cancel!
35
+ raise
36
+ end
37
+ end
38
+
39
+ fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
40
+
41
+ unless fulfilled
42
+ error = reasons.compact.first
43
+ raise error
44
+ end
45
+
46
+ values
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module ParallelExecution
6
+ private
7
+
8
+ def execute_parallel_step(transition, prepared_input: nil)
9
+ count = Parallel.resolve_branch_count(transition, @context)
10
+ agent_class = resolve_agent_class(transition)
11
+ estimates = compute_branch_estimates(@ledger, branch_count: count, agent_budget: agent_class&.budget)
12
+ env = BranchEnv.new(
13
+ prepared_input: prepared_input,
14
+ guardrail_sources: Tool.current_guardrails,
15
+ scoped_store: propagate_scoped_artifacts,
16
+ branch_estimates: estimates,
17
+ deadline: wall_clock_deadline
18
+ )
19
+ ledger = @ledger
20
+ branches = Array.new(count) do |i|
21
+ proc { |signal| run_branch(transition, i, env, ledger, signal) }
22
+ end
23
+ Parallel.execute(branches: branches)
24
+ end
25
+
26
+ def run_branch(transition, index, env, ledger, signal)
27
+ setup_branch_context(env, ledger)
28
+ with_agent_context(resolve_agent_class(transition)) do
29
+ branch_ledger = effective_call_ledger
30
+ reserved = reserve_branch_call(branch_ledger, env, ledger)
31
+ begin
32
+ result = guarded_branch_call(transition, env, signal)
33
+ finalize_branch(transition, index, result, branch_ledger, reserved).tap { reserved = nil }
34
+ ensure
35
+ settle_budget_on_failure(branch_ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
36
+ end
37
+ end
38
+ ensure
39
+ teardown_branch_context(env)
40
+ end
41
+
42
+ def reserve_branch_call(branch_ledger, env, workflow_ledger)
43
+ return reserve_branch_budget(branch_ledger, branch_estimates: env.branch_estimates) if workflow_ledger
44
+
45
+ reserve_serial_budget(branch_ledger) if branch_ledger
46
+ end
47
+
48
+ def setup_branch_context(env, ledger)
49
+ env.setup_thread
50
+ Tool.current_ledger = ledger
51
+ Tool.current_tool_result_collector = tool_result_collector
52
+ Thread.current[:smith_last_agent_result] = nil
53
+ end
54
+
55
+ def teardown_branch_context(env)
56
+ Thread.current[:smith_last_agent_result] = nil
57
+ Tool.current_ledger = nil
58
+ Tool.current_tool_result_collector = nil
59
+ env.teardown_thread
60
+ end
61
+
62
+ def guarded_branch_call(transition, env, signal)
63
+ check_cancellation!(signal)
64
+ check_deadline!
65
+ result = execute_transition_body(transition, prepared_input: env.prepared_input)
66
+ check_cancellation!(signal)
67
+ result
68
+ end
69
+
70
+ def check_cancellation!(signal)
71
+ raise Smith::WorkflowError, "cancelled" if signal.cancelled?
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module Persistence
6
+ def to_state
7
+ {
8
+ class: self.class.name,
9
+ state: @state,
10
+ persistence_key: @persistence_key,
11
+ context: persisted_context,
12
+ budget_consumed: ledger_consumed,
13
+ step_count: @step_count,
14
+ execution_namespace: @execution_namespace,
15
+ created_at: @created_at,
16
+ updated_at: @updated_at,
17
+ next_transition_name: @next_transition_name,
18
+ session_messages: @session_messages || [],
19
+ total_cost: @total_cost || 0.0,
20
+ total_tokens: @total_tokens || 0,
21
+ tool_results: @tool_results || [],
22
+ outcome: snapshot_outcome,
23
+ # New durable fields for hadithi billing. All wrapped in
24
+ # snapshot_value so non-JSON-safe runtime values (e.g.
25
+ # custom Hash details on DeterministicStepFailure) get the
26
+ # same deep-copy treatment as context/session_messages/etc.
27
+ usage_entries: snapshot_value((@usage_entries || []).map(&:to_h)),
28
+ last_output: snapshot_value(@last_output),
29
+ last_failed_step: snapshot_value(@last_failed_step),
30
+ # Optimistic-locking version. Adapters that support
31
+ # store_versioned use this to detect concurrent writes; adapters
32
+ # that don't (CacheStore, RailsCache) ignore it.
33
+ persistence_version: @persistence_version || 0,
34
+ # Schema version of the workflow class that wrote this payload.
35
+ # Restore dispatches through migrate_from blocks when the
36
+ # stored value lags the workflow's current
37
+ # persistence_schema_version.
38
+ schema_version: self.class.persistence_schema_version,
39
+ # SHA256 digest of the seed_messages produced at this
40
+ # workflow's construction. Stays stable across persist/restore
41
+ # cycles so seed_validation can detect when the seed builder
42
+ # has changed in code since this workflow was persisted.
43
+ seed_digest: @seed_digest,
44
+ # Step-in-progress idempotency marker. Set true between
45
+ # persist-before-advance and persist-after-advance when the
46
+ # workflow class opts into idempotency_mode :strict. Restore
47
+ # raises Smith::StepInProgressOnRestore if true under strict
48
+ # mode. Lax mode leaves this false and never raises.
49
+ step_in_progress: @step_in_progress || false,
50
+ # Keys recorded via DeterministicStep#write_context. Used by
51
+ # persist :auto Context mode to scope the persisted context
52
+ # slice. Always emitted (sorted for stable diffing) so
53
+ # explicit-mode workflows produce forward-compatible payloads.
54
+ persisted_keys: (@persisted_keys || ::Set.new).to_a.map(&:to_sym).sort
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def restore_state(hash)
61
+ migrated = migrate_if_needed(hash)
62
+ normalized = normalize_persisted_state(migrated)
63
+ restore_persisted_keys(normalized)
64
+ restore_core_fields(normalized)
65
+ @persistence_key = normalized[:persistence_key]
66
+ @ledger = rebuild_ledger(normalized[:budget_consumed] || {})
67
+ @next_transition_name = normalized[:next_transition_name]
68
+ @session_messages = normalized[:session_messages] || []
69
+ @total_cost = normalized[:total_cost] || 0.0
70
+ @total_tokens = normalized[:total_tokens] || 0
71
+ @outcome = normalized[:outcome]
72
+ initialize_tool_result_state
73
+ @tool_results = normalized[:tool_results] || []
74
+ # Mirror the eager inits from `Workflow#initialize`. `from_state`
75
+ # uses `allocate` and bypasses `initialize`, so any restored
76
+ # workflow that later records usage would `nil.synchronize` or
77
+ # `nil.map` without these. Backward-compat: pre-patch states
78
+ # have no `usage_entries`/`last_output`/`last_failed_step` keys
79
+ # and restore to the empty defaults.
80
+ @usage_mutex = Mutex.new
81
+ @usage_entries = restore_usage_entries(normalized)
82
+ @last_output = restore_last_output(normalized)
83
+ @last_failed_step = restore_last_failed_step(normalized)
84
+ # Restore the optimistic-locking version from the persisted payload.
85
+ # Backward-compat: pre-versioning payloads have no key, restore to 0
86
+ # so the first persist! after restore expects version 0 (matches
87
+ # the original store from the legacy adapter contract).
88
+ @persistence_version = normalized[:persistence_version] || 0
89
+ # Preserve the seed digest from the persisted payload so it
90
+ # round-trips on subsequent persists. validate_seed_digest!
91
+ # compares this against a fresh evaluation of the seed builder
92
+ # only when the workflow class opts into validation.
93
+ @seed_digest = normalized[:seed_digest]
94
+ validate_seed_digest!(normalized) if self.class.seed_validation != :off
95
+ # Restore the step-in-progress marker so a subsequent persist
96
+ # round-trips it. validate_step_in_progress! enforces strict
97
+ # mode by raising if the marker is set on restore.
98
+ @step_in_progress = normalized[:step_in_progress] || false
99
+ validate_step_in_progress!(normalized) if self.class.idempotency_mode == :strict
100
+ end
101
+
102
+ def validate_step_in_progress!(normalized)
103
+ return unless normalized[:step_in_progress] == true
104
+
105
+ raise Smith::StepInProgressOnRestore.new(
106
+ workflow: self.class.name,
107
+ persistence_key: normalized[:persistence_key]
108
+ )
109
+ end
110
+
111
+ def validate_seed_digest!(normalized)
112
+ stored_digest = normalized[:seed_digest]
113
+ return if stored_digest.nil?
114
+
115
+ current_messages = compute_seed_messages
116
+ current_digest = compute_seed_digest(current_messages)
117
+ return if current_digest == stored_digest
118
+
119
+ case self.class.seed_validation
120
+ when :strict
121
+ raise Smith::SeedMismatch.new(
122
+ workflow: self.class.name,
123
+ stored_digest: stored_digest,
124
+ current_digest: current_digest
125
+ )
126
+ when :warn
127
+ Smith.config.logger&.warn(
128
+ "Smith::Workflow seed_messages drift for #{self.class.name}: " \
129
+ "stored digest #{stored_digest.inspect}, current digest #{current_digest.inspect}"
130
+ )
131
+ end
132
+ end
133
+
134
+ def restore_usage_entries(normalized)
135
+ raw = normalized[:usage_entries]
136
+ return [] if raw.nil? || !raw.is_a?(Array)
137
+
138
+ raw.map { |h| Workflow::UsageEntry.from_h(h) }
139
+ end
140
+
141
+ # Use key-presence checks (NOT `||`) so a deliberately persisted
142
+ # `false` step output round-trips correctly. Smith's existing
143
+ # `RunResult#output` derivation uses `compact.first`, which only
144
+ # drops `nil` — `false` is a valid non-nil output.
145
+ def restore_last_output(normalized)
146
+ if normalized.key?(:last_output)
147
+ normalized[:last_output]
148
+ elsif normalized.key?("last_output")
149
+ normalized["last_output"]
150
+ end
151
+ end
152
+
153
+ # Symbolize ONLY the top-level keys of last_failed_step + the
154
+ # known value-symbols (`transition`, `from`, `to`, `error_kind`).
155
+ # `error_family` stays a String (the family_fallback compares
156
+ # against String literals). `error_details` is left exactly as
157
+ # JSON.parse returned it — documented as JSON-normalized
158
+ # semantics on round-trip (Hash keys become strings, symbol
159
+ # values become strings).
160
+ def restore_last_failed_step(normalized)
161
+ raw = normalized[:last_failed_step]
162
+ return nil unless raw.is_a?(Hash)
163
+
164
+ h = raw.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
165
+ {
166
+ transition: h[:transition]&.to_sym,
167
+ from: h[:from]&.to_sym,
168
+ to: h[:to]&.to_sym,
169
+ error_class: h[:error_class],
170
+ error_family: h[:error_family],
171
+ error_message: h[:error_message],
172
+ error_retryable: h[:error_retryable],
173
+ error_kind: h[:error_kind]&.to_sym,
174
+ error_details: h[:error_details]
175
+ }
176
+ end
177
+
178
+ def restore_core_fields(normalized)
179
+ @state = normalized[:state]
180
+ @context = filter_persisted_context(normalized[:context] || {})
181
+ @step_count = normalized[:step_count] || 0
182
+ @execution_namespace = normalized[:execution_namespace]
183
+ @created_at = normalized[:created_at]
184
+ @updated_at = normalized[:updated_at]
185
+ end
186
+
187
+ def restore_persisted_keys(normalized)
188
+ @persisted_keys_mutex = Mutex.new
189
+ raw = normalized[:persisted_keys]
190
+ if raw.is_a?(Array) && !raw.empty?
191
+ @persisted_keys = ::Set.new(raw.map(&:to_sym))
192
+ return
193
+ end
194
+
195
+ manager = self.class.context_manager
196
+ if manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
197
+ ctx = normalized[:context]
198
+ existing = ctx.is_a?(Hash) ? ctx.keys.map { |k| k.to_sym } : []
199
+ seed = manager.persist_auto_seed.map(&:to_sym)
200
+ @persisted_keys = ::Set.new(existing + seed)
201
+ else
202
+ @persisted_keys = ::Set.new
203
+ end
204
+ end
205
+
206
+ # Bridges stored schema_version to the workflow class's current
207
+ # persistence_schema_version by walking registered migrate_from
208
+ # blocks one step at a time. Returns the (possibly migrated)
209
+ # payload with symbol top-level keys, ready for the rest of the
210
+ # normalize pipeline. Pre-versioning payloads are treated as v1
211
+ # for backward compatibility with state written before Smith
212
+ # carried :schema_version.
213
+ def migrate_if_needed(hash)
214
+ payload = hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
215
+ current = self.class.persistence_schema_version
216
+ stored = payload[:schema_version] || 1
217
+
218
+ return payload if stored == current
219
+
220
+ if stored > current
221
+ raise Smith::PersistenceSchemaMismatch.new(
222
+ workflow: self.class.name, stored: stored, current: current
223
+ )
224
+ end
225
+
226
+ cursor = stored
227
+ while cursor < current
228
+ migration = self.class.migrations[cursor]
229
+ unless migration
230
+ raise Smith::PersistenceSchemaMismatch.new(
231
+ workflow: self.class.name, stored: cursor, current: current
232
+ )
233
+ end
234
+
235
+ payload = migration.call(payload)
236
+ payload = payload.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
237
+ cursor += 1
238
+ # Defensive: advance :schema_version if the migration block
239
+ # forgot to set it, so the loop terminates.
240
+ payload[:schema_version] = cursor if (payload[:schema_version] || 0) < cursor
241
+ end
242
+
243
+ payload
244
+ end
245
+
246
+ def normalize_persisted_state(hash)
247
+ normalized = hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
248
+ normalize_symbol_fields!(normalized)
249
+ normalize_nested_hashes!(normalized)
250
+ normalize_session_messages!(normalized)
251
+ normalize_tool_results!(normalized)
252
+ normalize_usage_entries!(normalized)
253
+ normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
254
+ normalized
255
+ end
256
+
257
+ def normalize_symbol_fields!(normalized)
258
+ normalized[:state] = normalized[:state]&.to_sym
259
+ if normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?(:kind)
260
+ normalized[:outcome][:kind] = normalized[:outcome][:kind]&.to_sym
261
+ elsif normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?("kind")
262
+ normalized[:outcome]["kind"] = normalized[:outcome]["kind"]&.to_sym
263
+ end
264
+ return unless normalized.key?(:next_transition_name)
265
+
266
+ normalized[:next_transition_name] = normalized[:next_transition_name]&.to_sym
267
+ end
268
+
269
+ def normalize_nested_hashes!(normalized)
270
+ normalized[:context] = symbolize_keys(normalized[:context]) if normalized[:context].is_a?(Hash)
271
+ return unless normalized[:budget_consumed].is_a?(Hash)
272
+
273
+ normalized[:budget_consumed] = symbolize_keys(normalized[:budget_consumed])
274
+ end
275
+
276
+ def normalize_session_messages!(normalized)
277
+ return unless normalized[:session_messages].is_a?(Array)
278
+
279
+ normalized[:session_messages] = normalized[:session_messages].map do |msg|
280
+ msg.is_a?(Hash) ? symbolize_keys(msg) : msg
281
+ end
282
+ end
283
+
284
+ def normalize_tool_results!(normalized)
285
+ return unless normalized[:tool_results].is_a?(Array)
286
+
287
+ normalized[:tool_results] = normalized[:tool_results].map do |entry|
288
+ entry.is_a?(Hash) ? symbolize_keys(entry) : entry
289
+ end
290
+ end
291
+
292
+ def normalize_usage_entries!(normalized)
293
+ return unless normalized[:usage_entries].is_a?(Array)
294
+
295
+ normalized[:usage_entries] = normalized[:usage_entries].map do |entry|
296
+ entry.is_a?(Hash) ? symbolize_keys(entry) : entry
297
+ end
298
+ end
299
+
300
+ def symbolize_keys(hash)
301
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
302
+ end
303
+
304
+ def symbolize_value(value)
305
+ case value
306
+ when Hash
307
+ value.each_with_object({}) do |(key, nested), copy|
308
+ normalized_key = key.is_a?(String) ? key.to_sym : key
309
+ copy[normalized_key] = symbolize_value(nested)
310
+ end
311
+ when Array
312
+ value.map { |nested| symbolize_value(nested) }
313
+ else
314
+ value
315
+ end
316
+ end
317
+
318
+ def ledger_consumed
319
+ @ledger ? @ledger.consumed.to_h : {}
320
+ end
321
+
322
+ def rebuild_ledger(consumed)
323
+ config = self.class.budget
324
+ return nil unless config
325
+
326
+ Budget::Ledger.new(limits: config, consumed: consumed)
327
+ end
328
+
329
+ def persisted_context
330
+ keys = resolve_persist_keys
331
+ return @context if keys.nil?
332
+ return @context.slice(*(@persisted_keys || ::Set.new).to_a) if keys == :auto
333
+
334
+ @context.slice(*keys)
335
+ end
336
+
337
+ def filter_persisted_context(context)
338
+ keys = resolve_persist_keys
339
+ return context if keys.nil?
340
+ return context.slice(*(@persisted_keys || ::Set.new).to_a) if keys == :auto
341
+
342
+ context.slice(*keys)
343
+ end
344
+
345
+ def resolve_persist_keys
346
+ manager = self.class.context_manager
347
+ return nil unless manager
348
+
349
+ if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
350
+ return :auto
351
+ end
352
+
353
+ keys = manager.persist
354
+ keys.empty? ? nil : keys
355
+ end
356
+ end
357
+ end
358
+ end