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
data/lib/smith/errors.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagnostic_text"
3
4
  require_relative "error"
4
5
  require_relative "persisted_failure_invalid"
5
6
  require_relative "pricing_configuration_error"
@@ -68,6 +69,21 @@ module Smith
68
69
  end
69
70
 
70
71
  class BudgetExceeded < Error; end
72
+
73
+ # A host's budget ledger (`budget_ledger`) refused a provider request's
74
+ # hold; nothing was sent. A BudgetExceeded, so it is not retried unless a
75
+ # step's `retry_on` names it (a durable retry waits for budget). One that
76
+ # paused a tool loop carries its `continuation` (Budget::Continuation),
77
+ # which the step's retry continues from.
78
+ class BudgetUnavailable < BudgetExceeded
79
+ attr_reader :continuation
80
+
81
+ def initialize(message = "the budget ledger refused the request", continuation: nil)
82
+ @continuation = continuation
83
+ super(message)
84
+ end
85
+ end
86
+
71
87
  class DeadlineExceeded < Error; end
72
88
  class MaxTransitionsExceeded < Error; end
73
89
  class GuardrailFailed < Error; end
@@ -88,6 +104,11 @@ module Smith
88
104
  class AgentError < Error; end
89
105
 
90
106
  class BlankAgentOutputError < AgentError
107
+ DETAIL_NAMES = %i[agent_name model_used].freeze
108
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
109
+ MAX_DETAIL_BYTES = 512
110
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
111
+
91
112
  attr_reader :agent_name, :model_used
92
113
 
93
114
  def initialize(agent_name:, model_used:)
@@ -101,17 +122,76 @@ module Smith
101
122
 
102
123
  super(detail)
103
124
  end
125
+
126
+ def details
127
+ {
128
+ agent_name: agent_name && DiagnosticText.capture(agent_name.to_s, max_bytes: MAX_DETAIL_BYTES),
129
+ model_used: model_used && DiagnosticText.capture(model_used.to_s, max_bytes: MAX_DETAIL_BYTES)
130
+ }.freeze
131
+ end
132
+
133
+ def self.from_details(details)
134
+ values = normalize_details(details)
135
+ new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used))
136
+ end
137
+
138
+ def self.normalize_details(details)
139
+ raise ArgumentError, "blank agent output details must be a Hash" unless details.is_a?(Hash)
140
+
141
+ values = {}
142
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
143
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
144
+ unless DETAIL_NAMES.include?(name)
145
+ raise ArgumentError, "blank agent output details contain an unknown attribute"
146
+ end
147
+ raise ArgumentError, "blank agent output details contain a duplicate attribute" if values.key?(name)
148
+
149
+ values[name] = bounded_detail(name, value)
150
+ end
151
+ raise ArgumentError, "blank agent output details are incomplete" unless values.length == DETAIL_NAMES.length
152
+
153
+ values
154
+ end
155
+
156
+ def self.bounded_detail(name, value)
157
+ return value if value.nil?
158
+
159
+ bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
160
+ raise ArgumentError, "blank agent output detail #{name} must be bounded text" unless bounded
161
+
162
+ value
163
+ end
164
+ private_class_method :normalize_details, :bounded_detail
104
165
  end
105
166
 
106
167
  class WorkflowError < Error; end
107
168
 
169
+ # A host's budget ledger (`budget_ledger`) could not be built or failed to
170
+ # answer `reserve!` (its service down, say); nothing was sent. Not a
171
+ # provider failure, so no fallback model is tried; not retried unless a
172
+ # step's `retry_on` names it.
173
+ class BudgetLedgerError < WorkflowError
174
+ def initialize(message = "the budget ledger failed") = super
175
+ end
176
+
108
177
  class DeterministicStepFailure < WorkflowError
109
- attr_reader :retryable, :kind, :details
178
+ attr_reader :retryable, :kind, :details, :new_idempotency_key, :idempotency_scope
179
+
180
+ # `new_idempotency_key: true` declares that the failed attempt's own
181
+ # requests took no effect, so a retry of the step that raised it sends
182
+ # them under new keys; `idempotency_scope` is that step's position. The
183
+ # flag is not persisted with the failure record.
184
+ def initialize(message, retryable: nil, kind: nil, details: nil, new_idempotency_key: false, # rubocop:disable Metrics/ParameterLists
185
+ idempotency_scope: nil)
186
+ unless [true, false, nil].include?(new_idempotency_key)
187
+ raise ArgumentError, "new_idempotency_key must be true or false"
188
+ end
110
189
 
