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
@@ -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
@@ -93,6 +93,7 @@ require_relative "graph/execution_successors"
93
93
  require_relative "graph/reachability"
94
94
  require_relative "graph/reachability_diagnostics"
95
95
  require_relative "graph/metrics"
96
+ require_relative "graph/inherited_untrusted_diagnostics"
96
97
  require_relative "graph/nested_readiness_diagnostics"
97
98
  require_relative "graph/report"
98
99
  require_relative "graph/runtime_binding_diagnostic_builder"
@@ -104,6 +105,17 @@ require_relative "graph/optimization_contract"
104
105
  require_relative "graph/orchestration_contract"
105
106
  require_relative "graph/transition_snapshot"
106
107
  require_relative "graph/retry_policy_diagnostic"
108
+ require_relative "graph/branch_limit_diagnostic"
109
+ require_relative "graph/isolated_input_diagnostic"
110
+ require_relative "graph/output_key_diagnostic"
111
+ require_relative "graph/stream_diagnostic"
112
+ require_relative "graph/batch_diagnostic"
113
+ require_relative "graph/budget_ledger_diagnostic"
114
+ require_relative "graph/untrusted_input_diagnostic"
115
+ require_relative "graph/output_schema_diagnostic"
116
+ require_relative "graph/run_launcher_diagnostic"
117
+ require_relative "graph/waiting_child_diagnostic"
118
+ require_relative "graph/durable_retry_diagnostic"
107
119
  require_relative "graph/transition_diagnostics"
108
120
  require_relative "graph/validator"
109
121
  require_relative "graph/runtime_readiness"
@@ -14,26 +14,71 @@ module Smith
14
14
  begin
15
15
  Thread.handle_interrupt(Object => :immediate) { run_tracked_guarded_step(transition) }
16
16
  rescue StandardError => e
17
- raise unless tracker.started?
18
- raise if terminal_retry_error?(e)
17
+ raise unless uncertain_after_tools?(tracker, e)
19
18
 
20
19
  raise ToolOutcomeUncertain.new(
21
20
  "transition failed after tool execution began; retry could replay an uncertain outcome"
22
21
  ), cause: e
23
22
  ensure
23
+ # Tool evidence travels up: a step running inside another step
24
+ # (a nested workflow's step) marks the enclosing step too, so the
25
+ # enclosing step is never retried over tools that already ran.
26
+ previous_tracker&.mark_started! if tracker.started?
24
27
  Tool.current_tool_execution_tracker = previous_tracker
25
28
  end
26
29
  end
27
30
  end
28
31
 
32
+ # A failure after a tool ran leaves its outcome uncertain, unless its
33
+ # retry could not run the tool again: a terminal failure, or a tool
34
+ # loop paused for budget, whose retry continues from its tools'
35
+ # results.
36
+ def uncertain_after_tools?(tracker, error)
37
+ tracker.started? && !terminal_retry_error?(error) &&
38
+ !(error.is_a?(BudgetUnavailable) && error.continuation)
39
+ end
40
+
29
41
  def run_tracked_guarded_step(transition)
30
42
  return apply_composite_reduction!(transition) if @composite_reduction
31
43
 
32
44
  @resolved_parallel_branch_count = preflight_branch_count(transition)
33
- run_standard_guarded_step(transition)
45
+ context_before = @context.dup
46
+ output = run_standard_guarded_step(transition)
47
+ enforce_context_shape!(context_before)
48
+ output
34
49
  ensure
35
50
  @resolved_parallel_branch_count = nil
36
51
  end
52
+
53
+ # Hooks that receive the live context (`before_eval`,
54
+ # `after_completion`, guardrails) can write to it directly. At the end
55
+ # of the step every new top-level key must be a Symbol that an explicit
56
+ # `persist` list allows, and every new or replaced value takes the
57
+ # context's key shape. On a violation the context goes back to its
58
+ # top-level values from the start of the step, as a failed
59
+ # deterministic step keeps none of its writes. A value changed in place
60
+ # is not seen until the next restore normalizes it. `O(K)` plus the size
61
+ # of changed values.
62
+ def enforce_context_shape!(before)
63
+ allowed = declared_persist_keys
64
+ normalized = @context.each_with_object({}) do |(key, value), changed|
65
+ next if before.key?(key) && before[key].equal?(value)
66
+
67
+ admit_context_key!(key, new_key: !before.key?(key), allowed:)
68
+ changed[key] = ContextKeys.normalize(value)
69
+ end
70
+ @context.merge!(normalized)
71
+ rescue WorkflowError
72
+ @context.replace(before)
73
+ raise
74
+ end
75
+
76
+ def admit_context_key!(key, new_key:, allowed:)
77
+ ContextKeys.validate_top_level_key!(key)
78
+ return unless new_key && allowed && !allowed.include?(key)
79
+
80
+ raise UndeclaredContextKey.new(key:, persisted_keys: allowed)
81
+ end
37
82
  end
38
83
  end
39
84
  end
@@ -24,6 +24,8 @@ module Smith
24
24
 
25
25
  def handle_step_failure(transition, error)
