smith-agents 0.4.1 → 0.4.3

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -3
  3. data/README.md +69 -3
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +85 -6
  6. data/lib/smith/agent/lifecycle.rb +11 -7
  7. data/lib/smith/agent/registry/introspection.rb +27 -0
  8. data/lib/smith/agent/registry.rb +22 -17
  9. data/lib/smith/agent/registry_binding.rb +43 -0
  10. data/lib/smith/agent.rb +3 -4
  11. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  12. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  13. data/lib/smith/version.rb +1 -1
  14. data/lib/smith/workflow/agent_result.rb +26 -0
  15. data/lib/smith/workflow/branch_env.rb +24 -0
  16. data/lib/smith/workflow/budget_integration.rb +27 -5
  17. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  18. data/lib/smith/workflow/deterministic_step.rb +32 -4
  19. data/lib/smith/workflow/durability.rb +38 -12
  20. data/lib/smith/workflow/evaluator_optimizer.rb +18 -10
  21. data/lib/smith/workflow/event_integration.rb +3 -3
  22. data/lib/smith/workflow/execution.rb +8 -1
  23. data/lib/smith/workflow/fanout_execution.rb +121 -0
  24. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  25. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  26. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  27. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  28. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  30. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  31. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  33. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  34. data/lib/smith/workflow/graph/targets.rb +5 -0
  35. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  36. data/lib/smith/workflow/graph/transition_snapshot.rb +80 -18
  37. data/lib/smith/workflow/graph.rb +16 -1
  38. data/lib/smith/workflow/graph_dsl.rb +4 -0
  39. data/lib/smith/workflow/guardrail_integration.rb +45 -10
  40. data/lib/smith/workflow/nested_execution.rb +5 -4
  41. data/lib/smith/workflow/optimization_state.rb +13 -0
  42. data/lib/smith/workflow/orchestration_state.rb +13 -0
  43. data/lib/smith/workflow/orchestrator_worker.rb +5 -16
  44. data/lib/smith/workflow/parallel/cancellation.rb +9 -0
  45. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  46. data/lib/smith/workflow/parallel.rb +12 -16
  47. data/lib/smith/workflow/parallel_execution.rb +5 -1
  48. data/lib/smith/workflow/persistence.rb +37 -6
  49. data/lib/smith/workflow/retry_execution.rb +52 -0
  50. data/lib/smith/workflow/router.rb +15 -4
  51. data/lib/smith/workflow/run_result.rb +54 -0
  52. data/lib/smith/workflow/transition.rb +260 -24
  53. data/lib/smith/workflow/usage_entry.rb +30 -0
  54. data/lib/smith/workflow/worker_execution.rb +13 -0
  55. data/lib/smith/workflow.rb +40 -136
  56. data/lib/smith.rb +12 -0
  57. metadata +24 -1
@@ -5,7 +5,8 @@ module Smith
5
5
  class Transition
6
6
  attr_reader :name, :from, :to, :agent_name, :agent_opts, :success_transition, :failure_transition,
7
7
  :router_config, :workflow_class, :optimization_config, :orchestrator_config,
8
- :deterministic_block, :deterministic_kind
8
+ :fanout_config, :retry_config, :deterministic_block, :deterministic_kind,
9
+ :deterministic_routes
9
10
 
10
11
  def initialize(name, from:, to:, &)
11
12
  @name = name
@@ -15,7 +16,7 @@ module Smith
15
16
  end
16
17
 
17
18
  def execute(agent_name, **opts)
18
- raise WorkflowError, "transition cannot declare both execute and compute/run" if @deterministic_block
19
+ validate_execute_conflicts!
19
20
 
20
21
  @agent_name = agent_name
21
22
  @agent_opts = opts
@@ -30,18 +31,29 @@ module Smith
30
31
  end
31
32
 
32
33
  def route(agent_name, routes:, confidence_threshold:, fallback:)
33
- raise WorkflowError, "transition cannot declare both route and compute/run" if @deterministic_block
34
+ validate_route_conflicts!
34
35
 
35
- @agent_name = agent_name
36
- @router_config = { routes: routes, confidence_threshold: confidence_threshold, fallback: fallback }
36
+ @agent_name = normalize_agent_name!(agent_name, "router")
37
+ @router_config = {
38
+ routes: normalize_router_routes!(routes),
39
+ confidence_threshold: normalize_confidence_threshold!(confidence_threshold),
40
+ fallback: normalize_transition_reference!(fallback, "router fallback")
41
+ }
37
42
  end
