smith-agents 0.4.5 → 0.5.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -0
  3. data/README.md +108 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +24 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +6 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  46. data/lib/smith/workflow/definition_identity.rb +25 -0
  47. data/lib/smith/workflow/deterministic_step.rb +2 -2
  48. data/lib/smith/workflow/dsl.rb +73 -5
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  51. data/lib/smith/workflow/execution.rb +16 -18
  52. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  53. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  54. data/lib/smith/workflow/fanout_execution.rb +1 -1
  55. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  56. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  57. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  58. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  59. data/lib/smith/workflow/graph/metrics.rb +1 -3
  60. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  61. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  62. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  63. data/lib/smith/workflow/graph/reachability.rb +19 -31
  64. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  65. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  66. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  67. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  68. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  69. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  70. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  71. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  72. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  73. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  74. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  75. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  76. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  77. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  78. data/lib/smith/workflow/graph/validator.rb +1 -1
  79. data/lib/smith/workflow/graph.rb +62 -4
  80. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  81. data/lib/smith/workflow/identifier.rb +24 -0
  82. data/lib/smith/workflow/message_admission.rb +40 -0
  83. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  84. data/lib/smith/workflow/message_batch.rb +60 -0
  85. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  86. data/lib/smith/workflow/nested_execution.rb +10 -0
  87. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  88. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  89. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  90. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  91. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  92. data/lib/smith/workflow/parallel.rb +19 -22
  93. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  94. data/lib/smith/workflow/parallel_execution.rb +7 -3
  95. data/lib/smith/workflow/persistence.rb +37 -8
  96. data/lib/smith/workflow/prepared_step.rb +122 -0
  97. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  98. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  99. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  100. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  101. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  102. data/lib/smith/workflow/retry_execution.rb +20 -11
  103. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  104. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  105. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  106. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  107. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  108. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  109. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  110. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  111. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  112. data/lib/smith/workflow/split_step_persistence/execution.rb +49 -47
  113. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  114. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  115. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  116. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  117. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  118. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  119. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  120. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  121. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  122. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  123. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  124. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  125. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  126. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  127. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  128. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  129. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  130. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +50 -0
  131. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  132. data/lib/smith/workflow/split_step_persistence.rb +36 -0
  133. data/lib/smith/workflow/step_completion.rb +31 -0
  134. data/lib/smith/workflow/string_snapshot.rb +25 -0
  135. data/lib/smith/workflow/transition.rb +122 -133
  136. data/lib/smith/workflow/transition_actionability.rb +22 -0
  137. data/lib/smith/workflow.rb +44 -18
  138. data/lib/smith.rb +41 -2
  139. data/sig/smith.rbs +1 -0
  140. metadata +110 -19
data/lib/smith/version.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.4.5"
4
+ VERSION = "0.5.0"
5
+ EXECUTION_SEMANTICS_VERSION = "1"
5
6
  end
@@ -4,7 +4,7 @@ module Smith
4
4
  class Workflow
5
5
  # rubocop:disable Style/RedundantStructKeywordInit
6
6
  BranchEnv = Struct.new(
7
- :prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
7
+ :prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline, :agent_class,
8
8
  keyword_init: true
9
9
  ) do
10
10
  def setup_thread
@@ -15,8 +15,7 @@ module Smith
15
15
  def reserve_branch_budget(ledger, branch_estimates:)
16
16
  return nil unless ledger && branch_estimates
17
17
 
18
- branch_estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
19
- branch_estimates
18
+ reserve_estimates!(ledger, branch_estimates)
20
19
  end
21
20
 
22
21
  def compute_branch_estimates(ledger, branch_count:, agent_budget: nil)
@@ -29,13 +28,16 @@ module Smith
29
28
  end
30
29
  end
31
30
 
32
- def reconcile_branch_budget(ledger, estimates, agent_result: nil)
33
- return unless ledger && estimates
31
+ def reconcile_branch_budget(ledger, reservation, agent_result: nil)
32
+ return unless ledger && reservation
34
33
 
35
34
  actuals = extract_actuals(agent_results_for_settlement(agent_result))
