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
@@ -36,29 +36,40 @@ module Smith
36
36
  end
37
37
  end
38
38
 
39
- # Walk Smith::Agent::Registry. For each agent, extract the model
40
- # id from chat_kwargs (static `model "..."` form). Block-form
41
- # `model do |ctx| ... end` agents are skipped because their
42
- # model is resolved per-attempt and can't be enumerated at boot.
39
+ # Walk Smith::Agent::Registry. For each agent, extract every static
40
+ # model id Smith can know at boot: the primary `model "..."` value and
41
+ # any static fallback models. Block-form primary models are skipped
42
+ # because they resolve per-attempt, but their static fallbacks still
43
+ # need coverage checks.
43
44
  # Check whether find_or_infer returns a custom (non-default)
44
45
  # Profile — meaning either an explicit override or an inference
45
46
  # rule matched.
46
47
  def uncovered_models
47
48
  return [] unless defined?(Smith::Agent::Registry)
48
49
 
49
- model_ids = []
50
- Smith::Agent::Registry.each do |_key, agent|
51
- next unless agent.is_a?(Class)
52
- next unless agent.respond_to?(:chat_kwargs)
50
+ static_model_ids.uniq.reject { |model_id| covered_model?(model_id) }
51
+ end
53
52
 
54
- id = agent.chat_kwargs[:model]
55
- model_ids << id if id
53
+ def static_model_ids
54
+ Smith::Agent::Registry.each.with_object([]) do |(_key, agent), ids|
55
+ ids.concat(static_model_ids_for(agent)) if inspectable_agent?(agent)
56
56
  end
57
+ end
57
58
 
