smith-agents 0.7.0 → 0.10.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +456 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +27 -6
  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 +43 -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 +128 -0
  13. data/lib/smith/agent/lifecycle.rb +8 -275
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +34 -0
  16. data/lib/smith/agent/provider_call_timing.rb +74 -0
  17. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  18. data/lib/smith/agent/provider_completion.rb +148 -0
  19. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  20. data/lib/smith/agent/provider_usage.rb +28 -0
  21. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  22. data/lib/smith/agent/usage_entry_recording.rb +66 -0
  23. data/lib/smith/agent/usage_traces.rb +49 -0
  24. data/lib/smith/agent/usage_tracking.rb +116 -0
  25. data/lib/smith/agent.rb +29 -197
  26. data/lib/smith/attribution.rb +115 -0
  27. data/lib/smith/context/observation_masking.rb +28 -4
  28. data/lib/smith/context/session.rb +7 -2
  29. data/lib/smith/diagnostic_text.rb +67 -0
  30. data/lib/smith/doctor/checks/configuration.rb +16 -3
  31. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  32. data/lib/smith/errors.rb +34 -1
  33. data/lib/smith/event.rb +10 -2
  34. data/lib/smith/events/bus.rb +63 -5
  35. data/lib/smith/events/step_completed.rb +3 -0
  36. data/lib/smith/events/step_failed.rb +25 -0
  37. data/lib/smith/events/subscription.rb +8 -0
  38. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  39. data/lib/smith/models/collision_error.rb +9 -0
  40. data/lib/smith/models/inference.rb +14 -0
  41. data/lib/smith/models/normalizer.rb +11 -67
  42. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  43. data/lib/smith/models/tool_routing.rb +140 -0
  44. data/lib/smith/models.rb +24 -78
  45. data/lib/smith/persisted_failure_invalid.rb +7 -0
  46. data/lib/smith/pricing.rb +138 -17
  47. data/lib/smith/pricing_configuration_error.rb +7 -0
  48. data/lib/smith/provider_permanent_failure.rb +16 -0
  49. data/lib/smith/tool/argument_container_reader.rb +97 -0
  50. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  51. data/lib/smith/tool/argument_snapshot.rb +19 -0
  52. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  53. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  54. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  55. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  56. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  57. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  58. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  59. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  60. data/lib/smith/tool/budget_enforcement.rb +15 -7
  61. data/lib/smith/tool/call_admission.rb +44 -0
  62. data/lib/smith/tool/call_allowance.rb +101 -28
  63. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  64. data/lib/smith/tool/call_batch.rb +47 -0
  65. data/lib/smith/tool/call_budget.rb +51 -0
  66. data/lib/smith/tool/call_reservation.rb +42 -0
  67. data/lib/smith/tool/capture.rb +9 -1
  68. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  69. data/lib/smith/tool/chat_execution_context.rb +7 -88
  70. data/lib/smith/tool/execution_authority.rb +42 -0
  71. data/lib/smith/tool/execution_authorization.rb +17 -0
  72. data/lib/smith/tool/execution_batch.rb +100 -0
  73. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  74. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  75. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  76. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  77. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  78. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  79. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  80. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  81. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  82. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  83. data/lib/smith/tool/execution_batch_state.rb +110 -0
  84. data/lib/smith/tool/execution_dispatch.rb +78 -0
  85. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  86. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  87. data/lib/smith/tool/execution_tracker.rb +20 -0
  88. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  89. data/lib/smith/tool/graceful_completion.rb +20 -0
  90. data/lib/smith/tool/invocation.rb +27 -0
  91. data/lib/smith/tool/invocation_request.rb +37 -0
  92. data/lib/smith/tool/invocation_sequence.rb +26 -0
  93. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  94. data/lib/smith/tool/scoped_context.rb +60 -8
  95. data/lib/smith/tool.rb +81 -13
  96. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  97. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  98. data/lib/smith/trace/logger.rb +1 -0
  99. data/lib/smith/trace/memory.rb +40 -4
  100. data/lib/smith/trace/open_telemetry.rb +47 -3
  101. data/lib/smith/trace.rb +24 -5
  102. data/lib/smith/version.rb +2 -2
  103. data/lib/smith/workflow/agent_result.rb +4 -3
  104. data/lib/smith/workflow/artifact_integration.rb +2 -1
  105. data/lib/smith/workflow/composite/effects.rb +64 -31
  106. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  107. data/lib/smith/workflow/deterministic_execution.rb +2 -1
  108. data/lib/smith/workflow/deterministic_step.rb +14 -1
  109. data/lib/smith/workflow/dsl.rb +25 -1
  110. data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
  111. data/lib/smith/workflow/event_integration.rb +48 -1
  112. data/lib/smith/workflow/execution.rb +10 -9
  113. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  114. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  115. data/lib/smith/workflow/failure_record.rb +139 -0
  116. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  117. data/lib/smith/workflow/failure_record_text.rb +83 -0
  118. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  119. data/lib/smith/workflow/fanout_execution.rb +1 -1
  120. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  121. data/lib/smith/workflow/guardrail_integration.rb +25 -0
  122. data/lib/smith/workflow/parallel.rb +14 -3
  123. data/lib/smith/workflow/parallel_execution.rb +6 -0
  124. data/lib/smith/workflow/persistence.rb +55 -20
  125. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  126. data/lib/smith/workflow/retry_execution.rb +5 -1
  127. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  128. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  129. data/lib/smith/workflow/step_completion.rb +9 -0
  130. data/lib/smith/workflow/step_context.rb +46 -0
  131. data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
  132. data/lib/smith/workflow/transition.rb +9 -4
  133. data/lib/smith/workflow/usage_entry.rb +43 -5
  134. data/lib/smith/workflow.rb +43 -131
  135. data/lib/smith.rb +10 -2
  136. metadata +82 -11
