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,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:
|
|
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
|
|
|
@@ -9,10 +9,7 @@ module Smith
|
|
|
9
9
|
text = formatter.call(persisted).to_s
|
|
10
10
|
content = "#{MARKER}\n#{text}"
|
|
11
11
|
|
|
12
|
-
existing_index = messages.index
|
|
13
|
-
message_content = message[:content] || message["content"]
|
|
14
|
-
message_content.is_a?(String) && message_content.start_with?(MARKER)
|
|
15
|
-
end
|
|
12
|
+
existing_index = messages.index { |message| injected_state?(message) }
|
|
16
13
|
|
|
17
14
|
if blank?(text)
|
|
18
15
|
without_marker(messages, existing_index)
|
|
@@ -23,12 +20,20 @@ module Smith
|
|
|
23
20
|
end
|
|
24
21
|
end
|
|
25
22
|
|
|
23
|
+
# Only a system message carries the marker: hosts cannot admit system
|
|
24
|
+
# messages, so a user, assistant or tool message that starts with the
|
|
25
|
+
# marker is content, never Smith's injected state.
|
|
26
|
+
def self.injected_state?(message)
|
|
27
|
+
content = message[:content] || message["content"]
|
|
28
|
+
(message[:role] || message["role"]).to_s == "system" && content.is_a?(String) && content.start_with?(MARKER)
|
|
29
|
+
end
|
|
30
|
+
|
|
26
31
|
def self.blank?(text) = text.valid_encoding? && text.strip.empty?
|
|
27
32
|
|
|
28
33
|
def self.without_marker(messages, index)
|
|
29
34
|
index ? messages.dup.tap { |msgs| msgs.delete_at(index) } : messages
|
|
30
35
|
end
|
|
31
|
-
private_class_method :blank?, :without_marker
|
|
36
|
+
private_class_method :blank?, :without_marker, :injected_state?
|
|
32
37
|
end
|
|
33
38
|
end
|
|
34
39
|
end
|
data/lib/smith/context.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Smith
|
|
|
10
10
|
subclass.instance_variable_set(:@persist_mode, @persist_mode || :explicit)
|
|
11
11
|
subclass.instance_variable_set(:@persist_auto_seed, (@persist_auto_seed || []).dup)
|
|
12
12
|
subclass.instance_variable_set(:@inject_state, @inject_state)
|
|
13
|
+
subclass.instance_variable_set(:@untrusted_keys, (@untrusted_keys || []).dup)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def session_strategy(strategy = nil, **opts)
|
|
@@ -23,6 +24,7 @@ module Smith
|
|
|
23
24
|
return @persist_keys || []
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
validate_persist_keys!(keys)
|
|
26
28
|
if also && !keys.include?(:auto)
|
|
27
29
|
raise Smith::WorkflowError, ":also is only valid alongside :auto"
|
|
28
30
|
end
|
|
@@ -47,10 +49,36 @@ module Smith
|
|
|
47
49
|
@persist_mode || :explicit
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
def validate_persist_keys!(keys)
|
|
53
|
+
invalid = keys.grep_v(Symbol)
|
|
54
|
+
return if invalid.empty?
|
|
55
|
+
|
|
56
|
+
raise Smith::WorkflowError, "persist keys must be Symbols, got #{invalid.map(&:inspect).join(", ")}"
|
|
57
|
+
end
|
|
58
|
+
private :validate_persist_keys!
|
|
59
|
+
|
|
50
60
|
def persist_auto_seed
|
|
51
61
|
@persist_auto_seed || []
|
|
52
62
|
end
|
|
53
63
|
|
|
64
|
+
# Keys whose values come from outside the system (a fetched document,
|
|
65
|
+
# an inbound message), or that a step derived from such a value:
|
|
66
|
+
# `inject_state` formatters and the `seed_messages` builder never
|
|
67
|
+
# receive them, and an agent that names one in `inputs` is refused,
|
|
68
|
+
# in this workflow and in any workflow it nests.
|
|
69
|
+
def untrusted(*keys)
|
|
70
|
+
return untrusted_keys if keys.empty?
|
|
71
|
+
|
|
72
|
+
validate_persist_keys!(keys)
|
|
73
|
+
raise Smith::WorkflowError, "untrusted takes context keys, not :auto" if keys.include?(:auto)
|
|
74
|
+
|
|
75
|
+
@untrusted_keys = (untrusted_keys + keys).uniq
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def untrusted_keys
|
|
79
|
+
@untrusted_keys || []
|
|
80
|
+
end
|
|
81
|
+
|
|
54
82
|
def inject_state(&block)
|
|
55
83
|
return @inject_state unless block_given?
|
|
56
84
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Doctor
|
|
7
|
+
module Checks
|
|
8
|
+
# The artifact store: configured, buildable, usable, and as durable as
|
|
9
|
+
# the workflow state, since a run that waits, retries or resumes in
|
|
10
|
+
# another process reads its artifacts there.
|
|
11
|
+
module Artifacts
|
|
12
|
+
PROCESS_LOCAL = %w[Smith::Artifacts::Memory].freeze
|
|
13
|
+
COLUMNS = %w[ref data content_type execution_namespace created_at].freeze
|
|
14
|
+
|
|
15
|
+
def self.run(report)
|
|
16
|
+
raw = ::Smith.config.artifact_store
|
|
17
|
+
return report.add(**missing) if raw.nil? && !::Smith.config.test_mode
|
|
18
|
+
|
|
19
|
+
store = ::Smith.artifact_backend
|
|
20
|
+
report.add(name: "artifacts.store", status: :pass, message: "Artifact store: #{store_name(store)}")
|
|
21
|
+
check_usable(report, store)
|
|
22
|
+
check_durability(report, store)
|
|
23
|
+
check_ownership(report, store)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
report.add(name: "artifacts.store", status: :fail, message: "Artifact store configuration is invalid",
|
|
26
|
+
detail: e.message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.store_name(store) = store.is_a?(Module) ? store.name : store.class.name
|
|
30
|
+
|
|
31
|
+
def self.missing
|
|
32
|
+
{ name: "artifacts.store", status: :warn, message: "No artifact store configured",
|
|
33
|
+
detail: "Set config.artifact_store to :s3 (S3, R2), :active_record, :file, or a store, with " \
|
|
34
|
+
"config.artifact_options; steps that store artifacts or read documents fail without one" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The table an Active Record store writes, the directory a File
|
|
38
|
+
# store writes, the bucket an S3 store writes.
|
|
39
|
+
def self.check_usable(report, store)
|
|
40
|
+
problem = case store
|
|
41
|
+
when ::Smith::Artifacts::ActiveRecordStore then table_problem(store)
|
|
42
|
+
when ::Smith::Artifacts::File then directory_problem(store)
|
|
43
|
+
when ::Smith::Artifacts::S3 then bucket_problem(store)
|
|
44
|
+
end
|
|
45
|
+
return unless problem
|
|
46
|
+
|
|
47
|
+
report.add(name: "artifacts.usable", status: :warn, message: "The artifact store cannot be used",
|
|
48
|
+
detail: problem)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.table_problem(store)
|
|
52
|
+
missing = COLUMNS - store.model.column_names
|
|
53
|
+
"#{store.model.name} lacks the columns #{missing.join(", ")}" if missing.any?
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
"the store's model cannot be loaded: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.directory_problem(store)
|
|
59
|
+
dir = store.instance_variable_get(:@dir)
|
|
60
|
+
"the directory #{dir} is not writable" unless ::File.directory?(dir) && ::File.writable?(dir)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.bucket_problem(store)
|
|
64
|
+
client = store.instance_variable_get(:@client)
|
|
65
|
+
return unless client.respond_to?(:head_bucket)
|
|
66
|
+
|
|
67
|
+
client.head_bucket(bucket: store.instance_variable_get(:@bucket))
|
|
68
|
+
nil
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
"the bucket cannot be reached: #{e.class.name}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.check_durability(report, store)
|
|
74
|
+
adapter = ::Smith.config.persistence_adapter
|
|
75
|
+
return unless PROCESS_LOCAL.include?(store.class.name) && adapter && !process_local_state?(adapter)
|
|
76
|
+
|
|
77
|
+
report.add(
|
|
78
|
+
name: "artifacts.durability", status: :warn,
|
|
79
|
+
message: "Artifacts are kept in process memory while workflow state is persisted",
|
|
80
|
+
detail: "A run that waits, retries or resumes in another process loses its artifacts; configure :s3, " \
|
|
81
|
+
":active_record or :file"
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.process_local_state?(adapter)
|
|
86
|
+
adapter.to_s == "memory" || adapter.is_a?(::Smith::PersistenceAdapters::Memory)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.check_ownership(report, store)
|
|
90
|
+
return if store.respond_to?(:stored_by?)
|
|
91
|
+
|
|
92
|
+
report.add(
|
|
93
|
+
name: "artifacts.ownership", status: :warn,
|
|
94
|
+
message: "The artifact store cannot tell which run stored a ref (stored_by?)",
|
|
95
|
+
detail: "Runs read any ref under their prefix, and content parts cannot read documents from it"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
private_class_method :store_name, :missing, :check_usable, :table_problem, :directory_problem, :bucket_problem,
|
|
99
|
+
:check_durability, :process_local_state?, :check_ownership
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -6,7 +6,6 @@ module Smith
|
|
|
6
6
|
module Configuration
|
|
7
7
|
def self.run(report)
|
|
8
8
|
check_logger(report)
|
|
9
|
-
check_artifact_store(report)
|
|
10
9
|
check_trace_adapter(report)
|
|
11
10
|
check_pricing(report)
|
|
12
11
|
end
|
|
@@ -21,16 +20,6 @@ module Smith
|
|
|
21
20
|
)
|
|
22
21
|
end
|
|
23
22
|
|
|
24
|
-
def self.check_artifact_store(report)
|
|
25
|
-
configured = !::Smith.config.artifact_store.nil?
|
|
26
|
-
report.add(
|
|
27
|
-
name: "config.artifact_store",
|
|
28
|
-
status: configured ? :pass : :warn,
|
|
29
|
-
message: configured ? "Artifact store configured" : "No artifact store configured",
|
|
30
|
-
detail: configured ? nil : "Large outputs will use in-memory default"
|
|
31
|
-
)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
23
|
def self.check_trace_adapter(report)
|
|
35
24
|
configured = !::Smith.config.trace_adapter.nil?
|
|
36
25
|
report.add(
|