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.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -0
  3. data/README.md +622 -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 +67 -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
@@ -242,9 +242,8 @@ specific forced tool takes precedence when only one enabled endpoint can carry
242
242
  it. If normalization removes a specifically forced tool, Smith clears that
243
243
  stale choice and attributes the reset only to that selected tool. An
244
244
  endpoint-constrained tool routed to OpenAI Responses inherits the current
245
- non-streaming Responses contract; callers requesting streaming receive the
246
- documented explicit unsupported error rather than silent Chat Completions
247
- fallback.
245
+ non-streaming Responses contract: a caller requesting streaming gets the whole
246
+ reply as one chunk, never a silent Chat Completions fallback.
248
247
 
249
248
  Bounded completion ownership and tool-call reservations mask asynchronous
250
249
  interrupts while installing and restoring process-local state. User work runs
@@ -270,6 +269,28 @@ authoritative replay proof. `ordinal` is allocated from one thread-safe sequence
270
269
  shared by every Smith-managed chat, branch, thread, and fiber inside the current
271
270
  host invocation scope.
272
271
 
272
+ For a key to send with a side-effecting request, a tool running in a workflow
273
+ step calls `idempotency_key(scope)` in `perform`:
274
+
275
+ ```ruby
276
+ class SendInvoice < Smith::Tool
277
+ def perform(customer_id:, amount_cents:)
278
+ Billing.send_invoice(customer_id:, amount_cents:,
279
+ idempotency_key: idempotency_key(customer_id:, amount_cents:))
280
+ end
281
+ end
282
+ ```
283
+
284
+ The key is unique to the run, the step and branch the tool runs in, the tool's
285
+ `name` and the scope, including in concurrent tool calls, and it is the same
286
+ when a replay sends the same request. The scope should be the request's stable
287
+ identity (customer and amount here), not free text such as a message body:
288
+ the model can call one tool several times in a step, and a replayed step's
289
+ model may word or order its calls differently. Renaming the tool class changes
290
+ its keys. Outside a workflow step it raises `Smith::WorkflowError`. Smith
291
+ supplies the key; holding operation receipts and reconciling outcomes stays
292
+ with the host, as above. See `docs/PERSISTENCE.md`, Replay Safety.
293
+
273
294
  Hosts that reconstruct an execution may supply an explicit next ordinal:
274
295
 
