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
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "await_step"
6
+ require_relative "message_value_normalizer"
7
+
8
+ module Smith
9
+ class Workflow
10
+ # The wait a run is in: an `await` for `event` (delivered under `token`
11
+ # by `resume!`), a `delay`, a map's or call's wait for its `children`, a
12
+ # batch step's wait for the results of its `children` requests
13
+ # (`:batch`, event `:batch_result`, with its `attempt`), or a durable
14
+ # retry's backoff (`:retry`, with the `attempt` it waits to make and the
15
+ # class name of the `error` that failed the last one), entered at
16
+ # `entered_at` and due to expire at `deadline_at`. Hosts index it to deliver events and to schedule the
17
+ # `expire_wait!` call; Smith never sleeps, polls or wakes a run itself.
18
+ Wait = Data.define(:kind, :event, :token, :transition, :into, :entered_at, :deadline_at, :children, :attempt,
19
+ :error)
20
+
21
+ # What a step that entered a wait returns instead of its output.
22
+ WAIT_PENDING = Object.new.freeze
23
+ private_constant :WAIT_PENDING
24
+
25
+ # Entering, resuming and expiring `await` and `delay` steps. Entering a
26
+ # wait is not a step: `step_count` stays, so the step's position and its
27
+ # token (a key under a position reserved for the wait) are the same when
28
+ # the wait is entered, when its block runs again after a crash, and when
29
+ # it completes. The completion (`resume!`, or `expire_wait!` past the
30
+ # deadline) is the step. One completion at a time claims the wait, so a
31
+ # delivery and an expiry racing on one instance cannot both win.
32
+ module WaitExecution
33
+ def waiting? = !@awaiting.nil?
34
+
35
+ # The run's wait, or nil.
36
+ def awaiting
37
+ record = @awaiting
38
+ WaitRecord.to_wait(record, awaited_transition(record), @pending_retry, @pending_batch) if record
39
+ end
40
+
41
+ # Delivers the event this run awaits: writes the normalized payload to
42
+ # the step's `into:` key and completes the step. A delivery can arrive
43
+ # before the wait is stored (the block's request answered at once);
44
+ # when the run stands at that await and the token is the one it will
45
+ # use, the step completes at once, without running the block.
46
+ # Returns `:resumed`, `:failed` when the completion took the failure
47
+ # path, or `:stale` (changing nothing) for any other event or token.
48
+ def resume!(event:, token:, payload:)
49
+ transition = claim_wait { resumable_transition(event.to_s, token.to_s) }
50
+ return :stale unless transition
51
+ return complete_batch_wait(transition, batch_results!(transition, payload), :resumed) if batch_waiting?
52
+
53
+ value = claimed_payload(transition, payload)
54
+ complete_claimed_wait(transition, :resumed) do
55
+ transition.child_runs? ? deliver_child_results(transition, value) : write_wait_payload(transition, value)
56
+ StepCompletion.instance_method(:complete_step).bind_call(self, transition, nil)
57
+ end
58
+ end
59
+
60
+ # Ends a wait whose deadline has passed at `now` (a Time, or an
61
+ # ISO 8601 String): a `delay` continues to its `to:`, an `await` takes
62
+ # `on_timeout`, or else its `on_failure` with Smith::WaitTimedOut, and
63
+ # a durable retry's wait ends with its step next (RetryWait). Returns
64
+ # `:expired`, `:not_due`, or `:stale` unless the run waits under this
65
+ # token.
66
+ def expire_wait!(token:, now: Time.now.utc)
67
+ now = wait_time(now)
68
+ record = @awaiting
69
+ not_due = false
70
+ transition = claim_wait do
71
+ next if record.nil? || !record.equal?(@awaiting) || record["token"] != token.to_s
72
+
73
+ not_due = now < wait_deadline(record)
74
+ awaited_transition(record) unless not_due
75
+ end
76
+ return not_due ? :not_due : :stale unless transition
77
+
78
+ expire_claimed_wait(transition, record)
79
+ end
80
+
81
+ private
82
+
83
+ # Runs the step's block, if any, then either finishes inline (the
84
+ # block called `resolve!`) or records the wait and returns
85
+ # WAIT_PENDING, which tells `advance!` the step has not completed. The
86
+ # payload key and the store's TTL are checked before the block sends
87
+ # anything.
88
+ def execute_await_step(transition)
89
+ check_deadline!
90
+ seconds = WaitRecord.seconds(transition.await_config.fetch(:timeout), snapshot_value(@context))
91
+ check_wait_admissible!(transition, seconds)
92
+ token = await_token_for(transition)
93
+ settled = settle_on_entry(transition, token)
94
+ return settled unless wait_pending?(settled)
95
+
96
+ enter_wait(transition, token, seconds)
97
+ WAIT_PENDING
98
+ end
99
+
100
+ # Runs the step's entry: an await's block, or a map's or call's
101
+ # launch. The step settles at once when the block resolves the wait or
102
+ # the launcher ran the children itself; otherwise WAIT_PENDING.
103
+ def settle_on_entry(transition, token)
104
+ if transition.child_runs?
105
+ results = launch_child_runs(transition, token)
106
+ return WAIT_PENDING unless results
107
+
108
+ payload = child_results_payload(collectable_results(transition.children_config, results))
109
+ return deliver_child_results(transition, payload, @launched_children)
110
+ end
111
+
112
+ step = run_await_block(transition, token) if transition.await_config[:block]
113
+ step&.resolved? ? write_wait_payload(transition, wait_payload(step.payload)) : WAIT_PENDING
114
+ end
115
+
116
+ def wait_pending?(value) = value.equal?(WAIT_PENDING)
117
+
118
+ # The delivered payload, normalized for the claimed step: a map's
119
+ # results checked against its children, anything else bounded like a
120
+ # session message. A payload that does not fit raises to the host and
121
+ # frees the wait.
122
+ def claimed_payload(transition, payload)
123
+ return wait_payload(payload) unless transition.child_runs?
124
+
125
+ entries = child_results_payload(collectable_results(transition.children_config, payload))
126
+ checked_child_results(entries, expected_children(transition), transition.children_config[:kind])
127
+ rescue StandardError
128
+ @wait_claimed = false
129
+ raise
130
+ end
131
+
132
+ # The wait's token: a key of the step's position under a segment only
133
+ # waits use, so no `idempotency_key` scope can produce it, and
134
+ # computable before the wait is stored. A wait is never nested (an
135
+ # in-process child cannot wait), so the position is the run's own.
136
+ def await_token_for(transition)
137
+ config = transition.await_config
138
+ wait_token_for(transition, [config.fetch(:kind), config[:event]].compact)
139
+ end
140
+
141
+ def wait_token_for(transition, scope)
142
+ IdempotencyKey.__send__(:root_scope, ArtifactIntegration.instance_method(:execution_namespace).bind_call(self))
143
+ .within(:step, @step_count + 1, transition.name.to_s).within(:wait).key(scope)
144
+ end
145
+
146
+ def check_wait_admissible!(transition, seconds)
147
+ writable_wait_key!(transition) if transition.await_config[:into]
148
+ check_wait_ttl!(seconds)
149
+ end
150
+
151
+ def check_wait_ttl!(seconds)
152
+ ttl = self.class.persistence_ttl || Smith.config.persistence_ttl
153
+ return unless @persistence_key && ttl && seconds >= ttl
154
+
155
+ raise WorkflowError, "a wait of #{seconds} seconds does not end within the persistence TTL of #{ttl} " \
156
+ "seconds; the stored run would expire before the wait ends"
157
+ end
158
+
159
+ def run_await_block(transition, token)
160
+ step = AwaitStep.new(context: snapshot_value(@context), await_token: token,
161
+ idempotency_scope: IdempotencyKey.__send__(:current_scope!),
162
+ attempt: @step_attempt || 1, transition_name: transition.name)
163
+ transition.await_config.fetch(:block).call(step)
164
+ step
165
+ end
166
+
167
+ def enter_wait(transition, token, seconds)
168
+ children = @launched_children if transition.child_runs?
169
+ @awaiting = WaitRecord.build(transition, token, seconds, Time.now.utc, children:)
170
+ record_wait_trace(transition, :waiting)
171
+ end
172
+
173
+ def record_wait_trace(transition, outcome)
174
+ Smith::Trace.record(
175
+ type: :transition, data: { transition: transition.name, from: transition.from, to: transition.to, outcome: }
176
+ )
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # The persisted form of a run's wait: a frozen Hash of frozen Strings, so
8
+ # it round-trips through JSON unchanged and no caller can change it.
9
+ # Built on entry, checked on restore against the workflow class and the
10
+ # run's position, and read as a Wait.
11
+ module WaitRecord
12
+ FIELDS = %w[kind event token transition into entered_at deadline_at children].freeze
13
+ TOKEN = /\A[0-9a-f]{64}\z/
14
+ ZONED = /(?:Z|[+-]\d\d:\d\d)\z/
15
+
16
+ module_function
17
+
18
+ # `children` is how many runs a map or call launched; nil otherwise.
19
+ def build(transition, token, seconds, now, children: nil)
20
+ config = transition.await_config
21
+ frozen(timing(transition, token, seconds, now).merge(
22
+ "kind" => config.fetch(:kind).to_s, "event" => config[:event]&.to_s, "into" => config[:into]&.to_s,
23
+ "children" => children
24
+ ))
25
+ end
26
+
27
+ # A batch step's wait for the results of its `count` requests.
28
+ def build_batch(transition, token, seconds, now, count)
29
+ frozen(timing(transition, token, seconds, now)
30
+ .merge("kind" => "batch", "event" => Batch::EVENT, "into" => nil, "children" => count))
31
+ end
32
+
33
+ # A durable retry's wait: no event or context key.
34
+ def build_retry(transition, token, seconds, now)
35
+ frozen(timing(transition, token, seconds, now)
36
+ .merge("kind" => "retry", "event" => nil, "into" => nil, "children" => nil))
37
+ end
38
+
39
+ def timing(transition, token, seconds, now)
40
+ { "token" => token, "transition" => transition.name.to_s, "entered_at" => now.utc.iso8601(6),
41
+ "deadline_at" => (now + seconds).utc.iso8601(6) }
42
+ end
43
+
44
+ # A wait's timeout in seconds: the declared number, or what its
45
+ # callable returns for the context, which must be positive.
46
+ def seconds(timeout, context)
47
+ seconds = timeout.respond_to?(:call) ? timeout.call(context) : timeout
48
+ return seconds if (seconds.is_a?(Integer) || (seconds.is_a?(Float) && seconds.finite?)) && seconds.positive?
49
+
50
+ raise WorkflowError, "a wait's timeout must be a positive number of seconds"
51
+ end
52
+
53
+ # The event and context key read as the transition declares them; a
54
+ # retry's attempt and error read from the run's pending retry, a
55
+ # batch's attempt from its pending batch.
56
+ def to_wait(record, transition, pending_retry, pending_batch = nil)
57
+ config = wait_config(record, transition)
58
+ pending = { "retry" => pending_retry, "batch" => pending_batch }[record["kind"]]
59
+ Wait.new(
60
+ kind: config.fetch(:kind), event: config[:event], token: record.fetch("token"), transition: transition.name,
61
+ into: config[:into], entered_at: Time.iso8601(record.fetch("entered_at")),
62
+ deadline_at: Time.iso8601(record.fetch("deadline_at")), children: record["children"],
63
+ attempt: pending&.fetch("attempt"), error: pending&.fetch("error", nil)
64
+ )
65
+ end
66
+
67
+ def wait_config(record, transition)
68
+ { "retry" => { kind: :retry }, "batch" => { kind: :batch, event: Batch::EVENT.to_sym } }
69
+ .fetch(record["kind"]) { transition.await_config }
70
+ end
71
+
72
+ # A persisted wait, checked: a malformed one raises SerializationError,
73
+ # and one the class no longer declares exactly (the transition, its
74
+ # kind, event and context key) or that does not start from the run's
75
+ # state is refused like any unknown position.
76
+ def restore(value, workflow_class:, persistence_key:, state:, next_transition:)
77
+ return if value.nil?
78
+
79
+ record = stringified(value)
80
+ unless well_formed?(record) && next_transition.nil?
81
+ raise Smith::SerializationError, "persisted workflow awaiting is malformed"
82
+ end
83
+
84
+ transition = workflow_class.transition_named(record["transition"])
85
+ return frozen(record.slice(*FIELDS)) if declared_wait?(transition, record, state)
86
+
87
+ raise Smith::UnknownTransitionOnRestore.new(workflow: workflow_class.name, persistence_key:,
88
+ transition: record["transition"].to_sym)
89
+ end
90
+
91
+ def stringified(value)
92
+ raise Smith::SerializationError, "persisted workflow awaiting is malformed" unless value.is_a?(Hash)
93
+
94
+ value.to_h { |key, field| [key.to_s, field.is_a?(Symbol) ? field.to_s : field] }
95
+ end
96
+
97
+ def well_formed?(record)
98
+ %w[await delay children retry batch].include?(record["kind"]) && TOKEN.match?(record["token"].to_s) &&
99
+ nonempty_string?(record["transition"]) && await_fields?(record) && times?(record) && children_count?(record)
100
+ end
101
+
102
+ # A map's or call's wait holds how many children it launched, a
103
+ # batch's how many requests it sent.
104
+ def children_count?(record)
105
+ count = record["children"]
106
+ case record["kind"]
107
+ when "batch" then count.is_a?(Integer) && count.between?(1, 10_000)
108
+ when "children" then count.is_a?(Integer) && count.between?(0, 10_000)
109
+ else count.nil?
110
+ end
111
+ end
112
+
113
+ # An await or a map's wait for its children names its event and
114
+ # context key; a batch its event alone; a delay or a retry neither.
115
+ def await_fields?(record)
116
+ event, into = record.values_at("event", "into")
117
+ case record["kind"]
118
+ when "batch" then event == Batch::EVENT && into.nil?
119
+ when "delay", "retry" then event.nil? && into.nil?
120
+ else nonempty_string?(event) && nonempty_string?(into)
121
+ end
122
+ end
123
+
124
+ # Both times carry their zone, and the deadline is not before entry.
125
+ def times?(record)
126
+ entered, deadline = record.values_at("entered_at", "deadline_at")
127
+ [entered, deadline].all? { |value| value.is_a?(String) && ZONED.match?(value) } &&
128
+ Time.iso8601(entered) <= Time.iso8601(deadline)
129
+ rescue ArgumentError
130
+ false
131
+ end
132
+
133
+ def declared_wait?(transition, record, state)
134
+ return false unless transition && [nil, state.to_s].include?(transition.from&.to_s)
135
+
136
+ declared_kind?(transition, record)
137
+ end
138
+
139
+ def declared_kind?(transition, record)
140
+ case record["kind"]
141
+ when "retry" then transition.durable_retry?
142
+ when "batch" then transition.batches? && record["children"] == batch_count(transition)
143
+ else transition.awaits? && declared_fields(transition.await_config) == record.values_at("kind", "event", "into")
144
+ end
145
+ end
146
+
147
+ def declared_fields(config) = [config.fetch(:kind), config[:event], config[:into]].map { _1&.to_s }
148
+
149
+ def batch_count(transition) = transition.fanout? ? transition.fanout_config.fetch(:branches).length : 1
150
+
151
+ def nonempty_string?(value) = value.is_a?(String) && !value.empty?
152
+
153
+ def frozen(record) = record.transform_values { |value| value&.dup&.freeze }.freeze
154
+ end
155
+ private_constant :WaitRecord
156
+ end
157
+ end
@@ -28,6 +28,19 @@ module Smith
28
28
  include ArtifactIntegration
