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
@@ -20,15 +20,17 @@ module Smith
20
20
  # Required keys are the pre-attribution UsageEntry shape, so effects
21
21
  # produced by an older Smith (a mid-deploy branch worker or a
22
22
  # restored checkpoint) stay valid; allowed keys are the current
23
- # member set, so unknown keys still reject. The attribution members
24
- # (transition, branch_key, round, attempt_id) are optional by
25
- # construction.
23
+ # member set, so unknown keys still reject. UsageEntry::OPTIONAL_MEMBERS
24
+ # (the attribution members, the cache and thinking counts, the
25
+ # response facts and mode) are optional by construction.
26
26
  USAGE_ALLOWED_ATTRIBUTES = Workflow::UsageEntry.members.map(&:to_s).freeze
27
27
  USAGE_REQUIRED_ATTRIBUTES =
28
- (USAGE_ALLOWED_ATTRIBUTES - %w[transition branch_key round attempt_id workflow]).freeze
28
+ (USAGE_ALLOWED_ATTRIBUTES - Workflow::UsageEntry::OPTIONAL_MEMBERS.map(&:to_s)).freeze
29
+ USAGE_COUNT_ATTRIBUTES = %w[cache_read_tokens cache_write_tokens thinking_tokens].freeze
30
+ USAGE_MODES = %w[sync stream batch].freeze
29
31
  TOOL_REQUIRED_ATTRIBUTES = %w[tool captured].freeze
30
32
  TOOL_ALLOWED_ATTRIBUTES = (TOOL_REQUIRED_ATTRIBUTES + %w[tool_call_id]).freeze
31
- private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES,
33
+ private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES, :USAGE_COUNT_ATTRIBUTES, :USAGE_MODES,
32
34
  :TOOL_REQUIRED_ATTRIBUTES, :TOOL_ALLOWED_ATTRIBUTES
33
35
 
34
36
  attribute :usage_entries, Types::Array
@@ -55,6 +57,7 @@ module Smith
55
57
  validate_bounded_keys!(entry, USAGE_REQUIRED_ATTRIBUTES, USAGE_ALLOWED_ATTRIBUTES, "composite usage entry")
56
58
  validate_usage_identity!(entry)
57
59
  validate_usage_attribution!(entry)
60
+ validate_usage_details!(entry)
58
61
  validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
59
62
  validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
60
63
  validate_cost!(entry.fetch("cost"))
@@ -63,7 +66,7 @@ module Smith
63
66
  end
64
67
 
65
68
  def usage_totals(entries)
66
- tokens = entries.sum { _1.fetch("input_tokens") + _1.fetch("output_tokens") }
69
+ tokens = entries.sum { entry_tokens(_1) }
67
70
  if tokens > PreparedStep::MAX_COUNTER_VALUE
68
71
  raise ArgumentError, "composite usage token total exceeds the signed 64-bit limit"
69
72
  end
@@ -76,6 +79,11 @@ module Smith
76
79
  [tokens, cost]
77
80
  end
78
81
 
82
+ def entry_tokens(entry)
83
+ %w[input_tokens output_tokens].sum { entry.fetch(_1) } +
84
+ %w[cache_read_tokens cache_write_tokens].sum { entry.fetch(_1, 0) }
85
+ end
86
+
79
87
  def validate_usage_identity!(entry)
80
88
  validate_uuid!(entry.fetch("usage_id"), "composite usage entry usage_id")
81
89
  # agent_name and provider are nil-allowed; any other value
@@ -101,6 +109,21 @@ module Smith
101
109
  validate_uuid!(entry.fetch("attempt_id"), "composite usage entry attempt_id") if entry.key?("attempt_id")
102
110
  end
103
111
 
112
+ # Optional usage details, bounded like the attribution keys: counts
113
+ # are non-negative Integers, response facts bounded Strings, and the
114
+ # mode one of the three call modes.
115
+ def validate_usage_details!(entry)
116
+ USAGE_COUNT_ATTRIBUTES.each do |key|
117
+ validate_usage_amount!(entry.fetch(key), key) if entry.key?(key)
118
+ end
119
+ %w[stop_reason provider_message_id].each do |key|
120
+ validate_bounded_bytes!(entry.fetch(key), "composite usage entry #{key}", 256) if entry.key?(key)
121
+ end
122
+ return if !entry.key?("mode") || USAGE_MODES.include?(entry.fetch("mode"))
123
+
124
+ raise ArgumentError, "composite usage entry mode is invalid"
125
+ end
126
+
104
127
  def validate_uuid!(value, label)
