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
@@ -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
@@ -51,7 +51,7 @@ module Smith
51
51
  end
52
52
 
53
53
  def run_fanout_branch(branch_key, agent_name, agent_class, env, signal)
54
- with_branch_context(env, @ledger, agent_class:) do
54
+ with_branch_context(env, @ledger, agent_class:, attribution_overlay: { branch_key: }) do
55
55
  with_agent_context(agent_class) do
56
56
  branch_ledger = effective_call_ledger
57
57
  reserved = reserve_fanout_branch_call(branch_ledger, env.branch_estimates[branch_key], agent_class)
@@ -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
@@ -24,6 +24,12 @@ module Smith
24
24
 
25
25
  def handle_step_failure(transition, error)
26
26
  step = { transition: transition.name, from: transition.from, to: transition.to, error: error }
27
+ # Staged, not emitted: this rescue runs under the step snapshot's
28
+ # interrupt mask, and host StepFailed handlers must not execute
29
+ # unkillable. with_step_context flushes after the mask closes.
30
+ # Staged before the split-step capture so a capture invariant
31
+ # failure still flushes an emission for the original error.
32
+ @pending_step_failure = step
27
33
  SplitStepPersistence
28
34
  .instance_method(:capture_split_step_execution_result!)
29
35
  .bind_call(self, step)
@@ -50,9 +56,28 @@ module Smith
50
56
 
51
57
  @outcome = nil
52
58
  step = { transition: error.requested_name, from: @state, to: fail_transition.to, error: error }
59
+
60
+ # A step body that raised UnresolvedTransitionError was already
61
+ # captured, staged, and emitted under its real transition identity
62
+ # by the step-failure path before advance!'s rescue reached here.
63
+ # Capturing or emitting again would record the same failure twice,
64
+ # the second time under the requested name, a transition that never
65
+ # executed. Routing to :fail still happens either way.
66
+ if error.equal?(@emitted_step_failure_error)
67
+ @state = fail_transition.to
68
+ return step
69
+ end
70
+
53
71
  SplitStepPersistence
54
72
  .instance_method(:capture_split_step_execution_result!)
55
73
  .bind_call(self, step)
74
+ # Unlike the step-body path, this handler runs outside any step
75
+ # context (advance! rescues UnresolvedTransitionError after the step
76
+ # unwound), so the run identity must be seeded here or the emitted
77
+ # facts get fallback random ids.
78
+ Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
79
+ emit_step_failed(step)
80
+ end
56
81
  @state = fail_transition.to
57
82
  step
58
83
  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
@@ -60,6 +60,7 @@ module Smith
60
60
  ledger,
61
61
  parallel_agent_binding: NO_PARALLEL_BINDING,
62
62
  agent_class: nil,
63
+ attribution_overlay: nil,
63
64
  &block
64
65
  )
65
66
  snapshot = ThreadContextSnapshot.new
@@ -69,6 +70,11 @@ module Smith
69
70
  else
70
71
  setup_branch_context(env, ledger)
71
72
  end
73
+
74
+ # Restoration is owned by the snapshot above, which tracks the
75
+ # attribution thread key.
76
+ Attribution.install(Attribution.ambient.merge(**attribution_overlay)) if attribution_overlay
77
+
72
78
  unless parallel_agent_binding.equal?(NO_PARALLEL_BINDING)
73
79
  Thread.current[:smith_parallel_agent_binding] = parallel_agent_binding
74
80
  end
@@ -26,8 +26,12 @@ module Smith
26
26
  # snapshot_value so non-JSON-safe runtime values (e.g.
27
27
  # custom Hash details on DeterministicStepFailure) get the
28
28
  # same deep-copy treatment as context/session_messages/etc.
29
- usage_entries: snapshot_value((@usage_entries || []).map(&:to_h)),
29
+ usage_entries: snapshot_value(usage_entries_for_state.map(&:to_h)),
30
30
  last_output: snapshot_value(@last_output),
31
+ # Durable { model:, provider: } of the most recent serial agent step,
32
+ # so a deterministic step that resumes after the agent step (across a
33
+ # crash) still reads its `last_agent_model` / `last_agent_provider`.
34
+ last_agent_execution: snapshot_value(@last_agent_execution),
31
35
  last_failed_step: snapshot_value(@last_failed_step),
