smith-agents 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +698 -0
  3. data/README.md +621 -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 +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # A batch step's requests on a host's budget ledger (`budget_ledger`,
6
+ # Budget::HostLedger): each is held when captured, under its custom_id,
7
+ # and settled when its result comes (BatchWait).
8
+ module BatchHolds
9
+ private
10
+
11
+ # A host's budget ledger admits each request's hold (priced at the
12
+ # batch discount) under its custom_id, inside the attempt, so a durable
13
+ # `retry_on Smith::BudgetUnavailable` can wait for budget. A refusal,
14
+ # or the step ending before the requests are queued, releases the
15
+ # holds taken; queued, they are settled when the results come.
16
+ def hold_batch!(token, holds)
17
+ ledger = run_budget_ledger
18
+ return unless ledger
19
+
20
+ release_batch_holds
21
+ @batch_holds = []
22
+ seconds = BatchExecution::WAIT_SECONDS + Smith.config.budget_hold_ttl
23
+ holds.each do |branch_key, amount|
24
+ key = Batch.custom_id(token, branch_key)
25
+ Budget::HostLedger.admit!(ledger, amount, key, seconds:)
26
+ @batch_holds << key
27
+ end
28
+ rescue StandardError
29
+ release_batch_holds
30
+ raise
31
+ end
32
+
33
+ def release_batch_holds
34
+ keys = @batch_holds
35
+ @batch_holds = nil
36
+ ledger = settling_ledger if keys
37
+ keys&.each { Budget::HostLedger.settle(ledger, _1, nil) } if ledger
38
+ end
39
+
40
+ # Each result settles the hold its request took on a host's budget
41
+ # ledger (BatchExecution), taken by a call or not: a succeeded one
42
+ # reconciles what it cost, any other (or a wait that expired) releases.
43
+ def settle_batch_holds(transition, token, entries)
44
+ ledger = settling_ledger
45
+ return unless ledger
46
+
47
+ entries.each do |branch_key, entry|
48
+ spent = batch_spent(transition, branch_key, entry.result)
49
+ Budget::HostLedger.settle(ledger, Batch.custom_id(token, branch_key), spent)
50
+ end
51
+ end
52
+
53
+ # The ledger to settle holds on. One that cannot be built then is
54
+ # logged, as a settlement that fails is: the money is spent, and the
55
+ # holds expire.
56
+ def settling_ledger
57
+ run_budget_ledger
58
+ rescue BudgetLedgerError => e
59
+ Smith.config.logger&.error("Smith budget ledger settle error: #{e.message}")
60
+ nil
61
+ end
62
+
63
+ def batch_spent(transition, branch_key, result)
64
+ return unless result["type"] == "succeeded"
65
+
66
+ message = result.fetch("message")
67
+ usage = batch_reply_usage(message)
68
+ model = batch_model_id(batch_agent_class(transition, branch_key), message)
69
+ Budget::HostLedger.amount(usage, model:, provider: "anthropic", mode: :batch) if usage
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # Completing a batch step's wait (BatchExecution). `resume!` with the
8
+ # results (`{custom_id => result}`, every request's), or `expire_wait!`
9
+ # past the deadline (every result then `missing`), runs the step again at
10
+ # its attempt: each call takes its result instead of going over the wire,
11
+ # parsed by RubyLLM's own provider parser, if the request it renders now
12
+ # is the one sent, and the step goes on as after a synchronous call
13
+ # (usage with `mode: :batch`, output checks, a follow-up call over the
14
+ # wire, guardrails, `output_to:`, retries). Results are taken only by the
15
+ # batch's own attempt; a succeeded one no call took (a branch cancelled
16
+ # by a sibling's failure) is charged all the same (BatchCharges).
17
+ module BatchWait
18
+ PENDING_BATCH_FIELDS = %w[attempt key_generation requests transition].freeze
19
+ DIGEST = /\A\h{64}\z/
20
+ private_constant :PENDING_BATCH_FIELDS, :DIGEST
21
+
22
+ private
23
+
24
+ # [attempts already made, key generation] where a delivered batch's
25
+ # step starts again, or nil. A delivery runs only the stored batch
26
+ # wait's step, whose pending batch restore guarantees.
27
+ def pending_batch_position
28
+ [@pending_batch["attempt"] - 1, @pending_batch["key_generation"]] if @batch_delivery
29
+ end
30
+
31
+ # The step's branches in the wait's order (nil for an agent step).
32
+ def batch_branches(transition) = transition.fanout? ? transition.fanout_config.fetch(:branches).keys : [nil]
33
+
34
+ def batch_ids(transition, token)
35
+ batch_branches(transition).to_h { |branch_key| [branch_key, Batch.custom_id(token, branch_key)] }
36
+ end
37
+
38
+ def batch_results!(transition, payload)
39
+ ids = batch_ids(transition, @awaiting.fetch("token"))
40
+ results = Batch.results!(payload, ids.values)
41
+ batch_entries(ids) { |id| results.fetch(id) }
42
+ rescue StandardError
43
+ @wait_claimed = false
44
+ raise
45
+ end
46
+
47
+ def missing_batch_results(transition)
48
+ batch_entries(batch_ids(transition, @awaiting.fetch("token"))) { { "type" => Batch::MISSING }.freeze }
49
+ end
50
+
51
+ def batch_entries(ids)
52
+ digests = @pending_batch.fetch("requests")
53
+ ids.to_h { |branch_key, id| [branch_key, Batch::Delivery::Entry.new(yield(id), digests.fetch(id))] }
54
+ end
55
+
56
+ # The step runs again at the batch's attempt, each call taking its
57
+ # result. It may enter another wait (a batch sent again, or a durable
58
+ # retry's backoff); otherwise it counts. A completion that raises
59
+ # leaves the run waiting.
60
+ def complete_batch_wait(transition, entries, done)
61
+ record = @awaiting
62
+ @awaiting = nil
63
+ @batch_delivery = Batch::Delivery.new(entries, on_change: transition.on_request_change || :keep)
64
+ step = with_step_context(transition) do
65
+ Execution.instance_method(:execute_step_body).bind_call(self, transition)
66
+ ensure
67
+ settle_batch_holds(transition, record.fetch("token"), entries)
68
+ charge_untaken_batch_results(transition)
69
+ end
70
+ settle_batch_wait(step)
71
+ return done if wait_pending?(step)
72
+
73
+ step.key?(:error) && done == :resumed ? :failed : done
74
+ rescue Exception # rubocop:disable Lint/RescueException -- the wait outlives a completion that did not happen
75
+ @awaiting ||= record
76
+ raise
77
+ ensure
78
+ @batch_delivery = nil
79
+ @wait_claimed = false
80
+ end
81
+
82
+ def settle_batch_wait(step)
83
+ @pending_batch = nil unless batch_waiting?
84
+ return if wait_pending?(step)
85
+
86
+ @step_count += 1
87
+ @updated_at = Time.now.utc.iso8601(6)
88
+ record_step_snapshot(step)
89
+ end
90
+
91
+ def batch_waiting? = @awaiting&.fetch("kind") == "batch"
92
+
93
+ # The result a call of the batch's attempt takes, once (a follow-up
94
+ # call, or a later attempt's, goes over the wire), for its branch.
95
+ def take_batch_result = (@batch_delivery.take(Attribution.current&.branch_key) if delivering_batch?)
96
+
97
+ def batch_result_due? = (delivering_batch? && @batch_delivery.for?(Attribution.current&.branch_key)) || false
98
+
99
+ # A persisted pending batch names the stored batch wait's transition
100
+ # (which WaitRecord checked is still a batch step), an attempt its
101
+ # retry policy allows, a key generation only a declared
102
+ # `new_idempotency_key_on` moves, and the digest of each request the
103
+ # step's branches send; a stored batch wait needs one. Anything else
104
+ # raises SerializationError.
105
+ def restore_pending_batch(value)
106
+ return if value.nil? && !batch_waiting?
107
+
108
+ record = value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
109
+ raise Smith::SerializationError, "persisted workflow pending_batch is invalid" unless
110
+ well_formed_pending_batch?(record)
111
+
112
+ record.transform_values(&:freeze).freeze
113
+ end
114
+
115
+ def well_formed_pending_batch?(record)
116
+ transition = self.class.transition_named(@awaiting["transition"]) if batch_waiting?
117
+ transition && record.keys.sort == PENDING_BATCH_FIELDS && record["transition"] == transition.name.to_s &&
118
+ allowed_batch_position?(transition, *record.values_at("attempt", "key_generation")) &&
119
+ batch_digests?(transition, record["requests"])
120
+ end
121
+
122
+ def allowed_batch_position?(transition, attempt, generation)
123
+ config = transition.retry_config
124
+ attempt.is_a?(Integer) && attempt.between?(1, config&.fetch(:attempts) || 1) && generation.is_a?(Integer) &&
125
+ generation.between?(0, config&.key?(:new_idempotency_key_on) ? attempt - 1 : 0)
126
+ end
127
+
128
+ def batch_digests?(transition, requests)
129
+ requests.is_a?(Hash) && requests.keys.sort == batch_ids(transition, @awaiting["token"]).values.sort &&
130
+ requests.values.all? { _1.is_a?(String) && DIGEST.match?(_1) }
131
+ end
132
+ end
133
+ end
134
+ end
@@ -31,6 +31,7 @@ module Smith
31
31
  def reconcile_branch_budget(ledger, reservation, agent_result: nil)
