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
@@ -13,61 +13,70 @@ module Smith
13
13
 
14
14
  private
15
15
 
16
+ # A failed attempt's own reported usage, cache counts included, is
17
+ # recorded, priced and settled into the budget like any billed
18
+ # response, so its usage entry agrees with its :provider_call trace.
16
19
  def account_failed_attempt(error, model_reference, agent_class, attempt_id: nil)
17
- return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
20
+ usage = ProviderUsage.from_message(error)
21
+ return unless usage
18
22
 
19
- input = error.input_tokens
20
- output = error.output_tokens
21
- return unless input.is_a?(Integer) && output.is_a?(Integer)
23
+ record_billed_usages(agent_class, [usage], :failed_attempt, model_reference, attempt_id:)
24
+ end
22
25
 
23
- record_failed_usage(agent_class, model_reference, input, output, attempt_id:)
26
+ # The completed rounds of a tool loop whose attempt then failed were
27
+ # billed: they are recorded and settled into the budget like a failed
28
+ # attempt's usage. Returns the provider usages recorded, so the
29
+ # attempt's trace carries the same totals its usage entries hold.
30
+ def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream_rounds: nil)
31
+ stream = stream_rounds && { rounds: stream_rounds, output_limit: nil }
32
+ completion = Completion.from_messages(response: nil, messages:, stream:)
33
+ record_billed_usages(agent_class, completion.provider_usages, :partial_attempt, model_reference, attempt_id:)
34
+ completion.provider_usages
24
35
  end
25
36
 
26
- def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil)
27
- completion = Completion.from_messages(response: nil, messages: messages)
28
- record_completion_usage(agent_class, completion, :partial_attempt, model_reference, attempt_id:)
37
+ # Usage that is billed but is not the invocation's result joins the
38
+ # failed billable attempts, which budget settlement reads beside it.
39
+ def record_billed_usages(agent_class, usages, attempt_kind, model_reference, attempt_id:)
40
+ results = record_response_usages(agent_class, usages, attempt_kind, model_reference, attempt_id:)
41
+ add_failed_agent_results(results)
29
42
  end
30
43
 
31
- def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_id: nil)
32
- model_reference = coerce_model_reference(model_reference)
33
- cost = Smith::Pricing.compute_cost(
34
- model: model_reference.model_id,
35
- provider: model_reference.provider,
36
- input_tokens:,
37
- output_tokens:
38
- )
39
- agent_result = Workflow::AgentResult.new(
40
- content: nil,
41
- input_tokens:,
42
- output_tokens:,
43
- cost: cost,
44
- model_used: model_reference.model_id,
45
- provider_used: model_reference.provider
46
- )
47
- Thread.current[:smith_failed_agent_results] ||= []
48
- Thread.current[:smith_failed_agent_results] << agent_result
49
- record_usage(agent_class, agent_result, :failed_attempt, model_reference, attempt_id:)
44
+ # A new Array, never the one in place: a step's thread context
45
+ # (ThreadContextSnapshot) puts back the list it started with, which
46
+ # must not carry this step's failed attempts into the next.
47
+ def add_failed_agent_results(results)
48
+ Thread.current[:smith_failed_agent_results] = [*Array(Thread.current[:smith_failed_agent_results]), *results]
50
49
  end
51
50
 
52
- def snapshot_and_finalize(agent_class, completion, model_reference, attempt_id: nil)
51
+ # Records and settles one completed attempt's usage and returns its
52
+ # result, whether or not the result becomes the output.
53
+ def account_attempt(agent_class, completion, model_reference, attempt_id: nil)
53
54
  model_reference = coerce_model_reference(model_reference)
54
- agent_result = Workflow::AgentResult.new(
55
- content: completion.content,
56
- input_tokens: completion.input_tokens,
57
- output_tokens: completion.output_tokens,
58
- cost: nil,
59
- model_used: model_reference.model_id,
60
- provider_used: model_reference.provider
61
- )
55
+ agent_result = completion_agent_result(completion, model_reference)
62
56
  Thread.current[:smith_last_agent_result] = agent_result
