smith-agents 0.10.0 → 0.12.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. metadata +88 -1
@@ -104,9 +104,26 @@ module Smith
104
104
  return false if fallback_age.nil? || fallback_age < threshold_seconds
105
105
  end
106
106
 
107
+ stuck_payload?(payload, now, threshold_seconds)
108
+ end
109
+
110
+ # A waiting run is not stuck until its deadline has passed by the
111
+ # threshold without the host expiring the wait; a run that waits is
112
+ # never terminal.
113
+ def stuck_payload?(payload, now, threshold_seconds)
114
+ wait_deadline = payload_wait_deadline(payload)
115
+ return (now - wait_deadline) > threshold_seconds if wait_deadline
116
+
107
117
  !terminal_in_payload?(payload)
108
118
  end
109
119
 
120
+ def payload_wait_deadline(payload)
121
+ deadline = JSON.parse(payload).dig("awaiting", "deadline_at")
122
+ deadline && Time.iso8601(deadline)
123
+ rescue JSON::ParserError, ArgumentError, TypeError
124
+ nil
125
+ end
126
+
110
127
  def heartbeat_age(persistence_key:, adapter: Smith.persistence_adapter)
111
128
  raise WorkflowError, "persistence_adapter is not configured" if adapter.nil?
112
129
 
@@ -121,6 +138,20 @@ module Smith
121
138
  age_from_payload_updated_at(payload, Time.now.utc)
122
139
  end
123
140
 
141
+ # Delivers `event` to the run stored under `key` (see
142
+ # Workflow#resume!) and stores the result with the version check.
143
+ # Returns `:resumed`, or `:stale` when no run is stored or it does not
144
+ # await this event and token.
145
+ def resume!(key, event:, token:, payload:, adapter: Smith.persistence_adapter)
146
+ stored_wait_call(key, adapter:, done: :resumed) { |workflow| workflow.resume!(event:, token:, payload:) }
147
+ end
148
+
149
+ # Ends the stored run's wait if its deadline has passed at `now` (see
150
+ # Workflow#expire_wait!). The host's scheduler decides when to call it.
151
+ def expire_wait!(key, token:, now: Time.now.utc, adapter: Smith.persistence_adapter)
152
+ stored_wait_call(key, adapter:, done: :expired) { |workflow| workflow.expire_wait!(token:, now:) }
153
+ end
154
+
124
155
  def run_persisted!(key: nil, context: {}, adapter: Smith.persistence_adapter, clear: :done, on_step: nil, **kwargs)
125
156
  clear_policy = normalize_clear_policy(clear)
126
157
  resolved_key = resolved_persistence_key(key:, context:)
@@ -133,6 +164,53 @@ module Smith
133
164
 
134
165
  private
135
166
 
167
+ # Exactly one completion of a wait must win, so the store must check
168
+ # versions. A run caught mid-step (strict mode) can still take an
169
+ # early delivery for the await it is entering; anything else about it
170
+ # is refused as an unfinished step.
171
+ def stored_wait_call(key, adapter:, done:, &)
172
+ resolved_key = explicit_persistence_key!(key)
173
+ store = persistence_adapter!(adapter)
174
+ unless Smith::PersistenceAdapters.supports?(store, :store_versioned)
175
+ raise WorkflowError, "resume! and expire_wait! need a persistence adapter with store_versioned"
176
+ end
177
+
178
+ payload = fetch_persisted_payload(resolved_key, adapter: store)
179
+ return :missing unless payload
180
+
181
+ stored_wait_result(JSON.parse(payload), resolved_key, store, done, &)
182
+ end
183
+
184
+ def stored_wait_result(state, key, store, done)
185
+ workflow, in_progress = restore_for_wait(state)
186
+ workflow.instance_variable_set(:@persistence_key, key)
187
+ mark_batch_completion!(workflow, key, store, in_progress)
188
+ result = yield(workflow)
189
+ raise in_progress if in_progress && result != :resumed && result != :failed
190
+
191
+ workflow.clear_step_in_progress!
192
+ workflow.persist!(key, adapter: store) if [done, :failed].include?(result)
193
+ result
194
+ end
195
+
196
+ # A batch completion makes provider calls (a follow-up, a retry over
197
+ # the wire, a new batch), so in strict mode it runs as a marked step:
198
+ # stored as under way first, and a run caught in one is unfinished.
199
+ def mark_batch_completion!(workflow, key, store, in_progress)
200
+ return unless workflow.send(:batch_waiting?)
201
+ raise in_progress if in_progress
202
+ return unless idempotency_mode == :strict
203
+
204
+ workflow.mark_step_in_progress!
205
+ workflow.persist!(key, adapter: store)
206
+ end
207
+
208
+ def restore_for_wait(state)
209
+ [from_state(state), nil]
210
+ rescue Smith::StepInProgressOnRestore => e
211
+ [allocate.tap { |workflow| workflow.send(:restore_state, state, allow_step_in_progress: true) }, e]
212
+ end
213
+
136
214
  def fetch_persisted_payload(key, adapter:)
