smith-agents 0.7.0 → 0.8.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  5. data/docs/PERSISTENCE.md +27 -1
  6. data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
  7. data/lib/smith/agent/chat_construction.rb +112 -0
  8. data/lib/smith/agent/completion.rb +46 -0
  9. data/lib/smith/agent/completion_usage_recording.rb +25 -0
  10. data/lib/smith/agent/dynamic_configuration.rb +69 -0
  11. data/lib/smith/agent/fallback_configuration.rb +26 -0
  12. data/lib/smith/agent/invocation_preparation.rb +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +29 -197
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +15 -7
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +101 -28
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  60. data/lib/smith/tool/chat_execution_context.rb +7 -88
  61. data/lib/smith/tool/execution_authority.rb +42 -0
  62. data/lib/smith/tool/execution_authorization.rb +17 -0
  63. data/lib/smith/tool/execution_batch.rb +100 -0
  64. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  65. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  66. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  67. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  68. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  69. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  70. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  71. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  72. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  73. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  74. data/lib/smith/tool/execution_batch_state.rb +110 -0
  75. data/lib/smith/tool/execution_dispatch.rb +78 -0
  76. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  77. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  78. data/lib/smith/tool/execution_tracker.rb +20 -0
  79. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  80. data/lib/smith/tool/graceful_completion.rb +20 -0
  81. data/lib/smith/tool/invocation.rb +27 -0
  82. data/lib/smith/tool/invocation_request.rb +37 -0
  83. data/lib/smith/tool/invocation_sequence.rb +26 -0
  84. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  85. data/lib/smith/tool/scoped_context.rb +60 -8
  86. data/lib/smith/tool.rb +71 -12
  87. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  88. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  89. data/lib/smith/version.rb +2 -2
  90. data/lib/smith/workflow/agent_result.rb +4 -3
  91. data/lib/smith/workflow/artifact_integration.rb +2 -1
  92. data/lib/smith/workflow/composite/effects.rb +3 -1
  93. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  94. data/lib/smith/workflow/execution.rb +2 -9
  95. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  96. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  97. data/lib/smith/workflow/failure_record.rb +139 -0
  98. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  99. data/lib/smith/workflow/failure_record_text.rb +83 -0
  100. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  101. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  102. data/lib/smith/workflow/parallel.rb +14 -3
  103. data/lib/smith/workflow/persistence.rb +22 -19
  104. data/lib/smith/workflow/retry_execution.rb +5 -1
  105. data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
  106. data/lib/smith/workflow/transition.rb +9 -4
  107. data/lib/smith/workflow/usage_entry.rb +12 -2
  108. data/lib/smith/workflow.rb +17 -130
  109. data/lib/smith.rb +3 -0
  110. metadata +78 -11
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+ require "json"
5
+
6
+ require_relative "../diagnostic_text"
7
+ require_relative "../errors"
8
+ require_relative "failure_record_text"
9
+ require_relative "failure_record_validator"
10
+ require_relative "message_value_normalizer"
11
+
12
+ module Smith
13
+ class Workflow
14
+ class FailureRecordRestore
15
+ ATTRIBUTE_NAMES = %i[
16
+ transition from to error_class error_family error_message error_retryable error_retry_forbidden
17
+ error_kind error_details error_cause_class error_cause_family error_cause_message
18
+ ].freeze
19
+ OPTIONAL_NAMES = %i[error_retry_forbidden error_cause_class error_cause_family error_cause_message].freeze
20
+ REQUIRED_NAMES = (ATTRIBUTE_NAMES - OPTIONAL_NAMES).freeze
21
+ ATTRIBUTE_KEYS = ATTRIBUTE_NAMES.to_h { |name| [name.to_s, name] }.freeze
22
+ MODULE_MATCH = Module.instance_method(:===)
23
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
24
+ private_constant :ATTRIBUTE_NAMES, :OPTIONAL_NAMES, :REQUIRED_NAMES, :ATTRIBUTE_KEYS, :MODULE_MATCH,
25
+ :HASH_EACH_PAIR
26
+
27
+ extend Dry::Initializer
28
+
29
+ param :raw
30
+ option :transition_normalizer
31
+ option :state_normalizer
32
+
33
+ def call
34
+ return if raw.nil?
35
+
36
+ reject!("persisted workflow failure record must be a Hash") unless MODULE_MATCH.bind_call(Hash, raw)
37
+
38
+ values = attributes
39
+ FailureRecordValidator.new(restored_record(values)).call
40
+ end
41
+
42
+ private
43
+
44
+ def restored_record(values)
45
+ identity_attributes(values).merge(error_attributes(values)).freeze
46
+ end
47
+
48
+ def identity_attributes(values)
49
+ {
50
+ transition: normalize_identifier(values.fetch(:transition), transition_normalizer, "transition"),
51
+ from: normalize_identifier(values.fetch(:from), state_normalizer, "from state"),
52
+ to: normalize_identifier(values.fetch(:to), state_normalizer, "to state")
53
+ }
54
+ end
55
+
56
+ def error_attributes(values)
57
+ {
58
+ error_class: bounded_string(values.fetch(:error_class), 512, "class"),
59
+ error_family: bounded_string(values.fetch(:error_family), 64, "family"),
60
+ error_message: message_string(values.fetch(:error_message), "message"),
61
+ error_retryable: boolean_or_nil(values.fetch(:error_retryable), "retryable"),
62
+ error_retry_forbidden: boolean_or_nil(values[:error_retry_forbidden], "retry policy"),
63
+ error_kind: normalize_kind(values.fetch(:error_kind)),
64
+ error_details: normalize_details(values.fetch(:error_details)),
65
+ **cause_attributes(values)
66
+ }
67
+ end
68
+
69
+ def cause_attributes(values)
70
+ {
71
+ error_cause_class: optional_string(values[:error_cause_class], 512, "cause class"),
72
+ error_cause_family: optional_string(values[:error_cause_family], 64, "cause family"),
73
+ error_cause_message: optional_message(values[:error_cause_message], "cause message")
74
+ }
75
+ end
76
+
77
+ def attributes
78
+ values = {}
79
+ HASH_EACH_PAIR.bind_call(raw) do |key, value|
80
+ name = normalize_key(key)
81
+ reject!("persisted workflow failure record contains a duplicate attribute") if values.key?(name)
82
+
83
+ values[name] = value
84
+ end
85
+ missing = REQUIRED_NAMES - values.keys
86
+ reject!("persisted workflow failure record is incomplete") if missing.any?
87
+ values
88
+ end
89
+
90
+ def normalize_key(key)
91
+ return key if symbol?(key) && ATTRIBUTE_NAMES.include?(key)
92
+ return ATTRIBUTE_KEYS.fetch(key) if string?(key) && ATTRIBUTE_KEYS.key?(key)
93
+
94
+ reject!("persisted workflow failure record contains an unknown attribute")
95
+ end
96
+
97
+ def normalize_identifier(value, normalizer, label)
98
+ bounded_string(value, 256, label).then { normalizer.call(_1) }
99
+ end
100
+
101
+ def normalize_kind(value)
102
+ return if value.nil?
103
+
104
+ bounded_string(value, 256, "kind").to_sym
105
+ end
106
+
107
+ def normalize_details(value)
108
+ return if value.nil?
109
+
110
+ snapshot = MessageValueNormalizer.new(value, label: "persisted workflow failure details").call
111
+ JSON.generate(snapshot)
112
+ snapshot
113
+ rescue WorkflowError, JSON::GeneratorError, EncodingError
114
+ reject!("persisted workflow failure details are invalid")
115
+ end
116
+
117
+ def bounded_string(value, limit, label)
118
+ FailureRecordText.capture(value, limit:, label:)
119
+ end
120
+
121
+ def optional_string(value, limit, label) = value.nil? ? nil : bounded_string(value, limit, label)
122
+
123
+ # Messages restore with capture normalization (placeholder for blank
124
+ # text, capture-identical truncation for overlong text) because legacy
125
+ # states persisted them unbounded; length alone never rejects restore.
126
+ def message_string(value, label)
127
+ FailureRecordText.capture(value, limit: DiagnosticText::MAX_BYTES, label:, normalize_length: true)
128
+ end
129
+
130
+ def optional_message(value, label) = value.nil? ? nil : message_string(value, label)
131
+
132
+ def boolean_or_nil(value, label)
133
+ return value if value.nil? || value == true || value == false
134
+
135
+ reject!("persisted workflow failure #{label} is invalid")
136
+ end
137
+
138
+ def reject!(message) = raise(Smith::PersistedFailureInvalid, message)
139
+
140
+ def string?(value) = MODULE_MATCH.bind_call(String, value)
141
+
142
+ def symbol?(value) = MODULE_MATCH.bind_call(Symbol, value)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../diagnostic_text"
6
+ require_relative "../errors"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class FailureRecordText
11
+ # Deterministic stand-in shared by capture and restore for failure
12
+ # messages that would otherwise be blank.
13
+ MISSING_TEXT = "failure message unavailable"
14
+
15
+ MODULE_MATCH = Module.instance_method(:===)
16
+ STRING_BYTESIZE = String.instance_method(:bytesize)
17
+ STRING_ENCODING = String.instance_method(:encoding)
18
+ STRING_INITIALIZE_COPY = String.instance_method(:initialize_copy)
19
+ STRING_VALID_ENCODING = String.instance_method(:valid_encoding?)
20
+ SYMBOL_TO_S = Symbol.instance_method(:to_s)
21
+ private_constant :MODULE_MATCH, :STRING_BYTESIZE, :STRING_ENCODING, :STRING_INITIALIZE_COPY,
22
+ :STRING_VALID_ENCODING, :SYMBOL_TO_S
23
+
24
+ extend Dry::Initializer
25
+
26
+ param :value
27
+ option :limit
28
+ option :label
29
+ option :normalize_length, default: proc { false }
30
+
31
+ def self.capture(value, limit:, label:, normalize_length: false)
32
+ new(value, limit:, label:, normalize_length:).call
33
+ end
34
+
35
+ def call
36
+ text = owned_text
37
+ validate_encoding!(text)
38
+ size = STRING_BYTESIZE.bind_call(text)
39
+ return normalized(text, size) if normalize_length
40
+ raise PersistedFailureInvalid, "persisted workflow failure #{label} is invalid" unless size.between?(1, limit)
41
+
42
+ text.freeze
43
+ end
44
+
45
+ private
46
+
47
+ def validate_encoding!(text)
48
+ valid = STRING_VALID_ENCODING.bind_call(text) && STRING_ENCODING.bind_call(text) == Encoding::UTF_8
49
+ return if valid
50
+
51
+ raise PersistedFailureInvalid, "persisted workflow failure #{label} is invalid"
52
+ end
53
+
54
+ # Message fields mirror capture normalization instead of rejecting on
55
+ # length: legacy states persisted messages unbounded (and possibly
56
+ # blank), and failing the whole workflow restore for message length
57
+ # alone would poison otherwise valid durable state. Blank text takes
58
+ # the capture placeholder; overlong text truncates exactly like
59
+ # capture does.
60
+ def normalized(text, size)
61
+ return MISSING_TEXT if size.zero?
62
+ return text.freeze if size <= limit
63
+
64
+ DiagnosticText.capture(text, max_bytes: limit)
65
+ end
66
+
67
+ def owned_text
68
+ return owned_string if string?
69
+ return SYMBOL_TO_S.bind_call(value) if symbol?
70
+
71
+ raise PersistedFailureInvalid, "persisted workflow failure #{label} must be text"
72
+ end
73
+
74
+ def owned_string
75
+ String.allocate.tap { STRING_INITIALIZE_COPY.bind_call(_1, value) }
76
+ end
77
+
78
+ def string? = MODULE_MATCH.bind_call(String, value)
79
+
80
+ def symbol? = MODULE_MATCH.bind_call(Symbol, value)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../errors"
6
+ require_relative "composite/branch_failure"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class FailureRecordValidator
11
+ KNOWN_ERROR_FAMILIES = {
12
+ "Smith::DeterministicStepFailure" => "deterministic_step_failure",
13
+ "Smith::ToolGuardrailFailed" => "tool_guardrail_failed",
14
+ "Smith::ToolCaptureFailed" => "tool_capture_failed",
15
+ "Smith::ToolFailureNotificationFailed" => "tool_failure_notification_failed",
16
+ "Smith::ToolOutcomeUncertain" => "tool_outcome_uncertain",
17
+ "Smith::ToolExecutionNotAdmitted" => "tool_execution_not_admitted",
18
+ "Smith::BoundedCompletionError" => "bounded_completion_error",
19
+ "Smith::PersistedFailureInvalid" => "persisted_failure_invalid",
20
+ "Smith::AgentError" => "agent_error",
21
+ "Smith::BlankAgentOutputError" => "agent_error",
22
+ "Smith::DeadlineExceeded" => "deadline_exceeded",
23
+ "Smith::WorkflowError" => "workflow_error",
24
+ "Smith::UnresolvedTransitionError" => "workflow_error",
25
+ "Smith::Workflow::Composite::BranchFailure" => "workflow_error"
26
+ }.freeze
27
+ KNOWN_FAMILIES = %w[
28
+ deterministic_step_failure tool_guardrail_failed tool_failure_notification_failed tool_capture_failed
29
+ tool_outcome_uncertain tool_execution_not_admitted bounded_completion_error persisted_failure_invalid
30
+ deadline_exceeded agent_error workflow_error other
31
+ ].freeze
32
+ RETRY_FORBIDDEN_FAMILIES = %w[
33
+ tool_capture_failed tool_failure_notification_failed tool_outcome_uncertain tool_execution_not_admitted
34
+ bounded_completion_error persisted_failure_invalid
35
+ ].freeze
36
+ DETAIL_VALIDATORS = {
37
+ "Smith::ToolCaptureFailed" => ->(details) { Smith::ToolCaptureFailed.from_details(details) },
38
+ "Smith::ToolFailureNotificationFailed" => lambda { |details|
39
+ Smith::ToolFailureNotificationFailed.from_details(details)
40
+ },
41
+ "Smith::Workflow::Composite::BranchFailure" => lambda { |details|
42
+ Smith::Workflow::Composite::BranchFailure.from_details(details)
43
+ }
44
+ }.freeze
45
+ BOOLEAN_VALUES = [true, false].freeze
46
+ private_constant :KNOWN_ERROR_FAMILIES, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES, :DETAIL_VALIDATORS,
47
+ :BOOLEAN_VALUES
48
+
49
+ extend Dry::Initializer
50
+
51
+ param :snapshot
52
+
53
+ def call
54
+ validate_family!
55
+ validate_class_family!
56
+ validate_retry_policy!
57
+ validate_cause!
58
+ validate_details!
59
+ snapshot
60
+ rescue ArgumentError, KeyError, TypeError
61
+ reject!("persisted workflow failure details are invalid")
62
+ end
63
+
64
+ private
65
+
66
+ def family = snapshot[:error_family]
67
+
68
+ def validate_family!
69
+ return if KNOWN_FAMILIES.include?(family)
70
+
71
+ reject!("persisted workflow failure family is invalid")
72
+ end
73
+
74
+ def validate_class_family!
75
+ expected = KNOWN_ERROR_FAMILIES[snapshot[:error_class]]
76
+ return unless expected && family != expected
77
+
78
+ reject!("persisted workflow failure class and family disagree")
79
+ end
80
+
81
+ def validate_retry_policy!
82
+ forbidden = snapshot[:error_retry_forbidden]
83
+ return if forbidden.nil?
84
+
85
+ reject!("persisted workflow failure retry policy is invalid") unless BOOLEAN_VALUES.include?(forbidden)
86
+ return if forbidden == RETRY_FORBIDDEN_FAMILIES.include?(family)
87
+
88
+ reject!("persisted workflow failure retry policy disagrees with its family")
89
+ end
90
+
91
+ # Cause classification travels as one unit: capture writes all three
92
+ # attributes together for uncertainty wrappers, so a partial set is
93
+ # corrupt data rather than a legacy shape (legacy records omit all
94
+ # three).
95
+ def validate_cause!
96
+ cause_values = snapshot.values_at(:error_cause_class, :error_cause_family, :error_cause_message)
97
+ return if cause_values.all?(&:nil?)
98
+
99
+ reject!("persisted workflow failure cause is incomplete") if cause_values.any?(&:nil?)
100
+ return if KNOWN_FAMILIES.include?(snapshot[:error_cause_family])
101
+
102
+ reject!("persisted workflow failure cause family is invalid")
103
+ end
104
+
105
+ def validate_details!
106
+ validator = DETAIL_VALIDATORS[snapshot[:error_class]]
107
+ validator&.call(snapshot[:error_details])
108
+ end
109
+
110
+ def reject!(message) = raise(Smith::PersistedFailureInvalid, message)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module GuardedStepExecution
6
+ private
7
+
8
+ def run_guarded_step(transition)
9
+ tracker = Tool::ExecutionTracker.new
10
+ previous_tracker = Tool.current_tool_execution_tracker
11
+
12
+ Thread.handle_interrupt(Object => :never) do
13
+ Tool.current_tool_execution_tracker = tracker
14
+ begin
15
+ Thread.handle_interrupt(Object => :immediate) { run_tracked_guarded_step(transition) }
16
+ rescue StandardError => e
17
+ raise unless tracker.started?
18
+ raise if terminal_retry_error?(e)
19
+
20
+ raise ToolOutcomeUncertain.new(
21
+ "transition failed after tool execution began; retry could replay an uncertain outcome"
22
+ ), cause: e
23
+ ensure
24
+ Tool.current_tool_execution_tracker = previous_tracker
25
+ end
26
+ end
27
+ end
28
+
29
+ def run_tracked_guarded_step(transition)
30
+ return apply_composite_reduction!(transition) if @composite_reduction
31
+
32
+ @resolved_parallel_branch_count = preflight_branch_count(transition)
33
+ run_standard_guarded_step(transition)
34
+ ensure
35
+ @resolved_parallel_branch_count = nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -38,11 +38,22 @@ module Smith
38
38
 