105
128
  return if value.is_a?(String) && PreparedStep::UUID_PATTERN.match?(value)
106
129
 
@@ -113,6 +136,14 @@ module Smith
113
136
  raise ArgumentError, "#{label} must be a bounded non-empty String"
114
137
  end
115
138
 
139
+ # Response facts are bounded in bytes where they are read, so they
140
+ # are checked in bytes here.
141
+ def validate_bounded_bytes!(value, label, max_bytes)
142
+ return if value.is_a?(String) && value.bytesize.between?(1, max_bytes)
143
+
144
+ raise ArgumentError, "#{label} must be a non-empty String of at most #{max_bytes} bytes"
145
+ end
146
+
116
147
  def validate_nonempty_string!(value, label)
117
148
  return if value.is_a?(String) && !value.empty?
118
149
 
@@ -10,7 +10,7 @@ module Smith
10
10
  class Error < Payload
11
11
  FAMILIES = %w[
12
12
  tool_capture_failed tool_guardrail_failed deterministic_step_failure
13
- deadline_exceeded agent_error workflow_error other
13
+ deadline_exceeded agent_error workflow_error output_failure other
14
14
  ].freeze
15
15
  ALWAYS_RETRYABLE_FAMILIES = %w[deadline_exceeded agent_error].freeze
16
16
  CONDITIONALLY_RETRYABLE_FAMILIES = %w[tool_guardrail_failed deterministic_step_failure].freeze
@@ -3,6 +3,7 @@
3
3
  require "dry-initializer"
4
4
 
5
5
  require_relative "../../errors"
6
+ require_relative "../../output_failure"
6
7
  require_relative "../../tool_capture_failed"
7
8
  require_relative "error"
8
9
 
@@ -10,6 +11,13 @@ module Smith
10
11
  class Workflow
11
12
  module Composite
12
13
  class ErrorEvidence
14
+ FAMILIES = [
15
+ [ToolCaptureFailed, "tool_capture_failed"], [ToolGuardrailFailed, "tool_guardrail_failed"],
16
+ [DeterministicStepFailure, "deterministic_step_failure"], [DeadlineExceeded, "deadline_exceeded"],
17
+ [OutputFailure, "output_failure"], [AgentError, "agent_error"], [WorkflowError, "workflow_error"]
18
+ ].freeze
19
+ private_constant :FAMILIES
20
+
13
21
  extend Dry::Initializer
14
22
 
15
23
  param :error
@@ -49,15 +57,7 @@ module Smith
49
57
  end
50
58
 
51
59
  def error_family
52
- case error
53
- when ToolCaptureFailed then "tool_capture_failed"
54
- when ToolGuardrailFailed then "tool_guardrail_failed"
55
- when DeterministicStepFailure then "deterministic_step_failure"
56
- when DeadlineExceeded then "deadline_exceeded"
57
- when AgentError then "agent_error"
58
- when WorkflowError then "workflow_error"
59
- else "other"
60
- end
60
+ FAMILIES.find { |error_class, _family| error.is_a?(error_class) }&.last || "other"
61
61
  end
62
62
 
63
63
  def tool_capture_metadata
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../types"
4
+ require_relative "../message_roles"
4
5
  require_relative "../message_value_normalizer"
5
6
  require_relative "../prepared_step"
6
7
  require_relative "payload"
@@ -37,10 +38,16 @@ module Smith
37
38
  private
38
39
 
39
40
  def normalized_values(agent_messages:, session_messages:)
40
- {
41
- agent_messages: normalize(agent_messages),
42
- session_messages: normalize(session_messages)
43
- }.freeze
41
+ values = { agent_messages: normalize(agent_messages), session_messages: normalize(session_messages) }.freeze
42
+ values.each_value { |messages| check_roles!(messages) }
43
+ values
44
+ end
45
+
46
+ def check_roles!(messages)
47
+ return unless messages.is_a?(Array)
48
+ return if messages.all? { |message| MessageRoles.role(message) }
49
+
50
+ raise WorkflowError, "composite input messages must have the role system, user, assistant or tool"
44
51
  end
45
52
 
46
53
  def normalize(value)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diagnostic_text"
