smith-agents 0.8.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +230 -0
  3. data/docs/CONFIGURATION.md +5 -5
  4. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  5. data/lib/smith/agent/invocation_preparation.rb +24 -0
  6. data/lib/smith/agent/lifecycle.rb +4 -2
  7. data/lib/smith/agent/provider_attempt.rb +10 -4
  8. data/lib/smith/agent/provider_call_timing.rb +74 -0
  9. data/lib/smith/agent/provider_completion.rb +27 -9
  10. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  11. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  12. data/lib/smith/agent/usage_traces.rb +49 -0
  13. data/lib/smith/agent/usage_tracking.rb +23 -20
  14. data/lib/smith/attribution.rb +115 -0
  15. data/lib/smith/event.rb +10 -2
  16. data/lib/smith/events/bus.rb +63 -5
  17. data/lib/smith/events/step_completed.rb +3 -0
  18. data/lib/smith/events/step_failed.rb +25 -0
  19. data/lib/smith/events/subscription.rb +8 -0
  20. data/lib/smith/tool/capture.rb +9 -1
  21. data/lib/smith/tool.rb +10 -1
  22. data/lib/smith/trace/logger.rb +1 -0
  23. data/lib/smith/trace/memory.rb +40 -4
  24. data/lib/smith/trace/open_telemetry.rb +47 -3
  25. data/lib/smith/trace.rb +24 -5
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/composite/effects.rb +64 -33
  28. data/lib/smith/workflow/deterministic_execution.rb +2 -1
  29. data/lib/smith/workflow/deterministic_step.rb +14 -1
  30. data/lib/smith/workflow/dsl.rb +25 -1
  31. data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
  32. data/lib/smith/workflow/event_integration.rb +48 -1
  33. data/lib/smith/workflow/execution.rb +8 -0
  34. data/lib/smith/workflow/fanout_execution.rb +1 -1
  35. data/lib/smith/workflow/guardrail_integration.rb +25 -0
  36. data/lib/smith/workflow/parallel_execution.rb +6 -0
  37. data/lib/smith/workflow/persistence.rb +33 -1
  38. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  39. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  40. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  41. data/lib/smith/workflow/step_completion.rb +9 -0
  42. data/lib/smith/workflow/step_context.rb +46 -0
  43. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  44. data/lib/smith/workflow/usage_entry.rb +32 -4
  45. data/lib/smith/workflow.rb +26 -1
  46. data/lib/smith.rb +7 -2
  47. metadata +5 -1
@@ -4,19 +4,32 @@ require_relative "../../types"
4
4
  require_relative "../../budget/decimal_context"
5
5
  require_relative "../message_value_normalizer"
6
6
  require_relative "../prepared_step"
7
+ require_relative "../usage_entry"
7
8
  require_relative "payload"
8
9
 
9
10
  module Smith
10
11
  class Workflow
11
12
  module Composite
12
- class Effects < Payload
13
+ # Length is fail-closed value validation, not logic: every key the
14
+ # contract admits gets a bounded value check beside the contract that
15
+ # admits it. Splitting the checks away from the payload they guard
16
+ # would trade cohesion for a metric.
17
+ class Effects < Payload # rubocop:disable Metrics/ClassLength
13
18
  attr_reader :total_tokens, :total_cost
14
19
 
15
- USAGE_ATTRIBUTES = %w[
16
- usage_id agent_name model provider input_tokens output_tokens cost attempt_kind recorded_at
17
- ].freeze
18
- TOOL_ATTRIBUTES = %w[tool captured].freeze
19
- private_constant :USAGE_ATTRIBUTES, :TOOL_ATTRIBUTES
20
+ # Required keys are the pre-attribution UsageEntry shape, so effects
21
+ # produced by an older Smith (a mid-deploy branch worker or a
22
+ # restored checkpoint) stay valid; allowed keys are the current
23
+ # member set, so unknown keys still reject. The attribution members
24
+ # (transition, branch_key, round, attempt_id) are optional by
25
+ # construction.
26
+ USAGE_ALLOWED_ATTRIBUTES = Workflow::UsageEntry.members.map(&:to_s).freeze
27
+ USAGE_REQUIRED_ATTRIBUTES =
28
+ (USAGE_ALLOWED_ATTRIBUTES - %w[transition branch_key round attempt_id workflow]).freeze
29
+ TOOL_REQUIRED_ATTRIBUTES = %w[tool captured].freeze
30
+ TOOL_ALLOWED_ATTRIBUTES = (TOOL_REQUIRED_ATTRIBUTES + %w[tool_call_id]).freeze
31
+ private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES,
32
+ :TOOL_REQUIRED_ATTRIBUTES, :TOOL_ALLOWED_ATTRIBUTES
20
33
 