39
39
  def self.preferred_error(reasons)
40
40
  errors = Array(reasons).compact
41
- errors.find { |error| !error.is_a?(StandardError) } ||
42
- errors.find { |error| error.is_a?(ToolCaptureFailed) } ||
43
- errors.find { |error| !error.is_a?(Cancellation) } ||
41
+ process_fatal_error(errors) ||
42
+ notification_failure(errors) ||
43
+ terminal_execution_error(errors) ||
44
+ ordinary_error(errors) ||
44
45
  errors.first
45
46
  end
47
+
48
+ def self.process_fatal_error(errors) = errors.find { !_1.is_a?(StandardError) }
49
+
50
+ def self.notification_failure(errors) = errors.find { _1.is_a?(ToolFailureNotificationFailed) }
51
+
52
+ def self.terminal_execution_error(errors) = errors.find { Smith::Errors.retry_forbidden?(_1) }
53
+
54
+ def self.ordinary_error(errors) = errors.find { !_1.is_a?(Cancellation) }
55
+
56
+ private_class_method :process_fatal_error, :notification_failure, :terminal_execution_error, :ordinary_error
46
57
  end
47
58
  end
48
59
  end
@@ -48,6 +48,10 @@ module Smith
48
48
  # cycles so seed_validation can detect when the seed builder
