smith-agents 0.5.0 → 0.6.1

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +83 -0
  4. data/lib/smith/agent.rb +13 -0
  5. data/lib/smith/errors.rb +4 -0
  6. data/lib/smith/version.rb +2 -2
  7. data/lib/smith/workflow/composite/branch.rb +93 -0
  8. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  9. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  10. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  11. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  12. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  13. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  14. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  15. data/lib/smith/workflow/composite/contract.rb +100 -0
  16. data/lib/smith/workflow/composite/effects.rb +140 -0
  17. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  18. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  19. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  20. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  21. data/lib/smith/workflow/composite/enums.rb +27 -0
  22. data/lib/smith/workflow/composite/error.rb +54 -0
  23. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  24. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  25. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  26. data/lib/smith/workflow/composite/input.rb +53 -0
  27. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  28. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  29. data/lib/smith/workflow/composite/payload.rb +128 -0
  30. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  31. data/lib/smith/workflow/composite/plan.rb +130 -0
  32. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  33. data/lib/smith/workflow/composite/planner.rb +53 -0
  34. data/lib/smith/workflow/composite/preparation.rb +27 -0
  35. data/lib/smith/workflow/composite/reducer.rb +133 -0
  36. data/lib/smith/workflow/composite/reduction.rb +31 -0
  37. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  38. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  39. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  40. data/lib/smith/workflow/execution.rb +16 -16
  41. data/lib/smith/workflow/fanout_execution.rb +19 -18
  42. data/lib/smith/workflow/message_value_normalizer.rb +11 -10
  43. data/lib/smith/workflow/parallel_execution.rb +41 -13
  44. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  45. data/lib/smith/workflow/prepared_step_execution_authorization.rb +14 -29
  46. data/lib/smith/workflow/prepared_step_execution_scope.rb +81 -15
  47. data/lib/smith/workflow/process_local.rb +33 -0
  48. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  49. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  50. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  51. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  52. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  53. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  54. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  55. data/lib/smith/workflow/split_step_persistence/execution.rb +7 -53
  56. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +41 -21
  57. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  58. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +5 -0
  59. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +17 -3
  60. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  61. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +17 -5
  62. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +8 -0
  63. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +21 -5
  64. data/lib/smith/workflow/split_step_persistence.rb +14 -0
  65. data/lib/smith/workflow/step_context.rb +46 -0
  66. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  67. data/lib/smith/workflow/transition.rb +16 -1
  68. data/lib/smith/workflow.rb +21 -1
  69. data/lib/smith.rb +3 -0
  70. metadata +46 -1
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "dry-initializer"
5
+
6
+ require_relative "../../budget/decimal_context"
7
+ require_relative "../../budget/ledger"
8
+ require_relative "../prepared_step"
9
+ require_relative "../usage_entry"
10
+ require_relative "budget_math"
11
+ require_relative "effects_application"
12
+
13
+ module Smith
14
+ class Workflow
15
+ module Composite
16
+ class EffectsPreflight
17
+ extend Dry::Initializer
18
+
19
+ option :effects
20
+ option :baseline
21
+ option :snapshotter
22
+
23
+ def call
24
+ next_total_tokens = total_tokens
25
+ next_total_cost = total_cost
26
+ next_usage = incoming_usage
27
+ next_ledger = ledger
28
+ next_tools = incoming_tools
29
+ tool_results = baseline.tool_results + next_tools
30
+ EffectsApplication.new(
31
+ usage_entries: baseline.usage_entries + next_usage,
32
+ tool_results:,
33
+ total_tokens: next_total_tokens,
34
+ total_cost: next_total_cost,
35
+ ledger: next_ledger,
36
+ baseline:
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def incoming_usage
43
+ @incoming_usage ||= effects.usage_entries.map { UsageEntry.from_h(_1) }.tap do |entries|
44
+ known = baseline.usage_entries.to_h { [_1.usage_id, true] }
45
+ raise WorkflowError, "composite usage entry was already applied" if
46
+ entries.any? { known.key?(_1.usage_id) }
47
+ end
48
+ end
49
+
50
+ def incoming_tools
51
+ @incoming_tools ||= effects.tool_results.map do |entry|
52
+ snapshotter.call(entry).transform_keys { _1.respond_to?(:to_sym) ? _1.to_sym : _1 }
53
+ end
54
+ end
55
+
56
+ def total_tokens
57
+ current = baseline.total_tokens
58
+ raise WorkflowError, "workflow token total is invalid" unless current.is_a?(Integer) && current >= 0
59
+
60
+ total = current + effects.total_tokens
61
+ return total if total <= PreparedStep::MAX_COUNTER_VALUE
62
+
63
+ raise WorkflowError, "workflow token total exceeds the signed 64-bit limit"
64
+ end
65
+
66
+ def total_cost
67
+ current = baseline.total_cost
68
+ unless current.is_a?(Numeric) && current.finite? && current >= 0
69
+ raise WorkflowError, "workflow cost total is invalid"
70
+ end
71
+
72
+ total = Budget::DecimalContext.call do
73
+ BigDecimal(current.to_s) + BigDecimal(effects.total_cost.to_s)
74
+ end.to_f
75
+ return total if total.finite?
76
+
77
+ raise WorkflowError, "workflow cost total must be finite"
78
+ end
79
+
80
+ def ledger
81
+ return baseline.ledger if effects.budget_consumed.empty?
82
+ raise WorkflowError, "composite budget effects require a workflow ledger" unless baseline.ledger
83
+
84
+ Budget::Ledger.new(
85
+ limits: baseline.ledger.limits,
86
+ consumed: combined_budget_consumed
87
+ )
88
+ end
89
+
90
+ def combined_budget_consumed
91
+ combined = BudgetMath
92
+ .sum([baseline.budget_consumed, effects.budget_consumed])
93
+ .transform_keys(&:to_sym)
94
+ combined.each do |dimension, amount|
95
+ raise BudgetExceeded if amount > baseline.ledger.limits.fetch(dimension)
96
+ end
97
+ combined
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+ require "json"
5
+
6
+ require_relative "../../errors"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class EncodedValueBudget
12
+ extend Dry::Initializer
13
+
14
+ option :max_bytes
15
+ option :label
16
+
17
+ def initialize(...)
18
+ super
19
+ @bytes = 2
20
+ @entries = 0
21
+ end
22
+
23
+ def add(value)
24
+ @bytes += JSON.generate(value, max_nesting: false).bytesize
25
+ @bytes += 1 if @entries.positive?
26
+ @entries += 1
27
+ raise WorkflowError, "#{label} exceeds maximum bytes #{max_bytes}" if @bytes > max_bytes
28
+
29
+ self
30
+ rescue JSON::GeneratorError => e
31
+ raise WorkflowError, "#{label} cannot be encoded: #{e.message}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module Composite
6
+ module Enums
7
+ VALUES = {
8
+ kind: { "parallel" => :parallel, "fanout" => :fanout }.freeze,
9
+ status: { "succeeded" => :succeeded, "failed" => :failed }.freeze,
10
+ resume_policy: { "incomplete_only" => :incomplete_only }.freeze,
11
+ failure_policy: { "host_committed_primary" => :host_committed_primary }.freeze,
12
+ reduction_policy: { "ordered_all_success" => :ordered_all_success }.freeze,
13
+ retry_policy: { "none" => :none }.freeze
14
+ }.freeze
15
+ private_constant :VALUES
16
+
17
+ def self.normalize(name, value)
18
+ return value unless value.is_a?(String)
19
+
20
+ VALUES.fetch(name).fetch(value, value)
21
+ end
22
+ end
23
+
24
+ private_constant :Enums
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "payload"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module Composite
9
+ class Error < Payload
10
+ FAMILIES = %w[
11
+ tool_guardrail_failed deterministic_step_failure deadline_exceeded
12
+ agent_error workflow_error other
13
+ ].freeze
14
+ ALWAYS_RETRYABLE_FAMILIES = %w[deadline_exceeded agent_error].freeze
15
+ CONDITIONALLY_RETRYABLE_FAMILIES = %w[tool_guardrail_failed deterministic_step_failure].freeze
16
+ private_constant :ALWAYS_RETRYABLE_FAMILIES, :CONDITIONALLY_RETRYABLE_FAMILIES
17
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
18
+ private_constant :OwnedString
19
+
20
+ attribute :class_name, OwnedString.constrained(min_size: 1, max_size: 256)
21
+ attribute :family, OwnedString.enum(*FAMILIES)
22
+ attribute :retryable, Types::Bool
23
+ attribute? :kind, OwnedString.constrained(min_size: 1, max_size: 128).optional
24
+
25
+ def initialize(attributes)
26
+ owned = self.class.normalize_attributes(attributes)
27
+ owned[:kind] = owned[:kind].to_s if owned[:kind]
28
+ super(owned)
29
+ validate_retryability!
30
+ validate_kind!
31
+ end
32
+
33
+ private
34
+
35
+ def validate_retryability!
36
+ valid = if ALWAYS_RETRYABLE_FAMILIES.include?(family)
37
+ retryable
38
+ elsif CONDITIONALLY_RETRYABLE_FAMILIES.include?(family)
39
+ [true, false].include?(retryable)
40
+ else
41
+ !retryable
42
+ end
43
+ raise ArgumentError, "composite error retryability does not match its family" unless valid
44
+ end
45
+
46
+ def validate_kind!
47
+ return if kind.nil? || family == "deterministic_step_failure"
48
+
49
+ raise ArgumentError, "composite error kind is not valid for its family"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -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