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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +456 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +27 -6
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
- data/lib/smith/agent/chat_construction.rb +112 -0
- data/lib/smith/agent/completion.rb +46 -0
- data/lib/smith/agent/completion_usage_recording.rb +43 -0
- data/lib/smith/agent/dynamic_configuration.rb +69 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -0
- data/lib/smith/agent/invocation_preparation.rb +128 -0
- data/lib/smith/agent/lifecycle.rb +8 -275
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +34 -0
- data/lib/smith/agent/provider_call_timing.rb +74 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +148 -0
- data/lib/smith/agent/provider_failure_handling.rb +93 -0
- data/lib/smith/agent/provider_usage.rb +28 -0
- data/lib/smith/agent/reserved_input_bridge.rb +40 -0
- data/lib/smith/agent/usage_entry_recording.rb +66 -0
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +116 -0
- data/lib/smith/agent.rb +29 -197
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/context/observation_masking.rb +28 -4
- data/lib/smith/context/session.rb +7 -2
- data/lib/smith/diagnostic_text.rb +67 -0
- data/lib/smith/doctor/checks/configuration.rb +16 -3
- data/lib/smith/doctor/checks/models_registry.rb +75 -17
- data/lib/smith/errors.rb +34 -1
- 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/models/ambiguous_profile_error.rb +9 -0
- data/lib/smith/models/collision_error.rb +9 -0
- data/lib/smith/models/inference.rb +14 -0
- data/lib/smith/models/normalizer.rb +11 -67
- data/lib/smith/models/provider_qualified_registry.rb +101 -0
- data/lib/smith/models/tool_routing.rb +140 -0
- data/lib/smith/models.rb +24 -78
- data/lib/smith/persisted_failure_invalid.rb +7 -0
- data/lib/smith/pricing.rb +138 -17
- data/lib/smith/pricing_configuration_error.rb +7 -0
- data/lib/smith/provider_permanent_failure.rb +16 -0
- data/lib/smith/tool/argument_container_reader.rb +97 -0
- data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
- data/lib/smith/tool/argument_snapshot.rb +19 -0
- data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
- data/lib/smith/tool/argument_snapshot_result.rb +15 -0
- data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
- data/lib/smith/tool/bounded_completion_context.rb +131 -0
- data/lib/smith/tool/bounded_completion_controls.rb +113 -0
- data/lib/smith/tool/bounded_completion_guard.rb +101 -0
- data/lib/smith/tool/bounded_completion_installation.rb +20 -0
- data/lib/smith/tool/bounded_completion_state.rb +57 -0
- data/lib/smith/tool/budget_enforcement.rb +15 -7
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +101 -28
- data/lib/smith/tool/call_allowance_counter.rb +43 -0
- data/lib/smith/tool/call_batch.rb +47 -0
- data/lib/smith/tool/call_budget.rb +51 -0
- data/lib/smith/tool/call_reservation.rb +42 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +7 -88
- data/lib/smith/tool/execution_authority.rb +42 -0
- data/lib/smith/tool/execution_authorization.rb +17 -0
- data/lib/smith/tool/execution_batch.rb +100 -0
- data/lib/smith/tool/execution_batch_admission.rb +56 -0
- data/lib/smith/tool/execution_batch_builder.rb +122 -0
- data/lib/smith/tool/execution_batch_collection.rb +70 -0
- data/lib/smith/tool/execution_batch_invocations.rb +28 -0
- data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
- data/lib/smith/tool/execution_batch_registry.rb +99 -0
- data/lib/smith/tool/execution_batch_requests.rb +59 -0
- data/lib/smith/tool/execution_batch_source_call.rb +36 -0
- data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
- data/lib/smith/tool/execution_batch_sources.rb +32 -0
- data/lib/smith/tool/execution_batch_state.rb +110 -0
- data/lib/smith/tool/execution_dispatch.rb +78 -0
- data/lib/smith/tool/execution_failure_handling.rb +101 -0
- data/lib/smith/tool/execution_lifecycle.rb +28 -0
- data/lib/smith/tool/execution_tracker.rb +20 -0
- data/lib/smith/tool/fail_fast_completion.rb +24 -0
- data/lib/smith/tool/graceful_completion.rb +20 -0
- data/lib/smith/tool/invocation.rb +27 -0
- data/lib/smith/tool/invocation_request.rb +37 -0
- data/lib/smith/tool/invocation_sequence.rb +26 -0
- data/lib/smith/tool/legacy_call_allowance.rb +57 -0
- data/lib/smith/tool/scoped_context.rb +60 -8
- data/lib/smith/tool.rb +81 -13
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- 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 +2 -2
- data/lib/smith/workflow/agent_result.rb +4 -3
- data/lib/smith/workflow/artifact_integration.rb +2 -1
- data/lib/smith/workflow/composite/effects.rb +64 -31
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- 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 +10 -9
- data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
- data/lib/smith/workflow/failure_reconstructor.rb +79 -0
- data/lib/smith/workflow/failure_record.rb +139 -0
- data/lib/smith/workflow/failure_record_restore.rb +145 -0
- data/lib/smith/workflow/failure_record_text.rb +83 -0
- data/lib/smith/workflow/failure_record_validator.rb +113 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/guardrail_integration.rb +25 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +55 -20
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/retry_execution.rb +5 -1
- 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 +6 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +43 -5
- data/lib/smith/workflow.rb +43 -131
- data/lib/smith.rb +10 -2
- 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
|
|
42
|
-
errors
|
|
43
|
-
errors
|
|
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(
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|