49
49
  # has changed in code since this workflow was persisted.
50
50
  seed_digest: @seed_digest,
51
+ # Number of immutable seed messages at the beginning of session
52
+ # history. Context strategies may preserve this exact prefix while
53
+ # masking later workflow observations.
54
+ seed_message_count: @seed_message_count || 0,
51
55
  # Step-in-progress idempotency marker. Set true between
52
56
  # persist-before-advance and persist-after-advance when the
53
57
  # workflow class opts into idempotency_mode :strict. Restore
@@ -76,6 +80,7 @@ module Smith
76
80
  @ledger = rebuild_ledger(normalized[:budget_consumed] || {})
77
81
  @next_transition_name = normalized[:next_transition_name]
78
82
  @session_messages = normalized[:session_messages] || []
83
+ @seed_message_count = validated_seed_message_count(normalized)
79
84
  @total_cost = normalized[:total_cost] || 0.0
80
85
  @total_tokens = normalized[:total_tokens] || 0
81
86
  @outcome = normalized[:outcome]
@@ -120,6 +125,14 @@ module Smith
120
125
  "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
121
126
  end
122
127
 
128
+ def validated_seed_message_count(normalized)
129
+ count = normalized.fetch(:seed_message_count, 0)
130
+ return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
131
+
132
+ raise Smith::SerializationError,
133
+ "persisted workflow seed_message_count must be between 0 and session message count"
134
+ end
135
+
123
136
  def validate_definition_digest!(normalized)
