smith-agents 0.11.0 → 0.12.1

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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -0
  3. data/README.md +622 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +67 -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/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. 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,7 +496,7 @@ 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
501
  Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message} (#{e.class})")
393
502
  end
@@ -33,7 +33,9 @@ module Smith
33
33
  state.config[:max_rounds].times do |round|
34
34
  # The overlay scopes every trace and usage fact from this round's
35
35
  # generator and evaluator calls to the round that produced them.
36
- 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
37
39
  return result if result
38
40
  end
39
41
 
@@ -41,10 +43,13 @@ module Smith
41
43
  "optimization exhausted #{state.config[:max_rounds]} rounds without acceptance")
42
44
  end
43
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.
44
48
  def run_optimization_round(state, round)
45
- generate_candidate!(state, round)
46
- rejection = before_eval_rejection(state)
47
- evaluation = rejection || 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) })
48
53
  validate_evaluation_structure!(evaluation)
49
54
  validate_evaluation_fields!(evaluation, state.config)
50
55
  record_evaluation(state, evaluation, round, rejection ? :before_eval : :evaluator)
@@ -219,8 +224,9 @@ module Smith
219
224
  prior.push(role: :user, content:)
220
225
  end
221
226
 
222
- # A structured candidate (an output_schema agent's Hash or Array) gets
223
- # the JSON serialisation the provider boundary applies; text stays as is.
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.
224
230
  def candidate_content(candidate)
225
231
  candidate.is_a?(Hash) || candidate.is_a?(Array) ? json_message_content(candidate) : candidate.to_s
226
232
  end
@@ -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
@@ -37,25 +39,47 @@ module Smith
37
39
  # The optimizer's verdict carrier resets for the same reason.
38
40
  @pending_agent_execution = nil
39
41
  @pending_evaluations = nil
40
- output = with_scoped_artifacts { run_with_retry_policy(transition) }
41
- StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
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
42
52
  end
43
53
 
54
+ # Restored with the step's thread context (ThreadContextSnapshot): a
55
+ # step's depth counts the runs whose steps it runs inside.
44
56
  def setup_step_context
57
+ Thread.current[:smith_step_depth] = (Thread.current[:smith_step_depth] || 0) + 1
45
58
  Tool.current_deadline = wall_clock_deadline
46
59
  Tool.current_ledger = @ledger
47
60
  Tool.current_tool_result_collector = tool_result_collector
48
61
  end
49
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
+
50
68
  def run_standard_guarded_step(transition)
69
+ return execute_waiting_step(transition) if transition.awaits? || transition.spawns?
51
70
  return dispatch_step(transition) if transition.deterministic?
52
71
  return run_guarded_fanout_step(transition) if transition.fanout?
53
72
 
54
73
  agent_class = resolve_agent_class(transition)
55
74
  run_input_guardrails(agent_class)
56
75
  apply_tool_guardrails(agent_class)
57
- 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
58
81
 
82
+ def guarded_agent_output(transition, agent_class, prepared_input)
59
83
  output = with_agent_context(agent_class) do
60
84
  dispatch_step(transition, prepared_input: prepared_input)
61
85
  end
@@ -82,13 +106,9 @@ module Smith
82
106
  @last_prepared_input = prepared_input
83
107
  return nil unless transition.agent_name
84
108
 
85
- agent_class = resolve_agent_class(transition)
86
- # Accepts either static `model "id"` (chat_kwargs[:model]) OR
87
- # block-form `model { |ctx| ... }` (model_block). The block-form
88
- # path resolves the actual id in build_model_chain at attempt time.
89
- return nil unless agent_class.model_configured?
90
-
91
- 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)
92
112
  end
93
113
 
94
114
  def execute_serial_step(transition, prepared_input: nil)
@@ -117,19 +137,6 @@ module Smith
117
137
  agent_class = resolve_agent_class(transition)
118
138
  reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
119
139
  end
120
-
121
- def resolve_agent_class(transition)
122
- parallel_binding = Thread.current[:smith_parallel_agent_binding]
123
- parallel_agent_class = parallel_binding&.resolve(workflow: self, transition:)
124
- return parallel_agent_class if parallel_agent_class
125
-
126
- transition.agent_name && resolve_registered_agent!(
127
- transition.agent_name,
128
- workflow_class: self.class,
129
- transition_name: transition.name,
130
- role: :agent
131
- )
132
- end
133
140
  end
134
141
  end
135
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
@@ -19,17 +19,32 @@ module Smith
19
19
  "persisted_failure_invalid" => ->(record) { Smith::PersistedFailureInvalid.new(record[:error_message]) },
20
20
  "deadline_exceeded" => ->(record) { Smith::DeadlineExceeded.new(record[:error_message]) },
21
21
  "provider_permanent_failure" => ->(record) { provider_permanent_failure(record) },
22
- "budget_exceeded" => ->(record) { Smith::BudgetExceeded.new(record[:error_message]) },
22
+ "budget_exceeded" => ->(record) { budget_exceeded(record) },
23
23
  "guardrail_failed" => ->(record) { Smith::GuardrailFailed.new(record[:error_message]) },
24
24
  "agent_error" => ->(record) { Smith::AgentError.new(record[:error_message]) },
25
25
  "workflow_error" => ->(record) { Smith::WorkflowError.new(record[:error_message]) },
26
+ "output_failure" => lambda { |record|
27
+ Smith::OutputFailure.from_details(record.fetch(:error_details), message: record[:error_message])
28
+ },
29
+ "child_runs_failed" => lambda { |record|
30
+ Smith::ChildRunsFailed.from_details(record.fetch(:error_details), message: record[:error_message])
31
+ },
32
+ "wait_timed_out" => lambda { |record|
33
+ Smith::WaitTimedOut.from_details(record.fetch(:error_details), message: record[:error_message])
34
+ },
26
35
  "other" => ->(record) { RuntimeError.new(record[:error_message]) }
27
36
  }.freeze
28
37
  SPECIAL_CLASS_BUILDERS = {
29
38
  "Smith::Workflow::Composite::BranchFailure" => lambda { |record|
30
39
  Smith::Workflow::Composite::BranchFailure.from_details(record[:error_details])
31
40
  },
32
- "Smith::BlankAgentOutputError" => ->(record) { blank_agent_output_failure(record) }
41
+ "Smith::BlankAgentOutputError" => ->(record) { blank_agent_output_failure(record) },
42
+ "Smith::PersistedStateTooLarge" => lambda { |record|
43
+ Smith::PersistedStateTooLarge.from_details(record.fetch(:error_details), message: record[:error_message])
44
+ },
45
+ **Smith::OutputFailure.restorers.transform_values do |restore|
46
+ ->(record) { restore.call(record.fetch(:error_details), record[:error_message]) }
47
+ end
33
48
  }.freeze
34
49
  private_constant :FAMILY_BUILDERS, :SPECIAL_CLASS_BUILDERS
35
50
 
@@ -77,6 +92,13 @@ module Smith
77
92
  Smith::ToolFailureNotificationFailed.from_details(record.fetch(:error_details))
78
93
  end
79
94
 
95
+ # A host ledger's refusal stays one, so a host can tell waiting for
96
+ # budget from a run whose own budget ran out.
97
+ def self.budget_exceeded(record)
98
+ error_class = record[:error_class] == "Smith::BudgetUnavailable" ? Smith::BudgetUnavailable : Smith::BudgetExceeded
99
+ error_class.new(record[:error_message])
100
+ end
101
+
80
102
  def self.provider_permanent_failure(record)
81
103
  Smith::ProviderPermanentFailure.from_details(record.fetch(:error_details), message: record[:error_message])
82
104
  end
@@ -88,8 +110,9 @@ module Smith
88
110
  Smith::BlankAgentOutputError.from_details(details)
89
111
  end
90
112
 
91
- private_class_method :deterministic_step_failure, :tool_guardrail_failure, :tool_failure_notification_failure,
92
- :provider_permanent_failure, :blank_agent_output_failure
113
+ private_class_method :budget_exceeded, :deterministic_step_failure, :tool_guardrail_failure,
114
+ :tool_failure_notification_failure, :provider_permanent_failure,
115
+ :blank_agent_output_failure
93
116
  end
94
117
  end
95
118
  end
@@ -4,6 +4,8 @@ require "dry-initializer"
4
4
 
5
5
  require_relative "../diagnostic_text"
6
6
  require_relative "../errors"
7
+ require_relative "../output_failure"
8
+ require_relative "../child_runs_failed"
7
9
  require_relative "failure_detail_snapshot"
8
10
  require_relative "failure_record_text"
9
11
 
@@ -23,7 +25,10 @@ module Smith
23
25
  [Smith::ProviderPermanentFailure, "provider_permanent_failure"],
24
26
  [Smith::BudgetExceeded, "budget_exceeded"],
25
27
  [Smith::GuardrailFailed, "guardrail_failed"],
28
+ [Smith::OutputFailure, "output_failure"],
26
29
  [Smith::AgentError, "agent_error"],
30
+ [Smith::WaitTimedOut, "wait_timed_out"],
31
+ [Smith::ChildRunsFailed, "child_runs_failed"],
27
32
  [Smith::WorkflowError, "workflow_error"]
28
33
  ].freeze
29
34
  BOOLEAN_VALUES = [true, false].freeze
@@ -126,7 +131,7 @@ module Smith
126
131
  FailureDetailSnapshot.new(error.details).call
127
132
  rescue StandardError => e
