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
@@ -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
@@ -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
@@ -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
@@ -13,7 +13,19 @@ module Smith
13
13
  context: snapshot_value(persisted_context),
14
14
  budget_consumed: ledger_consumed,
15
15
  step_count: @step_count,
16
+ awaiting: @awaiting&.dup,
17
+ # The attempt a durable retry makes next, and its key generation.
18
+ pending_retry: @pending_retry&.dup,
19
+ pending_batch: @pending_batch&.dup,
20
+ # The scope the run's budget_ledger is built from.
21
+ budget_ledger_scope: @budget_ledger_scope&.transform_keys(&:to_s),
22
+ active_seconds: @active_seconds || 0.0,
23
+ active_step_started_at: @active_step_started_at,
16
24
  execution_namespace: @execution_namespace,
25
+ # Untrusted keys inherited from a parent run (a durable child's).
26
+ inherited_untrusted_keys: (@inherited_untrusted_keys || []).map(&:to_s),
27
+ # Artifact namespaces a child run may read (its ancestors').
28
+ inherited_artifact_namespaces: (@inherited_artifact_namespaces || []).dup,
17
29
  created_at: @created_at,
18
30
  updated_at: @updated_at,
19
31
  next_transition_name: @next_transition_name,
@@ -85,6 +97,7 @@ module Smith
85
97
  @next_transition_name = normalized[:next_transition_name]
86
98
  @session_messages = normalized[:session_messages] || []
87
99
  @seed_message_count = validated_seed_message_count(normalized)
100
+ validate_session_parts!
88
101
  @total_cost = normalized[:total_cost] || 0.0
89
102
  @total_tokens = normalized[:total_tokens] || 0
90
103
  @outcome = normalized[:outcome]
@@ -99,6 +112,7 @@ module Smith
99
112
  @usage_mutex = Mutex.new
100
113
  @usage_entries = restore_usage_entries(normalized)
101
114
  @last_output = restore_last_output(normalized)
115
+ @last_transcript_output = restored_transcript_output
102
116
  # Backward-compat: pre-patch states have no last_agent_execution key and
103
117
  # restore to nil. @pending_agent_execution and @pending_evaluations are
104
118
  # transient (nil between steps), never persisted, but must be
@@ -113,6 +127,16 @@ module Smith
113
127
  # the original store from the legacy adapter contract).
114
128
  @persistence_version = persistence_version
115
129
  validate_definition_digest!(normalized)
130
+ validate_restored_position!(normalized)
131
+ @awaiting = WaitRecord.restore(normalized[:awaiting], workflow_class: self.class,
132
+ persistence_key: normalized[:persistence_key],
133
+ state: @state, next_transition: @next_transition_name)
134
+ @pending_retry = restore_pending_retry(normalized[:pending_retry], normalized[:persistence_key])
135
+ @pending_batch = restore_pending_batch(normalized[:pending_batch])
136
+ @active_seconds = restored_active_seconds(normalized[:active_seconds])
137
+ charge_unfinished_step!(normalized[:active_step_started_at])
138
+ @wait_mutex = Mutex.new
139
+ @wait_claimed = false
116
140
  # Preserve the seed digest from the persisted payload so it
117
141
  # round-trips on subsequent persists. validate_seed_digest!
118
142
  # compares this against a fresh evaluation of the seed builder
@@ -126,6 +150,18 @@ module Smith
126
150
  @split_step_mutex = Mutex.new
127
151
  validate_step_in_progress!(normalized) if
128
152
  self.class.idempotency_mode == :strict && !allow_step_in_progress
153
+ initialize_budget_ledger
154
+ @memo_attempts = []
155
+ @budget_ledger_scope = restored_budget_ledger_scope(normalized[:budget_ledger_scope])
156
+ end
157
+
158
+ # The stored scope, or, for a run stored before its class declared
159
+ # these scope keys, the one its context gives now.
160
+ def restored_budget_ledger_scope(stored)
161
+ return unless self.class.budget_ledger
162
+
163
+ Budget::HostLedger.stored_scope(self.class.budget_ledger_scope_keys, stored) ||
164
+ capture_budget_ledger_scope
129
165
  end
