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
@@ -13,7 +13,19 @@ module Smith
13
13
  context: snapshot_value(persisted_context),
14
14
  budget_consumed: ledger_consumed,
15
15
  step_count: @step_count,
16
+ awaiting: @awaiting&.dup,
17
+ # The attempt a durable retry makes next, and its key generation.
18
+ pending_retry: @pending_retry&.dup,
19
+ pending_batch: @pending_batch&.dup,
20
+ # The scope the run's budget_ledger is built from.
21
+ budget_ledger_scope: @budget_ledger_scope&.transform_keys(&:to_s),
22
+ active_seconds: @active_seconds || 0.0,
23
+ active_step_started_at: @active_step_started_at,
16
24
  execution_namespace: @execution_namespace,
25
+ # Untrusted keys inherited from a parent run (a durable child's).
26
+ inherited_untrusted_keys: (@inherited_untrusted_keys || []).map(&:to_s),
27
+ # Artifact namespaces a child run may read (its ancestors').
28
+ inherited_artifact_namespaces: (@inherited_artifact_namespaces || []).dup,
17
29
  created_at: @created_at,
18
30
  updated_at: @updated_at,
19
31
  next_transition_name: @next_transition_name,
@@ -85,6 +97,7 @@ module Smith
85
97
  @next_transition_name = normalized[:next_transition_name]
86
98
  @session_messages = normalized[:session_messages] || []
87
99
  @seed_message_count = validated_seed_message_count(normalized)
100
+ validate_session_parts!
88
101
  @total_cost = normalized[:total_cost] || 0.0
89
102
  @total_tokens = normalized[:total_tokens] || 0
90
103
  @outcome = normalized[:outcome]
@@ -99,12 +112,14 @@ module Smith
99
112
  @usage_mutex = Mutex.new
100
113
  @usage_entries = restore_usage_entries(normalized)
101
114
  @last_output = restore_last_output(normalized)
115
+ @last_transcript_output = restored_transcript_output
102
116
  # Backward-compat: pre-patch states have no last_agent_execution key and
103
- # restore to nil. @pending_agent_execution is transient (nil between
104
- # steps), never persisted, but must be initialized because from_state
105
- # allocates and bypasses #initialize.
117
+ # restore to nil. @pending_agent_execution and @pending_evaluations are
118
+ # transient (nil between steps), never persisted, but must be
119
+ # initialized because from_state allocates and bypasses #initialize.
106
120
  @last_agent_execution = restore_last_agent_execution(normalized)
107
121
  @pending_agent_execution = nil
122
+ @pending_evaluations = nil
108
123
  @last_failed_step = restore_last_failed_step(normalized)
109
124
  # Restore the optimistic-locking version from the persisted payload.
110
125
  # Backward-compat: pre-versioning payloads have no key, restore to 0
@@ -112,6 +127,16 @@ module Smith
112
127
  # the original store from the legacy adapter contract).
113
128
  @persistence_version = persistence_version
114
129
  validate_definition_digest!(normalized)
130
+ validate_restored_position!(normalized)
131
+ @awaiting = WaitRecord.restore(normalized[:awaiting], workflow_class: self.class,
132
+ persistence_key: normalized[:persistence_key],
133
+ state: @state, next_transition: @next_transition_name)
134
+ @pending_retry = restore_pending_retry(normalized[:pending_retry], normalized[:persistence_key])
135
+ @pending_batch = restore_pending_batch(normalized[:pending_batch])
136
+ @active_seconds = restored_active_seconds(normalized[:active_seconds])
137
+ charge_unfinished_step!(normalized[:active_step_started_at])
138
+ @wait_mutex = Mutex.new
139
+ @wait_claimed = false
115
140
  # Preserve the seed digest from the persisted payload so it
116
141
  # round-trips on subsequent persists. validate_seed_digest!
117
142
  # compares this against a fresh evaluation of the seed builder
@@ -125,6 +150,18 @@ module Smith
125
150
  @split_step_mutex = Mutex.new
126
151
  validate_step_in_progress!(normalized) if
