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
@@ -7,29 +7,86 @@ module Smith
7
7
 
8
8
  # `@step_attempt` is the attempt under way, from 1, which the optimizer
9
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.
10
20
  def run_with_retry_policy(transition)
11
21
  config = transition.retry_config
12
- @step_attempt = 1
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
13
27
  return run_guarded_step(transition) unless config
14
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)
15
34
  schedule = retry_schedule(config)
16
- attempt = 0
17
35
  begin
18
36
  attempt += 1
19
37
  @step_attempt = attempt
20
- run_guarded_step(transition)
38
+ @step_key_generation = key_generation
39
+ attempt_scope = attempt_idempotency_scope(key_generation)
40
+ run_attempt_under_keys(transition, attempt_scope)
21
41
  rescue StandardError => e
22
42
  raise unless retry_transition_error?(config, e, attempt)
23
43
 
24
- 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?
25
50
  retry
26
51
  end
27
52
  end
28
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
+
29
84
  def retry_transition_error?(config, error, attempt)
30
85
  return false if attempt >= config.fetch(:attempts)
31
86
  return false if terminal_retry_error?(error)
32
87
 
88
+ return false if retry_deadline_passed?
89
+
33
90
  classes = config.fetch(:error_classes)
34
91
  if classes.any?
35
92
  classes.any? { |error_class| error.is_a?(error_class) }
@@ -38,13 +95,15 @@ module Smith
38
95
  end
39
96
  end
40
97
 
41
- def terminal_retry_error?(error)
42
- 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
43
103
  end
44
104
 
45
- def sleep_for_retry(schedule, failed_attempt)
46
- delay = retry_delay(schedule, failed_attempt)
47
- sleep(delay) if delay.positive?
105
+ def terminal_retry_error?(error)
106
+ Smith::Errors.retry_forbidden?(error)
48
107
  end
49
108
 
50
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
@@ -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
@@ -46,7 +46,10 @@ module Smith
46
46
  # (execution key from the restored persistence key, the split
47
47
  # step's transition, then the branch overlay downstream).
48
48
  # Restoration is owned by within_raw_step_context's snapshot.
49
- install_step_attribution(@split_step_transition) if @split_step_transition
49
+ if @split_step_transition
50
+ install_step_attribution(@split_step_transition)
51
+ install_step_idempotency_scope(@split_step_transition)
52
+ end
50
53
  with_scoped_artifacts(&block)
51
54
  end
52
55
  [output, nil]
@@ -33,8 +33,10 @@ module Smith
33
33
 
34
34
  def prepare_composite_input(authorization, transition, specs)
35
35
  ThreadContextSnapshot.new.around do
36
+ # The step's position, as in-process guardrails run with it.
37
+ install_step_idempotency_scope(transition)
36
38
  run_composite_input_guardrails(authorization, transition, specs)
37
- agent_messages = build_session&.prepare!
39
+ agent_messages = step_prepared_input(transition)
38
40
  Composite::Input.build(agent_messages:, session_messages: snapshot_session_messages)
39
41
  end
40
42
  end
@@ -48,6 +48,7 @@ module Smith
48
48
  end
49
49
 
50
50
  def replace_exact_dispatch!(payload)