38
43
 
39
44
  def workflow(klass)
40
45
  raise WorkflowError, "workflow binding must be a Class" unless klass.is_a?(Class)
41
46
  raise WorkflowError, "workflow binding must be a Smith::Workflow subclass" unless klass < Workflow
42
- raise WorkflowError, "transition cannot declare both workflow and execute" if @agent_name && !@router_config
43
- raise WorkflowError, "transition cannot declare both workflow and route" if @router_config
44
- raise WorkflowError, "transition cannot declare both workflow and compute/run" if @deterministic_block
47
+
48
+ validate_conflicts!(
49
+ "workflow",
50
+ [
51
+ ["execute", @agent_name && !@router_config],
52
+ ["route", @router_config],
53
+ ["compute/run", @deterministic_block],
54
+ ["fan_out", @fanout_config]
55
+ ]
56
+ )
45
57
 
46
58
  @workflow_class = klass
47
59
  end
@@ -77,13 +89,33 @@ module Smith
77
89
  @orchestrator_config = opts
78
90
  end
79
91
 
92
+ def fan_out(branches:)
93
+ validate_fanout_conflicts!
94
+
95
+ @fanout_config = { branches: normalize_fanout_branches!(branches) }
96
+ end
97
+ alias fanout fan_out
98
+
99
+ def retry_on(*error_classes, attempts:, backoff: 0, max_delay: nil, jitter: 0)
100
+ validate_retry_controls!(error_classes, attempts:, backoff:, max_delay:, jitter:)
101
+
102
+ @retry_config = {
103
+ error_classes: error_classes.freeze,
104
+ attempts: attempts,
105
+ backoff: Float(backoff),
106
+ max_delay: max_delay.nil? ? nil : Float(max_delay),
107
+ jitter: Float(jitter)
108
+ }.freeze
109
+ end
110
+
80
111
  %i[compute run].each do |method_name|
81
- define_method(method_name) do |&block|
112
+ define_method(method_name) do |routes: nil, &block|
82
113
  validate_deterministic_conflicts!
83
114
  raise WorkflowError, "#{method_name} requires a block" unless block
84
115
 
85
116
  @deterministic_block = block
86
117
  @deterministic_kind = method_name
118
+ @deterministic_routes = normalize_deterministic_routes!(routes)
87
119
  end
88
120
  end
89
121
 
@@ -95,6 +127,10 @@ module Smith
95
127
  !@orchestrator_config.nil?
96
128
  end
97
129
 
130
+ def fanout?
131
+ !@fanout_config.nil?
132
+ end
133
+
98
134
  def optimized?
99
135
  !@optimization_config.nil?
100
136
  end
@@ -113,28 +149,228 @@ module Smith
113
149
 
114
150
  private
115
151
 
152
+ def validate_execute_conflicts!
153
+ validate_conflicts!(
154
+ "execute",
155
+ [
156
+ ["compute/run", @deterministic_block],
157
+ ["fan_out", @fanout_config]
158
+ ]
159
+ )
160
+ end
161
+
162
+ def validate_route_conflicts!
163
+ validate_conflicts!(
164
+ "route",
165
+ [
166
+ ["compute/run", @deterministic_block],
167
+ ["fan_out", @fanout_config]
168
+ ]
169
+ )
170
+ end
171
+
116
172
  def validate_deterministic_conflicts!
117
- raise WorkflowError, "transition cannot declare both compute/run and execute" if @agent_name && !@router_config
118
- raise WorkflowError, "transition cannot declare both compute/run and route" if @router_config
119
- raise WorkflowError, "transition cannot declare both compute/run and workflow" if @workflow_class
120
- raise WorkflowError, "transition cannot declare both compute/run and optimize" if @optimization_config
121
- raise WorkflowError, "transition cannot declare both compute/run and orchestrate" if @orchestrator_config
173
+ validate_conflicts!(
174
+ "compute/run",
175
+ [
176
+ ["execute", @agent_name && !@router_config],
177
+ ["route", @router_config],
178
+ ["workflow", @workflow_class],
179
+ ["optimize", @optimization_config],
180
+ ["orchestrate", @orchestrator_config],
181
+ ["fan_out", @fanout_config]
182
+ ]
183
+ )
122
184
  raise WorkflowError, "transition cannot declare both compute and run" if @deterministic_block
123
185
  end
124
186
 
125
187
  def validate_optimize_conflicts!
