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.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -0
  3. data/README.md +108 -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 +24 -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 +6 -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/definition_identity/class_methods.rb +48 -0
  46. data/lib/smith/workflow/definition_identity.rb +25 -0
  47. data/lib/smith/workflow/deterministic_step.rb +2 -2
  48. data/lib/smith/workflow/dsl.rb +73 -5
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  51. data/lib/smith/workflow/execution.rb +16 -18
  52. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  53. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  54. data/lib/smith/workflow/fanout_execution.rb +1 -1
  55. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  56. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  57. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  58. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  59. data/lib/smith/workflow/graph/metrics.rb +1 -3
  60. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  61. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  62. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  63. data/lib/smith/workflow/graph/reachability.rb +19 -31
  64. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  65. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  66. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  67. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  68. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  69. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  70. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  71. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  72. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  73. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  74. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  75. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  76. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  77. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  78. data/lib/smith/workflow/graph/validator.rb +1 -1
  79. data/lib/smith/workflow/graph.rb +62 -4
  80. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  81. data/lib/smith/workflow/identifier.rb +24 -0
  82. data/lib/smith/workflow/message_admission.rb +40 -0
  83. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  84. data/lib/smith/workflow/message_batch.rb +60 -0
  85. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  86. data/lib/smith/workflow/nested_execution.rb +10 -0
  87. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  88. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  89. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  90. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  91. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  92. data/lib/smith/workflow/parallel.rb +19 -22
  93. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  94. data/lib/smith/workflow/parallel_execution.rb +7 -3
  95. data/lib/smith/workflow/persistence.rb +37 -8
  96. data/lib/smith/workflow/prepared_step.rb +122 -0
  97. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  98. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  99. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  100. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  101. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  102. data/lib/smith/workflow/retry_execution.rb +20 -11
  103. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  104. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  105. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  106. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  107. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  108. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  109. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  110. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  111. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  112. data/lib/smith/workflow/split_step_persistence/execution.rb +49 -47
  113. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  114. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  115. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  116. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  117. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  118. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  119. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  120. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  121. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  122. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  123. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  124. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  125. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  126. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  127. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  128. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  129. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  130. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +50 -0
  131. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  132. data/lib/smith/workflow/split_step_persistence.rb +36 -0
  133. data/lib/smith/workflow/step_completion.rb +31 -0
  134. data/lib/smith/workflow/string_snapshot.rb +25 -0
  135. data/lib/smith/workflow/transition.rb +122 -133
  136. data/lib/smith/workflow/transition_actionability.rb +22 -0
  137. data/lib/smith/workflow.rb +44 -18
  138. data/lib/smith.rb +41 -2
  139. data/sig/smith.rbs +1 -0
  140. metadata +110 -19
data/docs/PERSISTENCE.md CHANGED
@@ -120,28 +120,112 @@ workflow.clear_persisted!("ticket:T-1042")
120
120
 
121
121
  These helpers do not make Smith a job system or durable runtime. They only remove repetitive restore/checkpoint boilerplate around the configured persistence adapter while leaving queueing, projection, and recovery policy with the host app.
122
122
 
123
+ ### Host-Coordinated Message Admission
124
+
125
+ At a stable workflow boundary, a host can append one message or a bounded batch
126
+ to the persisted session history:
127
+
128
+ ```ruby
129
+ workflow = ReviewWorkflow.restore("ticket:T-1042", adapter: adapter)
130
+ admission = workflow.append_session_messages!(
131
+ role: :user,
132
+ content: { answer: "The order id is 1042" }
133
+ )
134
+
135
+ workflow.persist!("ticket:T-1042", adapter: adapter)
136
+ ```
137
+
138
+ Smith canonicalizes String and Symbol keys, owns an immutable JSON-like copy,
139
+ appends the batch under the workflow lifecycle mutex, and returns an immutable
140
+ `Smith::Workflow::MessageAdmission`. Its `message_digest` is the SHA-256 digest
141
+ of the canonical message batch and can be correlated with host-owned evidence.
142
+
143
+ Admission is bounded to 100 messages, 64 levels, 100,000 visited values, and one
144
+ MiB of canonical JSON. Integers must fit a signed 64-bit value, Floats must be
145
+ finite, Hash keys must be String or Symbol values, and cyclic or unsupported
146
+ values fail closed. Work is `O(N + sum(K log K))`, where `N` is the number of
147
+ visited values and each `K` is the key count of one Hash; retained output is
148
+ `O(N)`.
149
+
150
+ This API deliberately does not persist, resume, schedule, identify a session,
151
+ or provide idempotency. A durable host must lock its run and checkpoint, verify
152
+ the exact before identity, call admission, persist the next Smith version, and
153
+ record its own cause and before/after evidence atomically. Calling the method
154
+ while ordinary execution or a prepared split-step boundary is active is
155
+ rejected.
156
+
123
157
  ### Host-Coordinated Step Boundaries
