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
data/lib/smith/tool.rb CHANGED
@@ -4,23 +4,68 @@ require "ruby_llm"
4
4
 
5
5
  require_relative "tool/capability_builder"
6
6
  require_relative "tool/policy"
7
+ require_relative "tool/call_budget"
8
+ require_relative "tool/call_batch"
9
+ require_relative "tool/legacy_call_allowance"
10
+ require_relative "tool/call_allowance_counter"
7
11
  require_relative "tool/call_allowance"
12
+ require_relative "tool/call_reservation"
13
+ require_relative "tool/call_admission"
14
+ require_relative "tool/execution_authority"
15
+ require_relative "tool/execution_authorization"
16
+ require_relative "tool/execution_tracker"
17
+ require_relative "tool/execution_lifecycle"
18
+ require_relative "tool/invocation"
19
+ require_relative "tool/argument_snapshot_result"
20
+ require_relative "tool/argument_scalar_snapshot"
21
+ require_relative "tool/argument_container_reader"
22
+ require_relative "tool/argument_snapshot_accounting"
23
+ require_relative "tool/argument_snapshot_traversal"
24
+ require_relative "tool/argument_snapshot"
25
+ require_relative "tool/invocation_request"
26
+ require_relative "tool/invocation_sequence"
27
+ require_relative "tool/execution_batch_collection"
28
+ require_relative "tool/execution_batch_invocations"
29
+ require_relative "tool/execution_batch_source_metadata"
30
+ require_relative "tool/execution_batch_source_call"
31
+ require_relative "tool/execution_batch_sources"
32
+ require_relative "tool/execution_batch_state"
33
+ require_relative "tool/execution_batch"
34
+ require_relative "tool/execution_batch_admission"
35
+ require_relative "tool/execution_batch_requests"
36
+ require_relative "tool/execution_batch_builder"
37
+ require_relative "tool/execution_batch_registry"
8
38
  require_relative "tool/budget_enforcement"
9
39
  require_relative "tool_capture_failed"
10
40
  require_relative "tool/capture"
11
41
  require_relative "tool/capture_configuration"
12
42
  require_relative "tool/compatibility"
13
43
  require_relative "tool/scoped_context"
44
+ require_relative "tool/bounded_completion_state"
45
+ require_relative "tool/bounded_completion_guard"
46
+ require_relative "tool/bounded_completion_controls"
47
+ require_relative "tool/fail_fast_completion"
48
+ require_relative "tool/graceful_completion"
49
+ require_relative "tool/bounded_completion_context"
50
+ require_relative "tool/bounded_completion_installation"
51
+ require_relative "tool/execution_failure_handling"
52
+ require_relative "tool/execution_dispatch"
53
+ require_relative "tool/chat_execution_callbacks"
54
+ require_relative "tool/execution_batch_lifecycle"
14
55
  require_relative "tool/chat_execution_context"
15
56
 
16
57
  module Smith
17
58
  class Tool < RubyLLM::Tool
18
59
  include Policy
60
+ include ExecutionAuthorization
61
+ include ExecutionLifecycle
19
62
  include BudgetEnforcement
20
63
  include Capture
21
64
  extend CaptureConfiguration
22
65
  extend ScopedContext
23
66
 
67
+ private_constant :ExecutionAuthority
68
+
24
69
  class << self
25
70
  # Tool subclasses inherit the parent's compatible_with spec by
26
71
  # reference (the spec is a frozen Hash; immutability makes shared
@@ -75,24 +120,32 @@ module Smith
75
120
  end
76
121
 
77
122
  def execute(**kwargs)
78
- ensure_capture_ready!
79
- run_before_execute_hook!(kwargs)
80
- check_tool_deadline!
81
- check_privilege!(kwargs)
82
- check_authorization!(kwargs)
83
- run_tool_guardrails!(kwargs)
84
- check_tool_deadline!
85
- charge_tool_call!
86
-
87
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
- result = perform(**kwargs)
89
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
123
+ authorize_tool_execution!
124
+ kwargs.freeze
125
+ prepare_tool_execution!(kwargs)
126
+ result, duration = perform_with_duration(kwargs)
90
127
 
91
128
  emit_tool_trace(kwargs, result, duration)
92
129
  capture_result_if_configured(kwargs, result)
93
130
  result
94
131
  end
