smith-agents 0.10.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  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/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. metadata +88 -1
@@ -2,27 +2,75 @@
2
2
 
3
3
  module Smith
4
4
  module Artifacts
5
+ # A run's view of the artifact store: it stores under its own
6
+ # namespace, and reads its own refs and those of the namespaces it may
7
+ # read (a child run's parent and that parent's ancestors). A store that
8
+ # records who stored a ref (`stored_by?`, as every built-in store does)
9
+ # is asked, so a ref names an artifact one of those runs stored and no
10
+ # other; with any other store the namespace prefix alone decides a read,
11
+ # and a delete is refused.
12
+ # Without a `backend:`, the configured store is resolved when a step
13
+ # first stores or reads, so a run that keeps no artifacts needs none.
5
14
  class ScopedStore
6
- def initialize(backend:, namespace:)
15
+ def initialize(namespace:, backend: nil, readable: [])
7
16
  @backend = backend
8
17
  @namespace = namespace
18
+ @readable = [namespace, *readable].freeze
9
19
  end
10
20
 
11
21
  def store(data, content_type: "application/octet-stream")
12
- inner_ref = @backend.store(data, content_type: content_type, execution_namespace: @namespace)
22
+ inner_ref = backend.store(data, content_type: content_type, execution_namespace: @namespace)
13
23
  "#{@namespace}:#{inner_ref}"
14
24
  end
15
25
 
16
26
  def fetch(ref)
17
- return nil unless ref.start_with?("#{@namespace}:")
27
+ return unless ref.is_a?(String)
18
28
 
19
- inner_ref = ref.delete_prefix("#{@namespace}:")
20
- @backend.fetch(inner_ref)
29
+ namespace = @readable.find { |candidate| ref.start_with?("#{candidate}:") }
30
+ return nil unless namespace
31
+
32
+ inner = ref.delete_prefix("#{namespace}:")
33
+ return nil if vouches? && !backend.stored_by?(inner, namespace)
34
+
35
+ backend.fetch(inner)
36
+ end
37
+
38
+ # Removes one of this run's own refs (never a readable ancestor's or
39
+ # another run's): only through a store that can tell which run stored
40
+ # a ref. A store whose delete takes the run (every built-in one) is
41
+ # asked to remove only what this run stored, and Memory drops only
42
+ # this run's claim on bytes other runs share; any other store deletes
43
+ # a ref this run stored.
44
+ def delete(ref)
45
+ unless vouches? && backend.respond_to?(:delete)
46
+ raise Smith::Error, "deleting a run's artifact needs a store with stored_by? and delete"
47
+ end
48
+ return unless ref.is_a?(String)
49
+
50
+ inner = ref.delete_prefix("#{@namespace}:")
51
+ if per_run_delete? then backend.delete(inner, execution_namespace: @namespace)
52
+ elsif backend.stored_by?(inner, @namespace) then backend.delete(inner)
53
+ end
54
+ nil
21
55
  end
22
56
 
57
+ # Whether the store can tell which run stored a ref.
58
+ def vouches? = backend.respond_to?(:stored_by?)
59
+
23
60
  def expired(retention: nil)
24
- @backend.expired(retention: retention, execution_namespace: @namespace)
25
- .map { |ref| "#{@namespace}:#{ref}" }
61
+ raise Smith::Error, "the artifact store does not list expired refs" unless backend.respond_to?(:expired)
62
+
63
+ backend.expired(retention: retention, execution_namespace: @namespace).map { |ref| "#{@namespace}:#{ref}" }
64
+ end
65
+
66
+ private
67
+
68
+ def backend = @backend ||= Smith.artifact_backend
69
+
70
+ def per_run_delete?
71
+ backend.method(:delete).parameters.any? do |kind, name|
72
+ %i[key keyreq].include?(kind) && name == :execution_namespace
73
+ end
26
74
  end
27
75
  end
