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
|
@@ -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
|
|
@@ -29,7 +29,8 @@ module Smith
|
|
|
29
29
|
session_messages: snapshot_value(@session_messages || []),
|
|
30
30
|
tool_results: snapshot_value(@tool_results || []),
|
|
31
31
|
state: @state,
|
|
32
|
-
transition: transition
|
|
32
|
+
transition: transition,
|
|
33
|
+
last_agent_execution: snapshot_value(@last_agent_execution)
|
|
33
34
|
)
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -15,11 +15,24 @@ module Smith
|
|
|
15
15
|
@current_state = state
|
|
16
16
|
@transition_name = transition ? transition.name : options.fetch(:transition_name)
|
|
17
17
|
@allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
|
|
18
|
+
@last_agent_execution = options[:last_agent_execution]
|
|
18
19
|
@context_writes = {}
|
|
19
20
|
@routed_to = nil
|
|
20
21
|
@outcome = nil
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# The model id / provider that actually served the most recent serial
|
|
25
|
+
# `execute :agent` step (post fallback resolution), or nil if no serial
|
|
26
|
+
# agent step has run. Symmetric with `last_output`, which returns that
|
|
27
|
+
# step's content; use these to attribute an agent output to its model.
|
|
28
|
+
def last_agent_model
|
|
29
|
+
@last_agent_execution && @last_agent_execution[:model]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def last_agent_provider
|
|
33
|
+
@last_agent_execution && @last_agent_execution[:provider]
|
|
34
|
+
end
|
|
35
|
+
|
|
23
36
|
def last_output
|
|
24
37
|
return @last_output if defined?(@last_output)
|
|
25
38
|
|
|
@@ -59,7 +72,7 @@ module Smith
|
|
|
59
72
|
private
|
|
60
73
|
|
|
61
74
|
def validate_options!(options)
|
|
62
|
-
unknown = options.keys - %i[transition transition_name allowed_routes]
|
|
75
|
+
unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution]
|
|
63
76
|
raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
|
|
64
77
|
return if options[:transition] || options.key?(:transition_name)
|
|
65
78
|
|
data/lib/smith/workflow/dsl.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Smith
|
|
|
17
17
|
duplicate_transition_index(@transitions_by_state)
|
|
18
18
|
)
|
|
19
19
|
subclass.instance_variable_set(:@transition_order, (@transition_order || {}).dup)
|
|
20
|
+
subclass.instance_variable_set(:@generated_transitions, (@generated_transitions || []).dup)
|
|
20
21
|
subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
|
|
21
22
|
subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
|
|
22
23
|
subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
|
|
@@ -50,7 +51,12 @@ module Smith
|
|
|
50
51
|
declared = Transition.new(name, from: from, to: to, &)
|
|
51
52
|
name = declared.name
|
|
52
53
|
@transitions ||= {}
|
|
53
|
-
|
|
54
|
+
|
|
55
|
+
if @transitions.key?(name)
|
|
56
|
+
remove_from_transition_index(@transitions[name])
|
|
57
|
+
release_generated_transition_order(name)
|
|
58
|
+
end
|
|
59
|
+
|
|
54
60
|
@transitions[name] = declared
|
|
55
61
|
insert_into_transition_index(declared)
|
|
56
62
|
end
|
|
@@ -279,10 +285,28 @@ module Smith
|
|
|
279
285
|
transitions_by_state.delete(transition.from) if indexed.empty?
|
|
280
286
|
end
|
|
281
287
|
|
|
288
|
+
# A user redeclaration replacing a GENERATED transition takes a fresh
|
|
289
|
+
# declaration-position order; a genuine user redefinition keeps its
|
|
290
|
+
# original position (matrix-pinned redefinition semantics). Without
|
|
291
|
+
# this, the synthetic :fail generated when `state :failed` is
|
|
292
|
+
# declared keeps its early order number, and a later user-declared
|
|
293
|
+
# :fail sharing an origin state with a primary transition would sort
|
|
294
|
+
# ahead of it and shadow it at run time.
|
|
295
|
+
def release_generated_transition_order(name)
|
|
296
|
+
return unless generated_transitions.delete(name)
|
|
297
|
+
|
|
298
|
+
transition_order.delete(name)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def generated_transitions
|
|
302
|
+
@generated_transitions ||= []
|
|
303
|
+
end
|
|
304
|
+
|
|
282
305
|
def generate_fail_transition
|
|
283
306
|
@transitions ||= {}
|
|
284
307
|
return if @transitions.key?(:fail)
|
|
285
308
|
|
|
309
|
+
generated_transitions << :fail
|
|
286
310
|
transition(:fail, from: nil, to: :failed)
|
|
287
311
|
end
|
|
288
312
|
end
|
|
@@ -29,7 +29,9 @@ module Smith
|
|
|
29
29
|
|
|
30
30
|
def run_optimization_loop(state)
|
|
31
31
|
state.config[:max_rounds].times do |round|
|
|
32
|
-
|
|
32
|
+
# The overlay scopes every trace and usage fact from this round's
|
|
33
|
+
# generator and evaluator calls to the round that produced them.
|
|
34
|
+
result = Attribution.with(round: round) { run_optimization_round(state, round) }
|
|
33
35
|
return result if result
|
|
34
36
|
end
|
|
35
37
|
|
|
@@ -15,7 +15,54 @@ module Smith
|
|
|
15
15
|
Events::StepCompleted.new(
|
|
16
16
|
transition: transition.name.to_sym,
|
|
17
17
|
from: transition.from&.to_sym,
|
|
18
|
-
to: transition.to.to_sym
|
|
18
|
+
to: transition.to.to_sym,
|
|
19
|
+
workflow: Attribution.ambient.workflow
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Failure-path counterpart to emit_step_completed, fired from both
|
|
25
|
+
# failure handlers before they branch or re-raise, so an observer sees
|
|
26
|
+
# exactly where execution went dark. Classification reuses
|
|
27
|
+
# FailureRecord's bounded taxonomy; raw messages are never emitted. The
|
|
28
|
+
# rescue keeps a broken instrument from altering failure semantics:
|
|
29
|
+
# the original error, not an emission error, must win.
|
|
30
|
+
def emit_step_failed(step)
|
|
31
|
+
failure = FailureRecord.capture(step)
|
|
32
|
+
record_failed_transition_trace(failure)
|
|
33
|
+
emit_step_failed_event(failure)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Smith.config.logger&.error("Smith failed-step emission error: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `outcome`, not `result`: `result` is a reserved content key in the
|
|
39
|
+
# trace pipeline (it carries tool results and is stripped by the
|
|
40
|
+
# default content policy). Matches the :provider_call vocabulary.
|
|
41
|
+
# `from`/`to` stay present even when nil, matching the success-trace
|
|
42
|
+
# shape: an absent key would let the ambient attribution of an
|
|
43
|
+
# enclosing scope (a parent step's `from`) show through the
|
|
44
|
+
# fields-under-data merge and fabricate a foreign state fact.
|
|
45
|
+
def record_failed_transition_trace(failure)
|
|
46
|
+
data = {
|
|
47
|
+
transition: failure[:transition], from: failure[:from], to: failure[:to],
|
|
48
|
+
outcome: :failed,
|
|
49
|
+
error_class: failure[:error_class],
|
|
50
|
+
error_family: failure[:error_family]
|
|
51
|
+
}
|
|
52
|
+
data[:retryable] = failure[:error_retryable] unless failure[:error_retryable].nil?
|
|
53
|
+
Smith::Trace.record(type: :transition, data: data)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def emit_step_failed_event(failure)
|
|
57
|
+
Smith::Events.emit(
|
|
58
|
+
Events::StepFailed.new(
|
|
59
|
+
transition: DiagnosticText.capture(failure[:transition].to_s, max_bytes: 256).to_sym,
|
|
60
|
+
from: failure[:from]&.to_sym,
|
|
61
|
+
to: failure[:to]&.to_sym,
|
|
62
|
+
error_class: failure[:error_class],
|
|
63
|
+
error_family: failure[:error_family],
|
|
64
|
+
retryable: failure[:error_retryable],
|
|
65
|
+
workflow: Attribution.ambient.workflow
|
|
19
66
|
)
|
|
20
67
|
)
|
|
21
68
|
end
|
|
@@ -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
|
|
@@ -29,6 +31,10 @@ module Smith
|
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def execute_step_body(transition)
|
|
34
|
+
# Reset the per-step agent-attribution carrier so only a serial agent
|
|
35
|
+
# step that actually runs (below) leaves model/provider for complete_step;
|
|
36
|
+
# a deterministic step never sets it and must not inherit a stale value.
|
|
37
|
+
@pending_agent_execution = nil
|
|
32
38
|
output = with_scoped_artifacts { run_with_retry_policy(transition) }
|
|
33
39
|
StepCompletion.instance_method(:complete_step).bind_call(self, transition, output)
|
|
34
40
|
end
|
|
@@ -39,15 +45,6 @@ module Smith
|
|
|
39
45
|
Tool.current_tool_result_collector = tool_result_collector
|
|
40
46
|
end
|
|
41
47
|
|
|
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
48
|
def run_standard_guarded_step(transition)
|
|
52
49
|
return dispatch_step(transition) if transition.deterministic?
|
|
53
50
|
return run_guarded_fanout_step(transition) if transition.fanout?
|
|
@@ -100,6 +97,10 @@ module Smith
|
|
|
100
97
|
begin
|
|
101
98
|
result = execute_transition_body(transition, prepared_input: prepared_input)
|
|
102
99
|
agent_result = result.is_a?(AgentResult) ? result : nil
|
|
100
|
+
# Capture the model/provider that actually served this serial agent
|
|
101
|
+
# step (post fallback resolution) for complete_step to fold into the
|
|
102
|
+
# durable step record. Nil when no agent ran (e.g. unconfigured model).
|
|
103
|
+
@pending_agent_execution = agent_result && { model: agent_result.model_used, provider: agent_result.provider_used }
|
|
103
104
|
reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
|
|
104
105
|
reserved = nil
|
|
105
106
|
agent_result ? agent_result.content : result
|
|
@@ -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
|