130
166
 
131
167
  def validated_persistence_version(normalized)
@@ -136,6 +172,24 @@ module Smith
136
172
  "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
137
173
  end
138
174
 
175
+ # Content parts come only from seed messages: anywhere else in a
176
+ # stored session they are refused, and a seed's are checked and kept
177
+ # as checked (String-keyed parts), however the payload was parsed.
178
+ def validate_session_parts!
179
+ @session_messages = @session_messages.each_with_index.map do |message, index|
180
+ next message unless Message.parts?(message)
181
+
182
+ checked = index < @seed_message_count && begin
183
+ Message.check_seed!(message)
184
+ rescue WorkflowError
185
+ nil
186
+ end
187
+ raise Smith::SerializationError, "persisted session content parts must be valid seed messages" unless checked
188
+
189
+ { role: checked["role"], content: checked["content"], parts: true }
190
+ end
191
+ end
192
+
139
193
  def validated_seed_message_count(normalized)
140
194
  count = normalized.fetch(:seed_message_count, 0)
141
195
  return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
@@ -227,6 +281,14 @@ module Smith
227
281
  end
228
282
  end
229
283
 
284
+ # The last assistant message after the seeds: the output the run last
285
+ # added to its transcript, unless the host admitted one after it.
286
+ def restored_transcript_output
287
+ seeds = @seed_message_count || 0
288
+ message = @session_messages.drop(seeds).reverse_each.find { |item| MessageRoles.role(item) == "assistant" }
289
+ message && message[:content]
290
+ end
291
+
230
292
  def restore_last_agent_execution(normalized)
231
293
  value = if normalized.key?(:last_agent_execution)
232
294
  normalized[:last_agent_execution]
@@ -256,15 +318,73 @@ module Smith
256
318
  ).call
257
319
  end
258
320
 
321
+ # A state or pending transition the class does not know (a deploy
322
+ # renamed or removed it, or the payload has none) fails the restore.
323
+ # Restoring it would leave the run in a state with no outgoing
324
+ # transitions, which reads as terminal and ends the run silently. Runs
325
+ # after `migrate_from` and after the definition digest check, so a
326
+ # migration can map an old name and a digest mismatch is reported as
327
+ # one.
328
+ def validate_restored_position!(normalized)
329
+ validate_restored_state!(normalized[:state], normalized[:persistence_key])
330
+ validate_restored_transition!(normalized[:next_transition_name], normalized[:persistence_key])
331
+ end
332
+
333
+ def validate_restored_state!(state, persistence_key)
334
+ return if state.nil? && self.class.initial_state.nil?
335
+ return if !state.nil? && self.class.known_state?(state)
336
+
337
+ raise Smith::UnknownStateOnRestore.new(workflow: self.class.name, persistence_key:, state:)
338
+ end
339
+
340
+ def validate_restored_transition!(transition, persistence_key)
341
+ return if transition.nil? || self.class.find_transition(transition)
342
+
343
+ raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:, transition:)
344
+ end
345
+
259
346
  def restore_core_fields(normalized)
260
347
  @state = normalized[:state]
261
348
  @context = filter_persisted_context(normalized[:context] || {})
262
349
  @step_count = normalized[:step_count] || 0
263
- @execution_namespace = normalized[:execution_namespace]
350
+ @execution_namespace = restored_execution_namespace(normalized)
351
+ @inherited_untrusted_keys = restored_inherited_untrusted_keys(normalized)
352
+ @inherited_artifact_namespaces = restored_artifact_namespaces(normalized)
264
353
  @created_at = normalized[:created_at]
265
354
  @updated_at = normalized[:updated_at]
266
355
  end
267
356
 
