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
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "amount"
4
+
5
+ module Smith
6
+ module Budget
7
+ # The protocol of a host's budget ledger (a workflow's `budget_ledger`),
8
+ # and how Smith calls it.
9
+ #
10
+ # `reserve!(amount, key:, expires_at:)` admits a provider request's hold
11
+ # (a Budget::Amount) when it returns true, and refuses it otherwise.
12
+ # `reconcile!(key, amount)` records what the request spent, in place of
13
+ # its hold if it had one: a key may be reconciled that was never held
14
+ # (a later round of a tool loop, or a result that came for a hold that
15
+ # expired). `release!(key)` says it spent nothing. A hold not settled by
16
+ # `expires_at` (its process died during the request) counts for nothing
17
+ # after it; Smith runs no timer. A key may come again after a crash: a
18
+ # reserve of it replaces its hold, a release ends its hold, and its
19
+ # reconcile is final (a ledger records it once). Fan-out branches call
20
+ # the ledger from their own threads.
21
+ #
22
+ # A refusal raises BudgetUnavailable before the request is sent. An
23
+ # error from building the ledger or from `reserve!` becomes
24
+ # BudgetLedgerError (the hold, if `reserve!` took it, is released), as
25
+ # nothing was sent. An error from `reconcile!` or `release!` is logged
26
+ # by class and the step goes on: the money is spent, and a ledger that
27
+ # could not record it must not cost the answer.
28
+ module HostLedger
29
+ METHODS = %i[reserve! reconcile! release!].freeze
30
+ INHERITED = :smith_inherited_budget_ledger
31
+ SCOPE_STRING_BYTES = 256
32
+ private_constant :INHERITED, :SCOPE_STRING_BYTES
33
+
34
+ module_function
35
+
36
+ def check!(ledger)
37
+ return ledger if ledger.nil? || METHODS.all? { ledger.respond_to?(_1) }
38
+
39
+ raise WorkflowError, "a budget_ledger must respond to #{METHODS.join(", ")}"
40
+ end
41
+
42
+ # The run's ledger from its builder and the scope it was created with.
43
+ def build(builder, scope)
44
+ check!(builder.call(scope))
45
+ rescue WorkflowError
46
+ raise
47
+ rescue StandardError => e
48
+ raise BudgetLedgerError, "the budget ledger could not be built (#{e.class})"
49
+ end
50
+
51
+ # Admits the hold until `seconds` (Smith.config.budget_hold_ttl, plus a
52
+ # batch's wait) from now.
53
+ def admit!(ledger, amount, key, seconds: Smith.config.budget_hold_ttl)
54
+ raise BudgetUnavailable unless ledger.reserve!(amount, key:, expires_at: Time.now.utc + seconds) == true
55
+ rescue BudgetUnavailable
56
+ raise
57
+ rescue StandardError => e
58
+ settle(ledger, key, nil)
59
+ raise BudgetLedgerError, "the budget ledger could not reserve (#{e.class})"
60
+ end
61
+
62
+ def settle(ledger, key, amount)
63
+ amount ? ledger.reconcile!(key, amount) : ledger.release!(key)
64
+ rescue StandardError => e
65
+ Smith.config.logger&.error("Smith budget ledger #{amount ? "reconcile!" : "release!"} error: #{e.class}")
66
+ nil
67
+ end
68
+
69
+ # What a response's usage cost, as an Amount, priced as Smith prices
70
+ # usage; a count that is not a non-negative Integer counts as 0.
71
+ def amount(usage, model:, provider:, mode: :sync)
72
+ counts = [usage.input_tokens, usage.output_tokens, usage.cache_read_tokens, usage.cache_write_tokens]
73
+ input, output, reads, writes = counts.map { _1.is_a?(Integer) && _1.positive? ? _1 : 0 }
74
+ Amount.new(
75
+ input_tokens: input + reads + writes, output_tokens: output,
76
+ cost: Pricing.compute_cost(model:, provider:, input_tokens: input, output_tokens: output,
77
+ cache_read_tokens: reads, cache_write_tokens: writes, mode:)
78
+ )
79
+ end
80
+
81
+ # The scope a run's ledger is built from: the values its `keys` have in
82
+ # the run's trusted context when it is created, JSON scalars (a String
83
+ # of at most SCOPE_STRING_BYTES bytes), kept with the run so that what
84
+ # a step writes later never chooses another ledger.
85
+ def scope!(keys, context, untrusted)
86
+ keys.to_h do |key|
87
+ raise WorkflowError, "budget_ledger scope key #{key.inspect} is untrusted" if untrusted.include?(key)
88
+
89
+ [key, scope_value!(key, context.is_a?(Hash) ? context[key] : nil)]
90
+ end.freeze
91
+ end
92
+
93
+ # A stored scope, if it names the declared keys; nil (capture it
94
+ # again) if the declaration changed.
95
+ def stored_scope(keys, stored)
96
+ return unless stored.is_a?(Hash) && same_keys?(keys, stored)
97
+
98
+ stored = stored.transform_keys(&:to_s)
99
+ keys.to_h { |key| [key, scope_value!(key, stored[key.to_s])] }.freeze
100
+ rescue WorkflowError
101
+ raise SerializationError, "persisted workflow budget_ledger_scope is invalid"
102
+ end
103
+
104
+ def same_keys?(keys, stored) = stored.keys.map(&:to_s).sort == keys.map(&:to_s).sort
105
+
106
+ def scope_value!(key, value)
107
+ case value
108
+ when nil, true, false, Integer then value
109
+ when Float then value.finite? ? value : invalid_scope!(key)
110
+ when String then value.bytesize <= SCOPE_STRING_BYTES ? value.dup.freeze : invalid_scope!(key)
111
+ else invalid_scope!(key)
112
+ end
113
+ end
114
+
115
+ def invalid_scope!(key)
116
+ raise WorkflowError, "budget_ledger scope key #{key.inspect} must hold a JSON scalar " \
117
+ "(a String of at most #{SCOPE_STRING_BYTES} bytes)"
118
+ end
119
+
120
+ # A nested workflow built in the block charges `source` (the enclosing
121
+ # run's ledger, as a callable) unless it names a ledger of its own.
122
+ def inheriting(source)
123
+ outer = Thread.current[INHERITED]
124
+ Thread.current[INHERITED] = source
125
+ yield
126
+ ensure
127
+ Thread.current[INHERITED] = outer
128
+ end
129
+
130
+ def take_inherited
131
+ source = Thread.current[INHERITED]
132
+ Thread.current[INHERITED] = nil
133
+ source
134
+ end
135
+ end
136
+ end
137
+ end
@@ -58,12 +58,16 @@ module Smith
58
58
  @mutex.synchronize { release_reservation(token) }
