smith-agents 0.4.5 → 0.5.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 +201 -0
- data/README.md +108 -4
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +347 -25
- data/lib/smith/agent/lifecycle.rb +19 -10
- data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
- data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
- data/lib/smith/agent/registry.rb +8 -0
- data/lib/smith/agent.rb +24 -2
- data/lib/smith/budget/amount_contract.rb +67 -0
- data/lib/smith/budget/amount_representation.rb +43 -0
- data/lib/smith/budget/decimal_context.rb +18 -0
- data/lib/smith/budget/ledger.rb +110 -18
- data/lib/smith/budget/ledger_publication.rb +60 -0
- data/lib/smith/budget/ledger_state.rb +21 -0
- data/lib/smith/budget/ledger_state_transition.rb +72 -0
- data/lib/smith/budget/public_state_validator.rb +25 -0
- data/lib/smith/budget/reservation.rb +20 -0
- data/lib/smith/budget/reservation_contract.rb +34 -0
- data/lib/smith/context/observation_masking.rb +3 -1
- data/lib/smith/context/state_injection.rb +1 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +6 -0
- data/lib/smith/exponential_backoff.rb +128 -0
- data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
- data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
- data/lib/smith/persistence_adapters/cache_store.rb +1 -0
- data/lib/smith/persistence_adapters/memory.rb +16 -1
- data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
- data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
- data/lib/smith/persistence_adapters/redis_store.rb +28 -11
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters.rb +21 -18
- data/lib/smith/persistence_payload_conflict.rb +12 -0
- data/lib/smith/tool/budget_enforcement.rb +2 -2
- data/lib/smith/types.rb +1 -0
- data/lib/smith/version.rb +2 -1
- data/lib/smith/workflow/branch_env.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +21 -11
- data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
- data/lib/smith/workflow/definition_identity.rb +25 -0
- data/lib/smith/workflow/deterministic_step.rb +2 -2
- data/lib/smith/workflow/dsl.rb +73 -5
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +16 -18
- data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
- data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/graph/diagnostic.rb +27 -1
- data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
- data/lib/smith/workflow/graph/execution_successors.rb +65 -0
- data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
- data/lib/smith/workflow/graph/metrics.rb +1 -3
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
- data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
- data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
- data/lib/smith/workflow/graph/reachability.rb +19 -31
- data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
- data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/transition_contract.rb +47 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
- data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
- data/lib/smith/workflow/graph/validator.rb +1 -1
- data/lib/smith/workflow/graph.rb +62 -4
- data/lib/smith/workflow/guardrail_integration.rb +23 -9
- data/lib/smith/workflow/identifier.rb +24 -0
- data/lib/smith/workflow/message_admission.rb +40 -0
- data/lib/smith/workflow/message_admission_boundary.rb +22 -0
- data/lib/smith/workflow/message_batch.rb +60 -0
- data/lib/smith/workflow/message_value_normalizer.rb +133 -0
- data/lib/smith/workflow/nested_execution.rb +10 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -7
- data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
- data/lib/smith/workflow/parallel/execution_context.rb +113 -0
- data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
- data/lib/smith/workflow/parallel/root_execution.rb +91 -0
- data/lib/smith/workflow/parallel.rb +19 -22
- data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
- data/lib/smith/workflow/parallel_execution.rb +7 -3
- data/lib/smith/workflow/persistence.rb +37 -8
- data/lib/smith/workflow/prepared_step.rb +122 -0
- data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
- data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
- data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +49 -47
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
- data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
- data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
- data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
- data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence.rb +36 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/transition.rb +122 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +44 -18
- data/lib/smith.rb +41 -2
- data/sig/smith.rbs +1 -0
- metadata +110 -19
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "transition_contract_configurations"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
class Graph
|
|
10
|
+
class TransitionContractAttributes
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
param :transition
|
|
14
|
+
option :identifiers
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
identity_attributes
|
|
18
|
+
.merge(routing_attributes)
|
|
19
|
+
.merge(kind_attributes)
|
|
20
|
+
.merge(TransitionContractConfigurations.new(transition, identifiers:).to_h)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def identity_attributes
|
|
26
|
+
{
|
|
27
|
+
name: identifiers.call(transition.name),
|
|
28
|
+
from: identifiers.call(transition.from),
|
|
29
|
+
to: identifiers.call(transition.to),
|
|
30
|
+
agent_name: own(transition.agent_name),
|
|
31
|
+
workflow_class: transition.workflow_class
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def routing_attributes
|
|
36
|
+
{
|
|
37
|
+
success_transition: project(transition.success_transition),
|
|
38
|
+
failure_transition: project(transition.failure_transition),
|
|
39
|
+
deterministic_routes: project_array(transition.deterministic_routes)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def kind_attributes
|
|
44
|
+
{
|
|
45
|
+
deterministic_kind: transition.deterministic_kind,
|
|
46
|
+
deterministic: transition.deterministic?,
|
|
47
|
+
parallel: transition.parallel?,
|
|
48
|
+
parallel_count: static_parallel_count
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def static_parallel_count
|
|
53
|
+
return unless transition.parallel?
|
|
54
|
+
|
|
55
|
+
count = transition.agent_opts[:count]
|
|
56
|
+
count.respond_to?(:call) ? nil : (count || 1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def project_array(array)
|
|
60
|
+
array&.map { identifiers.call(_1) }&.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def project(value)
|
|
64
|
+
identifiers.call(value) unless value.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def own(value)
|
|
68
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "transition_optimization_configuration"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
class Graph
|
|
10
|
+
class TransitionContractConfigurations
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
param :transition
|
|
14
|
+
option :identifiers
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{
|
|
18
|
+
router_config: router_config,
|
|
19
|
+
optimization_config: optimization_config,
|
|
20
|
+
orchestrator_config: orchestrator_config,
|
|
21
|
+
fanout_config: fanout_config,
|
|
22
|
+
retry_config: retry_config
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def router_config
|
|
29
|
+
config = transition.router_config
|
|
30
|
+
return unless config
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
routes: project_routes(config.fetch(:routes)),
|
|
34
|
+
confidence_threshold: config.fetch(:confidence_threshold),
|
|
35
|
+
fallback: identifiers.call(config.fetch(:fallback))
|
|
36
|
+
}.freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def optimization_config
|
|
40
|
+
config = transition.optimization_config
|
|
41
|
+
TransitionOptimizationConfiguration.new(config).to_h if config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def orchestrator_config
|
|
45
|
+
config = transition.orchestrator_config
|
|
46
|
+
return unless config
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
orchestrator: own(config.fetch(:orchestrator)),
|
|
50
|
+
worker: own(config.fetch(:worker)),
|
|
51
|
+
max_workers: config.fetch(:max_workers),
|
|
52
|
+
max_delegation_rounds: config.fetch(:max_delegation_rounds),
|
|
53
|
+
task_schema_label: label(config.fetch(:task_schema)),
|
|
54
|
+
worker_output_schema_label: label(config.fetch(:worker_output_schema)),
|
|
55
|
+
final_output_schema_label: label(config.fetch(:final_output_schema))
|
|
56
|
+
}.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fanout_config
|
|
60
|
+
branches = transition.fanout_config&.fetch(:branches, nil)
|
|
61
|
+
{ branches: own_hash(branches) }.freeze if branches
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def retry_config
|
|
65
|
+
config = transition.retry_config
|
|
66
|
+
return unless config
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
error_classes: config.fetch(:error_classes).dup.freeze,
|
|
70
|
+
attempts: config.fetch(:attempts),
|
|
71
|
+
backoff: config.fetch(:backoff),
|
|
72
|
+
max_delay: config[:max_delay],
|
|
73
|
+
jitter: config.fetch(:jitter)
|
|
74
|
+
}.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def own_hash(hash)
|
|
78
|
+
hash.each_with_object({}) do |(key, value), copy|
|
|
79
|
+
copy[own(key)] = own(value)
|
|
80
|
+
end.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def project_routes(routes)
|
|
84
|
+
routes.each_with_object({}) do |(key, transition_name), copy|
|
|
85
|
+
copy[own(key)] = identifiers.call(transition_name)
|
|
86
|
+
end.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def own(value)
|
|
90
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def label(value)
|
|
94
|
+
result = value.respond_to?(:name) && value.name && !value.name.empty? ? value.name : value.inspect
|
|
95
|
+
result.dup.freeze
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -17,7 +17,9 @@ module Smith
|
|
|
17
17
|
transition_target_diagnostic(transition, :failure_transition, transition.failure_transition),
|
|
18
18
|
*router_target_diagnostics(transition),
|
|
19
19
|
*deterministic_route_diagnostics(transition),
|
|
20
|
-
*target_state_mismatch_diagnostics(transition)
|
|
20
|
+
*target_state_mismatch_diagnostics(transition),
|
|
21
|
+
branch_limit_diagnostic(transition),
|
|
22
|
+
RetryPolicyDiagnostic.new(transition:).call
|
|
21
23
|
].compact
|
|
22
24
|
end
|
|
23
25
|
end
|
|
@@ -53,25 +55,65 @@ module Smith
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def target_state_mismatch_diagnostics(transition)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
next unless target
|
|
59
|
-
next if target.from.nil? || target.from == transition.to
|
|
60
|
-
|
|
61
|
-
mismatch_diagnostic(transition, target_name, target)
|
|
58
|
+
success = successful_target_names(transition).filter_map do |target_name|
|
|
59
|
+
target_state_mismatch_diagnostic(transition, target_name, expected_state: transition.to)
|
|
62
60
|
end
|
|
61
|
+
failure = target_state_mismatch_diagnostic(
|
|
62
|
+
transition,
|
|
63
|
+
transition.failure_transition,
|
|
64
|
+
expected_state: transition.from
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
[*success, failure].compact
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
def
|
|
70
|
+
def branch_limit_diagnostic(transition)
|
|
71
|
+
branch_count = static_branch_count(transition)
|
|
72
|
+
limit = Smith.config.parallel_branch_limit
|
|
73
|
+
return unless branch_count && branch_count > limit
|
|
74
|
+
|
|
66
75
|
Diagnostic.new(
|
|
67
|
-
severity: :
|
|
76
|
+
severity: :error,
|
|
77
|
+
code: :parallel_branch_limit_exceeded,
|
|
78
|
+
transition: transition.name,
|
|
79
|
+
message: "Transition #{ref(transition.name)} declares #{branch_count} parallel branches, " \
|
|
80
|
+
"exceeding the configured limit #{limit}.",
|
|
81
|
+
suggestion: "Reduce the branch count or explicitly raise Smith.config.parallel_branch_limit."
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def static_branch_count(transition)
|
|
86
|
+
return transition.fanout_config.fetch(:branches).length if transition.fanout?
|
|
87
|
+
|
|
88
|
+
transition.parallel_count if transition.parallel?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def successful_target_names(transition)
|
|
92
|
+
return Targets.router_for(transition) if transition.routed?
|
|
93
|
+
|
|
94
|
+
[transition.success_transition, *Array(transition.deterministic_routes)].compact.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def target_state_mismatch_diagnostic(transition, target_name, expected_state:)
|
|
98
|
+
return if target_name.nil?
|
|
99
|
+
|
|
100
|
+
target = graph.transitions[target_name]
|
|
101
|
+
return unless target
|
|
102
|
+
return if target.from.nil? || target.from == expected_state
|
|
103
|
+
|
|
104
|
+
mismatch_diagnostic(transition, target_name, target, expected_state:)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def mismatch_diagnostic(transition, target_name, target, expected_state:)
|
|
108
|
+
Diagnostic.new(
|
|
109
|
+
severity: :error,
|
|
68
110
|
code: :target_from_state_mismatch,
|
|
69
111
|
transition: transition.name,
|
|
70
112
|
target: target_name,
|
|
71
|
-
state:
|
|
113
|
+
state: expected_state,
|
|
72
114
|
message: "Transition #{ref(transition.name)} can route to #{ref(target_name)}, " \
|
|
73
|
-
"but #{ref(target_name)} starts from #{ref(target.from)} instead of #{ref(
|
|
74
|
-
suggestion: "Align #{ref(target_name)}'s from state with #{ref(
|
|
115
|
+
"but #{ref(target_name)} starts from #{ref(target.from)} instead of #{ref(expected_state)}.",
|
|
116
|
+
suggestion: "Align #{ref(target_name)}'s from state with #{ref(expected_state)}, " \
|
|
75
117
|
"or remove the named route."
|
|
76
118
|
)
|
|
77
119
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
class TransitionOptimizationConfiguration
|
|
9
|
+
extend Dry::Initializer
|
|
10
|
+
|
|
11
|
+
param :config
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
identity
|
|
15
|
+
.merge(evaluation)
|
|
16
|
+
.merge(exit_modes)
|
|
17
|
+
.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def identity
|
|
23
|
+
{
|
|
24
|
+
generator: own(config.fetch(:generator)),
|
|
25
|
+
evaluator: own(config.fetch(:evaluator)),
|
|
26
|
+
max_rounds: config.fetch(:max_rounds)
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def evaluation
|
|
31
|
+
{
|
|
32
|
+
evaluator_schema_label: label(config.fetch(:evaluator_schema)),
|
|
33
|
+
evaluator_context: callable_marker(config[:evaluator_context]),
|
|
34
|
+
improvement_threshold: config[:improvement_threshold],
|
|
35
|
+
before_eval: callable_marker(config[:before_eval])
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exit_modes
|
|
40
|
+
{
|
|
41
|
+
on_exhaustion: callable_marker(config.fetch(:on_exhaustion)),
|
|
42
|
+
on_converged: callable_marker(config.fetch(:on_converged)),
|
|
43
|
+
on_threshold: callable_marker(config.fetch(:on_threshold))
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def own(value)
|
|
48
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def label(value)
|
|
52
|
+
result = value.respond_to?(:name) && value.name && !value.name.empty? ? value.name : value.inspect
|
|
53
|
+
result.dup.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def callable_marker(value)
|
|
57
|
+
value.respond_to?(:call) ? :callable : own(value)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -26,6 +26,7 @@ module Smith
|
|
|
26
26
|
option :from
|
|
27
27
|
option :to
|
|
28
28
|
option :kind
|
|
29
|
+
option :parallel_count, default: proc {}
|
|
29
30
|
option :success_transition, default: proc {}
|
|
30
31
|
option :failure_transition, default: proc {}
|
|
31
32
|
option :routes, default: proc {}
|
|
@@ -46,7 +47,8 @@ module Smith
|
|
|
46
47
|
name: transition.name,
|
|
47
48
|
from: transition.from,
|
|
48
49
|
to: transition.to,
|
|
49
|
-
kind: kind_for(transition)
|
|
50
|
+
kind: kind_for(transition),
|
|
51
|
+
parallel_count: transition.parallel_count
|
|
50
52
|
}.merge(routing_attributes(transition), contract_attributes(transition, workflow_class: workflow_class))
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -109,6 +111,7 @@ module Smith
|
|
|
109
111
|
from: from,
|
|
110
112
|
to: to,
|
|
111
113
|
kind: kind,
|
|
114
|
+
parallel_count: parallel_count,
|
|
112
115
|
success_transition: success_transition,
|
|
113
116
|
failure_transition: failure_transition,
|
|
114
117
|
routes: routes,
|
data/lib/smith/workflow/graph.rb
CHANGED
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
class Graph
|
|
6
|
+
EMPTY_TRANSITIONS = [].freeze
|
|
7
|
+
|
|
6
8
|
attr_reader :workflow_class, :initial_state, :states, :transitions
|
|
7
9
|
|
|
8
10
|
def initialize(workflow_class:, initial_state:, states:, transitions:)
|
|
11
|
+
@identifier_projection = IdentifierProjection.new
|
|
9
12
|
@workflow_class = workflow_class
|
|
10
|
-
@initial_state = initial_state
|
|
11
|
-
@states =
|
|
12
|
-
@
|
|
13
|
+
@initial_state = project_identifier(initial_state)
|
|
14
|
+
@states = own_states(states)
|
|
15
|
+
@state_index = index_states
|
|
16
|
+
@transitions = own_transitions(transitions)
|
|
17
|
+
@transitions_by_state = build_transitions_by_state
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
def validate
|
|
@@ -25,13 +30,66 @@ module Smith
|
|
|
25
30
|
TransitionSnapshot.from_transition(transition, workflow_class: workflow_class)
|
|
26
31
|
end
|
|
27
32
|
end
|
|
33
|
+
|
|
34
|
+
def transitions_from(state)
|
|
35
|
+
@transitions_by_state.fetch(state, EMPTY_TRANSITIONS)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def first_transition_from(state)
|
|
39
|
+
transitions_from(state).first
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reachable_transitions
|
|
43
|
+
@reachable_transitions ||= Reachability.new(self).each_transition.to_a.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def state?(state)
|
|
47
|
+
@state_index.key?(state)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def project_identifier(identifier)
|
|
53
|
+
@identifier_projection.call(identifier)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def own_states(states)
|
|
57
|
+
Array(states).map { project_identifier(_1) }.uniq.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def index_states
|
|
61
|
+
states.to_h { [_1, true] }.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def own_transitions(transitions)
|
|
65
|
+
transitions.each_value.with_index.with_object({}) do |(transition, definition_index), snapshot|
|
|
66
|
+
contract = TransitionContract.from_transition(
|
|
67
|
+
transition,
|
|
68
|
+
identifiers: @identifier_projection,
|
|
69
|
+
definition_index:
|
|
70
|
+
)
|
|
71
|
+
snapshot[contract.name] = contract
|
|
72
|
+
end.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_transitions_by_state
|
|
76
|
+
index = transitions.values.each_with_object({}) do |transition, result|
|
|
77
|
+
(result[transition.from] ||= []) << transition
|
|
78
|
+
end
|
|
79
|
+
index.each_value(&:freeze)
|
|
80
|
+
index.freeze
|
|
81
|
+
end
|
|
28
82
|
end
|
|
29
83
|
end
|
|
30
84
|
end
|
|
31
85
|
|
|
32
86
|
require_relative "graph/reference"
|
|
87
|
+
require_relative "graph/identifier_projection"
|
|
88
|
+
require_relative "graph/transition_contract"
|
|
33
89
|
require_relative "graph/diagnostic"
|
|
34
90
|
require_relative "graph/state_diagnostics"
|
|
91
|
+
require_relative "graph/targets"
|
|
92
|
+
require_relative "graph/execution_successors"
|
|
35
93
|
require_relative "graph/reachability"
|
|
36
94
|
require_relative "graph/reachability_diagnostics"
|
|
37
95
|
require_relative "graph/metrics"
|
|
@@ -41,11 +99,11 @@ require_relative "graph/runtime_binding_diagnostic_builder"
|
|
|
41
99
|
require_relative "graph/runtime_binding_diagnostics"
|
|
42
100
|
require_relative "graph/runtime_readiness_report"
|
|
43
101
|
require_relative "graph/runtime_readiness_metrics"
|
|
44
|
-
require_relative "graph/targets"
|
|
45
102
|
require_relative "graph/fanout_contract"
|
|
46
103
|
require_relative "graph/optimization_contract"
|
|
47
104
|
require_relative "graph/orchestration_contract"
|
|
48
105
|
require_relative "graph/transition_snapshot"
|
|
106
|
+
require_relative "graph/retry_policy_diagnostic"
|
|
49
107
|
require_relative "graph/transition_diagnostics"
|
|
50
108
|
require_relative "graph/validator"
|
|
51
109
|
require_relative "graph/runtime_readiness"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "transition_actionability"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
class Workflow
|
|
5
7
|
module GuardrailIntegration
|
|
@@ -21,30 +23,42 @@ module Smith
|
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def handle_step_failure(transition, error)
|
|
26
|
+
step = { transition: transition.name, from: transition.from, to: transition.to, error: error }
|
|
27
|
+
SplitStepPersistence
|
|
28
|
+
.instance_method(:capture_split_step_execution_result!)
|
|
29
|
+
.bind_call(self, step)
|
|
24
30
|
failure_name = transition.failure_transition
|
|
25
31
|
raise error unless failure_name
|
|
26
32
|
|
|
27
33
|
fail_transition = self.class.find_transition(failure_name)
|
|
28
34
|
raise error unless fail_transition
|
|
35
|
+
|
|
29
36
|
validate_transition_origin!(fail_transition)
|
|
30
37
|
|
|
31
38
|
if actionable_failure_transition?(fail_transition)
|
|
32
39
|
@next_transition_name = failure_name
|
|
33
|
-
return
|
|
40
|
+
return step
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
@state = fail_transition.to
|
|
44
|
+
step
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handle_unresolved_transition_failure(error)
|
|
48
|
+
fail_transition = self.class.find_transition(:fail)
|
|
49
|
+
raise error unless fail_transition
|
|
50
|
+
|
|
51
|
+
@outcome = nil
|
|
52
|
+
step = { transition: error.requested_name, from: @state, to: fail_transition.to, error: error }
|
|
53
|
+
SplitStepPersistence
|
|
54
|
+
.instance_method(:capture_split_step_execution_result!)
|
|
55
|
+
.bind_call(self, step)
|
|
56
|
+
@state = fail_transition.to
|
|
57
|
+
step
|
|
37
58
|
end
|
|
38
59
|
|
|
39
60
|
def actionable_failure_transition?(transition)
|
|
40
|
-
transition
|
|
41
|
-
transition.deterministic? ||
|
|
42
|
-
transition.routed? ||
|
|
43
|
-
transition.fanout? ||
|
|
44
|
-
transition.nested? ||
|
|
45
|
-
transition.optimized? ||
|
|
46
|
-
transition.orchestrated? ||
|
|
47
|
-
transition.success_transition
|
|
61
|
+
TransitionActionability.call(transition)
|
|
48
62
|
end
|
|
49
63
|
|
|
50
64
|
def run_workflow_input_guardrails
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Identifier
|
|
6
|
+
def self.normalize(value, label:, allow_nil: false)
|
|
7
|
+
return if value.nil? && allow_nil
|
|
8
|
+
|
|
9
|
+
case value
|
|
10
|
+
when String
|
|
11
|
+
raise WorkflowError, "#{label} must not be blank" if value.strip.empty?
|
|
12
|
+
|
|
13
|
+
value.dup.freeze
|
|
14
|
+
when Symbol
|
|
15
|
+
raise WorkflowError, "#{label} must not be blank" if value.to_s.empty?
|
|
16
|
+
|
|
17
|
+
value
|
|
18
|
+
else
|
|
19
|
+
raise WorkflowError, "#{label} must be a String or Symbol"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "dry-struct"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "../types"
|
|
9
|
+
require_relative "message_value_normalizer"
|
|
10
|
+
|
|
11
|
+
module Smith
|
|
12
|
+
class Workflow
|
|
13
|
+
class MessageAdmission < Dry::Struct
|
|
14
|
+
transform_keys(&:to_sym)
|
|
15
|
+
schema schema.strict
|
|
16
|
+
|
|
17
|
+
attribute :messages, Types::Array.of(Types::Hash)
|
|
18
|
+
|
|
19
|
+
attr_reader :message_digest
|
|
20
|
+
|
|
21
|
+
def initialize(attributes)
|
|
22
|
+
normalized = MessageValueNormalizer.new(attributes.fetch(:messages)).call
|
|
23
|
+
payload = JSON.generate(normalized, max_nesting: MessageValueNormalizer::MAX_DEPTH)
|
|
24
|
+
if payload.bytesize > MessageValueNormalizer::MAX_BYTES
|
|
25
|
+
raise WorkflowError,
|
|
26
|
+
"session message batch exceeds maximum bytes #{MessageValueNormalizer::MAX_BYTES}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
super(messages: normalized)
|
|
30
|
+
@message_digest = Digest::SHA256.hexdigest(payload).freeze
|
|
31
|
+
self.attributes.freeze
|
|
32
|
+
freeze
|
|
33
|
+
rescue JSON::GeneratorError, JSON::NestingError => e
|
|
34
|
+
raise WorkflowError, "session message batch is not canonical JSON: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message_count = messages.length
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "message_batch"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
module MessageAdmissionBoundary
|
|
8
|
+
def append_session_messages!(messages)
|
|
9
|
+
admission = MessageBatch.new(messages).call
|
|
10
|
+
@split_step_mutex.synchronize do
|
|
11
|
+
SplitStepPersistence.instance_method(:ensure_no_split_step_boundary!).bind_call(self)
|
|
12
|
+
unless @session_messages.nil? || @session_messages.is_a?(Array)
|
|
13
|
+
raise WorkflowError, "workflow session messages are not appendable"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
(@session_messages ||= []).concat(admission.messages)
|
|
17
|
+
end
|
|
18
|
+
admission
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "message_admission"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
class MessageBatch
|
|
11
|
+
MAX_MESSAGES = 100
|
|
12
|
+
MAX_DEPTH = MessageValueNormalizer::MAX_DEPTH
|
|
13
|
+
MAX_NODES = MessageValueNormalizer::MAX_NODES
|
|
14
|
+
MAX_BYTES = MessageValueNormalizer::MAX_BYTES
|
|
15
|
+
ARRAY_EACH = Array.instance_method(:each)
|
|
16
|
+
private_constant :ARRAY_EACH
|
|
17
|
+
|
|
18
|
+
extend Dry::Initializer
|
|
19
|
+
|
|
20
|
+
param :messages
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
batch = normalize_batch
|
|
24
|
+
batch.each { reject!("each session message must be a Hash") unless _1.is_a?(Hash) }
|
|
25
|
+
admission = MessageAdmission.new(messages: batch)
|
|
26
|
+
admission.messages.each { validate_message!(_1) }
|
|
27
|
+
admission
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_batch
|
|
33
|
+
return [messages] if messages.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
reject!("session messages must be a message Hash or an Array of message Hashes") unless messages.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
batch = []
|
|
38
|
+
ARRAY_EACH.bind_call(messages) do |message|
|
|
39
|
+
reject!("session message batch exceeds maximum count #{MAX_MESSAGES}") if batch.length == MAX_MESSAGES
|
|
40
|
+
|
|
41
|
+
batch << message
|
|
42
|
+
end
|
|
43
|
+
reject!("session message batch must not be empty") if batch.empty?
|
|
44
|
+
|
|
45
|
+
batch
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_message!(message)
|
|
49
|
+
reject!("each session message must be a Hash") unless message.is_a?(Hash)
|
|
50
|
+
role = message["role"]
|
|
51
|
+
reject!("each session message requires a non-empty role") unless
|
|
52
|
+
role.is_a?(String) && !role.strip.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reject!(message)
|
|
56
|
+
raise WorkflowError, message
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|