111
- def initialize(message, retryable: nil, kind: nil, details: nil)
112
190
  @retryable = retryable
113
191
  @kind = kind
114
192
  @details = details
193
+ @new_idempotency_key = new_idempotency_key == true
194
+ @idempotency_scope = idempotency_scope
115
195
  super(message)
116
196
  end
117
197
  end
@@ -127,6 +207,141 @@ module Smith
127
207
  end
128
208
  end
129
209
 
210
+ # An `await` step passed its deadline before `resume!` delivered its
211
+ # event, and the transition declares no `on_timeout` route. It takes the
212
+ # failure path (`on_failure`, or a raise) and is never retried.
213
+ class WaitTimedOut < WorkflowError
214
+ DETAIL_NAMES = %w[transition event deadline_at].freeze
215
+ private_constant :DETAIL_NAMES
216
+
217
+ attr_reader :transition, :event, :deadline_at
218
+
219
+ def initialize(transition:, event:, deadline_at:, message: nil)
220
+ @transition = DiagnosticText.capture(transition.to_s, max_bytes: 256)
221
+ @event = DiagnosticText.capture(event.to_s, max_bytes: 256)
222
+ @deadline_at = DiagnosticText.capture(deadline_at.to_s, max_bytes: 64)
223
+ super(message || "await #{@event} on transition #{@transition} passed its deadline #{@deadline_at}")
224
+ end
225
+
226
+ # Kept with the failure record, so a restored run's error is still a
227
+ # WaitTimedOut that names its wait.
228
+ def details = { transition:, event:, deadline_at: }.freeze
229
+
230
+ def self.from_details(details, message: nil)
231
+ raise ArgumentError, "wait timeout details must be a Hash" unless details.is_a?(Hash)
232
+
233
+ values = details.to_h { |key, value| [key.to_s, value] }
234
+ unless values.keys.sort == DETAIL_NAMES.sort && values.values.all?(String)
235
+ raise ArgumentError, "wait timeout details are invalid"
236
+ end
237
+
238
+ new(transition: values["transition"], event: values["event"], deadline_at: values["deadline_at"], message:)
239
+ end
240
+ end
241
+
242
+ # A `compute` or `run` step that declares `routes:` finished without
243
+ # calling `route_to`, declares no `on_success`, and did not opt into
244
+ # `fallthrough: true`. Without this error the run would silently take the
245
+ # first transition declared from the step's target state.
246
+ class UnroutedDeterministicStep < WorkflowError
247
+ attr_reader :transition, :routes
248
+
249
+ def initialize(transition:, routes:)
250
+ @transition = transition
251
+ @routes = routes
252
+ super(
253
+ "#{DiagnosticText.capture(transition.inspect, max_bytes: 256)} declares routes: " \
254
+ "#{DiagnosticText.capture(routes.inspect, max_bytes: 1024)} but did not call route_to; " \
255
+ "route to one of them, declare on_success, or opt into fallthrough: true"
256
+ )
257
+ end
258
+ end
259
+
260
+ # A deterministic step wrote a context key that the workflow's
261
+ # Smith::Context does not list in `persist :a, :b`. The write would be
262
+ # dropped at the next checkpoint, so it fails the step instead.
263
+ class UndeclaredContextKey < WorkflowError
264
+ attr_reader :key, :persisted_keys
265
+
266
+ def initialize(key:, persisted_keys:)
267
+ @key = key
268
+ @persisted_keys = persisted_keys
269
+ super(
270
+ "context key #{DiagnosticText.capture(key.inspect, max_bytes: 256)} is not a persisted context key " \
271
+ "(persist #{DiagnosticText.capture(persisted_keys.map(&:inspect).join(", "), max_bytes: 1024)}); " \
272
+ "declare it in the context's persist list, or use persist :auto"
273
+ )
274
+ end
275
+ end
276
+
277
+ # A step's content parts cannot be sent: a document or image is missing,
278
+ # unreadable, empty, not what its media type says, or past a bound, or no
279
+ # model in the chain takes it. Raised before the provider call, so it is
280
+ # never retried as a provider failure.
281
+ class ContentUnavailable < WorkflowError; end
282
+
283
+ # No artifact store is configured. Artifacts must outlive the process that
284
+ # stored them whenever a run waits, retries or resumes elsewhere, so Smith
285
+ # assumes none outside test_mode.
286
+ class ArtifactStoreMissing < WorkflowError
287
+ def initialize(message = "Smith.config.artifact_store is not configured; set it to :s3, :active_record or " \
288
+ ":file (or a store) for runs that wait, retry or move between processes, or :memory " \
289
+ "for a single process")
290
+ super
291
+ end
292
+ end
293
+
294
+ # A step streams to a sink Smith.config.stream_sinks does not name.
295
+ # Raised before the step's provider call.
296
+ class StreamSinkMissing < WorkflowError
297
+ def initialize(name)
298
+ super("Smith.config.stream_sinks has no sink #{name.inspect}; register the sink the step streams to")
299
+ end
300
+ end
301
+
302
+ # A step sends its request as a batch and Smith.config.batch_queue is not
303
+ # configured. Raised before anything is sent.
304
+ class BatchQueueMissing < WorkflowError
305
+ def initialize(message = "Smith.config.batch_queue is not configured; batch mode hands its requests to it")
306
+ super
307
+ end
308
+ end
309
+
310
+ # A batch step's call cannot go as a batch: its model's provider has no
311
+ # batch interface in Smith (Anthropic has), or its agent has tools (a batch
312
+ # request is one turn). Raised before anything is sent.
313
+ class BatchModeUnavailable < WorkflowError; end
314
+
315
+ # A batch result that did not answer: a server error (`errored`), a
316
+ # `canceled` or `expired` request, or none by the wait's deadline
317
+ # (`missing`). An AgentError, so `retry_on` sends it again.
318
+ class BatchResultFailed < AgentError
319
+ attr_reader :kind, :error_type
320
+
321
+ def initialize(kind:, error_type: nil)
322
+ @kind = kind.to_s.dup.freeze
323
+ @error_type = DiagnosticText.capture(error_type.to_s, max_bytes: 128) if error_type
324
+ super("batch result #{@kind}#{" (#{@error_type})" if @error_type}")
325
+ end
326
+ end
327
+
328
+ # A batch result answers a request other than the one its step renders
329
+ # now (a date in the prompt, data read afresh, a changed prompt), and the
330
+ # step declares `on_request_change: :fail`. An AgentError, so `retry_on`
331
+ # may send the request again (synchronously, if the mode callable says so
332
+ # at the next attempt); the result is charged all the same.
333
+ class BatchRequestChanged < AgentError
334
+ def initialize(message = "the step renders another request than its batch result answers") = super
335
+ end
336
+
337
+ # The provider refused a batch request as invalid; sending it again would
338
+ # be refused again.
339
+ class BatchRequestInvalid < WorkflowError
340
+ def initialize(message = "the provider refused the batch request as invalid")
341
+ super
342
+ end
343
+ end
344
+
130
345
  class SerializationError < Error; end
131
346
  class AgentRegistryError < Error; end
132
347
 
@@ -144,6 +359,42 @@ module Smith
144
359
  end
145
360
  end
146
361
 
362
+ # A workflow state past Smith.config.persistence_max_bytes. A stored
363
+ # run's step whose result would not fit fails with it before the result
364
+ # is kept (its usage recorded, `on_failure` taken; a response memo keeps
365
+ # what its calls got). Any other state past the bound is refused when it
366
+ # is stored, before anything is written, and the stored state stays as it
367
+ # was.
368
+ class PersistedStateTooLarge < WorkflowError
369
+ DETAIL_NAMES = %w[key bytes limit].freeze
370
+
371
+ attr_reader :key, :bytes, :limit
372
+
373
+ def initialize(key:, bytes:, limit:, message: nil)
374
+ @key = key
375
+ @bytes = bytes
376
+ @limit = limit
377
+ super(message || "the workflow state for #{key.inspect} is #{bytes} bytes, past persistence_max_bytes (#{limit})")
378
+ end
379
+
380
+ def details = { key:, bytes:, limit: }.freeze
381
+
382
+ def self.from_details(details, message: nil)
383
+ raise ArgumentError, "state size details must be a Hash" unless details.is_a?(Hash)
384
+
385
+ values = details.to_h { |name, value| [name.to_s, value] }
386
+ raise ArgumentError, "state size details are invalid" unless details?(values)
387
+
388
+ new(key: values["key"], bytes: values["bytes"], limit: values["limit"], message:)
389
+ end
390
+
391
+ def self.details?(values)
392
+ values.keys.sort == DETAIL_NAMES.sort && values["key"].is_a?(String) &&
393
+ values.values_at("bytes", "limit").all? { _1.is_a?(Integer) && _1.positive? }
394
+ end
395
+ private_class_method :details?
396
+ end
397
+
147
398
  # Raised when an adapter's optimistic-lock check detects a concurrent
148
399
  # write: another process modified the key between this process's
149
400
  # restore and persist. Hosts can rescue + restore + retry, or fail
@@ -185,15 +436,20 @@ module Smith
185
436
  # `idempotency_mode :strict`. Signals that a previous worker crashed
186
437
  # between `persist!` (before advance) and `persist!` (after advance);
187
438
  # the step's effects are unknown, so blindly re-running could
188
- # double-execute non-idempotent agent calls or tools.
439
+ # double-execute non-idempotent agent calls or tools. `state` is the
440
+ # persisted state the interrupted step started from and `transition` the
441
+ # next transition the payload records; each is nil when unknown.
189
442
  class StepInProgressOnRestore < Error
190
- attr_reader :workflow, :persistence_key
443
+ attr_reader :workflow, :persistence_key, :state, :transition
191
444
 
192
- def initialize(workflow:, persistence_key:)
445
+ def initialize(workflow:, persistence_key:, state: nil, transition: nil)
193
446
  @workflow = workflow
194
447
  @persistence_key = persistence_key
448
+ @state = state.to_sym if state.is_a?(String) || state.is_a?(Symbol)
449
+ @transition = transition.to_sym if transition.is_a?(String) || transition.is_a?(Symbol)
195
450
  super(
196
- "step in progress on restore for #{workflow} key=#{persistence_key.inspect}: " \
451
+ "step in progress on restore for #{workflow} key=#{persistence_key.inspect}" \
452
+ "#{" state=#{@state.inspect}" if @state}: " \
197
453
  "a previous worker crashed mid-step. Hosts using idempotency_mode :strict must " \
198
454
  "decide whether to clear the persisted state (idempotent re-run unsafe) or " \
199
455
  "switch to :lax (assume re-run is safe)."
@@ -201,6 +457,48 @@ module Smith
201
457
  end
202
458
  end
203
459
 
460
+ # Raised when a persisted payload's state is not one the restoring
461
+ # workflow class knows: neither declared with `state` nor named by any
462
+ # transition's `from:` or `to:`. Usually a deploy renamed or removed the
463
+ # state while a run was persisted. Restoring anyway would treat the
464
+ # unknown state as terminal and end the run silently. Hosts bridge a
465
+ # rename with `migrate_from`, or let the run finish on the workflow
466
+ # version that declared the state.
467
+ class UnknownStateOnRestore < Error
468
+ attr_reader :workflow, :persistence_key, :state
469
+
470
+ def initialize(workflow:, persistence_key:, state:)
471
+ @workflow = workflow
472
+ @persistence_key = persistence_key
473
+ @state = state
474
+ super(
475
+ "unknown state #{DiagnosticText.capture(state.inspect, max_bytes: 256)} restoring #{workflow} " \
476
+ "key=#{DiagnosticText.capture(persistence_key.inspect, max_bytes: 256)}: bump " \
477
+ "persistence_schema_version and declare `migrate_from` to map it, or finish the run on the " \
478
+ "workflow version that declared it."
479
+ )
480
+ end
481
+ end
482
+
483
+ # Raised when a persisted payload's pending transition is not declared by
484
+ # the restoring workflow class, for the same reasons as
485
+ # UnknownStateOnRestore.
486
+ class UnknownTransitionOnRestore < Error
487
+ attr_reader :workflow, :persistence_key, :transition
488
+
489
+ def initialize(workflow:, persistence_key:, transition:)
490
+ @workflow = workflow
491
+ @persistence_key = persistence_key
492
+ @transition = transition
493
+ super(
494
+ "unknown pending transition #{DiagnosticText.capture(transition.inspect, max_bytes: 256)} restoring " \
495
+ "#{workflow} key=#{DiagnosticText.capture(persistence_key.inspect, max_bytes: 256)}: bump " \
496
+ "persistence_schema_version and declare `migrate_from` to map it, or finish the run on the " \
497
+ "workflow version that declared it."
498
+ )
499
+ end
500
+ end
501
+
204
502
  # Raised when restoring a persisted payload whose schema_version does
205
503
  # not match the workflow's current persistence_schema_version AND no
206
504
  # migration block is registered to bridge the gap. Hosts fix this by
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module IdempotencyKey
5
+ MAX_SCOPE_DEPTH = 32
6
+ MAX_SCOPE_NODES = 100_000
7
+
8
+ module Canonical
9
+ module_function
10
+
11
+ # The JSON form a scope is hashed in: Symbols become Strings, Hash
12
+ # entries are sorted by key, and a Float with a whole value becomes
13
+ # the Integer, so equal scopes give equal keys whatever their key order
14
+ # or numeric spelling (`12` and `12.0` from a model's JSON). Other
15
+ # finite Floats keep their shortest exact text. NaN, Infinity and
16
+ # other objects are refused, because they have no stable identity; so
17
+ # are scopes past MAX_SCOPE_DEPTH levels or MAX_SCOPE_NODES values,
18
+ # which bounds the work at O(MAX_SCOPE_NODES).
19
+ def value(input, budget = [MAX_SCOPE_NODES], depth = 0)
20
+ charge!(budget, depth)
21
+ case input
22
+ when nil, true, false, Integer then input
23
+ when Float then float_value(input)
24
+ when String, Symbol then string(input.to_s)
25
+ when Array then input.map { |item| value(item, budget, depth + 1) }
26
+ when Hash then hash_entries(input, budget, depth)
27
+ else
28
+ raise ArgumentError,
29
+ "idempotency key scope must be built from Strings, Symbols, numbers, booleans, nil, Arrays " \
30
+ "and Hashes, got #{DiagnosticText.error_class_name(input)}"
31
+ end
32
+ end
33
+
34
+ def charge!(budget, depth)
35
+ raise ArgumentError, "idempotency key scope is nested deeper than #{MAX_SCOPE_DEPTH} levels" if too_deep?(depth)
36
+ return unless (budget[0] -= 1).negative?
37
+
38
+ raise ArgumentError, "idempotency key scope has more than #{MAX_SCOPE_NODES} values"
39
+ end
40
+
41
+ def too_deep?(depth) = depth > MAX_SCOPE_DEPTH
42
+
43
+ def float_value(value)
44
+ raise ArgumentError, "idempotency key scope numbers must be finite" unless value.finite?
45
+
46
+ value == value.truncate ? value.to_i : value
47
+ end
48
+
49
+ def hash_entries(hash, budget, depth)
50
+ entries = hash.map { |key, item| [hash_key(key), value(item, budget, depth + 1)] }
51
+ keys = entries.map(&:first)
52
+ raise ArgumentError, "idempotency key scope has two keys with the same name" unless keys.uniq.size == keys.size
53
+
54
+ entries.sort_by(&:first).to_h
55
+ end
56
+
57
+ def hash_key(key)
58
+ return string(key.to_s) if key.is_a?(String) || key.is_a?(Symbol)
59
+
60
+ raise ArgumentError, "idempotency key scope Hash keys must be Strings or Symbols"
61
+ end
62
+
63
+ def string(string)
64
+ utf8 = string.encoding == Encoding::UTF_8 ? string : string.encode(Encoding::UTF_8)
65
+ return utf8 if utf8.valid_encoding?
66
+
67
+ raise EncodingError
68
+ rescue EncodingError
69
+ raise ArgumentError, "idempotency key scope Strings must be valid UTF-8 or convert to it"
70
+ end
71
+ end
72
+ private_constant :Canonical
73
+ end
74
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "idempotency_key/canonical"
7
+
8
+ module Smith
9
+ # Keys for requests a step sends outside the system, so that a replay of
10
+ # the step (a retry, or a restore after a crash) repeats each request
11
+ # under the key it used before and the receiver can drop the duplicate.
12
+ #
13
+ # A key is the SHA-256 of the run's execution namespace (a UUID assigned
14
+ # when the workflow is built and persisted with it), the step's position
15
+ # (step number and transition, then any nested workflow step, parallel or
16
+ # fan-out branch, optimizer round and role, or orchestrator round and
17
+ # task inside it) and a caller-chosen scope. It is the same on every
18
+ # attempt of the step, so a retry after a timeout cannot repeat a request
19
+ # that took effect, and after a restore. An attempt of a step that fails
20
+ # in a way showing its own requests took no effect can ask for new keys
21
+ # on the attempts after it (`fail!` with `new_idempotency_key: true`, or
22
+ # `retry_on ... new_idempotency_key_on:`), for receivers that store a
23
+ # failed reply under its key. A replay keeps its keys only when the run
24
+ # was persisted before the step began, as `advance_persisted!` and
25
+ # `run_persisted!` do. A persisted payload is a run's identity: runs
26
+ # built with `new` never share keys, but runs restored from copies of one
27
+ # payload do.
28
+ #
29
+ # Keys exist only while a step does its own work (its body, agents,
30
+ # tools and guardrails); event handlers and `on_step` callbacks get none.
31
+ # The position is thread-local state Smith carries into its own branch
32
+ # threads and concurrent tool calls; code in a Fiber or Thread the host
33
+ # creates has none, while `step.idempotency_key` works anywhere.
34
+ #
35
+ # transition :notify, from: :reviewed, to: :notified do
36
+ # compute { |step| Mailer.deliver(review, idempotency_key: step.idempotency_key) }
37
+ # end
38
+ module IdempotencyKey
39
+ VERSION = "smith.idempotency.v1"
40
+
41
+ # Where execution is: the run's namespace and the path of positions
42
+ # inside it, deeply frozen. Internal: hosts reach keys through
43
+ # `current`, `step.idempotency_key` and `Tool#idempotency_key`.
44
+ Scope = Data.define(:run, :path) do
45
+ def within(*segment)
46
+ self.class.new(run:, path: [*path, IdempotencyKey.__send__(:own_segment, segment)].freeze)
47
+ end
48
+
49
+ def key(scope = nil)
50
+ Digest::SHA256.hexdigest(JSON.generate([VERSION, run, path, Canonical.value(scope)]))
51
+ end
52
+ end
53
+ private_constant :Scope
54
+
55
+ class << self
56
+ # The key for `scope` at the current position. Raises WorkflowError
57
+ # outside a running workflow step.
58
+ def current(scope = nil)
59
+ current_scope!.key(scope)
60
+ end
61
+
62
+ private
63
+
64
+ def root_scope(run) = Scope.new(run: run.dup.freeze, path: [].freeze)
65
+
66
+ def current_scope
67
+ Tool.__send__(:current_idempotency_scope)
68
+ end
69
+
70
+ def current_scope!
71
+ current_scope || raise(WorkflowError, "an idempotency key is only available inside a running workflow step")
72
+ end
73
+
74
+ # Narrows the current position for the block, when there is one.
75
+ def within(*segment, &)
76
+ outer = current_scope
77
+ return yield unless outer
78
+
79
+ swap(outer.within(*segment), &)
80
+ end
81
+
82
+ # Runs the block with no position, so code that is not a step's own
83
+ # work (event handlers, callbacks) cannot take a step's key.
84
+ def without(&) = swap(nil, &)
85
+
86
+ # Interrupts are deferred while the position is installed and
87
+ # restored, as for every other per-step thread value.
88
+ def swap(scope, &block)
89
+ raise ArgumentError, "block required" unless block
90
+
91
+ outer = current_scope
92
+ Thread.handle_interrupt(Object => :never) do
93
+ install(scope)
94
+ begin
95
+ Thread.handle_interrupt(Object => :immediate, &block)
96
+ ensure
97
+ install(outer)
98
+ end
99
+ end
100
+ end
101
+
102
+ def install(scope)
103
+ raise ArgumentError, "an idempotency position must be a Scope or nil" unless scope.nil? || scope.is_a?(Scope)
104
+
105
+ Tool.__send__(:current_idempotency_scope=, scope)
106
+ end
107
+
108
+ # A namespace for a payload written before namespaces were assigned at
109
+ # construction: derived from the payload's own durable facts, so every
110
+ # restore of it gets the same one, and shaped as the version 4 UUID
111
+ # `new` assigns.
112
+ def legacy_namespace(*facts)
113
+ hex = Digest::SHA256.hexdigest(JSON.generate([VERSION, "namespace", *facts.map(&:to_s)]))
114
+ variant = ((hex[16].to_i(16) & 0x3) | 0x8).to_s(16)
115
+ "#{hex[0, 8]}-#{hex[8, 4]}-4#{hex[13, 3]}-#{variant}#{hex[17, 3]}-#{hex[20, 12]}"
116
+ end
117
+
118
+ # A position segment: a label and Strings, Symbols or Integers, held
119
+ # as frozen copies so a caller cannot change a key after the fact.
120
+ def own_segment(segment)
121
+ segment.map do |part|
122
+ case part
123
+ when Integer then part
124
+ when Symbol then part.to_s.freeze
125
+ when String then Canonical.string(part).dup.freeze
126
+ else raise ArgumentError, "an idempotency position segment must be Strings, Symbols or Integers"
127
+ end
128
+ end.freeze
129
+ end
130
+
131
+ # Narrows the position to the `count`th copy of `value` seen under
132
+ # `label`, named by its content: a list a model regenerates on replay
133
+ # keeps each item's key whatever its order.
134
+ def within_content(label, value, copies, &)
135
+ digest = content_digest(value)
136
+ copies[digest] += 1
137
+ within(label, digest, copies[digest], &)
138
+ end
139
+
140
+ # A digest of a value's canonical form, for a position named by its
141
+ # content (an orchestrator task) rather than by its place in a list.
142
+ def content_digest(value) = Digest::SHA256.hexdigest(JSON.generate(Canonical.value(value)))
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Launchers
5
+ # Runs each child to its end, one after another, in the parent's
6
+ # process, and returns their results, so the parent's step completes at
7
+ # once. Children run under the parent's deadline but with no tool
8
+ # evidence or attribution of the parent's. A child left waiting, or one
9
+ # that raised, is reported failed (with the error's class). For tests
10
+ # and scripts: a long map holds the parent's step for as long as it runs.
11
+ class Inline
12
+ def inline? = true
13
+
14
+ def launch(children:, parent_key:, await_token:) # rubocop:disable Lint/UnusedMethodArgument
15
+ deadline = Tool.current_deadline
16
+ children.map { |child| run_child(child, deadline) }
17
+ end
18
+
19
+ private
20
+
21
+ def run_child(child, deadline)
22
+ workflow = child.build
23
+ workflow.instance_variable_set(:@inherited_deadline, deadline) if deadline
24
+ run = apart { workflow.run! }
25
+ return failed(child, workflow.state, "waiting") if workflow.waiting?
26
+
27
+ ChildRun.result(ref: child.ref, run_result: run)
28
+ rescue StandardError => e
29
+ failed(child, nil, DiagnosticText.error_class_name(e))
30
+ end
31
+
32
+ def failed(child, state, error)
33
+ { ref: child.ref, status: "failed", state: state&.to_s, output: nil,
34
+ error: DiagnosticText.capture(error.to_s, max_bytes: 256) }
35
+ end
36
+
37
+ def apart(&)
38
+ tracker = Tool.current_tool_execution_tracker
39
+ Tool.current_tool_execution_tracker = nil
40
+ Attribution.carrying(nil, &)
41
+ ensure
42
+ Tool.current_tool_execution_tracker = tracker
43
+ end
44
+ end
45
+ end
46
+ end