4
+
5
+ module Smith
6
+ class Workflow
7
+ # The one shape a workflow context has everywhere: top-level keys are
8
+ # Symbols, and every Hash key inside a value is the Symbol of its String
9
+ # form, which is exactly what a JSON checkpoint and restore produce, so a
10
+ # key reads the same live and after a round trip. Values are not
11
+ # converted; only JSON-native values (Strings, numbers, booleans, nil,
12
+ # Hashes, Arrays) round-trip unchanged.
13
+ module ContextKeys
14
+ # JSON's own default nesting limit: a deeper context could not be
15
+ # checkpointed anyway.
16
+ MAX_DEPTH = 100
17
+
18
+ module_function
19
+
20
+ # A context given to `Workflow.new` or to a class-level helper that
21
+ # derives a persistence key from it. Top-level keys must already be
22
+ # Symbols, the rule `write_context` applies to every key it writes.
23
+ def normalize_context(context)
24
+ raise WorkflowError, "workflow context must be a Hash, got #{context.class}" unless context.is_a?(Hash)
25
+
26
+ context.each_key { |key| validate_top_level_key!(key) }
27
+ normalize(context)
28
+ end
29
+
30
+ def validate_top_level_key!(key)
31
+ return if key.is_a?(Symbol)
32
+
33
+ raise WorkflowError,
34
+ "workflow context keys must be Symbols, got #{bounded(key.inspect)} (#{key.class}); " \
35
+ "convert them before building the workflow"
36
+ end
37
+
38
+ # A copy of `value` with every Hash key at every depth turned into the
39
+ # Symbol of its String form. Raises WorkflowError when two keys of one
40
+ # Hash become the same Symbol, when a container contains itself, or
41
+ # past MAX_DEPTH. `O(N)` in the number of Hash entries and Array
42
+ # elements.
43
+ def normalize(value, path = {}.compare_by_identity, depth = 0)
44
+ case value
45
+ when Hash then within(value, path, depth) { normalize_hash(value, path, depth) }
46
+ when Array then within(value, path, depth) { value.map { |item| normalize(item, path, depth + 1) } }
47
+ else value
48
+ end
49
+ end
50
+
51
+ def normalize_hash(hash, path, depth)
52
+ hash.each_with_object({}) do |(key, nested), normalized|
53
+ symbol = key.is_a?(Symbol) ? key : key.to_s.to_sym
54
+ if normalized.key?(symbol)
55
+ raise WorkflowError, "workflow context has two keys that both become #{bounded(symbol.inspect)}"
56
+ end
57
+
58
+ normalized[symbol] = normalize(nested, path, depth + 1)
59
+ end
60
+ end
61
+
62
+ def within(container, path, depth)
63
+ raise WorkflowError, "workflow context is nested deeper than #{MAX_DEPTH} levels" if depth > MAX_DEPTH
64
+ raise WorkflowError, "workflow context contains itself" if path.key?(container)
65
+
66
+ path[container] = true
67
+ begin
68
+ yield
69
+ ensure
70
+ path.delete(container)
71
+ end
72
+ end
73
+
74
+ def bounded(text) = DiagnosticText.capture(text, max_bytes: 256)
75
+ end
76
+ end
77
+ end
@@ -123,7 +123,7 @@ module Smith
123
123
 
124
124
  def compute_wall_clock_deadline
125
125
  limit = self.class.budget&.dig(:wall_clock)
126
- own_deadline = limit ? Time.iso8601(@created_at) + limit : nil
126
+ own_deadline = limit ? active_deadline(limit) : nil
127
127
 
128
128
  return own_deadline unless @inherited_deadline
129
129
  return @inherited_deadline unless own_deadline
@@ -8,19 +8,56 @@ module Smith
8
8
  def execute_deterministic_step(transition)
9
9
  check_deadline!
10
10
  step = build_deterministic_step(transition)
11
- emit_deterministic_trace(transition, result: :started)
11
+ emit_deterministic_trace(transition, outcome: :started)
12
12
  transition.deterministic_block.call(step)
13
+ finish_deterministic_step!(transition, step)
14
+ nil
15
+ rescue StandardError => e
16
+ emit_deterministic_trace(transition, outcome: :failed, failure: deterministic_failure_fields(transition, e))
17
+ raise
18
+ end
19
+
20
+ # A write the step body rescued still fails the step, a declared route
21
+ # must be taken, and only then are writes applied and success traced.
22
+ def finish_deterministic_step!(transition, step)
23
+ raise step.write_violation if step.write_violation
24
+
25
+ ensure_deterministic_routed!(transition, step)
13
26
  apply_deterministic_writes!(step)
