smith-agents 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +698 -0
  3. data/README.md +621 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. metadata +88 -1
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Launchers
5
+ # Runs each child to its end, one after another, in the parent's
6
+ # process, and returns their results, so the parent's step completes at
7
+ # once. Children run under the parent's deadline but with no tool
8
+ # evidence or attribution of the parent's. A child left waiting, or one
9
+ # that raised, is reported failed (with the error's class). For tests
10
+ # and scripts: a long map holds the parent's step for as long as it runs.
11
+ class Inline
12
+ def inline? = true
13
+
14
+ def launch(children:, parent_key:, await_token:) # rubocop:disable Lint/UnusedMethodArgument
15
+ deadline = Tool.current_deadline
16
+ children.map { |child| run_child(child, deadline) }
17
+ end
18
+
19
+ private
20
+
21
+ def run_child(child, deadline)
22
+ workflow = child.build
23
+ workflow.instance_variable_set(:@inherited_deadline, deadline) if deadline
24
+ run = apart { workflow.run! }
25
+ return failed(child, workflow.state, "waiting") if workflow.waiting?
26
+
27
+ ChildRun.result(ref: child.ref, run_result: run)
28
+ rescue StandardError => e
29
+ failed(child, nil, DiagnosticText.error_class_name(e))
30
+ end
31
+
32
+ def failed(child, state, error)
33
+ { ref: child.ref, status: "failed", state: state&.to_s, output: nil,
34
+ error: DiagnosticText.capture(error.to_s, max_bytes: 256) }
35
+ end
36
+
37
+ def apart(&)
38
+ tracker = Tool.current_tool_execution_tracker
39
+ Tool.current_tool_execution_tracker = nil
40
+ Attribution.carrying(nil, &)
41
+ ensure
42
+ Tool.current_tool_execution_tracker = tracker
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "content"
4
+ require_relative "errors"
5
+ require_relative "workflow/message_roles"
6
+
7
+ module Smith
8
+ # Messages for a step's `input:` and the `seed_messages` builder. Content
9
+ # is a String, or an Array of 1 to MAX_PARTS Smith::Content parts:
10
+ #
11
+ # Smith::Message.user("Summarize the contract.")
12
+ # Smith::Message.system([Smith::Content.text(style_guide, cache: true)])
13
+ #
14
+ # A message with parts carries `"parts" => true` beside its role and
15
+ # content. No agent output and no message a host admits
16
+ # (`append_session_messages!`) can carry it, so a structured output that
17
+ # looks like parts stays data. Only a user message takes documents and
18
+ # images; system and assistant messages, and every seed message (built
19
+ # before the run can store an artifact), take text parts.
20
+ module Message
21
+ MAX_PARTS = 100
22
+
23
+ module_function
24
+
25
+ def user(content) = build("user", content)
26
+
27
+ def assistant(content) = build("assistant", content)
28
+
29
+ def system(content) = build("system", content)
30
+
31
+ def build(role, content)
32
+ return { "role" => role, "content" => content.dup.freeze }.freeze if content.is_a?(String)
33
+
34
+ check!({ "role" => role, "content" => content, "parts" => true })
35
+ end
36
+
37
+ # Whether a message Hash (String or Symbol keys) is one with parts.
38
+ def parts?(message)
39
+ message.is_a?(Hash) && (message["parts"] == true || message[:parts] == true)
40
+ end
41
+
42
+ # A seed message with parts: text parts only.
43
+ def check_seed!(message)
44
+ checked = check!(message)
45
+ return checked if checked["content"].all? { |part| part["type"] == "text" }
46
+
47
+ raise WorkflowError, "a seed message takes text parts only; send documents and images through a step's input:"
48
+ end
49
+
50
+ # A message with parts, checked and returned frozen with String keys.
51
+ def check!(message)
52
+ fields = message.is_a?(Hash) ? message.transform_keys(&:to_s) : {}
53
+ role = Workflow::MessageRoles.role(fields, %w[system user assistant])
54
+ unless role && shaped?(fields)
55
+ raise WorkflowError, "a message with content parts is { role:, content: [parts], parts: true } with a " \
56
+ "system, user or assistant role and 1 to #{MAX_PARTS} parts"
57
+ end
58
+
59
+ { "role" => role, "content" => checked_parts(role, fields["content"]), "parts" => true }.freeze
60
+ end
61
+
62
+ def shaped?(fields)
63
+ content = fields["content"]
64
+ fields.keys.sort == %w[content parts role] && fields["parts"] == true && content.is_a?(Array) &&
65
+ content.length.between?(1, MAX_PARTS)
66
+ end
67
+
68
+ def checked_parts(role, content)
69
+ parts = content.map { |part| Content.check!(part) }.freeze
70
+ return parts if role == "user" || parts.all? { |part| part["type"] == "text" }
71
+
72
+ raise WorkflowError, "a #{role} message takes text parts only"
73
+ end
74
+ private_class_method :build, :shaped?, :checked_parts
75
+ end
76
+ end
@@ -15,41 +15,64 @@ module Smith
15
15
  option :profile
