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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Batch
5
+ # Whether a succeeded result's message has the shape of an Anthropic
6
+ # Messages reply, as the provider's parser reads it: `content` an Array of
7
+ # blocks, each a Hash with a String `type` (a text block's `text` a
8
+ # String), and `usage` a Hash whose token counts are non-negative
9
+ # Integers. Anything else is refused before the step runs.
10
+ module MessageShape
11
+ COUNTS = %w[input_tokens output_tokens].freeze
12
+ OPTIONAL_COUNTS = %w[cache_read_input_tokens cache_creation_input_tokens].freeze
13
+ private_constant :COUNTS, :OPTIONAL_COUNTS
14
+
15
+ module_function
16
+
17
+ def valid?(message)
18
+ message.is_a?(Hash) && message["content"].is_a?(Array) && message["content"].all? { block?(_1) } &&
19
+ usage?(message["usage"])
20
+ end
21
+
22
+ def block?(block)
23
+ return false unless block.is_a?(Hash) && block["type"].is_a?(String)
24
+
25
+ block["type"] != "text" || block["text"].is_a?(String)
26
+ end
27
+
28
+ def usage?(usage)
29
+ usage.is_a?(Hash) && COUNTS.all? { count?(usage[_1]) } &&
30
+ OPTIONAL_COUNTS.all? { usage[_1].nil? || count?(usage[_1]) }
31
+ end
32
+
33
+ def count?(value) = value.is_a?(Integer) && !value.negative?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "diagnostic_text"
7
+ require_relative "batch/message_shape"
8
+
9
+ module Smith
10
+ # Batch mode (`execute Agent, mode: :batch`, and the same on `fan_out`): a
11
+ # step's provider request is sent through the host's batch queue instead of
12
+ # over the wire, and the step waits for its result. Smith renders exactly
13
+ # the request a synchronous call would post, hands it to
14
+ # `Smith.config.batch_queue.enqueue(custom_id:, await_token:, request:,
15
+ # execution_key:)` and waits on `:batch_result`; the host submits batches
16
+ # (Smith::Providers::Anthropic::Batches), collects their results, and
17
+ # resumes the run with every result of its wait. Smith never submits or
18
+ # polls, and waits for the results without sleeping (an in-process
19
+ # `retry_on` backoff sleeps as on any step; `durable: true` waits instead).
20
+ module Batch
21
+ EVENT = "batch_result"
22
+ RESULT_TYPES = %w[succeeded errored canceled expired].freeze
23
+ MISSING = "missing"
24
+ MAX_REQUEST_BYTES = 32 * 1024 * 1024
25
+ MAX_RESULTS_BYTES = 64 * 1024 * 1024
26
+ INVALID_REQUEST = "invalid_request_error"
27
+ CREDENTIAL_HEADERS = %w[authorization x-api-key x-goog-api-key].freeze
28
+
29
+ module_function
30
+
31
+ # The request a batch carries for one call: the provider, the payload it
32
+ # would post, and the call's own headers (never credentials: the
33
+ # connection's stay in it, and a call's own are refused), JSON-shaped,
34
+ # bounded and frozen.
35
+ def request(provider:, params:, headers:)
36
+ if headers.to_h.keys.any? { CREDENTIAL_HEADERS.include?(_1.to_s.downcase) }
37
+ raise WorkflowError, "a batch request cannot carry credentials in its headers"
38
+ end
39
+
40
+ encoded = JSON.generate("provider" => provider.to_s, "params" => params, "headers" => headers.to_h)
41
+ if encoded.bytesize > MAX_REQUEST_BYTES
42
+ raise WorkflowError, "a batch request is #{encoded.bytesize} bytes, past the #{MAX_REQUEST_BYTES} Smith sends"
43
+ end
44
+
45
+ deep_freeze(JSON.parse(encoded))
46
+ end
47
+
48
+ # What identifies a request's content: a SHA-256 of its payload and
49
+ # headers as JSON gives them, so the request a delivery answers can be
50
+ # told from one the step would render now.
51
+ def digest(request) = digest_of(request.fetch("params"), request.fetch("headers"))
52
+
53
+ # Whether a call renders a request other than the one sent (`digest`).
54
+ # Nothing is sent, so nothing in it is refused; one JSON cannot give
55
+ # counts as changed.
56
+ def changed?(digest, params, headers)
57
+ digest_of(params, headers.to_h) != digest
58
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
59
+ true
60
+ end
61
+
62
+ def digest_of(params, headers) = Digest::SHA256.hexdigest(JSON.generate([params, headers]))
63
+
64
+ # A step's request id: its wait token, and for a fan-out branch a key of
65
+ # the token and the branch (64 hex characters, a valid custom_id).
66
+ def custom_id(token, branch_key = nil)
67
+ branch_key.nil? ? token : Digest::SHA256.hexdigest("#{token}/#{branch_key}")
68
+ end
69
+
70
+ # The delivered results, checked: one per expected id, each a result of
71
+ # a known type, keys as JSON gives them. Raises ArgumentError otherwise.
72
+ def results!(payload, expected_ids)
73
+ results = stringified(payload)
74
+ unless results.keys.sort == expected_ids.sort
75
+ raise ArgumentError, "a batch delivery must hold exactly the results of its #{expected_ids.length} requests"
76
+ end
77
+
78
+ results.each_value { |result| check_result!(result) }
79
+ deep_freeze(results)
80
+ end
81
+
82
+ # The message of a succeeded result, or the error a failed one raises:
83
+ # BatchRequestInvalid for a request the provider refused, else
84
+ # BatchResultFailed (retryable).
85
+ def message!(result)
86
+ case result.fetch("type")
87
+ when "succeeded" then result.fetch("message")
88
+ when "errored" then raise_errored!(result)
89
+ else raise BatchResultFailed.new(kind: result.fetch("type"))
90
+ end
91
+ end
92
+
93
+ def raise_errored!(result)
94
+ error = result["error"]
95
+ type = error.dig("error", "type") || error["type"] if error.is_a?(Hash)
96
+ raise BatchRequestInvalid if type == INVALID_REQUEST
97
+
98
+ raise BatchResultFailed.new(kind: "errored", error_type: type.is_a?(String) ? type : nil)
99
+ end
100
+
101
+ def check_result!(result)
102
+ raise ArgumentError, "a batch result must be a Hash with a type" unless result.is_a?(Hash)
103
+
104
+ type = result["type"]
105
+ raise ArgumentError, "a batch result's type must be one of #{RESULT_TYPES.join(", ")}" unless
106
+ RESULT_TYPES.include?(type) || type == MISSING
107
+ return unless type == "succeeded" && !MessageShape.valid?(result["message"])
108
+
109
+ raise ArgumentError, "a succeeded batch result must carry a message of the provider's reply shape"
110
+ end
111
+
112
+ def stringified(payload)
113
+ raise ArgumentError, "a batch delivery must be a Hash of request ids to results" unless payload.is_a?(Hash)
114
+
115
+ encoded = JSON.generate(payload)
116
+ raise ArgumentError, "a batch delivery is past #{MAX_RESULTS_BYTES} bytes" if encoded.bytesize > MAX_RESULTS_BYTES
117
+
118
+ JSON.parse(encoded)
119
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
120
+ raise ArgumentError, "a batch delivery must be JSON-shaped"
121
+ end
122
+
123
+ def deep_freeze(value)
124
+ case value
125
+ when Hash then value.each_value { deep_freeze(_1) }.freeze
126
+ when Array then value.each { deep_freeze(_1) }.freeze
127
+ else value.freeze
128
+ end
129
+ end
130
+
131
+ # The results a resumed step takes, one per branch (nil for a step
132
+ # without branches), each with the digest of the request it answers, and
133
+ # what a result does when its call renders another request
134
+ # (`on_change`). Each is taken once: a follow-up call the step makes
135
+ # after it (a repair) goes over the wire. `untaken` is what no call took
136
+ # (a branch cancelled by a sibling's failure) or set aside (a result its
137
+ # call would not use), paid for all the same.
138
+ class Delivery
139
+ Entry = Struct.new(:result, :digest)
140
+
141
+ attr_reader :on_change
142
+
143
+ def initialize(entries_by_branch, on_change: :keep)
144
+ @entries = entries_by_branch.dup
145
+ @set_aside = {}
146
+ @on_change = on_change
147
+ @lock = Mutex.new
148
+ end
149
+
150
+ def take(branch_key) = @lock.synchronize { @entries.delete(branch_key) }
151
+
152
+ def for?(branch_key) = @lock.synchronize { @entries.key?(branch_key) }
153
+
154
+ def set_aside(branch_key, entry) = @lock.synchronize { @set_aside[branch_key] = entry }
155
+
156
+ def untaken
157
+ @lock.synchronize do
158
+ @set_aside.merge(@entries).tap do
159
+ @entries.clear
160
+ @set_aside.clear
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Budget
5
+ # What a provider request may spend (the hold a host's budget ledger is
6
+ # asked to admit) or did spend (what it is reconciled with): its input
7
+ # tokens (uncached, cache reads and cache writes together), its output
8
+ # tokens (thinking included), and its cost from the pricing catalog, nil
9
+ # when the model has no price there.
10
+ Amount = Data.define(:input_tokens, :output_tokens, :cost) do
11
+ def initialize(input_tokens:, output_tokens:, cost:)
12
+ unless [input_tokens, output_tokens].all? { _1.is_a?(Integer) && !_1.negative? }
13
+ raise ArgumentError, "a budget amount's token counts must be non-negative Integers"
14
+ end
15
+ unless cost.nil? || (cost.is_a?(Numeric) && cost.finite? && !cost.negative?)
16
+ raise ArgumentError, "a budget amount's cost must be nil or a non-negative number"
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ def tokens = input_tokens + output_tokens
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Smith
6
+ module Budget
7
+ # The rest of a tool loop that paused for budget: the messages a step's
8
+ # call added before a host's budget ledger refused a later round (the
9
+ # model's tool calls, their results, its thinking and its signatures),
10
+ # kept with the step's retry so that the retry continues the loop from
11
+ # them instead of running its tools again. JSON-shaped and at most
12
+ # MAX_BYTES; a loop whose messages cannot be kept so (an attachment in
13
+ # them, or too many bytes) does not pause. A response memo
14
+ # (Smith::ResponseMemo) keeps a call's messages the same way, with each
15
+ # response's `usage`: its token counts, stop reason and message id.
16
+ module Continuation
17
+ MAX_BYTES = 1024 * 1024
18
+ ROLES = %w[assistant tool user].freeze
19
+ TOKENS = %w[input_tokens output_tokens cached_tokens cache_creation_tokens thinking_tokens].freeze
20
+
21
+ module_function
22
+
23
+ # `kept` (an earlier pause's) followed by `messages`, or nil; with
24
+ # `usage`, each response's usage too (its facts from `facts`, by
25
+ # message, for a streamed response, whose facts its stream gave).
26
+ def dump(kept, messages, usage: false, facts: {})
27
+ dumped = messages.map { message(_1, usage: (facts[_1] || {} if usage)) || (return nil) }
28
+ list = JSON.parse(JSON.generate([*kept, *dumped]))
29
+ list if JSON.generate(list).bytesize <= MAX_BYTES
30
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
31
+ nil
32
+ end
33
+
34
+ def message(message, usage: nil)
35
+ content = content_of(message.content)
36
+ return if content == :unkept
37
+
38
+ { "role" => message.role.to_s, "content" => content, "tool_call_id" => message.tool_call_id,
39
+ "model_id" => message.model_id, "tool_calls" => message.tool_calls&.values&.map { tool_call(_1) },
40
+ "thinking" => thinking_of(message.thinking), "usage" => (usage_of(message, usage) if usage) }.compact
41
+ end
42
+
43
+ def usage_of(message, streamed)
44
+ tokens = message.respond_to?(:tokens) ? message.tokens&.to_h : nil
45
+ facts = Providers::ResponseFacts.from_message(message).merge(streamed)
46
+ usage = { "tokens" => tokens&.transform_keys(&:to_s), "stop_reason" => facts[:stop_reason],
47
+ "id" => facts[:provider_message_id] }.compact
48
+ usage unless usage.empty?
49
+ end
50
+
51
+ def thinking_of(thinking) = thinking && { "text" => thinking.text, "signature" => thinking.signature }.compact
52
+
53
+ def content_of(content)
54
+ case content
55
+ when nil, String then content
56
+ when RubyLLM::Content::Raw then { "raw" => content.value }
57
+ # A structured reply, as RubyLLM parses one under a schema.
58
+ when Hash, Array then { "json" => content }
59
+ else :unkept
60
+ end
61
+ end
62
+
63
+ def tool_call(call)
64
+ { "id" => call.id, "name" => call.name, "arguments" => call.arguments,
65
+ "thought_signature" => call.thought_signature }.compact
66
+ end
67
+
68
+ # The messages a kept continuation holds, for a chat; ArgumentError if
69
+ # it is not one `dump` makes.
70
+ def load(list)
71
+ raise ArgumentError, "a budget continuation must be a list of messages" unless valid?(list)
72
+
73
+ list.map { message_from(_1) }
74
+ end
75
+
76
+ def message_from(entry)
77
+ content = entry["content"]
78
+ message = RubyLLM::Message.new(
79
+ role: entry["role"].to_sym, content: content(content), tool_call_id: entry["tool_call_id"],
80
+ model_id: entry["model_id"], tool_calls: tool_calls(entry["tool_calls"]),
81
+ thinking: thinking(entry["thinking"]), **kept_usage(entry["usage"])
82
+ )
83
+ # RubyLLM's constructor reads a Hash as attachments.
84
+ message.content = content["json"] if structured?(content)
85
+ message
86
+ end
87
+
88
+ # A kept response's token counts, and its facts as a raw body
89
+ # Providers::ResponseFacts reads.
90
+ def kept_usage(usage)
91
+ return {} unless usage
92
+
93
+ { **usage.fetch("tokens", {}).transform_keys(&:to_sym),
94
+ raw: Providers::ResponseFacts.kept(stop_reason: usage["stop_reason"], id: usage["id"]) }
95
+ end
96
+
97
+ def valid?(list)
98
+ list.is_a?(Array) && !list.empty? && JSON.generate(list).bytesize <= MAX_BYTES && list.all? { entry?(_1) }
99
+ rescue JSON::GeneratorError, JSON::NestingError
100
+ false
101
+ end
102
+
103
+ def entry?(entry)
104
+ entry.is_a?(Hash) && ROLES.include?(entry["role"]) && content?(entry["content"]) &&
105
+ entry.values_at("tool_call_id", "model_id").all? { optional_string?(_1) } &&
106
+ calls?(entry["tool_calls"]) && thinking?(entry["thinking"]) && usage?(entry["usage"])
107
+ end
108
+
109
+ def usage?(usage)
110
+ return true if usage.nil?
111
+ return false unless usage.is_a?(Hash) && (usage.keys - %w[tokens stop_reason id]).empty?
112
+
113
+ tokens?(usage.fetch("tokens", {})) && optional_string?(usage["stop_reason"]) && optional_string?(usage["id"])
114
+ end
115
+
116
+ def tokens?(tokens)
117
+ tokens.is_a?(Hash) && (tokens.keys - TOKENS).empty? && tokens.values.all? { _1.is_a?(Integer) && !_1.negative? }
118
+ end
119
+
120
+ def calls?(calls) = calls.nil? || (calls.is_a?(Array) && calls.all? { call?(_1) })
121
+
122
+ def thinking?(thinking) = thinking.nil? || (thinking.is_a?(Hash) && thinking.values.all?(String))
123
+
124
+ def content?(content)
125
+ content.nil? || content.is_a?(String) || (content.is_a?(Hash) && [["raw"], ["json"]].include?(content.keys))
126
+ end
127
+
128
+ def structured?(content) = content.is_a?(Hash) && content.key?("json")
129
+
130
+ def call?(call)
131
+ call.is_a?(Hash) && call["id"].is_a?(String) && call["name"].is_a?(String) && call["arguments"].is_a?(Hash) &&
132
+ optional_string?(call["thought_signature"])
133
+ end
134
+
135
+ def optional_string?(value) = value.nil? || value.is_a?(String)
136
+
137
+ def content(value)
138
+ return value unless value.is_a?(Hash)
139
+
140
+ structured?(value) ? nil : RubyLLM::Content::Raw.new(value.fetch("raw"))
141
+ end
142
+
143
+ def tool_calls(calls)
144
+ calls&.to_h do |call|
145
+ [call["id"], RubyLLM::ToolCall.new(id: call["id"], name: call["name"], arguments: call["arguments"],
146
+ thought_signature: call["thought_signature"])]
147
+ end
148
+ end
149
+
150
+ def thinking(value) = value && RubyLLM::Thinking.new(text: value["text"], signature: value["signature"])
151
+ end
152
+ end
153
+ end
@@ -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
@@ -100,14 +100,14 @@ module Smith
100
100
  @representation.internalize(external)
101
101
  end
102
102
 
103
- def create_reservation(amounts)
103
+ def create_reservation(amounts, checked: true)
104
104
  token = Object.new.freeze
105
105
  receipt = Reservation.new(
106
106
  ledger_identity: @identity,
107
107
  token:,
108
108
  amounts: @representation.externalize(amounts)
109
109
  )
110
- next_state = @state_transition.reserve(@publication.state, amounts)
110
+ next_state = @state_transition.reserve(@publication.state, amounts, checked:)
111
111
  @publication.add(@public_state_validator.call(next_state), token:, amounts:)
112
112
  receipt
113
113
  end
@@ -136,3 +136,5 @@ module Smith
136
136
  end
137
137
  end
138
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