14
27
  emit_deterministic_trace(
15
28
  transition,
16
- result: step.routed_to ? :routed : :success,
29
+ outcome: step.routed_to ? :routed : :success,
17
30
  routed_to: step.routed_to,
18
31
  outcome_kind: step.outcome&.dig(:kind)
19
32
  )
20
- nil
21
- rescue StandardError => e
22
- emit_deterministic_trace(transition, result: :failed, error: e.message)
23
- raise
33
+ end
34
+
35
+ # The bounded classification failed :transition traces carry, never
36
+ # the raw message, which may hold content. Skipped when nothing would
37
+ # record the trace, and never allowed to replace the step's own error.
38
+ def deterministic_failure_fields(transition, error)
39
+ return {} unless Smith::Trace.resolve_adapter
40
+
41
+ failure = FailureRecord.capture({ transition: transition.name, from: transition.from, to: transition.to,
42
+ error: error })
43
+ fields = { error_class: failure[:error_class], error_family: failure[:error_family] }
44
+ fields[:retryable] = failure[:error_retryable] unless failure[:error_retryable].nil?
45
+ fields
46
+ rescue StandardError
47
+ { error_class: DiagnosticText.error_class_name(error) }
48
+ end
49
+
50
+ # A step declaring `routes:` must route, unless it declares `on_success`,
51
+ # opts into `fallthrough: true`, or its target state has no outgoing
52
+ # transition (the run then ends there, as declared); otherwise the run
53
+ # would silently take the first transition from the target state.
54
+ # Raised before any write is applied, like any failing step.
55
+ def ensure_deterministic_routed!(transition, step)
56
+ return if step.routed_to || transition.deterministic_routes.nil?
57
+ return if transition.deterministic_fallthrough || transition.success_transition
58
+ return unless self.class.transition_from?(transition.to)
59
+
60
+ raise UnroutedDeterministicStep.new(transition: transition.name, routes: transition.deterministic_routes)
24
61
  end
25
62
 
26
63
  def build_deterministic_step(transition)
@@ -30,10 +67,21 @@ module Smith
30
67
  tool_results: snapshot_value(@tool_results || []),
31
68
  state: @state,
32
69
  transition: transition,
33
- last_agent_execution: snapshot_value(@last_agent_execution)
70
+ last_agent_execution: snapshot_value(@last_agent_execution),
71
+ last_output: snapshot_value(@last_output),
72
+ persisted_keys: declared_persist_keys,
73
+ idempotency_scope: IdempotencyKey.__send__(:current_scope),
74
+ attempt: @step_attempt || 1
34
75
  )
35
76
  end
36
77
 
78
+ # The explicit `persist :a, :b` list a deterministic write must stay
79
+ # within, or nil when the workflow declares none or uses `persist :auto`.
80
+ def declared_persist_keys
81
+ keys = resolve_persist_keys
82
+ keys.is_a?(Array) ? keys : nil
83
+ end
84
+
37
85
  def apply_deterministic_writes!(step)
38
86
  @context.merge!(step.context_writes)
39
87
  step.context_writes.each_key { |key| record_persisted_key!(key) }
@@ -41,11 +89,14 @@ module Smith
41
89
  @outcome = symbolize_value(snapshot_value(step.outcome)) if step.outcome
42
90
  end
43
91
 
44
- def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
92
+ # `outcome`, not `result`: `result` is a reserved content key that the
93
+ # default content policy strips, matching the :transition and
94
+ # :provider_call vocabulary.
95
+ def emit_deterministic_trace(transition, outcome:, routed_to: nil, failure: nil, outcome_kind: nil)
45
96
  data = { transition: transition.name, from: transition.from, to: transition.to,
46
- kind: transition.deterministic_kind, result: result }
97
+ kind: transition.deterministic_kind, outcome: outcome }
47
98
  data[:routed_to] = routed_to if routed_to
48
- data[:error] = error if error
99
+ data.merge!(failure) if failure
49
100
  data[:outcome_kind] = outcome_kind if outcome_kind
50
101
  Smith::Trace.record(type: :deterministic_step, data: data)
51
102
  end
@@ -4,7 +4,9 @@ module Smith
4
4
  class Workflow
5
5
  class DeterministicStep
6
6
  attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
7
- :context_writes, :routed_to, :outcome, :allowed_routes
7
+ :context_writes, :routed_to, :outcome, :allowed_routes, :write_violation
8
+ # The attempt under way, from 1; above 1 only under `retry_on`.
9
+ attr_reader :attempt
8
10
 
