smith-agents 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -0
  3. data/README.md +622 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +67 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. metadata +88 -1
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ require_relative "response_facts"
6
+
7
+ module Smith
8
+ module Providers
9
+ # The stop reason and message id of each response of a streamed call. A
10
+ # streamed RubyLLM message keeps no raw body for ResponseFacts to read,
11
+ # so the facts are read from the provider's stream events as RubyLLM
12
+ # turns them into chunks, each event recognized by its own markers:
13
+ #
14
+ # Anthropic "message_start" message.id; "message_delta" delta.stop_reason
15
+ # OpenAI and kin "choices" => [...]: id, choices[0].finish_reason, delta.refusal
16
+ # Gemini candidates[0].finishReason, responseId; promptFeedback.blockReason
17
+ # Bedrock stopReason, or messageStop.stopReason
18
+ #
19
+ # Only the call's own provider object is read (a tool's own RubyLLM call
20
+ # streams through another), and each provider request (a round of a
21
+ # tool loop) keeps facts of its own. The collection lives in fiber
22
+ # storage, which fibers and threads started inside the call inherit.
23
+ # Reading never raises.
24
+ #
25
+ # A streamed reply without a stop reason did not end as the provider
26
+ # ends a reply: it counts as cut off, as LIMIT_REACHED when its output
27
+ # tokens reached the request's limit, else as INCOMPLETE.
28
+ module StreamFacts
29
+ HELD = :smith_stream_facts
30
+ LIMIT_REACHED = "output_limit_reached"
31
+ INCOMPLETE = "stream_incomplete"
32
+ MARK = :@smith_stream_facts_observer
33
+ private_constant :HELD, :MARK
34
+
35
+ # The streams of one call: its provider (nil reads any) and the facts
36
+ # of each of its requests.
37
+ class Collection
38
+ attr_reader :rounds
39
+
40
+ def initialize(provider)
41
+ @provider = provider
42
+ @rounds = []
43
+ end
44
+
45
+ def owns?(provider) = @provider.nil? || provider.equal?(@provider)
46
+
47
+ def open_round = @rounds << {}
48
+
49
+ def current
50
+ open_round if @rounds.empty?
51
+ @rounds.last
52
+ end
53
+
54
+ # Each round's facts, bounded.
55
+ def facts
56
+ @rounds.map do |round|
57
+ { stop_reason: ResponseFacts.bounded(round[:stop_reason]),
58
+ provider_message_id: ResponseFacts.bounded(round[:provider_message_id]) }.compact
59
+ end
60
+ end
61
+ end
62
+
63
+ module_function
64
+
65
+ # Runs the block, given the collection, collecting the facts of the
66
+ # streams `provider` reads; returns them, one bounded Hash per round.
67
+ def collect(provider)
68
+ outer = Fiber[HELD]
69
+ collection = Fiber[HELD] = Collection.new(provider)
70
+ yield collection
71
+ collection.facts
72
+ ensure
73
+ Fiber[HELD] = outer
74
+ end
75
+
76
+ # The facts of each response of a streamed call, in order: each
77
+ # round's over its raw body's, the last settled. Rounds and responses
78
+ # pair up in order; a round past the last response (a request that
79
+ # failed) has none.
80
+ def per_message(messages, rounds, output_limit)
81
+ missing = messages.length - rounds.length
82
+ aligned = missing.positive? ? [*Array.new(missing, {}), *rounds] : rounds.first(messages.length)
83
+ facts = messages.zip(aligned).map { |message, round| ResponseFacts.from_message(message).merge(round) }
84
+ facts[-1] = settle(messages.last, aligned.last, output_limit)
85
+ facts
86
+ end
87
+
88
+ # A streamed response's facts: its stream's, over its raw body's (a
89
+ # path that does not stream keeps one); without a stop reason,
90
+ # LIMIT_REACHED or INCOMPLETE.
91
+ def settle(message, collected, output_limit)
92
+ facts = ResponseFacts.from_message(message).merge(collected)
93
+ return facts if facts[:stop_reason]
94
+
95
+ tokens = message.output_tokens if message.respond_to?(:output_tokens)
96
+ reached = output_limit.is_a?(Integer) && tokens.is_a?(Integer) && tokens >= output_limit
97
+ facts.merge(stop_reason: reached ? LIMIT_REACHED : INCOMPLETE)
98
+ end
99
+
100
+ # A request of the collecting call begins (RoundObserver).
101
+ def round(provider)
102
+ collection = Fiber[HELD]
103
+ collection.open_round if collection&.owns?(provider)
104
+ end
105
+
106
+ def observe(provider, data)
107
+ collection = Fiber[HELD]
108
+ record(collection.current, data) if collection&.owns?(provider) && data.is_a?(Hash)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ def record(facts, data)
114
+ if data["type"].is_a?(String) then anthropic(facts, data)
115
+ elsif data["choices"].is_a?(Array) then openai(facts, data)
116
+ elsif data.key?("candidates") || data.key?("promptFeedback") then gemini(facts, data)
117
+ else bedrock(facts, data)
118
+ end
119
+ end
120
+
121
+ def anthropic(facts, data)
122
+ case data["type"]
123
+ when "message_start"
124
+ facts.delete(:stop_reason)
125
+ facts[:provider_message_id] = data.dig("message", "id")
126
+ when "message_delta" then facts[:stop_reason] = data.dig("delta", "stop_reason") || facts[:stop_reason]
127
+ end
128
+ end
129
+
130
+ # A refusal streams as `delta.refusal` text before the finish reason.
131
+ def openai(facts, data)
132
+ facts[:provider_message_id] = data["id"] if data["id"]
133
+ choice = ResponseFacts.first_entry(data["choices"])
134
+ return unless choice
135
+
136
+ facts[:refused] = true if ResponseFacts.chat_refusal?("message" => choice["delta"])
137
+ reason = choice["finish_reason"]
138
+ facts[:stop_reason] = facts[:refused] ? ResponseFacts::REFUSAL : reason if reason
139
+ end
140
+
141
+ def gemini(facts, data)
142
+ facts[:provider_message_id] = data["responseId"] if data["responseId"]
143
+ candidate = ResponseFacts.first_entry(data["candidates"])
144
+ blocked = ResponseFacts::PROMPT_BLOCKED if data.dig("promptFeedback", "blockReason").is_a?(String)
145
+ reason = candidate ? candidate["finishReason"] : blocked
146
+ facts[:stop_reason] = reason if reason
147
+ end
148
+
149
+ def bedrock(facts, data)
150
+ reason = data["stopReason"] || data.dig("messageStop", "stopReason")
151
+ facts[:stop_reason] = reason if reason
152
+ end
153
+
154
+ # Prepended once on each of RubyLLM's streaming modules, with the
155
+ # visibility that module gives `build_chunk`: every event a provider
156
+ # turns into a chunk is read first.
157
+ def install(streaming)
158
+ return if streaming.ancestors.any? { observer?(_1) }
159
+
160
+ observer = Module.new do
161
+ define_method(:build_chunk) do |data|
162
+ StreamFacts.observe(self, data)
163
+ super(data)
164
+ end
165
+ end
166
+ observer.instance_variable_set(MARK, streaming.name)
167
+ observer.send(:private, :build_chunk) if streaming.private_method_defined?(:build_chunk)
168
+ streaming.prepend(observer)
169
+ end
170
+
171
+ def observer?(mod) = mod.instance_variable_defined?(MARK)
172
+
173
+ # Prepended on RubyLLM::Provider: each streamed request opens a round.
174
+ module RoundObserver
175
+ def complete(*args, **kwargs, &block)
176
+ StreamFacts.round(self) if block
177
+ super
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ # Installed at gem-require; the subclasses of these providers (DeepSeek,
185
+ # Mistral, xAI, Vertex AI and other OpenAI- or Gemini-compatible ones)
186
+ # inherit it.
187
+ [
188
+ RubyLLM::Providers::Anthropic::Streaming, RubyLLM::Providers::OpenAI::Streaming,
189
+ RubyLLM::Providers::Gemini::Streaming, RubyLLM::Providers::OpenRouter::Streaming,
190
+ RubyLLM::Providers::Bedrock::Streaming
191
+ ].each { |streaming| Smith::Providers::StreamFacts.install(streaming) }
192
+ RubyLLM::Provider.prepend(Smith::Providers::StreamFacts::RoundObserver)
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Smith
7
+ # A host's record of what a step's provider calls got
8
+ # (`Smith.config.response_memo`: `fetch(key)` and `store(key, record)`),
9
+ # so a step replayed after its process died reuses it instead of paying
10
+ # again.
11
+ #
12
+ # A call (one agent call, its tool loop included) is kept under a key of
13
+ # its position (IdempotencyKey: the run, the step, any branch or nested
14
+ # position, the key generation), the attempts it runs under (the step's,
15
+ # and each enclosing run's for a nested workflow), its attempt index (its
16
+ # model in the fallback chain, or a follow-up's place), its place among
17
+ # the attempt's calls, and a digest of its request (model, messages,
18
+ # tools, schema, params). A replay reaches the same key; a call whose
19
+ # request changed since (a deployed prompt, another model, a date in the
20
+ # prompt) misses and is sent, as a miss always may be.
21
+ #
22
+ # Its record, `{"version" => 1, "messages" => [...], "complete" => bool}`,
23
+ # holds the messages the call added (Budget::Continuation, with each
24
+ # response's usage and facts), stored after each round's tool results and
25
+ # when the call ends; a complete one ends with the model's reply. Replayed,
26
+ # a complete call sends nothing; an unfinished one continues from its
27
+ # tools' results. Either way the step counts its tools as run (a retry or
28
+ # fallback never runs them again). A call whose messages cannot be kept
29
+ # (an attachment, or too many bytes) is not stored, and is logged. A record
30
+ # Smith does not keep is ignored, and an error from `fetch` or `store` is
31
+ # logged by class: a memo never fails a step. Keeping records and dropping
32
+ # them is the host's.
33
+ module ResponseMemo
34
+ VERSION = 1
35
+ PROGRESS = :smith_response_memo
36
+ INHERITED = :smith_inherited_memo_attempts
37
+ private_constant :PROGRESS, :INHERITED
38
+
39
+ Recorder = Struct.new(:provider, :memo, :key, :start, :rounds, :busy)
40
+
41
+ module_function
42
+
43
+ # A step call's key, or nil outside a step or for a request it cannot
44
+ # digest.
45
+ def key(attempts, candidate, call, chat)
46
+ digest = request_digest(chat)
47
+ IdempotencyKey.current(["smith.response", attempts, candidate, call, digest]) if digest
48
+ rescue WorkflowError
49
+ nil
50
+ end
51
+
52
+ def request_digest(chat)
53
+ shape = [chat.model.id, chat.model.provider.to_s, chat.messages.map { message_shape(_1) },
54
+ chat.tools.keys.map(&:to_s).sort, chat.schema, chat.params]
55
+ Digest::SHA256.hexdigest(JSON.generate(shape))
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def message_shape(message)
61
+ content = message.content
62
+ body = case content
63
+ when RubyLLM::Content::Raw then content.value
64
+ when RubyLLM::Content
65
+ [content.text, content.attachments.map { [_1.filename, _1.mime_type, _1.content.to_s.bytesize] }]
66
+ else content
67
+ end
68
+ [message.role.to_s, body, message.tool_calls&.keys, message.tool_call_id]
69
+ end
70
+
71
+ # The call's kept messages and whether it ended, or nil.
72
+ def fetch(memo, key)
73
+ record = memo.fetch(key)
74
+ return unless record
75
+
76
+ record = JSON.parse(JSON.generate(record))
77
+ raise ArgumentError, "not a response memo record" unless record?(record)
78
+
79
+ { messages: Budget::Continuation.load(record["messages"]), complete: record["complete"] }
80
+ rescue StandardError => e
81
+ Smith.config.logger&.error("Smith response memo fetch error: #{e.class}")
82
+ nil
83
+ end
84
+
85
+ def record?(record)
86
+ record.is_a?(Hash) && record.keys.sort == %w[complete messages version] && record["version"] == VERSION &&
87
+ [true, false].include?(record["complete"]) && Budget::Continuation.valid?(record["messages"]) &&
88
+ (!record["complete"] || reply?(record["messages"].last))
89
+ end
90
+
91
+ def reply?(message) = message["role"] == "assistant" && message["tool_calls"].nil?
92
+
93
+ # Stores the call's messages (`facts`: a streamed reply's, by message),
94
+ # unless they cannot be kept.
95
+ def store(memo, key, messages, complete:, facts: {})
96
+ dumped = Budget::Continuation.dump(nil, messages, usage: true, facts:)
97
+ return memo.store(key, { "version" => VERSION, "messages" => dumped, "complete" => complete }) if dumped
98
+
99
+ Smith.config.logger&.info("Smith response memo: a call not kept (an attachment, or past its byte limit)")
100
+ nil
101
+ rescue StandardError => e
102
+ Smith.config.logger&.error("Smith response memo store error: #{e.class}")
103
+ nil
104
+ end
105
+
106
+ # Runs the block with the provider's call recorded: before each of its
107
+ # later rounds, its messages from `start` (the round before's tool
108
+ # results included) are stored as its progress.
109
+ def recording(provider, memo, key, start)
110
+ outer = Fiber[PROGRESS]
111
+ Fiber[PROGRESS] = Recorder.new(provider, memo, key, start, 0, false)
112
+ yield
113
+ ensure
114
+ Fiber[PROGRESS] = outer
115
+ end
116
+
117
+ # A request the provider is about to make, from RubyLLM's
118
+ # `Provider#complete` (`messages`, its chat's).
119
+ def round(provider, messages, &)
120
+ recorder = Fiber[PROGRESS]
121
+ return yield unless recorder&.provider.equal?(provider) && !recorder.busy
122
+
123
+ recorder.rounds += 1
124
+ store(recorder.memo, recorder.key, messages.drop(recorder.start), complete: false) if recorder.rounds > 1
125
+ busy(recorder, &)
126
+ end
127
+
128
+ def busy(recorder)
129
+ recorder.busy = true
130
+ yield
131
+ ensure
132
+ recorder.busy = false
133
+ end
134
+
135
+ # A nested workflow built in the block runs its calls under `attempts`
136
+ # too (the enclosing runs' attempts), so a retry of its parent's step is
137
+ # never answered from the attempt before.
138
+ def inheriting(attempts)
139
+ outer = Thread.current[INHERITED]
140
+ Thread.current[INHERITED] = attempts
141
+ yield
142
+ ensure
143
+ Thread.current[INHERITED] = outer
144
+ end
145
+
146
+ def take_inherited
147
+ attempts = Thread.current[INHERITED]
148
+ Thread.current[INHERITED] = nil
149
+ attempts || []
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ # Constructors that check Smith.config's settings as they are set.
5
+ module SettingConstructors
6
+ module_function
7
+
8
+ def positive_integer(name)
9
+ lambda do |value|
10
+ unless value.is_a?(Integer) && value.positive?
11
+ raise ArgumentError, "Smith.config.#{name} must be a positive integer, got #{value.inspect}"
12
+ end
13
+
14
+ value
15
+ end
16
+ end
17
+
18
+ def optional_positive_integer(name)
19
+ lambda do |value|
20
+ next value if value.nil? || (value.is_a?(Integer) && value.positive?)
21
+
22
+ raise ArgumentError, "Smith.config.#{name} must be nil or a positive Integer, got #{value.inspect}"
23
+ end
24
+ end
25
+
26
+ def bounded_positive_integer(name, maximum)
27
+ lambda do |value|
28
+ unless value.is_a?(Integer) && value.positive? && value <= maximum
29
+ message = "Smith.config.#{name} must be a positive integer no greater than #{maximum}"
30
+ raise ArgumentError, "#{message}, got #{value.inspect}"
31
+ end
32
+
33
+ value
34
+ end
35
+ end
36
+
37
+ # A host adapter: nil, or an object that answers `calls` (each named
38
+ # with its arguments, as the message says).
39
+ def adapter(name, calls)
40
+ lambda do |value|
41
+ next value if value.nil? || calls.keys.all? { value.respond_to?(_1) }
42
+
43
+ raise ArgumentError, "Smith.config.#{name} must respond to #{calls.values.join(" and ")}"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "json_string"
4
+
5
+ module Smith
6
+ module Streaming
7
+ # The growing value of one top-level string field of a JSON object
8
+ # that arrives as text in pieces (a step with an output schema streams
9
+ # its JSON):
10
+ #
11
+ # field = Smith::Streaming::JsonField.new(:reply)
12
+ # def delta(text) = (piece = field.push(text)) && screen.append(piece)
13
+ # def reset(_reason) = field.reset || screen.clear
14
+ #
15
+ # `push` returns the text the piece added to the field, unescaped, or
16
+ # nil when it added none; `value` is the field's text so far. Text before
17
+ # the object (a code fence) is skipped. The field is read from the first
18
+ # top-level object only (keys unescaped as JSON reads them), from the
19
+ # first member of that name whose value is a string: a later duplicate,
20
+ # which `JSON.parse` would take, comes after the text already streamed.
21
+ # Work and memory are linear in the text pushed.
22
+ class JsonField
23
+ WHITESPACE = " \t\n\r"
24
+ private_constant :WHITESPACE
25
+
26
+ def initialize(name)
27
+ unless (name.is_a?(Symbol) || name.is_a?(String)) && !name.to_s.empty?
28
+ raise ArgumentError, "a JsonField is named by a Symbol or String"
29
+ end
30
+
31
+ @name = name.to_s.freeze
32
+ reset
33
+ end
34
+
35
+ def push(text)
36
+ before = @string.text.bytesize
37
+ text.to_s.each_char { |char| step(char) }
38
+ @string.text.byteslice(before..).freeze if @string.text.bytesize > before
39
+ end
40
+
41
+ def value = @string.text.dup.freeze
42
+
43
+ # Starts over, for text that replaces what was pushed.
44
+ def reset
45
+ @state = :outside
46
+ @depth = 0
47
+ @in_string = @escape = @expect_key = @armed = false
48
+ @key = @last_key = nil
49
+ @string = JsonString.new
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def step(char)
56
+ case @state
57
+ when :outside then enter_object if char == "{"
58
+ when :scan then @in_string ? scan_string(char) : scan(char)
59
+ when :capture then capture(char)
60
+ end
61
+ end
62
+
63
+ def enter_object
64
+ @state = :scan
65
+ @depth = 1
66
+ @expect_key = true
67
+ end
68
+
69
+ def scan(char)
70
+ return if WHITESPACE.include?(char)
71
+ return value_start(char) if @armed
72
+
73
+ case char
74
+ when '"' then open_string
75
+ when "{", "[" then @depth += 1
76
+ when "}", "]" then close_container
77
+ else separator(char) if @depth == 1
78
+ end
79
+ end
80
+
81
+ # The first character of the value after the field's key.
82
+ def value_start(char)
83
+ @armed = false
84
+ return @state = :capture if char == '"'
85
+
86
+ scan(char)
87
+ end
88
+
89
+ def open_string
90
+ @in_string = true
91
+ @key = JsonString.new(limit: @name.length) if @expect_key
92
+ end
93
+
94
+ def close_container
95
+ @depth -= 1
96
+ @state = :done if @depth.zero?
97
+ end
98
+
99
+ # A member's `:` after its key, or the `,` before the next member.
100
+ def separator(char)
101
+ if char == ":"
102
+ @expect_key = false
103
+ @armed = @last_key == @name
104
+ elsif char == ","
105
+ @expect_key = true
106
+ end
107
+ end
108
+
109
+ # Inside a string that is not the field's value: a key is read as JSON
110
+ # reads it, up to the length of the field's name; any other string is
111
+ # skipped to its closing quote.
112
+ def scan_string(char)
113
+ return key_string(char) if @key
114
+
115
+ if @escape then @escape = false
116
+ elsif char == "\\" then @escape = true
117
+ elsif char == '"' then @in_string = false
118
+ end
119
+ end
120
+
121
+ def key_string(char)
122
+ @key.add(char)
123
+ return unless @key.closed?
124
+
125
+ @in_string = false
126
+ @last_key = @key.overflowed? ? nil : @key.text
127
+ @key = nil
128
+ end
129
+
130
+ def capture(char)
131
+ @string.add(char)
132
+ @state = :done if @string.closed?
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Streaming
5
+ # A JSON string's characters after its opening quote, given one at a
6
+ # time and unescaped as they come (JsonField). A UTF-16 surrogate pair
7
+ # (two escapes) is one character; half of one alone, or a `\u` escape
8
+ # that is not four hex digits, is the replacement character. With a
9
+ # `limit`, text past that many characters is dropped and the string is
10
+ # `overflowed?`.
11
+ class JsonString
12
+ ESCAPES = { '"' => '"', "\\" => "\\", "/" => "/", "b" => "\b", "f" => "\f", "n" => "\n", "r" => "\r",
13
+ "t" => "\t" }.freeze
14
+ REPLACEMENT = "\uFFFD"
15
+ private_constant :ESCAPES, :REPLACEMENT
16
+
17
+ attr_reader :text
18
+
19
+ def initialize(limit: nil)
20
+ @text = String.new(encoding: Encoding::UTF_8)
21
+ @limit = limit
22
+ @length = 0
23
+ @escape = @overflowed = false
24
+ @unicode = @high = nil
25
+ @closed = false
26
+ end
27
+
28
+ def closed? = @closed
29
+
30
+ def overflowed? = @overflowed
31
+
32
+ def add(char)
33
+ return unicode_digit(char) if @unicode
34
+ return escaped(char) if @escape
35
+
36
+ case char
37
+ when "\\" then @escape = true
38
+ when '"' then close
39
+ else append(char)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def escaped(char)
46
+ @escape = false
47
+ char == "u" ? @unicode = +"" : append(ESCAPES.fetch(char, char))
48
+ end
49
+
50
+ def unicode_digit(char)
51
+ @unicode << char
52
+ return if @unicode.length < 4
53
+
54
+ digits = @unicode
55
+ @unicode = nil
56
+ digits.match?(/\A\h{4}\z/) ? append_code(digits.hex) : append(REPLACEMENT)
57
+ end
58
+
59
+ def append_code(code)
60
+ high = @high
61
+ @high = nil
62
+ return keep((0x10000 + ((high - 0xD800) << 10) + (code - 0xDC00)).chr(Encoding::UTF_8)) if
63
+ high && code.between?(0xDC00, 0xDFFF)
64
+
65
+ keep(REPLACEMENT) if high
66
+ if code.between?(0xD800, 0xDBFF) then @high = code
67
+ else keep(code.between?(0xDC00, 0xDFFF) ? REPLACEMENT : code.chr(Encoding::UTF_8))
68
+ end
69
+ end
70
+
71
+ def append(text)
72
+ keep(REPLACEMENT) if @high
73
+ @high = nil
74
+ keep(text) unless text.empty?
75
+ end
76
+
77
+ # One character more, unless past the limit.
78
+ def keep(char)
79
+ return @overflowed = true if @limit && @length >= @limit
80
+
81
+ @length += 1
82
+ @text << char
83
+ end
84
+
85
+ def close
86
+ append("")
87
+ @closed = true
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Streaming
5
+ # One step execution's stream, from the step's start to its outcome.
6
+ # Each provider call opens with `start(info)`, info being the step's
7
+ # (the stream's name, the run's execution key, the transition) with the
8
+ # step attempt (from 1), the model and its provider. `delta(text)` follows with each text
9
+ # chunk of the reply (tool calls and thinking are not sent; a tool
10
+ # loop's rounds are sent one after another). Before a call that
11
+ # replaces the text sent, that text is withdrawn with `reset(reason)`:
12
+ #
13
+ # :retry the step is attempted again (`retry_on`)
14
+ # :fallback another model answers (a provider failure, or a refusal)
15
+ # :truncation the reply was cut off and is asked for again
16
+ # :repair the reply could not be the output and is asked for again
17
+ # :retry_wait the step waits out a durable retry's backoff
18
+ #
19
+ # The step then ends with `finish(output)`, the output it accepted
20
+ # (after guardrails, the output contract and `output_to:`, once the step
21
+ # is stored), `fail(error_class)`, or `reset(:retry_wait)`, after which
22
+ # the attempt that resumes it streams to a new sink. A sink that raises
23
+ # (a ScriptError such as NotImplementedError included) is logged and the
24
+ # step goes on.
25
+ class Session
26
+ def initialize(sink, info)
27
+ @sink = sink
28
+ @info = info
29
+ @open = false
30
+ @settled = false
31
+ end
32
+
33
+ def start(attempt:, model:, provider:)
34
+ return if @settled
35
+
36
+ @open = true
37
+ notify(:start, @info.merge(attempt:, model:, provider:).freeze)
38
+ end
39
+
40
+ def delta(text)
41
+ notify(:delta, text) if @open && text.is_a?(String) && !text.empty?
42
+ end
43
+
44
+ # Withdraws the text sent since the last start, if any.
45
+ def withdraw(reason)
46
+ return unless @open
47
+
48
+ @open = false
49
+ notify(:reset, reason)
50
+ end
51
+
52
+ def finish(output) = settle(:finish, output)
53
+
54
+ def fail(error_class) = settle(:fail, error_class)
55
+
56
+ # The step waits out a durable retry's backoff: the sink's last word,
57
+ # whether or not text was sent.
58
+ def suspend = settle(:reset, :retry_wait)
59
+
60
+ private
61
+
62
+ def settle(method, value)
63
+ return if @settled
64
+
65
+ @settled = true
66
+ @open = false
67
+ notify(method, value)
68
+ end
69
+
70
+ def notify(method, value)
71
+ @sink.public_send(method, value) if @sink.respond_to?(method)
72
+ rescue StandardError, ScriptError => e
73
+ Smith.config.logger&.error("Smith stream sink #{@info[:stream].inspect} #{method} error: #{e.class}")
74
+ end
75
+ end
76
+ end
77
+ end