smith-agents 0.8.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 +433 -0
- data/README.md +16 -6
- data/docs/CONFIGURATION.md +26 -6
- data/docs/PATTERNS.md +59 -0
- data/docs/PERSISTENCE.md +5 -1
- data/lib/smith/agent/completion_usage_recording.rb +21 -3
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +24 -0
- data/lib/smith/agent/lifecycle.rb +4 -2
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +129 -0
- data/lib/smith/agent/provider_completion.rb +26 -8
- data/lib/smith/agent/provider_failure_handling.rb +2 -2
- data/lib/smith/agent/provider_usage.rb +6 -0
- data/lib/smith/agent/usage_entry_recording.rb +27 -4
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +26 -20
- data/lib/smith/agent.rb +9 -1
- data/lib/smith/attribution.rb +115 -0
- 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/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- 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/tool/capture.rb +9 -1
- data/lib/smith/tool.rb +10 -1
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +3 -3
- data/lib/smith/workflow/composite/effects.rb +64 -33
- data/lib/smith/workflow/deterministic_execution.rb +3 -2
- data/lib/smith/workflow/deterministic_step.rb +15 -2
- data/lib/smith/workflow/dsl.rb +40 -6
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +10 -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/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guardrail_integration.rb +27 -0
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +55 -6
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/retry_execution.rb +4 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +13 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/usage_entry.rb +32 -4
- data/lib/smith/workflow.rb +30 -3
- data/lib/smith.rb +9 -4
- metadata +5 -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,12 +25,15 @@ 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
|
|
|
30
32
|
def run_optimization_loop(state)
|
|
31
33
|
state.config[:max_rounds].times do |round|
|
|
32
|
-
|
|
34
|
+
# The overlay scopes every trace and usage fact from this round's
|
|
35
|
+
# generator and evaluator calls to the round that produced them.
|
|
36
|
+
result = Attribution.with(round: round) { run_optimization_round(state, round) }
|
|
33
37
|
return result if result
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -39,10 +43,11 @@ module Smith
|
|
|
39
43
|
|
|
40
44
|
def run_optimization_round(state, round)
|
|
41
45
|
generate_candidate!(state, round)
|
|
42
|
-
|
|
43
|
-
evaluation = normalize_evaluation(evaluate_candidate(state))
|
|
46
|
+
rejection = before_eval_rejection(state)
|
|
47
|
+
evaluation = rejection || normalize_evaluation(evaluate_candidate(state))
|
|
44
48
|
validate_evaluation_structure!(evaluation)
|
|
45
49
|
validate_evaluation_fields!(evaluation, state.config)
|
|
50
|
+
record_evaluation(state, evaluation, round, rejection ? :before_eval : :evaluator)
|
|
46
51
|
|
|
47
52
|
return state.candidate if evaluation[:accept]
|
|
48
53
|
|
|
@@ -59,6 +64,88 @@ module Smith
|
|
|
59
64
|
nil
|
|
60
65
|
end
|
|
61
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
|
+
|
|
62
149
|
# :raise => WorkflowError(message); :return_last => state.candidate;
|
|
63
150
|
# callable => mode.call(state). Default :raise preserves legacy
|
|
64
151
|
# behavior for hosts that don't opt in to graceful exits.
|
|
@@ -125,21 +212,34 @@ module Smith
|
|
|
125
212
|
# evaluator sees the same seed_messages + inject_state context.
|
|
126
213
|
# Default nil keeps the legacy candidate-only payload.
|
|
127
214
|
def build_evaluator_input(state)
|
|
128
|
-
|
|
215
|
+
content = candidate_content(state.candidate)
|
|
216
|
+
return [{ role: :user, content: }] unless state.config[:evaluator_context] == :inject_state
|
|
129
217
|
|
|
130
218
|
prior = Array(state.prepared_input).dup
|
|
131
|
-
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
|
|
132
226
|
end
|
|
133
227
|
|
|
134
228
|
# Runs after candidate generation, before evaluator invocation.
|
|
135
|
-
# Receives (state, @context); @context is mutable.
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
|
|
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)
|
|
139
235
|
callback = state.config[:before_eval]
|
|
140
236
|
return unless callback
|
|
141
237
|
|
|
142
|
-
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)
|
|
143
243
|
end
|
|
144
244
|
|
|
145
245
|
def invoke_with_evaluator_schema(evaluator_class, schema, input)
|
|
@@ -193,7 +293,7 @@ module Smith
|
|
|
193
293
|
return prepared_input if round.zero?
|
|
194
294
|
|
|
195
295
|
(prepared_input&.dup || []).push(
|
|
196
|
-
{ role: :assistant, content: prior_candidate
|
|
296
|
+
{ role: :assistant, content: candidate_content(prior_candidate) },
|
|
197
297
|
{
|
|
198
298
|
role: :user,
|
|
199
299
|
content: "[smith:refinement-round] #{round + 1}\n[smith:evaluator-feedback]\n#{feedback}"
|
|
@@ -15,7 +15,54 @@ module Smith
|
|
|
15
15
|
Events::StepCompleted.new(
|
|
16
16
|
transition: transition.name.to_sym,
|
|
17
17
|
from: transition.from&.to_sym,
|
|
18
|
-
to: transition.to.to_sym
|
|
18
|
+
to: transition.to.to_sym,
|
|
19
|
+
workflow: Attribution.ambient.workflow
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Failure-path counterpart to emit_step_completed, fired from both
|
|
25
|
+
# failure handlers before they branch or re-raise, so an observer sees
|
|
26
|
+
# exactly where execution went dark. Classification reuses
|
|
27
|
+
# FailureRecord's bounded taxonomy; raw messages are never emitted. The
|
|
28
|
+
# rescue keeps a broken instrument from altering failure semantics:
|
|
29
|
+
# the original error, not an emission error, must win.
|
|
30
|
+
def emit_step_failed(step)
|
|
31
|
+
failure = FailureRecord.capture(step)
|
|
32
|
+
record_failed_transition_trace(failure)
|
|
33
|
+
emit_step_failed_event(failure)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Smith.config.logger&.error("Smith failed-step emission error: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `outcome`, not `result`: `result` is a reserved content key in the
|
|
39
|
+
# trace pipeline (it carries tool results and is stripped by the
|
|
40
|
+
# default content policy). Matches the :provider_call vocabulary.
|
|
41
|
+
# `from`/`to` stay present even when nil, matching the success-trace
|
|
42
|
+
# shape: an absent key would let the ambient attribution of an
|
|
43
|
+
# enclosing scope (a parent step's `from`) show through the
|
|
44
|
+
# fields-under-data merge and fabricate a foreign state fact.
|
|
45
|
+
def record_failed_transition_trace(failure)
|
|
46
|
+
data = {
|
|
47
|
+
transition: failure[:transition], from: failure[:from], to: failure[:to],
|
|
48
|
+
outcome: :failed,
|
|
49
|
+
error_class: failure[:error_class],
|
|
50
|
+
error_family: failure[:error_family]
|
|
51
|
+
}
|
|
52
|
+
data[:retryable] = failure[:error_retryable] unless failure[:error_retryable].nil?
|
|
53
|
+
Smith::Trace.record(type: :transition, data: data)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def emit_step_failed_event(failure)
|
|
57
|
+
Smith::Events.emit(
|
|
58
|
+
Events::StepFailed.new(
|
|
59
|
+
transition: DiagnosticText.capture(failure[:transition].to_s, max_bytes: 256).to_sym,
|
|
60
|
+
from: failure[:from]&.to_sym,
|
|
61
|
+
to: failure[:to]&.to_sym,
|
|
62
|
+
error_class: failure[:error_class],
|
|
63
|
+
error_family: failure[:error_family],
|
|
64
|
+
retryable: failure[:error_retryable],
|
|
65
|
+
workflow: Attribution.ambient.workflow
|
|
19
66
|
)
|
|
20
67
|
)
|
|
21
68
|
end
|
|
@@ -31,6 +31,12 @@ module Smith
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def execute_step_body(transition)
|
|
34
|
+
# Reset the per-step agent-attribution carrier so only a serial agent
|
|
35
|
+
# step that actually runs (below) leaves model/provider for complete_step;
|
|
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.
|
|
38
|
+
@pending_agent_execution = nil
|
|
39
|
+
@pending_evaluations = nil
|
|
34
40
|
output = with_scoped_artifacts { run_with_retry_policy(transition) }
|
|
35
41
|
StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
|
|
36
42
|
end
|
|
@@ -93,6 +99,10 @@ module Smith
|
|
|
93
99
|
begin
|
|
94
100
|
result = execute_transition_body(transition, prepared_input: prepared_input)
|
|
95
101
|
agent_result = result.is_a?(AgentResult) ? result : nil
|
|
102
|
+
# Capture the model/provider that actually served this serial agent
|
|
103
|
+
# step (post fallback resolution) for complete_step to fold into the
|
|
104
|
+
# durable step record. Nil when no agent ran (e.g. unconfigured model).
|
|
105
|
+
@pending_agent_execution = agent_result && { model: agent_result.model_used, provider: agent_result.provider_used }
|
|
96
106
|
reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
|
|
97
107
|
reserved = nil
|
|
98
108
|
agent_result ? agent_result.content : result
|
|
@@ -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
|
|
@@ -51,7 +51,7 @@ module Smith
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def run_fanout_branch(branch_key, agent_name, agent_class, env, signal)
|
|
54
|
-
with_branch_context(env, @ledger, agent_class:) do
|
|
54
|
+
with_branch_context(env, @ledger, agent_class:, attribution_overlay: { branch_key: }) do
|
|
55
55
|
with_agent_context(agent_class) do
|
|
56
56
|
branch_ledger = effective_call_ledger
|
|
57
57
|
reserved = reserve_fanout_branch_call(branch_ledger, env.branch_estimates[branch_key], agent_class)
|
|
@@ -24,6 +24,14 @@ 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
|
|
29
|
+
# Staged, not emitted: this rescue runs under the step snapshot's
|
|
30
|
+
# interrupt mask, and host StepFailed handlers must not execute
|
|
31
|
+
# unkillable. with_step_context flushes after the mask closes.
|
|
32
|
+
# Staged before the split-step capture so a capture invariant
|
|
33
|
+
# failure still flushes an emission for the original error.
|
|
34
|
+
@pending_step_failure = step
|
|
27
35
|
SplitStepPersistence
|
|
28
36
|
.instance_method(:capture_split_step_execution_result!)
|
|
29
37
|
.bind_call(self, step)
|
|
@@ -50,9 +58,28 @@ module Smith
|
|
|
50
58
|
|
|
51
59
|
@outcome = nil
|
|
52
60
|
step = { transition: error.requested_name, from: @state, to: fail_transition.to, error: error }
|
|
61
|
+
|
|
62
|
+
# A step body that raised UnresolvedTransitionError was already
|
|
63
|
+
# captured, staged, and emitted under its real transition identity
|
|
64
|
+
# by the step-failure path before advance!'s rescue reached here.
|
|
65
|
+
# Capturing or emitting again would record the same failure twice,
|
|
66
|
+
# the second time under the requested name, a transition that never
|
|
67
|
+
# executed. Routing to :fail still happens either way.
|
|
68
|
+
if error.equal?(@emitted_step_failure_error)
|
|
69
|
+
@state = fail_transition.to
|
|
70
|
+
return step
|
|
71
|
+
end
|
|
72
|
+
|
|
53
73
|
SplitStepPersistence
|
|
54
74
|
.instance_method(:capture_split_step_execution_result!)
|
|
55
75
|
.bind_call(self, step)
|
|
76
|
+
# Unlike the step-body path, this handler runs outside any step
|
|
77
|
+
# context (advance! rescues UnresolvedTransitionError after the step
|
|
78
|
+
# unwound), so the run identity must be seeded here or the emitted
|
|
79
|
+
# facts get fallback random ids.
|
|
80
|
+
Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
|
|
81
|
+
emit_step_failed(step)
|
|
82
|
+
end
|
|
56
83
|
@state = fail_transition.to
|
|
57
84
|
step
|
|
58
85
|
end
|
|
@@ -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
|
|
@@ -60,6 +60,7 @@ module Smith
|
|
|
60
60
|
ledger,
|
|
61
61
|
parallel_agent_binding: NO_PARALLEL_BINDING,
|
|
62
62
|
agent_class: nil,
|
|
63
|
+
attribution_overlay: nil,
|
|
63
64
|
&block
|
|
64
65
|
)
|
|
65
66
|
snapshot = ThreadContextSnapshot.new
|
|
@@ -69,6 +70,11 @@ module Smith
|
|
|
69
70
|
else
|
|
70
71
|
setup_branch_context(env, ledger)
|
|
71
72
|
end
|
|
73
|
+
|
|
74
|
+
# Restoration is owned by the snapshot above, which tracks the
|
|
75
|
+
# attribution thread key.
|
|
76
|
+
Attribution.install(Attribution.ambient.merge(**attribution_overlay)) if attribution_overlay
|
|
77
|
+
|
|
72
78
|
unless parallel_agent_binding.equal?(NO_PARALLEL_BINDING)
|
|
73
79
|
Thread.current[:smith_parallel_agent_binding] = parallel_agent_binding
|
|
74
80
|
end
|
|
@@ -26,8 +26,12 @@ module Smith
|
|
|
26
26
|
# snapshot_value so non-JSON-safe runtime values (e.g.
|
|
27
27
|
# custom Hash details on DeterministicStepFailure) get the
|
|
28
28
|
# same deep-copy treatment as context/session_messages/etc.
|
|
29
|
-
usage_entries: snapshot_value(
|
|
29
|
+
usage_entries: snapshot_value(usage_entries_for_state.map(&:to_h)),
|
|
30
30
|
last_output: snapshot_value(@last_output),
|
|
31
|
+
# Durable { model:, provider: } of the most recent serial agent step,
|
|
32
|
+
# so a deterministic step that resumes after the agent step (across a
|
|
33
|
+
# crash) still reads its `last_agent_model` / `last_agent_provider`.
|
|
34
|
+
last_agent_execution: snapshot_value(@last_agent_execution),
|
|
31
35
|
last_failed_step: snapshot_value(@last_failed_step),
|
|
32
36
|
# Optimistic-locking version. Adapters that support
|
|
33
37
|
# store_versioned use this to detect concurrent writes; adapters
|
|
@@ -95,6 +99,13 @@ module Smith
|
|
|
95
99
|
@usage_mutex = Mutex.new
|
|
96
100
|
@usage_entries = restore_usage_entries(normalized)
|
|
97
101
|
@last_output = restore_last_output(normalized)
|
|
102
|
+
# Backward-compat: pre-patch states have no last_agent_execution key and
|
|
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
|
+
@last_agent_execution = restore_last_agent_execution(normalized)
|
|
107
|
+
@pending_agent_execution = nil
|
|
108
|
+
@pending_evaluations = nil
|
|
98
109
|
@last_failed_step = restore_last_failed_step(normalized)
|
|
99
110
|
# Restore the optimistic-locking version from the persisted payload.
|
|
100
111
|
# Backward-compat: pre-versioning payloads have no key, restore to 0
|
|
@@ -150,16 +161,17 @@ module Smith
|
|
|
150
161
|
|
|
151
162
|
raise Smith::StepInProgressOnRestore.new(
|
|
152
163
|
workflow: self.class.name,
|
|
153
|
-
persistence_key: normalized[:persistence_key]
|
|
164
|
+
persistence_key: normalized[:persistence_key],
|
|
165
|
+
state: normalized[:state],
|
|
166
|
+
transition: normalized[:next_transition_name]
|
|
154
167
|
)
|
|
155
168
|
end
|
|
156
169
|
|
|
157
170
|
def validate_raw_step_in_progress!(state)
|
|
158
171
|
return unless state.is_a?(Hash)
|
|
159
172
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
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]] })
|
|
163
175
|
end
|
|
164
176
|
|
|
165
177
|
def validate_seed_digest!(normalized)
|
|
@@ -192,6 +204,17 @@ module Smith
|
|
|
192
204
|
raw.map { |h| Workflow::UsageEntry.from_h(h) }
|
|
193
205
|
end
|
|
194
206
|
|
|
207
|
+
# Serialization reads the ledger under the recording mutex so a state
|
|
208
|
+
# written mid-fan-out never captures a torn array. The mutex can be
|
|
209
|
+
# absent on an allocated-but-unrestored instance; fall back to the
|
|
210
|
+
# bare read to_state always tolerated.
|
|
211
|
+
def usage_entries_for_state
|
|
212
|
+
mutex = @usage_mutex
|
|
213
|
+
return (@usage_entries || []).dup unless mutex
|
|
214
|
+
|
|
215
|
+
mutex.synchronize { (@usage_entries || []).dup }
|
|
216
|
+
end
|
|
217
|
+
|
|
195
218
|
# Use key-presence checks (NOT `||`) so a deliberately persisted
|
|
196
219
|
# `false` step output round-trips correctly. Smith's existing
|
|
197
220
|
# `RunResult#output` derivation uses `compact.first`, which only
|
|
@@ -204,6 +227,19 @@ module Smith
|
|
|
204
227
|
end
|
|
205
228
|
end
|
|
206
229
|
|
|
230
|
+
def restore_last_agent_execution(normalized)
|
|
231
|
+
value = if normalized.key?(:last_agent_execution)
|
|
232
|
+
normalized[:last_agent_execution]
|
|
233
|
+
elsif normalized.key?("last_agent_execution")
|
|
234
|
+
normalized["last_agent_execution"]
|
|
235
|
+
end
|
|
236
|
+
return unless value.is_a?(Hash)
|
|
237
|
+
|
|
238
|
+
execution = symbolize_keys(value)
|
|
239
|
+
execution[:provider] = execution[:provider].to_sym if execution[:provider].is_a?(String)
|
|
240
|
+
execution
|
|
241
|
+
end
|
|
242
|
+
|
|
207
243
|
# Symbolize ONLY the top-level keys of last_failed_step + the
|
|
208
244
|
# known value-symbols (`transition`, `from`, `to`, `error_kind`).
|
|
209
245
|
# `error_family` stays a String (the family_fallback compares
|
|
@@ -398,7 +434,20 @@ module Smith
|
|
|
398
434
|
config = self.class.budget
|
|
399
435
|
return nil unless config
|
|
400
436
|
|
|
401
|
-
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
|
|
402
451
|
end
|
|
403
452
|
|
|
404
453
|
def persisted_context
|
|
@@ -7,18 +7,27 @@ module Smith
|
|
|
7
7
|
|
|
8
8
|
def prepared_branch(implementation, *arguments)
|
|
9
9
|
tool_context = Tool::ScopedContext.capture
|
|
10
|
+
# Attribution is captured on the preparing thread (where the step's
|
|
11
|
+
# context is ambient) and carried into the branch thread, exactly as
|
|
12
|
+
# the tool context is; carrying nil deliberately clears stale state
|
|
13
|
+
# on a pooled thread.
|
|
14
|
+
ambient_attribution = Attribution.current
|
|
10
15
|
unless @split_step_active_execution_authorization
|
|
11
16
|
return proc do |signal|
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
Attribution.carrying(ambient_attribution) do
|
|
18
|
+
Tool::ScopedContext.around(tool_context) do
|
|
19
|
+
__send__(implementation.name, *arguments, signal)
|
|
20
|
+
end
|
|
14
21
|
end
|
|
15
22
|
end
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
proc do |signal|
|
|
19
26
|
run = proc { implementation.bind_call(self, *arguments, signal) }
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
Attribution.carrying(ambient_attribution) do
|
|
28
|
+
Tool::ScopedContext.around(tool_context) do
|
|
29
|
+
PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
|
|
30
|
+
end
|
|
22
31
|
end
|
|
23
32
|
end
|
|
24
33
|
end
|
|
@@ -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)
|