smith-agents 0.4.3 → 0.4.5

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +8 -2
  4. data/docs/PERSISTENCE.md +180 -1
  5. data/docs/workflow_claim.md +4 -1
  6. data/lib/smith/agent/lifecycle.rb +30 -10
  7. data/lib/smith/doctor/checks/persistence_capabilities.rb +1 -0
  8. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  9. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  10. data/lib/smith/persistence_adapters/active_record_store.rb +91 -51
  11. data/lib/smith/persistence_adapters/cache_store.rb +3 -1
  12. data/lib/smith/persistence_adapters/memory.rb +29 -13
  13. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  14. data/lib/smith/persistence_adapters/redis_store.rb +11 -33
  15. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  16. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  17. data/lib/smith/persistence_adapters.rb +6 -1
  18. data/lib/smith/version.rb +1 -1
  19. data/lib/smith/workflow/durability.rb +1 -0
  20. data/lib/smith/workflow/persistence.rb +12 -2
  21. data/lib/smith/workflow/split_step_persistence/boundary.rb +115 -0
  22. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +121 -0
  23. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  24. data/lib/smith/workflow/split_step_persistence/execution.rb +126 -0
  25. data/lib/smith/workflow/split_step_persistence/inheritance.rb +20 -0
  26. data/lib/smith/workflow/split_step_persistence/payloads.rb +50 -0
  27. data/lib/smith/workflow/split_step_persistence/preparation.rb +97 -0
  28. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +91 -0
  29. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +46 -0
  30. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  31. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +52 -0
  32. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  33. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  34. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  35. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  36. data/lib/smith/workflow/split_step_persistence.rb +43 -0
  37. data/lib/smith/workflow.rb +13 -1
  38. data/lib/smith.rb +1 -0
  39. metadata +22 -1
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module SplitStepPersistence
8
+ class TransitionContractFreezer
9
+ extend Dry::Initializer
10
+
11
+ CORE_FREEZE = Object.instance_method(:freeze)
12
+
13
+ option :value
14
+ option :max_depth
15
+ option :max_nodes
16
+
17
+ def call
18
+ stack = [[value, 0]]
19
+ seen = {}.compare_by_identity
20
+ nodes = 0
21
+ until stack.empty?
22
+ item, depth = stack.pop
23
+ nodes = visit!(nodes, depth)
24
+ freeze_item(item, depth, stack, seen)
25
+ end
26
+ value
27
+ end
28
+
29
+ private
30
+
31
+ def visit!(nodes, depth)
32
+ raise WorkflowError, "split-step transition contract exceeds maximum depth #{max_depth}" if depth > max_depth
33
+ return nodes + 1 if nodes < max_nodes
34
+
35
+ raise WorkflowError, "split-step transition contract exceeds maximum size #{max_nodes}"
36
+ end
37
+
38
+ def freeze_item(item, depth, stack, seen)
39
+ return if immutable?(item) || item.is_a?(Module) || seen.key?(item)
40
+
41
+ seen[item] = true
42
+ children(item).reverse_each { |child| stack << [child, depth + 1] }
43
+ CORE_FREEZE.bind_call(item)
44
+ end
45
+
46
+ def children(item)
47
+ return [] if leaf?(item)
48
+ return item.flat_map { |key, value| [key, value] } if item.is_a?(Hash)
49
+ return [item.begin, item.end] if item.is_a?(Range)
50
+
51
+ object_children(item)
52
+ end
53
+
54
+ def object_children(item)
55
+ return item.to_a if item.is_a?(Array) || item.is_a?(Set)
56
+ return member_children(item) if item.is_a?(Struct) || item.is_a?(Data)
57
+
58
+ transition_children(item)
59
+ end
60
+
61
+ def member_children(item)
62
+ item.members.map { |name| item.public_send(name) }
63
+ end
64
+
65
+ def transition_children(item)
66
+ unless item.is_a?(Workflow::Transition)
67
+ raise WorkflowError, "split-step transition contract contains unsupported value #{item.class}"
68
+ end
69
+
70
+ item.instance_variables.map { |name| item.instance_variable_get(name) }
71
+ end
72
+
73
+ def leaf?(item)
74
+ item.is_a?(String) || item.is_a?(Proc)
75
+ end
76
+
77
+ def immutable?(item)
78
+ item.nil? || item.is_a?(Symbol) || item.is_a?(Numeric) || item == true || item == false
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+ require "digest"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module SplitStepPersistence
9
+ class TransitionContractSignature
10
+ extend Dry::Initializer
11
+ include TransitionContractStructuredValues
12
+
13
+ option :value
14
+ option :max_depth
15
+ option :max_nodes
16
+ option :max_bytes
17
+
18
+ def call
19
+ @nodes = 0
20
+ @bytes = 0
21
+ @seen = {}.compare_by_identity
22
+ signature_value(value, 0)
23
+ end
24
+
25
+ private
26
+
27
+ def signature_value(item, depth)
28
+ visit!(depth)
29
+ reference = reference_for(item)
30
+ return reference if reference
31
+ return terminal_signature(item) if terminal?(item)
32
+
33
+ composite_signature(item, depth)
34
+ end
35
+
36
+ def composite_signature(item, depth)
37
+ case item
38
+ when Hash then hash_signature(item, depth)
39
+ when Array then collection_signature(:array, item, depth)
40
+ when Set then collection_signature(:set, item, depth)
41
+ else structured_signature(item, depth)
42
+ end
43
+ end
44
+
45
+ def terminal_signature(item)
46
+ case item
47
+ when String then string_signature(item)
48
+ when Module then [:module, item.name, item.object_id]
49
+ when Proc then proc_signature(item)
50
+ else item
51
+ end
52
+ end
53
+
54
+ def hash_signature(hash, depth)
55
+ [:hash, reference_id(hash), hash.map do |key, item|
56
+ [signature_value(key, depth + 1), signature_value(item, depth + 1)]
57
+ end]
58
+ end
59
+
60
+ def collection_signature(kind, collection, depth)
61
+ [kind, reference_id(collection), collection.map { |item| signature_value(item, depth + 1) }]
62
+ end
63
+
64
+ def proc_signature(callable)
65
+ [:proc, callable.object_id, callable.source_location, callable.parameters, callable.lambda?]
66
+ end
67
+
68
+ def string_signature(string)
69
+ @bytes += string.bytesize
70
+ raise WorkflowError, "split-step transition contract exceeds maximum bytes #{max_bytes}" if @bytes > max_bytes
71
+
72
+ [:string, string.bytesize, Digest::SHA256.hexdigest(string)]
73
+ end
74
+
75
+ def reference_for(item)
76
+ return unless referenceable?(item)
77
+ return [:reference, reference_id(item)] if @seen.key?(item)
78
+
79
+ @seen[item] = @seen.size
80
+ nil
81
+ end
82
+
83
+ def reference_id(item) = @seen.fetch(item)
84
+
85
+ def referenceable?(item)
86
+ !terminal?(item)
87
+ end
88
+
89
+ def terminal?(item)
90
+ case item
91
+ when String, Symbol, Numeric, Module, Proc, true, false, nil then true
92
+ else false
93
+ end
94
+ end
95
+
96
+ def visit!(depth)
97
+ raise WorkflowError, "split-step transition contract exceeds maximum depth #{max_depth}" if depth > max_depth
98
+
99
+ @nodes += 1
100
+ return if @nodes <= max_nodes
101
+
102
+ raise WorkflowError, "split-step transition contract exceeds maximum size #{max_nodes}"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module TransitionContractStructuredValues
7
+ private
8
+
9
+ def structured_signature(item, depth)
10
+ case item
11
+ when Struct then member_signature(:struct, item, depth)
12
+ when Data then member_signature(:data, item, depth)
13
+ when Range then range_signature(item, depth)
14
+ when Workflow::Transition then object_signature(item, depth)
15
+ else unsupported!(item)
16
+ end
17
+ end
18
+
19
+ def member_signature(kind, object, depth)
20
+ members = object.members.map do |name|
21
+ [name, signature_value(object.public_send(name), depth + 1)]
22
+ end
23
+ [kind, object.class.name, reference_id(object), members]
24
+ end
25
+
26
+ def object_signature(item, depth)
27
+ variables = item.instance_variables.sort.map do |name|
28
+ [name, signature_value(item.instance_variable_get(name), depth + 1)]
29
+ end
30
+ [:object, item.class.name, item.object_id, variables]
31
+ end
32
+
33
+ def range_signature(range, depth)
34
+ [
35
+ :range,
36
+ reference_id(range),
37
+ signature_value(range.begin, depth + 1),
38
+ signature_value(range.end, depth + 1),
39
+ range.exclude_end?
40
+ ]
41
+ end
42
+
43
+ def unsupported!(item)
44
+ raise WorkflowError,
45
+ "split-step transition contract contains unsupported value #{item.class}; " \
46
+ "use primitives, Hash, Array, Set, Struct, Data, Range, Module, or Proc"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ require_relative "split_step_persistence/subclass_boundary"
8
+ require_relative "split_step_persistence/inheritance"
9
+ require_relative "split_step_persistence/transition_contract_structured_values"
10
+ require_relative "split_step_persistence/transition_contract_signature"
11
+ require_relative "split_step_persistence/transition_contract_freezer"
12
+ require_relative "split_step_persistence/transition_contract"
13
+ require_relative "split_step_persistence/boundary"
14
+ require_relative "split_step_persistence/state_snapshot"
15
+ require_relative "split_step_persistence/preparation_claim"
16
+ require_relative "split_step_persistence/preparation_recovery"
17
+ require_relative "split_step_persistence/preparation"
18
+ require_relative "split_step_persistence/execution"
19
+ require_relative "split_step_persistence/checkpoint_state"
20
+ require_relative "split_step_persistence/checkpoint"
21
+ require_relative "split_step_persistence/payloads"
22
+
23
+ module Smith
24
+ class Workflow
25
+ module SplitStepPersistence
26
+ NO_SPLIT_TRANSITION = Object.new.freeze
27
+
28
+ def self.prepended(base)
29
+ base.singleton_class.prepend(Inheritance)
30
+ end
31
+
32
+ include Boundary
33
+ include StateSnapshot
34
+ include PreparationClaim
35
+ include PreparationRecovery
36
+ include Preparation
37
+ include Execution
38
+ include CheckpointState
39
+ include Checkpoint
40
+ include Payloads
41
+ end
42
+ end
43
+ end
@@ -16,6 +16,7 @@ module Smith
16
16
  include DSL
