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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +26 -6
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  8. data/lib/smith/agent/fallback_configuration.rb +26 -3
  9. data/lib/smith/agent/invocation_preparation.rb +24 -0
  10. data/lib/smith/agent/lifecycle.rb +4 -2
  11. data/lib/smith/agent/provider_attempt.rb +17 -4
  12. data/lib/smith/agent/provider_call_timing.rb +129 -0
  13. data/lib/smith/agent/provider_completion.rb +26 -8
  14. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  15. data/lib/smith/agent/provider_usage.rb +6 -0
  16. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  17. data/lib/smith/agent/usage_traces.rb +49 -0
  18. data/lib/smith/agent/usage_tracking.rb +26 -20
  19. data/lib/smith/agent.rb +9 -1
  20. data/lib/smith/attribution.rb +115 -0
  21. data/lib/smith/budget/amount_representation.rb +20 -0
  22. data/lib/smith/budget/ledger.rb +6 -2
  23. data/lib/smith/context/state_injection.rb +12 -2
  24. data/lib/smith/diagnostic_text.rb +10 -1
  25. data/lib/smith/doctor/checks/durability.rb +16 -0
  26. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  27. data/lib/smith/errors.rb +55 -4
  28. data/lib/smith/event.rb +10 -2
  29. data/lib/smith/events/bus.rb +63 -5
  30. data/lib/smith/events/step_completed.rb +3 -0
  31. data/lib/smith/events/step_failed.rb +25 -0
  32. data/lib/smith/events/subscription.rb +8 -0
  33. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  34. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  35. data/lib/smith/persistence_adapters.rb +10 -0
  36. data/lib/smith/provider_permanent_failure.rb +44 -0
  37. data/lib/smith/tool/capture.rb +9 -1
  38. data/lib/smith/tool.rb +10 -1
  39. data/lib/smith/trace/logger.rb +1 -0
  40. data/lib/smith/trace/memory.rb +40 -4
  41. data/lib/smith/trace/open_telemetry.rb +47 -3
  42. data/lib/smith/trace.rb +24 -5
  43. data/lib/smith/version.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +3 -3
  45. data/lib/smith/workflow/composite/effects.rb +64 -33
  46. data/lib/smith/workflow/deterministic_execution.rb +3 -2
  47. data/lib/smith/workflow/deterministic_step.rb +15 -2
  48. data/lib/smith/workflow/dsl.rb +40 -6
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
  51. data/lib/smith/workflow/event_integration.rb +48 -1
  52. data/lib/smith/workflow/execution.rb +10 -0
  53. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  54. data/lib/smith/workflow/failure_record.rb +6 -11
  55. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  56. data/lib/smith/workflow/fanout_execution.rb +1 -1
  57. data/lib/smith/workflow/guardrail_integration.rb +27 -0
  58. data/lib/smith/workflow/optimization_state.rb +2 -2
  59. data/lib/smith/workflow/parallel_execution.rb +6 -0
  60. data/lib/smith/workflow/persistence.rb +55 -6
  61. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  62. data/lib/smith/workflow/retry_execution.rb +4 -0
  63. data/lib/smith/workflow/run_result.rb +10 -0
  64. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  65. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  66. data/lib/smith/workflow/step_completion.rb +13 -0
  67. data/lib/smith/workflow/step_context.rb +46 -0
  68. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  69. data/lib/smith/workflow/usage_entry.rb +32 -4
  70. data/lib/smith/workflow.rb +30 -3
  71. data/lib/smith.rb +9 -4
  72. 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
- result = run_optimization_round(state, round)
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
- invoke_before_eval(state)
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
- return [{ role: :user, content: state.candidate.to_s }] unless state.config[:evaluator_context] == :inject_state
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: state.candidate.to_s)
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. Return value
136
- # is discarded. Raised exceptions bubble through the standard
137
- # step failure path.
138
- def invoke_before_eval(state)
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.to_s },
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
- private_class_method :deterministic_step_failure, :tool_guardrail_failure, :tool_failure_notification_failure
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
- MODULE_NAME = Module.instance_method(:name)
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.capture(error_class_name(error), max_bytes: 512),
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.capture(error_class_name(cause), max_bytes: 512),
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 other
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, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES, :DETAIL_VALIDATORS,
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((@usage_entries || []).map(&:to_h)),
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
- marker = state[:step_in_progress] || state["step_in_progress"]
161
- persistence_key = state[:persistence_key] || state["persistence_key"]
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
- Tool::ScopedContext.around(tool_context) do
13
- __send__(implementation.name, *arguments, signal)
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
- Tool::ScopedContext.around(tool_context) do
21
- PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
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)