32
36
  # Optimistic-locking version. Adapters that support
33
37
  # store_versioned use this to detect concurrent writes; adapters
@@ -48,6 +52,10 @@ module Smith
48
52
  # cycles so seed_validation can detect when the seed builder
49
53
  # has changed in code since this workflow was persisted.
50
54
  seed_digest: @seed_digest,
55
+ # Number of immutable seed messages at the beginning of session
56
+ # history. Context strategies may preserve this exact prefix while
57
+ # masking later workflow observations.
58
+ seed_message_count: @seed_message_count || 0,
51
59
  # Step-in-progress idempotency marker. Set true between
52
60
  # persist-before-advance and persist-after-advance when the
53
61
  # workflow class opts into idempotency_mode :strict. Restore
@@ -76,6 +84,7 @@ module Smith
76
84
  @ledger = rebuild_ledger(normalized[:budget_consumed] || {})
77
85
  @next_transition_name = normalized[:next_transition_name]
78
86
  @session_messages = normalized[:session_messages] || []
87
+ @seed_message_count = validated_seed_message_count(normalized)
79
88
  @total_cost = normalized[:total_cost] || 0.0
80
89
  @total_tokens = normalized[:total_tokens] || 0
81
90
  @outcome = normalized[:outcome]
@@ -90,6 +99,12 @@ module Smith
90
99
  @usage_mutex = Mutex.new
91
100
  @usage_entries = restore_usage_entries(normalized)
92
101
  @last_output = restore_last_output(normalized)
102
+ # Backward-compat: pre-patch states have no last_agent_execution key and
103
+ # restore to nil. @pending_agent_execution is transient (nil between
104
+ # steps), never persisted, but must be initialized because from_state
105
+ # allocates and bypasses #initialize.
106
+ @last_agent_execution = restore_last_agent_execution(normalized)
107
+ @pending_agent_execution = nil
93
108
  @last_failed_step = restore_last_failed_step(normalized)
94
109
  # Restore the optimistic-locking version from the persisted payload.
95
110
  # Backward-compat: pre-versioning payloads have no key, restore to 0
@@ -120,6 +135,14 @@ module Smith
120
135
  "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
121
136
  end
122
137
 
138
+ def validated_seed_message_count(normalized)
139
+ count = normalized.fetch(:seed_message_count, 0)
140
+ return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
141
+
142
+ raise Smith::SerializationError,
143
+ "persisted workflow seed_message_count must be between 0 and session message count"
144
+ end
145
+
123
146
  def validate_definition_digest!(normalized)
124
147
  stored = normalized[:definition_digest]
125
148
  return if stored.nil?
@@ -179,6 +202,17 @@ module Smith
179
202
  raw.map { |h| Workflow::UsageEntry.from_h(h) }
180
203
  end
181
204
 
205
+ # Serialization reads the ledger under the recording mutex so a state
206
+ # written mid-fan-out never captures a torn array. The mutex can be
207
+ # absent on an allocated-but-unrestored instance; fall back to the
208
+ # bare read to_state always tolerated.
209
+ def usage_entries_for_state
210
+ mutex = @usage_mutex
211
+ return (@usage_entries || []).dup unless mutex
212
+
213
+ mutex.synchronize { (@usage_entries || []).dup }
214
+ end
215
+
182
216
  # Use key-presence checks (NOT `||`) so a deliberately persisted
183
217
  # `false` step output round-trips correctly. Smith's existing
184
218
  # `RunResult#output` derivation uses `compact.first`, which only
@@ -191,6 +225,17 @@ module Smith
191
225
  end
192
226
  end
193
227
 
228
+ def restore_last_agent_execution(normalized)
229
+ value = if normalized.key?(:last_agent_execution)
230
+ normalized[:last_agent_execution]
231
+ elsif normalized.key?("last_agent_execution")
232
+ normalized["last_agent_execution"]
233
+ end
234
+ return unless value.is_a?(Hash)
235
+
236
+ symbolize_keys(value)
237
+ end
238
+
194
239
  # Symbolize ONLY the top-level keys of last_failed_step + the
