smith-agents 0.4.5 → 0.6.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -0
  3. data/README.md +191 -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 +37 -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 +10 -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/composite/branch.rb +93 -0
  46. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  47. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  48. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  49. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  50. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  51. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  52. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  53. data/lib/smith/workflow/composite/contract.rb +100 -0
  54. data/lib/smith/workflow/composite/effects.rb +140 -0
  55. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  56. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  57. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  58. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  59. data/lib/smith/workflow/composite/enums.rb +27 -0
  60. data/lib/smith/workflow/composite/error.rb +54 -0
  61. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  62. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  63. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  64. data/lib/smith/workflow/composite/input.rb +53 -0
  65. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  66. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  67. data/lib/smith/workflow/composite/payload.rb +128 -0
  68. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  69. data/lib/smith/workflow/composite/plan.rb +130 -0
  70. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  71. data/lib/smith/workflow/composite/planner.rb +53 -0
  72. data/lib/smith/workflow/composite/preparation.rb +27 -0
  73. data/lib/smith/workflow/composite/reducer.rb +133 -0
  74. data/lib/smith/workflow/composite/reduction.rb +31 -0
  75. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  76. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  77. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  78. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  79. data/lib/smith/workflow/definition_identity.rb +25 -0
  80. data/lib/smith/workflow/deterministic_step.rb +2 -2
  81. data/lib/smith/workflow/dsl.rb +73 -5
  82. data/lib/smith/workflow/durability.rb +1 -1
  83. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  84. data/lib/smith/workflow/execution.rb +28 -30
  85. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  86. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  87. data/lib/smith/workflow/fanout_execution.rb +20 -19
  88. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  89. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  90. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  91. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  92. data/lib/smith/workflow/graph/metrics.rb +1 -3
  93. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  94. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  95. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  96. data/lib/smith/workflow/graph/reachability.rb +19 -31
  97. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  98. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  99. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  100. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  101. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  102. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  103. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  104. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  105. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  106. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  107. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  108. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  109. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  110. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  111. data/lib/smith/workflow/graph/validator.rb +1 -1
  112. data/lib/smith/workflow/graph.rb +62 -4
  113. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  114. data/lib/smith/workflow/identifier.rb +24 -0
  115. data/lib/smith/workflow/message_admission.rb +40 -0
  116. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  117. data/lib/smith/workflow/message_batch.rb +60 -0
  118. data/lib/smith/workflow/message_value_normalizer.rb +134 -0
  119. data/lib/smith/workflow/nested_execution.rb +10 -0
  120. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  121. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  122. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  123. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  124. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  125. data/lib/smith/workflow/parallel.rb +19 -22
  126. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  127. data/lib/smith/workflow/parallel_execution.rb +46 -14
  128. data/lib/smith/workflow/persistence.rb +37 -8
  129. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  130. data/lib/smith/workflow/prepared_step.rb +122 -0
  131. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  132. data/lib/smith/workflow/prepared_step_execution_authorization.rb +101 -0
  133. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  134. data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
  135. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  136. data/lib/smith/workflow/process_local.rb +33 -0
  137. data/lib/smith/workflow/retry_execution.rb +20 -11
  138. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  139. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  140. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  141. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  142. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  143. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  144. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  145. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  146. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  147. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  148. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  149. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  150. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  151. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  152. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  153. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  154. data/lib/smith/workflow/split_step_persistence/execution.rb +20 -64
  155. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
  156. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  157. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
  158. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
  159. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  160. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  161. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
  162. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  163. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  164. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  165. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  166. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  167. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  168. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  169. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  170. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  171. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  172. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  173. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  174. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +66 -0
  175. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  176. data/lib/smith/workflow/split_step_persistence.rb +50 -0
  177. data/lib/smith/workflow/step_completion.rb +31 -0
  178. data/lib/smith/workflow/step_context.rb +46 -0
  179. data/lib/smith/workflow/string_snapshot.rb +25 -0
  180. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  181. data/lib/smith/workflow/transition.rb +137 -133
  182. data/lib/smith/workflow/transition_actionability.rb +22 -0
  183. data/lib/smith/workflow.rb +64 -18
  184. data/lib/smith.rb +44 -2
  185. data/sig/smith.rbs +1 -0
  186. metadata +155 -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.6.0"