124
158
 
125
- Hosts can split one strict transition into explicit prepare, execute, and
126
- checkpoint phases:
159
+ Hosts can split one strict transition into explicit prepare, optional exact
160
+ dispatch claim, execution authorization, execute, and checkpoint phases:
127
161
 
128
162
  ```ruby
129
- transition_name = nil
163
+ def execute_one_step(workflow, key, adapter)
164
+ transition_name = nil
165
+
166
+ ApplicationRecord.transaction do
167
+ transition_name = workflow.prepare_persisted_step!(key, adapter: adapter)
168
+ next unless transition_name
169
+
170
+ prepared = workflow.prepared_persisted_step
171
+ HostEvent.create!(
172
+ name: "step.started",
173
+ transition: transition_name,
174
+ operation_token: prepared.token,
175
+ preparation_digest: prepared.preparation_digest
176
+ )
177
+ end
130
178
 
131
- ApplicationRecord.transaction do
132
- transition_name = workflow.prepare_persisted_step!(key, adapter: adapter)
133
- HostEvent.create!(name: "step.started", transition: transition_name)
134
- end
179
+ return unless transition_name
180
+
181
+ workflow.confirm_prepared_step!
182
+
183
+ if workflow.class.definition_digest
184
+ dispatch = nil
185
+ ApplicationRecord.transaction do
186
+ dispatch = workflow.claim_prepared_step_dispatch!
187
+ HostAttempt.find_by!(operation_token: workflow.prepared_persisted_step.token)
188
+ .update!(
189
+ status: "dispatch_claimed",
190
+ dispatch_token: dispatch.token,
191
+ dispatch_receipt: dispatch.to_h
192
+ )
193
+ end
194
+ workflow.confirm_prepared_step_dispatch!
195
+ end
135
196
 
136
- workflow.confirm_prepared_step!
137
- step = workflow.execute_prepared_step! # provider/tool work; no host transaction
197
+ authorization = workflow.authorize_prepared_step_execution!
198
+ execution_started = false
199
+ begin
200
+ operation_token = authorization.prepared_step.token
201
+ dispatch_token = authorization.dispatch_claim&.token
202
+ start_status = dispatch_token ? "dispatch_claimed" : "prepared"
203
+ changed = ApplicationRecord.transaction do
204
+ HostAttempt.where(
205
+ operation_token:,
206
+ dispatch_token:,
207
+ status: start_status
208
+ ).update_all(status: "executing")
209
+ end
210
+ committed = HostAttempt.find_by(operation_token:, dispatch_token:)
211
+ execution_started = changed == 1 && committed&.status == "executing"
212
+ raise "host execution start did not commit exactly once" unless execution_started
213
+ ensure
214
+ workflow.release_prepared_step_execution!(authorization) unless execution_started
215
+ end
138
216
 
139
- ApplicationRecord.transaction do
140
- workflow.persist!(key, adapter: adapter)
141
- HostEvent.create!(name: "step.completed", transition: step.fetch(:transition))
142
- end
217
+ execution = workflow.execute_authorized_prepared_step!(authorization) # external work; no host transaction
218
+ raise execution.error if execution.failed?
219
+
220
+ step = execution.step
143
221
 
144
- workflow.complete_persisted_step!
222
+ ApplicationRecord.transaction do
223
+ workflow.persist!(key, adapter: adapter)
224
+ HostEvent.create!(name: "step.completed", transition: step.fetch(:transition))
225
+ end
226
+
227
+ workflow.complete_persisted_step!
228
+ end
145
229
  ```
146
230
 
147
231
  This contract is available only to workflows using `idempotency_mode :strict`,
@@ -157,12 +241,134 @@ preparation. The key is copied into an immutable string and the transition
157
241
  contract is frozen. When an adapter can report an open transaction,
158
242
  `confirm_prepared_step!` must verify the committed preparation before execution;