357
+ def restored_inherited_untrusted_keys(normalized)
358
+ keys = normalized.fetch(:inherited_untrusted_keys, [])
359
+ unless keys.is_a?(Array) && keys.length <= 1_000 && keys.all? { untrusted_key_name?(_1) }
360
+ raise Smith::SerializationError, "persisted workflow inherited_untrusted_keys are invalid"
361
+ end
362
+
363
+ keys.map(&:to_sym).uniq.freeze
364
+ end
365
+
366
+ def untrusted_key_name?(key) = (key.is_a?(String) || key.is_a?(Symbol)) && key.length.between?(1, 256)
367
+
368
+ def restored_artifact_namespaces(normalized)
369
+ ArtifactIntegration.artifact_namespaces!(normalized.fetch(:inherited_artifact_namespaces, []))
370
+ rescue WorkflowError
371
+ raise Smith::SerializationError, "persisted workflow inherited_artifact_namespaces are invalid"
372
+ end
373
+
374
+ # A payload written before namespaces were assigned at construction
375
+ # gets one derived from its own durable facts, the same on every
376
+ # restore of it, until a checkpoint stores it.
377
+ def restored_execution_namespace(normalized)
378
+ namespace = normalized[:execution_namespace]
379
+ if namespace.nil?
380
+ return IdempotencyKey.__send__(:legacy_namespace, normalized[:class] || self.class.name,
381
+ normalized[:persistence_key], normalized[:created_at])
382
+ end
383
+ return namespace if namespace.is_a?(String) && PreparedStep::UUID_PATTERN.match?(namespace)
384
+
385
+ raise Smith::SerializationError, "persisted workflow execution_namespace must be a UUID"
386
+ end
387
+
268
388
  def restore_persisted_keys(normalized)
269
389
  @persisted_keys_mutex = Mutex.new
270
390
  raw = normalized[:persisted_keys]
@@ -361,24 +481,22 @@ module Smith
361
481
  symbolized
362
482
  end
363
483
 
484
+ # The identifier the class itself uses for a payload's state name, so a
485
+ # String-named state stays a String; an unknown name is symbolized.
364
486
  def normalize_state_name(value)
365
487
  return if value.nil?
366
- return value if declared_state?(value)
367
- return value unless value.is_a?(String)
368
488
 
369
- symbolized = value.to_sym
370
- return symbolized if declared_state?(symbolized)
489
+ resolved = self.class.resolve_state_name(value)
490
+ return resolved unless resolved.nil?
371
491
 
372
- symbolized
373
- end
374
-
375
- def declared_state?(value)
376
- states = self.class.instance_variable_get(:@states) || []
377
- states.include?(value)
492
+ value.is_a?(String) ? value.to_sym : value
378
493
  end
379
494
 
495
+ # Context keys are Symbols at every depth, matching what construction
496
+ # and deterministic writes produce, so a nested read behaves the same
497
+ # before and after a restore.
380
498
  def normalize_nested_hashes!(normalized)
381
- normalized[:context] = symbolize_keys(normalized[:context]) if normalized[:context].is_a?(Hash)
499
+ normalized[:context] = ContextKeys.normalize(normalized[:context]) if normalized[:context].is_a?(Hash)
382
500
  return unless normalized[:budget_consumed].is_a?(Hash)
383
501
 
384
502
  normalized[:budget_consumed] = symbolize_keys(normalized[:budget_consumed])
@@ -388,7 +506,12 @@ module Smith
388
506
  return unless normalized[:session_messages].is_a?(Array)
389
507
 
390
508
  normalized[:session_messages] = normalized[:session_messages].map do |msg|
391
- msg.is_a?(Hash) ? symbolize_keys(snapshot_value(msg)) : snapshot_value(msg)
509
+ unless MessageRoles.role(msg)
510
+ raise Smith::SerializationError,
511
+ "persisted session message must have the role system, user, assistant or tool"
512
+ end
513
+
514
+ symbolize_keys(snapshot_value(msg))
392
515
  end
393
516
  end
