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
@@ -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)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ # A map, call or spawn step needs a run launcher to start its runs.
9
+ class RunLauncherDiagnostic
10
+ extend Dry::Initializer
11
+
12
+ param :graph
13
+
14
+ def to_a
15
+ return [] if Smith.config.run_launcher
16
+
17
+ graph.reachable_transitions.select(&:child_runs?).map do |transition|
18
+ kind = transition.children_config.fetch(:kind)
19
+ Diagnostic.new(
20
+ severity: :error, code: :run_launcher_missing, transition: transition.name,
21
+ message: "Transition #{Reference.format(transition.name)} is a #{kind}, which starts runs through " \
22
+ "Smith.config.run_launcher, and none is configured.",
23
+ suggestion: "Set Smith.config.run_launcher (Smith::Launchers::Inline runs children in process)."
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -22,7 +22,7 @@ module Smith
22
22
  end
23
23
 
24
24
  agent_class = registry_binding.fetch(:agent_class)
25
- return if agent_class.model_configured?
25
+ return if agent_class.model_configured? || fallback_models?(agent_class)
26
26
  return required_model_diagnostic if model_required?
27
27
 
28
28
  model_warning
@@ -80,18 +80,24 @@ module Smith
80
80
  )
81
81
  end
82
82
 
83
+ # An agent with neither a model nor fallback models fails when its
84
+ # step runs.
83
85
  def model_warning
84
86
  Diagnostic.new(
85
- severity: :warning,
87
+ severity: :error,
86
88
  code: :agent_without_model,
87
89
  transition: transition.name,
88
90
  target: agent,
89
91
  message: "Transition #{ref(transition.name)} references #{role} #{ref(agent)}, " \
90
92
  "but the registered agent has no model configured.",
91
- suggestion: "Configure a model for agent #{ref(agent)} if this transition should call a provider."
93
+ suggestion: "Configure a model (or fallback models) for agent #{ref(agent)}."
92
94
  )
93
95
  end
94
96
 
97
+ def fallback_models?(agent_class)
98
+ !agent_class.fallback_models_block.nil? || Array(agent_class.fallback_models).any?
99
+ end
100
+
95
101
  def agent
96
102
  binding.fetch(:agent)
97
103
  end
@@ -11,9 +11,13 @@ module Smith
11
11
  param :graph
12
12
 
13
13
  def to_a
14
- agent_bindings.filter_map do |binding|
15
- RuntimeBindingDiagnosticBuilder.new(graph, binding: binding).to_diagnostic
16
- end
14
+ agent_bindings.flat_map do |binding|
15
+ [
16
+ RuntimeBindingDiagnosticBuilder.new(graph, binding: binding).to_diagnostic,
17
+ UntrustedInputDiagnostic.new(graph, binding:).call,
18
+ OutputSchemaDiagnostic.new(binding:).call
19
+ ]
20
+ end.compact
17
21
  end
18
22
 
19
23
  def agent_bindings
@@ -31,10 +31,17 @@ module Smith
31
31
  invalid_agent_bindings_count: diagnostic_count(:invalid_agent_binding),
32
32
  uninspectable_agent_bindings_count: diagnostic_count(:uninspectable_agent_binding),
33
33
  modelless_agent_bindings_count: diagnostic_count(:agent_without_model),
34
- required_model_missing_count: diagnostic_count(:agent_without_required_model)
34
+ required_model_missing_count: diagnostic_count(:agent_without_required_model),
35
+ budget_ledger: budget_ledger_metric
35
36
  }
36
37
  end
37
38
 
39
+ # The scope keys of the workflow's budget_ledger, or nil for none.
40
+ def budget_ledger_metric
41
+ klass = graph.workflow_class
42
+ { scope_keys: klass.budget_ledger_scope_keys } if BudgetLedgerDiagnostic.declared?(klass)
43
+ end
44
+
38
45
  def binding_metrics