59
59
  end
60
60
 
61
- def remaining(key)
61
+ def remaining(key) = remaining_share(key, 1)
62
+
63
+ # One of parts equal shares of the remaining amount, taken from the exact
64
+ # internal value so that parts reservations of it always fit.
65
+ def remaining_share(key, parts)
62
66
  dimension = @amount_contract.dimension!(key)
63
67
  @amount_contract.known_dimension!(dimension)
64
68
  @mutex.synchronize do
65
69
  amount = @state_transition.remaining(@publication.state, dimension)
66
- @representation.externalize_amount(dimension, amount)
70
+ @representation.externalize_remaining(dimension, amount, parts)
67
71
  end
68
72
  end
69
73
 
@@ -96,14 +100,14 @@ module Smith
96
100
  @representation.internalize(external)
97
101
  end
98
102
 
99
- def create_reservation(amounts)
103
+ def create_reservation(amounts, checked: true)
100
104
  token = Object.new.freeze
101
105
  receipt = Reservation.new(
102
106
  ledger_identity: @identity,
103
107
  token:,
104
108
  amounts: @representation.externalize(amounts)
105
109
  )
106
- next_state = @state_transition.reserve(@publication.state, amounts)
110
+ next_state = @state_transition.reserve(@publication.state, amounts, checked:)
107
111
  @publication.add(@public_state_validator.call(next_state), token:, amounts:)
108
112
  receipt
109
113
  end
@@ -132,3 +136,5 @@ module Smith
132
136
  end
133
137
  end
134
138
  end
139
+
140
+ require_relative "spent_reservation"
@@ -11,12 +11,9 @@ module Smith
11
11
 
12
12
  option :limits
13
13
 
14
- def reserve(state, amounts)
14
+ def reserve(state, amounts, checked: true)
15
15
  DecimalContext.call do
