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
@@ -46,7 +46,10 @@ module Smith
46
46
  # (execution key from the restored persistence key, the split
47
47
  # step's transition, then the branch overlay downstream).
48
48
  # Restoration is owned by within_raw_step_context's snapshot.
49
- install_step_attribution(@split_step_transition) if @split_step_transition
49
+ if @split_step_transition
50
+ install_step_attribution(@split_step_transition)
51
+ install_step_idempotency_scope(@split_step_transition)
52
+ end
50
53
  with_scoped_artifacts(&block)
51
54
  end
52
55
  [output, nil]
@@ -33,8 +33,10 @@ module Smith
33
33
 
34
34
  def prepare_composite_input(authorization, transition, specs)
35
35
  ThreadContextSnapshot.new.around do
36
+ # The step's position, as in-process guardrails run with it.
37
+ install_step_idempotency_scope(transition)
36
38
  run_composite_input_guardrails(authorization, transition, specs)
37
- agent_messages = build_session&.prepare!
39
+ agent_messages = step_prepared_input(transition)
38
40
  Composite::Input.build(agent_messages:, session_messages: snapshot_session_messages)
39
41
  end
40
42
  end
@@ -48,6 +48,7 @@ module Smith
48
48
  end
49
49
 
50
50
  def replace_exact_dispatch!(payload)
