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
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Smith
6
+ module Providers
7
+ # Where a host's budget ledger (a workflow's `budget_ledger`) meets
8
+ # RubyLLM: each request a step's call makes over the wire (each round of
9
+ # a tool loop, each fallback model, each follow-up, streamed or not, and
10
+ # OpenAI's Responses route, which never reaches the base class) goes
11
+ # through `Provider#complete`. A call's first request is held before
12
+ # anything is sent. A later round of a tool loop continues a call whose
13
+ # tools already ran: it is held only when its step can pause for budget
14
+ # (`pausable`), and otherwise goes on, reconciled all the same. Each
15
+ # request is then reconciled with what it spent (its usage; its hold
16
+ # when it answered without usage or its stream failed part way) or, if
17
+ # it failed before anything came back, its hold is released. Only the
18
+ # call's own provider object is held, so a tool's own RubyLLM call is
19
+ # not. While a batch step captures its request, the hold is noted on the
20
+ # capture instead, for the batch to reserve.
21
+ module RequestHolds
22
+ HELD = :smith_request_holds
23
+ private_constant :HELD
24
+
25
+ Holder = Struct.new(:provider, :ledger, :attempt_id, :pausable, :rounds, :busy)
26
+ # Whether a streamed request's response began.
27
+ Stream = Struct.new(:started)
28
+
29
+ module_function
30
+
31
+ # Runs the block with the provider's requests held on `ledger`, keyed
32
+ # "<attempt_id>:<round>".
33
+ def within(provider, ledger, attempt_id, pausable: false, rounds: 0)
34
+ outer = Fiber[HELD]
35
+ Fiber[HELD] = Holder.new(provider, ledger, attempt_id, pausable, rounds, false)
36
+ yield Fiber[HELD]
37
+ ensure
38
+ Fiber[HELD] = outer
39
+ end
40
+
41
+ # The holder of a request the provider is about to make, unless one is
42
+ # under way (the base class's hook inside OpenAI's).
43
+ def holder_for(provider)
44
+ holder = Fiber[HELD]
45
+ holder if holder&.provider.equal?(provider) && !holder.busy
46
+ end
47
+
48
+ def hold(provider, args, kwargs, mode: :sync)
49
+ Budget::RequestHold.for_request(
50
+ provider: provider.slug, model: kwargs.fetch(:model), messages: args.first, tools: kwargs[:tools],
51
+ schema: kwargs[:schema], params: kwargs[:params], mode:
52
+ )
53
+ end
54
+
55
+ # The request (`request`: the provider's own `complete`), held.
56
+ def complete(provider, args, kwargs, block, &request)
57
+ exchange = BatchExchange.exchange_for(provider)
58
+ return capture(provider, exchange, args, kwargs, block, &request) if exchange
59
+
60
+ ResponseMemo.round(provider, args.first) { send_held(provider, args, kwargs, block, &request) }
61
+ end
62
+
63
+ def send_held(provider, args, kwargs, block, &request)
64
+ holder = holder_for(provider)
65
+ return request.call(*args, **kwargs, &block) unless holder
66
+
67
+ held(holder, hold(provider, args, kwargs), kwargs.fetch(:model).id) do |stream|
68
+ next request.call(*args, **kwargs) unless block
69
+
70
+ request.call(*args, **kwargs) do |chunk|
71
+ stream.started = true
72
+ block.call(chunk)
73
+ end
74
+ end
75
+ end
76
+
77
+ def capture(provider, exchange, args, kwargs, block, &request)
78
+ exchange.hold ||= hold(provider, args, kwargs, mode: :batch) if exchange.holding && exchange.result.nil?
79
+ request.call(*args, **kwargs, &block)
80
+ end
81
+
82
+ def held(holder, amount, model_id)
83
+ key, reserved = open_round(holder, amount)
84
+ stream = Stream.new(false)
85
+ begin
86
+ message = busy(holder) { yield(stream) }
87
+ rescue Exception => e # rubocop:disable Lint/RescueException -- an interrupted request settles too
88
+ settle_failed(holder, key, spent(holder.provider, model_id, e) || (amount if stream.started), reserved)
89
+ raise
90
+ end
91
+ Budget::HostLedger.settle(holder.ledger, key, spent(holder.provider, model_id, message) || amount)
92
+ message
93
+ end
94
+
95
+ # The request's key and whether it is held: a call's first request, or
96
+ # any round of a step that can pause for budget.
97
+ def open_round(holder, amount)
98
+ holder.rounds += 1
99
+ key = "#{holder.attempt_id}:#{holder.rounds}"
100
+ reserved = holder.rounds == 1 || holder.pausable
101
+ Budget::HostLedger.admit!(holder.ledger, amount, key) if reserved
102
+ [key, reserved]
103
+ end
104
+
105
+ def busy(holder)
106
+ holder.busy = true
107
+ yield
108
+ ensure
109
+ holder.busy = false
110
+ end
111
+
112
+ # A failed request reconciles what it spent; a held one that spent
113
+ # nothing is released, an unheld one settles nothing.
114
+ def settle_failed(holder, key, spent, reserved)
115
+ Budget::HostLedger.settle(holder.ledger, key, spent) if spent || reserved
116
+ end
117
+
118
+ # What a request spent, from its reply (or the error that reported
119
+ # usage), priced for the request's model; nil when it reported none.
120
+ def spent(provider, model_id, reply)
121
+ usage = Agent::ProviderUsage.from_message(reply)
122
+ Budget::HostLedger.amount(usage, model: model_id, provider: provider.slug) if usage
123
+ end
124
+
125
+ # Prepended on RubyLLM::Provider, and on RubyLLM::Providers::OpenAI
126
+ # outside Smith's Responses routing.
127
+ module Hook
128
+ def complete(*args, **kwargs, &block)
129
+ RequestHolds.complete(self, args, kwargs, block) do |*sent, **options, &chunks|
130
+ super(*sent, **options, &chunks)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ RubyLLM::Provider.prepend(Smith::Providers::RequestHolds::Hook)
139
+ RubyLLM::Providers::OpenAI.prepend(Smith::Providers::RequestHolds::Hook)
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diagnostic_text"
4
+
5
+ module Smith
6
+ module Providers
7
+ # The stop reason and the provider's own message id of one provider
8
+ # response, read from the raw body RubyLLM keeps on the message. Each
9
+ # provider's body is recognized by its own marker, never guessed from
10
+ # loose field names:
11
+ #
12
+ # Anthropic Messages "type" => "message" stop_reason, id
13
+ # OpenAI Chat Completions "object" => "chat.completion" choices[0].finish_reason, id
14
+ # OpenAI Responses "object" => "response" incomplete_details.reason, else status; id
15
+ # Gemini "candidates" => [...] candidates[0].finishReason, responseId
16
+ # Bedrock Converse "stopReason" => String stopReason
17
+ #
18
+ # OpenAI reports a refusal on the message rather than as a finish
19
+ # reason (Chat Completions `message.refusal`, a Responses `refusal`
20
+ # content item); it is read as the stop reason "refusal". Gemini blocks a
21
+ # prompt with `promptFeedback.blockReason` and no candidates; that is read
22
+ # as the stop reason "prompt_blocked".
23
+ #
24
+ # Both facts are metadata: bounded Strings, absent when the body is of
25
+ # none of these shapes, does not carry them, or cannot be read (streamed
26
+ # responses keep no raw body). Reading never raises.
27
+ module ResponseFacts
28
+ MAX_BYTES = 256
29
+ REFUSAL = "refusal"
30
+ PROMPT_BLOCKED = "prompt_blocked"
31
+
32
+ KEPT = "smith.response"
33
+ # A response Smith made or kept itself (a response memo's, a test
34
+ # stub's) carries its facts as a raw body of this shape.
35
+ Kept = Struct.new(:body)
36
+
37
+ READERS = [
38
+ [->(body) { body["object"] == KEPT }, ->(body) { [body["stop_reason"], body["id"]] }],
39
+ [->(body) { body["type"] == "message" }, ->(body) { [body["stop_reason"], body["id"]] }],
40
+ [->(body) { body["object"] == "chat.completion" },
41
+ lambda do |body|
42
+ choice = first_entry(body["choices"])
43
+ [(REFUSAL if chat_refusal?(choice)) || choice&.fetch("finish_reason", nil), body["id"]]
44
+ end],
45
+ [->(body) { body["object"] == "response" },
46
+ lambda do |body|
47
+ details = body["incomplete_details"]
48
+ reason = (REFUSAL if responses_refusal?(body["output"])) || (details["reason"] if details.is_a?(Hash))
49
+ [reason || body["status"], body["id"]]
50
+ end],
51
+ [->(body) { !body.key?("candidates") && body.dig("promptFeedback", "blockReason").is_a?(String) },
52
+ ->(body) { [PROMPT_BLOCKED, body["responseId"]] }],
53
+ [->(body) { body["candidates"].is_a?(Array) },
54
+ ->(body) { [first_entry(body["candidates"])&.fetch("finishReason", nil), body["responseId"]] }],
55
+ [->(body) { body["stopReason"].is_a?(String) }, ->(body) { [body["stopReason"], nil] }]
56
+ ].freeze
57
+ private_constant :READERS
58
+
59
+ module_function
60
+
61
+ def kept(stop_reason:, id:) = Kept.new({ "object" => KEPT, "stop_reason" => stop_reason, "id" => id })
62
+
63
+ def from_message(message)
64
+ body = raw_body(message)
65
+ return {} unless body.instance_of?(Hash)
66
+
67
+ _, reader = READERS.find { |(matches, _)| matches.call(body) }
68
+ return {} unless reader
69
+
70
+ stop_reason, message_id = reader.call(body)
71
+ { stop_reason: bounded(stop_reason), provider_message_id: bounded(message_id) }.compact
72
+ rescue StandardError
73
+ {}
74
+ end
75
+
76
+ def raw_body(message)
77
+ return unless message.respond_to?(:raw)
78
+
79
+ raw = message.raw
80
+ raw.respond_to?(:body) ? raw.body : raw
81
+ end
82
+
83
+ def chat_refusal?(choice)
84
+ message = choice&.fetch("message", nil)
85
+ message.is_a?(Hash) && message["refusal"].is_a?(String) && !message["refusal"].empty?
86
+ end
87
+
88
+ def responses_refusal?(output)
89
+ return false unless output.is_a?(Array)
90
+
91
+ output.any? do |item|
92
+ item.is_a?(Hash) && item["content"].is_a?(Array) &&
93
+ item["content"].any? { |part| part.is_a?(Hash) && part["type"] == REFUSAL }
94
+ end
95
+ end
96
+
97
+ def first_entry(list)
98
+ list.first if list.is_a?(Array) && list.first.is_a?(Hash)
99
+ end
100
+
101
+ def bounded(value)
102
+ return unless value.is_a?(String) && !value.empty?
103
+
104
+ DiagnosticText.capture(value, max_bytes: MAX_BYTES)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema_rules"
4
+
5
+ module Smith
6
+ module Providers
7
+ # Checks a parsed structured output against the constraints in its JSON
8
+ # schema that SchemaConstraints may have kept from the provider:
9
+ # numeric bounds and multiples, string lengths, array sizes and
10
+ # uniqueness, and required properties, through properties, items,
11
+ # local `$ref`s, `allOf`, `anyOf` (one alternative must accept the
12
+ # value) and `oneOf` (exactly one must). When no alternative accepts a
13
+ # value, the problems of the one alternative of its type are reported
14
+ # if there is one. Each schema and value pair is judged once, so the
15
+ # walk stays polynomial however deeply the output nests; it reports at
16
+ # most MAX_PROBLEMS "path: problem" lines and stops descending after
17
+ # MAX_VISITS visits.
18
+ class SchemaConstraintCheck
19
+ MAX_PROBLEMS = 20
20
+ MAX_DEPTH = 64
21
+ MAX_VISITS = 1_000_000
22
+ include SchemaRules
23
+
24
+ def initialize(root)
25
+ @root = root
26
+ end
27
+
28
+ def call(value)
29
+ @found = []
30
+ @visits = 0
31
+ @verdicts = {}
32
+ check(@root, value, "", 0)
33
+ @found.first(MAX_PROBLEMS)
34
+ end
35
+
36
+ private
37
+
38
+ def check(schema, value, path, depth)
39
+ return unless schema.is_a?(Hash) && depth <= MAX_DEPTH && @found.length < MAX_PROBLEMS
40
+ return if (@visits += 1) > MAX_VISITS
41
+
42
+ schema = resolve(schema)
43
+ check_alternatives(schema, value, path, depth) if schema["anyOf"] || schema["oneOf"]
44
+ Array(schema["allOf"]).each { check(_1, value, path, depth + 1) }
45
+ check_value(schema, value, path, depth)
46
+ end
47
+
48
+ def check_value(schema, value, path, depth)
49
+ own_problems(schema, value).each { |problem| report(path, problem) }
50
+ check_children(schema, value, path, depth)
51
+ end
52
+
53
+ def report(path, problem) = @found << "#{path.empty? ? "/" : path}: #{problem}"
54
+
55
+ def check_alternatives(schema, value, path, depth)
56
+ alternatives = Array(schema["anyOf"] || schema["oneOf"]).map { resolve(_1) }
57
+ accepted = alternatives.count { accepts?(_1, value, depth + 1) }
58
+ return if schema["anyOf"] ? accepted.positive? : accepted == 1
59
+ return report(path, "matches more than one of the allowed forms") if accepted > 1
60
+
61
+ report_closest(alternatives, value, path, depth)
62
+ end
63
+
64
+ def report_closest(alternatives, value, path, depth)
65
+ typed = alternatives.select { type?(_1["type"], value) }
66
+ return report(path, "matches none of the allowed forms") unless typed.length == 1
67
+
68
+ check(typed.first, value, path, depth + 1)
69
+ end
70
+
71
+ # Whether the alternative accepts the value, judged once per schema
72
+ # and value.
73
+ def accepts?(schema, value, depth)
74
+ @verdicts.fetch([schema.object_id, value.object_id]) do |key|
75
+ @verdicts[key] = type?(schema["type"], value) && trial(schema, value, depth)
76
+ end
77
+ end
78
+
79
+ def trial(schema, value, depth)
80
+ outer = @found
81
+ @found = []
82
+ check(schema, value, "", depth)
83
+ @found.empty?
84
+ ensure
85
+ @found = outer
86
+ end
87
+
88
+ def check_children(schema, value, path, depth)
89
+ case value
90
+ when Hash
91
+ Hash(schema["properties"]).each do |name, child|
92
+ check(child, value[name], "#{path}/#{name}", depth + 1) if value.key?(name)
93
+ end
94
+ when Array
95
+ value.each_with_index do |item, index|
96
+ check(item_schema(schema, index), item, "#{path}/#{index}", depth + 1)
97
+ end
98
+ end
99
+ end
100
+
101
+ def item_schema(schema, index)
102
+ tuple = schema["prefixItems"] || (schema["items"] if schema["items"].is_a?(Array))
103
+ Array(tuple)[index] || (schema["items"] unless schema["items"].is_a?(Array))
104
+ end
105
+
106
+ def own_problems(schema, value)
107
+ case value
108
+ when Integer, Float then number_problems(schema, value)
109
+ when String then size_problems(schema, value.length, "characters", "minLength", "maxLength")
110
+ when Array then array_problems(schema, value)
111
+ when Hash then Array(schema["required"]).reject { value.key?(_1) }.map { "is missing #{_1}" }
112
+ else []
113
+ end
114
+ end
115
+
116
+ def number_problems(schema, number)
117
+ # NUMBER_RULES: [keyword, broken?(number, limit), message]
118
+ NUMBER_RULES.filter_map do |keyword, broken, message|
119
+ limit = schema[keyword]
120
+ format(message, limit) if numeric?(limit) && broken.call(number, limit)
121
+ end
122
+ end
123
+
124
+ # A local JSON pointer `$ref` ("#" for the whole schema,
125
+ # "#/$defs/name", "#/definitions/name", or deeper, as
126
+ # "#/$defs/a/properties/b"); any other reference is left as the schema
127
+ # it sits in.
128
+ def resolve(schema)
129
+ ref = schema["$ref"]
130
+ return @root if ref == "#"
131
+ return schema unless ref.is_a?(String) && ref.start_with?("#/")
132
+
133
+ target = ref.delete_prefix("#/").split("/").reduce(@root) do |level, part|
134
+ key = part.gsub("~1", "/").gsub("~0", "~")
135
+ level.is_a?(Hash) ? level[key] : nil
136
+ end
137
+ target.is_a?(Hash) ? target : schema
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "schema_constraint_check"
6
+
7
+ module Smith
8
+ module Providers
9
+ # Structured-output schemas and the constraints providers do not enforce.
10
+ #
11
+ # Anthropic's structured outputs reject numeric constraints (`minimum`,
12
+ # `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`),
13
+ # string lengths (`minLength`, `maxLength`) and array sizes beyond a
14
+ # `minItems` of 0 or 1 (`maxItems`, `uniqueItems`), and need
15
+ # `additionalProperties: false` on every object. `for_provider` sends
16
+ # such a schema without them; `problems` checks them on the parsed
17
+ # output, for every provider (SchemaConstraintCheck), so one schema
18
+ # serves both the request and the check. Walks are bounded in depth.
19
+ module SchemaConstraints
20
+ STRIPPED = %w[minimum maximum exclusiveMinimum exclusiveMaximum multipleOf minLength maxLength maxItems
21
+ uniqueItems].freeze
22
+ NESTED_LISTS = %w[anyOf allOf oneOf prefixItems].freeze
23
+ NESTED_MAPS = %w[properties $defs definitions].freeze
24
+ NESTED_SCHEMAS = %w[items not].freeze
25
+ MAX_DEPTH = 64
26
+
27
+ module_function
28
+
29
+ # The JSON schema payload ({ "schema" => ..., with "name" and the
30
+ # like when given }, String keys) of an output schema: a RubyLLM
31
+ # schema class or instance, or a Hash. nil when it has none.
32
+ def json_schema(output_schema)
33
+ instance = output_schema.is_a?(Class) ? output_schema.new : output_schema
34
+ raw = instance.respond_to?(:to_json_schema) ? instance.to_json_schema : instance
35
+ return unless raw.is_a?(Hash)
36
+
37
+ schema = JSON.parse(JSON.generate(raw))
38
+ schema["schema"].is_a?(Hash) ? schema : { "schema" => schema }
39
+ rescue StandardError
40
+ nil
41
+ end
42
+
43
+ # The schema payload to send to `provider`: for Anthropic, without the
44
+ # constraints it rejects and with closed objects; unchanged otherwise.
45
+ def for_provider(payload, provider)
46
+ return payload unless provider.to_s == "anthropic"
47
+
48
+ payload.merge("schema" => strip(payload.fetch("schema"), 0))
49
+ end
50
+
51
+ # "path: problem" lines for each constraint the value breaks, at most
52
+ # SchemaConstraintCheck::MAX_PROBLEMS.
53
+ def problems(schema, value) = SchemaConstraintCheck.new(schema).call(value)
54
+
55
+ def strip(schema, depth)
56
+ return schema unless schema.is_a?(Hash) && depth <= MAX_DEPTH
57
+
58
+ copy = schema.reject { |key, setting| STRIPPED.include?(key) || (key == "minItems" && setting.to_i > 1) }
59
+ close_object!(copy)
60
+ copy.to_h { |key, setting| [key, strip_nested(key, setting, depth)] }
61
+ end
62
+
63
+ def strip_nested(key, setting, depth)
64
+ if (NESTED_LISTS.include?(key) || key == "items") && setting.is_a?(Array)
65
+ setting.map { strip(_1, depth + 1) }
66
+ elsif NESTED_MAPS.include?(key) && setting.is_a?(Hash) then setting.transform_values { strip(_1, depth + 1) }
67
+ elsif NESTED_SCHEMAS.include?(key) then strip(setting, depth + 1)
68
+ else setting
69
+ end
70
+ end
71
+
72
+ # An object gets `additionalProperties: false`. One that allows other
73
+ # properties, or declares none (a free-form map, which closed could
74
+ # only be `{}`), cannot be sent and is refused before the call.
75
+ def close_object!(schema)
76
+ return unless Array(schema["type"]).include?("object") || schema.key?("properties")
77
+
78
+ open = schema.key?("additionalProperties") && schema["additionalProperties"] != false
79
+ if open || Hash(schema["properties"]).empty?
80
+ raise Smith::WorkflowError, "Anthropic structured outputs need every object to declare its properties " \
81
+ "and allow no others"
82
+ end
83
+ schema["additionalProperties"] = false
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Smith
6
+ module Providers
7
+ # The JSON schema rules SchemaConstraintCheck applies: numeric bounds
8
+ # and multiples, array sizes and uniqueness, and JSON types.
9
+ module SchemaRules
10
+ NUMBER_RULES = [
11
+ ["minimum", ->(number, limit) { number < limit }, "is below the minimum %s"],
12
+ ["maximum", ->(number, limit) { number > limit }, "is above the maximum %s"],
13
+ ["exclusiveMinimum", ->(number, limit) { number <= limit }, "must be above %s"],
14
+ ["exclusiveMaximum", ->(number, limit) { number >= limit }, "must be below %s"],
15
+ ["multipleOf", ->(number, divisor) { divisor.positive? && !SchemaRules.multiple?(number, divisor) },
16
+ "is not a multiple of %s"]
17
+ ].freeze
18
+ TYPES = {
19
+ "object" => ->(value) { value.is_a?(Hash) }, "array" => ->(value) { value.is_a?(Array) },
20
+ "string" => ->(value) { value.is_a?(String) },
21
+ "integer" => ->(value) { value.is_a?(Integer) || SchemaRules.integral_float?(value) },
22
+ "number" => ->(value) { value.is_a?(Integer) || value.is_a?(Float) },
23
+ "boolean" => ->(value) { [true, false].include?(value) }, "null" => lambda(&:nil?)
24
+ }.freeze
25
+
26
+ # Compared as the decimals written in the schema and the output, so
27
+ # 5 is a multiple of 0.01 and 0.3 of 0.1.
28
+ def self.integral_float?(value) = value.is_a?(Float) && value.finite? && value == value.floor
29
+
30
+ def self.multiple?(number, divisor)
31
+ quotient = BigDecimal(number.to_s) / BigDecimal(divisor.to_s)
32
+ (quotient - quotient.round).abs < BigDecimal("1e-9")
33
+ end
34
+
35
+ private
36
+
37
+ def numeric?(setting) = setting.is_a?(Integer) || (setting.is_a?(Float) && setting.finite?)
38
+
39
+ def array_problems(schema, array)
40
+ problems = size_problems(schema, array.length, "items", "minItems", "maxItems")
41
+ problems << "has duplicate items" if schema["uniqueItems"] == true && duplicates?(array)
42
+ problems
43
+ end
44
+
45
+ # 1 and 1.0 are the same JSON number.
46
+ def duplicates?(array)
47
+ normalized = array.map { |item| SchemaRules.integral_float?(item) ? item.to_i : item }
48
+ normalized.uniq.length != normalized.length
49
+ end
50
+
51
+ def size_problems(schema, size, unit, min_key, max_key)
52
+ [
53
+ ("has fewer than #{schema[min_key]} #{unit}" if schema[min_key].is_a?(Integer) && size < schema[min_key]),
54
+ ("has more than #{schema[max_key]} #{unit}" if schema[max_key].is_a?(Integer) && size > schema[max_key])
55
+ ].compact
56
+ end
57
+
58
+ def type?(type, value)
59
+ return true if type.nil?
60
+ return Array(type).any? { type?(_1, value) } if type.is_a?(Array)
61
+
62
+ TYPES.fetch(type, ->(_value) { true }).call(value)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Providers
5
+ # What a provider's stop reason says about how a response ended: cut off
6
+ # at the output limit (:truncated), declined (:refused), or nothing Smith
7
+ # acts on (nil, which includes an absent or unknown stop reason).
8
+ #
9
+ # truncated max_tokens, model_context_window_exceeded (Anthropic,
10
+ # Bedrock), length (OpenAI Chat Completions),
11
+ # max_output_tokens (OpenAI Responses), MAX_TOKENS (Gemini),
12
+ # output_limit_reached and stream_incomplete (Smith, for
13
+ # a streamed reply that ended without a stop reason, with
14
+ # or without its output reaching the request's limit)
15
+ # refused refusal (Anthropic; OpenAI's message-level refusal),
16
+ # prompt_blocked (a Gemini prompt block),
17
+ # content_filter (OpenAI), SAFETY, RECITATION, BLOCKLIST,
18
+ # PROHIBITED_CONTENT, SPII, IMAGE_SAFETY (Gemini),
19
+ # content_filtered, guardrail_intervened (Bedrock)
20
+ module StopReason
21
+ TRUNCATED = %w[
22
+ max_tokens model_context_window_exceeded length max_output_tokens MAX_TOKENS output_limit_reached
23
+ stream_incomplete
24
+ ].freeze
25
+ REFUSED = %w[
26
+ refusal prompt_blocked content_filter SAFETY RECITATION BLOCKLIST PROHIBITED_CONTENT SPII IMAGE_SAFETY
27
+ content_filtered guardrail_intervened
28
+ ].freeze
29
+
30
+ module_function
31
+
32
+ def classify(stop_reason)
33
+ return :truncated if TRUNCATED.include?(stop_reason)
34
+
35
+ :refused if REFUSED.include?(stop_reason)
36
+ end
37
+ end
38
+ end
39
+ end