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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. 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
- private_class_method :deterministic_step_failure, :tool_guardrail_failure, :tool_failure_notification_failure
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
- 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
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.capture(error_class_name(error), max_bytes: 512),
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.capture(error_class_name(cause), max_bytes: 512),
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.message, max_bytes: 1_024)
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::WorkflowError" => "workflow_error",
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 other
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, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES, :DETAIL_VALIDATORS,
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 = build_session&.prepare!
24
- output = execute_fanout_step(
25
- transition,
26
- branches: branches,
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 = agent_class.model_configured? ? invoke_agent(agent_class, env.prepared_input) : nil
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 || graph.first_transition_from(transition.to)&.name
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