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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +203 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +21 -1
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/fallback_configuration.rb +26 -3
  8. data/lib/smith/agent/provider_attempt.rb +11 -4
  9. data/lib/smith/agent/provider_call_timing.rb +62 -7
  10. data/lib/smith/agent/provider_completion.rb +8 -8
  11. data/lib/smith/agent/provider_usage.rb +6 -0
  12. data/lib/smith/agent/usage_tracking.rb +3 -0
  13. data/lib/smith/agent.rb +9 -1
  14. data/lib/smith/budget/amount_representation.rb +20 -0
  15. data/lib/smith/budget/ledger.rb +6 -2
  16. data/lib/smith/context/state_injection.rb +12 -2
  17. data/lib/smith/diagnostic_text.rb +10 -1
  18. data/lib/smith/doctor/checks/durability.rb +16 -0
  19. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  20. data/lib/smith/errors.rb +55 -4
  21. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  22. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  23. data/lib/smith/persistence_adapters.rb +10 -0
  24. data/lib/smith/provider_permanent_failure.rb +44 -0
  25. data/lib/smith/version.rb +1 -1
  26. data/lib/smith/workflow/budget_integration.rb +3 -3
  27. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  28. data/lib/smith/workflow/deterministic_step.rb +1 -1
  29. data/lib/smith/workflow/dsl.rb +15 -5
  30. data/lib/smith/workflow/durability.rb +1 -1
  31. data/lib/smith/workflow/evaluator_optimizer.rb +108 -10
  32. data/lib/smith/workflow/execution.rb +2 -0
  33. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  34. data/lib/smith/workflow/failure_record.rb +6 -11
  35. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  36. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  37. data/lib/smith/workflow/optimization_state.rb +2 -2
  38. data/lib/smith/workflow/persistence.rb +26 -9
  39. data/lib/smith/workflow/retry_execution.rb +4 -0
  40. data/lib/smith/workflow/run_result.rb +10 -0
  41. data/lib/smith/workflow/step_completion.rb +4 -0
  42. data/lib/smith/workflow.rb +5 -3
  43. data/lib/smith.rb +2 -2
  44. 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
- invoke_before_eval(state)
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
- 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
131
217
 
132
218
  prior = Array(state.prepared_input).dup
133
- 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
134
226
  end
135
227
 
136
228
  # Runs after candidate generation, before evaluator invocation.
137
- # Receives (state, @context); @context is mutable. Return value
138
- # is discarded. Raised exceptions bubble through the standard
139
- # step failure path.
140
- 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)
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.to_s },
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
- 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
@@ -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 is transient (nil between
104
- # steps), never persisted, but must be initialized because from_state
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
- marker = state[:step_in_progress] || state["step_in_progress"]
171
- persistence_key = state[:persistence_key] || state["persistence_key"]
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
@@ -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 TTL is
63
- # deferred (would need an `expires_at` column + sweeper).
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak