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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../../errors"
6
+ require_relative "error"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class ErrorEvidence
12
+ extend Dry::Initializer
13
+
14
+ param :error
15
+
16
+ def self.call(error) = new(error).call
17
+
18
+ def call
19
+ Error.new(
20
+ class_name: error_class_name,
21
+ family: error_family,
22
+ retryable: retryable_error?,
23
+ kind: error_kind
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def error_class_name
30
+ value = error.class.name.to_s
31
+ value.empty? || value.length > 256 ? "StandardError" : value
32
+ end
33
+
34
+ def retryable_error?
35
+ Errors.retryable?(error)
36
+ end
37
+
38
+ def error_kind
39
+ return unless error.is_a?(DeterministicStepFailure)
40
+
41
+ value = error.kind
42
+ string = value.to_s if value
43
+ string if string&.length&.between?(1, 128)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def error_family
49
+ case error
50
+ when ToolGuardrailFailed then "tool_guardrail_failed"
51
+ when DeterministicStepFailure then "deterministic_step_failure"
52
+ when DeadlineExceeded then "deadline_exceeded"
53
+ when AgentError then "agent_error"
54
+ when WorkflowError then "workflow_error"
55
+ else "other"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "payload_digest"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module Composite
8
+ module ExecutionContract
9
+ private
10
+
11
+ def validate_composite_dispatch!(authorization, plan)
12
+ validate_composite_dispatch_value!(authorization, plan.dispatch)
13
+ end
14
+
15
+ def validate_composite_dispatch_value!(authorization, dispatch)
16
+ return if dispatch.to_h == authorization.dispatch_claim.to_h
17
+
18
+ raise WorkflowError, "composite plan does not belong to the prepared dispatch"
19
+ end
20
+
21
+ def validate_composite_input!(plan, input)
22
+ validate_composite_input_digest!(plan.input_digest, input)
23
+ end
24
+
25
+ def validate_composite_input_digest!(expected_digest, input)
26
+ return if input.digest == expected_digest
27
+
28
+ raise WorkflowError, "composite input does not match plan"
29
+ end
30
+
31
+ def validate_composite_budget_state!(plan)
32
+ validate_composite_budget_state_digest!(plan.budget_state_digest)
33
+ end
34
+
35
+ def validate_composite_budget_state_digest!(expected_digest)
36
+ return if expected_digest == composite_budget_state_digest
37
+
38
+ raise WorkflowError, "composite plan budget state has changed"
39
+ end
40
+
41
+ def validate_composite_execution_namespace!(plan)
42
+ validate_composite_execution_namespace_value!(plan.execution_namespace)
43
+ end
44
+
45
+ def validate_composite_execution_namespace_value!(expected_namespace)
46
+ return if @execution_namespace.nil? || @execution_namespace == expected_namespace
47
+
48
+ raise WorkflowError, "composite plan execution namespace has changed"
49
+ end
50
+
51
+ def validate_composite_transition_identity!(plan, transition)
52
+ validate_composite_transition_values!(plan, transition)
53
+ end
54
+
55
+ def validate_composite_transition_values!(contract, transition)
56
+ expected = [transition.name.to_s, transition.from.to_s, composite_kind(transition)]
57
+ return if expected == [contract.transition, contract.from, contract.kind]
58
+
59
+ raise WorkflowError, "composite plan does not match the prepared transition"
60
+ end
61
+
62
+ def composite_kind(transition)
63
+ transition.parallel? ? :parallel : :fanout
64
+ end
65
+
66
+ def composite_budget_state_digest
67
+ state = @ledger ? { limits: @ledger.limits, consumed: @ledger.consumed } : { limits: {}, consumed: {} }
68
+ PayloadDigest.call(state)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module Composite
6
+ module FanoutBranchContract
7
+ private
8
+
9
+ def fanout_branch_specs(authorization, transition)
10
+ branches = transition.fanout_config.fetch(:branches)
11
+ count = branches.length
12
+ Parallel.validate_branch_count!(count)
13
+ validate_composite_branch_count!(count)
14
+ allocator = composite_budget_allocator(count)
15
+ branches.map.with_index do |(key, agent), ordinal|
16
+ agent_class = captured_agent(authorization, transition, agent, :fanout_agent)
17
+ fanout_branch_spec(key, agent, agent_class, allocator, ordinal)
18
+ end.freeze
19
+ end
20
+
21
+ def fanout_branch_spec(key, agent, agent_class, allocator, ordinal)
22
+ {
23
+ key: key.to_s,
24
+ agent: agent.to_s,
25
+ binding_identity: composite_binding_identity!(agent_class, agent),
26
+ budget: composite_branch_budget(allocator, ordinal, agent_class&.budget)
27
+ }.freeze
28
+ end
29
+
30
+ def selected_fanout_branch_spec(authorization, execution, transition)
31
+ branches = transition.fanout_config.fetch(:branches)
32
+ Parallel.validate_branch_count!(branches.length)
33
+ validate_selected_fanout_count!(branches, execution)
34
+
35
+ agent = Transition
36
+ .instance_method(:fetch_fanout_agent!)
37
+ .bind_call(transition, execution.branch.key)
38
+ agent_class = captured_agent(authorization, transition, agent, :fanout_agent)
39
+ selected_fanout_spec(execution, agent, agent_class)
40
+ end
41
+
42
+ def validate_selected_fanout_count!(branches, execution)
43
+ return if branches.length == execution.branch_count
44
+
45
+ raise WorkflowError, "composite branch count does not match the prepared transition"
46
+ end
47
+
48
+ def selected_fanout_spec(execution, agent, agent_class)
49
+ branch = execution.branch
50
+ fanout_branch_spec(
51
+ branch.key,
52
+ agent,
53
+ agent_class,
54
+ composite_budget_allocator(execution.branch_count),
55
+ branch.ordinal
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,53 @@
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 Input < Payload
13
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
14
+ private_constant :OwnedString
15
+
16
+ attribute :agent_messages, Types::Any
17
+ attribute :session_messages, Types::Array
18
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
19
+
20
+ def self.build(agent_messages:, session_messages:)
21
+ values = normalized_values(agent_messages:, session_messages:)
22
+ new(values.merge(digest: PayloadDigest.call(values)))
23
+ end
24
+
25
+ def initialize(attributes)
26
+ owned = self.class.normalize_attributes(attributes)
27
+ values = self.class.send(
28
+ :normalized_values,
29
+ agent_messages: owned[:agent_messages],
30
+ session_messages: owned[:session_messages]
31
+ )
32
+ super(values.merge(digest: owned[:digest]))
33
+ raise ArgumentError, "composite input digest does not match" unless digest == PayloadDigest.call(values)
34
+ end
35
+
36
+ class << self
37
+ private
38
+
39
+ def normalized_values(agent_messages:, session_messages:)
40
+ {
41
+ agent_messages: normalize(agent_messages),
42
+ session_messages: normalize(session_messages)
43
+ }.freeze
44
+ end
45
+
46
+ def normalize(value)
47
+ MessageValueNormalizer.new(value, label: "composite input").call
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../execution_result_snapshot"
6
+ require_relative "../message_value_normalizer"
7
+ require_relative "encoded_value_budget"
8
+ require_relative "outcome_set"
9
+ require_relative "value_budget"
10
+
11
+ module Smith
12
+ class Workflow
13
+ module Composite
14
+ class OutcomeAccumulator
15
+ extend Dry::Initializer
16
+
17
+ ARRAY_EACH = Array.instance_method(:each)
18
+ private_constant :ARRAY_EACH
19
+
20
+ option :plan
21
+ option :outcomes
22
+
23
+ def call
24
+ validate_array_count!
25
+ initialize_accumulators
26
+ each_outcome { insert!(_1) }
27
+ complete_outcome_set
28
+ end
29
+
30
+ private
31
+
32
+ def initialize_accumulators
33
+ @seen = 0
34
+ @ordered = Array.new(plan.branches.length)
35
+ @output = Array.new(plan.branches.length)
36
+ @effects_budget = value_budget(MessageValueNormalizer::MAX_BYTES, "composite aggregate effects")
37
+ @encoded_effects_budget = EncodedValueBudget.new(
38
+ max_bytes: MessageValueNormalizer::MAX_BYTES,
39
+ label: "composite aggregate effects"
40
+ )
41
+ @output_budget = value_budget(ExecutionResultSnapshot::MAX_BYTES, "composite aggregate output")
42
+ @encoded_output_budget = EncodedValueBudget.new(
43
+ max_bytes: ExecutionResultSnapshot::MAX_BYTES,
44
+ label: "composite aggregate output"
45
+ )
46
+ @output_budget.add(nil)
47
+ end
48
+
49
+ def complete_outcome_set
50
+ raise ArgumentError, "composite outcomes are incomplete" unless @seen == plan.branches.length
51
+
52
+ OutcomeSet.new(ordered: @ordered.freeze, output: @output.freeze)
53
+ end
54
+
55
+ def validate_array_count!
56
+ return unless outcomes.is_a?(Array)
57
+ return if Array.instance_method(:length).bind_call(outcomes) == plan.branches.length
58
+
59
+ raise ArgumentError, "composite outcome count does not match plan"
60
+ end
61
+
62
+ def each_outcome(&)
63
+ if outcomes.is_a?(Array)
64
+ ARRAY_EACH.bind_call(outcomes, &)
65
+ elsif outcomes.is_a?(Enumerator)
66
+ outcomes.each(&)
67
+ else
68
+ raise ArgumentError, "composite outcomes must be an Array or Enumerator"
69
+ end
70
+ end
71
+
72
+ def insert!(outcome)
73
+ raise ArgumentError, "composite outcome count does not match plan" if @seen >= plan.branches.length
74
+
75
+ branch = outcome_branch(outcome)
76
+ validate_identity!(outcome, branch)
77
+ raise ArgumentError, "composite outcome ordinal is duplicated" if @ordered[outcome.ordinal]
78
+
79
+ output = output_entry(outcome, branch)
80
+ add_to_budgets(outcome, output)
81
+ store(outcome, output)
82
+ end
83
+
84
+ def outcome_branch(outcome)
85
+ raise ArgumentError, "composite outcome must be typed" unless outcome.is_a?(BranchOutcome)
86
+
87
+ plan.branches.fetch(outcome.ordinal) do
88
+ raise ArgumentError, "composite outcome ordinal is invalid"
89
+ end
90
+ end
91
+
92
+ def add_to_budgets(outcome, output)
93
+ effects = outcome.effects.to_h
94
+ @effects_budget.add(effects)
95
+ @encoded_effects_budget.add(effects)
96
+ @output_budget.add(output, depth: 1)
97
+ @encoded_output_budget.add(output)
98
+ end
99
+
100
+ def store(outcome, output)
101
+ @ordered[outcome.ordinal] = outcome
102
+ @output[outcome.ordinal] = output
103
+ @seen += 1
104
+ end
105
+
106
+ def validate_identity!(outcome, branch)
107
+ valid = outcome.plan_digest == plan.plan_digest &&
108
+ outcome.branch_digest == branch.digest &&
109
+ outcome.branch_key == branch.key &&
110
+ outcome.agent == branch.agent
111
+ raise ArgumentError, "composite outcome does not match its branch" unless valid
112
+ end
113
+
114
+ def output_entry(outcome, branch)
115
+ branch_key = plan.kind == :parallel ? branch.ordinal : branch.key
116
+ { "branch" => branch_key, "agent" => branch.agent, "output" => outcome.output }.freeze
117
+ end
118
+
119
+ def value_budget(max_bytes, label)
120
+ result_budget = label == "composite aggregate output"
121
+ ValueBudget.new(
122
+ max_bytes:,
123
+ max_nodes: result_budget ? ExecutionResultSnapshot::MAX_NODES : MessageValueNormalizer::MAX_NODES,
124
+ max_depth: result_budget ? ExecutionResultSnapshot::MAX_DEPTH : MessageValueNormalizer::MAX_DEPTH,
125
+ label:
126
+ )
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "branch_outcome"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module Composite
9
+ class OutcomeSet < Dry::Struct
10
+ attribute :ordered, Types::Array.of(Types.Instance(BranchOutcome))
11
+ attribute :output, Types::Array.of(Types::Hash)
12
+
13
+ def initialize(attributes)
14
+ super
15
+ ordered.freeze
16
+ output.freeze
17
+ self.attributes.freeze
18
+ freeze
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "json"
5
+
6
+ require_relative "../message_value_normalizer"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class Payload < Dry::Struct
12
+ MAX_SERIALIZED_BYTES = MessageValueNormalizer::MAX_BYTES
13
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
14
+ private_constant :HASH_EACH_PAIR
15
+
16
+ class << self
17
+ def deserialize(value)
18
+ attributes = parse_attributes(value)
19
+ preflight_attributes!(attributes)
20
+ bounded = MessageValueNormalizer.new(attributes, label: payload_name).call
21
+ new(normalize_attributes(bounded))
22
+ rescue JSON::ParserError, TypeError => e
23
+ raise ArgumentError, "#{payload_name} is invalid: #{e.message}"
24
+ end
25
+
26
+ def normalize_attributes(attributes)
27
+ raise ArgumentError, "#{payload_name} must contain an object" unless attributes.is_a?(Hash)
28
+
29
+ result = normalize_attribute_keys(attributes)
30
+ validate_known_attributes!(result)
31
+ result
32
+ end
33
+
34
+ def payload_name
35
+ name.split("::").last.gsub(/([a-z])([A-Z])/, '\\1-\\2').downcase
36
+ end
37
+
38
+ def preflight_attributes!(attributes) = attributes
39
+
40
+ private
41
+
42
+ def normalize_attribute_keys(attributes)
43
+ result = {}
44
+ HASH_EACH_PAIR.bind_call(attributes) do |key, value|
45
+ normalized_key = normalize_attribute_key(key)
46
+ raise ArgumentError, "#{payload_name} contains duplicate attributes" if result.key?(normalized_key)
47
+
48
+ result[normalized_key] = value
49
+ end
50
+ result
51
+ end
52
+
53
+ def normalize_attribute_key(key)
54
+ unless key.is_a?(String) || key.is_a?(Symbol)
55
+ raise ArgumentError, "#{payload_name} attribute names must be strings or symbols"
56
+ end
57
+
58
+ return key if key.is_a?(Symbol)
59
+
60
+ payload_attribute_lookup.fetch(key) { key.dup.freeze }
61
+ end
62
+
63
+ def validate_known_attributes!(attributes)
64
+ unknown = attributes.keys - payload_attribute_names
65
+ missing = payload_attribute_names - attributes.keys
66
+ raise ArgumentError, "#{payload_name} contains unknown attributes: #{unknown.join(", ")}" if unknown.any?
67
+ return if missing.empty?
68
+
69
+ raise ArgumentError, "#{payload_name} is missing attributes: #{missing.join(", ")}"
70
+ end
71
+
72
+ def payload_attribute_names
73
+ @payload_attribute_names ||= schema.keys.map(&:name).freeze
74
+ end
75
+
76
+ def payload_attribute_lookup
77
+ @payload_attribute_lookup ||= payload_attribute_names.to_h do |name|
78
+ [name.to_s.freeze, name]
79
+ end.freeze
80
+ end
81
+
82
+ def raw_attribute(attributes, name)
83
+ symbol_present = Hash.instance_method(:key?).bind_call(attributes, name)
84
+ string_present = Hash.instance_method(:key?).bind_call(attributes, name.to_s)
85
+ raise ArgumentError, "#{payload_name} contains duplicate attributes" if symbol_present && string_present
86
+
87
+ Hash.instance_method(:[]).bind_call(attributes, symbol_present ? name : name.to_s)
88
+ end
89
+
90
+ def parse_attributes(value)
91
+ return value if value.is_a?(Hash)
92
+ unless value.is_a?(String) && value.bytesize <= MAX_SERIALIZED_BYTES
93
+ raise ArgumentError, "#{payload_name} must be a bounded Hash or JSON object"
94
+ end
95
+
96
+ JSON.parse(value).tap do |parsed|
97
+ raise ArgumentError, "#{payload_name} JSON must contain an object" unless parsed.is_a?(Hash)
98
+ end
99
+ end
100
+ end
101
+
102
+ def serialize
103
+ JSON.generate(to_h).tap do |payload|
104
+ raise WorkflowError, "#{self.class.payload_name} exceeds maximum bytes" if
105
+ payload.bytesize > self.class::MAX_SERIALIZED_BYTES
106
+ end
107
+ end
108
+
109
+ def initialize(attributes)
110
+ super
111
+ validate_serialized_size!
112
+ self.attributes.freeze
113
+ freeze
114
+ end
115
+
116
+ private
117
+
118
+ def validate_serialized_size!
119
+ return if JSON.generate(to_h).bytesize <= self.class::MAX_SERIALIZED_BYTES
120
+
121
+ raise ArgumentError, "#{self.class.payload_name} exceeds maximum encoded bytes"
122
+ end
123
+ end
124
+
125
+ private_constant :Payload
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "dry-initializer"
5
+ require "json"
6
+
7
+ require_relative "../message_value_normalizer"
8
+
9
+ module Smith
10
+ class Workflow
11
+ module Composite
12
+ class PayloadDigest
13
+ extend Dry::Initializer
14
+
15
+ param :value
16
+
17
+ def self.call(value) = new(value).call
18
+
19
+ def call
20
+ normalized = MessageValueNormalizer.new(value, label: "composite payload").call
21
+ Digest::SHA256.hexdigest(JSON.generate(normalized))
22
+ end
23
+ end
24
+
25
+ private_constant :PayloadDigest
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../../version"
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_integrity"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class Plan < Payload
16
+ VERSION = 1
17
+ MAX_BRANCHES = 10_000
18
+ RESUME_POLICY = :incomplete_only
19
+ FAILURE_POLICY = :host_committed_primary
20
+ REDUCTION_POLICY = :ordered_all_success
21
+ RETRY_POLICY = :none
22
+ ENUM_ATTRIBUTES = %i[kind resume_policy failure_policy reduction_policy retry_policy].freeze
23
+ private_constant :ENUM_ATTRIBUTES
24
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
25
+ private_constant :OwnedString
26
+
27
+ attribute :version, Types::Integer.enum(VERSION)
28
+ attribute :execution_semantics_version, OwnedString.constrained(min_size: 1, max_size: 32)
29
+ attribute :dispatch, Types.Instance(PreparedStepDispatch)
30
+ attribute :kind, Types::Symbol.enum(:parallel, :fanout)
31
+ attribute :transition, OwnedString.constrained(min_size: 1, max_size: 256)
32
+ attribute :from, OwnedString.constrained(min_size: 1, max_size: 256)
33
+ attribute :execution_namespace, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
34
+ attribute :branches, Types::Array.of(Types.Instance(Branch))
35
+ attribute :input_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
36
+ attribute :budget_state_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
37
+ attribute :resume_policy, Types::Symbol.enum(RESUME_POLICY)
38
+ attribute :failure_policy, Types::Symbol.enum(FAILURE_POLICY)
39
+ attribute :reduction_policy, Types::Symbol.enum(REDUCTION_POLICY)
40
+ attribute :retry_policy, Types::Symbol.enum(RETRY_POLICY)
41
+ attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
42
+
43
+ class << self
44
+ def build(**attributes)
45
+ values = {
46
+ version: VERSION,
47
+ execution_semantics_version: Smith::EXECUTION_SEMANTICS_VERSION,
48
+ dispatch: attributes.fetch(:dispatch),
49
+ kind: attributes.fetch(:kind),
50
+ transition: attributes.fetch(:transition).to_s,
51
+ from: attributes.fetch(:from).to_s,
52
+ execution_namespace: attributes.fetch(:execution_namespace),
53
+ branches: attributes.fetch(:branches),
54
+ input_digest: attributes.fetch(:input_digest),
55
+ budget_state_digest: attributes.fetch(:budget_state_digest),
56
+ resume_policy: RESUME_POLICY,
57
+ failure_policy: FAILURE_POLICY,
58
+ reduction_policy: REDUCTION_POLICY,
59
+ retry_policy: RETRY_POLICY
60
+ }
61
+ new(values.merge(plan_digest: PayloadDigest.call(serializable(values))))
62
+ end
63
+
64
+ def normalize_attributes(attributes)
65
+ normalized = super
66
+ normalize_dispatch!(normalized)
67
+ normalize_branches!(normalized)
68
+ normalize_policies!(normalized)
69
+ normalized
70
+ end
71
+
72
+ def preflight_attributes!(attributes)
73
+ branches = raw_attribute(attributes, :branches)
74
+ validate_branch_count!(branches) if branches.is_a?(Array)
75
+ attributes
76
+ end
77
+
78
+ def serializable(values)
79
+ values.merge(
80
+ dispatch: values.fetch(:dispatch).to_h,
81
+ branches: values.fetch(:branches).map(&:to_h)
82
+ )
83
+ end
84
+
85
+ def validate_branch_count!(branches_or_count)
86
+ count = if branches_or_count.is_a?(Array)
87
+ Array.instance_method(:length).bind_call(branches_or_count)
88
+ else
89
+ branches_or_count
90
+ end
91
+ return if count&.between?(1, MAX_BRANCHES)
92
+
93
+ raise ArgumentError, "composite plan branch count is outside the transport limit"
94
+ end
95
+
96
+ private
97
+
98
+ def normalize_dispatch!(attributes)
99
+ dispatch = attributes[:dispatch]
100
+ attributes[:dispatch] = PreparedStepDispatch.deserialize(dispatch) unless
101
+ dispatch.is_a?(PreparedStepDispatch)
102
+ end
103
+
104
+ def normalize_branches!(attributes)
105
+ branches = attributes[:branches]
106
+ validate_branch_count!(branches)
107
+ normalized = []
108
+ Array.instance_method(:each).bind_call(branches) do |branch|
109
+ normalized << (branch.is_a?(Branch) ? branch : Branch.deserialize(branch))
110
+ end
111
+ attributes[:branches] = normalized.freeze
112
+ end
113
+
114
+ def normalize_policies!(attributes)
115
+ ENUM_ATTRIBUTES.each { |key| attributes[key] = Enums.normalize(key, attributes[key]) }
116
+ end
117
+ end
118
+
119
+ def initialize(attributes)
120
+ self.class.preflight_attributes!(attributes)
121
+ owned = self.class.normalize_attributes(attributes)
122
+ super(owned)
123
+ PlanIntegrity.new(self).call
124
+ end
125
+
126
+ def execution_for(branch) = BranchExecution.build(plan: self, branch:)
127
+ end
128
+ end
129
+ end
130
+ end