29
29
  include DataVolumePolicy
30
30
  include DeadlineEnforcement
31
+ include ActiveClock
32
+ include StepIO
33
+ include WaitCompletion
34
+ include WaitExecution
35
+ include RetryWait
36
+ include BatchExecution
37
+ include BatchWait
38
+ include BatchCharges
39
+ include BatchHolds
40
+ include BudgetPause
41
+ include RememberedCalls
42
+ include ChildRunResults
43
+ include ChildRunExecution
31
44
  include Execution
32
45
 
33
46
  DEFAULT_MAX_TRANSITIONS = 100
@@ -40,8 +53,21 @@ module Smith
40
53
 
41
54
  def initialize(context: {}, ledger: nil, created_at: nil)
42
55
  @state = self.class.initial_state
43
- @context = context
56
+ # Symbol keys at every depth, the shape a restore produces, so a
57
+ # context reads the same live and after a checkpoint round trip.
58
+ @context = ContextKeys.normalize_context(context)
44
59
  @step_count = 0
60
+ # The wait the run is in (a persisted Hash of Strings), and the seconds
61
+ # its steps have spent running, which `wall_clock` budgets bound.
62
+ @awaiting = nil
63
+ @wait_mutex = Mutex.new
64
+ @wait_claimed = false
65
+ @active_seconds = 0.0
66
+ @active_step_started_at = nil
67
+ # The run's own identity, persisted with it: artifacts are scoped to
68
+ # it and idempotency keys derive from it, so it exists from the start
69
+ # and is in the checkpoint written before the first step.
70
+ @execution_namespace = SecureRandom.uuid
45
71
  @next_transition_name = nil
