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