32
32
  return unless ledger && reservation
33
33
 
34
+ forget_call_reservation(reservation)
34
35
  actuals = extract_actuals(agent_results_for_settlement(agent_result))
35
36
  settlement = reservation.amounts.to_h do |dimension, _amount|
36
37
  [dimension, actual_for_dimension(dimension, actuals[:tokens], actuals[:cost])]
@@ -44,7 +45,7 @@ module Smith
44
45
  results = Array(agent_results).compact
45
46
 
46
47
  {
47
- tokens: results.sum { |result| (result.input_tokens || 0) + (result.output_tokens || 0) },
48
+ tokens: results.sum(&:billable_tokens),
48
49
  cost: results.sum { |result| result.cost || 0 }
49
50
  }
50
51
  end
@@ -71,11 +72,16 @@ module Smith
71
72
  def release_branch_budget(ledger, reservation)
72
73
  return unless ledger && reservation
73
74
 
75
+ forget_call_reservation(reservation)
74
76
  return ledger.release_many!(reservation) if reservation.amounts.length > 1
75
77
 
76
78
  ledger.release!(reservation)
77
79
  end
78
80
 
81
+ def forget_call_reservation(reservation)
82
+ Thread.current[:smith_call_reservation] = nil if Thread.current[:smith_call_reservation].equal?(reservation)
83
+ end
84
+
79
85
  def settle_budget_on_failure(ledger, estimates, agent_result)
