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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +83 -0
- data/lib/smith/agent.rb +13 -0
- data/lib/smith/errors.rb +4 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/composite/branch.rb +93 -0
- data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
- data/lib/smith/workflow/composite/branch_contract.rb +102 -0
- data/lib/smith/workflow/composite/branch_execution.rb +124 -0
- data/lib/smith/workflow/composite/branch_failure.rb +91 -0
- data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
- data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
- data/lib/smith/workflow/composite/budget_math.rb +51 -0
- data/lib/smith/workflow/composite/contract.rb +100 -0
- data/lib/smith/workflow/composite/effects.rb +140 -0
- data/lib/smith/workflow/composite/effects_application.rb +36 -0
- data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
- data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
- data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
- data/lib/smith/workflow/composite/enums.rb +27 -0
- data/lib/smith/workflow/composite/error.rb +54 -0
- data/lib/smith/workflow/composite/error_evidence.rb +61 -0
- data/lib/smith/workflow/composite/execution_contract.rb +73 -0
- data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
- data/lib/smith/workflow/composite/input.rb +53 -0
- data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
- data/lib/smith/workflow/composite/outcome_set.rb +23 -0
- data/lib/smith/workflow/composite/payload.rb +128 -0
- data/lib/smith/workflow/composite/payload_digest.rb +28 -0
- data/lib/smith/workflow/composite/plan.rb +130 -0
- data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
- data/lib/smith/workflow/composite/planner.rb +53 -0
- data/lib/smith/workflow/composite/preparation.rb +27 -0
- data/lib/smith/workflow/composite/reducer.rb +133 -0
- data/lib/smith/workflow/composite/reduction.rb +31 -0
- data/lib/smith/workflow/composite/value_budget.rb +90 -0
- data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
- data/lib/smith/workflow/deadline_enforcement.rb +22 -5
- data/lib/smith/workflow/execution.rb +16 -16
- data/lib/smith/workflow/fanout_execution.rb +19 -18
- data/lib/smith/workflow/message_value_normalizer.rb +11 -10
- data/lib/smith/workflow/parallel_execution.rb +41 -13
- data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +14 -29
- data/lib/smith/workflow/prepared_step_execution_scope.rb +81 -15
- data/lib/smith/workflow/process_local.rb +33 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
- data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +7 -53
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +41 -21
- data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +17 -3
- data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +17 -5
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +8 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +21 -5
- data/lib/smith/workflow/split_step_persistence.rb +14 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
- data/lib/smith/workflow/transition.rb +16 -1
- data/lib/smith/workflow.rb +21 -1
- data/lib/smith.rb +3 -0
- metadata +46 -1
|
@@ -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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "payload_digest"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
module Composite
|
|
10
|
+
class PlanIntegrity
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
param :plan
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
validate_execution_semantics!
|
|
17
|
+
validate_branches!
|
|
18
|
+
validate_digest!
|
|
19
|
+
plan
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def validate_execution_semantics!
|
|
25
|
+
return if plan.execution_semantics_version == Smith::EXECUTION_SEMANTICS_VERSION
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "composite plan execution semantics do not match"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_branches!
|
|
31
|
+
keys = {}
|
|
32
|
+
plan.branches.each_with_index do |branch, ordinal|
|
|
33
|
+
raise ArgumentError, "composite plan ordinals must be contiguous" unless branch.ordinal == ordinal
|
|
34
|
+
raise ArgumentError, "composite plan branch keys must be unique" if keys.key?(branch.key)
|
|
35
|
+
|
|
36
|
+
keys[branch.key] = true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_digest!
|
|
41
|
+
attributes = plan.to_h.except(:plan_digest).merge(
|
|
42
|
+
dispatch: plan.dispatch.to_h,
|
|
43
|
+
branches: plan.branches.map(&:to_h)
|
|
44
|
+
)
|
|
45
|
+
return if plan.plan_digest == PayloadDigest.call(attributes)
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "composite plan digest does not match"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "branch"
|
|
6
|
+
require_relative "plan"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
module Composite
|
|
11
|
+
class Planner
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
option :dispatch
|
|
15
|
+
option :kind
|
|
16
|
+
option :transition
|
|
17
|
+
option :from
|
|
18
|
+
option :execution_namespace
|
|
19
|
+
option :branch_specs
|
|
20
|
+
option :input_digest
|
|
21
|
+
option :budget_state_digest
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
Plan.validate_branch_count!(branch_specs)
|
|
25
|
+
Plan.build(
|
|
26
|
+
dispatch:,
|
|
27
|
+
kind:,
|
|
28
|
+
transition:,
|
|
29
|
+
from:,
|
|
30
|
+
execution_namespace:,
|
|
31
|
+
branches: build_branches,
|
|
32
|
+
input_digest:,
|
|
33
|
+
budget_state_digest:
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_branches
|
|
40
|
+
branch_specs.each_with_index.map do |spec, ordinal|
|
|
41
|
+
Branch.build(
|
|
42
|
+
ordinal:,
|
|
43
|
+
key: spec.fetch(:key),
|
|
44
|
+
agent: spec.fetch(:agent),
|
|
45
|
+
binding_identity: spec.fetch(:binding_identity),
|
|
46
|
+
budget: spec.fetch(:budget, {})
|
|
47
|
+
)
|
|
48
|
+
end.freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../types"
|
|
4
|
+
require_relative "input"
|
|
5
|
+
require_relative "plan"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
module Composite
|
|
10
|
+
class Preparation < Dry::Struct
|
|
11
|
+
attribute :plan, Types.Instance(Plan)
|
|
12
|
+
attribute :input, Types.Instance(Input)
|
|
13
|
+
|
|
14
|
+
def initialize(attributes)
|
|
15
|
+
super
|
|
16
|
+
unless plan.input_digest == input.digest
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"composite preparation input does not match plan"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
self.attributes.freeze
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "branch_outcome"
|
|
6
|
+
require_relative "budget_math"
|
|
7
|
+
require_relative "effects"
|
|
8
|
+
require_relative "outcome_accumulator"
|
|
9
|
+
require_relative "plan"
|
|
10
|
+
require_relative "reduction"
|
|
11
|
+
|
|
12
|
+
module Smith
|
|
13
|
+
class Workflow
|
|
14
|
+
module Composite
|
|
15
|
+
class Reducer
|
|
16
|
+
extend Dry::Initializer
|
|
17
|
+
|
|
18
|
+
option :plan
|
|
19
|
+
option :outcomes
|
|
20
|
+
option :primary_failure, optional: true
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
outcome_set = OutcomeAccumulator.new(plan:, outcomes:).call
|
|
24
|
+
ordered = outcome_set.ordered
|
|
25
|
+
validate_effects!(ordered)
|
|
26
|
+
effects = merged_effects(ordered)
|
|
27
|
+
failure_seen, selected_failure = failure_state(ordered)
|
|
28
|
+
return successful_reduction(outcome_set.output, effects) unless failure_seen
|
|
29
|
+
|
|
30
|
+
failed_reduction(selected_failure, effects)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_effects!(ordered)
|
|
36
|
+
usage_ids = {}
|
|
37
|
+
ordered.each { validate_outcome_effects!(_1, usage_ids) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_outcome_effects!(outcome, usage_ids)
|
|
41
|
+
branch = plan.branches.fetch(outcome.ordinal)
|
|
42
|
+
validate_budget!(outcome.effects, branch.budget)
|
|
43
|
+
validate_usage!(outcome.effects.usage_entries, branch, usage_ids)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_usage!(entries, branch, usage_ids)
|
|
47
|
+
entries.each { validate_usage_entry!(_1, branch, usage_ids) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_usage_entry!(entry, branch, usage_ids)
|
|
51
|
+
unless entry.fetch("agent_name") == branch.agent
|
|
52
|
+
raise ArgumentError, "composite usage entry does not match its branch agent"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
usage_id = entry.fetch("usage_id")
|
|
56
|
+
raise ArgumentError, "composite usage entry is duplicated" if usage_ids.key?(usage_id)
|
|
57
|
+
|
|
58
|
+
usage_ids[usage_id] = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_budget!(effects, envelope)
|
|
62
|
+
consumed = effects.budget_consumed
|
|
63
|
+
validate_budget_dimensions!(consumed, envelope)
|
|
64
|
+
envelope.each { |dimension, limit| validate_budget_dimension!(dimension, limit, consumed, effects) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_budget_dimensions!(consumed, envelope)
|
|
68
|
+
return if (consumed.keys - envelope.keys).empty?
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "composite branch consumed an undeclared budget dimension"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_budget_dimension!(dimension, limit, consumed, effects)
|
|
74
|
+
amount = consumed.fetch(dimension, 0)
|
|
75
|
+
unless amount.is_a?(Numeric) && amount.finite? && amount >= 0 && amount <= limit
|
|
76
|
+
raise ArgumentError, "composite branch budget consumption exceeds its envelope"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
expected = expected_consumption(dimension, effects)
|
|
80
|
+
return if decimal_equal?(amount, expected)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "composite branch budget consumption does not match recorded usage"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def expected_consumption(dimension, effects)
|
|
86
|
+
return effects.total_tokens if %w[total_tokens token_limit].include?(dimension)
|
|
87
|
+
return effects.total_cost if dimension == "total_cost"
|
|
88
|
+
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def decimal_equal?(left, right)
|
|
93
|
+
Budget::DecimalContext.call { BigDecimal(left.to_s) == BigDecimal(right.to_s) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def merged_effects(ordered)
|
|
97
|
+
Effects.new(
|
|
98
|
+
usage_entries: ordered.flat_map { _1.effects.usage_entries },
|
|
99
|
+
tool_results: ordered.flat_map { _1.effects.tool_results },
|
|
100
|
+
budget_consumed: BudgetMath.sum(ordered.map { _1.effects.budget_consumed })
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def failure_state(ordered)
|
|
105
|
+
expected_key = primary_failure&.to_s
|
|
106
|
+
failure_seen = false
|
|
107
|
+
selected_failure = nil
|
|
108
|
+
ordered.each do |outcome|
|
|
109
|
+
next unless outcome.failed?
|
|
110
|
+
|
|
111
|
+
failure_seen = true
|
|
112
|
+
selected_failure = outcome if outcome.branch_key == expected_key
|
|
113
|
+
end
|
|
114
|
+
[failure_seen, selected_failure]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def successful_reduction(output, effects)
|
|
118
|
+
raise ArgumentError, "primary failure must be absent for a successful composite" if primary_failure
|
|
119
|
+
|
|
120
|
+
Reduction.new(status: :succeeded, output:, error: nil,
|
|
121
|
+
failed_branch_key: nil, effects:)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def failed_reduction(failure, effects)
|
|
125
|
+
raise ArgumentError, "primary failure must identify a failed branch" unless failure
|
|
126
|
+
|
|
127
|
+
Reduction.new(status: :failed, output: nil, error: failure.error,
|
|
128
|
+
failed_branch_key: failure.branch_key, effects:)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../types"
|
|
4
|
+
require_relative "effects"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
module Composite
|
|
10
|
+
class Reduction < Dry::Struct
|
|
11
|
+
attribute :status, Types::Symbol.enum(:succeeded, :failed)
|
|
12
|
+
attribute? :output, Types::Any.optional
|
|
13
|
+
attribute? :error, Types.Instance(Error).optional
|
|
14
|
+
attribute? :failed_branch_key, Types::String.optional
|
|
15
|
+
attribute :effects, Types.Instance(Effects)
|
|
16
|
+
|
|
17
|
+
def initialize(attributes)
|
|
18
|
+
super
|
|
19
|
+
valid = status == :succeeded ? error.nil? && failed_branch_key.nil? : error && failed_branch_key
|
|
20
|
+
raise ArgumentError, "composite reduction fields do not match status" unless valid
|
|
21
|
+
|
|
22
|
+
self.attributes.freeze
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def succeeded? = status == :succeeded
|
|
27
|
+
def failed? = status == :failed
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "../../errors"
|
|
6
|
+
require_relative "../string_snapshot"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
module Composite
|
|
11
|
+
class ValueBudget
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
ARRAY_EACH = Array.instance_method(:each)
|
|
15
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
16
|
+
private_constant :ARRAY_EACH, :HASH_EACH_PAIR
|
|
17
|
+
|
|
18
|
+
option :max_bytes
|
|
19
|
+
option :max_nodes
|
|
20
|
+
option :max_depth
|
|
21
|
+
option :label
|
|
22
|
+
|
|
23
|
+
def initialize(...)
|
|
24
|
+
super
|
|
25
|
+
@bytes = 0
|
|
26
|
+
@nodes = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(value, depth: 0)
|
|
30
|
+
pending = [[value, depth]]
|
|
31
|
+
until pending.empty?
|
|
32
|
+
item, item_depth = pending.pop
|
|
33
|
+
visit!(item, item_depth, pending)
|
|
34
|
+
end
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def visit!(item, depth, pending)
|
|
41
|
+
validate_visit!(depth)
|
|
42
|
+
dispatch_value(item, depth, pending)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def dispatch_value(item, depth, pending)
|
|
46
|
+
case item
|
|
47
|
+
when Hash then enqueue_hash(item, depth, pending)
|
|
48
|
+
when Array then enqueue_array(item, depth, pending)
|
|
49
|
+
when String, Symbol then add_bytes!(item.to_s)
|
|
50
|
+
when Float then validate_float!(item)
|
|
51
|
+
when Integer, true, false, nil then nil
|
|
52
|
+
else raise WorkflowError, "#{label} contains unsupported value #{item.class}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_visit!(depth)
|
|
57
|
+
raise WorkflowError, "#{label} exceeds maximum depth #{max_depth}" if depth > max_depth
|
|
58
|
+
|
|
59
|
+
@nodes += 1
|
|
60
|
+
raise WorkflowError, "#{label} exceeds maximum size #{max_nodes}" if @nodes > max_nodes
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def enqueue_hash(hash, depth, pending)
|
|
64
|
+
HASH_EACH_PAIR.bind_call(hash) do |key, value|
|
|
65
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
66
|
+
raise WorkflowError, "#{label} contains unsupported Hash key #{key.class}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
pending << [value, depth + 1] << [key, depth + 1]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def enqueue_array(array, depth, pending)
|
|
74
|
+
ARRAY_EACH.bind_call(array) { |value| pending << [value, depth + 1] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def add_bytes!(string)
|
|
78
|
+
@bytes += StringSnapshot.bytesize(string)
|
|
79
|
+
raise WorkflowError, "#{label} exceeds maximum bytes #{max_bytes}" if @bytes > max_bytes
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_float!(float)
|
|
83
|
+
return if float.finite?
|
|
84
|
+
|
|
85
|
+
raise WorkflowError, "#{label} contains a non-finite Float"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|