127
152
  self.class.idempotency_mode == :strict && !allow_step_in_progress
153
+ initialize_budget_ledger
154
+ @memo_attempts = []
155
+ @budget_ledger_scope = restored_budget_ledger_scope(normalized[:budget_ledger_scope])
156
+ end
157
+
158
+ # The stored scope, or, for a run stored before its class declared
159
+ # these scope keys, the one its context gives now.
160
+ def restored_budget_ledger_scope(stored)
161
+ return unless self.class.budget_ledger
162
+
163
+ Budget::HostLedger.stored_scope(self.class.budget_ledger_scope_keys, stored) ||
164
+ capture_budget_ledger_scope
128
165
  end
129
166
 
130
167
  def validated_persistence_version(normalized)
@@ -135,6 +172,24 @@ module Smith
135
172
  "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
136
173
  end
137
174
 
175
+ # Content parts come only from seed messages: anywhere else in a
176
+ # stored session they are refused, and a seed's are checked and kept
177
+ # as checked (String-keyed parts), however the payload was parsed.
178
+ def validate_session_parts!
179
+ @session_messages = @session_messages.each_with_index.map do |message, index|
180
+ next message unless Message.parts?(message)
181
+
182
+ checked = index < @seed_message_count && begin
183
+ Message.check_seed!(message)
184
+ rescue WorkflowError
185
+ nil
186
+ end
187
+ raise Smith::SerializationError, "persisted session content parts must be valid seed messages" unless checked
188
+
189
+ { role: checked["role"], content: checked["content"], parts: true }
190
+ end
191
+ end
192
+
138
193
  def validated_seed_message_count(normalized)
139
194
  count = normalized.fetch(:seed_message_count, 0)
140
195
  return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
@@ -160,16 +215,17 @@ module Smith
160
215
 
161
216
  raise Smith::StepInProgressOnRestore.new(
162
217
  workflow: self.class.name,
163
- persistence_key: normalized[:persistence_key]
218
+ persistence_key: normalized[:persistence_key],
219
+ state: normalized[:state],
220
+ transition: normalized[:next_transition_name]
164
221
  )
165
222
  end
166
223
 
167
224
  def validate_raw_step_in_progress!(state)
168
225
  return unless state.is_a?(Hash)
169
226
 
170
- marker = state[:step_in_progress] || state["step_in_progress"]
171
- persistence_key = state[:persistence_key] || state["persistence_key"]
172
- validate_step_in_progress!(step_in_progress: marker, persistence_key:)
227
+ fields = %i[step_in_progress persistence_key state next_transition_name]
228
+ validate_step_in_progress!(fields.to_h { |field| [field, state[field] || state[field.to_s]] })
173
229
  end
174
230
 
175
231
  def validate_seed_digest!(normalized)
@@ -225,6 +281,14 @@ module Smith
225
281
  end
226
282
  end
227
283
 
284
+ # The last assistant message after the seeds: the output the run last
285
+ # added to its transcript, unless the host admitted one after it.
286
+ def restored_transcript_output
287
+ seeds = @seed_message_count || 0
288
+ message = @session_messages.drop(seeds).reverse_each.find { |item| MessageRoles.role(item) == "assistant" }
289
+ message && message[:content]
290
+ end
291
+
228
292
  def restore_last_agent_execution(normalized)
229
293
  value = if normalized.key?(:last_agent_execution)
230
294
  normalized[:last_agent_execution]
@@ -233,7 +297,9 @@ module Smith
233
297
  end
234
298
  return unless value.is_a?(Hash)
235
299
 
236
- symbolize_keys(value)
300
+ execution = symbolize_keys(value)
301
+ execution[:provider] = execution[:provider].to_sym if execution[:provider].is_a?(String)
302
+ execution
237
303
  end
238
304
 
239
305
  # Symbolize ONLY the top-level keys of last_failed_step + the
@@ -252,15 +318,73 @@ module Smith
252
318
  ).call
253
319
  end
254
320
 