159
243
  it refuses confirmation while that transaction is still open.
160
- `execute_prepared_step!` re-verifies the exact durable preparation and permits
161
- one execution attempt. Preparation also takes an O(S) defensive snapshot of
244
+ After preparation, `prepared_persisted_step` returns an immutable
245
+ `Smith::Workflow::PreparedStep` descriptor containing the opaque preparation
246
+ token, transition and origin names, pinned persistence key and logical version,
247
+ next step number, and canonical preparation-payload digest. It exposes no
248
+ workflow context, messages, tool results, prompts, or provider output. The
249
+ descriptor is a host correlation witness for
250
+ the active process-local boundary, not a replacement checkpoint or authority to
251
+ reconstruct and execute a workflow. Its scalar identity remains stable through
252
+ execution and checkpointing. Use `prepared_persisted_step.to_h` when serializing
253
+ the descriptor itself. It returns `nil` before persistence
254
+ acknowledges preparation, after an ambiguous preparation outcome, when
255
+ preparation begins on an already-terminal workflow, and after committed
256
+ completion. With a transactional adapter, the
257
+ descriptor is available inside the still-open transaction so the host can write
258
+ its own correlated record atomically. The adapter's exact transaction identity
259
+ is authoritative even when a host propagates that transaction context across
260
+ fibers or executors. The descriptor is a correlation witness, not proof that the
261
+ transaction subsequently committed.
262
+ Use `Smith::Workflow::PreparedStep.deserialize` to restore a descriptor from a
263
+ Hash or bounded JSON object. The decoder rejects missing, duplicated, and
264
+ unknown attributes, and constrains persistence/step counters to positive signed
265
+ 64-bit values; direct `Dry::Struct` construction is not a transport decoder.
266
+
267
+ For a workflow declaring `definition_digest`,
268
+ `claim_prepared_step_dispatch!` atomically replaces the exact `prepared`
269
+ payload with a `dispatching` payload through the adapter's `replace_exact`
270
+ capability. The logical `persistence_version` does not change during this claim.
271
+ Memory uses its monitor, Redis uses WATCH/MULTI/EXEC within the client's native
272
+ no-reconnect scope, and Active Record uses one conditional SQL update while
273
+ advancing its optimistic-lock column. The method
274
+ performs no transition, provider, or tool work,
275
+ so an Active Record host can commit its own attempt-ledger state in the same
276
+ transaction without holding that transaction across external work. After that
277
+ transaction commits, `confirm_prepared_step_dispatch!` verifies the exact
278
+ durable claim. A rolled-back claim restores the known preparation; a missing,
279
+ modified, concurrently claimed, or ambiguously acknowledged payload fails
280
+ closed. `authorize_prepared_step_execution!` then re-verifies the exact durable
281
+ claim and returns one process-local execution capability without performing
282
+ transition, provider, or tool work. A host can commit its own `executing`
283
+ attempt record after authorization and before external work. That host write
284
+ must be a conditional compare-and-set on the exact dispatch identity, and the
285
+ host must re-read committed database state after the outer transaction returns.
286
+ An in-memory record or transaction return value is not commit evidence because
287
+ Rails deliberately swallows `ActiveRecord::Rollback`. If the exact host state
288
+ does not commit, the host must call `release_prepared_step_execution!` with the
289
+ exact capability. `execute_authorized_prepared_step!` consumes it once and
290
+ returns a `PreparedStepExecutionResult`. Its `status`, `failed?`, and `error`
291
+ fields preserve transition failure independently of Smith failure routing; a
292
+ host must not treat a failure-routed workflow state as proof that external work
293
+ succeeded. The result owns an iterative, cycle-aware snapshot bounded to 128
294
+ levels, 100,000 visited values, and 4 MiB of string data. Hashes, Arrays,
295
+ Strings, finite scalar JSON-like values, String or Symbol Hash keys, and the
296
+ exact top-level `StandardError` are supported; other values fail closed. Only the exact-payload
297
+ winner may enter provider, tool, or deterministic step work. Smith snapshots
298
+ and validates a successful step result before advancing workflow state. If the
299
+ external call returns an unsupported or over-limit value, the validation error
300
+ follows the transition's normal failure route and is returned as a typed failed
301
+ result; the success destination is never committed.
302
+
303
+ The authorization rejects copying and the standard Ruby Marshal, Psych/YAML,
304
+ and JSON serialization hooks. It is bound by object identity to one workflow
305
+ instance and by process id to the issuing Ruby process, so a forked child cannot
306
+ consume inherited authority. It is not a lease, cross-process fence, or durable
307
+ attempt receipt.
308
+ Before consuming the capability, a host may inspect the exact captured agent
309
+ bindings with a block:
310
+
311
+ ```ruby
312
+ authorization.each_agent_binding do |registry_name, agent_class|
313
+ verify_host_execution_identity!(registry_name, agent_class)
314
+ end
315
+ ```
316
+
317
+ This bounded enumeration performs no provider or tool work and exposes the same
318
+ classes Smith will execute. It is available only while the authorization is
319
+ active in its issuing process. Calling it after release or consumption, from a
320
+ forked process, or without a block fails closed. Smith intentionally does not
321
+ return an `Enumerator`, because deferred iteration could outlive the capability
322
+ scope. Smith first discovers binding names through a bounded graph traversal,
323
+ then resolves the complete bounded set while holding one registry monitor epoch.
324
+ Concurrent registry writes cannot split one authorization across old and new
325
+ registry states. The registry-owned bulk capture reads concrete agent bindings
326
+ without invoking lazy container callbacks, and all public registry mutation
327
+ paths share the same monitor.
328
+
329
+ While that authority is active, Smith's prepended subclass boundary dispatches
330
+ private execution methods through the Smith-owned implementations captured by
331
+ the runtime. This includes nested child workflows that inherit the same
332
+ authorization. Ordinary workflow runs remain polymorphic and continue to honor
333
+ subclass overrides; the membrane exists only for the authorized external-work
334
+ boundary.
335
+ Until the capability is consumed or released, the host must retain exclusive
336
+ authority over that workflow recovery attempt. A process crash after the host
337
+ commits `executing` is an unknown outcome for the host to reconcile; Smith does
338
+ not infer that transition work did or did not begin.
339
+
340
+ `execute_prepared_step!` remains the compatible one-call API. It internally
341
+ authorizes and consumes the step and returns the legacy step Hash. Hosts that
342
+ need a durable pre-dispatch attempt boundary should use the explicit
343
+ authorization API instead.
344
+
345
+ Active Record exact replacement is deliberately a low-level persistence write.
346
+ It does not instantiate the model, run callbacks or validations, or update
347
+ timestamp columns; the payload and locking columns must therefore be treated as
348
+ infrastructure-owned state. Values still use Active Record type casting and
349
+ serialization. This matches Rails' documented
350
+ [`update_all`](https://api.rubyonrails.org/v8.1.1/classes/ActiveRecord/Relation.html#method-i-update_all)
351
+ contract and keeps the comparison and replacement in one SQL statement.
352
+ Smith supplies byte-exact predicates for PostgreSQL, SQLite, MySQL, and Trilogy
353
+ Active Record adapters and fails closed before mutation on an unknown adapter.
354
+ The payload column must be `text` or `string`; JSON/JSONB columns normalize
355
+ representation and therefore cannot satisfy this byte-identity contract.
356
+
357
+ The claim returns an immutable `Smith::Workflow::PreparedStepDispatch` receipt
358
+ containing the prepared-step descriptor, an opaque dispatch token, and the
359
+ canonical dispatch-payload digest. Persist `receipt.to_h` with the host attempt
360
+ record. Use `PreparedStepDispatch.deserialize` for a bounded Hash/JSON transport
361
+ round-trip; direct `Dry::Struct` construction is not a transport decoder.
362
+
363
+ Preparation also takes an O(S) defensive snapshot of
162
364
  mutable workflow execution state, where S is the serialized state size. This
163
365
  is the necessary ownership boundary: aliases held before preparation cannot
164
366
  change what the accepted transition later consumes, and public mutable-state
165
367
  readers and `to_state` return defensive snapshots while the boundary is active.
368
+ The canonical correlation digest is computed before dispatch in O(S log K)
369
+ time, where K is the largest object-key count, and is bounded to 4 MiB and
370
+ 100,000 JSON nodes. Larger state must use Smith artifact references instead of
371
+ an inline split-step payload.
166
372
  Subclass execution entry points remain guarded, and subclass TTL helpers cannot
167
373
  override the pinned boundary policy. The
168
374
  in-memory marker remains armed so any serialization
@@ -175,11 +381,115 @@ likewise refuses to run while the adapter reports an open transaction.
175
381
  `prepared_persisted_step?` exposes whether the one execution attempt remains
176
382
  available without revealing transition internals.
177
383
 
384
+ #### Restart-Safe Prepared Recovery
385
+
386
+ Cross-process recovery is opt-in. The workflow class must declare a lowercase
387
+ SHA-256 `definition_digest` covering the complete executable definition:
388
+ workflow topology, deterministic code assets, effective agents, prompts,
389
+ models, tools, schemas, and guardrails. Smith validates this digest but does not
390
+ derive it from Ruby reflection. `Proc#source_location` exposes only a filename
391
+ and line, while `RubyVM::InstructionSequence` is MRI-specific,
392
+ version-sensitive, and not portable across machines or Ruby versions. See the
393
+ official [Proc documentation](https://docs.ruby-lang.org/en/master/Proc.html)
394
+ and [InstructionSequence documentation](https://ruby-doc.org/3.2/RubyVM/InstructionSequence.html).
395
+
396
+ ```ruby
397
+ class GeneratedWorkflow < Smith::Workflow
398
+ definition_digest signed_package.digest
399
+ idempotency_mode :strict
400
+ # states and transitions...
401
+ end
402
+
403
+
404
+ adapter = Smith::PersistenceAdapters::ActiveRecordStore.new(
405
+ model: WorkflowState,
406
+ identity: "primary:workflow-states"
407
+ )
408
+
409
+ descriptor = Smith::Workflow::PreparedStep.deserialize(host_receipt.fetch(:prepared_step))
410
+ decision = Smith::Workflow::PreparedStepRecovery.not_started(descriptor)
411
+ workflow = GeneratedWorkflow.recover_prepared_step(decision, adapter: adapter)
412
+ dispatch = workflow.claim_prepared_step_dispatch!
413
+ authorization = workflow.authorize_prepared_step_execution!
414
+ # Conditionally commit the exact host attempt as executing and re-read it here.
415
+ # Release the authorization unless the committed row proves that this worker
416
+ # won; otherwise consume it outside the transaction.
417
+ execution = workflow.execute_authorized_prepared_step!(authorization)
418
+
419
+ # A replacement worker may continue a committed claim only when the host's
420
+ # exclusive attempt ledger proves transition/provider/tool execution did not start.
421
+ dispatch = Smith::Workflow::PreparedStepDispatch.deserialize(host_receipt.fetch(:dispatch_receipt))
422
+ decision = Smith::Workflow::PreparedStepRecovery.not_started(dispatch)
423
+ workflow = GeneratedWorkflow.recover_prepared_step(decision, adapter: adapter)
424
+ authorization = workflow.authorize_prepared_step_execution!
425
+ execution = workflow.execute_authorized_prepared_step!(authorization)
426
+ ```
427
+
428
+ The host may construct `not_started` only after acquiring exclusive recovery
429
+ authority and proving from its durable attempt ledger that provider/tool
430
+ dispatch never started. Smith does not own leases, queues, attempt records, or
431
+ that fact. Recovery performs one fetch, validates the exact canonical payload,
432
+ class name, schema, definition digest, adapter identity, transition, origin,
433
+ step number, token, key, and version, then reconstructs a guarded process-local
434
+ boundary. It refuses an open adapter transaction and never migrates an
435
+ in-progress payload. Ordinary `restore` remains fail-closed.
436
+
437
+ Restart-safe preparation additionally requires a non-expiring adapter exposing
438
+ both `replace_exact` and a stable, bounded `persistence_identity`. Configure the
439
+ same non-secret identity for every process connected to one durable storage
440
+ domain. Memory's generated identity is process-local and is suitable only for
441
+ same-process tests. Cache adapters do not provide exact CAS and are unsupported
442
+ for restart-safe prepared recovery.
443
+
444
+ Recovery authorizes a payload already marked `dispatching` only when the host
445
+ supplies the exact committed `PreparedStepDispatch` receipt and an explicit
446
+ `not_started` decision. The host may make that decision only while holding
447
+ exclusive recovery authority and after its durable attempt ledger proves that
448
+ transition/provider/tool execution never started. Without that proof, a crash
449
+ or lost acknowledgement after the exact claim has an uncertain external
450
+ outcome. Smith never rewinds or executes it on its own.
451
+
452
+ When claim and host-attempt writes share an Active Record transaction,
453
+ `claim_prepared_step_dispatch!` leaves execution unavailable until
454
+ `confirm_prepared_step_dispatch!` runs after commit. A rollback restores the
455
+ known prepared boundary and permits a new exact claim. Any other confirmation
456
+ result remains uncertain and cannot execute.
457
+
458
+ The definition digest is pinned and sealed on the class object when preparation
459
+ or recovery authority is acquired. A concurrent DSL setter either completes
460
+ before sealing and becomes the pinned identity, or raises before claim and
461
+ execution. Repeating the same digest remains idempotent. A reloaded definition
462
+ must use a new class object; recovery through that class still validates its
463
+ digest against the durable descriptor.
464
+
465
+ Exact replacement is a current-value compare-and-swap, not a historical fencing
466
+ ledger. A restart-safe workflow key must be exclusively owned by Smith's
467
+ versioned/exact persistence path. Hosts must not call unconditional `store`,
468
+ delete/recreate the key, or restore an earlier byte-identical payload while a
469
+ boundary is active. Such an out-of-band A-to-B-to-A rewrite is
470
+ information-theoretically indistinguishable from the original value without a
471
+ separate monotonic storage generation. Hosts that permit other writers must
472
+ fence them in their own durable attempt/storage ledger; Smith does not absorb
473
+ that host policy.
474
+
475
+ Redis versioned and exact CAS require a client-native reconnect-disabling
476
+ scope around WATCH/MULTI/EXEC. redis-rb 5.4 exposes `without_reconnect`, while
477
+ newer clients may expose `disable_reconnection`; Smith accepts either and
478
+ refuses CAS before WATCH when neither exists. This follows redis-rb's official
479
+ [reconnection contract](https://github.com/redis/redis-rb#reconnections) and
480
+ prevents a transaction from being replayed after connection-scoped WATCH state
481
+ has been lost.
482
+
178
483
  Workflow classes are part of the execution contract once preparation begins.
179
484
  Hosts must not add or prepend methods to that class until the boundary is
180
- complete, and custom `inherited` hooks must call `super`. Ruby intentionally
181
- allows open-class mutation, so Smith treats post-preparation class mutation as
182
- an unsupported host lifecycle violation rather than attempting to sandbox it.
485
+ complete, and custom `inherited` hooks must call `super`. The same rule applies
486
+ to reachable nested workflow and captured agent classes. Smith captures and
487
+ revalidates nested transition contracts and exact agent class bindings, while a
488
+ host must not concurrently mutate those class objects. Ruby intentionally
489
+ allows open-class mutation. Smith blocks definition-digest changes after the
490
+ class is sealed, while other post-preparation class mutation remains an
491
+ unsupported host lifecycle violation rather than something Smith attempts to
492
+ sandbox.
183
493
 
184
494
  The lifecycle row and Smith checkpoint are atomic only when the persistence
185
495
  adapter participates in the same transaction and database connection domain as
@@ -189,8 +499,11 @@ cannot confirm an uncommitted boundary. Memory, Redis, cache-backed,
189
499
  cross-database, and external adapters do not participate in
190
500
  `ApplicationRecord.transaction`; hosts using them must supply their own
191
501
  coordination protocol and must not claim the two writes are atomic.
192
- Custom transactional adapters should implement `transaction_open?` so Smith can
193
- enforce the same commit-aware confirmation rule.
502
+ Custom transactional adapters should implement both `transaction_open?` and
503
+ `transaction_identity`. The identity must be stable for one open transaction or
504
+ savepoint and change for every later transaction. Smith's `ActiveRecordStore`
505
+ uses the public `current_transaction.uuid` API for this exact fence. Smith fails
506
+ before writing when an open transaction has no exact identity capability.
194
507
 
195
508
  If execution raises or the process dies after external work, the durable marker
196
509
  remains set. A strict restore therefore fails closed with
@@ -248,7 +561,7 @@ standard optimistic-locking column:
248
561
  ```ruby
249
562
  create_table :workflow_states do |t|
250
563
  t.string :key, null: false, index: { unique: true }
251
- t.jsonb :payload, null: false
564
+ t.text :payload, null: false
252
565
  t.integer :lock_version, null: false, default: 0
253
566
  t.timestamps
254
567
  end
@@ -258,7 +571,10 @@ end
258
571
 
259
572
  Smith.configure do |config|
260
573
  config.persistence_adapter = :active_record
261
- config.persistence_options = { model: WorkflowState }
574
+ config.persistence_options = {
575
+ model: WorkflowState,
576
+ identity: "primary:workflow-states"
577
+ }
262
578
  end
263
579
  ```
264
580
 
@@ -283,8 +599,14 @@ Smith.configure do |config|
283
599
  end
284
600
  ```
285
601
 
286
- The unique key must be enforced by the database. Initial creation uses Rails'
287
- native `create_or_find_by!` savepoint path, so a concurrent key insert does not
602
+ The unique key must be enforced by the database. Before every exact write,
603
+ Smith verifies from database metadata that the configured key is the database
604
+ primary key or has its own unconditional single-column unique index. Model-only
605
+ primary-key declarations, partial indexes, and composite indexes are not
606
+ accepted. Smith uses Active Record's table-keyed schema cache; a cold check is
607
+ `O(I)` in the table's index count and never scans workflow rows. Initial
608
+ creation uses Rails' native
609
+ `create_or_find_by!` savepoint path, so a concurrent key insert does not
288
610
  invalidate a PostgreSQL caller transaction. A collision on another unique
289
611
  constraint is not misreported as a key-version conflict.
290
612
 
@@ -21,9 +21,9 @@ module Smith
21
21
  instance.after_completion(result, context)
22
22
  end
23
23
 
24
- def invoke_agent(agent_class, prepared_input)
24
+ def invoke_agent(agent_class, prepared_input, output_schema: agent_class.output_schema)
25
25
  check_deadline!
26
- response, model_used = complete_with_provider(agent_class, prepared_input)
26
+ response, model_used = complete_with_provider(agent_class, prepared_input, output_schema:)
27
27
  snapshot_and_finalize(agent_class, response, model_used)
28
28
  end
29
29
 
@@ -33,12 +33,12 @@ module Smith
33
33
  # parallel fan-out, two branches sharing the workflow could race
34
34
  # and attribute the wrong model to the wrong response. Local data
35
35
  # eliminates the race entirely.
36
- def complete_with_provider(agent_class, prepared_input)
36
+ def complete_with_provider(agent_class, prepared_input, output_schema:)
37
37
  models = build_model_chain(agent_class)
38
38
 
39
39
  models.each_with_index do |model_id, index|
40
40
  check_deadline! if index.positive?
41
- response = attempt_model(agent_class, prepared_input, model_id)
41
+ response = attempt_model(agent_class, prepared_input, model_id, output_schema:)
42
42
  return [response, model_id]
43
43
  rescue Smith::Error
44
44
  raise
@@ -71,10 +71,10 @@ module Smith
71
71
  "model block for #{agent_class} must return a non-empty string; got #{result.inspect}"
72
72
  end
73
73
 
74
- def attempt_model(agent_class, prepared_input, model_id)
74
+ def attempt_model(agent_class, prepared_input, model_id, output_schema:)
75
75
  chat = agent_class.chat(model: model_id, **bridge_workflow_inputs(agent_class))
76
76
  add_prepared_input(chat, prepared_input)
77
- chat = chat.with_schema(agent_class.output_schema) if agent_class.output_schema
77
+ chat = chat.with_schema(output_schema) if output_schema
78
78
  chat.complete
79
79
  end
80
80
 
@@ -121,7 +121,7 @@ module Smith
121
121
  end
122
122
 
123
123
  merge_system_messages!(chat, system_messages) if system_messages.any?
124
- other_messages.each { |message| chat.add_message(message) }
124
+ other_messages.each { |message| add_message(chat, message) }
125
125
  end
126
126
 
127
127
  def provider_safe_prepared_input(prepared_input)
@@ -141,7 +141,7 @@ module Smith
141
141
  end
142
142
 
143
143
  def merge_system_messages!(chat, prepared_system_messages)
144
- return prepared_system_messages.each { |message| chat.add_message(message) } unless chat.respond_to?(:messages)
144
+ return prepared_system_messages.each { |message| add_message(chat, message) } unless chat.respond_to?(:messages)
145
145
 
146
146
  existing_system_contents = chat.messages.filter_map do |message|
147
147
  message.content if message_role(message) == :system
@@ -153,16 +153,25 @@ module Smith
153
153
  combined_contents = existing_system_contents + prepared_system_contents
154
154
  return if combined_contents.empty?
155
155
  unless combined_contents.all?(String)
156
- return prepared_system_messages.each { |message| chat.add_message(message) }
156
+ return prepared_system_messages.each { |message| add_message(chat, message) }
157
157
  end
158
158
 
159
159
  if chat.respond_to?(:with_instructions)
160
160
  chat.with_instructions(combined_contents.join("\n\n"))
161
161
  else
162
- prepared_system_messages.each { |message| chat.add_message(message) }
162
+ prepared_system_messages.each { |message| add_message(chat, message) }
163
163
  end
164
164
  end
165
165
 
166
+ def add_message(chat, message)
167
+ attributes = if message.is_a?(Hash)
168
+ message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
169
+ else
170
+ message
171
+ end
172
+ chat.add_message(attributes)
173
+ end
174
+
166
175
  def message_role(message)
167
176
  message_attribute(message, :role)&.to_sym
168
177
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module Registry
6
+ module ExecutionBindingCapture
7
+ def capture_bindings!(requests)
8
+ registry_monitor.synchronize do
9
+ requests.each_with_object({}) do |request, bindings|
10
+ key = normalize_key(request.fetch(:name))
11
+ binding = binding_for(key)
12
+ klass = binding&.fetch(:agent_class, nil)
13
+ unless klass
14
+ raise Smith::WorkflowError,
15
+ "unresolved #{request.fetch(:role)} :#{key}" \
16
+ "#{fetch_suffix(request.fetch(:workflow_class), request.fetch(:transition_name))}"
17
+ end
18
+
19
+ bindings[key] = klass
20
+ end.freeze
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module Registry
6
+ module MutationBoundary
7
+ def merge(...)
8
+ registry_monitor.synchronize { super }
9
+ end
10
+
11
+ def decorate(...)
12
+ registry_monitor.synchronize { super }
13
+ end
14
+
15
+ def namespace(...)
16
+ registry_monitor.synchronize { super }
17
+ end
18
+
19
+ def import(...)
20
+ registry_monitor.synchronize { super }
21
+ end
22
+
23
+ def configure(...)
24
+ registry_monitor.synchronize { super }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,11 +3,15 @@
3
3
  require "dry-container"
4
4
  require "monitor"
5
5
  require_relative "registry_binding"
6
+ require_relative "registry/execution_binding_capture"
7
+ require_relative "registry/mutation_boundary"
6
8
 
7
9
  module Smith
8
10
  class Agent
9
11
  module Registry
10
12
  extend Dry::Container::Mixin
13
+ extend ExecutionBindingCapture
14
+ extend MutationBoundary
11
15
 
12
16
  def self.normalize_key(name)
13
17
  name.to_s
@@ -126,6 +130,10 @@ module Smith
126
130
  existing_name == klass_name
127
131
  end
128
132
  private_class_method :stale_reload_binding?
133
+
134
+ class << self
135
+ private :_container, :config
136
+ end
129
137
  end
130
138
  end
131
139
  end
data/lib/smith/agent.rb CHANGED
@@ -56,10 +56,19 @@ module Smith
56
56
  @fallback_models_list = entries.uniq
57
57
  end
58
58
 
59
- def register_as(name = nil)
59
+ def register_as(name = nil, publish: true)
60
60
  return @registered_name if name.nil?
61
61
 
62
- @registered_name = name
62
+ raise ArgumentError, "publish must be true or false" unless [true, false].include?(publish)
63
+
64
+ @registered_name = canonical_registration_name(name)
65
+ publish ? publish_registration! : self
66
+ end
67
+
68
+ def publish_registration!
69
+ name = @registered_name
70
+ raise Smith::AgentRegistryError, "agent registration identity is not configured" unless name
71
+
63
72
  Registry.ensure_registered(name.to_sym, self)
64
73
  end
65
74
 
@@ -109,6 +118,19 @@ module Smith
109
118
 
110
119
  attr_reader :model_block
111
120
 
121
+ private
122
+
123
+ def canonical_registration_name(name)
124
+ symbol = name.to_sym
125
+ raise TypeError, "agent registration name must convert to a Symbol" unless symbol.is_a?(Symbol)
126
+
127
+ name.is_a?(String) ? name.dup.freeze : symbol
128
+ rescue NoMethodError
129
+ raise TypeError, "agent registration name must respond to #to_sym"
130
+ end
131
+
132
+ public
133
+
112
134
  # Whether this agent class has any model configured (static or block).
113
135
  # Smith::Workflow::Execution uses this as a precondition for invoking
114
136
  # the agent; agents declared without a model are skipped.