46
72
  @ledger = ledger || build_ledger
47
73
  @created_at = created_at || Time.now.utc.iso8601(6)
@@ -58,6 +84,9 @@ module Smith
58
84
  @usage_entries = []
59
85
  @usage_mutex = Mutex.new
60
86
  @last_output = nil
87
+ # The last output the run added to its transcript, which the next
88
+ # agent step reads as a handoff; an `output_to:` step's is not one.
89
+ @last_transcript_output = nil
61
90
  # Durable attribution of the most recent serial `execute :agent` step:
62
91
  # { model:, provider: } of the model that actually served it (post
63
92
  # fallback resolution), exposed to the following deterministic step as
@@ -98,8 +127,17 @@ module Smith
98
127
  # also: declaration so explicit input keys round-trip.
99
128
  @persisted_keys = ::Set.new(initial_persist_auto_seed)
100
129
  @persisted_keys_mutex = Mutex.new
130
+ # A nested workflow's parent's untrusted keys, in force before its
131
+ # seed builder runs (NestedExecution).
132
+ @inherited_untrusted_keys = StepIO.take_inherited_untrusted_keys
133
+ # A child run's parent's artifact namespaces, which it may read.
134
+ @inherited_artifact_namespaces = ArtifactIntegration.take_inherited_artifact_namespaces
101
135
  initialize_tool_result_state