21
34
  attribute :usage_entries, Types::Array
22
35
  attribute :tool_results, Types::Array
@@ -25,17 +38,12 @@ module Smith
25
38
  def initialize(attributes)
26
39
  owned = self.class.normalize_attributes(attributes)
27
40
  normalized = MessageValueNormalizer.new(owned, label: "composite effects").call
28
- usage_entries = normalized.fetch("usage_entries")
29
- tool_results = normalized.fetch("tool_results")
30
- budget_consumed = normalized.fetch("budget_consumed")
41
+ usage_entries, tool_results, budget_consumed =
42
+ normalized.values_at("usage_entries", "tool_results", "budget_consumed")
31
43
  @total_tokens, @total_cost = validate_usage_entries!(usage_entries)
32
44
  validate_tool_results!(tool_results)
33
45
  validate_budget!(budget_consumed)
34
- super(
35
- usage_entries:,
36
- tool_results:,
37
- budget_consumed:
38
- )
46
+ super(usage_entries:, tool_results:, budget_consumed:)
39
47
  end
40
48
 
41
49
  private
@@ -44,8 +52,9 @@ module Smith
44
52
  raise ArgumentError, "composite usage entries must be an Array" unless entries.is_a?(Array)
45
53
 
46
54
  entries.each do |entry|
47
- validate_exact_keys!(entry, USAGE_ATTRIBUTES, "composite usage entry")
55
+ validate_bounded_keys!(entry, USAGE_REQUIRED_ATTRIBUTES, USAGE_ALLOWED_ATTRIBUTES, "composite usage entry")
48
56
  validate_usage_identity!(entry)
57
+ validate_usage_attribution!(entry)
49
58
  validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
50
59
  validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
51
60
  validate_cost!(entry.fetch("cost"))
@@ -68,25 +77,40 @@ module Smith
68
77
  end
69
78
 
70
79
  def validate_usage_identity!(entry)
71
- validate_usage_id!(entry.fetch("usage_id"))
72
- validate_agent_name!(entry.fetch("agent_name"))
73
- provider = entry.fetch("provider")
74
- validate_nonempty_string!(provider, "composite usage entry provider") if provider
80
+ validate_uuid!(entry.fetch("usage_id"), "composite usage entry usage_id")
81
+ # agent_name and provider are nil-allowed; any other value
82
+ # (false included) must be a non-empty String.
83
+ %w[agent_name provider].each do |key|
84
+ value = entry.fetch(key)
85
+ validate_nonempty_string!(value, "composite usage entry #{key}") unless value.nil?
86
+ end
75
87
  %w[model attempt_kind recorded_at].each do |key|
76
88
  validate_nonempty_string!(entry.fetch(key), "composite usage entry #{key}")
77
89
  end
78
90
  end
79
91
 
80
- def validate_usage_id!(usage_id)
81
- return if usage_id.is_a?(String) && PreparedStep::UUID_PATTERN.match?(usage_id)
92
+ # The optional attribution keys are bounded values, not just bounded
93
+ # keys: a present key with a wrong-typed, empty, or oversized value
94
+ # rejects exactly like the identity fields do. Absent keys (older
95
+ # producers, or nil-omitting serialization) stay valid.
96
+ def validate_usage_attribution!(entry)
97
+ %w[transition branch_key workflow].each do |key|
98
+ validate_bounded_string!(entry.fetch(key), "composite usage entry #{key}", 256) if entry.key?(key)
99
+ end
100
+ validate_usage_amount!(entry.fetch("round"), "round") if entry.key?("round")
101
+ validate_uuid!(entry.fetch("attempt_id"), "composite usage entry attempt_id") if entry.key?("attempt_id")
102
+ end
103
+
104
+ def validate_uuid!(value, label)
105
+ return if value.is_a?(String) && PreparedStep::UUID_PATTERN.match?(value)
82
106
 
83
- raise ArgumentError, "composite usage entry usage_id must be a UUID"
107
+ raise ArgumentError, "#{label} must be a UUID"
84
108
  end
85
109
 
86
- def validate_agent_name!(agent_name)
87
- return if agent_name.nil?
110
+ def validate_bounded_string!(value, label, max_length)
111
+ return if value.is_a?(String) && value.length.between?(1, max_length)
88
112
 
89
- validate_nonempty_string!(agent_name, "composite usage entry agent_name")
113
+ raise ArgumentError, "#{label} must be a bounded non-empty String"
90
114
  end