321
+ # A state or pending transition the class does not know (a deploy
322
+ # renamed or removed it, or the payload has none) fails the restore.
323
+ # Restoring it would leave the run in a state with no outgoing
324
+ # transitions, which reads as terminal and ends the run silently. Runs
325
+ # after `migrate_from` and after the definition digest check, so a
326
+ # migration can map an old name and a digest mismatch is reported as
327
+ # one.
328
+ def validate_restored_position!(normalized)
329
+ validate_restored_state!(normalized[:state], normalized[:persistence_key])
330
+ validate_restored_transition!(normalized[:next_transition_name], normalized[:persistence_key])
331
+ end
332
+
333
+ def validate_restored_state!(state, persistence_key)
334
+ return if state.nil? && self.class.initial_state.nil?
335
+ return if !state.nil? && self.class.known_state?(state)
336
+
337
+ raise Smith::UnknownStateOnRestore.new(workflow: self.class.name, persistence_key:, state:)
338
+ end
339
+
340
+ def validate_restored_transition!(transition, persistence_key)
341
+ return if transition.nil? || self.class.find_transition(transition)
342
+
343
+ raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:, transition:)
344
+ end
345
+
255
346
  def restore_core_fields(normalized)
256
347
  @state = normalized[:state]
257
348
  @context = filter_persisted_context(normalized[:context] || {})
258
349
  @step_count = normalized[:step_count] || 0
259
- @execution_namespace = normalized[:execution_namespace]
350
+ @execution_namespace = restored_execution_namespace(normalized)
351
+ @inherited_untrusted_keys = restored_inherited_untrusted_keys(normalized)
352
+ @inherited_artifact_namespaces = restored_artifact_namespaces(normalized)
260
353
  @created_at = normalized[:created_at]
261
354
  @updated_at = normalized[:updated_at]
262
355
  end
263
356
 
357
+ def restored_inherited_untrusted_keys(normalized)
358
+ keys = normalized.fetch(:inherited_untrusted_keys, [])
359
+ unless keys.is_a?(Array) && keys.length <= 1_000 && keys.all? { untrusted_key_name?(_1) }
360
+ raise Smith::SerializationError, "persisted workflow inherited_untrusted_keys are invalid"
361
+ end
362
+
363
+ keys.map(&:to_sym).uniq.freeze
364
+ end
365
+
366
+ def untrusted_key_name?(key) = (key.is_a?(String) || key.is_a?(Symbol)) && key.length.between?(1, 256)
367
+
368
+ def restored_artifact_namespaces(normalized)
369
+ ArtifactIntegration.artifact_namespaces!(normalized.fetch(:inherited_artifact_namespaces, []))
370
+ rescue WorkflowError
371
+ raise Smith::SerializationError, "persisted workflow inherited_artifact_namespaces are invalid"
372
+ end
373
+
374
+ # A payload written before namespaces were assigned at construction
375
+ # gets one derived from its own durable facts, the same on every
376
+ # restore of it, until a checkpoint stores it.
377
+ def restored_execution_namespace(normalized)
378
+ namespace = normalized[:execution_namespace]
379
+ if namespace.nil?
380
+ return IdempotencyKey.__send__(:legacy_namespace, normalized[:class] || self.class.name,
381
+ normalized[:persistence_key], normalized[:created_at])
382
+ end
383
+ return namespace if namespace.is_a?(String) && PreparedStep::UUID_PATTERN.match?(namespace)
384
+
385
+ raise Smith::SerializationError, "persisted workflow execution_namespace must be a UUID"
386
+ end
387
+
264
388
  def restore_persisted_keys(normalized)
265
389
  @persisted_keys_mutex = Mutex.new
266
390
  raw = normalized[:persisted_keys]
@@ -357,24 +481,22 @@ module Smith
357
481
  symbolized
358
482
  end
359
483
 
484
+ # The identifier the class itself uses for a payload's state name, so a
485
+ # String-named state stays a String; an unknown name is symbolized.
360
486
  def normalize_state_name(value)
361
487
  return if value.nil?
362
- return value if declared_state?(value)
363
- return value unless value.is_a?(String)
364
488
 
