smith-agents 0.11.0 → 0.12.1

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 +712 -0
  3. data/README.md +622 -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 +67 -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,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)
@@ -4,6 +4,10 @@ module Smith
4
4
  class Workflow
5
5
  class Graph
6
6
  class TransitionDiagnostics
7
+ TARGET_ATTRIBUTES = %i[success_transition failure_transition timeout_transition].freeze
8
+ DECLARATION_CHECKS = [
9
+ BranchLimitDiagnostic, RetryPolicyDiagnostic, IsolatedInputDiagnostic, OutputKeyDiagnostic
10
+ ].freeze
7
11
  attr_reader :graph
8
12
 
9
13
  def initialize(graph)
@@ -13,13 +17,11 @@ module Smith
13
17
  def to_a
14
18
  graph.transitions.values.flat_map do |transition|
15
19
  [
16
- transition_target_diagnostic(transition, :success_transition, transition.success_transition),
17
- transition_target_diagnostic(transition, :failure_transition, transition.failure_transition),
20
+ *TARGET_ATTRIBUTES.map { transition_target_diagnostic(transition, _1, transition.public_send(_1)) },
18
21
  *router_target_diagnostics(transition),
19
22
  *deterministic_route_diagnostics(transition),
20
23
  *target_state_mismatch_diagnostics(transition),
21
- branch_limit_diagnostic(transition),
22
- RetryPolicyDiagnostic.new(transition:).call
24
+ *declaration_diagnostics(transition)
23
25
  ].compact
24
26
  end
25
27
  end
@@ -63,29 +65,14 @@ module Smith
63
65
  transition.failure_transition,
64
66
  expected_state: transition.from
65
67
  )
68
+ timeout = target_state_mismatch_diagnostic(transition, transition.timeout_transition,
69
+ expected_state: transition.from)
66
70
 
67
- [*success, failure].compact
71
+ [*success, failure, timeout].compact
68
72
  end
69
73
 
70
- def branch_limit_diagnostic(transition)
71
- branch_count = static_branch_count(transition)
72
- limit = Smith.config.parallel_branch_limit
73
- return unless branch_count && branch_count > limit
74
-
75
- Diagnostic.new(
76
- severity: :error,
77
- code: :parallel_branch_limit_exceeded,
78
- transition: transition.name,
79
- message: "Transition #{ref(transition.name)} declares #{branch_count} parallel branches, " \
80
- "exceeding the configured limit #{limit}.",
81
- suggestion: "Reduce the branch count or explicitly raise Smith.config.parallel_branch_limit."
82
- )
83
- end
84
-
85
- def static_branch_count(transition)
86
- return transition.fanout_config.fetch(:branches).length if transition.fanout?
87
-
88
- transition.parallel_count if transition.parallel?
74
+ def declaration_diagnostics(transition)
75
+ DECLARATION_CHECKS.map { _1.new(transition:, workflow_class: graph.workflow_class).call }
89
76
  end
90
77
 
91
78
  def successful_target_names(transition)
@@ -29,14 +29,23 @@ module Smith
29
29
  option :parallel_count, default: proc {}
30
30
  option :success_transition, default: proc {}
31
31
  option :failure_transition, default: proc {}
32
+ option :timeout_transition, default: proc {}
32
33
  option :routes, default: proc {}
33
34
  option :fallback, default: proc {}
34
35
  option :deterministic_routes, default: proc {}
36
+ option :deterministic_fallthrough, default: proc {}
35
37
  option :fanout_branches, default: proc {}
36
38
  option :fanout, default: proc {}
37
39
  option :optimization, default: proc {}
38
40
  option :orchestration, default: proc {}
39
41
  option :retry_policy, default: proc {}
42
+ option :wait, default: proc {}
43
+ option :input, default: proc {}
44
+ option :output_to, default: proc {}
45
+ option :stream, default: proc {}
46
+ option :mode, default: proc {}
47
+ option :on_request_change, default: proc {}
48
+ option :children, default: proc {}
40
49
 