17
17
  include Persistence
18
18
  include Durability
19
+ prepend SplitStepPersistence
19
20
  include GuardrailIntegration
20
21
  include BudgetIntegration
21
22
  include EventIntegration
@@ -104,6 +105,12 @@ module Smith
104
105
  # workflows with the marker set raise
105
106
  # Smith::StepInProgressOnRestore. Lax mode leaves it false.
106
107
  @step_in_progress = false
108
+ # Process-local phase for the split-step persistence API. This is not
109
+ # serialized: strict restore rejects the durable step_in_progress marker
110
+ # before an uncertain step can be resumed.
111
+ @split_step_phase = nil
112
+ @split_step_transition_name = nil
113
+ @split_step_mutex = Mutex.new
107
114
  # Set of context keys recorded via deterministic step write_context
108
115
  # writes. Used by persist :auto Context mode to compute the
109
116
  # persisted-context slice. Seeded from the Context class's
@@ -119,10 +126,12 @@ module Smith
119
126
  end
120
127
 
121
128
  def advance!
129
+ advance_claim = SplitStepPersistence.instance_method(:claim_split_step_advance!).bind_call(self)
122
130
  ensure_transition_budget!
123
131
  @step_work_started = false
124
132
 
125
- transition = resolve_transition
133
+ transition = SplitStepPersistence.instance_method(:resolve_split_step_advance_transition).bind_call(self)
134
+ transition = resolve_transition if transition.equal?(SplitStepPersistence::NO_SPLIT_TRANSITION)
126
135
  return if transition.nil?