365
- symbolized = value.to_sym
366
- return symbolized if declared_state?(symbolized)
489
+ resolved = self.class.resolve_state_name(value)
490
+ return resolved unless resolved.nil?
367
491
 
368
- symbolized
369
- end
370
-
371
- def declared_state?(value)
372
- states = self.class.instance_variable_get(:@states) || []
373
- states.include?(value)
492
+ value.is_a?(String) ? value.to_sym : value
374
493
  end
375
494
 
495
+ # Context keys are Symbols at every depth, matching what construction
496
+ # and deterministic writes produce, so a nested read behaves the same
497
+ # before and after a restore.
376
498
  def normalize_nested_hashes!(normalized)
377
- normalized[:context] = symbolize_keys(normalized[:context]) if normalized[:context].is_a?(Hash)
499
+ normalized[:context] = ContextKeys.normalize(normalized[:context]) if normalized[:context].is_a?(Hash)
378
500
  return unless normalized[:budget_consumed].is_a?(Hash)
379
501
 
380
502
  normalized[:budget_consumed] = symbolize_keys(normalized[:budget_consumed])
@@ -384,7 +506,12 @@ module Smith
384
506
  return unless normalized[:session_messages].is_a?(Array)
385
507
 
386
508
  normalized[:session_messages] = normalized[:session_messages].map do |msg|
387
- msg.is_a?(Hash) ? symbolize_keys(snapshot_value(msg)) : snapshot_value(msg)
509
+ unless MessageRoles.role(msg)
510
+ raise Smith::SerializationError,
511
+ "persisted session message must have the role system, user, assistant or tool"
512
+ end
513
+
514
+ symbolize_keys(snapshot_value(msg))
388
515
  end
389
516
  end
390
517
 
@@ -430,7 +557,20 @@ module Smith
430
557
  config = self.class.budget
431
558
  return nil unless config
432
559
 
433
- Budget::Ledger.new(limits: config, consumed: consumed)
560
+ Budget::Ledger.new(limits: config, consumed: declared_budget_consumed(consumed, config))
561
+ end
562
+
563
+ # A dimension the workflow no longer declares has no limit, so its
564
+ # consumed entry (a key an earlier version accepted and ignored) is
565
+ # dropped rather than refused.
566
+ def declared_budget_consumed(consumed, config)
567
+ return consumed unless consumed.is_a?(Hash)
568
+
569
+ declared = config.each_key.to_h { |dimension| [dimension.to_s, dimension] }
570
+ consumed.each_with_object({}) do |(key, amount), kept|
571
+ dimension = declared[key.to_s]
572
+ kept[dimension] = amount if dimension
573
+ end
434
574
  end
435
575
 