394
517
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # A step's provider calls through a host's response memo
6
+ # (Smith::ResponseMemo): each call takes its kept messages, if any, and
7
+ # sends nothing when they end it (a streamed step's sink gets the reply's
8
+ # text at once), else goes on from them, and is recorded as it goes.
9
+ # Kept tool results count as tools the step ran, and the live part goes
10
+ # on from the kept rounds (Resumed): its first request is a later round,
11
+ # and a pause for budget keeps the kept rounds too.
12
+ module RememberedCalls
13
+ CALLS_LOCK = Mutex.new
14
+ private_constant :CALLS_LOCK
15
+
16
+ # Where a call's live part starts: the call's first message, and the
17
+ # rounds its kept messages made.
18
+ Resumed = Data.define(:start, :rounds)
19
+
20
+ private
21
+
22
+ def remembered_completion(chat, model_reference, facts, &)
23
+ memo = Smith.config.response_memo
24
+ key = memo && memo_key(chat, facts)
25
+ return yield(nil) unless key
26
+
27
+ start = chat.messages.length
28
+ kept = ResponseMemo.fetch(memo, key)
29
+ keep_messages(chat, kept.fetch(:messages)) if kept
30
+ return replayed_completion(chat, model_reference) if kept&.fetch(:complete)
31
+
32
+ recorded_completion(chat, memo, key, Resumed.new(start:, rounds: kept_rounds(kept)), &)
33
+ end
34
+
35
+ def kept_rounds(kept) = kept ? kept.fetch(:messages).count { _1.role == :assistant } : 0
36
+
37
+ def memo_key(chat, facts)
38
+ ResponseMemo.key([*@memo_attempts, @step_attempt.to_i], facts[:attempt_index], next_memo_call, chat)
39
+ end
40
+
41
+ # The kept messages, after the call's own; their tool results are
42
+ # tools the step ran.
43
+ def keep_messages(chat, messages)
44
+ messages.each { chat.add_message(_1) }
45
+ Tool.current_tool_execution_tracker&.mark_started! if messages.any? { _1.role == :tool }
46
+ end
47
+
48
+ def recorded_completion(chat, memo, key, resumed)
49
+ live = chat.messages.length
50
+ result = ResponseMemo.recording(chat_provider(chat), memo, key, resumed.start) { yield(resumed) }
51
+ ResponseMemo.store(memo, key, chat.messages.drop(resumed.start), complete: true,
52
+ facts: streamed_facts(chat, live, result))
53
+ result
54
+ end
55
+
56
+ # A streamed call's replies' facts, read from its stream.
57
+ def streamed_facts(chat, live, result)
58
+ stream = result[1]
59
+ return {} unless stream.is_a?(Hash) && stream[:rounds]
60
+
61
+ replies = chat.messages.drop(live).select { _1.role == :assistant }
62
+ replies.zip(Providers::StreamFacts.per_message(replies, stream[:rounds], stream[:output_limit]))
63
+ .to_h.compare_by_identity
64
+ end
65
+
66
+ def replayed_completion(chat, model_reference)
67
+ response = chat.messages.last
68
+ if (stream = @step_stream)
69
+ stream.start(attempt: @step_attempt, model: model_reference.model_id,
70
+ provider: model_reference.provider&.to_s)
71
+ content = response.content
72
+ stream.delta(content.is_a?(String) ? content : JSON.generate(content))
73
+ end
74
+ [response, nil, nil, { memo: true }]
75
+ end
76
+
77
+ # The call's place among its attempt's calls at its position (a
78
+ # fan-out's branches count their own).
79
+ def next_memo_call
80
+ slot = [IdempotencyKey.__send__(:current_scope)&.key, @step_attempt]
81
+ memo_calls_lock.synchronize do
82
+ @memo_calls ||= {}
83
+ @memo_calls[slot] = @memo_calls.fetch(slot, 0) + 1
84
+ end
85
+ end
86
+
87
+ def memo_calls_lock = @memo_calls_lock || CALLS_LOCK.synchronize { @memo_calls_lock ||= Mutex.new }
88
+
89
+ def start_memo_calls = memo_calls_lock.synchronize { @memo_calls = {} }
90
+ end
91
+ end
92
+ end