275
296
  ```ruby
@@ -1,22 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "rails/generators/active_record"
4
5
 
5
6
  module Smith
7
+ # `rails g smith:install`: the initializer, and a table and model for
8
+ # workflow state (Smith's :active_record persistence adapter) unless
9
+ # --skip-state-model.
6
10
  class InstallGenerator < Rails::Generators::Base
11
+ include Rails::Generators::Migration
12
+
13
+ # The most a PostgreSQL text column holds (1 GiB less a byte), the
14
+ # payload's limit when no bound is set.
15
+ UNBOUNDED_PAYLOAD = 1_073_741_823
16
+ # The default bound: well above what one step can add to a state (a
17
+ # map's collected results, 4 MiB; a wait's payload or a paused tool
18
+ # loop, 1 MiB each), so it refuses runaway growth, not a run Smith built.
19
+ MAX_STATE_BYTES = 16 * 1024 * 1024
20
+
7
21
  source_root File.expand_path("templates", __dir__)
22
+ desc "Writes Smith's initializer, and a table and model for workflow state"
23
+
24
+ class_option :state_model, type: :string, default: "WorkflowState",
25
+ desc: "The model that keeps workflow state"
26
+ class_option :max_state_bytes, type: :numeric, default: MAX_STATE_BYTES,
27
+ desc: "persistence_max_bytes, and the payload CHECK above it (0: neither)"
28
+ class_option :skip_state_model, type: :boolean, default: false,
29
+ desc: "Write only the initializer"
30
+
31
+ def self.next_migration_number(dirname) = ActiveRecord::Generators::Base.next_migration_number(dirname)
32
+
33
+ def check_options
34
+ bytes = options[:max_state_bytes]
35
+ return if bytes.is_a?(Integer) && !bytes.negative?
36
+
37
+ raise Thor::Error, "--max-state-bytes must be a whole number of bytes (0 for no bound), got #{bytes.inspect}"
38
+ end
8
39
 
9
40
  def create_smith_initializer
10
41
  template "smith.rb.tt", "config/initializers/smith.rb"
11
42
  end
12
43
 
44
+ def create_state_model
45
+ return if options[:skip_state_model]
46
+
47
+ migration_template "create_workflow_states.rb.tt", "db/migrate/create_#{table_name}.rb"
48
+ template "workflow_state.rb.tt", File.join("app/models", "#{state_model.underscore}.rb")
49
+ end
50
+
13
51
  def show_next_steps
14
52
  say ""
15
53
  say "Smith installed. Next steps:", :green
16
54
  say " 1. Configure RubyLLM in config/initializers/ruby_llm.rb"
17
- say " 2. Run: bin/rails smith:doctor"
18
- say " 3. Define your first agent and workflow"
55
+ say " 2. Run: bin/rails db:migrate" unless options[:skip_state_model]
56
+ say " #{options[:skip_state_model] ? 2 : 3}. Run: bin/rails smith:doctor"
19
57
  say ""
20
58
  end
59
+
60
+ private
61
+
62
+ def state_model = options[:state_model].camelize
63
+
64
+ # The table's name, set on the model: a namespace's own
65
+ # table_name_prefix cannot move it.
66
+ def table_name = state_model.demodulize.underscore.pluralize
67
+
68
+ def namespaces = state_model.split("::")[0...-1]
69
+
70
+ def max_state_bytes = options[:max_state_bytes]
71
+
72
+ # The payload CHECK's bound: past persistence_max_bytes by a third and
73
+ # a little, so Smith's refusal comes first even for a column the host
74
+ # encrypts (Base64 ciphertext with its headers).
75
+ def check_bytes
76
+ (max_state_bytes * 4 / 3) + 4096 if max_state_bytes.positive?
77
+ end
78
+
79
+ # The payload column's limit: MySQL picks its text type by it (TEXT
80
+ # holds only 64 KiB); PostgreSQL refuses one past 1 GiB and otherwise
81
+ # ignores it, as SQLite does.
82
+ def payload_limit = [check_bytes || UNBOUNDED_PAYLOAD, UNBOUNDED_PAYLOAD].min
83
+
84
+ # An Integer as a Ruby literal: 16_777_216.
85
+ def literal(number) = number.to_s.gsub(/\B(?=(\d{3})+(?!\d))/, "_")
21
86
  end
22
87
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Workflow runs' stored state, for Smith's :active_record persistence
4
+ # adapter: a key the database keeps unique (child runs' keys are 64
5
+ # characters), the state as JSON text, and the lock_version Smith's writes
6
+ # compare. The payload's limit makes MySQL choose a text type that holds it.
7
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
8
+ def change
9
+ create_table :<%= table_name %> do |t|
10
+ t.string :key, null: false, index: { unique: true }
11
+ t.text :payload, null: false, limit: <%= literal(payload_limit) %>
12
+ t.integer :lock_version, null: false, default: 0
13
+ t.timestamps
14
+ end
15
+ <%- if check_bytes -%>
16
+
17
+ # Smith refuses a state past persistence_max_bytes (<%= literal(max_state_bytes) %>) before
18
+ # writing it; this bounds the stored bytes, with room for an encrypted column.
19
+ add_check_constraint :<%= table_name %>, "#{payload_bytes} <= <%= check_bytes %>",
20
+ name: "<%= table_name %>_payload_size"
21
+ <%- end -%>
22
+ end
23
+ <%- if check_bytes -%>
24
+
25
+ private
26
+
27
+ def payload_bytes
28
+ connection.adapter_name.match?(/sqlite/i) ? "length(CAST(payload AS BLOB))" : "octet_length(payload)"
29
+ end
30
+ <%- end -%>
31
+ end
@@ -3,7 +3,21 @@
3
3
  Smith.configure do |config|
4
4
  config.logger = Rails.logger
5
5
 
6
+ <%- unless options[:skip_state_model] -%>
7
+ # Workflow state in the <%= table_name %> table, through <%= state_model %>.
8
+ config.persistence_adapter = :active_record
9
+ config.persistence_options = { model: "<%= state_model %>" }
10
+ <%- if max_state_bytes.positive? -%>
11
+ # The most bytes a run's state may take: past it Smith raises
12
+ # Smith::PersistedStateTooLarge before writing. The payload column's
13
+ # CHECK sits above it.
14
+ config.persistence_max_bytes = <%= literal(max_state_bytes) %>
15
+ <%- end -%>
16
+
17
+ # Other persistence adapters:
18
+ <%- else -%>
6
19
  # Host durability verification / persistence adapter options:
20
+ <%- end -%>
7
21
  # config.persistence_adapter = :rails_cache
8
22
  # config.persistence_options = { namespace: "smith" }
9
23
  #
@@ -16,29 +30,46 @@ Smith.configure do |config|
16
30
  # namespace: "smith"
17
31
  # }
18
32
  #
19
- # config.persistence_adapter = :active_record
33
+ <%- if options[:skip_state_model] -%>
34
+ # config.persistence_adapter = :active_record # `rails g smith:install` writes its table and model
20
35
  # config.persistence_options = {
21
- # model: WorkflowState,
36
+ # model: "WorkflowState",
22
37
  # key_column: :key,
23
38
  # payload_column: :payload
24
39
  # }
25
40
  #
41
+ <%- end -%>
26
42
  # Custom adapters are also supported if they implement:
27
43
  # store(key, payload)
28
44
  # fetch(key)
29
45
  # delete(key)
30
46
 
31
- # Artifact storage for large outputs
32
- config.artifact_store = Smith::Artifacts::Memory.new
47
+ # Artifact storage for large outputs and the documents steps attach. Like
48
+ # workflow state it must outlive the process for runs that wait, retry or
49
+ # resume elsewhere; with none set, storing or reading an artifact raises
50
+ # Smith::ArtifactStoreMissing (smith:doctor warns).
51
+ #
52
+ # config.artifact_store = :s3 # AWS S3, or Cloudflare R2 (needs the aws-sdk-s3 gem)
53
+ # config.artifact_options = {
54
+ # bucket: "smith-artifacts",
55
+ # endpoint: "https://<account>.r2.cloudflarestorage.com", region: "auto",
56
+ # access_key_id: ENV["R2_ACCESS_KEY_ID"], secret_access_key: ENV["R2_SECRET_ACCESS_KEY"]
57
+ # }
58
+ #
59
+ # config.artifact_store = :active_record # a table: see the PERSISTENCE guide, Artifacts
60
+ # config.artifact_options = { model: "SmithArtifact" }
61
+ #
62
+ # config.artifact_store = :memory # one process only, e.g. in tests
33
63
 
34
64
  # Trace adapter (Smith::Trace::Memory, Smith::Trace::Logger, Smith::Trace::OpenTelemetry)
35
65
  config.trace_adapter = Smith::Trace::Logger
36
66
 
37
67
  # Best-known model-call cost tracking (optional)
38
- # config.pricing = {
39
- # "gpt-4.1-nano" => {
68
+ # config.pricing = Smith::Pricing.validate_catalog!(
69
+ # %w[openai gpt-4.1-nano] => {
40
70
  # input_cost_per_token: 0.0000001,
41
- # output_cost_per_token: 0.0000004
71
+ # output_cost_per_token: 0.0000004,
72
+ # cache_read_cost_per_token: 0.000000025 # optional; 0 when absent
42
73
  # }
43
- # }
74
+ # )
44
75
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ <%- namespaces.each_with_index do |namespace, depth| -%>
4
+ <%= " " * depth %>module <%= namespace %>
5
+ <%- end -%>
6
+ <%- indent = " " * namespaces.length -%>
7
+ <%= indent %># A workflow run's stored state (Smith's :active_record persistence
8
+ <%= indent %># adapter). Keep it to persistence: Smith writes it under optimistic
9
+ <%= indent %># locking, and a callback that raises after the commit leaves a write it
10
+ <%= indent %># cannot confirm. An encrypted payload (`encrypts`) holds runs, but not
11
+ <%= indent %># split-step exact writes (smith doctor warns).
12
+ <%= indent %>class <%= state_model.demodulize %> < ApplicationRecord
13
+ <%- unless namespaces.empty? -%>
14
+ <%= indent %> self.table_name = "<%= table_name %>"
15
+ <%- end -%>
16
+ <%= indent %>end
17
+ <%- namespaces.length.times.reverse_each do |depth| -%>
18
+ <%= " " * depth %>end
19
+ <%- end -%>
@@ -10,18 +10,27 @@ module Smith
10
10
  attribute :response, Types::Any
11
11
  attribute :provider_usages, Types::Array.of(Types.Instance(ProviderUsage))
12
12
  attribute :usage_complete, Types::Bool
13
+ # The stop reason and message id of the last provider response,
14
+ # whether or not that response reported usage.
15
+ attribute :response_facts, Types::Hash.default({}.freeze)
13
16
 
14
- def self.from_messages(response:, messages:)
17
+ # `stream` is set for a streamed call: the facts of each of its
18
+ # requests, read from the stream, and the request's output limit
19
+ # (Providers::StreamFacts). Each response's usage carries its facts and
20
+ # `mode: :stream`. `mode` marks another call's usage (:batch).
21
+ def self.from_messages(response:, messages:, stream: nil, mode: nil)
15
22
  assistant_messages = Array(messages).select do |message|
16
23
  message.respond_to?(:role) && message.role.to_s == "assistant"
17
24
  end
18
25
  usage_source = assistant_messages.empty? ? [response] : assistant_messages
26
+ facts = response_facts(usage_source, stream)
19
27
 
20
- provider_usages = build_provider_usages(usage_source)
28
+ provider_usages = build_provider_usages(usage_source, stream && facts, mode)
21
29
  new(
22
30
  response: response,
23
31
  provider_usages:,
24
- usage_complete: provider_usages.length == usage_source.length
32
+ usage_complete: provider_usages.length == usage_source.length,
33
+ response_facts: facts.last
25
34
  )
26
35
  end
27
36
 
@@ -37,10 +46,27 @@ module Smith
37
46
  provider_usages.sum(&:output_tokens) if usage_complete
38
47
  end
39
48
 
40
- def self.build_provider_usages(messages)
41
- messages.filter_map { |message| ProviderUsage.from_message(message) }.freeze
49
+ # The whole completion's usage, cache and thinking counts and the
50
+ # final response's stop reason included; nil unless every response
51
+ # reported usage.
52
+ def usage
53
+ ProviderUsage.sum(provider_usages) if usage_complete
42
54
  end
43
- private_class_method :build_provider_usages
55
+
56
+ def self.response_facts(messages, stream)
57
+ return messages.map { Providers::ResponseFacts.from_message(_1) } unless stream
58
+
59
+ Providers::StreamFacts.per_message(messages, stream.fetch(:rounds), stream.fetch(:output_limit))
60
+ end
61
+
62
+ def self.build_provider_usages(messages, streamed_facts, mode)
63
+ messages.each_with_index.filter_map do |message, index|
64
+ usage = ProviderUsage.from_message(message)
65
+ usage = usage.streamed(streamed_facts[index]) if streamed_facts && usage
66
+ mode && usage ? usage.new(mode:) : usage
67
+ end.freeze
68
+ end
69
+ private_class_method :response_facts, :build_provider_usages
44
70
  end
45
71
  end
46
72
  end
@@ -5,33 +5,42 @@ module Smith
5
5
  module CompletionUsageRecording
6
6
  private
7
7
 
8
- # Returns [invocation_cost, fully_priced]. The cost is the sum of the
9
- # recorded entries' costs (nil when nothing was priced): per-response
10
- # pricing is what tiered catalogs bill, so the invocation cost comes
11
- # from this sum, never from pricing the aggregate token totals (which
12
- # would resolve the wrong tier for multi-response tool loops).
13
- # fully_priced is true only when every provider response carried usage
14
- # and every usage priced; a partial sum is still returned (it is what
15
- # was verifiably billed) but callers must not present it as the
16
- # complete invocation cost.
17
- def record_completion_usage(agent_class, completion, attempt_kind, model_reference, attempt_id: nil)
8
+ # Records and prices one usage entry per provider response and returns
9
+ # the per-response AgentResults: per-response pricing is what tiered
10
+ # catalogs bill, so an invocation's cost is the sum of these, never the
11
+ # aggregate token totals priced as one call (which would resolve the
12
+ # wrong tier for a multi-response tool loop).
13
+ def record_response_usages(agent_class, usages, attempt_kind, model_reference, attempt_id: nil)
18
14
  model_reference = coerce_model_reference(model_reference)
19
- costs = completion.provider_usages.map do |usage|
15
+ usages.map do |usage|
20
16
  result = Workflow::AgentResult.new(
21
17
  content: nil,
22
18
  input_tokens: usage.input_tokens,
23
19
  output_tokens: usage.output_tokens,
24
20
  cost: nil,
25
21
  model_used: model_reference.model_id,
26
- provider_used: model_reference.provider
22
+ provider_used: model_reference.provider,
23
+ **Workflow::AgentResult.usage_details(usage)
27
24
  )
28
25
  compute_agent_cost(result)
29
26
  record_usage(agent_class, result, attempt_kind, model_reference, attempt_id:)
30
- result.cost
27
+ result
31
28
  end
32
- summarize_invocation_costs(costs, completion)
33
29
  end
34
30
 
31
+ # Returns [invocation_cost, fully_priced, per_response_results].
32
+ def record_completion_usage(agent_class, completion, attempt_kind, model_reference, attempt_id: nil)
33
+ results = record_response_usages(agent_class, completion.provider_usages, attempt_kind, model_reference,
34
+ attempt_id:)
35
+ [*summarize_invocation_costs(results.map(&:cost), completion), results]
36
+ end
37
+
38
+ # Returns [invocation_cost, fully_priced]. The cost is the sum of the
39
+ # priced responses (nil when none was priced). fully_priced is true
40
+ # only when every provider response carried usage and every usage
41
+ # priced; a partial sum is still returned (it is what was verifiably
42
+ # billed) but callers must not present it as the complete invocation
43
+ # cost.
35
44
  def summarize_invocation_costs(costs, completion)
36
45
  priced = costs.compact
37
46
  invocation_cost = priced.empty? ? nil : priced.sum
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # The documents and images of one agent invocation, read from the run's
6
+ # artifacts once, before its first attempt, and checked: readable, a
7
+ # non-empty String, of the kind its media type names (a PDF, PNG, JPEG,
8
+ # GIF or WebP by its leading bytes, `text/plain` as UTF-8), an image at
9
+ # most MAX_IMAGE_BYTES and any part at most Content::MAX_PART_BYTES, and
10
+ # the request's text with its documents and images as they are sent
11
+ # (PDFs and images in base64) at most MAX_REQUEST_BYTES. Anything else
12
+ # raises Smith::ContentUnavailable; the store's own errors pass on.
13
+ # Held per thread for the invocation, so parallel branches read their
14
+ # own.
15
+ module ContentBytes
16
+ MAX_IMAGE_BYTES = 5 * 1024 * 1024
17
+ MAX_REQUEST_BYTES = 32 * 1024 * 1024
18
+ SIGNATURES = {
19
+ "application/pdf" => ->(bytes) { bytes.start_with?("%PDF-".b) },
20
+ "image/png" => ->(bytes) { bytes.start_with?("\x89PNG\r\n\x1A\n".b) },
21
+ "image/jpeg" => ->(bytes) { bytes.start_with?("\xFF\xD8\xFF".b) },
22
+ "image/gif" => ->(bytes) { bytes.start_with?("GIF87a".b, "GIF89a".b) },
23
+ "image/webp" => ->(bytes) { bytes.byteslice(0, 4) == "RIFF".b && bytes.byteslice(8, 4) == "WEBP".b },
24
+ "text/plain" => ->(bytes) { bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? }
25
+ }.freeze
26
+ HELD = :smith_content_bytes
27
+ private_constant :SIGNATURES, :HELD
28
+
29
+ module_function
30
+
31
+ def with(messages)
32
+ outer = Thread.current[HELD]
33
+ Thread.current[HELD] = read(Array(messages))
34
+ yield
35
+ ensure
36
+ Thread.current[HELD] = outer
37
+ end
38
+
39
+ # A part's bytes, as the invocation read them, or read now.
40
+ def fetch(part)
41
+ held = Thread.current[HELD]
42
+ key = [part["artifact"], part["media_type"]]
43
+ held&.key?(key) ? held.fetch(key) : checked_bytes(part)
44
+ end
45
+
46
+ def read(messages)
47
+ held = {}
48
+ total = 0
49
+ messages.each do |message|
50
+ next unless Message.parts?(message)
51
+
52
+ (message[:content] || message["content"]).map { |part| Content.check!(part) }.each do |part|
53
+ total += sent_bytes(part, held)
54
+ next if total <= MAX_REQUEST_BYTES
55
+
56
+ raise ContentUnavailable, "the request's text, documents and images come to more than " \
57
+ "#{MAX_REQUEST_BYTES} bytes as sent"
58
+ end
59
+ end
60
+ held.freeze
61
+ end
62
+
63
+ def sent_bytes(part, held)
64
+ return part["text"].bytesize if part["type"] == "text"
65
+
66
+ bytes = (held[[part["artifact"], part["media_type"]]] ||= checked_bytes(part))
67
+ part["media_type"] == "text/plain" ? bytes.bytesize : 4 * ((bytes.bytesize + 2) / 3)
68
+ end
69
+
70
+ def checked_bytes(part)
71
+ bytes = stored_bytes(part)
72
+ limit = part["type"] == "image" ? MAX_IMAGE_BYTES : Content::MAX_PART_BYTES
73
+ problem = if !bytes.is_a?(String) then "is missing or expired, or not a String"
74
+ elsif bytes.empty? then "is empty"
75
+ elsif bytes.bytesize > limit then "is larger than #{limit} bytes"
76
+ elsif !SIGNATURES.fetch(part["media_type"]).call(bytes.b) then "is not #{part["media_type"]}"
77
+ end
78
+ return bytes unless problem
79
+
80
+ raise ContentUnavailable, "the artifact of a #{part["type"]} part #{problem}"
81
+ end
82
+
83
+ # Through the run's scoped store, which must be able to tell which run
84
+ # stored a ref. The store's own errors (a timeout, a missing store)
85
+ # pass on as they are, before any provider attempt, so `retry_on` can
86
+ # take a transient one.
87
+ def stored_bytes(part)
88
+ store = Smith.scoped_artifacts
89
+ unless store.respond_to?(:vouches?) && store.vouches?
90
+ raise ContentUnavailable, "content parts are read inside a step, from an artifact store that records " \
91
+ "which run stored each artifact (stored_by?)"
92
+ end
93
+
94
+ store.fetch(part["artifact"])
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "stringio"
5
+
6
+ module Smith
7
+ class Agent
8
+ # A message with content parts (Smith::Message) as the provider takes
9
+ # it, built for each attempt's chat. Anthropic gets native blocks
10
+ # (`RubyLLM::Content::Raw`), a cached part with `cache_control`, at most
11
+ # MAX_CACHE_BREAKPOINTS cached blocks in the request (Smith's and the
12
+ # chat's own). Any other provider gets the parts as one text, in order,
13
+ # with a `text/plain` document inline and each PDF or image as an
14
+ # attachment named where it stands in the text; `cache:` is ignored.
15
+ #
16
+ # The documents and images of one invocation are read from the run's
17
+ # artifacts once, before the first attempt (ContentBytes), so a store
18
+ # failure is never taken for a provider failure.
19
+ module ContentParts
20
+ MAX_CACHE_BREAKPOINTS = 4
21
+ EXTENSIONS = { "application/pdf" => "pdf", "text/plain" => "txt", "image/png" => "png",
22
+ "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
23
+ private_constant :MAX_CACHE_BREAKPOINTS, :EXTENSIONS
24
+
25
+ private
26
+
27
+ # Counts the cached blocks already in the chat (its instructions) and
28
+ # those the prepared input adds, before any is added.
29
+ def check_cache_breakpoints!(chat, messages, provider)
30
+ return unless anthropic?(provider)
31
+
32
+ cached = chat_cache_controls(chat) +
33
+ messages.sum { |message| message_parts(message).count { |part| Content.cached?(part) } }
34
+ return if cached <= MAX_CACHE_BREAKPOINTS
35
+
36
+ raise ContentUnavailable, "#{cached} blocks ask to be cached; Anthropic takes at most " \
37
+ "#{MAX_CACHE_BREAKPOINTS} cache breakpoints in one request"
38
+ end
39
+
40
+ def check_chain_cache_breakpoints!(chain, prepared_input)
41
+ return unless chain.any? { |reference| anthropic?(reference.provider) }
42
+
43
+ check_cache_breakpoints!(nil, Array(prepared_input), :anthropic)
44
+ end
45
+
46
+ # A model the chain reaches whose provider takes no such content.
47
+ def unsupported_content?(error) = error.is_a?(::RubyLLM::UnsupportedAttachmentError)
48
+
49
+ def chat_cache_controls(chat)
50
+ return 0 unless chat.respond_to?(:messages)
51
+
52
+ chat.messages.sum do |message|
53
+ content = message.respond_to?(:content) ? message.content : nil
54
+ content.is_a?(::RubyLLM::Content::Raw) ? Array(content.value).count { |block| cached_block?(block) } : 0
55
+ end
56
+ end
57
+
58
+ def cached_block?(block) = block.is_a?(Hash) && !(block[:cache_control] || block["cache_control"]).nil?
59
+
60
+ # A parts message's parts: checked where they came in (input:, seeds,
61
+ # restore) and again before the first attempt (ContentBytes).
62
+ def message_parts(message)
63
+ return [] unless Message.parts?(message)
64
+
65
+ message[:content] || message["content"]
66
+ end
67
+
68
+ BUILT = :smith_built_contents
69
+ private_constant :BUILT
70
+
71
+ # The contents Smith builds while it adds one prepared input to a
72
+ # chat, held per thread for that call.
73
+ def with_built_contents
74
+ outer = Thread.current[BUILT]
75
+ Thread.current[BUILT] = {}.compare_by_identity
76
+ yield
77
+ ensure
78
+ Thread.current[BUILT] = outer
79
+ end
80
+
81
+ # The message with its parts as `provider` takes them; the content is
82
+ # recorded as Smith's own, so it passes to the chat unencoded.
83
+ def provider_parts_message(message, provider)
84
+ parts = message_parts(message)
85
+ content = anthropic?(provider) ? anthropic_content(parts) : provider_content(parts)
86
+ Thread.current[BUILT]&.store(content, true)
87
+ { role: Workflow::MessageRoles.role(message).to_sym, content: }
88
+ end
89
+
90
+ def built_content?(content) = Thread.current[BUILT]&.key?(content) || false
91
+
92
+ def anthropic?(provider) = provider.to_s == "anthropic"
93
+
94
+ def anthropic_content(parts)
95
+ ::RubyLLM::Content::Raw.new(parts.map { |part| anthropic_block(part) })
96
+ end
97
+
98
+ def anthropic_block(part)
99
+ block = case part["type"]
100
+ when "text" then { type: "text", text: part["text"] }
101
+ when "image" then { type: "image", source: base64_source(part) }
102
+ else document_block(part)
103
+ end
104
+ Content.cached?(part) ? block.merge(cache_control: { type: "ephemeral" }) : block
105
+ end
106
+
107
+ def document_block(part)
108
+ if part["media_type"] == "text/plain"
109
+ return { type: "document", source: { type: "text", media_type: "text/plain", data: artifact_text(part) } }
110
+ end
111
+
112
+ { type: "document", source: base64_source(part) }
113
+ end
114
+
115
+ def base64_source(part)
116
+ { type: "base64", media_type: part["media_type"], data: Base64.strict_encode64(artifact_bytes(part)) }
117
+ end
118
+
119
+ # The parts in order as one text: text parts as they are, a
120
+ # `text/plain` document inline, and a marker where each PDF or image
121
+ # stands, attached under the marker's name.
122
+ def provider_content(parts)
123
+ texts = []
124
+ attachments = []
125
+ parts.each.with_index(1) { |part, position| add_provider_part(part, position, texts, attachments) }
126
+ text = texts.join("\n\n")
127
+ return text if attachments.empty?
128
+
129
+ ::RubyLLM::Content.new(text.empty? ? nil : text, attachments)
130
+ end
131
+
132
+ def add_provider_part(part, position, texts, attachments)
133
+ return texts << part["text"] if part["type"] == "text"
134
+
135
+ name = "#{part["type"]}-#{position}.#{EXTENSIONS.fetch(part["media_type"])}"
136
+ if part["media_type"] == "text/plain"
137
+ texts << "<file name='#{name}' mime_type='text/plain'>#{artifact_text(part)}</file>"
138
+ else
139
+ texts << "[attached: #{name}]"
140
+ attachments << ::RubyLLM::Attachment.new(StringIO.new(artifact_bytes(part)), filename: name)
141
+ end
142
+ end
143
+
144
+ def artifact_text(part) = artifact_bytes(part).dup.force_encoding(Encoding::UTF_8)
145
+
146
+ def artifact_bytes(part) = ContentBytes.fetch(part)
147
+ end
148
+ end
149
+ end