smith-agents 0.4.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 +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- metadata +258 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "set"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
class Workflow
|
|
11
|
+
include DSL
|
|
12
|
+
include Persistence
|
|
13
|
+
include Durability
|
|
14
|
+
include GuardrailIntegration
|
|
15
|
+
include BudgetIntegration
|
|
16
|
+
include EventIntegration
|
|
17
|
+
include ArtifactIntegration
|
|
18
|
+
include DataVolumePolicy
|
|
19
|
+
include DeadlineEnforcement
|
|
20
|
+
include Execution
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_TRANSITIONS = 100
|
|
23
|
+
|
|
24
|
+
# `keyword_init: true` is mandatory: `build_run_result` constructs
|
|
25
|
+
# the result via keyword arguments. Plain Ruby Structs treat the
|
|
26
|
+
# kwargs hash as the first positional field, silently leaving the
|
|
27
|
+
# remaining fields nil — verified empirically. The `keyword_init`
|
|
28
|
+
# flag routes kwargs to the right fields. `usage_entries` is the
|
|
29
|
+
# 10th field, added in this slice for hadithi billing.
|
|
30
|
+
RunResult = Struct.new(:state, :output, :steps, :total_cost, :total_tokens, :context, :session_messages,
|
|
31
|
+
:tool_results, :outcome, :usage_entries, keyword_init: true) do
|
|
32
|
+
def done?
|
|
33
|
+
state == :done
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def failed?
|
|
37
|
+
state == :failed
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def terminal_output
|
|
41
|
+
output
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def outcome_kind
|
|
45
|
+
outcome&.dig(:kind)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def outcome_payload
|
|
49
|
+
outcome&.dig(:payload)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def last_error
|
|
53
|
+
steps.reverse.map { |step| step[:error] }.compact.first
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failed_transition
|
|
57
|
+
failure_detail&.fetch(:transition)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def failure_detail
|
|
61
|
+
failed_step = steps.reverse.find { |step| step[:error] }
|
|
62
|
+
return nil unless failed_step
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
transition: failed_step[:transition],
|
|
66
|
+
from: failed_step[:from],
|
|
67
|
+
to: failed_step[:to],
|
|
68
|
+
error: failed_step[:error]
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# keyword_init: true so adding new fields in future schema versions
|
|
74
|
+
# remains backward-compatible: `from_response` and `from_h` can fill
|
|
75
|
+
# extra fields without breaking existing call sites that pass the
|
|
76
|
+
# historical positional args. Reading old persisted records into a
|
|
77
|
+
# newer Struct shape: from_h slices to current members (unknown keys
|
|
78
|
+
# silently dropped; missing keys default to nil).
|
|
79
|
+
AgentResult = Struct.new(
|
|
80
|
+
:content, :input_tokens, :output_tokens, :cost, :model_used,
|
|
81
|
+
keyword_init: true
|
|
82
|
+
) do
|
|
83
|
+
def self.from_response(response, content, model_used: nil)
|
|
84
|
+
new(
|
|
85
|
+
content: content,
|
|
86
|
+
input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
87
|
+
output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
|
|
88
|
+
cost: nil,
|
|
89
|
+
model_used: model_used
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def usage_known?
|
|
94
|
+
!input_tokens.nil? && !output_tokens.nil?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One row per agent provider call. `usage_id` is a UUID generated
|
|
99
|
+
# at recording time and stable across persist/restore — hadithi
|
|
100
|
+
# uses it as the idempotency anchor on `usage_events.smith_usage_id`.
|
|
101
|
+
# Includes `to_h`/`from_h` for JSON serialization (plain Struct
|
|
102
|
+
# JSON-encodes to `"#<struct ...>"` — useless).
|
|
103
|
+
#
|
|
104
|
+
# keyword_init: true gives forward/backward compatibility:
|
|
105
|
+
# - Adding a new field: old persisted hashes restore cleanly (new
|
|
106
|
+
# field defaults to nil).
|
|
107
|
+
# - Reading new persisted hashes with an older Smith version: from_h
|
|
108
|
+
# slices to known members (unknown keys silently dropped).
|
|
109
|
+
UsageEntry = Struct.new(
|
|
110
|
+
:usage_id,
|
|
111
|
+
:agent_name,
|
|
112
|
+
:model,
|
|
113
|
+
:input_tokens,
|
|
114
|
+
:output_tokens,
|
|
115
|
+
:cost,
|
|
116
|
+
:attempt_kind,
|
|
117
|
+
:recorded_at,
|
|
118
|
+
keyword_init: true
|
|
119
|
+
) do
|
|
120
|
+
def self.from_h(hash)
|
|
121
|
+
sym = hash.transform_keys(&:to_sym)
|
|
122
|
+
filtered = sym.slice(*members)
|
|
123
|
+
# Symbolize :agent_name and :attempt_kind for backward compat
|
|
124
|
+
# with callers that consume the field as Symbol.
|
|
125
|
+
filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
|
|
126
|
+
filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
|
|
127
|
+
new(**filtered)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Reconstruct Smith error classes from `@last_failed_step` snapshots.
|
|
132
|
+
# Order matters: more-specific subclasses first, so a real DSF doesn't
|
|
133
|
+
# get caught by the WorkflowError handler. Each lambda preserves the
|
|
134
|
+
# billing-critical attributes (`retryable`, `kind`, `details`) by
|
|
135
|
+
# routing through the original constructor — Smith's retryable errors
|
|
136
|
+
# expose `attr_reader :retryable` only, with no setter, so kwargs
|
|
137
|
+
# MUST flow through `initialize`.
|
|
138
|
+
KNOWN_RECONSTRUCTORS = {
|
|
139
|
+
"Smith::ToolGuardrailFailed" => ->(s) {
|
|
140
|
+
Smith::ToolGuardrailFailed.new(s[:error_message], retryable: s[:error_retryable])
|
|
141
|
+
},
|
|
142
|
+
"Smith::DeterministicStepFailure" => ->(s) {
|
|
143
|
+
Smith::DeterministicStepFailure.new(
|
|
144
|
+
s[:error_message],
|
|
145
|
+
retryable: s[:error_retryable],
|
|
146
|
+
kind: s[:error_kind],
|
|
147
|
+
details: s[:error_details]
|
|
148
|
+
)
|
|
149
|
+
},
|
|
150
|
+
"Smith::AgentError" => ->(s) { Smith::AgentError.new(s[:error_message]) },
|
|
151
|
+
"Smith::DeadlineExceeded" => ->(s) { Smith::DeadlineExceeded.new(s[:error_message]) },
|
|
152
|
+
"Smith::WorkflowError" => ->(s) { Smith::WorkflowError.new(s[:error_message]) },
|
|
153
|
+
# Smith errors with non-message constructors map to compatible
|
|
154
|
+
# superclass — message preserved, original metadata (agent_name,
|
|
155
|
+
# model_used, requested_name, workflow_class, origin_state) lossy
|
|
156
|
+
# but `is_a?` classification round-trips via the superclass.
|
|
157
|
+
"Smith::BlankAgentOutputError" => ->(s) { Smith::AgentError.new(s[:error_message]) },
|
|
158
|
+
"Smith::UnresolvedTransitionError" => ->(s) { Smith::WorkflowError.new(s[:error_message]) }
|
|
159
|
+
}.freeze
|
|
160
|
+
private_constant :KNOWN_RECONSTRUCTORS
|
|
161
|
+
|
|
162
|
+
# Families whose retryable/kind/details attributes are billing-critical.
|
|
163
|
+
# For these, the reconstruction path bypasses `const_get(...).new(message)`
|
|
164
|
+
# (which would succeed for unknown subclasses with message-only
|
|
165
|
+
# constructors but discard the kwargs) and uses the family fallback
|
|
166
|
+
# directly so the parent-class constructor preserves the attrs.
|
|
167
|
+
RETRYABLE_BEARING_FAMILIES = %w[deterministic_step_failure tool_guardrail_failed].freeze
|
|
168
|
+
private_constant :RETRYABLE_BEARING_FAMILIES
|
|
169
|
+
|
|
170
|
+
# keyword_init: true for forward/backward compat (see UsageEntry).
|
|
171
|
+
BranchEnv = Struct.new(
|
|
172
|
+
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
|
|
173
|
+
keyword_init: true
|
|
174
|
+
) do
|
|
175
|
+
def setup_thread
|
|
176
|
+
Smith::Tool.current_guardrails = guardrail_sources
|
|
177
|
+
Smith::Tool.current_deadline = deadline
|
|
178
|
+
Smith.scoped_artifacts = scoped_store
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def teardown_thread
|
|
182
|
+
Smith::Tool.current_guardrails = nil
|
|
183
|
+
Smith::Tool.current_deadline = nil
|
|
184
|
+
Smith.scoped_artifacts = nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
attr_reader :state, :last_prepared_input, :session_messages, :ledger
|
|
189
|
+
|
|
190
|
+
def initialize(context: {}, ledger: nil, created_at: nil)
|
|
191
|
+
@state = self.class.initial_state
|
|
192
|
+
@context = context
|
|
193
|
+
@step_count = 0
|
|
194
|
+
@next_transition_name = nil
|
|
195
|
+
@ledger = ledger || build_ledger
|
|
196
|
+
@created_at = created_at || Time.now.utc.iso8601
|
|
197
|
+
@updated_at = @created_at
|
|
198
|
+
@total_cost = 0.0
|
|
199
|
+
@total_tokens = 0
|
|
200
|
+
@outcome = nil
|
|
201
|
+
# Eager init for usage tracking. Both `@usage_mutex` (lazy
|
|
202
|
+
# init at the call site would race across parallel fan-out
|
|
203
|
+
# branches) and the durable per-call/output/failure fields
|
|
204
|
+
# must be present before any agent recording fires.
|
|
205
|
+
# `restore_state` mirrors these inits because `from_state` uses
|
|
206
|
+
# `allocate` and bypasses `initialize` — see persistence.rb.
|
|
207
|
+
@usage_entries = []
|
|
208
|
+
@usage_mutex = Mutex.new
|
|
209
|
+
@last_output = nil
|
|
210
|
+
@last_failed_step = nil
|
|
211
|
+
# Optimistic-locking version. Incremented on each persist!; restored
|
|
212
|
+
# from the persisted payload. Adapters that support store_versioned
|
|
213
|
+
# raise Smith::PersistenceVersionConflict when expected_version
|
|
214
|
+
# doesn't match the stored payload's version (i.e., a concurrent
|
|
215
|
+
# write occurred between this process's restore and persist).
|
|
216
|
+
@persistence_version = 0
|
|
217
|
+
# Digest of the seed_messages produced at construction time.
|
|
218
|
+
# Compared on restore against the live builder's output when
|
|
219
|
+
# seed_validation is :warn or :strict; nil when no seed builder
|
|
220
|
+
# ran or its output was empty.
|
|
221
|
+
@seed_digest = nil
|
|
222
|
+
# Idempotency marker stamped between persist-before-advance and
|
|
223
|
+
# persist-after-advance under idempotency_mode :strict; restored
|
|
224
|
+
# workflows with the marker set raise
|
|
225
|
+
# Smith::StepInProgressOnRestore. Lax mode leaves it false.
|
|
226
|
+
@step_in_progress = false
|
|
227
|
+
# Set of context keys recorded via deterministic step write_context
|
|
228
|
+
# writes. Used by persist :auto Context mode to compute the
|
|
229
|
+
# persisted-context slice. Seeded from the Context class's
|
|
230
|
+
# also: declaration so explicit input keys round-trip.
|
|
231
|
+
@persisted_keys = ::Set.new(initial_persist_auto_seed)
|
|
232
|
+
@persisted_keys_mutex = Mutex.new
|
|
233
|
+
initialize_tool_result_state
|
|
234
|
+
seed_initial_session_messages
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def persisted_keys
|
|
238
|
+
@persisted_keys.dup.freeze
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def advance!
|
|
242
|
+
max = self.class.max_transitions || DEFAULT_MAX_TRANSITIONS
|
|
243
|
+
raise MaxTransitionsExceeded if @step_count >= max
|
|
244
|
+
|
|
245
|
+
transition = resolve_transition
|
|
246
|
+
return if transition.nil?
|
|
247
|
+
|
|
248
|
+
step_result = execute_step(transition)
|
|
249
|
+
@step_count += 1
|
|
250
|
+
@updated_at = Time.now.utc.iso8601
|
|
251
|
+
record_step_snapshot(step_result)
|
|
252
|
+
step_result
|
|
253
|
+
rescue UnresolvedTransitionError => e
|
|
254
|
+
origin_state = @state
|
|
255
|
+
@outcome = nil
|
|
256
|
+
raise unless route_to_fail_state!
|
|
257
|
+
|
|
258
|
+
step_result = { transition: e.requested_name, from: origin_state, to: @state, error: e }
|
|
259
|
+
record_step_snapshot(step_result)
|
|
260
|
+
step_result
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def run!
|
|
264
|
+
steps = []
|
|
265
|
+
until terminal?
|
|
266
|
+
step = advance!
|
|
267
|
+
steps << step if step
|
|
268
|
+
end
|
|
269
|
+
build_run_result(steps)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def terminal?
|
|
273
|
+
self.class.transitions_from(@state).empty? && @next_transition_name.nil?
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def done?
|
|
277
|
+
@state == :done
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def failed?
|
|
281
|
+
@state == :failed
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def record_persisted_key!(key)
|
|
285
|
+
@persisted_keys_mutex.synchronize do
|
|
286
|
+
@persisted_keys << key.to_sym
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
def initial_persist_auto_seed
|
|
293
|
+
manager = self.class.context_manager
|
|
294
|
+
return [] unless manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
295
|
+
|
|
296
|
+
manager.persist_auto_seed.map(&:to_sym)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Centralized capture for both `advance!` paths — the normal
|
|
300
|
+
# `execute_step` return AND the `UnresolvedTransitionError` rescue
|
|
301
|
+
# path. Without centralization, the rescue path's step would never
|
|
302
|
+
# populate `@last_failed_step`, and an unresolved-transition
|
|
303
|
+
# terminal failure restored after persist would have nil last_error.
|
|
304
|
+
#
|
|
305
|
+
# On a successful step (no :error key, possibly with :output): clear
|
|
306
|
+
# `@last_failed_step` (a workflow that handled a failure and reached
|
|
307
|
+
# :done shouldn't synthesize a stale error on terminal restore) and
|
|
308
|
+
# capture the latest non-nil `:output` into `@last_output` (last
|
|
309
|
+
# non-nil wins; matches `RunResult#output`'s `compact.first` shape).
|
|
310
|
+
def record_step_snapshot(step_result)
|
|
311
|
+
return unless step_result
|
|
312
|
+
|
|
313
|
+
if step_result[:error]
|
|
314
|
+
err = step_result[:error]
|
|
315
|
+
# error_family preserves classification across reconstruction
|
|
316
|
+
# even when the exact class can't be rebuilt. Order matters:
|
|
317
|
+
# specific subclasses first (DSF before WorkflowError, etc.)
|
|
318
|
+
# so a real DSF doesn't get classified as workflow_error.
|
|
319
|
+
error_family = case err
|
|
320
|
+
when Smith::DeterministicStepFailure then "deterministic_step_failure"
|
|
321
|
+
when Smith::ToolGuardrailFailed then "tool_guardrail_failed"
|
|
322
|
+
when Smith::DeadlineExceeded then "deadline_exceeded"
|
|
323
|
+
when Smith::AgentError then "agent_error"
|
|
324
|
+
when Smith::WorkflowError then "workflow_error"
|
|
325
|
+
else "other"
|
|
326
|
+
end
|
|
327
|
+
@last_failed_step = {
|
|
328
|
+
transition: step_result[:transition],
|
|
329
|
+
from: step_result[:from],
|
|
330
|
+
to: step_result[:to],
|
|
331
|
+
error_class: err.class.name,
|
|
332
|
+
error_family: error_family,
|
|
333
|
+
error_message: err.message,
|
|
334
|
+
error_retryable: err.respond_to?(:retryable) ? err.retryable : nil,
|
|
335
|
+
error_kind: err.respond_to?(:kind) ? err.kind : nil,
|
|
336
|
+
error_details: err.respond_to?(:details) ? err.details : nil
|
|
337
|
+
}
|
|
338
|
+
else
|
|
339
|
+
# Successful step: clear any prior failed-step snapshot
|
|
340
|
+
# (workflow handled the failure and continued) and capture
|
|
341
|
+
# the output if non-nil (preserves `false` as a valid output;
|
|
342
|
+
# matches `RunResult#output`'s `.compact.first` semantics).
|
|
343
|
+
@last_failed_step = nil
|
|
344
|
+
@last_output = step_result[:output] if step_result.key?(:output) && !step_result[:output].nil?
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def build_ledger
|
|
349
|
+
config = self.class.budget
|
|
350
|
+
return nil unless config
|
|
351
|
+
|
|
352
|
+
Budget::Ledger.new(limits: config)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def route_to_fail_state!
|
|
356
|
+
fail_transition = self.class.find_transition(:fail)
|
|
357
|
+
return false unless fail_transition
|
|
358
|
+
|
|
359
|
+
@state = fail_transition.to
|
|
360
|
+
true
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def resolve_transition
|
|
364
|
+
if @next_transition_name
|
|
365
|
+
name = @next_transition_name
|
|
366
|
+
@next_transition_name = nil
|
|
367
|
+
self.class.find_transition(name) ||
|
|
368
|
+
raise(UnresolvedTransitionError.new(name, self.class, @state))
|
|
369
|
+
else
|
|
370
|
+
self.class.transitions_from(@state).first
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def build_run_result(steps)
|
|
375
|
+
# `output` derivation matches existing semantics on fresh runs
|
|
376
|
+
# (last non-nil step output via `compact.first`). Terminal-restore
|
|
377
|
+
# path (steps.empty?) falls back to `@last_output` so the durable
|
|
378
|
+
# output survives persist/restore. Gate on `steps.empty?` to avoid
|
|
379
|
+
# leaking a stale `@last_output` into a fresh run that produced
|
|
380
|
+
# nil output.
|
|
381
|
+
output = steps.reverse.map { |step| step[:output] }.compact.first
|
|
382
|
+
output = @last_output if output.nil? && steps.empty?
|
|
383
|
+
|
|
384
|
+
# On terminal-restore of a failed workflow with empty steps,
|
|
385
|
+
# synthesize a single-step array from `@last_failed_step` so
|
|
386
|
+
# `RunResult#last_error` and `#failure_detail` work exactly as
|
|
387
|
+
# they do on fresh-run failures. Gate on `failed?` so a `:done`
|
|
388
|
+
# terminal state never produces a synthetic error even if the
|
|
389
|
+
# snapshot wasn't cleared.
|
|
390
|
+
effective_steps = if steps.empty? && failed? && @last_failed_step
|
|
391
|
+
[reconstruct_failed_step]
|
|
392
|
+
else
|
|
393
|
+
steps
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
RunResult.new(
|
|
397
|
+
state: @state,
|
|
398
|
+
output: output,
|
|
399
|
+
steps: effective_steps,
|
|
400
|
+
total_cost: @total_cost,
|
|
401
|
+
total_tokens: @total_tokens,
|
|
402
|
+
context: snapshot_context,
|
|
403
|
+
session_messages: snapshot_session_messages,
|
|
404
|
+
tool_results: snapshot_tool_results,
|
|
405
|
+
outcome: snapshot_outcome,
|
|
406
|
+
usage_entries: snapshot_usage_entries
|
|
407
|
+
)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def reconstruct_failed_step
|
|
411
|
+
snap = @last_failed_step
|
|
412
|
+
builder = KNOWN_RECONSTRUCTORS[snap[:error_class]]
|
|
413
|
+
error = if builder
|
|
414
|
+
builder.call(snap)
|
|
415
|
+
elsif RETRYABLE_BEARING_FAMILIES.include?(snap[:error_family])
|
|
416
|
+
# Skip const_get for retryable-bearing families. An unknown
|
|
417
|
+
# subclass with a message-only constructor would const_get
|
|
418
|
+
# successfully but discard the snapshot's `retryable`/`kind`/
|
|
419
|
+
# `details` (defaults to nil), and hadithi's `retryable?`
|
|
420
|
+
# check would misclassify a retryable failure as terminal.
|
|
421
|
+
# Family fallback rebuilds the parent class with kwargs intact.
|
|
422
|
+
family_fallback(snap)
|
|
423
|
+
else
|
|
424
|
+
# Unknown subclass without retryable-bearing semantics. Try
|
|
425
|
+
# the exact class for shape preservation; fall back via family
|
|
426
|
+
# if the constructor doesn't accept message-only args (or the
|
|
427
|
+
# class doesn't exist).
|
|
428
|
+
begin
|
|
429
|
+
Kernel.const_get(snap[:error_class]).new(snap[:error_message])
|
|
430
|
+
rescue NameError, ArgumentError
|
|
431
|
+
family_fallback(snap)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Symbol coercion on the way out: live steps carry these as
|
|
436
|
+
# symbols; JSON round-trip stringifies them; coerce back to
|
|
437
|
+
# match fresh-run shape exactly.
|
|
438
|
+
{
|
|
439
|
+
transition: snap[:transition]&.to_sym,
|
|
440
|
+
from: snap[:from]&.to_sym,
|
|
441
|
+
to: snap[:to]&.to_sym,
|
|
442
|
+
error: error
|
|
443
|
+
}
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def family_fallback(snap)
|
|
447
|
+
case snap[:error_family]
|
|
448
|
+
when "deterministic_step_failure"
|
|
449
|
+
Smith::DeterministicStepFailure.new(
|
|
450
|
+
snap[:error_message],
|
|
451
|
+
retryable: snap[:error_retryable],
|
|
452
|
+
kind: snap[:error_kind],
|
|
453
|
+
details: snap[:error_details]
|
|
454
|
+
)
|
|
455
|
+
when "tool_guardrail_failed"
|
|
456
|
+
Smith::ToolGuardrailFailed.new(snap[:error_message], retryable: snap[:error_retryable])
|
|
457
|
+
when "deadline_exceeded" then Smith::DeadlineExceeded.new(snap[:error_message])
|
|
458
|
+
when "agent_error" then Smith::AgentError.new(snap[:error_message])
|
|
459
|
+
when "workflow_error" then Smith::WorkflowError.new(snap[:error_message])
|
|
460
|
+
else RuntimeError.new(snap[:error_message])
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def seed_initial_session_messages
|
|
465
|
+
messages = compute_seed_messages
|
|
466
|
+
return if messages.nil?
|
|
467
|
+
|
|
468
|
+
@session_messages = messages
|
|
469
|
+
@seed_digest = compute_seed_digest(messages)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Fresh evaluation of the seed builder against the workflow's
|
|
473
|
+
# current @context. Returns nil when no builder is defined so
|
|
474
|
+
# callers (initialize, validate_seed_digest!) can distinguish
|
|
475
|
+
# "no builder" from "builder returned empty".
|
|
476
|
+
def compute_seed_messages
|
|
477
|
+
builder = self.class.seed_messages
|
|
478
|
+
return nil unless builder
|
|
479
|
+
|
|
480
|
+
seeded = builder.arity == 1 ? builder.call(@context) : builder.call
|
|
481
|
+
normalize_seed_messages(seeded)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def compute_seed_digest(messages)
|
|
485
|
+
return nil if messages.nil? || messages.empty?
|
|
486
|
+
|
|
487
|
+
Digest::SHA256.hexdigest(JSON.generate(messages))
|
|
488
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
|
|
489
|
+
raise WorkflowError,
|
|
490
|
+
"seed_messages content must be valid UTF-8 (Smith hashes via JSON.generate " \
|
|
491
|
+
"for drift detection): #{e.message}"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def normalize_seed_messages(seeded)
|
|
495
|
+
return [] if seeded.nil?
|
|
496
|
+
return [seeded] if seeded.is_a?(Hash)
|
|
497
|
+
return seeded.to_a if seeded.respond_to?(:to_a)
|
|
498
|
+
|
|
499
|
+
raise WorkflowError, "seed_messages must return a message Hash or an Array of message Hashes"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def snapshot_context
|
|
503
|
+
snapshot_value(@context)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def snapshot_session_messages
|
|
507
|
+
snapshot_value(@session_messages || [])
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def snapshot_tool_results
|
|
511
|
+
snapshot_value(@tool_results || [])
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def snapshot_outcome
|
|
515
|
+
snapshot_value(@outcome)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Defensive deep copy via `from_h(snapshot_value(to_h))` round-trip.
|
|
519
|
+
# `Struct#dup` is shallow — it shares mutable string fields between
|
|
520
|
+
# the original and the duplicate. Smith's existing snapshot helpers
|
|
521
|
+
# (`snapshot_context`, etc.) also use this round-trip pattern; the
|
|
522
|
+
# billing-facing RunResult must not alias mutable workflow state.
|
|
523
|
+
# Same rule applies to nested-workflow rollup (see
|
|
524
|
+
# `nested_execution.rb`).
|
|
525
|
+
def snapshot_usage_entries
|
|
526
|
+
@usage_entries.map { |entry| Workflow::UsageEntry.from_h(snapshot_value(entry.to_h)) }
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def tool_result_collector
|
|
530
|
+
->(entry) { @tool_results_mutex.synchronize { @tool_results << entry } }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def initialize_tool_result_state
|
|
534
|
+
@tool_results = []
|
|
535
|
+
@tool_results_mutex = Mutex.new
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def snapshot_value(value)
|
|
539
|
+
case value
|
|
540
|
+
when Hash
|
|
541
|
+
value.each_with_object({}) do |(key, nested), copy|
|
|
542
|
+
copy[snapshot_value(key)] = snapshot_value(nested)
|
|
543
|
+
end
|
|
544
|
+
when Array
|
|
545
|
+
value.map { |nested| snapshot_value(nested) }
|
|
546
|
+
when String
|
|
547
|
+
value.dup
|
|
548
|
+
else
|
|
549
|
+
value.dup
|
|
550
|
+
end
|
|
551
|
+
rescue TypeError
|
|
552
|
+
value
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|