51
+ check_state_size!(@split_step_persistence_key, payload)
51
52
  @split_step_adapter.replace_exact(
52
53
  @split_step_persistence_key,
53
54
  payload,
@@ -70,10 +70,15 @@ module Smith
70
70
  end
71
71
 
72
72
  def prepare_claimed_persisted_step!(key, adapter)
73
+ raise WorkflowError, "split-step persistence cannot prepare a step while the run waits" if waiting?
74
+
73
75
  transition = pending_split_step_transition
74
76
  transition_name = @next_transition_name || transition&.name
75
77
  raise WorkflowError, "workflow has no pending transition" unless transition_name
76
78
 
79
+ refusal = "split-step persistence does not run steps that wait or launch runs"
80
+ raise WorkflowError, refusal if transition&.waits_or_launches?
81
+
77
82
  ensure_transition_budget!
78
83
  resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
79
84
  store = persistence_adapter!(adapter)
@@ -58,6 +58,7 @@ module Smith
58
58
 
59
59
  def detach_split_step_results!
60
60
  @last_output = snapshot_value(@last_output)
61
+ @last_transcript_output = snapshot_value(@last_transcript_output)
61
62
  @last_agent_execution = snapshot_value(@last_agent_execution)
62
63
  @last_failed_step = snapshot_value(@last_failed_step)
63
64
  @last_prepared_input = snapshot_value(@last_prepared_input)
@@ -3,26 +3,32 @@
3
3
  module Smith
4
4
  class Workflow
5
5
  module StepCompletion
6
+ # Room for what completing a step adds besides its output (its state,
7
+ # step count, last agent).
8
+ RESULT_MARGIN = 1024
9
+
6
10
  private
7
11
 
8
12
  def complete_step(transition, output)
13
+ StepCompletion.instance_method(:check_result_storable!).bind_call(self, output)
9
14
  step = { transition: transition.name, from: transition.from, to: transition.to, output: output }
10
15
  # Fold in the serial agent attribution captured for this step (if any),
11
16
  # then clear the transient carrier. record_step_snapshot reads these off
12
17
  # the returned step to update the durable @last_agent_execution.
13
- if @pending_agent_execution
14
- step[:model] = @pending_agent_execution[:model]
15
- step[:provider] = @pending_agent_execution[:provider]
16
- end
18
+ step.merge!(@pending_agent_execution.slice(:model, :provider)) if @pending_agent_execution
17
19
 
18
20
  @pending_agent_execution = nil
21
+ fold_pending_evaluations(step)
19
22
  result = SplitStepPersistence
20
23
  .instance_method(:prepare_split_step_execution_result)
21
24
  .bind_call(self, step)
22
- StepCompletion.instance_method(:append_accepted_output).bind_call(self, output)
25
+ StepCompletion.instance_method(:keep_accepted_output).bind_call(self, transition, output)
23
26
  SplitStepPersistence
24
27
  .instance_method(:commit_split_step_execution_result!)
25
28
  .bind_call(self, result)
29
+ # Cleared only once the record is committed: a completion that fails
30
+ # before this folds the same verdicts into its failure record.
31
+ @pending_evaluations = nil
26
32
  @state = transition.to
27
33
  @next_transition_name = @router_next_transition || transition.success_transition
28
34
  @router_next_transition = nil
@@ -30,10 +36,37 @@ module Smith
30
36
  step
31
37
  end
32
38
 
33
- def append_accepted_output(output)
34
- return unless @session_messages && !output.nil?
39
+ # A stored run's step whose result would take its state past
40
+ # persistence_max_bytes fails with PersistedStateTooLarge before the
41
+ # result is kept, as with any error: its usage is recorded, its
42
+ # on_failure taken, and its run stays storable (strict mode
43
+ # included), where a refused store after the step would leave it
44
+ # unsaved. The estimate is the state's JSON and the output's, kept
45
+ # twice (in the context or transcript, and as the last output).
46
+ def check_result_storable!(output)
47
+ limit = Smith.config.persistence_max_bytes
48
+ return unless limit && @persistence_version.to_i.positive?
49
+
50
+ bytes = JSON.generate(to_state).bytesize + (JSON.generate(output).bytesize * 2) + RESULT_MARGIN
51
+ raise PersistedStateTooLarge.new(key: @persistence_key, bytes:, limit:) if bytes > limit
52
+ rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
53
+ nil
54
+ end
55
+
56
+ # Once the result is captured, so a step whose result is refused leaves
57
+ # neither the context nor the transcript changed.
58
+ def keep_accepted_output(transition, output)
59
+ StepIO.instance_method(:deliver_output_to).bind_call(self, transition, output) if transition.output_to
60
+ StepCompletion.instance_method(:append_accepted_output).bind_call(self, transition, output)
61
+ end
62
+
63
+ # The transcript keeps an accepted output unless the step writes it to
64
+ # the context (`output_to:`) or the session is isolated.
65
+ def append_accepted_output(transition, output)
66
+ return unless @session_messages && !output.nil? && transcript_output?(transition)
35
67
 
36
68
  @session_messages << { role: :assistant, content: output }
69
+ @last_transcript_output = output
37
70
  end
38
71
  end
39
72
  end
@@ -10,14 +10,17 @@ module Smith
10
10
 
11
11
  def with_step_context(transition, &block)
12
12
  ThreadContextSnapshot.new.around do
13
+ start_active_clock!
13
14
  setup_step_context
14
15
  install_step_attribution(transition)
16
+ install_step_idempotency_scope(transition)
15
17
  Thread.handle_interrupt(Object => :immediate, &block)
16
18
  rescue StandardError => e
17
19
  @outcome = nil
18
20
  GuardrailIntegration.instance_method(:handle_step_failure).bind_call(self, transition, e)
19
21
  ensure
20
22
  teardown_step_context
23
+ stop_active_clock!
21
24
  end
22
25
  ensure
23
26
  flush_step_failure_emission
@@ -47,12 +50,17 @@ module Smith
47
50
  end
48
51
  end
49
52
 
53
+ # A durable composite branch runs here, in its own restored instance:
54
+ # it starts from what is left of the run's wall_clock, and its running
55
+ # time is not added back to the run's active_seconds.
50
56
  def within_raw_step_context(&block)
51
57
  ThreadContextSnapshot.new.around do
58
+ start_active_clock!
52
59
  setup_step_context
53
60
  Thread.handle_interrupt(Object => :immediate, &block)
54
61
  ensure
55
62
  teardown_step_context
63
+ stop_active_clock!
56
64
  end
57
65
  end
58
66
 
@@ -80,6 +88,19 @@ module Smith
80
88
  )
81
89
  end
82
90
 
91
+ # The step's position for idempotency keys: its number and transition
92
+ # in this run's namespace. A nested workflow sharing the namespace (an
93
+ # in-process child) is placed under the parent's current position, so
94
+ # a child step never takes a key a parent step already used; any other
95
+ # workflow started inside a step is a run of its own. Restoration is
96
+ # owned by the surrounding ThreadContextSnapshot.
97
+ def install_step_idempotency_scope(transition)
98
+ namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
99
+ outer = IdempotencyKey.__send__(:current_scope)
100
+ base = outer && outer.run == namespace ? outer : IdempotencyKey.__send__(:root_scope, namespace)
101
+ IdempotencyKey.__send__(:install, base.within(:step, @step_count + 1, transition.name.to_s))
102
+ end
103
+
83
104
  def teardown_step_context