95
132
 
133
+ protected
134
+
135
+ def invocation_argument_error(arguments) = validate_keyword_arguments(normalize_args(arguments))
136
+
137
+ def execute_keyword_signature
138
+ parameters = method(:perform).parameters
139
+ required_keywords = parameters.filter_map { |kind, name| name if kind == :keyreq }
140
+ optional_keywords = parameters.filter_map { |kind, name| name if kind == :key }
141
+ accepts_extra_keywords = parameters.any? { |kind, _| kind == :keyrest }
142
+ accepts_positional_arguments = parameters.any? do |kind, _|
143
+ RubyLLM::Tool::POSITIONAL_PARAMETER_KINDS.include?(kind)
144
+ end
145
+
146
+ [required_keywords, optional_keywords, accepts_extra_keywords, accepts_positional_arguments]
147
+ end
148
+
96
149
  private
97
150
 
98
151
  def run_before_execute_hook!(kwargs)
@@ -126,6 +179,12 @@ module Smith
126
179
  raise DeadlineExceeded, "wall_clock deadline exceeded during tool execution" if Time.now.utc >= deadline
127
180
  end
128
181
 
182
+ def check_dispatch_deadline!
183
+ check_tool_deadline!
184
+ rescue DeadlineExceeded
185
+ raise ToolDispatchRejected, "tool deadline expired before dispatch"
186
+ end
187
+
129
188
  def perform(**kwargs)
130
189
  raise NotImplementedError, "#{self.class} must implement #perform"
131
190
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Smith
6
+ class ToolExecutionNotAdmitted < Error; end
7
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "diagnostic_text"
5
+
6
+ module Smith
7
+ class ToolFailureNotificationFailed < Error
8
+ DETAIL_NAMES = %i[
9
+ dispatch_error_class dispatch_error_message notification_error_class notification_error_message
10
+ ].freeze
11
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
12
+ EXCEPTION_MESSAGE = Exception.instance_method(:message)
13
+ MODULE_NAME = Module.instance_method(:name)
14
+ OBJECT_CLASS = Object.instance_method(:class)
15
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS, :EXCEPTION_MESSAGE, :MODULE_NAME, :OBJECT_CLASS
16
+
17
+ attr_reader :details, :dispatch_error, :notification_error
18
+
19
+ def initialize(dispatch_error:, notification_error:)
20
+ @dispatch_error = dispatch_error
21
+ @notification_error = notification_error
22
+ @details = build_details
23
+ super(
24
+ "host failed to record a terminal tool outcome: " \
25
+ "#{@details.fetch(:notification_error_class)}: #{@details.fetch(:notification_error_message)}"
26
+ )
27
+ end
28
+
29
+ def build_details
30
+ {
31
+ dispatch_error_class: DiagnosticText.capture(error_class_name(dispatch_error), max_bytes: 512),
32
+ dispatch_error_message: DiagnosticText.capture(error_message(dispatch_error)),
33
+ notification_error_class: DiagnosticText.capture(error_class_name(notification_error), max_bytes: 512),
34
+ notification_error_message: DiagnosticText.capture(error_message(notification_error))
35
+ }.freeze
36
+ end
37
+ private :build_details
38
+
39
+ def self.from_details(details)
40
+ values = normalize_details(details)
41
+ new(
42
+ dispatch_error: restored_error(values, :dispatch_error),
43
+ notification_error: restored_error(values, :notification_error)
44
+ )
45
+ end
46
+
47
+ def self.normalize_details(details)
48
+ raise ArgumentError, "tool failure notification details must be a Hash" unless details.is_a?(Hash)
49
+
50
+ values = details.each_with_object({}) do |(key, value), normalized|
51
+ append_detail!(normalized, key, value)
52
+ end
53
+ validate_complete!(values)
54
+ values.freeze
55
+ end
56
+
57
+ def self.append_detail!(normalized, key, value)
58
+ name = normalize_detail_name(key)
59
+ raise ArgumentError, "tool failure notification details contain an unknown attribute" unless name
60
+ raise ArgumentError, "tool failure notification details contain a duplicate attribute" if normalized.key?(name)
61
+ raise ArgumentError, "tool failure notification detail values must be strings" unless value.is_a?(String)
62
+
63
+ normalized[name] = value
64
+ end
65
+
66
+ def self.validate_complete!(values)
67
+ missing = DETAIL_NAMES - values.keys
68
+ raise ArgumentError, "tool failure notification details are missing required attributes" if missing.any?
69
+ end
70
+
71
+ def self.normalize_detail_name(key)
72
+ return key if key.is_a?(Symbol) && DETAIL_NAMES.include?(key)
73
+
74
+ DETAIL_KEYS[key] if key.is_a?(String)
75
+ end
76
+
77
+ def self.restored_error(values, prefix)
78
+ RuntimeError.new(
79
+ "#{values.fetch(:"#{prefix}_class")}: #{values.fetch(:"#{prefix}_message")}"
80
+ )
81
+ end
82
+
83
+ def error_class_name(error)
84
+ error_class = OBJECT_CLASS.bind_call(error)
85
+ MODULE_NAME.bind_call(error_class) || MODULE_NAME.bind_call(error_class.superclass) || "StandardError"
86
+ end
87
+
88
+ def error_message(error) = EXCEPTION_MESSAGE.bind_call(error)
89
+ private :error_class_name, :error_message
90
+
91
+ private_class_method :normalize_details, :append_detail!, :validate_complete!, :normalize_detail_name,
92
+ :restored_error
93
+ end
94
+ end
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.7.0"
5
- EXECUTION_SEMANTICS_VERSION = "3"
4
+ VERSION = "0.8.0"
5
+ EXECUTION_SEMANTICS_VERSION = "5"
6
6
  end
