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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +712 -0
- data/README.md +622 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +67 -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/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 +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- 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 +70 -3
- 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 -51
- data/lib/smith/agent.rb +5 -0
- 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/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- 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 +10 -5
- data/lib/smith/context.rb +28 -0
- 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 +20 -18
- 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 +249 -2
- 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 +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- 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 +11 -5
- 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 +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- 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/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/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -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 +36 -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 +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -8,23 +8,79 @@ module Smith
|
|
|
8
8
|
attr_reader :name, :from, :to, :agent_name, :agent_opts, :success_transition, :failure_transition,
|
|
9
9
|
:router_config, :workflow_class, :optimization_config, :orchestrator_config,
|
|
10
10
|
:fanout_config, :retry_config, :deterministic_block, :deterministic_kind,
|
|
11
|
-
:deterministic_routes
|
|
11
|
+
:deterministic_routes, :deterministic_fallthrough, :await_config, :timeout_transition,
|
|
12
|
+
:step_input, :output_to, :stream, :batch_mode, :on_request_change
|
|
12
13
|
|
|
13
14
|
def initialize(name, from:, to:, &)
|
|
14
15
|
@name = own_identifier(name)
|
|
15
16
|
@from = own_identifier(from, allow_nil: true)
|
|
16
17
|
@to = own_identifier(to)
|
|
17
18
|
instance_eval(&) if block_given?
|
|
19
|
+
validate_child_run_controls!
|
|
20
|
+
validate_durable_retry!
|
|
21
|
+
validate_batch_mode!
|
|
22
|
+
validate_timeout_route!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Waits, bounded by a required timeout, for an event the host delivers
|
|
26
|
+
# with `Workflow.resume!`, writing its payload to the context key
|
|
27
|
+
# `into:`. The optional block runs on entry with an AwaitStep and may
|
|
28
|
+
# send the request the event answers, or finish at once with
|
|
29
|
+
# `resolve!`. `timeout:` is seconds, or a callable of the context
|
|
30
|
+
# returning seconds; past it, `expire_wait!` takes `on_timeout`.
|
|
31
|
+
def await(event, into:, timeout:, &block)
|
|
32
|
+
validate_execution_primitive_conflict!("await")
|
|
33
|
+
raise WorkflowError, "await into: must be a Symbol context key" unless into.is_a?(Symbol)
|
|
34
|
+
|
|
35
|
+
@await_config = {
|
|
36
|
+
kind: :await, event: Identifier.normalize(event, label: "await event"), into:,
|
|
37
|
+
timeout: wait_seconds!(timeout, "await timeout:"), block:
|
|
38
|
+
}.freeze
|
|
39
|
+
commit_execution_primitive!("await")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Continues to `to:` no earlier than `seconds` after the step is
|
|
43
|
+
# entered (seconds, or a callable of the context returning seconds),
|
|
44
|
+
# when the host calls `expire_wait!`.
|
|
45
|
+
def delay(seconds)
|
|
46
|
+
validate_execution_primitive_conflict!("delay")
|
|
47
|
+
@await_config = { kind: :delay, event: nil, into: nil, timeout: wait_seconds!(seconds, "delay"), block: nil }
|
|
48
|
+
.freeze
|
|
49
|
+
commit_execution_primitive!("delay")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The transition an `await` takes when its deadline passes.
|
|
53
|
+
def on_timeout(transition_name)
|
|
54
|
+
@timeout_transition = own_identifier(transition_name)
|
|
18
55
|
end
|
|
19
56
|
|
|
20
|
-
def
|
|
57
|
+
def awaits? = !@await_config.nil?
|
|
58
|
+
|
|
59
|
+
def delay? = @await_config&.fetch(:kind) == :delay
|
|
60
|
+
|
|
61
|
+
# `input:` (on every agent primitive) is a callable of a copy of the
|
|
62
|
+
# context returning the step's messages, which replace the session
|
|
63
|
+
# transcript for this step. `output_to:` names the context key the
|
|
64
|
+
# step's output is written to, instead of the transcript.
|
|
65
|
+
# `stream:` names the sink in `Smith.config.stream_sinks` the reply's
|
|
66
|
+
# text is streamed to as the model writes it (Smith::Streaming).
|
|
67
|
+
# `mode:` is :sync (the default), :batch, or a callable of the context
|
|
68
|
+
# and the step attempt returning one: a batch step sends its request
|
|
69
|
+
# through the host's batch queue and waits for the result.
|
|
70
|
+
# `on_request_change:` says what a batch result does when the step
|
|
71
|
+
# renders another request by the time it comes (Smith::Batch).
|
|
72
|
+
def execute(agent_name, input: nil, output_to: nil, **opts)
|
|
21
73
|
validate_execution_primitive_conflict!("execute")
|
|
22
74
|
|
|
23
75
|
normalized_agent = normalize_agent_reference!(agent_name, "agent")
|
|
24
76
|
validate_parallel_options!(opts)
|
|
77
|
+
assign_step_io!(input, output_to)
|
|
78
|
+
@stream = stream_name!(opts[:stream], opts)
|
|
79
|
+
@batch_mode = batch_mode!(opts[:mode], stream: opts[:stream], parallel: opts[:parallel] == true)
|
|
80
|
+
@on_request_change = request_change!(opts[:on_request_change])
|
|
25
81
|
|
|
26
82
|
@agent_name = normalized_agent
|
|
27
|
-
@agent_opts = opts.freeze
|
|
83
|
+
@agent_opts = opts.except(:stream, :mode, :on_request_change).freeze
|
|
28
84
|
commit_execution_primitive!("execute")
|
|
29
85
|
end
|
|
30
86
|
|
|
@@ -40,8 +96,9 @@ module Smith
|
|
|
40
96
|
@failure_transition = own_identifier(transition_name)
|
|
41
97
|
end
|
|
42
98
|
|
|
43
|
-
def route(agent_name, routes:, confidence_threshold:, fallback:)
|
|
99
|
+
def route(agent_name, routes:, confidence_threshold:, fallback:, input: nil)
|
|
44
100
|
validate_execution_primitive_conflict!("route")
|
|
101
|
+
assign_step_io!(input, nil)
|
|
45
102
|
if @success_transition
|
|
46
103
|
raise WorkflowError, "routed transitions cannot declare on_success; router routes select the next transition"
|
|
47
104
|
end
|
|
@@ -74,8 +131,11 @@ module Smith
|
|
|
74
131
|
before_eval: nil,
|
|
75
132
|
on_exhaustion: :raise,
|
|
76
133
|
on_converged: :raise,
|
|
77
|
-
on_threshold: :raise
|
|
134
|
+
on_threshold: :raise,
|
|
135
|
+
input: nil,
|
|
136
|
+
output_to: nil)
|
|
78
137
|
validate_execution_primitive_conflict!("optimize")
|
|
138
|
+
assign_step_io!(input, output_to)
|
|
79
139
|
validate_optimize_controls!(generator, evaluator, max_rounds, evaluator_schema)
|
|
80
140
|
validate_optimize_exit_modes!(on_exhaustion: on_exhaustion, on_converged: on_converged,
|
|
81
141
|
on_threshold: on_threshold)
|
|
@@ -98,8 +158,9 @@ module Smith
|
|
|
98
158
|
commit_execution_primitive!("optimize")
|
|
99
159
|
end
|
|
100
160
|
|
|
101
|
-
def orchestrate(**opts)
|
|
161
|
+
def orchestrate(input: nil, output_to: nil, **opts)
|
|
102
162
|
validate_execution_primitive_conflict!("orchestrate")
|
|
163
|
+
assign_step_io!(input, output_to)
|
|
103
164
|
validate_orchestrate_controls!(opts)
|
|
104
165
|
config = opts.merge(
|
|
105
166
|
orchestrator: normalize_agent_reference!(opts.fetch(:orchestrator), "orchestrator"),
|
|
@@ -110,8 +171,11 @@ module Smith
|
|
|
110
171
|
commit_execution_primitive!("orchestrate")
|
|
111
172
|
end
|
|
112
173
|
|
|
113
|
-
def fan_out(branches:)
|
|
174
|
+
def fan_out(branches:, input: nil, output_to: nil, mode: nil, on_request_change: nil)
|
|
114
175
|
validate_execution_primitive_conflict!("fan_out")
|
|
176
|
+
assign_step_io!(input, output_to)
|
|
177
|
+
@batch_mode = batch_mode!(mode, stream: nil, parallel: false)
|
|
178
|
+
@on_request_change = request_change!(on_request_change)
|
|
115
179
|
|
|
116
180
|
normalized = normalize_fanout_branches!(branches)
|
|
117
181
|
@fanout_branch_lookup = normalized.to_h do |key, agent|
|
|
@@ -124,28 +188,69 @@ module Smith
|
|
|
124
188
|
end
|
|
125
189
|
alias fanout fan_out
|
|
126
190
|
|
|
127
|
-
|
|
191
|
+
# `new_idempotency_key_on:` names errors that show an attempt's
|
|
192
|
+
# requests took no effect (a refusal, not a timeout): the attempts
|
|
193
|
+
# after one of them use new idempotency keys. Every other retry keeps
|
|
194
|
+
# the step's keys. It is refused on a transition whose failures can
|
|
195
|
+
# come from several steps or branches (a nested workflow, parallel or
|
|
196
|
+
# fan-out branches, an optimizer or an orchestrator), where one error
|
|
197
|
+
# cannot speak for every request of the attempt.
|
|
198
|
+
#
|
|
199
|
+
# `durable: true` waits out each backoff as a wait of the run (kind
|
|
200
|
+
# `:retry`) instead of sleeping in the worker: the host wakes it with
|
|
201
|
+
# `expire_wait!` and continues the run, which makes the next attempt
|
|
202
|
+
# under the recorded attempt number and key generation.
|
|
203
|
+
# rubocop:disable Metrics/ParameterLists
|
|
204
|
+
def retry_on(*error_classes, attempts:, backoff: 0, max_delay: nil, jitter: 0, new_idempotency_key_on: [],
|
|
205
|
+
durable: false)
|
|
128
206
|
policy = normalize_retry_policy!(error_classes, attempts:, backoff:, max_delay:, jitter:)
|
|
207
|
+
new_key_classes = normalize_new_idempotency_key_on!(new_idempotency_key_on, error_classes)
|
|
208
|
+
validate_durable_policy!(durable, policy)
|
|
129
209
|
|
|
130
|
-
|
|
210
|
+
config = {
|
|
131
211
|
error_classes: error_classes.freeze,
|
|
132
212
|
attempts: policy.attempts,
|
|
133
213
|
backoff: policy.base_delay,
|
|
134
214
|
max_delay: policy.max_delay,
|
|
135
215
|
jitter: policy.jitter
|
|
136
|
-
}
|
|
216
|
+
}
|
|
217
|
+
config[:new_idempotency_key_on] = new_key_classes if new_key_classes.any?
|
|
218
|
+
config[:durable] = true if durable
|
|
219
|
+
@retry_config = config.freeze
|
|
220
|
+
reject_new_idempotency_key_on_composite!
|
|
137
221
|
end
|
|
222
|
+
# rubocop:enable Metrics/ParameterLists
|
|
223
|
+
|
|
224
|
+
def durable_retry? = @retry_config&.fetch(:durable, false) == true
|
|
225
|
+
|
|
226
|
+
# Whether the step may send its requests as a batch: its mode is not
|
|
227
|
+
# :sync.
|
|
228
|
+
def batches? = !@batch_mode.nil? && @batch_mode != :sync
|
|
229
|
+
|
|
230
|
+
def validate_durable_policy!(durable, policy)
|
|
231
|
+
raise WorkflowError, "retry_on durable: must be true or false" unless [true, false].include?(durable)
|
|
232
|
+
return unless durable && !(policy.attempts > 1 && policy.base_delay.positive?)
|
|
233
|
+
|
|
234
|
+
raise WorkflowError, "retry_on durable: needs at least 2 attempts and a positive backoff, so it has a wait"
|
|
235
|
+
end
|
|
236
|
+
private :validate_durable_policy!
|
|
237
|
+
|
|
238
|
+
# A step that waits (an await, delay, map or call), launches runs of
|
|
239
|
+
# its own (a spawn), or waits between attempts (a durable retry).
|
|
240
|
+
def waits_or_launches? = awaits? || child_runs? || durable_retry?
|
|
138
241
|
|
|
139
242
|
%i[compute run].each do |method_name|
|
|
140
|
-
define_method(method_name) do |routes: nil, &block|
|
|
243
|
+
define_method(method_name) do |routes: nil, fallthrough: false, &block|
|
|
141
244
|
validate_execution_primitive_conflict!("compute/run", declaration: method_name.to_s)
|
|
142
245
|
raise WorkflowError, "#{method_name} requires a block" unless block
|
|
143
246
|
|
|
144
247
|
normalized_routes = normalize_deterministic_routes!(routes)
|
|
248
|
+
validate_deterministic_fallthrough!(fallthrough, normalized_routes)
|
|
145
249
|
|
|
146
250
|
@deterministic_block = block
|
|
147
251
|
@deterministic_kind = method_name
|
|
148
252
|
@deterministic_routes = normalized_routes
|
|
253
|
+
@deterministic_fallthrough = fallthrough
|
|
149
254
|
commit_execution_primitive!("compute/run", declaration: method_name.to_s)
|
|
150
255
|
end
|
|
151
256
|
end
|
|
@@ -189,8 +294,110 @@ module Smith
|
|
|
189
294
|
agent_opts&.dig(:parallel) == true
|
|
190
295
|
end
|
|
191
296
|
|
|
297
|
+
# Whether the step runs an agent: `execute`, `route`, `fan_out`,
|
|
298
|
+
# `optimize` or `orchestrate`.
|
|
299
|
+
def agent_step?
|
|
300
|
+
!agent_name.nil? || optimized? || orchestrated? || fanout?
|
|
301
|
+
end
|
|
302
|
+
|
|
192
303
|
private
|
|
193
304
|
|
|
305
|
+
def assign_step_io!(input, output_to)
|
|
306
|
+
raise WorkflowError, "input: must be a callable of the context" unless input.nil? || input.respond_to?(:call)
|
|
307
|
+
raise WorkflowError, "output_to: must be a Symbol context key" unless output_to.nil? || output_to.is_a?(Symbol)
|
|
308
|
+
|
|
309
|
+
@step_input = input
|
|
310
|
+
@output_to = output_to
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A batch request is one turn of one model, sent and answered apart
|
|
314
|
+
# from the step: a streamed or parallel step has nothing to send it as.
|
|
315
|
+
def batch_mode!(mode, stream:, parallel:)
|
|
316
|
+
return if mode.nil?
|
|
317
|
+
unless %i[sync batch].include?(mode) || mode.respond_to?(:call)
|
|
318
|
+
raise WorkflowError, "mode: must be :sync, :batch, or a callable of the context and attempt returning one"
|
|
319
|
+
end
|
|
320
|
+
raise WorkflowError, "mode: :batch cannot stream" if stream && mode != :sync
|
|
321
|
+
raise WorkflowError, "mode: is not available on a parallel step" if parallel
|
|
322
|
+
|
|
323
|
+
mode
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# What a batch result does when the step renders another request by
|
|
327
|
+
# the time it comes: :keep it (the default), :resend the request over
|
|
328
|
+
# the wire, or :fail the step with BatchRequestChanged.
|
|
329
|
+
def request_change!(value)
|
|
330
|
+
return if value.nil?
|
|
331
|
+
unless %i[keep resend fail].include?(value)
|
|
332
|
+
raise WorkflowError, "on_request_change: must be :keep, :resend or :fail"
|
|
333
|
+
end
|
|
334
|
+
raise WorkflowError, "on_request_change: needs mode: :batch or a mode callable" unless batches?
|
|
335
|
+
|
|
336
|
+
value
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# One reply streams to one sink: a parallel step has several.
|
|
340
|
+
def stream_name!(stream, opts)
|
|
341
|
+
return if stream.nil?
|
|
342
|
+
raise WorkflowError, "execute stream: must name a sink with a Symbol" unless stream.is_a?(Symbol)
|
|
343
|
+
raise WorkflowError, "execute stream: is not available on a parallel step" if opts[:parallel] == true
|
|
344
|
+
|
|
345
|
+
stream
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def wait_seconds!(value, label)
|
|
349
|
+
return value if value.respond_to?(:call)
|
|
350
|
+
return value if (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && value.positive?
|
|
351
|
+
|
|
352
|
+
raise WorkflowError, "#{label} must be a positive number of seconds or a callable returning one"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# An await must say where its timeout goes: past its deadline it takes
|
|
356
|
+
# `on_timeout`, or else `on_failure` with Smith::WaitTimedOut. Without
|
|
357
|
+
# either, an expired wait could never end.
|
|
358
|
+
# A durable retry waits between attempts, so its step must be one a
|
|
359
|
+
# run can wait in: not a step that waits itself, nor one whose
|
|
360
|
+
# branches keep durable state of their own.
|
|
361
|
+
# Its last failure must settle the run through `on_failure`: a failure
|
|
362
|
+
# raised to the host leaves the stored run before that attempt, so a
|
|
363
|
+
# job that runs again would make the attempt again at once, past the
|
|
364
|
+
# policy's attempts.
|
|
365
|
+
def validate_durable_retry!
|
|
366
|
+
return unless durable_retry?
|
|
367
|
+
|
|
368
|
+
refuse_durable_retry_on_waiting_step!
|
|
369
|
+
return if @failure_transition
|
|
370
|
+
|
|
371
|
+
raise WorkflowError, "retry_on durable: needs on_failure, so the last failure settles the run instead of " \
|
|
372
|
+
"being made again by a host that runs the job again"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A batch result can fail or never come, as an await's event can: the
|
|
376
|
+
# step must say where that goes.
|
|
377
|
+
def validate_batch_mode!
|
|
378
|
+
return unless batches? && @failure_transition.nil?
|
|
379
|
+
|
|
380
|
+
raise WorkflowError, "mode: :batch needs on_failure, where a batch result that fails or never comes goes"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def refuse_durable_retry_on_waiting_step!
|
|
384
|
+
return unless awaits? || parallel? || fanout? || optimized? || orchestrated?
|
|
385
|
+
|
|
386
|
+
raise WorkflowError, "retry_on durable: is not available on await, delay, parallel, fan_out, optimize or " \
|
|
387
|
+
"orchestrate steps"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def validate_timeout_route!
|
|
391
|
+
if @timeout_transition
|
|
392
|
+
return if @await_config && !delay?
|
|
393
|
+
|
|
394
|
+
raise WorkflowError, "on_timeout requires an await step"
|
|
395
|
+
end
|
|
396
|
+
return unless @await_config && !delay? && @failure_transition.nil?
|
|
397
|
+
|
|
398
|
+
raise WorkflowError, "an await step must declare on_timeout or on_failure for its deadline"
|
|
399
|
+
end
|
|
400
|
+
|
|
194
401
|
def own_identifier(identifier, allow_nil: false)
|
|
195
402
|
Identifier.normalize(identifier, label: "workflow identifier", allow_nil:)
|
|
196
403
|
end
|
|
@@ -218,6 +425,17 @@ module Smith
|
|
|
218
425
|
def commit_execution_primitive!(primitive, declaration: primitive)
|
|
219
426
|
@execution_primitive = primitive
|
|
220
427
|
@execution_declaration = declaration
|
|
428
|
+
reject_new_idempotency_key_on_composite!
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def several_step_execution? = nested? || parallel? || fanout? || optimized? || orchestrated?
|
|
432
|
+
|
|
433
|
+
def reject_new_idempotency_key_on_composite!
|
|
434
|
+
return unless @retry_config&.key?(:new_idempotency_key_on) && several_step_execution?
|
|
435
|
+
|
|
436
|
+
raise WorkflowError,
|
|
437
|
+
"retry_on new_idempotency_key_on: cannot be declared on a nested workflow, parallel, fan_out, " \
|
|
438
|
+
"optimize or orchestrate transition, where one error cannot speak for every request of the attempt"
|
|
221
439
|
end
|
|
222
440
|
|
|
223
441
|
def normalize_fanout_branches!(branches)
|
|
@@ -349,12 +567,48 @@ module Smith
|
|
|
349
567
|
raise WorkflowError, "retry_on #{e.message}"
|
|
350
568
|
end
|
|
351
569
|
|
|
352
|
-
def
|
|
570
|
+
def normalize_new_idempotency_key_on!(classes, retried_classes)
|
|
571
|
+
unless classes.is_a?(Array) && classes.all? { |error_class| standard_error_class?(error_class) }
|
|
572
|
+
raise WorkflowError, "retry_on new_idempotency_key_on: must be an Array of StandardError classes"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
if classes.uniq.size != classes.size
|
|
576
|
+
raise WorkflowError, "retry_on new_idempotency_key_on: lists a class more than once"
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
reject_retry_forbidden!(classes, context: "move to new idempotency keys after")
|
|
580
|
+
reject_unretried_new_key_classes!(classes, retried_classes)
|
|
581
|
+
classes.dup.freeze
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def standard_error_class?(value) = value.is_a?(Class) && value <= StandardError
|
|
585
|
+
|
|
586
|
+
# With no error classes the policy retries whatever Smith classifies
|
|
587
|
+
# retryable, which cannot be checked here.
|
|
588
|
+
def reject_unretried_new_key_classes!(classes, retried_classes)
|
|
589
|
+
return if retried_classes.empty?
|
|
590
|
+
|
|
591
|
+
unretried = classes.find { |error_class| retried_classes.none? { |retried| error_class <= retried } }
|
|
592
|
+
return unless unretried
|
|
593
|
+
|
|
594
|
+
raise WorkflowError, "retry_on new_idempotency_key_on: lists #{unretried.name || unretried.inspect}, " \
|
|
595
|
+
"which this policy does not retry"
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def reject_retry_forbidden!(error_classes, context: "retry")
|
|
353
599
|
forbidden = error_classes.find { |error_class| Smith::Errors.retry_forbidden_class?(error_class) }
|
|
354
600
|
return unless forbidden
|
|
355
601
|
|
|
356
602
|
label = forbidden.name || "an anonymous terminal tool-evidence error class"
|
|
357
|
-
raise WorkflowError, "retry_on cannot
|
|
603
|
+
raise WorkflowError, "retry_on cannot #{context} #{label} because the tool outcome may be uncertain"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# `fallthrough: true` lets a step that declares `routes:` finish without
|
|
607
|
+
# routing, taking `on_success` or the first transition from its target
|
|
608
|
+
# state. It means nothing without `routes:`.
|
|
609
|
+
def validate_deterministic_fallthrough!(fallthrough, routes)
|
|
610
|
+
raise WorkflowError, "fallthrough must be true or false" unless [true, false].include?(fallthrough)
|
|
611
|
+
raise WorkflowError, "fallthrough: true requires routes:" if fallthrough && routes.nil?
|
|
358
612
|
end
|
|
359
613
|
|
|
360
614
|
def normalize_deterministic_routes!(routes)
|
|
@@ -18,8 +18,14 @@ module Smith
|
|
|
18
18
|
# non-provider error emits with `outcome: :aborted` before re-raising),
|
|
19
19
|
# so prefix-accounted entries always have their join target.
|
|
20
20
|
# All optional members are nil on entries restored from checkpoints written by
|
|
21
|
-
# earlier Smith versions, and nil
|
|
21
|
+
# earlier Smith versions, and nil optional members are omitted from
|
|
22
22
|
# serialization so those documents re-serialize byte-identically.
|
|
23
|
+
#
|
|
24
|
+
# `input_tokens` counts uncached input; `cache_read_tokens` and
|
|
25
|
+
# `cache_write_tokens` are the prompt-cache input beside it;
|
|
26
|
+
# `thinking_tokens` are already inside `output_tokens`. `stop_reason` and
|
|
27
|
+
# `provider_message_id` come from the provider response, and `mode` is
|
|
28
|
+
# how the call was made (`:sync`, `:stream`, `:batch`).
|
|
23
29
|
# rubocop:disable Style/RedundantStructKeywordInit
|
|
24
30
|
UsageEntry = Struct.new(
|
|
25
31
|
:usage_id,
|
|
@@ -36,6 +42,12 @@ module Smith
|
|
|
36
42
|
:round,
|
|
37
43
|
:attempt_id,
|
|
38
44
|
:workflow,
|
|
45
|
+
:cache_read_tokens,
|
|
46
|
+
:cache_write_tokens,
|
|
47
|
+
:thinking_tokens,
|
|
48
|
+
:stop_reason,
|
|
49
|
+
:provider_message_id,
|
|
50
|
+
:mode,
|
|
39
51
|
keyword_init: true
|
|
40
52
|
) do
|
|
41
53
|
def initialize(**attributes)
|
|
@@ -51,18 +63,27 @@ module Smith
|
|
|
51
63
|
# re-serializes byte-identically. Hosts that digest whole persisted
|
|
52
64
|
# documents (exact-mutation proofs) depend on this stability.
|
|
53
65
|
def to_h
|
|
54
|
-
super.reject { |key, value| value.nil? &&
|
|
66
|
+
super.reject { |key, value| value.nil? && Workflow::UsageEntry::OPTIONAL_MEMBERS.include?(key) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Every input token the provider processed, cached or not, plus output.
|
|
70
|
+
def total_tokens
|
|
71
|
+
input_tokens + output_tokens + (cache_read_tokens || 0) + (cache_write_tokens || 0)
|
|
55
72
|
end
|
|
56
73
|
|
|
57
74
|
def self.from_h(hash)
|
|
58
75
|
sym = hash.transform_keys(&:to_sym)
|
|
59
76
|
filtered = sym.slice(*members)
|
|
60
|
-
%i[agent_name provider attempt_kind transition branch_key].each do |attribute|
|
|
77
|
+
%i[agent_name provider attempt_kind transition branch_key mode].each do |attribute|
|
|
61
78
|
filtered[attribute] = filtered[attribute].to_sym if filtered[attribute].is_a?(String)
|
|
62
79
|
end
|
|
63
80
|
new(**filtered)
|
|
64
81
|
end
|
|
65
82
|
end
|
|
83
|
+
UsageEntry::OPTIONAL_MEMBERS = %i[
|
|
84
|
+
transition branch_key round attempt_id workflow
|
|
85
|
+
cache_read_tokens cache_write_tokens thinking_tokens stop_reason provider_message_id mode
|
|
86
|
+
].freeze
|
|
66
87
|
# rubocop:enable Style/RedundantStructKeywordInit
|
|
67
88
|
end
|
|
68
89
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
require_relative "message_value_normalizer"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
# Completing a wait, by delivery or expiry: claiming it so only one
|
|
10
|
+
# completion wins, running the completion as the step, and the timeout
|
|
11
|
+
# route. Part of WaitExecution, split out for size.
|
|
12
|
+
module WaitCompletion
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# The awaited transition when the delivery matches the stored wait, or
|
|
16
|
+
# when no wait is stored yet but the run stands at that await and the
|
|
17
|
+
# token is the one entering it will use.
|
|
18
|
+
def resumable_transition(event, token)
|
|
19
|
+
record = @awaiting
|
|
20
|
+
return (awaited_transition(record) if record["event"] == event && record["token"] == token) if record
|
|
21
|
+
|
|
22
|
+
transition = pending_await_transition
|
|
23
|
+
return unless transition && transition.await_config[:event].to_s == event
|
|
24
|
+
return unless await_token_for(transition) == token
|
|
25
|
+
|
|
26
|
+
transition
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pending_await_transition
|
|
30
|
+
transition = self.class.find_transition(@next_transition_name || self.class.first_transition_from(@state)&.name)
|
|
31
|
+
transition if transition&.awaits? && !transition.delay? && [nil, @state].include?(transition.from)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def claim_wait
|
|
35
|
+
(@wait_mutex ||= Mutex.new).synchronize do
|
|
36
|
+
return nil if @wait_claimed
|
|
37
|
+
|
|
38
|
+
claimed = yield
|
|
39
|
+
@wait_claimed = true if claimed
|
|
40
|
+
claimed
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def expire_claimed_wait(transition, record)
|
|
45
|
+
return end_retry_wait(transition) if record["kind"] == "retry"
|
|
46
|
+
return complete_batch_wait(transition, missing_batch_results(transition), :expired) if record["kind"] == "batch"
|
|
47
|
+
|
|
48
|
+
complete_claimed_wait(transition, :expired) { expire_step(transition, record) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def complete_claimed_wait(transition, done, &)
|
|
52
|
+
step = finish_wait(transition, &)
|
|
53
|
+
step.key?(:error) && done == :resumed ? :failed : done
|
|
54
|
+
ensure
|
|
55
|
+
@wait_claimed = false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def expire_step(transition, record)
|
|
59
|
+
return StepCompletion.instance_method(:complete_step).bind_call(self, transition, nil) if transition.delay?
|
|
60
|
+
return route_to_timeout!(transition) if transition.timeout_transition
|
|
61
|
+
|
|
62
|
+
raise WaitTimedOut.new(transition: transition.name, event: record["event"], deadline_at: record["deadline_at"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The await ends without reaching its `to:`; the run stays in its
|
|
66
|
+
# `from:` state and takes `on_timeout` next. Hosts see a StepCompleted
|
|
67
|
+
# whose `to` is that state.
|
|
68
|
+
def route_to_timeout!(transition)
|
|
69
|
+
name = transition.timeout_transition
|
|
70
|
+
target = self.class.find_transition(name) || raise(UnresolvedTransitionError.new(name, self.class, @state))
|
|
71
|
+
validate_transition_origin!(target)
|
|
72
|
+
@next_transition_name = name
|
|
73
|
+
record_wait_trace(transition, :timed_out)
|
|
74
|
+
emit_step_completed(TimedOutStep.new(transition.name, transition.from, @state), nil)
|
|
75
|
+
{ transition: transition.name, from: transition.from, to: @state, timed_out: true }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
TimedOutStep = Struct.new(:name, :from, :to)
|
|
79
|
+
private_constant :TimedOutStep
|
|
80
|
+
|
|
81
|
+
# A payload is bounded and JSON-shaped like a session message, and
|
|
82
|
+
# takes the context's key shape. It never enters the session.
|
|
83
|
+
def wait_payload(payload)
|
|
84
|
+
ContextKeys.normalize(MessageValueNormalizer.new(payload, label: "wait payload").call)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns nil, the inline-resolved step's output.
|
|
88
|
+
def write_wait_payload(transition, value)
|
|
89
|
+
key = writable_wait_key!(transition)
|
|
90
|
+
@context[key] = value
|
|
91
|
+
record_persisted_key!(key)
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def writable_wait_key!(transition)
|
|
96
|
+
key = transition.await_config.fetch(:into)
|
|
97
|
+
keys = declared_persist_keys
|
|
98
|
+
raise UndeclaredContextKey.new(key:, persisted_keys: keys) if keys && !keys.include?(key)
|
|
99
|
+
|
|
100
|
+
key
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def wait_time(now)
|
|
104
|
+
return now.utc if now.is_a?(Time)
|
|
105
|
+
return Time.iso8601(now).utc if now.is_a?(String)
|
|
106
|
+
return now.to_time.utc if now.respond_to?(:to_time)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "now: must be a Time or an ISO 8601 String"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def wait_deadline(record) = Time.iso8601(record.fetch("deadline_at"))
|
|
112
|
+
|
|
113
|
+
# The completion of a wait is the step: it runs in the step's context
|
|
114
|
+
# (so its position, attribution and events are the step's), then
|
|
115
|
+
# counts. A completion that raises leaves the run waiting.
|
|
116
|
+
def finish_wait(transition, &)
|
|
117
|
+
step = with_step_context(transition, &)
|
|
118
|
+
@awaiting = nil
|
|
119
|
+
@step_count += 1
|
|
120
|
+
@updated_at = Time.now.utc.iso8601(6)
|
|
121
|
+
record_step_snapshot(step)
|
|
122
|
+
step
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def awaited_transition(record)
|
|
126
|
+
self.class.transition_named(record.fetch("transition")) ||
|
|
127
|
+
raise(WorkflowError, "the awaited transition is not declared")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|