63
57
  emit_token_usage(agent_result)
64
58
  account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
59
+ agent_result
60
+ end
65
61
 
66
- agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
62
+ # The output has been through `after_completion` (OutputIntegrity).
63
+ def finalize_output(agent_class, agent_result)
67
64
  raise_blank_output!(agent_class, agent_result)
68
65
  agent_result
69
66
  end
70
67
 
68
+ def completion_agent_result(completion, model_reference)
69
+ Workflow::AgentResult.new(
70
+ content: completion.content,
71
+ input_tokens: completion.input_tokens,
72
+ output_tokens: completion.output_tokens,
73
+ cost: nil,
74
+ model_used: model_reference.model_id,
75
+ provider_used: model_reference.provider,
76
+ **Workflow::AgentResult.usage_details(completion.usage)
77
+ )
78
+ end
79
+
71
80
  def raise_blank_output!(agent_class, agent_result)
72
81
  return unless blank_agent_output?(agent_result.content)
73
82
 
@@ -90,26 +99,33 @@ module Smith
90
99
  # budget settlement and result surfaces read) and the recorded entries,
91
100
  # the budget ledger, and the :cost trace all agree. The trace emits
92
101
  # only for fully metered, fully priced invocations, so a partial
93
- # figure is never presented as the invocation cost; the partial sum
94
- # still settles the budget because it is what was verifiably billed.
102
+ # figure is never presented as the invocation cost; the partial sums
103
+ # of cost and tokens still settle the budget because they are what
104
+ # was verifiably billed.
95
105
  def account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