16
- amounts.each do |key, amount|
17
- requested = state.consumed[key] + state.reserved[key] + amount
18
- raise BudgetExceeded unless requested.finite? && requested <= limits.fetch(key)
19
- end
16
+ amounts.each { |key, amount| admit!(state, key, amount, checked) }
20
17
 
21
18
  LedgerState.new(consumed: state.consumed, reserved: add(state.reserved, amounts))
22
19
  end
@@ -49,6 +46,11 @@ module Smith
49
46
 
50
47
  private
51
48
 
49
+ def admit!(state, key, amount, checked)
50
+ requested = state.consumed[key] + state.reserved[key] + amount
51
+ raise BudgetExceeded unless requested.finite? && (!checked || requested <= limits.fetch(key))
52
+ end
53
+
52
54
  def add(current, amounts)
53
55
  merge(current, amounts) { |value, amount| value + amount }
54
56
  end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "amount"
6
+
7
+ module Smith
8
+ module Budget
9
+ # The hold a provider request asks a host's budget ledger for before it
10
+ # is sent: its output cap and an estimate of its input, priced from the
11
+ # pricing catalog.
12
+ #
13
+ # The output cap is the request's own (`max_tokens`, or its provider's
14
+ # name for it, as Providers::OutputLimit reads it), else the model's
15
+ # maximum, else for Anthropic DEFAULT_OUTPUT_CAP (what RubyLLM asks for
16
+ # when it knows no more), else the model's context window, else
17
+ # DEFAULT_OUTPUT_CAP; Smith.config.budget_hold_output_tokens, when set,
18
+ # caps it. The input estimate counts text (messages, tool definitions
19
+ # and calls, the output schema) at one token per BYTES_PER_TOKEN bytes,
20
+ # an image at IMAGE_TOKENS, a PDF at PAGE_TOKENS a page (its pages
21
+ # counted in it, else one per BYTES_PER_PAGE), another attachment's
22
+ # bytes as text, and each message's framing at MESSAGE_TOKENS, and is
23
+ # capped at the model's context window. Images and documents are
24
+ # recognized as RubyLLM attachments and in raw content blocks (base64
25
+ # `data` beside its media type, or a data URL). It is an estimate, not a
26
+ # bound: what the request spent replaces it.
27
+ module RequestHold
28
+ BYTES_PER_TOKEN = 3
29
+ IMAGE_TOKENS = 1_600
30
+ PAGE_TOKENS = 2_000
31
+ BYTES_PER_PAGE = 50_000
32
+ MESSAGE_TOKENS = 8
33
+ DEFAULT_OUTPUT_CAP = 4_096
34
+ OUTPUT_CAPS = Providers::OutputLimit::PARAM_PATHS.values.flatten(1).uniq.freeze
35
+
36
+ module_function
37
+
38
+ # The hold for a request RubyLLM's Provider#complete is about to make,
39
+ # from its `messages:`, `tools:`, `schema:` and `params:`.
40
+ def for_request(provider:, model:, mode: :sync, **request)
41
+ context_window = model.context_window if model.respond_to?(:context_window)
42
+ input = [input_estimate(*request.values_at(:messages, :tools, :schema)), context_window].compact.min
43
+ output = [output_cap(provider, request[:params], model), Smith.config.budget_hold_output_tokens].compact.min
44
+ Amount.new(input_tokens: input, output_tokens: output,
45
+ cost: Pricing.compute_cost(model: model.id, provider: provider.to_s, input_tokens: input,
46
+ output_tokens: output, mode:))
47
+ end
48
+
49
+ def input_estimate(messages, tools, schema)
50
+ counter = Counter.new
51
+ Array(messages).each { counter.message(_1) }
52
+ tools.to_h.each_value { counter.tool(_1) }
53
+ counter.text(schema) if schema
54
+ counter.tokens
55
+ end
56
+
57
+ PAGE = %r{/Type\s*/Page(?![a-zA-Z])}n
58
+ PAGES = ObjectSpace::WeakMap.new
59
+ PAGES_LOCK = Mutex.new
60
+ private_constant :PAGE, :PAGES, :PAGES_LOCK
61
+
62
+ # A PDF's pages (its bytes from the block), counted in it once per
63
+ # String, else one per BYTES_PER_PAGE.
64
+ def pages(source)
65
+ PAGES_LOCK.synchronize do
66
+ PAGES[source] ||= begin
67
+ bytes = yield(source).b
68
+ counted = bytes.scan(PAGE).length
69
+ counted.positive? ? counted : [(bytes.bytesize + BYTES_PER_PAGE - 1) / BYTES_PER_PAGE, 1].max
70
+ end
71
+ end
72
+ end
73
+
74
+ def output_cap(provider, params, model)
75
+ paths = Providers::OutputLimit::PARAM_PATHS.fetch(provider.to_s, OUTPUT_CAPS)
76
+ [paths.lazy.filter_map { Providers::OutputLimit.lookup(params.to_h, _1) }.first,
77
+ (model.max_tokens if model.respond_to?(:max_tokens)),
78
+ (DEFAULT_OUTPUT_CAP if provider.to_s == "anthropic"),
79
+ (model.context_window if model.respond_to?(:context_window))]
80
+ .find { _1.is_a?(Integer) && _1.positive? } || DEFAULT_OUTPUT_CAP
81
+ end
82
+
83
+ # Counts what a request carries: bytes of text, images, document pages
84
+ # and messages.
85
+ class Counter
86
+ IMAGE_BLOCKS = %w[image input_image image_url].freeze
87
+ DOCUMENT_BLOCKS = %w[document file input_file].freeze
88
+ SOURCE_KEYS = %i[source inline_data inlineData].freeze
89
+ MEDIA_KEYS = %i[media_type mime_type mimeType].freeze
90
+ DATA_URL = %r{\Adata:([\w.+-]+/[\w.+-]+);base64,}
91
+
92
+ def initialize
93
+ @bytes = 0
94
+ @images = 0
95
+ @pages = 0
96
+ @messages = 0
97
+ end
98
+
99
+ def tokens
100
+ ((@bytes + BYTES_PER_TOKEN - 1) / BYTES_PER_TOKEN) + (@images * IMAGE_TOKENS) + (@pages * PAGE_TOKENS) +
101
+ (@messages * MESSAGE_TOKENS)
102
+ end
103
+
104
+ def message(message)
105
+ @messages += 1
106
+ content(message.content)
107
+ Array(message.tool_calls&.values).each { text(_1.to_h) } if message.respond_to?(:tool_calls)
108
+ thinking = message.thinking if message.respond_to?(:thinking)
109
+ text(thinking.text) if thinking.respond_to?(:text)
110
+ end
111
+
112
+ def tool(tool)
113
+ text(tool.name)
114
+ text(tool.description)
115
+ text(tool.params_schema) if tool.respond_to?(:params_schema)
116
+ end
117
+
118
+ def content(content)
119
+ case content
120
+ when RubyLLM::Content::Raw then raw(content.value)
121
+ when RubyLLM::Content
122
+ text(content.text)
123
+ content.attachments.each { attachment(_1) }
124
+ else raw(content)
125
+ end
126
+ end
127
+
128
+ def attachment(attachment)
129
+ return @images += 1 if attachment.image?
130
+ # A URL is not fetched to count it: one page.
131
+ return @pages += 1 if attachment.url?
132
+ return @pages += RequestHold.pages(attachment.content.to_s) { _1 } if attachment.pdf?
133
+
134
+ @bytes += attachment.content.to_s.bytesize
135
+ end
136
+
137
+ # Raw content: an image block, a block of base64 data or a data URL
138
+ # counts as what it carries; anything else as its text.
139
+ def raw(value)
140
+ case value
141
+ when Hash then block(value)
142
+ when Array then value.each { raw(_1) }
143
+ when String then string(value)
144
+ else text(value)
145
+ end
146
+ end
147
+
148
+ def block(hash)
149
+ kind = fetch(hash, :type).to_s
150
+ return @images += 1 if IMAGE_BLOCKS.include?(kind)
151
+
152
+ data, media = base64_data(hash)
153
+ return base64(data, media) if data
154
+ return @pages += 1 if DOCUMENT_BLOCKS.include?(kind) && !text_source?(hash)
155
+
156
+ hash.each do |key, value|
157
+ text(key)
158
+ raw(value)
159
+ end
160
+ end
161
+
162
+ # Anthropic's `source` (base64), Gemini's `inline_data`, or the
163
+ # block's own `data` beside a media type.
164
+ def base64_data(hash)
165
+ source = first(hash, SOURCE_KEYS) || hash
166
+ return unless source.is_a?(Hash) && fetch(source, :type) != "text"
167
+
168
+ data = fetch(source, :data)
169
+ media = first(source, MEDIA_KEYS)
170
+ [data, media] if data.is_a?(String) && media.is_a?(String)
171
+ end
172
+
173
+ def first(hash, keys) = keys.lazy.filter_map { fetch(hash, _1) }.first
174
+
175
+ def text_source?(hash)
176
+ source = fetch(hash, :source)
177
+ source.is_a?(Hash) && %w[text content].include?(fetch(source, :type).to_s)
178
+ end
179
+
180
+ def string(value)
181
+ url = DATA_URL.match(value)
182
+ url ? base64(value, url[1], from: url[0].bytesize) : text(value)
183
+ end
184
+
185
+ # Base64 data (from byte `from` of `encoded`): an image, a PDF's
186
+ # pages, or its decoded size as text. Only a PDF is decoded.
187
+ def base64(encoded, media, from: 0)
188
+ return @images += 1 if media.start_with?("image/")
189
+ if media == "application/pdf"
190
+ return @pages += RequestHold.pages(encoded) { _1.byteslice(from..).unpack1("m") }
191
+ end
192
+
193
+ @bytes += (encoded.bytesize - from) * 3 / 4
194
+ end
195
+
196
+ def fetch(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
197
+
198
+ def text(value)
199
+ case value
200
+ when nil then nil
201
+ when String then @bytes += value.bytesize
202
+ when Symbol then @bytes += value.to_s.bytesize
203
+ else @bytes += JSON.generate(value).bytesize
204
+ end
205
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
206
+ @bytes += value.to_s.bytesize
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Budget
5
+ # Reserving for spending that has already happened (a batch result
6
+ # delivered after its request was admitted): never refused, so the
7
+ # reservation can be reconciled with what was spent, past the limits if
8
+ # need be. Part of Ledger.
9
+ module SpentReservation
10
+ def reserve_spent!(reservations)
11
+ amounts = validated_amounts(reservations, "budget reservation")
12
+ @mutex.synchronize { create_reservation(amounts, checked: false) }
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ Smith::Budget::Ledger.include(Smith::Budget::SpentReservation)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Smith
6
+ # One run a `map`, `call` or `spawn` step asks the host's run launcher to
7
+ # start: its `ref` (the index of its item, so the same on every replay of
8
+ # the step), its `key` (the step's token and the ref together: what the
9
+ # launcher must be idempotent on, unique across every child-run step of
10
+ # every run), its workflow class, the context `input:` returned for it,
11
+ # the untrusted keys it inherits from its parent (the parent's untrusted
12
+ # keys that `input:` passed under the same names), and the artifact
13
+ # namespaces it may read (its parent's and the parent's ancestors', the
14
+ # nearest first), so a document a parent stored reaches its children.
15
+ ChildRun = Data.define(:ref, :key, :workflow, :input, :untrusted_keys, :artifact_namespaces) do
16
+ def self.key_for(await_token, ref) = Digest::SHA256.hexdigest("smith.child_run.v1:#{await_token}:#{ref}")
17
+
18
+ def initialize(artifact_namespaces: [], **)
19
+ super
20
+ end
21
+
22
+ # The child, built with what it inherits in force; both persist with
23
+ # it, so a restored child keeps them.
24
+ def build
25
+ Workflow::StepIO.with_inherited_untrusted_keys(untrusted_keys) do
26
+ Workflow::ArtifactIntegration.with_inherited_artifact_namespaces(artifact_namespaces) do
27
+ workflow.new(context: input)
28
+ end
29
+ end
30
+ end
31
+
32
+ # The entry a host reports for a finished child when it resumes the
33
+ # parent: `{ ref:, status: "succeeded" | "failed", state:, output: }`.
34
+ # A child failed when it ended in its failed state or any of its steps
35
+ # raised, even one its own `on_failure` handled.
36
+ def self.result(ref:, run_result:)
37
+ failed = run_result.failed? || Array(run_result.steps).any? { _1.is_a?(Hash) && _1.key?(:error) }
38
+ { ref:, status: failed ? "failed" : "succeeded", state: run_result.state&.to_s, output: run_result.output }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "diagnostic_text"
5
+
6
+ module Smith
7
+ # A `map` or `call` step whose child runs did not all succeed, under
8
+ # `on_item_failure: :fail`. Its failure family is `child_runs_failed`;
9
+ # details name the transition, up to MAX_REFS failed refs and the counts,
10
+ # and persist with the failed step.
11
+ class ChildRunsFailed < WorkflowError
12
+ DETAIL_NAMES = %i[transition failed_refs failed total].freeze
13
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
14
+ MAX_REFS = 20
15
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS
16
+
17
+ attr_reader :transition, :failed_refs, :failed, :total
18
+
19
+ def initialize(transition:, failed_refs:, total:, failed: failed_refs.length, message: nil)
20
+ @transition = DiagnosticText.capture(transition.to_s, max_bytes: 256)
21
+ @failed_refs = Array(failed_refs).first(MAX_REFS).freeze
22
+ @failed = failed
23
+ @total = total
24
+ super(message || "#{failed} of #{total} child runs of #{@transition} failed (refs #{@failed_refs.join(", ")})")
25
+ end
26
+
27
+ def details = { transition:, failed_refs:, failed:, total: }.freeze
28
+
29
+ def self.from_details(details, message: nil)
30
+ raise ArgumentError, "child run failure details must be a Hash" unless details.is_a?(Hash)
31
+
32
+ values = details.to_h { |key, value| [key.is_a?(Symbol) ? key : DETAIL_KEYS[key], value] }
33
+ unless values.keys.sort == DETAIL_NAMES.sort && valid_counts?(values)
34
+ raise ArgumentError, "child run failure details are invalid"
35
+ end
36
+
37
+ new(transition: values[:transition], failed_refs: values[:failed_refs], failed: values[:failed],
38
+ total: values[:total], message:)
39
+ end
40
+
41
+ # Counts within bounds, and refs that are distinct indexes of the total,
42
+ # as many as the failures up to MAX_REFS (so neither count is negative).
43
+ def self.valid_counts?(values)
44
+ refs, failed, total = values.values_at(:failed_refs, :failed, :total)
45
+ return false unless values[:transition].is_a?(String) && [failed, total].all?(Integer)
46
+
47
+ failed <= total && valid_refs?(refs, failed, total)
48
+ end
49
+
50
+ def self.valid_refs?(refs, failed, total)
51
+ refs.is_a?(Array) && refs.all? { _1.is_a?(Integer) && _1.between?(0, total - 1) } &&
52
+ refs.length == [failed, MAX_REFS].min && refs.uniq.length == refs.length
53
+ end
54
+ private_class_method :valid_counts?, :valid_refs?
55
+ end
56
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Smith
6
+ # Content parts of a Smith::Message: text, and documents and images held
7
+ # as artifacts of the run (`Smith.artifacts.store` inside a step), never
8
+ # as bytes, so a message with parts stays small and JSON-shaped wherever
9
+ # it is stored or digested.
10
+ #
11
+ # ref = Smith.artifacts.store(pdf_bytes, content_type: "application/pdf")
12
+ # Smith::Message.user([Smith::Content.document(ref, media_type: "application/pdf"),
13
+ # Smith::Content.text("List the parties to this contract.")])
14
+ #
15
+ # `cache: true` marks the part as the end of a prefix Anthropic may cache;
16
+ # other providers cache on their own and ignore it.
17
+ module Content
18
+ DOCUMENT_TYPES = %w[application/pdf text/plain].freeze
19
+ IMAGE_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
20
+ MAX_TEXT_BYTES = 1024 * 1024
21
+ MAX_REF_BYTES = 1024
22
+ # The bytes one document part may hold when it is sent: as base64 it
23
+ # still fits a 32 MiB request.
24
+ MAX_PART_BYTES = 24 * 1024 * 1024
25
+ # An artifact ref as Smith's stores make them: a run's namespace, then
26
+ # colon-separated names of letters, digits, `-` and `_`. No path, dot or
27
+ # NUL can reach a store through it.
28
+ REF = /\A[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)+\z/
29
+
30
+ module_function
31
+
32
+ def text(text, cache: false) = part({ "type" => "text", "text" => text }, cache)
33
+
34
+ def document(ref, media_type:, cache: false)
35
+ part({ "type" => "document", "artifact" => ref, "media_type" => media_type }, cache)
36
+ end
37
+
38
+ def image(ref, media_type:, cache: false)
39
+ part({ "type" => "image", "artifact" => ref, "media_type" => media_type }, cache)
40
+ end
41
+
42
+ def part(fields, cache)
43
+ raise WorkflowError, "a content part's cache: must be true or false" unless [true, false].include?(cache)
44
+
45
+ check!(cache ? fields.merge("cache" => true) : fields)
46
+ end
47
+
48
+ # A part, String or Symbol keyed (and Symbol valued), checked and
49
+ # returned frozen with String keys and UTF-8 Strings: a text part with
50
+ # more than whitespace, or a document or image part naming an artifact
51
+ # (REF) and a media type of its kind, each with an optional
52
+ # `"cache" => true`.
53
+ def check!(value)
54
+ fields = value.is_a?(Hash) ? value.to_h { |key, field| [key.to_s, utf8(field)] } : {}
55
+ unless valid?(fields)
56
+ raise WorkflowError, "a content part is Smith::Content.text, .document or .image (documents " \
57
+ "#{DOCUMENT_TYPES.join(", ")}; images #{IMAGE_TYPES.join(", ")})"
58
+ end
59
+
60
+ fields.freeze
61
+ end
62
+
63
+ def utf8(field)
64
+ return field.to_s.freeze if field.is_a?(Symbol)
65
+ return field unless field.is_a?(String)
66
+
67
+ field.dup.force_encoding(Encoding::UTF_8).freeze
68
+ end
69
+
70
+ def valid?(fields)
71
+ return false unless fields.fetch("cache", true) == true
72
+
73
+ keys = fields.keys - ["cache"]
74
+ case fields["type"]
75
+ when "text" then keys.sort == %w[text type] && text?(fields["text"])
76
+ when "document" then held?(keys, fields, DOCUMENT_TYPES)
77
+ when "image" then held?(keys, fields, IMAGE_TYPES)
78
+ else false
79
+ end
80
+ end
81
+
82
+ def text?(text) = bounded?(text, MAX_TEXT_BYTES) && text.match?(/\S/)
83
+
84
+ def held?(keys, fields, media_types)
85
+ keys.sort == %w[artifact media_type type] && bounded?(fields["artifact"], MAX_REF_BYTES) &&
86
+ REF.match?(fields["artifact"]) && media_types.include?(fields["media_type"])
87
+ end
88
+
89
+ def bounded?(value, max_bytes)
90
+ value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, max_bytes)
91
+ end
92
+
93
+ def cached?(part) = part["cache"] == true
94
+ private_class_method :part, :utf8, :valid?, :text?, :held?, :bounded?
95
+ end
96
+ end
@@ -5,11 +5,12 @@ module Smith
5
5
  class Session