5
+ EXECUTION_SEMANTICS_VERSION = "2"
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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../message_value_normalizer"
5
+ require_relative "../prepared_step"
6
+ require_relative "payload"
7
+ require_relative "payload_digest"
8
+
9
+ module Smith
10
+ class Workflow
11
+ module Composite
12
+ class Branch < Payload
13
+ MAX_BUDGET_DIMENSIONS = 128
14
+ HASH_LENGTH = Hash.instance_method(:length)
15
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
16
+ private_constant :HASH_LENGTH, :HASH_EACH_PAIR
17
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
18
+ private_constant :OwnedString
19
+
20
+ attribute :ordinal, Types::Integer.constrained(gteq: 0, lteq: PreparedStep::MAX_COUNTER_VALUE)
21
+ attribute :key, OwnedString.constrained(min_size: 1, max_size: 256)
22
+ attribute :agent, OwnedString.constrained(min_size: 1, max_size: 256)
23
+ attribute :binding_identity, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
24
+ attribute :budget, Types::Hash
25
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
26
+
27
+ def self.build(ordinal:, key:, agent:, binding_identity:, budget:)
28
+ attributes = {
29
+ ordinal:,
30
+ key: key.to_s,
31
+ agent: agent.to_s,
32
+ binding_identity:,
33
+ budget: normalize_budget(budget)
34
+ }
35
+ new(attributes.merge(digest: PayloadDigest.call(attributes)))
36
+ end
37
+
38
+ def initialize(attributes)
39
+ owned = self.class.normalize_attributes(attributes)
40
+ owned[:budget] = self.class.send(:normalize_budget, owned[:budget])
41
+ super(owned)
42
+ expected = PayloadDigest.call(to_h.except(:digest))
43
+ raise ArgumentError, "composite branch digest does not match" unless digest == expected
44
+ end
45
+
46
+ class << self
47
+ private
48
+
49
+ def normalize_budget(value)
50
+ normalized = normalized_budget(value)
51
+ validate_budget_dimensions!(normalized)
52
+ copy_budget(normalized)
53
+ end
54
+
55
+ def normalized_budget(value)
56
+ normalized = MessageValueNormalizer.new(value, label: "composite branch budget").call
57
+ raise ArgumentError, "composite branch budget must be a Hash" unless normalized.is_a?(Hash)
58
+
59
+ normalized
60
+ end
61
+
62
+ def validate_budget_dimensions!(budget)
63
+ return if HASH_LENGTH.bind_call(budget) <= MAX_BUDGET_DIMENSIONS
64
+
65
+ raise ArgumentError, "composite branch budget contains too many dimensions"
66
+ end
67
+
68
+ def copy_budget(normalized)
69
+ budget = {}
70
+ HASH_EACH_PAIR.bind_call(normalized) do |name, amount|
71
+ validate_budget_dimension!(name)
72
+ validate_budget_amount!(amount)
73
+ budget[name.freeze] = amount
74
+ end
75
+ budget.freeze
76
+ end
77
+
78
+ def validate_budget_dimension!(name)
79
+ return if name.length.between?(1, 256)
80
+
81
+ raise ArgumentError, "composite branch budget dimensions must be bounded non-empty strings"
82
+ end
83
+
84
+ def validate_budget_amount!(amount)
85
+ return if amount.is_a?(Numeric) && amount.finite? && amount >= 0
86
+
87
+ raise ArgumentError, "composite branch budget values must be finite non-negative numbers"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "budget_allocator"
4
+ require_relative "plan"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module Composite
9
+ module BranchBudgetContract
10
+ private
11
+
12
+ def validate_composite_branch_count!(count)
13
+ Plan.validate_branch_count!(count)
14
+ end
15
+
16
+ def composite_budget_allocator(branch_count)
17
+ return unless @ledger
18
+
19
+ BudgetAllocator.new(ledger: @ledger, branch_count:)
20
+ end
21
+
22
+ def composite_branch_budget(allocator, ordinal, agent_budget)
23
+ return {} unless allocator
24
+
25
+ caps = @ledger.limits.each_key.with_object({}) do |dimension, result|
26
+ cap = agent_cap_for_dimension(dimension, agent_budget)
27
+ result[dimension] = cap unless cap.nil?
28
+ end
29
+ allocator.call(ordinal:, caps:)
30
+ end
31
+
32
+ def composite_binding_identity!(agent_class, agent)
33
+ identity = agent_class&.execution_identity
34
+ return identity if identity
35
+
36
+ raise WorkflowError, "durable composite agent :#{agent} requires an execution_identity"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parallel"
4
+ require_relative "branch"
5
+ require_relative "branch_budget_contract"
6
+ require_relative "fanout_branch_contract"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ module BranchContract
12
+ include BranchBudgetContract
13
+ include FanoutBranchContract
14
+
15
+ private
16
+
17
+ def composite_branch_specs(authorization, transition, branch_count: nil)
18
+ return parallel_branch_specs(authorization, transition, branch_count:) if transition.parallel?
19
+
20
+ fanout_branch_specs(authorization, transition)
21
+ end
22
+
23
+ def parallel_branch_specs(authorization, transition, branch_count: nil)
24
+ validate_planned_parallel_count!(transition, branch_count) if branch_count
25
+ count = branch_count || Parallel.resolve_branch_count(transition, @context)
26
+ validate_composite_branch_count!(count)
27
+ agent = transition.agent_name
28
+ agent_class = captured_agent(authorization, transition, agent, :agent)
29
+ allocator = composite_budget_allocator(count)
30
+ Array.new(count) do |ordinal|
31
+ parallel_branch_spec(agent, agent_class, allocator, ordinal).merge(key: ordinal.to_s)
32
+ end.freeze
33
+ end
34
+
35
+ def parallel_branch_spec(agent, agent_class, allocator, ordinal)
36
+ {
37
+ agent: agent.to_s,
38
+ binding_identity: composite_binding_identity!(agent_class, agent),
39
+ budget: composite_branch_budget(allocator, ordinal, agent_class&.budget)
40
+ }.freeze
41
+ end
42
+
43
+ def validate_planned_parallel_count!(transition, branch_count)
44
+ Parallel.validate_branch_count!(branch_count)
45
+ configured = transition.agent_opts[:count]
46
+ return if configured.respond_to?(:call)
47
+ return if branch_count == (configured || 1)
48
+
49
+ raise WorkflowError, "composite plan branch count does not match the prepared transition"
50
+ end
51
+
52
+ def captured_agent(authorization, transition, agent, role)
53
+ authorization.fetch_agent!(
54
+ agent,
55
+ workflow_class: self.class,
56
+ transition_name: transition.name,
57
+ role:
58
+ )
59
+ end
60
+
61
+ def validate_composite_branches!(authorization, plan, transition)
62
+ specs = composite_branch_specs(authorization, transition, branch_count: plan.branches.length)
63
+ specs.each_with_index do |spec, ordinal|
64
+ expected = comparable_branch(Branch.build(ordinal:, **spec))
65
+ actual = comparable_branch(plan.branches.fetch(ordinal))
66
+ next if actual == expected
67
+
68
+ raise WorkflowError, "composite plan branches do not match the prepared transition"
69
+ end
70
+ end
71
+
72
+ def validate_composite_selected_branch!(authorization, execution, transition)
73
+ branch = execution.branch
74
+ spec = selected_branch_spec(authorization, execution, transition)
75
+ candidate = Branch.build(ordinal: branch.ordinal, **spec)
76
+ raise WorkflowError, "composite branch does not match the prepared transition" unless
77
+ comparable_branch(candidate) == comparable_branch(branch)
78
+ end
79
+
80
+ def selected_branch_spec(authorization, execution, transition)
81
+ return selected_parallel_branch_spec(authorization, execution, transition) if transition.parallel?
82
+
83
+ selected_fanout_branch_spec(authorization, execution, transition)
84
+ end
85
+
86
+ def selected_parallel_branch_spec(authorization, execution, transition)
87
+ branch = execution.branch
88
+ count = execution.branch_count
89
+ validate_planned_parallel_count!(transition, count)
90
+ agent = transition.agent_name
91
+ agent_class = captured_agent(authorization, transition, agent, :agent)
92
+ allocator = composite_budget_allocator(count)
93
+ parallel_branch_spec(agent, agent_class, allocator, branch.ordinal).merge(key: branch.ordinal.to_s)
94
+ end
95
+
96
+ def comparable_branch(branch)
97
+ [branch.ordinal, branch.key, branch.agent, branch.binding_identity, branch.budget]
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../prepared_step"
5
+ require_relative "../prepared_step_dispatch"
6
+ require_relative "branch"
7
+ require_relative "enums"
8
+ require_relative "payload"
9
+ require_relative "payload_digest"
10
+ require_relative "plan"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class BranchExecution < Payload
16
+ VERSION = 1
17
+ ENUM_ATTRIBUTES = %i[kind resume_policy failure_policy reduction_policy retry_policy].freeze
18
+ private_constant :ENUM_ATTRIBUTES
19
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
20
+ private_constant :OwnedString
21
+
22
+ attribute :version, Types::Integer.enum(VERSION)
23
+ attribute :execution_semantics_version, OwnedString.constrained(min_size: 1, max_size: 32)
24
+ attribute :dispatch, Types.Instance(PreparedStepDispatch)
25
+ attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
26
+ attribute :kind, Types::Symbol.enum(:parallel, :fanout)
27
+ attribute :transition, OwnedString.constrained(min_size: 1, max_size: 256)
28
+ attribute :from, OwnedString.constrained(min_size: 1, max_size: 256)
29
+ attribute :execution_namespace, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
30
+ attribute :branch_count, Types::Integer.constrained(gteq: 1, lteq: Plan::MAX_BRANCHES)
31
+ attribute :input_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
32
+ attribute :budget_state_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
33
+ attribute :resume_policy, Types::Symbol.enum(Plan::RESUME_POLICY)
34
+ attribute :failure_policy, Types::Symbol.enum(Plan::FAILURE_POLICY)
35
+ attribute :reduction_policy, Types::Symbol.enum(Plan::REDUCTION_POLICY)
36
+ attribute :retry_policy, Types::Symbol.enum(Plan::RETRY_POLICY)
37
+ attribute :branch, Types.Instance(Branch)
38
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
39
+
40
+ class << self
41
+ def build(plan:, branch:)
42
+ validate_plan_branch!(plan, branch)
43
+ values = execution_attributes(plan, branch)
44
+ new(values.merge(digest: PayloadDigest.call(serializable(values))))
45
+ end
46
+
47
+ def normalize_attributes(attributes)
48
+ normalized = super
49
+ normalize_dispatch!(normalized)
50
+ normalize_branch!(normalized)
51
+ ENUM_ATTRIBUTES.each { |key| normalized[key] = Enums.normalize(key, normalized[key]) }
52
+ normalized
53
+ end
54
+
55
+ def serializable(values)
56
+ values.merge(dispatch: values.fetch(:dispatch).to_h, branch: values.fetch(:branch).to_h)
57
+ end
58
+
59
+ private
60
+
61
+ def execution_attributes(plan, branch)
62
+ {
63
+ version: VERSION,
64
+ execution_semantics_version: plan.execution_semantics_version,
65
+ dispatch: plan.dispatch,
66
+ plan_digest: plan.plan_digest,
67
+ kind: plan.kind,
68
+ transition: plan.transition,
69
+ from: plan.from,
70
+ execution_namespace: plan.execution_namespace,
71
+ branch_count: plan.branches.length,
72
+ input_digest: plan.input_digest,
73
+ budget_state_digest: plan.budget_state_digest,
74
+ resume_policy: plan.resume_policy,
75
+ failure_policy: plan.failure_policy,
76
+ reduction_policy: plan.reduction_policy,
77
+ retry_policy: plan.retry_policy,
78
+ branch:
79
+ }
80
+ end
81
+
82
+ def validate_plan_branch!(plan, branch)
83
+ raise ArgumentError, "plan must be a Smith composite plan" unless plan.is_a?(Plan)
84
+ raise ArgumentError, "branch must be a Smith composite branch" unless branch.is_a?(Branch)
85
+
86
+ expected = plan.branches.fetch(branch.ordinal) do
87
+ raise ArgumentError, "composite branch ordinal is invalid"
88
+ end
89
+ raise ArgumentError, "composite branch does not belong to plan" unless expected.to_h == branch.to_h
90
+ end
91
+
92
+ def normalize_dispatch!(attributes)
93
+ value = attributes[:dispatch]
94
+ attributes[:dispatch] = PreparedStepDispatch.deserialize(value) unless value.is_a?(PreparedStepDispatch)
95
+ end
96
+
97
+ def normalize_branch!(attributes)
98
+ value = attributes[:branch]
99
+ attributes[:branch] = Branch.deserialize(value) unless value.is_a?(Branch)
100
+ end
101
+ end
102
+
103
+ def initialize(attributes)
104
+ owned = self.class.normalize_attributes(attributes)
105
+ super(owned)
106
+ validate_contract!
107
+ end
108
+
109
+ private
110
+
111
+ def validate_contract!
112
+ Plan.validate_branch_count!(branch_count)
113
+ raise ArgumentError, "composite branch ordinal exceeds branch count" if branch.ordinal >= branch_count
114
+ unless execution_semantics_version == Smith::EXECUTION_SEMANTICS_VERSION
115
+ raise ArgumentError, "composite branch execution semantics do not match"
116
+ end
117
+
118
+ expected = PayloadDigest.call(self.class.serializable(to_h.except(:digest)))
119
+ raise ArgumentError, "composite branch execution digest does not match" unless digest == expected
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "error"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module Composite
9
+ class BranchFailure < WorkflowError
10
+ DETAIL_KEYS = {
11
+ "branch_key" => :branch_key,
12
+ "error_class" => :error_class,
13
+ "error_family" => :error_family,
14
+ "retryable" => :retryable,
15
+ "kind" => :kind
16
+ }.freeze
17
+ DETAIL_NAMES = DETAIL_KEYS.values.freeze
18
+ private_constant :DETAIL_KEYS, :DETAIL_NAMES
19
+
20
+ attr_reader :branch_key, :error_class, :error_family, :retryable, :kind, :details
21
+
22
+ def self.from_details(details)
23
+ values = normalize_details(details)
24
+ error = Error.new(
25
+ class_name: values.fetch(:error_class),
26
+ family: values.fetch(:error_family),
27
+ retryable: values.fetch(:retryable),
28
+ kind: values.fetch(:kind)
29
+ )
30
+ new(branch_key: values.fetch(:branch_key), error:)
31
+ end
32
+
33
+ def self.normalize_details(details)
34
+ raise ArgumentError, "composite branch failure details must be a Hash" unless details.is_a?(Hash)
35
+
36
+ normalized = {}
37
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
38
+ name = normalize_detail_key(key)
39
+ if normalized.key?(name)
40
+ raise ArgumentError, "composite branch failure details contain a duplicate attribute"
41
+ end
42
+
43
+ normalized[name] = value
44
+ end
45
+ missing = DETAIL_NAMES - normalized.keys
46
+ raise ArgumentError, "composite branch failure details are missing required attributes" if missing.any?
47
+
48
+ normalized
49
+ end
50
+
51
+ def self.normalize_detail_key(key)
52
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS.fetch(key, key)
53
+ return name if DETAIL_NAMES.include?(name)
54
+
55
+ raise ArgumentError, "composite branch failure details contain an unknown attribute"
56
+ end
57
+ private_class_method :normalize_details
58
+ private_class_method :normalize_detail_key
59
+
60
+ def initialize(branch_key:, error:)
61
+ validate_arguments!(branch_key, error)
62
+ @branch_key = branch_key.dup.freeze
63
+ @error_class = error.class_name.dup.freeze
64
+ @error_family = error.family.dup.freeze
65
+ @retryable = error.retryable
66
+ @kind = error.kind&.dup&.freeze
67
+ @details = failure_details
68
+ super("composite branch #{branch_key.inspect} failed")
69
+ end
70
+
71
+ private
72
+
73
+ def validate_arguments!(branch_key, error)
74
+ valid_key = branch_key.is_a?(String) && branch_key.length.between?(1, 256)
75
+ raise ArgumentError, "composite branch failure key must be a bounded non-empty String" unless valid_key
76
+ raise ArgumentError, "composite branch failure requires typed error evidence" unless error.is_a?(Error)
77
+ end
78
+
79
+ def failure_details
80
+ {
81
+ branch_key: @branch_key,
82
+ error_class: @error_class,
83
+ error_family: @error_family,
84
+ retryable: @retryable,
85
+ kind: @kind
86
+ }.freeze
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../message_value_normalizer"
5
+ require_relative "../prepared_step"
6
+ require_relative "effects"
7
+ require_relative "enums"
8
+ require_relative "error"
9
+ require_relative "payload"
10
+ require_relative "payload_digest"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class BranchOutcome < Payload
16
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
17
+ private_constant :OwnedString
18
+
19
+ attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
20
+ attribute :branch_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
21
+ attribute :ordinal, Types::Integer.constrained(gteq: 0, lteq: PreparedStep::MAX_COUNTER_VALUE)
22
+ attribute :branch_key, OwnedString.constrained(min_size: 1, max_size: 256)
23
+ attribute :agent, OwnedString.constrained(min_size: 1, max_size: 256)
24
+ attribute :status, Types::Symbol.enum(:succeeded, :failed)
25
+ attribute? :output, Types::Any.optional
26
+ attribute? :error, Types.Instance(Error).optional
27
+ attribute :effects, Types.Instance(Effects)
28
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
29
+
30
+ class << self
31
+ def succeeded(plan_digest:, branch:, output:, effects:)
32
+ build(plan_digest:, branch:, effects:, status: :succeeded, output:, error: nil)
33
+ end
34
+
35
+ def failed(plan_digest:, branch:, error:, effects:)
36
+ build(plan_digest:, branch:, effects:, status: :failed, output: nil, error:)
37
+ end
38
+
39
+ def normalize_attributes(attributes)
40
+ normalized = super
41
+ normalize_status!(normalized)
42
+ normalize_error!(normalized)
43
+ normalize_effects!(normalized)
44
+ normalized
45
+ end
46
+
47
+ private
48
+
49
+ def build(plan_digest:, branch:, effects:, **result)
50
+ values = {
51
+ plan_digest:,
52
+ branch_digest: branch.digest,
53
+ ordinal: branch.ordinal,
54
+ branch_key: branch.key,
55
+ agent: branch.agent,
56
+ status: result.fetch(:status),
57
+ output: normalize_output(result.fetch(:output)),
58
+ error: result.fetch(:error),
59
+ effects:
60
+ }
61
+ new(values.merge(digest: PayloadDigest.call(serializable(values))))
62
+ end
63
+
64
+ def normalize_status!(attributes)
65
+ attributes[:status] = Enums.normalize(:status, attributes[:status])
66
+ end
67
+
68
+ def normalize_error!(attributes)
69
+ error = attributes[:error]
70
+ attributes[:error] = Error.deserialize(error) if error && !error.is_a?(Error)
71
+ end
72
+
73
+ def normalize_effects!(attributes)
74
+ effects = attributes[:effects]
75
+ attributes[:effects] = Effects.deserialize(effects) unless effects.is_a?(Effects)
76
+ end
77
+
78
+ def normalize_output(value)
79
+ MessageValueNormalizer.new(value, label: "composite branch output").call
80
+ end
81
+
82
+ def serializable(values)
83
+ values.merge(error: values[:error]&.to_h, effects: values.fetch(:effects).to_h)
84
+ end
85
+ end
86
+
87
+ def initialize(attributes)
88
+ owned = self.class.normalize_attributes(attributes)
89
+ owned[:output] = self.class.send(:normalize_output, owned[:output])
90
+ super(owned)
91
+ validate_shape!
92
+ expected = PayloadDigest.call(self.class.send(:serializable, to_h.except(:digest)))
93
+ raise ArgumentError, "composite branch outcome digest does not match" unless digest == expected
94
+ end
95
+
96
+ def succeeded? = status == :succeeded
97
+ def failed? = status == :failed
98
+
99
+ private
100
+
101
+ def validate_shape!
102
+ valid = succeeded? ? error.nil? : error && output.nil?
103
+ raise ArgumentError, "composite branch outcome fields do not match status" unless valid
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end