@@ -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
@@ -29,7 +29,8 @@ module Smith
29
29
  session_messages: snapshot_value(@session_messages || []),
30
30
  tool_results: snapshot_value(@tool_results || []),
31
31
  state: @state,
32
- transition: transition
32
+ transition: transition,
33
+ last_agent_execution: snapshot_value(@last_agent_execution)
33
34
  )
34
35
  end
35
36
 
@@ -15,11 +15,24 @@ module Smith
15
15
  @current_state = state
16
16
  @transition_name = transition ? transition.name : options.fetch(:transition_name)
17
17
  @allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
18
+ @last_agent_execution = options[:last_agent_execution]
18
19
  @context_writes = {}
19
20
  @routed_to = nil
20
21
  @outcome = nil
21
22
  end
22
23
 
24
+ # The model id / provider that actually served the most recent serial
25
+ # `execute :agent` step (post fallback resolution), or nil if no serial
26
+ # agent step has run. Symmetric with `last_output`, which returns that
27
+ # step's content; use these to attribute an agent output to its model.
28
+ def last_agent_model
29
+ @last_agent_execution && @last_agent_execution[:model]
30
+ end
31
+
32
+ def last_agent_provider
33
+ @last_agent_execution && @last_agent_execution[:provider]
34
+ end
35
+
23
36
  def last_output
24
37
  return @last_output if defined?(@last_output)
25
38
 
@@ -59,7 +72,7 @@ module Smith
59
72
  private
60
73
 
61
74
  def validate_options!(options)
62
- unknown = options.keys - %i[transition transition_name allowed_routes]
75
+ unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution]
63
76
  raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
64
77
  return if options[:transition] || options.key?(:transition_name)
65
78
 
@@ -17,6 +17,7 @@ module Smith
17
17
  duplicate_transition_index(@transitions_by_state)
18
18
  )
19
19
  subclass.instance_variable_set(:@transition_order, (@transition_order || {}).dup)
20
+ subclass.instance_variable_set(:@generated_transitions, (@generated_transitions || []).dup)
20
21
  subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
21
22
  subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
22
23
  subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
@@ -50,7 +51,12 @@ module Smith
50
51
  declared = Transition.new(name, from: from, to: to, &)
51
52
  name = declared.name
52
53
  @transitions ||= {}
53
- remove_from_transition_index(@transitions[name]) if @transitions.key?(name)
54
+
55
+ if @transitions.key?(name)
56
+ remove_from_transition_index(@transitions[name])
57
+ release_generated_transition_order(name)
58
+ end
59
+
54
60
  @transitions[name] = declared
55
61
  insert_into_transition_index(declared)
56
62
  end
@@ -279,10 +285,28 @@ module Smith
279
285
  transitions_by_state.delete(transition.from) if indexed.empty?
280
286
  end
281
287
 
288
+ # A user redeclaration replacing a GENERATED transition takes a fresh
289
+ # declaration-position order; a genuine user redefinition keeps its
290
+ # original position (matrix-pinned redefinition semantics). Without
291
+ # this, the synthetic :fail generated when `state :failed` is
292
+ # declared keeps its early order number, and a later user-declared
293
+ # :fail sharing an origin state with a primary transition would sort
294
+ # ahead of it and shadow it at run time.
295
+ def release_generated_transition_order(name)
296
+ return unless generated_transitions.delete(name)
297
+
298
+ transition_order.delete(name)
299
+ end
300
+
301
+ def generated_transitions
302
+ @generated_transitions ||= []
303
+ end
304
+
282
305
  def generate_fail_transition
283
306
  @transitions ||= {}
284
307
  return if @transitions.key?(:fail)
285
308
 
309
+ generated_transitions << :fail
286
310
  transition(:fail, from: nil, to: :failed)
287
311
  end
288
312
  end
@@ -29,7 +29,9 @@ module Smith
29
29
 
30
30
  def run_optimization_loop(state)
31
31
  state.config[:max_rounds].times do |round|
32
- result = run_optimization_round(state, round)
32
+ # The overlay scopes every trace and usage fact from this round's
33
+ # generator and evaluator calls to the round that produced them.
34
+ result = Attribution.with(round: round) { run_optimization_round(state, round) }
33
35
  return result if result
34
36
  end
35
37
 
@@ -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
@@ -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
@@ -29,6 +31,10 @@ module Smith
29
31
  end
30
32
 
31
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
+ @pending_agent_execution = nil
32
38
  output = with_scoped_artifacts { run_with_retry_policy(transition) }
33
39
  StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
34
40
  end
@@ -39,15 +45,6 @@ module Smith
39
45
  Tool.current_tool_result_collector = tool_result_collector
40
46
  end
41
47
 
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
48
  def run_standard_guarded_step(transition)
52
49
  return dispatch_step(transition) if transition.deterministic?
53
50
  return run_guarded_fanout_step(transition) if transition.fanout?
@@ -100,6 +97,10 @@ module Smith
100
97
  begin
101
98
  result = execute_transition_body(transition, prepared_input: prepared_input)
102
99
  agent_result = result.is_a?(AgentResult) ? result : nil
100
+ # Capture the model/provider that actually served this serial agent
101
+ # step (post fallback resolution) for complete_step to fold into the
102
+ # durable step record. Nil when no agent ran (e.g. unconfigured model).
103
+ @pending_agent_execution = agent_result && { model: agent_result.model_used, provider: agent_result.provider_used }
103
104
  reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
104
105
  reserved = nil
105
106
  agent_result ? agent_result.content : result
@@ -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
@@ -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