@@ -4,16 +4,17 @@ module Smith
4
4
  class Workflow
5
5
  # rubocop:disable Style/RedundantStructKeywordInit
6
6
  AgentResult = Struct.new(
7
- :content, :input_tokens, :output_tokens, :cost, :model_used,
7
+ :content, :input_tokens, :output_tokens, :cost, :model_used, :provider_used,
8
8
  keyword_init: true
9
9
  ) do
10
- def self.from_response(response, content, model_used: nil)
10
+ def self.from_response(response, content, model_used: nil, provider_used: nil)
11
11
  new(
12
12
  content: content,
13
13
  input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
14
14
  output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
15
15
  cost: nil,
16
- model_used: model_used
16
+ model_used: model_used,
17
+ provider_used: provider_used
17
18
  )
18
19
  end
19
20
 
@@ -33,7 +33,8 @@ module Smith
33
33
  Context::Session.new(
34
34
  messages: messages,
35
35
  context_manager: manager,
36
- persisted_context: @context
36
+ persisted_context: @context,
37
+ seed_message_count: @seed_message_count || 0
37
38
  )
38
39
  end
39
40
  end
@@ -13,7 +13,7 @@ module Smith
13
13
  attr_reader :total_tokens, :total_cost
14
14
 
15
15
  USAGE_ATTRIBUTES = %w[
16
- usage_id agent_name model input_tokens output_tokens cost attempt_kind recorded_at
16
+ usage_id agent_name model provider input_tokens output_tokens cost attempt_kind recorded_at
17
17
  ].freeze
18
18
  TOOL_ATTRIBUTES = %w[tool captured].freeze
19
19
  private_constant :USAGE_ATTRIBUTES, :TOOL_ATTRIBUTES
@@ -70,6 +70,8 @@ module Smith
70
70
  def validate_usage_identity!(entry)
71
71
  validate_usage_id!(entry.fetch("usage_id"))
72
72
  validate_agent_name!(entry.fetch("agent_name"))
73
+ provider = entry.fetch("provider")
74
+ validate_nonempty_string!(provider, "composite usage entry provider") if provider
73
75
  %w[model attempt_kind recorded_at].each do |key|
74
76
  validate_nonempty_string!(entry.fetch(key), "composite usage entry #{key}")
75
77
  end
@@ -25,7 +25,7 @@ module Smith
25
25
  saved_deadline = Tool.current_deadline
26
26
  saved_call_ledger = Thread.current[:smith_call_ledger]
27
27
  snapshot = ThreadContextSnapshot.new(
28
- tool_attributes: %i[current_deadline current_tool_call_allowance],
28
+ tool_attributes: %i[current_deadline current_tool_call_allowance current_tool_execution_tracker],
29
29
  thread_keys: %i[smith_call_deadline smith_call_ledger],
30
30
  scoped_artifacts: false
31
31
  )