84
105
  Tool.current_guardrails = nil
85
106
  Tool.current_deadline = nil
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_result_snapshot"
4
+ require_relative "message_batch"
5
+ require_relative "message_value_normalizer"
6
+
7
+ module Smith
8
+ class Workflow
9
+ # Per-step input and output: a step's `input:` replaces the session
10
+ # transcript as its messages, `output_to:` writes its output to a context
11
+ # key instead of the transcript, and an isolated session keeps no
12
+ # transcript for agents at all. Also the run's untrusted keys: its own
13
+ # context's, and those of every workflow it is nested in.
14
+ module StepIO
15
+ INPUT_ROLES = %w[user assistant].freeze
16
+ INHERITED_UNTRUSTED_KEYS = :smith_inherited_untrusted_keys
17
+ private_constant :INPUT_ROLES, :INHERITED_UNTRUSTED_KEYS
18
+
19
+ # Builds a nested workflow with its parent's untrusted keys in force
20
+ # from construction, before its seed builder runs.
21
+ def self.with_inherited_untrusted_keys(keys)
22
+ outer = Thread.current[INHERITED_UNTRUSTED_KEYS]
23
+ Thread.current[INHERITED_UNTRUSTED_KEYS] = untrusted_key_names!(keys)
24
+ yield
25
+ ensure
26
+ Thread.current[INHERITED_UNTRUSTED_KEYS] = outer
27
+ end
28
+
29
+ # Context key names: at most 1,000, each a Symbol or String of 1 to 256
30
+ # characters, as Symbols.
31
+ def self.untrusted_key_names!(keys)
32
+ valid = keys.is_a?(Array) && keys.length <= 1_000 &&
33
+ keys.all? { (_1.is_a?(Symbol) || _1.is_a?(String)) && _1.length.between?(1, 256) }
34
+ raise WorkflowError, "untrusted keys must be up to 1,000 context key names" unless valid
35
+
36
+ keys.map(&:to_sym).uniq.freeze
37
+ end
38
+
39
+ # The keys handed to the workflow under construction; taken once, so a
40
+ # workflow its constructor builds does not inherit them too.
41
+ def self.take_inherited_untrusted_keys
42
+ keys = Thread.current[INHERITED_UNTRUSTED_KEYS]
43
+ Thread.current[INHERITED_UNTRUSTED_KEYS] = nil
44
+ keys || [].freeze
45
+ end
46
+
47
+ private
48
+
49
+ def isolated_session? = self.class.session == :isolated
50
+
51
+ def untrusted_context_keys
52
+ manager = self.class.context_manager
53
+ own = manager.respond_to?(:untrusted_keys) ? manager.untrusted_keys : []
54
+ own | (@inherited_untrusted_keys || [])
55
+ end
56
+
57
+ # The context without its untrusted keys, as `inject_state` formatters
58
+ # and the `seed_messages` builder read it.
59
+ def trusted_context_view
60
+ keys = untrusted_context_keys
61
+ keys.empty? || !@context.is_a?(Hash) ? @context : @context.except(*keys)
62
+ end
63
+
64
+ # The messages an agent step sees: its `input:`, or else the session
65
+ # transcript (with injected state). An isolated session has no
66
+ # transcript to give: an agent step there must declare `input:`, and
67
+ # any other step gets nothing, so state is never injected.
68
+ def step_prepared_input(transition)
69
+ return step_input_messages(transition) if transition.step_input
70
+ return build_session&.prepare! unless isolated_session?
71
+ return unless transition.agent_step?
72
+
73
+ raise WorkflowError, "transition #{DiagnosticText.capture(transition.name.to_s, max_bytes: 256)} runs an " \
74
+ "agent in an isolated session and must declare input:"
75
+ end
76
+
77
+ # A non-empty Array of at most MessageBatch::MAX_MESSAGES user and
78
+ # assistant messages, bounded and JSON-shaped like session messages.
79
+ # The callable gets a copy of the context, so it cannot change the
80
+ # run's.
81
+ def step_input_messages(transition)
82
+ messages = transition.step_input.call(snapshot_value(@context))
83
+ unless messages.is_a?(Array) && !messages.empty? && messages.length <= MessageBatch::MAX_MESSAGES
84
+ raise WorkflowError, "input: must return a non-empty Array of at most #{MessageBatch::MAX_MESSAGES} messages"
85
+ end
86
+
87
+ MessageValueNormalizer.new(messages, label: "step input").call.map { |message| step_input_message(message) }
88
+ end
89
+
90
+ def step_input_message(message)
91
+ return step_input_parts_message(message) if Message.parts?(message)
92
+ unless message.is_a?(Hash) && message.keys.sort == %w[content role] && INPUT_ROLES.include?(message["role"]) &&
93
+ !message["content"].nil?
94
+ raise WorkflowError, "each input: message must be { role: :user or :assistant, content: } with content"
95
+ end
96
+
97
+ { role: message["role"].to_sym, content: message["content"] }
98
+ end
99
+
100
+ # A message with content parts (Smith::Message), user or assistant.
101
+ def step_input_parts_message(message)
102
+ checked = Message.check!(message)
103
+ raise WorkflowError, "an input: message with content parts must be a user or assistant message" unless
104
+ INPUT_ROLES.include?(checked["role"])
105
+
106
+ { role: checked["role"].to_sym, content: checked["content"], parts: true }
107
+ end
108
+
109
+ # Checked before the step runs, so a key the persist list refuses
110
+ # costs no agent call.
111
+ def writable_output_key!(transition)
112
+ key = transition.output_to
113
+ keys = declared_persist_keys
114
+ raise UndeclaredContextKey.new(key:, persisted_keys: keys) if keys && !keys.include?(key)
115
+
116
+ key
117
+ end
118
+
119
+ # The step's output, nil included, under `output_to:`: JSON-shaped and
120
+ # in the context's key shape, so it reads the same live and restored.
121
+ def deliver_output_to(transition, output)
122
+ key = writable_output_key!(transition)
123
+ value = MessageValueNormalizer.new(output, label: "output_to: value",
124
+ max_bytes: ExecutionResultSnapshot::MAX_BYTES).call
125
+ @context[key] = ContextKeys.normalize(value)
126
+ record_persisted_key!(key)
127
+ end
128
+
129
+ def transcript_output?(transition) = transition.output_to.nil? && !isolated_session?
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # A step declared with `stream:` streams through its sink
6
+ # (Smith::Streaming), which hears the step's outcome: its accepted
7
+ # output, a durable retry's wait, or its failure. Every other step clears
8
+ # the stream, so none of its calls reach another step's sink; a nested
9
+ # workflow is a run of its own, with its own steps.
10
+ #
11
+ # The accepted output reaches the sink once the step is stored: at once
12
+ # for a run that is not stored (no persistence key), else when `persist!`
13
+ # stores it, or when the run moves on without storing it. A store that
14
+ # fails tells the sink the step failed, with the store's error.
15
+ module StepStream
16
+ private
17
+
18
+ def with_step_stream(transition)
19
+ @step_stream = transition.stream &&
20
+ Streaming.open(transition.stream, execution_key: @persistence_key, transition: transition.name)
21
+ step = yield
22
+ if wait_pending?(step) then @step_stream&.suspend
23
+ elsif @step_stream then accept_streamed(@step_stream, step[:output])
24
+ end
25
+ step
26
+ rescue Exception => e # rubocop:disable Lint/RescueException -- the sink hears of every failure, then it goes on
27
+ @step_stream&.fail(e.class)
28
+ raise
29
+ ensure
30
+ @step_stream = nil
31
+ end
32
+
33
+ def accept_streamed(session, output)
34
+ return session.finish(output) unless @persistence_key
35
+
36
+ @streamed_output = [session, output]
37
+ end
38
+
39
+ def deliver_streamed_output
40
+ session, output = @streamed_output
41
+ @streamed_output = nil
42
+ session&.finish(output)
43
+ end
44
+
45
+ def discard_streamed_output(error_class)
46
+ session, = @streamed_output
47
+ @streamed_output = nil
48
+ session&.fail(error_class)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -20,6 +20,7 @@ module Smith
20
20
  current_invocation_context
21
21
  current_invocation_sequence
22
22
  current_invocation
23
+ current_idempotency_scope
23
24
  ].freeze
24
25
  THREAD_KEYS = %i[
25
26
  smith_attribution
@@ -28,6 +29,7 @@ module Smith
28
29
  smith_failed_agent_results
29
30
  smith_last_agent_result
30
31
  smith_parallel_agent_binding
32
+ smith_step_depth
31
33
  ].freeze
32
34
 
33
35
  option :tool_attributes, default: proc { TOOL_ATTRIBUTES }