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
@@ -8,23 +8,79 @@ module Smith
8
8
  attr_reader :name, :from, :to, :agent_name, :agent_opts, :success_transition, :failure_transition,
9
9
  :router_config, :workflow_class, :optimization_config, :orchestrator_config,
10
10
  :fanout_config, :retry_config, :deterministic_block, :deterministic_kind,
11
- :deterministic_routes
11
+ :deterministic_routes, :deterministic_fallthrough, :await_config, :timeout_transition,
12
+ :step_input, :output_to, :stream, :batch_mode, :on_request_change
12
13
 
13
14
  def initialize(name, from:, to:, &)
14
15
  @name = own_identifier(name)
15
16
  @from = own_identifier(from, allow_nil: true)
16
17
  @to = own_identifier(to)
17
18
  instance_eval(&) if block_given?
19
+ validate_child_run_controls!
20
+ validate_durable_retry!
21
+ validate_batch_mode!
22
+ validate_timeout_route!
23
+ end
24
+
25
+ # Waits, bounded by a required timeout, for an event the host delivers
26
+ # with `Workflow.resume!`, writing its payload to the context key
27
+ # `into:`. The optional block runs on entry with an AwaitStep and may
28
+ # send the request the event answers, or finish at once with
29
+ # `resolve!`. `timeout:` is seconds, or a callable of the context
30
+ # returning seconds; past it, `expire_wait!` takes `on_timeout`.
31
+ def await(event, into:, timeout:, &block)
32
+ validate_execution_primitive_conflict!("await")
33
+ raise WorkflowError, "await into: must be a Symbol context key" unless into.is_a?(Symbol)
34
+
35
+ @await_config = {
36
+ kind: :await, event: Identifier.normalize(event, label: "await event"), into:,
37
+ timeout: wait_seconds!(timeout, "await timeout:"), block:
38
+ }.freeze
39
+ commit_execution_primitive!("await")
40
+ end
41
+
42
+ # Continues to `to:` no earlier than `seconds` after the step is
43
+ # entered (seconds, or a callable of the context returning seconds),
44
+ # when the host calls `expire_wait!`.
45
+ def delay(seconds)
46
+ validate_execution_primitive_conflict!("delay")
47
+ @await_config = { kind: :delay, event: nil, into: nil, timeout: wait_seconds!(seconds, "delay"), block: nil }
48
+ .freeze
49
+ commit_execution_primitive!("delay")
50
+ end
51
+
52
+ # The transition an `await` takes when its deadline passes.
53
+ def on_timeout(transition_name)
54
+ @timeout_transition = own_identifier(transition_name)
18
55
  end
19
56
 
20
- def execute(agent_name, **opts)
57
+ def awaits? = !@await_config.nil?
58
+
59
+ def delay? = @await_config&.fetch(:kind) == :delay
60
+
61
+ # `input:` (on every agent primitive) is a callable of a copy of the
62
+ # context returning the step's messages, which replace the session
63
+ # transcript for this step. `output_to:` names the context key the
64
+ # step's output is written to, instead of the transcript.
65
+ # `stream:` names the sink in `Smith.config.stream_sinks` the reply's
66
+ # text is streamed to as the model writes it (Smith::Streaming).
67
+ # `mode:` is :sync (the default), :batch, or a callable of the context
68
+ # and the step attempt returning one: a batch step sends its request
69
+ # through the host's batch queue and waits for the result.
70
+ # `on_request_change:` says what a batch result does when the step
71
+ # renders another request by the time it comes (Smith::Batch).
72
+ def execute(agent_name, input: nil, output_to: nil, **opts)
21
73
  validate_execution_primitive_conflict!("execute")
22
74
 
23
75
  normalized_agent = normalize_agent_reference!(agent_name, "agent")
24
76
  validate_parallel_options!(opts)
77
+ assign_step_io!(input, output_to)
78
+ @stream = stream_name!(opts[:stream], opts)
79
+ @batch_mode = batch_mode!(opts[:mode], stream: opts[:stream], parallel: opts[:parallel] == true)
80
+ @on_request_change = request_change!(opts[:on_request_change])
25
81
 