127
136
 
128
137
  @step_work_started = true
@@ -139,9 +148,12 @@ module Smith
139
148
  step_result = { transition: e.requested_name, from: origin_state, to: @state, error: e }
140
149
  record_step_snapshot(step_result)
141
150
  step_result
151
+ ensure
152
+ SplitStepPersistence.instance_method(:release_split_step_advance!).bind_call(self, advance_claim) if advance_claim
142
153
  end
143
154
 
144
155
  def run!
156
+ SplitStepPersistence.instance_method(:ensure_split_step_execution_allowed!).bind_call(self)
145
157
  steps = []
146
158
  until terminal?
147
159
  step = advance!
data/lib/smith.rb CHANGED
@@ -241,6 +241,7 @@ require_relative "smith/workflow/worker_execution"
241
241
  require_relative "smith/workflow/dsl"
242
242
  require_relative "smith/workflow/persistence"
243
243
  require_relative "smith/workflow/durability"
244
+ require_relative "smith/workflow/split_step_persistence"
244
245
  require_relative "smith/workflow/guardrail_integration"
245
246
  require_relative "smith/workflow/budget_integration"
246
247
  require_relative "smith/workflow/event_integration"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -179,12 +179,17 @@ files:
179
179
  - lib/smith/models/normalizer.rb