436
576
  def persisted_context
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # A step's provider calls through a host's response memo
6
+ # (Smith::ResponseMemo): each call takes its kept messages, if any, and
7
+ # sends nothing when they end it (a streamed step's sink gets the reply's
8
+ # text at once), else goes on from them, and is recorded as it goes.
9
+ # Kept tool results count as tools the step ran, and the live part goes
10
+ # on from the kept rounds (Resumed): its first request is a later round,
11
+ # and a pause for budget keeps the kept rounds too.
12
+ module RememberedCalls
13
+ CALLS_LOCK = Mutex.new
14
+ private_constant :CALLS_LOCK
15
+
16
+ # Where a call's live part starts: the call's first message, and the
17
+ # rounds its kept messages made.
18
+ Resumed = Data.define(:start, :rounds)
19
+
20
+ private
21
+
22
+ def remembered_completion(chat, model_reference, facts, &)
23
+ memo = Smith.config.response_memo
24
+ key = memo && memo_key(chat, facts)
25
+ return yield(nil) unless key
26
+
27
+ start = chat.messages.length
28
+ kept = ResponseMemo.fetch(memo, key)
29
+ keep_messages(chat, kept.fetch(:messages)) if kept
30
+ return replayed_completion(chat, model_reference) if kept&.fetch(:complete)
31
+
32
+ recorded_completion(chat, memo, key, Resumed.new(start:, rounds: kept_rounds(kept)), &)
33
+ end
34
+
35
+ def kept_rounds(kept) = kept ? kept.fetch(:messages).count { _1.role == :assistant } : 0
36
+
37
+ def memo_key(chat, facts)
38
+ ResponseMemo.key([*@memo_attempts, @step_attempt.to_i], facts[:attempt_index], next_memo_call, chat)
39
+ end
40
+
41
+ # The kept messages, after the call's own; their tool results are
42
+ # tools the step ran.
43
+ def keep_messages(chat, messages)
44
+ messages.each { chat.add_message(_1) }
45
+ Tool.current_tool_execution_tracker&.mark_started! if messages.any? { _1.role == :tool }
46
+ end
47
+
48
+ def recorded_completion(chat, memo, key, resumed)
49
+ live = chat.messages.length
50
+ result = ResponseMemo.recording(chat_provider(chat), memo, key, resumed.start) { yield(resumed) }
51
+ ResponseMemo.store(memo, key, chat.messages.drop(resumed.start), complete: true,
52
+ facts: streamed_facts(chat, live, result))
53
+ result
54
+ end
55
+
56
+ # A streamed call's replies' facts, read from its stream.
57
+ def streamed_facts(chat, live, result)
58
+ stream = result[1]
59
+ return {} unless stream.is_a?(Hash) && stream[:rounds]
60
+
61
+ replies = chat.messages.drop(live).select { _1.role == :assistant }
62
+ replies.zip(Providers::StreamFacts.per_message(replies, stream[:rounds], stream[:output_limit]))
63
+ .to_h.compare_by_identity
64
+ end
65
+
66
+ def replayed_completion(chat, model_reference)
67
+ response = chat.messages.last
68
+ if (stream = @step_stream)
69
+ stream.start(attempt: @step_attempt, model: model_reference.model_id,
70
+ provider: model_reference.provider&.to_s)
71
+ content = response.content
72
+ stream.delta(content.is_a?(String) ? content : JSON.generate(content))
73
+ end
74
+ [response, nil, nil, { memo: true }]
75
+ end
76
+
77
+ # The call's place among its attempt's calls at its position (a
78
+ # fan-out's branches count their own).
79
+ def next_memo_call
80
+ slot = [IdempotencyKey.__send__(:current_scope)&.key, @step_attempt]
81
+ memo_calls_lock.synchronize do
82
+ @memo_calls ||= {}
83
+ @memo_calls[slot] = @memo_calls.fetch(slot, 0) + 1
84
+ end
85
+ end
86
+
87
+ def memo_calls_lock = @memo_calls_lock || CALLS_LOCK.synchronize { @memo_calls_lock ||= Mutex.new }
88
+
89
+ def start_memo_calls = memo_calls_lock.synchronize { @memo_calls = {} }
90
+ end
91
+ end
92
+ end
@@ -5,27 +5,88 @@ module Smith
5
5
  module RetryExecution
6
6
  private
7
7
 
8
+ # `@step_attempt` is the attempt under way, from 1, which the optimizer
9
+ # names its verdicts by; transient, set here before every attempt.
10
+ #
11
+ # Every attempt sends its requests under the step's idempotency keys,
12
+ # so a retry after an uncertain failure (a timeout, a lost connection)
13
+ # cannot repeat a request that took effect. Only an attempt that
14
+ # failed in a way the step declares settled moves later attempts to
15
+ # new keys. Attempts replay in the same order after a restore, so a
16
+ # replay reaches the same keys as long as the receivers answer the
17
+ # replayed requests as before. A durable policy waits out the backoff
18
+ # as a wait of the run (RetryWait) and resumes at the attempt and key
19
+ # generation it recorded.
8
20
  def run_with_retry_policy(transition)
9
21
  config = transition.retry_config
22
+ position = pending_batch_position
23
+ @step_attempt = position ? position.first + 1 : 1
24
+ @step_key_generation = position ? position.last : 0
25
+ start_budget_pause(transition)
26
+ start_memo_calls
10
27
  return run_guarded_step(transition) unless config