195
240
  # known value-symbols (`transition`, `from`, `to`, `error_kind`).
196
241
  # `error_family` stays a String (the family_fallback compares
@@ -200,20 +245,11 @@ module Smith
200
245
  # values become strings).
201
246
  def restore_last_failed_step(normalized)
202
247
  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
- }
248
+ FailureRecordRestore.new(
249
+ raw,
250
+ transition_normalizer: method(:normalize_transition_name),
251
+ state_normalizer: method(:normalize_state_name)
252
+ ).call
217
253
  end
218
254
 
219
255
  def restore_core_fields(normalized)
@@ -234,9 +270,9 @@ module Smith
234
270
  end
235
271
 
236
272
  manager = self.class.context_manager
237
- if manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
273
+ if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
238
274
  ctx = normalized[:context]
239
- existing = ctx.is_a?(Hash) ? ctx.keys.map { |k| k.to_sym } : []
275
+ existing = ctx.is_a?(Hash) ? ctx.keys.map(&:to_sym) : []
240
276
  seed = manager.persist_auto_seed.map(&:to_sym)
241
277
  @persisted_keys = ::Set.new(existing + seed)
242
278
  else
@@ -309,6 +345,7 @@ module Smith
309
345
 
310
346
  def normalize_transition_name(value)
311
347
  return if value.nil?
348
+
312
349
  transition = self.class.find_transition(value)
313
350
  return transition.name if transition
314
351
  return value unless value.is_a?(String)
@@ -416,9 +453,7 @@ module Smith
416
453
  manager = self.class.context_manager
417
454
  return nil unless manager
418
455
 
419
- if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
420
- return :auto
421
- end
456
+ return :auto if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
422
457
 
423
458
  keys = manager.persist
424
459
  keys.empty? ? nil : keys
@@ -7,18 +7,27 @@ module Smith
7
7
 
8
8
  def prepared_branch(implementation, *arguments)
9
9
  tool_context = Tool::ScopedContext.capture
10
+ # Attribution is captured on the preparing thread (where the step's
11
+ # context is ambient) and carried into the branch thread, exactly as
12
+ # the tool context is; carrying nil deliberately clears stale state
13
+ # on a pooled thread.
14
+ ambient_attribution = Attribution.current
10
15
  unless @split_step_active_execution_authorization
11
16
  return proc do |signal|
12
- Tool::ScopedContext.around(tool_context) do
13
- __send__(implementation.name, *arguments, signal)
17
+ Attribution.carrying(ambient_attribution) do
18
+ Tool::ScopedContext.around(tool_context) do
19
+ __send__(implementation.name, *arguments, signal)
20
+ end
14
21
  end
15
22
  end
16
23
  end
17
24
 
18
25
  proc do |signal|
19
26
  run = proc { implementation.bind_call(self, *arguments, signal) }
20
- Tool::ScopedContext.around(tool_context) do
21
- PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
27
+ Attribution.carrying(ambient_attribution) do
28
+ Tool::ScopedContext.around(tool_context) do
29
+ PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
30
+ end
22
31
  end
23
32
  end
24
33
  end
@@ -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?
@@ -40,7 +40,15 @@ module Smith
40
40
  end
41
41
 
42
42
  def capture_composite_branch(&block)
43
- output = within_raw_step_context { with_scoped_artifacts(&block) }
43
+ output = within_raw_step_context do
44
+ # Same ambient attribution the in-process step path installs, so
45
+ # durably executed branches tag usage and traces identically
46
+ # (execution key from the restored persistence key, the split
47
+ # step's transition, then the branch overlay downstream).
48
+ # Restoration is owned by within_raw_step_context's snapshot.
49
+ install_step_attribution(@split_step_transition) if @split_step_transition
50
+ with_scoped_artifacts(&block)
51
+ end
44
52
  [output, nil]
45
53
  rescue StandardError => e
46
54
  [nil, e]
@@ -58,6 +58,7 @@ module Smith
58
58
 
59
59
  def detach_split_step_results!
60
60
  @last_output = snapshot_value(@last_output)