51
+ check_state_size!(@split_step_persistence_key, payload)
51
52
  @split_step_adapter.replace_exact(
52
53
  @split_step_persistence_key,
53
54
  payload,
@@ -70,10 +70,15 @@ module Smith
70
70
  end
71
71
 
72
72
  def prepare_claimed_persisted_step!(key, adapter)
73
+ raise WorkflowError, "split-step persistence cannot prepare a step while the run waits" if waiting?
74
+
73
75
  transition = pending_split_step_transition
74
76
  transition_name = @next_transition_name || transition&.name
75
77
  raise WorkflowError, "workflow has no pending transition" unless transition_name
76
78
 
79
+ refusal = "split-step persistence does not run steps that wait or launch runs"
80
+ raise WorkflowError, refusal if transition&.waits_or_launches?
81
+
77
82
  ensure_transition_budget!
78
83
  resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
79
84
  store = persistence_adapter!(adapter)
@@ -58,6 +58,7 @@ module Smith
58
58
 
59
59
  def detach_split_step_results!
60
60
  @last_output = snapshot_value(@last_output)
61
+ @last_transcript_output = snapshot_value(@last_transcript_output)
61
62
  @last_agent_execution = snapshot_value(@last_agent_execution)
62
63
  @last_failed_step = snapshot_value(@last_failed_step)
63
64
  @last_prepared_input = snapshot_value(@last_prepared_input)
@@ -3,24 +3,26 @@
3
3
  module Smith
4
4
  class Workflow
5
5
  module StepCompletion
6
+ # Room for what completing a step adds besides its output (its state,
7
+ # step count, last agent).
8
+ RESULT_MARGIN = 1024
9
+
6
10
  private
7
11
 
8
12
  def complete_step(transition, output)
13
+ StepCompletion.instance_method(:check_result_storable!).bind_call(self, output)
9
14
  step = { transition: transition.name, from: transition.from, to: transition.to, output: output }
10
15
  # Fold in the serial agent attribution captured for this step (if any),
11
16
  # then clear the transient carrier. record_step_snapshot reads these off
12
17
  # the returned step to update the durable @last_agent_execution.
13
- if @pending_agent_execution
14
- step[:model] = @pending_agent_execution[:model]
15
- step[:provider] = @pending_agent_execution[:provider]
16
- end
18
+ step.merge!(@pending_agent_execution.slice(:model, :provider)) if @pending_agent_execution
17
19
 
18
20
  @pending_agent_execution = nil
19
21
  fold_pending_evaluations(step)
20
22
  result = SplitStepPersistence
21
23
  .instance_method(:prepare_split_step_execution_result)
22
24
  .bind_call(self, step)
23
- StepCompletion.instance_method(:append_accepted_output).bind_call(self, output)
25
+ StepCompletion.instance_method(:keep_accepted_output).bind_call(self, transition, output)
24
26
  SplitStepPersistence
25
27
  .instance_method(:commit_split_step_execution_result!)
26
28
  .bind_call(self, result)
@@ -34,10 +36,37 @@ module Smith
34
36
  step
35
37
  end
36
38
 
37
- def append_accepted_output(output)
38
- return unless @session_messages && !output.nil?
39
+ # A stored run's step whose result would take its state past
40
+ # persistence_max_bytes fails with PersistedStateTooLarge before the
41
+ # result is kept, as with any error: its usage is recorded, its
42
+ # on_failure taken, and its run stays storable (strict mode
43
+ # included), where a refused store after the step would leave it
44
+ # unsaved. The estimate is the state's JSON and the output's, kept
45
+ # twice (in the context or transcript, and as the last output).
46
+ def check_result_storable!(output)
47
+ limit = Smith.config.persistence_max_bytes
48
+ return unless limit && @persistence_version.to_i.positive?
49
+
50
+ bytes = JSON.generate(to_state).bytesize + (JSON.generate(output).bytesize * 2) + RESULT_MARGIN
51
+ raise PersistedStateTooLarge.new(key: @persistence_key, bytes:, limit:) if bytes > limit
52
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
53
+ nil
54
+ end
55
+
56
+ # Once the result is captured, so a step whose result is refused leaves
57
+ # neither the context nor the transcript changed.
58
+ def keep_accepted_output(transition, output)
59
+ StepIO.instance_method(:deliver_output_to).bind_call(self, transition, output) if transition.output_to
60
+ StepCompletion.instance_method(:append_accepted_output).bind_call(self, transition, output)
61
+ end
62
+
63
+ # The transcript keeps an accepted output unless the step writes it to
64
+ # the context (`output_to:`) or the session is isolated.
65
+ def append_accepted_output(transition, output)
66
+ return unless @session_messages && !output.nil? && transcript_output?(transition)
39
67
 
40
68
  @session_messages << { role: :assistant, content: output }
69
+ @last_transcript_output = output
41
70
  end
42
71
  end
43
72
  end
@@ -10,14 +10,17 @@ module Smith
10
10
 
11
11
  def with_step_context(transition, &block)
12
12
  ThreadContextSnapshot.new.around do
13
+ start_active_clock!
13
14
  setup_step_context
14
15
  install_step_attribution(transition)
16
+ install_step_idempotency_scope(transition)
15
17
  Thread.handle_interrupt(Object => :immediate, &block)
16
18
  rescue StandardError => e
17
19
  @outcome = nil
18
20
  GuardrailIntegration.instance_method(:handle_step_failure).bind_call(self, transition, e)
19
21
  ensure
20
22
  teardown_step_context
23
+ stop_active_clock!
21
24
  end
22
25
  ensure
23
26
  flush_step_failure_emission
@@ -47,12 +50,17 @@ module Smith
47
50
  end
48
51
  end
49
52
 
53
+ # A durable composite branch runs here, in its own restored instance:
54
+ # it starts from what is left of the run's wall_clock, and its running
55
+ # time is not added back to the run's active_seconds.
50
56
  def within_raw_step_context(&block)
51
57
  ThreadContextSnapshot.new.around do
58
+ start_active_clock!
52
59
  setup_step_context
53
60
  Thread.handle_interrupt(Object => :immediate, &block)
54
61
  ensure
55
62
  teardown_step_context
63
+ stop_active_clock!
56
64
  end
57
65
  end
58
66
 
@@ -80,6 +88,19 @@ module Smith
80
88
  )
81
89
  end
82
90
 