124
137
  stored = normalized[:definition_digest]
125
138
  return if stored.nil?
@@ -200,20 +213,11 @@ module Smith
200
213
  # values become strings).
201
214
  def restore_last_failed_step(normalized)
202
215
  raw = normalized[:last_failed_step]
203
- return nil unless raw.is_a?(Hash)
204
-
205
- h = raw.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
206
- {
207
- transition: normalize_transition_name(h[:transition]),
208
- from: normalize_state_name(h[:from]),
209
- to: normalize_state_name(h[:to]),
210
- error_class: h[:error_class],
211
- error_family: h[:error_family],
212
- error_message: h[:error_message],
213
- error_retryable: h[:error_retryable],
214
- error_kind: h[:error_kind]&.to_sym,
215
- error_details: h[:error_details]
216
- }
216
+ FailureRecordRestore.new(
217
+ raw,
218
+ transition_normalizer: method(:normalize_transition_name),
219
+ state_normalizer: method(:normalize_state_name)
220
+ ).call
217
221
  end
218
222
 
219
223
  def restore_core_fields(normalized)
@@ -234,9 +238,9 @@ module Smith
234
238
  end
235
239
 
236
240
  manager = self.class.context_manager
237
- if manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
241
+ if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
238
242
  ctx = normalized[:context]