128
133
  FailureDetailSnapshot.new(
129
- smith_failure_details_omitted: DiagnosticText.capture(e.message, max_bytes: 1_024)
134
+ smith_failure_details_omitted: DiagnosticText.capture(error_message(e), max_bytes: 1_024)
130
135
  ).call
131
136
  end
132
137
  end
@@ -3,6 +3,8 @@
3
3
  require "dry-initializer"
4
4
 
5
5
  require_relative "../errors"
6
+ require_relative "../output_failure"
7
+ require_relative "../child_runs_failed"
6
8
  require_relative "composite/branch_failure"
7
9
 
8
10
  module Smith
@@ -19,11 +21,14 @@ module Smith
19
21
  "Smith::PersistedFailureInvalid" => "persisted_failure_invalid",
20
22
  "Smith::AgentError" => "agent_error",
21
23
  "Smith::BlankAgentOutputError" => "agent_error",
24
+ **Smith::OutputFailure.restorers.transform_values { "output_failure" },
22
25
  "Smith::DeadlineExceeded" => "deadline_exceeded",
23
26
  "Smith::ProviderPermanentFailure" => "provider_permanent_failure",
24
- "Smith::BudgetExceeded" => "budget_exceeded",
27
+ "Smith::BudgetExceeded" => "budget_exceeded", "Smith::BudgetUnavailable" => "budget_exceeded",
25
28
  "Smith::GuardrailFailed" => "guardrail_failed",
26
- "Smith::WorkflowError" => "workflow_error",
29
+ "Smith::WaitTimedOut" => "wait_timed_out",
30
+ "Smith::ChildRunsFailed" => "child_runs_failed",
31
+ "Smith::WorkflowError" => "workflow_error", "Smith::PersistedStateTooLarge" => "workflow_error",
27
32
  "Smith::UnresolvedTransitionError" => "workflow_error",
28
33
  "Smith::Workflow::Composite::BranchFailure" => "workflow_error"
29
34
  }.freeze
@@ -38,7 +43,7 @@ module Smith
38
43
  deterministic_step_failure tool_guardrail_failed tool_failure_notification_failed tool_capture_failed
39
44
  tool_outcome_uncertain tool_execution_not_admitted bounded_completion_error persisted_failure_invalid
40
45
  deadline_exceeded provider_permanent_failure budget_exceeded guardrail_failed agent_error workflow_error
41
- other
46
+ wait_timed_out output_failure child_runs_failed other
42
47
  ].freeze
43
48
  RETRY_FORBIDDEN_FAMILIES = %w[
44
49
  tool_capture_failed tool_failure_notification_failed tool_outcome_uncertain tool_execution_not_admitted
@@ -53,9 +58,11 @@ module Smith
53
58
  Smith::Workflow::Composite::BranchFailure.from_details(details)
54
59
  },
55
60
  "Smith::ProviderPermanentFailure" => ->(details) { Smith::ProviderPermanentFailure.from_details(details) },
61
+ **[WaitTimedOut, ChildRunsFailed, PersistedStateTooLarge].to_h { [_1.name, _1.method(:from_details)] },
56
62
  "Smith::BlankAgentOutputError" => lambda { |details|
57
63
  Smith::BlankAgentOutputError.from_details(details) unless details.nil?
58
- }
64
+ },
65
+ **Smith::OutputFailure.restorers
59
66
  }.freeze
60
67
  BOOLEAN_VALUES = [true, false].freeze
61
68
  private_constant :KNOWN_ERROR_FAMILIES, :LEGACY_ERROR_FAMILIES, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES,
@@ -20,13 +20,10 @@ module Smith
20
20
  branch_agent_classes = fanout_agent_classes(transition, branches)
21
21
  run_workflow_input_guardrails
22
22
  run_fanout_agent_input_guardrails(branch_agent_classes)
23
- prepared_input = build_session&.prepare!
24
- output = execute_fanout_step(
25
- transition,
26
- branches: branches,
27
- branch_agent_classes: branch_agent_classes,
28
- prepared_input: prepared_input
29
- )
23
+ prepared_input = step_prepared_input(transition)
24
+ return send_fanout_batch(transition, branches, branch_agent_classes, prepared_input) if batch_send?(transition)
25
+
26
+ output = execute_fanout_step(transition, branches:, branch_agent_classes:, prepared_input:)
30
27
  run_workflow_output_guardrails(output)
31
28
  output
32
29
  end
@@ -68,7 +65,7 @@ module Smith
68
65
  def guarded_fanout_branch_call(agent_class, env, signal)
69
66
  check_cancellation!(signal)
70
67
  check_deadline!
71
- result = agent_class.model_configured? ? invoke_agent(agent_class, env.prepared_input) : nil
68
+ result = invoke_agent(agent_class, env.prepared_input)
72
69
  output = result.is_a?(AgentResult) ? result.content : result
73
70
  validate_data_volume!(output, agent_class)
74
71
  run_agent_output_guardrails(output, agent_class)