smith-agents 0.11.0 → 0.12.1
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 +712 -0
- data/README.md +622 -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 +67 -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
data/lib/smith/workflow/graph.rb
CHANGED
|
@@ -93,6 +93,7 @@ require_relative "graph/execution_successors"
|
|
|
93
93
|
require_relative "graph/reachability"
|
|
94
94
|
require_relative "graph/reachability_diagnostics"
|
|
95
95
|
require_relative "graph/metrics"
|
|
96
|
+
require_relative "graph/inherited_untrusted_diagnostics"
|
|
96
97
|
require_relative "graph/nested_readiness_diagnostics"
|
|
97
98
|
require_relative "graph/report"
|
|
98
99
|
require_relative "graph/runtime_binding_diagnostic_builder"
|
|
@@ -104,6 +105,17 @@ require_relative "graph/optimization_contract"
|
|
|
104
105
|
require_relative "graph/orchestration_contract"
|
|
105
106
|
require_relative "graph/transition_snapshot"
|
|
106
107
|
require_relative "graph/retry_policy_diagnostic"
|
|
108
|
+
require_relative "graph/branch_limit_diagnostic"
|
|
109
|
+
require_relative "graph/isolated_input_diagnostic"
|
|
110
|
+
require_relative "graph/output_key_diagnostic"
|
|
111
|
+
require_relative "graph/stream_diagnostic"
|
|
112
|
+
require_relative "graph/batch_diagnostic"
|
|
113
|
+
require_relative "graph/budget_ledger_diagnostic"
|
|
114
|
+
require_relative "graph/untrusted_input_diagnostic"
|
|
115
|
+
require_relative "graph/output_schema_diagnostic"
|
|
116
|
+
require_relative "graph/run_launcher_diagnostic"
|
|
117
|
+
require_relative "graph/waiting_child_diagnostic"
|
|
118
|
+
require_relative "graph/durable_retry_diagnostic"
|
|
107
119
|
require_relative "graph/transition_diagnostics"
|
|
108
120
|
require_relative "graph/validator"
|
|
109
121
|
require_relative "graph/runtime_readiness"
|
|
@@ -14,26 +14,71 @@ module Smith
|
|
|
14
14
|
begin
|
|
15
15
|
Thread.handle_interrupt(Object => :immediate) { run_tracked_guarded_step(transition) }
|
|
16
16
|
rescue StandardError => e
|
|
17
|
-
raise unless tracker
|
|
18
|
-
raise if terminal_retry_error?(e)
|
|
17
|
+
raise unless uncertain_after_tools?(tracker, e)
|
|
19
18
|
|
|
20
19
|
raise ToolOutcomeUncertain.new(
|
|
21
20
|
"transition failed after tool execution began; retry could replay an uncertain outcome"
|
|
22
21
|
), cause: e
|
|
23
22
|
ensure
|
|
23
|
+
# Tool evidence travels up: a step running inside another step
|
|
24
|
+
# (a nested workflow's step) marks the enclosing step too, so the
|
|
25
|
+
# enclosing step is never retried over tools that already ran.
|
|
26
|
+
previous_tracker&.mark_started! if tracker.started?
|
|
24
27
|
Tool.current_tool_execution_tracker = previous_tracker
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
# A failure after a tool ran leaves its outcome uncertain, unless its
|
|
33
|
+
# retry could not run the tool again: a terminal failure, or a tool
|
|
34
|
+
# loop paused for budget, whose retry continues from its tools'
|
|
35
|
+
# results.
|
|
36
|
+
def uncertain_after_tools?(tracker, error)
|
|
37
|
+
tracker.started? && !terminal_retry_error?(error) &&
|
|
38
|
+
!(error.is_a?(BudgetUnavailable) && error.continuation)
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def run_tracked_guarded_step(transition)
|
|
30
42
|
return apply_composite_reduction!(transition) if @composite_reduction
|
|
31
43
|
|
|
32
44
|
@resolved_parallel_branch_count = preflight_branch_count(transition)
|
|
33
|
-
|
|
45
|
+
context_before = @context.dup
|
|
46
|
+
output = run_standard_guarded_step(transition)
|
|
47
|
+
enforce_context_shape!(context_before)
|
|
48
|
+
output
|
|
34
49
|
ensure
|
|
35
50
|
@resolved_parallel_branch_count = nil
|
|
36
51
|
end
|
|
52
|
+
|
|
53
|
+
# Hooks that receive the live context (`before_eval`,
|
|
54
|
+
# `after_completion`, guardrails) can write to it directly. At the end
|
|
55
|
+
# of the step every new top-level key must be a Symbol that an explicit
|
|
56
|
+
# `persist` list allows, and every new or replaced value takes the
|
|
57
|
+
# context's key shape. On a violation the context goes back to its
|
|
58
|
+
# top-level values from the start of the step, as a failed
|
|
59
|
+
# deterministic step keeps none of its writes. A value changed in place
|
|
60
|
+
# is not seen until the next restore normalizes it. `O(K)` plus the size
|
|
61
|
+
# of changed values.
|
|
62
|
+
def enforce_context_shape!(before)
|
|
63
|
+
allowed = declared_persist_keys
|
|
64
|
+
normalized = @context.each_with_object({}) do |(key, value), changed|
|
|
65
|
+
next if before.key?(key) && before[key].equal?(value)
|
|
66
|
+
|
|
67
|
+
admit_context_key!(key, new_key: !before.key?(key), allowed:)
|
|
68
|
+
changed[key] = ContextKeys.normalize(value)
|
|
69
|
+
end
|
|
70
|
+
@context.merge!(normalized)
|
|
71
|
+
rescue WorkflowError
|
|
72
|
+
@context.replace(before)
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def admit_context_key!(key, new_key:, allowed:)
|
|
77
|
+
ContextKeys.validate_top_level_key!(key)
|
|
78
|
+
return unless new_key && allowed && !allowed.include?(key)
|
|
79
|
+
|
|
80
|
+
raise UndeclaredContextKey.new(key:, persisted_keys: allowed)
|
|
81
|
+
end
|
|
37
82
|
end
|
|
38
83
|
end
|
|
39
84
|
end
|
|
@@ -4,6 +4,7 @@ require "dry-initializer"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../errors"
|
|
6
6
|
require_relative "message_admission"
|
|
7
|
+
require_relative "message_roles"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
9
10
|
class Workflow
|
|
@@ -50,6 +51,11 @@ module Smith
|
|
|
50
51
|
role = message["role"]
|
|
51
52
|
reject!("each session message requires a non-empty role") unless
|
|
52
53
|
role.is_a?(String) && !role.strip.empty?
|
|
54
|
+
reject!("session messages admitted by the host cannot have the system role") if role.strip.casecmp?("system")
|
|
55
|
+
reject!("session messages admitted by the host cannot carry content parts") if Message.parts?(message)
|
|
56
|
+
return if MessageRoles.role(message, MessageRoles::ADMITTED)
|
|
57
|
+
|
|
58
|
+
reject!("session messages admitted by the host must have the role user, assistant or tool")
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def reject!(message)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# The roles a session message may carry, matched exactly. A host admits
|
|
6
|
+
# user, assistant and tool messages; system messages come only from
|
|
7
|
+
# Smith (injected state) and the host's `seed_messages` builder.
|
|
8
|
+
module MessageRoles
|
|
9
|
+
ALL = %w[system user assistant tool].freeze
|
|
10
|
+
ADMITTED = %w[user assistant tool].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The role of a message Hash with String or Symbol keys, when it is
|
|
15
|
+
# one of `allowed`; nil otherwise.
|
|
16
|
+
def role(message, allowed = ALL)
|
|
17
|
+
return unless message.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
value = message.key?(:role) ? message[:role] : message["role"]
|
|
20
|
+
value = value.to_s if value.is_a?(Symbol)
|
|
21
|
+
value if value.is_a?(String) && allowed.include?(value)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -20,6 +20,7 @@ module Smith
|
|
|
20
20
|
|
|
21
21
|
param :value
|
|
22
22
|
option :label, default: proc { "session message" }
|
|
23
|
+
option :max_bytes, default: proc { MAX_BYTES }
|
|
23
24
|
|
|
24
25
|
def call
|
|
25
26
|
@active = {}.compare_by_identity
|
|
@@ -100,7 +101,7 @@ module Smith
|
|
|
100
101
|
|
|
101
102
|
def copy_string(string)
|
|
102
103
|
@bytes += StringSnapshot.bytesize(string)
|
|
103
|
-
reject!("#{label} exceeds maximum bytes #{
|
|
104
|
+
reject!("#{label} exceeds maximum bytes #{max_bytes}") if @bytes > max_bytes
|
|
104
105
|
|
|
105
106
|
StringSnapshot.copy(string, freeze: true)
|
|
106
107
|
end
|
|
@@ -11,18 +11,55 @@ module Smith
|
|
|
11
11
|
check_deadline!
|
|
12
12
|
child = build_child_workflow(transition.workflow_class)
|
|
13
13
|
child_result = run_child_workflow(child)
|
|
14
|
+
reject_waiting_child!(child, child_result)
|
|
14
15
|
handle_child_result(child_result)
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
# A child that raises passes its error to the parent unchanged, after
|
|
19
|
+
# its billable usage is rolled up. If a tool ran in the child, the
|
|
20
|
+
# parent's step already carries that evidence and turns the failure
|
|
21
|
+
# into ToolOutcomeUncertain (GuardedStepExecution).
|
|
17
22
|
def run_child_workflow(child)
|
|
18
23
|
child.run!
|
|
19
|
-
rescue
|
|
20
|
-
|
|
24
|
+
rescue StandardError
|
|
25
|
+
roll_up_child_totals(raised_child_usage(child))
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# An in-process child cannot be left waiting inside its parent's step:
|
|
30
|
+
# its usage is kept, and the parent's step fails.
|
|
31
|
+
def reject_waiting_child!(child, child_result)
|
|
32
|
+
return unless child.waiting?
|
|
33
|
+
|
|
34
|
+
roll_up_child_totals(child_result)
|
|
35
|
+
cause = RetryWait.instance_method(:retry_wait_error).bind_call(child)
|
|
36
|
+
raise WorkflowError, "a nested workflow reached a step that waits (an await, delay or durable retry); " \
|
|
37
|
+
"an in-process child cannot wait", cause:
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RaisedChildUsage = Struct.new(:usage_entries, :total_cost, :total_tokens)
|
|
41
|
+
private_constant :RaisedChildUsage
|
|
42
|
+
|
|
43
|
+
def raised_child_usage(child)
|
|
44
|
+
RaisedChildUsage.new(child.usage_entries, child.instance_variable_get(:@total_cost),
|
|
45
|
+
child.instance_variable_get(:@total_tokens))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A nested workflow inherits the run's untrusted keys, and charges the
|
|
49
|
+
# run's budget ledger unless it names its own.
|
|
50
|
+
def new_nested_workflow(child_class)
|
|
51
|
+
StepIO.with_inherited_untrusted_keys(untrusted_context_keys) do
|
|
52
|
+
Budget::HostLedger.inheriting(method(:run_budget_ledger)) do
|
|
53
|
+
ResponseMemo.inheriting([*@memo_attempts, @step_attempt.to_i]) do
|
|
54
|
+
child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
21
58
|
end
|
|
22
59
|
|
|
23
60
|
def build_child_workflow(child_class)
|
|
24
61
|
@split_step_active_execution_authorization&.verify_workflow!(child_class)
|
|
25
|
-
child = child_class
|
|
62
|
+
child = new_nested_workflow(child_class)
|
|
26
63
|
unless child.instance_of?(child_class)
|
|
27
64
|
raise WorkflowError, "nested workflow constructor returned #{child.class} instead of #{child_class}"
|
|
28
65
|
end
|
|
@@ -49,8 +86,14 @@ module Smith
|
|
|
49
86
|
def handle_child_result(child_result)
|
|
50
87
|
roll_up_child_totals(child_result)
|
|
51
88
|
|
|
89
|
+
# A child that handled its failure through its own `on_failure` route
|
|
90
|
+
# has already applied its failure policy, so the parent sees a
|
|
91
|
+
# non-retryable WorkflowError, as before.
|
|
52
92
|
failed_step = child_result.steps.find { |s| s.key?(:error) }
|
|
53
|
-
|
|
93
|
+
if failed_step
|
|
94
|
+
raise WorkflowError,
|
|
95
|
+
"nested workflow failed: #{DiagnosticText.capture(failed_step[:error]&.message.to_s, max_bytes: 1024)}"
|
|
96
|
+
end
|
|
54
97
|
|
|
55
98
|
child_result.output
|
|
56
99
|
end
|
|
@@ -40,7 +40,7 @@ module Smith
|
|
|
40
40
|
|
|
41
41
|
def run_orchestration_loop(state)
|
|
42
42
|
state.config[:max_delegation_rounds].times do |round|
|
|
43
|
-
result = run_orchestration_round(state, round)
|
|
43
|
+
result = IdempotencyKey.__send__(:within, :round, round) { run_orchestration_round(state, round) }
|
|
44
44
|
return result if result
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -65,12 +65,18 @@ module Smith
|
|
|
65
65
|
invoke_agent_with_budget(state.orchestrator_class, input)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# A task's position is its content, not its place in the list: a
|
|
69
|
+
# replayed orchestrator may list the same tasks in another order.
|
|
68
70
|
def execute_workers(state, tasks)
|
|
69
71
|
runner = method(:run_worker_with_schema)
|
|
72
|
+
output_schema = state.config[:worker_output_schema]
|
|
73
|
+
copies = Hash.new(0)
|
|
70
74
|
tasks.map do |task|
|
|
71
75
|
validate_task!(task, state.config[:task_schema])
|
|
72
|
-
execution =
|
|
73
|
-
|
|
76
|
+
execution = IdempotencyKey.__send__(:within_content, :task, task, copies) do
|
|
77
|
+
WorkerExecution.run(state.worker_class, task, output_schema, runner)
|
|
78
|
+
end
|
|
79
|
+
validate_worker_output!(execution.output, output_schema)
|
|
74
80
|
{ execution_id: execution.execution_id, task: execution.task, output: execution.output }
|
|
75
81
|
end
|
|
76
82
|
end
|
|
@@ -36,6 +36,7 @@ module Smith
|
|
|
36
36
|
def run_branch(transition, index, env, ledger, signal)
|
|
37
37
|
binding = ParallelAgentBinding.new(self, transition, env.agent_class)
|
|
38
38
|
with_branch_context(env, ledger, parallel_agent_binding: binding) do
|
|
39
|
+
narrow_idempotency_scope(:branch, index)
|
|
39
40
|
with_agent_context(env.agent_class) do
|
|
40
41
|
branch_ledger = effective_call_ledger
|
|
41
42
|
reserved = reserve_branch_call(branch_ledger, env, ledger)
|
|
@@ -74,6 +75,7 @@ module Smith
|
|
|
74
75
|
# Restoration is owned by the snapshot above, which tracks the
|
|
75
76
|
# attribution thread key.
|
|
76
77
|
Attribution.install(Attribution.ambient.merge(**attribution_overlay)) if attribution_overlay
|
|
78
|
+
narrow_idempotency_scope(:branch, attribution_overlay[:branch_key]) if attribution_overlay&.key?(:branch_key)
|
|
77
79
|
|
|
78
80
|
unless parallel_agent_binding.equal?(NO_PARALLEL_BINDING)
|
|
79
81
|
Thread.current[:smith_parallel_agent_binding] = parallel_agent_binding
|
|
@@ -84,6 +86,14 @@ module Smith
|
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
|
|
89
|
+
# Each branch of a step is a position of its own, so the same request
|
|
90
|
+
# from two branches gets two keys. Restoration is owned by the
|
|
91
|
+
# branch's ThreadContextSnapshot.
|
|
92
|
+
def narrow_idempotency_scope(*segment)
|
|
93
|
+
scope = IdempotencyKey.__send__(:current_scope)
|
|
94
|
+
IdempotencyKey.__send__(:install, scope.within(*segment)) if scope
|
|
95
|
+
end
|
|
96
|
+
|
|
87
97
|
def setup_branch_context(env, ledger)
|
|
88
98
|
env.setup_thread
|
|
89
99
|
Tool.current_ledger = ledger
|
|
@@ -13,7 +13,19 @@ module Smith
|
|
|
13
13
|
context: snapshot_value(persisted_context),
|
|
14
14
|
budget_consumed: ledger_consumed,
|
|
15
15
|
step_count: @step_count,
|
|
16
|
+
awaiting: @awaiting&.dup,
|
|
17
|
+
# The attempt a durable retry makes next, and its key generation.
|
|
18
|
+
pending_retry: @pending_retry&.dup,
|
|
19
|
+
pending_batch: @pending_batch&.dup,
|
|
20
|
+
# The scope the run's budget_ledger is built from.
|
|
21
|
+
budget_ledger_scope: @budget_ledger_scope&.transform_keys(&:to_s),
|
|
22
|
+
active_seconds: @active_seconds || 0.0,
|
|
23
|
+
active_step_started_at: @active_step_started_at,
|
|
16
24
|
execution_namespace: @execution_namespace,
|
|
25
|
+
# Untrusted keys inherited from a parent run (a durable child's).
|
|
26
|
+
inherited_untrusted_keys: (@inherited_untrusted_keys || []).map(&:to_s),
|
|
27
|
+
# Artifact namespaces a child run may read (its ancestors').
|
|
28
|
+
inherited_artifact_namespaces: (@inherited_artifact_namespaces || []).dup,
|
|
17
29
|
created_at: @created_at,
|
|
18
30
|
updated_at: @updated_at,
|
|
19
31
|
next_transition_name: @next_transition_name,
|
|
@@ -85,6 +97,7 @@ module Smith
|
|
|
85
97
|
@next_transition_name = normalized[:next_transition_name]
|
|
86
98
|
@session_messages = normalized[:session_messages] || []
|
|
87
99
|
@seed_message_count = validated_seed_message_count(normalized)
|
|
100
|
+
validate_session_parts!
|
|
88
101
|
@total_cost = normalized[:total_cost] || 0.0
|
|
89
102
|
@total_tokens = normalized[:total_tokens] || 0
|
|
90
103
|
@outcome = normalized[:outcome]
|
|
@@ -99,6 +112,7 @@ module Smith
|
|
|
99
112
|
@usage_mutex = Mutex.new
|
|
100
113
|
@usage_entries = restore_usage_entries(normalized)
|
|
101
114
|
@last_output = restore_last_output(normalized)
|
|
115
|
+
@last_transcript_output = restored_transcript_output
|
|
102
116
|
# Backward-compat: pre-patch states have no last_agent_execution key and
|
|
103
117
|
# restore to nil. @pending_agent_execution and @pending_evaluations are
|
|
104
118
|
# transient (nil between steps), never persisted, but must be
|
|
@@ -113,6 +127,16 @@ module Smith
|
|
|
113
127
|
# the original store from the legacy adapter contract).
|
|
114
128
|
@persistence_version = persistence_version
|
|
115
129
|
validate_definition_digest!(normalized)
|
|
130
|
+
validate_restored_position!(normalized)
|
|
131
|
+
@awaiting = WaitRecord.restore(normalized[:awaiting], workflow_class: self.class,
|
|
132
|
+
persistence_key: normalized[:persistence_key],
|
|
133
|
+
state: @state, next_transition: @next_transition_name)
|
|
134
|
+
@pending_retry = restore_pending_retry(normalized[:pending_retry], normalized[:persistence_key])
|
|
135
|
+
@pending_batch = restore_pending_batch(normalized[:pending_batch])
|
|
136
|
+
@active_seconds = restored_active_seconds(normalized[:active_seconds])
|
|
137
|
+
charge_unfinished_step!(normalized[:active_step_started_at])
|
|
138
|
+
@wait_mutex = Mutex.new
|
|
139
|
+
@wait_claimed = false
|
|
116
140
|
# Preserve the seed digest from the persisted payload so it
|
|
117
141
|
# round-trips on subsequent persists. validate_seed_digest!
|
|
118
142
|
# compares this against a fresh evaluation of the seed builder
|
|
@@ -126,6 +150,18 @@ module Smith
|
|
|
126
150
|
@split_step_mutex = Mutex.new
|
|
127
151
|
validate_step_in_progress!(normalized) if
|
|
128
152
|
self.class.idempotency_mode == :strict && !allow_step_in_progress
|
|
153
|
+
initialize_budget_ledger
|
|
154
|
+
@memo_attempts = []
|
|
155
|
+
@budget_ledger_scope = restored_budget_ledger_scope(normalized[:budget_ledger_scope])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The stored scope, or, for a run stored before its class declared
|
|
159
|
+
# these scope keys, the one its context gives now.
|
|
160
|
+
def restored_budget_ledger_scope(stored)
|
|
161
|
+
return unless self.class.budget_ledger
|
|
162
|
+
|
|
163
|
+
Budget::HostLedger.stored_scope(self.class.budget_ledger_scope_keys, stored) ||
|
|
164
|
+
capture_budget_ledger_scope
|
|
129
165
|
end
|
|
130
166
|
|
|
131
167
|
def validated_persistence_version(normalized)
|
|
@@ -136,6 +172,24 @@ module Smith
|
|
|
136
172
|
"persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
|
|
137
173
|
end
|
|
138
174
|
|
|
175
|
+
# Content parts come only from seed messages: anywhere else in a
|
|
176
|
+
# stored session they are refused, and a seed's are checked and kept
|
|
177
|
+
# as checked (String-keyed parts), however the payload was parsed.
|
|
178
|
+
def validate_session_parts!
|
|
179
|
+
@session_messages = @session_messages.each_with_index.map do |message, index|
|
|
180
|
+
next message unless Message.parts?(message)
|
|
181
|
+
|
|
182
|
+
checked = index < @seed_message_count && begin
|
|
183
|
+
Message.check_seed!(message)
|
|
184
|
+
rescue WorkflowError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
raise Smith::SerializationError, "persisted session content parts must be valid seed messages" unless checked
|
|
188
|
+
|
|
189
|
+
{ role: checked["role"], content: checked["content"], parts: true }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
139
193
|
def validated_seed_message_count(normalized)
|
|
140
194
|
count = normalized.fetch(:seed_message_count, 0)
|
|
141
195
|
return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
|
|
@@ -227,6 +281,14 @@ module Smith
|
|
|
227
281
|
end
|
|
228
282
|
end
|
|
229
283
|
|
|
284
|
+
# The last assistant message after the seeds: the output the run last
|
|
285
|
+
# added to its transcript, unless the host admitted one after it.
|
|
286
|
+
def restored_transcript_output
|
|
287
|
+
seeds = @seed_message_count || 0
|
|
288
|
+
message = @session_messages.drop(seeds).reverse_each.find { |item| MessageRoles.role(item) == "assistant" }
|
|
289
|
+
message && message[:content]
|
|
290
|
+
end
|
|
291
|
+
|
|
230
292
|
def restore_last_agent_execution(normalized)
|
|
231
293
|
value = if normalized.key?(:last_agent_execution)
|
|
232
294
|
normalized[:last_agent_execution]
|
|
@@ -256,15 +318,73 @@ module Smith
|
|
|
256
318
|
).call
|
|
257
319
|
end
|
|
258
320
|
|
|
321
|
+
# A state or pending transition the class does not know (a deploy
|
|
322
|
+
# renamed or removed it, or the payload has none) fails the restore.
|
|
323
|
+
# Restoring it would leave the run in a state with no outgoing
|
|
324
|
+
# transitions, which reads as terminal and ends the run silently. Runs
|
|
325
|
+
# after `migrate_from` and after the definition digest check, so a
|
|
326
|
+
# migration can map an old name and a digest mismatch is reported as
|
|
327
|
+
# one.
|
|
328
|
+
def validate_restored_position!(normalized)
|
|
329
|
+
validate_restored_state!(normalized[:state], normalized[:persistence_key])
|
|
330
|
+
validate_restored_transition!(normalized[:next_transition_name], normalized[:persistence_key])
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def validate_restored_state!(state, persistence_key)
|
|
334
|
+
return if state.nil? && self.class.initial_state.nil?
|
|
335
|
+
return if !state.nil? && self.class.known_state?(state)
|
|
336
|
+
|
|
337
|
+
raise Smith::UnknownStateOnRestore.new(workflow: self.class.name, persistence_key:, state:)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def validate_restored_transition!(transition, persistence_key)
|
|
341
|
+
return if transition.nil? || self.class.find_transition(transition)
|
|
342
|
+
|
|
343
|
+
raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:, transition:)
|
|
344
|
+
end
|
|
345
|
+
|
|
259
346
|
def restore_core_fields(normalized)
|
|
260
347
|
@state = normalized[:state]
|
|
261
348
|
@context = filter_persisted_context(normalized[:context] || {})
|
|
262
349
|
@step_count = normalized[:step_count] || 0
|
|
263
|
-
@execution_namespace = normalized
|
|
350
|
+
@execution_namespace = restored_execution_namespace(normalized)
|
|
351
|
+
@inherited_untrusted_keys = restored_inherited_untrusted_keys(normalized)
|
|
352
|
+
@inherited_artifact_namespaces = restored_artifact_namespaces(normalized)
|
|
264
353
|
@created_at = normalized[:created_at]
|
|
265
354
|
@updated_at = normalized[:updated_at]
|
|
266
355
|
end
|
|
267
356
|
|
|
357
|
+
def restored_inherited_untrusted_keys(normalized)
|
|
358
|
+
keys = normalized.fetch(:inherited_untrusted_keys, [])
|
|
359
|
+
unless keys.is_a?(Array) && keys.length <= 1_000 && keys.all? { untrusted_key_name?(_1) }
|
|
360
|
+
raise Smith::SerializationError, "persisted workflow inherited_untrusted_keys are invalid"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
keys.map(&:to_sym).uniq.freeze
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def untrusted_key_name?(key) = (key.is_a?(String) || key.is_a?(Symbol)) && key.length.between?(1, 256)
|
|
367
|
+
|
|
368
|
+
def restored_artifact_namespaces(normalized)
|
|
369
|
+
ArtifactIntegration.artifact_namespaces!(normalized.fetch(:inherited_artifact_namespaces, []))
|
|
370
|
+
rescue WorkflowError
|
|
371
|
+
raise Smith::SerializationError, "persisted workflow inherited_artifact_namespaces are invalid"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A payload written before namespaces were assigned at construction
|
|
375
|
+
# gets one derived from its own durable facts, the same on every
|
|
376
|
+
# restore of it, until a checkpoint stores it.
|
|
377
|
+
def restored_execution_namespace(normalized)
|
|
378
|
+
namespace = normalized[:execution_namespace]
|
|
379
|
+
if namespace.nil?
|
|
380
|
+
return IdempotencyKey.__send__(:legacy_namespace, normalized[:class] || self.class.name,
|
|
381
|
+
normalized[:persistence_key], normalized[:created_at])
|
|
382
|
+
end
|
|
383
|
+
return namespace if namespace.is_a?(String) && PreparedStep::UUID_PATTERN.match?(namespace)
|
|
384
|
+
|
|
385
|
+
raise Smith::SerializationError, "persisted workflow execution_namespace must be a UUID"
|
|
386
|
+
end
|
|
387
|
+
|
|
268
388
|
def restore_persisted_keys(normalized)
|
|
269
389
|
@persisted_keys_mutex = Mutex.new
|
|
270
390
|
raw = normalized[:persisted_keys]
|
|
@@ -361,24 +481,22 @@ module Smith
|
|
|
361
481
|
symbolized
|
|
362
482
|
end
|
|
363
483
|
|
|
484
|
+
# The identifier the class itself uses for a payload's state name, so a
|
|
485
|
+
# String-named state stays a String; an unknown name is symbolized.
|
|
364
486
|
def normalize_state_name(value)
|
|
365
487
|
return if value.nil?
|
|
366
|
-
return value if declared_state?(value)
|
|
367
|
-
return value unless value.is_a?(String)
|
|
368
488
|
|
|
369
|
-
|
|
370
|
-
return
|
|
489
|
+
resolved = self.class.resolve_state_name(value)
|
|
490
|
+
return resolved unless resolved.nil?
|
|
371
491
|
|
|
372
|
-
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def declared_state?(value)
|
|
376
|
-
states = self.class.instance_variable_get(:@states) || []
|
|
377
|
-
states.include?(value)
|
|
492
|
+
value.is_a?(String) ? value.to_sym : value
|
|
378
493
|
end
|
|
379
494
|
|
|
495
|
+
# Context keys are Symbols at every depth, matching what construction
|
|
496
|
+
# and deterministic writes produce, so a nested read behaves the same
|
|
497
|
+
# before and after a restore.
|
|
380
498
|
def normalize_nested_hashes!(normalized)
|
|
381
|
-
normalized[:context] =
|
|
499
|
+
normalized[:context] = ContextKeys.normalize(normalized[:context]) if normalized[:context].is_a?(Hash)
|
|
382
500
|
return unless normalized[:budget_consumed].is_a?(Hash)
|
|
383
501
|
|
|
384
502
|
normalized[:budget_consumed] = symbolize_keys(normalized[:budget_consumed])
|
|
@@ -388,7 +506,12 @@ module Smith
|
|
|
388
506
|
return unless normalized[:session_messages].is_a?(Array)
|
|
389
507
|
|
|
390
508
|
normalized[:session_messages] = normalized[:session_messages].map do |msg|
|
|
391
|
-
|
|
509
|
+
unless MessageRoles.role(msg)
|
|
510
|
+
raise Smith::SerializationError,
|
|
511
|
+
"persisted session message must have the role system, user, assistant or tool"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
symbolize_keys(snapshot_value(msg))
|
|
392
515
|
end
|
|
393
516
|
end
|
|
394
517
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# A step's provider calls through a host's response memo
|
|
6
|
+
# (Smith::ResponseMemo): each call takes its kept messages, if any, and
|
|
7
|
+
# sends nothing when they end it (a streamed step's sink gets the reply's
|
|
8
|
+
# text at once), else goes on from them, and is recorded as it goes.
|
|
9
|
+
# Kept tool results count as tools the step ran, and the live part goes
|
|
10
|
+
# on from the kept rounds (Resumed): its first request is a later round,
|
|
11
|
+
# and a pause for budget keeps the kept rounds too.
|
|
12
|
+
module RememberedCalls
|
|
13
|
+
CALLS_LOCK = Mutex.new
|
|
14
|
+
private_constant :CALLS_LOCK
|
|
15
|
+
|
|
16
|
+
# Where a call's live part starts: the call's first message, and the
|
|
17
|
+
# rounds its kept messages made.
|
|
18
|
+
Resumed = Data.define(:start, :rounds)
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def remembered_completion(chat, model_reference, facts, &)
|
|
23
|
+
memo = Smith.config.response_memo
|
|
24
|
+
key = memo && memo_key(chat, facts)
|
|
25
|
+
return yield(nil) unless key
|
|
26
|
+
|
|
27
|
+
start = chat.messages.length
|
|
28
|
+
kept = ResponseMemo.fetch(memo, key)
|
|
29
|
+
keep_messages(chat, kept.fetch(:messages)) if kept
|
|
30
|
+
return replayed_completion(chat, model_reference) if kept&.fetch(:complete)
|
|
31
|
+
|
|
32
|
+
recorded_completion(chat, memo, key, Resumed.new(start:, rounds: kept_rounds(kept)), &)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def kept_rounds(kept) = kept ? kept.fetch(:messages).count { _1.role == :assistant } : 0
|
|
36
|
+
|
|
37
|
+
def memo_key(chat, facts)
|
|
38
|
+
ResponseMemo.key([*@memo_attempts, @step_attempt.to_i], facts[:attempt_index], next_memo_call, chat)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The kept messages, after the call's own; their tool results are
|
|
42
|
+
# tools the step ran.
|
|
43
|
+
def keep_messages(chat, messages)
|
|
44
|
+
messages.each { chat.add_message(_1) }
|
|
45
|
+
Tool.current_tool_execution_tracker&.mark_started! if messages.any? { _1.role == :tool }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def recorded_completion(chat, memo, key, resumed)
|
|
49
|
+
live = chat.messages.length
|
|
50
|
+
result = ResponseMemo.recording(chat_provider(chat), memo, key, resumed.start) { yield(resumed) }
|
|
51
|
+
ResponseMemo.store(memo, key, chat.messages.drop(resumed.start), complete: true,
|
|
52
|
+
facts: streamed_facts(chat, live, result))
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A streamed call's replies' facts, read from its stream.
|
|
57
|
+
def streamed_facts(chat, live, result)
|
|
58
|
+
stream = result[1]
|
|
59
|
+
return {} unless stream.is_a?(Hash) && stream[:rounds]
|
|
60
|
+
|
|
61
|
+
replies = chat.messages.drop(live).select { _1.role == :assistant }
|
|
62
|
+
replies.zip(Providers::StreamFacts.per_message(replies, stream[:rounds], stream[:output_limit]))
|
|
63
|
+
.to_h.compare_by_identity
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def replayed_completion(chat, model_reference)
|
|
67
|
+
response = chat.messages.last
|
|
68
|
+
if (stream = @step_stream)
|
|
69
|
+
stream.start(attempt: @step_attempt, model: model_reference.model_id,
|
|
70
|
+
provider: model_reference.provider&.to_s)
|
|
71
|
+
content = response.content
|
|
72
|
+
stream.delta(content.is_a?(String) ? content : JSON.generate(content))
|
|
73
|
+
end
|
|
74
|
+
[response, nil, nil, { memo: true }]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The call's place among its attempt's calls at its position (a
|
|
78
|
+
# fan-out's branches count their own).
|
|
79
|
+
def next_memo_call
|
|
80
|
+
slot = [IdempotencyKey.__send__(:current_scope)&.key, @step_attempt]
|
|
81
|
+
memo_calls_lock.synchronize do
|
|
82
|
+
@memo_calls ||= {}
|
|
83
|
+
@memo_calls[slot] = @memo_calls.fetch(slot, 0) + 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def memo_calls_lock = @memo_calls_lock || CALLS_LOCK.synchronize { @memo_calls_lock ||= Mutex.new }
|
|
88
|
+
|
|
89
|
+
def start_memo_calls = memo_calls_lock.synchronize { @memo_calls = {} }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|