41
50
  def self.from_transition(transition, workflow_class: nil)
42
51
  new(**attributes_for(transition, workflow_class: workflow_class))
@@ -56,48 +65,56 @@ module Smith
56
65
  {
57
66
  success_transition: transition.success_transition,
58
67
  failure_transition: transition.failure_transition,
68
+ timeout_transition: transition.timeout_transition,
59
69
  routes: transition.router_config&.fetch(:routes, nil),
60
70
  fallback: transition.router_config&.fetch(:fallback, nil),
61
71
  deterministic_routes: transition.deterministic_routes,
72
+ deterministic_fallthrough: transition.deterministic_routes && transition.deterministic_fallthrough == true,
62
73
  fanout_branches: transition.fanout_config&.fetch(:branches, nil)
63
74
  }
64
75
  end
65
76
 
66
77
  def self.contract_attributes(transition, workflow_class:)
67
78
  {
68
- fanout: fanout_for(transition, workflow_class: workflow_class),
69
- optimization: optimization_for(transition, workflow_class: workflow_class),
70
- orchestration: orchestration_for(transition, workflow_class: workflow_class),
71
- retry_policy: retry_policy_for(transition)
79
+ fanout: FanoutContract.from_transition(transition, workflow_class:),
80
+ optimization: OptimizationContract.from_transition(transition, workflow_class:),
81
+ orchestration: OrchestrationContract.from_transition(transition, workflow_class:),
82
+ retry_policy: retry_policy_for(transition),
83
+ wait: transition.await_config,
84
+ input: (:callable if transition.step_input),
85
+ output_to: transition.output_to,
86
+ stream: transition.stream,
87
+ mode: transition.batch_mode,
88
+ on_request_change: transition.on_request_change,
89
+ children: children_for(transition)
72
90
  }
73
91
  end
74
92
 
75
- def self.fanout_for(transition, workflow_class:)
76
- FanoutContract.from_transition(transition, workflow_class: workflow_class)
77
- end
78
-
79
- def self.optimization_for(transition, workflow_class:)
80
- OptimizationContract.from_transition(transition, workflow_class: workflow_class)
81
- end
93
+ def self.retry_policy_for(transition)
94
+ config = transition.retry_config
95
+ return unless config
82
96
 
83
- def self.orchestration_for(transition, workflow_class:)
84
- OrchestrationContract.from_transition(transition, workflow_class: workflow_class)
97
+ new_key_classes = config.fetch(:new_idempotency_key_on, [])
98
+ { attempts: config.fetch(:attempts), error_classes: config.fetch(:error_classes).map(&:name),
99
+ backoff: config.fetch(:backoff), max_delay: config[:max_delay], jitter: config.fetch(:jitter),
100
+ new_idempotency_key_on: (new_key_classes.map(&:name) if new_key_classes.any?),
101
+ durable: (true if config[:durable]) }.compact
85
102
  end
86
103
 
87
- def self.retry_policy_for(transition)
88
- config = transition.retry_config
104
+ # A map, call or spawn: its child workflow (by name), bound, collect
105
+ # and failure modes, and its items (a context key, or :callable).
106
+ def self.children_for(transition)
107
+ config = transition.children_config
89
108
  return unless config
90
109
 
91
- {
92
- attempts: config.fetch(:attempts),
93
- error_classes: config.fetch(:error_classes).map(&:name),
94
- backoff: config.fetch(:backoff),
95
- max_delay: config[:max_delay],
96
- jitter: config.fetch(:jitter)
97
- }.compact
110
+ workflow = config[:workflow]
111
+ config.merge(workflow: workflow.name || workflow.inspect)
98
112
  end
99
113
 
100
114
  def self.kind_for(transition)
115
+ return transition.children_config.fetch(:kind) if transition.child_runs?
116
+ return transition.await_config.fetch(:kind) if transition.awaits?
117
+
101
118
  kind = KINDS.find { |_name, predicate| transition.public_send(predicate) }