80
86
  return unless ledger && estimates
81
87
 
@@ -98,11 +104,11 @@ module Smith
98
104
  reserve_estimates!(ledger, estimates)
99
105
  end
100
106
 
107
+ # The reservation is also the current call's, for OutputIntegrity to
108
+ # stop making follow-up calls once it is spent.
101
109
  def reserve_estimates!(ledger, estimates)
102
- return ledger.reserve_many!(estimates) if estimates.length > 1
103
-
104
- dimension, amount = estimates.first
105
- ledger.reserve!(dimension, amount)
110
+ reservation = estimates.length > 1 ? ledger.reserve_many!(estimates) : ledger.reserve!(*estimates.first)
111
+ Thread.current[:smith_call_reservation] = reservation
106
112
  end
107
113
 
108
114
  def finalize_branch(transition, index, result, ledger, reserved)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # A tool loop that pauses for budget. A step whose `retry_on` names
6
+ # BudgetUnavailable (or an ancestor), on an attempt it may retry, holds
7
+ # every round of its call's tool loop on the host's budget ledger, not
8
+ # only the first. When the ledger refuses a later round, the step's
9
+ # tools have run: the refusal carries the messages the call added (its
10
+ # tool calls and their results, Budget::Continuation), the step's tool
11
+ # guard lets it through, and the retry (durable: a wait the host ends
12
+ # once there is budget, its continuation kept with the pending retry)
13
+ # continues the loop from them, running none of its tools again. A step
14
+ # that cannot retry for budget lets a later round go on, unheld, and
15
+ # reconciles what it spent. Only a serial agent step pauses so.
16
+ module BudgetPause
17
+ private
18
+
19
+ def start_budget_pause(transition)
20
+ @budget_pause_attempts = budget_pause_attempts(transition)
21
+ pending = @pending_retry
22
+ @budget_continuation = (pending["continuation"] if pending && pending["transition"] == transition.name.to_s)
23
+ end
24
+
25
+ def budget_pause_attempts(transition)
26
+ config = transition.retry_config
27
+ return unless config && serial_agent_step?(transition)
28
+
29
+ config.fetch(:attempts) if config.fetch(:error_classes).any? { _1 >= BudgetUnavailable }
30
+ end
31
+
32
+ def serial_agent_step?(transition)
33
+ !transition.agent_name.nil? && !transition.parallel? && !transition.fanout? && !transition.optimized? &&
34
+ !transition.orchestrated?
35
+ end
36
+
37
+ def budget_pausable? = !@budget_pause_attempts.nil? && @step_attempt.to_i < @budget_pause_attempts
38
+
39
+ def keep_budget_continuation(error)
40
+ @budget_continuation = error.continuation if error.is_a?(BudgetUnavailable) && error.continuation
41
+ end
42
+
43
+ # The paused loop's messages, after the attempt's prepared input.
44
+ def continue_paused_loop(chat)
45
+ return unless @budget_continuation
46
+
47
+ Budget::Continuation.load(@budget_continuation).each { chat.add_message(_1) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Transition
6
+ # Steps that start other workflows as runs of their own, through the
7
+ # host's run launcher (`Smith.config.run_launcher`):
8
+ #
9
+ # map :section_ids, workflow: Sections::Review, max_items: 500, timeout: 6 * 3600,
10
+ # input: ->(section_id, ctx) { { document_id: ctx[:document_id], section_id: } },
11
+ # into: :section_reviews, collect: :outputs, on_item_failure: :collect
12
+ # call Documents::Summarize, input: ->(ctx) { { document_id: ctx[:document_id] } },
13
+ # timeout: 3600, into: :summary
14
+ # spawn :watcher_ids, workflow: Watchers::Notify, max_items: 1_000,
15
+ # input: ->(watcher_id, ctx) { { watcher_id: } }
16
+ #
17
+ # `map` launches a run per item and waits, bounded by `timeout:`, for
18
+ # the host to resume the parent with every child's result; `call` is a
19
+ # map of one whose `into:` gets the child's output itself; `spawn`
20
+ # launches and continues. `items` is a context key holding an Array or
21
+ # a callable of the context, and more than `max_items` fails the step
22
+ # before anything launches. `input:` returns each child's whole
23
+ # context.
24
+ module ChildRunDeclarations
25
+ MAX_ITEMS = 10_000
26
+ COLLECT_MODES = %i[outputs states none].freeze
27
+ FAILURE_MODES = %i[fail collect].freeze
28
+ # Each kind's required options, optional ones with their defaults,
29
+ # and the settings it fixes.
30
+ OPTIONS = {
31
+ map: { required: %i[workflow max_items input timeout into],
32
+ optional: { collect: :outputs, on_item_failure: :fail }, fixed: {} },
33
+ call: { required: %i[input timeout into], optional: {},
34
+ fixed: { max_items: 1, collect: :outputs, on_item_failure: :fail } },
35
+ spawn: { required: %i[workflow max_items input], optional: {},
36
+ fixed: { collect: :none, on_item_failure: :collect } }
37
+ }.freeze
38
+ private_constant :OPTIONS
39
+
40
+ attr_reader :children_config
41
+
42
+ def map(items, **options) = declare_child_runs!(:map, items, options)
43
+
44
+ def call(workflow, **options) = declare_child_runs!(:call, nil, options, workflow:)
45
+
46
+ def spawn(items, **options) = declare_child_runs!(:spawn, items, options)
47
+
48
+ def child_runs? = !@children_config.nil?
49
+
50
+ def spawns? = @children_config&.fetch(:kind) == :spawn
51
+
52
+ private
53
+
54
+ # A map or call that fails when a child fails needs on_failure. No
55
+ # map, call or spawn retries: its launcher retries launching, and a
56
+ # map's results arrive with the host's resume, outside any attempt.
57
+ def validate_child_run_controls!
58
+ return unless child_runs?
59
+ raise WorkflowError, "a map, call or spawn does not take retry_on" if @retry_config
60
+ return unless @children_config.fetch(:on_item_failure) == :fail && @failure_transition.nil?
61
+
62
+ raise WorkflowError, "a map or call that fails when a child fails must declare on_failure"
63
+ end
64
+
65
+ def declare_child_runs!(kind, items, options, given = {})
66
+ validate_execution_primitive_conflict!(kind.to_s)
67
+ settings = child_run_options!(kind, options).merge(given)
68
+ validate_child_runs!(kind, items, settings)
69
+ @children_config = settings.slice(:workflow, :max_items, :input, :collect, :on_item_failure)
70
+ .merge(kind:, items:).freeze
71
+ wait_for_children!(settings[:into], settings[:timeout], kind) unless kind == :spawn
72
+ commit_execution_primitive!(kind.to_s)
73
+ end
74
+
75
+ def child_run_options!(kind, options)
76
+ spec = OPTIONS.fetch(kind)
77
+ refuse_options!(kind, "does not take", options.keys - spec[:required] - spec[:optional].keys)
78
+ refuse_options!(kind, "needs", spec[:required] - options.keys)
79
+ spec[:optional].merge(options).merge(spec[:fixed])
80
+ end
81
+
82
+ def refuse_options!(kind, problem, names)
83
+ raise WorkflowError, "#{kind} #{problem} #{names.map(&:inspect).join(", ")}" if names.any?
84
+ end
85
+
86
+ def validate_child_runs!(kind, items, settings)
87
+ workflow = settings[:workflow]
88
+ unless workflow.is_a?(Class) && workflow < Workflow
89
+ raise WorkflowError, "#{kind} workflow must be a Smith::Workflow subclass"
90
+ end
91
+ raise WorkflowError, "#{kind} input: must be a callable" unless settings[:input].respond_to?(:call)
92
+
93
+ validate_child_items!(kind, items, settings[:max_items])
94
+ validate_child_modes!(kind, settings[:collect], settings[:on_item_failure])
95
+ end
96
+
97
+ def validate_child_items!(kind, items, max_items)
98
+ unless kind == :call || items.is_a?(Symbol) || items.respond_to?(:call)
99
+ raise WorkflowError, "#{kind} items must be a Symbol context key or a callable of the context"
100
+ end
101
+ return if max_items.is_a?(Integer) && max_items.between?(1, MAX_ITEMS)
102
+
103
+ raise WorkflowError, "#{kind} max_items: must be an Integer from 1 to #{MAX_ITEMS}"
104
+ end
105
+
106
+ def validate_child_modes!(kind, collect, on_item_failure)
107
+ unless COLLECT_MODES.include?(collect)
108
+ raise WorkflowError, "#{kind} collect: must be one of #{COLLECT_MODES.inspect}"
109
+ end
110
+ return if FAILURE_MODES.include?(on_item_failure)
111
+
112
+ raise WorkflowError, "#{kind} on_item_failure: must be one of #{FAILURE_MODES.inspect}"
113
+ end
114
+
115
+ # A map or call waits like an await, for the event `children`.
116
+ def wait_for_children!(into, timeout, kind)
117
+ raise WorkflowError, "#{kind} into: must be a Symbol context key" unless into.is_a?(Symbol)
118
+
119
+ @await_config = { kind: :children, event: :children, into:,
120
+ timeout: wait_seconds!(timeout, "#{kind} timeout:"), block: nil }.freeze
121
+ end
122
+ end
123
+
124
+ include ChildRunDeclarations
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_result_snapshot"
4
+ require_relative "message_value_normalizer"
5
+
6
+ module Smith
7
+ class Workflow
8
+ # Launching the runs of `map`, `call` and `spawn` steps through the
9
+ # host's run launcher, and taking their results when the host resumes
10
+ # the parent. A child's ref is the index of its item; items come from
11
+ # the context (or a callable of it), so a replayed step launches the
12
+ # same children under the same keys, and the launcher must be
13
+ # idempotent on each child's `key`.
14
+ module ChildRunExecution
15
+ MAX_LAUNCH_BYTES = 4 * ExecutionResultSnapshot::MAX_BYTES
16
+ private_constant :MAX_LAUNCH_BYTES
17
+
18
+ private
19
+
20
+ # Launches the children; returns their results when the launcher runs
21
+ # them itself (`inline?`), else nil (the host resumes the parent
22
+ # later), whatever the launcher returned.
23
+ def launch_child_runs(transition, token)
24
+ launcher = Smith.config.run_launcher
25
+ kind = transition.children_config.fetch(:kind)
26
+ raise WorkflowError, "#{kind} needs Smith.config.run_launcher" unless launcher
27
+
28
+ children = child_runs(transition, token).freeze
29
+ unless @persistence_key || inline_launcher?(launcher)
30
+ raise WorkflowError, "#{kind} starts runs of their own, so it needs a persisted run " \
31
+ "(run_persisted! or advance_persisted!)"
32
+ end
33
+
34
+ @launched_children = children.length
35
+ return [] if children.empty?
36
+
37
+ results = launcher.launch(children:, parent_key: @persistence_key, await_token: token)
38
+ return unless inline_launcher?(launcher)
39
+ return results if results.is_a?(Array)
40
+
41
+ raise WorkflowError, "a run launcher whose inline? is true must return the children's results"
42
+ end
43
+
44
+ def inline_launcher?(launcher) = launcher.respond_to?(:inline?) && launcher.inline? == true
45
+
46
+ def child_runs(transition, token)
47
+ config = transition.children_config
48
+ context = snapshot_value(@context)
49
+ bytes = 0
50
+ child_items(transition).each_with_index.map do |item, index|
51
+ input = child_input(config, item, context)
52
+ bytes = check_launch_size!(bytes + JSON.generate(input).bytesize, config[:kind])
53
+ child_run(config, token, index, input)
54
+ end
55
+ end
56
+
57
+ def child_run(config, token, ref, input)
58
+ ChildRun.new(ref:, key: ChildRun.key_for(token, ref), workflow: config.fetch(:workflow), input:,
59
+ untrusted_keys: (untrusted_context_keys & input.keys).freeze,
60
+ artifact_namespaces: child_artifact_namespaces)
61
+ end
62
+
63
+ # The inputs of one step's children together are bounded, as one
64
+ # step's result is, four times over; checked as each is built.
65
+ def check_launch_size!(bytes, kind)
66
+ return bytes if bytes <= MAX_LAUNCH_BYTES
67
+
68
+ raise WorkflowError, "#{kind} inputs come to more than #{MAX_LAUNCH_BYTES} bytes"
69
+ end
70
+
71
+ def child_items(transition)
72
+ config = transition.children_config
73
+ return [nil] if config.fetch(:kind) == :call
74
+
75
+ source = config.fetch(:items)
76
+ check_items_persisted!(source, config[:kind])
77
+ items = item_source(source)
78
+ raise WorkflowError, "#{config[:kind]} items must be an Array" unless items.is_a?(Array)
79
+ return items if items.length <= config.fetch(:max_items)
80
+
81
+ raise WorkflowError, "#{config[:kind]} has #{items.length} items, more than its max_items of " \
82
+ "#{config[:max_items]}"
83
+ end
84
+
85
+ # A persisted run takes its children's results after a restore, which
86
+ # needs the items key the step launched from.
87
+ def check_items_persisted!(source, kind)
88
+ return unless @persistence_key && source.is_a?(Symbol)
89
+
90
+ keys = resolve_persist_keys
91
+ persisted = keys.nil? || (keys == :auto ? @persisted_keys.include?(source) : keys.include?(source))
92
+ raise WorkflowError, "#{kind} items key #{source.inspect} must be persisted" unless persisted
93
+ end
94
+
95
+ def item_source(source) = source.respond_to?(:call) ? source.call(snapshot_value(@context)) : @context[source]
96
+
97
+ # The child's whole context: a Hash, JSON-shaped and bounded, in the
98
+ # context's key shape.
99
+ def child_input(config, item, context)
100
+ input = config.fetch(:input)
101
+ raw = config.fetch(:kind) == :call ? input.call(context) : input.call(item, context)
102
+ raise WorkflowError, "#{config[:kind]} input: must return a Hash" unless raw.is_a?(Hash)
103
+
104
+ ContextKeys.normalize(MessageValueNormalizer.new(raw, label: "child run input").call).freeze
105
+ end
106
+
107
+ # Checks the results against the children the step launched (one per
108
+ # ref, each succeeded or failed), then, unless a failed child fails
109
+ # the step, writes them to `into:` as the step collects them.
110
+ def deliver_child_results(transition, results, expected = expected_children(transition))
111
+ config = transition.children_config
112
+ entries = checked_child_results(results, expected, config[:kind])
113
+ fail_on_failed_children!(transition, entries) if config[:on_item_failure] == :fail
114
+ write_wait_payload(transition, collected_child_results(config, entries))
115
+ end
116
+
117
+ def fail_on_failed_children!(transition, entries)
118
+ failed = entries.select { _1[:status] == "failed" }
119
+ return if failed.empty?
120
+
121
+ raise ChildRunsFailed.new(transition: transition.name, failed_refs: failed.map { _1[:ref] },
122
+ total: entries.length)
123
+ end
124
+
125
+ # How many children the step launched: as the stored wait records it,
126
+ # or, for a delivery that comes before the wait is stored, its items.
127
+ def expected_children(transition)
128
+ stored = @awaiting && @awaiting["children"]
129
+ stored.is_a?(Integer) ? stored : child_items(transition).length
130
+ end
131
+
132
+ # Child results are larger than a session message: a map's outputs
133
+ # together are bounded like a step's result.
134
+ def child_results_payload(payload)
135
+ bound = ExecutionResultSnapshot::MAX_BYTES
136
+ ContextKeys.normalize(MessageValueNormalizer.new(payload, label: "child run results", max_bytes: bound).call)
137
+ end
138
+
139
+ # A spawn launches and the step completes.
140
+ def execute_spawn_step(transition)
141
+ check_deadline!
142
+ namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
143
+ token = IdempotencyKey.__send__(:root_scope, namespace)
144
+ .within(:step, @step_count + 1, transition.name.to_s).within(:spawn).key
145
+ launch_child_runs(transition, token)
146
+ nil
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # Checking the results a host reports for a step's child runs, and
6
+ # shaping them as the step collects them.
7
+ module ChildRunResults
8
+ STATUSES = %w[succeeded failed].freeze
9
+ RESULT_KEYS = %i[ref status state output error].freeze
10
+ private_constant :STATUSES, :RESULT_KEYS
11
+
12
+ private
13
+
14
+ # Outputs a map does not collect are dropped before the results are
15
+ # bounded, so a large map can report states or counts.
16
+ def collectable_results(config, payload)
17
+ return payload if config[:kind] == :call || config[:collect] == :outputs || !payload.is_a?(Array)
18
+
19
+ payload.map { |entry| entry.is_a?(Hash) ? entry.reject { |key, _value| key.to_s == "output" } : entry }
20
+ end
21
+
22
+ def checked_child_results(results, expected, kind)
23
+ entries = results.is_a?(Array) ? results.map { child_result(_1, kind) } : nil
24
+ unless entries && entries.map { _1[:ref] }.sort == (0...expected).to_a
25
+ raise WorkflowError, "#{kind} results must hold one entry for each of its #{expected} children, by ref"
26
+ end
27
+
28
+ entries.sort_by { _1[:ref] }
29
+ end
30
+
31
+ def child_result(entry, kind)
32
+ valid = entry.is_a?(Hash) && (entry.keys - RESULT_KEYS).empty? && entry[:ref].is_a?(Integer) &&
33
+ STATUSES.include?(entry[:status]) && [entry[:state], entry[:error]].all? { short_text?(_1) }
34
+ return entry if valid
35
+
36
+ raise WorkflowError, "#{kind} results are { ref:, status: \"succeeded\" | \"failed\", state:, output:, " \
37
+ "error: }, with state and error short text"
38
+ end
39
+
40
+ def short_text?(value) = value.nil? || (value.is_a?(String) && value.bytesize <= 256)
41
+
42
+ def collected_child_results(config, entries)
43
+ return entries.first[:output] if config[:kind] == :call
44
+
45
+ case config[:collect]
46
+ when :outputs then entries.map { _1.slice(*RESULT_KEYS) }
47
+ when :states then entries.map { _1.slice(:ref, :status, :state, :error) }
48
+ else
49
+ failed = entries.count { _1[:status] == "failed" }
50
+ { succeeded: entries.length - failed, failed: }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end