16
16
  option :decision_recorder
17
17
 
18
- def call
19
- return unless chat.respond_to?(:tools)
18
+ # Whether #call sends a chat of `profile` with these tools (tools or tool
19
+ # classes; `choice` the chat's forced tool choice) to the Responses
20
+ # API: thinking that needs it, or a tool only it takes, or more tools
21
+ # it takes. Runtime readiness asks this without a chat.
22
+ def self.responses?(profile:, tools:, thinking:, choice: nil)
23
+ return false unless profile.provider == :openai && Smith.config.openai_api_mode == :auto && tools.any?
24
+ return true if thinking && thinking_route?(profile)
25
+
26
+ forced = forced_endpoint(tools, choice, profile)
27
+ return forced == :responses if forced
28
+
29
+ compatible_count(tools, :responses, profile) > compatible_count(tools, :chat_completions, profile)
30
+ end
20
31
 
21
- tools = chat.tools.values
22
- return if tools.empty?
32
+ def self.thinking_route?(profile)
33
+ !profile.tools_with_thinking_native && profile.tools_with_thinking_route == :responses
34
+ end
23
35
 
24
- route_tools_with_thinking if thinking_active?
25
- route_tools_for_compatibility(tools)
26
- drop_incompatible_tools(tools)
36
+ # The one endpoint that can carry a specifically forced tool.
37
+ def self.forced_endpoint(tools, choice, profile)
38
+ tool = forced_tool(tools, choice&.to_sym)
39
+ return unless tool
40
+
41
+ endpoints = TOOL_ENDPOINTS.select { |endpoint| compatible?(tool, endpoint, profile) }
42
+ endpoints.first if endpoints.one?
27
43
  end
28
44
 
29
- private
45
+ def self.forced_tool(tools, choice)
46
+ return if choice.nil? || %i[auto none required].include?(choice)
47
+
48
+ tools.find { |candidate| !candidate.is_a?(Class) && candidate.name.to_sym == choice }
49
+ end
30
50
 
31
- def route_tools_with_thinking
32
- return unless profile.provider == :openai
33
- return if profile.tools_with_thinking_native
34
- return unless profile.tools_with_thinking_route == :responses
35
- return unless Smith.config.openai_api_mode == :auto
51
+ def self.compatible_count(tools, endpoint, profile) = tools.count { compatible?(_1, endpoint, profile) }
52
+
53
+ def self.compatible?(tool, endpoint, profile)
54
+ return true unless defined?(Smith::Tool::Compatibility)
36
55
 
37
- route_via_responses
56
+ tool_class = tool.is_a?(Class) ? tool : tool.class
57
+ spec = tool_class.respond_to?(:compatible_with_spec) ? tool_class.compatible_with_spec : nil
58
+ Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: endpoint)
38
59
  end
60
+ private_class_method :thinking_route?, :forced_endpoint, :forced_tool, :compatible_count, :compatible?
39
61
 
40
- def route_tools_for_compatibility(tools)
41
- return unless profile.provider == :openai
42
- return unless Smith.config.openai_api_mode == :auto
43
- return if responses_routed?
62
+ def call
63
+ return unless chat.respond_to?(:tools)
44
64
 
45
- forced_endpoint = forced_tool_endpoint(tools)
46
- return if forced_endpoint == :chat_completions
47
- return unless forced_endpoint == :responses ||
48
- compatible_tool_count(tools, :responses) > compatible_tool_count(tools, :chat_completions)
65
+ tools = chat.tools.values
66
+ return if tools.empty?
49
67
 
50
- route_via_responses
68
+ if self.class.responses?(profile:, tools:, thinking: thinking_active?, choice: chat.tool_prefs[:choice])
69
+ route_via_responses
70
+ end
71
+ drop_incompatible_tools(tools)
51
72
  end
52
73
 