137
215
  persistence_adapter!(adapter).fetch(key)
138
216
  end
@@ -158,25 +236,19 @@ module Smith
158
236
  parsed = JSON.parse(payload)
159
237
  state_name = parsed["state"] || parsed[:state]
160
238
  class_name = parsed["class"] || parsed[:class]
161
- next_transition = parsed["next_transition_name"] || parsed[:next_transition_name]
162
-
163
239
  return false unless state_name && class_name
164
240
 
165
- klass = Object.const_get(class_name)
166
- state = state_name_for_payload(klass, state_name)
167
- !klass.transition_from?(state) && next_transition.nil?
241
+ next_transition = parsed["next_transition_name"] || parsed[:next_transition_name]
242
+ terminal_payload_state?(Object.const_get(class_name), state_name, next_transition)
168
243
  rescue JSON::ParserError, NameError, NoMethodError
169
244
  false
170
245
  end
171
246
 
172
- def state_name_for_payload(klass, state_name)
173
- states = klass.instance_variable_get(:@states) || []
174
- return state_name if states.include?(state_name)
175
-
176
- symbolized = state_name.to_sym if state_name.respond_to?(:to_sym)
177
- return symbolized if states.include?(symbolized)
178
-
179
- state_name
247
+ # A state the class no longer knows can never advance or restore, so
248
+ # it is not terminal: an old one reports as stuck.
249
+ def terminal_payload_state?(klass, state_name, next_transition)
250
+ state = klass.resolve_state_name(state_name)
251
+ !state.nil? && !klass.transition_from?(state) && next_transition.nil?
180
252
  end
181
253
 
182
254
  def persistence_adapter!(adapter)
@@ -192,7 +264,7 @@ module Smith
192
264
  raise WorkflowError, "persistence key is required unless workflow defines persistence_key" unless builder
193
265
 
194
266
  derived = if builder.arity == 1
195
- builder.call(context)
267
+ builder.call(ContextKeys.normalize_context(context))
196
268
  else
197
269
  builder.call
198
270
  end
@@ -241,17 +313,19 @@ module Smith
241
313
  end
242
314
 
243
315
  def run_persisted!(key = nil, adapter: Smith.persistence_adapter, on_step: nil)
244
- return build_run_result([]) if terminal?
316
+ return build_run_result([]) if nothing_to_advance?
245
317
 
246
318
  resolved_key = resolve_persistence_key!(key)
247
319
  steps = []
320
+ mark_active_step_start!(true)
248
321
  persist!(resolved_key, adapter:)
249
322
 
250
- until terminal?
323
+ until nothing_to_advance?
251
324
  ensure_transition_budget!
252
325
 
253
326
  if strict_idempotency?
254
- mark_step_in_progress!
327
+ mark_step_in_progress! unless replay_safe_pending_step?
328
+ mark_active_step_start!(true)
255
329
  persist!(resolved_key, adapter:)
256
330
  end
257
331
 
@@ -264,6 +338,7 @@ module Smith
264
338
  steps << step if step
265
339
 
266
340
  clear_step_in_progress!
341
+ mark_active_step_start!(!nothing_to_advance?)
267
342
  persist!(resolved_key, adapter:)
268
343
  invoke_on_step_callback(step, on_step) if step
269
344
  end
@@ -272,17 +347,15 @@ module Smith
272
347
  end
