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
|
@@ -104,9 +104,26 @@ module Smith
|
|
|
104
104
|
return false if fallback_age.nil? || fallback_age < threshold_seconds
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
stuck_payload?(payload, now, threshold_seconds)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A waiting run is not stuck until its deadline has passed by the
|
|
111
|
+
# threshold without the host expiring the wait; a run that waits is
|
|
112
|
+
# never terminal.
|
|
113
|
+
def stuck_payload?(payload, now, threshold_seconds)
|
|
114
|
+
wait_deadline = payload_wait_deadline(payload)
|
|
115
|
+
return (now - wait_deadline) > threshold_seconds if wait_deadline
|
|
116
|
+
|
|
107
117
|
!terminal_in_payload?(payload)
|
|
108
118
|
end
|
|
109
119
|
|
|
120
|
+
def payload_wait_deadline(payload)
|
|
121
|
+
deadline = JSON.parse(payload).dig("awaiting", "deadline_at")
|
|
122
|
+
deadline && Time.iso8601(deadline)
|
|
123
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
110
127
|
def heartbeat_age(persistence_key:, adapter: Smith.persistence_adapter)
|
|
111
128
|
raise WorkflowError, "persistence_adapter is not configured" if adapter.nil?
|
|
112
129
|
|
|
@@ -121,6 +138,20 @@ module Smith
|
|
|
121
138
|
age_from_payload_updated_at(payload, Time.now.utc)
|
|
122
139
|
end
|
|
123
140
|
|
|
141
|
+
# Delivers `event` to the run stored under `key` (see
|
|
142
|
+
# Workflow#resume!) and stores the result with the version check.
|
|
143
|
+
# Returns `:resumed`, or `:stale` when no run is stored or it does not
|
|
144
|
+
# await this event and token.
|
|
145
|
+
def resume!(key, event:, token:, payload:, adapter: Smith.persistence_adapter)
|
|
146
|
+
stored_wait_call(key, adapter:, done: :resumed) { |workflow| workflow.resume!(event:, token:, payload:) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Ends the stored run's wait if its deadline has passed at `now` (see
|
|
150
|
+
# Workflow#expire_wait!). The host's scheduler decides when to call it.
|
|
151
|
+
def expire_wait!(key, token:, now: Time.now.utc, adapter: Smith.persistence_adapter)
|
|
152
|
+
stored_wait_call(key, adapter:, done: :expired) { |workflow| workflow.expire_wait!(token:, now:) }
|
|
153
|
+
end
|
|
154
|
+
|
|
124
155
|
def run_persisted!(key: nil, context: {}, adapter: Smith.persistence_adapter, clear: :done, on_step: nil, **kwargs)
|
|
125
156
|
clear_policy = normalize_clear_policy(clear)
|
|
126
157
|
resolved_key = resolved_persistence_key(key:, context:)
|
|
@@ -133,6 +164,53 @@ module Smith
|
|
|
133
164
|
|
|
134
165
|
private
|
|
135
166
|
|
|
167
|
+
# Exactly one completion of a wait must win, so the store must check
|
|
168
|
+
# versions. A run caught mid-step (strict mode) can still take an
|
|
169
|
+
# early delivery for the await it is entering; anything else about it
|
|
170
|
+
# is refused as an unfinished step.
|
|
171
|
+
def stored_wait_call(key, adapter:, done:, &)
|
|
172
|
+
resolved_key = explicit_persistence_key!(key)
|
|
173
|
+
store = persistence_adapter!(adapter)
|
|
174
|
+
unless Smith::PersistenceAdapters.supports?(store, :store_versioned)
|
|
175
|
+
raise WorkflowError, "resume! and expire_wait! need a persistence adapter with store_versioned"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
payload = fetch_persisted_payload(resolved_key, adapter: store)
|
|
179
|
+
return :missing unless payload
|
|
180
|
+
|
|
181
|
+
stored_wait_result(JSON.parse(payload), resolved_key, store, done, &)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def stored_wait_result(state, key, store, done)
|
|
185
|
+
workflow, in_progress = restore_for_wait(state)
|
|
186
|
+
workflow.instance_variable_set(:@persistence_key, key)
|
|
187
|
+
mark_batch_completion!(workflow, key, store, in_progress)
|
|
188
|
+
result = yield(workflow)
|
|
189
|
+
raise in_progress if in_progress && result != :resumed && result != :failed
|
|
190
|
+
|
|
191
|
+
workflow.clear_step_in_progress!
|
|
192
|
+
workflow.persist!(key, adapter: store) if [done, :failed].include?(result)
|
|
193
|
+
result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A batch completion makes provider calls (a follow-up, a retry over
|
|
197
|
+
# the wire, a new batch), so in strict mode it runs as a marked step:
|
|
198
|
+
# stored as under way first, and a run caught in one is unfinished.
|
|
199
|
+
def mark_batch_completion!(workflow, key, store, in_progress)
|
|
200
|
+
return unless workflow.send(:batch_waiting?)
|
|
201
|
+
raise in_progress if in_progress
|
|
202
|
+
return unless idempotency_mode == :strict
|
|
203
|
+
|
|
204
|
+
workflow.mark_step_in_progress!
|
|
205
|
+
workflow.persist!(key, adapter: store)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def restore_for_wait(state)
|
|
209
|
+
[from_state(state), nil]
|
|
210
|
+
rescue Smith::StepInProgressOnRestore => e
|
|
211
|
+
[allocate.tap { |workflow| workflow.send(:restore_state, state, allow_step_in_progress: true) }, e]
|
|
212
|
+
end
|
|
213
|
+
|
|
136
214
|
def fetch_persisted_payload(key, adapter:)
|
|
137
215
|
persistence_adapter!(adapter).fetch(key)
|
|
138
216
|
end
|
|
@@ -158,25 +236,19 @@ module Smith
|
|
|
158
236
|
parsed = JSON.parse(payload)
|
|
159
237
|
state_name = parsed["state"] || parsed[:state]
|
|
160
238
|
class_name = parsed["class"] || parsed[:class]
|
|
161
|
-
next_transition = parsed["next_transition_name"] || parsed[:next_transition_name]
|
|
162
|
-
|
|
163
239
|
return false unless state_name && class_name
|
|
164
240
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
!klass.transition_from?(state) && next_transition.nil?
|
|
241
|
+
next_transition = parsed["next_transition_name"] || parsed[:next_transition_name]
|
|
242
|
+
terminal_payload_state?(Object.const_get(class_name), state_name, next_transition)
|
|
168
243
|
rescue JSON::ParserError, NameError, NoMethodError
|
|
169
244
|
false
|
|
170
245
|
end
|
|
171
246
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return symbolized if states.include?(symbolized)
|
|
178
|
-
|
|
179
|
-
state_name
|
|
247
|
+
# A state the class no longer knows can never advance or restore, so
|
|
248
|
+
# it is not terminal: an old one reports as stuck.
|
|
249
|
+
def terminal_payload_state?(klass, state_name, next_transition)
|
|
250
|
+
state = klass.resolve_state_name(state_name)
|
|
251
|
+
!state.nil? && !klass.transition_from?(state) && next_transition.nil?
|
|
180
252
|
end
|
|
181
253
|
|
|
182
254
|
def persistence_adapter!(adapter)
|
|
@@ -192,7 +264,7 @@ module Smith
|
|
|
192
264
|
raise WorkflowError, "persistence key is required unless workflow defines persistence_key" unless builder
|
|
193
265
|
|
|
194
266
|
derived = if builder.arity == 1
|
|
195
|
-
builder.call(context)
|
|
267
|
+
builder.call(ContextKeys.normalize_context(context))
|
|
196
268
|
else
|
|
197
269
|
builder.call
|
|
198
270
|
end
|
|
@@ -241,17 +313,19 @@ module Smith
|
|
|
241
313
|
end
|
|
242
314
|
|
|
243
315
|
def run_persisted!(key = nil, adapter: Smith.persistence_adapter, on_step: nil)
|
|
244
|
-
return build_run_result([]) if
|
|
316
|
+
return build_run_result([]) if nothing_to_advance?
|
|
245
317
|
|
|
246
318
|
resolved_key = resolve_persistence_key!(key)
|
|
247
319
|
steps = []
|
|
320
|
+
mark_active_step_start!(true)
|
|
248
321
|
persist!(resolved_key, adapter:)
|
|
249
322
|
|
|
250
|
-
until
|
|
323
|
+
until nothing_to_advance?
|
|
251
324
|
ensure_transition_budget!
|
|
252
325
|
|
|
253
326
|
if strict_idempotency?
|
|
254
|
-
mark_step_in_progress!
|
|
327
|
+
mark_step_in_progress! unless replay_safe_pending_step?
|
|
328
|
+
mark_active_step_start!(true)
|
|
255
329
|
persist!(resolved_key, adapter:)
|
|
256
330
|
end
|
|
257
331
|
|
|
@@ -264,6 +338,7 @@ module Smith
|
|
|
264
338
|
steps << step if step
|
|
265
339
|
|
|
266
340
|
clear_step_in_progress!
|
|
341
|
+
mark_active_step_start!(!nothing_to_advance?)
|
|
267
342
|
persist!(resolved_key, adapter:)
|
|
268
343
|
invoke_on_step_callback(step, on_step) if step
|
|
269
344
|
end
|
|
@@ -272,17 +347,15 @@ module Smith
|
|
|
272
347
|
end
|
|
273
348
|
|
|
274
349
|
def advance_persisted!(key = nil, adapter: Smith.persistence_adapter, on_step: nil)
|
|
275
|
-
return if
|
|
350
|
+
return if nothing_to_advance?
|
|
276
351
|
|
|
277
352
|
resolved_key = resolve_persistence_key!(key)
|
|
278
353
|
ensure_transition_budget!
|
|
279
|
-
mark_step_in_progress! if strict_idempotency?
|
|
354
|
+
mark_step_in_progress! if strict_idempotency? && !replay_safe_pending_step?
|
|
355
|
+
mark_active_step_start!(true)
|
|
280
356
|
persist!(resolved_key, adapter:)
|
|
281
357
|
step = advance!
|
|
282
|
-
|
|
283
|
-
persist!(resolved_key, adapter:) if step
|
|
284
|
-
invoke_on_step_callback(step, on_step) if step
|
|
285
|
-
step
|
|
358
|
+
finish_persisted_advance!(step, resolved_key, adapter, on_step)
|
|
286
359
|
rescue StandardError
|
|
287
360
|
clear_pre_step_marker!(resolved_key, adapter:) if defined?(resolved_key) && resolved_key
|
|
288
361
|
raise
|
|
@@ -296,12 +369,7 @@ module Smith
|
|
|
296
369
|
payload = JSON.generate(to_state.merge(persistence_version: next_version))
|
|
297
370
|
payload = yield(payload, next_version) if block_given?
|
|
298
371
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
# Increment ONLY after successful store. On PersistenceVersionConflict
|
|
302
|
-
# (raised by store_versioned), @persistence_version stays at the
|
|
303
|
-
# previous value so callers can rescue + restore + retry cleanly.
|
|
304
|
-
@persistence_version = next_version
|
|
372
|
+
store_payload!(store, resolved_key, payload, previous_version:)
|
|
305
373
|
self
|
|
306
374
|
end
|
|
307
375
|
|
|
@@ -311,6 +379,12 @@ module Smith
|
|
|
311
379
|
self
|
|
312
380
|
end
|
|
313
381
|
|
|
382
|
+
# A map, call or spawn launches through a launcher that is idempotent
|
|
383
|
+
# by contract, so strict mode lets its step run again after a crash.
|
|
384
|
+
def replay_safe_pending_step?
|
|
385
|
+
self.class.find_transition(pending_transition_name)&.child_runs? || false
|
|
386
|
+
end
|
|
387
|
+
|
|
314
388
|
def mark_step_in_progress!
|
|
315
389
|
@step_in_progress = true
|
|
316
390
|
end
|
|
@@ -321,6 +395,33 @@ module Smith
|
|
|
321
395
|
|
|
322
396
|
private
|
|
323
397
|
|
|
398
|
+
# Increment ONLY after successful store. On PersistenceVersionConflict
|
|
399
|
+
# (raised by store_versioned), @persistence_version stays at the
|
|
400
|
+
# previous value so callers can rescue + restore + retry cleanly. A
|
|
401
|
+
# streamed step's output reaches its sink once stored (StepStream); a
|
|
402
|
+
# store that fails tells the sink the step failed.
|
|
403
|
+
def store_payload!(store, key, payload, previous_version:)
|
|
404
|
+
begin
|
|
405
|
+
check_state_size!(key, payload)
|
|
406
|
+
dispatch_store!(store, key, payload, previous_version:)
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
StepStream.instance_method(:discard_streamed_output).bind_call(self, e.class)
|
|
409
|
+
raise
|
|
410
|
+
end
|
|
411
|
+
@persistence_version = previous_version + 1
|
|
412
|
+
StepStream.instance_method(:deliver_streamed_output).bind_call(self)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# The step, or the wait it entered, is stored, and the mark of a step
|
|
416
|
+
# under way is cleared.
|
|
417
|
+
def finish_persisted_advance!(step, key, adapter, on_step)
|
|
418
|
+
clear_step_in_progress!
|
|
419
|
+
mark_active_step_start!(false)
|
|
420
|
+
persist!(key, adapter:)
|
|
421
|
+
invoke_on_step_callback(step, on_step) if step
|
|
422
|
+
step
|
|
423
|
+
end
|
|
424
|
+
|
|
324
425
|
def strict_idempotency?
|
|
325
426
|
self.class.idempotency_mode == :strict
|
|
326
427
|
end
|
|
@@ -330,6 +431,7 @@ module Smith
|
|
|
330
431
|
return if step_work_started?
|
|
331
432
|
|
|
332
433
|
clear_step_in_progress!
|
|
434
|
+
mark_active_step_start!(false)
|
|
333
435
|
persist!(key, adapter:)
|
|
334
436
|
end
|
|
335
437
|
|
|
@@ -350,6 +452,13 @@ module Smith
|
|
|
350
452
|
store.record_heartbeat(key, **kwargs) if Smith::PersistenceAdapters.supports?(store, :record_heartbeat)
|
|
351
453
|
end
|
|
352
454
|
|
|
455
|
+
# A state past persistence_max_bytes is refused before anything is
|
|
456
|
+
# written, and before a split-step boundary counts a write as begun.
|
|
457
|
+
def check_state_size!(key, payload)
|
|
458
|
+
limit = Smith.config.persistence_max_bytes
|
|
459
|
+
raise PersistedStateTooLarge.new(key:, bytes: payload.bytesize, limit:) if limit && payload.bytesize > limit
|
|
460
|
+
end
|
|
461
|
+
|
|
353
462
|
# Resolves the effective TTL once per persist, with the class-level
|
|
354
463
|
# DSL override taking precedence over Smith.config.persistence_ttl.
|
|
355
464
|
# Returns nil when neither is set, which collapses ttl_kwarg to an
|
|
@@ -387,9 +496,9 @@ module Smith
|
|
|
387
496
|
end
|
|
388
497
|
|
|
389
498
|
def invoke_on_step_callback(step, callback)
|
|
390
|
-
callback&.call(step)
|
|
499
|
+
IdempotencyKey.__send__(:without) { callback&.call(step) }
|
|
391
500
|
rescue StandardError => e
|
|
392
|
-
Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message}")
|
|
501
|
+
Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message} (#{e.class})")
|
|
393
502
|
end
|
|
394
503
|
end
|
|
395
504
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
require_relative "agent_result"
|
|
6
|
+
require_relative "execution_result_snapshot"
|
|
6
7
|
require_relative "optimization_state"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
@@ -24,6 +25,7 @@ module Smith
|
|
|
24
25
|
transition_name: transition.name,
|
|
25
26
|
role: :evaluator
|
|
26
27
|
)
|
|
28
|
+
pend_evaluations
|
|
27
29
|
run_optimization_loop(state)
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -31,7 +33,9 @@ module Smith
|
|
|
31
33
|
state.config[:max_rounds].times do |round|
|
|
32
34
|
# The overlay scopes every trace and usage fact from this round's
|
|
33
35
|
# generator and evaluator calls to the round that produced them.
|
|
34
|
-
result = Attribution.with(round: round)
|
|
36
|
+
result = Attribution.with(round: round) do
|
|
37
|
+
IdempotencyKey.__send__(:within, :round, round) { run_optimization_round(state, round) }
|
|
38
|
+
end
|
|
35
39
|
return result if result
|
|
36
40
|
end
|
|
37
41
|
|
|
@@ -39,12 +43,16 @@ module Smith
|
|
|
39
43
|
"optimization exhausted #{state.config[:max_rounds]} rounds without acceptance")
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# Each role is a position of its own in the round, so the generator,
|
|
47
|
+
# the before_eval callback and the evaluator never share a key.
|
|
42
48
|
def run_optimization_round(state, round)
|
|
43
|
-
generate_candidate!(state, round)
|
|
44
|
-
|
|
45
|
-
evaluation =
|
|
49
|
+
IdempotencyKey.__send__(:within, :generator) { generate_candidate!(state, round) }
|
|
50
|
+
rejection = IdempotencyKey.__send__(:within, :before_eval) { before_eval_rejection(state) }
|
|
51
|
+
evaluation = rejection ||
|
|
52
|
+
normalize_evaluation(IdempotencyKey.__send__(:within, :evaluator) { evaluate_candidate(state) })
|
|
46
53
|
validate_evaluation_structure!(evaluation)
|
|
47
54
|
validate_evaluation_fields!(evaluation, state.config)
|
|
55
|
+
record_evaluation(state, evaluation, round, rejection ? :before_eval : :evaluator)
|
|
48
56
|
|
|
49
57
|
return state.candidate if evaluation[:accept]
|
|
50
58
|
|
|
@@ -61,6 +69,88 @@ module Smith
|
|
|
61
69
|
nil
|
|
62
70
|
end
|
|
63
71
|
|
|
72
|
+
# Each valid round's evaluation, in round order: the round, whether the
|
|
73
|
+
# evaluator or a before_eval rejection gave it, and the verdict as
|
|
74
|
+
# normalized, copied and frozen so neither the loop nor a host holding a
|
|
75
|
+
# record can change it. Recorded before any exit, so the verdict that
|
|
76
|
+
# ends the loop (accepted, converged, or under the threshold) is kept.
|
|
77
|
+
# The loop's state carries a frozen copy, so a callable reads the
|
|
78
|
+
# verdicts so far and cannot rewrite the step's.
|
|
79
|
+
def record_evaluation(state, evaluation, round, source)
|
|
80
|
+
records = @pending_evaluations.values.last
|
|
81
|
+
record = { round: round, source: source, verdict: frozen_verdict(evaluation) }.freeze
|
|
82
|
+
within_step_record_limits!(@pending_evaluations.values.flatten(1) << record)
|
|
83
|
+
records << record
|
|
84
|
+
state.evaluations = records.dup.freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The verdicts ride on the step's record, which a split step snapshots
|
|
88
|
+
# under ExecutionResultSnapshot's limits. A round whose verdict would
|
|
89
|
+
# take the step's verdicts past them fails here, the same way in every
|
|
90
|
+
# run mode, and the verdicts already recorded still fit a failed step's
|
|
91
|
+
# record.
|
|
92
|
+
def within_step_record_limits!(records)
|
|
93
|
+
ExecutionResultSnapshot.new({ evaluations: records }).call
|
|
94
|
+
rescue WorkflowError => e
|
|
95
|
+
raise WorkflowError, "evaluations exceed a step record's limits: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A verdict holds JSON values only, as a provider's structured output
|
|
99
|
+
# does: anything else (an object a before_eval returned, a key that is
|
|
100
|
+
# not a String or Symbol, a non-finite Float) fails the round here, the
|
|
101
|
+
# same way in every run mode, rather than when a split step snapshots it.
|
|
102
|
+
def frozen_verdict(value)
|
|
103
|
+
case value
|
|
104
|
+
when Hash then value.to_h { |key, nested| [verdict_key(key), frozen_verdict(nested)] }.freeze
|
|
105
|
+
when Array then value.map { |nested| frozen_verdict(nested) }.freeze
|
|
106
|
+
when String then StringSnapshot.copy(value, freeze: true)
|
|
107
|
+
else verdict_scalar(value)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verdict_scalar(value)
|
|
112
|
+
case value
|
|
113
|
+
when Float then finite_verdict_number(value)
|
|
114
|
+
when Symbol, Integer, true, false, nil then value
|
|
115
|
+
else raise WorkflowError, "evaluation must hold JSON values; got #{value.class}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def verdict_key(key)
|
|
120
|
+
return key if key.is_a?(Symbol)
|
|
121
|
+
return StringSnapshot.copy(key, freeze: true) if key.is_a?(String)
|
|
122
|
+
|
|
123
|
+
raise WorkflowError, "evaluation keys must be Strings or Symbols; got #{key.class}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def finite_verdict_number(value)
|
|
127
|
+
return value if value.finite?
|
|
128
|
+
|
|
129
|
+
raise WorkflowError, "evaluation must hold finite numbers; got #{value}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# An optimize step's record carries every verdict its loops recorded
|
|
133
|
+
# under :evaluations, whether the step completed or failed, each naming
|
|
134
|
+
# the attempt that gave it (`@step_attempt`, the retry policy's): a step
|
|
135
|
+
# `retry_on` runs again starts its loop afresh, rounds counting from 0
|
|
136
|
+
# again, and an earlier attempt's verdicts were given and paid for all
|
|
137
|
+
# the same. The key is there whenever a loop ran; a step that failed
|
|
138
|
+
# before its loop (an agent not registered, a guardrail) and any other
|
|
139
|
+
# step have none. The carrier is transient: the loop's list keyed by its
|
|
140
|
+
# attempt, cleared once the step's record is committed or its failure
|
|
141
|
+
# captured, and at every step's start.
|
|
142
|
+
def pend_evaluations
|
|
143
|
+
(@pending_evaluations ||= {})[@step_attempt || 1] = []
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def fold_pending_evaluations(step)
|
|
147
|
+
return unless @pending_evaluations
|
|
148
|
+
|
|
149
|
+
step[:evaluations] = @pending_evaluations.flat_map do |attempt, records|
|
|
150
|
+
records.map { |record| { attempt: attempt, **record }.freeze }
|
|
151
|
+
end.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
64
154
|
# :raise => WorkflowError(message); :return_last => state.candidate;
|
|
65
155
|
# callable => mode.call(state). Default :raise preserves legacy
|
|
66
156
|
# behavior for hosts that don't opt in to graceful exits.
|
|
@@ -127,21 +217,35 @@ module Smith
|
|
|
127
217
|
# evaluator sees the same seed_messages + inject_state context.
|
|
128
218
|
# Default nil keeps the legacy candidate-only payload.
|
|
129
219
|
def build_evaluator_input(state)
|
|
130
|
-
|
|
220
|
+
content = candidate_content(state.candidate)
|
|
221
|
+
return [{ role: :user, content: }] unless state.config[:evaluator_context] == :inject_state
|
|
131
222
|
|
|
132
223
|
prior = Array(state.prepared_input).dup
|
|
133
|
-
prior.push(role: :user, content:
|
|
224
|
+
prior.push(role: :user, content:)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A structured candidate (an output_schema agent's Hash, or an Array a
|
|
228
|
+
# schema-less generator returned) gets the JSON serialisation the
|
|
229
|
+
# provider boundary applies; text stays as is.
|
|
230
|
+
def candidate_content(candidate)
|
|
231
|
+
candidate.is_a?(Hash) || candidate.is_a?(Array) ? json_message_content(candidate) : candidate.to_s
|
|
134
232
|
end
|
|
135
233
|
|
|
136
234
|
# Runs after candidate generation, before evaluator invocation.
|
|
137
|
-
# Receives (state, @context); @context is mutable.
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
235
|
+
# Receives (state, @context); @context is mutable. A returned Hash
|
|
236
|
+
# whose accept (Symbol or String key) is false is the round's
|
|
237
|
+
# evaluation, validated like evaluator output, and the evaluator is
|
|
238
|
+
# not called; any other return value is discarded. Raised exceptions
|
|
239
|
+
# bubble through the standard step failure path.
|
|
240
|
+
def before_eval_rejection(state)
|
|
141
241
|
callback = state.config[:before_eval]
|
|
142
242
|
return unless callback
|
|
143
243
|
|
|
144
|
-
callback.call(state, @context)
|
|
244
|
+
verdict = callback.call(state, @context)
|
|
245
|
+
return unless verdict.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
evaluation = normalize_evaluation(verdict)
|
|
248
|
+
evaluation if evaluation.key?(:accept) && evaluation[:accept].equal?(false)
|
|
145
249
|
end
|
|
146
250
|
|
|
147
251
|
def invoke_with_evaluator_schema(evaluator_class, schema, input)
|
|
@@ -195,7 +299,7 @@ module Smith
|
|
|
195
299
|
return prepared_input if round.zero?
|
|
196
300
|
|
|
197
301
|
(prepared_input&.dup || []).push(
|
|
198
|
-
{ role: :assistant, content: prior_candidate
|
|
302
|
+
{ role: :assistant, content: candidate_content(prior_candidate) },
|
|
199
303
|
{
|
|
200
304
|
role: :user,
|
|
201
305
|
content: "[smith:refinement-round] #{round + 1}\n[smith:evaluator-feedback]\n#{feedback}"
|
|
@@ -11,6 +11,11 @@ module Smith
|
|
|
11
11
|
data: { transition: transition.name, from: transition.from, to: transition.to }
|
|
12
12
|
)
|
|
13
13
|
|
|
14
|
+
# Handlers are not a step's own work, so they get no idempotency key.
|
|
15
|
+
IdempotencyKey.__send__(:without) { emit_step_completed_event(transition) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def emit_step_completed_event(transition)
|
|
14
19
|
Smith::Events.emit(
|
|
15
20
|
Events::StepCompleted.new(
|
|
16
21
|
transition: transition.name.to_sym,
|
|
@@ -54,6 +59,10 @@ module Smith
|
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
def emit_step_failed_event(failure)
|
|
62
|
+
IdempotencyKey.__send__(:without) { emit_step_failed_bus_event(failure) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def emit_step_failed_bus_event(failure)
|
|
57
66
|
Smith::Events.emit(
|
|
58
67
|
Events::StepFailed.new(
|
|
59
68
|
transition: DiagnosticText.capture(failure[:transition].to_s, max_bytes: 256).to_sym,
|
|
@@ -6,6 +6,7 @@ require_relative "guarded_step_execution"
|
|
|
6
6
|
require_relative "prepared_branch_execution"
|
|
7
7
|
require_relative "step_completion"
|
|
8
8
|
require_relative "step_context"
|
|
9
|
+
require_relative "step_stream"
|
|
9
10
|
|
|
10
11
|
module Smith
|
|
11
12
|
class Workflow
|
|
@@ -15,6 +16,7 @@ module Smith
|
|
|
15
16
|
include PreparedBranchExecution
|
|
16
17
|
include StepCompletion
|
|
17
18
|
include StepContext
|
|
19
|
+
include StepStream
|
|
18
20
|
include Agent::Lifecycle
|
|
19
21
|
include NestedExecution
|
|
20
22
|
include EvaluatorOptimizer
|
|
@@ -34,26 +36,50 @@ module Smith
|
|
|
34
36
|
# Reset the per-step agent-attribution carrier so only a serial agent
|
|
35
37
|
# step that actually runs (below) leaves model/provider for complete_step;
|
|
36
38
|
# a deterministic step never sets it and must not inherit a stale value.
|
|
39
|
+
# The optimizer's verdict carrier resets for the same reason.
|
|
37
40
|
@pending_agent_execution = nil
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
@pending_evaluations = nil
|
|
42
|
+
writable_output_key!(transition) if transition.output_to
|
|
43
|
+
with_step_stream(transition) do
|
|
44
|
+
output = with_scoped_artifacts { run_with_retry_policy(transition) }
|
|
45
|
+
output = queue_batch(transition, output) if output.is_a?(BatchExecution::Captured)
|
|
46
|
+
next output if wait_pending?(output)
|
|
47
|
+
|
|
48
|
+
StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
|
|
49
|
+
end
|
|
50
|
+
ensure
|
|
51
|
+
release_batch_holds
|
|
40
52
|
end
|
|
41
53
|
|
|
54
|
+
# Restored with the step's thread context (ThreadContextSnapshot): a
|
|
55
|
+
# step's depth counts the runs whose steps it runs inside.
|
|
42
56
|
def setup_step_context
|
|
57
|
+
Thread.current[:smith_step_depth] = (Thread.current[:smith_step_depth] || 0) + 1
|
|
43
58
|
Tool.current_deadline = wall_clock_deadline
|
|
44
59
|
Tool.current_ledger = @ledger
|
|
45
60
|
Tool.current_tool_result_collector = tool_result_collector
|
|
46
61
|
end
|
|
47
62
|
|
|
63
|
+
# An await, delay, map or call waits; a spawn launches and continues.
|
|
64
|
+
def execute_waiting_step(transition)
|
|
65
|
+
transition.spawns? ? execute_spawn_step(transition) : execute_await_step(transition)
|
|
66
|
+
end
|
|
67
|
+
|
|
48
68
|
def run_standard_guarded_step(transition)
|
|
69
|
+
return execute_waiting_step(transition) if transition.awaits? || transition.spawns?
|
|
49
70
|
return dispatch_step(transition) if transition.deterministic?
|
|
50
71
|
return run_guarded_fanout_step(transition) if transition.fanout?
|
|
51
72
|
|
|
52
73
|
agent_class = resolve_agent_class(transition)
|
|
53
74
|
run_input_guardrails(agent_class)
|
|
54
75
|
apply_tool_guardrails(agent_class)
|
|
55
|
-
prepared_input =
|
|
76
|
+
prepared_input = step_prepared_input(transition)
|
|
77
|
+
return send_agent_batch(transition, agent_class, prepared_input) if batch_send?(transition)
|
|
78
|
+
|
|
79
|
+
guarded_agent_output(transition, agent_class, prepared_input)
|
|
80
|
+
end
|
|
56
81
|
|
|
82
|
+
def guarded_agent_output(transition, agent_class, prepared_input)
|
|
57
83
|
output = with_agent_context(agent_class) do
|
|
58
84
|
dispatch_step(transition, prepared_input: prepared_input)
|
|
59
85
|
end
|
|
@@ -80,13 +106,9 @@ module Smith
|
|
|
80
106
|
@last_prepared_input = prepared_input
|
|
81
107
|
return nil unless transition.agent_name
|
|
82
108
|
|
|
83
|
-
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
# path resolves the actual id in build_model_chain at attempt time.
|
|
87
|
-
return nil unless agent_class.model_configured?
|
|
88
|
-
|
|
89
|
-
invoke_agent(agent_class, prepared_input)
|
|
109
|
+
# An agent with no model and no fallback models fails in
|
|
110
|
+
# complete_with_provider rather than skipping the step.
|
|
111
|
+
invoke_agent(resolve_agent_class(transition), prepared_input)
|
|
90
112
|
end
|
|
91
113
|
|
|
92
114
|
def execute_serial_step(transition, prepared_input: nil)
|
|
@@ -115,19 +137,6 @@ module Smith
|
|
|
115
137
|
agent_class = resolve_agent_class(transition)
|
|
116
138
|
reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
|
|
117
139
|
end
|
|
118
|
-
|
|
119
|
-
def resolve_agent_class(transition)
|
|
120
|
-
parallel_binding = Thread.current[:smith_parallel_agent_binding]
|
|
121
|
-
parallel_agent_class = parallel_binding&.resolve(workflow: self, transition:)
|
|
122
|
-
return parallel_agent_class if parallel_agent_class
|
|
123
|
-
|
|
124
|
-
transition.agent_name && resolve_registered_agent!(
|
|
125
|
-
transition.agent_name,
|
|
126
|
-
workflow_class: self.class,
|
|
127
|
-
transition_name: transition.name,
|
|
128
|
-
role: :agent
|
|
129
|
-
)
|
|
130
|
-
end
|
|
131
140
|
end
|
|
132
141
|
end
|
|
133
142
|
end
|
|
@@ -11,6 +11,19 @@ module Smith
|
|
|
11
11
|
|
|
12
12
|
Agent::Registry.fetch!(name, workflow_class:, transition_name:, role:)
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
def resolve_agent_class(transition)
|
|
16
|
+
parallel_binding = Thread.current[:smith_parallel_agent_binding]
|
|
17
|
+
parallel_agent_class = parallel_binding&.resolve(workflow: self, transition:)
|
|
18
|
+
return parallel_agent_class if parallel_agent_class
|
|
19
|
+
|
|
20
|
+
transition.agent_name && resolve_registered_agent!(
|
|
21
|
+
transition.agent_name,
|
|
22
|
+
workflow_class: self.class,
|
|
23
|
+
transition_name: transition.name,
|
|
24
|
+
role: :agent
|
|
25
|
+
)
|
|
26
|
+
end
|
|
14
27
|
end
|
|
15
28
|
end
|
|
16
29
|
end
|