smith-agents 0.7.0 → 0.8.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 +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- 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 +25 -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 +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -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 +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +29 -197
- 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/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/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 +71 -12
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- 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 +3 -1
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/execution.rb +2 -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/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- metadata +78 -11
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../diagnostic_text"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "failure_record_text"
|
|
9
|
+
require_relative "failure_record_validator"
|
|
10
|
+
require_relative "message_value_normalizer"
|
|
11
|
+
|
|
12
|
+
module Smith
|
|
13
|
+
class Workflow
|
|
14
|
+
class FailureRecordRestore
|
|
15
|
+
ATTRIBUTE_NAMES = %i[
|
|
16
|
+
transition from to error_class error_family error_message error_retryable error_retry_forbidden
|
|
17
|
+
error_kind error_details error_cause_class error_cause_family error_cause_message
|
|
18
|
+
].freeze
|
|
19
|
+
OPTIONAL_NAMES = %i[error_retry_forbidden error_cause_class error_cause_family error_cause_message].freeze
|
|
20
|
+
REQUIRED_NAMES = (ATTRIBUTE_NAMES - OPTIONAL_NAMES).freeze
|
|
21
|
+
ATTRIBUTE_KEYS = ATTRIBUTE_NAMES.to_h { |name| [name.to_s, name] }.freeze
|
|
22
|
+
MODULE_MATCH = Module.instance_method(:===)
|
|
23
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
24
|
+
private_constant :ATTRIBUTE_NAMES, :OPTIONAL_NAMES, :REQUIRED_NAMES, :ATTRIBUTE_KEYS, :MODULE_MATCH,
|
|
25
|
+
:HASH_EACH_PAIR
|
|
26
|
+
|
|
27
|
+
extend Dry::Initializer
|
|
28
|
+
|
|
29
|
+
param :raw
|
|
30
|
+
option :transition_normalizer
|
|
31
|
+
option :state_normalizer
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
return if raw.nil?
|
|
35
|
+
|
|
36
|
+
reject!("persisted workflow failure record must be a Hash") unless MODULE_MATCH.bind_call(Hash, raw)
|
|
37
|
+
|
|
38
|
+
values = attributes
|
|
39
|
+
FailureRecordValidator.new(restored_record(values)).call
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def restored_record(values)
|
|
45
|
+
identity_attributes(values).merge(error_attributes(values)).freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def identity_attributes(values)
|
|
49
|
+
{
|
|
50
|
+
transition: normalize_identifier(values.fetch(:transition), transition_normalizer, "transition"),
|
|
51
|
+
from: normalize_identifier(values.fetch(:from), state_normalizer, "from state"),
|
|
52
|
+
to: normalize_identifier(values.fetch(:to), state_normalizer, "to state")
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def error_attributes(values)
|
|
57
|
+
{
|
|
58
|
+
error_class: bounded_string(values.fetch(:error_class), 512, "class"),
|
|
59
|
+
error_family: bounded_string(values.fetch(:error_family), 64, "family"),
|
|
60
|
+
error_message: message_string(values.fetch(:error_message), "message"),
|
|
61
|
+
error_retryable: boolean_or_nil(values.fetch(:error_retryable), "retryable"),
|
|
62
|
+
error_retry_forbidden: boolean_or_nil(values[:error_retry_forbidden], "retry policy"),
|
|
63
|
+
error_kind: normalize_kind(values.fetch(:error_kind)),
|
|
64
|
+
error_details: normalize_details(values.fetch(:error_details)),
|
|
65
|
+
**cause_attributes(values)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cause_attributes(values)
|
|
70
|
+
{
|
|
71
|
+
error_cause_class: optional_string(values[:error_cause_class], 512, "cause class"),
|
|
72
|
+
error_cause_family: optional_string(values[:error_cause_family], 64, "cause family"),
|
|
73
|
+
error_cause_message: optional_message(values[:error_cause_message], "cause message")
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def attributes
|
|
78
|
+
values = {}
|
|
79
|
+
HASH_EACH_PAIR.bind_call(raw) do |key, value|
|
|
80
|
+
name = normalize_key(key)
|
|
81
|
+
reject!("persisted workflow failure record contains a duplicate attribute") if values.key?(name)
|
|
82
|
+
|
|
83
|
+
values[name] = value
|
|
84
|
+
end
|
|
85
|
+
missing = REQUIRED_NAMES - values.keys
|
|
86
|
+
reject!("persisted workflow failure record is incomplete") if missing.any?
|
|
87
|
+
values
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_key(key)
|
|
91
|
+
return key if symbol?(key) && ATTRIBUTE_NAMES.include?(key)
|
|
92
|
+
return ATTRIBUTE_KEYS.fetch(key) if string?(key) && ATTRIBUTE_KEYS.key?(key)
|
|
93
|
+
|
|
94
|
+
reject!("persisted workflow failure record contains an unknown attribute")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_identifier(value, normalizer, label)
|
|
98
|
+
bounded_string(value, 256, label).then { normalizer.call(_1) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalize_kind(value)
|
|
102
|
+
return if value.nil?
|
|
103
|
+
|
|
104
|
+
bounded_string(value, 256, "kind").to_sym
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalize_details(value)
|
|
108
|
+
return if value.nil?
|
|
109
|
+
|
|
110
|
+
snapshot = MessageValueNormalizer.new(value, label: "persisted workflow failure details").call
|
|
111
|
+
JSON.generate(snapshot)
|
|
112
|
+
snapshot
|
|
113
|
+
rescue WorkflowError, JSON::GeneratorError, EncodingError
|
|
114
|
+
reject!("persisted workflow failure details are invalid")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def bounded_string(value, limit, label)
|
|
118
|
+
FailureRecordText.capture(value, limit:, label:)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def optional_string(value, limit, label) = value.nil? ? nil : bounded_string(value, limit, label)
|
|
122
|
+
|
|
123
|
+
# Messages restore with capture normalization (placeholder for blank
|
|
124
|
+
# text, capture-identical truncation for overlong text) because legacy
|
|
125
|
+
# states persisted them unbounded; length alone never rejects restore.
|
|
126
|
+
def message_string(value, label)
|
|
127
|
+
FailureRecordText.capture(value, limit: DiagnosticText::MAX_BYTES, label:, normalize_length: true)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def optional_message(value, label) = value.nil? ? nil : message_string(value, label)
|
|
131
|
+
|
|
132
|
+
def boolean_or_nil(value, label)
|
|
133
|
+
return value if value.nil? || value == true || value == false
|
|
134
|
+
|
|
135
|
+
reject!("persisted workflow failure #{label} is invalid")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def reject!(message) = raise(Smith::PersistedFailureInvalid, message)
|
|
139
|
+
|
|
140
|
+
def string?(value) = MODULE_MATCH.bind_call(String, value)
|
|
141
|
+
|
|
142
|
+
def symbol?(value) = MODULE_MATCH.bind_call(Symbol, value)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -48,6 +48,10 @@ module Smith
|
|
|
48
48
|
# cycles so seed_validation can detect when the seed builder
|
|
49
49
|
# has changed in code since this workflow was persisted.
|
|
50
50
|
seed_digest: @seed_digest,
|
|
51
|
+
# Number of immutable seed messages at the beginning of session
|
|
52
|
+
# history. Context strategies may preserve this exact prefix while
|
|
53
|
+
# masking later workflow observations.
|
|
54
|
+
seed_message_count: @seed_message_count || 0,
|
|
51
55
|
# Step-in-progress idempotency marker. Set true between
|
|
52
56
|
# persist-before-advance and persist-after-advance when the
|
|
53
57
|
# workflow class opts into idempotency_mode :strict. Restore
|
|
@@ -76,6 +80,7 @@ module Smith
|
|
|
76
80
|
@ledger = rebuild_ledger(normalized[:budget_consumed] || {})
|
|
77
81
|
@next_transition_name = normalized[:next_transition_name]
|
|
78
82
|
@session_messages = normalized[:session_messages] || []
|
|
83
|
+
@seed_message_count = validated_seed_message_count(normalized)
|
|
79
84
|
@total_cost = normalized[:total_cost] || 0.0
|
|
80
85
|
@total_tokens = normalized[:total_tokens] || 0
|
|
81
86
|
@outcome = normalized[:outcome]
|
|
@@ -120,6 +125,14 @@ module Smith
|
|
|
120
125
|
"persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
|
|
121
126
|
end
|
|
122
127
|
|
|
128
|
+
def validated_seed_message_count(normalized)
|
|
129
|
+
count = normalized.fetch(:seed_message_count, 0)
|
|
130
|
+
return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
|
|
131
|
+
|
|
132
|
+
raise Smith::SerializationError,
|
|
133
|
+
"persisted workflow seed_message_count must be between 0 and session message count"
|
|
134
|
+
end
|
|
135
|
+
|
|
123
136
|
def validate_definition_digest!(normalized)
|
|
124
137
|
stored = normalized[:definition_digest]
|
|
125
138
|
return if stored.nil?
|
|
@@ -200,20 +213,11 @@ module Smith
|
|
|
200
213
|
# values become strings).
|
|
201
214
|
def restore_last_failed_step(normalized)
|
|
202
215
|
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
|
-
}
|
|
216
|
+
FailureRecordRestore.new(
|
|
217
|
+
raw,
|
|
218
|
+
transition_normalizer: method(:normalize_transition_name),
|
|
219
|
+
state_normalizer: method(:normalize_state_name)
|
|
220
|
+
).call
|
|
217
221
|
end
|
|
218
222
|
|
|
219
223
|
def restore_core_fields(normalized)
|
|
@@ -234,9 +238,9 @@ module Smith
|
|
|
234
238
|
end
|
|
235
239
|
|
|
236
240
|
manager = self.class.context_manager
|
|
237
|
-
if manager
|
|
241
|
+
if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
238
242
|
ctx = normalized[:context]
|
|
239
|
-
existing = ctx.is_a?(Hash) ? ctx.keys.map
|
|
243
|
+
existing = ctx.is_a?(Hash) ? ctx.keys.map(&:to_sym) : []
|
|
240
244
|
seed = manager.persist_auto_seed.map(&:to_sym)
|
|
241
245
|
@persisted_keys = ::Set.new(existing + seed)
|
|
242
246
|
else
|
|
@@ -309,6 +313,7 @@ module Smith
|
|
|
309
313
|
|
|
310
314
|
def normalize_transition_name(value)
|
|
311
315
|
return if value.nil?
|
|
316
|
+
|
|
312
317
|
transition = self.class.find_transition(value)
|
|
313
318
|
return transition.name if transition
|
|
314
319
|
return value unless value.is_a?(String)
|
|
@@ -416,9 +421,7 @@ module Smith
|
|
|
416
421
|
manager = self.class.context_manager
|
|
417
422
|
return nil unless manager
|
|
418
423
|
|
|
419
|
-
if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
420
|
-
return :auto
|
|
421
|
-
end
|
|
424
|
+
return :auto if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
422
425
|
|
|
423
426
|
keys = manager.persist
|
|
424
427
|
keys.empty? ? nil : keys
|
|
@@ -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?
|
|
@@ -15,8 +15,11 @@ module Smith
|
|
|
15
15
|
current_deadline
|
|
16
16
|
current_ledger
|
|
17
17
|
current_tool_call_allowance
|
|
18
|
+
current_tool_execution_tracker
|
|
18
19
|
current_tool_result_collector
|
|
19
20
|
current_invocation_context
|
|
21
|
+
current_invocation_sequence
|
|
22
|
+
current_invocation
|
|
20
23
|
].freeze
|
|
21
24
|
THREAD_KEYS = %i[
|
|
22
25
|
smith_call_deadline
|
|
@@ -68,14 +71,14 @@ module Smith
|
|
|
68
71
|
def restore_context!
|
|
69
72
|
raise WorkflowError, "thread context snapshot has already been restored" if @restored
|
|
70
73
|
|
|
71
|
-
@tool_values.each { |attribute, value| Tool.
|
|
74
|
+
@tool_values.each { |attribute, value| Tool.send("#{attribute}=", value) }
|
|
72
75
|
@thread_values.each { |key, value| Thread.current[key] = value }
|
|
73
76
|
Smith.scoped_artifacts = @artifact_store if scoped_artifacts
|
|
74
77
|
@restored = true
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
def capture_tool_values
|
|
78
|
-
tool_attributes.to_h { |attribute| [attribute, Tool.
|
|
81
|
+
tool_attributes.to_h { |attribute| [attribute, Tool.send(attribute)] }.freeze
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
def capture_thread_values
|
|
@@ -336,10 +336,7 @@ module Smith
|
|
|
336
336
|
|
|
337
337
|
raise WorkflowError, "retry_on error classes must inherit from StandardError"
|
|
338
338
|
end
|
|
339
|
-
|
|
340
|
-
message = "retry_on cannot retry Smith::ToolCaptureFailed because the tool outcome may be uncertain"
|
|
341
|
-
raise WorkflowError, message
|
|
342
|
-
end
|
|
339
|
+
reject_retry_forbidden!(error_classes)
|
|
343
340
|
|
|
344
341
|
ExponentialBackoff.new(
|
|
345
342
|
attempts:,
|
|
@@ -352,6 +349,14 @@ module Smith
|
|
|
352
349
|
raise WorkflowError, "retry_on #{e.message}"
|
|
353
350
|
end
|
|
354
351
|
|
|
352
|
+
def reject_retry_forbidden!(error_classes)
|
|
353
|
+
forbidden = error_classes.find { |error_class| Smith::Errors.retry_forbidden_class?(error_class) }
|
|
354
|
+
return unless forbidden
|
|
355
|
+
|
|
356
|
+
label = forbidden.name || "an anonymous terminal tool-evidence error class"
|
|
357
|
+
raise WorkflowError, "retry_on cannot retry #{label} because the tool outcome may be uncertain"
|
|
358
|
+
end
|
|
359
|
+
|
|
355
360
|
def normalize_deterministic_routes!(routes)
|
|
356
361
|
return nil if routes.nil?
|
|
357
362
|
raise WorkflowError, "deterministic routes must be an Array" unless routes.is_a?(Array)
|
|
@@ -10,6 +10,7 @@ module Smith
|
|
|
10
10
|
:usage_id,
|
|
11
11
|
:agent_name,
|
|
12
12
|
:model,
|
|
13
|
+
:provider,
|
|
13
14
|
:input_tokens,
|
|
14
15
|
:output_tokens,
|
|
15
16
|
:cost,
|
|
@@ -17,11 +18,20 @@ module Smith
|
|
|
17
18
|
:recorded_at,
|
|
18
19
|
keyword_init: true
|
|
19
20
|
) do
|
|
21
|
+
def initialize(**attributes)
|
|
22
|
+
attributes = attributes.transform_values do |value|
|
|
23
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
24
|
+
end
|
|
25
|
+
super(**attributes) # rubocop:disable Style/SuperArguments
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
def self.from_h(hash)
|
|
21
30
|
sym = hash.transform_keys(&:to_sym)
|
|
22
31
|
filtered = sym.slice(*members)
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
%i[agent_name provider attempt_kind].each do |attribute|
|
|
33
|
+
filtered[attribute] = filtered[attribute].to_sym if filtered[attribute].is_a?(String)
|
|
34
|
+
end
|
|
25
35
|
new(**filtered)
|
|
26
36
|
end
|
|
27
37
|
end
|