28
76
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Artifacts
5
+ # What every built-in store holds to: a tenant `namespace:` of letters,
6
+ # digits, `-` and `_` (so its refs stay readable), the
7
+ # `artifact_tenant_isolation` setting (refuse to work without a
8
+ # namespace), refs that are the store's own (its tenant's prefix, then
9
+ # the store's ref shape, nothing else), the `artifact_retention`
10
+ # setting when no retention is given, and no run behind a ref whose run
11
+ # is not named.
12
+ module StoreRules
13
+ NAMESPACE = /\A[A-Za-z0-9_-]+\z/
14
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
15
+
16
+ private
17
+
18
+ def check_namespace!(namespace)
19
+ return namespace if namespace.nil? || (namespace.is_a?(String) && NAMESPACE.match?(namespace))
20
+
21
+ raise ArgumentError, "an artifact store's namespace: must be letters, digits, - and _"
22
+ end
23
+
24
+ def enforce_tenant_isolation!
25
+ return unless Smith.config.artifact_tenant_isolation
26
+
27
+ raise Smith::Error, "artifact_tenant_isolation requires a namespace" unless @namespace
28
+ end
29
+
30
+ # The store's tenant prefix, then a ref of the store's shape.
31
+ def owns_ref?(ref)
32
+ return false unless ref.is_a?(String)
33
+
34
+ inner = @namespace ? ref.delete_prefix("#{@namespace}:") : ref
35
+ (!@namespace || inner != ref) && self.class::INNER_REF.match?(inner)
36
+ end
37
+
38
+ def effective_retention(retention) = retention || Smith.config.artifact_retention
39
+
40
+ # An artifact is bytes: other values would be stored as whatever a
41
+ # store makes of them (Ruby's inspect, or a refusal).
42
+ def check_data!(data)
43
+ raise TypeError, "an artifact is bytes (a String); serialize other values first" unless data.is_a?(String)
44
+ end
45
+
46
+ def generate_ref(raw) = @namespace ? "#{@namespace}:#{raw}" : raw
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- module Artifacts; end
4
+ # Where steps keep payloads too large to keep inline, and the documents
5
+ # and images content parts name. Like workflow state, artifacts must
6
+ # outlive the process that stored them whenever a run waits, retries or
7
+ # resumes elsewhere, so the store is configured, never assumed:
8
+ #
9
+ # Smith.configure do |config|
10
+ # config.artifact_store = :s3 # or :active_record, :file, :memory, or a store
11
+ # config.artifact_options = { bucket: "smith-artifacts",
12
+ # endpoint: "https://<account>.r2.cloudflarestorage.com" }
13
+ # end
14
+ #
15
+ # With nothing configured, `Smith.artifacts` raises
16
+ # Smith::ArtifactStoreMissing, except in `test_mode`, where it is an
17
+ # in-process Memory store.
18
+ module Artifacts
19
+ REQUIRED_METHODS = %i[store fetch].freeze
20
+ NAMED = { memory: :Memory, file: :File, s3: :S3, active_record: :ActiveRecordStore }.freeze
21
+
22
+ # The store a configuration names: a store itself (an object, or a
23
+ # class or module whose own methods store and fetch), a store class
24
+ # (built with the options), or one of :memory, :file, :s3 and
25
+ # :active_record. A store needs `store` and `fetch`; `stored_by?` lets
26
+ # runs read only their own refs, and `expired` and `delete` serve
27
+ # cleanup.
28
+ def self.resolve(store, **)
29
+ return if store.nil?
30
+ return store if REQUIRED_METHODS.all? { |method_name| store.respond_to?(method_name) }
31
+ return validate!(store.new(**)) if store.is_a?(Class)
32
+ return validate!(store) unless store.is_a?(Symbol) || store.is_a?(String)
33
+
34
+ named(store, **)
35
+ end
36
+
37
+ def self.named(store, **)
38
+ name = NAMED.fetch(store.to_sym) do
39
+ raise ArgumentError, "unknown artifact store #{store.inspect}; use :s3, :active_record, :file or :memory"
40
+ end
41
+ const_get(name).new(**)
42
+ end
43
+
44
+ def self.validate!(store)
45
+ missing = REQUIRED_METHODS.reject { |method_name| store.respond_to?(method_name) }
46
+ return store if missing.empty?
47
+
48
+ raise ArgumentError, "an artifact store must implement #{missing.join(", ")}"
49
+ end
50
+ end
51
+
52
+ # Inside a step, the run's scoped view of the artifact store; elsewhere
53
+ # the store itself.
54
+ def self.artifacts
55
+ scoped_artifacts || artifact_backend
56
+ end
57
+
58
+ # The configured store itself, never a run's scoped view of it.
59
+ # Built once per configuration, under a lock, so threads that resolve it
60
+ # together share one store.
61
+ def self.artifact_backend
62
+ signature = artifact_signature
63
+ current = @_artifact_current
64
+ return current.last if current&.first == signature
65
+
66
+ (@_artifact_lock ||= Mutex.new).synchronize do
67
+ current = @_artifact_current
68
+ return current.last if current&.first == signature
69
+
70
+ @_artifact_current = [signature, build_artifact_backend].freeze
71
+ @_artifact_current.last
72
+ end
73
+ end
74
+
75
+ def self.build_artifact_backend
76
+ resolved = Artifacts.resolve(config.artifact_store, **(config.artifact_options || {}))
77
+ resolved ||= Artifacts::Memory.new if config.test_mode
78
+ resolved || raise(ArtifactStoreMissing)
79
+ end
80
+ private_class_method :build_artifact_backend
81
+
82
+ # A configured store object is compared by identity; a name or class by
83
+ # value, with its options; test_mode matters only when nothing is set.
84
+ def self.artifact_signature
85
+ store = config.artifact_store
86
+ name = store.is_a?(Symbol) || store.is_a?(String) || store.is_a?(Module) ? snapshot_value(store) : store.object_id
87
+ [name, snapshot_value(config.artifact_options || {}), store.nil? && config.test_mode]
88
+ end
89
+ private_class_method :artifact_signature
90
+
91
+ def self.artifacts=(store)
92
+ config.artifact_store = store
93
+ end
94
+
95
+ def self.scoped_artifacts
96
+ Thread.current[:smith_scoped_artifacts]
97
+ end
98
+
99
+ def self.scoped_artifacts=(store)
100
+ Thread.current[:smith_scoped_artifacts] = store
101
+ end
5
102
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Batch
5
+ # Whether a succeeded result's message has the shape of an Anthropic
6
+ # Messages reply, as the provider's parser reads it: `content` an Array of
7
+ # blocks, each a Hash with a String `type` (a text block's `text` a
8
+ # String), and `usage` a Hash whose token counts are non-negative
9
+ # Integers. Anything else is refused before the step runs.
10
+ module MessageShape
11
+ COUNTS = %w[input_tokens output_tokens].freeze
12
+ OPTIONAL_COUNTS = %w[cache_read_input_tokens cache_creation_input_tokens].freeze
13
+ private_constant :COUNTS, :OPTIONAL_COUNTS
14
+
15
+ module_function
16
+
17
+ def valid?(message)
18
+ message.is_a?(Hash) && message["content"].is_a?(Array) && message["content"].all? { block?(_1) } &&
19
+ usage?(message["usage"])
20
+ end
21
+
22
+ def block?(block)
23
+ return false unless block.is_a?(Hash) && block["type"].is_a?(String)
24
+
25
+ block["type"] != "text" || block["text"].is_a?(String)
26
+ end
27
+
28
+ def usage?(usage)
29
+ usage.is_a?(Hash) && COUNTS.all? { count?(usage[_1]) } &&
30
+ OPTIONAL_COUNTS.all? { usage[_1].nil? || count?(usage[_1]) }
31
+ end
32
+
33
+ def count?(value) = value.is_a?(Integer) && !value.negative?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "diagnostic_text"
7
+ require_relative "batch/message_shape"
8
+
9
+ module Smith
10
+ # Batch mode (`execute Agent, mode: :batch`, and the same on `fan_out`): a
11
+ # step's provider request is sent through the host's batch queue instead of
12
+ # over the wire, and the step waits for its result. Smith renders exactly
13
+ # the request a synchronous call would post, hands it to
14
+ # `Smith.config.batch_queue.enqueue(custom_id:, await_token:, request:,
15
+ # execution_key:)` and waits on `:batch_result`; the host submits batches
16
+ # (Smith::Providers::Anthropic::Batches), collects their results, and
17
+ # resumes the run with every result of its wait. Smith never submits or
18
+ # polls, and waits for the results without sleeping (an in-process
19
+ # `retry_on` backoff sleeps as on any step; `durable: true` waits instead).
20
+ module Batch
21
+ EVENT = "batch_result"
22
+ RESULT_TYPES = %w[succeeded errored canceled expired].freeze
23
+ MISSING = "missing"
24
+ MAX_REQUEST_BYTES = 32 * 1024 * 1024
25
+ MAX_RESULTS_BYTES = 64 * 1024 * 1024
26
+ INVALID_REQUEST = "invalid_request_error"
27
+ CREDENTIAL_HEADERS = %w[authorization x-api-key x-goog-api-key].freeze
28
+
29
+ module_function
30
+
31
+ # The request a batch carries for one call: the provider, the payload it
32
+ # would post, and the call's own headers (never credentials: the
33
+ # connection's stay in it, and a call's own are refused), JSON-shaped,
34
+ # bounded and frozen.
35
+ def request(provider:, params:, headers:)
36
+ if headers.to_h.keys.any? { CREDENTIAL_HEADERS.include?(_1.to_s.downcase) }
37
+ raise WorkflowError, "a batch request cannot carry credentials in its headers"
38
+ end
39
+
40
+ encoded = JSON.generate("provider" => provider.to_s, "params" => params, "headers" => headers.to_h)
41
+ if encoded.bytesize > MAX_REQUEST_BYTES
42
+ raise WorkflowError, "a batch request is #{encoded.bytesize} bytes, past the #{MAX_REQUEST_BYTES} Smith sends"
43
+ end
44
+
45
+ deep_freeze(JSON.parse(encoded))
46
+ end
47
+
48
+ # What identifies a request's content: a SHA-256 of its payload and
49
+ # headers as JSON gives them, so the request a delivery answers can be
50
+ # told from one the step would render now.
51
+ def digest(request) = digest_of(request.fetch("params"), request.fetch("headers"))
52
+
53
+ # Whether a call renders a request other than the one sent (`digest`).
54
+ # Nothing is sent, so nothing in it is refused; one JSON cannot give
55
+ # counts as changed.
56
+ def changed?(digest, params, headers)
57
+ digest_of(params, headers.to_h) != digest
58
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
59
+ true
60
+ end
61
+
62
+ def digest_of(params, headers) = Digest::SHA256.hexdigest(JSON.generate([params, headers]))
63
+
64
+ # A step's request id: its wait token, and for a fan-out branch a key of
65
+ # the token and the branch (64 hex characters, a valid custom_id).
66
+ def custom_id(token, branch_key = nil)
67
+ branch_key.nil? ? token : Digest::SHA256.hexdigest("#{token}/#{branch_key}")
68
+ end
69
+
70
+ # The delivered results, checked: one per expected id, each a result of
71
+ # a known type, keys as JSON gives them. Raises ArgumentError otherwise.
72
+ def results!(payload, expected_ids)
73
+ results = stringified(payload)
74
+ unless results.keys.sort == expected_ids.sort
75
+ raise ArgumentError, "a batch delivery must hold exactly the results of its #{expected_ids.length} requests"
76
+ end
77
+
78
+ results.each_value { |result| check_result!(result) }
79
+ deep_freeze(results)
80
+ end
81
+
82
+ # The message of a succeeded result, or the error a failed one raises:
83
+ # BatchRequestInvalid for a request the provider refused, else
84
+ # BatchResultFailed (retryable).
85
+ def message!(result)
86
+ case result.fetch("type")
87
+ when "succeeded" then result.fetch("message")
88
+ when "errored" then raise_errored!(result)
89
+ else raise BatchResultFailed.new(kind: result.fetch("type"))
90
+ end
91
+ end
92
+
93
+ def raise_errored!(result)
94
+ error = result["error"]
95
+ type = error.dig("error", "type") || error["type"] if error.is_a?(Hash)
96
+ raise BatchRequestInvalid if type == INVALID_REQUEST
97
+
98
+ raise BatchResultFailed.new(kind: "errored", error_type: type.is_a?(String) ? type : nil)
99
+ end
100
+
101
+ def check_result!(result)
102
+ raise ArgumentError, "a batch result must be a Hash with a type" unless result.is_a?(Hash)
103
+
104
+ type = result["type"]
105
+ raise ArgumentError, "a batch result's type must be one of #{RESULT_TYPES.join(", ")}" unless
106
+ RESULT_TYPES.include?(type) || type == MISSING
107
+ return unless type == "succeeded" && !MessageShape.valid?(result["message"])
108
+
109
+ raise ArgumentError, "a succeeded batch result must carry a message of the provider's reply shape"
110
+ end
111
+
112
+ def stringified(payload)
113
+ raise ArgumentError, "a batch delivery must be a Hash of request ids to results" unless payload.is_a?(Hash)
114
+
115
+ encoded = JSON.generate(payload)
116
+ raise ArgumentError, "a batch delivery is past #{MAX_RESULTS_BYTES} bytes" if encoded.bytesize > MAX_RESULTS_BYTES
117
+
118
+ JSON.parse(encoded)
119
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
120
+ raise ArgumentError, "a batch delivery must be JSON-shaped"
121
+ end
122
+
123
+ def deep_freeze(value)
124
+ case value
125
+ when Hash then value.each_value { deep_freeze(_1) }.freeze
126
+ when Array then value.each { deep_freeze(_1) }.freeze
127
+ else value.freeze
128
+ end
129
+ end
130
+
131
+ # The results a resumed step takes, one per branch (nil for a step
132
+ # without branches), each with the digest of the request it answers, and
133
+ # what a result does when its call renders another request
134
+ # (`on_change`). Each is taken once: a follow-up call the step makes
135
+ # after it (a repair) goes over the wire. `untaken` is what no call took
136
+ # (a branch cancelled by a sibling's failure) or set aside (a result its
137
+ # call would not use), paid for all the same.
138
+ class Delivery
139
+ Entry = Struct.new(:result, :digest)
140
+
141
+ attr_reader :on_change
142
+
143
+ def initialize(entries_by_branch, on_change: :keep)
144
+ @entries = entries_by_branch.dup
145
+ @set_aside = {}
146
+ @on_change = on_change
147
+ @lock = Mutex.new
148
+ end
149
+
150
+ def take(branch_key) = @lock.synchronize { @entries.delete(branch_key) }
151
+
152
+ def for?(branch_key) = @lock.synchronize { @entries.key?(branch_key) }
153
+
154
+ def set_aside(branch_key, entry) = @lock.synchronize { @set_aside[branch_key] = entry }
155
+
156
+ def untaken
157
+ @lock.synchronize do
158
+ @set_aside.merge(@entries).tap do
159
+ @entries.clear
160
+ @set_aside.clear
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Budget
5
+ # What a provider request may spend (the hold a host's budget ledger is
6
+ # asked to admit) or did spend (what it is reconciled with): its input
7
+ # tokens (uncached, cache reads and cache writes together), its output
8
+ # tokens (thinking included), and its cost from the pricing catalog, nil
9
+ # when the model has no price there.
10
+ Amount = Data.define(:input_tokens, :output_tokens, :cost) do
11
+ def initialize(input_tokens:, output_tokens:, cost:)
12
+ unless [input_tokens, output_tokens].all? { _1.is_a?(Integer) && !_1.negative? }
13
+ raise ArgumentError, "a budget amount's token counts must be non-negative Integers"
14
+ end
15
+ unless cost.nil? || (cost.is_a?(Numeric) && cost.finite? && !cost.negative?)
16
+ raise ArgumentError, "a budget amount's cost must be nil or a non-negative number"
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ def tokens = input_tokens + output_tokens
23
+ end
24
+ end
25
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bigdecimal"
4
4
  require "dry-initializer"
