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
|
@@ -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,11 +53,24 @@ 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
|
-
@created_at = created_at || Time.now.utc.iso8601
|
|
73
|
+
@created_at = created_at || Time.now.utc.iso8601(6)
|
|
48
74
|
@updated_at = @created_at
|
|
49
75
|
@total_cost = 0.0
|
|
50
76
|
@total_tokens = 0
|
|
@@ -58,14 +84,19 @@ 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
|
|
64
93
|
# `last_agent_model` / `last_agent_provider`. `@pending_agent_execution`
|
|
65
94
|
# is the transient per-step carrier from `execute_serial_step` to
|
|
66
|
-
# `complete_step`; it is never persisted.
|
|
95
|
+
# `complete_step`; it is never persisted. `@pending_evaluations` is the
|
|
96
|
+
# optimizer's, carrying a loop's verdicts into its step's record.
|
|
67
97
|
@last_agent_execution = nil
|
|
68
98
|
@pending_agent_execution = nil
|
|
99
|
+
@pending_evaluations = nil
|
|
69
100
|
@last_failed_step = nil
|
|
70
101
|
# Optimistic-locking version. Incremented on each persist!; restored
|
|
71
102
|
# from the persisted payload. Adapters that support store_versioned
|
|
@@ -96,8 +127,17 @@ module Smith
|
|
|
96
127
|
# also: declaration so explicit input keys round-trip.
|
|
97
128
|
@persisted_keys = ::Set.new(initial_persist_auto_seed)
|
|
98
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
|
|
99
135
|
initialize_tool_result_state
|
|
100
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
|
|
101
141
|
end
|
|
102
142
|
|
|
103
143
|
def persisted_keys
|
|
@@ -105,9 +145,12 @@ module Smith
|
|
|
105
145
|
end
|
|
106
146
|
|
|
107
147
|
def advance!
|
|
148
|
+
StepStream.instance_method(:deliver_streamed_output).bind_call(self)
|
|
149
|
+
return if waiting?
|
|
150
|
+
|
|
151
|
+
@step_work_started = false
|
|
108
152
|
advance_claim = SplitStepPersistence.instance_method(:claim_split_step_advance!).bind_call(self)
|
|
109
153
|
ensure_transition_budget!
|
|
110
|
-
@step_work_started = false
|
|
111
154
|
|
|
112
155
|
transition = SplitStepPersistence.instance_method(:resolve_split_step_advance_transition).bind_call(self)
|
|
113
156
|
transition = resolve_transition if transition.equal?(SplitStepPersistence::NO_SPLIT_TRANSITION)
|
|
@@ -119,10 +162,7 @@ module Smith
|
|
|
119
162
|
else
|
|
120
163
|
execute_step(transition)
|
|
121
164
|
end
|
|
122
|
-
|
|
123
|
-
@updated_at = Time.now.utc.iso8601
|
|
124
|
-
record_step_snapshot(step_result)
|
|
125
|
-
step_result
|
|
165
|
+
count_step(step_result)
|
|
126
166
|
rescue UnresolvedTransitionError => e
|
|
127
167
|
step_result = GuardrailIntegration
|
|
128
168
|
.instance_method(:handle_unresolved_transition_failure)
|
|
@@ -130,25 +170,31 @@ module Smith
|
|
|
130
170
|
record_step_snapshot(step_result)
|
|
131
171
|
step_result
|
|
132
172
|
ensure
|
|
173
|
+
clear_settled_pending_retry
|
|
133
174
|
SplitStepPersistence.instance_method(:release_split_step_advance!).bind_call(self, advance_claim) if advance_claim
|
|
134
175
|
end
|
|
135
176
|
|
|
136
177
|
def run!
|
|
137
178
|
SplitStepPersistence.instance_method(:ensure_split_step_execution_allowed!).bind_call(self)
|
|
138
179
|
steps = []
|
|
139
|
-
until
|
|
180
|
+
until nothing_to_advance?
|
|
140
181
|
step = advance!
|
|
141
182
|
steps << step if step
|
|
142
183
|
end
|
|
184
|
+
StepStream.instance_method(:deliver_streamed_output).bind_call(self)
|
|
143
185
|
build_run_result(steps)
|
|
144
186
|
end
|
|
145
187
|
|
|
188
|
+
# The awaited transition while the run waits.
|
|
146
189
|
def pending_transition_name
|
|
190
|
+
return awaiting.transition if waiting?
|
|
191
|
+
|
|
147
192
|
@next_transition_name || self.class.first_transition_from(@state)&.name
|
|
148
193
|
end
|
|
149
194
|
|
|
195
|
+
# A waiting run is never terminal, whatever its state.
|
|
150
196
|
def terminal?
|
|
151
|
-
!self.class.transition_from?(@state) && @next_transition_name.nil?
|
|
197
|
+
!waiting? && !self.class.transition_from?(@state) && @next_transition_name.nil?
|
|
152
198
|
end
|
|
153
199
|
|
|
154
200
|
def done?
|
|
@@ -179,6 +225,18 @@ module Smith
|
|
|
179
225
|
|
|
180
226
|
private
|
|
181
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
|
+
|
|
182
240
|
def initial_persist_auto_seed
|
|
183
241
|
manager = self.class.context_manager
|
|
184
242
|
return [] unless manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
@@ -216,6 +274,38 @@ module Smith
|
|
|
216
274
|
end
|
|
217
275
|
end
|
|
218
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
|
+
|
|
219
309
|
def build_ledger
|
|
220
310
|
config = self.class.budget
|
|
221
311
|
return nil unless config
|
|
@@ -311,14 +401,14 @@ module Smith
|
|
|
311
401
|
end
|
|
312
402
|
|
|
313
403
|
# Fresh evaluation of the seed builder against the workflow's
|
|
314
|
-
# current @context. Returns nil when no
|
|
315
|
-
# callers (initialize, validate_seed_digest!)
|
|
316
|
-
# "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".
|
|
317
407
|
def compute_seed_messages
|
|
318
408
|
builder = self.class.seed_messages
|
|
319
409
|
return nil unless builder
|
|
320
410
|
|
|
321
|
-
seeded = builder.arity == 1 ? builder.call(
|
|
411
|
+
seeded = builder.arity == 1 ? builder.call(trusted_context_view) : builder.call
|
|
322
412
|
normalize_seed_messages(seeded)
|
|
323
413
|
end
|
|
324
414
|
|
|
@@ -333,11 +423,12 @@ module Smith
|
|
|
333
423
|
end
|
|
334
424
|
|
|
335
425
|
def normalize_seed_messages(seeded)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 }
|
|
341
432
|
end
|
|
342
433
|
|
|
343
434
|
def snapshot_context
|
|
@@ -415,6 +506,13 @@ Smith::Workflow::SplitStepPersistence::SubclassBoundary.protect_execution_path!(
|
|
|
415
506
|
Smith::Workflow::ArtifactIntegration,
|
|
416
507
|
Smith::Workflow::DataVolumePolicy,
|
|
417
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,
|
|
418
516
|
Smith::Workflow::SplitStepPersistence::DefinitionBoundary,
|
|
419
517
|
Smith::Workflow::SplitStepPersistence::ExecutionVerification,
|
|
420
518
|
Smith::Workflow::SplitStepPersistence::ExecutionAuthorization,
|