102
136
  seed_initial_session_messages
137
+ initialize_budget_ledger(Budget::HostLedger.take_inherited)
138
+ # The attempts of the runs a nested workflow runs inside (ResponseMemo).
139
+ @memo_attempts = ResponseMemo.take_inherited
140
+ @budget_ledger_scope = capture_budget_ledger_scope
103
141
  end
104
142
 
105
143
  def persisted_keys
@@ -107,9 +145,12 @@ module Smith
107
145
  end
108
146
 
109
147
  def advance!
148
+ StepStream.instance_method(:deliver_streamed_output).bind_call(self)
149
+ return if waiting?
150
+
151
+ @step_work_started = false
110
152
  advance_claim = SplitStepPersistence.instance_method(:claim_split_step_advance!).bind_call(self)
111
153
  ensure_transition_budget!
112
- @step_work_started = false
113
154
 
114
155
  transition = SplitStepPersistence.instance_method(:resolve_split_step_advance_transition).bind_call(self)
115
156
  transition = resolve_transition if transition.equal?(SplitStepPersistence::NO_SPLIT_TRANSITION)
@@ -121,10 +162,7 @@ module Smith
121
162
  else
122
163
  execute_step(transition)
123
164
  end
124
- @step_count += 1
125
- @updated_at = Time.now.utc.iso8601(6)
126
- record_step_snapshot(step_result)
127
- step_result
165
+ count_step(step_result)
128
166
  rescue UnresolvedTransitionError => e