@@ -41,6 +41,7 @@ module Smith
41
41
  apply_agent_deadline(agent_class)
42
42
  narrow_tool_deadline!
43
43
  apply_agent_tool_calls(agent_class)
44
+ Tool.current_tool_execution_tracker ||= Tool::ExecutionTracker.new
44
45
  apply_agent_call_ledger(agent_class)
45
46
  end
46
47
 
@@ -49,6 +50,7 @@ module Smith
49
50
  Thread.current[:smith_call_ledger] = call_ledger
50
51
  clear_agent_deadline
51
52
  clear_agent_tool_calls
53
+ Tool.current_tool_execution_tracker = nil
52
54
  end
53
55
 
54
56
  def effective_call_ledger
@@ -74,7 +76,23 @@ module Smith
74
76
 
75
77
  def apply_agent_tool_calls(agent_class)
76
78
  agent_tc = agent_class&.budget&.dig(:tool_calls)
77
- Tool.current_tool_call_allowance = agent_tc ? Tool::CallAllowance.new(agent_tc) : nil
79
+ if agent_class&.tool_budget_exhaustion == :complete && agent_tc.nil?
80
+ raise AgentError, "tool_budget_exhaustion :complete requires a finite tool_calls budget"
81
+ end
82
+
83
+ Tool.current_tool_call_allowance = build_agent_tool_call_allowance(agent_class, agent_tc)
84
+ end
85
+
86
+ def build_agent_tool_call_allowance(agent_class, agent_tool_calls)
87
+ return unless agent_tool_calls
88
+
89
+ parent = Tool.current_tool_call_allowance
90
+ if parent && !parent.is_a?(Tool::CallAllowance)
91
+ raise AgentError, "agent tool_calls budgets cannot scope a legacy Hash tool call allowance"
92
+ end
93
+ return parent.scope(agent_tool_calls, on_exhaustion: agent_class.tool_budget_exhaustion) if parent
94
+
95
+ Tool::CallAllowance.new(agent_tool_calls, on_exhaustion: agent_class.tool_budget_exhaustion)
78
96
  end
79
97
 
80
98
  def clear_agent_tool_calls
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "agent_result"
4
4
  require_relative "execution_binding_resolution"
5
+ require_relative "guarded_step_execution"
5
6
  require_relative "prepared_branch_execution"
6
7
  require_relative "step_completion"
7
8
  require_relative "step_context"
@@ -10,6 +11,7 @@ module Smith
10
11
  class Workflow
11
12
  module Execution
12
13
  include ExecutionBindingResolution
14
+ include GuardedStepExecution
13
15
  include PreparedBranchExecution
14
16
  include StepCompletion
15
17
  include StepContext
@@ -39,15 +41,6 @@ module Smith
39
41
  Tool.current_tool_result_collector = tool_result_collector
40
42
  end
41
43
 
42
- def run_guarded_step(transition)
43
- return apply_composite_reduction!(transition) if @composite_reduction
44
-
45
- @resolved_parallel_branch_count = preflight_branch_count(transition)
46
- run_standard_guarded_step(transition)
47
- ensure
48
- @resolved_parallel_branch_count = nil
49
- end
50
-
51
44
  def run_standard_guarded_step(transition)
52
45
  return dispatch_step(transition) if transition.deterministic?
