smith-agents 0.11.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +698 -0
  3. data/README.md +621 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  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/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. metadata +88 -1
@@ -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
@@ -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
+ # In an isolated session an agent step has no transcript to read, so it
9
+ # must declare `input:`; without one it fails at runtime. A nested
10
+ # workflow keeps its own session mode, so an isolated workflow that
11
+ # nests a shared one is reported too.
12
+ class IsolatedInputDiagnostic
13
+ extend Dry::Initializer
14
+
15
+ option :transition
16
+ option :workflow_class
17
+
18
+ def call
19
+ return unless isolated?(workflow_class)
20
+ return nested_shared_session if transition.nested?
21
+ return if transition.step_input || !transition.agent_step?
22
+
23
+ name = Reference.format(transition.name)
24
+ Diagnostic.new(
25
+ severity: :error, code: :isolated_step_without_input, transition: transition.name,
26
+ message: "Transition #{name} runs an agent in an isolated session without input:.",
27
+ suggestion: "Declare input: on transition #{name}, or use session :shared."
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def isolated?(klass) = klass.respond_to?(:session) && klass.session == :isolated
34
+
35
+ def nested_shared_session
36
+ child = transition.workflow_class
37
+ return if isolated?(child)
38
+
39
+ label = child.name || child.inspect
40
+ Diagnostic.new(
41
+ severity: :warning, code: :isolated_workflow_nests_shared_session, transition: transition.name,
42
+ target: label,
43
+ message: "Transition #{Reference.format(transition.name)} nests #{label}, whose agents share a transcript.",
44
+ suggestion: "Declare session :isolated on #{label} if its agents should not read one another's output."
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -11,9 +11,12 @@ module Smith
11
11
  param :graph
12
12
  option :reports
13
13
  option :cycle_transitions, default: proc { {}.compare_by_identity }
14
+ # The graphs the readiness walk built, by workflow class.
15
+ option :graphs, default: proc { {}.compare_by_identity }
14
16
 
15
17
  def to_a
16
- cycle_diagnostics + report_entries.flat_map { |entry| diagnostics_for(entry) }
18
+ cycle_diagnostics + waiting_child_diagnostics + inherited_untrusted_diagnostics +
19
+ report_entries.flat_map { |entry| diagnostics_for(entry) }
17
20
  end
18
21
 
19
22
  def child_reports
@@ -30,7 +33,7 @@ module Smith
30
33
 
31
34
  def cycle_diagnostics
32
35
  cycle_transitions.keys.map do |transition|
33
- workflow_class = transition.workflow_class
36
+ workflow_class = transition.target_workflow_class
34
37
  cycle_diagnostic(transition, workflow_class)
35
38
  end
36
39
  end
@@ -39,7 +42,7 @@ module Smith
39
42
  @report_entries ||= nested_workflow_transitions.filter_map do |transition|
40
43
  next if cycle_transitions.key?(transition)
41
44
 
42
- workflow_class = transition.workflow_class
45
+ workflow_class = transition.target_workflow_class
43
46
  report = reports[workflow_class]
44
47
  next unless report
45
48
 
@@ -52,7 +55,43 @@ module Smith
52
55
  end
53
56
 
54
57
  def nested_workflow_transitions
55
- @nested_workflow_transitions ||= graph.reachable_transitions.select(&:nested?)
58
+ @nested_workflow_transitions ||= graph.reachable_transitions.select do |transition|
59
+ transition.nested? || transition.child_runs?
60
+ end
61
+ end
62
+
63
+ # An in-process child cannot wait: a nested workflow, or a child run
64
+ # the launcher runs in process (`inline?`), that reaches an await, a
65
+ # delay, a durable retry or a batch step fails its parent's step. A nested
66
+ # workflow cannot launch runs either, unless the launcher runs them
67
+ # in process; a child run of a queueing launcher is a run of its own
68
+ # and may wait.
69
+ def waiting_child_diagnostics
70
+ launcher = Smith.config.run_launcher
71
+ inline = launcher.respond_to?(:inline?) && launcher.inline? == true
72
+ report_entries.filter_map do |entry|
73
+ transition = entry.fetch(:transition)
74
+ next unless (transition.nested? || (inline && transition.child_runs?)) && waiting_child?(entry, inline)
75
+
76
+ WaitingChildDiagnostic.build(transition, workflow_label(entry.fetch(:workflow_class)))
77
+ end
78
+ end
79
+
80
+ def waiting_child?(entry, inline)
81
+ kinds = inline ? %i[await delay] : %i[await delay map call spawn]
82
+ entry.fetch(:report).topology_report.transitions.any? { waits?(_1, kinds) }
83
+ end
84
+
85
+ def waits?(snapshot, kinds)
86
+ kinds.include?(snapshot.kind) || snapshot.retry_policy&.dig(:durable) == true ||
87
+ %i[batch callable].include?(snapshot.mode)
88
+ end
89
+
90
+ def inherited_untrusted_diagnostics
91
+ entries = report_entries.map do |entry|
92
+ entry.merge(label: workflow_label(entry.fetch(:workflow_class)))
93
+ end
94
+ InheritedUntrustedDiagnostics.new(graph, entries:, graphs:).to_a
56
95
  end
57
96
 
58
97
  def cycle_diagnostic(transition, workflow_class)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ # A step's `output_to:` key, or an await's `into:` key, that an explicit
9
+ # `persist` list leaves out fails the step at runtime.
10
+ class OutputKeyDiagnostic
11
+ extend Dry::Initializer
12
+
13
+ option :transition
14
+ option :workflow_class
15
+
16
+ def call
17
+ option, key = output_key
18
+ return unless key
19
+
20
+ keys = persisted_keys
21
+ return if keys.nil? || keys.include?(key)
22
+
23
+ Diagnostic.new(
24
+ severity: :error, code: :"#{option}_not_persisted", transition: transition.name, target: key,
25
+ message: "Transition #{Reference.format(transition.name)} writes #{Reference.format(key)} " \
26
+ "(#{option}:), which the context's persist list leaves out.",
27
+ suggestion: "Add #{Reference.format(key)} to the context's persist list."
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def output_key
34
+ return [:output_to, transition.output_to] if transition.output_to
35
+
36
+ into = transition.await_config&.fetch(:into, nil)
37
+ [:await_into, into] if into
38
+ end
39
+
40
+ # The explicit list, or nil when the workflow has none or uses
41
+ # `persist :auto`.
42
+ def persisted_keys
43
+ manager = workflow_class.context_manager if workflow_class.respond_to?(:context_manager)
44
+ return unless manager.respond_to?(:persist)
45
+ return if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
46
+
47
+ keys = manager.persist
48
+ keys.empty? ? nil : keys
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ # An agent declared for Anthropic whose output schema Anthropic's
9
+ # structured outputs cannot take (an object that allows other
10
+ # properties or declares none) fails before its call.
11
+ class OutputSchemaDiagnostic
12
+ extend Dry::Initializer
13
+
14
+ option :binding
15
+
16
+ def call
17
+ agent_class = Agent::Registry.binding_for(binding.fetch(:agent))&.fetch(:agent_class)
18
+ payload = anthropic_schema(agent_class)
19
+ return unless payload
20
+
21
+ Providers::SchemaConstraints.for_provider(payload, "anthropic")
22
+ nil
23
+ rescue Smith::WorkflowError => e
24
+ diagnostic(e)
25
+ end
26
+
27
+ private
28
+
29
+ def anthropic_schema(agent_class)
30
+ return unless agent_class.respond_to?(:chat_kwargs) && agent_class.chat_kwargs[:provider].to_s == "anthropic"
31
+
32
+ schema = agent_class.output_schema || (agent_class.schema if agent_class.respond_to?(:schema))
33
+ Providers::SchemaConstraints.json_schema(schema) if schema && !schema.is_a?(Proc)
34
+ end
35
+
36
+ def diagnostic(error)
37
+ agent = binding.fetch(:agent)
38
+ Diagnostic.new(
39
+ severity: :error, code: :output_schema_unsupported, transition: binding.fetch(:transition).name,
40
+ target: agent,
41
+ message: "Agent #{Reference.format(agent)} declares an output schema Anthropic cannot take: " \
42
+ "#{error.message}.",
43
+ suggestion: "Give every object in the schema its properties and additionalProperties: false."
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -9,6 +9,7 @@ module Smith
9
9
  extend Dry::Initializer
10
10
 
11
11
  option :transition
12
+ option :workflow_class, optional: true
12
13
 
13
14
  def call
14
15
  attempts = transition.retry_config&.fetch(:attempts, nil)