26
26
  step = { transition: transition.name, from: transition.from, to: transition.to, error: error }
27
+ fold_pending_evaluations(step)
28
+ @pending_evaluations = nil
27
29
  # Staged, not emitted: this rescue runs under the step snapshot's
28
30
  # interrupt mask, and host StepFailed handlers must not execute
29
31
  # unkillable. with_step_context flushes after the mask closes.
@@ -4,6 +4,7 @@ require "dry-initializer"
4
4
 
5
5
  require_relative "../errors"
6
6
  require_relative "message_admission"
7
+ require_relative "message_roles"
7
8
 
8
9
  module Smith
9
10
  class Workflow
@@ -50,6 +51,11 @@ module Smith
50
51
  role = message["role"]
51
52
  reject!("each session message requires a non-empty role") unless
52
53
  role.is_a?(String) && !role.strip.empty?
54
+ reject!("session messages admitted by the host cannot have the system role") if role.strip.casecmp?("system")
55
+ reject!("session messages admitted by the host cannot carry content parts") if Message.parts?(message)
56
+ return if MessageRoles.role(message, MessageRoles::ADMITTED)
57
+
58
+ reject!("session messages admitted by the host must have the role user, assistant or tool")
53
59
  end
54
60
 
55
61
  def reject!(message)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # The roles a session message may carry, matched exactly. A host admits
6
+ # user, assistant and tool messages; system messages come only from
7
+ # Smith (injected state) and the host's `seed_messages` builder.
8
+ module MessageRoles
9
+ ALL = %w[system user assistant tool].freeze
10
+ ADMITTED = %w[user assistant tool].freeze
11
+
12
+ module_function
13
+
14
+ # The role of a message Hash with String or Symbol keys, when it is
15
+ # one of `allowed`; nil otherwise.
16
+ def role(message, allowed = ALL)
17
+ return unless message.is_a?(Hash)
18
+
19
+ value = message.key?(:role) ? message[:role] : message["role"]
20
+ value = value.to_s if value.is_a?(Symbol)
21
+ value if value.is_a?(String) && allowed.include?(value)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -20,6 +20,7 @@ module Smith
20
20
 
21
21
  param :value
22
22
  option :label, default: proc { "session message" }
23
+ option :max_bytes, default: proc { MAX_BYTES }
23
24
 
24
25
  def call
25
26
  @active = {}.compare_by_identity
@@ -100,7 +101,7 @@ module Smith
100
101
 
101
102
  def copy_string(string)
102
103
  @bytes += StringSnapshot.bytesize(string)
103
- reject!("#{label} exceeds maximum bytes #{MAX_BYTES}") if @bytes > MAX_BYTES
104
+ reject!("#{label} exceeds maximum bytes #{max_bytes}") if @bytes > max_bytes
104
105
 
105
106
  StringSnapshot.copy(string, freeze: true)
106
107
  end
@@ -11,18 +11,55 @@ module Smith
11
11
  check_deadline!
12
12
  child = build_child_workflow(transition.workflow_class)
13
13
  child_result = run_child_workflow(child)
14
+ reject_waiting_child!(child, child_result)
14
15
  handle_child_result(child_result)
15
16
  end
16
17
 
18
+ # A child that raises passes its error to the parent unchanged, after
19
+ # its billable usage is rolled up. If a tool ran in the child, the
20
+ # parent's step already carries that evidence and turns the failure
21
+ # into ToolOutcomeUncertain (GuardedStepExecution).
17
22
  def run_child_workflow(child)
18
23
  child.run!
19
- rescue Smith::Error => e
20
- raise WorkflowError, "nested workflow failed: #{e.message}"
24
+ rescue StandardError
25
+ roll_up_child_totals(raised_child_usage(child))
26
+ raise
27
+ end
28
+
29
+ # An in-process child cannot be left waiting inside its parent's step:
30
+ # its usage is kept, and the parent's step fails.
31
+ def reject_waiting_child!(child, child_result)
32
+ return unless child.waiting?
33
+
34
+ roll_up_child_totals(child_result)
35
+ cause = RetryWait.instance_method(:retry_wait_error).bind_call(child)
36
+ raise WorkflowError, "a nested workflow reached a step that waits (an await, delay or durable retry); " \
37
+ "an in-process child cannot wait", cause:
38
+ end
39
+
40
+ RaisedChildUsage = Struct.new(:usage_entries, :total_cost, :total_tokens)
41
+ private_constant :RaisedChildUsage
42
+
43
+ def raised_child_usage(child)
44
+ RaisedChildUsage.new(child.usage_entries, child.instance_variable_get(:@total_cost),
45
+ child.instance_variable_get(:@total_tokens))
46
+ end
47
+
48
+ # A nested workflow inherits the run's untrusted keys, and charges the
49
+ # run's budget ledger unless it names its own.
50
+ def new_nested_workflow(child_class)
51
+ StepIO.with_inherited_untrusted_keys(untrusted_context_keys) do
52
+ Budget::HostLedger.inheriting(method(:run_budget_ledger)) do
53
+ ResponseMemo.inheriting([*@memo_attempts, @step_attempt.to_i]) do
54
+ child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
55
+ end
56
+ end
57
+ end
21
58
  end
