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
data/docs/PERSISTENCE.md CHANGED
@@ -41,9 +41,43 @@ What Smith does for you:
41
41
  original roles and order, while bounding only later workflow observations
42
42
  - injects a state summary message into that prepared input
43
43
  - persists declared workflow context keys
44
+ - keeps one context shape everywhere. Top-level keys must be Symbols: `new`,
45
+ and the class-level helpers that derive a persistence key from a context
46
+ (`restore_or_initialize`, `persisted_state_exists?`,
47
+ `restorable_billing_state?`, `run_persisted!`), raise `Smith::WorkflowError`
48
+ for any other key, so a `persistence_key` builder sees the same context on
49
+ every path. Every Hash key inside a value becomes the Symbol of its String
50
+ form, at every depth, in `new`, at each deterministic write, after hooks
51
+ that write the live context, and on restore, so a nested read such as
52
+ `context.dig(:fields, :title)` is the same live and after a JSON round trip.
53
+ Integer and other keys become Symbols too (`101` becomes `:"101"`, as JSON
54
+ would make it); Hash subclasses become plain Hashes, without default procs
55
+ or identity comparison; colliding keys, self-containing values and nesting
56
+ deeper than 100 levels raise `Smith::WorkflowError`. Values are not
57
+ converted: only JSON-native values (Strings, numbers, booleans, nil, Hashes,
58
+ Arrays) read the same after a restore, while Symbols, Times and other
59
+ objects come back as Strings. `new` holds a normalized copy of the context,
60
+ not the caller's Hash
44
61
  - persists accepted session history
45
62
  - preserves chosen next transitions across persistence
46
63
  - supports JSON host round-trips through `to_state` and `.from_state`
64
+ - refuses to restore a payload whose state, or whose pending transition, the
65
+ workflow class does not know (a deploy renamed or removed it, or the payload
66
+ has no state while the class has an initial state), raising
67
+ `Smith::UnknownStateOnRestore` or `Smith::UnknownTransitionOnRestore`
68
+ instead of restoring a run that would read as terminal. A state is known
69
+ when it is declared with `state` or `initial_state`, or named by any
70
+ transition's `from:` or `to:`, matched by its String form, so String-named
71
+ states restore as the Strings the class declares. The check runs after
72
+ `migrate_from` and after the `definition_digest` check, so a digest
73
+ mismatch is reported as one and a migration can map an old name: bump
74
+ `persistence_schema_version` and declare `migrate_from(<previous version>)`,
75
+ since migrations run only when the stored version is behind. Finished runs
76
+ are refused too, by `restore`, `restore_or_initialize`,
77
+ `restorable_billing_state?` and the class-level `run_persisted!`: to read the
78
+ outcome of a finished run whose terminal state was renamed, migrate it or
79
+ read the stored JSON directly. `stuck_for?` reports a stored run in an
80
+ unknown state as stuck, since it can neither advance nor restore
47
81
 
48
82
  `preserve_seed: true` is intended for workflows whose seed is durable external
49
83
  conversation and whose later accepted outputs are internal stage handoffs. Smith
@@ -150,6 +184,8 @@ appends the batch under the workflow lifecycle mutex, and returns an immutable
150
184
  `Smith::Workflow::MessageAdmission`. Its `message_digest` is the SHA-256 digest
151
185
  of the canonical message batch and can be correlated with host-owned evidence.
152
186
 
187
+ A host-admitted message cannot have the system role: only Smith (its injected
188
+ state) and the host's `seed_messages` builder put system messages in a session.
153
189
  Admission is bounded to 100 messages, 64 levels, 100,000 visited values, and one
154
190
  MiB of canonical JSON. Integers must fit a signed 64-bit value, Floats must be
155
191
  finite, Hash keys must be String or Symbol values, and cyclic or unsupported
@@ -534,7 +570,11 @@ before writing when an open transaction has no exact identity capability.
534
570
  If execution raises or the process dies after external work, the durable marker
535
571
  remains set. A strict restore therefore fails closed with
536
572
  `StepInProgressOnRestore`; the host must reconcile operation results or classify
537
- the run as uncertain rather than blindly replaying the transition. The same
573
+ the run as uncertain rather than blindly replaying the transition. The error's
574
+ `state` is the persisted state the interrupted step started from (a Symbol) and
575
+ its `transition` is the next transition the payload records, such as a routed
576
+ one, or nil when it records none, so the host can tell which step died without
577
+ reading Smith's payload itself. The same
538
578
  in-memory workflow object cannot retry an attempted transition. If a host
539
579
  transaction rolls back after a successful `persist!`,
540
580
  `complete_persisted_step!` rejects the rolled-back checkpoint. When the adapter
@@ -563,6 +603,619 @@ entry points remain guarded even when a host prepends modules after class
563
603
  creation, and prepared execution bypasses host `advance!` wrappers so they
564
604
  cannot become transition execution authority.
565
605
 