36
- estimates.each do |dim, amt|
37
- ledger.reconcile!(dim, amt, actual_for_dimension(dim, actuals[:tokens], actuals[:cost]))
35
+ settlement = reservation.amounts.to_h do |dimension, _amount|
36
+ [dimension, actual_for_dimension(dimension, actuals[:tokens], actuals[:cost])]
38
37
  end
38
+ return ledger.reconcile_many!(reservation, actual: settlement) if settlement.length > 1
39
+
40
+ ledger.reconcile!(reservation, settlement.values.first)
39
41
  end
40
42
 
41
43
  def extract_actuals(agent_results)
@@ -66,10 +68,12 @@ module Smith
66
68
  0
67
69
  end
68
70
 
69
- def release_branch_budget(ledger, estimates)
70
- return unless ledger && estimates
71
+ def release_branch_budget(ledger, reservation)
72
+ return unless ledger && reservation
73
+
74
+ return ledger.release_many!(reservation) if reservation.amounts.length > 1
71
75
 
72
- estimates.each { |dim, amount| ledger.release!(dim, amount) }
76
+ ledger.release!(reservation)
73
77
  end
74
78
 
75
79
  def settle_budget_on_failure(ledger, estimates, agent_result)
@@ -91,8 +95,14 @@ module Smith
91
95
  est[dim] = cap ? [remaining, cap].min : remaining
92
96
  end
93
97
 
94
- estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
95
- estimates
98
+ reserve_estimates!(ledger, estimates)
99
+ end
100
+
101
+ def reserve_estimates!(ledger, estimates)
102
+ return ledger.reserve_many!(estimates) if estimates.length > 1
103
+
104
+ dimension, amount = estimates.first
105
+ ledger.reserve!(dimension, amount)
96
106
  end
97
107
 
98
108
  def finalize_branch(transition, index, result, ledger, reserved)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module DefinitionIdentity
6
+ module ClassMethods
7
+ def inherited(subclass)
8
+ super
9
+ digest = definition_identity_mutex.synchronize { @definition_digest }
10
+ subclass.instance_variable_set(:@definition_digest, digest)
11
+ subclass.instance_variable_set(:@definition_identity_mutex, Mutex.new)
12
+ subclass.instance_variable_set(:@definition_identity_sealed, false)
13
+ end
14
+
15
+ def definition_digest(value = nil)
16
+ return definition_identity_mutex.synchronize { @definition_digest } if value.nil?
17
+
18
+ unless Smith::Types::Sha256Hex.valid?(value)
19
+ raise ArgumentError, "definition_digest must be a lowercase SHA-256 hex digest"
20
+ end
21
+
22
+ digest = value.dup.freeze
23
+ definition_identity_mutex.synchronize do
24
+ return @definition_digest if @definition_digest == digest
25
+ if @definition_identity_sealed || frozen?
26
+ raise ArgumentError, "definition_digest is sealed for this workflow class"
27
+ end
28
+
29
+ @definition_digest = digest
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def seal_definition_identity!
36
+ definition_identity_mutex.synchronize do
37
+ @definition_identity_sealed = true unless frozen?
38
+ @definition_digest
39
+ end
40
+ end
41
+
42
+ def definition_identity_mutex
43
+ @definition_identity_mutex ||= Mutex.new
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition_identity/class_methods"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module DefinitionIdentity
8
+ def self.included(base)
9
+ base.instance_variable_set(:@definition_identity_mutex, Mutex.new)
10
+ base.instance_variable_set(:@definition_identity_sealed, false)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ private
15
+
16
+ def effective_definition_digest
17
+ if instance_variable_defined?(:@split_step_definition_digest)
18
+ @split_step_definition_digest
19
+ else
20
+ self.class.definition_digest
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -23,8 +23,8 @@ module Smith
23
23
  def last_output
24
24
  return @last_output if defined?(@last_output)
25
25
 
26
- msg = session_messages.reverse.find { |m| m[:role] == :assistant || m[:role] == "assistant" }
27
- @last_output = msg&.dig(:content)
26
+ msg = session_messages.reverse.find { |m| m[:role] == :assistant || m["role"] == "assistant" }
27
+ @last_output = msg && (msg[:content] || msg["content"])
28
28
  end
29
29
 
30
30
  alias_method :output, :last_output
@@ -12,6 +12,12 @@ module Smith
12
12
  super
13
13
  subclass.instance_variable_set(:@states, (@states || []).dup)