126
- raise WorkflowError, "transition cannot declare both optimize and execute" if @agent_name && !@router_config
127
- raise WorkflowError, "transition cannot declare both optimize and route" if @router_config
128
- raise WorkflowError, "transition cannot declare both optimize and workflow" if @workflow_class
129
- raise WorkflowError, "transition cannot declare both optimize and compute/run" if @deterministic_block
188
+ validate_conflicts!(
189
+ "optimize",
190
+ [
191
+ ["execute", @agent_name && !@router_config],
192
+ ["route", @router_config],
193
+ ["workflow", @workflow_class],
194
+ ["compute/run", @deterministic_block],
195
+ ["fan_out", @fanout_config]
196
+ ]
197
+ )
130
198
  end
131
199
 
132
200
  def validate_orchestrate_conflicts!
133
- raise WorkflowError, "transition cannot declare both orchestrate and execute" if @agent_name && !@router_config
134
- raise WorkflowError, "transition cannot declare both orchestrate and route" if @router_config
135
- raise WorkflowError, "transition cannot declare both orchestrate and workflow" if @workflow_class
136
- raise WorkflowError, "transition cannot declare both orchestrate and optimize" if @optimization_config
137
- raise WorkflowError, "transition cannot declare both orchestrate and compute/run" if @deterministic_block
201
+ validate_conflicts!(
202
+ "orchestrate",
203
+ [
204
+ ["execute", @agent_name && !@router_config],
205
+ ["route", @router_config],
206
+ ["workflow", @workflow_class],
207
+ ["optimize", @optimization_config],
208
+ ["compute/run", @deterministic_block],
209
+ ["fan_out", @fanout_config]
210
+ ]
211
+ )
212
+ end
213
+
214
+ def validate_fanout_conflicts!
215
+ validate_conflicts!(
216
+ "fan_out",
217
+ [
218
+ ["execute", @agent_name && !@router_config],
219
+ ["route", @router_config],
220
+ ["workflow", @workflow_class],
221
+ ["optimize", @optimization_config],
222
+ ["orchestrate", @orchestrator_config],
223
+ ["compute/run", @deterministic_block]
224
+ ]
225
+ )
226
+ end
227
+
228
+ def validate_conflicts!(primitive, conflicts)
229
+ conflicts.each do |other, present|
230
+ raise WorkflowError, "transition cannot declare both #{primitive} and #{other}" if present
231
+ end
232
+ end
233
+
234
+ def normalize_fanout_branches!(branches)
235
+ raise WorkflowError, "fan_out branches must be a Hash" unless branches.is_a?(Hash)
236
+ raise WorkflowError, "fan_out requires at least one branch" if branches.empty?
237
+
238
+ normalized = branches.each_with_object({}) do |(branch_key, agent_name), map|
239
+ key = normalize_fanout_branch_key!(branch_key)
240
+ agent = normalize_fanout_agent_name!(agent_name, key)
241
+ raise WorkflowError, "fan_out branch #{key.inspect} is duplicated" if map.key?(key)
242
+
243
+ map[key] = agent
244
+ end
245
+
246
+ validate_distinct_fanout_agents!(normalized)
247
+ normalized.freeze
248
+ end
249
+
250
+ def normalize_agent_name!(agent_name, label)
251
+ value = agent_name.to_s.strip
252
+ raise WorkflowError, "#{label} agent must not be blank" if value.empty?
253
+
254
+ value.to_sym
255
+ end
256
+
257
+ def normalize_router_routes!(routes)
258
+ raise WorkflowError, "router routes must be a Hash" unless routes.is_a?(Hash)
259
+ raise WorkflowError, "router routes must not be empty" if routes.empty?
260
+
261
+ routes.each_with_object({}) do |(route_key, transition_name), map|
262
+ key = normalize_router_route_key!(route_key)
263
+ raise WorkflowError, "router route :#{key} is duplicated" if map.key?(key)
264
+
265
+ map[key] = normalize_transition_reference!(transition_name, "router route :#{key}")
266
+ end.freeze
267
+ end
268
+
269
+ def normalize_router_route_key!(route_key)
270
+ value = route_key.to_s.strip
271
+ raise WorkflowError, "router route keys must not be blank" if value.empty?
272
+
273
+ value.to_sym
274
+ end
275
+
276
+ def normalize_confidence_threshold!(threshold)
277
+ numeric = Float(threshold)
278
+ return numeric if numeric >= 0.0 && numeric <= 1.0
279
+
280
+ raise WorkflowError, "router confidence_threshold must be a number in 0.0..1.0"
281
+ rescue TypeError, ArgumentError
282
+ raise WorkflowError, "router confidence_threshold must be a number in 0.0..1.0"
283
+ end
284
+
285
+ def normalize_transition_reference!(transition_name, label)
286
+ case transition_name
287
+ when Symbol
288
+ raise WorkflowError, "#{label} transition must not be blank" if transition_name.to_s.empty?
289
+
290
+ transition_name
291
+ when String
292
+ value = transition_name.strip
293
+ raise WorkflowError, "#{label} transition must not be blank" if value.empty?
294
+
295
+ value.freeze
296
+ else
297
+ raise WorkflowError, "#{label} transition must be String or Symbol"
298
+ end
299
+ end
300
+
301
+ def normalize_fanout_branch_key!(branch_key)
302
+ key = branch_key.to_s.strip
303
+ raise WorkflowError, "fan_out branch keys must not be blank" if key.empty?
304
+
305
+ key.to_sym
306
+ end
307
+
308
+ def normalize_fanout_agent_name!(agent_name, branch_key)
309
+ value = agent_name.to_s.strip
310
+ raise WorkflowError, "fan_out branch #{branch_key.inspect} must declare an agent" if value.empty?
311
+
312
+ value.to_sym
313
+ end
314
+
315
+ def validate_distinct_fanout_agents!(branches)
316
+ duplicates = branches.values.tally.select { |_agent, count| count > 1 }.keys
317
+ return if duplicates.empty?
318
+
319
+ raise WorkflowError, "fan_out branch agents must be distinct: #{duplicates.map(&:inspect).join(", ")}"
320
+ end
321
+
322
+ def validate_retry_controls!(error_classes, attempts:, backoff:, max_delay:, jitter:)
323
+ unless attempts.is_a?(Integer) && attempts.positive?
324
+ raise WorkflowError, "retry_on attempts must be a positive integer"
325
+ end
326
+
327
+ error_classes.each do |error_class|
328
+ next if error_class.is_a?(Class) && error_class <= StandardError
329
+
330
+ raise WorkflowError, "retry_on error classes must inherit from StandardError"
331
+ end
332
+
333
+ validate_non_negative_numeric!(:backoff, backoff)
334
+ validate_non_negative_numeric!(:jitter, jitter)
335
+ validate_non_negative_numeric!(:max_delay, max_delay) unless max_delay.nil?
336
+ end
337
+
338
+ def normalize_deterministic_routes!(routes)
339
+ return nil if routes.nil?
340
+ raise WorkflowError, "deterministic routes must be an Array" unless routes.is_a?(Array)
341
+ raise WorkflowError, "deterministic routes must not be empty" if routes.empty?
342
+
343
+ routes.each_with_object([]) do |route, list|
344
+ name = normalize_deterministic_route!(route)
345
+ raise WorkflowError, "deterministic route #{name.inspect} is duplicated" if list.include?(name)
346
+
347
+ list << name
348
+ end.freeze
349
+ end
350
+
351
+ def normalize_deterministic_route!(route)
352
+ case route
353
+ when Symbol
354
+ raise WorkflowError, "deterministic route names must not be blank" if route.to_s.empty?
355
+
356
+ route
357
+ when String
358
+ value = route.strip
359
+ raise WorkflowError, "deterministic route names must not be blank" if value.empty?
360
+
361
+ value.freeze
362
+ else
363
+ raise WorkflowError, "deterministic route names must be String or Symbol"
364
+ end
365
+ end
366
+
367
+ def validate_non_negative_numeric!(name, value)
368
+ numeric = Float(value)
369
+ return if numeric >= 0.0
370
+
371
+ raise WorkflowError, "retry_on #{name} must be non-negative"
372
+ rescue TypeError, ArgumentError
373
+ raise WorkflowError, "retry_on #{name} must be numeric"
138
374
  end
