smith-agents 0.11.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 +698 -0
- data/README.md +621 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- 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/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 +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- 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 +70 -3
- 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 -51
- data/lib/smith/agent.rb +5 -0
- 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/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- 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 +10 -5
- data/lib/smith/context.rb +28 -0
- 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 +20 -18
- 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 +249 -2
- 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 +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- 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 +11 -5
- 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 +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- 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/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/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -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 +36 -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 +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
require_relative "await_step"
|
|
6
|
+
require_relative "message_value_normalizer"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
# The wait a run is in: an `await` for `event` (delivered under `token`
|
|
11
|
+
# by `resume!`), a `delay`, a map's or call's wait for its `children`, a
|
|
12
|
+
# batch step's wait for the results of its `children` requests
|
|
13
|
+
# (`:batch`, event `:batch_result`, with its `attempt`), or a durable
|
|
14
|
+
# retry's backoff (`:retry`, with the `attempt` it waits to make and the
|
|
15
|
+
# class name of the `error` that failed the last one), entered at
|
|
16
|
+
# `entered_at` and due to expire at `deadline_at`. Hosts index it to deliver events and to schedule the
|
|
17
|
+
# `expire_wait!` call; Smith never sleeps, polls or wakes a run itself.
|
|
18
|
+
Wait = Data.define(:kind, :event, :token, :transition, :into, :entered_at, :deadline_at, :children, :attempt,
|
|
19
|
+
:error)
|
|
20
|
+
|
|
21
|
+
# What a step that entered a wait returns instead of its output.
|
|
22
|
+
WAIT_PENDING = Object.new.freeze
|
|
23
|
+
private_constant :WAIT_PENDING
|
|
24
|
+
|
|
25
|
+
# Entering, resuming and expiring `await` and `delay` steps. Entering a
|
|
26
|
+
# wait is not a step: `step_count` stays, so the step's position and its
|
|
27
|
+
# token (a key under a position reserved for the wait) are the same when
|
|
28
|
+
# the wait is entered, when its block runs again after a crash, and when
|
|
29
|
+
# it completes. The completion (`resume!`, or `expire_wait!` past the
|
|
30
|
+
# deadline) is the step. One completion at a time claims the wait, so a
|
|
31
|
+
# delivery and an expiry racing on one instance cannot both win.
|
|
32
|
+
module WaitExecution
|
|
33
|
+
def waiting? = !@awaiting.nil?
|
|
34
|
+
|
|
35
|
+
# The run's wait, or nil.
|
|
36
|
+
def awaiting
|
|
37
|
+
record = @awaiting
|
|
38
|
+
WaitRecord.to_wait(record, awaited_transition(record), @pending_retry, @pending_batch) if record
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Delivers the event this run awaits: writes the normalized payload to
|
|
42
|
+
# the step's `into:` key and completes the step. A delivery can arrive
|
|
43
|
+
# before the wait is stored (the block's request answered at once);
|
|
44
|
+
# when the run stands at that await and the token is the one it will
|
|
45
|
+
# use, the step completes at once, without running the block.
|
|
46
|
+
# Returns `:resumed`, `:failed` when the completion took the failure
|
|
47
|
+
# path, or `:stale` (changing nothing) for any other event or token.
|
|
48
|
+
def resume!(event:, token:, payload:)
|
|
49
|
+
transition = claim_wait { resumable_transition(event.to_s, token.to_s) }
|
|
50
|
+
return :stale unless transition
|
|
51
|
+
return complete_batch_wait(transition, batch_results!(transition, payload), :resumed) if batch_waiting?
|
|
52
|
+
|
|
53
|
+
value = claimed_payload(transition, payload)
|
|
54
|
+
complete_claimed_wait(transition, :resumed) do
|
|
55
|
+
transition.child_runs? ? deliver_child_results(transition, value) : write_wait_payload(transition, value)
|
|
56
|
+
StepCompletion.instance_method(:complete_step).bind_call(self, transition, nil)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Ends a wait whose deadline has passed at `now` (a Time, or an
|
|
61
|
+
# ISO 8601 String): a `delay` continues to its `to:`, an `await` takes
|
|
62
|
+
# `on_timeout`, or else its `on_failure` with Smith::WaitTimedOut, and
|
|
63
|
+
# a durable retry's wait ends with its step next (RetryWait). Returns
|
|
64
|
+
# `:expired`, `:not_due`, or `:stale` unless the run waits under this
|
|
65
|
+
# token.
|
|
66
|
+
def expire_wait!(token:, now: Time.now.utc)
|
|
67
|
+
now = wait_time(now)
|
|
68
|
+
record = @awaiting
|
|
69
|
+
not_due = false
|
|
70
|
+
transition = claim_wait do
|
|
71
|
+
next if record.nil? || !record.equal?(@awaiting) || record["token"] != token.to_s
|
|
72
|
+
|
|
73
|
+
not_due = now < wait_deadline(record)
|
|
74
|
+
awaited_transition(record) unless not_due
|
|
75
|
+
end
|
|
76
|
+
return not_due ? :not_due : :stale unless transition
|
|
77
|
+
|
|
78
|
+
expire_claimed_wait(transition, record)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Runs the step's block, if any, then either finishes inline (the
|
|
84
|
+
# block called `resolve!`) or records the wait and returns
|
|
85
|
+
# WAIT_PENDING, which tells `advance!` the step has not completed. The
|
|
86
|
+
# payload key and the store's TTL are checked before the block sends
|
|
87
|
+
# anything.
|
|
88
|
+
def execute_await_step(transition)
|
|
89
|
+
check_deadline!
|
|
90
|
+
seconds = WaitRecord.seconds(transition.await_config.fetch(:timeout), snapshot_value(@context))
|
|
91
|
+
check_wait_admissible!(transition, seconds)
|
|
92
|
+
token = await_token_for(transition)
|
|
93
|
+
settled = settle_on_entry(transition, token)
|
|
94
|
+
return settled unless wait_pending?(settled)
|
|
95
|
+
|
|
96
|
+
enter_wait(transition, token, seconds)
|
|
97
|
+
WAIT_PENDING
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Runs the step's entry: an await's block, or a map's or call's
|
|
101
|
+
# launch. The step settles at once when the block resolves the wait or
|
|
102
|
+
# the launcher ran the children itself; otherwise WAIT_PENDING.
|
|
103
|
+
def settle_on_entry(transition, token)
|
|
104
|
+
if transition.child_runs?
|
|
105
|
+
results = launch_child_runs(transition, token)
|
|
106
|
+
return WAIT_PENDING unless results
|
|
107
|
+
|
|
108
|
+
payload = child_results_payload(collectable_results(transition.children_config, results))
|
|
109
|
+
return deliver_child_results(transition, payload, @launched_children)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
step = run_await_block(transition, token) if transition.await_config[:block]
|
|
113
|
+
step&.resolved? ? write_wait_payload(transition, wait_payload(step.payload)) : WAIT_PENDING
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def wait_pending?(value) = value.equal?(WAIT_PENDING)
|
|
117
|
+
|
|
118
|
+
# The delivered payload, normalized for the claimed step: a map's
|
|
119
|
+
# results checked against its children, anything else bounded like a
|
|
120
|
+
# session message. A payload that does not fit raises to the host and
|
|
121
|
+
# frees the wait.
|
|
122
|
+
def claimed_payload(transition, payload)
|
|
123
|
+
return wait_payload(payload) unless transition.child_runs?
|
|
124
|
+
|
|
125
|
+
entries = child_results_payload(collectable_results(transition.children_config, payload))
|
|
126
|
+
checked_child_results(entries, expected_children(transition), transition.children_config[:kind])
|
|
127
|
+
rescue StandardError
|
|
128
|
+
@wait_claimed = false
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The wait's token: a key of the step's position under a segment only
|
|
133
|
+
# waits use, so no `idempotency_key` scope can produce it, and
|
|
134
|
+
# computable before the wait is stored. A wait is never nested (an
|
|
135
|
+
# in-process child cannot wait), so the position is the run's own.
|
|
136
|
+
def await_token_for(transition)
|
|
137
|
+
config = transition.await_config
|
|
138
|
+
wait_token_for(transition, [config.fetch(:kind), config[:event]].compact)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def wait_token_for(transition, scope)
|
|
142
|
+
IdempotencyKey.__send__(:root_scope, ArtifactIntegration.instance_method(:execution_namespace).bind_call(self))
|
|
143
|
+
.within(:step, @step_count + 1, transition.name.to_s).within(:wait).key(scope)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def check_wait_admissible!(transition, seconds)
|
|
147
|
+
writable_wait_key!(transition) if transition.await_config[:into]
|
|
148
|
+
check_wait_ttl!(seconds)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_wait_ttl!(seconds)
|
|
152
|
+
ttl = self.class.persistence_ttl || Smith.config.persistence_ttl
|
|
153
|
+
return unless @persistence_key && ttl && seconds >= ttl
|
|
154
|
+
|
|
155
|
+
raise WorkflowError, "a wait of #{seconds} seconds does not end within the persistence TTL of #{ttl} " \
|
|
156
|
+
"seconds; the stored run would expire before the wait ends"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_await_block(transition, token)
|
|
160
|
+
step = AwaitStep.new(context: snapshot_value(@context), await_token: token,
|
|
161
|
+
idempotency_scope: IdempotencyKey.__send__(:current_scope!),
|
|
162
|
+
attempt: @step_attempt || 1, transition_name: transition.name)
|
|
163
|
+
transition.await_config.fetch(:block).call(step)
|
|
164
|
+
step
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def enter_wait(transition, token, seconds)
|
|
168
|
+
children = @launched_children if transition.child_runs?
|
|
169
|
+
@awaiting = WaitRecord.build(transition, token, seconds, Time.now.utc, children:)
|
|
170
|
+
record_wait_trace(transition, :waiting)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def record_wait_trace(transition, outcome)
|
|
174
|
+
Smith::Trace.record(
|
|
175
|
+
type: :transition, data: { transition: transition.name, from: transition.from, to: transition.to, outcome: }
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# The persisted form of a run's wait: a frozen Hash of frozen Strings, so
|
|
8
|
+
# it round-trips through JSON unchanged and no caller can change it.
|
|
9
|
+
# Built on entry, checked on restore against the workflow class and the
|
|
10
|
+
# run's position, and read as a Wait.
|
|
11
|
+
module WaitRecord
|
|
12
|
+
FIELDS = %w[kind event token transition into entered_at deadline_at children].freeze
|
|
13
|
+
TOKEN = /\A[0-9a-f]{64}\z/
|
|
14
|
+
ZONED = /(?:Z|[+-]\d\d:\d\d)\z/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# `children` is how many runs a map or call launched; nil otherwise.
|
|
19
|
+
def build(transition, token, seconds, now, children: nil)
|
|
20
|
+
config = transition.await_config
|
|
21
|
+
frozen(timing(transition, token, seconds, now).merge(
|
|
22
|
+
"kind" => config.fetch(:kind).to_s, "event" => config[:event]&.to_s, "into" => config[:into]&.to_s,
|
|
23
|
+
"children" => children
|
|
24
|
+
))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A batch step's wait for the results of its `count` requests.
|
|
28
|
+
def build_batch(transition, token, seconds, now, count)
|
|
29
|
+
frozen(timing(transition, token, seconds, now)
|
|
30
|
+
.merge("kind" => "batch", "event" => Batch::EVENT, "into" => nil, "children" => count))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A durable retry's wait: no event or context key.
|
|
34
|
+
def build_retry(transition, token, seconds, now)
|
|
35
|
+
frozen(timing(transition, token, seconds, now)
|
|
36
|
+
.merge("kind" => "retry", "event" => nil, "into" => nil, "children" => nil))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def timing(transition, token, seconds, now)
|
|
40
|
+
{ "token" => token, "transition" => transition.name.to_s, "entered_at" => now.utc.iso8601(6),
|
|
41
|
+
"deadline_at" => (now + seconds).utc.iso8601(6) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A wait's timeout in seconds: the declared number, or what its
|
|
45
|
+
# callable returns for the context, which must be positive.
|
|
46
|
+
def seconds(timeout, context)
|
|
47
|
+
seconds = timeout.respond_to?(:call) ? timeout.call(context) : timeout
|
|
48
|
+
return seconds if (seconds.is_a?(Integer) || (seconds.is_a?(Float) && seconds.finite?)) && seconds.positive?
|
|
49
|
+
|
|
50
|
+
raise WorkflowError, "a wait's timeout must be a positive number of seconds"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The event and context key read as the transition declares them; a
|
|
54
|
+
# retry's attempt and error read from the run's pending retry, a
|
|
55
|
+
# batch's attempt from its pending batch.
|
|
56
|
+
def to_wait(record, transition, pending_retry, pending_batch = nil)
|
|
57
|
+
config = wait_config(record, transition)
|
|
58
|
+
pending = { "retry" => pending_retry, "batch" => pending_batch }[record["kind"]]
|
|
59
|
+
Wait.new(
|
|
60
|
+
kind: config.fetch(:kind), event: config[:event], token: record.fetch("token"), transition: transition.name,
|
|
61
|
+
into: config[:into], entered_at: Time.iso8601(record.fetch("entered_at")),
|
|
62
|
+
deadline_at: Time.iso8601(record.fetch("deadline_at")), children: record["children"],
|
|
63
|
+
attempt: pending&.fetch("attempt"), error: pending&.fetch("error", nil)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def wait_config(record, transition)
|
|
68
|
+
{ "retry" => { kind: :retry }, "batch" => { kind: :batch, event: Batch::EVENT.to_sym } }
|
|
69
|
+
.fetch(record["kind"]) { transition.await_config }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A persisted wait, checked: a malformed one raises SerializationError,
|
|
73
|
+
# and one the class no longer declares exactly (the transition, its
|
|
74
|
+
# kind, event and context key) or that does not start from the run's
|
|
75
|
+
# state is refused like any unknown position.
|
|
76
|
+
def restore(value, workflow_class:, persistence_key:, state:, next_transition:)
|
|
77
|
+
return if value.nil?
|
|
78
|
+
|
|
79
|
+
record = stringified(value)
|
|
80
|
+
unless well_formed?(record) && next_transition.nil?
|
|
81
|
+
raise Smith::SerializationError, "persisted workflow awaiting is malformed"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
transition = workflow_class.transition_named(record["transition"])
|
|
85
|
+
return frozen(record.slice(*FIELDS)) if declared_wait?(transition, record, state)
|
|
86
|
+
|
|
87
|
+
raise Smith::UnknownTransitionOnRestore.new(workflow: workflow_class.name, persistence_key:,
|
|
88
|
+
transition: record["transition"].to_sym)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def stringified(value)
|
|
92
|
+
raise Smith::SerializationError, "persisted workflow awaiting is malformed" unless value.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
value.to_h { |key, field| [key.to_s, field.is_a?(Symbol) ? field.to_s : field] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def well_formed?(record)
|
|
98
|
+
%w[await delay children retry batch].include?(record["kind"]) && TOKEN.match?(record["token"].to_s) &&
|
|
99
|
+
nonempty_string?(record["transition"]) && await_fields?(record) && times?(record) && children_count?(record)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A map's or call's wait holds how many children it launched, a
|
|
103
|
+
# batch's how many requests it sent.
|
|
104
|
+
def children_count?(record)
|
|
105
|
+
count = record["children"]
|
|
106
|
+
case record["kind"]
|
|
107
|
+
when "batch" then count.is_a?(Integer) && count.between?(1, 10_000)
|
|
108
|
+
when "children" then count.is_a?(Integer) && count.between?(0, 10_000)
|
|
109
|
+
else count.nil?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# An await or a map's wait for its children names its event and
|
|
114
|
+
# context key; a batch its event alone; a delay or a retry neither.
|
|
115
|
+
def await_fields?(record)
|
|
116
|
+
event, into = record.values_at("event", "into")
|
|
117
|
+
case record["kind"]
|
|
118
|
+
when "batch" then event == Batch::EVENT && into.nil?
|
|
119
|
+
when "delay", "retry" then event.nil? && into.nil?
|
|
120
|
+
else nonempty_string?(event) && nonempty_string?(into)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Both times carry their zone, and the deadline is not before entry.
|
|
125
|
+
def times?(record)
|
|
126
|
+
entered, deadline = record.values_at("entered_at", "deadline_at")
|
|
127
|
+
[entered, deadline].all? { |value| value.is_a?(String) && ZONED.match?(value) } &&
|
|
128
|
+
Time.iso8601(entered) <= Time.iso8601(deadline)
|
|
129
|
+
rescue ArgumentError
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def declared_wait?(transition, record, state)
|
|
134
|
+
return false unless transition && [nil, state.to_s].include?(transition.from&.to_s)
|
|
135
|
+
|
|
136
|
+
declared_kind?(transition, record)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def declared_kind?(transition, record)
|
|
140
|
+
case record["kind"]
|
|
141
|
+
when "retry" then transition.durable_retry?
|
|
142
|
+
when "batch" then transition.batches? && record["children"] == batch_count(transition)
|
|
143
|
+
else transition.awaits? && declared_fields(transition.await_config) == record.values_at("kind", "event", "into")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def declared_fields(config) = [config.fetch(:kind), config[:event], config[:into]].map { _1&.to_s }
|
|
148
|
+
|
|
149
|
+
def batch_count(transition) = transition.fanout? ? transition.fanout_config.fetch(:branches).length : 1
|
|
150
|
+
|
|
151
|
+
def nonempty_string?(value) = value.is_a?(String) && !value.empty?
|
|
152
|
+
|
|
153
|
+
def frozen(record) = record.transform_values { |value| value&.dup&.freeze }.freeze
|
|
154
|
+
end
|
|
155
|
+
private_constant :WaitRecord
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/smith/workflow.rb
CHANGED
|
@@ -28,6 +28,19 @@ module Smith
|
|
|
28
28
|
include ArtifactIntegration
|
|
29
29
|
include DataVolumePolicy
|
|
30
30
|
include DeadlineEnforcement
|
|
31
|
+
include ActiveClock
|
|
32
|
+
include StepIO
|
|
33
|
+
include WaitCompletion
|
|
34
|
+
include WaitExecution
|
|
35
|
+
include RetryWait
|
|
36
|
+
include BatchExecution
|
|
37
|
+
include BatchWait
|
|
38
|
+
include BatchCharges
|
|
39
|
+
include BatchHolds
|
|
40
|
+
include BudgetPause
|
|
41
|
+
include RememberedCalls
|
|
42
|
+
include ChildRunResults
|
|
43
|
+
include ChildRunExecution
|
|
31
44
|
include Execution
|
|
32
45
|
|
|
33
46
|
DEFAULT_MAX_TRANSITIONS = 100
|
|
@@ -40,8 +53,21 @@ module Smith
|
|
|
40
53
|
|
|
41
54
|
def initialize(context: {}, ledger: nil, created_at: nil)
|
|
42
55
|
@state = self.class.initial_state
|
|
43
|
-
|
|
56
|
+
# Symbol keys at every depth, the shape a restore produces, so a
|
|
57
|
+
# context reads the same live and after a checkpoint round trip.
|
|
58
|
+
@context = ContextKeys.normalize_context(context)
|
|
44
59
|
@step_count = 0
|
|
60
|
+
# The wait the run is in (a persisted Hash of Strings), and the seconds
|
|
61
|
+
# its steps have spent running, which `wall_clock` budgets bound.
|
|
62
|
+
@awaiting = nil
|
|
63
|
+
@wait_mutex = Mutex.new
|
|
64
|
+
@wait_claimed = false
|
|
65
|
+
@active_seconds = 0.0
|
|
66
|
+
@active_step_started_at = nil
|
|
67
|
+
# The run's own identity, persisted with it: artifacts are scoped to
|
|
68
|
+
# it and idempotency keys derive from it, so it exists from the start
|
|
69
|
+
# and is in the checkpoint written before the first step.
|
|
70
|
+
@execution_namespace = SecureRandom.uuid
|
|
45
71
|
@next_transition_name = nil
|
|
46
72
|
@ledger = ledger || build_ledger
|
|
47
73
|
@created_at = created_at || Time.now.utc.iso8601(6)
|
|
@@ -58,6 +84,9 @@ module Smith
|
|
|
58
84
|
@usage_entries = []
|
|
59
85
|
@usage_mutex = Mutex.new
|
|
60
86
|
@last_output = nil
|
|
87
|
+
# The last output the run added to its transcript, which the next
|
|
88
|
+
# agent step reads as a handoff; an `output_to:` step's is not one.
|
|
89
|
+
@last_transcript_output = nil
|
|
61
90
|
# Durable attribution of the most recent serial `execute :agent` step:
|
|
62
91
|
# { model:, provider: } of the model that actually served it (post
|
|
63
92
|
# fallback resolution), exposed to the following deterministic step as
|
|
@@ -98,8 +127,17 @@ module Smith
|
|
|
98
127
|
# also: declaration so explicit input keys round-trip.
|
|
99
128
|
@persisted_keys = ::Set.new(initial_persist_auto_seed)
|
|
100
129
|
@persisted_keys_mutex = Mutex.new
|
|
130
|
+
# A nested workflow's parent's untrusted keys, in force before its
|
|
131
|
+
# seed builder runs (NestedExecution).
|
|
132
|
+
@inherited_untrusted_keys = StepIO.take_inherited_untrusted_keys
|
|
133
|
+
# A child run's parent's artifact namespaces, which it may read.
|
|
134
|
+
@inherited_artifact_namespaces = ArtifactIntegration.take_inherited_artifact_namespaces
|
|
101
135
|
initialize_tool_result_state
|
|
102
136
|
seed_initial_session_messages
|
|
137
|
+
initialize_budget_ledger(Budget::HostLedger.take_inherited)
|
|
138
|
+
# The attempts of the runs a nested workflow runs inside (ResponseMemo).
|
|
139
|
+
@memo_attempts = ResponseMemo.take_inherited
|
|
140
|
+
@budget_ledger_scope = capture_budget_ledger_scope
|
|
103
141
|
end
|
|
104
142
|
|
|
105
143
|
def persisted_keys
|
|
@@ -107,9 +145,12 @@ module Smith
|
|
|
107
145
|
end
|
|
108
146
|
|
|
109
147
|
def advance!
|
|
148
|
+
StepStream.instance_method(:deliver_streamed_output).bind_call(self)
|
|
149
|
+
return if waiting?
|
|
150
|
+
|
|
151
|
+
@step_work_started = false
|
|
110
152
|
advance_claim = SplitStepPersistence.instance_method(:claim_split_step_advance!).bind_call(self)
|
|
111
153
|
ensure_transition_budget!
|
|
112
|
-
@step_work_started = false
|
|
113
154
|
|
|
114
155
|
transition = SplitStepPersistence.instance_method(:resolve_split_step_advance_transition).bind_call(self)
|
|
115
156
|
transition = resolve_transition if transition.equal?(SplitStepPersistence::NO_SPLIT_TRANSITION)
|
|
@@ -121,10 +162,7 @@ module Smith
|
|
|
121
162
|
else
|
|
122
163
|
execute_step(transition)
|
|
123
164
|
end
|
|
124
|
-
|
|
125
|
-
@updated_at = Time.now.utc.iso8601(6)
|
|
126
|
-
record_step_snapshot(step_result)
|
|
127
|
-
step_result
|
|
165
|
+
count_step(step_result)
|
|
128
166
|
rescue UnresolvedTransitionError => e
|
|
129
167
|
step_result = GuardrailIntegration
|
|
130
168
|
.instance_method(:handle_unresolved_transition_failure)
|
|
@@ -132,25 +170,31 @@ module Smith
|
|
|
132
170
|
record_step_snapshot(step_result)
|
|
133
171
|
step_result
|
|
134
172
|
ensure
|
|
173
|
+
clear_settled_pending_retry
|
|
135
174
|
SplitStepPersistence.instance_method(:release_split_step_advance!).bind_call(self, advance_claim) if advance_claim
|
|
136
175
|
end
|
|
137
176
|
|
|
138
177
|
def run!
|
|
139
178
|
SplitStepPersistence.instance_method(:ensure_split_step_execution_allowed!).bind_call(self)
|
|
140
179
|
steps = []
|
|
141
|
-
until
|
|
180
|
+
until nothing_to_advance?
|
|
142
181
|
step = advance!
|
|
143
182
|
steps << step if step
|
|
144
183
|
end
|
|
184
|
+
StepStream.instance_method(:deliver_streamed_output).bind_call(self)
|
|
145
185
|
build_run_result(steps)
|
|
146
186
|
end
|
|
147
187
|
|
|
188
|
+
# The awaited transition while the run waits.
|
|
148
189
|
def pending_transition_name
|
|
190
|
+
return awaiting.transition if waiting?
|
|
191
|
+
|
|
149
192
|
@next_transition_name || self.class.first_transition_from(@state)&.name
|
|
150
193
|
end
|
|
151
194
|
|
|
195
|
+
# A waiting run is never terminal, whatever its state.
|
|
152
196
|
def terminal?
|
|
153
|
-
!self.class.transition_from?(@state) && @next_transition_name.nil?
|
|
197
|
+
!waiting? && !self.class.transition_from?(@state) && @next_transition_name.nil?
|
|
154
198
|
end
|
|
155
199
|
|
|
156
200
|
def done?
|
|
@@ -181,6 +225,18 @@ module Smith
|
|
|
181
225
|
|
|
182
226
|
private
|
|
183
227
|
|
|
228
|
+
# Entering a wait is not a step: nothing counts, and advance! returns nil.
|
|
229
|
+
def count_step(step_result)
|
|
230
|
+
@updated_at = Time.now.utc.iso8601(6)
|
|
231
|
+
return if wait_pending?(step_result)
|
|
232
|
+
|
|
233
|
+
@step_count += 1
|
|
234
|
+
record_step_snapshot(step_result)
|
|
235
|
+
step_result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def nothing_to_advance? = terminal? || waiting?
|
|
239
|
+
|
|
184
240
|
def initial_persist_auto_seed
|
|
185
241
|
manager = self.class.context_manager
|
|
186
242
|
return [] unless manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
@@ -218,6 +274,38 @@ module Smith
|
|
|
218
274
|
end
|
|
219
275
|
end
|
|
220
276
|
|
|
277
|
+
# A run's budget ledger is built at its first use; a nested workflow
|
|
278
|
+
# without one of its own charges `inherited`, the enclosing run's.
|
|
279
|
+
def initialize_budget_ledger(inherited = nil)
|
|
280
|
+
@inherited_budget_ledger = inherited
|
|
281
|
+
@budget_ledger = nil
|
|
282
|
+
@budget_ledger_built = false
|
|
283
|
+
@budget_ledger_mutex = Mutex.new
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The scope the run's ledger is built from, from its trusted context, so
|
|
287
|
+
# no untrusted key chooses whose budget is charged.
|
|
288
|
+
def capture_budget_ledger_scope
|
|
289
|
+
return unless self.class.budget_ledger
|
|
290
|
+
|
|
291
|
+
Budget::HostLedger.scope!(self.class.budget_ledger_scope_keys, trusted_context_view, untrusted_context_keys)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The host's budget ledger this run's requests are held on: its own,
|
|
295
|
+
# built at first use from the scope it was created with, else the
|
|
296
|
+
# enclosing run's (a nested workflow's); nil for none.
|
|
297
|
+
def run_budget_ledger
|
|
298
|
+
return @inherited_budget_ledger&.call unless self.class.budget_ledger
|
|
299
|
+
|
|
300
|
+
@budget_ledger_mutex.synchronize do
|
|
301
|
+
unless @budget_ledger_built
|
|
302
|
+
@budget_ledger = Budget::HostLedger.build(self.class.budget_ledger, @budget_ledger_scope)
|
|
303
|
+
@budget_ledger_built = true
|
|
304
|
+
end
|
|
305
|
+
@budget_ledger
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
221
309
|
def build_ledger
|
|
222
310
|
config = self.class.budget
|
|
223
311
|
return nil unless config
|
|
@@ -313,14 +401,14 @@ module Smith
|
|
|
313
401
|
end
|
|
314
402
|
|
|
315
403
|
# Fresh evaluation of the seed builder against the workflow's
|
|
316
|
-
# current @context. Returns nil when no
|
|
317
|
-
# callers (initialize, validate_seed_digest!)
|
|
318
|
-
# "no builder" from "builder returned empty".
|
|
404
|
+
# current @context, without its untrusted keys. Returns nil when no
|
|
405
|
+
# builder is defined so callers (initialize, validate_seed_digest!)
|
|
406
|
+
# can distinguish "no builder" from "builder returned empty".
|
|
319
407
|
def compute_seed_messages
|
|
320
408
|
builder = self.class.seed_messages
|
|
321
409
|
return nil unless builder
|
|
322
410
|
|
|
323
|
-
seeded = builder.arity == 1 ? builder.call(
|
|
411
|
+
seeded = builder.arity == 1 ? builder.call(trusted_context_view) : builder.call
|
|
324
412
|
normalize_seed_messages(seeded)
|
|
325
413
|
end
|
|
326
414
|
|
|
@@ -335,11 +423,12 @@ module Smith
|
|
|
335
423
|
end
|
|
336
424
|
|
|
337
425
|
def normalize_seed_messages(seeded)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
426
|
+
messages = if seeded.nil? then []
|
|
427
|
+
elsif seeded.is_a?(Hash) then [seeded]
|
|
428
|
+
elsif seeded.respond_to?(:to_a) then seeded.to_a
|
|
429
|
+
else raise WorkflowError, "seed_messages must return a message Hash or an Array of message Hashes"
|
|
430
|
+
end
|
|
431
|
+
messages.map { |message| Message.parts?(message) ? Message.check_seed!(message) : message }
|
|
343
432
|
end
|
|
344
433
|
|
|
345
434
|
def snapshot_context
|
|
@@ -417,6 +506,13 @@ Smith::Workflow::SplitStepPersistence::SubclassBoundary.protect_execution_path!(
|
|
|
417
506
|
Smith::Workflow::ArtifactIntegration,
|
|
418
507
|
Smith::Workflow::DataVolumePolicy,
|
|
419
508
|
Smith::Workflow::DeadlineEnforcement,
|
|
509
|
+
Smith::Workflow::ActiveClock,
|
|
510
|
+
Smith::Workflow::StepIO,
|
|
511
|
+
Smith::Workflow::WaitCompletion,
|
|
512
|
+
Smith::Workflow::ChildRunResults,
|
|
513
|
+
Smith::Workflow::ChildRunExecution,
|
|
514
|
+
Smith::Workflow::WaitExecution,
|
|
515
|
+
Smith::Workflow::RetryWait,
|
|
420
516
|
Smith::Workflow::SplitStepPersistence::DefinitionBoundary,
|
|
421
517
|
Smith::Workflow::SplitStepPersistence::ExecutionVerification,
|
|
422
518
|
Smith::Workflow::SplitStepPersistence::ExecutionAuthorization,
|