smith-agents 0.6.1 → 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 +264 -1
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +293 -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 +31 -177
- 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 +17 -11
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +134 -0
- 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 +60 -4
- data/lib/smith/tool/capture_configuration.rb +20 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +24 -0
- 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 +123 -0
- data/lib/smith/tool.rb +78 -57
- data/lib/smith/tool_capture_failed.rb +106 -0
- 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/branch_failure.rb +34 -13
- data/lib/smith/workflow/composite/branch_outcome.rb +19 -2
- data/lib/smith/workflow/composite/effects.rb +3 -1
- data/lib/smith/workflow/composite/error.rb +53 -5
- data/lib/smith/workflow/composite/error_evidence.rb +10 -2
- 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/fanout_execution.rb +12 -11
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel/cancellation_signal.rb +1 -1
- data/lib/smith/workflow/parallel/root_execution.rb +1 -1
- data/lib/smith/workflow/parallel.rb +17 -1
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/prepared_branch_execution.rb +9 -2
- data/lib/smith/workflow/retry_execution.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +13 -4
- data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
- data/lib/smith/workflow/transition.rb +11 -0
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -124
- data/lib/smith.rb +3 -0
- metadata +83 -5
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "dry-initializer"
|
|
4
4
|
|
|
5
5
|
require_relative "../../errors"
|
|
6
|
+
require_relative "../../tool_capture_failed"
|
|
6
7
|
require_relative "error"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
@@ -16,12 +17,14 @@ module Smith
|
|
|
16
17
|
def self.call(error) = new(error).call
|
|
17
18
|
|
|
18
19
|
def call
|
|
19
|
-
|
|
20
|
+
attributes = {
|
|
20
21
|
class_name: error_class_name,
|
|
21
22
|
family: error_family,
|
|
22
23
|
retryable: retryable_error?,
|
|
23
24
|
kind: error_kind
|
|
24
|
-
|
|
25
|
+
}
|
|
26
|
+
attributes.merge!(tool_capture_metadata) if error.is_a?(ToolCaptureFailed)
|
|
27
|
+
Error.new(attributes)
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
private
|
|
@@ -47,6 +50,7 @@ module Smith
|
|
|
47
50
|
|
|
48
51
|
def error_family
|
|
49
52
|
case error
|
|
53
|
+
when ToolCaptureFailed then "tool_capture_failed"
|
|
50
54
|
when ToolGuardrailFailed then "tool_guardrail_failed"
|
|
51
55
|
when DeterministicStepFailure then "deterministic_step_failure"
|
|
52
56
|
when DeadlineExceeded then "deadline_exceeded"
|
|
@@ -55,6 +59,10 @@ module Smith
|
|
|
55
59
|
else "other"
|
|
56
60
|
end
|
|
57
61
|
end
|
|
62
|
+
|
|
63
|
+
def tool_capture_metadata
|
|
64
|
+
{ tool_name: error.tool_name, reason: error.reason.to_s }
|
|
65
|
+
end
|
|
58
66
|
end
|
|
59
67
|
end
|
|
60
68
|
end
|
|
@@ -25,7 +25,7 @@ module Smith
|
|
|
25
25
|
saved_deadline = Tool.current_deadline
|
|
26
26
|
saved_call_ledger = Thread.current[:smith_call_ledger]
|
|
27
27
|
snapshot = ThreadContextSnapshot.new(
|
|
28
|
-
tool_attributes: %i[current_deadline current_tool_call_allowance],
|
|
28
|
+
tool_attributes: %i[current_deadline current_tool_call_allowance current_tool_execution_tracker],
|
|
29
29
|
thread_keys: %i[smith_call_deadline smith_call_ledger],
|
|
30
30
|
scoped_artifacts: false
|
|
31
31
|
)
|
|
@@ -41,6 +41,7 @@ module Smith
|
|
|
41
41
|
apply_agent_deadline(agent_class)
|
|
42
42
|
narrow_tool_deadline!
|
|
43
43
|
apply_agent_tool_calls(agent_class)
|
|
44
|
+
Tool.current_tool_execution_tracker ||= Tool::ExecutionTracker.new
|
|
44
45
|
apply_agent_call_ledger(agent_class)
|
|
45
46
|
end
|
|
46
47
|
|
|
@@ -49,6 +50,7 @@ module Smith
|
|
|
49
50
|
Thread.current[:smith_call_ledger] = call_ledger
|
|
50
51
|
clear_agent_deadline
|
|
51
52
|
clear_agent_tool_calls
|
|
53
|
+
Tool.current_tool_execution_tracker = nil
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def effective_call_ledger
|
|
@@ -74,7 +76,23 @@ module Smith
|
|
|
74
76
|
|
|
75
77
|
def apply_agent_tool_calls(agent_class)
|
|
76
78
|
agent_tc = agent_class&.budget&.dig(:tool_calls)
|
|
77
|
-
|
|
79
|
+
if agent_class&.tool_budget_exhaustion == :complete && agent_tc.nil?
|
|
80
|
+
raise AgentError, "tool_budget_exhaustion :complete requires a finite tool_calls budget"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Tool.current_tool_call_allowance = build_agent_tool_call_allowance(agent_class, agent_tc)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_agent_tool_call_allowance(agent_class, agent_tool_calls)
|
|
87
|
+
return unless agent_tool_calls
|
|
88
|
+
|
|
89
|
+
parent = Tool.current_tool_call_allowance
|
|
90
|
+
if parent && !parent.is_a?(Tool::CallAllowance)
|
|
91
|
+
raise AgentError, "agent tool_calls budgets cannot scope a legacy Hash tool call allowance"
|
|
92
|
+
end
|
|
93
|
+
return parent.scope(agent_tool_calls, on_exhaustion: agent_class.tool_budget_exhaustion) if parent
|
|
94
|
+
|
|
95
|
+
Tool::CallAllowance.new(agent_tool_calls, on_exhaustion: agent_class.tool_budget_exhaustion)
|
|
78
96
|
end
|
|
79
97
|
|
|
80
98
|
def clear_agent_tool_calls
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "agent_result"
|
|
4
4
|
require_relative "execution_binding_resolution"
|
|
5
|
+
require_relative "guarded_step_execution"
|
|
5
6
|
require_relative "prepared_branch_execution"
|
|
6
7
|
require_relative "step_completion"
|
|
7
8
|
require_relative "step_context"
|
|
@@ -10,6 +11,7 @@ module Smith
|
|
|
10
11
|
class Workflow
|
|
11
12
|
module Execution
|
|
12
13
|
include ExecutionBindingResolution
|
|
14
|
+
include GuardedStepExecution
|
|
13
15
|
include PreparedBranchExecution
|
|
14
16
|
include StepCompletion
|
|
15
17
|
include StepContext
|
|
@@ -39,15 +41,6 @@ module Smith
|
|
|
39
41
|
Tool.current_tool_result_collector = tool_result_collector
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
def run_guarded_step(transition)
|
|
43
|
-
return apply_composite_reduction!(transition) if @composite_reduction
|
|
44
|
-
|
|
45
|
-
@resolved_parallel_branch_count = preflight_branch_count(transition)
|
|
46
|
-
run_standard_guarded_step(transition)
|
|
47
|
-
ensure
|
|
48
|
-
@resolved_parallel_branch_count = nil
|
|
49
|
-
end
|
|
50
|
-
|
|
51
44
|
def run_standard_guarded_step(transition)
|
|
52
45
|
return dispatch_step(transition) if transition.deterministic?
|
|
53
46
|
return run_guarded_fanout_step(transition) if transition.fanout?
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../diagnostic_text"
|
|
7
|
+
require_relative "message_value_normalizer"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
class Workflow
|
|
11
|
+
class FailureDetailSnapshot
|
|
12
|
+
OMITTED_KEY = "smith_failure_details_omitted"
|
|
13
|
+
OMITTED_REASON_BYTES = 1_024
|
|
14
|
+
private_constant :OMITTED_KEY, :OMITTED_REASON_BYTES
|
|
15
|
+
|
|
16
|
+
extend Dry::Initializer
|
|
17
|
+
|
|
18
|
+
param :value
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
return if value.nil?
|
|
22
|
+
|
|
23
|
+
snapshot = MessageValueNormalizer.new(value, label: "workflow failure details").call
|
|
24
|
+
JSON.generate(snapshot)
|
|
25
|
+
snapshot
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
{
|
|
28
|
+
OMITTED_KEY => DiagnosticText.capture(e.message, max_bytes: OMITTED_REASON_BYTES)
|
|
29
|
+
}.freeze
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "failure_record_validator"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
class FailureReconstructor
|
|
11
|
+
FAMILY_BUILDERS = {
|
|
12
|
+
"deterministic_step_failure" => ->(record) { deterministic_step_failure(record) },
|
|
13
|
+
"tool_guardrail_failed" => ->(record) { tool_guardrail_failure(record) },
|
|
14
|
+
"tool_failure_notification_failed" => ->(record) { tool_failure_notification_failure(record) },
|
|
15
|
+
"tool_capture_failed" => ->(record) { Smith::ToolCaptureFailed.from_details(record.fetch(:error_details)) },
|
|
16
|
+
"tool_outcome_uncertain" => ->(record) { Smith::ToolOutcomeUncertain.new(record[:error_message]) },
|
|
17
|
+
"tool_execution_not_admitted" => ->(record) { Smith::ToolExecutionNotAdmitted.new(record[:error_message]) },
|
|
18
|
+
"bounded_completion_error" => ->(record) { Smith::BoundedCompletionError.new(record[:error_message]) },
|
|
19
|
+
"persisted_failure_invalid" => ->(record) { Smith::PersistedFailureInvalid.new(record[:error_message]) },
|
|
20
|
+
"deadline_exceeded" => ->(record) { Smith::DeadlineExceeded.new(record[:error_message]) },
|
|
21
|
+
"agent_error" => ->(record) { Smith::AgentError.new(record[:error_message]) },
|
|
22
|
+
"workflow_error" => ->(record) { Smith::WorkflowError.new(record[:error_message]) },
|
|
23
|
+
"other" => ->(record) { RuntimeError.new(record[:error_message]) }
|
|
24
|
+
}.freeze
|
|
25
|
+
SPECIAL_CLASS_BUILDERS = {
|
|
26
|
+
"Smith::Workflow::Composite::BranchFailure" => lambda { |record|
|
|
27
|
+
Smith::Workflow::Composite::BranchFailure.from_details(record[:error_details])
|
|
28
|
+
}
|
|
29
|
+
}.freeze
|
|
30
|
+
private_constant :FAMILY_BUILDERS, :SPECIAL_CLASS_BUILDERS
|
|
31
|
+
|
|
32
|
+
extend Dry::Initializer
|
|
33
|
+
|
|
34
|
+
option :snapshot
|
|
35
|
+
option :transition_normalizer
|
|
36
|
+
option :state_normalizer
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
{
|
|
40
|
+
transition: transition_normalizer.call(snapshot[:transition]),
|
|
41
|
+
from: state_normalizer.call(snapshot[:from]),
|
|
42
|
+
to: state_normalizer.call(snapshot[:to]),
|
|
43
|
+
error: reconstruct_error
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def reconstruct_error
|
|
50
|
+
FailureRecordValidator.new(snapshot).call
|
|
51
|
+
builder = SPECIAL_CLASS_BUILDERS[snapshot[:error_class]] || FAMILY_BUILDERS.fetch(snapshot[:error_family])
|
|
52
|
+
builder.call(snapshot)
|
|
53
|
+
rescue Smith::PersistedFailureInvalid
|
|
54
|
+
raise
|
|
55
|
+
rescue ArgumentError, KeyError, TypeError
|
|
56
|
+
raise Smith::PersistedFailureInvalid, "persisted workflow failure details are invalid"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.deterministic_step_failure(record)
|
|
60
|
+
Smith::DeterministicStepFailure.new(
|
|
61
|
+
record[:error_message],
|
|
62
|
+
retryable: record[:error_retryable],
|
|
63
|
+
kind: record[:error_kind],
|
|
64
|
+
details: record[:error_details]
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.tool_guardrail_failure(record)
|
|
69
|
+
Smith::ToolGuardrailFailed.new(record[:error_message], retryable: record[:error_retryable])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.tool_failure_notification_failure(record)
|
|
73
|
+
Smith::ToolFailureNotificationFailed.from_details(record.fetch(:error_details))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method :deterministic_step_failure, :tool_guardrail_failure, :tool_failure_notification_failure
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "../diagnostic_text"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "failure_detail_snapshot"
|
|
8
|
+
require_relative "failure_record_text"
|
|
9
|
+
|
|
10
|
+
module Smith
|
|
11
|
+
class Workflow
|
|
12
|
+
class FailureRecord
|
|
13
|
+
ERROR_FAMILIES = [
|
|
14
|
+
[Smith::DeterministicStepFailure, "deterministic_step_failure"],
|
|
15
|
+
[Smith::ToolFailureNotificationFailed, "tool_failure_notification_failed"],
|
|
16
|
+
[Smith::ToolCaptureFailed, "tool_capture_failed"],
|
|
17
|
+
[Smith::ToolOutcomeUncertain, "tool_outcome_uncertain"],
|
|
18
|
+
[Smith::ToolExecutionNotAdmitted, "tool_execution_not_admitted"],
|
|
19
|
+
[Smith::BoundedCompletionError, "bounded_completion_error"],
|
|
20
|
+
[Smith::PersistedFailureInvalid, "persisted_failure_invalid"],
|
|
21
|
+
[Smith::ToolGuardrailFailed, "tool_guardrail_failed"],
|
|
22
|
+
[Smith::DeadlineExceeded, "deadline_exceeded"],
|
|
23
|
+
[Smith::AgentError, "agent_error"],
|
|
24
|
+
[Smith::WorkflowError, "workflow_error"]
|
|
25
|
+
].freeze
|
|
26
|
+
BOOLEAN_VALUES = [true, false].freeze
|
|
27
|
+
EXCEPTION_CAUSE = Exception.instance_method(:cause)
|
|
28
|
+
EXCEPTION_MESSAGE = Exception.instance_method(:message)
|
|
29
|
+
MODULE_MATCH = Module.instance_method(:===)
|
|
30
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
31
|
+
OBJECT_CLASS = Object.instance_method(:class)
|
|
32
|
+
private_constant :ERROR_FAMILIES, :BOOLEAN_VALUES, :EXCEPTION_CAUSE, :EXCEPTION_MESSAGE, :MODULE_MATCH,
|
|
33
|
+
:MODULE_NAME, :OBJECT_CLASS
|
|
34
|
+
|
|
35
|
+
extend Dry::Initializer
|
|
36
|
+
|
|
37
|
+
option :step_result
|
|
38
|
+
|
|
39
|
+
def self.capture(step_result) = new(step_result:).call
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
error = step_result.fetch(:error)
|
|
43
|
+
{
|
|
44
|
+
transition: step_result[:transition],
|
|
45
|
+
from: step_result[:from],
|
|
46
|
+
to: step_result[:to],
|
|
47
|
+
**error_attributes(error)
|
|
48
|
+
}.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def error_attributes(error)
|
|
54
|
+
{
|
|
55
|
+
error_class: DiagnosticText.capture(error_class_name(error), max_bytes: 512),
|
|
56
|
+
error_family: error_family(error),
|
|
57
|
+
error_message: error_message(error),
|
|
58
|
+
error_retryable: retryable_value(error),
|
|
59
|
+
error_retry_forbidden: Smith::Errors.retry_forbidden?(error),
|
|
60
|
+
error_kind: error_kind(error),
|
|
61
|
+
error_details: error_details(error),
|
|
62
|
+
**cause_attributes(error)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Uncertainty wrappers replace the causal failure at the transition
|
|
67
|
+
# boundary; without this classification a restored host cannot
|
|
68
|
+
# distinguish a deadline, cancellation, or defect behind an
|
|
69
|
+
# uncertain tool outcome.
|
|
70
|
+
def cause_attributes(error)
|
|
71
|
+
cause = uncertainty_cause(error)
|
|
72
|
+
{
|
|
73
|
+
error_cause_class: cause && DiagnosticText.capture(error_class_name(cause), max_bytes: 512),
|
|
74
|
+
error_cause_family: cause && error_family(cause),
|
|
75
|
+
error_cause_message: cause && error_message(cause)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def uncertainty_cause(error)
|
|
80
|
+
return unless MODULE_MATCH.bind_call(Smith::ToolOutcomeUncertain, error)
|
|
81
|
+
|
|
82
|
+
cause = EXCEPTION_CAUSE.bind_call(error)
|
|
83
|
+
cause if MODULE_MATCH.bind_call(Exception, cause)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def error_family(error)
|
|
89
|
+
ERROR_FAMILIES.find { |error_class, _| MODULE_MATCH.bind_call(error_class, error) }&.last || "other"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def error_class_name(error)
|
|
93
|
+
error_class = OBJECT_CLASS.bind_call(error)
|
|
94
|
+
MODULE_NAME.bind_call(error_class) || "anonymous_error"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Never captures blank text: restore requires non-empty messages, so
|
|
98
|
+
# a blank live message takes the shared deterministic placeholder.
|
|
99
|
+
def error_message(error)
|
|
100
|
+
captured = DiagnosticText.capture(EXCEPTION_MESSAGE.bind_call(error))
|
|
101
|
+
captured.empty? ? FailureRecordText::MISSING_TEXT : captured
|
|
102
|
+
rescue StandardError
|
|
103
|
+
FailureRecordText::MISSING_TEXT
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def retryable_value(error)
|
|
107
|
+
return unless error.respond_to?(:retryable)
|
|
108
|
+
|
|
109
|
+
value = error.retryable
|
|
110
|
+
value if BOOLEAN_VALUES.include?(value)
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def error_kind(error)
|
|
116
|
+
return unless error.respond_to?(:kind)
|
|
117
|
+
|
|
118
|
+
value = error.kind
|
|
119
|
+
return unless value.is_a?(String) || value.is_a?(Symbol)
|
|
120
|
+
|
|
121
|
+
captured = DiagnosticText.capture(value.to_s, max_bytes: 256)
|
|
122
|
+
# Restore rejects blank kinds; an empty kind carries no signal.
|
|
123
|
+
captured unless captured.empty?
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def error_details(error)
|
|
129
|
+
return unless error.respond_to?(:details)
|
|
130
|
+
|
|
131
|
+
FailureDetailSnapshot.new(error.details).call
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
FailureDetailSnapshot.new(
|
|
134
|
+
smith_failure_details_omitted: DiagnosticText.capture(e.message, max_bytes: 1_024)
|
|
135
|
+
).call
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -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
|