129
167
  step_result = GuardrailIntegration
130
168
  .instance_method(:handle_unresolved_transition_failure)
@@ -132,25 +170,31 @@ module Smith
132
170
  record_step_snapshot(step_result)
133
171
  step_result
134
172
  ensure
173
+ clear_settled_pending_retry
135
174
  SplitStepPersistence.instance_method(:release_split_step_advance!).bind_call(self, advance_claim) if advance_claim
136
175
  end
137
176
 
138
177
  def run!
139
178
  SplitStepPersistence.instance_method(:ensure_split_step_execution_allowed!).bind_call(self)
140
179
  steps = []
141
- until terminal?
180
+ until nothing_to_advance?
142
181
  step = advance!
143
182
  steps << step if step
144
183
  end
184
+ StepStream.instance_method(:deliver_streamed_output).bind_call(self)
145
185
  build_run_result(steps)
146
186
  end
147
187
 
188
+ # The awaited transition while the run waits.
148
189
  def pending_transition_name
190
+ return awaiting.transition if waiting?
191
+
149
192
  @next_transition_name || self.class.first_transition_from(@state)&.name
150
193
  end
151
194
 
195
+ # A waiting run is never terminal, whatever its state.
152
196
  def terminal?
153
- !self.class.transition_from?(@state) && @next_transition_name.nil?
197
+ !waiting? && !self.class.transition_from?(@state) && @next_transition_name.nil?
154
198
  end
155
199
 
156
200
  def done?
@@ -181,6 +225,18 @@ module Smith
181
225
 
182
226
  private
183
227
 
228
+ # Entering a wait is not a step: nothing counts, and advance! returns nil.
229
+ def count_step(step_result)
230
+ @updated_at = Time.now.utc.iso8601(6)
231
+ return if wait_pending?(step_result)
232
+
233
+ @step_count += 1
234
+ record_step_snapshot(step_result)
235
+ step_result
236
+ end
237
+
238
+ def nothing_to_advance? = terminal? || waiting?
239
+
184
240
  def initial_persist_auto_seed
