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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +901 -0
- data/README.md +636 -20
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +100 -14
- data/docs/PATTERNS.md +88 -12
- data/docs/PERSISTENCE.md +738 -6
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +71 -7
- data/lib/smith/agent/provider_completion.rb +41 -38
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +75 -2
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -48
- data/lib/smith/agent.rb +14 -1
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +10 -4
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +21 -6
- data/lib/smith/context.rb +28 -0
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +35 -17
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +304 -6
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +14 -8
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +62 -11
- data/lib/smith/workflow/deterministic_step.rb +56 -7
- data/lib/smith/workflow/dsl.rb +74 -5
- data/lib/smith/workflow/durability.rb +140 -31
- data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +32 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +41 -2
- data/lib/smith/workflow/failure_record.rb +12 -12
- data/lib/smith/workflow/failure_record_validator.rb +32 -5
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +162 -22
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +71 -8
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +40 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +117 -19
- data/lib/smith.rb +103 -47
- 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
|