smith-agents 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. metadata +88 -1
data/lib/smith/tool.rb CHANGED
@@ -148,6 +148,20 @@ module Smith
148
148
 
149
149
  private
150
150
 
151
+ # A key for a request this tool sends outside the system: unique to the
152
+ # run, the step and branch the tool runs in, this tool's `name` and
153
+ # `scope`, and the same when a replay sends the same request. A tool can
154
+ # run several times in one step, and a replayed model may word or order
155
+ # its calls differently, so `scope` is required and should be the
156
+ # request's stable identity (`idempotency_key(customer_id:, invoice:)`),
157
+ # not free text. Renaming the tool changes its keys. Raises
158
+ # Smith::WorkflowError outside a workflow step.
159
+ def idempotency_key(scope)
160
+ raise WorkflowError, "an idempotency key needs a tool with a name" if name.to_s.empty?
161
+
162
+ IdempotencyKey.__send__(:current_scope!).within(:tool, name).key(scope)
163
+ end
164
+
151
165
  def run_before_execute_hook!(kwargs)
152
166
  hook = self.class.before_execute
153
167
  return unless hook
@@ -37,7 +37,7 @@ module Smith
37
37
  when true
38
38
  data
39
39
  when :redacted
40
- data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
40
+ Smith::Trace.redact_sensitive_keys(data, CONTENT_KEYS)
41
41
  else
42
42
  data.except(*CONTENT_KEYS)
43
43
  end
@@ -79,7 +79,7 @@ module Smith
79
79
  when true
80
80
  data
81
81
  when :redacted
82
- data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
82
+ Smith::Trace.redact_sensitive_keys(data, CONTENT_KEYS)
83
83
  else
84
84
  data.except(*CONTENT_KEYS)
85
85
  end
@@ -91,7 +91,7 @@ module Smith
91
91
  when true
92
92
  data
93
93
  when :redacted
94
- data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
94
+ Smith::Trace.redact_sensitive_keys(data, CONTENT_KEYS)
95
95
  else
96
96
  data.except(*CONTENT_KEYS)
97
97
  end
data/lib/smith/trace.rb CHANGED
@@ -72,9 +72,12 @@ module Smith
72
72
  end
73
73
  end
74
74
 
75
- def self.redact_sensitive_keys(data)
75
+ # Masks content only: every String and Symbol inside the values of
76
+ # `content_keys`, at any depth. Identifiers (model, execution key, tool
77
+ # name, error class) stay readable.
78
+ def self.redact_sensitive_keys(data, content_keys = SENSITIVITY_CONTENT_KEYS)
76
79
  data.each_with_object({}) do |(key, value), filtered|
77
- filtered[key] = if SENSITIVITY_CONTENT_KEYS.include?(key)
80
+ filtered[key] = if content_keys.include?(key)
78
81
  redact_value(value)
79
82
  else
80
83
  value
@@ -82,12 +85,16 @@ module Smith
82
85
  end
83
86
  end
84
87
 
88
+ # Every String and Symbol leaf at any depth of Hashes and Arrays;
89
+ # numbers, booleans and nil are kept.
85
90
  def self.redact_value(value)
86
91
  case value
87
- when String
92
+ when String, Symbol
88
93
  "[REDACTED]"
89
94
  when Hash
90
- value.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
95
+ value.transform_values { |nested| redact_value(nested) }
96
+ when Array
97
+ value.map { |nested| redact_value(nested) }
91
98
  else
92
99
  value