39
46
  {
40
47
  direct_agent_bindings_count: agent_bindings.length,
@@ -11,12 +11,13 @@ module Smith
11
11
  param :graph
12
12
  option :reports
13
13
  option :cycle_transitions
14
+ option :graphs, default: proc { {}.compare_by_identity }
14
15
 
15
16
  def call
16
17
  topology_report = graph.validate
17
18
  bindings = RuntimeBindingDiagnostics.new(graph)
18
- nested = NestedReadinessDiagnostics.new(graph, reports:, cycle_transitions:)
19
- diagnostics = [*bindings.to_a, *nested.to_a]
19
+ nested = NestedReadinessDiagnostics.new(graph, reports:, cycle_transitions:, graphs:)
20
+ diagnostics = runtime_diagnostics(bindings, nested)
20
21
  RuntimeReadinessReport.new(
21
22
  workflow_class: workflow_label,
22
23
  topology_report: topology_report,
@@ -27,6 +28,12 @@ module Smith
27
28
 
28
29
  private
29
30
 
31
+ def runtime_diagnostics(bindings, nested)
32
+ [*bindings.to_a, *RunLauncherDiagnostic.new(graph).to_a, *DurableRetryDiagnostic.new(graph).to_a,
33
+ *StreamDiagnostic.new(graph).to_a, *BatchDiagnostic.new(graph).to_a,
34
+ *BudgetLedgerDiagnostic.new(graph).to_a, *nested.to_a]
35
+ end
36
+
30
37
  def metrics(topology_report, diagnostics, bindings, nested)
31
38
  RuntimeReadinessMetrics.new(
32
39
  graph,
@@ -62,9 +62,9 @@ module Smith
62
62
  end
63
63
 
64
64
  def visit_child(parent_graph, transition, frames)
65
- workflow_class = transition.workflow_class
65
+ workflow_class = transition.target_workflow_class
66
66
  case @states[workflow_class]
67
- when :visiting then mark_cycle(parent_graph, transition)
67
+ when :visiting then mark_cycle(parent_graph, transition) unless transition.child_runs?
68
68
  when :done then nil
69
69
  else
70
70
  child_graph = graph_for(workflow_class)
@@ -78,7 +78,8 @@ module Smith
78
78
  @reports[workflow_class] = RuntimeReadinessReportBuilder.new(
79
79
  current_graph,
80
80
  reports: @reports,
81
- cycle_transitions: cycles_for(current_graph)
81
+ cycle_transitions: cycles_for(current_graph),
82
+ graphs: @graphs
82
83
  ).call
83
84
  @states[workflow_class] = :done
84
85
  end
@@ -88,7 +89,9 @@ module Smith
88
89
  end
89
90
 
90
91
  def nested_transitions_for(current_graph)
91
- @nested_transitions[current_graph] ||= current_graph.reachable_transitions.select(&:nested?).freeze
92
+ @nested_transitions[current_graph] ||= current_graph.reachable_transitions.select do |transition|
93
+ transition.nested? || transition.child_runs?
94
+ end.freeze
92
95
  end
93
96
 
94
97
  def mark_cycle(current_graph, transition)
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ # A streamed step needs its sink in Smith.config.stream_sinks (the
9
+ # step fails without it). An agent whose calls go to OpenAI's Responses
10
+ # API (asked for in its params, or chosen by Smith's tool routing under
11
+ # `openai_api_mode` :auto, as at runtime) is not streamed: its sink
12
+ # gets each reply as one delta.
13
+ class StreamDiagnostic
14
+ extend Dry::Initializer
15
+
16
+ param :graph
17
+
18
+ def to_a
19
+ graph.reachable_transitions.filter_map do |transition|
20
+ next unless transition.stream
21
+ next missing_sink(transition) unless Smith.config.stream_sinks.key?(transition.stream)
22
+
23
+ whole_replies(transition) if responses_route?(transition.agent_name)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def responses_route?(agent)
30
+ agent_class = Agent::Registry.binding_for(agent)&.fetch(:agent_class)
31
+ return false unless agent_class.respond_to?(:chat_kwargs)
32
+
33
+ profiles = static_models(agent_class).filter_map { profile(_1) }.select { _1.provider == :openai }
34
+ return false if profiles.empty?
35
+ return true if params(agent_class)[:openai_api_mode].to_s == "responses"
36
+
37
+ profiles.any? { |profile| routed?(profile, agent_class) }
38
+ end
39
+
40
+ # Tools given by a block are known only per run: they may route.
41
+ def routed?(profile, agent_class)
42
+ tools = agent_class.respond_to?(:tools) ? agent_class.tools : []
43
+ return Smith.config.openai_api_mode == :auto unless tools.is_a?(Array)
44
+
45
+ Smith::Models::ToolRouting.responses?(profile:, tools:, thinking: thinking?(agent_class))
46
+ end
47
+
48
+ def static_models(agent_class)
49
+ primary = agent_class.chat_kwargs[:model] &&
50
+ Agent::ModelReference.new(model_id: agent_class.chat_kwargs[:model],
51
+ provider: agent_class.chat_kwargs[:provider])
52
+ fallbacks = agent_class.fallback_models_block ? [] : Array(agent_class.fallback_models)
53
+ [primary, *fallbacks.map { Agent::ModelReference.coerce(_1) }].compact
54
+ end
55
+
56
+ def profile(reference)
57
+ Smith::Models.find_or_infer(reference.model_id, provider: reference.provider&.to_sym)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def params(agent_class)
63
+ params = agent_class.params if agent_class.respond_to?(:params)
64
+ params.is_a?(Hash) ? params.transform_keys(&:to_sym) : {}
65
+ end
66
+
67
+ def thinking?(agent_class)
68
+ configured = agent_class.thinking if agent_class.respond_to?(:thinking)
69
+ !configured.nil? || params(agent_class).keys.intersect?(%i[thinking reasoning reasoning_effort])
70
+ end
71
+
72
+ def missing_sink(transition)
73
+ Diagnostic.new(
74
+ severity: :error, code: :stream_sink_missing, transition: transition.name, target: transition.stream,
75
+ message: "Transition #{Reference.format(transition.name)} streams to #{transition.stream.inspect}, " \
76
+ "which Smith.config.stream_sinks does not name.",
77
+ suggestion: "Register the sink: Smith.config.stream_sinks = { #{transition.stream.inspect} => sink }."
78
+ )
79
+ end
80
+
81
+ def whole_replies(transition)
82
+ Diagnostic.new(
83
+ severity: :warning, code: :stream_whole_replies, transition: transition.name,
84
+ target: transition.agent_name,
85
+ message: "Transition #{Reference.format(transition.name)} streams agent " \
86
+ "#{Reference.format(transition.agent_name)}, whose calls go to OpenAI's Responses API, which " \
87
+ "does not stream: its sink gets each reply as one delta.",
88
+ suggestion: "Accept whole replies there, or keep the agent on Chat Completions " \
89
+ "(Smith.config.openai_api_mode = :off, and no openai_api_mode param)."
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -23,8 +23,17 @@ module Smith
23
23
  option :orchestrator_config, default: proc {}
24
24
  option :fanout_config, default: proc {}
25
25
  option :retry_config, default: proc {}
26
+ option :await_config, default: proc {}
27
+ option :timeout_transition, default: proc {}
28
+ option :step_input, default: proc { false }
29
+ option :output_to, default: proc {}
30
+ option :stream, default: proc {}
31
+ option :batch_mode, default: proc {}
32
+ option :on_request_change, default: proc {}
33
+ option :children_config, default: proc {}
26
34
  option :deterministic_kind, default: proc {}
27
35
  option :deterministic_routes, default: proc {}
36
+ option :deterministic_fallthrough, default: proc { false }
28
37
  option :deterministic, default: proc { false }
29
38
  option :parallel, default: proc { false }
30
39
  option :parallel_count, default: proc {}
@@ -41,6 +50,16 @@ module Smith
41
50
  def nested? = !workflow_class.nil?
42
51
  def routed? = !router_config.nil?
43
52
  def parallel? = parallel
53
+ def awaits? = !await_config.nil?
54
+ def delay? = await_config&.fetch(:kind) == :delay
55
+ def agent_step? = !agent_name.nil? || optimized? || orchestrated? || fanout?
56
+ def child_runs? = !children_config.nil?
57
+ def spawns? = children_config&.fetch(:kind) == :spawn
58
+ def batches? = !batch_mode.nil? && batch_mode != :sync
59
+
60
+ # The workflow class this step runs: nested in process, or launched
61
+ # as runs of their own.
62
+ def target_workflow_class = workflow_class || children_config&.fetch(:workflow)
44
63
  end
45
64
  end
46
65
  end
@@ -36,7 +36,9 @@ module Smith
36
36
  {
37
37
  success_transition: project(transition.success_transition),
38
38
  failure_transition: project(transition.failure_transition),
39
- deterministic_routes: project_array(transition.deterministic_routes)
39
+ timeout_transition: project(transition.timeout_transition),
40
+ deterministic_routes: project_array(transition.deterministic_routes),
41
+ deterministic_fallthrough: transition.deterministic_fallthrough == true
40
42
  }
41
43
  end
42
44
 
@@ -45,10 +47,31 @@ module Smith
45
47
  deterministic_kind: transition.deterministic_kind,
46
48
  deterministic: transition.deterministic?,
47
49
  parallel: transition.parallel?,
48
- parallel_count: static_parallel_count
50
+ parallel_count: static_parallel_count,
51
+ step_input: !transition.step_input.nil?,
52
+ output_to: transition.output_to,
53
+ **call_attributes,
54
+ children_config: children_config
49
55
  }
50
56
  end
51
57
 
58
+ # How the step's calls are made: streamed to a sink, and :sync,
59
+ # :batch or :callable (a mode chosen per attempt), and what a batch
60
+ # result does when the step renders another request by then.
61
+ def call_attributes
62
+ mode = transition.batch_mode
63
+ { stream: transition.stream, batch_mode: mode.respond_to?(:call) ? :callable : mode,
64
+ on_request_change: transition.on_request_change }
65
+ end
66
+
67
+ def children_config
68
+ config = transition.children_config
69
+ return unless config
70
+
71
+ items = config[:items].respond_to?(:call) ? :callable : config[:items]
72
+ config.slice(:kind, :workflow, :max_items, :collect, :on_item_failure).merge(items:).freeze
73
+ end
74
+
52
75
  def static_parallel_count
53
76
  return unless transition.parallel?
54
77
 
@@ -19,7 +19,8 @@ module Smith
19
19
  optimization_config: optimization_config,
20
20
  orchestrator_config: orchestrator_config,
21
21
  fanout_config: fanout_config,
22
- retry_config: retry_config
22
+ retry_config: retry_config,
23
+ await_config: await_config
23
24
  }
24
25
  end
25
26
 
@@ -61,17 +62,37 @@ module Smith
61
62
  { branches: own_hash(branches) }.freeze if branches
62
63
  end
63
64
 
65
+ # The wait's kind, event, context key and timeout (`:callable` when
66
+ # the timeout is computed from the context); the block is not shown.
67
+ def await_config
68
+ config = transition.await_config
69
+ return unless config
70
+
71
+ timeout = config.fetch(:timeout)
72
+ {
73
+ kind: config.fetch(:kind), event: own(config[:event]), into: config[:into],
74
+ timeout: timeout.respond_to?(:call) ? :callable : timeout
75
+ }.compact.freeze
76
+ end
77
+
64
78
  def retry_config
65
79
  config = transition.retry_config
66
80
  return unless config
67
81
 
68
- {
82
+ exported = {
69
83
  error_classes: config.fetch(:error_classes).dup.freeze,
70
84
  attempts: config.fetch(:attempts),
71
85
  backoff: config.fetch(:backoff),
72
86
  max_delay: config[:max_delay],
73
87
  jitter: config.fetch(:jitter)
74
- }.freeze
88
+ }
89
+ exported.merge(optional_retry_fields(config)).freeze
90
+ end
91
+
92
+ def optional_retry_fields(config)
93
+ new_key_classes = config.fetch(:new_idempotency_key_on, [])
94
+ { new_idempotency_key_on: (new_key_classes.dup.freeze if new_key_classes.any?),
95
+ durable: (true if config[:durable]) }.compact
75
96
  end
76
97
 
77
98
  def own_hash(hash)