180
180
  - lib/smith/models/profile.rb
181
181
  - lib/smith/persistence_adapters.rb
182
+ - lib/smith/persistence_adapters/active_record_connection_errors.rb
183
+ - lib/smith/persistence_adapters/active_record_initial_write.rb
182
184
  - lib/smith/persistence_adapters/active_record_store.rb
183
185
  - lib/smith/persistence_adapters/cache_store.rb
184
186
  - lib/smith/persistence_adapters/memory.rb
187
+ - lib/smith/persistence_adapters/payload_version.rb
185
188
  - lib/smith/persistence_adapters/rails_cache.rb
186
189
  - lib/smith/persistence_adapters/redis_store.rb
190
+ - lib/smith/persistence_adapters/redis_versioned_write.rb
187
191
  - lib/smith/persistence_adapters/retry.rb
192
+ - lib/smith/persistence_adapters/version_expectation.rb
188
193
  - lib/smith/pricing.rb
189
194
  - lib/smith/providers/openai/responses.rb
190
195
  - lib/smith/providers/openai/routing.rb
@@ -261,6 +266,22 @@ files:
261
266
  - lib/smith/workflow/retry_execution.rb
262
267
  - lib/smith/workflow/router.rb
263
268
  - lib/smith/workflow/run_result.rb
269
+ - lib/smith/workflow/split_step_persistence.rb
270
+ - lib/smith/workflow/split_step_persistence/boundary.rb
271
+ - lib/smith/workflow/split_step_persistence/checkpoint.rb
272
+ - lib/smith/workflow/split_step_persistence/checkpoint_state.rb
273
+ - lib/smith/workflow/split_step_persistence/execution.rb
274
+ - lib/smith/workflow/split_step_persistence/inheritance.rb
275
+ - lib/smith/workflow/split_step_persistence/payloads.rb
276
+ - lib/smith/workflow/split_step_persistence/preparation.rb
277
+ - lib/smith/workflow/split_step_persistence/preparation_claim.rb
278
+ - lib/smith/workflow/split_step_persistence/preparation_recovery.rb
279
+ - lib/smith/workflow/split_step_persistence/state_snapshot.rb
280
+ - lib/smith/workflow/split_step_persistence/subclass_boundary.rb
281
+ - lib/smith/workflow/split_step_persistence/transition_contract.rb
282
+ - lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb
283
+ - lib/smith/workflow/split_step_persistence/transition_contract_signature.rb
284
+ - lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb
264
285
  - lib/smith/workflow/transition.rb
265
286
  - lib/smith/workflow/usage_entry.rb
266
287
  - lib/smith/workflow/worker_execution.rb