14
14
  subclass.instance_variable_set(:@transitions, (@transitions || {}).dup)
15
+ subclass.instance_variable_set(
16
+ :@transitions_by_state,
17
+ duplicate_transition_index(@transitions_by_state)
18
+ )
19
+ subclass.instance_variable_set(:@transition_order, (@transition_order || {}).dup)
20
+ subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
15
21
  subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
16
22
  subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
17
23
  subclass.instance_variable_set(:@max_transitions_count, @max_transitions_count)
@@ -29,19 +35,24 @@ module Smith
29
35
  def initial_state(name = nil)
30
36
  return @initial_state_name if name.nil?
31
37
 
32
- @initial_state_name = name
33
- state(name)
38
+ @initial_state_name = own_identifier(name)
39
+ state(@initial_state_name)
34
40
  end
35
41
 
36
42
  def state(name)
43
+ name = own_identifier(name)
37
44
  @states ||= []
38
45
  @states << name unless @states.include?(name)
39
46
  generate_fail_transition if name == :failed
40
47
  end
41
48
 
42
49
  def transition(name, from:, to:, &)
50
+ declared = Transition.new(name, from: from, to: to, &)
51
+ name = declared.name
43
52
  @transitions ||= {}
44
- @transitions[name] = Transition.new(name, from: from, to: to, &)
53
+ remove_from_transition_index(@transitions[name]) if @transitions.key?(name)
54
+ @transitions[name] = declared
55
+ insert_into_transition_index(declared)
45
56
  end
46
57
 
47
58
  def budget(**opts)
@@ -196,13 +207,25 @@ module Smith
196
207
  end
197
208
 
198
209
  def transitions_from(state)
199
- (@transitions || {}).values.select { |t| t.from == state }
210
+ transitions_by_state.fetch(state, []).dup
211
+ end
212
+
213
+ def first_transition_from(state)
214
+ transitions_by_state.fetch(state, []).first
215
+ end
216
+
217
+ def transition_from?(state)
218
+ transitions_by_state.fetch(state, []).any?
200
219
  end
201
220
 
202
221
  def find_transition(name)
203
222
  (@transitions || {})[name]
204
223
  end
205
224
 
225
+ def transition_at(index)
226
+ (@transitions || {}).values.fetch(index)
227
+ end
228
+
206
229
  def from_state(hash)
207
230
  workflow = allocate
208
231
  workflow.send(:restore_state, hash)
@@ -211,11 +234,56 @@ module Smith
211
234
 
212
235
  private
213
236
 
237
+ def own_identifier(identifier)
238
+ Identifier.normalize(identifier, label: "workflow identifier")
239
+ end
240
+
241
+ def transitions_by_state
242
+ @transitions_by_state ||= Hash.new { |index, state| index[state] = [] }
243
+ end
244
+
245
+ def transition_order
246
+ @transition_order ||= {}
247
+ end
248
+
249
+ def transition_sequence
250
+ @transition_sequence ||= 0
251
+ end
252
+
253
+ def insert_into_transition_index(transition)
254
+ order = transition_order_for(transition.name)
255
+ indexed = transitions_by_state[transition.from]
256
+ insertion = indexed.bsearch_index { |candidate| transition_order.fetch(candidate.name) > order }
257
+ indexed.insert(insertion || indexed.length, transition)
258
+ end
259
+
260
+ def transition_order_for(name)
261
+ return transition_order.fetch(name) if transition_order.key?(name)
262
+
263
+ transition_order[name] = transition_sequence
264
+ @transition_sequence = transition_sequence + 1
265
+ transition_order.fetch(name)
266
+ end
267
+
268
+ def duplicate_transition_index(source)
269
+ source.to_h.each_with_object(Hash.new { |index, state| index[state] = [] }) do |(state, transitions), copy|
270
+ copy[state] = transitions.dup
271
+ end
272
+ end
273
+
274
+ def remove_from_transition_index(transition)
275
+ return unless transition
276
+
277
+ indexed = transitions_by_state[transition.from]
278
+ indexed.delete(transition)
279
+ transitions_by_state.delete(transition.from) if indexed.empty?
280
+ end
281
+
214
282
  def generate_fail_transition
215
283
  @transitions ||= {}
