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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module EvaluatorOptimizer
6
+ OptimizationState = Struct.new(
7
+ :config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
8
+ ) do
9
+ def initialize(config, prepared_input)
10
+ super(config, prepared_input, nil, nil, nil, nil, nil)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def execute_optimization_step(transition, prepared_input: nil)
17
+ state = OptimizationState.new(transition.optimization_config, prepared_input)
18
+ state.generator_class = Agent::Registry.fetch!(
19
+ state.config[:generator],
20
+ workflow_class: self.class,
21
+ transition_name: transition.name,
22
+ role: :generator
23
+ )
24
+ state.evaluator_class = Agent::Registry.fetch!(
25
+ state.config[:evaluator],
26
+ workflow_class: self.class,
27
+ transition_name: transition.name,
28
+ role: :evaluator
29
+ )
30
+ run_optimization_loop(state)
31
+ end
32
+
33
+ def run_optimization_loop(state)
34
+ state.config[:max_rounds].times do |round|
35
+ result = run_optimization_round(state, round)
36
+ return result if result
37
+ end
38
+
39
+ handle_exit(state, :on_exhaustion,
40
+ "optimization exhausted #{state.config[:max_rounds]} rounds without acceptance")
41
+ end
42
+
43
+ def run_optimization_round(state, round)
44
+ generate_candidate!(state, round)
45
+ invoke_before_eval(state)
46
+ evaluation = normalize_evaluation(evaluate_candidate(state))
47
+ validate_evaluation_structure!(evaluation)
48
+ validate_evaluation_fields!(evaluation, state.config)
49
+
50
+ return state.candidate if evaluation[:accept]
51
+
52
+ if evaluation[:converged]
53
+ return handle_exit(state, :on_converged,
54
+ "optimization converged without acceptance after round #{round + 1}")
55
+ end
56
+
57
+ threshold_exit = check_improvement_threshold!(evaluation, state, round)
58
+ return threshold_exit if threshold_exit
59
+
60
+ state.last_score = evaluation[:score]
61
+ state.feedback = evaluation[:feedback]
62
+ nil
63
+ end
64
+
65
+ # :raise => WorkflowError(message); :return_last => state.candidate;
66
+ # callable => mode.call(state). Default :raise preserves legacy
67
+ # behavior for hosts that don't opt in to graceful exits.
68
+ def handle_exit(state, mode_key, message)
69
+ mode = state.config[mode_key]
70
+ case mode
71
+ when :raise then raise WorkflowError, message
72
+ when :return_last then state.candidate
73
+ else
74
+ mode.call(state)
75
+ end
76
+ end
77
+
78
+ # Real RubyLLM schema-bound responses come back as Hash with String
79
+ # keys. The validate_evaluation_* helpers below expect symbol keys
80
+ # (test stubs use symbol keys, masking the gap). String input also
81
+ # arrives when an evaluator stubs raw JSON. Normalize to a uniform
82
+ # symbol-keyed Hash so the validators stay clean and the rest of
83
+ # the loop can use `evaluation[:accept]` semantics without caring
84
+ # which provider returned the payload.
85
+ #
86
+ # Pure-Ruby deep-symbolize: Smith doesn't depend on ActiveSupport,
87
+ # so `deep_symbolize_keys` isn't available. The recursion mirrors
88
+ # what `Hash#transform_keys` plus a nested-Hash walk would do.
89
+ def normalize_evaluation(evaluation)
90
+ case evaluation
91
+ when Hash
92
+ deep_symbolize_evaluation(evaluation)
93
+ when String
94
+ parsed = (JSON.parse(evaluation, symbolize_names: true) rescue nil)
95
+ parsed.is_a?(Hash) ? parsed : evaluation
96
+ else
97
+ evaluation
98
+ end
99
+ end
100
+
101
+ def deep_symbolize_evaluation(value)
102
+ case value
103
+ when Hash
104
+ value.each_with_object({}) do |(key, nested), out|
105
+ sym_key = key.is_a?(String) ? key.to_sym : key
106
+ out[sym_key] = deep_symbolize_evaluation(nested)
107
+ end
108
+ when Array
109
+ value.map { |item| deep_symbolize_evaluation(item) }
110
+ else
111
+ value
112
+ end
113
+ end
114
+
115
+ def generate_candidate!(state, round)
116
+ input = prepare_generator_input(state.prepared_input, round, state.candidate, state.feedback)
117
+ result = invoke_agent_with_budget(state.generator_class, input)
118
+ state.candidate = result
119
+ end
120
+
121
+ def evaluate_candidate(state)
122
+ input = build_evaluator_input(state)
123
+ invoke_with_evaluator_schema(state.evaluator_class, state.config[:evaluator_schema], input)
124
+ end
125
+
126
+ # evaluator_context: :inject_state appends the candidate as a
127
+ # user turn to the prepared_input the generator received, so the
128
+ # evaluator sees the same seed_messages + inject_state context.
129
+ # Default nil keeps the legacy candidate-only payload.
130
+ def build_evaluator_input(state)
131
+ return [{ role: :user, content: state.candidate.to_s }] unless state.config[:evaluator_context] == :inject_state
132
+
133
+ prior = Array(state.prepared_input).dup
134
+ prior.push(role: :user, content: state.candidate.to_s)
135
+ end
136
+
137
+ # Runs after candidate generation, before evaluator invocation.
138
+ # Receives (state, @context); @context is mutable. Return value
139
+ # is discarded. Raised exceptions bubble through the standard
140
+ # step failure path.
141
+ def invoke_before_eval(state)
142
+ callback = state.config[:before_eval]
143
+ return unless callback
144
+
145
+ callback.call(state, @context)
146
+ end
147
+
148
+ def invoke_with_evaluator_schema(evaluator_class, schema, input)
149
+ original_schema = evaluator_class.output_schema
150
+ evaluator_class.output_schema(schema)
151
+ invoke_agent_with_budget(evaluator_class, input)
152
+ ensure
153
+ evaluator_class.output_schema(original_schema)
154
+ end
155
+
156
+ def invoke_agent_with_budget(agent_class, prepared_input)
157
+ Thread.current[:smith_last_agent_result] = nil
158
+ with_agent_context(agent_class) do
159
+ invoke_with_call_ledger(agent_class, prepared_input)
160
+ end
161
+ end
162
+
163
+ def invoke_with_call_ledger(agent_class, prepared_input)
164
+ ledger = effective_call_ledger
165
+ reserved = reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
166
+ begin
167
+ result = invoke_agent(agent_class, prepared_input)
168
+ agent_result = result.is_a?(AgentResult) ? result : nil
169
+ reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
170
+ reserved = nil
171
+ agent_result ? agent_result.content : result
172
+ ensure
173
+ settle_budget_on_failure(ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
174
+ Thread.current[:smith_last_agent_result] = nil
175
+ end
176
+ end
177
+
178
+ # Returns nil when the threshold doesn't trip. When it does,
179
+ # routes through on_threshold and returns the resulting value
180
+ # (non-nil terminates the loop with that as the step output).
181
+ def check_improvement_threshold!(evaluation, state, round)
182
+ return nil unless stop_for_threshold?(evaluation[:score], state.last_score, state.config[:improvement_threshold])
183
+
184
+ handle_exit(state, :on_threshold,
185
+ "optimization improvement below threshold after round #{round + 1}")
186
+ end
187
+
188
+ def prepare_generator_input(prepared_input, round, prior_candidate, feedback)
189
+ return prepared_input if round.zero?
190
+
191
+ (prepared_input&.dup || []).push(
192
+ { role: :assistant, content: prior_candidate.to_s },
193
+ {
194
+ role: :user,
195
+ content: "[smith:refinement-round] #{round + 1}\n[smith:evaluator-feedback]\n#{feedback}"
196
+ }
197
+ )
198
+ end
199
+
200
+ def validate_evaluation_structure!(evaluation)
201
+ raise WorkflowError, "evaluator output must be a Hash" unless evaluation.is_a?(Hash)
202
+ raise WorkflowError, "evaluator output missing :accept" unless evaluation.key?(:accept)
203
+ raise WorkflowError, "evaluator :accept must be boolean" unless [true, false].include?(evaluation[:accept])
204
+ end
205
+
206
+ def validate_evaluation_fields!(evaluation, config)
207
+ unless evaluation[:accept] || evaluation[:feedback]
208
+ raise WorkflowError, "evaluator must provide :feedback when not accepted"
209
+ end
210
+ return unless config[:improvement_threshold] && !evaluation[:score].is_a?(Numeric)
211
+
212
+ raise WorkflowError, "evaluator must provide numeric :score when improvement_threshold is configured"
213
+ end
214
+
215
+ def stop_for_threshold?(current_score, last_score, threshold)
216
+ threshold && last_score && current_score.is_a?(Numeric) && (current_score - last_score).abs < threshold
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module EventIntegration
6
+ private
7
+
8
+ def emit_step_completed(transition, _output)
9
+ Smith::Trace.record(
10
+ type: :transition,
11
+ data: { transition: transition.name, from: transition.from, to: transition.to }
12
+ )
13
+
14
+ Smith::Events.emit(
15
+ Events::StepCompleted.new(
16
+ transition: transition.name,
17
+ from: transition.from,
18
+ to: transition.to
19
+ )
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module Execution
6
+ include Agent::Lifecycle
7
+ include NestedExecution
8
+ include EvaluatorOptimizer
9
+ include OrchestratorWorker
10
+ include ParallelExecution
11
+ include DeterministicExecution
12
+
13
+ private
14
+
15
+ def execute_step(transition)
16
+ setup_step_context
17
+ output = with_scoped_artifacts { run_guarded_step(transition) }
18
+ complete_step(transition, output)
19
+ rescue StandardError => e
20
+ @outcome = nil
21
+ handle_step_failure(transition, e)
22
+ { transition: transition.name, from: transition.from, to: transition.to, error: e }
23
+ ensure
24
+ teardown_step_context
25
+ end
26
+
27
+ def setup_step_context
28
+ Tool.current_deadline = wall_clock_deadline
29
+ Tool.current_ledger = @ledger
30
+ Tool.current_tool_result_collector = tool_result_collector
31
+ end
32
+
33
+ def teardown_step_context
34
+ Tool.current_guardrails = nil
35
+ Tool.current_deadline = nil
36
+ Tool.current_ledger = nil
37
+ Tool.current_tool_result_collector = nil
38
+ Smith.scoped_artifacts = nil
39
+ end
40
+
41
+ def run_guarded_step(transition)
42
+ return dispatch_step(transition) if transition.deterministic?
43
+
44
+ agent_class = resolve_agent_class(transition)
45
+ run_input_guardrails(agent_class)
46
+ apply_tool_guardrails(agent_class)
47
+ prepared_input = build_session&.prepare!
48
+
49
+ output = with_agent_context(agent_class) do
50
+ dispatch_step(transition, prepared_input: prepared_input)
51
+ end
52
+
53
+ validate_data_volume!(output, agent_class)
54
+ run_output_guardrails(output, agent_class)
55
+ resolve_router_output(transition, output)
56
+ end
57
+
58
+ def complete_step(transition, output)
59
+ @state = transition.to
60
+ @next_transition_name = @router_next_transition || transition.success_transition
61
+ @router_next_transition = nil
62
+ append_accepted_output(output)
63
+ emit_step_completed(transition, output)
64
+ { transition: transition.name, from: transition.from, to: transition.to, output: output }
65
+ end
66
+
67
+ def append_accepted_output(output)
68
+ return unless @session_messages
69
+ return if output.nil?
70
+
71
+ @session_messages << { role: :assistant, content: output }
72
+ end
73
+
74
+ def resolve_router_output(transition, output)
75
+ return output unless transition.routed?
76
+
77
+ @router_next_transition = Router.resolve(output, transition.router_config, workflow_class: self.class)
78
+ nil # routed steps have no user-facing output
79
+ end
80
+
81
+ def execute_transition_body(transition, prepared_input: nil)
82
+ @last_prepared_input = prepared_input
83
+ return nil unless transition.agent_name
84
+
85
+ agent_class = resolve_agent_class(transition)
86
+ # Accepts either static `model "id"` (chat_kwargs[:model]) OR
87
+ # block-form `model { |ctx| ... }` (model_block). The block-form
88
+ # path resolves the actual id in build_model_chain at attempt time.
89
+ return nil unless agent_class.model_configured?
90
+
91
+ invoke_agent(agent_class, prepared_input)
92
+ end
93
+
94
+ def execute_serial_step(transition, prepared_input: nil)
95
+ Thread.current[:smith_last_agent_result] = nil
96
+ ledger = effective_call_ledger
97
+ reserved = reserve_for_serial(transition, ledger)
98
+ begin
99
+ result = execute_transition_body(transition, prepared_input: prepared_input)
100
+ agent_result = result.is_a?(AgentResult) ? result : nil
101
+ reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
102
+ reserved = nil
103
+ agent_result ? agent_result.content : result
104
+ ensure
105
+ settle_budget_on_failure(ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
106
+ Thread.current[:smith_last_agent_result] = nil
107
+ end
108
+ end
109
+
110
+ def reserve_for_serial(transition, ledger)
111
+ agent_class = resolve_agent_class(transition)
112
+ reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
113
+ end
114
+
115
+ def resolve_agent_class(transition)
116
+ return nil unless transition.agent_name
117
+
118
+ Agent::Registry.fetch!(
119
+ transition.agent_name,
120
+ workflow_class: self.class,
121
+ transition_name: transition.name,
122
+ role: :agent
123
+ )
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # Absorbs the five-flag bookkeeping pattern (claimed, result_obtained,
8
+ # recorded, intentional_retry, finalize_succeeded) duplicated across
9
+ # host Execution wrappers. The host yields its per-attempt work in,
10
+ # records lifecycle milestones via mark_*! setters, and the frame's
11
+ # ensure invokes on_clear / always_ensure based on the canonical
12
+ # decision: claimed && (finalize_succeeded || (intentional_retry &&
13
+ # recorded) || !result_obtained).
14
+ #
15
+ # OrderingError and AlreadyRun inherit from Smith::Error (NOT
16
+ # Smith::WorkflowError) so host `rescue Smith::WorkflowError` blocks
17
+ # cannot silently downgrade ordering bugs to handler-error states.
18
+ class ExecutionFrame
19
+ class OrderingError < Smith::Error; end
20
+ class AlreadyRun < Smith::Error; end
21
+
22
+ def self.run(workflow: nil, on_clear: nil, always_ensure: nil, logger: nil)
23
+ new(workflow: workflow, on_clear: on_clear, always_ensure: always_ensure, logger: logger).run { |frame| yield frame }
24
+ end
25
+
26
+ def initialize(workflow: nil, on_clear: nil, always_ensure: nil, logger: nil)
27
+ @workflow = workflow
28
+ @on_clear = on_clear
29
+ @always_ensure = always_ensure
30
+ @logger = logger
31
+ @claimed = false
32
+ @claimed_set = false
33
+ @result_obtained = false
34
+ @recorded = false
35
+ @intentional_retry = false
36
+ @finalize_succeeded = false
37
+ @run_invoked = false
38
+ @finished = false
39
+ end
40
+
41
+ def run
42
+ raise AlreadyRun, "ExecutionFrame already run" if @run_invoked
43
+
44
+ @run_invoked = true
45
+ result = yield(self)
46
+ result
47
+ ensure
48
+ finish!
49
+ end
50
+
51
+ def mark_claimed!(value = true)
52
+ if @claimed_set && @claimed != value
53
+ raise OrderingError, "mark_claimed! called twice with conflicting values (#{@claimed.inspect} then #{value.inspect})"
54
+ end
55
+
56
+ @claimed = value
57
+ @claimed_set = true
58
+ value
59
+ end
60
+
61
+ def mark_result_obtained!
62
+ raise OrderingError, "mark_result_obtained! requires prior mark_claimed!(true)" unless @claimed == true
63
+
64
+ @result_obtained = true
65
+ end
66
+
67
+ def mark_recorded!
68
+ raise OrderingError, "mark_recorded! requires prior mark_result_obtained!" unless @result_obtained
69
+
70
+ @recorded = true
71
+ end
72
+
73
+ def mark_intentional_retry!(value = true)
74
+ if @claimed_set && @claimed == false
75
+ raise OrderingError, "mark_intentional_retry! invalid after mark_claimed!(false)"
76
+ end
77
+
78
+ @intentional_retry = value
79
+ end
80
+
81
+ def mark_finalize_succeeded!
82
+ raise OrderingError, "mark_finalize_succeeded! requires prior mark_recorded!" unless @recorded
83
+
84
+ @finalize_succeeded = true
85
+ end
86
+
87
+ def claimed?
88
+ @claimed == true
89
+ end
90
+
91
+ def result_obtained?
92
+ @result_obtained
93
+ end
94
+
95
+ def recorded?
96
+ @recorded
97
+ end
98
+
99
+ def intentional_retry?
100
+ @intentional_retry
101
+ end
102
+
103
+ def finalize_succeeded?
104
+ @finalize_succeeded
105
+ end
106
+
107
+ def should_clear?
108
+ claimed? && (@finalize_succeeded || (@intentional_retry && @recorded) || !@result_obtained)
109
+ end
110
+
111
+ def finish!
112
+ return false if @finished
113
+
114
+ @finished = true
115
+ cleared = false
116
+
117
+ if should_clear?
118
+ cleared = invoke_on_clear
119
+ end
120
+
121
+ invoke_always_ensure if claimed?
122
+
123
+ cleared
124
+ end
125
+
126
+ private
127
+
128
+ def invoke_on_clear
129
+ if @on_clear.respond_to?(:call)
130
+ @on_clear.call
131
+ return true
132
+ end
133
+
134
+ target = resolve_workflow
135
+ if target.nil?
136
+ resolved_logger.warn("Smith::Workflow::ExecutionFrame: workflow resolver returned nil; skipping clear")
137
+ return false
138
+ end
139
+
140
+ target.clear_persisted!
141
+ true
142
+ rescue StandardError => e
143
+ resolved_logger.error("Smith::Workflow::ExecutionFrame on_clear raised: #{e.class}: #{e.message}")
144
+ true
145
+ end
146
+
147
+ def invoke_always_ensure
148
+ return unless @always_ensure.respond_to?(:call)
149
+
150
+ @always_ensure.call
151
+ rescue StandardError => e
152
+ resolved_logger.error("Smith::Workflow::ExecutionFrame always_ensure raised: #{e.class}: #{e.message}")
153
+ end
154
+
155
+ def resolve_workflow
156
+ return @workflow.call if @workflow.respond_to?(:call)
157
+
158
+ @workflow
159
+ end
160
+
161
+ def resolved_logger
162
+ @logger || Smith.config.logger || (@_fallback_logger ||= Logger.new($stderr))
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module GuardrailIntegration
6
+ private
7
+
8
+ def apply_tool_guardrails(agent_class)
9
+ sources = [self.class.guardrails, agent_class&.guardrails].compact
10
+ Tool.current_guardrails = sources.empty? ? nil : sources
11
+ end
12
+
13
+ def run_input_guardrails(agent_class)
14
+ wf_guardrails = self.class.guardrails
15
+ Guardrails::Runner.run_inputs(wf_guardrails, @context) if wf_guardrails
16
+
17
+ agent_guardrails = agent_class&.guardrails
18
+ Guardrails::Runner.run_inputs(agent_guardrails, @context) if agent_guardrails
19
+ end
20
+
21
+ def run_output_guardrails(output, agent_class)
22
+ wf_guardrails = self.class.guardrails
23
+ Guardrails::Runner.run_outputs(wf_guardrails, output) if wf_guardrails
24
+
25
+ agent_guardrails = agent_class&.guardrails
26
+ Guardrails::Runner.run_outputs(agent_guardrails, output) if agent_guardrails
27
+ end
28
+
29
+ def handle_step_failure(transition, _error)
30
+ failure_name = transition.failure_transition
31
+ return unless failure_name
32
+
33
+ fail_transition = self.class.find_transition(failure_name)
34
+ return unless fail_transition
35
+
36
+ @state = fail_transition.to
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module NestedExecution
6
+ private
7
+
8
+ def execute_nested_workflow(transition)
9
+ check_deadline!
10
+ child = build_child_workflow(transition.workflow_class)
11
+ child_result = run_child_workflow(child)
12
+ handle_child_result(child_result)
13
+ end
14
+
15
+ def run_child_workflow(child)
16
+ child.run!
17
+ rescue Smith::Error => e
18
+ raise WorkflowError, "nested workflow failed: #{e.message}"
19
+ end
20
+
21
+ def build_child_workflow(child_class)
22
+ child = child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
23
+ child.instance_variable_set(:@execution_namespace, @execution_namespace)
24
+ child.instance_variable_set(:@inherited_deadline, wall_clock_deadline)
25
+ child.instance_variable_set(:@inherited_scoped_artifacts, Smith.scoped_artifacts)
26
+ child
27
+ end
28
+
29
+ # Roll up child totals AND usage_entries BEFORE the failed-step
30
+ # check raises. Previously the rollup only fired on child success
31
+ # — billable agent work inside a failed child was silently
32
+ # dropped from the parent's totals/entries. The drift guard at
33
+ # the hadithi boundary wouldn't catch it (parent rollups + entries
34
+ # consistently undercount the same way; sum invariant still holds
35
+ # incorrectly). Roll up first, then re-raise, so the parent's
36
+ # terminal state reflects the child's billable work even when the
37
+ # child failed.
38
+ def handle_child_result(child_result)
39
+ roll_up_child_totals(child_result)
40
+
41
+ failed_step = child_result.steps.find { |s| s.key?(:error) }
42
+ raise WorkflowError, "nested workflow failed: #{failed_step[:error]&.message}" if failed_step
43
+
44
+ child_result.output
45
+ end
46
+
47
+ # `@usage_mutex` is eagerly initialized in `Workflow#initialize`
48
+ # AND `Workflow#restore_state`, so it's always present. Single
49
+ # synchronize block updates totals + entries together, matching
50
+ # the lifecycle.rb `record_usage` pattern.
51
+ #
52
+ # Defensive deep-copy via `from_h(snapshot_value(entry.to_h))`:
53
+ # `Struct#dup` is shallow (shares mutable string fields like
54
+ # `usage_id`/`model`), and aliasing child entries into multiple
55
+ # parents could let later mutations corrupt earlier parents.
56
+ def roll_up_child_totals(child_result)
57
+ child_entries = (child_result.usage_entries || []).map do |entry|
58
+ Workflow::UsageEntry.from_h(snapshot_value(entry.to_h))
59
+ end
60
+
61
+ @usage_mutex.synchronize do
62
+ @total_cost = (@total_cost || 0.0) + (child_result.total_cost || 0.0)
63
+ @total_tokens = (@total_tokens || 0) + (child_result.total_tokens || 0)
64
+ @usage_entries.concat(child_entries)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end