606
+ ## Step Isolation and Untrusted Content
607
+
608
+ By default a workflow keeps one transcript: each agent step sees the session
609
+ (with `inject_state` and masking applied) and its accepted output is appended
610
+ for the next step. Three declarations narrow that:
611
+
612
+ ```ruby
613
+ class ReviewContext < Smith::Context
614
+ persist :document_id, :source, :fields
615
+ untrusted :source, :fields # the document, and what an agent drew from it
616
+ end
617
+
618
+ class DocumentReview < Smith::Workflow
619
+ session :isolated
620
+ context_manager ReviewContext
621
+ initial_state :fetched
622
+ state :extracted
623
+
624
+ transition :extract, from: :fetched, to: :extracted do
625
+ execute :field_extractor,
626
+ input: ->(ctx) { [{ role: :user, content: Smith::Untrusted.wrap(ctx[:source], source: "document") }] },
627
+ output_to: :fields
628
+ end
629
+ end
630
+ ```
631
+
632
+ - `input:` (on `execute`, `route`, `fan_out`, `optimize` and `orchestrate`)
633
+ is a callable of a copy of the context that returns the step's messages: a
634
+ non-empty Array of at most 100 `{ role: :user or :assistant, content: }`
635
+ with content, bounded and JSON-shaped like session messages (values that are
636
+ not JSON are copied shallowly). They replace the transcript for that step;
637
+ the agent's own instructions still apply, and Smith adds its continuation
638
+ user turn only when they end with the output the run last added to its
639
+ transcript, as it does for the transcript. A fan-out's branches share one
640
+ `input:`. An optimizer's `input:` is its generator's, and also its
641
+ evaluator's under `evaluator_context: :inject_state`, which then sees the
642
+ input followed by the candidate and no injected state.
643
+ - `output_to: :key` writes the step's output to `key` instead of the
644
+ transcript, once the step completes. The value is JSON-shaped (Symbols
645
+ become Strings) and in the context's key shape, so it reads the same after a
646
+ restore; a step with no output writes nil. An explicit `persist` list must
647
+ allow the key: graph validation reports `:output_to_not_persisted` (and
648
+ `:await_into_not_persisted` for an await's `into:`), and the step is refused
649
+ before it runs. The step's output, `RunResult#output` and a later compute
650
+ step's `last_output` are this output; the next shared agent step still reads
651
+ the transcript, whose last output gets the continuation turn.
652
+ - `session :isolated` (per workflow; `:shared` is the default) keeps no
653
+ transcript for agents: every agent step must declare `input:` (it fails
654
+ otherwise, and graph validation reports `:isolated_step_without_input`),
655
+ accepted outputs are never appended, and `inject_state` never runs. A nested
656
+ workflow keeps its own session mode; validation warns
657
+ (`:isolated_workflow_nests_shared_session`) when an isolated workflow nests a
658
+ shared one.
659
+ - `untrusted :key` in a `Smith::Context` marks values from outside the system,
660
+ and values a step derived from them: `inject_state` formatters and the
661
+ `seed_messages` builder never receive them, and an agent that names one in
662
+ `inputs` is refused when it runs, in this workflow and in any workflow it
663
+ nests (runtime readiness reports `:untrusted_agent_input` and
664
+ `:nested_untrusted_agent_input`). Reserved input names (`provider`,
665
+ `model_id`, `endpoint_mode`) are never read from the context, so they are not
666
+ refused. Untrustedness belongs to the key, not the value: a key that an
667
+ agent writes from untrusted content (`output_to: :fields` above) carries the
668
+ outside text's influence, so declare it untrusted too.
669
+ - `Smith::Untrusted.wrap(text, source:)` places outside text between tags with
670
+ a boundary the text cannot guess. Inside a step the boundary derives from the
671
+ run's namespace, the step's position, the source and the text, so a step that
672
+ prepares its input again (a durable composite step on recovery) gets the same
673
+ messages; elsewhere it is random. Wrapping changes the text: an untrusted tag
674
+ in it (with spaces, invisible characters or full-width forms) becomes
675
+ `untrusted-text`, and a line that opens with a role label (`system:`,
676
+ `assistant:`, `user:`, `tool:`, `developer:`, `human:`, `ai:`, after any line
677
+ break and behind spaces, invisible characters, brackets, quotes, `#`, `>`,
678
+ `*`, `_` or `-`) gains "(quoted text)", ordinary text such as `user: alice`
679
+ included. Markdown headings, XML-like tags and look-alike letters are left
680
+ as they are. Wrapping marks where outside content starts and ends; it does
681
+ not make the content safe to act on.
682
+ - A host admits session messages with the role `user`, `assistant` or `tool`
683
+ only; system messages come from Smith (injected state) and the
684
+ `seed_messages` builder. A restored session message, and a durable composite
685
+ step's prepared message, must have one of those roles or `system`. A payload
686
+ written by an earlier version may hold a system message the host admitted;
687
+ it still reaches agents, so rebuild such sessions before relying on this.
688
+
689
+ ### Content Parts
690
+
691
+ A message in a step's `input:` (and, for text, in `seed_messages`) can carry
692
+ content parts instead of a String:
693
+
694
+ ```ruby
695
+ ref = Smith.artifacts.store(pdf_bytes, content_type: "application/pdf") # inside a step
696
+ Smith::Message.user([Smith::Content.document(ref, media_type: "application/pdf", cache: true),
697
+ Smith::Content.text("Who signed?")])
698
+ Smith::Message.system([Smith::Content.text(house_style, cache: true)]) # in seed_messages
699
+ ```
700
+
701
+ - **Parts.** `Smith::Content.text(text, cache: false)`,
702
+ `Smith::Content.document(ref, media_type:, cache: false)` and
703
+ `Smith::Content.image(ref, media_type:, cache: false)` build frozen,
704
+ JSON-shaped parts: text of 1 byte to 1 MiB with more than whitespace, in
705
+ UTF-8; a document (`application/pdf`, `text/plain`) or image (`image/png`,
706
+ `image/jpeg`, `image/gif`, `image/webp`) as a ref from `Smith.artifacts.store`
707
+ (letters, digits, `-`, `_` and `:` only, at most 1 KiB), never bytes, so a
708
+ message with parts stays small in sessions, payloads and digests.
709
+ - **Messages.** `Smith::Message.user`, `.assistant` and `.system` take a String
710
+ or 1 to 100 parts. Only a user message takes documents and images; system
711
+ and assistant messages take text parts. `input:` takes user and assistant
712
+ messages; `seed_messages` take text parts only, since a seed is built
713
+ before the run can store an artifact. A message with parts carries
714
+ `"parts" => true` beside its role and content; no agent output can carry
715
+ it and `append_session_messages!` refuses it, so a structured output that
716
+ looks like parts stays data and reaches providers as JSON text, and only
717
+ content Smith builds from parts for the call passes to RubyLLM unencoded.
718
+ - **Whose artifacts.** A ref is read by the run whose step stored it, the
719
+ workflows it nests, and its child runs: a child run (map, call or spawn) may
720
+ read its parent's artifacts and those of the parent's nearest ancestors, up
721
+ to 16 generations, so a document stored once reaches every item of a map. A
722
+ run never reads an unrelated run's artifacts, nor its own children's; a
723
+ child returns content, or the parent stores what it needs. Content parts are
724
+ read only from a store that records which run stored each artifact
725
+ (`stored_by?`, as every built-in store does), so a ref built from untrusted
726
+ text cannot name another run's artifact or a file outside the store. A run
727
+ that waits, retries durably or resumes in another process needs a shared
728
+ store (S3 or R2, Active Record, or File on a shared disk) whose retention
729
+ outlives it; see Artifacts.
730
+ - **Reading.** Before the first provider attempt Smith reads each document and
731
+ image once and checks it, or fails the step with `Smith::ContentUnavailable`
732
+ (a `WorkflowError`, never retried as a provider failure); the store's own
733
+ errors (a timeout, `Smith::ArtifactStoreMissing`) pass on as they are, so
734
+ `retry_on` can take a transient one. Each must be a
735
+ non-empty String whose bytes match its media type (a PDF, PNG, JPEG, GIF or
736
+ WebP by its signature; `text/plain` as UTF-8), a document at most 24 MiB and
737
+ an image at most 5 MiB, and the request's text with its documents and images
738
+ as they are sent (PDFs and images in base64) at most 32 MiB.
739
+ - **Anthropic** gets native blocks (`RubyLLM::Content::Raw`): a PDF or image as
740
+ base64, a `text/plain` document as text, and `cache_control: { type:
741
+ "ephemeral" }` on a part marked `cache: true`. A cache breakpoint caches the
742
+ whole request prefix up to and including that block, in Anthropic's order:
743
+ tools, then system (the agent's instructions, then the seed system
744
+ messages), then the messages. So a cached seed prefix serves later calls of
745
+ the same agent (same tools and instructions), and a cached `input:` part
746
+ caches the agent's tools and instructions with it. Anthropic needs a minimum
747
+ prefix length (about 1,024 tokens on most models), keeps a cache about 5
748
+ minutes, charges a premium to write one, and takes at most 4 breakpoints per
749
+ request: Smith counts the chat's own (cached instructions) with its parts,
750
+ against every Anthropic model in the step's chain, and refuses more before
751
+ the first call. Concurrent branches (`parallel:`, `fan_out`) each write the
752
+ cache.
753
+ - **Other providers** get the parts as one text in their order: text parts as
754
+ they are, a `text/plain` document inline (`<file name='document-2.txt'
755
+ mime_type='text/plain'>...</file>`), and `[attached: image-3.png]` where each
756
+ PDF or image stands, attached under that name. `cache:` adds nothing for
757
+ them (OpenAI and Gemini cache on their own; Bedrock and OpenRouter get no
758
+ cache markers from Smith). Each attempt builds its own chat, so a fallback
759
+ model on another provider gets its own form, and a model whose provider
760
+ takes no such content (RubyLLM raises `UnsupportedAttachmentError`) passes
761
+ the step to the next model in the chain, or fails it with
762
+ `Smith::ContentUnavailable` when none is left.
763
+ - **Sessions.** Seed messages keep their text parts in the session, digests
764
+ included, and reach the agent steps that read the session: a step with
765
+ `input:` sees only its input and its agent's instructions. A restored
766
+ session may hold parts only among its seed messages, each valid (text parts
767
+ only), however the payload was parsed, or the restore raises
768
+ `Smith::SerializationError`. A document is outside content by nature and
769
+ cannot be wrapped like text; give a step that reads one an isolated session
770
+ or a narrow `input:`.
771
+
772
+ ## Waiting
773
+
774
+ `await` and `delay` steps let a run wait, durably and bounded, without anything
775
+ sleeping or polling:
776
+
777
+ ```ruby
778
+ class DocumentReview < Smith::Workflow
779
+ initial_state :requested
780
+ state :failed
781
+
782
+ transition :fetch_source, from: :requested, to: :fetched do
783
+ await :source_ready, into: :source, timeout: 6 * 3600 do |step|
784
+ reply = SourceClient.request(ref: step.read_context(:source_ref), idempotency_key: step.await_token)
785
+ step.resolve!(reply.to_h) if reply.complete? # finished inline: no wait
786
+ end
787
+ on_timeout :source_unavailable
788
+ end
789
+ transition :source_unavailable, from: :requested, to: :unavailable
790
+
791
+ transition(:cool_off, from: :fetched, to: :reviewed) { delay 600 }
792
+ end
793
+ ```
794
+
795
+ - `await event, into:, timeout:` waits for `event`. `into:` is the Symbol
796
+ context key its payload is written to; an explicit `persist` list must allow
797
+ it, which is checked before the block runs. `timeout:` is required: positive
798
+ seconds, or a callable that receives the context and returns them. An await
799
+ must also declare where its timeout goes, `on_timeout` or `on_failure`; one
800
+ with neither is refused when the class is defined. `delay seconds` waits for
801
+ the time alone and then continues to its `to:` state. Either is the
802
+ transition's one execution body.
803
+ - The optional block runs when the step is entered, with a step object that
804
+ reads the context (`read_context`, `context`), names the wait (`await_token`,
805
+ the key whatever the block sends must carry), gives other keys
806
+ (`idempotency_key`), and may finish at once with `resolve!(payload)`, which
807
+ takes a copy of the payload. It cannot write the context. In lax mode it runs
808
+ again if the process dies before the wait is stored, under the same token, so
809
+ what it sends must be idempotent by that token; in strict mode the restore of
810
+ such a run raises `Smith::StepInProgressOnRestore` instead. `retry_on`
811
+ retries the block, never a timeout.
812
+ - Entering a wait is not a step. `advance!` returns nil, `run!`,
813
+ `run_persisted!` and `advance_persisted!` stop with the wait stored, and the
814
+ step count and `max_transitions` do not move; `wall_clock` is charged only
815
+ for the time the block ran. `waiting?` and `awaiting` (a
816
+ `Smith::Workflow::Wait` with `kind`, `event`, `token`, `transition`, `into`,
817
+ `entered_at`, `deadline_at`, and `children`, `attempt` and `error` for the
818
+ waits that have them; the event and key as the transition declares them)
819
+ let the host index the wait, and `pending_transition_name` is the
820
+ awaited transition. While a run waits, `advance!` does nothing and the run is
821
+ never terminal, so no `clear:` policy deletes it.
822
+ - `Workflow.resume!(key, event:, token:, payload:, adapter:)` restores the run,
823
+ checks that it awaits that event under that token, writes the payload to
824
+ `into:` (bounded and JSON-shaped like a session message, with the context's
825
+ key shape; it never enters the session), completes the step, and stores the
826
+ run with the version check. It returns `:resumed`, `:failed` when the
827
+ completion took the failure path, `:missing` when no run is stored, or
828
+ `:stale`, changing nothing, for any other event or token.
829
+ - A reply can arrive before its wait is stored, when the block's request is
830
+ answered at once. If the stored run stands at that await and the token is
831
+ the one it is entering the wait with, `resume!` completes the step at once
832
+ (in strict mode too, where the run is caught mid-step) and the process still
833
+ entering the wait loses its next store with
834
+ `Smith::PersistenceVersionConflict`; restoring the run shows it past the
835
+ await. Any other delivery to a strict run caught mid-step raises
836
+ `Smith::StepInProgressOnRestore`.
837
+ - `Workflow.expire_wait!(key, token:, now:, adapter:)` ends a wait whose
838
+ `deadline_at` has passed at `now` (a Time or an ISO 8601 String): a `delay`
839
+ continues to its `to:`, an `await` takes `on_timeout` (a `StepCompleted`
840
+ whose `to` is the await's own state) or else `on_failure` with
841
+ `Smith::WaitTimedOut`, which is never retried and restores as itself with the
842
+ wait it names; a durable retry's wait (`kind: :retry`) ends without a step,
843
+ and the host must then continue the run (see Durable Retry). It returns
844
+ `:expired`, `:not_due`, `:missing` or `:stale`. The host's scheduler decides
845
+ when to call it; Smith never wakes a run. `now:` is the host's clock: passing
846
+ a time at or past `deadline_at` ends a wait early, as a "retry now" control
847
+ may.
848
+ - Only one completion of a wait wins. On one instance, a completion claims the
849
+ wait, so a delivery and an expiry racing there cannot both complete it. Across
850
+ processes, the class methods need an adapter with `store_versioned` (Memory,
851
+ Redis, Active Record), so the second store fails with
852
+ `Smith::PersistenceVersionConflict`; they refuse adapters without it.
853
+ - The completion of an await, delay, map or call, by `resume!` or
854
+ `expire_wait!`, is the step: it has the step's position, so the token matches
855
+ the entry's, and it emits `StepCompleted` (or `StepFailed`). The end of a
856
+ retry wait is not a step and emits nothing; the attempt that follows is. In strict mode a waiting run is a
857
+ checkpoint, not a step in progress, and restores cleanly.
858
+ - A stored wait is checked on restore: one that is malformed, whose deadline
859
+ precedes its entry or has no time zone, or that sits beside a pending
860
+ transition raises `Smith::SerializationError`; one whose transition the
861
+ class no longer declares exactly (kind, event, `into:`) or that does not
862
+ start from the run's state raises `Smith::UnknownTransitionOnRestore`. A
863
+ `migrate_from` that renames such things must rewrite `awaiting` too, and
864
+ `pending_retry` for a retry wait.
865
+ - A wait that does not end within the store's persistence TTL is refused when
866
+ it is entered, before the block runs. `stuck_for?` reports a waiting run as stuck
867
+ only once its deadline has passed by the threshold.
868
+ - Not in 0.12: split-step persistence refuses to prepare a step that waits or
869
+ launches runs (an `await`, `delay`, `map`, `call` or `spawn`, or a step with
870
+ a durable retry), or any step while the run waits, and a durable retry
871
+ cannot wait inside split-step execution. An in-process nested workflow that
872
+ reaches a wait fails its parent's step (runtime readiness reports
873
+ `:nested_workflow_waits`).
874
+
875
+ ### Durable Retry
876
+
877
+ `retry_on ..., durable: true` waits out each backoff as a wait of the run
878
+ instead of sleeping in the worker:
879
+
880
+ ```ruby
881
+ transition :charge, from: :approved, to: :charged do
882
+ compute { |step| Payments.charge(order, idempotency_key: step.idempotency_key) }
883
+ retry_on Payments::Timeout, attempts: 5, backoff: 60, max_delay: 3600, durable: true
884
+ on_failure :charge_failed
885
+ end
886
+ ```
887
+
888
+ - An attempt that fails in a way the policy retries, with attempts left,
889
+ enters a wait of kind `:retry` for the transition (no event, no `into:`),
890
+ due when the backoff ends, under a token of the attempt it waits for. The
891
+ step does not complete or count, and the run records its pending retry (the
892
+ transition, the next attempt, the key generation, and the class name of the
893
+ error) as `pending_retry`; `awaiting.attempt` and `awaiting.error` show them.
894
+ Traces record `outcome: :retrying` (with the attempt, error and deadline)
895
+ and, when the wait ends, `:retry_due`.
896
+ - `expire_wait!` past the deadline ends the wait without a step and returns
897
+ `:expired`; the transition becomes the run's next. The host continues the run
898
+ (`run_persisted!` or `advance_persisted!`), as after any wait, and the step
899
+ makes the recorded attempt under the recorded keys: the same as the failed
900
+ attempt's, unless that attempt was declared settled (see Replay Safety).
901
+ It may succeed, fail for good (`on_failure`, or the error), or wait again.
902
+ `resume!` never completes a retry wait.
903
+ - The pending retry persists until the step ends without entering a wait:
904
+ it completes, fails, or raises. A crash during the attempt makes the same
905
+ attempt again under the same keys in lax mode; in strict mode the attempt
906
+ runs under the step-in-progress mark, so the restore of a run caught in it
907
+ raises `Smith::StepInProgressOnRestore`.
908
+ - No retry wait is entered once the run's wall-clock deadline has passed, after
909
+ a tool ran (`Smith::ToolOutcomeUncertain` is never retried), or for a backoff
910
+ that does not end within the store's persistence TTL (the step fails with a
911
+ `Smith::WorkflowError` whose cause is the attempt's error); runtime readiness
912
+ reports a policy whose longest backoff, jitter included, reaches the TTL
913
+ (`:durable_retry_outlasts_ttl`). Time spent waiting does not count toward
914
+ `wall_clock`.
915
+ - `durable:` needs at least 2 attempts, a positive backoff and `on_failure`,
916
+ and is refused on an await, delay, parallel, fan_out, optimize or
917
+ orchestrate step. `on_failure` is required because a last failure raised to
918
+ the host would leave the stored run before that attempt, as a crash does,
919
+ so a job that runs again would make the attempt again at once, past the
920
+ policy's attempts; through `on_failure` the failure settles the run; a map,
921
+ call or spawn takes no `retry_on` at all. A durable retry inside an
922
+ in-process nested workflow, or a child run that `Smith::Launchers::Inline`
923
+ runs, fails the parent's step (a nested workflow's with the child's error as
924
+ the cause), and runtime readiness reports it as `:nested_workflow_waits`.
925
+ Graph inspection shows `durable: true` in the retry policy.
926
+ - A streamed step's sink (`stream:`) gets `reset(:retry_wait)` when the run
927
+ enters a retry wait, and holds nothing durable: the attempt the host's later
928
+ job makes streams to a new sink from the start. In a stored run, a streamed
929
+ step's `finish` comes once `persist!` has stored the step (a store that fails
930
+ gives the sink `fail` with the store's error instead), so a sink never shows
931
+ as final an output the store did not keep.
932
+ - A stored `pending_retry` is checked on restore: it must name the transition
933
+ of the stored retry wait, or the run's next transition when the run does not
934
+ wait, with an attempt after the first, a key generation an earlier attempt
935
+ could reach, and an error class name; a stored retry wait needs one.
936
+ Anything else raises `Smith::SerializationError`, except a definition
937
+ change: a transition that no longer declares a durable retry, or now
938
+ declares fewer attempts than the recorded one, raises
939
+ `Smith::UnknownTransitionOnRestore`.
940
+
941
+ ### Batch Waits
942
+
943
+ A step whose mode is `:batch` (`execute` or `fan_out`, README "Batch mode")
944
+ sends no request: it captures each call's request as a synchronous call would
945
+ post it, hands it to `Smith.config.batch_queue` and waits.
946
+
947
+ - The requests are queued once the step's attempt has ended without error
948
+ (its context checks included), so a step that fails queues nothing. The
949
+ wait is of kind `:batch` (event `:batch_result`, `children` the number of
950
+ requests, `attempt` the step attempt), due 25 hours after entry (batches end
951
+ within 24). Its token is a key of the step's position and attempt, and is
952
+ the `custom_id` of an agent step's request; a fan-out branch's is
953
+ `Smith::Batch.custom_id(token, branch_key)`. The run records its pending
954
+ batch (`pending_batch`: transition, attempt, key generation, and the digest
955
+ of each request by its id). On restore a malformed one, an attempt past the
956
+ step's `retry_on attempts:`, a key generation without
957
+ `new_idempotency_key_on`, or ids the step's branches no longer give raise
958
+ `SerializationError`, and a wait the class no longer batches is refused
959
+ like an unknown position.
960
+ - Enqueues are one call per request. If the queue raises after taking some
961
+ of a wait's requests, the step fails and those entries answer no wait: a
962
+ host submits only entries whose run waits under their `await_token`
963
+ (`awaiting.token`), and drops the rest.
964
+ - The host resumes the run once, with every result of the wait:
965
+ `resume!(key, event: :batch_result, token:, payload: {custom_id => result})`,
966
+ each result as the provider's results file gives it (`succeeded` with its
967
+ `message`, `errored`, `canceled` or `expired`). A payload that names other
968
+ ids, or a result of an unknown shape, raises ArgumentError and leaves the
969
+ wait for a good one. `expire_wait!` past the deadline completes the step as
970
+ if every result were missing.
971
+ - The step runs again at its attempt and key generation, each call taking its
972
+ result instead of going over the wire, then goes on as after a synchronous
973
+ call; the completion is the step. A call that renders another request than
974
+ the one sent (`pending_batch` keeps each request's digest to tell: a model
975
+ block, instructions or input that changed while the run waited, a date, data
976
+ read afresh) does what the step's `on_request_change:` says. `:keep` (the
977
+ default) takes the result, and the call's `:provider_call` trace says
978
+ `request_changed: true`; `:resend` sends the request as rendered now over
979
+ the wire; `:fail` raises `Smith::BatchRequestChanged`, an `AgentError` that
980
+ `retry_on` may retry. A result set aside so is charged as a
981
+ `:failed_attempt` with `mode: :batch`.
982
+ Only the batch's own attempt takes results; a succeeded result no call took
983
+ (a fan-out branch cancelled by a sibling's failure) is recorded and charged
984
+ as a `:failed_attempt` all the same. In strict mode a completion is stored as
985
+ a step under way before it runs, and a run caught in one is refused as
986
+ unfinished (`StepInProgressOnRestore`). A failed result raises
987
+ `Smith::BatchResultFailed`, which `retry_on` retries: in process, the next
988
+ attempt sends a new batch under a new token (or goes over the wire, if the
989
+ mode callable says :sync); a durable retry waits its backoff first, and the
990
+ batch its next attempt sends takes the place of its pending retry. A request
991
+ the provider refused raises `Smith::BatchRequestInvalid`, never retried.
992
+ - Admission against the budget happens when the request is queued; nothing is
993
+ held while the run waits. The result's usage is charged when it comes, past
994
+ the limits if need be (`Budget::Ledger#reserve_spent!`).
995
+ - A batch step cannot wait inside split-step execution, and a batch step in
996
+ an in-process child (a nested workflow, or a child run run inline) fails
997
+ before anything is captured or queued (readiness: `:nested_workflow_waits`).
998
+
999
+ ## Budget Ledgers
1000
+
1001
+ A workflow's `budget_ledger` (README, "Budget ledgers") is never persisted,
1002
+ and restoring a run never builds it: Smith builds it at the run's first
1003
+ request from the run's `budget_ledger_scope`, the values its scope keys had in
1004
+ its trusted context when it was created, kept with the run (a run stored
1005
+ before its class named those keys takes them from its context when
1006
+ restored). A stored scope that is not of JSON scalars raises
1007
+ `SerializationError`.
1008
+
1009
+ A batch request's hold is keyed by its `custom_id`, which the run's
1010
+ `pending_batch` already keeps, so the delivery that resumes the run (in any
1011
+ process) settles it on the same ledger: a succeeded result, used by the step
1012
+ or not, reconciles what it cost; a failed result, or an expired wait,
1013
+ releases it. A ledger that cannot be built then is logged, and its holds
1014
+ expire. Holds are taken while the step's attempt runs and released if it ends
1015
+ before its requests are queued, except one the queue already took. A process
1016
+ that dies during a request leaves its hold unsettled until its `expires_at`;
1017
+ a delivery that ran again after a crash settles the same keys again.
1018
+
1019
+ A tool loop paused for budget keeps its messages (its tool calls, their
1020
+ results, its thinking and signatures; at most 1 MiB) in the step's
1021
+ `pending_retry` as `continuation`, and the retry continues from them. A
1022
+ continuation that is not of the shape Smith keeps raises `SerializationError`
1023
+ on restore.
1024
+
1025
+ ## Child Runs
1026
+
1027
+ `map`, `call` and `spawn` steps start other workflows as runs of their own.
1028
+ Smith decides which runs to start and checks what comes back; the host's run
1029
+ launcher starts them, and the host persists them, runs them and reports their
1030
+ results. Smith never queues, polls or waits on a child.
1031
+
1032
+ ```ruby
1033
+ class QueueLauncher
1034
+ # children: Smith::ChildRun (ref, key, workflow, input, untrusted_keys,
1035
+ # artifact_namespaces, #build)
1036
+ def launch(children:, parent_key:, await_token:)
1037
+ children.each do |child|
1038
+ ChildRunJob.perform_later(key: child.key, parent_key:, await_token:, ref: child.ref,
1039
+ workflow: child.workflow.name, input: child.input,
1040
+ untrusted_keys: child.untrusted_keys,
1041
+ artifact_namespaces: child.artifact_namespaces)
1042
+ end
1043
+ end
1044
+ end
1045
+
1046
+ Smith.config.run_launcher = QueueLauncher.new
1047
+ ```
1048
+
1049
+ - A child's `ref` is the index of its item, and its `key` is the step's token
1050
+ and the ref together, unique across every child-run step of every run. Items
1051
+ come from the context (a persisted key holding an Array, or a callable of the
1052
+ context, which must give the same list on a replay), so a replayed step
1053
+ launches the same children under the same keys: the launcher must be
1054
+ idempotent on `child.key`. Only a launcher whose `inline?` is true has its
1055
+ return value taken as results (it must return them, as an Array); anything
1056
+ another launcher returns is ignored. The job side rebuilds a
1057
+ `Smith::ChildRun` from its arguments (input keys as Symbols) and calls
1058
+ `#build`, which checks the untrusted key names it gets back as Strings and
1059
+ the artifact namespaces the child may read (its parent's and the parent's
1060
+ nearest ancestors', at most 16; a job that omits them gives the child none,
1061
+ and its reads of the parent's documents fail).
1062
+ - A child gets only the context `input:` returns (JSON-shaped, bounded at 1 MiB
1063
+ each and 16 MiB for a step's children together) and no session. A key the
1064
+ parent treats as untrusted that `input:` passes under the same name stays
1065
+ untrusted in the child: `ChildRun#build` constructs the child with those keys
1066
+ in force, and they persist with it (`inherited_untrusted_keys`). The mark
1067
+ goes by name: a value passed under another name, or inside another value, is
1068
+ not marked, so pass untrusted content under its own key.
1069
+ - A map, call or spawn needs a persisted run (`run_persisted!` or
1070
+ `advance_persisted!`), except with a launcher that runs the children itself
1071
+ (`inline?` true, as `Smith::Launchers::Inline`): it returns their results and
1072
+ the step completes at once. A map over no items completes at once and
1073
+ launches nothing. In `idempotency_mode :strict` a map, call or spawn step
1074
+ that crashed mid-launch runs again on restore, since launching is idempotent.
1075
+ - Otherwise the step waits like an `await` for the event `:children`, bounded by
1076
+ its `timeout:` and routed by `on_timeout` or `on_failure`. Once every child is
1077
+ terminal the host resumes the parent with one entry per ref,
1078
+ `Smith::ChildRun.result(ref:, run_result:)`:
1079
+ `{ ref:, status: "succeeded" | "failed", state:, output:, error: }`, where a
1080
+ child failed when it ended in its failed state or any of its steps raised.
1081
+ `state`, `output` and `error` may be left out (as nil); `state` and `error`
1082
+ are text of at most 256 bytes. Results that do not match the number of
1083
+ children the step launched (`Wait#children`) raise to the host and leave the
1084
+ run waiting. They are bounded at 4 MiB together, after the outputs a map does
1085
+ not collect are dropped, so `collect: :states` or `:none` suits a large map.
1086
+ - `collect: :outputs` writes the entries to `into:` in ref order, `:states`
1087
+ drops the outputs, and `:none` writes `{ succeeded:, failed: }`. A `call`
1088
+ writes its child's output itself. With `on_item_failure: :fail` (always for
1089
+ a call) any failed child fails the step with `Smith::ChildRunsFailed`
1090
+ (failure family `child_runs_failed`, up to 20 failed refs with the counts),
1091
+ so such a step must declare `on_failure`.
1092
+ - No map, call or spawn takes `retry_on`: the launcher retries launching, and
1093
+ a map's results arrive with the host's resume, outside any attempt. Each
1094
+ child is a run with its own budget; the parent's budget does not cover it.
1095
+ - `Smith::Launchers::Inline` runs each child to its end under the parent's
1096
+ deadline, apart from the parent's tool evidence and attribution, and reports
1097
+ a child left waiting, or one that raised, as failed with its error.
1098
+ - A `spawn` launches and completes its step; nothing comes back.
1099
+ - Graph inspection shows the kind (`:map`, `:call`, `:spawn`) and the child
1100
+ workflow, bound, items, collect and failure modes. Runtime readiness walks
1101
+ the child workflow like a nested one (a child may wait: it is a run of its
1102
+ own, and may map over its own workflow) and reports `:run_launcher_missing`
1103
+ when no launcher is configured. An in-process nested workflow that reaches a
1104
+ map, call or spawn is reported (`:nested_workflow_waits`) unless the launcher
1105
+ runs children in process. An agent in a child run that reads a key the
1106
+ parent marks untrusted is a warning (`:nested_untrusted_agent_input`), not
1107
+ an error as in a nested workflow, since readiness cannot see whether
1108
+ `input:` passes that key.
1109
+
1110
+ ## Replay Safety: Idempotency Keys
1111
+
1112
+ A replayed step's provider calls are paid for again unless
1113
+ `Smith.config.response_memo` keeps them (README, "Response memo"): each call
1114
+ is keyed by its position's idempotency scope (so a settled failure's new key
1115
+ generation reaches new records), its attempt, its model in the fallback chain
1116
+ and its place among the attempt's calls. Nothing about a memo is persisted
1117
+ with the run.
1118
+
1119
+
1120
+ A step that is replayed, by a retry or by a restore after a crash, sends its
1121
+ outside requests again. Smith gives each request a key the step can pass along,
1122
+ so a receiver that honours idempotency keys can drop the repeat. Passing the key
1123
+ is the step's job, and what a receiver does with it stays the host's concern:
1124
+
1125
+ ```ruby
1126
+ transition :charge, from: :approved, to: :charged do
1127
+ compute do |step|
1128
+ Payments.charge(order, idempotency_key: step.idempotency_key)
1129
+ Receipts.email(order, idempotency_key: step.idempotency_key(:receipt))
1130
+ end
1131
+ retry_on Payments::Timeout, Payments::Declined, attempts: 3,
1132
+ new_idempotency_key_on: [Payments::Declined]
1133
+ end
1134
+ ```
1135
+
1136
+ - `step.idempotency_key(scope = nil)` in `compute` and `run` steps,
1137
+ `idempotency_key(scope)` inside a `Smith::Tool`'s `perform`, and
1138
+ `Smith::IdempotencyKey.current(scope = nil)` for host code called from a
1139
+ running step. Each returns 64 lowercase hex characters (a SHA-256), which
1140
+ fits receivers that cap keys at 64 characters.
1141
+ - A key is unique to the run, the step's position and the scope. The position
1142
+ is the step number and transition, then any nested workflow step, parallel or
1143
+ fan-out branch (durable composite branches included), optimizer round and
1144
+ role (generator, `before_eval`, evaluator), or orchestrator round and worker
1145
+ task. A worker task is named by its content and which copy of equal tasks it
1146
+ is, so a replayed orchestrator that lists the same tasks in another order
1147
+ keeps each task's key. A transition that runs again later in the run (a loop,
1148
+ or an `on_failure` route back to it) has a new step number and so new keys.
1149
+ - Use a scope to tell apart several requests of one step. A tool can run several
1150
+ times in a step, and a replayed model may word or order its calls
1151
+ differently, so a tool's scope is required and should be the request's stable
1152
+ identity (`idempotency_key(customer_id:, invoice_number:)`), not free text. A
1153
+ tool's key also includes its `name`, so renaming a tool class changes its
1154
+ keys; a tool without a name is refused. A plain RubyLLM tool has only
1155
+ `Smith::IdempotencyKey.current`, so it should put its own name in the scope.
1156
+ - A scope is built from Strings, Symbols, numbers, `true`, `false`, `nil`,
1157
+ Arrays and Hashes with String or Symbol keys. Symbols count as their Strings,
1158
+ Hash order does not matter, and a Float with a whole value counts as the
1159
+ Integer (a model's JSON may send `12` or `12.0`). NaN, Infinity and other
1160
+ objects raise `ArgumentError`, as do scopes nested deeper than 32 levels or
1161
+ holding more than 100,000 values, which bounds the work per key.
1162
+ - Keys exist only while a step does its own work: its body, agents, tools and
1163
+ guardrails. Event handlers and `on_step` callbacks get none. The position is
1164
+ thread-local state that Smith carries into its own branch threads and
1165
+ concurrent tool calls; code in a Fiber or Thread the host creates has none,
1166
+ while `step.idempotency_key` works anywhere.
1167
+ - The run part is the workflow's execution namespace, a UUID assigned when the
1168
+ workflow is built and persisted with it. A payload written without one (by an
1169
+ earlier version) gets one derived from its class, persistence key and
1170
+ creation time, the same on every restore, until a checkpoint stores it. A
1171
+ stored namespace that is not a UUID raises `Smith::SerializationError`. Runs
1172
+ built with `new` never share keys, even when one reuses another's persistence
1173
+ key after it was cleared; runs restored from copies of one payload share
1174
+ them, and their artifact scope, so a payload must not be cloned into several
1175
+ runs.
1176
+ - A replay keeps a step's keys when the checkpoint written before the step
1177
+ holds the namespace and step count, as `advance_persisted!` and
1178
+ `run_persisted!` write it. In `idempotency_mode :lax` a run restored after a
1179
+ crash mid-step replays that step under its keys. In `:strict` mode the restore
1180
+ raises `Smith::StepInProgressOnRestore` instead, and reconciling the step's
1181
+ requests is the host's; a host-coordinated prepared step is never executed
1182
+ again after dispatch. A run that is never persisted has no replay to protect.
1183
+
1184
+ Every attempt of a step uses the same keys. A retry after an uncertain failure
1185
+ (a timeout, a dropped connection) then cannot repeat a request that took
1186
+ effect. Some receivers store a failed reply under its key and return it for
1187
+ every repeat. When an attempt fails in a way that shows its own requests took
1188
+ no effect, the attempts after it can use new keys:
1189
+
1190
+ - `step.fail!("declined", retryable: true, new_idempotency_key: true)` in a
1191
+ `compute` or `run` step with `retry_on`. It counts only for the step that
1192
+ raised it: a nested workflow's step that raises it does not move the parent's
1193
+ retry to new keys, because the child's earlier steps may have taken effect.
1194
+ The flag is not kept in the persisted failure record.
1195
+ - `retry_on ..., new_idempotency_key_on: [Payments::Declined]`. The listed
1196
+ classes must be ones the policy retries (any class, when the policy lists none
1197
+ and Smith's retry classification decides), each listed once. Classes that may
1198
+ hide a tool's outcome are refused, and so is the option on a nested workflow,
1199
+ parallel, fan-out, optimize or orchestrate transition, where one error cannot
1200
+ speak for every request of the attempt. A broad class (`StandardError`) moves
1201
+ every retry, timeouts included, to new keys. Graph inspection shows the list
1202
+ in the transition's retry policy.
1203
+
1204
+ `step.attempt` is the attempt under way within one run of the step's
1205
+ `retry_on` attempts, from 1 (a nested workflow's step counts its own). A host
1206
+ that wants a different key on every attempt can pass it in the scope
1207
+ (`step.idempotency_key([:charge, step.attempt])`).
1208
+
1209
+ The generation of keys lives only within one run of the step's retries (a
1210
+ durable retry persists it across its waits; see Durable Retry). When
1211
+ the attempts run out and the host retries the run later (a job that runs again),
1212
+ the step starts again from its first keys, so a receiver that stored a failed
1213
+ reply returns it again. A host that wants its own later retries to use new keys
1214
+ puts a counter it owns in the scope. After a crash, the step's retries run again
1215
+ from the first attempt (a durable retry's from the attempt it recorded); a replay reaches the newer keys again only if the
1216
+ receivers refuse the replayed requests as they did before, which a receiver that
1217
+ stores replies under their keys does.
1218
+
566
1219
  ## Active Record Optimistic Locking
567
1220
 
568
1221
  `ActiveRecordStore` keeps two version domains deliberately separate:
@@ -582,7 +1235,22 @@ Valid JSON scalars are not workflow-state documents, and explicit null,
582
1235
  negative, or non-integer versions fail closed instead of being overwritten.
583
1236
 
584
1237
  The default host model needs a unique key, a JSON/text payload, and Rails'
585
- standard optimistic-locking column:
1238
+ standard optimistic-locking column. `bin/rails generate smith:install`
1239
+ writes its migration, model and initializer (`--state-model`,
1240
+ `--max-state-bytes`; see the README's Installation), with a CHECK on the
1241
+ payload's size a third above `persistence_max_bytes`. Smith keeps a run
1242
+ within the bound: a stored run's step whose result would not fit fails with
1243
+ `Smith::PersistedStateTooLarge` before the result is kept (its usage
1244
+ recorded, its `on_failure` taken, strict mode included), a tool loop never
1245
+ pauses into a state the bound would refuse (it goes on unheld), and any
1246
+ other state past the bound is refused before anything is written. The CHECK
1247
+ bounds the stored bytes, a column the host encrypts included; an encrypted
1248
+ payload holds runs, but `replace_exact` (split-step exact writes) compares
1249
+ payloads in the database and refuses one. The key is a string at least 64
1250
+ characters wide: child runs are stored under 64-character keys of their
1251
+ own, so it is never a host row's id. `smith doctor` checks the model on
1252
+ every run (skipping when the database cannot be reached), and its durability
1253
+ probe writes with `store_versioned`, as runs do. By hand:
586
1254
 
587
1255
  ```ruby
588
1256
  create_table :workflow_states do |t|
@@ -680,17 +1348,81 @@ class LargeReportAgent < Smith::Agent
680
1348
  end
681
1349
  ```
682
1350
 
683
- Configure a backend:
1351
+ Configure a store. Like workflow state, artifacts must outlive the process
1352
+ that stored them whenever a run waits, retries or resumes elsewhere, so Smith
1353
+ assumes none: outside `test_mode`, storing or reading an artifact with
1354
+ nothing configured raises `Smith::ArtifactStoreMissing` (a run that keeps no
1355
+ artifacts needs no store), and `smith doctor` warns. In a test environment,
1356
+ set `test_mode` (an in-process store) or `artifact_store = :memory`.
684
1357
 
685
1358
  ```ruby
686
1359
  Smith.configure do |config|
687
- config.artifact_store = Smith::Artifacts::Memory.new
688
- config.artifact_retention = 3600
1360
+ # Cloudflare R2, or any S3-compatible bucket (needs the aws-sdk-s3 gem, or client:)
1361
+ config.artifact_store = :s3
1362
+ config.artifact_options = {
1363
+ bucket: "smith-artifacts", prefix: "artifacts",
1364
+ endpoint: "https://<account>.r2.cloudflarestorage.com", region: "auto",
1365
+ access_key_id: ENV.fetch("R2_ACCESS_KEY_ID"), secret_access_key: ENV.fetch("R2_SECRET_ACCESS_KEY"),
1366
+ # Cloudflare's advice for aws-sdk-s3 1.178 and later:
1367
+ request_checksum_calculation: "when_required", response_checksum_validation: "when_required"
1368
+ }
1369
+ config.artifact_retention = 30 * 86_400
689
1370
  end
690
1371
  ```
691
1372
 
1373
+ - An artifact is bytes: `store` takes a String (serialize anything else,
1374
+ for example with `JSON.generate`) and refuses other values with a
1375
+ `TypeError`.
1376
+ - `:s3` keeps each artifact as one object under `prefix`, its key the ref (a
1377
+ random UUID after the store's tenant), with the run that stored it in the
1378
+ object's metadata; only keys of that shape are artifacts, so other objects
1379
+ in the bucket are never read, listed or deleted. It builds an
1380
+ `Aws::S3::Client` from the remaining options, or takes the host's as
1381
+ `client:` (not both); a Rails app already using R2 through Active Storage
1382
+ can pass that service's client. Smith does not depend on the SDK.
1383
+ - `:active_record` keeps each artifact as a row through the host's model
1384
+ (`model:`, a class or its name):
1385
+
1386
+ ```ruby
1387
+ create_table :smith_artifacts do |t|
1388
+ t.string :ref, null: false, index: { unique: true }
1389
+ t.binary :data, null: false, limit: 64.megabytes # MySQL's default blob holds 64 KiB
1390
+ t.string :content_type, null: false
1391
+ t.string :execution_namespace, index: true
1392
+ t.timestamps
1393
+ end
1394
+ ```
1395
+
1396
+ - `:file` keeps files in a directory (`dir:`), shared only when the directory
1397
+ is; `:memory` keeps them in the process, for one process or tests (its refs
1398
+ are content hashes, so runs that store the same bytes share one, each with
1399
+ its own claim). A store object (or a class or module whose own methods
1400
+ store), or a store class built with the options, works too: it implements
1401
+ `store` and `fetch`; `stored_by?(ref, execution_namespace)` lets runs read
1402
+ only their own refs (content parts and a run's deletes require it), and
1403
+ `expired` and `delete(ref, execution_namespace: nil)` serve cleanup. Every
1404
+ built-in store implements them all.
1405
+ - Any built-in store takes `namespace:`, a tenant prefix of letters, digits,
1406
+ `-` and `_` that its refs carry; a store reads no other tenant's refs, and
1407
+ with `artifact_tenant_isolation` it refuses to work without one.
1408
+ `artifact_retention` is the retention every built-in store's `expired`
1409
+ uses when none is given.
1410
+ - Cleanup is the host's. `workflow.artifacts` is a run's view of the store
1411
+ (what its steps see as `Smith.artifacts`): it reads the run's refs, lists
1412
+ its expired ones (`expired(retention:)`) and deletes them (`delete(ref)`,
1413
+ only the run's own, and in Memory only the run's claim). Across runs,
1414
+ `Smith.artifact_backend.expired(retention:)` lists refs older than the
1415
+ retention and `delete(ref)` removes them. A run that waits keeps its
1416
+ artifacts for as long as it waits, so keep the retention longer than the
1417
+ longest wait, or clean up per run once it is cleared. A child run reads its
1418
+ ancestors' artifacts, so a parent's must outlive its spawned children. On
1419
+ S3, listing one run's expired refs costs a HEAD request per expired object
1420
+ under the prefix.
1421
+
692
1422
  Why this matters:
693
1423
 
694
1424
  - large payloads can move out of the inline workflow result
695
- - refs are execution-scoped
1425
+ - refs are execution-scoped: a step stores and reads under its run's scope,
1426
+ which ends with the step, so outside a run's steps `Smith.artifacts` is the
1427
+ configured store itself; a child run reads its ancestors' refs too
696
1428
  - nested workflows inherit artifact scope correctly