9
11
  def initialize(context:, session_messages:, tool_results:, state:, **options)
10
12
  validate_options!(options)
@@ -15,7 +17,7 @@ module Smith
15
17
  @current_state = state
16
18
  @transition_name = transition ? transition.name : options.fetch(:transition_name)
17
19
  @allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
18
- @last_agent_execution = options[:last_agent_execution]
20
+ assign_execution_facts(options)
19
21
  @context_writes = {}
20
22
  @routed_to = nil
21
23
  @outcome = nil
@@ -33,7 +35,11 @@ module Smith
33
35
  @last_agent_execution && @last_agent_execution[:provider]
34
36
  end
35
37
 
38
+ # The output of the most recent step that produced one, whether it
39
+ # went to the transcript or to an `output_to:` key; before any step
40
+ # has, the last assistant message in the transcript.
36
41
  def last_output
42
+ return @step_output unless @step_output.nil?
37
43
  return @last_output if defined?(@last_output)
38
44
 
39
45
  msg = session_messages.reverse.find { |m| (m[:role] || m["role"]).to_s == "assistant" }
@@ -46,10 +52,27 @@ module Smith
46
52
  @context_writes.key?(key) ? @context_writes[key] : context[key]
47
53
  end
48
54
 
55
+ # The value is stored in the context's key shape at once, so a
56
+ # `read_context` later in the same step sees what the next step will.
57
+ # A rejected write is remembered as well as raised, so a `rescue` in
58
+ # the step body cannot turn it into a silently dropped write.
49
59
  def write_context(key, value)
50
60
  raise WorkflowError, "write_context key must be a Symbol, got #{key.class}" unless key.is_a?(Symbol)
51
61
 
52
- @context_writes[key] = value
62
+ if @persisted_keys && !@persisted_keys.include?(key)
63
+ reject_write!(UndeclaredContextKey.new(key:, persisted_keys: @persisted_keys))
64
+ end
65
+
66
+ @context_writes[key] = begin
67
+ ContextKeys.normalize(value)
68
+ rescue WorkflowError => e
69
+ reject_write!(e)
70
+ end
71
+ end
72
+
73
+ def reject_write!(error)
74
+ @write_violation ||= error
75
+ raise error
53
76
  end
54
77
 
55
78
  def route_to(transition_name)
@@ -65,14 +88,40 @@ module Smith
65
88
  @outcome = { kind: kind, payload: payload }
66
89
  end
67
90
 
68
- def fail!(message, retryable: nil, kind: nil, details: nil)
69
- raise DeterministicStepFailure.new(message, retryable: retryable, kind: kind, details: details)
91
+ def fail!(message, retryable: nil, kind: nil, details: nil, new_idempotency_key: false)
92
+ unless [true, false, nil].include?(new_idempotency_key)
93
+ raise ArgumentError, "fail! new_idempotency_key must be true or false"
94
+ end
95
+
96
+ raise DeterministicStepFailure.new(message, retryable:, kind:, details:, new_idempotency_key:,
97
+ idempotency_scope: @idempotency_scope)
98
+ end
99
+
100
+ # A key for a request this step sends outside the system: the same on
101
+ # every attempt of the step and after a restore, unique to this run,
102
+ # step and `scope`. Use a scope to tell apart several requests of one
103
+ # step. See Smith::IdempotencyKey.
104
+ def idempotency_key(scope = nil)
105
+ unless @idempotency_scope
106
+ raise WorkflowError, "an idempotency key is only available inside a running workflow step"
107
+ end
108
+
109
+ @idempotency_scope.key(scope)
70
110
  end
71
111
 
72
112
  private
73
113
 
114
+ def assign_execution_facts(options)
115
+ @last_agent_execution = options[:last_agent_execution]
116
+ @step_output = options[:last_output]
117
+ @persisted_keys = options[:persisted_keys]&.dup&.freeze
118
+ @idempotency_scope = options[:idempotency_scope]
119
+ @attempt = options.fetch(:attempt, 1)
120
+ end
121
+
74
122
  def validate_options!(options)
75
- unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution]
123
+ unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution last_output
124
+ persisted_keys idempotency_scope attempt]
76
125
  raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
77
126
  return if options[:transition] || options.key?(:transition_name)
78
127
 
@@ -24,6 +24,8 @@ module Smith
24
24
  subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
25
25
  subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
26
26
  subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
