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,133 @@
|
|
|
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
|
+
class MessageValueNormalizer
|
|
11
|
+
MAX_DEPTH = 64
|
|
12
|
+
MAX_NODES = 100_000
|
|
13
|
+
MAX_BYTES = 1 * 1024 * 1024
|
|
14
|
+
INTEGER_RANGE = (-(2**63)..((2**63) - 1))
|
|
15
|
+
ARRAY_EACH = Array.instance_method(:each)
|
|
16
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
17
|
+
private_constant :ARRAY_EACH, :HASH_EACH_PAIR
|
|
18
|
+
|
|
19
|
+
extend Dry::Initializer
|
|
20
|
+
|
|
21
|
+
param :value
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
@active = {}.compare_by_identity
|
|
25
|
+
@nodes = 0
|
|
26
|
+
@bytes = 0
|
|
27
|
+
copy_value(value, depth: 0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def copy_value(item, depth:)
|
|
33
|
+
visit!(depth)
|
|
34
|
+
return copy_container_value(item, depth:) if item.is_a?(Hash) || item.is_a?(Array)
|
|
35
|
+
|
|
36
|
+
copy_scalar(item)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def copy_container_value(item, depth:)
|
|
40
|
+
copy_container(item) do
|
|
41
|
+
item.is_a?(Hash) ? copy_hash(item, depth:) : copy_array(item, depth:)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def copy_scalar(item)
|
|
46
|
+
case item
|
|
47
|
+
when String then copy_string(item)
|
|
48
|
+
when Symbol then copy_string(item.to_s)
|
|
49
|
+
when Integer then copy_integer(item)
|
|
50
|
+
when Float then copy_float(item)
|
|
51
|
+
when true, false, nil then item
|
|
52
|
+
else
|
|
53
|
+
reject!("session message contains unsupported value #{item.class}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def copy_container(source)
|
|
58
|
+
reject!("session message contains a cyclic value") if @active.key?(source)
|
|
59
|
+
|
|
60
|
+
@active[source] = true
|
|
61
|
+
yield
|
|
62
|
+
ensure
|
|
63
|
+
@active.delete(source)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def copy_hash(source, depth:)
|
|
67
|
+
pairs = canonical_pairs(source)
|
|
68
|
+
pairs.sort_by!(&:first)
|
|
69
|
+
validate_unique_keys!(pairs)
|
|
70
|
+
|
|
71
|
+
pairs.each_with_object({}) do |(key, nested), copy|
|
|
72
|
+
copy[key] = copy_value(nested, depth: depth + 1)
|
|
73
|
+
end.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def canonical_pairs(source)
|
|
77
|
+
pairs = []
|
|
78
|
+
remaining_nodes = MAX_NODES - @nodes
|
|
79
|
+
HASH_EACH_PAIR.bind_call(source) do |key, nested|
|
|
80
|
+
reject!("session message batch exceeds maximum size #{MAX_NODES}") if pairs.length == remaining_nodes
|
|
81
|
+
reject!("session message Hash keys must be strings or symbols") unless key.is_a?(String) || key.is_a?(Symbol)
|
|
82
|
+
|
|
83
|
+
pairs << [copy_string(key.to_s), nested]
|
|
84
|
+
end
|
|
85
|
+
pairs
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_unique_keys!(pairs)
|
|
89
|
+
return unless pairs.each_cons(2).any? { |left, right| left.first == right.first }
|
|
90
|
+
|
|
91
|
+
reject!("session message contains duplicate canonical Hash keys")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def copy_array(source, depth:)
|
|
95
|
+
copy = []
|
|
96
|
+
ARRAY_EACH.bind_call(source) { |nested| copy << copy_value(nested, depth: depth + 1) }
|
|
97
|
+
copy.freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def copy_string(string)
|
|
101
|
+
@bytes += StringSnapshot.bytesize(string)
|
|
102
|
+
reject!("session message batch exceeds maximum bytes #{MAX_BYTES}") if @bytes > MAX_BYTES
|
|
103
|
+
|
|
104
|
+
StringSnapshot.copy(string, freeze: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def copy_integer(integer)
|
|
108
|
+
return integer if INTEGER_RANGE.cover?(integer)
|
|
109
|
+
|
|
110
|
+
reject!("session message integers must fit a signed 64-bit value")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def copy_float(float)
|
|
114
|
+
return float if float.finite?
|
|
115
|
+
|
|
116
|
+
reject!("session message contains a non-finite Float")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def visit!(depth)
|
|
120
|
+
reject!("session message batch exceeds maximum depth #{MAX_DEPTH}") if depth > MAX_DEPTH
|
|
121
|
+
|
|
122
|
+
@nodes += 1
|
|
123
|
+
return if @nodes <= MAX_NODES
|
|
124
|
+
|
|
125
|
+
reject!("session message batch exceeds maximum size #{MAX_NODES}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reject!(message)
|
|
129
|
+
raise WorkflowError, message
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -21,10 +21,20 @@ module Smith
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def build_child_workflow(child_class)
|
|
24
|
+
@split_step_active_execution_authorization&.verify_workflow!(child_class)
|
|
24
25
|
child = child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
|
|
26
|
+
unless child.instance_of?(child_class)
|
|
27
|
+
raise WorkflowError, "nested workflow constructor returned #{child.class} instead of #{child_class}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@split_step_active_execution_authorization&.verify_workflow!(child_class)
|
|
25
31
|
child.instance_variable_set(:@execution_namespace, @execution_namespace)
|
|
26
32
|
child.instance_variable_set(:@inherited_deadline, wall_clock_deadline)
|
|
27
33
|
child.instance_variable_set(:@inherited_scoped_artifacts, Smith.scoped_artifacts)
|
|
34
|
+
child.instance_variable_set(
|
|
35
|
+
:@split_step_active_execution_authorization,
|
|
36
|
+
@split_step_active_execution_authorization
|
|
37
|
+
)
|
|
28
38
|
child
|
|
29
39
|
end
|
|
30
40
|
|
|
@@ -23,13 +23,13 @@ module Smith
|
|
|
23
23
|
|
|
24
24
|
def execute_orchestration_step(transition, prepared_input: nil)
|
|
25
25
|
state = OrchestrationState.new(transition.orchestrator_config, prepared_input)
|
|
26
|
-
state.orchestrator_class =
|
|
26
|
+
state.orchestrator_class = resolve_registered_agent!(
|
|
27
27
|
state.config[:orchestrator],
|
|
28
28
|
workflow_class: self.class,
|
|
29
29
|
transition_name: transition.name,
|
|
30
30
|
role: :orchestrator
|
|
31
31
|
)
|
|
32
|
-
state.worker_class =
|
|
32
|
+
state.worker_class = resolve_registered_agent!(
|
|
33
33
|
state.config[:worker],
|
|
34
34
|
workflow_class: self.class,
|
|
35
35
|
transition_name: transition.name,
|
|
@@ -77,11 +77,7 @@ module Smith
|
|
|
77
77
|
|
|
78
78
|
def run_worker_with_schema(worker_class, task, worker_output_schema)
|
|
79
79
|
input = [{ role: :user, content: task.to_json }]
|
|
80
|
-
|
|
81
|
-
worker_class.output_schema(worker_output_schema)
|
|
82
|
-
invoke_agent_with_budget(worker_class, input)
|
|
83
|
-
ensure
|
|
84
|
-
worker_class.output_schema(original_schema)
|
|
80
|
+
invoke_agent_with_budget(worker_class, input, output_schema: worker_output_schema)
|
|
85
81
|
end
|
|
86
82
|
|
|
87
83
|
def prepare_orchestrator_input(prepared_input, round, worker_results)
|
|
@@ -3,17 +3,26 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
class Parallel
|
|
6
|
-
CancellationSignal
|
|
6
|
+
class CancellationSignal
|
|
7
7
|
def initialize
|
|
8
|
-
|
|
8
|
+
@cancelled = false
|
|
9
|
+
@reason = nil
|
|
10
|
+
@mutex = Mutex.new
|
|
9
11
|
end
|
|
10
12
|
|
|
11
|
-
def cancel!
|
|
12
|
-
mutex.synchronize
|
|
13
|
+
def cancel!(error = nil)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
@reason ||= error
|
|
16
|
+
@cancelled = true
|
|
17
|
+
end
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
def cancelled?
|
|
16
|
-
mutex.synchronize { cancelled }
|
|
21
|
+
@mutex.synchronize { @cancelled }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reason
|
|
25
|
+
@mutex.synchronize { @reason }
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Parallel
|
|
8
|
+
class ExecutionContext
|
|
9
|
+
extend Dry::Initializer
|
|
10
|
+
|
|
11
|
+
THREAD_KEY = :smith_parallel_execution_context
|
|
12
|
+
DEPTH_KEY = :smith_parallel_execution_depth
|
|
13
|
+
|
|
14
|
+
option :executor
|
|
15
|
+
option :signal
|
|
16
|
+
option :concurrency
|
|
17
|
+
option :nesting_limit
|
|
18
|
+
option :top_level_branch_count
|
|
19
|
+
|
|
20
|
+
def initialize(...)
|
|
21
|
+
super
|
|
22
|
+
@pending_top_level = top_level_branch_count
|
|
23
|
+
@active_top_level = 0
|
|
24
|
+
@reserved_nested_workers = 0
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.current
|
|
29
|
+
Fiber[THREAD_KEY]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.current_depth
|
|
33
|
+
Fiber[DEPTH_KEY] || 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def within(depth: self.class.current_depth)
|
|
37
|
+
previous = self.class.current
|
|
38
|
+
previous_depth = Fiber[DEPTH_KEY]
|
|
39
|
+
Fiber[THREAD_KEY] = self
|
|
40
|
+
Fiber[DEPTH_KEY] = depth
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
Fiber[THREAD_KEY] = previous
|
|
44
|
+
Fiber[DEPTH_KEY] = previous_depth
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def raise_if_cancelled!
|
|
48
|
+
raise(signal.reason || Cancellation.new("cancelled")) if signal.cancelled?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def next_nesting_depth!
|
|
52
|
+
depth = self.class.current_depth + 1
|
|
53
|
+
return depth if depth <= nesting_limit
|
|
54
|
+
|
|
55
|
+
raise WorkflowError, "parallel nesting exceeds configured limit #{nesting_limit}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def top_level_started!
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
@pending_top_level -= 1
|
|
61
|
+
@active_top_level += 1
|
|
62
|
+
validate_worker_count!
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def top_level_finished!
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@active_top_level -= 1
|
|
69
|
+
validate_worker_count!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reserve_workers(requested)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
reserved = [requested, available_workers].min
|
|
76
|
+
@reserved_nested_workers += reserved
|
|
77
|
+
validate_worker_count!
|
|
78
|
+
reserved
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def release_workers(count)
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@reserved_nested_workers -= count
|
|
85
|
+
validate_worker_count!
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def validate_worker_count!
|
|
92
|
+
valid = @pending_top_level >= 0 && @active_top_level >= 0 && @reserved_nested_workers >= 0
|
|
93
|
+
return if valid && occupied_workers <= concurrency
|
|
94
|
+
|
|
95
|
+
raise WorkflowError, "parallel worker accounting exceeded its concurrency bound"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def available_workers
|
|
99
|
+
concurrency - occupied_workers
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def occupied_workers
|
|
103
|
+
@active_top_level + reserved_top_level_workers + @reserved_nested_workers
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reserved_top_level_workers
|
|
107
|
+
capacity = [concurrency - @active_top_level - @reserved_nested_workers, 0].max
|
|
108
|
+
[@pending_top_level, capacity].min
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "dry-initializer"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Workflow
|
|
8
|
+
class Parallel
|
|
9
|
+
class NestedExecution
|
|
10
|
+
extend Dry::Initializer
|
|
11
|
+
|
|
12
|
+
option :branches
|
|
13
|
+
option :context
|
|
14
|
+
|
|
15
|
+
def initialize(...)
|
|
16
|
+
super
|
|
17
|
+
@depth = context.next_nesting_depth!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
Thread.handle_interrupt(Object => :never) do
|
|
22
|
+
prepare
|
|
23
|
+
Thread.handle_interrupt(Object => :immediate) { execute }
|
|
24
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
25
|
+
context.signal.cancel!(e)
|
|
26
|
+
raise
|
|
27
|
+
ensure
|
|
28
|
+
drain_futures
|
|
29
|
+
release_workers
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def prepare
|
|
36
|
+
context.raise_if_cancelled!
|
|
37
|
+
@reserved_workers = context.reserve_workers(branches.length - 1)
|
|
38
|
+
return if @reserved_workers.zero?
|
|
39
|
+
|
|
40
|
+
@values = Array.new(branches.length)
|
|
41
|
+
@cursor = Concurrent::AtomicFixnum.new(0)
|
|
42
|
+
@futures = []
|
|
43
|
+
@reserved_workers.times { @futures << build_future }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def execute
|
|
47
|
+
return execute_inline if @reserved_workers.zero?
|
|
48
|
+
|
|
49
|
+
execute_concurrently
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def execute_concurrently
|
|
53
|
+
fulfilled, reasons = execute_workers
|
|
54
|
+
raise_worker_failure!(fulfilled, reasons)
|
|
55
|
+
@values
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute_workers
|
|
59
|
+
current_error = capture_error { work }
|
|
60
|
+
fulfilled, _values, reasons = Concurrent::Promises.zip(*@futures).result
|
|
61
|
+
[fulfilled, [context.signal.reason, current_error, *reasons]]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def raise_worker_failure!(fulfilled, reasons)
|
|
65
|
+
error = Parallel.preferred_error(reasons)
|
|
66
|
+
raise(error || Cancellation.new("parallel execution failed")) unless fulfilled && !error
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_future
|
|
70
|
+
Concurrent::Promises.future_on(context.executor, context, @depth) do |execution_context, depth|
|
|
71
|
+
execution_context.within(depth:) { work }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def work
|
|
76
|
+
while (index = next_index)
|
|
77
|
+
@values[index] = execute_branch(index)
|
|
78
|
+
end
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
context.signal.cancel!(e)
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def next_index
|
|
85
|
+
context.raise_if_cancelled!
|
|
86
|
+
index = @cursor.increment - 1
|
|
87
|
+
return if index >= branches.length
|
|
88
|
+
|
|
89
|
+
context.raise_if_cancelled!
|
|
90
|
+
index
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def execute_branch(index)
|
|
94
|
+
context.within(depth: @depth) { branches.fetch(index).call(context.signal) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def execute_inline
|
|
98
|
+
branches.map do |branch|
|
|
99
|
+
context.raise_if_cancelled!
|
|
100
|
+
context.within(depth: @depth) { branch.call(context.signal) }
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
context.signal.cancel!(e)
|
|
104
|
+
raise
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def capture_error
|
|
108
|
+
yield
|
|
109
|
+
nil
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
e
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def drain_futures
|
|
115
|
+
Concurrent::Promises.zip(*@futures).result if @futures&.any?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def release_workers
|
|
119
|
+
context.release_workers(@reserved_workers) if @reserved_workers&.positive?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "dry-initializer"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Workflow
|
|
8
|
+
class Parallel
|
|
9
|
+
class RootExecution
|
|
10
|
+
extend Dry::Initializer
|
|
11
|
+
|
|
12
|
+
option :branches
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
Thread.handle_interrupt(Object => :never) do
|
|
16
|
+
prepare
|
|
17
|
+
values = Thread.handle_interrupt(Object => :immediate) { resolve(@futures) }
|
|
18
|
+
@completed = true
|
|
19
|
+
values
|
|
20
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
21
|
+
@signal&.cancel!(e)
|
|
22
|
+
raise
|
|
23
|
+
ensure
|
|
24
|
+
cleanup
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def prepare
|
|
31
|
+
@completed = false
|
|
32
|
+
@signal = CancellationSignal.new
|
|
33
|
+
@concurrency = Smith.config.parallel_concurrency
|
|
34
|
+
@nesting_limit = Smith.config.parallel_nesting_limit
|
|
35
|
+
@executor = build_executor
|
|
36
|
+
@futures = build_futures(build_context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_executor
|
|
40
|
+
Concurrent::FixedThreadPool.new(
|
|
41
|
+
@concurrency,
|
|
42
|
+
max_queue: [@concurrency, branches.length].max,
|
|
43
|
+
fallback_policy: :abort
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_context
|
|
48
|
+
ExecutionContext.new(
|
|
49
|
+
executor: @executor,
|
|
50
|
+
signal: @signal,
|
|
51
|
+
concurrency: @concurrency,
|
|
52
|
+
nesting_limit: @nesting_limit,
|
|
53
|
+
top_level_branch_count: branches.length
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_futures(context)
|
|
58
|
+
branches.map { future_for(_1, context) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def future_for(branch, context)
|
|
62
|
+
Concurrent::Promises.future_on(context.executor, branch, context) do |callable, execution_context|
|
|
63
|
+
execution_context.top_level_started!
|
|
64
|
+
execution_context.raise_if_cancelled!
|
|
65
|
+
execution_context.within(depth: 0) { callable.call(execution_context.signal) }
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
execution_context.signal.cancel!(e)
|
|
68
|
+
raise
|
|
69
|
+
ensure
|
|
70
|
+
execution_context.top_level_finished!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve(futures)
|
|
75
|
+
fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
|
|
76
|
+
raise(@signal.reason || Parallel.preferred_error(reasons)) unless fulfilled
|
|
77
|
+
|
|
78
|
+
values
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cleanup
|
|
82
|
+
@signal&.cancel!(Cancellation.new("parallel execution interrupted")) unless @completed
|
|
83
|
+
return unless @executor
|
|
84
|
+
|
|
85
|
+
@executor.shutdown
|
|
86
|
+
@executor.wait_for_termination
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require_relative "parallel/cancellation"
|
|
5
4
|
require_relative "parallel/cancellation_signal"
|
|
5
|
+
require_relative "parallel/execution_context"
|
|
6
|
+
require_relative "parallel/nested_execution"
|
|
7
|
+
require_relative "parallel/root_execution"
|
|
6
8
|
|
|
7
9
|
module Smith
|
|
8
10
|
class Workflow
|
|
@@ -10,35 +12,30 @@ module Smith
|
|
|
10
12
|
def self.resolve_branch_count(transition, context)
|
|
11
13
|
count = transition.agent_opts[:count]
|
|
12
14
|
resolved = count.respond_to?(:call) ? count.call(context) : (count || 1)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
raise WorkflowError, "parallel branch count must be a positive integer"
|
|
15
|
+
validate_branch_count!(resolved)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def self.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
futures = branches.map do |branch|
|
|
22
|
-
Concurrent::Promises.future(branch, signal) do |b, s|
|
|
23
|
-
b.call(s)
|
|
24
|
-
rescue StandardError
|
|
25
|
-
s.cancel!
|
|
26
|
-
raise
|
|
27
|
-
end
|
|
18
|
+
def self.validate_branch_count!(count)
|
|
19
|
+
unless count.is_a?(Integer) && count.positive?
|
|
20
|
+
raise WorkflowError, "parallel branch count must be a positive integer"
|
|
28
21
|
end
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
limit = Smith.config.parallel_branch_limit
|
|
24
|
+
raise WorkflowError, "parallel branch count exceeds configured limit #{limit}" if count > limit
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
count
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.execute(branches:)
|
|
30
|
+
validate_branch_count!(branches.length)
|
|
31
|
+
context = ExecutionContext.current
|
|
32
|
+
return NestedExecution.new(branches:, context:).call if context
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
RootExecution.new(branches:).call
|
|
38
35
|
end
|
|
39
36
|
|
|
40
37
|
def self.preferred_error(reasons)
|
|
41
|
-
errors = reasons.compact
|
|
38
|
+
errors = Array(reasons).compact
|
|
42
39
|
errors.find { |error| !error.is_a?(Cancellation) } || errors.first
|
|
43
40
|
end
|
|
44
41
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class ParallelAgentBinding
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
param :workflow
|
|
11
|
+
param :transition
|
|
12
|
+
param :agent_class
|
|
13
|
+
|
|
14
|
+
def initialize(...)
|
|
15
|
+
super
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve(workflow:, transition:)
|
|
20
|
+
agent_class if @workflow.equal?(workflow) && @transition.equal?(transition)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "branch_env"
|
|
4
|
+
require_relative "parallel_agent_binding"
|
|
4
5
|
|
|
5
6
|
module Smith
|
|
6
7
|
class Workflow
|
|
@@ -8,7 +9,7 @@ module Smith
|
|
|
8
9
|
private
|
|
9
10
|
|
|
10
11
|
def execute_parallel_step(transition, prepared_input: nil)
|
|
11
|
-
count = Parallel.resolve_branch_count(transition, @context)
|
|
12
|
+
count = @resolved_parallel_branch_count || Parallel.resolve_branch_count(transition, @context)
|
|
12
13
|
agent_class = resolve_agent_class(transition)
|
|
13
14
|
estimates = compute_branch_estimates(@ledger, branch_count: count, agent_budget: agent_class&.budget)
|
|
14
15
|
env = BranchEnv.new(
|
|
@@ -16,7 +17,8 @@ module Smith
|
|
|
16
17
|
guardrail_sources: Tool.current_guardrails,
|
|
17
18
|
scoped_store: propagate_scoped_artifacts,
|
|
18
19
|
branch_estimates: estimates,
|
|
19
|
-
deadline: wall_clock_deadline
|
|
20
|
+
deadline: wall_clock_deadline,
|
|
21
|
+
agent_class:
|
|
20
22
|
)
|
|
21
23
|
ledger = @ledger
|
|
22
24
|
branches = Array.new(count) do |i|
|
|
@@ -27,7 +29,8 @@ module Smith
|
|
|
27
29
|
|
|
28
30
|
def run_branch(transition, index, env, ledger, signal)
|
|
29
31
|
setup_branch_context(env, ledger)
|
|
30
|
-
|
|
32
|
+
Thread.current[:smith_parallel_agent_binding] = ParallelAgentBinding.new(self, transition, env.agent_class)
|
|
33
|
+
with_agent_context(env.agent_class) do
|
|
31
34
|
branch_ledger = effective_call_ledger
|
|
32
35
|
reserved = reserve_branch_call(branch_ledger, env, ledger)
|
|
33
36
|
begin
|
|
@@ -60,6 +63,7 @@ module Smith
|
|
|
60
63
|
clear_failed_billable_attempts
|
|
61
64
|
Tool.current_ledger = nil
|
|
62
65
|
Tool.current_tool_result_collector = nil
|
|
66
|
+
Thread.current[:smith_parallel_agent_binding] = nil
|
|
63
67
|
env.teardown_thread
|
|
64
68
|
end
|
|
65
69
|
|