139
375
 
140
376
  def validate_orchestrate_controls!(opts)
@@ -177,7 +413,7 @@ module Smith
177
413
  raise WorkflowError, "optimize max_rounds must be a positive integer"
178
414
  end
179
415
 
180
- VALID_EXIT_MODES = [:raise, :return_last].freeze
416
+ VALID_EXIT_MODES = %i[raise return_last].freeze
181
417
  private_constant :VALID_EXIT_MODES
182
418
 
183
419
  def validate_optimize_exit_modes!(on_exhaustion:, on_converged:, on_threshold:)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # One row per agent provider call. `usage_id` is a UUID generated at
6
+ # recording time and stable across persist/restore so hosts can use it as an
7
+ # idempotency anchor.
8
+ # rubocop:disable Style/RedundantStructKeywordInit
9
+ UsageEntry = Struct.new(
10
+ :usage_id,
11
+ :agent_name,
12
+ :model,
13
+ :input_tokens,
14
+ :output_tokens,
15
+ :cost,
16
+ :attempt_kind,
17
+ :recorded_at,
18
+ keyword_init: true
19
+ ) do
20
+ def self.from_h(hash)
21
+ sym = hash.transform_keys(&:to_sym)
22
+ filtered = sym.slice(*members)
23
+ filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
24
+ filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
25
+ new(**filtered)
26
+ end
27
+ end
28
+ # rubocop:enable Style/RedundantStructKeywordInit
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Smith
6
+ class Workflow
7
+ WorkerExecution = Struct.new(:execution_id, :task, :output) do
8
+ def self.run(worker_class, task, schema, budget_runner)
9
+ new(SecureRandom.uuid, task, budget_runner.call(worker_class, task, schema))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -6,6 +6,11 @@ require "securerandom"
6
6
  require "set"