91
+ # The step's position for idempotency keys: its number and transition
92
+ # in this run's namespace. A nested workflow sharing the namespace (an
93
+ # in-process child) is placed under the parent's current position, so
94
+ # a child step never takes a key a parent step already used; any other
95
+ # workflow started inside a step is a run of its own. Restoration is
96
+ # owned by the surrounding ThreadContextSnapshot.
97
+ def install_step_idempotency_scope(transition)
98
+ namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
99
+ outer = IdempotencyKey.__send__(:current_scope)
100
+ base = outer && outer.run == namespace ? outer : IdempotencyKey.__send__(:root_scope, namespace)
101
+ IdempotencyKey.__send__(:install, base.within(:step, @step_count + 1, transition.name.to_s))
102
+ end
103
+
83
104
  def teardown_step_context
84
105
  Tool.current_guardrails = nil
85
106
  Tool.current_deadline = nil
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_result_snapshot"
4
+ require_relative "message_batch"
5
+ require_relative "message_value_normalizer"
6
+
7
+ module Smith
8
+ class Workflow
9
+ # Per-step input and output: a step's `input:` replaces the session
10
+ # transcript as its messages, `output_to:` writes its output to a context
11
+ # key instead of the transcript, and an isolated session keeps no
12
+ # transcript for agents at all. Also the run's untrusted keys: its own
13
+ # context's, and those of every workflow it is nested in.
14
+ module StepIO
15
+ INPUT_ROLES = %w[user assistant].freeze
16
+ INHERITED_UNTRUSTED_KEYS = :smith_inherited_untrusted_keys
17
+ private_constant :INPUT_ROLES, :INHERITED_UNTRUSTED_KEYS
18
+
19
+ # Builds a nested workflow with its parent's untrusted keys in force
20
+ # from construction, before its seed builder runs.
21
+ def self.with_inherited_untrusted_keys(keys)
22
+ outer = Thread.current[INHERITED_UNTRUSTED_KEYS]
23
+ Thread.current[INHERITED_UNTRUSTED_KEYS] = untrusted_key_names!(keys)
24
+ yield
25
+ ensure
26
+ Thread.current[INHERITED_UNTRUSTED_KEYS] = outer
27
+ end
28
+
29
+ # Context key names: at most 1,000, each a Symbol or String of 1 to 256
30
+ # characters, as Symbols.
31
+ def self.untrusted_key_names!(keys)
32
+ valid = keys.is_a?(Array) && keys.length <= 1_000 &&
33
+ keys.all? { (_1.is_a?(Symbol) || _1.is_a?(String)) && _1.length.between?(1, 256) }
34
+ raise WorkflowError, "untrusted keys must be up to 1,000 context key names" unless valid
35
+
36
+ keys.map(&:to_sym).uniq.freeze
37
+ end
38
+
39
+ # The keys handed to the workflow under construction; taken once, so a
40
+ # workflow its constructor builds does not inherit them too.
41
+ def self.take_inherited_untrusted_keys
42
+ keys = Thread.current[INHERITED_UNTRUSTED_KEYS]
43
+ Thread.current[INHERITED_UNTRUSTED_KEYS] = nil
44
+ keys || [].freeze
45
+ end
46
+
47
+ private
48
+
49
+ def isolated_session? = self.class.session == :isolated
50
+
51
+ def untrusted_context_keys
52
+ manager = self.class.context_manager
53
+ own = manager.respond_to?(:untrusted_keys) ? manager.untrusted_keys : []
54
+ own | (@inherited_untrusted_keys || [])
55
+ end
56
+
57
+ # The context without its untrusted keys, as `inject_state` formatters
58
+ # and the `seed_messages` builder read it.
59
+ def trusted_context_view
60
+ keys = untrusted_context_keys
61
+ keys.empty? || !@context.is_a?(Hash) ? @context : @context.except(*keys)
62
+ end
63
+
64
+ # The messages an agent step sees: its `input:`, or else the session
65
+ # transcript (with injected state). An isolated session has no
66
+ # transcript to give: an agent step there must declare `input:`, and
67
+ # any other step gets nothing, so state is never injected.
68
+ def step_prepared_input(transition)
69
+ return step_input_messages(transition) if transition.step_input
70
+ return build_session&.prepare! unless isolated_session?
71
+ return unless transition.agent_step?
72
+
73
+ raise WorkflowError, "transition #{DiagnosticText.capture(transition.name.to_s, max_bytes: 256)} runs an " \
74
+ "agent in an isolated session and must declare input:"
75
+ end
76
+
77
+ # A non-empty Array of at most MessageBatch::MAX_MESSAGES user and
78
+ # assistant messages, bounded and JSON-shaped like session messages.
79
+ # The callable gets a copy of the context, so it cannot change the
80
+ # run's.
81
+ def step_input_messages(transition)
82
+ messages = transition.step_input.call(snapshot_value(@context))
83
+ unless messages.is_a?(Array) && !messages.empty? && messages.length <= MessageBatch::MAX_MESSAGES
84
+ raise WorkflowError, "input: must return a non-empty Array of at most #{MessageBatch::MAX_MESSAGES} messages"
85
+ end
86
+
87
+ MessageValueNormalizer.new(messages, label: "step input").call.map { |message| step_input_message(message) }
88
+ end
89
+
90
+ def step_input_message(message)
91
+ return step_input_parts_message(message) if Message.parts?(message)
92
+ unless message.is_a?(Hash) && message.keys.sort == %w[content role] && INPUT_ROLES.include?(message["role"]) &&
93
+ !message["content"].nil?
94
+ raise WorkflowError, "each input: message must be { role: :user or :assistant, content: } with content"
95
+ end
96
+
97
+ { role: message["role"].to_sym, content: message["content"] }
98
+ end
99
+
100
+ # A message with content parts (Smith::Message), user or assistant.
101
+ def step_input_parts_message(message)
102
+ checked = Message.check!(message)
103
+ raise WorkflowError, "an input: message with content parts must be a user or assistant message" unless
104
+ INPUT_ROLES.include?(checked["role"])
105
+
106
+ { role: checked["role"].to_sym, content: checked["content"], parts: true }
107
+ end
108
+
109
+ # Checked before the step runs, so a key the persist list refuses
110
+ # costs no agent call.
111
+ def writable_output_key!(transition)
112
+ key = transition.output_to
113
+ keys = declared_persist_keys
114
+ raise UndeclaredContextKey.new(key:, persisted_keys: keys) if keys && !keys.include?(key)
115
+
116
+ key
117
+ end
118
+
119
+ # The step's output, nil included, under `output_to:`: JSON-shaped and
120
+ # in the context's key shape, so it reads the same live and restored.
121
+ def deliver_output_to(transition, output)
122
+ key = writable_output_key!(transition)
123
+ value = MessageValueNormalizer.new(output, label: "output_to: value",
124
+ max_bytes: ExecutionResultSnapshot::MAX_BYTES).call
125
+ @context[key] = ContextKeys.normalize(value)
126
+ record_persisted_key!(key)
127
+ end
128
+
129
+ def transcript_output?(transition) = transition.output_to.nil? && !isolated_session?
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # A step declared with `stream:` streams through its sink
6
+ # (Smith::Streaming), which hears the step's outcome: its accepted
7
+ # output, a durable retry's wait, or its failure. Every other step clears
8
+ # the stream, so none of its calls reach another step's sink; a nested
9
+ # workflow is a run of its own, with its own steps.
10
+ #
11
+ # The accepted output reaches the sink once the step is stored: at once
12
+ # for a run that is not stored (no persistence key), else when `persist!`
13
+ # stores it, or when the run moves on without storing it. A store that
14
+ # fails tells the sink the step failed, with the store's error.
15
+ module StepStream
16
+ private
17
+
18
+ def with_step_stream(transition)
19
+ @step_stream = transition.stream &&
20
+ Streaming.open(transition.stream, execution_key: @persistence_key, transition: transition.name)
21
+ step = yield
22
+ if wait_pending?(step) then @step_stream&.suspend
23
+ elsif @step_stream then accept_streamed(@step_stream, step[:output])
24
+ end
25
+ step
26
+ rescue Exception => e # rubocop:disable Lint/RescueException -- the sink hears of every failure, then it goes on
27
+ @step_stream&.fail(e.class)
28
+ raise
29
+ ensure
30
+ @step_stream = nil
31
+ end
32
+
33
+ def accept_streamed(session, output)
34
+ return session.finish(output) unless @persistence_key
35
+
36
+ @streamed_output = [session, output]
37
+ end
38
+
39
+ def deliver_streamed_output
40
+ session, output = @streamed_output
41
+ @streamed_output = nil
42
+ session&.finish(output)
43
+ end
44
+
45
+ def discard_streamed_output(error_class)
46
+ session, = @streamed_output
47
+ @streamed_output = nil
48
+ session&.fail(error_class)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -20,6 +20,7 @@ module Smith
20
20
  current_invocation_context
21
21
  current_invocation_sequence
22
22
  current_invocation
23
+ current_idempotency_scope
23
24
  ].freeze
24
25
  THREAD_KEYS = %i[
25
26
  smith_attribution
@@ -28,6 +29,7 @@ module Smith
28
29
  smith_failed_agent_results
29
30
  smith_last_agent_result
30
31
  smith_parallel_agent_binding
32
+ smith_step_depth
31
33
  ].freeze
32
34
 
33
35
  option :tool_attributes, default: proc { TOOL_ATTRIBUTES }