5
+ require_relative "decimal_context"
5
6
 
6
7
  module Smith
7
8
  module Budget
@@ -26,6 +27,25 @@ module Smith
26
27
  finite_float!(amount)
27
28
  end
28
29
 
30
+ # Remaining capacity, or one of parts equal shares of it, is reserved as
31
+ # read, so parts reservations of the external value must never exceed the
32
+ # exact amount. An Integer keeps floor division; a Float steps down until
33
+ # its decimal form fits, which takes O(1) steps because Float division
34
+ # lands within one ulp of the exact share.
35
+ def externalize_remaining(key, amount, parts = 1)
36
+ unless parts.is_a?(Integer) && parts.positive?
37
+ raise ArgumentError, "budget share parts must be a positive Integer"
38
+ end
39
+
40
+ share = externalize_amount(key, amount) / parts
41
+ return share unless share.is_a?(Float)
42
+
43
+ DecimalContext.call do
44
+ share = share.prev_float while internalize_amount(share) * parts > amount
45
+ end
46
+ share
47
+ end
48
+
29
49
  private
30
50
 
31
51
  def internalize_amount(amount)
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Smith
6
+ module Budget
7
+ # The rest of a tool loop that paused for budget: the messages a step's
8
+ # call added before a host's budget ledger refused a later round (the
9
+ # model's tool calls, their results, its thinking and its signatures),
10
+ # kept with the step's retry so that the retry continues the loop from
11
+ # them instead of running its tools again. JSON-shaped and at most
12
+ # MAX_BYTES; a loop whose messages cannot be kept so (an attachment in
13
+ # them, or too many bytes) does not pause. A response memo
14
+ # (Smith::ResponseMemo) keeps a call's messages the same way, with each
15
+ # response's `usage`: its token counts, stop reason and message id.
16
+ module Continuation
17
+ MAX_BYTES = 1024 * 1024
18
+ ROLES = %w[assistant tool user].freeze
19
+ TOKENS = %w[input_tokens output_tokens cached_tokens cache_creation_tokens thinking_tokens].freeze
20
+
21
+ module_function
22
+
23
+ # `kept` (an earlier pause's) followed by `messages`, or nil; with
24
+ # `usage`, each response's usage too (its facts from `facts`, by
25
+ # message, for a streamed response, whose facts its stream gave).
26
+ def dump(kept, messages, usage: false, facts: {})
27
+ dumped = messages.map { message(_1, usage: (facts[_1] || {} if usage)) || (return nil) }
28
+ list = JSON.parse(JSON.generate([*kept, *dumped]))
29
+ list if JSON.generate(list).bytesize <= MAX_BYTES
30
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
31
+ nil
32
+ end
33
+
34
+ def message(message, usage: nil)
35
+ content = content_of(message.content)
36
+ return if content == :unkept
37
+
38
+ { "role" => message.role.to_s, "content" => content, "tool_call_id" => message.tool_call_id,
39
+ "model_id" => message.model_id, "tool_calls" => message.tool_calls&.values&.map { tool_call(_1) },
40
+ "thinking" => thinking_of(message.thinking), "usage" => (usage_of(message, usage) if usage) }.compact
41
+ end
42
+
43
+ def usage_of(message, streamed)
44
+ tokens = message.respond_to?(:tokens) ? message.tokens&.to_h : nil
45
+ facts = Providers::ResponseFacts.from_message(message).merge(streamed)
46
+ usage = { "tokens" => tokens&.transform_keys(&:to_s), "stop_reason" => facts[:stop_reason],
47
+ "id" => facts[:provider_message_id] }.compact
48
+ usage unless usage.empty?
49
+ end
50
+
51
+ def thinking_of(thinking) = thinking && { "text" => thinking.text, "signature" => thinking.signature }.compact
52
+
53
+ def content_of(content)
54
+ case content
55
+ when nil, String then content
56
+ when RubyLLM::Content::Raw then { "raw" => content.value }
57
+ # A structured reply, as RubyLLM parses one under a schema.
58
+ when Hash, Array then { "json" => content }
59
+ else :unkept
60
+ end
61
+ end
62
+
63
+ def tool_call(call)
64
+ { "id" => call.id, "name" => call.name, "arguments" => call.arguments,
65
+ "thought_signature" => call.thought_signature }.compact
66
+ end
67
+
68
+ # The messages a kept continuation holds, for a chat; ArgumentError if
69
+ # it is not one `dump` makes.
70
+ def load(list)
71
+ raise ArgumentError, "a budget continuation must be a list of messages" unless valid?(list)
72
+
73
+ list.map { message_from(_1) }
74
+ end
75
+
76
+ def message_from(entry)
77
+ content = entry["content"]
78
+ message = RubyLLM::Message.new(
79
+ role: entry["role"].to_sym, content: content(content), tool_call_id: entry["tool_call_id"],
80
+ model_id: entry["model_id"], tool_calls: tool_calls(entry["tool_calls"]),
81
+ thinking: thinking(entry["thinking"]), **kept_usage(entry["usage"])
82
+ )
83
+ # RubyLLM's constructor reads a Hash as attachments.
84
+ message.content = content["json"] if structured?(content)
85
+ message
86
+ end
87
+
88
+ # A kept response's token counts, and its facts as a raw body
89
+ # Providers::ResponseFacts reads.
90
+ def kept_usage(usage)
91
+ return {} unless usage
92
+
93
+ { **usage.fetch("tokens", {}).transform_keys(&:to_sym),
94
+ raw: Providers::ResponseFacts.kept(stop_reason: usage["stop_reason"], id: usage["id"]) }
95
+ end
96
+
97
+ def valid?(list)
98
+ list.is_a?(Array) && !list.empty? && JSON.generate(list).bytesize <= MAX_BYTES && list.all? { entry?(_1) }
99
+ rescue JSON::GeneratorError, JSON::NestingError
100
+ false
101
+ end
102
+
103
+ def entry?(entry)
104
+ entry.is_a?(Hash) && ROLES.include?(entry["role"]) && content?(entry["content"]) &&
105
+ entry.values_at("tool_call_id", "model_id").all? { optional_string?(_1) } &&
106
+ calls?(entry["tool_calls"]) && thinking?(entry["thinking"]) && usage?(entry["usage"])
107
+ end
108
+
109
+ def usage?(usage)
110
+ return true if usage.nil?
111
+ return false unless usage.is_a?(Hash) && (usage.keys - %w[tokens stop_reason id]).empty?
112
+
113
+ tokens?(usage.fetch("tokens", {})) && optional_string?(usage["stop_reason"]) && optional_string?(usage["id"])
114
+ end
115
+
116
+ def tokens?(tokens)
117
+ tokens.is_a?(Hash) && (tokens.keys - TOKENS).empty? && tokens.values.all? { _1.is_a?(Integer) && !_1.negative? }
118
+ end
119
+
120
+ def calls?(calls) = calls.nil? || (calls.is_a?(Array) && calls.all? { call?(_1) })
121
+
122
+ def thinking?(thinking) = thinking.nil? || (thinking.is_a?(Hash) && thinking.values.all?(String))
123
+
124
+ def content?(content)
125
+ content.nil? || content.is_a?(String) || (content.is_a?(Hash) && [["raw"], ["json"]].include?(content.keys))
126
+ end
127
+
128
+ def structured?(content) = content.is_a?(Hash) && content.key?("json")
129
+
130
+ def call?(call)
131
+ call.is_a?(Hash) && call["id"].is_a?(String) && call["name"].is_a?(String) && call["arguments"].is_a?(Hash) &&
132
+ optional_string?(call["thought_signature"])
133
+ end
134
+
135
+ def optional_string?(value) = value.nil? || value.is_a?(String)
136
+
137
+ def content(value)
138
+ return value unless value.is_a?(Hash)
139
+
140
+ structured?(value) ? nil : RubyLLM::Content::Raw.new(value.fetch("raw"))
141
+ end
142
+
143
+ def tool_calls(calls)
144
+ calls&.to_h do |call|
145
+ [call["id"], RubyLLM::ToolCall.new(id: call["id"], name: call["name"], arguments: call["arguments"],
146
+ thought_signature: call["thought_signature"])]
147
+ end
148
+ end
149
+
150
+ def thinking(value) = value && RubyLLM::Thinking.new(text: value["text"], signature: value["signature"])
151
+ end
152
+ end
153
+ end