22
59
 
23
60
  def build_child_workflow(child_class)
24
61
  @split_step_active_execution_authorization&.verify_workflow!(child_class)
25
- child = child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
62
+ child = new_nested_workflow(child_class)
26
63
  unless child.instance_of?(child_class)
27
64
  raise WorkflowError, "nested workflow constructor returned #{child.class} instead of #{child_class}"
28
65
  end
@@ -49,8 +86,14 @@ module Smith
49
86
  def handle_child_result(child_result)
50
87
  roll_up_child_totals(child_result)
51
88
 
89
+ # A child that handled its failure through its own `on_failure` route
90
+ # has already applied its failure policy, so the parent sees a
91
+ # non-retryable WorkflowError, as before.
52
92
  failed_step = child_result.steps.find { |s| s.key?(:error) }
53
- raise WorkflowError, "nested workflow failed: #{failed_step[:error]&.message}" if failed_step
93
+ if failed_step
94
+ raise WorkflowError,
95
+ "nested workflow failed: #{DiagnosticText.capture(failed_step[:error]&.message.to_s, max_bytes: 1024)}"
96
+ end
54
97
 
55
98
  child_result.output
56
99
  end
@@ -3,10 +3,10 @@
3
3
  module Smith
4
4
  class Workflow
5
5
  OptimizationState = Struct.new(
6
- :config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
6
+ :config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class, :evaluations
7
7
  ) do
8
8
  def initialize(config, prepared_input)
9
- super(config, prepared_input, nil, nil, nil, nil, nil)
9
+ super(config, prepared_input, nil, nil, nil, nil, nil, [].freeze)
10
10
  end
11
11
  end
12
12
  end
@@ -40,7 +40,7 @@ module Smith
40
40
 
41
41
  def run_orchestration_loop(state)
42
42
  state.config[:max_delegation_rounds].times do |round|
43
- result = run_orchestration_round(state, round)
43
+ result = IdempotencyKey.__send__(:within, :round, round) { run_orchestration_round(state, round) }
44
44
  return result if result
45
45
  end
46
46
 
@@ -65,12 +65,18 @@ module Smith
65
65
  invoke_agent_with_budget(state.orchestrator_class, input)
66
66
  end
67
67
 
68
+ # A task's position is its content, not its place in the list: a
69
+ # replayed orchestrator may list the same tasks in another order.
68
70
  def execute_workers(state, tasks)
69
71
  runner = method(:run_worker_with_schema)
72
+ output_schema = state.config[:worker_output_schema]
73
+ copies = Hash.new(0)
70
74
  tasks.map do |task|
71
75
  validate_task!(task, state.config[:task_schema])
72
- execution = WorkerExecution.run(state.worker_class, task, state.config[:worker_output_schema], runner)
73
- validate_worker_output!(execution.output, state.config[:worker_output_schema])
76
+ execution = IdempotencyKey.__send__(:within_content, :task, task, copies) do
77
+ WorkerExecution.run(state.worker_class, task, output_schema, runner)
78
+ end
79
+ validate_worker_output!(execution.output, output_schema)
74
80
  { execution_id: execution.execution_id, task: execution.task, output: execution.output }
75
81
  end
76
82
  end
@@ -36,6 +36,7 @@ module Smith
36
36
  def run_branch(transition, index, env, ledger, signal)
37
37
  binding = ParallelAgentBinding.new(self, transition, env.agent_class)
38
38
  with_branch_context(env, ledger, parallel_agent_binding: binding) do
39
+ narrow_idempotency_scope(:branch, index)
39
40
  with_agent_context(env.agent_class) do
40
41
  branch_ledger = effective_call_ledger
41
42
  reserved = reserve_branch_call(branch_ledger, env, ledger)
@@ -74,6 +75,7 @@ module Smith
74
75
  # Restoration is owned by the snapshot above, which tracks the
75
76
  # attribution thread key.
76
77
  Attribution.install(Attribution.ambient.merge(**attribution_overlay)) if attribution_overlay
78
+ narrow_idempotency_scope(:branch, attribution_overlay[:branch_key]) if attribution_overlay&.key?(:branch_key)
77
79
 
78
80
  unless parallel_agent_binding.equal?(NO_PARALLEL_BINDING)
79
81
  Thread.current[:smith_parallel_agent_binding] = parallel_agent_binding
@@ -84,6 +86,14 @@ module Smith
84
86
  end
85
87
  end
86
88
 
89
+ # Each branch of a step is a position of its own, so the same request
90
+ # from two branches gets two keys. Restoration is owned by the
91
+ # branch's ThreadContextSnapshot.
92
+ def narrow_idempotency_scope(*segment)
93
+ scope = IdempotencyKey.__send__(:current_scope)
94
+ IdempotencyKey.__send__(:install, scope.within(*segment)) if scope
95
+ end
96
+
87
97
  def setup_branch_context(env, ledger)
88
98
  env.setup_thread
89
99
  Tool.current_ledger = ledger