273
348
 
274
349
  def advance_persisted!(key = nil, adapter: Smith.persistence_adapter, on_step: nil)
275
- return if terminal?
350
+ return if nothing_to_advance?
276
351
 
277
352
  resolved_key = resolve_persistence_key!(key)
278
353
  ensure_transition_budget!
279
- mark_step_in_progress! if strict_idempotency?
354
+ mark_step_in_progress! if strict_idempotency? && !replay_safe_pending_step?
355
+ mark_active_step_start!(true)
280
356
  persist!(resolved_key, adapter:)
281
357
  step = advance!
282
- clear_step_in_progress!
283
- persist!(resolved_key, adapter:) if step
284
- invoke_on_step_callback(step, on_step) if step
285
- step
358
+ finish_persisted_advance!(step, resolved_key, adapter, on_step)
286
359
  rescue StandardError
287
360
  clear_pre_step_marker!(resolved_key, adapter:) if defined?(resolved_key) && resolved_key
288
361
  raise
@@ -296,12 +369,7 @@ module Smith
296
369
  payload = JSON.generate(to_state.merge(persistence_version: next_version))
297
370
  payload = yield(payload, next_version) if block_given?
298
371
 
299
- dispatch_store!(store, resolved_key, payload, previous_version: previous_version)
300
-
301
- # Increment ONLY after successful store. On PersistenceVersionConflict
302
- # (raised by store_versioned), @persistence_version stays at the
303
- # previous value so callers can rescue + restore + retry cleanly.
304
- @persistence_version = next_version
372
+ store_payload!(store, resolved_key, payload, previous_version:)
305
373
  self
306
374
  end
307
375
 
@@ -311,6 +379,12 @@ module Smith
311
379
  self
312
380
  end
313
381
 
382
+ # A map, call or spawn launches through a launcher that is idempotent
383
+ # by contract, so strict mode lets its step run again after a crash.
384
+ def replay_safe_pending_step?
385
+ self.class.find_transition(pending_transition_name)&.child_runs? || false
386
+ end
387
+
314
388
  def mark_step_in_progress!
315
389
  @step_in_progress = true
316
390
  end
@@ -321,6 +395,33 @@ module Smith
321
395
 
322
396
  private
323
397
 
398
+ # Increment ONLY after successful store. On PersistenceVersionConflict
399
+ # (raised by store_versioned), @persistence_version stays at the
400
+ # previous value so callers can rescue + restore + retry cleanly. A
401
+ # streamed step's output reaches its sink once stored (StepStream); a
402
+ # store that fails tells the sink the step failed.
403
+ def store_payload!(store, key, payload, previous_version:)
404
+ begin
405
+ check_state_size!(key, payload)
406
+ dispatch_store!(store, key, payload, previous_version:)
407
+ rescue StandardError => e
408
+ StepStream.instance_method(:discard_streamed_output).bind_call(self, e.class)
409
+ raise
410
+ end
411
+ @persistence_version = previous_version + 1
412
+ StepStream.instance_method(:deliver_streamed_output).bind_call(self)
413
+ end
414
+
415
+ # The step, or the wait it entered, is stored, and the mark of a step
416
+ # under way is cleared.
417
+ def finish_persisted_advance!(step, key, adapter, on_step)
418
+ clear_step_in_progress!
419
+ mark_active_step_start!(false)
420
+ persist!(key, adapter:)
421
+ invoke_on_step_callback(step, on_step) if step
422
+ step
423
+ end
424
+
324
425
  def strict_idempotency?
325
426
  self.class.idempotency_mode == :strict
326
427
  end
@@ -330,6 +431,7 @@ module Smith
330
431
  return if step_work_started?
331
432
 
332
433
  clear_step_in_progress!
434
+ mark_active_step_start!(false)
333
435
  persist!(key, adapter:)
334
436
  end
335
437
 
@@ -350,6 +452,13 @@ module Smith
350
452
  store.record_heartbeat(key, **kwargs) if Smith::PersistenceAdapters.supports?(store, :record_heartbeat)
351
453
  end
352
454
 