185
241
  manager = self.class.context_manager
186
242
  return [] unless manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
@@ -218,6 +274,38 @@ module Smith
218
274
  end
219
275
  end
220
276
 
277
+ # A run's budget ledger is built at its first use; a nested workflow
278
+ # without one of its own charges `inherited`, the enclosing run's.
279
+ def initialize_budget_ledger(inherited = nil)
280
+ @inherited_budget_ledger = inherited
281
+ @budget_ledger = nil
282
+ @budget_ledger_built = false
283
+ @budget_ledger_mutex = Mutex.new
284
+ end
285
+
286
+ # The scope the run's ledger is built from, from its trusted context, so
287
+ # no untrusted key chooses whose budget is charged.
288
+ def capture_budget_ledger_scope
289
+ return unless self.class.budget_ledger
290
+
291
+ Budget::HostLedger.scope!(self.class.budget_ledger_scope_keys, trusted_context_view, untrusted_context_keys)
292
+ end
293
+
294
+ # The host's budget ledger this run's requests are held on: its own,
295
+ # built at first use from the scope it was created with, else the
296
+ # enclosing run's (a nested workflow's); nil for none.
297
+ def run_budget_ledger
298
+ return @inherited_budget_ledger&.call unless self.class.budget_ledger
299
+
300
+ @budget_ledger_mutex.synchronize do
301
+ unless @budget_ledger_built
302
+ @budget_ledger = Budget::HostLedger.build(self.class.budget_ledger, @budget_ledger_scope)
303
+ @budget_ledger_built = true
304
+ end
305
+ @budget_ledger
306
+ end
307
+ end
308
+
221
309
  def build_ledger
222
310
  config = self.class.budget
223
311
  return nil unless config
@@ -313,14 +401,14 @@ module Smith
313
401
  end
314
402
 
315
403
  # Fresh evaluation of the seed builder against the workflow's
316
- # current @context. Returns nil when no builder is defined so
317
- # callers (initialize, validate_seed_digest!) can distinguish
318
- # "no builder" from "builder returned empty".
404
+ # current @context, without its untrusted keys. Returns nil when no
405
+ # builder is defined so callers (initialize, validate_seed_digest!)
406
+ # can distinguish "no builder" from "builder returned empty".
319
407
  def compute_seed_messages
320
408
  builder = self.class.seed_messages
321
409
  return nil unless builder
322
410
 
323
- seeded = builder.arity == 1 ? builder.call(@context) : builder.call
411
+ seeded = builder.arity == 1 ? builder.call(trusted_context_view) : builder.call
324
412
  normalize_seed_messages(seeded)
325
413
  end
326
414
 
@@ -335,11 +423,12 @@ module Smith
335
423
  end
336
424
 
337
425
  def normalize_seed_messages(seeded)
338
- return [] if seeded.nil?
339
- return [seeded] if seeded.is_a?(Hash)
340
- return seeded.to_a if seeded.respond_to?(:to_a)
341
-
342
- raise WorkflowError, "seed_messages must return a message Hash or an Array of message Hashes"
426
+ messages = if seeded.nil? then []
427
+ elsif seeded.is_a?(Hash) then [seeded]
428
+ elsif seeded.respond_to?(:to_a) then seeded.to_a
429
+ else raise WorkflowError, "seed_messages must return a message Hash or an Array of message Hashes"
430
+ end
431
+ messages.map { |message| Message.parts?(message) ? Message.check_seed!(message) : message }
343
432
  end
344
433
 
345
434
  def snapshot_context
@@ -417,6 +506,13 @@ Smith::Workflow::SplitStepPersistence::SubclassBoundary.protect_execution_path!(
417
506
  Smith::Workflow::ArtifactIntegration,
418
507
  Smith::Workflow::DataVolumePolicy,
419
508
  Smith::Workflow::DeadlineEnforcement,
509
+ Smith::Workflow::ActiveClock,
510
+ Smith::Workflow::StepIO,
511
+ Smith::Workflow::WaitCompletion,
512
+ Smith::Workflow::ChildRunResults,
513
+ Smith::Workflow::ChildRunExecution,
514
+ Smith::Workflow::WaitExecution,
515
+ Smith::Workflow::RetryWait,
420
516
  Smith::Workflow::SplitStepPersistence::DefinitionBoundary,
421
517
  Smith::Workflow::SplitStepPersistence::ExecutionVerification,
422
518
  Smith::Workflow::SplitStepPersistence::ExecutionAuthorization,