58
- model_ids.uniq.reject do |model_id|
59
- Smith::Models.find(model_id) ||
60
- (defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
61
- end
59
+ def inspectable_agent?(agent)
60
+ agent.is_a?(Class) && agent.respond_to?(:chat_kwargs)
61
+ end
62
+
63
+ def static_model_ids_for(agent)
64
+ [
65
+ agent.chat_kwargs[:model],
66
+ *(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil)
67
+ ].compact
68
+ end
69
+
70
+ def covered_model?(model_id)
71
+ Smith::Models.find(model_id) ||
72
+ (defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
62
73
  end
63
74
  end
64
75
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../persistence_adapters"
4
+
3
5
  module Smith
4
6
  module Doctor
5
7
  module Checks
@@ -12,41 +14,16 @@ module Smith
12
14
  module PersistenceCapabilities
13
15
  module_function
14
16
 
15
- OPTIONAL_CAPABILITIES = %i[store_versioned].freeze
17
+ OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
16
18
 
17
19
  def run(report)
18
20
  adapter = resolve_adapter
19
- if adapter.nil?
20
- report.add(
21
- name: "persistence.capabilities",
22
- status: :warn,
23
- message: "No persistence adapter configured",
24
- detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
25
- "Hosts using durable workflows must set persistence_adapter."
26
- )
27
- return
28
- end
21
+ return report_missing_adapter(report) if adapter.nil?
29
22
 
30
23
  missing = OPTIONAL_CAPABILITIES.reject { |cap| Smith::PersistenceAdapters.supports?(adapter, cap) }
24
+ return report_supported_capabilities(report, adapter) if missing.empty?
31
25
 
32
- if missing.empty?
33
- report.add(
34
- name: "persistence.capabilities",
35
- status: :pass,
36
- message: "#{adapter.class.name} supports all optional persistence capabilities",
37
- detail: "Supported: #{OPTIONAL_CAPABILITIES.join(', ')}"
38
- )
39
- else
40
- report.add(
41
- name: "persistence.capabilities",
42
- status: :warn,
43
- message: "#{adapter.class.name} missing optional capabilities: #{missing.join(', ')}",
44
- detail: "Workflows using these capabilities fall back to non-versioned writes " \
45
- "with a one-time warning per adapter class. Switch to RedisStore, " \
46
- "ActiveRecordStore (with lock_version column), or the Memory adapter " \
47
- "for full coverage."
48
- )
49
- end
26
+ report_missing_capabilities(report, adapter, missing)
50
27
  end
51
28
 
52
29
  def resolve_adapter
@@ -54,6 +31,37 @@ module Smith
54
31
  rescue StandardError
55
32
  nil
56
33
  end
34
+
35
+ def report_missing_adapter(report)
36
+ report.add(
37
+ name: "persistence.capabilities",
38
+ status: :warn,
39
+ message: "No persistence adapter configured",
40
+ detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
41
+ "Hosts using durable workflows must set persistence_adapter."
42
+ )
43
+ end
44
+
45
+ def report_supported_capabilities(report, adapter)
46
+ report.add(
47
+ name: "persistence.capabilities",
48
+ status: :pass,
49
+ message: "#{adapter.class.name} supports all optional persistence capabilities",
50
+ detail: "Supported: #{OPTIONAL_CAPABILITIES.join(", ")}"
51
+ )
52
+ end
53
+
54
+ def report_missing_capabilities(report, adapter, missing)
55
+ report.add(
56
+ name: "persistence.capabilities",
57
+ status: :warn,
58
+ message: "#{adapter.class.name} missing optional capabilities: #{missing.join(", ")}",
59
+ detail: "Smith will fall back where possible: non-versioned writes when store_versioned " \
60
+ "is missing, and payload updated_at parsing when heartbeat methods are missing. " \
61
+ "Use RedisStore or Memory for full versioning and heartbeat coverage; " \
62
+ "ActiveRecordStore currently covers optimistic locking when lock_version is present."
63
+ )
64
+ end
57
65
  end
58
66
  end
59
67
  end
data/lib/smith/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.3"
5
5
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # rubocop:disable Style/RedundantStructKeywordInit
6
+ AgentResult = Struct.new(
7
+ :content, :input_tokens, :output_tokens, :cost, :model_used,
8
+ keyword_init: true
9
+ ) do
10
+ def self.from_response(response, content, model_used: nil)
11
+ new(
12
+ content: content,
13
+ input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
14
+ output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
15
+ cost: nil,
16
+ model_used: model_used
17
+ )
18
+ end
19
+
20
+ def usage_known?
21
+ !input_tokens.nil? && !output_tokens.nil?
22
+ end
23
+ end
24
+ # rubocop:enable Style/RedundantStructKeywordInit
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # rubocop:disable Style/RedundantStructKeywordInit
6
+ BranchEnv = Struct.new(
7
+ :prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
8
+ keyword_init: true
9
+ ) do
10
+ def setup_thread
11
+ Smith::Tool.current_guardrails = guardrail_sources
12
+ Smith::Tool.current_deadline = deadline
13
+ Smith.scoped_artifacts = scoped_store
14
+ end
15
+
16
+ def teardown_thread
17
+ Smith::Tool.current_guardrails = nil
18
+ Smith::Tool.current_deadline = nil
19
+ Smith.scoped_artifacts = nil
20
+ end
21
+ end
22
+ # rubocop:enable Style/RedundantStructKeywordInit
23
+ end
24
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agent_result"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module BudgetIntegration
@@ -30,19 +32,33 @@ module Smith
30
32
  def reconcile_branch_budget(ledger, estimates, agent_result: nil)
31
33
  return unless ledger && estimates
32
34
 
33
- actuals = extract_actuals(agent_result)
35
+ actuals = extract_actuals(agent_results_for_settlement(agent_result))
34
36
  estimates.each do |dim, amt|
35
37
  ledger.reconcile!(dim, amt, actual_for_dimension(dim, actuals[:tokens], actuals[:cost]))
36
38
  end
37
39
  end
38
40
 
39
- def extract_actuals(agent_result)
41
+ def extract_actuals(agent_results)
42
+ results = Array(agent_results).compact
43
+
40
44
  {
41
- tokens: (agent_result&.input_tokens || 0) + (agent_result&.output_tokens || 0),
42
- cost: agent_result&.cost || 0
45
+ tokens: results.sum { |result| (result.input_tokens || 0) + (result.output_tokens || 0) },
46
+ cost: results.sum { |result| result.cost || 0 }
43
47
  }
44
48
  end
45
49
 
50
+ def agent_results_for_settlement(agent_result = nil)
51
+ [*failed_billable_attempts, agent_result].compact
52
+ end
53
+
54
+ def failed_billable_attempts
55
+ Array(Thread.current[:smith_failed_agent_results])
56
+ end
57
+
58
+ def clear_failed_billable_attempts
59
+ Thread.current[:smith_failed_agent_results] = []
60
+ end
61
+
46
62
  def actual_for_dimension(dim, actual_tokens, actual_cost = 0)
47
63
  return actual_tokens if TOKEN_DIMENSIONS.include?(dim)
48
64
  return actual_cost if COST_DIMENSIONS.include?(dim)
@@ -59,7 +75,7 @@ module Smith
59
75
  def settle_budget_on_failure(ledger, estimates, agent_result)
60
76
  return unless ledger && estimates
61
77
 
62
- if agent_result
78
+ if agent_result || failed_billable_attempts.any?
63
79
  reconcile_branch_budget(ledger, estimates, agent_result: agent_result)
64
80
  else
65
81
  release_branch_budget(ledger, estimates)
@@ -85,6 +101,12 @@ module Smith
85
101
  { branch: index, agent: transition.agent_name, output: agent_result ? agent_result.content : result }
86
102
  end
87
103
 
104
+ def finalize_named_branch(branch_key, agent_name, result, ledger, reserved)
105
+ agent_result = result.is_a?(Workflow::AgentResult) ? result : nil
106
+ reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
107
+ { branch: branch_key, agent: agent_name, output: agent_result ? agent_result.content : result }
108
+ end
109
+
88
110
  def estimate_for_dimension(dim, limit, branch_count)
89
111
  return 0 unless BUDGET_DIMENSIONS.include?(dim)
90
112
 
@@ -29,7 +29,7 @@ module Smith
29
29
  session_messages: snapshot_value(@session_messages || []),
30
30
  tool_results: snapshot_value(@tool_results || []),
31
31
  state: @state,
32
- transition_name: transition.name
32
+ transition: transition
33
33
  )
34
34
  end
35
35
 
@@ -4,14 +4,17 @@ module Smith
4
4
  class Workflow
5
5
  class DeterministicStep
6
6
  attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
7
- :context_writes, :routed_to, :outcome
7
+ :context_writes, :routed_to, :outcome, :allowed_routes
8
8
 
9
- def initialize(context:, session_messages:, tool_results:, state:, transition_name:)
9
+ def initialize(context:, session_messages:, tool_results:, state:, **options)
10
+ validate_options!(options)
11
+ transition = options[:transition]
10
12
  @context = context
11
13
  @session_messages = session_messages
12
14
  @tool_results = tool_results
13
15
  @current_state = state
14
- @transition_name = transition_name
16
+ @transition_name = transition ? transition.name : options.fetch(:transition_name)
17
+ @allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
15
18
  @context_writes = {}
16
19
  @routed_to = nil
17
20
  @outcome = nil
@@ -39,7 +42,7 @@ module Smith
39
42
  def route_to(transition_name)
40
43
  raise WorkflowError, "route_to already called with :#{@routed_to}" if @routed_to
41
44
 
42
- @routed_to = transition_name
45
+ @routed_to = route_target_for(transition_name)
43
46
  end
44
47
 
45
48
  def write_outcome(kind:, payload:)
@@ -52,6 +55,31 @@ module Smith
52
55
  def fail!(message, retryable: nil, kind: nil, details: nil)
53
56
  raise DeterministicStepFailure.new(message, retryable: retryable, kind: kind, details: details)
54
57
  end
58
+
59
+ private
60
+
61
+ def validate_options!(options)
62
+ unknown = options.keys - %i[transition transition_name allowed_routes]
63
+ raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
64
+ return if options[:transition] || options.key?(:transition_name)
65
+
66
+ raise ArgumentError, "missing keyword: :transition"
67
+ end
68
+
69
+ def snapshot_allowed_routes(routes)
70
+ return nil if routes.nil?
71
+
72
+ routes.map { |route| route.is_a?(String) ? route.dup.freeze : route }.freeze
73
+ end
74
+
75
+ def route_target_for(transition_name)
76
+ return transition_name if allowed_routes.nil?
77
+
78
+ allowed_route = allowed_routes.find { |route| route == transition_name }
79
+ return allowed_route if allowed_route
80
+
81
+ raise WorkflowError, "route_to #{transition_name.inspect} is not declared in deterministic routes"
82
+ end
55
83
  end
56
84
  end
57
85
  end
@@ -32,11 +32,9 @@ module Smith
32
32
  # `store/fetch/delete`, and this peek piggybacks on `fetch`.
33
33
  # Custom adapters work without changes.
34
34
  #
35
- # Hadithi uses this to skip the credits guard at execution time
36
- # when persisted state already exists for a workflow key (a
37
- # prior attempt's billable work is durable in Redis, OR an
38
- # in-flight workflow is being resumed — either way, no NEW
39
- # credit authorization is needed).
35
+ # Hosts can use this to distinguish a resumed workflow from a
36
+ # brand-new workflow when coordinating external accounting or
37
+ # admission-control checks.
40
38
  def persisted_state_exists?(key: nil, context: {}, adapter: Smith.persistence_adapter)
41
39
  resolved_key = resolved_persistence_key(key:, context:)
42
40
  !fetch_persisted_payload(resolved_key, adapter:).nil?
@@ -51,11 +49,9 @@ module Smith
51
49
  # the top of `run_persisted!` BEFORE the first `advance!`. A
52
50
  # worker that dies between that initial `persist!` and the
53
51
  # first model call leaves a Redis key with no billable work.
54
- # If the credits guard's bypass keys on `persisted_state_exists?`
55
- # alone, a zero-balance user's retry on that abandoned init
56
- # state silently runs the first model call (the guard is
57
- # skipped because state exists, but the state has nothing to
58
- # bill — it's just the workflow's starting state).
52
+ # If external accounting or admission-control checks key on
53
+ # `persisted_state_exists?` alone, a retry on that abandoned
54
+ # init state can be mistaken for a billable/resumable workflow.
59
55
  #
60
56
  # `restorable_billing_state?` returns true only when there's
61
57
  # actual `usage_entries` to bill on idempotent replay. Terminal
@@ -167,11 +163,22 @@ module Smith
167
163
  return false unless state_name && class_name
168
164
 
169
165
  klass = Object.const_get(class_name)
170
- klass.transitions_from(state_name.to_sym).empty? && next_transition.nil?
166
+ state = state_name_for_payload(klass, state_name)
167
+ klass.transitions_from(state).empty? && next_transition.nil?
171
168
  rescue JSON::ParserError, NameError, NoMethodError
172
169
  false
173
170
  end
174
171
 
172
+ def state_name_for_payload(klass, state_name)
173
+ states = klass.instance_variable_get(:@states) || []
174
+ return state_name if states.include?(state_name)
175
+
176
+ symbolized = state_name.to_sym if state_name.respond_to?(:to_sym)
177
+ return symbolized if states.include?(symbolized)
178
+
179
+ state_name
180
+ end
181
+
175
182
  def persistence_adapter!(adapter)
176
183
  return adapter if adapter
177
184
 
@@ -241,12 +248,19 @@ module Smith
241
248
  persist!(resolved_key, adapter:)
242
249
 
243
250
  until terminal?
251
+ ensure_transition_budget!
252
+
244
253
  if strict_idempotency?
245
254
  mark_step_in_progress!
246
255
  persist!(resolved_key, adapter:)
247
256
  end
248
257
 
249
- step = advance!
258
+ begin
259
+ step = advance!
260
+ rescue StandardError
261
+ clear_pre_step_marker!(resolved_key, adapter:)
262
+ raise
263
+ end
250
264
  steps << step if step
251
265
 
252
266
  clear_step_in_progress!
@@ -261,6 +275,7 @@ module Smith
261
275
  return if terminal?
262
276
 
263
277
  resolved_key = resolve_persistence_key!(key)
278
+ ensure_transition_budget!
264
279
  mark_step_in_progress! if strict_idempotency?
265
280
  persist!(resolved_key, adapter:)
266
281
  step = advance!
@@ -268,6 +283,9 @@ module Smith
268
283
  persist!(resolved_key, adapter:) if step
269
284
  invoke_on_step_callback(step, on_step) if step
270
285
  step
286
+ rescue StandardError
287
+ clear_pre_step_marker!(resolved_key, adapter:) if defined?(resolved_key) && resolved_key
288
+ raise
271
289
  end
272
290
 
273
291
  def persist!(key = nil, adapter: Smith.persistence_adapter)
@@ -306,6 +324,14 @@ module Smith
306
324
  self.class.idempotency_mode == :strict
307
325
  end
308
326
 
327
+ def clear_pre_step_marker!(key, adapter:)
328
+ return unless strict_idempotency?
329
+ return if step_work_started?
330
+
331
+ clear_step_in_progress!
332
+ persist!(key, adapter:)
333
+ end
334
+
309
335
  # Forwards the persist payload to the adapter, splatting `ttl:`
310
336
  # only when a TTL is resolved. The empty-Hash splat is a no-op so
311
337
  # external duck-typed adapters that don't accept a `ttl:` kwarg
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
5
+ require_relative "agent_result"
6
+ require_relative "optimization_state"
7
+
3
8
  module Smith
4
9
  class Workflow
5
10
  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
11
  private
15
12
 
16
13
  def execute_optimization_step(transition, prepared_input: nil)
@@ -91,7 +88,7 @@ module Smith
91
88
  when Hash
92
89
  deep_symbolize_evaluation(evaluation)
93
90
  when String
94
- parsed = (JSON.parse(evaluation, symbolize_names: true) rescue nil)
91
+ parsed = parse_evaluation_json(evaluation)
95
92
  parsed.is_a?(Hash) ? parsed : evaluation
96
93
  else
97
94
  evaluation
@@ -155,9 +152,12 @@ module Smith
155
152
 
156
153
  def invoke_agent_with_budget(agent_class, prepared_input)
157
154
  Thread.current[:smith_last_agent_result] = nil
155
+ clear_failed_billable_attempts
158
156
  with_agent_context(agent_class) do
159
157
  invoke_with_call_ledger(agent_class, prepared_input)
160
158
  end
159
+ ensure
160
+ clear_failed_billable_attempts
161
161
  end
162
162
 
163
163
  def invoke_with_call_ledger(agent_class, prepared_input)
@@ -179,12 +179,20 @@ module Smith
179
179
  # routes through on_threshold and returns the resulting value
180
180
  # (non-nil terminates the loop with that as the step output).
181
181
  def check_improvement_threshold!(evaluation, state, round)
182
- return nil unless stop_for_threshold?(evaluation[:score], state.last_score, state.config[:improvement_threshold])
182
+ unless stop_for_threshold?(evaluation[:score], state.last_score, state.config[:improvement_threshold])
183
+ return nil
184
+ end
183
185
 
184
186
  handle_exit(state, :on_threshold,
185
187
  "optimization improvement below threshold after round #{round + 1}")
186
188
  end
187
189
 
190
+ def parse_evaluation_json(evaluation)
191
+ JSON.parse(evaluation, symbolize_names: true)
192
+ rescue JSON::ParserError
193
+ nil
194
+ end
195
+
188
196
  def prepare_generator_input(prepared_input, round, prior_candidate, feedback)
189
197
  return prepared_input if round.zero?
190
198
 
@@ -13,9 +13,9 @@ module Smith
13
13
 
14
14
  Smith::Events.emit(
15
15
  Events::StepCompleted.new(
16
- transition: transition.name,
17
- from: transition.from,
18
- to: transition.to
16
+ transition: transition.name.to_sym,
17
+ from: transition.from&.to_sym,
18
+ to: transition.to.to_sym
19
19
  )
20
20
  )
21
21
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agent_result"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module Execution
@@ -8,13 +10,15 @@ module Smith
8
10
  include EvaluatorOptimizer
9
11
  include OrchestratorWorker
10
12
  include ParallelExecution
13
+ include FanoutExecution
14
+ include RetryExecution
11
15
  include DeterministicExecution
12
16
 
13
17
  private
14
18
 
15
19
  def execute_step(transition)
16
20
  setup_step_context
17
- output = with_scoped_artifacts { run_guarded_step(transition) }
21
+ output = with_scoped_artifacts { run_with_retry_policy(transition) }
18
22
  complete_step(transition, output)
19
23
  rescue StandardError => e
20
24
  @outcome = nil
@@ -40,6 +44,7 @@ module Smith
40
44
 
41
45
  def run_guarded_step(transition)
42
46
  return dispatch_step(transition) if transition.deterministic?
47
+ return run_guarded_fanout_step(transition) if transition.fanout?
43
48
 
44
49
  agent_class = resolve_agent_class(transition)
45
50
  run_input_guardrails(agent_class)
@@ -93,6 +98,7 @@ module Smith
93
98
 
94
99
  def execute_serial_step(transition, prepared_input: nil)
95
100
  Thread.current[:smith_last_agent_result] = nil
101
+ clear_failed_billable_attempts
96
102
  ledger = effective_call_ledger
97
103
  reserved = reserve_for_serial(transition, ledger)
98
104
  begin
@@ -104,6 +110,7 @@ module Smith
104
110
  ensure
105
111
  settle_budget_on_failure(ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
106
112
  Thread.current[:smith_last_agent_result] = nil
113
+ clear_failed_billable_attempts
107
114
  end
108
115
  end
109
116