74
+ private
75
+
53
76
  def route_via_responses
54
77
  return if responses_routed?
55
78
 
@@ -78,17 +101,6 @@ module Smith
78
101
  detail
79
102
  end
80
103
 
81
- def forced_tool_endpoint(tools)
82
- choice = chat.tool_prefs[:choice]&.to_sym
83
- return if %i[auto none required].include?(choice)
84
-
85
- tool = tools.find { |candidate| candidate.name.to_sym == choice }
86
- return unless tool
87
-
88
- compatible_endpoints = TOOL_ENDPOINTS.select { |endpoint| tool_compatible_with?(tool, endpoint) }
89
- compatible_endpoints.first if compatible_endpoints.one?
90
- end
91
-
92
104
  def reset_invalid_tool_choice(remaining)
93
105
  choice = chat.tool_prefs[:choice]
94
106
  return unless choice
@@ -100,16 +112,7 @@ module Smith
100
112
  choice
101
113
  end
102
114
 
103
- def compatible_tool_count(tools, endpoint)
104
- tools.count { |tool| tool_compatible_with?(tool, endpoint) }
105
- end
106
-
107
- def tool_compatible_with?(tool, endpoint)
108
- return true unless defined?(Smith::Tool::Compatibility)
109
-
110
- spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
111
- Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: endpoint)
112
- end
115
+ def tool_compatible_with?(tool, endpoint) = self.class.__send__(:compatible?, tool, endpoint, profile)
113
116
 
114
117
  def thinking_active?
115
118
  thinking = chat.instance_variable_get(:@thinking)
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "diagnostic_text"
5
+
6
+ module Smith
7
+ # An agent's response that cannot be its step's output: cut off at its
8
+ # output limit (OutputTruncated), refused (OutputRefused), or not the
9
+ # structured output the agent declares (OutputInvalid). The response was
10
+ # billed all the same. Not an AgentError and not retryable: the agent's
11
+ # own retry, fallback and repairs have run, and running the same request
12
+ # again would repeat the cost. Its failure family is `output_failure`;
13
+ # details are bounded and persist with a failed step, so a restored run
14
+ # raises the same error.
15
+ class OutputFailure < Error
16
+ DETAIL_NAMES = %i[agent_name model_used stop_reason problems].freeze
17
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
18
+ MAX_TEXT_BYTES = 256
19
+ MAX_PROBLEMS = 20
20
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS
21
+
22
+ attr_reader :agent_name, :model_used, :stop_reason, :problems
23
+
24
+ # `message` is the persisted one when the error is restored.
25
+ def initialize(agent_name:, model_used:, stop_reason: nil, problems: [], message: nil)
26
+ @agent_name = agent_name
27
+ @model_used = model_used
28
+ @stop_reason = stop_reason
29
+ @problems = Array(problems).first(MAX_PROBLEMS).map { |problem| self.class.bounded(problem.to_s) }.freeze
30
+ super(message || describe)
31
+ end
32
+
33
+ def details
34
+ texts = %i[agent_name model_used stop_reason].to_h { |name| [name, text_detail(public_send(name))] }
35
+ texts.merge(problems:).freeze
36
+ end
37
+
38
+ class << self
39
+ # Each output failure class's name and its restore from persisted
40
+ # details, for the failure record validator and reconstructor.
41
+ def restorers
42
+ [OutputFailure, OutputTruncated, OutputRefused, OutputInvalid].to_h do |error_class|
43
+ [error_class.name, ->(details, message = nil) { error_class.from_details(details, message:) }]
44
+ end.freeze
45
+ end
46
+
47
+ def from_details(details, message: nil)
48
+ values = normalize_details(details)
49
+ new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used),
50
+ stop_reason: values.fetch(:stop_reason), problems: values.fetch(:problems), message:)
51
+ end
52
+
53
+ def bounded(text) = DiagnosticText.capture(text, max_bytes: MAX_TEXT_BYTES)
54
+
55
+ private
56
+
57
+ def normalize_details(details)
58
+ raise ArgumentError, "output failure details must be a Hash" unless details.is_a?(Hash)
59
+
60
+ values = {}
61
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
62
+ name = detail_name(key, values)
63
+ values[name] = name == :problems ? problem_list(value) : bounded_text(name, value)
64
+ end
65
+ raise ArgumentError, "output failure details are incomplete" unless values.length == DETAIL_NAMES.length
66
+
67
+ values
68
+ end
69
+
70
+ def detail_name(key, seen)
71
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
72
+ raise ArgumentError, "output failure details contain an unknown attribute" unless DETAIL_NAMES.include?(name)
73
+ raise ArgumentError, "output failure details contain a duplicate attribute" if seen.key?(name)
74
+
75
+ name
76
+ end
77
+
78
+ def bounded_text(name, value)
79
+ return value if value.nil?
80
+ return value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_TEXT_BYTES
81
+
82
+ raise ArgumentError, "output failure detail #{name} must be bounded text"
83
+ end
84
+
85
+ def problem_list(value)
86
+ unless value.is_a?(Array) && value.length <= MAX_PROBLEMS
87
+ raise ArgumentError, "output failure problems must be a list of at most #{MAX_PROBLEMS}"
88
+ end
89
+
90
+ value.map { |problem| bounded_text(:problems, problem) }
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def text_detail(value) = value && self.class.bounded(value.to_s)
97
+
98
+ def outcome = "could not be used"
99
+
100
+ def describe
101
+ [
102
+ "agent#{" :#{agent_name}" if agent_name} response#{" from model #{model_used}" if model_used} #{outcome}",
103
+ (" (stop reason #{stop_reason})" if stop_reason),
104
+ (": #{problems.first(3).join("; ")}" unless problems.empty?)
105
+ ].join
106
+ end
107
+ end
108
+
109
+ # The response stopped at its output limit.
110
+ class OutputTruncated < OutputFailure
111
+ private
112
+
113
+ def outcome = "was cut off at its output limit"
114
+ end
115
+
116
+ # The provider or model declined to answer.
117
+ class OutputRefused < OutputFailure
118
+ private
119
+
120
+ def outcome = "was refused"
121
+ end
122
+
123
+ # The output is not a JSON object, or breaks the agent's output contract
124
+ # or a schema constraint Smith checks itself.
125
+ class OutputInvalid < OutputFailure
126
+ private
127
+
128
+ def outcome = "is invalid"
129
+ end
130
+ end
@@ -3,12 +3,16 @@
3
3
  module Smith
