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.
Files changed (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -0
  3. data/README.md +191 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +37 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +10 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/composite/branch.rb +93 -0
  46. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  47. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  48. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  49. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  50. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  51. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  52. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  53. data/lib/smith/workflow/composite/contract.rb +100 -0
  54. data/lib/smith/workflow/composite/effects.rb +140 -0
  55. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  56. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  57. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  58. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  59. data/lib/smith/workflow/composite/enums.rb +27 -0
  60. data/lib/smith/workflow/composite/error.rb +54 -0
  61. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  62. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  63. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  64. data/lib/smith/workflow/composite/input.rb +53 -0
  65. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  66. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  67. data/lib/smith/workflow/composite/payload.rb +128 -0
  68. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  69. data/lib/smith/workflow/composite/plan.rb +130 -0
  70. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  71. data/lib/smith/workflow/composite/planner.rb +53 -0
  72. data/lib/smith/workflow/composite/preparation.rb +27 -0
  73. data/lib/smith/workflow/composite/reducer.rb +133 -0
  74. data/lib/smith/workflow/composite/reduction.rb +31 -0
  75. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  76. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  77. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  78. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  79. data/lib/smith/workflow/definition_identity.rb +25 -0
  80. data/lib/smith/workflow/deterministic_step.rb +2 -2
  81. data/lib/smith/workflow/dsl.rb +73 -5
  82. data/lib/smith/workflow/durability.rb +1 -1
  83. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  84. data/lib/smith/workflow/execution.rb +28 -30
  85. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  86. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  87. data/lib/smith/workflow/fanout_execution.rb +20 -19
  88. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  89. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  90. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  91. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  92. data/lib/smith/workflow/graph/metrics.rb +1 -3
  93. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  94. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  95. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  96. data/lib/smith/workflow/graph/reachability.rb +19 -31
  97. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  98. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  99. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  100. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  101. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  102. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  103. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  104. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  105. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  106. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  107. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  108. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  109. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  110. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  111. data/lib/smith/workflow/graph/validator.rb +1 -1
  112. data/lib/smith/workflow/graph.rb +62 -4
  113. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  114. data/lib/smith/workflow/identifier.rb +24 -0
  115. data/lib/smith/workflow/message_admission.rb +40 -0
  116. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  117. data/lib/smith/workflow/message_batch.rb +60 -0
  118. data/lib/smith/workflow/message_value_normalizer.rb +134 -0
  119. data/lib/smith/workflow/nested_execution.rb +10 -0
  120. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  121. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  122. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  123. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  124. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  125. data/lib/smith/workflow/parallel.rb +19 -22
  126. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  127. data/lib/smith/workflow/parallel_execution.rb +46 -14
  128. data/lib/smith/workflow/persistence.rb +37 -8
  129. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  130. data/lib/smith/workflow/prepared_step.rb +122 -0
  131. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  132. data/lib/smith/workflow/prepared_step_execution_authorization.rb +101 -0
  133. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  134. data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
  135. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  136. data/lib/smith/workflow/process_local.rb +33 -0
  137. data/lib/smith/workflow/retry_execution.rb +20 -11
  138. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  139. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  140. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  141. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  142. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  143. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  144. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  145. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  146. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  147. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  148. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  149. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  150. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  151. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  152. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  153. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  154. data/lib/smith/workflow/split_step_persistence/execution.rb +20 -64
  155. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
  156. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  157. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
  158. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
  159. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  160. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  161. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
  162. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  163. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  164. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  165. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  166. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  167. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  168. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  169. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  170. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  171. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  172. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  173. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  174. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +66 -0
  175. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  176. data/lib/smith/workflow/split_step_persistence.rb +50 -0
  177. data/lib/smith/workflow/step_completion.rb +31 -0
  178. data/lib/smith/workflow/step_context.rb +46 -0
  179. data/lib/smith/workflow/string_snapshot.rb +25 -0
  180. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  181. data/lib/smith/workflow/transition.rb +137 -133
  182. data/lib/smith/workflow/transition_actionability.rb +22 -0
  183. data/lib/smith/workflow.rb +64 -18
  184. data/lib/smith.rb +44 -2
  185. data/sig/smith.rbs +1 -0
  186. 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
@@ -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, :consumed
15
+ attr_reader :limits
7
16
 
8
17
  def initialize(limits: {}, consumed: {})
9
18
  @mutex = Mutex.new
10
- @limits = limits
11
- @consumed = Hash.new(0).merge(consumed)
12
- @reserved = Hash.new(0)
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 reserve!(key, amount)
16
- @mutex.synchronize do
17
- committed = @consumed[key] + @reserved[key]
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
- @reserved[key] += amount
21
- end
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!(key, reserved_amount, actual_amount)
25
- @mutex.synchronize do
26
- @reserved[key] = [0, @reserved[key] - reserved_amount].max
27
- @consumed[key] += actual_amount
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 release!(key, amount)
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
- @reserved[key] = [0, @reserved[key] - amount].max
65
+ amount = @state_transition.remaining(@publication.state, dimension)
66
+ @representation.externalize_amount(dimension, amount)
34
67
  end
35
68
  end
36
69
 
37
- def remaining(key)
38
- @mutex.synchronize { [@limits[key] - @consumed[key] - @reserved[key], 0].max }
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 { |m| SYSTEM_ROLES.include?(m[:role]) }
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| Smith::PersistenceAdapters.supports?(adapter, 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?, commit-aware split-step confirmation is unavailable. " \
62
- "Use RedisStore or Memory for full versioning and heartbeat coverage; " \
63
- "ActiveRecordStore currently covers optimistic locking when lock_version is present."
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Error < StandardError; end
5
+ end
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