smith-agents 0.4.5 → 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 +264 -0
- data/README.md +191 -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 +37 -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 +10 -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/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/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 +28 -30
- 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 +20 -19
- 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 +134 -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 +46 -14
- data/lib/smith/workflow/persistence.rb +37 -8
- data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
- 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 +101 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/process_local.rb +33 -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/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/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 +20 -64
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
- data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -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 +66 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence.rb +50 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
- data/lib/smith/workflow/transition.rb +137 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +64 -18
- data/lib/smith.rb +44 -2
- data/sig/smith.rbs +1 -0
- metadata +155 -19
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class AmountContract
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :limits, default: proc {}
|
|
11
|
+
|
|
12
|
+
def own_hash!(amounts, label)
|
|
13
|
+
case amounts
|
|
14
|
+
when Hash then own_amount_hash(amounts, label)
|
|
15
|
+
else raise ArgumentError, "#{label} values must be a Hash"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def known_hash!(amounts)
|
|
20
|
+
amounts.each_key { known_dimension!(_1) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def known_dimension!(key)
|
|
24
|
+
return if limits&.key?(key)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError, "unknown budget dimension #{key.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def matching_dimensions!(reserved, actual)
|
|
30
|
+
return if reserved.length == actual.length && reserved.each_key.all? { actual.key?(_1) }
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "reserved and actual budget dimensions must match"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def dimension!(key)
|
|
36
|
+
case key
|
|
37
|
+
when Symbol then key
|
|
38
|
+
when String then String.new(key).freeze
|
|
39
|
+
else raise ArgumentError, "budget dimension keys must be symbols or strings"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def own_amount_hash(amounts, label)
|
|
46
|
+
{}.tap do |owned|
|
|
47
|
+
Hash.instance_method(:each_pair).bind_call(amounts) do |key, amount|
|
|
48
|
+
owned[dimension!(key)] = own_amount!(amount, label)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def own_amount!(amount, label)
|
|
54
|
+
validate_amount!(amount, label)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_amount!(amount, label)
|
|
58
|
+
case amount
|
|
59
|
+
when Integer then return amount if amount >= 0
|
|
60
|
+
when Float then return amount if amount.finite? && amount >= 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "#{label} values must be finite and non-negative Integer or Float values"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "dry-initializer"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
module Budget
|
|
8
|
+
class AmountRepresentation
|
|
9
|
+
extend Dry::Initializer
|
|
10
|
+
|
|
11
|
+
option :limits
|
|
12
|
+
|
|
13
|
+
def internalize(amounts)
|
|
14
|
+
amounts.to_h { |key, amount| [key, internalize_amount(amount)] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def externalize(amounts)
|
|
18
|
+
amounts.to_h { |key, amount| [key, externalize_amount(key, amount)] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def externalize_amount(key, amount)
|
|
22
|
+
return finite_float!(amount) if limits.fetch(key).is_a?(Float)
|
|
23
|
+
return amount if amount.is_a?(Integer)
|
|
24
|
+
return amount.to_i if amount.frac.zero?
|
|
25
|
+
|
|
26
|
+
finite_float!(amount)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def internalize_amount(amount)
|
|
32
|
+
BigDecimal(amount.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finite_float!(amount)
|
|
36
|
+
external = amount.to_f
|
|
37
|
+
return external if external.finite?
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "budget state values must remain JSON-safe finite numerics"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class DecimalContext
|
|
8
|
+
def self.call
|
|
9
|
+
BigDecimal.save_limit do
|
|
10
|
+
BigDecimal.limit(0)
|
|
11
|
+
yield
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private_class_method :new
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/smith/budget/ledger.rb
CHANGED
|
@@ -1,41 +1,133 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "amount_contract"
|
|
4
|
+
require_relative "amount_representation"
|
|
5
|
+
require_relative "ledger_publication"
|
|
6
|
+
require_relative "ledger_state"
|
|
7
|
+
require_relative "ledger_state_transition"
|
|
8
|
+
require_relative "public_state_validator"
|
|
9
|
+
require_relative "reservation"
|
|
10
|
+
require_relative "reservation_contract"
|
|
11
|
+
|
|
3
12
|
module Smith
|
|
4
13
|
module Budget
|
|
5
14
|
class Ledger
|
|
6
|
-
attr_reader :limits
|
|
15
|
+
attr_reader :limits
|
|
7
16
|
|
|
8
17
|
def initialize(limits: {}, consumed: {})
|
|
9
18
|
@mutex = Mutex.new
|
|
10
|
-
@
|
|
11
|
-
@
|
|
12
|
-
|
|
19
|
+
@identity = Object.new.freeze
|
|
20
|
+
@reservation_contract = ReservationContract.new(ledger_identity: @identity)
|
|
21
|
+
initialize_contracts(limits)
|
|
22
|
+
initialize_state(consumed)
|
|
13
23
|
end
|
|
14
24
|
|
|
15
|
-
def
|
|
16
|
-
@mutex.synchronize
|
|
17
|
-
|
|
18
|
-
raise BudgetExceeded if committed + amount > @limits[key]
|
|
25
|
+
def consumed
|
|
26
|
+
@mutex.synchronize { @representation.externalize(@publication.state.consumed).freeze }
|
|
27
|
+
end
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
def reserve!(key, amount) = reserve_many!(key => amount)
|
|
30
|
+
|
|
31
|
+
def reserve_many!(reservations)
|
|
32
|
+
amounts = validated_amounts(reservations, "budget reservation")
|
|
33
|
+
@mutex.synchronize { create_reservation(amounts) }
|
|
22
34
|
end
|
|
23
35
|
|
|
24
|
-
def reconcile!(
|
|
25
|
-
@
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
def reconcile!(reservation, actual_amount)
|
|
37
|
+
amounts = @reservation_contract.amounts!(reservation)
|
|
38
|
+
unless amounts.one?
|
|
39
|
+
raise ArgumentError, "single-dimension reconciliation requires a single-dimension reservation"
|
|
28
40
|
end
|
|
41
|
+
|
|
42
|
+
reconcile_many!(reservation, actual: { amounts.keys.first => actual_amount }).values.first
|
|
29
43
|
end
|
|
30
44
|
|
|
31
|
-
def
|
|
45
|
+
def reconcile_many!(reservation, actual:)
|
|
46
|
+
token = @reservation_contract.token!(reservation)
|
|
47
|
+
actuals = validated_amounts(actual, "actual budget")
|
|
48
|
+
@mutex.synchronize { settle_reservation(token, actuals) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def release!(reservation)
|
|
52
|
+
released = release_many!(reservation)
|
|
53
|
+
released.values.first if released.one?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def release_many!(reservation)
|
|
57
|
+
token = @reservation_contract.token!(reservation)
|
|
58
|
+
@mutex.synchronize { release_reservation(token) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remaining(key)
|
|
62
|
+
dimension = @amount_contract.dimension!(key)
|
|
63
|
+
@amount_contract.known_dimension!(dimension)
|
|
32
64
|
@mutex.synchronize do
|
|
33
|
-
|
|
65
|
+
amount = @state_transition.remaining(@publication.state, dimension)
|
|
66
|
+
@representation.externalize_amount(dimension, amount)
|
|
34
67
|
end
|
|
35
68
|
end
|
|
36
69
|
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def initialize_contracts(limits)
|
|
73
|
+
@limits = AmountContract.new.own_hash!(limits, "budget limit").freeze
|
|
74
|
+
@amount_contract = AmountContract.new(limits: @limits)
|
|
75
|
+
@representation = AmountRepresentation.new(limits: @limits)
|
|
76
|
+
internal_limits = @representation.internalize(@limits)
|
|
77
|
+
@state_transition = LedgerStateTransition.new(limits: internal_limits)
|
|
78
|
+
@public_state_validator = PublicStateValidator.new(
|
|
79
|
+
limits: @limits,
|
|
80
|
+
representation: @representation,
|
|
81
|
+
state_transition: @state_transition
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize_state(consumed)
|
|
86
|
+
initial_consumed = @amount_contract.own_hash!(consumed, "consumed budget")
|
|
87
|
+
@amount_contract.known_hash!(initial_consumed)
|
|
88
|
+
internal_consumed = @representation.internalize(initial_consumed)
|
|
89
|
+
state = LedgerState.new(consumed: internal_consumed, reserved: {})
|
|
90
|
+
@publication = LedgerPublication.new(state: @public_state_validator.call(state))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validated_amounts(amounts, label)
|
|
94
|
+
external = @amount_contract.own_hash!(amounts, label)
|
|
95
|
+
@amount_contract.known_hash!(external)
|
|
96
|
+
@representation.internalize(external)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_reservation(amounts)
|
|
100
|
+
token = Object.new.freeze
|
|
101
|
+
receipt = Reservation.new(
|
|
102
|
+
ledger_identity: @identity,
|
|
103
|
+
token:,
|
|
104
|
+
amounts: @representation.externalize(amounts)
|
|
105
|
+
)
|
|
106
|
+
next_state = @state_transition.reserve(@publication.state, amounts)
|
|
107
|
+
@publication.add(@public_state_validator.call(next_state), token:, amounts:)
|
|
108
|
+
receipt
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def settle_reservation(token, actuals)
|
|
112
|
+
reserved_amounts = active_reservation!(token)
|
|
113
|
+
@amount_contract.matching_dimensions!(reserved_amounts, actuals)
|
|
114
|
+
next_state = @state_transition.reconcile(@publication.state, reserved_amounts, actuals)
|
|
115
|
+
consumed = @representation.externalize(next_state.consumed.slice(*actuals.keys))
|
|
116
|
+
@publication.remove(@public_state_validator.call(next_state), token:, amounts: reserved_amounts)
|
|
117
|
+
consumed
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def release_reservation(token)
|
|
121
|
+
reserved_amounts = active_reservation!(token)
|
|
122
|
+
next_state = @state_transition.release(@publication.state, reserved_amounts)
|
|
123
|
+
remaining = @representation.externalize(next_state.reserved.slice(*reserved_amounts.keys))
|
|
124
|
+
@publication.remove(@public_state_validator.call(next_state), token:, amounts: reserved_amounts)
|
|
125
|
+
remaining
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def active_reservation!(token)
|
|
129
|
+
@publication.reservation(token) ||
|
|
130
|
+
raise(ArgumentError, "budget reservation is unknown or already settled")
|
|
39
131
|
end
|
|
40
132
|
end
|
|
41
133
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class LedgerPublication
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :state
|
|
11
|
+
option :reservations, default: proc { {} }
|
|
12
|
+
|
|
13
|
+
private :reservations
|
|
14
|
+
|
|
15
|
+
def reservation(token)
|
|
16
|
+
reservations[token]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(next_state, token:, amounts:)
|
|
20
|
+
publish(
|
|
21
|
+
next_state,
|
|
22
|
+
commit: -> { reservations[token] = amounts },
|
|
23
|
+
rollback: -> { reservations.delete(token) }
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove(next_state, token:, amounts:)
|
|
28
|
+
publish(
|
|
29
|
+
next_state,
|
|
30
|
+
commit: -> { reservations.delete(token) },
|
|
31
|
+
rollback: -> { reservations[token] = amounts }
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def publish(next_state, commit:, rollback:)
|
|
38
|
+
previous_state = state
|
|
39
|
+
published = false
|
|
40
|
+
begin
|
|
41
|
+
Thread.handle_interrupt(Object => :never) do
|
|
42
|
+
@state = next_state
|
|
43
|
+
published = true
|
|
44
|
+
commit.call
|
|
45
|
+
end
|
|
46
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
47
|
+
rollback(previous_state, rollback) if published
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rollback(previous_state, operation)
|
|
53
|
+
Thread.handle_interrupt(Object => :never) do
|
|
54
|
+
@state = previous_state
|
|
55
|
+
operation.call
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Budget
|
|
5
|
+
class LedgerState
|
|
6
|
+
attr_reader :consumed, :reserved
|
|
7
|
+
|
|
8
|
+
def initialize(consumed:, reserved:)
|
|
9
|
+
@consumed = amount_hash(consumed)
|
|
10
|
+
@reserved = amount_hash(reserved)
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def amount_hash(amounts)
|
|
17
|
+
Hash.new(0).merge(amounts).freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "dry-initializer"
|
|
5
|
+
require_relative "decimal_context"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module Budget
|
|
9
|
+
class LedgerStateTransition
|
|
10
|
+
extend Dry::Initializer
|
|
11
|
+
|
|
12
|
+
option :limits
|
|
13
|
+
|
|
14
|
+
def reserve(state, amounts)
|
|
15
|
+
DecimalContext.call do
|
|
16
|
+
amounts.each do |key, amount|
|
|
17
|
+
requested = state.consumed[key] + state.reserved[key] + amount
|
|
18
|
+
raise BudgetExceeded unless requested.finite? && requested <= limits.fetch(key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
LedgerState.new(consumed: state.consumed, reserved: add(state.reserved, amounts))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reconcile(state, reserved_amounts, actuals)
|
|
26
|
+
DecimalContext.call do
|
|
27
|
+
LedgerState.new(
|
|
28
|
+
consumed: add(state.consumed, actuals),
|
|
29
|
+
reserved: subtract(state.reserved, reserved_amounts)
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def release(state, reserved_amounts)
|
|
35
|
+
DecimalContext.call do
|
|
36
|
+
LedgerState.new(
|
|
37
|
+
consumed: state.consumed,
|
|
38
|
+
reserved: subtract(state.reserved, reserved_amounts)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remaining(state, key)
|
|
44
|
+
DecimalContext.call do
|
|
45
|
+
amount = limits.fetch(key) - state.consumed[key] - state.reserved[key]
|
|
46
|
+
amount.negative? ? BigDecimal("0") : amount
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def add(current, amounts)
|
|
53
|
+
merge(current, amounts) { |value, amount| value + amount }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def subtract(current, amounts)
|
|
57
|
+
merge(current, amounts) { |value, amount| value - amount }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def merge(current, amounts)
|
|
61
|
+
current.dup.tap do |result|
|
|
62
|
+
amounts.each do |key, amount|
|
|
63
|
+
value = yield(current.fetch(key, 0), amount)
|
|
64
|
+
raise ArgumentError, "budget state values must be non-negative" if value.negative?
|
|
65
|
+
|
|
66
|
+
result[key] = value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class PublicStateValidator
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :limits
|
|
11
|
+
option :representation
|
|
12
|
+
option :state_transition
|
|
13
|
+
|
|
14
|
+
def call(state)
|
|
15
|
+
representation.externalize(state.consumed)
|
|
16
|
+
representation.externalize(state.reserved)
|
|
17
|
+
limits.each_key do |dimension|
|
|
18
|
+
remaining = state_transition.remaining(state, dimension)
|
|
19
|
+
representation.externalize_amount(dimension, remaining)
|
|
20
|
+
end
|
|
21
|
+
state
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Budget
|
|
5
|
+
class Reservation
|
|
6
|
+
attr_reader :amounts
|
|
7
|
+
|
|
8
|
+
def initialize(ledger_identity:, token:, amounts:)
|
|
9
|
+
@ledger_identity = ledger_identity
|
|
10
|
+
@token = token
|
|
11
|
+
@amounts = amounts.dup.freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def identity_for(ledger_identity)
|
|
16
|
+
@token if @ledger_identity.equal?(ledger_identity)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class ReservationContract
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :ledger_identity
|
|
11
|
+
|
|
12
|
+
def token!(reservation)
|
|
13
|
+
validate_receipt!(reservation)
|
|
14
|
+
reservation.identity_for(ledger_identity) ||
|
|
15
|
+
raise(ArgumentError, "budget reservation belongs to another ledger")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def amounts!(reservation)
|
|
19
|
+
token!(reservation)
|
|
20
|
+
reservation.amounts
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_receipt!(reservation)
|
|
26
|
+
case reservation
|
|
27
|
+
when Reservation then return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "budget settlement requires a reservation receipt"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -11,7 +11,9 @@ module Smith
|
|
|
11
11
|
window = strategy[:window]
|
|
12
12
|
return messages unless window
|
|
13
13
|
|
|
14
|
-
system_msgs, non_system = messages.partition
|
|
14
|
+
system_msgs, non_system = messages.partition do |message|
|
|
15
|
+
SYSTEM_ROLES.include?(message[:role] || message["role"])
|
|
16
|
+
end
|
|
15
17
|
system_msgs + non_system.last(window)
|
|
16
18
|
end
|
|
17
19
|
end
|
|
@@ -9,7 +9,7 @@ module Smith
|
|
|
9
9
|
content = "#{MARKER}\n#{formatter.call(persisted)}"
|
|
10
10
|
|
|
11
11
|
existing_index = messages.index do |message|
|
|
12
|
-
message_content = message[:content]
|
|
12
|
+
message_content = message[:content] || message["content"]
|
|
13
13
|
message_content.is_a?(String) && message_content.start_with?(MARKER)
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ module Smith
|
|
|
20
20
|
adapter = resolve_adapter
|
|
21
21
|
return report_missing_adapter(report) if adapter.nil?
|
|
22
22
|
|
|
23
|
-
missing = OPTIONAL_CAPABILITIES.reject { |cap|
|
|
23
|
+
missing = OPTIONAL_CAPABILITIES.reject { |cap| capability_available?(adapter, cap) }
|
|
24
24
|
return report_supported_capabilities(report, adapter) if missing.empty?
|
|
25
25
|
|
|
26
26
|
report_missing_capabilities(report, adapter, missing)
|
|
@@ -32,6 +32,16 @@ module Smith
|
|
|
32
32
|
nil
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def capability_available?(adapter, capability)
|
|
36
|
+
return false unless Smith::PersistenceAdapters.supports?(adapter, capability)
|
|
37
|
+
return true unless capability == :persistence_identity
|
|
38
|
+
|
|
39
|
+
identity = adapter.persistence_identity
|
|
40
|
+
identity.is_a?(String) && !identity.empty? && identity.bytesize <= 256
|
|
41
|
+
rescue StandardError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
35
45
|
def report_missing_adapter(report)
|
|
36
46
|
report.add(
|
|
37
47
|
name: "persistence.capabilities",
|
|
@@ -58,9 +68,13 @@ module Smith
|
|
|
58
68
|
message: "#{adapter.class.name} missing optional capabilities: #{missing.join(", ")}",
|
|
59
69
|
detail: "Smith will fall back where possible: non-versioned writes when store_versioned " \
|
|
60
70
|
"is missing, and payload updated_at parsing when heartbeat methods are missing. " \
|
|
61
|
-
"Without transaction_open
|
|
62
|
-
"
|
|
63
|
-
"
|
|
71
|
+
"Without transaction_open? and transaction_identity, exact commit-aware " \
|
|
72
|
+
"split-step confirmation is unavailable. " \
|
|
73
|
+
"If transaction_open? returns true, transaction_identity is required and " \
|
|
74
|
+
"split-step preparation fails before writing when it is missing. " \
|
|
75
|
+
"Use RedisStore with an explicit persistence identity or Memory for full versioning " \
|
|
76
|
+
"and heartbeat coverage; ActiveRecordStore covers optimistic locking and exact " \
|
|
77
|
+
"transaction identity when configured with an identity."
|
|
64
78
|
)
|
|
65
79
|
end
|
|
66
80
|
end
|
data/lib/smith/error.rb
ADDED
data/lib/smith/errors.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
# Classification surface for host retry policies. Smith owns the
|
|
5
7
|
# answer to "should the workflow attempt be retried?" so consumers
|
|
@@ -13,6 +15,10 @@ module Smith
|
|
|
13
15
|
def self.retryable?(error)
|
|
14
16
|
return false if error.nil?
|
|
15
17
|
|
|
18
|
+
composite_failure = defined?(Smith::Workflow::Composite::BranchFailure) &&
|
|
19
|
+
error.is_a?(Smith::Workflow::Composite::BranchFailure)
|
|
20
|
+
return false if composite_failure
|
|
21
|
+
|
|
16
22
|
case error
|
|
17
23
|
when Smith::DeterministicStepFailure, Smith::ToolGuardrailFailed
|
|
18
24
|
error.retryable == true
|
|
@@ -35,6 +41,7 @@ module Smith
|
|
|
35
41
|
class DeadlineExceeded < Error; end
|
|
36
42
|
class MaxTransitionsExceeded < Error; end
|
|
37
43
|
class GuardrailFailed < Error; end
|
|
44
|
+
|
|
38
45
|
class ToolGuardrailFailed < Error
|
|
39
46
|
attr_reader :retryable
|
|
40
47
|
|
|
@@ -43,8 +50,10 @@ module Smith
|
|
|
43
50
|
super(message)
|
|
44
51
|
end
|
|
45
52
|
end
|
|
53
|
+
|
|
46
54
|
class ToolPolicyDenied < Error; end
|
|
47
55
|
class AgentError < Error; end
|
|
56
|
+
|
|
48
57
|
class BlankAgentOutputError < AgentError
|
|
49
58
|
attr_reader :agent_name, :model_used
|
|
50
59
|
|
|
@@ -60,6 +69,7 @@ module Smith
|
|
|
60
69
|
super(detail)
|
|
61
70
|
end
|
|
62
71
|
end
|
|
72
|
+
|
|
63
73
|
class WorkflowError < Error; end
|
|
64
74
|
|
|
65
75
|
class DeterministicStepFailure < WorkflowError
|