239
- existing = ctx.is_a?(Hash) ? ctx.keys.map { |k| k.to_sym } : []
243
+ existing = ctx.is_a?(Hash) ? ctx.keys.map(&:to_sym) : []
240
244
  seed = manager.persist_auto_seed.map(&:to_sym)
241
245
  @persisted_keys = ::Set.new(existing + seed)
242
246
  else
@@ -309,6 +313,7 @@ module Smith
309
313
 
310
314
  def normalize_transition_name(value)
311
315
  return if value.nil?
316
+
312
317
  transition = self.class.find_transition(value)
313
318
  return transition.name if transition
314
319
  return value unless value.is_a?(String)
@@ -416,9 +421,7 @@ module Smith
416
421
  manager = self.class.context_manager
417
422
  return nil unless manager
418
423
 
419
- if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
420
- return :auto
421
- end
424
+ return :auto if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
422
425
 
423
426
  keys = manager.persist
424
427
  keys.empty? ? nil : keys
@@ -24,7 +24,7 @@ module Smith
24
24
 
25
25
  def retry_transition_error?(config, error, attempt)
26
26
  return false if attempt >= config.fetch(:attempts)
27
- return false if error.is_a?(ToolCaptureFailed)
27
+ return false if terminal_retry_error?(error)
28
28
 
