smith-agents 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +712 -0
- data/README.md +622 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +67 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +70 -3
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -51
- data/lib/smith/agent.rb +5 -0
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +10 -5
- data/lib/smith/context.rb +28 -0
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +20 -18
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +249 -2
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +11 -5
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +36 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +113 -17
- data/lib/smith.rb +101 -45
- 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
|
data/lib/smith/batch.rb
ADDED
|
@@ -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
|
data/lib/smith/budget/ledger.rb
CHANGED
|
@@ -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
|
|
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
|