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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +230 -0
- data/docs/CONFIGURATION.md +5 -5
- data/lib/smith/agent/completion_usage_recording.rb +21 -3
- data/lib/smith/agent/invocation_preparation.rb +24 -0
- data/lib/smith/agent/lifecycle.rb +4 -2
- data/lib/smith/agent/provider_attempt.rb +10 -4
- data/lib/smith/agent/provider_call_timing.rb +74 -0
- data/lib/smith/agent/provider_completion.rb +27 -9
- data/lib/smith/agent/provider_failure_handling.rb +2 -2
- data/lib/smith/agent/usage_entry_recording.rb +27 -4
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +23 -20
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool.rb +10 -1
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/composite/effects.rb +64 -33
- data/lib/smith/workflow/deterministic_execution.rb +2 -1
- data/lib/smith/workflow/deterministic_step.rb +14 -1
- data/lib/smith/workflow/dsl.rb +25 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +8 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guardrail_integration.rb +25 -0
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +33 -1
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +9 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/usage_entry.rb +32 -4
- data/lib/smith/workflow.rb +26 -1
- data/lib/smith.rb +7 -2
- 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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 =
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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, "
|
|
107
|
+
raise ArgumentError, "#{label} must be a UUID"
|
|
84
108
|
end
|
|
85
109
|
|
|
86
|
-
def
|
|
87
|
-
return if
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
data/lib/smith/workflow/dsl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|