96
- invocation_cost, fully_priced = record_completion_usage(
106
+ invocation_cost, fully_priced, results = record_completion_usage(
97
107
  agent_class, completion, :completed_attempt, model_reference, attempt_id:
98
108
  )
99
109
  agent_result.cost = invocation_cost
100
- emit_cost_trace(agent_result, invocation_cost) if fully_priced
110
+ agent_result.metered_tokens = results.sum(&:total_tokens) unless completion.usage_complete
111
+ return unless fully_priced
112
+
113
+ emit_cost_trace(agent_result, invocation_cost, cache_unpriced: results.any?(&:cache_unpriced))
101
114
  end
102
115
 
116
+ # Prices the result and notes whether cache tokens were priced at 0
117
+ # for lack of a rate, which the :cost trace reports.
103
118
  def compute_agent_cost(agent_result)
104
119
  return unless agent_result.usage_known?
105
120
 
106
- model = agent_result.model_used
107
- agent_result.cost = Smith::Pricing.compute_cost(
108
- model: model,
109
- provider: agent_result.provider_used,
110
- input_tokens: agent_result.input_tokens,
111
- output_tokens: agent_result.output_tokens
112
- )
121
+ usage = {
122
+ model: agent_result.model_used, provider: agent_result.provider_used,
123
+ input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens,
124
+ cache_read_tokens: agent_result.cache_read_tokens || 0,
125
+ cache_write_tokens: agent_result.cache_write_tokens || 0
126
+ }
127
+ agent_result.cost = Smith::Pricing.compute_cost(**usage, mode: agent_result.mode || :sync)
128
+ agent_result.cache_unpriced = Smith::Pricing.cache_unpriced?(**usage.except(:output_tokens))
113
129
  end
114
130
  end
115
131
  end
data/lib/smith/agent.rb CHANGED
@@ -11,9 +11,10 @@ module Smith
11
11
  EXECUTION_IDENTITY_UNSET = Object.new.freeze
12
12
  TOOL_BUDGET_EXHAUSTION_UNSET = Object.new.freeze
13
13
  TOOL_BUDGET_EXHAUSTION_POLICIES = %i[raise complete].freeze
14
+ BUDGET_KEYS = %i[token_limit cost wall_clock tool_calls total_tokens total_cost].freeze
14
15
 
15
16
  private_constant :EXECUTION_IDENTITY_UNSET
16
- private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
17
+ private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES, :BUDGET_KEYS
17
18
 
18
19
  # Reserved input names auto-injected by the normalizer into
19
20
  # runtime_context. User-side `inputs :name` calls cannot redeclare
@@ -30,8 +31,12 @@ module Smith
30
31
  subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
31
32
  subclass.instance_variable_set(:@data_volume, @data_volume)
32
33
  subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
34
+ subclass.instance_variable_set(:@fallback_models_block, @fallback_models_block)
33
35
  subclass.instance_variable_set(:@model_block, @model_block)
34
36
  subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
37
+ %i[@on_truncation @on_refusal @output_contract @output_repairs].each do |name|
38
+ subclass.instance_variable_set(name, instance_variable_get(name))
39
+ end
35
40
  subclass.instance_variable_set(:@execution_identity, nil)
36
41
  subclass.instance_variable_set(:@registered_name, nil)
37
42
  end
@@ -48,6 +53,12 @@ module Smith
48
53
  def budget(**opts)
49
54
  return @budget_config if opts.empty?
50
55
 
56
+ unknown = opts.keys - BUDGET_KEYS
57
+ unless unknown.empty?
58
+ raise ArgumentError, "agent budget does not accept #{unknown.map(&:inspect).join(", ")}; " \
59
+ "accepted keys are #{BUDGET_KEYS.map(&:inspect).join(", ")}"
60
+ end
61
+
51
62
  @budget_config = opts
52
63
  end
53
64
 
@@ -140,8 +151,10 @@ require_relative "agent/dynamic_configuration"
140
151
  require_relative "agent/fallback_configuration"
141
152
  require_relative "agent/reserved_input_bridge"
142
153
  require_relative "agent/chat_construction"
154
+ require_relative "agent/output_policy"
143
155
 
144
156
  Smith::Agent.extend(Smith::Agent::DynamicConfiguration)
145
157
  Smith::Agent.extend(Smith::Agent::FallbackConfiguration)
146
158
  Smith::Agent.extend(Smith::Agent::ReservedInputBridge)
147
159
  Smith::Agent.extend(Smith::Agent::ChatConstruction)
160
+ Smith::Agent.extend(Smith::Agent::OutputPolicy)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "store_rules"
6
+
7
+ module Smith
8
+ module Artifacts
9
+ # An artifact store in a database table, through the host's Active
10
+ # Record model (a class or its name):
11
+ #
12
+ # create_table :smith_artifacts do |t|
13
+ # t.string :ref, null: false, index: { unique: true }
14
+ # t.binary :data, null: false, limit: 64.megabytes # MySQL's blob holds 64 KiB
15
+ # t.string :content_type, null: false
16
+ # t.string :execution_namespace, index: true
17
+ # t.timestamps
18
+ # end
19
+ #
20
+ # Each artifact is one row, its ref a random UUID (after the store's
21
+ # tenant `namespace:` when one is set), with the run that stored it, so
22
+ # the store can say which run stored a ref.
23
+ class ActiveRecordStore
24
+ include StoreRules
25
+
26
+ INNER_REF = StoreRules::UUID
27
+
28
+ def initialize(model:, namespace: nil)
29
+ @model_source = model.is_a?(String) ? model.dup.freeze : model
30
+ @namespace = check_namespace!(namespace)
31
+ end
32
+
33
+ def store(data, content_type: "application/octet-stream", execution_namespace: nil)
34
+ enforce_tenant_isolation!
35
+ check_data!(data)
36
+ ref = generate_ref(SecureRandom.uuid)
37
+ model_class.create!(ref:, data:, content_type:, execution_namespace:)
38
+ ref
39
+ end
40
+
41
+ def fetch(ref)
42
+ enforce_tenant_isolation!
43
+ model_class.find_by(ref:)&.data if owns_ref?(ref)
44
+ end
45
+
46
+ # Whether a run (its execution namespace) stored this ref.
47
+ def stored_by?(ref, execution_namespace)
48
+ !execution_namespace.nil? && owns_ref?(ref) && model_class.where(ref:, execution_namespace:).exists?
49
+ end
50
+
51
+ def model = model_class
52
+
53
+ def expired(retention: nil, execution_namespace: nil)
54
+ retention = effective_retention(retention)
55
+ return [] unless retention
56
+
57
+ scope = model_class.where(model_class.arel_table[:created_at].lt(Time.now.utc - retention))
58
+ scope = scope.where(execution_namespace:) if execution_namespace
59
+ scope.pluck(:ref).select { |ref| owns_ref?(ref) }
60
+ end
61
+
62
+ # Removes the artifact (when a run is named, only if that run stored
63
+ # it).
64
+ def delete(ref, execution_namespace: nil)
65
+ enforce_tenant_isolation!
66
+ return unless owns_ref?(ref)
67
+
68
+ scope = model_class.where(ref:)
69
+ scope = scope.where(execution_namespace:) if execution_namespace
70
+ scope.delete_all
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ def model_class = @model_source.is_a?(String) ? Object.const_get(@model_source) : @model_source
77
+ end
78
+ end
79
+ end
@@ -1,38 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require "fileutils"
4
4
  require "json"
5
+ require "securerandom"
6
+
7
+ require_relative "store_rules"
5
8
 
6
9
  module Smith
7
10
  module Artifacts
11
+ # An artifact store in a directory (`dir:`), shared only when the
12
+ # directory is. Each artifact is a file named by a random UUID (after
13
+ # the store's tenant `namespace:` when one is set) beside a `.meta`
14
+ # file with its type and the run that stored it; no ref names any other
15
+ # file.
8
16
  class File
17
+ include StoreRules
18
+
19
+ INNER_REF = StoreRules::UUID
20
+
9
21
  def initialize(dir:, namespace: nil)
10
22
  @dir = dir
11
- @namespace = namespace
23
+ @namespace = check_namespace!(namespace)
12
24
  end
13
25
 
14
26
  def store(data, content_type: "application/octet-stream", execution_namespace: nil)
15
- ref = generate_ref
27
+ enforce_tenant_isolation!
28
+ check_data!(data)
29
+ ref = generate_ref(SecureRandom.uuid)
16
30
  meta = { content_type: content_type }
17
31
  meta[:execution_namespace] = execution_namespace if execution_namespace
18
- ::File.write(::File.join(@dir, ref), data)
32
+ ::File.binwrite(::File.join(@dir, ref), data)
19
33
  ::File.write(::File.join(@dir, "#{ref}.meta"), JSON.generate(meta))
20
34
  ref
21
35
  end
22
36
 
23
37
  def fetch(ref)
38
+ enforce_tenant_isolation!
39
+ return unless owns_ref?(ref)
40
+
24
41
  path = ::File.join(@dir, ref)
25
- ::File.exist?(path) ? ::File.read(path) : nil
42
+ ::File.binread(path) if ::File.file?(path)
43
+ end
44
+
45
+ # Removes the artifact (when a run is named, only if that run stored
46
+ # it).
47
+ def delete(ref, execution_namespace: nil)
48
+ enforce_tenant_isolation!
49
+ return unless owns_ref?(ref) && (execution_namespace.nil? || stored_by?(ref, execution_namespace))
50
+
51
+ [ref, "#{ref}.meta"].each { |name| ::FileUtils.rm_f(::File.join(@dir, name)) }
52
+ nil
53
+ end
54
+
55
+ # Whether a run (its execution namespace) stored this ref.
56
+ def stored_by?(ref, execution_namespace)
57
+ !execution_namespace.nil? && owns_ref?(ref) && stored_namespace(ref) == execution_namespace
26
58
  end
27
59
 
28
60
  def expired(retention: nil, execution_namespace: nil)
61
+ retention = effective_retention(retention)
29
62
  return [] unless retention
30
63
 
31
64
  cutoff = Time.now.utc - retention
32
- Dir.glob(::File.join(@dir, "*")).reject { |f| f.end_with?(".meta") }.filter_map do |path|
65
+ Dir.glob(::File.join(@dir, "*")).filter_map do |path|
33
66
  ref = ::File.basename(path)
34
- next unless ::File.mtime(path).utc < cutoff
35
- next if execution_namespace && !matches_execution_namespace?(ref, execution_namespace)
67
+ next unless owns_ref?(ref) && ::File.mtime(path).utc < cutoff
68
+ next if execution_namespace && stored_namespace(ref) != execution_namespace
36
69
 
37
70
  ref
38
71
  end
@@ -40,19 +73,13 @@ module Smith
40
73
 
41
74
  private
42
75
 
43
- def generate_ref
44
- raw = SecureRandom.uuid
45
- @namespace ? "#{@namespace}:#{raw}" : raw
46
- end
47
-
48
- def matches_execution_namespace?(ref, execution_namespace)
76
+ def stored_namespace(ref)
49
77
  meta_path = ::File.join(@dir, "#{ref}.meta")
50
- return false unless ::File.exist?(meta_path)
78
+ return unless ::File.exist?(meta_path)
51
79
 
52
- meta = JSON.parse(::File.read(meta_path), symbolize_names: true)
53
- meta[:execution_namespace] == execution_namespace
80
+ JSON.parse(::File.read(meta_path))["execution_namespace"]
54
81
  rescue JSON::ParserError
55
- false
82
+ nil
56
83
  end
57
84
  end
58
85
  end
@@ -2,73 +2,84 @@
2
2
 
3
3
  require "digest"
4
4
 
5
+ require_relative "store_rules"
6
+
5
7
  module Smith
6
8
  module Artifacts
9
+ # An artifact store in the process: for one process and for tests.
10
+ # Refs are content hashes, so runs that store the same bytes share one;
11
+ # each run's claim on it (and when it made the claim) is kept apart, so
12
+ # a run's delete drops only its own claim, and the bytes go with the
13
+ # last claim.
7
14
  class Memory
15
+ include StoreRules
16
+
17
+ INNER_REF = /\A\h{64}\z/
18
+ HOST = :host
19
+ private_constant :HOST
20
+
8
21
  attr_reader :namespace
9
22
 
10
23
  def initialize(namespace: nil)
11
- @namespace = namespace
24
+ @namespace = check_namespace!(namespace)
12
25
  @store = {}
13
26
  @metadata = {}
14
27
  end
15
28
 
16
29
  def store(data, content_type: "application/octet-stream", execution_namespace: nil)
17
30
  enforce_tenant_isolation!
18
- ref = generate_ref(data)
31
+ check_data!(data)
32
+ ref = generate_ref(Digest::SHA256.hexdigest(data))
19
33
  @store[ref] = data
20
- @metadata[ref] ||= { content_type: content_type, stored_at: Time.now.utc, execution_namespaces: [] }
21
- tag_execution_namespace(ref, execution_namespace)
34
+ meta = (@metadata[ref] ||= { content_type:, claims: {} })
35
+ meta[:claims][execution_namespace || HOST] = Time.now.utc
22
36
  ref
23
37
  end
24
38
 
25
39
  def fetch(ref)
26
40
  enforce_tenant_isolation!
27
- return nil unless owns_ref?(ref)
28
-
29
- @store[ref]
30
- end
31
-
32
- def expired(retention: nil, execution_namespace: nil)
33
- effective_retention = retention || Smith.config.artifact_retention
34
- return [] unless effective_retention
35
-
36
- cutoff = Time.now.utc - effective_retention
37
- @metadata.select { |ref, meta| expired_match?(ref, meta, cutoff, execution_namespace) }.keys
41
+ @store[ref] if owns_ref?(ref)
38
42
  end
39
43
 
40
- private
44
+ # Drops a run's claim (or, with no run named, every claim); the bytes
45
+ # go when no claim is left.
46
+ def delete(ref, execution_namespace: nil)
47
+ enforce_tenant_isolation!
48
+ meta = @metadata[ref] if owns_ref?(ref)
49
+ return unless meta
41
50
 
42
- def tag_execution_namespace(ref, execution_namespace)
43
- return unless execution_namespace
51
+ execution_namespace ? meta[:claims].delete(execution_namespace) : meta[:claims].clear
52
+ return unless meta[:claims].empty?
44
53
 
45
- namespaces = @metadata[ref][:execution_namespaces]
46
- namespaces << execution_namespace unless namespaces.include?(execution_namespace)
54
+ @store.delete(ref)
55
+ @metadata.delete(ref)
56
+ nil
47
57
  end
48
58
 
49
- def expired_match?(ref, meta, cutoff, execution_namespace)
50
- owns_ref?(ref) &&
51
- meta[:stored_at] < cutoff &&
52
- (execution_namespace.nil? || meta[:execution_namespaces]&.include?(execution_namespace))
53
- end
59
+ # Whether a run (its execution namespace) stored this ref.
60
+ def stored_by?(ref, execution_namespace)
61
+ return false if execution_namespace.nil? || !owns_ref?(ref)
54
62
 
55
- def generate_ref(data)
56
- content_hash = Digest::SHA256.hexdigest(data.to_s)
57
- @namespace ? "#{@namespace}:#{content_hash}" : content_hash
63
+ @metadata.dig(ref, :claims)&.key?(execution_namespace) || false
58
64
  end
59
65
 
60
- def owns_ref?(ref)
61
- if @namespace
62
- ref.start_with?("#{@namespace}:")
63
- else
64
- !ref.include?(":")
66
+ # Refs whose claims (or the run's claim, when one is named) are all
67
+ # older than the retention.
68
+ def expired(retention: nil, execution_namespace: nil)
69
+ retention = effective_retention(retention)
70
+ return [] unless retention
71
+
72
+ cutoff = Time.now.utc - retention
73
+ @metadata.filter_map do |ref, meta|
74
+ ref if owns_ref?(ref) && claims_before?(meta[:claims], execution_namespace, cutoff)
65
75
  end
66
76
  end
67
77
 
68
- def enforce_tenant_isolation!
69
- return unless Smith.config.artifact_tenant_isolation
78
+ private
70
79
 
71
- raise Smith::Error, "artifact_tenant_isolation requires a namespace" unless @namespace
80
+ def claims_before?(claims, execution_namespace, cutoff)
81
+ claims = claims.slice(execution_namespace) if execution_namespace
82
+ !claims.empty? && claims.values.all? { |stored_at| stored_at < cutoff }
72
83
  end
73
84
  end
74
85
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "store_rules"
6
+
7
+ module Smith
8
+ module Artifacts
9
+ # An artifact store in an S3-compatible bucket: AWS S3, Cloudflare R2
10
+ # (`endpoint: "https://<account>.r2.cloudflarestorage.com"`,
11
+ # `region: "auto"`), MinIO and the like. Each artifact is one object
12
+ # under `prefix`, its ref a random UUID (after the store's tenant
13
+ # `namespace:` when one is set), and the run that stored it is kept in
14
+ # the object's metadata, so the store can say which run stored a ref.
15
+ #
16
+ # The client is the host's `Aws::S3::Client` (`client:`), or one built
17
+ # from the remaining options (`region:`, `endpoint:`,
18
+ # `access_key_id:`, `secret_access_key:`, ...), which needs the
19
+ # `aws-sdk-s3` gem in the host's bundle; Smith does not depend on it.
20
+ # Only keys of the store's ref shape under the prefix are artifacts, so
21
+ # other objects in the bucket are neither read, listed nor deleted.
22
+ class S3
23
+ include StoreRules
24
+
25
+ INNER_REF = StoreRules::UUID
26
+ NAMESPACE_KEY = "smith-execution-namespace"
27
+
28
+ def initialize(bucket:, prefix: "smith/artifacts", namespace: nil, client: nil, **client_options)
29
+ if client && client_options.any?
30
+ raise ArgumentError, "give Smith::Artifacts::S3 a client: or the options to build one, not both"
31
+ end
32
+
33
+ @bucket = bucket
34
+ @prefix = prefix.to_s.delete_suffix("/")
35
+ @namespace = check_namespace!(namespace)
36
+ @client = client || self.class.build_client(**client_options)
37
+ end
38
+
39
+ def self.build_client(**)
40
+ require "aws-sdk-s3"
41
+ ::Aws::S3::Client.new(**)
42
+ rescue LoadError
43
+ raise Smith::Error, "Smith::Artifacts::S3 needs the aws-sdk-s3 gem in the host's bundle, or a client:"
44
+ end
45
+
46
+ def store(data, content_type: "application/octet-stream", execution_namespace: nil)
47
+ enforce_tenant_isolation!
48
+ check_data!(data)
49
+ ref = generate_ref(SecureRandom.uuid)
50
+ metadata = execution_namespace ? { NAMESPACE_KEY => execution_namespace } : {}
51
+ @client.put_object(bucket: @bucket, key: object_key(ref), body: data, content_type:, metadata:)
52
+ ref
53
+ end
54
+
55
+ def fetch(ref)
56
+ enforce_tenant_isolation!
57
+ return unless owns_ref?(ref)
58
+
59
+ @client.get_object(bucket: @bucket, key: object_key(ref)).body.read
60
+ rescue StandardError => e
61
+ raise unless missing?(e)
62
+ end
63
+
64
+ # Whether a run (its execution namespace) stored this ref.
65
+ def stored_by?(ref, execution_namespace)
66
+ return false if execution_namespace.nil? || !owns_ref?(ref)
67
+
68
+ @client.head_object(bucket: @bucket, key: object_key(ref)).metadata[NAMESPACE_KEY] == execution_namespace
69
+ rescue StandardError => e
70
+ raise unless missing?(e)
71
+
72
+ false
73
+ end
74
+
75
+ # Listing the prefix, then (for one run's refs) a HEAD per expired
76
+ # object.
77
+ def expired(retention: nil, execution_namespace: nil)
78
+ retention = effective_retention(retention)
79
+ return [] unless retention
80
+
81
+ cutoff = Time.now.utc - retention
82
+ objects.filter_map do |object|
83
+ ref = object.key.delete_prefix(key_prefix)
84
+ next unless object.last_modified.utc < cutoff && owns_ref?(ref)
85
+ next if execution_namespace && !stored_by?(ref, execution_namespace)
86
+
87
+ ref
88
+ end
89
+ end
90
+
91
+ # Removes the artifact (when a run is named, only if that run stored
92
+ # it).
93
+ def delete(ref, execution_namespace: nil)
94
+ enforce_tenant_isolation!
95
+ return unless owns_ref?(ref) && (execution_namespace.nil? || stored_by?(ref, execution_namespace))
96
+
97
+ @client.delete_object(bucket: @bucket, key: object_key(ref))
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ def key_prefix = @prefix.empty? ? "" : "#{@prefix}/"
104
+
105
+ def object_key(ref) = "#{key_prefix}#{ref}"
106
+
107
+ # Every object under the prefix, page by page.
108
+ def objects
109
+ Enumerator.new do |yielder|
110
+ token = nil
111
+ loop do
112
+ page = @client.list_objects_v2(bucket: @bucket, prefix: key_prefix, continuation_token: token)
113
+ page.contents.each { |object| yielder << object }
114
+ break unless page.is_truncated
115
+
116
+ token = page.next_continuation_token
117
+ end
118
+ end
119
+ end
120
+
121
+ # The SDK's missing-object errors (NoSuchKey on a read, NotFound on a
122
+ # head), by name, so Smith loads no SDK class.
123
+ def missing?(error) = error.class.name.to_s.end_with?("::NoSuchKey", "::NotFound")
124
+ end
125
+ end
126
+ end