7
7
  require "time"
8
8
 
9
+ require_relative "workflow/agent_result"
10
+ require_relative "workflow/branch_env"
11
+ require_relative "workflow/run_result"
12
+ require_relative "workflow/usage_entry"
13
+
9
14
  module Smith
10
15
  class Workflow
11
16
  include DSL
@@ -21,113 +26,6 @@ module Smith
21
26
 
22
27
  DEFAULT_MAX_TRANSITIONS = 100
23
28
 
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
29
  # Reconstruct Smith error classes from `@last_failed_step` snapshots.
132
30
  # Order matters: more-specific subclasses first, so a real DSF doesn't
133
31
  # get caught by the WorkflowError handler. Each lambda preserves the
@@ -167,24 +65,6 @@ module Smith
167
65
  RETRYABLE_BEARING_FAMILIES = %w[deterministic_step_failure tool_guardrail_failed].freeze
168
66
  private_constant :RETRYABLE_BEARING_FAMILIES
169
67
 
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
68
  attr_reader :state, :last_prepared_input, :session_messages, :ledger
189
69
 
190
70
  def initialize(context: {}, ledger: nil, created_at: nil)
@@ -239,12 +119,13 @@ module Smith
239
119
  end
240
120
 
241
121
  def advance!
242
- max = self.class.max_transitions || DEFAULT_MAX_TRANSITIONS
243
- raise MaxTransitionsExceeded if @step_count >= max
122
+ ensure_transition_budget!
123
+ @step_work_started = false
244
124
 
245
125
  transition = resolve_transition
246
126
  return if transition.nil?
247
127
 
128
+ @step_work_started = true
248
129
  step_result = execute_step(transition)
249
130
  @step_count += 1
250
131
  @updated_at = Time.now.utc.iso8601
@@ -274,11 +155,11 @@ module Smith
274
155
  end
275
156
 
276
157
  def done?
277
- @state == :done
158
+ state_named?(:done)
278
159
  end
279
160
 
280
161
  def failed?
281
- @state == :failed
162
+ state_named?(:failed)
282
163
  end
283
164
 
284
165
  def record_persisted_key!(key)
@@ -360,17 +241,40 @@ module Smith
360
241
  true
361
242
  end
362
243
 
244
+ def ensure_transition_budget!
245
+ max = self.class.max_transitions || DEFAULT_MAX_TRANSITIONS
246
+ raise MaxTransitionsExceeded if @step_count >= max
247
+ end
248
+
363
249
  def resolve_transition
364
250
  if @next_transition_name
365
251
  name = @next_transition_name
366
252
  @next_transition_name = nil
367
- self.class.find_transition(name) ||
253
+ transition = self.class.find_transition(name) ||
368
254
  raise(UnresolvedTransitionError.new(name, self.class, @state))
255
+ validate_transition_origin!(transition)
256
+ transition
369
257
  else
370
258
  self.class.transitions_from(@state).first
371
259
  end
372
260
  end
373
261
 