455
+ # A state past persistence_max_bytes is refused before anything is
456
+ # written, and before a split-step boundary counts a write as begun.
457
+ def check_state_size!(key, payload)
458
+ limit = Smith.config.persistence_max_bytes
459
+ raise PersistedStateTooLarge.new(key:, bytes: payload.bytesize, limit:) if limit && payload.bytesize > limit
460
+ end
461
+
353
462
  # Resolves the effective TTL once per persist, with the class-level
354
463
  # DSL override taking precedence over Smith.config.persistence_ttl.
355
464
  # Returns nil when neither is set, which collapses ttl_kwarg to an
@@ -387,9 +496,9 @@ module Smith
387
496
  end
388
497
 
389
498
  def invoke_on_step_callback(step, callback)
390
- callback&.call(step)
499
+ IdempotencyKey.__send__(:without) { callback&.call(step) }
391
500
  rescue StandardError => e
392
- Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message}")
501
+ Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message} (#{e.class})")
393
502
  end
394
503
  end
395
504
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  require_relative "agent_result"
6
+ require_relative "execution_result_snapshot"
6
7
  require_relative "optimization_state"
7
8
 
8
9
  module Smith
@@ -24,6 +25,7 @@ module Smith
24
25
  transition_name: transition.name,
25
26
  role: :evaluator
26
27
  )
28
+ pend_evaluations
27
29
  run_optimization_loop(state)
28
30
  end
29
31
 
@@ -31,7 +33,9 @@ module Smith
31
33
  state.config[:max_rounds].times do |round|
32
34
  # The overlay scopes every trace and usage fact from this round's
33
35
  # generator and evaluator calls to the round that produced them.
34
- result = Attribution.with(round: round) { run_optimization_round(state, round) }
36
+ result = Attribution.with(round: round) do
37
+ IdempotencyKey.__send__(:within, :round, round) { run_optimization_round(state, round) }
38
+ end
35
39
  return result if result
36
40
  end
37
41
 
@@ -39,12 +43,16 @@ module Smith
39
43
  "optimization exhausted #{state.config[:max_rounds]} rounds without acceptance")
40
44
  end
41
45
 
46
+ # Each role is a position of its own in the round, so the generator,
47
+ # the before_eval callback and the evaluator never share a key.
42
48
  def run_optimization_round(state, round)
43
- generate_candidate!(state, round)
44
- invoke_before_eval(state)
45
- evaluation = normalize_evaluation(evaluate_candidate(state))
49
+ IdempotencyKey.__send__(:within, :generator) { generate_candidate!(state, round) }
50
+ rejection = IdempotencyKey.__send__(:within, :before_eval) { before_eval_rejection(state) }
51
+ evaluation = rejection ||
52
+ normalize_evaluation(IdempotencyKey.__send__(:within, :evaluator) { evaluate_candidate(state) })
46
53
  validate_evaluation_structure!(evaluation)
47
54
  validate_evaluation_fields!(evaluation, state.config)
55
+ record_evaluation(state, evaluation, round, rejection ? :before_eval : :evaluator)
48
56
 
49
57
  return state.candidate if evaluation[:accept]
50
58
 
@@ -61,6 +69,88 @@ module Smith
61
69
  nil
62
70
  end
63
71
 