53
46
  return run_guarded_fanout_step(transition) if transition.fanout?
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+ require "json"
5
+
6
+ require_relative "../diagnostic_text"
7
+ require_relative "message_value_normalizer"
8
+
9
+ module Smith
10
+ class Workflow
11
+ class FailureDetailSnapshot
12
+ OMITTED_KEY = "smith_failure_details_omitted"
13
+ OMITTED_REASON_BYTES = 1_024
14
+ private_constant :OMITTED_KEY, :OMITTED_REASON_BYTES
15
+
16
+ extend Dry::Initializer
17
+
18
+ param :value
19
+
20
+ def call
21
+ return if value.nil?
22
+
23
+ snapshot = MessageValueNormalizer.new(value, label: "workflow failure details").call
24
+ JSON.generate(snapshot)
25
+ snapshot
26
+ rescue StandardError => e
27
+ {
28
+ OMITTED_KEY => DiagnosticText.capture(e.message, max_bytes: OMITTED_REASON_BYTES)
29
+ }.freeze
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../errors"
6
+ require_relative "failure_record_validator"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class FailureReconstructor
11
+ FAMILY_BUILDERS = {
12
+ "deterministic_step_failure" => ->(record) { deterministic_step_failure(record) },
13
+ "tool_guardrail_failed" => ->(record) { tool_guardrail_failure(record) },
14
+ "tool_failure_notification_failed" => ->(record) { tool_failure_notification_failure(record) },
15
+ "tool_capture_failed" => ->(record) { Smith::ToolCaptureFailed.from_details(record.fetch(:error_details)) },
16
+ "tool_outcome_uncertain" => ->(record) { Smith::ToolOutcomeUncertain.new(record[:error_message]) },
17
+ "tool_execution_not_admitted" => ->(record) { Smith::ToolExecutionNotAdmitted.new(record[:error_message]) },
18
+ "bounded_completion_error" => ->(record) { Smith::BoundedCompletionError.new(record[:error_message]) },
19
+ "persisted_failure_invalid" => ->(record) { Smith::PersistedFailureInvalid.new(record[:error_message]) },
20
+ "deadline_exceeded" => ->(record) { Smith::DeadlineExceeded.new(record[:error_message]) },
21
+ "agent_error" => ->(record) { Smith::AgentError.new(record[:error_message]) },
22
+ "workflow_error" => ->(record) { Smith::WorkflowError.new(record[:error_message]) },
23
+ "other" => ->(record) { RuntimeError.new(record[:error_message]) }
24
+ }.freeze
25
+ SPECIAL_CLASS_BUILDERS = {
26
+ "Smith::Workflow::Composite::BranchFailure" => lambda { |record|
27
+ Smith::Workflow::Composite::BranchFailure.from_details(record[:error_details])
28
+ }
29
+ }.freeze
30
+ private_constant :FAMILY_BUILDERS, :SPECIAL_CLASS_BUILDERS
31
+
32
+ extend Dry::Initializer
33
+
34
+ option :snapshot
35
+ option :transition_normalizer
36
+ option :state_normalizer
37
+
38
+ def call
39
+ {
40
+ transition: transition_normalizer.call(snapshot[:transition]),
41
+ from: state_normalizer.call(snapshot[:from]),
42
+ to: state_normalizer.call(snapshot[:to]),
43
+ error: reconstruct_error
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def reconstruct_error
50
+ FailureRecordValidator.new(snapshot).call
51
+ builder = SPECIAL_CLASS_BUILDERS[snapshot[:error_class]] || FAMILY_BUILDERS.fetch(snapshot[:error_family])
52
+ builder.call(snapshot)
53
+ rescue Smith::PersistedFailureInvalid
54
+ raise
55
+ rescue ArgumentError, KeyError, TypeError
56
+ raise Smith::PersistedFailureInvalid, "persisted workflow failure details are invalid"
57
+ end
58
+
59
+ def self.deterministic_step_failure(record)
60
+ Smith::DeterministicStepFailure.new(
61
+ record[:error_message],
62
+ retryable: record[:error_retryable],
63
+ kind: record[:error_kind],
64
+ details: record[:error_details]
65
+ )
66
+ end
67
+
68
+ def self.tool_guardrail_failure(record)
69
+ Smith::ToolGuardrailFailed.new(record[:error_message], retryable: record[:error_retryable])
70
+ end
71
+
72
+ def self.tool_failure_notification_failure(record)
73
+ Smith::ToolFailureNotificationFailed.from_details(record.fetch(:error_details))
74
+ end
75
+
76
+ private_class_method :deterministic_step_failure, :tool_guardrail_failure, :tool_failure_notification_failure
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../diagnostic_text"
6
+ require_relative "../errors"
7
+ require_relative "failure_detail_snapshot"
8
+ require_relative "failure_record_text"
9
+
10
+ module Smith
11
+ class Workflow
12
+ class FailureRecord
13
+ ERROR_FAMILIES = [
14
+ [Smith::DeterministicStepFailure, "deterministic_step_failure"],
15
+ [Smith::ToolFailureNotificationFailed, "tool_failure_notification_failed"],
16
+ [Smith::ToolCaptureFailed, "tool_capture_failed"],
17
+ [Smith::ToolOutcomeUncertain, "tool_outcome_uncertain"],
18
+ [Smith::ToolExecutionNotAdmitted, "tool_execution_not_admitted"],
19
+ [Smith::BoundedCompletionError, "bounded_completion_error"],
20
+ [Smith::PersistedFailureInvalid, "persisted_failure_invalid"],
21
+ [Smith::ToolGuardrailFailed, "tool_guardrail_failed"],
22
+ [Smith::DeadlineExceeded, "deadline_exceeded"],
23
+ [Smith::AgentError, "agent_error"],
24
+ [Smith::WorkflowError, "workflow_error"]
25
+ ].freeze
26
+ BOOLEAN_VALUES = [true, false].freeze
27
+ EXCEPTION_CAUSE = Exception.instance_method(:cause)
28
+ EXCEPTION_MESSAGE = Exception.instance_method(:message)
29
+ 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
34
+
35
+ extend Dry::Initializer
36
+
37
+ option :step_result
38
+
39
+ def self.capture(step_result) = new(step_result:).call
40
+
41
+ def call
42
+ error = step_result.fetch(:error)
43
+ {
44
+ transition: step_result[:transition],
45
+ from: step_result[:from],
46
+ to: step_result[:to],
47
+ **error_attributes(error)
48
+ }.freeze
49
+ end
50
+
51
+ private
52
+
53
+ def error_attributes(error)
54
+ {
55
+ error_class: DiagnosticText.capture(error_class_name(error), max_bytes: 512),
56
+ error_family: error_family(error),
57
+ error_message: error_message(error),
58
+ error_retryable: retryable_value(error),
59
+ error_retry_forbidden: Smith::Errors.retry_forbidden?(error),
60
+ error_kind: error_kind(error),
61
+ error_details: error_details(error),
62
+ **cause_attributes(error)
63
+ }
64
+ end
65
+
66
+ # Uncertainty wrappers replace the causal failure at the transition
67
+ # boundary; without this classification a restored host cannot
68
+ # distinguish a deadline, cancellation, or defect behind an
69
+ # uncertain tool outcome.
70
+ def cause_attributes(error)
71
+ cause = uncertainty_cause(error)
72
+ {
73
+ error_cause_class: cause && DiagnosticText.capture(error_class_name(cause), max_bytes: 512),
74
+ error_cause_family: cause && error_family(cause),
75
+ error_cause_message: cause && error_message(cause)
76
+ }
77
+ end
78
+
79
+ def uncertainty_cause(error)
80
+ return unless MODULE_MATCH.bind_call(Smith::ToolOutcomeUncertain, error)
81
+
82
+ cause = EXCEPTION_CAUSE.bind_call(error)
83
+ cause if MODULE_MATCH.bind_call(Exception, cause)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ def error_family(error)
89
+ ERROR_FAMILIES.find { |error_class, _| MODULE_MATCH.bind_call(error_class, error) }&.last || "other"
90
+ end
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
+ # Never captures blank text: restore requires non-empty messages, so
98
+ # a blank live message takes the shared deterministic placeholder.
99
+ def error_message(error)
100
+ captured = DiagnosticText.capture(EXCEPTION_MESSAGE.bind_call(error))
101
+ captured.empty? ? FailureRecordText::MISSING_TEXT : captured
102
+ rescue StandardError
103
+ FailureRecordText::MISSING_TEXT
104
+ end
105
+
106
+ def retryable_value(error)
107
+ return unless error.respond_to?(:retryable)
108
+
109
+ value = error.retryable
110
+ value if BOOLEAN_VALUES.include?(value)
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ def error_kind(error)
116
+ return unless error.respond_to?(:kind)
117
+
118
+ value = error.kind
119
+ return unless value.is_a?(String) || value.is_a?(Symbol)
120
+
121
+ captured = DiagnosticText.capture(value.to_s, max_bytes: 256)
122
+ # Restore rejects blank kinds; an empty kind carries no signal.
123
+ captured unless captured.empty?
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ def error_details(error)
129
+ return unless error.respond_to?(:details)
130
+
131
+ FailureDetailSnapshot.new(error.details).call
132
+ rescue StandardError => e
133
+ FailureDetailSnapshot.new(
134
+ smith_failure_details_omitted: DiagnosticText.capture(e.message, max_bytes: 1_024)
135
+ ).call
136
+ end
137
+ end
138
+ end
139
+ end