smith-agents 0.5.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -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/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,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
|
|
@@ -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
|