29
29
  classes = config.fetch(:error_classes)
30
30
  if classes.any?
@@ -34,6 +34,10 @@ module Smith
34
34
  end
35
35
  end
36
36
 
37
+ def terminal_retry_error?(error)
38
+ Smith::Errors.retry_forbidden?(error)
39
+ end
40
+
37
41
  def sleep_for_retry(schedule, failed_attempt)
38
42
  delay = retry_delay(schedule, failed_attempt)
39
43
  sleep(delay) if delay.positive?
@@ -15,8 +15,11 @@ module Smith
15
15
  current_deadline
16
16
  current_ledger
17
17
  current_tool_call_allowance
18
+ current_tool_execution_tracker
18
19
  current_tool_result_collector
19
20
  current_invocation_context
21
+ current_invocation_sequence
22
+ current_invocation
20
23
  ].freeze
21
24
  THREAD_KEYS = %i[
22
25
  smith_call_deadline
@@ -68,14 +71,14 @@ module Smith
68
71
  def restore_context!
69
72
  raise WorkflowError, "thread context snapshot has already been restored" if @restored
70
73
 
71
- @tool_values.each { |attribute, value| Tool.public_send("#{attribute}=", value) }
74
+ @tool_values.each { |attribute, value| Tool.send("#{attribute}=", value) }
72
75
  @thread_values.each { |key, value| Thread.current[key] = value }
73
76
  Smith.scoped_artifacts = @artifact_store if scoped_artifacts
74
77
  @restored = true
75
78
  end
76
79
 
77
80
  def capture_tool_values
78
- tool_attributes.to_h { |attribute| [attribute, Tool.public_send(attribute)] }.freeze
81
+ tool_attributes.to_h { |attribute| [attribute, Tool.send(attribute)] }.freeze
79
82
  end
80
83
 
81
84
  def capture_thread_values
@@ -336,10 +336,7 @@ module Smith
336
336
 
337
337
  raise WorkflowError, "retry_on error classes must inherit from StandardError"
338
338
  end
339
- if error_classes.any? { |error_class| error_class <= ToolCaptureFailed }
340
- message = "retry_on cannot retry Smith::ToolCaptureFailed because the tool outcome may be uncertain"
341
- raise WorkflowError, message
342
- end
339
+ reject_retry_forbidden!(error_classes)
343
340
 
344
341
  ExponentialBackoff.new(
345
342
  attempts:,
@@ -352,6 +349,14 @@ module Smith
352
349
  raise WorkflowError, "retry_on #{e.message}"
353
350
  end
354
351
 
352
+ def reject_retry_forbidden!(error_classes)
353
+ forbidden = error_classes.find { |error_class| Smith::Errors.retry_forbidden_class?(error_class) }
354
+ return unless forbidden
355
+
356
+ label = forbidden.name || "an anonymous terminal tool-evidence error class"
357
+ raise WorkflowError, "retry_on cannot retry #{label} because the tool outcome may be uncertain"
358
+ end
359
+
355
360
  def normalize_deterministic_routes!(routes)
356
361
  return nil if routes.nil?
357
362
  raise WorkflowError, "deterministic routes must be an Array" unless routes.is_a?(Array)
@@ -10,6 +10,7 @@ module Smith
10
10
  :usage_id,
11
11
  :agent_name,
12
12
  :model,
13
+ :provider,
13
14
  :input_tokens,
14
15
  :output_tokens,
15
16
  :cost,
@@ -17,11 +18,20 @@ module Smith
17
18
  :recorded_at,
18
19
  keyword_init: true
19
20
  ) do
21
+ def initialize(**attributes)
22
+ attributes = attributes.transform_values do |value|
23
+ value.is_a?(String) ? value.dup.freeze : value
24
+ end
25
+ super(**attributes) # rubocop:disable Style/SuperArguments
26
+ freeze
27
+ end
28
+
20
29
  def self.from_h(hash)
21
30
  sym = hash.transform_keys(&:to_sym)
22
31
  filtered = sym.slice(*members)
23
- filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
24
- filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
32
+ %i[agent_name provider attempt_kind].each do |attribute|
33
+ filtered[attribute] = filtered[attribute].to_sym if filtered[attribute].is_a?(String)
34
+ end
25
35
  new(**filtered)
26
36
  end
27
37
  end