91
115
 
92
116
  def validate_nonempty_string!(value, label)
@@ -102,8 +126,7 @@ module Smith
102
126
  end
103
127
 
104
128
  def validate_cost!(cost)
105
- return if cost.nil?
106
- return if cost.is_a?(Numeric) && cost.finite? && cost >= 0
129
+ return if cost.nil? || (cost.is_a?(Numeric) && cost.finite? && cost >= 0)
107
130
 
108
131
  raise ArgumentError, "composite usage entry cost must be a finite non-negative number or nil"
109
132
  end
@@ -112,10 +135,15 @@ module Smith
112
135
  raise ArgumentError, "composite tool results must be an Array" unless entries.is_a?(Array)
113
136
 
114
137
  entries.each do |entry|
115
- validate_exact_keys!(entry, TOOL_ATTRIBUTES, "composite tool result")
116
- tool = entry.fetch("tool")
117
- unless tool.is_a?(String) && tool.length.between?(1, 256)
118
- raise ArgumentError, "composite tool result tool must be a bounded non-empty String"
138
+ validate_bounded_keys!(entry, TOOL_REQUIRED_ATTRIBUTES, TOOL_ALLOWED_ATTRIBUTES, "composite tool result")
139
+ validate_bounded_string!(entry.fetch("tool"), "composite tool result tool", 256)
140
+
141
+ # Present only for provider-batch invocations; the producer never
142
+ # writes a nil, so a present key must carry a real id. Provider
143
+ # tool-call ids are short strings; 1024 is far above any observed
144
+ # provider format while still bounding the payload.
145
+ if entry.key?("tool_call_id")
146
+ validate_bounded_string!(entry.fetch("tool_call_id"), "composite tool result tool_call_id", 1024)
119
147
  end
120
148
  end
121
149
  end
@@ -130,9 +158,12 @@ module Smith
130
158
  end
131
159
  end
132
160
 
133
- def validate_exact_keys!(value, expected, label)
161
+ # Every required key present, no key outside the allowed set: older
162
+ # producers (missing optional keys) pass, unknown keys still reject.
163
+ # Passing the same set for both is an exact-keys check.
164
+ def validate_bounded_keys!(value, required, allowed, label)
134
165
  raise ArgumentError, "#{label} must be a Hash" unless value.is_a?(Hash)
135
- return if value.keys.sort == expected.sort
166
+ return if (required - value.keys).empty? && (value.keys - allowed).empty?
136
167
 
137
168
  raise ArgumentError, "#{label} attributes are invalid"
138
169
  end
@@ -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
@@ -31,6 +31,10 @@ module Smith
31
31
  end
32
32
 
33
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
34
38
  output = with_scoped_artifacts { run_with_retry_policy(transition) }
35
39
  StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
36
40
  end
@@ -93,6 +97,10 @@ module Smith
93
97
  begin
94
98
  result = execute_transition_body(transition, prepared_input: prepared_input)
95
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 }
96
104
  reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
97
105
  reserved = nil
98
106
  agent_result ? agent_result.content : result
@@ -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)
@@ -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
@@ -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
@@ -95,6 +99,12 @@ module Smith
95
99
  @usage_mutex = Mutex.new
96
100
  @usage_entries = restore_usage_entries(normalized)
97
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
98
108
  @last_failed_step = restore_last_failed_step(normalized)
99
109
  # Restore the optimistic-locking version from the persisted payload.
100
110
  # Backward-compat: pre-versioning payloads have no key, restore to 0
@@ -192,6 +202,17 @@ module Smith
192
202
  raw.map { |h| Workflow::UsageEntry.from_h(h) }
193
203
  end
194
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
+
195
216
  # Use key-presence checks (NOT `||`) so a deliberately persisted
196
217
  # `false` step output round-trips correctly. Smith's existing
197
218
  # `RunResult#output` derivation uses `compact.first`, which only
@@ -204,6 +225,17 @@ module Smith
204
225
  end
205
226
  end
206
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
+
207
239
  # Symbolize ONLY the top-level keys of last_failed_step + the
208
240
  # known value-symbols (`transition`, `from`, `to`, `error_kind`).
209
241
  # `error_family` stays a String (the family_fallback compares
@@ -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
@@ -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
@@ -22,6 +22,7 @@ module Smith
22
22
  current_invocation
23
23
  ].freeze
24
24
  THREAD_KEYS = %i[
25
+ smith_attribution
25
26
  smith_call_deadline
26
27
  smith_call_ledger
27
28
  smith_failed_agent_results