61
+ @last_agent_execution = snapshot_value(@last_agent_execution)
61
62
  @last_failed_step = snapshot_value(@last_failed_step)
62
63
  @last_prepared_input = snapshot_value(@last_prepared_input)
63
64
  end
@@ -7,6 +7,15 @@ module Smith
7
7
 
8
8
  def complete_step(transition, output)
9
9
  step = { transition: transition.name, from: transition.from, to: transition.to, output: output }
10
+ # Fold in the serial agent attribution captured for this step (if any),
11
+ # then clear the transient carrier. record_step_snapshot reads these off
12
+ # the returned step to update the durable @last_agent_execution.
13
+ if @pending_agent_execution
14
+ step[:model] = @pending_agent_execution[:model]
15
+ step[:provider] = @pending_agent_execution[:provider]
16
+ end
17
+
18
+ @pending_agent_execution = nil
10
19
  result = SplitStepPersistence
11
20
  .instance_method(:prepare_split_step_execution_result)
12
21
  .bind_call(self, step)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../attribution"
3
4
  require_relative "thread_context_snapshot"
4
5
 
5
6
  module Smith
@@ -10,6 +11,7 @@ module Smith
10
11
  def with_step_context(transition, &block)
11
12
  ThreadContextSnapshot.new.around do
12
13
  setup_step_context
14
+ install_step_attribution(transition)
13
15
  Thread.handle_interrupt(Object => :immediate, &block)
14
16
  rescue StandardError => e
15
17
  @outcome = nil
@@ -17,6 +19,32 @@ module Smith
17
19
  ensure
18
20
  teardown_step_context
19
21
  end
22
+ ensure
23
+ flush_step_failure_emission
24
+ end
25
+
26
+ # Runs after the snapshot's interrupt mask has closed, so host
27
+ # StepFailed handlers execute interruptible, exactly as StepCompleted
28
+ # handlers do inside the step. The step context is gone by now, so the
29
+ # run identity and workflow label are seeded explicitly; the failed
30
+ # step's own transition facts travel in the staged step hash. Flushes
31
+ # on every exit of with_step_context, including the re-raise paths.
32
+ # The read-clear-mark triple is masked so an async interrupt cannot
33
+ # strand a staged failure between read and clear; the marker lets the
34
+ # unresolved-transition handler recognize an error this path already
35
+ # emitted. Only the emission itself stays interruptible.
36
+ def flush_step_failure_emission
37
+ step = nil
38
+ Thread.handle_interrupt(Object => :never) do
39
+ step = @pending_step_failure
40
+ @pending_step_failure = nil
41
+ @emitted_step_failure_error = step[:error] if step
42
+ end
43
+ return unless step
44
+
45
+ Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
46
+ emit_step_failed(step)
47
+ end
20
48
  end
21
49
 
22
50
  def within_raw_step_context(&block)
@@ -34,6 +62,24 @@ module Smith
34
62
  Tool.current_tool_result_collector = tool_result_collector
35
63
  end
36
64
 
65
+ # The ambient attribution for this step. Restoration is owned by the
66
+ # surrounding ThreadContextSnapshot (the attribution thread key is one
67
+ # of its THREAD_KEYS), so installation is a plain assignment. A nil
68
+ # persistence key preserves any host-seeded outer execution_key, and
69
+ # `workflow` is always a string ("anonymous" when the class has no
70
+ # name) so a nested child always overrides the parent's label. The
71
+ # per-step facts (transition, from, to) are replaced verbatim, nil
72
+ # included: a nested child's `from: nil` transition must not inherit
73
+ # the parent step's `from` through the nil-ignoring merge. branch_key
74
+ # and round inherit deliberately (a child genuinely runs within them).
75
+ def install_step_attribution(transition)
76
+ Attribution.install(
77
+ Attribution.ambient
78
+ .merge(execution_key: @persistence_key, workflow: self.class.name || "anonymous")
79
+ .override(transition: transition.name, from: transition.from, to: transition.to)
80
+ )
81
+ end
82
+
37
83
  def teardown_step_context
38
84
  Tool.current_guardrails = nil
39
85
  Tool.current_deadline = nil