smith-agents 0.10.0 → 0.11.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 +203 -0
- data/README.md +16 -6
- data/docs/CONFIGURATION.md +21 -1
- data/docs/PATTERNS.md +59 -0
- data/docs/PERSISTENCE.md +5 -1
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/provider_attempt.rb +11 -4
- data/lib/smith/agent/provider_call_timing.rb +62 -7
- data/lib/smith/agent/provider_completion.rb +8 -8
- data/lib/smith/agent/provider_usage.rb +6 -0
- data/lib/smith/agent/usage_tracking.rb +3 -0
- data/lib/smith/agent.rb +9 -1
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/ledger.rb +6 -2
- data/lib/smith/context/state_injection.rb +12 -2
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/durability.rb +16 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/errors.rb +55 -4
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +3 -3
- data/lib/smith/workflow/deterministic_execution.rb +1 -1
- data/lib/smith/workflow/deterministic_step.rb +1 -1
- data/lib/smith/workflow/dsl.rb +15 -5
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +108 -10
- data/lib/smith/workflow/execution.rb +2 -0
- data/lib/smith/workflow/failure_reconstructor.rb +18 -2
- data/lib/smith/workflow/failure_record.rb +6 -11
- data/lib/smith/workflow/failure_record_validator.rb +23 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/persistence.rb +26 -9
- data/lib/smith/workflow/retry_execution.rb +4 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/step_completion.rb +4 -0
- data/lib/smith/workflow.rb +5 -3
- data/lib/smith.rb +2 -2
- metadata +1 -1
|
@@ -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
|
|
|
@@ -41,10 +43,11 @@ module Smith
|
|
|
41
43
|
|
|
42
44
|
def run_optimization_round(state, round)
|
|
43
45
|
generate_candidate!(state, round)
|
|
44
|
-
|
|
45
|
-
evaluation = normalize_evaluation(evaluate_candidate(state))
|
|
46
|
+
rejection = before_eval_rejection(state)
|
|
47
|
+
evaluation = rejection || normalize_evaluation(evaluate_candidate(state))
|
|
46
48
|
validate_evaluation_structure!(evaluation)
|
|
47
49
|
validate_evaluation_fields!(evaluation, state.config)
|
|
50
|
+
record_evaluation(state, evaluation, round, rejection ? :before_eval : :evaluator)
|
|
48
51
|
|
|
49
52
|
return state.candidate if evaluation[:accept]
|
|
50
53
|
|
|
@@ -61,6 +64,88 @@ module Smith
|
|
|
61
64
|
nil
|
|
62
65
|
end
|
|
63
66
|
|
|
67
|
+
# Each valid round's evaluation, in round order: the round, whether the
|
|
68
|
+
# evaluator or a before_eval rejection gave it, and the verdict as
|
|
69
|
+
# normalized, copied and frozen so neither the loop nor a host holding a
|
|
70
|
+
# record can change it. Recorded before any exit, so the verdict that
|
|
71
|
+
# ends the loop (accepted, converged, or under the threshold) is kept.
|
|
72
|
+
# The loop's state carries a frozen copy, so a callable reads the
|
|
73
|
+
# verdicts so far and cannot rewrite the step's.
|
|
74
|
+
def record_evaluation(state, evaluation, round, source)
|
|
75
|
+
records = @pending_evaluations.values.last
|
|
76
|
+
record = { round: round, source: source, verdict: frozen_verdict(evaluation) }.freeze
|
|
77
|
+
within_step_record_limits!(@pending_evaluations.values.flatten(1) << record)
|
|
78
|
+
records << record
|
|
79
|
+
state.evaluations = records.dup.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The verdicts ride on the step's record, which a split step snapshots
|
|
83
|
+
# under ExecutionResultSnapshot's limits. A round whose verdict would
|
|
84
|
+
# take the step's verdicts past them fails here, the same way in every
|
|
85
|
+
# run mode, and the verdicts already recorded still fit a failed step's
|
|
86
|
+
# record.
|
|
87
|
+
def within_step_record_limits!(records)
|
|
88
|
+
ExecutionResultSnapshot.new({ evaluations: records }).call
|
|
89
|
+
rescue WorkflowError => e
|
|
90
|
+
raise WorkflowError, "evaluations exceed a step record's limits: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A verdict holds JSON values only, as a provider's structured output
|
|
94
|
+
# does: anything else (an object a before_eval returned, a key that is
|
|
95
|
+
# not a String or Symbol, a non-finite Float) fails the round here, the
|
|
96
|
+
# same way in every run mode, rather than when a split step snapshots it.
|
|
97
|
+
def frozen_verdict(value)
|
|
98
|
+
case value
|
|
99
|
+
when Hash then value.to_h { |key, nested| [verdict_key(key), frozen_verdict(nested)] }.freeze
|
|
100
|
+
when Array then value.map { |nested| frozen_verdict(nested) }.freeze
|
|
101
|
+
when String then StringSnapshot.copy(value, freeze: true)
|
|
102
|
+
else verdict_scalar(value)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verdict_scalar(value)
|
|
107
|
+
case value
|
|
108
|
+
when Float then finite_verdict_number(value)
|
|
109
|
+
when Symbol, Integer, true, false, nil then value
|
|
110
|
+
else raise WorkflowError, "evaluation must hold JSON values; got #{value.class}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def verdict_key(key)
|
|
115
|
+
return key if key.is_a?(Symbol)
|
|
116
|
+
return StringSnapshot.copy(key, freeze: true) if key.is_a?(String)
|
|
117
|
+
|
|
118
|
+
raise WorkflowError, "evaluation keys must be Strings or Symbols; got #{key.class}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def finite_verdict_number(value)
|
|
122
|
+
return value if value.finite?
|
|
123
|
+
|
|
124
|
+
raise WorkflowError, "evaluation must hold finite numbers; got #{value}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# An optimize step's record carries every verdict its loops recorded
|
|
128
|
+
# under :evaluations, whether the step completed or failed, each naming
|
|
129
|
+
# the attempt that gave it (`@step_attempt`, the retry policy's): a step
|
|
130
|
+
# `retry_on` runs again starts its loop afresh, rounds counting from 0
|
|
131
|
+
# again, and an earlier attempt's verdicts were given and paid for all
|
|
132
|
+
# the same. The key is there whenever a loop ran; a step that failed
|
|
133
|
+
# before its loop (an agent not registered, a guardrail) and any other
|
|
134
|
+
# step have none. The carrier is transient: the loop's list keyed by its
|
|
135
|
+
# attempt, cleared once the step's record is committed or its failure
|
|
136
|
+
# captured, and at every step's start.
|
|
137
|
+
def pend_evaluations
|
|
138
|
+
(@pending_evaluations ||= {})[@step_attempt || 1] = []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fold_pending_evaluations(step)
|
|
142
|
+
return unless @pending_evaluations
|
|
143
|
+
|
|
144
|
+
step[:evaluations] = @pending_evaluations.flat_map do |attempt, records|
|
|
145
|
+
records.map { |record| { attempt: attempt, **record }.freeze }
|
|
146
|
+
end.freeze
|
|
147
|
+
end
|
|
148
|
+
|
|
64
149
|
# :raise => WorkflowError(message); :return_last => state.candidate;
|
|
65
150
|
# callable => mode.call(state). Default :raise preserves legacy
|
|
66
151
|
# behavior for hosts that don't opt in to graceful exits.
|
|
@@ -127,21 +212,34 @@ module Smith
|
|
|
127
212
|
# evaluator sees the same seed_messages + inject_state context.
|
|
128
213
|
# Default nil keeps the legacy candidate-only payload.
|
|
129
214
|
def build_evaluator_input(state)
|
|
130
|
-
|
|
215
|
+
content = candidate_content(state.candidate)
|
|
216
|
+
return [{ role: :user, content: }] unless state.config[:evaluator_context] == :inject_state
|
|
131
217
|
|
|
132
218
|
prior = Array(state.prepared_input).dup
|
|
133
|
-
prior.push(role: :user, content:
|
|
219
|
+
prior.push(role: :user, content:)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A structured candidate (an output_schema agent's Hash or Array) gets
|
|
223
|
+
# the JSON serialisation the provider boundary applies; text stays as is.
|
|
224
|
+
def candidate_content(candidate)
|
|
225
|
+
candidate.is_a?(Hash) || candidate.is_a?(Array) ? json_message_content(candidate) : candidate.to_s
|
|
134
226
|
end
|
|
135
227
|
|
|
136
228
|
# Runs after candidate generation, before evaluator invocation.
|
|
137
|
-
# Receives (state, @context); @context is mutable.
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
229
|
+
# Receives (state, @context); @context is mutable. A returned Hash
|
|
230
|
+
# whose accept (Symbol or String key) is false is the round's
|
|
231
|
+
# evaluation, validated like evaluator output, and the evaluator is
|
|
232
|
+
# not called; any other return value is discarded. Raised exceptions
|
|
233
|
+
# bubble through the standard step failure path.
|
|
234
|
+
def before_eval_rejection(state)
|
|
141
235
|
callback = state.config[:before_eval]
|
|
142
236
|
return unless callback
|
|
143
237
|
|
|
144
|
-
callback.call(state, @context)
|
|
238
|
+
verdict = callback.call(state, @context)
|
|
239
|
+
return unless verdict.is_a?(Hash)
|
|
240
|
+
|
|
241
|
+
evaluation = normalize_evaluation(verdict)
|
|
242
|
+
evaluation if evaluation.key?(:accept) && evaluation[:accept].equal?(false)
|
|
145
243
|
end
|
|
146
244
|
|
|
147
245
|
def invoke_with_evaluator_schema(evaluator_class, schema, input)
|
|
@@ -195,7 +293,7 @@ module Smith
|
|
|
195
293
|
return prepared_input if round.zero?
|
|
196
294
|
|
|
197
295
|
(prepared_input&.dup || []).push(
|
|
198
|
-
{ role: :assistant, content: prior_candidate
|
|
296
|
+
{ role: :assistant, content: candidate_content(prior_candidate) },
|
|
199
297
|
{
|
|
200
298
|
role: :user,
|
|
201
299
|
content: "[smith:refinement-round] #{round + 1}\n[smith:evaluator-feedback]\n#{feedback}"
|
|
@@ -34,7 +34,9 @@ module Smith
|
|
|
34
34
|
# Reset the per-step agent-attribution carrier so only a serial agent
|
|
35
35
|
# step that actually runs (below) leaves model/provider for complete_step;
|
|
36
36
|
# a deterministic step never sets it and must not inherit a stale value.
|
|
37
|
+
# The optimizer's verdict carrier resets for the same reason.
|
|
37
38
|
@pending_agent_execution = nil
|
|
39
|
+
@pending_evaluations = nil
|
|
38
40
|
output = with_scoped_artifacts { run_with_retry_policy(transition) }
|
|
39
41
|
StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
|
|
40
42
|
end
|
|
@@ -18,6 +18,9 @@ module Smith
|
|
|
18
18
|
"bounded_completion_error" => ->(record) { Smith::BoundedCompletionError.new(record[:error_message]) },
|
|
19
19
|
"persisted_failure_invalid" => ->(record) { Smith::PersistedFailureInvalid.new(record[:error_message]) },
|
|
20
20
|
"deadline_exceeded" => ->(record) { Smith::DeadlineExceeded.new(record[:error_message]) },
|
|
21
|
+
"provider_permanent_failure" => ->(record) { provider_permanent_failure(record) },
|
|
22
|
+
"budget_exceeded" => ->(record) { Smith::BudgetExceeded.new(record[:error_message]) },
|
|
23
|
+
"guardrail_failed" => ->(record) { Smith::GuardrailFailed.new(record[:error_message]) },
|
|
21
24
|
"agent_error" => ->(record) { Smith::AgentError.new(record[:error_message]) },
|
|
22
25
|
"workflow_error" => ->(record) { Smith::WorkflowError.new(record[:error_message]) },
|
|
23
26
|
"other" => ->(record) { RuntimeError.new(record[:error_message]) }
|
|
@@ -25,7 +28,8 @@ module Smith
|
|
|
25
28
|
SPECIAL_CLASS_BUILDERS = {
|
|
26
29
|
"Smith::Workflow::Composite::BranchFailure" => lambda { |record|
|
|
27
30
|
Smith::Workflow::Composite::BranchFailure.from_details(record[:error_details])
|
|
28
|
-
}
|
|
31
|
+
},
|
|
32
|
+
"Smith::BlankAgentOutputError" => ->(record) { blank_agent_output_failure(record) }
|
|
29
33
|
}.freeze
|
|
30
34
|
private_constant :FAMILY_BUILDERS, :SPECIAL_CLASS_BUILDERS
|
|
31
35
|
|
|
@@ -73,7 +77,19 @@ module Smith
|
|
|
73
77
|
Smith::ToolFailureNotificationFailed.from_details(record.fetch(:error_details))
|
|
74
78
|
end
|
|
75
79
|
|
|
76
|
-
|
|
80
|
+
def self.provider_permanent_failure(record)
|
|
81
|
+
Smith::ProviderPermanentFailure.from_details(record.fetch(:error_details), message: record[:error_message])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.blank_agent_output_failure(record)
|
|
85
|
+
details = record[:error_details]
|
|
86
|
+
return Smith::AgentError.new(record[:error_message]) if details.nil?
|
|
87
|
+
|
|
88
|
+
Smith::BlankAgentOutputError.from_details(details)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private_class_method :deterministic_step_failure, :tool_guardrail_failure, :tool_failure_notification_failure,
|
|
92
|
+
:provider_permanent_failure, :blank_agent_output_failure
|
|
77
93
|
end
|
|
78
94
|
end
|
|
79
95
|
end
|
|
@@ -20,6 +20,9 @@ module Smith
|
|
|
20
20
|
[Smith::PersistedFailureInvalid, "persisted_failure_invalid"],
|
|
21
21
|
[Smith::ToolGuardrailFailed, "tool_guardrail_failed"],
|
|
22
22
|
[Smith::DeadlineExceeded, "deadline_exceeded"],
|
|
23
|
+
[Smith::ProviderPermanentFailure, "provider_permanent_failure"],
|
|
24
|
+
[Smith::BudgetExceeded, "budget_exceeded"],
|
|
25
|
+
[Smith::GuardrailFailed, "guardrail_failed"],
|
|
23
26
|
[Smith::AgentError, "agent_error"],
|
|
24
27
|
[Smith::WorkflowError, "workflow_error"]
|
|
25
28
|
].freeze
|
|
@@ -27,10 +30,7 @@ module Smith
|
|
|
27
30
|
EXCEPTION_CAUSE = Exception.instance_method(:cause)
|
|
28
31
|
EXCEPTION_MESSAGE = Exception.instance_method(:message)
|
|
29
32
|
MODULE_MATCH = Module.instance_method(:===)
|
|
30
|
-
|
|
31
|
-
OBJECT_CLASS = Object.instance_method(:class)
|
|
32
|
-
private_constant :ERROR_FAMILIES, :BOOLEAN_VALUES, :EXCEPTION_CAUSE, :EXCEPTION_MESSAGE, :MODULE_MATCH,
|
|
33
|
-
:MODULE_NAME, :OBJECT_CLASS
|
|
33
|
+
private_constant :ERROR_FAMILIES, :BOOLEAN_VALUES, :EXCEPTION_CAUSE, :EXCEPTION_MESSAGE, :MODULE_MATCH
|
|
34
34
|
|
|
35
35
|
extend Dry::Initializer
|
|
36
36
|
|
|
@@ -52,7 +52,7 @@ module Smith
|
|
|
52
52
|
|
|
53
53
|
def error_attributes(error)
|
|
54
54
|
{
|
|
55
|
-
error_class: DiagnosticText.
|
|
55
|
+
error_class: DiagnosticText.error_class_name(error),
|
|
56
56
|
error_family: error_family(error),
|
|
57
57
|
error_message: error_message(error),
|
|
58
58
|
error_retryable: retryable_value(error),
|
|
@@ -70,7 +70,7 @@ module Smith
|
|
|
70
70
|
def cause_attributes(error)
|
|
71
71
|
cause = uncertainty_cause(error)
|
|
72
72
|
{
|
|
73
|
-
error_cause_class: cause && DiagnosticText.
|
|
73
|
+
error_cause_class: cause && DiagnosticText.error_class_name(cause),
|
|
74
74
|
error_cause_family: cause && error_family(cause),
|
|
75
75
|
error_cause_message: cause && error_message(cause)
|
|
76
76
|
}
|
|
@@ -89,11 +89,6 @@ module Smith
|
|
|
89
89
|
ERROR_FAMILIES.find { |error_class, _| MODULE_MATCH.bind_call(error_class, error) }&.last || "other"
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
def error_class_name(error)
|
|
93
|
-
error_class = OBJECT_CLASS.bind_call(error)
|
|
94
|
-
MODULE_NAME.bind_call(error_class) || "anonymous_error"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
92
|
# Never captures blank text: restore requires non-empty messages, so
|
|
98
93
|
# a blank live message takes the shared deterministic placeholder.
|
|
99
94
|
def error_message(error)
|
|
@@ -20,14 +20,25 @@ module Smith
|
|
|
20
20
|
"Smith::AgentError" => "agent_error",
|
|
21
21
|
"Smith::BlankAgentOutputError" => "agent_error",
|
|
22
22
|
"Smith::DeadlineExceeded" => "deadline_exceeded",
|
|
23
|
+
"Smith::ProviderPermanentFailure" => "provider_permanent_failure",
|
|
24
|
+
"Smith::BudgetExceeded" => "budget_exceeded",
|
|
25
|
+
"Smith::GuardrailFailed" => "guardrail_failed",
|
|
23
26
|
"Smith::WorkflowError" => "workflow_error",
|
|
24
27
|
"Smith::UnresolvedTransitionError" => "workflow_error",
|
|
25
28
|
"Smith::Workflow::Composite::BranchFailure" => "workflow_error"
|
|
26
29
|
}.freeze
|
|
30
|
+
# Records written before these classes had their own families carry
|
|
31
|
+
# "other" and no details; they keep restoring exactly as they did.
|
|
32
|
+
LEGACY_ERROR_FAMILIES = {
|
|
33
|
+
"Smith::ProviderPermanentFailure" => "other",
|
|
34
|
+
"Smith::BudgetExceeded" => "other",
|
|
35
|
+
"Smith::GuardrailFailed" => "other"
|
|
36
|
+
}.freeze
|
|
27
37
|
KNOWN_FAMILIES = %w[
|
|
28
38
|
deterministic_step_failure tool_guardrail_failed tool_failure_notification_failed tool_capture_failed
|
|
29
39
|
tool_outcome_uncertain tool_execution_not_admitted bounded_completion_error persisted_failure_invalid
|
|
30
|
-
deadline_exceeded agent_error workflow_error
|
|
40
|
+
deadline_exceeded provider_permanent_failure budget_exceeded guardrail_failed agent_error workflow_error
|
|
41
|
+
other
|
|
31
42
|
].freeze
|
|
32
43
|
RETRY_FORBIDDEN_FAMILIES = %w[
|
|
33
44
|
tool_capture_failed tool_failure_notification_failed tool_outcome_uncertain tool_execution_not_admitted
|
|
@@ -40,11 +51,15 @@ module Smith
|
|
|
40
51
|
},
|
|
41
52
|
"Smith::Workflow::Composite::BranchFailure" => lambda { |details|
|
|
42
53
|
Smith::Workflow::Composite::BranchFailure.from_details(details)
|
|
54
|
+
},
|
|
55
|
+
"Smith::ProviderPermanentFailure" => ->(details) { Smith::ProviderPermanentFailure.from_details(details) },
|
|
56
|
+
"Smith::BlankAgentOutputError" => lambda { |details|
|
|
57
|
+
Smith::BlankAgentOutputError.from_details(details) unless details.nil?
|
|
43
58
|
}
|
|
44
59
|
}.freeze
|
|
45
60
|
BOOLEAN_VALUES = [true, false].freeze
|
|
46
|
-
private_constant :KNOWN_ERROR_FAMILIES, :
|
|
47
|
-
:BOOLEAN_VALUES
|
|
61
|
+
private_constant :KNOWN_ERROR_FAMILIES, :LEGACY_ERROR_FAMILIES, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES,
|
|
62
|
+
:DETAIL_VALIDATORS, :BOOLEAN_VALUES
|
|
48
63
|
|
|
49
64
|
extend Dry::Initializer
|
|
50
65
|
|
|
@@ -74,10 +89,13 @@ module Smith
|
|
|
74
89
|
def validate_class_family!
|
|
75
90
|
expected = KNOWN_ERROR_FAMILIES[snapshot[:error_class]]
|
|
76
91
|
return unless expected && family != expected
|
|
92
|
+
return if legacy_family?
|
|
77
93
|
|
|
78
94
|
reject!("persisted workflow failure class and family disagree")
|
|
79
95
|
end
|
|
80
96
|
|
|
97
|
+
def legacy_family? = LEGACY_ERROR_FAMILIES[snapshot[:error_class]] == family
|
|
98
|
+
|
|
81
99
|
def validate_retry_policy!
|
|
82
100
|
forbidden = snapshot[:error_retry_forbidden]
|
|
83
101
|
return if forbidden.nil?
|
|
@@ -103,6 +121,8 @@ module Smith
|
|
|
103
121
|
end
|
|
104
122
|
|
|
105
123
|
def validate_details!
|
|
124
|
+
return if legacy_family?
|
|
125
|
+
|
|
106
126
|
validator = DETAIL_VALIDATORS[snapshot[:error_class]]
|
|
107
127
|
validator&.call(snapshot[:error_details])
|
|
108
128
|
end
|
|
@@ -24,6 +24,8 @@ module Smith
|
|
|
24
24
|
|
|
25
25
|
def handle_step_failure(transition, error)
|
|
26
26
|
step = { transition: transition.name, from: transition.from, to: transition.to, error: error }
|
|
27
|
+
fold_pending_evaluations(step)
|
|
28
|
+
@pending_evaluations = nil
|
|
27
29
|
# Staged, not emitted: this rescue runs under the step snapshot's
|
|
28
30
|
# interrupt mask, and host StepFailed handlers must not execute
|
|
29
31
|
# unkillable. with_step_context flushes after the mask closes.
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
OptimizationState = Struct.new(
|
|
6
|
-
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
|
|
6
|
+
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class, :evaluations
|
|
7
7
|
) do
|
|
8
8
|
def initialize(config, prepared_input)
|
|
9
|
-
super(config, prepared_input, nil, nil, nil, nil, nil)
|
|
9
|
+
super(config, prepared_input, nil, nil, nil, nil, nil, [].freeze)
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -100,11 +100,12 @@ module Smith
|
|
|
100
100
|
@usage_entries = restore_usage_entries(normalized)
|
|
101
101
|
@last_output = restore_last_output(normalized)
|
|
102
102
|
# Backward-compat: pre-patch states have no last_agent_execution key and
|
|
103
|
-
# restore to nil. @pending_agent_execution
|
|
104
|
-
# steps), never persisted, but must be
|
|
105
|
-
# allocates and bypasses #initialize.
|
|
103
|
+
# restore to nil. @pending_agent_execution and @pending_evaluations are
|
|
104
|
+
# transient (nil between steps), never persisted, but must be
|
|
105
|
+
# initialized because from_state allocates and bypasses #initialize.
|
|
106
106
|
@last_agent_execution = restore_last_agent_execution(normalized)
|
|
107
107
|
@pending_agent_execution = nil
|
|
108
|
+
@pending_evaluations = nil
|
|
108
109
|
@last_failed_step = restore_last_failed_step(normalized)
|
|
109
110
|
# Restore the optimistic-locking version from the persisted payload.
|
|
110
111
|
# Backward-compat: pre-versioning payloads have no key, restore to 0
|
|
@@ -160,16 +161,17 @@ module Smith
|
|
|
160
161
|
|
|
161
162
|
raise Smith::StepInProgressOnRestore.new(
|
|
162
163
|
workflow: self.class.name,
|
|
163
|
-
persistence_key: normalized[:persistence_key]
|
|
164
|
+
persistence_key: normalized[:persistence_key],
|
|
165
|
+
state: normalized[:state],
|
|
166
|
+
transition: normalized[:next_transition_name]
|
|
164
167
|
)
|
|
165
168
|
end
|
|
166
169
|
|
|
167
170
|
def validate_raw_step_in_progress!(state)
|
|
168
171
|
return unless state.is_a?(Hash)
|
|
169
172
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
validate_step_in_progress!(step_in_progress: marker, persistence_key:)
|
|
173
|
+
fields = %i[step_in_progress persistence_key state next_transition_name]
|
|
174
|
+
validate_step_in_progress!(fields.to_h { |field| [field, state[field] || state[field.to_s]] })
|
|
173
175
|
end
|
|
174
176
|
|
|
175
177
|
def validate_seed_digest!(normalized)
|
|
@@ -233,7 +235,9 @@ module Smith
|
|
|
233
235
|
end
|
|
234
236
|
return unless value.is_a?(Hash)
|
|
235
237
|
|
|
236
|
-
symbolize_keys(value)
|
|
238
|
+
execution = symbolize_keys(value)
|
|
239
|
+
execution[:provider] = execution[:provider].to_sym if execution[:provider].is_a?(String)
|
|
240
|
+
execution
|
|
237
241
|
end
|
|
238
242
|
|
|
239
243
|
# Symbolize ONLY the top-level keys of last_failed_step + the
|
|
@@ -430,7 +434,20 @@ module Smith
|
|
|
430
434
|
config = self.class.budget
|
|
431
435
|
return nil unless config
|
|
432
436
|
|
|
433
|
-
Budget::Ledger.new(limits: config, consumed: consumed)
|
|
437
|
+
Budget::Ledger.new(limits: config, consumed: declared_budget_consumed(consumed, config))
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A dimension the workflow no longer declares has no limit, so its
|
|
441
|
+
# consumed entry (a key an earlier version accepted and ignored) is
|
|
442
|
+
# dropped rather than refused.
|
|
443
|
+
def declared_budget_consumed(consumed, config)
|
|
444
|
+
return consumed unless consumed.is_a?(Hash)
|
|
445
|
+
|
|
446
|
+
declared = config.each_key.to_h { |dimension| [dimension.to_s, dimension] }
|
|
447
|
+
consumed.each_with_object({}) do |(key, amount), kept|
|
|
448
|
+
dimension = declared[key.to_s]
|
|
449
|
+
kept[dimension] = amount if dimension
|
|
450
|
+
end
|
|
434
451
|
end
|
|
435
452
|
|
|
436
453
|
def persisted_context
|
|
@@ -5,14 +5,18 @@ module Smith
|
|
|
5
5
|
module RetryExecution
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
# `@step_attempt` is the attempt under way, from 1, which the optimizer
|
|
9
|
+
# names its verdicts by; transient, set here before every attempt.
|
|
8
10
|
def run_with_retry_policy(transition)
|
|
9
11
|
config = transition.retry_config
|
|
12
|
+
@step_attempt = 1
|
|
10
13
|
return run_guarded_step(transition) unless config
|
|
11
14
|
|
|
12
15
|
schedule = retry_schedule(config)
|
|
13
16
|
attempt = 0
|
|
14
17
|
begin
|
|
15
18
|
attempt += 1
|
|
19
|
+
@step_attempt = attempt
|
|
16
20
|
run_guarded_step(transition)
|
|
17
21
|
rescue StandardError => e
|
|
18
22
|
raise unless retry_transition_error?(config, e, attempt)
|
|
@@ -33,6 +33,16 @@ module Smith
|
|
|
33
33
|
steps.reverse.map { |step| step[:error] }.compact.first
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Every verdict an optimize step's loops recorded, in step, attempt and
|
|
37
|
+
# round order, each a frozen record naming its step's transition. Like
|
|
38
|
+
# `steps`, they belong to the run that executed the step: a terminal
|
|
39
|
+
# restore has none.
|
|
40
|
+
def evaluations
|
|
41
|
+
steps.flat_map do |step|
|
|
42
|
+
(step[:evaluations] || []).map { |evaluation| { transition: step[:transition], **evaluation }.freeze }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
def failed_transition
|
|
37
47
|
failure_detail&.fetch(:transition)
|
|
38
48
|
end
|
|
@@ -16,6 +16,7 @@ module Smith
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
@pending_agent_execution = nil
|
|
19
|
+
fold_pending_evaluations(step)
|
|
19
20
|
result = SplitStepPersistence
|
|
20
21
|
.instance_method(:prepare_split_step_execution_result)
|
|
21
22
|
.bind_call(self, step)
|
|
@@ -23,6 +24,9 @@ module Smith
|
|
|
23
24
|
SplitStepPersistence
|
|
24
25
|
.instance_method(:commit_split_step_execution_result!)
|
|
25
26
|
.bind_call(self, result)
|
|
27
|
+
# Cleared only once the record is committed: a completion that fails
|
|
28
|
+
# before this folds the same verdicts into its failure record.
|
|
29
|
+
@pending_evaluations = nil
|
|
26
30
|
@state = transition.to
|
|
27
31
|
@next_transition_name = @router_next_transition || transition.success_transition
|
|
28
32
|
@router_next_transition = nil
|
data/lib/smith/workflow.rb
CHANGED
|
@@ -44,7 +44,7 @@ module Smith
|
|
|
44
44
|
@step_count = 0
|
|
45
45
|
@next_transition_name = nil
|
|
46
46
|
@ledger = ledger || build_ledger
|
|
47
|
-
@created_at = created_at || Time.now.utc.iso8601
|
|
47
|
+
@created_at = created_at || Time.now.utc.iso8601(6)
|
|
48
48
|
@updated_at = @created_at
|
|
49
49
|
@total_cost = 0.0
|
|
50
50
|
@total_tokens = 0
|
|
@@ -63,9 +63,11 @@ module Smith
|
|
|
63
63
|
# fallback resolution), exposed to the following deterministic step as
|
|
64
64
|
# `last_agent_model` / `last_agent_provider`. `@pending_agent_execution`
|
|
65
65
|
# is the transient per-step carrier from `execute_serial_step` to
|
|
66
|
-
# `complete_step`; it is never persisted.
|
|
66
|
+
# `complete_step`; it is never persisted. `@pending_evaluations` is the
|
|
67
|
+
# optimizer's, carrying a loop's verdicts into its step's record.
|
|
67
68
|
@last_agent_execution = nil
|
|
68
69
|
@pending_agent_execution = nil
|
|
70
|
+
@pending_evaluations = nil
|
|
69
71
|
@last_failed_step = nil
|
|
70
72
|
# Optimistic-locking version. Incremented on each persist!; restored
|
|
71
73
|
# from the persisted payload. Adapters that support store_versioned
|
|
@@ -120,7 +122,7 @@ module Smith
|
|
|
120
122
|
execute_step(transition)
|
|
121
123
|
end
|
|
122
124
|
@step_count += 1
|
|
123
|
-
@updated_at = Time.now.utc.iso8601
|
|
125
|
+
@updated_at = Time.now.utc.iso8601(6)
|
|
124
126
|
record_step_snapshot(step_result)
|
|
125
127
|
step_result
|
|
126
128
|
rescue UnresolvedTransitionError => e
|
data/lib/smith.rb
CHANGED
|
@@ -59,8 +59,8 @@ module Smith
|
|
|
59
59
|
|
|
60
60
|
# Persistence TTL in Integer seconds. nil (default) means workflows
|
|
61
61
|
# persist indefinitely. Adapters that natively support TTL (Redis,
|
|
62
|
-
# CacheStore, Memory) pass this through; ActiveRecordStore
|
|
63
|
-
#
|
|
62
|
+
# CacheStore, Memory) pass this through; ActiveRecordStore has no expiry
|
|
63
|
+
# column and refuses a non-nil TTL.
|
|
64
64
|
# Per-workflow `Workflow.persistence_ttl 1.day.to_i` DSL overrides this.
|
|
65
65
|
setting :persistence_ttl, default: nil
|
|
66
66
|
|