102
119
  return kind.first if kind
103
120
  return :agent if transition.agent_name
@@ -106,23 +123,7 @@ module Smith
106
123
  end
107
124
 
108
125
  def to_h
109
- {
110
- name: name,
111
- from: from,
112
- to: to,
113
- kind: kind,
114
- parallel_count: parallel_count,
115
- success_transition: success_transition,
116
- failure_transition: failure_transition,
117
- routes: routes,
118
- fallback: fallback,
119
- deterministic_routes: deterministic_routes,
120
- fanout_branches: fanout_branches,
121
- fanout: fanout,
122
- optimization: optimization,
123
- orchestration: orchestration,
124
- retry_policy: retry_policy
125
- }.compact
126
+ self.class.dry_initializer.attributes(self).compact
126
127
  end
127
128
  end
128
129
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ # An agent that names a context key the workflow's context marks
9
+ # untrusted among its `inputs` is refused at invocation.
10
+ class UntrustedInputDiagnostic
11
+ extend Dry::Initializer
12
+
13
+ param :graph
14
+ option :binding
15
+
16
+ # The context keys an agent reads as inputs: its declared `inputs`,
17
+ # without the reserved names Smith never reads from the context.
18
+ def self.context_inputs(agent_name)
19
+ agent_class = Agent::Registry.binding_for(agent_name)&.fetch(:agent_class)
20
+ inputs = agent_class.respond_to?(:inputs) ? Array(agent_class.inputs) : []
21
+ inputs - Agent::RESERVED_INPUT_NAMES
22
+ end
23
+
24
+ # The untrusted context keys a workflow class declares.
25
+ def self.declared_keys(workflow_class)
26
+ manager = workflow_class.context_manager if workflow_class.respond_to?(:context_manager)
27
+ manager.respond_to?(:untrusted_keys) ? manager.untrusted_keys : []
28
+ end
29
+
30
+ def self.suggestion(key)
31
+ "Pass #{Reference.format(key)} through the step's input:, wrapped with Smith::Untrusted.wrap."
32
+ end
33
+
34
+ def call
35
+ key = untrusted_input
36
+ return unless key
37
+
38
+ Diagnostic.new(
39
+ severity: :error, code: :untrusted_agent_input, transition: binding.fetch(:transition).name,
40
+ target: binding.fetch(:agent), message: message(key), suggestion: self.class.suggestion(key)
41
+ )
42
+ end
43
+
44
+ def untrusted_input
45
+ (self.class.context_inputs(binding.fetch(:agent)) & self.class.declared_keys(graph.workflow_class)).first
46
+ end
47
+
48
+ def message(key)
49
+ role = binding.fetch(:role)
50
+ "#{role[0].upcase}#{role[1..]} #{Reference.format(binding.fetch(:agent))} on transition " \
51
+ "#{Reference.format(binding.fetch(:transition).name)} declares untrusted context key " \
52
+ "#{Reference.format(key)} as an input."
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ # An in-process child (a nested workflow, or a child run the launcher
7
+ # runs in process) that reaches a step that waits, or a nested
8
+ # workflow that launches runs of its own.
9
+ module WaitingChildDiagnostic
10
+ def self.build(transition, label)
11
+ runs = transition.child_runs?
12
+ verb = runs ? "starts in-process runs of" : "nests"
13
+ Diagnostic.new(
14
+ severity: :error, code: :nested_workflow_waits, transition: transition.name, target: label,
15
+ message: "Transition #{Reference.format(transition.name)} #{verb} #{label}, " \
16
+ "which reaches a step that waits#{" or launches runs of its own" unless runs}.",
17
+ suggestion: "An in-process child cannot wait#{" or launch" unless runs}; move the step into the parent " \
18
+ "workflow#{", or use a launcher that queues children" if runs}."
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end