4
4
  module PersistenceAdapters
5
5
  module ActiveRecordExactStore
6
+ # Child runs are stored under 64-character keys (digests).
7
+ KEY_WIDTH = 64
8
+
6
9
  attr_reader :persistence_identity
7
10
 
8
11
  def replace_exact(key, payload, expected_payload:, ttl: nil)
9
12
  ensure_versioned_write!(ttl)
10
13
  model = model_class
11
14
  ensure_exact_schema!(model)
15
+ ensure_plain_payload!(model)
12
16
  ActiveRecordExactWrite.new(
13
17
  model: model,
14
18
  key_column: @key_column,
@@ -22,6 +26,28 @@ module Smith
22
26
  raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
23
27
  end
24
28
 
29
+ # Raises ArgumentError unless the model's table holds runs as their
30
+ # writes need: the locking column, a text payload, and a string key the
31
+ # database keeps unique, wide enough for child runs' keys (smith:doctor
32
+ # reports it). Returns self.
33
+ def check_schema!
34
+ model = model_class
35
+ ensure_version_column!
36
+ ensure_locking_configuration!
37
+ ensure_exact_schema!(model)
38
+ ensure_key_column!(model)
39
+ self
40
+ end
41
+
42
+ # Whether Rails encrypts the payload (`encrypts`): runs store and read
43
+ # it, but replace_exact, which compares payloads in the database,
44
+ # cannot.
45
+ def encrypted_payload?
46
+ model = model_class
47
+ attributes = model.encrypted_attributes if model.respond_to?(:encrypted_attributes)
48
+ Array(attributes).map(&:to_s).include?(@payload_column.to_s)
49
+ end
50
+
25
51
  private
26
52
 
27
53
  def ensure_versioned_write!(ttl)
@@ -44,6 +70,23 @@ module Smith
44
70
  "#{model.table_name}.#{@payload_column}"
45
71
  end
46
72
 
73
+ def ensure_key_column!(model)
74
+ column = model.columns_hash.fetch(@key_column.to_s)
75
+ return if %i[string text].include?(column.type) && (column.limit.nil? || column.limit >= KEY_WIDTH)
76
+
77
+ raise ArgumentError,
78
+ "ActiveRecordStore requires #{model.table_name}.#{@key_column} to be a string or text column of at " \
79
+ "least #{KEY_WIDTH} characters: child runs are stored under #{KEY_WIDTH}-character keys"
80
+ end
81
+
82
+ def ensure_plain_payload!(model)
83
+ return unless encrypted_payload?
84
+
85
+ raise ArgumentError,
86
+ "ActiveRecordStore#replace_exact compares payloads in the database, so it cannot use " \
87
+ "#{model.name}'s encrypted #{@payload_column}"
88
+ end
89
+
47
90
  def ensure_exact_key_uniqueness!(model)
48
91
  connection = model.connection
49
92
  schema_cache = connection.schema_cache
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Pricing
5
+ # Admission-time catalog validation, the raising surface for the rules
6
+ # `compute_cost` applies without raising. See Pricing.validate_catalog!.
7
+ module CatalogValidation
8
+ OPTIONAL_RATE_KEYS = %i[cache_read_cost_per_token cache_write_cost_per_token].freeze
9
+
10
+ module_function
11
+
12
+ def validate!(catalog)
13
+ return catalog if catalog.nil?
14
+ raise Smith::PricingConfigurationError, "pricing catalog must be a Hash" unless catalog.is_a?(Hash)
15
+
16
+ catalog.each_with_object({}) do |(key, entry), seen|
17
+ provider, model = validated_key!(key)
18
+ validate_entry!(key, entry)
19
+ reject_collision!(seen, key, provider, model)
20
+ end
21
+ catalog
22
+ end
23
+
24
+ def validated_key!(key)
25
+ provider, model = Pricing.__send__(:canonical_key, key)
26
+ raise Smith::PricingConfigurationError, "unrecognized pricing catalog key #{key.inspect}" unless model
27
+ return [provider, model] if provider
28
+
29
+ raise Smith::PricingConfigurationError, "pricing for #{model} must use a provider-qualified catalog key"
30
+ end
31
+
32
+ def validate_entry!(key, entry)
33
+ unless entry.is_a?(Hash)
34
+ raise Smith::PricingConfigurationError, "pricing entry for #{key.inspect} must be a Hash"
35
+ end
36
+
37
+ tiers = Pricing.__send__(:value_at, entry, :tiers)
38
+ tiers = [] unless tiers.is_a?(Array)
39
+ [entry, *tiers].each { |rates| validate_optional_rates!(key, rates) }
40
+ values_at(entry, :batch_discount).each { |discount| validate_batch_discount!(key, discount) }
41
+ reject_tier_batch_discount!(key, tiers)
42
+ end
43
+
44
+ def validate_optional_rates!(key, rates)
45
+ return unless rates.is_a?(Hash)
46
+
47
+ OPTIONAL_RATE_KEYS.each do |name|
48
+ next if values_at(rates, name).all? { |rate| non_negative_number?(rate) }
49
+
50
+ raise Smith::PricingConfigurationError, "#{name} for #{key.inspect} must be a non-negative Integer or Float"
51
+ end
52
+ end
53
+
54
+ # Every form of a key the entry carries, Symbol and String, so a
55
+ # malformed value cannot hide behind a valid one under the other form.
56
+ def values_at(source, name)
57
+ [name, name.to_s].select { |form| source.key?(form) }.map { |form| source[form] }.compact
58
+ end
59
+
60
+ def validate_batch_discount!(key, discount)
61
+ return if non_negative_number?(discount) && discount < 1
62
+
63
+ raise Smith::PricingConfigurationError, "batch_discount for #{key.inspect} must be at least 0 and below 1"
64
+ end
65
+
66
+ # A batch discount applies to a whole call, so it belongs on the entry;
67
+ # one inside a tier would never be read.
68
+ def reject_tier_batch_discount!(key, tiers)
69
+ return unless tiers.any? { |tier| tier.is_a?(Hash) && values_at(tier, :batch_discount).any? }
70
+
71
+ raise Smith::PricingConfigurationError, "batch_discount for #{key.inspect} belongs on the entry, not a tier"
72
+ end
73
+
74
+ def non_negative_number?(value)
75
+ (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && value >= 0
76
+ end
77
+
78
+ def reject_collision!(seen, key, provider, model)
79
+ previous = seen[[provider, model]]
80
+ if previous
81
+ raise Smith::PricingConfigurationError,
82
+ "pricing catalog keys #{previous.inspect} and #{key.inspect} collide as #{provider}/#{model}"
83
+ end
84
+
85
+ seen[[provider, model]] = key
86
+ end
87
+ end
88
+
89
+ private_constant :CatalogValidation
90
+ end
91
+ end