6
6
  attr_reader :messages
7
7
 
8
- def initialize(messages:, context_manager:, persisted_context:, seed_message_count: 0)
8
+ def initialize(messages:, context_manager:, persisted_context:, seed_message_count: 0, untrusted_keys: nil)
9
9
  @messages = messages
10
10
  @context_manager = context_manager
11
11
  @persisted_context = persisted_context
12
12
  @seed_message_count = seed_message_count
13
+ @untrusted_keys = untrusted_keys
13
14
  end
14
15
 
15
16
  def inject_state!
@@ -19,10 +20,21 @@ module Smith
19
20
  return unless formatter
20
21
 
21
22
  @messages.replace(
22
- StateInjection.inject(@messages, formatter: formatter, persisted: @persisted_context)
23
+ StateInjection.inject(@messages, formatter: formatter, persisted: trusted_context)
23
24
  )
24
25
  end
25
26
 
27
+ # The context without its untrusted keys: the run's own and those of
28
+ # any workflow it is nested in, when the run passes them, or else its
29
+ # context manager's.
30
+ def trusted_context
31
+ untrusted = @untrusted_keys
32
+ untrusted ||= @context_manager.respond_to?(:untrusted_keys) ? @context_manager.untrusted_keys : []
33
+ return @persisted_context if untrusted.empty? || !@persisted_context.is_a?(Hash)
34
+
35
+ @persisted_context.except(*untrusted)
36
+ end
37
+
26
38
  def masked_view
27
39
  return @messages unless @context_manager
28
40