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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# A batch step's requests on a host's budget ledger (`budget_ledger`,
|
|
6
|
+
# Budget::HostLedger): each is held when captured, under its custom_id,
|
|
7
|
+
# and settled when its result comes (BatchWait).
|
|
8
|
+
module BatchHolds
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# A host's budget ledger admits each request's hold (priced at the
|
|
12
|
+
# batch discount) under its custom_id, inside the attempt, so a durable
|
|
13
|
+
# `retry_on Smith::BudgetUnavailable` can wait for budget. A refusal,
|
|
14
|
+
# or the step ending before the requests are queued, releases the
|
|
15
|
+
# holds taken; queued, they are settled when the results come.
|
|
16
|
+
def hold_batch!(token, holds)
|
|
17
|
+
ledger = run_budget_ledger
|
|
18
|
+
return unless ledger
|
|
19
|
+
|
|
20
|
+
release_batch_holds
|
|
21
|
+
@batch_holds = []
|
|
22
|
+
seconds = BatchExecution::WAIT_SECONDS + Smith.config.budget_hold_ttl
|
|
23
|
+
holds.each do |branch_key, amount|
|
|
24
|
+
key = Batch.custom_id(token, branch_key)
|
|
25
|
+
Budget::HostLedger.admit!(ledger, amount, key, seconds:)
|
|
26
|
+
@batch_holds << key
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError
|
|
29
|
+
release_batch_holds
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def release_batch_holds
|
|
34
|
+
keys = @batch_holds
|
|
35
|
+
@batch_holds = nil
|
|
36
|
+
ledger = settling_ledger if keys
|
|
37
|
+
keys&.each { Budget::HostLedger.settle(ledger, _1, nil) } if ledger
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Each result settles the hold its request took on a host's budget
|
|
41
|
+
# ledger (BatchExecution), taken by a call or not: a succeeded one
|
|
42
|
+
# reconciles what it cost, any other (or a wait that expired) releases.
|
|
43
|
+
def settle_batch_holds(transition, token, entries)
|
|
44
|
+
ledger = settling_ledger
|
|
45
|
+
return unless ledger
|
|
46
|
+
|
|
47
|
+
entries.each do |branch_key, entry|
|
|
48
|
+
spent = batch_spent(transition, branch_key, entry.result)
|
|
49
|
+
Budget::HostLedger.settle(ledger, Batch.custom_id(token, branch_key), spent)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The ledger to settle holds on. One that cannot be built then is
|
|
54
|
+
# logged, as a settlement that fails is: the money is spent, and the
|
|
55
|
+
# holds expire.
|
|
56
|
+
def settling_ledger
|
|
57
|
+
run_budget_ledger
|
|
58
|
+
rescue BudgetLedgerError => e
|
|
59
|
+
Smith.config.logger&.error("Smith budget ledger settle error: #{e.message}")
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def batch_spent(transition, branch_key, result)
|
|
64
|
+
return unless result["type"] == "succeeded"
|
|
65
|
+
|
|
66
|
+
message = result.fetch("message")
|
|
67
|
+
usage = batch_reply_usage(message)
|
|
68
|
+
model = batch_model_id(batch_agent_class(transition, branch_key), message)
|
|
69
|
+
Budget::HostLedger.amount(usage, model:, provider: "anthropic", mode: :batch) if usage
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# Completing a batch step's wait (BatchExecution). `resume!` with the
|
|
8
|
+
# results (`{custom_id => result}`, every request's), or `expire_wait!`
|
|
9
|
+
# past the deadline (every result then `missing`), runs the step again at
|
|
10
|
+
# its attempt: each call takes its result instead of going over the wire,
|
|
11
|
+
# parsed by RubyLLM's own provider parser, if the request it renders now
|
|
12
|
+
# is the one sent, and the step goes on as after a synchronous call
|
|
13
|
+
# (usage with `mode: :batch`, output checks, a follow-up call over the
|
|
14
|
+
# wire, guardrails, `output_to:`, retries). Results are taken only by the
|
|
15
|
+
# batch's own attempt; a succeeded one no call took (a branch cancelled
|
|
16
|
+
# by a sibling's failure) is charged all the same (BatchCharges).
|
|
17
|
+
module BatchWait
|
|
18
|
+
PENDING_BATCH_FIELDS = %w[attempt key_generation requests transition].freeze
|
|
19
|
+
DIGEST = /\A\h{64}\z/
|
|
20
|
+
private_constant :PENDING_BATCH_FIELDS, :DIGEST
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# [attempts already made, key generation] where a delivered batch's
|
|
25
|
+
# step starts again, or nil. A delivery runs only the stored batch
|
|
26
|
+
# wait's step, whose pending batch restore guarantees.
|
|
27
|
+
def pending_batch_position
|
|
28
|
+
[@pending_batch["attempt"] - 1, @pending_batch["key_generation"]] if @batch_delivery
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The step's branches in the wait's order (nil for an agent step).
|
|
32
|
+
def batch_branches(transition) = transition.fanout? ? transition.fanout_config.fetch(:branches).keys : [nil]
|
|
33
|
+
|
|
34
|
+
def batch_ids(transition, token)
|
|
35
|
+
batch_branches(transition).to_h { |branch_key| [branch_key, Batch.custom_id(token, branch_key)] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def batch_results!(transition, payload)
|
|
39
|
+
ids = batch_ids(transition, @awaiting.fetch("token"))
|
|
40
|
+
results = Batch.results!(payload, ids.values)
|
|
41
|
+
batch_entries(ids) { |id| results.fetch(id) }
|
|
42
|
+
rescue StandardError
|
|
43
|
+
@wait_claimed = false
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def missing_batch_results(transition)
|
|
48
|
+
batch_entries(batch_ids(transition, @awaiting.fetch("token"))) { { "type" => Batch::MISSING }.freeze }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def batch_entries(ids)
|
|
52
|
+
digests = @pending_batch.fetch("requests")
|
|
53
|
+
ids.to_h { |branch_key, id| [branch_key, Batch::Delivery::Entry.new(yield(id), digests.fetch(id))] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The step runs again at the batch's attempt, each call taking its
|
|
57
|
+
# result. It may enter another wait (a batch sent again, or a durable
|
|
58
|
+
# retry's backoff); otherwise it counts. A completion that raises
|
|
59
|
+
# leaves the run waiting.
|
|
60
|
+
def complete_batch_wait(transition, entries, done)
|
|
61
|
+
record = @awaiting
|
|
62
|
+
@awaiting = nil
|
|
63
|
+
@batch_delivery = Batch::Delivery.new(entries, on_change: transition.on_request_change || :keep)
|
|
64
|
+
step = with_step_context(transition) do
|
|
65
|
+
Execution.instance_method(:execute_step_body).bind_call(self, transition)
|
|
66
|
+
ensure
|
|
67
|
+
settle_batch_holds(transition, record.fetch("token"), entries)
|
|
68
|
+
charge_untaken_batch_results(transition)
|
|
69
|
+
end
|
|
70
|
+
settle_batch_wait(step)
|
|
71
|
+
return done if wait_pending?(step)
|
|
72
|
+
|
|
73
|
+
step.key?(:error) && done == :resumed ? :failed : done
|
|
74
|
+
rescue Exception # rubocop:disable Lint/RescueException -- the wait outlives a completion that did not happen
|
|
75
|
+
@awaiting ||= record
|
|
76
|
+
raise
|
|
77
|
+
ensure
|
|
78
|
+
@batch_delivery = nil
|
|
79
|
+
@wait_claimed = false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def settle_batch_wait(step)
|
|
83
|
+
@pending_batch = nil unless batch_waiting?
|
|
84
|
+
return if wait_pending?(step)
|
|
85
|
+
|
|
86
|
+
@step_count += 1
|
|
87
|
+
@updated_at = Time.now.utc.iso8601(6)
|
|
88
|
+
record_step_snapshot(step)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def batch_waiting? = @awaiting&.fetch("kind") == "batch"
|
|
92
|
+
|
|
93
|
+
# The result a call of the batch's attempt takes, once (a follow-up
|
|
94
|
+
# call, or a later attempt's, goes over the wire), for its branch.
|
|
95
|
+
def take_batch_result = (@batch_delivery.take(Attribution.current&.branch_key) if delivering_batch?)
|
|
96
|
+
|
|
97
|
+
def batch_result_due? = (delivering_batch? && @batch_delivery.for?(Attribution.current&.branch_key)) || false
|
|
98
|
+
|
|
99
|
+
# A persisted pending batch names the stored batch wait's transition
|
|
100
|
+
# (which WaitRecord checked is still a batch step), an attempt its
|
|
101
|
+
# retry policy allows, a key generation only a declared
|
|
102
|
+
# `new_idempotency_key_on` moves, and the digest of each request the
|
|
103
|
+
# step's branches send; a stored batch wait needs one. Anything else
|
|
104
|
+
# raises SerializationError.
|
|
105
|
+
def restore_pending_batch(value)
|
|
106
|
+
return if value.nil? && !batch_waiting?
|
|
107
|
+
|
|
108
|
+
record = value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
|
|
109
|
+
raise Smith::SerializationError, "persisted workflow pending_batch is invalid" unless
|
|
110
|
+
well_formed_pending_batch?(record)
|
|
111
|
+
|
|
112
|
+
record.transform_values(&:freeze).freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def well_formed_pending_batch?(record)
|
|
116
|
+
transition = self.class.transition_named(@awaiting["transition"]) if batch_waiting?
|
|
117
|
+
transition && record.keys.sort == PENDING_BATCH_FIELDS && record["transition"] == transition.name.to_s &&
|
|
118
|
+
allowed_batch_position?(transition, *record.values_at("attempt", "key_generation")) &&
|
|
119
|
+
batch_digests?(transition, record["requests"])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def allowed_batch_position?(transition, attempt, generation)
|
|
123
|
+
config = transition.retry_config
|
|
124
|
+
attempt.is_a?(Integer) && attempt.between?(1, config&.fetch(:attempts) || 1) && generation.is_a?(Integer) &&
|
|
125
|
+
generation.between?(0, config&.key?(:new_idempotency_key_on) ? attempt - 1 : 0)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def batch_digests?(transition, requests)
|
|
129
|
+
requests.is_a?(Hash) && requests.keys.sort == batch_ids(transition, @awaiting["token"]).values.sort &&
|
|
130
|
+
requests.values.all? { _1.is_a?(String) && DIGEST.match?(_1) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -31,6 +31,7 @@ module Smith
|
|
|
31
31
|
def reconcile_branch_budget(ledger, reservation, agent_result: nil)
|
|
32
32
|
return unless ledger && reservation
|
|
33
33
|
|
|
34
|
+
forget_call_reservation(reservation)
|
|
34
35
|
actuals = extract_actuals(agent_results_for_settlement(agent_result))
|
|
35
36
|
settlement = reservation.amounts.to_h do |dimension, _amount|
|
|
36
37
|
[dimension, actual_for_dimension(dimension, actuals[:tokens], actuals[:cost])]
|
|
@@ -44,7 +45,7 @@ module Smith
|
|
|
44
45
|
results = Array(agent_results).compact
|
|
45
46
|
|
|
46
47
|
{
|
|
47
|
-
tokens: results.sum
|
|
48
|
+
tokens: results.sum(&:billable_tokens),
|
|
48
49
|
cost: results.sum { |result| result.cost || 0 }
|
|
49
50
|
}
|
|
50
51
|
end
|
|
@@ -71,11 +72,16 @@ module Smith
|
|
|
71
72
|
def release_branch_budget(ledger, reservation)
|
|
72
73
|
return unless ledger && reservation
|
|
73
74
|
|
|
75
|
+
forget_call_reservation(reservation)
|
|
74
76
|
return ledger.release_many!(reservation) if reservation.amounts.length > 1
|
|
75
77
|
|
|
76
78
|
ledger.release!(reservation)
|
|
77
79
|
end
|
|
78
80
|
|
|
81
|
+
def forget_call_reservation(reservation)
|
|
82
|
+
Thread.current[:smith_call_reservation] = nil if Thread.current[:smith_call_reservation].equal?(reservation)
|
|
83
|
+
end
|
|
84
|
+
|
|
79
85
|
def settle_budget_on_failure(ledger, estimates, agent_result)
|
|
80
86
|
return unless ledger && estimates
|
|
81
87
|
|
|
@@ -98,11 +104,11 @@ module Smith
|
|
|
98
104
|
reserve_estimates!(ledger, estimates)
|
|
99
105
|
end
|
|
100
106
|
|
|
107
|
+
# The reservation is also the current call's, for OutputIntegrity to
|
|
108
|
+
# stop making follow-up calls once it is spent.
|
|
101
109
|
def reserve_estimates!(ledger, estimates)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
dimension, amount = estimates.first
|
|
105
|
-
ledger.reserve!(dimension, amount)
|
|
110
|
+
reservation = estimates.length > 1 ? ledger.reserve_many!(estimates) : ledger.reserve!(*estimates.first)
|
|
111
|
+
Thread.current[:smith_call_reservation] = reservation
|
|
106
112
|
end
|
|
107
113
|
|
|
108
114
|
def finalize_branch(transition, index, result, ledger, reserved)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# A tool loop that pauses for budget. A step whose `retry_on` names
|
|
6
|
+
# BudgetUnavailable (or an ancestor), on an attempt it may retry, holds
|
|
7
|
+
# every round of its call's tool loop on the host's budget ledger, not
|
|
8
|
+
# only the first. When the ledger refuses a later round, the step's
|
|
9
|
+
# tools have run: the refusal carries the messages the call added (its
|
|
10
|
+
# tool calls and their results, Budget::Continuation), the step's tool
|
|
11
|
+
# guard lets it through, and the retry (durable: a wait the host ends
|
|
12
|
+
# once there is budget, its continuation kept with the pending retry)
|
|
13
|
+
# continues the loop from them, running none of its tools again. A step
|
|
14
|
+
# that cannot retry for budget lets a later round go on, unheld, and
|
|
15
|
+
# reconciles what it spent. Only a serial agent step pauses so.
|
|
16
|
+
module BudgetPause
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def start_budget_pause(transition)
|
|
20
|
+
@budget_pause_attempts = budget_pause_attempts(transition)
|
|
21
|
+
pending = @pending_retry
|
|
22
|
+
@budget_continuation = (pending["continuation"] if pending && pending["transition"] == transition.name.to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def budget_pause_attempts(transition)
|
|
26
|
+
config = transition.retry_config
|
|
27
|
+
return unless config && serial_agent_step?(transition)
|
|
28
|
+
|
|
29
|
+
config.fetch(:attempts) if config.fetch(:error_classes).any? { _1 >= BudgetUnavailable }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serial_agent_step?(transition)
|
|
33
|
+
!transition.agent_name.nil? && !transition.parallel? && !transition.fanout? && !transition.optimized? &&
|
|
34
|
+
!transition.orchestrated?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def budget_pausable? = !@budget_pause_attempts.nil? && @step_attempt.to_i < @budget_pause_attempts
|
|
38
|
+
|
|
39
|
+
def keep_budget_continuation(error)
|
|
40
|
+
@budget_continuation = error.continuation if error.is_a?(BudgetUnavailable) && error.continuation
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The paused loop's messages, after the attempt's prepared input.
|
|
44
|
+
def continue_paused_loop(chat)
|
|
45
|
+
return unless @budget_continuation
|
|
46
|
+
|
|
47
|
+
Budget::Continuation.load(@budget_continuation).each { chat.add_message(_1) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Transition
|
|
6
|
+
# Steps that start other workflows as runs of their own, through the
|
|
7
|
+
# host's run launcher (`Smith.config.run_launcher`):
|
|
8
|
+
#
|
|
9
|
+
# map :section_ids, workflow: Sections::Review, max_items: 500, timeout: 6 * 3600,
|
|
10
|
+
# input: ->(section_id, ctx) { { document_id: ctx[:document_id], section_id: } },
|
|
11
|
+
# into: :section_reviews, collect: :outputs, on_item_failure: :collect
|
|
12
|
+
# call Documents::Summarize, input: ->(ctx) { { document_id: ctx[:document_id] } },
|
|
13
|
+
# timeout: 3600, into: :summary
|
|
14
|
+
# spawn :watcher_ids, workflow: Watchers::Notify, max_items: 1_000,
|
|
15
|
+
# input: ->(watcher_id, ctx) { { watcher_id: } }
|
|
16
|
+
#
|
|
17
|
+
# `map` launches a run per item and waits, bounded by `timeout:`, for
|
|
18
|
+
# the host to resume the parent with every child's result; `call` is a
|
|
19
|
+
# map of one whose `into:` gets the child's output itself; `spawn`
|
|
20
|
+
# launches and continues. `items` is a context key holding an Array or
|
|
21
|
+
# a callable of the context, and more than `max_items` fails the step
|
|
22
|
+
# before anything launches. `input:` returns each child's whole
|
|
23
|
+
# context.
|
|
24
|
+
module ChildRunDeclarations
|
|
25
|
+
MAX_ITEMS = 10_000
|
|
26
|
+
COLLECT_MODES = %i[outputs states none].freeze
|
|
27
|
+
FAILURE_MODES = %i[fail collect].freeze
|
|
28
|
+
# Each kind's required options, optional ones with their defaults,
|
|
29
|
+
# and the settings it fixes.
|
|
30
|
+
OPTIONS = {
|
|
31
|
+
map: { required: %i[workflow max_items input timeout into],
|
|
32
|
+
optional: { collect: :outputs, on_item_failure: :fail }, fixed: {} },
|
|
33
|
+
call: { required: %i[input timeout into], optional: {},
|
|
34
|
+
fixed: { max_items: 1, collect: :outputs, on_item_failure: :fail } },
|
|
35
|
+
spawn: { required: %i[workflow max_items input], optional: {},
|
|
36
|
+
fixed: { collect: :none, on_item_failure: :collect } }
|
|
37
|
+
}.freeze
|
|
38
|
+
private_constant :OPTIONS
|
|
39
|
+
|
|
40
|
+
attr_reader :children_config
|
|
41
|
+
|
|
42
|
+
def map(items, **options) = declare_child_runs!(:map, items, options)
|
|
43
|
+
|
|
44
|
+
def call(workflow, **options) = declare_child_runs!(:call, nil, options, workflow:)
|
|
45
|
+
|
|
46
|
+
def spawn(items, **options) = declare_child_runs!(:spawn, items, options)
|
|
47
|
+
|
|
48
|
+
def child_runs? = !@children_config.nil?
|
|
49
|
+
|
|
50
|
+
def spawns? = @children_config&.fetch(:kind) == :spawn
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# A map or call that fails when a child fails needs on_failure. No
|
|
55
|
+
# map, call or spawn retries: its launcher retries launching, and a
|
|
56
|
+
# map's results arrive with the host's resume, outside any attempt.
|
|
57
|
+
def validate_child_run_controls!
|
|
58
|
+
return unless child_runs?
|
|
59
|
+
raise WorkflowError, "a map, call or spawn does not take retry_on" if @retry_config
|
|
60
|
+
return unless @children_config.fetch(:on_item_failure) == :fail && @failure_transition.nil?
|
|
61
|
+
|
|
62
|
+
raise WorkflowError, "a map or call that fails when a child fails must declare on_failure"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def declare_child_runs!(kind, items, options, given = {})
|
|
66
|
+
validate_execution_primitive_conflict!(kind.to_s)
|
|
67
|
+
settings = child_run_options!(kind, options).merge(given)
|
|
68
|
+
validate_child_runs!(kind, items, settings)
|
|
69
|
+
@children_config = settings.slice(:workflow, :max_items, :input, :collect, :on_item_failure)
|
|
70
|
+
.merge(kind:, items:).freeze
|
|
71
|
+
wait_for_children!(settings[:into], settings[:timeout], kind) unless kind == :spawn
|
|
72
|
+
commit_execution_primitive!(kind.to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def child_run_options!(kind, options)
|
|
76
|
+
spec = OPTIONS.fetch(kind)
|
|
77
|
+
refuse_options!(kind, "does not take", options.keys - spec[:required] - spec[:optional].keys)
|
|
78
|
+
refuse_options!(kind, "needs", spec[:required] - options.keys)
|
|
79
|
+
spec[:optional].merge(options).merge(spec[:fixed])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def refuse_options!(kind, problem, names)
|
|
83
|
+
raise WorkflowError, "#{kind} #{problem} #{names.map(&:inspect).join(", ")}" if names.any?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_child_runs!(kind, items, settings)
|
|
87
|
+
workflow = settings[:workflow]
|
|
88
|
+
unless workflow.is_a?(Class) && workflow < Workflow
|
|
89
|
+
raise WorkflowError, "#{kind} workflow must be a Smith::Workflow subclass"
|
|
90
|
+
end
|
|
91
|
+
raise WorkflowError, "#{kind} input: must be a callable" unless settings[:input].respond_to?(:call)
|
|
92
|
+
|
|
93
|
+
validate_child_items!(kind, items, settings[:max_items])
|
|
94
|
+
validate_child_modes!(kind, settings[:collect], settings[:on_item_failure])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_child_items!(kind, items, max_items)
|
|
98
|
+
unless kind == :call || items.is_a?(Symbol) || items.respond_to?(:call)
|
|
99
|
+
raise WorkflowError, "#{kind} items must be a Symbol context key or a callable of the context"
|
|
100
|
+
end
|
|
101
|
+
return if max_items.is_a?(Integer) && max_items.between?(1, MAX_ITEMS)
|
|
102
|
+
|
|
103
|
+
raise WorkflowError, "#{kind} max_items: must be an Integer from 1 to #{MAX_ITEMS}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_child_modes!(kind, collect, on_item_failure)
|
|
107
|
+
unless COLLECT_MODES.include?(collect)
|
|
108
|
+
raise WorkflowError, "#{kind} collect: must be one of #{COLLECT_MODES.inspect}"
|
|
109
|
+
end
|
|
110
|
+
return if FAILURE_MODES.include?(on_item_failure)
|
|
111
|
+
|
|
112
|
+
raise WorkflowError, "#{kind} on_item_failure: must be one of #{FAILURE_MODES.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A map or call waits like an await, for the event `children`.
|
|
116
|
+
def wait_for_children!(into, timeout, kind)
|
|
117
|
+
raise WorkflowError, "#{kind} into: must be a Symbol context key" unless into.is_a?(Symbol)
|
|
118
|
+
|
|
119
|
+
@await_config = { kind: :children, event: :children, into:,
|
|
120
|
+
timeout: wait_seconds!(timeout, "#{kind} timeout:"), block: nil }.freeze
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
include ChildRunDeclarations
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "execution_result_snapshot"
|
|
4
|
+
require_relative "message_value_normalizer"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Workflow
|
|
8
|
+
# Launching the runs of `map`, `call` and `spawn` steps through the
|
|
9
|
+
# host's run launcher, and taking their results when the host resumes
|
|
10
|
+
# the parent. A child's ref is the index of its item; items come from
|
|
11
|
+
# the context (or a callable of it), so a replayed step launches the
|
|
12
|
+
# same children under the same keys, and the launcher must be
|
|
13
|
+
# idempotent on each child's `key`.
|
|
14
|
+
module ChildRunExecution
|
|
15
|
+
MAX_LAUNCH_BYTES = 4 * ExecutionResultSnapshot::MAX_BYTES
|
|
16
|
+
private_constant :MAX_LAUNCH_BYTES
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Launches the children; returns their results when the launcher runs
|
|
21
|
+
# them itself (`inline?`), else nil (the host resumes the parent
|
|
22
|
+
# later), whatever the launcher returned.
|
|
23
|
+
def launch_child_runs(transition, token)
|
|
24
|
+
launcher = Smith.config.run_launcher
|
|
25
|
+
kind = transition.children_config.fetch(:kind)
|
|
26
|
+
raise WorkflowError, "#{kind} needs Smith.config.run_launcher" unless launcher
|
|
27
|
+
|
|
28
|
+
children = child_runs(transition, token).freeze
|
|
29
|
+
unless @persistence_key || inline_launcher?(launcher)
|
|
30
|
+
raise WorkflowError, "#{kind} starts runs of their own, so it needs a persisted run " \
|
|
31
|
+
"(run_persisted! or advance_persisted!)"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@launched_children = children.length
|
|
35
|
+
return [] if children.empty?
|
|
36
|
+
|
|
37
|
+
results = launcher.launch(children:, parent_key: @persistence_key, await_token: token)
|
|
38
|
+
return unless inline_launcher?(launcher)
|
|
39
|
+
return results if results.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
raise WorkflowError, "a run launcher whose inline? is true must return the children's results"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inline_launcher?(launcher) = launcher.respond_to?(:inline?) && launcher.inline? == true
|
|
45
|
+
|
|
46
|
+
def child_runs(transition, token)
|
|
47
|
+
config = transition.children_config
|
|
48
|
+
context = snapshot_value(@context)
|
|
49
|
+
bytes = 0
|
|
50
|
+
child_items(transition).each_with_index.map do |item, index|
|
|
51
|
+
input = child_input(config, item, context)
|
|
52
|
+
bytes = check_launch_size!(bytes + JSON.generate(input).bytesize, config[:kind])
|
|
53
|
+
child_run(config, token, index, input)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def child_run(config, token, ref, input)
|
|
58
|
+
ChildRun.new(ref:, key: ChildRun.key_for(token, ref), workflow: config.fetch(:workflow), input:,
|
|
59
|
+
untrusted_keys: (untrusted_context_keys & input.keys).freeze,
|
|
60
|
+
artifact_namespaces: child_artifact_namespaces)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The inputs of one step's children together are bounded, as one
|
|
64
|
+
# step's result is, four times over; checked as each is built.
|
|
65
|
+
def check_launch_size!(bytes, kind)
|
|
66
|
+
return bytes if bytes <= MAX_LAUNCH_BYTES
|
|
67
|
+
|
|
68
|
+
raise WorkflowError, "#{kind} inputs come to more than #{MAX_LAUNCH_BYTES} bytes"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def child_items(transition)
|
|
72
|
+
config = transition.children_config
|
|
73
|
+
return [nil] if config.fetch(:kind) == :call
|
|
74
|
+
|
|
75
|
+
source = config.fetch(:items)
|
|
76
|
+
check_items_persisted!(source, config[:kind])
|
|
77
|
+
items = item_source(source)
|
|
78
|
+
raise WorkflowError, "#{config[:kind]} items must be an Array" unless items.is_a?(Array)
|
|
79
|
+
return items if items.length <= config.fetch(:max_items)
|
|
80
|
+
|
|
81
|
+
raise WorkflowError, "#{config[:kind]} has #{items.length} items, more than its max_items of " \
|
|
82
|
+
"#{config[:max_items]}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A persisted run takes its children's results after a restore, which
|
|
86
|
+
# needs the items key the step launched from.
|
|
87
|
+
def check_items_persisted!(source, kind)
|
|
88
|
+
return unless @persistence_key && source.is_a?(Symbol)
|
|
89
|
+
|
|
90
|
+
keys = resolve_persist_keys
|
|
91
|
+
persisted = keys.nil? || (keys == :auto ? @persisted_keys.include?(source) : keys.include?(source))
|
|
92
|
+
raise WorkflowError, "#{kind} items key #{source.inspect} must be persisted" unless persisted
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def item_source(source) = source.respond_to?(:call) ? source.call(snapshot_value(@context)) : @context[source]
|
|
96
|
+
|
|
97
|
+
# The child's whole context: a Hash, JSON-shaped and bounded, in the
|
|
98
|
+
# context's key shape.
|
|
99
|
+
def child_input(config, item, context)
|
|
100
|
+
input = config.fetch(:input)
|
|
101
|
+
raw = config.fetch(:kind) == :call ? input.call(context) : input.call(item, context)
|
|
102
|
+
raise WorkflowError, "#{config[:kind]} input: must return a Hash" unless raw.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
ContextKeys.normalize(MessageValueNormalizer.new(raw, label: "child run input").call).freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Checks the results against the children the step launched (one per
|
|
108
|
+
# ref, each succeeded or failed), then, unless a failed child fails
|
|
109
|
+
# the step, writes them to `into:` as the step collects them.
|
|
110
|
+
def deliver_child_results(transition, results, expected = expected_children(transition))
|
|
111
|
+
config = transition.children_config
|
|
112
|
+
entries = checked_child_results(results, expected, config[:kind])
|
|
113
|
+
fail_on_failed_children!(transition, entries) if config[:on_item_failure] == :fail
|
|
114
|
+
write_wait_payload(transition, collected_child_results(config, entries))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fail_on_failed_children!(transition, entries)
|
|
118
|
+
failed = entries.select { _1[:status] == "failed" }
|
|
119
|
+
return if failed.empty?
|
|
120
|
+
|
|
121
|
+
raise ChildRunsFailed.new(transition: transition.name, failed_refs: failed.map { _1[:ref] },
|
|
122
|
+
total: entries.length)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# How many children the step launched: as the stored wait records it,
|
|
126
|
+
# or, for a delivery that comes before the wait is stored, its items.
|
|
127
|
+
def expected_children(transition)
|
|
128
|
+
stored = @awaiting && @awaiting["children"]
|
|
129
|
+
stored.is_a?(Integer) ? stored : child_items(transition).length
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Child results are larger than a session message: a map's outputs
|
|
133
|
+
# together are bounded like a step's result.
|
|
134
|
+
def child_results_payload(payload)
|
|
135
|
+
bound = ExecutionResultSnapshot::MAX_BYTES
|
|
136
|
+
ContextKeys.normalize(MessageValueNormalizer.new(payload, label: "child run results", max_bytes: bound).call)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A spawn launches and the step completes.
|
|
140
|
+
def execute_spawn_step(transition)
|
|
141
|
+
check_deadline!
|
|
142
|
+
namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
|
|
143
|
+
token = IdempotencyKey.__send__(:root_scope, namespace)
|
|
144
|
+
.within(:step, @step_count + 1, transition.name.to_s).within(:spawn).key
|
|
145
|
+
launch_child_runs(transition, token)
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# Checking the results a host reports for a step's child runs, and
|
|
6
|
+
# shaping them as the step collects them.
|
|
7
|
+
module ChildRunResults
|
|
8
|
+
STATUSES = %w[succeeded failed].freeze
|
|
9
|
+
RESULT_KEYS = %i[ref status state output error].freeze
|
|
10
|
+
private_constant :STATUSES, :RESULT_KEYS
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Outputs a map does not collect are dropped before the results are
|
|
15
|
+
# bounded, so a large map can report states or counts.
|
|
16
|
+
def collectable_results(config, payload)
|
|
17
|
+
return payload if config[:kind] == :call || config[:collect] == :outputs || !payload.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
payload.map { |entry| entry.is_a?(Hash) ? entry.reject { |key, _value| key.to_s == "output" } : entry }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def checked_child_results(results, expected, kind)
|
|
23
|
+
entries = results.is_a?(Array) ? results.map { child_result(_1, kind) } : nil
|
|
24
|
+
unless entries && entries.map { _1[:ref] }.sort == (0...expected).to_a
|
|
25
|
+
raise WorkflowError, "#{kind} results must hold one entry for each of its #{expected} children, by ref"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
entries.sort_by { _1[:ref] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def child_result(entry, kind)
|
|
32
|
+
valid = entry.is_a?(Hash) && (entry.keys - RESULT_KEYS).empty? && entry[:ref].is_a?(Integer) &&
|
|
33
|
+
STATUSES.include?(entry[:status]) && [entry[:state], entry[:error]].all? { short_text?(_1) }
|
|
34
|
+
return entry if valid
|
|
35
|
+
|
|
36
|
+
raise WorkflowError, "#{kind} results are { ref:, status: \"succeeded\" | \"failed\", state:, output:, " \
|
|
37
|
+
"error: }, with state and error short text"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def short_text?(value) = value.nil? || (value.is_a?(String) && value.bytesize <= 256)
|
|
41
|
+
|
|
42
|
+
def collected_child_results(config, entries)
|
|
43
|
+
return entries.first[:output] if config[:kind] == :call
|
|
44
|
+
|
|
45
|
+
case config[:collect]
|
|
46
|
+
when :outputs then entries.map { _1.slice(*RESULT_KEYS) }
|
|
47
|
+
when :states then entries.map { _1.slice(:ref, :status, :state, :error) }
|
|
48
|
+
else
|
|
49
|
+
failed = entries.count { _1[:status] == "failed" }
|
|
50
|
+
{ succeeded: entries.length - failed, failed: }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|