smith-agents 0.10.0 → 0.12.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 +901 -0
- data/README.md +636 -20
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +100 -14
- data/docs/PATTERNS.md +88 -12
- data/docs/PERSISTENCE.md +738 -6
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +71 -7
- data/lib/smith/agent/provider_completion.rb +41 -38
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +75 -2
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -48
- data/lib/smith/agent.rb +14 -1
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +10 -4
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +21 -6
- data/lib/smith/context.rb +28 -0
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +35 -17
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +304 -6
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +14 -8
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +62 -11
- data/lib/smith/workflow/deterministic_step.rb +56 -7
- data/lib/smith/workflow/dsl.rb +74 -5
- data/lib/smith/workflow/durability.rb +140 -31
- data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +32 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +41 -2
- data/lib/smith/workflow/failure_record.rb +12 -12
- data/lib/smith/workflow/failure_record_validator.rb +32 -5
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +162 -22
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +71 -8
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +40 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +117 -19
- data/lib/smith.rb +103 -47
- metadata +88 -1
|
@@ -18,14 +18,33 @@ module Smith
|
|
|
18
18
|
"bounded_completion_error" => ->(record) { Smith::BoundedCompletionError.new(record[:error_message]) },
|
|
19
19
|
"persisted_failure_invalid" => ->(record) { Smith::PersistedFailureInvalid.new(record[:error_message]) },
|
|
20
20
|
"deadline_exceeded" => ->(record) { Smith::DeadlineExceeded.new(record[:error_message]) },
|
|
21
|
+
"provider_permanent_failure" => ->(record) { provider_permanent_failure(record) },
|
|
22
|
+
"budget_exceeded" => ->(record) { budget_exceeded(record) },
|
|
23
|
+
"guardrail_failed" => ->(record) { Smith::GuardrailFailed.new(record[:error_message]) },
|
|
21
24
|
"agent_error" => ->(record) { Smith::AgentError.new(record[:error_message]) },
|
|
22
25
|
"workflow_error" => ->(record) { Smith::WorkflowError.new(record[:error_message]) },
|
|
26
|
+
"output_failure" => lambda { |record|
|
|
27
|
+
Smith::OutputFailure.from_details(record.fetch(:error_details), message: record[:error_message])
|
|
28
|
+
},
|
|
29
|
+
"child_runs_failed" => lambda { |record|
|
|
30
|
+
Smith::ChildRunsFailed.from_details(record.fetch(:error_details), message: record[:error_message])
|
|
31
|
+
},
|
|
32
|
+
"wait_timed_out" => lambda { |record|
|
|
33
|
+
Smith::WaitTimedOut.from_details(record.fetch(:error_details), message: record[:error_message])
|
|
34
|
+
},
|
|
23
35
|
"other" => ->(record) { RuntimeError.new(record[:error_message]) }
|
|
24
36
|
}.freeze
|
|
25
37
|
SPECIAL_CLASS_BUILDERS = {
|
|
26
38
|
"Smith::Workflow::Composite::BranchFailure" => lambda { |record|
|
|
27
39
|
Smith::Workflow::Composite::BranchFailure.from_details(record[:error_details])
|
|
28
|
-
}
|
|
40
|
+
},
|
|
41
|
+
"Smith::BlankAgentOutputError" => ->(record) { blank_agent_output_failure(record) },
|
|
42
|
+
"Smith::PersistedStateTooLarge" => lambda { |record|
|
|
43
|
+
Smith::PersistedStateTooLarge.from_details(record.fetch(:error_details), message: record[:error_message])
|
|
44
|
+
},
|
|
45
|
+
**Smith::OutputFailure.restorers.transform_values do |restore|
|
|
46
|
+
->(record) { restore.call(record.fetch(:error_details), record[:error_message]) }
|
|
47
|
+
end
|
|
29
48
|
}.freeze
|
|
30
49
|
private_constant :FAMILY_BUILDERS, :SPECIAL_CLASS_BUILDERS
|
|
31
50
|
|
|
@@ -73,7 +92,27 @@ module Smith
|
|
|
73
92
|
Smith::ToolFailureNotificationFailed.from_details(record.fetch(:error_details))
|
|
74
93
|
end
|
|
75
94
|
|
|
76
|
-
|
|
95
|
+
# A host ledger's refusal stays one, so a host can tell waiting for
|
|
96
|
+
# budget from a run whose own budget ran out.
|
|
97
|
+
def self.budget_exceeded(record)
|
|
98
|
+
error_class = record[:error_class] == "Smith::BudgetUnavailable" ? Smith::BudgetUnavailable : Smith::BudgetExceeded
|
|
99
|
+
error_class.new(record[:error_message])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.provider_permanent_failure(record)
|
|
103
|
+
Smith::ProviderPermanentFailure.from_details(record.fetch(:error_details), message: record[:error_message])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.blank_agent_output_failure(record)
|
|
107
|
+
details = record[:error_details]
|
|
108
|
+
return Smith::AgentError.new(record[:error_message]) if details.nil?
|
|
109
|
+
|
|
110
|
+
Smith::BlankAgentOutputError.from_details(details)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private_class_method :budget_exceeded, :deterministic_step_failure, :tool_guardrail_failure,
|
|
114
|
+
:tool_failure_notification_failure, :provider_permanent_failure,
|
|
115
|
+
:blank_agent_output_failure
|
|
77
116
|
end
|
|
78
117
|
end
|
|
79
118
|
end
|
|
@@ -4,6 +4,8 @@ require "dry-initializer"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../diagnostic_text"
|
|
6
6
|
require_relative "../errors"
|
|
7
|
+
require_relative "../output_failure"
|
|
8
|
+
require_relative "../child_runs_failed"
|
|
7
9
|
require_relative "failure_detail_snapshot"
|
|
8
10
|
require_relative "failure_record_text"
|
|
9
11
|
|
|
@@ -20,17 +22,20 @@ module Smith
|
|
|
20
22
|
[Smith::PersistedFailureInvalid, "persisted_failure_invalid"],
|
|
21
23
|
[Smith::ToolGuardrailFailed, "tool_guardrail_failed"],
|
|
22
24
|
[Smith::DeadlineExceeded, "deadline_exceeded"],
|
|
25
|
+
[Smith::ProviderPermanentFailure, "provider_permanent_failure"],
|
|
26
|
+
[Smith::BudgetExceeded, "budget_exceeded"],
|
|
27
|
+
[Smith::GuardrailFailed, "guardrail_failed"],
|
|
28
|
+
[Smith::OutputFailure, "output_failure"],
|
|
23
29
|
[Smith::AgentError, "agent_error"],
|
|
30
|
+
[Smith::WaitTimedOut, "wait_timed_out"],
|
|
31
|
+
[Smith::ChildRunsFailed, "child_runs_failed"],
|
|
24
32
|
[Smith::WorkflowError, "workflow_error"]
|
|
25
33
|
].freeze
|
|
26
34
|
BOOLEAN_VALUES = [true, false].freeze
|
|
27
35
|
EXCEPTION_CAUSE = Exception.instance_method(:cause)
|
|
28
36
|
EXCEPTION_MESSAGE = Exception.instance_method(:message)
|
|
29
37
|
MODULE_MATCH = Module.instance_method(:===)
|
|
30
|
-
|
|
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
|
|
38
|
+
private_constant :ERROR_FAMILIES, :BOOLEAN_VALUES, :EXCEPTION_CAUSE, :EXCEPTION_MESSAGE, :MODULE_MATCH
|
|
34
39
|
|
|
35
40
|
extend Dry::Initializer
|
|
36
41
|
|
|
@@ -52,7 +57,7 @@ module Smith
|
|
|
52
57
|
|
|
53
58
|
def error_attributes(error)
|
|
54
59
|
{
|
|
55
|
-
error_class: DiagnosticText.
|
|
60
|
+
error_class: DiagnosticText.error_class_name(error),
|
|
56
61
|
error_family: error_family(error),
|
|
57
62
|
error_message: error_message(error),
|
|
58
63
|
error_retryable: retryable_value(error),
|
|
@@ -70,7 +75,7 @@ module Smith
|
|
|
70
75
|
def cause_attributes(error)
|
|
71
76
|
cause = uncertainty_cause(error)
|
|
72
77
|
{
|
|
73
|
-
error_cause_class: cause && DiagnosticText.
|
|
78
|
+
error_cause_class: cause && DiagnosticText.error_class_name(cause),
|
|
74
79
|
error_cause_family: cause && error_family(cause),
|
|
75
80
|
error_cause_message: cause && error_message(cause)
|
|
76
81
|
}
|
|
@@ -89,11 +94,6 @@ module Smith
|
|
|
89
94
|
ERROR_FAMILIES.find { |error_class, _| MODULE_MATCH.bind_call(error_class, error) }&.last || "other"
|
|
90
95
|
end
|
|
91
96
|
|
|
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
97
|
# Never captures blank text: restore requires non-empty messages, so
|
|
98
98
|
# a blank live message takes the shared deterministic placeholder.
|
|
99
99
|
def error_message(error)
|
|
@@ -131,7 +131,7 @@ module Smith
|
|
|
131
131
|
FailureDetailSnapshot.new(error.details).call
|
|
132
132
|
rescue StandardError => e
|
|
133
133
|
FailureDetailSnapshot.new(
|
|
134
|
-
smith_failure_details_omitted: DiagnosticText.capture(e
|
|
134
|
+
smith_failure_details_omitted: DiagnosticText.capture(error_message(e), max_bytes: 1_024)
|
|
135
135
|
).call
|
|
136
136
|
end
|
|
137
137
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "dry-initializer"
|
|
4
4
|
|
|
5
5
|
require_relative "../errors"
|
|
6
|
+
require_relative "../output_failure"
|
|
7
|
+
require_relative "../child_runs_failed"
|
|
6
8
|
require_relative "composite/branch_failure"
|
|
7
9
|
|
|
8
10
|
module Smith
|
|
@@ -19,15 +21,29 @@ module Smith
|
|
|
19
21
|
"Smith::PersistedFailureInvalid" => "persisted_failure_invalid",
|
|
20
22
|
"Smith::AgentError" => "agent_error",
|
|
21
23
|
"Smith::BlankAgentOutputError" => "agent_error",
|
|
24
|
+
**Smith::OutputFailure.restorers.transform_values { "output_failure" },
|
|
22
25
|
"Smith::DeadlineExceeded" => "deadline_exceeded",
|
|
23
|
-
"Smith::
|
|
26
|
+
"Smith::ProviderPermanentFailure" => "provider_permanent_failure",
|
|
27
|
+
"Smith::BudgetExceeded" => "budget_exceeded", "Smith::BudgetUnavailable" => "budget_exceeded",
|
|
28
|
+
"Smith::GuardrailFailed" => "guardrail_failed",
|
|
29
|
+
"Smith::WaitTimedOut" => "wait_timed_out",
|
|
30
|
+
"Smith::ChildRunsFailed" => "child_runs_failed",
|
|
31
|
+
"Smith::WorkflowError" => "workflow_error", "Smith::PersistedStateTooLarge" => "workflow_error",
|
|
24
32
|
"Smith::UnresolvedTransitionError" => "workflow_error",
|
|
25
33
|
"Smith::Workflow::Composite::BranchFailure" => "workflow_error"
|
|
26
34
|
}.freeze
|
|
35
|
+
# Records written before these classes had their own families carry
|
|
36
|
+
# "other" and no details; they keep restoring exactly as they did.
|
|
37
|
+
LEGACY_ERROR_FAMILIES = {
|
|
38
|
+
"Smith::ProviderPermanentFailure" => "other",
|
|
39
|
+
"Smith::BudgetExceeded" => "other",
|
|
40
|
+
"Smith::GuardrailFailed" => "other"
|
|
41
|
+
}.freeze
|
|
27
42
|
KNOWN_FAMILIES = %w[
|
|
28
43
|
deterministic_step_failure tool_guardrail_failed tool_failure_notification_failed tool_capture_failed
|
|
29
44
|
tool_outcome_uncertain tool_execution_not_admitted bounded_completion_error persisted_failure_invalid
|
|
30
|
-
deadline_exceeded agent_error workflow_error
|
|
45
|
+
deadline_exceeded provider_permanent_failure budget_exceeded guardrail_failed agent_error workflow_error
|
|
46
|
+
wait_timed_out output_failure child_runs_failed other
|
|
31
47
|
].freeze
|
|
32
48
|
RETRY_FORBIDDEN_FAMILIES = %w[
|
|
33
49
|
tool_capture_failed tool_failure_notification_failed tool_outcome_uncertain tool_execution_not_admitted
|
|
@@ -40,11 +56,17 @@ module Smith
|
|
|
40
56
|
},
|
|
41
57
|
"Smith::Workflow::Composite::BranchFailure" => lambda { |details|
|
|
42
58
|
Smith::Workflow::Composite::BranchFailure.from_details(details)
|
|
43
|
-
}
|
|
59
|
+
},
|
|
60
|
+
"Smith::ProviderPermanentFailure" => ->(details) { Smith::ProviderPermanentFailure.from_details(details) },
|
|
61
|
+
**[WaitTimedOut, ChildRunsFailed, PersistedStateTooLarge].to_h { [_1.name, _1.method(:from_details)] },
|
|
62
|
+
"Smith::BlankAgentOutputError" => lambda { |details|
|
|
63
|
+
Smith::BlankAgentOutputError.from_details(details) unless details.nil?
|
|
64
|
+
},
|
|
65
|
+
**Smith::OutputFailure.restorers
|
|
44
66
|
}.freeze
|
|
45
67
|
BOOLEAN_VALUES = [true, false].freeze
|
|
46
|
-
private_constant :KNOWN_ERROR_FAMILIES, :
|
|
47
|
-
:BOOLEAN_VALUES
|
|
68
|
+
private_constant :KNOWN_ERROR_FAMILIES, :LEGACY_ERROR_FAMILIES, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES,
|
|
69
|
+
:DETAIL_VALIDATORS, :BOOLEAN_VALUES
|
|
48
70
|
|
|
49
71
|
extend Dry::Initializer
|
|
50
72
|
|
|
@@ -74,10 +96,13 @@ module Smith
|
|
|
74
96
|
def validate_class_family!
|
|
75
97
|
expected = KNOWN_ERROR_FAMILIES[snapshot[:error_class]]
|
|
76
98
|
return unless expected && family != expected
|
|
99
|
+
return if legacy_family?
|
|
77
100
|
|
|
78
101
|
reject!("persisted workflow failure class and family disagree")
|
|
79
102
|
end
|
|
80
103
|
|
|
104
|
+
def legacy_family? = LEGACY_ERROR_FAMILIES[snapshot[:error_class]] == family
|
|
105
|
+
|
|
81
106
|
def validate_retry_policy!
|
|
82
107
|
forbidden = snapshot[:error_retry_forbidden]
|
|
83
108
|
return if forbidden.nil?
|
|
@@ -103,6 +128,8 @@ module Smith
|
|
|
103
128
|
end
|
|
104
129
|
|
|
105
130
|
def validate_details!
|
|
131
|
+
return if legacy_family?
|
|
132
|
+
|
|
106
133
|
validator = DETAIL_VALIDATORS[snapshot[:error_class]]
|
|
107
134
|
validator&.call(snapshot[:error_details])
|
|
108
135
|
end
|
|
@@ -20,13 +20,10 @@ module Smith
|
|
|
20
20
|
branch_agent_classes = fanout_agent_classes(transition, branches)
|
|
21
21
|
run_workflow_input_guardrails
|
|
22
22
|
run_fanout_agent_input_guardrails(branch_agent_classes)
|
|
23
|
-
prepared_input =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
branch_agent_classes: branch_agent_classes,
|
|
28
|
-
prepared_input: prepared_input
|
|
29
|
-
)
|
|
23
|
+
prepared_input = step_prepared_input(transition)
|
|
24
|
+
return send_fanout_batch(transition, branches, branch_agent_classes, prepared_input) if batch_send?(transition)
|
|
25
|
+
|
|
26
|
+
output = execute_fanout_step(transition, branches:, branch_agent_classes:, prepared_input:)
|
|
30
27
|
run_workflow_output_guardrails(output)
|
|
31
28
|
output
|
|
32
29
|
end
|
|
@@ -68,7 +65,7 @@ module Smith
|
|
|
68
65
|
def guarded_fanout_branch_call(agent_class, env, signal)
|
|
69
66
|
check_cancellation!(signal)
|
|
70
67
|
check_deadline!
|
|
71
|
-
result =
|
|
68
|
+
result = invoke_agent(agent_class, env.prepared_input)
|
|
72
69
|
output = result.is_a?(AgentResult) ? result.content : result
|
|
73
70
|
validate_data_volume!(output, agent_class)
|
|
74
71
|
run_agent_output_guardrails(output, agent_class)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A step that may send its requests as a batch (`mode:`) needs
|
|
9
|
+
# Smith.config.batch_queue, and agents whose calls can go as one: a
|
|
10
|
+
# model whose provider has a batch interface in Smith (Anthropic's) and
|
|
11
|
+
# no tools (a batch request is one turn). Errors for `mode: :batch`;
|
|
12
|
+
# warnings for a mode callable, which may choose :sync, and for a
|
|
13
|
+
# model or tools given by a block, known only per run.
|
|
14
|
+
class BatchDiagnostic
|
|
15
|
+
extend Dry::Initializer
|
|
16
|
+
|
|
17
|
+
param :graph
|
|
18
|
+
|
|
19
|
+
def to_a
|
|
20
|
+
graph.reachable_transitions.select(&:batches?).flat_map do |transition|
|
|
21
|
+
severity = transition.batch_mode == :batch ? :error : :warning
|
|
22
|
+
[*queue_missing(transition), *agents(transition).filter_map { unavailable(transition, _1, severity) }]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def agents(transition)
|
|
29
|
+
return transition.fanout_config.fetch(:branches).values if transition.fanout?
|
|
30
|
+
|
|
31
|
+
[transition.agent_name]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def queue_missing(transition)
|
|
35
|
+
return [] if Smith.config.batch_queue
|
|
36
|
+
|
|
37
|
+
[Diagnostic.new(
|
|
38
|
+
severity: :error, code: :batch_queue_missing, transition: transition.name,
|
|
39
|
+
message: "Transition #{Reference.format(transition.name)} may send its requests as a batch, which goes " \
|
|
40
|
+
"through Smith.config.batch_queue, and none is configured.",
|
|
41
|
+
suggestion: "Set Smith.config.batch_queue to the host's queue (enqueue(custom_id:, await_token:, " \
|
|
42
|
+
"request:, execution_key:))."
|
|
43
|
+
)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unavailable(transition, agent, severity)
|
|
47
|
+
agent_class = Agent::Registry.binding_for(agent)&.fetch(:agent_class)
|
|
48
|
+
return unless agent_class.respond_to?(:chat_kwargs)
|
|
49
|
+
|
|
50
|
+
reason = unavailable_reason(agent_class)
|
|
51
|
+
return unless reason
|
|
52
|
+
|
|
53
|
+
severity = :warning if agent_class.model_block || agent_class.tools.is_a?(Proc)
|
|
54
|
+
Diagnostic.new(
|
|
55
|
+
severity:, code: :batch_unavailable, transition: transition.name, target: agent,
|
|
56
|
+
message: "Transition #{Reference.format(transition.name)} may batch agent #{Reference.format(agent)}, " \
|
|
57
|
+
"#{reason}.",
|
|
58
|
+
suggestion: "Give the agent an Anthropic model and no tools, or keep the step synchronous."
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unavailable_reason(agent_class)
|
|
63
|
+
tools = agent_class.respond_to?(:tools) ? agent_class.tools : []
|
|
64
|
+
return "which has tools (a batch request is one turn)" unless tools.respond_to?(:empty?) && tools.empty?
|
|
65
|
+
|
|
66
|
+
provider = provider_of(agent_class)
|
|
67
|
+
"whose model's provider (#{provider || "unknown"}) has no batch interface in Smith" unless
|
|
68
|
+
provider == "anthropic"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def provider_of(agent_class)
|
|
72
|
+
model, provider = agent_class.chat_kwargs.values_at(:model, :provider)
|
|
73
|
+
return provider.to_s if provider
|
|
74
|
+
return unless model
|
|
75
|
+
|
|
76
|
+
Smith::Models.find_or_infer(model)&.provider&.to_s
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A parallel or fan-out step that declares more branches than
|
|
9
|
+
# Smith.config.parallel_branch_limit allows.
|
|
10
|
+
class BranchLimitDiagnostic
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
option :transition
|
|
14
|
+
option :workflow_class, optional: true
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
branch_count = static_branch_count
|
|
18
|
+
limit = Smith.config.parallel_branch_limit
|
|
19
|
+
return unless branch_count && branch_count > limit
|
|
20
|
+
|
|
21
|
+
Diagnostic.new(
|
|
22
|
+
severity: :error,
|
|
23
|
+
code: :parallel_branch_limit_exceeded,
|
|
24
|
+
transition: transition.name,
|
|
25
|
+
message: "Transition #{Reference.format(transition.name)} declares #{branch_count} parallel branches, " \
|
|
26
|
+
"exceeding the configured limit #{limit}.",
|
|
27
|
+
suggestion: "Reduce the branch count or explicitly raise Smith.config.parallel_branch_limit."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def static_branch_count
|
|
34
|
+
return transition.fanout_config.fetch(:branches).length if transition.fanout?
|
|
35
|
+
|
|
36
|
+
transition.parallel_count if transition.parallel?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A workflow with a `budget_ledger` holds each provider request on it,
|
|
9
|
+
# priced from the pricing catalog under the id RubyLLM resolves the
|
|
10
|
+
# model to (as holds and Smith's own usage are): an agent whose model
|
|
11
|
+
# (or fallback model) has no price there gives the ledger holds and
|
|
12
|
+
# amounts without a cost (nil), a warning. The agents are those of each
|
|
13
|
+
# step (a fan-out's branches, an optimizer's generator and evaluator,
|
|
14
|
+
# an orchestrator and its worker) and of a nested workflow without a
|
|
15
|
+
# ledger of its own, which charges this one. A model given by a block
|
|
16
|
+
# is known only per run, and is not checked. A child run (`call`, `map`,
|
|
17
|
+
# `spawn`) is a run of its own, held on its own class's ledger: one
|
|
18
|
+
# whose class names none sends its requests unheld, also a warning.
|
|
19
|
+
class BudgetLedgerDiagnostic
|
|
20
|
+
extend Dry::Initializer
|
|
21
|
+
|
|
22
|
+
param :graph
|
|
23
|
+
|
|
24
|
+
def self.declared?(workflow_class)
|
|
25
|
+
workflow_class.respond_to?(:budget_ledger) && !workflow_class.budget_ledger.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_a
|
|
29
|
+
return [] unless self.class.declared?(graph.workflow_class)
|
|
30
|
+
|
|
31
|
+
graph.reachable_transitions.flat_map do |transition|
|
|
32
|
+
[*agents(transition, Set[graph.workflow_class]).uniq.filter_map { unpriced(transition, _1) },
|
|
33
|
+
*unbudgeted_child(transition)]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def agents(transition, seen)
|
|
40
|
+
[*step_agents(transition), *(nested_agents(transition.workflow_class, seen) if transition.nested?)].compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def step_agents(transition)
|
|
44
|
+
return transition.fanout_config.fetch(:branches).values if transition.fanout?
|
|
45
|
+
return transition.optimization_config.values_at(:generator, :evaluator) if transition.optimized?
|
|
46
|
+
return transition.orchestrator_config.values_at(:orchestrator, :worker) if transition.orchestrated?
|
|
47
|
+
|
|
48
|
+
[transition.agent_name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unbudgeted_child(transition)
|
|
52
|
+
child = transition.children_config&.fetch(:workflow)
|
|
53
|
+
return if child.nil? || self.class.declared?(child)
|
|
54
|
+
|
|
55
|
+
name = child.name || child.inspect
|
|
56
|
+
Diagnostic.new(
|
|
57
|
+
severity: :warning, code: :child_run_unbudgeted, transition: transition.name, target: name,
|
|
58
|
+
message: "Transition #{Reference.format(transition.name)} starts runs of #{name}, which declares no " \
|
|
59
|
+
"budget_ledger: a child run is a run of its own, so its provider requests are held on no " \
|
|
60
|
+
"ledger and go unapproved and uncounted.",
|
|
61
|
+
suggestion: "Declare budget_ledger on #{name}, scoped by a key the parent passes in each child's " \
|
|
62
|
+
"input (budget_ledger(:account_id) { |scope| ... })."
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A nested workflow without its own ledger charges this one.
|
|
67
|
+
def nested_agents(workflow_class, seen)
|
|
68
|
+
return [] if seen.include?(workflow_class) || self.class.declared?(workflow_class) ||
|
|
69
|
+
!workflow_class.respond_to?(:graph)
|
|
70
|
+
|
|
71
|
+
seen << workflow_class
|
|
72
|
+
workflow_class.graph.reachable_transitions.flat_map { agents(_1, seen) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unpriced(transition, agent)
|
|
76
|
+
agent_class = Agent::Registry.binding_for(agent)&.fetch(:agent_class)
|
|
77
|
+
return unless agent_class.respond_to?(:chat_kwargs)
|
|
78
|
+
|
|
79
|
+
model = models(agent_class).find { |reference| !priced?(reference) }
|
|
80
|
+
return unless model
|
|
81
|
+
|
|
82
|
+
Diagnostic.new(
|
|
83
|
+
severity: :warning, code: :budget_ledger_unpriced, transition: transition.name, target: agent,
|
|
84
|
+
message: "Transition #{Reference.format(transition.name)} holds agent #{Reference.format(agent)}'s " \
|
|
85
|
+
"requests on the workflow's budget_ledger, and its model #{model.model_id} has no price " \
|
|
86
|
+
"in Smith.config.pricing, so their amounts carry no cost.",
|
|
87
|
+
suggestion: "Price the model in Smith.config.pricing, or have the ledger count tokens."
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The models the agent's calls are priced under: its own and its
|
|
92
|
+
# fallbacks', as RubyLLM resolves them.
|
|
93
|
+
def models(agent_class)
|
|
94
|
+
kwargs = agent_class.chat_kwargs
|
|
95
|
+
primary = kwargs[:model] && resolved(kwargs[:model], provider_of(agent_class), kwargs[:assume_model_exists])
|
|
96
|
+
[primary, *Array(agent_class.fallback_models).map { reference(Agent::ModelReference.coerce(_1)) }].compact
|
|
97
|
+
rescue StandardError
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reference(coerced) = resolved(coerced.model_id, coerced.provider&.to_s, false)
|
|
102
|
+
|
|
103
|
+
def resolved(model_id, provider, assume)
|
|
104
|
+
info = RubyLLM::Models.resolve(model_id, provider:, assume_exists: assume || false).first
|
|
105
|
+
Agent::ModelReference.new(model_id: info.id, provider: provider || info.provider.to_s)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
Agent::ModelReference.new(model_id:, provider:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def provider_of(agent_class)
|
|
111
|
+
provider = agent_class.chat_kwargs[:provider]
|
|
112
|
+
return provider.to_s if provider
|
|
113
|
+
|
|
114
|
+
Smith::Models.find_or_infer(agent_class.chat_kwargs[:model])&.provider&.to_s
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def priced?(reference)
|
|
120
|
+
!Pricing.compute_cost(model: reference.model_id, provider: reference.provider&.to_s, input_tokens: 0,
|
|
121
|
+
output_tokens: 0).nil?
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A durable retry waits out its backoff in the store, so its longest
|
|
9
|
+
# backoff (jitter included) must end within the persistence TTL, or
|
|
10
|
+
# the stored run would expire while it waits. At runtime such a wait
|
|
11
|
+
# fails the step only after earlier attempts and waits.
|
|
12
|
+
class DurableRetryDiagnostic
|
|
13
|
+
extend Dry::Initializer
|
|
14
|
+
|
|
15
|
+
param :graph
|
|
16
|
+
|
|
17
|
+
def to_a
|
|
18
|
+
ttl = graph.workflow_class.persistence_ttl || Smith.config.persistence_ttl
|
|
19
|
+
return [] unless ttl
|
|
20
|
+
|
|
21
|
+
graph.reachable_transitions.filter_map do |transition|
|
|
22
|
+
config = transition.retry_config
|
|
23
|
+
next unless config&.fetch(:durable, false)
|
|
24
|
+
|
|
25
|
+
longest = longest_backoff(config)
|
|
26
|
+
diagnostic(transition.name, longest, ttl) if longest >= ttl
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def longest_backoff(config)
|
|
33
|
+
attempts = config.fetch(:attempts)
|
|
34
|
+
ExponentialBackoff.new(attempts:, base_delay: config.fetch(:backoff), max_delay: config[:max_delay],
|
|
35
|
+
jitter: config.fetch(:jitter), delay_label: "backoff")
|
|
36
|
+
.delay(attempts - 1, random: -> { ExponentialBackoff::MAX_RANDOM_FACTOR })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def diagnostic(name, longest, ttl)
|
|
40
|
+
Diagnostic.new(
|
|
41
|
+
severity: :error, code: :durable_retry_outlasts_ttl, transition: name,
|
|
42
|
+
message: "Transition #{Reference.format(name)} waits up to #{longest.round(3)} seconds between attempts, " \
|
|
43
|
+
"which does not end within the persistence TTL of #{ttl} seconds.",
|
|
44
|
+
suggestion: "Lower backoff, max_delay or jitter, or raise the persistence TTL."
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -17,7 +17,7 @@ module Smith
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def for(transition)
|
|
20
|
-
successful_successors(transition) + failure_successors(transition)
|
|
20
|
+
successful_successors(transition) + failure_successors(transition) + timeout_successors(transition)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def failure_control_marker(transition)
|
|
@@ -42,15 +42,29 @@ module Smith
|
|
|
42
42
|
successor ? [successor] : []
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# An await that times out continues at its `on_timeout` transition,
|
|
46
|
+
# which starts from the await's own state.
|
|
47
|
+
def timeout_successors(transition)
|
|
48
|
+
target = executable_target(transition.timeout_transition, expected_state: transition.from)
|
|
49
|
+
target ? [target] : []
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
def successful_target_names(transition)
|
|
46
53
|
return Targets.router_for(transition) if transition.routed?
|
|
47
54
|
|
|
48
55
|
names = Array(transition.deterministic_routes).dup
|
|
49
|
-
name = transition.success_transition
|
|
56
|
+
name = transition.success_transition
|
|
57
|
+
name ||= graph.first_transition_from(transition.to)&.name if default_successor?(transition)
|
|
50
58
|
names << name if name
|
|
51
59
|
names.uniq
|
|
52
60
|
end
|
|
53
61
|
|
|
62
|
+
# A step declaring `routes:` reaches only its routes and `on_success`,
|
|
63
|
+
# unless it opts into `fallthrough: true`.
|
|
64
|
+
def default_successor?(transition)
|
|
65
|
+
transition.deterministic_routes.nil? || transition.deterministic_fallthrough
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
def executable_target(name, expected_state:)
|
|
55
69
|
return if name.nil?
|
|
56
70
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A nested workflow's agents, at any depth, are refused a key the
|
|
9
|
+
# nesting workflow marks untrusted. A key a nested workflow marks
|
|
10
|
+
# untrusted itself is in that workflow's own report, so it is left out
|
|
11
|
+
# here. A child run inherits only the keys its `input:` passes under
|
|
12
|
+
# the same names, which readiness cannot see, so past a map, call or
|
|
13
|
+
# spawn the refusal is a warning.
|
|
14
|
+
class InheritedUntrustedDiagnostics
|
|
15
|
+
extend Dry::Initializer
|
|
16
|
+
|
|
17
|
+
param :graph
|
|
18
|
+
# Report entries ({ transition:, workflow_class:, label: }) for the
|
|
19
|
+
# workflows this one nests, and the graphs the readiness walk built.
|
|
20
|
+
option :entries
|
|
21
|
+
option :graphs
|
|
22
|
+
|
|
23
|
+
def to_a
|
|
24
|
+
keys = UntrustedInputDiagnostic.declared_keys(graph.workflow_class)
|
|
25
|
+
return [] if keys.empty?
|
|
26
|
+
|
|
27
|
+
entries.flat_map do |entry|
|
|
28
|
+
runs = entry.fetch(:transition).child_runs?
|
|
29
|
+
reads(entry.fetch(:workflow_class), keys, {}, runs).map { |read| diagnostic(entry, *read) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# [binding, key, runs] for each agent that reads a key, where runs
|
|
36
|
+
# says a map, call or spawn lies on the way to it.
|
|
37
|
+
def reads(workflow_class, keys, seen, runs)
|
|
38
|
+
nested_graph = graphs[workflow_class]
|
|
39
|
+
return [] if nested_graph.nil? || seen.key?([workflow_class, keys, runs])
|
|
40
|
+
|
|
41
|
+
seen[[workflow_class, keys, runs]] = true
|
|
42
|
+
keys -= UntrustedInputDiagnostic.declared_keys(workflow_class)
|
|
43
|
+
return [] if keys.empty?
|
|
44
|
+
|
|
45
|
+
agent_reads(nested_graph, keys, runs) + inner_reads(nested_graph, keys, seen, runs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inner_reads(nested_graph, keys, seen, runs)
|
|
49
|
+
inner = nested_graph.reachable_transitions.select { _1.nested? || _1.child_runs? }
|
|
50
|
+
inner.flat_map { reads(_1.target_workflow_class, keys, seen, runs || _1.child_runs?) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def agent_reads(nested_graph, keys, runs)
|
|
54
|
+
RuntimeBindingDiagnostics.new(nested_graph).agent_bindings.filter_map do |binding|
|
|
55
|
+
key = (UntrustedInputDiagnostic.context_inputs(binding.fetch(:agent)) & keys).first
|
|
56
|
+
[binding, key, runs] if key
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def diagnostic(entry, binding, key, runs)
|
|
61
|
+
name = entry.fetch(:transition).name
|
|
62
|
+
label = entry.fetch(:label)
|
|
63
|
+
Diagnostic.new(
|
|
64
|
+
severity: runs ? :warning : :error, code: :nested_untrusted_agent_input, transition: name,
|
|
65
|
+
target: label, message: "Transition #{ref(name)} #{runs ? "starts runs of" : "nests"} #{label}, where " \
|
|
66
|
+
"#{binding.fetch(:role)} #{ref(binding.fetch(:agent))} on transition " \
|
|
67
|
+
"#{ref(binding.fetch(:transition).name)} declares #{ref(key)}, which this " \
|
|
68
|
+
"workflow marks untrusted, as an input#{" (refused if input: passes it)" if runs}.",
|
|
69
|
+
suggestion: UntrustedInputDiagnostic.suggestion(key)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ref(value) = Reference.format(value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|