72
+ # Each valid round's evaluation, in round order: the round, whether the
73
+ # evaluator or a before_eval rejection gave it, and the verdict as
74
+ # normalized, copied and frozen so neither the loop nor a host holding a
75
+ # record can change it. Recorded before any exit, so the verdict that
76
+ # ends the loop (accepted, converged, or under the threshold) is kept.
77
+ # The loop's state carries a frozen copy, so a callable reads the
78
+ # verdicts so far and cannot rewrite the step's.
79
+ def record_evaluation(state, evaluation, round, source)
80
+ records = @pending_evaluations.values.last
81
+ record = { round: round, source: source, verdict: frozen_verdict(evaluation) }.freeze
82
+ within_step_record_limits!(@pending_evaluations.values.flatten(1) << record)
83
+ records << record
84
+ state.evaluations = records.dup.freeze
85
+ end
86
+
87
+ # The verdicts ride on the step's record, which a split step snapshots
88
+ # under ExecutionResultSnapshot's limits. A round whose verdict would
89
+ # take the step's verdicts past them fails here, the same way in every
90
+ # run mode, and the verdicts already recorded still fit a failed step's
91
+ # record.
92
+ def within_step_record_limits!(records)
93
+ ExecutionResultSnapshot.new({ evaluations: records }).call
94
+ rescue WorkflowError => e
95
+ raise WorkflowError, "evaluations exceed a step record's limits: #{e.message}"
96
+ end
97
+
98
+ # A verdict holds JSON values only, as a provider's structured output
99
+ # does: anything else (an object a before_eval returned, a key that is
100
+ # not a String or Symbol, a non-finite Float) fails the round here, the
101
+ # same way in every run mode, rather than when a split step snapshots it.
102
+ def frozen_verdict(value)
103
+ case value
104
+ when Hash then value.to_h { |key, nested| [verdict_key(key), frozen_verdict(nested)] }.freeze
105
+ when Array then value.map { |nested| frozen_verdict(nested) }.freeze
106
+ when String then StringSnapshot.copy(value, freeze: true)
107
+ else verdict_scalar(value)
108
+ end
109
+ end
110
+
111
+ def verdict_scalar(value)
112
+ case value
113
+ when Float then finite_verdict_number(value)
114
+ when Symbol, Integer, true, false, nil then value
115
+ else raise WorkflowError, "evaluation must hold JSON values; got #{value.class}"
116
+ end
117
+ end
118
+
119
+ def verdict_key(key)
120
+ return key if key.is_a?(Symbol)
121
+ return StringSnapshot.copy(key, freeze: true) if key.is_a?(String)
122
+
123
+ raise WorkflowError, "evaluation keys must be Strings or Symbols; got #{key.class}"
124
+ end
125
+
126
+ def finite_verdict_number(value)
127
+ return value if value.finite?
128
+
129
+ raise WorkflowError, "evaluation must hold finite numbers; got #{value}"
130
+ end
131
+
132
+ # An optimize step's record carries every verdict its loops recorded
133
+ # under :evaluations, whether the step completed or failed, each naming
134
+ # the attempt that gave it (`@step_attempt`, the retry policy's): a step
135
+ # `retry_on` runs again starts its loop afresh, rounds counting from 0
136
+ # again, and an earlier attempt's verdicts were given and paid for all
137
+ # the same. The key is there whenever a loop ran; a step that failed
138
+ # before its loop (an agent not registered, a guardrail) and any other
139
+ # step have none. The carrier is transient: the loop's list keyed by its
140
+ # attempt, cleared once the step's record is committed or its failure
141
+ # captured, and at every step's start.
142
+ def pend_evaluations
143
+ (@pending_evaluations ||= {})[@step_attempt || 1] = []
144
+ end
145
+
146
+ def fold_pending_evaluations(step)
147
+ return unless @pending_evaluations
148
+
149
+ step[:evaluations] = @pending_evaluations.flat_map do |attempt, records|
150
+ records.map { |record| { attempt: attempt, **record }.freeze }
151
+ end.freeze
152
+ end
153
+
64
154
  # :raise => WorkflowError(message); :return_last => state.candidate;
65
155
  # callable => mode.call(state). Default :raise preserves legacy
66
156
  # behavior for hosts that don't opt in to graceful exits.
@@ -127,21 +217,35 @@ module Smith
127
217
  # evaluator sees the same seed_messages + inject_state context.
128
218
  # Default nil keeps the legacy candidate-only payload.
129
219
  def build_evaluator_input(state)
130
- return [{ role: :user, content: state.candidate.to_s }] unless state.config[:evaluator_context] == :inject_state
220
+ content = candidate_content(state.candidate)
221
+ return [{ role: :user, content: }] unless state.config[:evaluator_context] == :inject_state
131
222
 
132
223
  prior = Array(state.prepared_input).dup
133
- prior.push(role: :user, content: state.candidate.to_s)
224
+ prior.push(role: :user, content:)
225
+ end
226
+
227
+ # A structured candidate (an output_schema agent's Hash, or an Array a
228
+ # schema-less generator returned) gets the JSON serialisation the
229
+ # provider boundary applies; text stays as is.
230
+ def candidate_content(candidate)
231
+ candidate.is_a?(Hash) || candidate.is_a?(Array) ? json_message_content(candidate) : candidate.to_s
134
232
  end