216
284
  return if @transitions.key?(:fail)
217
285
 
218
- @transitions[:fail] = Transition.new(:fail, from: nil, to: :failed)
286
+ transition(:fail, from: nil, to: :failed)
219
287
  end
220
288
  end
221
289
  end
@@ -164,7 +164,7 @@ module Smith
164
164
 
165
165
  klass = Object.const_get(class_name)
166
166
  state = state_name_for_payload(klass, state_name)
167
- klass.transitions_from(state).empty? && next_transition.nil?
167
+ !klass.transition_from?(state) && next_transition.nil?
168
168
  rescue JSON::ParserError, NameError, NoMethodError
169
169
  false
170
170
  end
@@ -12,13 +12,13 @@ module Smith
12
12
 
13
13
  def execute_optimization_step(transition, prepared_input: nil)
14
14
  state = OptimizationState.new(transition.optimization_config, prepared_input)
15
- state.generator_class = Agent::Registry.fetch!(
15
+ state.generator_class = resolve_registered_agent!(
16
16
  state.config[:generator],
17
17
  workflow_class: self.class,
18
18
  transition_name: transition.name,
19
19
  role: :generator
20
20
  )
21
- state.evaluator_class = Agent::Registry.fetch!(
21
+ state.evaluator_class = resolve_registered_agent!(
22
22
  state.config[:evaluator],
23
23
  workflow_class: self.class,
24
24
  transition_name: transition.name,
@@ -143,28 +143,24 @@ module Smith
143
143
  end
144
144
 
145
145
  def invoke_with_evaluator_schema(evaluator_class, schema, input)
146
- original_schema = evaluator_class.output_schema
147
- evaluator_class.output_schema(schema)
148
- invoke_agent_with_budget(evaluator_class, input)
149
- ensure
150
- evaluator_class.output_schema(original_schema)
146
+ invoke_agent_with_budget(evaluator_class, input, output_schema: schema)
151
147
  end
152
148
 
153
- def invoke_agent_with_budget(agent_class, prepared_input)
149
+ def invoke_agent_with_budget(agent_class, prepared_input, output_schema: agent_class.output_schema)
154
150
  Thread.current[:smith_last_agent_result] = nil
155
151
  clear_failed_billable_attempts
156
152
  with_agent_context(agent_class) do
157
- invoke_with_call_ledger(agent_class, prepared_input)
153
+ invoke_with_call_ledger(agent_class, prepared_input, output_schema:)
158
154
  end
159
155
  ensure
160
156
  clear_failed_billable_attempts
161
157
  end
162
158
 
163
- def invoke_with_call_ledger(agent_class, prepared_input)
159
+ def invoke_with_call_ledger(agent_class, prepared_input, output_schema:)
164
160
  ledger = effective_call_ledger
165
161
  reserved = reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
166
162
  begin
167
- result = invoke_agent(agent_class, prepared_input)
163
+ result = invoke_agent(agent_class, prepared_input, output_schema:)
168
164
  agent_result = result.is_a?(AgentResult) ? result : nil
169
165
  reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
170
166
  reserved = nil
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "agent_result"
4
+ require_relative "execution_binding_resolution"
5
+ require_relative "step_completion"
4
6
 
5
7
  module Smith
6
8
  class Workflow
7
9
  module Execution
10
+ include ExecutionBindingResolution
11
+ include StepCompletion
8
12
  include Agent::Lifecycle
9
13
  include NestedExecution
10
14
  include EvaluatorOptimizer
@@ -19,11 +23,10 @@ module Smith
19
23
  def execute_step(transition)
20
24
  setup_step_context
21
25
  output = with_scoped_artifacts { run_with_retry_policy(transition) }
22
- complete_step(transition, output)
26
+ StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
23
27
  rescue StandardError => e
24
28
  @outcome = nil
25
- handle_step_failure(transition, e)
26
- { transition: transition.name, from: transition.from, to: transition.to, error: e }
29
+ GuardrailIntegration.instance_method(:handle_step_failure).bind_call(self, transition, e)
27
30
  ensure
28
31
  teardown_step_context
29
32
  end
@@ -43,6 +46,7 @@ module Smith
43
46
  end
44
47
 
45
48
  def run_guarded_step(transition)
49
+ @resolved_parallel_branch_count = preflight_branch_count(transition)
46
50
  return dispatch_step(transition) if transition.deterministic?
47
51
  return run_guarded_fanout_step(transition) if transition.fanout?
48
52
 
@@ -58,22 +62,14 @@ module Smith
58
62
  validate_data_volume!(output, agent_class)
59
63
  run_output_guardrails(output, agent_class)
60
64
  resolve_router_output(transition, output)
65
+ ensure
66
+ @resolved_parallel_branch_count = nil
61
67
  end
62
68
 
63
- def complete_step(transition, output)
64
- @state = transition.to
65
- @next_transition_name = @router_next_transition || transition.success_transition
66
- @router_next_transition = nil
67
- append_accepted_output(output)
68
- emit_step_completed(transition, output)
69
- { transition: transition.name, from: transition.from, to: transition.to, output: output }
70
- end
71
-
72
- def append_accepted_output(output)
73
- return unless @session_messages
74
- return if output.nil?
69
+ def preflight_branch_count(transition)
70
+ return Parallel.validate_branch_count!(transition.fanout_config.fetch(:branches).length) if transition.fanout?
75
71
 
76
- @session_messages << { role: :assistant, content: output }
72
+ Parallel.resolve_branch_count(transition, @context) if transition.parallel?
77
73
  end
78
74
 
79
75
  def resolve_router_output(transition, output)
@@ -120,9 +116,11 @@ module Smith
120
116
  end
121
117
 
122
118
  def resolve_agent_class(transition)
123
- return nil unless transition.agent_name
119
+ parallel_binding = Thread.current[:smith_parallel_agent_binding]
120
+ parallel_agent_class = parallel_binding&.resolve(workflow: self, transition:)
121
+ return parallel_agent_class if parallel_agent_class
124
122
 
125
- Agent::Registry.fetch!(
123
+ transition.agent_name && resolve_registered_agent!(
126
124
  transition.agent_name,
127
125
  workflow_class: self.class,
128
126
  transition_name: transition.name,
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module ExecutionBindingResolution
6
+ private
7
+
8
+ def resolve_registered_agent!(name, workflow_class:, transition_name:, role:)
9
+ authorization = @split_step_active_execution_authorization
10
+ return authorization.fetch_agent!(name, workflow_class:, transition_name:, role:) if authorization
11
+
12
+ Agent::Registry.fetch!(name, workflow_class:, transition_name:, role:)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../errors"
6
+ require_relative "string_snapshot"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class ExecutionResultSnapshot
11
+ MAX_DEPTH = 128
12
+ MAX_NODES = 100_000
13
+ MAX_BYTES = 4 * 1024 * 1024
14
+
15
+ extend Dry::Initializer
16
+
17
+ param :value
18
+ option :freeze_copy, default: proc { true }
19
+ option :preserved_error, default: proc {}
20
+
21
+ def call
22
+ reset
23
+ root = copy_value(value, depth: 0, preserve_error: true)
24
+ drain
25
+ @containers.each(&:freeze) if freeze_copy
26
+ root
27
+ end
28
+
29
+ private
30
+
31
+ def reset
32
+ @bytes = 0
33
+ @nodes = 0
34
+ @copies = {}.compare_by_identity
35
+ @containers = []
36
+ @pending = []
37
+ end
38
+
39
+ def drain
40
+ until @pending.empty?
41
+ source, copy, depth, preserve_error = @pending.pop
42
+ source.is_a?(Hash) ? copy_hash(source, copy, depth, preserve_error) : copy_array(source, copy, depth)
43
+ end
44
+ end
45
+
46
+ def copy_value(item, depth:, preserve_error: false)
47
+ visit!(depth)
48
+ return @copies.fetch(item) if @copies.key?(item)
49
+
50
+ case item
51
+ when Hash then prepare_container(item, {}, depth, preserve_error)
52
+ when Array then prepare_container(item, [], depth, false)
53
+ when String then copy_string(item)
54
+ when Float then copy_float(item)
55
+ when Symbol, Integer, true, false, nil then item
56
+ else
57
+ raise WorkflowError,
58
+ "prepared-step execution result contains unsupported mutable value #{item.class}"
59
+ end
60
+ end
61
+
62
+ def prepare_container(source, copy, depth, preserve_error)
63
+ @copies[source] = copy
64
+ @containers << copy
65
+ @pending << [source, copy, depth, preserve_error]
66
+ copy
67
+ end
68
+
69
+ def copy_hash(source, copy, depth, preserve_error)
70
+ source.each do |key, item|
71
+ copied_key = copy_hash_key(key, depth + 1)
72
+ copied_item = preserved_error?(copied_key, item, preserve_error) ? item : copy_value(item, depth: depth + 1)
73
+ copy[copied_key] = copied_item
74
+ end
75
+ end
76
+
77
+ def copy_hash_key(key, depth)
78
+ unless key.is_a?(String) || key.is_a?(Symbol)
79
+ raise WorkflowError,
80
+ "prepared-step execution result contains unsupported Hash key #{key.class}"
81
+ end
82
+
83
+ copy_value(key, depth: depth)
84
+ end
85
+
86
+ def copy_array(source, copy, depth)
87
+ source.each { |item| copy << copy_value(item, depth: depth + 1) }
88
+ end
89
+
90
+ def preserved_error?(key, item, permitted)
91
+ permitted && key == :error && item.equal?(preserved_error)
92
+ end
93
+
94
+ def copy_string(string)
95
+ @bytes += StringSnapshot.bytesize(string)
96
+ raise WorkflowError, "prepared-step execution result exceeds maximum bytes #{MAX_BYTES}" if
97
+ @bytes > MAX_BYTES
98
+
99
+ StringSnapshot.copy(string, freeze: freeze_copy)
100
+ end
101
+
102
+ def copy_float(float)
103
+ return float if float.finite?
104
+
105
+ raise WorkflowError, "prepared-step execution result contains non-finite Float"
106
+ end
107
+
108
+ def visit!(depth)
109
+ raise WorkflowError, "prepared-step execution result exceeds maximum depth #{MAX_DEPTH}" if depth > MAX_DEPTH
110
+
111
+ @nodes += 1
112
+ return if @nodes <= MAX_NODES
113
+
114
+ raise WorkflowError, "prepared-step execution result exceeds maximum size #{MAX_NODES}"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -109,7 +109,7 @@ module Smith
109
109
  end
110
110
 
111
111
  def resolve_fanout_agent_class(transition, agent_name)
112
- Agent::Registry.fetch!(
112
+ resolve_registered_agent!(
113
113
  agent_name,
114
114
  workflow_class: self.class,
115
115
  transition_name: transition&.name,
@@ -1,21 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagnostic_path"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  class Graph
6
8
  class Diagnostic
7
- attr_reader :severity, :code, :message, :state, :transition, :target, :suggestion
9
+ attr_reader :severity, :code, :state, :transition, :target, :suggestion
8
10
 
9
11
  def initialize(**attributes)
10
12
  @severity = attributes.fetch(:severity)
11
13
  @code = attributes.fetch(:code)
12
14
  @message = attributes.fetch(:message)
15
+ @path = attributes[:path]
13
16
  @state = attributes[:state]
14
17
  @transition = attributes[:transition]
15
18
  @target = attributes[:target]
16
19
  @suggestion = attributes[:suggestion]
17
20
  end
18
21
 
22
+ def message
23
+ return @message unless @path
24
+ return @rendered_message if defined?(@rendered_message)
25
+
26
+ @rendered_message = String.new.tap do |rendered|
27
+ @path.each_label { |label| rendered << "Nested workflow #{label}: " }
28
+ rendered << @message
29
+ end.freeze
30
+ end
31
+
32
+ def nested(label:, code:, transition:, target:)
33
+ self.class.new(
34
+ severity: severity,
35
+ code: code,
36
+ message: @message,
37
+ path: DiagnosticPath.new(label:, tail: @path),
38
+ state: state,
39
+ transition: transition,
40
+ target: target,
41
+ suggestion: suggestion
42
+ )
43
+ end
44
+
19
45
  def to_h
20
46
  {
21
47
  severity: severity,
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class DiagnosticPath
9
+ extend Dry::Initializer
10
+
11
+ option :label
12
+ option :tail, default: proc {}
13
+
14
+ def each_label
15
+ current = self
16
+ while current
17
+ yield current.label
18
+ current = current.tail
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end