26
82
  @agent_name = normalized_agent
27
- @agent_opts = opts.freeze
83
+ @agent_opts = opts.except(:stream, :mode, :on_request_change).freeze
28
84
  commit_execution_primitive!("execute")
29
85
  end
30
86
 
@@ -40,8 +96,9 @@ module Smith
40
96
  @failure_transition = own_identifier(transition_name)
41
97
  end
42
98
 
43
- def route(agent_name, routes:, confidence_threshold:, fallback:)
99
+ def route(agent_name, routes:, confidence_threshold:, fallback:, input: nil)
44
100
  validate_execution_primitive_conflict!("route")
101
+ assign_step_io!(input, nil)
45
102
  if @success_transition
46
103
  raise WorkflowError, "routed transitions cannot declare on_success; router routes select the next transition"
47
104
  end
@@ -74,8 +131,11 @@ module Smith
74
131
  before_eval: nil,
75
132
  on_exhaustion: :raise,
76
133
  on_converged: :raise,
77
- on_threshold: :raise)
134
+ on_threshold: :raise,
135
+ input: nil,
136
+ output_to: nil)
78
137
  validate_execution_primitive_conflict!("optimize")
138
+ assign_step_io!(input, output_to)
79
139
  validate_optimize_controls!(generator, evaluator, max_rounds, evaluator_schema)
80
140
  validate_optimize_exit_modes!(on_exhaustion: on_exhaustion, on_converged: on_converged,
81
141
  on_threshold: on_threshold)
@@ -98,8 +158,9 @@ module Smith
98
158
  commit_execution_primitive!("optimize")
99
159
  end
100
160
 
101
- def orchestrate(**opts)
161
+ def orchestrate(input: nil, output_to: nil, **opts)
102
162
  validate_execution_primitive_conflict!("orchestrate")
163
+ assign_step_io!(input, output_to)
103
164
  validate_orchestrate_controls!(opts)