11
28
 
29
+ run_attempts(transition, config, *(position || pending_retry_position(transition)))
30
+ end
31
+
32
+ # Attempts after the `attempt` already made, under `key_generation`.
33
+ def run_attempts(transition, config, attempt, key_generation)
12
34
  schedule = retry_schedule(config)
13
- attempt = 0
14
35
  begin
15
36
  attempt += 1
16
- run_guarded_step(transition)
37
+ @step_attempt = attempt
38
+ @step_key_generation = key_generation
39
+ attempt_scope = attempt_idempotency_scope(key_generation)
40
+ run_attempt_under_keys(transition, attempt_scope)
17
41
  rescue StandardError => e
18
42
  raise unless retry_transition_error?(config, e, attempt)
19
43
 
20
- sleep_for_retry(schedule, attempt)
44
+ key_generation += 1 if new_idempotency_key_after?(config, e, attempt_scope)
45
+ keep_budget_continuation(e)
46
+ delay = retry_delay(schedule, attempt)
47
+ return wait_to_retry(transition, delay, attempt + 1, key_generation, e) if config[:durable]
48
+
49
+ sleep(delay) if delay.positive?
21
50
  retry
22
51
  end
23
52
  end
24
53
 
54
+ def attempt_idempotency_scope(key_generation)
55
+ step_scope = IdempotencyKey.__send__(:current_scope)
56
+ return step_scope if step_scope.nil? || key_generation.zero?
57
+
58
+ step_scope.within(:key_generation, key_generation)
59
+ end
60
+
61
+ def run_attempt_under_keys(transition, attempt_scope)
62
+ return run_guarded_step(transition) if attempt_scope.equal?(IdempotencyKey.__send__(:current_scope))
63
+
64
+ IdempotencyKey.__send__(:swap, attempt_scope) { run_guarded_step(transition) }
65
+ end
66
+
67
+ # A `fail!(new_idempotency_key: true)` counts only when this step's own
68
+ # attempt raised it, never a nested workflow's step, whose flag speaks
69
+ # for its own requests and not for the earlier steps of the attempt.
70
+ def new_idempotency_key_after?(config, error, attempt_scope)
71
+ return true if own_settled_failure?(error, attempt_scope)
72
+
73
+ config.fetch(:new_idempotency_key_on, EMPTY_ERROR_CLASSES).any? { |error_class| error.is_a?(error_class) }
74
+ end
75
+
76
+ def own_settled_failure?(error, attempt_scope)
77
+ error.is_a?(DeterministicStepFailure) && error.new_idempotency_key &&
78
+ !attempt_scope.nil? && error.idempotency_scope == attempt_scope
79
+ end
80
+
81
+ EMPTY_ERROR_CLASSES = [].freeze
82
+ private_constant :EMPTY_ERROR_CLASSES
83
+
25
84
  def retry_transition_error?(config, error, attempt)
26
85
  return false if attempt >= config.fetch(:attempts)
27
86
  return false if terminal_retry_error?(error)
28
87
 
88
+ return false if retry_deadline_passed?
89
+
29
90
  classes = config.fetch(:error_classes)
30
91
  if classes.any?
31
92
  classes.any? { |error_class| error.is_a?(error_class) }
@@ -34,13 +95,15 @@ module Smith
34
95
  end
35
96
  end
36
97
 
37
- def terminal_retry_error?(error)
38
- Smith::Errors.retry_forbidden?(error)
98
+ # No attempt starts once the wall-clock deadline has passed: it could
99
+ # only fail again, after sleeping through its backoff.
100
+ def retry_deadline_passed?
101
+ deadline = effective_deadline
102
+ !deadline.nil? && Time.now.utc >= deadline
39
103
  end
40
104
 
41
- def sleep_for_retry(schedule, failed_attempt)
42
- delay = retry_delay(schedule, failed_attempt)
43
- sleep(delay) if delay.positive?
105
+ def terminal_retry_error?(error)
106
+ Smith::Errors.retry_forbidden?(error)
44
107
  end