135
233
 
136
234
  # Runs after candidate generation, before evaluator invocation.
137
- # Receives (state, @context); @context is mutable. Return value
138
- # is discarded. Raised exceptions bubble through the standard
139
- # step failure path.
140
- def invoke_before_eval(state)
235
+ # Receives (state, @context); @context is mutable. A returned Hash
236
+ # whose accept (Symbol or String key) is false is the round's
237
+ # evaluation, validated like evaluator output, and the evaluator is
238
+ # not called; any other return value is discarded. Raised exceptions
239
+ # bubble through the standard step failure path.
240
+ def before_eval_rejection(state)
141
241
  callback = state.config[:before_eval]
142
242
  return unless callback
143
243
 
144
- callback.call(state, @context)
244
+ verdict = callback.call(state, @context)
245
+ return unless verdict.is_a?(Hash)
246
+
247
+ evaluation = normalize_evaluation(verdict)
248
+ evaluation if evaluation.key?(:accept) && evaluation[:accept].equal?(false)
145
249
  end
146
250
 
147
251
  def invoke_with_evaluator_schema(evaluator_class, schema, input)
@@ -195,7 +299,7 @@ module Smith
195
299
  return prepared_input if round.zero?
196
300
 
197
301
  (prepared_input&.dup || []).push(
198
- { role: :assistant, content: prior_candidate.to_s },
302
+ { role: :assistant, content: candidate_content(prior_candidate) },
199
303
  {
200
304
  role: :user,
201
305
  content: "[smith:refinement-round] #{round + 1}\n[smith:evaluator-feedback]\n#{feedback}"
@@ -11,6 +11,11 @@ module Smith
11
11
  data: { transition: transition.name, from: transition.from, to: transition.to }
12
12
  )
13
13
 
14
+ # Handlers are not a step's own work, so they get no idempotency key.
15
+ IdempotencyKey.__send__(:without) { emit_step_completed_event(transition) }
16
+ end
17
+
18
+ def emit_step_completed_event(transition)
14
19
  Smith::Events.emit(
15
20
  Events::StepCompleted.new(
16
21
  transition: transition.name.to_sym,
@@ -54,6 +59,10 @@ module Smith
54
59
  end
55
60
 
56
61
  def emit_step_failed_event(failure)
62
+ IdempotencyKey.__send__(:without) { emit_step_failed_bus_event(failure) }
63
+ end
64
+
65
+ def emit_step_failed_bus_event(failure)
57
66
  Smith::Events.emit(
58
67
  Events::StepFailed.new(
59
68
  transition: DiagnosticText.capture(failure[:transition].to_s, max_bytes: 256).to_sym,
@@ -6,6 +6,7 @@ require_relative "guarded_step_execution"
6
6
  require_relative "prepared_branch_execution"
7
7
  require_relative "step_completion"
8
8
  require_relative "step_context"
9
+ require_relative "step_stream"
9
10
 
10
11
  module Smith
11
12
  class Workflow
@@ -15,6 +16,7 @@ module Smith
15
16
  include PreparedBranchExecution
16
17
  include StepCompletion
17
18
  include StepContext
19
+ include StepStream
18
20
  include Agent::Lifecycle
19
21
  include NestedExecution
20
22
  include EvaluatorOptimizer
@@ -34,26 +36,50 @@ module Smith
34
36
  # Reset the per-step agent-attribution carrier so only a serial agent
35
37
  # step that actually runs (below) leaves model/provider for complete_step;
36
38
  # a deterministic step never sets it and must not inherit a stale value.
39
+ # The optimizer's verdict carrier resets for the same reason.
37
40
  @pending_agent_execution = nil
38
- output = with_scoped_artifacts { run_with_retry_policy(transition) }
39
- StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
41
+ @pending_evaluations = nil
42
+ writable_output_key!(transition) if transition.output_to
43
+ with_step_stream(transition) do
44
+ output = with_scoped_artifacts { run_with_retry_policy(transition) }
45
+ output = queue_batch(transition, output) if output.is_a?(BatchExecution::Captured)
46
+ next output if wait_pending?(output)
47
+
48
+ StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
49
+ end
50
+ ensure
51
+ release_batch_holds
40
52
  end
41
53
 
54
+ # Restored with the step's thread context (ThreadContextSnapshot): a
55
+ # step's depth counts the runs whose steps it runs inside.
42
56
  def setup_step_context
57
+ Thread.current[:smith_step_depth] = (Thread.current[:smith_step_depth] || 0) + 1
43
58
  Tool.current_deadline = wall_clock_deadline
44
59
  Tool.current_ledger = @ledger
45
60
  Tool.current_tool_result_collector = tool_result_collector
46
61
  end
47
62
 
63
+ # An await, delay, map or call waits; a spawn launches and continues.
64
+ def execute_waiting_step(transition)
65
+ transition.spawns? ? execute_spawn_step(transition) : execute_await_step(transition)
66
+ end
67
+
48
68
  def run_standard_guarded_step(transition)
69
+ return execute_waiting_step(transition) if transition.awaits? || transition.spawns?
49
70
  return dispatch_step(transition) if transition.deterministic?
50
71
  return run_guarded_fanout_step(transition) if transition.fanout?
51
72
 
52
73
  agent_class = resolve_agent_class(transition)
53
74
  run_input_guardrails(agent_class)
54
75
  apply_tool_guardrails(agent_class)
55
- prepared_input = build_session&.prepare!
76
+ prepared_input = step_prepared_input(transition)
77
+ return send_agent_batch(transition, agent_class, prepared_input) if batch_send?(transition)
78
+
79
+ guarded_agent_output(transition, agent_class, prepared_input)
80
+ end
56
81
 
82
+ def guarded_agent_output(transition, agent_class, prepared_input)
57
83
  output = with_agent_context(agent_class) do
58
84
  dispatch_step(transition, prepared_input: prepared_input)
59
85
  end
@@ -80,13 +106,9 @@ module Smith
80
106
  @last_prepared_input = prepared_input
81
107
  return nil unless transition.agent_name
82
108
 
83
- agent_class = resolve_agent_class(transition)
84
- # Accepts either static `model "id"` (chat_kwargs[:model]) OR
85
- # block-form `model { |ctx| ... }` (model_block). The block-form
86
- # path resolves the actual id in build_model_chain at attempt time.
87
- return nil unless agent_class.model_configured?
88
-
89
- invoke_agent(agent_class, prepared_input)
109
+ # An agent with no model and no fallback models fails in
110
+ # complete_with_provider rather than skipping the step.
111
+ invoke_agent(resolve_agent_class(transition), prepared_input)
90
112
  end
91
113
 
92
114
  def execute_serial_step(transition, prepared_input: nil)
@@ -115,19 +137,6 @@ module Smith
115
137
  agent_class = resolve_agent_class(transition)
116
138
  reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
117
139
  end
118
-
119
- def resolve_agent_class(transition)
120
- parallel_binding = Thread.current[:smith_parallel_agent_binding]
121
- parallel_agent_class = parallel_binding&.resolve(workflow: self, transition:)
122
- return parallel_agent_class if parallel_agent_class
123
-
124
- transition.agent_name && resolve_registered_agent!(
125
- transition.agent_name,
126
- workflow_class: self.class,
127
- transition_name: transition.name,
128
- role: :agent
129
- )
130
- end
131
140
  end
132
141
  end
133
142
  end
@@ -11,6 +11,19 @@ module Smith
11
11
 
12
12
  Agent::Registry.fetch!(name, workflow_class:, transition_name:, role:)
13
13
  end
14
+
15
+ def resolve_agent_class(transition)
16
+ parallel_binding = Thread.current[:smith_parallel_agent_binding]
17
+ parallel_agent_class = parallel_binding&.resolve(workflow: self, transition:)
18
+ return parallel_agent_class if parallel_agent_class
19
+
20
+ transition.agent_name && resolve_registered_agent!(
21
+ transition.agent_name,
22
+ workflow_class: self.class,
23
+ transition_name: transition.name,
24
+ role: :agent
25
+ )
26
+ end
14
27
  end
15
28
  end
16
29
  end