27
+ subclass.instance_variable_set(:@budget_ledger_builder, @budget_ledger_builder)
28
+ subclass.instance_variable_set(:@budget_ledger_scope_keys, @budget_ledger_scope_keys)
27
29
  subclass.instance_variable_set(:@max_transitions_count, @max_transitions_count)
28
30
  subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
29
31
  subclass.instance_variable_set(:@context_manager_class, @context_manager_class)
@@ -33,6 +35,7 @@ module Smith
33
35
  subclass.instance_variable_set(:@migrations, (@migrations || {}).dup)
34
36
  subclass.instance_variable_set(:@seed_validation_mode, @seed_validation_mode)
35
37
  subclass.instance_variable_set(:@idempotency_mode, @idempotency_mode)
38
+ subclass.instance_variable_set(:@session_mode, @session_mode)
36
39
  subclass.instance_variable_set(:@persistence_ttl, @persistence_ttl)
37
40
  end
38
41
 
@@ -76,6 +79,23 @@ module Smith
76
79
  @budget_config = opts
77
80
  end
78
81
 
82
+ # The host's budget ledger for a run (Budget::HostLedger): the block
83
+ # gets the run's scope, the values `scope_keys` have in its trusted
84
+ # context when it is created, kept with the run, and returns the
85
+ # ledger, or nil for none. It is built at the first request a step
86
+ # makes, never persisted. Each provider request a step makes is held
87
+ # on it first.
88
+ def budget_ledger(*scope_keys, &block)
89
+ return @budget_ledger_builder if scope_keys.empty? && !block
90
+ raise ArgumentError, "budget_ledger needs a block that returns the ledger" unless block
91
+ raise ArgumentError, "budget_ledger scope keys must be Symbols" unless scope_keys.all?(Symbol)
92
+
93
+ @budget_ledger_scope_keys = scope_keys.uniq.freeze
94
+ @budget_ledger_builder = block
95
+ end
96
+
97
+ def budget_ledger_scope_keys = @budget_ledger_scope_keys || []
98
+
79
99
  def max_transitions(count = nil)
80
100
  return @max_transitions_count if count.nil?
81
101
 
@@ -185,6 +205,20 @@ module Smith
185
205
  # set, indicating a previous worker crashed mid-step
186
206
  # and re-running could double-execute non-idempotent
187
207
  # agent calls or tools.
208
+ # `:shared` (the default) keeps one transcript: each agent step sees
209
+ # the session and its accepted output is appended to it. `:isolated`
210
+ # keeps no transcript for agents: every agent step must declare
211
+ # `input:`, its output is never appended, and `inject_state` does
212
+ # not run for it.
213
+ def session(mode = nil)
214
+ return @session_mode || :shared if mode.nil?
215
+ unless %i[shared isolated].include?(mode)
216
+ raise ArgumentError, "session must be :shared or :isolated, got #{mode.inspect}"
217
+ end
218
+
219
+ @session_mode = mode
220
+ end
221
+
188
222
  def idempotency_mode(mode = nil)
189
223
  return @idempotency_mode || :lax if mode.nil?
190
224
 
@@ -238,6 +272,31 @@ module Smith
238
272
  (@transitions || {})[name]
239
273
  end
240
274
 
275
+ # The transition whose name has this String form, for a name read
276
+ # back from a payload. `O(T)`.
277
+ def transition_named(name)
278
+ return if name.nil?
279
+
280
+ find_transition(name) ||
281
+ (@transitions || {}).each_value.find { |transition| transition.name.to_s == name.to_s }
282
+ end
283
+
284
+ # A state this class can hold: declared with `state` (or
285
+ # `initial_state`), or named by any transition's `from:` or `to:`.
286
+ # Matched by String form, so a payload's "prepared" finds a state
287
+ # declared as :prepared or "prepared". `O(S + T)`.
288
+ def known_state?(name) = !resolve_state_name(name).nil?
289
+
290
+ # The identifier this class declares for `name` (matched by String
291
+ # form), or nil when the class knows no such state.
292
+ def resolve_state_name(name)
293
+ return if name.nil?
294
+
295
+ wanted = name.to_s
296
+ candidates = (@states || []) + (@transitions || {}).each_value.flat_map { |t| [t.from, t.to] }
297
+ candidates.find { |candidate| !candidate.nil? && candidate.to_s == wanted }
298
+ end
299
+
241
300
  def transition_at(index)
242
301
  (@transitions || {}).values.fetch(index)
243
302
  end