45
108
 
46
109
  def retry_delay(config_or_schedule, failed_attempt)
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # The waits of durable retries. An attempt that fails in a way a
8
+ # `retry_on ..., durable: true` policy retries does not sleep: the run
9
+ # enters a wait of kind `retry`, due when the backoff ends, and records
10
+ # its pending retry (the transition, the next attempt, its key
11
+ # generation, and the class of the error that failed the last attempt).
12
+ # `expire_wait!` past the deadline ends the wait without a step and
13
+ # leaves the transition as the run's next; the host continues the run as
14
+ # after any wait, and the step makes the recorded attempt under the
15
+ # recorded keys. A step that ends without entering a wait clears the
16
+ # pending retry (Workflow#advance!).
17
+ module RetryWait
18
+ PENDING_RETRY_FIELDS = %w[attempt error key_generation transition].freeze
19
+ private_constant :PENDING_RETRY_FIELDS
20
+
21
+ private
22
+
23
+ # Enters the wait, due after the backoff, under a token of the attempt
24
+ # it waits to make. Like any wait it is not a step. The pending retry
25
+ # is recorded before the wait, so an interrupt between the two leaves
26
+ # a step that ended without a wait, whose pending retry is cleared.
27
+ def wait_to_retry(transition, seconds, attempt, key_generation, error)
28
+ if @split_step_phase == :executing
29
+ raise WorkflowError, "a durable retry cannot wait inside split-step execution"
30
+ end
31
+
32
+ check_wait_ttl!(seconds)
33
+ error_name = DiagnosticText.error_class_name(error)
34
+ @pending_retry = { "transition" => transition.name.to_s, "attempt" => attempt,
35
+ "key_generation" => key_generation, "error" => error_name,
36
+ "continuation" => @budget_continuation }.compact.freeze
37
+ @retry_wait_error = error
38
+ @awaiting = WaitRecord.build_retry(transition, wait_token_for(transition, ["retry", attempt]), seconds,
39
+ Time.now.utc)
40
+ record_retry_trace(transition, :retrying, attempt:, error: error_name, deadline_at: @awaiting["deadline_at"])
41
+ WAIT_PENDING
42
+ end
43
+
44
+ # A step that ran and did not enter a wait has made its pending retry,
45
+ # whether it completed, failed or raised.
46
+ def clear_settled_pending_retry
47
+ @pending_retry = nil if step_work_started? && !waiting?
48
+ end
49
+
50
+ # [attempts already made, key generation] where the step starts: the
51
+ # pending retry's for its transition, else [0, 0].
52
+ def pending_retry_position(transition)
53
+ pending = @pending_retry
54
+ return [0, 0] unless pending && pending["transition"] == transition.name.to_s
55
+
56
+ [pending["attempt"] - 1, pending["key_generation"]]
57
+ end
58
+
59
+ # The backoff is over: the transition becomes the run's next step, then
60
+ # the wait ends; nothing counts. Returns :expired.
61
+ def end_retry_wait(transition)
62
+ @next_transition_name = transition.name
63
+ @updated_at = Time.now.utc.iso8601(6)
64
+ @awaiting = nil
65
+ Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
66
+ record_retry_trace(transition, :retry_due, attempt: @pending_retry&.fetch("attempt"))
67
+ end
68
+ :expired
69
+ ensure
70
+ @wait_claimed = false
71
+ end
72
+
73
+ def record_retry_trace(transition, outcome, **details)
74
+ Smith::Trace.record(
75
+ type: :transition,
76
+ data: { transition: transition.name, from: transition.from, to: transition.to, outcome:, **details }
77
+ )
78
+ end
79
+
80
+ # The error that failed the attempt a retry wait follows, while this
81
+ # instance holds it (for a nested child's parent).
82
+ def retry_wait_error = (@retry_wait_error if retry_waiting?)
83
+
84
+ # A persisted pending retry names the transition of the stored retry
85
+ # wait, or else the run's next one, with an attempt after the first, a
86
+ # key generation an earlier attempt could reach, and an error class
87
+ # name; a stored retry wait needs one. A malformed one raises
88
+ # SerializationError; one the class no longer allows (the transition
89
+ # declares no durable retry, or fewer attempts) is refused like an
90
+ # unknown position.
91
+ def restore_pending_retry(value, persistence_key)
92
+ return if value.nil? && !retry_waiting?
93
+
94
+ record = value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
95
+ transition = pending_retry_transition(record)
96
+ raise Smith::SerializationError, "persisted workflow pending_retry is invalid" unless
97
+ well_formed_pending_retry?(transition, record)
98
+
99
+ refuse_disallowed_pending_retry!(transition, record, persistence_key)
100
+ record.transform_values(&:freeze).freeze
101
+ end
102
+
103
+ def retry_waiting? = @awaiting&.fetch("kind") == "retry"
104
+
105
+ # The transition the record names, when it is the stored retry wait's,
106
+ # or the run's next while the run does not wait.
107
+ def pending_retry_transition(record)
108
+ return if @awaiting && !retry_waiting?
109
+
110
+ name = @awaiting ? @awaiting["transition"] : @next_transition_name&.to_s
111
+ self.class.transition_named(name) if name && record["transition"] == name
112
+ end
113
+
114
+ def well_formed_pending_retry?(transition, record)
115
+ !transition.nil? && (record.keys - ["continuation"]).sort == PENDING_RETRY_FIELDS &&
116
+ pending_retry_fields?(record) &&
117
+ (!record.key?("continuation") || Budget::Continuation.valid?(record["continuation"]))
118
+ end
119
+
120
+ def pending_retry_fields?(record)
121
+ attempt, generation, error = record.values_at("attempt", "key_generation", "error")
122
+ attempt.is_a?(Integer) && attempt >= 2 && generation.is_a?(Integer) && generation.between?(0, attempt - 1) &&
123
+ error.is_a?(String) && error.bytesize.between?(1, 256)
124
+ end
125
+
126
+ def refuse_disallowed_pending_retry!(transition, record, persistence_key)
127
+ return if transition.durable_retry? && record["attempt"] <= transition.retry_config.fetch(:attempts)
128
+
129
+ raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:,
130
+ transition: transition.name)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -33,6 +33,16 @@ module Smith
33
33
  steps.reverse.map { |step| step[:error] }.compact.first