104
165
  config = opts.merge(
105
166
  orchestrator: normalize_agent_reference!(opts.fetch(:orchestrator), "orchestrator"),
@@ -110,8 +171,11 @@ module Smith
110
171
  commit_execution_primitive!("orchestrate")
111
172
  end
112
173
 
113
- def fan_out(branches:)
174
+ def fan_out(branches:, input: nil, output_to: nil, mode: nil, on_request_change: nil)
114
175
  validate_execution_primitive_conflict!("fan_out")
176
+ assign_step_io!(input, output_to)
177
+ @batch_mode = batch_mode!(mode, stream: nil, parallel: false)
178
+ @on_request_change = request_change!(on_request_change)
115
179
 
116
180
  normalized = normalize_fanout_branches!(branches)
117
181
  @fanout_branch_lookup = normalized.to_h do |key, agent|
@@ -124,28 +188,69 @@ module Smith
124
188
  end
125
189
  alias fanout fan_out
126
190
 
127
- def retry_on(*error_classes, attempts:, backoff: 0, max_delay: nil, jitter: 0)
191
+ # `new_idempotency_key_on:` names errors that show an attempt's
192
+ # requests took no effect (a refusal, not a timeout): the attempts
193
+ # after one of them use new idempotency keys. Every other retry keeps
194
+ # the step's keys. It is refused on a transition whose failures can
195
+ # come from several steps or branches (a nested workflow, parallel or
196
+ # fan-out branches, an optimizer or an orchestrator), where one error
197
+ # cannot speak for every request of the attempt.
198
+ #
199
+ # `durable: true` waits out each backoff as a wait of the run (kind
200
+ # `:retry`) instead of sleeping in the worker: the host wakes it with
201
+ # `expire_wait!` and continues the run, which makes the next attempt
202
+ # under the recorded attempt number and key generation.
203
+ # rubocop:disable Metrics/ParameterLists
204
+ def retry_on(*error_classes, attempts:, backoff: 0, max_delay: nil, jitter: 0, new_idempotency_key_on: [],
205
+ durable: false)
128
206
  policy = normalize_retry_policy!(error_classes, attempts:, backoff:, max_delay:, jitter:)
207
+ new_key_classes = normalize_new_idempotency_key_on!(new_idempotency_key_on, error_classes)
208
+ validate_durable_policy!(durable, policy)
129
209
 
130
- @retry_config = {
210
+ config = {
131
211
  error_classes: error_classes.freeze,
132
212
  attempts: policy.attempts,
133
213
  backoff: policy.base_delay,
134
214
  max_delay: policy.max_delay,
135
215
  jitter: policy.jitter
136
- }.freeze
216
+ }
217
+ config[:new_idempotency_key_on] = new_key_classes if new_key_classes.any?
218
+ config[:durable] = true if durable
219
+ @retry_config = config.freeze
220
+ reject_new_idempotency_key_on_composite!
137
221
  end
222
+ # rubocop:enable Metrics/ParameterLists
223
+
224
+ def durable_retry? = @retry_config&.fetch(:durable, false) == true
225
+
226
+ # Whether the step may send its requests as a batch: its mode is not
227
+ # :sync.
228
+ def batches? = !@batch_mode.nil? && @batch_mode != :sync
229
+
230
+ def validate_durable_policy!(durable, policy)
231
+ raise WorkflowError, "retry_on durable: must be true or false" unless [true, false].include?(durable)
232
+ return unless durable && !(policy.attempts > 1 && policy.base_delay.positive?)
233
+
234
+ raise WorkflowError, "retry_on durable: needs at least 2 attempts and a positive backoff, so it has a wait"
235
+ end
236
+ private :validate_durable_policy!
237
+
238
+ # A step that waits (an await, delay, map or call), launches runs of
239
+ # its own (a spawn), or waits between attempts (a durable retry).
240
+ def waits_or_launches? = awaits? || child_runs? || durable_retry?
138
241
 
139
242
  %i[compute run].each do |method_name|
140
- define_method(method_name) do |routes: nil, &block|
243
+ define_method(method_name) do |routes: nil, fallthrough: false, &block|
141
244
  validate_execution_primitive_conflict!("compute/run", declaration: method_name.to_s)
142
245
  raise WorkflowError, "#{method_name} requires a block" unless block
143
246
 
144
247
  normalized_routes = normalize_deterministic_routes!(routes)
248
+ validate_deterministic_fallthrough!(fallthrough, normalized_routes)
145
249
 
146
250
  @deterministic_block = block
147
251
  @deterministic_kind = method_name
148
252
  @deterministic_routes = normalized_routes
253
+ @deterministic_fallthrough = fallthrough
149
254
  commit_execution_primitive!("compute/run", declaration: method_name.to_s)
150
255
  end
151
256
  end
@@ -189,8 +294,110 @@ module Smith
189
294
  agent_opts&.dig(:parallel) == true
190
295
  end
191
296
 
297
+ # Whether the step runs an agent: `execute`, `route`, `fan_out`,
298
+ # `optimize` or `orchestrate`.
299
+ def agent_step?
300
+ !agent_name.nil? || optimized? || orchestrated? || fanout?
301
+ end
302
+
192
303
  private
193
304
 
305
+ def assign_step_io!(input, output_to)
306
+ raise WorkflowError, "input: must be a callable of the context" unless input.nil? || input.respond_to?(:call)
307
+ raise WorkflowError, "output_to: must be a Symbol context key" unless output_to.nil? || output_to.is_a?(Symbol)
308
+
309
+ @step_input = input
310
+ @output_to = output_to
311
+ end
312
+
313
+ # A batch request is one turn of one model, sent and answered apart
314
+ # from the step: a streamed or parallel step has nothing to send it as.
315
+ def batch_mode!(mode, stream:, parallel:)
316
+ return if mode.nil?
317
+ unless %i[sync batch].include?(mode) || mode.respond_to?(:call)
318
+ raise WorkflowError, "mode: must be :sync, :batch, or a callable of the context and attempt returning one"
319
+ end
320
+ raise WorkflowError, "mode: :batch cannot stream" if stream && mode != :sync
321
+ raise WorkflowError, "mode: is not available on a parallel step" if parallel
322
+
323
+ mode
324
+ end
325
+
326
+ # What a batch result does when the step renders another request by
327
+ # the time it comes: :keep it (the default), :resend the request over
328
+ # the wire, or :fail the step with BatchRequestChanged.
329
+ def request_change!(value)
330
+ return if value.nil?
331
+ unless %i[keep resend fail].include?(value)
332
+ raise WorkflowError, "on_request_change: must be :keep, :resend or :fail"
333
+ end
334
+ raise WorkflowError, "on_request_change: needs mode: :batch or a mode callable" unless batches?
335
+
336
+ value
337
+ end
338
+
339
+ # One reply streams to one sink: a parallel step has several.
340
+ def stream_name!(stream, opts)
341
+ return if stream.nil?
342
+ raise WorkflowError, "execute stream: must name a sink with a Symbol" unless stream.is_a?(Symbol)
343
+ raise WorkflowError, "execute stream: is not available on a parallel step" if opts[:parallel] == true
344
+
345
+ stream
346
+ end
347
+
348
+ def wait_seconds!(value, label)
349
+ return value if value.respond_to?(:call)
350
+ return value if (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && value.positive?
351
+
352
+ raise WorkflowError, "#{label} must be a positive number of seconds or a callable returning one"
353
+ end
354
+
355
+ # An await must say where its timeout goes: past its deadline it takes
356
+ # `on_timeout`, or else `on_failure` with Smith::WaitTimedOut. Without
357
+ # either, an expired wait could never end.
358
+ # A durable retry waits between attempts, so its step must be one a
359
+ # run can wait in: not a step that waits itself, nor one whose
360
+ # branches keep durable state of their own.
361
+ # Its last failure must settle the run through `on_failure`: a failure
362
+ # raised to the host leaves the stored run before that attempt, so a
363
+ # job that runs again would make the attempt again at once, past the
364
+ # policy's attempts.
365
+ def validate_durable_retry!
366
+ return unless durable_retry?
367
+
368
+ refuse_durable_retry_on_waiting_step!
369
+ return if @failure_transition
370
+
371
+ raise WorkflowError, "retry_on durable: needs on_failure, so the last failure settles the run instead of " \
372
+ "being made again by a host that runs the job again"
373
+ end
374
+
375
+ # A batch result can fail or never come, as an await's event can: the
376
+ # step must say where that goes.
377
+ def validate_batch_mode!
378
+ return unless batches? && @failure_transition.nil?
379
+
380
+ raise WorkflowError, "mode: :batch needs on_failure, where a batch result that fails or never comes goes"
381
+ end
382
+
383
+ def refuse_durable_retry_on_waiting_step!
384
+ return unless awaits? || parallel? || fanout? || optimized? || orchestrated?
385
+
386
+ raise WorkflowError, "retry_on durable: is not available on await, delay, parallel, fan_out, optimize or " \
387
+ "orchestrate steps"
388
+ end
389
+
390
+ def validate_timeout_route!
391
+ if @timeout_transition
392
+ return if @await_config && !delay?
393
+
394
+ raise WorkflowError, "on_timeout requires an await step"
395
+ end
396
+ return unless @await_config && !delay? && @failure_transition.nil?
397
+
398
+ raise WorkflowError, "an await step must declare on_timeout or on_failure for its deadline"
399
+ end
400
+
194
401
  def own_identifier(identifier, allow_nil: false)
195
402
  Identifier.normalize(identifier, label: "workflow identifier", allow_nil:)
196
403
  end
@@ -218,6 +425,17 @@ module Smith
218
425
  def commit_execution_primitive!(primitive, declaration: primitive)
219
426
  @execution_primitive = primitive
220
427
  @execution_declaration = declaration
428
+ reject_new_idempotency_key_on_composite!
429
+ end
430
+
431
+ def several_step_execution? = nested? || parallel? || fanout? || optimized? || orchestrated?
432
+
433
+ def reject_new_idempotency_key_on_composite!
434
+ return unless @retry_config&.key?(:new_idempotency_key_on) && several_step_execution?
435
+
436
+ raise WorkflowError,
437
+ "retry_on new_idempotency_key_on: cannot be declared on a nested workflow, parallel, fan_out, " \
438
+ "optimize or orchestrate transition, where one error cannot speak for every request of the attempt"
221
439
  end
222
440
 
223
441
  def normalize_fanout_branches!(branches)
@@ -349,12 +567,48 @@ module Smith
349
567
  raise WorkflowError, "retry_on #{e.message}"
350
568
  end
351
569
 
352
- def reject_retry_forbidden!(error_classes)
570
+ def normalize_new_idempotency_key_on!(classes, retried_classes)
571
+ unless classes.is_a?(Array) && classes.all? { |error_class| standard_error_class?(error_class) }
572
+ raise WorkflowError, "retry_on new_idempotency_key_on: must be an Array of StandardError classes"
573
+ end
574
+
575
+ if classes.uniq.size != classes.size
576
+ raise WorkflowError, "retry_on new_idempotency_key_on: lists a class more than once"
577
+ end
578
+
579
+ reject_retry_forbidden!(classes, context: "move to new idempotency keys after")
580
+ reject_unretried_new_key_classes!(classes, retried_classes)
581
+ classes.dup.freeze
582
+ end
583
+
584
+ def standard_error_class?(value) = value.is_a?(Class) && value <= StandardError
585
+
586
+ # With no error classes the policy retries whatever Smith classifies
587
+ # retryable, which cannot be checked here.
588
+ def reject_unretried_new_key_classes!(classes, retried_classes)
589
+ return if retried_classes.empty?
590
+
591
+ unretried = classes.find { |error_class| retried_classes.none? { |retried| error_class <= retried } }
592
+ return unless unretried
593
+
594
+ raise WorkflowError, "retry_on new_idempotency_key_on: lists #{unretried.name || unretried.inspect}, " \
595
+ "which this policy does not retry"
596
+ end
597
+
598
+ def reject_retry_forbidden!(error_classes, context: "retry")
353
599
  forbidden = error_classes.find { |error_class| Smith::Errors.retry_forbidden_class?(error_class) }
354
600
  return unless forbidden
355
601
 
356
602
  label = forbidden.name || "an anonymous terminal tool-evidence error class"
357
- raise WorkflowError, "retry_on cannot retry #{label} because the tool outcome may be uncertain"
603
+ raise WorkflowError, "retry_on cannot #{context} #{label} because the tool outcome may be uncertain"
604
+ end
605
+
606
+ # `fallthrough: true` lets a step that declares `routes:` finish without
607
+ # routing, taking `on_success` or the first transition from its target
608
+ # state. It means nothing without `routes:`.
609
+ def validate_deterministic_fallthrough!(fallthrough, routes)
610
+ raise WorkflowError, "fallthrough must be true or false" unless [true, false].include?(fallthrough)
611
+ raise WorkflowError, "fallthrough: true requires routes:" if fallthrough && routes.nil?
358
612
  end
359
613
 
360
614
  def normalize_deterministic_routes!(routes)
@@ -14,6 +14,8 @@ module Smith
14
14
  transition.nested?,
15
15
  transition.optimized?,
16
16
  transition.orchestrated?,
17
+ transition.awaits?,
18
+ transition.child_runs?,
17
19
  transition.success_transition
18
20
  ].any?
19
21
  end
@@ -18,8 +18,14 @@ module Smith
18
18
  # non-provider error emits with `outcome: :aborted` before re-raising),
19
19
  # so prefix-accounted entries always have their join target.
20
20
  # All optional members are nil on entries restored from checkpoints written by
21
- # earlier Smith versions, and nil attribution members are omitted from
21
+ # earlier Smith versions, and nil optional members are omitted from
22
22
  # serialization so those documents re-serialize byte-identically.
23
+ #
24
+ # `input_tokens` counts uncached input; `cache_read_tokens` and
25
+ # `cache_write_tokens` are the prompt-cache input beside it;
26
+ # `thinking_tokens` are already inside `output_tokens`. `stop_reason` and
27
+ # `provider_message_id` come from the provider response, and `mode` is
28
+ # how the call was made (`:sync`, `:stream`, `:batch`).
23
29
  # rubocop:disable Style/RedundantStructKeywordInit
24
30
  UsageEntry = Struct.new(
25
31
  :usage_id,
@@ -36,6 +42,12 @@ module Smith
36
42
  :round,
37
43
  :attempt_id,
38
44
  :workflow,
45
+ :cache_read_tokens,
46
+ :cache_write_tokens,
47
+ :thinking_tokens,
48
+ :stop_reason,
49
+ :provider_message_id,
50
+ :mode,
39
51
  keyword_init: true
40
52
  ) do
41
53
  def initialize(**attributes)
@@ -51,18 +63,27 @@ module Smith
51
63
  # re-serializes byte-identically. Hosts that digest whole persisted
52
64
  # documents (exact-mutation proofs) depend on this stability.
53
65
  def to_h
54
- super.reject { |key, value| value.nil? && %i[transition branch_key round attempt_id workflow].include?(key) }
66
+ super.reject { |key, value| value.nil? && Workflow::UsageEntry::OPTIONAL_MEMBERS.include?(key) }
67
+ end
68
+
69
+ # Every input token the provider processed, cached or not, plus output.
70
+ def total_tokens
71
+ input_tokens + output_tokens + (cache_read_tokens || 0) + (cache_write_tokens || 0)
55
72
  end
56
73
 
57
74
  def self.from_h(hash)
58
75
  sym = hash.transform_keys(&:to_sym)
59
76
  filtered = sym.slice(*members)
60
- %i[agent_name provider attempt_kind transition branch_key].each do |attribute|
77
+ %i[agent_name provider attempt_kind transition branch_key mode].each do |attribute|
61
78
  filtered[attribute] = filtered[attribute].to_sym if filtered[attribute].is_a?(String)
62
79
  end
63
80
  new(**filtered)
64
81
  end
65
82
  end
83
+ UsageEntry::OPTIONAL_MEMBERS = %i[
84
+ transition branch_key round attempt_id workflow
85
+ cache_read_tokens cache_write_tokens thinking_tokens stop_reason provider_message_id mode
86
+ ].freeze
66
87
  # rubocop:enable Style/RedundantStructKeywordInit
67
88
  end
68
89
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "message_value_normalizer"
6
+
7
+ module Smith
8
+ class Workflow
9
+ # Completing a wait, by delivery or expiry: claiming it so only one
10
+ # completion wins, running the completion as the step, and the timeout
11
+ # route. Part of WaitExecution, split out for size.
12
+ module WaitCompletion
13
+ private
14
+
15
+ # The awaited transition when the delivery matches the stored wait, or
16
+ # when no wait is stored yet but the run stands at that await and the
17
+ # token is the one entering it will use.
18
+ def resumable_transition(event, token)
19
+ record = @awaiting
20
+ return (awaited_transition(record) if record["event"] == event && record["token"] == token) if record
21
+
22
+ transition = pending_await_transition
23
+ return unless transition && transition.await_config[:event].to_s == event
24
+ return unless await_token_for(transition) == token
25
+
26
+ transition
27
+ end
28
+
29
+ def pending_await_transition
30
+ transition = self.class.find_transition(@next_transition_name || self.class.first_transition_from(@state)&.name)
31
+ transition if transition&.awaits? && !transition.delay? && [nil, @state].include?(transition.from)
32
+ end
33
+
34
+ def claim_wait
35
+ (@wait_mutex ||= Mutex.new).synchronize do
36
+ return nil if @wait_claimed
37
+
38
+ claimed = yield
39
+ @wait_claimed = true if claimed
40
+ claimed
41
+ end
42
+ end
43
+
44
+ def expire_claimed_wait(transition, record)
45
+ return end_retry_wait(transition) if record["kind"] == "retry"
46
+ return complete_batch_wait(transition, missing_batch_results(transition), :expired) if record["kind"] == "batch"
47
+
48
+ complete_claimed_wait(transition, :expired) { expire_step(transition, record) }
49
+ end
50
+
51
+ def complete_claimed_wait(transition, done, &)
52
+ step = finish_wait(transition, &)
53
+ step.key?(:error) && done == :resumed ? :failed : done
54
+ ensure
55
+ @wait_claimed = false
56
+ end
57
+
58
+ def expire_step(transition, record)
59
+ return StepCompletion.instance_method(:complete_step).bind_call(self, transition, nil) if transition.delay?
60
+ return route_to_timeout!(transition) if transition.timeout_transition
61
+
62
+ raise WaitTimedOut.new(transition: transition.name, event: record["event"], deadline_at: record["deadline_at"])
63
+ end
64
+
65
+ # The await ends without reaching its `to:`; the run stays in its
66
+ # `from:` state and takes `on_timeout` next. Hosts see a StepCompleted
67
+ # whose `to` is that state.
68
+ def route_to_timeout!(transition)
69
+ name = transition.timeout_transition
70
+ target = self.class.find_transition(name) || raise(UnresolvedTransitionError.new(name, self.class, @state))
71
+ validate_transition_origin!(target)
72
+ @next_transition_name = name
73
+ record_wait_trace(transition, :timed_out)
74
+ emit_step_completed(TimedOutStep.new(transition.name, transition.from, @state), nil)
75
+ { transition: transition.name, from: transition.from, to: @state, timed_out: true }
76
+ end
77
+
78
+ TimedOutStep = Struct.new(:name, :from, :to)
79
+ private_constant :TimedOutStep
80
+
81
+ # A payload is bounded and JSON-shaped like a session message, and
82
+ # takes the context's key shape. It never enters the session.
83
+ def wait_payload(payload)
84
+ ContextKeys.normalize(MessageValueNormalizer.new(payload, label: "wait payload").call)
85
+ end
86
+
87
+ # Returns nil, the inline-resolved step's output.
88
+ def write_wait_payload(transition, value)
89
+ key = writable_wait_key!(transition)
90
+ @context[key] = value
91
+ record_persisted_key!(key)
92
+ nil
93
+ end
94
+
95
+ def writable_wait_key!(transition)
96
+ key = transition.await_config.fetch(:into)
97
+ keys = declared_persist_keys
98
+ raise UndeclaredContextKey.new(key:, persisted_keys: keys) if keys && !keys.include?(key)
99
+
100
+ key
101
+ end
102
+
103
+ def wait_time(now)
104
+ return now.utc if now.is_a?(Time)
105
+ return Time.iso8601(now).utc if now.is_a?(String)
106
+ return now.to_time.utc if now.respond_to?(:to_time)
107
+
108
+ raise ArgumentError, "now: must be a Time or an ISO 8601 String"
109
+ end
110
+
111
+ def wait_deadline(record) = Time.iso8601(record.fetch("deadline_at"))
112
+
113
+ # The completion of a wait is the step: it runs in the step's context
114
+ # (so its position, attribution and events are the step's), then
115
+ # counts. A completion that raises leaves the run waiting.
116
+ def finish_wait(transition, &)
117
+ step = with_step_context(transition, &)
118
+ @awaiting = nil
119
+ @step_count += 1
120
+ @updated_at = Time.now.utc.iso8601(6)
121
+ record_step_snapshot(step)
122
+ step
123
+ end
124
+
125
+ def awaited_transition(record)
126
+ self.class.transition_named(record.fetch("transition")) ||
127
+ raise(WorkflowError, "the awaited transition is not declared")
128
+ end
129
+ end
130
+ end
131
+ end