93
100
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module Smith
7
+ # Delimits text from outside the system (a fetched document, an inbound
8
+ # message) before it goes into a prompt, so the model can tell it apart
9
+ # from instructions:
10
+ #
11
+ # Smith::Untrusted.wrap(document_text, source: "document")
12
+ # # => <untrusted source="document" boundary="9f2c...">
13
+ # # ...text...
14
+ # # </untrusted boundary="9f2c...">
15
+ #
16
+ # The text cannot guess the boundary. Inside a workflow step it is derived
17
+ # from the run's namespace (a random UUID), the step's position, the
18
+ # source and the text, so a step that prepares its input again after a
19
+ # crash gets the same message; anywhere else it is random.
20
+ #
21
+ # Wrapping changes the text so it cannot close the block or pose as
22
+ # another turn: anything shaped like an untrusted tag (spaces, invisible
23
+ # characters or full-width forms included) becomes `untrusted-text`, and
24
+ # a role label (`system:`, `assistant:`, `user:`, `tool:`, `developer:`,
25
+ # `human:`, `ai:`) that opens any line gains "(quoted text)", also behind
26
+ # spaces, invisible characters, brackets, quotes, `#`, `>`, `*`, `_` or
27
+ # `-`. Ordinary text with such a line (`user: alice` in YAML) changes the
28
+ # same way. Markdown headings, XML-like tags such as `<system>` and
29
+ # look-alike letters are left as they are. Wrapping does not make text
30
+ # safe to act on; it only marks where it starts and ends.
31
+ module Untrusted
32
+ GAP = "\\p{Cf}*"
33
+ ROLE_WORDS = %w[system assistant user tool developer human ai].map { |role| role.chars.join(GAP) }.join("|")
34
+ ROLE_LABEL = /
35
+ (\A|[\n\r\v\f\u0085\u2028\u2029])
36
+ ([\p{Space}\p{Cf}#>\[(*_"'`-]*)
37
+ (#{ROLE_WORDS})
38
+ ([\p{Space}\p{Cf}*_\])"'`]*[:\uFF1A\uFE55])
39
+ /ix
40
+ TAG = %r{[<\uFF1C][\p{Space}\p{Cf}\p{Cc}]*([/\uFF0F]?)[\p{Space}\p{Cf}\p{Cc}]*#{"untrusted".chars.join(GAP)}}i
41
+ SOURCE = %r{\A[\w .:/-]{1,64}\z}
42
+
43
+ module_function
44
+
45
+ def wrap(text, source:)
46
+ raise ArgumentError, "untrusted text must be a String" unless text.is_a?(String)
47
+
48
+ label = source.to_s
49
+ unless label.is_a?(String) && SOURCE.match?(label)
50
+ raise ArgumentError, "untrusted source must be 1 to 64 letters, digits, spaces or . : / - _"
51
+ end
52
+
53
+ body = escape(text)
54
+ boundary = boundary_for(label, body)
55
+ %(<untrusted source="#{label}" boundary="#{boundary}">\n#{body}\n</untrusted boundary="#{boundary}">)
56
+ end
57
+
58
+ def escape(text)
59
+ utf8 = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
60
+ utf8.gsub(TAG) { "<#{"/" unless Regexp.last_match(1).empty?}untrusted-text" }
61
+ .gsub(ROLE_LABEL) { Regexp.last_match.captures.insert(3, " (quoted text)").join }
62
+ end
63
+
64
+ # A position no idempotency key uses, so no key a step hands to another
65
+ # service gives the boundary away.
66
+ def boundary_for(label, body)
67
+ scope = IdempotencyKey.__send__(:current_scope)
68
+ return SecureRandom.hex(12) unless scope
69
+
70
+ scope.within(:untrusted).key([label, Digest::SHA256.hexdigest(body)])[0, 24]
71
+ end
72
+ private_class_method :boundary_for
73
+ end
74
+ end
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.10.0"
5
- EXECUTION_SEMANTICS_VERSION = "5"
4
+ VERSION = "0.12.0"
5
+ EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # `wall_clock` bounds the time the run's steps spend running, not the
6
+ # time since it was created: a run that waits for days, or whose host
7
+ # advances it step by step from a queue, spends only its active time.
8
+ # Each step's deadline is its start plus what is left of the budget, and
9
+ # the step's monotonic duration is added to the persisted
10
+ # `active_seconds` when it ends.
11
+ module ActiveClock
12
+ private
13
+
14
+ def start_active_clock!
15
+ @active_clock_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ @active_clock_started_at = Time.now.utc
17
+ remove_instance_variable(:@wall_clock_deadline) if defined?(@wall_clock_deadline)
18
+ end
19
+
20
+ def stop_active_clock!
21
+ started = @active_clock_started
22
+ return unless started
23
+
24
+ @active_clock_started = nil
25
+ @active_seconds = (@active_seconds || 0.0) + (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
26
+ end
27
+
28
+ def active_deadline(limit)
29
+ (@active_clock_started_at || Time.now.utc) + (limit - (@active_seconds || 0.0))
30
+ end
31
+
32
+ # The checkpoint written before a step records when it began; the one
33
+ # written after clears it. A run restored with the mark still set had a
34
+ # step that raised or whose process died before finishing, so the time
35
+ # since that step began is charged, at most what is left of the budget,
36
+ # which keeps a run that keeps failing bounded across retries.
37
+ def mark_active_step_start!(running)
38
+ @active_step_started_at = running ? Time.now.utc.iso8601(6) : nil
39
+ end
40
+
41
+ def charge_unfinished_step!(started_at)
42
+ @active_step_started_at = nil
43
+ return if started_at.nil?
44
+
45
+ elapsed = [Time.now.utc - Time.iso8601(started_at.to_s), 0.0].max
46
+ limit = self.class.budget&.dig(:wall_clock)
47
+ elapsed = elapsed.clamp(0.0, [limit - @active_seconds, 0.0].max) if limit
48
+ @active_seconds += elapsed
49
+ rescue ArgumentError
50
+ raise Smith::SerializationError, "persisted workflow active_step_started_at must be an ISO 8601 time"
51
+ end
52
+
53
+ def restored_active_seconds(value)
54
+ return 0.0 if value.nil?
55
+ return value.to_f if (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && !value.negative?
56
+
57
+ raise Smith::SerializationError, "persisted workflow active_seconds must be a non-negative number"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -3,10 +3,24 @@
3
3
  module Smith
4
4
  class Workflow
5
5
  # rubocop:disable Style/RedundantStructKeywordInit
6
+ # `input_tokens` counts uncached input; `cache_read_tokens` and
7
+ # `cache_write_tokens` are the prompt-cache input beside it, and
8
+ # `total_tokens` adds all three to the output. `mode` is how the call was
9
+ # made, set by streaming and batch steps (nil for an ordinary call).
6
10
  AgentResult = Struct.new(
7
11
  :content, :input_tokens, :output_tokens, :cost, :model_used, :provider_used,
12
+ :cache_read_tokens, :cache_write_tokens, :thinking_tokens, :stop_reason, :provider_message_id, :mode,
8
13
  keyword_init: true
9
14
  ) do
15
+ # The detail members of an AgentResult taken from a ProviderUsage.
16
+ def self.usage_details(usage)
17
+ return {} unless usage
18
+
19
+ [*self::USAGE_DETAIL_MEMBERS, :mode].to_h do |member|
20
+ [member, usage.respond_to?(member) ? usage.public_send(member) : nil]
21
+ end
22
+ end
23
+
10
24
  def self.from_response(response, content, model_used: nil, provider_used: nil)
11
25
  new(
12
26
  content: content,
@@ -18,10 +32,33 @@ module Smith
18
32
  )
19
33
  end
20
34
 
35
+ # Not members, so they stay out of to_h, to_a and ==. metered_tokens
36
+ # is set on a partially metered invocation (some response reported no
37
+ # usage) to the tokens its metered responses recorded; cache_unpriced
38
+ # says cache tokens were priced at 0 for lack of a catalog rate.
39
+ attr_accessor :metered_tokens, :cache_unpriced
40
+
21
41
  def usage_known?
22
42
  !input_tokens.nil? && !output_tokens.nil?
23
43
  end
44
+
45
+ # The tokens a budget settles: the total when usage is known, the
46
+ # metered part of a partially metered invocation, otherwise every
47
+ # count that is known.
48
+ def billable_tokens
49
+ total_tokens || metered_tokens ||
50
+ [input_tokens, output_tokens, cache_read_tokens, cache_write_tokens].sum { |count| count || 0 }
51
+ end
52
+
53
+ def total_tokens
54
+ return unless usage_known?
55
+
56
+ input_tokens + output_tokens + (cache_read_tokens || 0) + (cache_write_tokens || 0)
57
+ end
24
58
  end
59
+ AgentResult::USAGE_DETAIL_MEMBERS = %i[
60
+ cache_read_tokens cache_write_tokens thinking_tokens stop_reason provider_message_id
61
+ ].freeze
25
62
  # rubocop:enable Style/RedundantStructKeywordInit
26
63
  end
27
64
  end
@@ -5,18 +5,67 @@ require "securerandom"
5
5
  module Smith
6
6
  class Workflow
7
7
  module ArtifactIntegration
8
+ # This run's view of the artifact store, for the host after or between
9
+ # its steps: read the refs the run stored (or its ancestors'), list
10
+ # its expired refs and delete them. Inside a step, Smith.artifacts is
11
+ # the same view.
12
+ def artifacts
13
+ Artifacts::ScopedStore.new(namespace: execution_namespace, readable: @inherited_artifact_namespaces || [])
14
+ end
15
+
16
+ # How many ancestors' artifacts a child run may read: the nearest
17
+ # ones, so a deep recursion reads its near ancestors and no others.
18
+ MAX_INHERITED_NAMESPACES = 16
19
+ INHERITED_NAMESPACES = :smith_inherited_artifact_namespaces
20
+ private_constant :INHERITED_NAMESPACES
21
+
22
+ # Builds a child run with the namespaces whose artifacts it may read
23
+ # in force from construction; they persist with it.
24
+ def self.with_inherited_artifact_namespaces(namespaces)
25
+ outer = Thread.current[INHERITED_NAMESPACES]
26
+ Thread.current[INHERITED_NAMESPACES] = artifact_namespaces!(namespaces)
27
+ yield
28
+ ensure
29
+ Thread.current[INHERITED_NAMESPACES] = outer
30
+ end
31
+
32
+ # At most MAX_INHERITED_NAMESPACES Strings of 1 to 256 characters.
33
+ def self.artifact_namespaces!(namespaces)
34
+ valid = namespaces.is_a?(Array) && namespaces.length <= MAX_INHERITED_NAMESPACES &&
35
+ namespaces.all? { _1.is_a?(String) && _1.length.between?(1, 256) }
36
+ raise WorkflowError, "artifact namespaces must be up to #{MAX_INHERITED_NAMESPACES} names" unless valid
37
+
38
+ namespaces.uniq.map { _1.dup.freeze }.freeze
39
+ end
40
+
41
+ # Taken once, so a workflow its constructor builds does not inherit
42
+ # them too.
43
+ def self.take_inherited_artifact_namespaces
44
+ namespaces = Thread.current[INHERITED_NAMESPACES]
45
+ Thread.current[INHERITED_NAMESPACES] = nil
46
+ namespaces || [].freeze
47
+ end
48
+
8
49
  private
9
50
 
51
+ # The step's artifacts are scoped to its run (or, in a nested
52
+ # workflow, its parent's), over the configured store itself even when
53
+ # the step runs inside another run's step (an inline child run),
54
+ # resolved when the step first stores or reads. The step context's
55
+ # snapshot restores the outer scope when the step ends.
10
56
  def with_scoped_artifacts
11
- if @inherited_scoped_artifacts
12
- Smith.scoped_artifacts = @inherited_scoped_artifacts
13
- else
14
- backend = Smith.config.artifact_store || Smith.artifacts
15
- Smith.scoped_artifacts = Artifacts::ScopedStore.new(backend: backend, namespace: execution_namespace)
16
- end
57
+ Smith.scoped_artifacts = @inherited_scoped_artifacts ||
58
+ Artifacts::ScopedStore.new(namespace: execution_namespace,
59
+ readable: @inherited_artifact_namespaces || [])
17
60
  yield
18
61
  end
19
62
 
63
+ # What a child run of this run may read: this run's namespace, then
64
+ # the ones it may read itself, the nearest first.
65
+ def child_artifact_namespaces
66
+ [execution_namespace, *(@inherited_artifact_namespaces || [])].first(MAX_INHERITED_NAMESPACES).freeze
67
+ end
68
+
20
69
  def execution_namespace
21
70
  @execution_namespace ||= SecureRandom.uuid
22
71
  end
@@ -34,7 +83,8 @@ module Smith
34
83
  messages: messages,
35
84
  context_manager: manager,
36
85
  persisted_context: @context,
37
- seed_message_count: @seed_message_count || 0
86
+ seed_message_count: @seed_message_count || 0,
87
+ untrusted_keys: untrusted_context_keys
38
88
  )
39
89
  end
40
90
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # What an `await` block receives when the step is entered. It reads the
6
+ # context, names the wait (`await_token`, the key the request the block
7
+ # sends must carry, the same each time the block runs again after a
8
+ # crash), and may finish the wait at once with `resolve!`. It cannot
9
+ # write the context: the payload `resolve!` or `resume!` delivers is
10
+ # written to the step's `into:` key.
11
+ class AwaitStep
12
+ attr_reader :context, :await_token, :attempt, :transition_name, :payload
13
+
14
+ def initialize(context:, await_token:, idempotency_scope:, attempt:, transition_name:)
15
+ @context = context
16
+ @await_token = await_token
17
+ @idempotency_scope = idempotency_scope
18
+ @attempt = attempt
19
+ @transition_name = transition_name
20
+ @resolved = false
21
+ @payload = nil
22
+ end
23
+
24
+ def read_context(key) = @context[key]
25
+
26
+ # A key for another request the block sends, as on any step.
27
+ def idempotency_key(scope = nil) = @idempotency_scope.key(scope)
28
+
29
+ # Finishes the wait inline with `payload`, which is written to `into:`
30
+ # as a delivered event's would be.
31
+ def resolve!(payload)
32
+ raise WorkflowError, "resolve! was already called" if @resolved
33
+
34
+ @payload = MessageValueNormalizer.new(payload, label: "wait payload").call
35
+ @resolved = true
36
+ nil
37
+ end
38
+
39
+ def resolved? = @resolved
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # What batch results cost (BatchWait). A call whose result has come is
6
+ # reserved for whatever the budget has left, never refused, as the money
7
+ # is spent; a succeeded result no call took is recorded as a billed
8
+ # response of its branch's agent (`:failed_attempt`, `mode: :batch`) and
9
+ # charged to the run's budget.
10
+ module BatchCharges
11
+ private
12
+
13
+ def reserve_estimates!(ledger, estimates)
14
+ return super unless batch_result_due?
15
+
16
+ Thread.current[:smith_call_reservation] = ledger.reserve_spent!(estimates)
17
+ end
18
+
19
+ def charge_untaken_batch_results(transition)
20
+ @batch_delivery.untaken.each do |branch_key, entry|
21
+ next unless entry.result["type"] == "succeeded"
22
+
23
+ Attribution.with(branch_key:) do
24
+ charge_batch_result(batch_agent_class(transition, branch_key), entry.result.fetch("message"))
25
+ end
26
+ end
27
+ end
28
+
29
+ def batch_agent_class(transition, branch_key)
30
+ return resolve_agent_class(transition) unless branch_key
31
+
32
+ resolve_fanout_agent_class(transition, transition.fanout_config.fetch(:branches).fetch(branch_key))
33
+ end
34
+
35
+ def charge_batch_result(agent_class, message)
36
+ usage = batch_reply_usage(message)
37
+ return unless usage
38
+
39
+ model = Agent::ModelReference.new(model_id: batch_model_id(agent_class, message), provider: "anthropic")
40
+ record_billed_usages(agent_class, [usage], :failed_attempt, model, attempt_id: nil)
41
+ settle_spent_batch_charges
42
+ end
43
+
44
+ # The id a call of the agent prices its usage under, as RubyLLM
45
+ # resolves the agent's model (an alias to its dated id), else the
46
+ # result's own (a model given by a block).
47
+ def batch_model_id(agent_class, message)
48
+ kwargs = agent_class.chat_kwargs
49
+ return message["model"] unless kwargs[:model]
50
+
51
+ RubyLLM::Models.resolve(kwargs[:model], provider: kwargs[:provider],
52
+ assume_exists: kwargs[:assume_model_exists] || false).first.id
53
+ rescue StandardError
54
+ kwargs[:model]
55
+ end
56
+
57
+ # A result's usage, as the provider's own parser reads its message.
58
+ def batch_reply_usage(message)
59
+ reply = RubyLLM::Providers::Anthropic.new(RubyLLM.config)
60
+ .send(:parse_completion_response,
61
+ Providers::BatchExchange::Response.new(message.to_h))
62
+ Agent::ProviderUsage.from_message(reply)&.new(mode: :batch)
63
+ end
64
+
65
+ def settle_spent_batch_charges
66
+ ledger = effective_call_ledger
67
+ return clear_failed_billable_attempts unless ledger
68
+
69
+ reconcile_branch_budget(ledger, ledger.reserve_spent!(ledger.limits.keys.to_h { [_1, 0] }))
70
+ clear_failed_billable_attempts
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # Batch mode (Smith::Batch). An attempt whose mode is :batch prepares its
8
+ # agent call as a synchronous one would, admits it against the budget,
9
+ # and captures the request instead of sending it (a fan-out captures each
10
+ # branch's, in turn). Once the step's attempt has ended without error
11
+ # (its context checks included), the requests go to
12
+ # Smith.config.batch_queue under ids of the wait's token and the run waits
13
+ # on `:batch_result`, recording its pending batch (the transition, the
14
+ # attempt, its key generation, and each request's digest). Like any wait
15
+ # it is not a step; BatchWait completes it. A step inside another run's
16
+ # step (an in-process child) cannot wait, so it never sends a batch.
17
+ module BatchExecution
18
+ # Batches end within 24 hours; the wait allows one more.
19
+ WAIT_SECONDS = 25 * 3600
20
+ # The requests an attempt captured, for the step to queue.
21
+ Captured = Data.define(:token, :requests, :attempt, :key_generation)
22
+
23
+ private
24
+
25
+ # The attempt sends its requests as a batch: its mode says so and no
26
+ # result of it is being delivered.
27
+ def batch_send?(transition)
28
+ transition.batches? && !delivering_batch? && batch_mode_for(transition) == :batch
29
+ end
30
+
31
+ # A delivery runs only the stored batch wait's step (BatchWait).
32
+ def delivering_batch? = !@batch_delivery.nil? && @pending_batch["attempt"] == (@step_attempt || 1)
33
+
34
+ def batch_mode_for(transition)
35
+ mode = transition.batch_mode
36
+ return mode unless mode.respond_to?(:call)
37
+
38
+ context = snapshot_value(@context)
39
+ arity = mode.is_a?(Proc) ? mode.arity : mode.method(:call).arity
40
+ value = arity == 1 ? mode.call(context) : mode.call(context, @step_attempt || 1)
41
+ return value if %i[sync batch].include?(value)
42
+
43
+ raise WorkflowError, "a mode: callable must return :sync or :batch, got #{value.inspect}"
44
+ end
45
+
46
+ # An agent step's batch: its one call.
47
+ def send_agent_batch(transition, agent_class, prepared_input)
48
+ send_batch(transition, nil => lambda do
49
+ admit_batch_call { reserve_for_serial(transition, effective_call_ledger) }
50
+ with_agent_context(agent_class) { capture_batch_request(agent_class, prepared_input) }
51
+ end)
52
+ end
53
+
54
+ # A fan-out's batch: each branch's call, captured in its own branch
55
+ # context (attribution, idempotency scope, budget share) in turn.
56
+ def send_fanout_batch(transition, branches, branch_agent_classes, prepared_input)
57
+ env = fanout_branch_environment(branches, branch_agent_classes, prepared_input)
58
+ captures = branches.to_h do |branch_key, _agent_name|
59
+ agent_class = branch_agent_classes.fetch(branch_key)
60
+ [branch_key, -> { capture_fanout_branch(env, branch_key, agent_class) }]
61
+ end
62
+ send_batch(transition, captures)
63
+ end
64
+
65
+ def capture_fanout_branch(env, branch_key, agent_class)
66
+ with_branch_context(env, @ledger, agent_class:, attribution_overlay: { branch_key: }) do
67
+ with_agent_context(agent_class) do
68
+ admit_batch_call do
69
+ reserve_fanout_branch_call(effective_call_ledger, env.branch_estimates[branch_key], agent_class)
70
+ end
71
+ capture_batch_request(agent_class, env.prepared_input)
72
+ end
73
+ end
74
+ end
75
+
76
+ # The budget admits the call now; what it costs is charged when its
77
+ # result comes.
78
+ def admit_batch_call
79
+ ledger = effective_call_ledger
80
+ release_branch_budget(ledger, yield)
81
+ end
82
+
83
+ # The request the agent's call would send (BatchExchange throws it out
84
+ # of the call before anything is sent), and its hold.
85
+ def capture_batch_request(agent_class, prepared_input)
86
+ @batch_capture = nil
87
+ request = catch(Providers::BatchExchange::CAPTURED) do
88
+ @batch_capturing = true
89
+ invoke_agent(agent_class, prepared_input)
90
+ nil
91
+ ensure
92
+ @batch_capturing = false
93
+ end
94
+ [request || raise(BatchModeUnavailable, "a batch step's agent made no provider request"), @batch_capture.hold]
95
+ end
96
+
97
+ def send_batch(transition, captures)
98
+ refuse_batch_wait!
99
+ token = wait_token_for(transition, ["batch", @step_attempt || 1])
100
+ captured = captures.transform_values(&:call)
101
+ hold_batch!(token, captured.transform_values(&:last))
102
+ Captured.new(token:, requests: captured.transform_values(&:first), attempt: @step_attempt || 1,
103
+ key_generation: @step_key_generation || 0)
104
+ end
105
+
106
+ # The run can wait for a batch: a queue takes its requests, it is not
107
+ # inside split-step execution or another run's step (an in-process
108
+ # child), and the wait ends within the store's TTL.
109
+ def refuse_batch_wait!
110
+ Smith.config.batch_queue || raise(BatchQueueMissing)
111
+ raise WorkflowError, "a batch step cannot wait inside split-step execution" if @split_step_phase == :executing
112
+ raise WorkflowError, "an in-process child cannot wait for a batch" if Thread.current[:smith_step_depth].to_i > 1
113
+
114
+ check_wait_ttl!(WAIT_SECONDS)
115
+ end
116
+
117
+ # Hands the captured requests to the queue and waits. The batch is the
118
+ # attempt a pending retry waited to make, so the pending batch takes
119
+ # its place.
120
+ def queue_batch(transition, captured)
121
+ ids = enqueue_requests(captured)
122
+ @batch_holds = nil
123
+ @pending_retry = nil
124
+ @pending_batch = pending_batch_record(transition, captured, ids)
125
+ @awaiting = WaitRecord.build_batch(transition, captured.token, WAIT_SECONDS, Time.now.utc, ids.length)
126
+ record_wait_trace(transition, :waiting)
127
+ WAIT_PENDING
128
+ end
129
+
130
+ # Enqueues each captured request under its custom id; returns them by id.
131
+ def enqueue_requests(captured)
132
+ ids = captured.requests.to_h { |branch_key, request| [Batch.custom_id(captured.token, branch_key), request] }
133
+ ids.each do |custom_id, request|
134
+ Smith.config.batch_queue.enqueue(custom_id:, await_token: captured.token, request:,
135
+ execution_key: @persistence_key)
136
+ # Handed off, it may be sent: its hold stays until its result, or
137
+ # expires.
138
+ @batch_holds&.delete(custom_id)
139
+ end
140
+ ids
141
+ end
142
+
143
+ def pending_batch_record(transition, captured, ids)
144
+ { "transition" => transition.name.to_s, "attempt" => captured.attempt,
145
+ "key_generation" => captured.key_generation, "requests" => ids.transform_values { Batch.digest(_1) } }.freeze
146
+ end
147
+ end
148
+ end
149
+ end