262
+ def validate_transition_origin!(transition)
263
+ expected = transition.from
264
+ return if expected.nil? || expected == @state
265
+
266
+ raise WorkflowError,
267
+ "transition :#{transition.name} cannot run from state :#{@state}; expected :#{expected}"
268
+ end
269
+
270
+ def step_work_started?
271
+ @step_work_started == true
272
+ end
273
+
274
+ def state_named?(name)
275
+ @state == name || @state.to_s == name.to_s
276
+ end
277
+
374
278
  def build_run_result(steps)
375
279
  # `output` derivation matches existing semantics on fresh runs
376
280
  # (last non-nil step output via `compact.first`). Terminal-restore
@@ -416,8 +320,8 @@ module Smith
416
320
  # Skip const_get for retryable-bearing families. An unknown
417
321
  # subclass with a message-only constructor would const_get
418
322
  # 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.
323
+ # `details` (defaults to nil), and host retry classification
324
+ # could misclassify a retryable failure as terminal.
421
325
  # Family fallback rebuilds the parent class with kwargs intact.
422
326
  family_fallback(snap)
423
327
  else
@@ -436,9 +340,9 @@ module Smith
436
340
  # symbols; JSON round-trip stringifies them; coerce back to
437
341
  # match fresh-run shape exactly.
438
342
  {
439
- transition: snap[:transition]&.to_sym,
440
- from: snap[:from]&.to_sym,
441
- to: snap[:to]&.to_sym,
343
+ transition: normalize_transition_name(snap[:transition]),
344
+ from: normalize_state_name(snap[:from]),
345
+ to: normalize_state_name(snap[:to]),
442
346
  error: error
443
347
  }
444
348
  end
@@ -519,7 +423,7 @@ module Smith
519
423
  # `Struct#dup` is shallow — it shares mutable string fields between
520
424
  # the original and the duplicate. Smith's existing snapshot helpers
521
425
  # (`snapshot_context`, etc.) also use this round-trip pattern; the
522
- # billing-facing RunResult must not alias mutable workflow state.
426
+ # usage-facing RunResult must not alias mutable workflow state.
523
427
  # Same rule applies to nested-workflow rollup (see
524
428
  # `nested_execution.rb`).
525
429
  def snapshot_usage_entries
data/lib/smith.rb CHANGED
@@ -224,12 +224,20 @@ require_relative "smith/context/session"
224
224
  # Agent (depends on RubyLLM::Agent)
225
225
  require_relative "smith/agent"
226
226
  require_relative "smith/agent/lifecycle"
227
+ require_relative "smith/agent/registry_binding"
227
228
  require_relative "smith/agent/registry"
228
229
 
229
230
  # Workflow (Transition, DSL, Persistence, and Execution must load before Workflow)
230
231
  require_relative "smith/workflow/transition"
231
232
  require_relative "smith/workflow/graph"
232
233
  require_relative "smith/workflow/graph_dsl"
234
+ require_relative "smith/workflow/run_result"
235
+ require_relative "smith/workflow/agent_result"
236
+ require_relative "smith/workflow/usage_entry"
237
+ require_relative "smith/workflow/branch_env"
238
+ require_relative "smith/workflow/optimization_state"
239
+ require_relative "smith/workflow/orchestration_state"
240
+ require_relative "smith/workflow/worker_execution"
233
241
  require_relative "smith/workflow/dsl"
234
242
  require_relative "smith/workflow/persistence"
235
243
  require_relative "smith/workflow/durability"
@@ -243,6 +251,8 @@ require_relative "smith/workflow/nested_execution"
243
251
  require_relative "smith/workflow/evaluator_optimizer"
244
252
  require_relative "smith/workflow/orchestrator_worker"
245
253
  require_relative "smith/workflow/parallel_execution"
254
+ require_relative "smith/workflow/fanout_execution"
255
+ require_relative "smith/workflow/retry_execution"
246
256
  require_relative "smith/workflow/deterministic_step"
247
257
  require_relative "smith/workflow/deterministic_execution"
248
258
  require_relative "smith/workflow/execution"
@@ -252,6 +262,8 @@ require_relative "smith/workflow/execution_frame"
252
262
  require_relative "smith/workflow/pipeline"
253
263
  require_relative "smith/workflow/router"
254
264
  require_relative "smith/workflow/parallel"
265
+ require_relative "smith/workflow/parallel/cancellation_signal"
266
+ require_relative "smith/workflow/parallel/cancellation"
255
267
 
256
268
  # Conditional Rails integration
257
269
  require_relative "smith/railtie" if defined?(Rails::Railtie)