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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +901 -0
- data/README.md +636 -20
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +100 -14
- data/docs/PATTERNS.md +88 -12
- data/docs/PERSISTENCE.md +738 -6
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +71 -7
- data/lib/smith/agent/provider_completion.rb +41 -38
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +75 -2
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -48
- data/lib/smith/agent.rb +14 -1
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +10 -4
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +21 -6
- data/lib/smith/context.rb +28 -0
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +35 -17
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +304 -6
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +14 -8
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +62 -11
- data/lib/smith/workflow/deterministic_step.rb +56 -7
- data/lib/smith/workflow/dsl.rb +74 -5
- data/lib/smith/workflow/durability.rb +140 -31
- data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +32 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +41 -2
- data/lib/smith/workflow/failure_record.rb +12 -12
- data/lib/smith/workflow/failure_record_validator.rb +32 -5
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +162 -22
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +71 -8
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +40 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +117 -19
- data/lib/smith.rb +103 -47
- 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
|
|
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
|
|
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
|
-
|
|
688
|
-
config.
|
|
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
|