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
data/lib/smith/errors.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "diagnostic_text"
|
|
3
4
|
require_relative "error"
|
|
4
5
|
require_relative "persisted_failure_invalid"
|
|
5
6
|
require_relative "pricing_configuration_error"
|
|
@@ -68,6 +69,21 @@ module Smith
|
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
class BudgetExceeded < Error; end
|
|
72
|
+
|
|
73
|
+
# A host's budget ledger (`budget_ledger`) refused a provider request's
|
|
74
|
+
# hold; nothing was sent. A BudgetExceeded, so it is not retried unless a
|
|
75
|
+
# step's `retry_on` names it (a durable retry waits for budget). One that
|
|
76
|
+
# paused a tool loop carries its `continuation` (Budget::Continuation),
|
|
77
|
+
# which the step's retry continues from.
|
|
78
|
+
class BudgetUnavailable < BudgetExceeded
|
|
79
|
+
attr_reader :continuation
|
|
80
|
+
|
|
81
|
+
def initialize(message = "the budget ledger refused the request", continuation: nil)
|
|
82
|
+
@continuation = continuation
|
|
83
|
+
super(message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
71
87
|
class DeadlineExceeded < Error; end
|
|
72
88
|
class MaxTransitionsExceeded < Error; end
|
|
73
89
|
class GuardrailFailed < Error; end
|
|
@@ -88,6 +104,11 @@ module Smith
|
|
|
88
104
|
class AgentError < Error; end
|
|
89
105
|
|
|
90
106
|
class BlankAgentOutputError < AgentError
|
|
107
|
+
DETAIL_NAMES = %i[agent_name model_used].freeze
|
|
108
|
+
DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
|
|
109
|
+
MAX_DETAIL_BYTES = 512
|
|
110
|
+
private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
|
|
111
|
+
|
|
91
112
|
attr_reader :agent_name, :model_used
|
|
92
113
|
|
|
93
114
|
def initialize(agent_name:, model_used:)
|
|
@@ -101,17 +122,76 @@ module Smith
|
|
|
101
122
|
|
|
102
123
|
super(detail)
|
|
103
124
|
end
|
|
125
|
+
|
|
126
|
+
def details
|
|
127
|
+
{
|
|
128
|
+
agent_name: agent_name && DiagnosticText.capture(agent_name.to_s, max_bytes: MAX_DETAIL_BYTES),
|
|
129
|
+
model_used: model_used && DiagnosticText.capture(model_used.to_s, max_bytes: MAX_DETAIL_BYTES)
|
|
130
|
+
}.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.from_details(details)
|
|
134
|
+
values = normalize_details(details)
|
|
135
|
+
new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.normalize_details(details)
|
|
139
|
+
raise ArgumentError, "blank agent output details must be a Hash" unless details.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
values = {}
|
|
142
|
+
Hash.instance_method(:each_pair).bind_call(details) do |key, value|
|
|
143
|
+
name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
|
|
144
|
+
unless DETAIL_NAMES.include?(name)
|
|
145
|
+
raise ArgumentError, "blank agent output details contain an unknown attribute"
|
|
146
|
+
end
|
|
147
|
+
raise ArgumentError, "blank agent output details contain a duplicate attribute" if values.key?(name)
|
|
148
|
+
|
|
149
|
+
values[name] = bounded_detail(name, value)
|
|
150
|
+
end
|
|
151
|
+
raise ArgumentError, "blank agent output details are incomplete" unless values.length == DETAIL_NAMES.length
|
|
152
|
+
|
|
153
|
+
values
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.bounded_detail(name, value)
|
|
157
|
+
return value if value.nil?
|
|
158
|
+
|
|
159
|
+
bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
|
|
160
|
+
raise ArgumentError, "blank agent output detail #{name} must be bounded text" unless bounded
|
|
161
|
+
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
private_class_method :normalize_details, :bounded_detail
|
|
104
165
|
end
|
|
105
166
|
|
|
106
167
|
class WorkflowError < Error; end
|
|
107
168
|
|
|
169
|
+
# A host's budget ledger (`budget_ledger`) could not be built or failed to
|
|
170
|
+
# answer `reserve!` (its service down, say); nothing was sent. Not a
|
|
171
|
+
# provider failure, so no fallback model is tried; not retried unless a
|
|
172
|
+
# step's `retry_on` names it.
|
|
173
|
+
class BudgetLedgerError < WorkflowError
|
|
174
|
+
def initialize(message = "the budget ledger failed") = super
|
|
175
|
+
end
|
|
176
|
+
|
|
108
177
|
class DeterministicStepFailure < WorkflowError
|
|
109
|
-
attr_reader :retryable, :kind, :details
|
|
178
|
+
attr_reader :retryable, :kind, :details, :new_idempotency_key, :idempotency_scope
|
|
179
|
+
|
|
180
|
+
# `new_idempotency_key: true` declares that the failed attempt's own
|
|
181
|
+
# requests took no effect, so a retry of the step that raised it sends
|
|
182
|
+
# them under new keys; `idempotency_scope` is that step's position. The
|
|
183
|
+
# flag is not persisted with the failure record.
|
|
184
|
+
def initialize(message, retryable: nil, kind: nil, details: nil, new_idempotency_key: false, # rubocop:disable Metrics/ParameterLists
|
|
185
|
+
idempotency_scope: nil)
|
|
186
|
+
unless [true, false, nil].include?(new_idempotency_key)
|
|
187
|
+
raise ArgumentError, "new_idempotency_key must be true or false"
|
|
188
|
+
end
|
|
110
189
|
|
|
111
|
-
def initialize(message, retryable: nil, kind: nil, details: nil)
|
|
112
190
|
@retryable = retryable
|
|
113
191
|
@kind = kind
|
|
114
192
|
@details = details
|
|
193
|
+
@new_idempotency_key = new_idempotency_key == true
|
|
194
|
+
@idempotency_scope = idempotency_scope
|
|
115
195
|
super(message)
|
|
116
196
|
end
|
|
117
197
|
end
|
|
@@ -127,6 +207,141 @@ module Smith
|
|
|
127
207
|
end
|
|
128
208
|
end
|
|
129
209
|
|
|
210
|
+
# An `await` step passed its deadline before `resume!` delivered its
|
|
211
|
+
# event, and the transition declares no `on_timeout` route. It takes the
|
|
212
|
+
# failure path (`on_failure`, or a raise) and is never retried.
|
|
213
|
+
class WaitTimedOut < WorkflowError
|
|
214
|
+
DETAIL_NAMES = %w[transition event deadline_at].freeze
|
|
215
|
+
private_constant :DETAIL_NAMES
|
|
216
|
+
|
|
217
|
+
attr_reader :transition, :event, :deadline_at
|
|
218
|
+
|
|
219
|
+
def initialize(transition:, event:, deadline_at:, message: nil)
|
|
220
|
+
@transition = DiagnosticText.capture(transition.to_s, max_bytes: 256)
|
|
221
|
+
@event = DiagnosticText.capture(event.to_s, max_bytes: 256)
|
|
222
|
+
@deadline_at = DiagnosticText.capture(deadline_at.to_s, max_bytes: 64)
|
|
223
|
+
super(message || "await #{@event} on transition #{@transition} passed its deadline #{@deadline_at}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Kept with the failure record, so a restored run's error is still a
|
|
227
|
+
# WaitTimedOut that names its wait.
|
|
228
|
+
def details = { transition:, event:, deadline_at: }.freeze
|
|
229
|
+
|
|
230
|
+
def self.from_details(details, message: nil)
|
|
231
|
+
raise ArgumentError, "wait timeout details must be a Hash" unless details.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
values = details.to_h { |key, value| [key.to_s, value] }
|
|
234
|
+
unless values.keys.sort == DETAIL_NAMES.sort && values.values.all?(String)
|
|
235
|
+
raise ArgumentError, "wait timeout details are invalid"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
new(transition: values["transition"], event: values["event"], deadline_at: values["deadline_at"], message:)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A `compute` or `run` step that declares `routes:` finished without
|
|
243
|
+
# calling `route_to`, declares no `on_success`, and did not opt into
|
|
244
|
+
# `fallthrough: true`. Without this error the run would silently take the
|
|
245
|
+
# first transition declared from the step's target state.
|
|
246
|
+
class UnroutedDeterministicStep < WorkflowError
|
|
247
|
+
attr_reader :transition, :routes
|
|
248
|
+
|
|
249
|
+
def initialize(transition:, routes:)
|
|
250
|
+
@transition = transition
|
|
251
|
+
@routes = routes
|
|
252
|
+
super(
|
|
253
|
+
"#{DiagnosticText.capture(transition.inspect, max_bytes: 256)} declares routes: " \
|
|
254
|
+
"#{DiagnosticText.capture(routes.inspect, max_bytes: 1024)} but did not call route_to; " \
|
|
255
|
+
"route to one of them, declare on_success, or opt into fallthrough: true"
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A deterministic step wrote a context key that the workflow's
|
|
261
|
+
# Smith::Context does not list in `persist :a, :b`. The write would be
|
|
262
|
+
# dropped at the next checkpoint, so it fails the step instead.
|
|
263
|
+
class UndeclaredContextKey < WorkflowError
|
|
264
|
+
attr_reader :key, :persisted_keys
|
|
265
|
+
|
|
266
|
+
def initialize(key:, persisted_keys:)
|
|
267
|
+
@key = key
|
|
268
|
+
@persisted_keys = persisted_keys
|
|
269
|
+
super(
|
|
270
|
+
"context key #{DiagnosticText.capture(key.inspect, max_bytes: 256)} is not a persisted context key " \
|
|
271
|
+
"(persist #{DiagnosticText.capture(persisted_keys.map(&:inspect).join(", "), max_bytes: 1024)}); " \
|
|
272
|
+
"declare it in the context's persist list, or use persist :auto"
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# A step's content parts cannot be sent: a document or image is missing,
|
|
278
|
+
# unreadable, empty, not what its media type says, or past a bound, or no
|
|
279
|
+
# model in the chain takes it. Raised before the provider call, so it is
|
|
280
|
+
# never retried as a provider failure.
|
|
281
|
+
class ContentUnavailable < WorkflowError; end
|
|
282
|
+
|
|
283
|
+
# No artifact store is configured. Artifacts must outlive the process that
|
|
284
|
+
# stored them whenever a run waits, retries or resumes elsewhere, so Smith
|
|
285
|
+
# assumes none outside test_mode.
|
|
286
|
+
class ArtifactStoreMissing < WorkflowError
|
|
287
|
+
def initialize(message = "Smith.config.artifact_store is not configured; set it to :s3, :active_record or " \
|
|
288
|
+
":file (or a store) for runs that wait, retry or move between processes, or :memory " \
|
|
289
|
+
"for a single process")
|
|
290
|
+
super
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A step streams to a sink Smith.config.stream_sinks does not name.
|
|
295
|
+
# Raised before the step's provider call.
|
|
296
|
+
class StreamSinkMissing < WorkflowError
|
|
297
|
+
def initialize(name)
|
|
298
|
+
super("Smith.config.stream_sinks has no sink #{name.inspect}; register the sink the step streams to")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A step sends its request as a batch and Smith.config.batch_queue is not
|
|
303
|
+
# configured. Raised before anything is sent.
|
|
304
|
+
class BatchQueueMissing < WorkflowError
|
|
305
|
+
def initialize(message = "Smith.config.batch_queue is not configured; batch mode hands its requests to it")
|
|
306
|
+
super
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# A batch step's call cannot go as a batch: its model's provider has no
|
|
311
|
+
# batch interface in Smith (Anthropic has), or its agent has tools (a batch
|
|
312
|
+
# request is one turn). Raised before anything is sent.
|
|
313
|
+
class BatchModeUnavailable < WorkflowError; end
|
|
314
|
+
|
|
315
|
+
# A batch result that did not answer: a server error (`errored`), a
|
|
316
|
+
# `canceled` or `expired` request, or none by the wait's deadline
|
|
317
|
+
# (`missing`). An AgentError, so `retry_on` sends it again.
|
|
318
|
+
class BatchResultFailed < AgentError
|
|
319
|
+
attr_reader :kind, :error_type
|
|
320
|
+
|
|
321
|
+
def initialize(kind:, error_type: nil)
|
|
322
|
+
@kind = kind.to_s.dup.freeze
|
|
323
|
+
@error_type = DiagnosticText.capture(error_type.to_s, max_bytes: 128) if error_type
|
|
324
|
+
super("batch result #{@kind}#{" (#{@error_type})" if @error_type}")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# A batch result answers a request other than the one its step renders
|
|
329
|
+
# now (a date in the prompt, data read afresh, a changed prompt), and the
|
|
330
|
+
# step declares `on_request_change: :fail`. An AgentError, so `retry_on`
|
|
331
|
+
# may send the request again (synchronously, if the mode callable says so
|
|
332
|
+
# at the next attempt); the result is charged all the same.
|
|
333
|
+
class BatchRequestChanged < AgentError
|
|
334
|
+
def initialize(message = "the step renders another request than its batch result answers") = super
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The provider refused a batch request as invalid; sending it again would
|
|
338
|
+
# be refused again.
|
|
339
|
+
class BatchRequestInvalid < WorkflowError
|
|
340
|
+
def initialize(message = "the provider refused the batch request as invalid")
|
|
341
|
+
super
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
130
345
|
class SerializationError < Error; end
|
|
131
346
|
class AgentRegistryError < Error; end
|
|
132
347
|
|
|
@@ -144,6 +359,42 @@ module Smith
|
|
|
144
359
|
end
|
|
145
360
|
end
|
|
146
361
|
|
|
362
|
+
# A workflow state past Smith.config.persistence_max_bytes. A stored
|
|
363
|
+
# run's step whose result would not fit fails with it before the result
|
|
364
|
+
# is kept (its usage recorded, `on_failure` taken; a response memo keeps
|
|
365
|
+
# what its calls got). Any other state past the bound is refused when it
|
|
366
|
+
# is stored, before anything is written, and the stored state stays as it
|
|
367
|
+
# was.
|
|
368
|
+
class PersistedStateTooLarge < WorkflowError
|
|
369
|
+
DETAIL_NAMES = %w[key bytes limit].freeze
|
|
370
|
+
|
|
371
|
+
attr_reader :key, :bytes, :limit
|
|
372
|
+
|
|
373
|
+
def initialize(key:, bytes:, limit:, message: nil)
|
|
374
|
+
@key = key
|
|
375
|
+
@bytes = bytes
|
|
376
|
+
@limit = limit
|
|
377
|
+
super(message || "the workflow state for #{key.inspect} is #{bytes} bytes, past persistence_max_bytes (#{limit})")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def details = { key:, bytes:, limit: }.freeze
|
|
381
|
+
|
|
382
|
+
def self.from_details(details, message: nil)
|
|
383
|
+
raise ArgumentError, "state size details must be a Hash" unless details.is_a?(Hash)
|
|
384
|
+
|
|
385
|
+
values = details.to_h { |name, value| [name.to_s, value] }
|
|
386
|
+
raise ArgumentError, "state size details are invalid" unless details?(values)
|
|
387
|
+
|
|
388
|
+
new(key: values["key"], bytes: values["bytes"], limit: values["limit"], message:)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def self.details?(values)
|
|
392
|
+
values.keys.sort == DETAIL_NAMES.sort && values["key"].is_a?(String) &&
|
|
393
|
+
values.values_at("bytes", "limit").all? { _1.is_a?(Integer) && _1.positive? }
|
|
394
|
+
end
|
|
395
|
+
private_class_method :details?
|
|
396
|
+
end
|
|
397
|
+
|
|
147
398
|
# Raised when an adapter's optimistic-lock check detects a concurrent
|
|
148
399
|
# write: another process modified the key between this process's
|
|
149
400
|
# restore and persist. Hosts can rescue + restore + retry, or fail
|
|
@@ -185,15 +436,20 @@ module Smith
|
|
|
185
436
|
# `idempotency_mode :strict`. Signals that a previous worker crashed
|
|
186
437
|
# between `persist!` (before advance) and `persist!` (after advance);
|
|
187
438
|
# the step's effects are unknown, so blindly re-running could
|
|
188
|
-
# double-execute non-idempotent agent calls or tools.
|
|
439
|
+
# double-execute non-idempotent agent calls or tools. `state` is the
|
|
440
|
+
# persisted state the interrupted step started from and `transition` the
|
|
441
|
+
# next transition the payload records; each is nil when unknown.
|
|
189
442
|
class StepInProgressOnRestore < Error
|
|
190
|
-
attr_reader :workflow, :persistence_key
|
|
443
|
+
attr_reader :workflow, :persistence_key, :state, :transition
|
|
191
444
|
|
|
192
|
-
def initialize(workflow:, persistence_key:)
|
|
445
|
+
def initialize(workflow:, persistence_key:, state: nil, transition: nil)
|
|
193
446
|
@workflow = workflow
|
|
194
447
|
@persistence_key = persistence_key
|
|
448
|
+
@state = state.to_sym if state.is_a?(String) || state.is_a?(Symbol)
|
|
449
|
+
@transition = transition.to_sym if transition.is_a?(String) || transition.is_a?(Symbol)
|
|
195
450
|
super(
|
|
196
|
-
"step in progress on restore for #{workflow} key=#{persistence_key.inspect}
|
|
451
|
+
"step in progress on restore for #{workflow} key=#{persistence_key.inspect}" \
|
|
452
|
+
"#{" state=#{@state.inspect}" if @state}: " \
|
|
197
453
|
"a previous worker crashed mid-step. Hosts using idempotency_mode :strict must " \
|
|
198
454
|
"decide whether to clear the persisted state (idempotent re-run unsafe) or " \
|
|
199
455
|
"switch to :lax (assume re-run is safe)."
|
|
@@ -201,6 +457,48 @@ module Smith
|
|
|
201
457
|
end
|
|
202
458
|
end
|
|
203
459
|
|
|
460
|
+
# Raised when a persisted payload's state is not one the restoring
|
|
461
|
+
# workflow class knows: neither declared with `state` nor named by any
|
|
462
|
+
# transition's `from:` or `to:`. Usually a deploy renamed or removed the
|
|
463
|
+
# state while a run was persisted. Restoring anyway would treat the
|
|
464
|
+
# unknown state as terminal and end the run silently. Hosts bridge a
|
|
465
|
+
# rename with `migrate_from`, or let the run finish on the workflow
|
|
466
|
+
# version that declared the state.
|
|
467
|
+
class UnknownStateOnRestore < Error
|
|
468
|
+
attr_reader :workflow, :persistence_key, :state
|
|
469
|
+
|
|
470
|
+
def initialize(workflow:, persistence_key:, state:)
|
|
471
|
+
@workflow = workflow
|
|
472
|
+
@persistence_key = persistence_key
|
|
473
|
+
@state = state
|
|
474
|
+
super(
|
|
475
|
+
"unknown state #{DiagnosticText.capture(state.inspect, max_bytes: 256)} restoring #{workflow} " \
|
|
476
|
+
"key=#{DiagnosticText.capture(persistence_key.inspect, max_bytes: 256)}: bump " \
|
|
477
|
+
"persistence_schema_version and declare `migrate_from` to map it, or finish the run on the " \
|
|
478
|
+
"workflow version that declared it."
|
|
479
|
+
)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Raised when a persisted payload's pending transition is not declared by
|
|
484
|
+
# the restoring workflow class, for the same reasons as
|
|
485
|
+
# UnknownStateOnRestore.
|
|
486
|
+
class UnknownTransitionOnRestore < Error
|
|
487
|
+
attr_reader :workflow, :persistence_key, :transition
|
|
488
|
+
|
|
489
|
+
def initialize(workflow:, persistence_key:, transition:)
|
|
490
|
+
@workflow = workflow
|
|
491
|
+
@persistence_key = persistence_key
|
|
492
|
+
@transition = transition
|
|
493
|
+
super(
|
|
494
|
+
"unknown pending transition #{DiagnosticText.capture(transition.inspect, max_bytes: 256)} restoring " \
|
|
495
|
+
"#{workflow} key=#{DiagnosticText.capture(persistence_key.inspect, max_bytes: 256)}: bump " \
|
|
496
|
+
"persistence_schema_version and declare `migrate_from` to map it, or finish the run on the " \
|
|
497
|
+
"workflow version that declared it."
|
|
498
|
+
)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
204
502
|
# Raised when restoring a persisted payload whose schema_version does
|
|
205
503
|
# not match the workflow's current persistence_schema_version AND no
|
|
206
504
|
# migration block is registered to bridge the gap. Hosts fix this by
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module IdempotencyKey
|
|
5
|
+
MAX_SCOPE_DEPTH = 32
|
|
6
|
+
MAX_SCOPE_NODES = 100_000
|
|
7
|
+
|
|
8
|
+
module Canonical
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# The JSON form a scope is hashed in: Symbols become Strings, Hash
|
|
12
|
+
# entries are sorted by key, and a Float with a whole value becomes
|
|
13
|
+
# the Integer, so equal scopes give equal keys whatever their key order
|
|
14
|
+
# or numeric spelling (`12` and `12.0` from a model's JSON). Other
|
|
15
|
+
# finite Floats keep their shortest exact text. NaN, Infinity and
|
|
16
|
+
# other objects are refused, because they have no stable identity; so
|
|
17
|
+
# are scopes past MAX_SCOPE_DEPTH levels or MAX_SCOPE_NODES values,
|
|
18
|
+
# which bounds the work at O(MAX_SCOPE_NODES).
|
|
19
|
+
def value(input, budget = [MAX_SCOPE_NODES], depth = 0)
|
|
20
|
+
charge!(budget, depth)
|
|
21
|
+
case input
|
|
22
|
+
when nil, true, false, Integer then input
|
|
23
|
+
when Float then float_value(input)
|
|
24
|
+
when String, Symbol then string(input.to_s)
|
|
25
|
+
when Array then input.map { |item| value(item, budget, depth + 1) }
|
|
26
|
+
when Hash then hash_entries(input, budget, depth)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"idempotency key scope must be built from Strings, Symbols, numbers, booleans, nil, Arrays " \
|
|
30
|
+
"and Hashes, got #{DiagnosticText.error_class_name(input)}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def charge!(budget, depth)
|
|
35
|
+
raise ArgumentError, "idempotency key scope is nested deeper than #{MAX_SCOPE_DEPTH} levels" if too_deep?(depth)
|
|
36
|
+
return unless (budget[0] -= 1).negative?
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, "idempotency key scope has more than #{MAX_SCOPE_NODES} values"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def too_deep?(depth) = depth > MAX_SCOPE_DEPTH
|
|
42
|
+
|
|
43
|
+
def float_value(value)
|
|
44
|
+
raise ArgumentError, "idempotency key scope numbers must be finite" unless value.finite?
|
|
45
|
+
|
|
46
|
+
value == value.truncate ? value.to_i : value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hash_entries(hash, budget, depth)
|
|
50
|
+
entries = hash.map { |key, item| [hash_key(key), value(item, budget, depth + 1)] }
|
|
51
|
+
keys = entries.map(&:first)
|
|
52
|
+
raise ArgumentError, "idempotency key scope has two keys with the same name" unless keys.uniq.size == keys.size
|
|
53
|
+
|
|
54
|
+
entries.sort_by(&:first).to_h
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hash_key(key)
|
|
58
|
+
return string(key.to_s) if key.is_a?(String) || key.is_a?(Symbol)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "idempotency key scope Hash keys must be Strings or Symbols"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def string(string)
|
|
64
|
+
utf8 = string.encoding == Encoding::UTF_8 ? string : string.encode(Encoding::UTF_8)
|
|
65
|
+
return utf8 if utf8.valid_encoding?
|
|
66
|
+
|
|
67
|
+
raise EncodingError
|
|
68
|
+
rescue EncodingError
|
|
69
|
+
raise ArgumentError, "idempotency key scope Strings must be valid UTF-8 or convert to it"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_constant :Canonical
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "idempotency_key/canonical"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
# Keys for requests a step sends outside the system, so that a replay of
|
|
10
|
+
# the step (a retry, or a restore after a crash) repeats each request
|
|
11
|
+
# under the key it used before and the receiver can drop the duplicate.
|
|
12
|
+
#
|
|
13
|
+
# A key is the SHA-256 of the run's execution namespace (a UUID assigned
|
|
14
|
+
# when the workflow is built and persisted with it), the step's position
|
|
15
|
+
# (step number and transition, then any nested workflow step, parallel or
|
|
16
|
+
# fan-out branch, optimizer round and role, or orchestrator round and
|
|
17
|
+
# task inside it) and a caller-chosen scope. It is the same on every
|
|
18
|
+
# attempt of the step, so a retry after a timeout cannot repeat a request
|
|
19
|
+
# that took effect, and after a restore. An attempt of a step that fails
|
|
20
|
+
# in a way showing its own requests took no effect can ask for new keys
|
|
21
|
+
# on the attempts after it (`fail!` with `new_idempotency_key: true`, or
|
|
22
|
+
# `retry_on ... new_idempotency_key_on:`), for receivers that store a
|
|
23
|
+
# failed reply under its key. A replay keeps its keys only when the run
|
|
24
|
+
# was persisted before the step began, as `advance_persisted!` and
|
|
25
|
+
# `run_persisted!` do. A persisted payload is a run's identity: runs
|
|
26
|
+
# built with `new` never share keys, but runs restored from copies of one
|
|
27
|
+
# payload do.
|
|
28
|
+
#
|
|
29
|
+
# Keys exist only while a step does its own work (its body, agents,
|
|
30
|
+
# tools and guardrails); event handlers and `on_step` callbacks get none.
|
|
31
|
+
# The position is thread-local state Smith carries into its own branch
|
|
32
|
+
# threads and concurrent tool calls; code in a Fiber or Thread the host
|
|
33
|
+
# creates has none, while `step.idempotency_key` works anywhere.
|
|
34
|
+
#
|
|
35
|
+
# transition :notify, from: :reviewed, to: :notified do
|
|
36
|
+
# compute { |step| Mailer.deliver(review, idempotency_key: step.idempotency_key) }
|
|
37
|
+
# end
|
|
38
|
+
module IdempotencyKey
|
|
39
|
+
VERSION = "smith.idempotency.v1"
|
|
40
|
+
|
|
41
|
+
# Where execution is: the run's namespace and the path of positions
|
|
42
|
+
# inside it, deeply frozen. Internal: hosts reach keys through
|
|
43
|
+
# `current`, `step.idempotency_key` and `Tool#idempotency_key`.
|
|
44
|
+
Scope = Data.define(:run, :path) do
|
|
45
|
+
def within(*segment)
|
|
46
|
+
self.class.new(run:, path: [*path, IdempotencyKey.__send__(:own_segment, segment)].freeze)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def key(scope = nil)
|
|
50
|
+
Digest::SHA256.hexdigest(JSON.generate([VERSION, run, path, Canonical.value(scope)]))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_constant :Scope
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# The key for `scope` at the current position. Raises WorkflowError
|
|
57
|
+
# outside a running workflow step.
|
|
58
|
+
def current(scope = nil)
|
|
59
|
+
current_scope!.key(scope)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def root_scope(run) = Scope.new(run: run.dup.freeze, path: [].freeze)
|
|
65
|
+
|
|
66
|
+
def current_scope
|
|
67
|
+
Tool.__send__(:current_idempotency_scope)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def current_scope!
|
|
71
|
+
current_scope || raise(WorkflowError, "an idempotency key is only available inside a running workflow step")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Narrows the current position for the block, when there is one.
|
|
75
|
+
def within(*segment, &)
|
|
76
|
+
outer = current_scope
|
|
77
|
+
return yield unless outer
|
|
78
|
+
|
|
79
|
+
swap(outer.within(*segment), &)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Runs the block with no position, so code that is not a step's own
|
|
83
|
+
# work (event handlers, callbacks) cannot take a step's key.
|
|
84
|
+
def without(&) = swap(nil, &)
|
|
85
|
+
|
|
86
|
+
# Interrupts are deferred while the position is installed and
|
|
87
|
+
# restored, as for every other per-step thread value.
|
|
88
|
+
def swap(scope, &block)
|
|
89
|
+
raise ArgumentError, "block required" unless block
|
|
90
|
+
|
|
91
|
+
outer = current_scope
|
|
92
|
+
Thread.handle_interrupt(Object => :never) do
|
|
93
|
+
install(scope)
|
|
94
|
+
begin
|
|
95
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
96
|
+
ensure
|
|
97
|
+
install(outer)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def install(scope)
|
|
103
|
+
raise ArgumentError, "an idempotency position must be a Scope or nil" unless scope.nil? || scope.is_a?(Scope)
|
|
104
|
+
|
|
105
|
+
Tool.__send__(:current_idempotency_scope=, scope)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A namespace for a payload written before namespaces were assigned at
|
|
109
|
+
# construction: derived from the payload's own durable facts, so every
|
|
110
|
+
# restore of it gets the same one, and shaped as the version 4 UUID
|
|
111
|
+
# `new` assigns.
|
|
112
|
+
def legacy_namespace(*facts)
|
|
113
|
+
hex = Digest::SHA256.hexdigest(JSON.generate([VERSION, "namespace", *facts.map(&:to_s)]))
|
|
114
|
+
variant = ((hex[16].to_i(16) & 0x3) | 0x8).to_s(16)
|
|
115
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-4#{hex[13, 3]}-#{variant}#{hex[17, 3]}-#{hex[20, 12]}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A position segment: a label and Strings, Symbols or Integers, held
|
|
119
|
+
# as frozen copies so a caller cannot change a key after the fact.
|
|
120
|
+
def own_segment(segment)
|
|
121
|
+
segment.map do |part|
|
|
122
|
+
case part
|
|
123
|
+
when Integer then part
|
|
124
|
+
when Symbol then part.to_s.freeze
|
|
125
|
+
when String then Canonical.string(part).dup.freeze
|
|
126
|
+
else raise ArgumentError, "an idempotency position segment must be Strings, Symbols or Integers"
|
|
127
|
+
end
|
|
128
|
+
end.freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Narrows the position to the `count`th copy of `value` seen under
|
|
132
|
+
# `label`, named by its content: a list a model regenerates on replay
|
|
133
|
+
# keeps each item's key whatever its order.
|
|
134
|
+
def within_content(label, value, copies, &)
|
|
135
|
+
digest = content_digest(value)
|
|
136
|
+
copies[digest] += 1
|
|
137
|
+
within(label, digest, copies[digest], &)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A digest of a value's canonical form, for a position named by its
|
|
141
|
+
# content (an orchestrator task) rather than by its place in a list.
|
|
142
|
+
def content_digest(value) = Digest::SHA256.hexdigest(JSON.generate(Canonical.value(value)))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Launchers
|
|
5
|
+
# Runs each child to its end, one after another, in the parent's
|
|
6
|
+
# process, and returns their results, so the parent's step completes at
|
|
7
|
+
# once. Children run under the parent's deadline but with no tool
|
|
8
|
+
# evidence or attribution of the parent's. A child left waiting, or one
|
|
9
|
+
# that raised, is reported failed (with the error's class). For tests
|
|
10
|
+
# and scripts: a long map holds the parent's step for as long as it runs.
|
|
11
|
+
class Inline
|
|
12
|
+
def inline? = true
|
|
13
|
+
|
|
14
|
+
def launch(children:, parent_key:, await_token:) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
deadline = Tool.current_deadline
|
|
16
|
+
children.map { |child| run_child(child, deadline) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def run_child(child, deadline)
|
|
22
|
+
workflow = child.build
|
|
23
|
+
workflow.instance_variable_set(:@inherited_deadline, deadline) if deadline
|
|
24
|
+
run = apart { workflow.run! }
|
|
25
|
+
return failed(child, workflow.state, "waiting") if workflow.waiting?
|
|
26
|
+
|
|
27
|
+
ChildRun.result(ref: child.ref, run_result: run)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
failed(child, nil, DiagnosticText.error_class_name(e))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failed(child, state, error)
|
|
33
|
+
{ ref: child.ref, status: "failed", state: state&.to_s, output: nil,
|
|
34
|
+
error: DiagnosticText.capture(error.to_s, max_bytes: 256) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apart(&)
|
|
38
|
+
tracker = Tool.current_tool_execution_tracker
|
|
39
|
+
Tool.current_tool_execution_tracker = nil
|
|
40
|
+
Attribution.carrying(nil, &)
|
|
41
|
+
ensure
|
|
42
|
+
Tool.current_tool_execution_tracker = tracker
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|