34
34
  end
35
35
 
36
+ # Every verdict an optimize step's loops recorded, in step, attempt and
37
+ # round order, each a frozen record naming its step's transition. Like
38
+ # `steps`, they belong to the run that executed the step: a terminal
39
+ # restore has none.
40
+ def evaluations
41
+ steps.flat_map do |step|
42
+ (step[:evaluations] || []).map { |evaluation| { transition: step[:transition], **evaluation }.freeze }
43
+ end
44
+ end
45
+
36
46
  def failed_transition
37
47
  failure_detail&.fetch(:transition)
38
48
  end
@@ -35,6 +35,9 @@ module Smith
35
35
  end
36
36
  @split_step_mutex.synchronize { @split_step_phase = :checkpointed } if checkpoint_claimed
37
37
  result
38
+ rescue PersistedStateTooLarge
39
+ release_split_step_checkpoint! if checkpoint_claimed
40
+ raise
38
41
  rescue StandardError
39
42
  mark_split_step_checkpoint_unknown! if checkpoint_claimed
40
43
  raise
@@ -40,6 +40,13 @@ module Smith
40
40
  end
41
41
  end
42
42
 
43
+ # A checkpoint refused before anything was written may be made again.
44
+ def release_split_step_checkpoint!
45
+ @split_step_mutex.synchronize do
46
+ @split_step_phase = :checkpoint_retryable if @split_step_phase == :checkpointing
47
+ end
48
+ end
49
+
43
50
  def mark_split_step_checkpoint_unknown!
44
51
  @split_step_mutex.synchronize do
45
52
  @split_step_phase = :checkpoint_unknown if @split_step_phase == :checkpointing