smith-agents 0.12.6 → 0.13.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 +105 -0
- data/README.md +11 -4
- data/docs/AGENTS.md +9 -5
- data/docs/ERRORS_AND_RETRIES.md +20 -4
- data/docs/PATTERNS.md +5 -3
- data/docs/PERSISTENCE.md +41 -9
- data/docs/TOOLS_AND_GUARDRAILS.md +86 -6
- data/lib/smith/agent/attempt_captures.rb +62 -0
- data/lib/smith/agent/lifecycle.rb +7 -3
- data/lib/smith/agent/output_integrity.rb +7 -6
- data/lib/smith/agent/output_recovery.rb +6 -4
- data/lib/smith/agent/provider_attempt.rb +3 -0
- data/lib/smith/agent/provider_completion.rb +18 -16
- data/lib/smith/agent/provider_failure_handling.rb +5 -3
- data/lib/smith/agent/provider_streaming.rb +3 -1
- data/lib/smith/errors.rb +6 -3
- data/lib/smith/response_memo.rb +2 -1
- data/lib/smith/tool/budget_enforcement.rb +1 -1
- data/lib/smith/tool/capability_builder.rb +14 -1
- data/lib/smith/tool/capability_declaration.rb +45 -0
- data/lib/smith/tool/capture_configuration.rb +5 -2
- data/lib/smith/tool/declarations.rb +73 -0
- data/lib/smith/tool/execution_tracker.rb +13 -2
- data/lib/smith/tool.rb +8 -38
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_pause.rb +8 -1
- data/lib/smith/workflow/child_run_execution.rb +2 -1
- data/lib/smith/workflow/dsl.rb +123 -0
- data/lib/smith/workflow/durability.rb +4 -3
- data/lib/smith/workflow/graph/rename_diagnostics.rb +69 -0
- data/lib/smith/workflow/graph/validator.rb +1 -0
- data/lib/smith/workflow/graph.rb +1 -0
- data/lib/smith/workflow/guarded_step_execution.rb +8 -8
- data/lib/smith/workflow/nested_execution.rb +3 -3
- data/lib/smith/workflow/persistence.rb +69 -1
- data/lib/smith/workflow/remembered_calls.rb +14 -2
- data/lib/smith/workflow/retry_execution.rb +24 -3
- data/lib/smith/workflow/retry_wait.rb +2 -0
- data/lib/smith/workflow/step_context.rb +2 -1
- data/lib/smith/workflow/wait_execution.rb +2 -1
- data/lib/smith/workflow.rb +11 -0
- metadata +5 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "provider_failure_handling"
|
|
4
|
+
require_relative "attempt_captures"
|
|
4
5
|
require_relative "content_bytes"
|
|
5
6
|
require_relative "content_parts"
|
|
6
7
|
require_relative "invocation_preparation"
|
|
@@ -15,6 +16,7 @@ module Smith
|
|
|
15
16
|
class Agent
|
|
16
17
|
module ProviderCompletion
|
|
17
18
|
include ProviderFailureHandling
|
|
19
|
+
include AttemptCaptures
|
|
18
20
|
include InvocationPreparation
|
|
19
21
|
include ContentParts
|
|
20
22
|
include ProviderCallTiming
|
|
@@ -26,13 +28,14 @@ module Smith
|
|
|
26
28
|
|
|
27
29
|
# The input's documents and images are read once (ContentBytes), and
|
|
28
30
|
# its cached parts checked against every Anthropic model in the chain,
|
|
29
|
-
# before the first attempt.
|
|
30
|
-
|
|
31
|
+
# before the first attempt. A candidate that fails over to the next
|
|
32
|
+
# joins `set_aside` (AttemptCaptures).
|
|
33
|
+
def complete_with_provider(agent_class, prepared_input, shape:, set_aside: [])
|
|
31
34
|
chain = build_model_chain(agent_class).tap { check_chain_cache_breakpoints!(_1, prepared_input) }
|
|
32
|
-
ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape) }
|
|
35
|
+
ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape, set_aside) }
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
def attempt_candidates(agent_class, prepared_input, chain, shape)
|
|
38
|
+
def attempt_candidates(agent_class, prepared_input, chain, shape, set_aside)
|
|
36
39
|
candidates = ProviderCandidateSequence.new(chain)
|
|
37
40
|
interrupted = nil
|
|
38
41
|
candidates.each do |model_reference, index|
|
|
@@ -43,7 +46,7 @@ module Smith
|
|
|
43
46
|
|
|
44
47
|
interrupted ||= attempt if Interruptions.find(attempt.error)
|
|
45
48
|
candidates.suppress(account_failed_provider(attempt, model_reference))
|
|
46
|
-
fail_over!(attempt, agent_class, candidates, interrupted)
|
|
49
|
+
fail_over!(attempt, agent_class, candidates, interrupted, set_aside)
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
# Fail closed: an empty candidate chain (agent without a model)
|
|
@@ -53,16 +56,15 @@ module Smith
|
|
|
53
56
|
"no executable model candidate for #{agent_class}; declare a model or fallback_models"
|
|
54
57
|
end
|
|
55
58
|
|
|
56
|
-
# Returns when another candidate is left to try
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
59
|
+
# Returns when another candidate is left to try, the failed attempt's
|
|
60
|
+
# captures set aside. A candidate that met an interrupting error (an
|
|
61
|
+
# account out of credit) outlasts how a later one fails: the step was
|
|
62
|
+
# cut short, not failed, and is taken again from its first candidate
|
|
63
|
+
# once the host can do its part.
|
|
64
|
+
def fail_over!(attempt, agent_class, candidates, interrupted, set_aside)
|
|
65
|
+
handle_provider_failure!(attempt.error, attempt.model_reference, agent_class,
|
|
66
|
+
fallback_available: candidates.fallback_available?, attempt_id: attempt.attempt_id)
|
|
67
|
+
set_aside.concat(attempt.captures)
|
|
66
68
|
rescue StandardError => e
|
|
67
69
|
raise if interrupted.nil? || Interruptions.find(e)
|
|
68
70
|
|
|
@@ -126,7 +128,7 @@ module Smith
|
|
|
126
128
|
message_count = chat_message_count(chat)
|
|
127
129
|
observed_reference = observed_model_reference(chat, fallback: model_reference)
|
|
128
130
|
timer = ProviderCallTiming::Timer.start
|
|
129
|
-
response, stream, mode, call_facts =
|
|
131
|
+
response, stream, mode, call_facts = captured_completion(chat, observed_reference, collected = [], facts)
|
|
130
132
|
timer.stop
|
|
131
133
|
completion = Completion.from_messages(response:, messages: new_chat_messages(chat, message_count), stream:,
|
|
132
134
|
mode:)
|
|
@@ -23,7 +23,7 @@ module Smith
|
|
|
23
23
|
|
|
24
24
|
def handle_provider_failure!(error, model_reference, agent_class, fallback_available:, attempt_id: nil)
|
|
25
25
|
account_failed_attempt(error, model_reference, agent_class, attempt_id:)
|
|
26
|
-
if
|
|
26
|
+
if uncertain_tool_calls?
|
|
27
27
|
raise Smith::ToolOutcomeUncertain.new(
|
|
28
28
|
"provider failed after tool execution began; retry or fallback could replay an uncertain outcome"
|
|
29
29
|
), cause: error
|
|
@@ -44,8 +44,10 @@ module Smith
|
|
|
44
44
|
PROVIDER_ACCOUNT_ERRORS.any? { |error_class| error.is_a?(error_class) }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
# A tool that is not replay-safe has run in the step: a fallback would
|
|
48
|
+
# run it again.
|
|
49
|
+
def uncertain_tool_calls?
|
|
50
|
+
Tool.current_tool_execution_tracker&.uncertain?
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def fallback_eligible?(error)
|
|
@@ -72,7 +72,9 @@ module Smith
|
|
|
72
72
|
|
|
73
73
|
def paused_loop(error, chat, start)
|
|
74
74
|
continuation = Budget::Continuation.dump(@budget_continuation, chat.messages.drop(start || 0))
|
|
75
|
-
|
|
75
|
+
return unless continuation && storable_pause?(continuation)
|
|
76
|
+
|
|
77
|
+
BudgetUnavailable.new(error.message, continuation:, paused_by: self)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
# Whether the run's state, with the paused loop kept, stays within
|
data/lib/smith/errors.rb
CHANGED
|
@@ -74,12 +74,15 @@ module Smith
|
|
|
74
74
|
# hold; nothing was sent. A BudgetExceeded, so it is not retried unless a
|
|
75
75
|
# step's `retry_on` names it (a durable retry waits for budget). One that
|
|
76
76
|
# paused a tool loop carries its `continuation` (Budget::Continuation),
|
|
77
|
-
# which the step's retry continues from
|
|
77
|
+
# which the step's retry continues from, and the run whose step paused
|
|
78
|
+
# (`paused_by`): only that run's retry continues it, never a parent's,
|
|
79
|
+
# whose retry would start a nested workflow over.
|
|
78
80
|
class BudgetUnavailable < BudgetExceeded
|
|
79
|
-
attr_reader :continuation
|
|
81
|
+
attr_reader :continuation, :paused_by
|
|
80
82
|
|
|
81
|
-
def initialize(message = "the budget ledger refused the request", continuation: nil)
|
|
83
|
+
def initialize(message = "the budget ledger refused the request", continuation: nil, paused_by: nil)
|
|
82
84
|
@continuation = continuation
|
|
85
|
+
@paused_by = paused_by
|
|
83
86
|
super(message)
|
|
84
87
|
end
|
|
85
88
|
end
|
data/lib/smith/response_memo.rb
CHANGED
|
@@ -31,7 +31,8 @@ module Smith
|
|
|
31
31
|
# whose tools had not run answers the call's first request without
|
|
32
32
|
# sending it, and its tools run again, under the same keys; kept tool
|
|
33
33
|
# results count as tools the step ran (charged to its tool-call
|
|
34
|
-
# allowances, and never run again by a retry or fallback
|
|
34
|
+
# allowances, and never run again by a retry or fallback unless each is a
|
|
35
|
+
# replay-safe tool's). A call whose
|
|
35
36
|
# messages cannot be kept (an attachment, or too many bytes) is not
|
|
36
37
|
# stored, and is logged. A record Smith does not keep (its shape, or tool
|
|
37
38
|
# results that do not answer its replies' calls) is ignored, and an error
|
|
@@ -31,7 +31,7 @@ module Smith
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def mark_tool_execution_started!
|
|
34
|
-
self.class.current_tool_execution_tracker&.mark_started!
|
|
34
|
+
self.class.current_tool_execution_tracker&.mark_started!(replay_safe: self.class.replay_safe?)
|
|
35
35
|
self.class.__send__(:current_tool_dispatch_start_handler)&.call(self)
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Tool < RubyLLM::Tool
|
|
5
5
|
class CapabilityBuilder
|
|
6
|
+
REPLAY = %i[safe unsafe].freeze
|
|
7
|
+
private_constant :REPLAY
|
|
8
|
+
|
|
6
9
|
def initialize
|
|
7
10
|
@capabilities = {}
|
|
8
11
|
end
|
|
@@ -12,7 +15,17 @@ module Smith
|
|
|
12
15
|
def network(value) = @capabilities[:network] = value
|
|
13
16
|
def approval(value) = @capabilities[:approval] = value
|
|
14
17
|
def data_volume(value) = @capabilities[:data_volume] = value
|
|
15
|
-
|
|
18
|
+
|
|
19
|
+
# :safe when running the tool again under the same keys does what
|
|
20
|
+
# running it once did (a lookup), so a step whose tools are all safe
|
|
21
|
+
# keeps its retries and fallbacks; :unsafe, the default, otherwise.
|
|
22
|
+
def replay(value)
|
|
23
|
+
raise ArgumentError, "replay is :safe or :unsafe" unless REPLAY.include?(value)
|
|
24
|
+
|
|
25
|
+
@capabilities[:replay] = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h = @capabilities
|
|
16
29
|
end
|
|
17
30
|
end
|
|
18
31
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
# What a tool class declares about itself (`capabilities do ... end`,
|
|
6
|
+
# CapabilityBuilder), read for its privilege check, its traces'
|
|
7
|
+
# sensitivity and whether a step may run it again. A subclass inherits
|
|
8
|
+
# its parent's, key by key: its own block sets or replaces only the keys
|
|
9
|
+
# it names, so a subclass is never less guarded than its parent unless
|
|
10
|
+
# it says so. `replay` alone is not inherited: whether a tool is safe to
|
|
11
|
+
# run again is a fact about its own `perform`, so a subclass is
|
|
12
|
+
# replay-safe only when it declares `replay :safe` itself.
|
|
13
|
+
module CapabilityDeclaration
|
|
14
|
+
NOT_INHERITED = %i[replay].freeze
|
|
15
|
+
private_constant :NOT_INHERITED
|
|
16
|
+
|
|
17
|
+
def capabilities(&)
|
|
18
|
+
return inherited_capabilities unless block_given?
|
|
19
|
+
|
|
20
|
+
builder = CapabilityBuilder.new
|
|
21
|
+
builder.instance_eval(&)
|
|
22
|
+
@capabilities = builder.to_h.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Whether this class itself declares `capabilities { replay :safe }`.
|
|
26
|
+
def replay_safe? = capabilities&.dig(:replay) == :safe
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def inherited_capabilities
|
|
31
|
+
inherited = parent_capabilities
|
|
32
|
+
own = @capabilities if instance_variable_defined?(:@capabilities)
|
|
33
|
+
return own unless inherited
|
|
34
|
+
|
|
35
|
+
own ? inherited.merge(own) : inherited
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The parent's capabilities this class takes (all but `replay`), or nil.
|
|
39
|
+
def parent_capabilities
|
|
40
|
+
inherited = parent_tool&.capabilities&.except(*NOT_INHERITED)
|
|
41
|
+
inherited if inherited&.any?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Tool < RubyLLM::Tool
|
|
5
|
+
# A tool class's capture, inherited (Declarations) with its strictness: a
|
|
6
|
+
# subclass's own `capture_result` replaces its parent's, block and
|
|
7
|
+
# strictness together.
|
|
5
8
|
module CaptureConfiguration
|
|
6
9
|
def capture_result(strict: false, &block)
|
|
7
|
-
return
|
|
10
|
+
return declared(:@capture_result) unless block
|
|
8
11
|
|
|
9
12
|
raise ArgumentError, "capture_result strict must be true or false" unless [true, false].include?(strict)
|
|
10
13
|
|
|
@@ -13,7 +16,7 @@ module Smith
|
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def capture_result_strict?
|
|
16
|
-
|
|
19
|
+
declaring_tool(:@capture_result)&.instance_variable_get(:@capture_result_strict) == true
|
|
17
20
|
end
|
|
18
21
|
end
|
|
19
22
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
# A tool class's declarations, which its subclasses inherit: each reads
|
|
6
|
+
# the nearest class up the chain that declared it, so a subclass
|
|
7
|
+
# overrides one by declaring it again. Parameters add up by name (a
|
|
8
|
+
# subclass's `param` adds one or replaces its parent's of that name), up
|
|
9
|
+
# to a class that gave a schema with `params`, which replaced the ones
|
|
10
|
+
# above it; that schema gives way to a subclass's own `param`s.
|
|
11
|
+
# Capabilities (CapabilityDeclaration) merge key by key, and a capture
|
|
12
|
+
# (CaptureConfiguration) comes with its strictness. A tool none of whose
|
|
13
|
+
# classes declares `authorize` or `before_execute` runs without one, as
|
|
14
|
+
# before. `O(D)` for a chain `D` classes deep.
|
|
15
|
+
module Declarations
|
|
16
|
+
def description(text = nil)
|
|
17
|
+
return declared(:@description) unless text
|
|
18
|
+
|
|
19
|
+
@description = text
|
|
20
|
+
end
|
|
21
|
+
alias desc description
|
|
22
|
+
|
|
23
|
+
def param(name, **) = own_parameters[name] = RubyLLM::Parameter.new(name, **)
|
|
24
|
+
|
|
25
|
+
def parameters
|
|
26
|
+
parent = parent_tool
|
|
27
|
+
inherited = parent.parameters if parent && !parent.instance_variable_defined?(:@params_schema_definition)
|
|
28
|
+
inherited.nil? || inherited.empty? ? own_parameters : inherited.merge(own_parameters)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def params_schema_definition
|
|
32
|
+
return @params_schema_definition if instance_variable_defined?(:@params_schema_definition)
|
|
33
|
+
|
|
34
|
+
parent_tool&.params_schema_definition if own_parameters.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def provider_params = declared(:@provider_params) || {}
|
|
38
|
+
|
|
39
|
+
def category(value = nil)
|
|
40
|
+
return declared(:@category) if value.nil?
|
|
41
|
+
|
|
42
|
+
@category = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def authorize(&block)
|
|
46
|
+
return declared(:@authorize) unless block
|
|
47
|
+
|
|
48
|
+
@authorize = block
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def before_execute(&block)
|
|
52
|
+
return declared(:@before_execute) unless block
|
|
53
|
+
|
|
54
|
+
@before_execute = block
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def own_parameters = (@own_parameters ||= {})
|
|
60
|
+
|
|
61
|
+
def parent_tool = (superclass if superclass <= Tool)
|
|
62
|
+
|
|
63
|
+
# The nearest class from this one up that set `ivar`, or nil.
|
|
64
|
+
def declaring_tool(ivar)
|
|
65
|
+
tool = self
|
|
66
|
+
tool = tool.__send__(:parent_tool) while tool && !tool.instance_variable_defined?(ivar)
|
|
67
|
+
tool
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def declared(ivar) = declaring_tool(ivar)&.instance_variable_get(ivar)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -2,19 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Tool < RubyLLM::Tool
|
|
5
|
+
# What the tools of one step attempt did: whether any began, and whether
|
|
6
|
+
# one that is not replay-safe did, which leaves the step's outcome
|
|
7
|
+
# uncertain (nothing may run its tools again).
|
|
5
8
|
class ExecutionTracker
|
|
6
9
|
def initialize
|
|
7
10
|
@started = false
|
|
11
|
+
@uncertain = false
|
|
8
12
|
@mutex = Mutex.new
|
|
9
13
|
end
|
|
10
14
|
|
|
11
|
-
def mark_started!
|
|
12
|
-
@mutex.synchronize
|
|
15
|
+
def mark_started!(replay_safe: false)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@started = true
|
|
18
|
+
@uncertain = true unless replay_safe
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
def started?
|
|
16
23
|
@mutex.synchronize { @started }
|
|
17
24
|
end
|
|
25
|
+
|
|
26
|
+
def uncertain?
|
|
27
|
+
@mutex.synchronize { @uncertain }
|
|
28
|
+
end
|
|
18
29
|
end
|
|
19
30
|
end
|
|
20
31
|
end
|
data/lib/smith/tool.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "ruby_llm"
|
|
4
4
|
|
|
5
|
+
require_relative "tool/declarations"
|
|
5
6
|
require_relative "tool/capability_builder"
|
|
7
|
+
require_relative "tool/capability_declaration"
|
|
6
8
|
require_relative "tool/policy"
|
|
7
9
|
require_relative "tool/call_budget"
|
|
8
10
|
require_relative "tool/call_batch"
|
|
@@ -61,22 +63,14 @@ module Smith
|
|
|
61
63
|
include ExecutionLifecycle
|
|
62
64
|
include BudgetEnforcement
|
|
63
65
|
include Capture
|
|
66
|
+
extend Declarations
|
|
67
|
+
extend CapabilityDeclaration
|
|
64
68
|
extend CaptureConfiguration
|
|
65
69
|
extend ScopedContext
|
|
66
70
|
|
|
67
71
|
private_constant :ExecutionAuthority
|
|
68
72
|
|
|
69
73
|
class << self
|
|
70
|
-
# Tool subclasses inherit the parent's compatible_with spec by
|
|
71
|
-
# reference (the spec is a frozen Hash; immutability makes shared
|
|
72
|
-
# references safe). Subclasses can override by calling
|
|
73
|
-
# `compatible_with` again — assigns a NEW frozen Hash to its own
|
|
74
|
-
# @compatible_with_spec, leaving the parent untouched.
|
|
75
|
-
def inherited(subclass)
|
|
76
|
-
super
|
|
77
|
-
subclass.instance_variable_set(:@compatible_with_spec, @compatible_with_spec)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
74
|
# Declarative compatibility DSL. Examples:
|
|
81
75
|
# compatible_with :anthropic, :gemini
|
|
82
76
|
# compatible_with :anthropic, :gemini, openai: :responses
|
|
@@ -85,38 +79,14 @@ module Smith
|
|
|
85
79
|
# Tools that NEVER declare compatible_with are universally compatible.
|
|
86
80
|
# Consumed by Smith::Models::Normalizer.drop_incompatible_tools when
|
|
87
81
|
# the resolved model rejects the (tools + thinking) combo and no
|
|
88
|
-
# routing fallback (e.g., openai_api_mode :auto) is available.
|
|
82
|
+
# routing fallback (e.g., openai_api_mode :auto) is available. The
|
|
83
|
+
# spec is a frozen Hash, read like the other declarations: a subclass
|
|
84
|
+
# takes the nearest one up its chain (Declarations).
|
|
89
85
|
def compatible_with(*providers, except: nil, **provider_endpoints)
|
|
90
86
|
@compatible_with_spec = Compatibility.parse(providers, except: except, **provider_endpoints)
|
|
91
87
|
end
|
|
92
88
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def category(value = nil)
|
|
96
|
-
return @category if value.nil?
|
|
97
|
-
|
|
98
|
-
@category = value
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def capabilities(&)
|
|
102
|
-
return @capabilities unless block_given?
|
|
103
|
-
|
|
104
|
-
builder = CapabilityBuilder.new
|
|
105
|
-
builder.instance_eval(&)
|
|
106
|
-
@capabilities = builder.to_h
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def authorize(&block)
|
|
110
|
-
return @authorize unless block_given?
|
|
111
|
-
|
|
112
|
-
@authorize = block
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def before_execute(&block)
|
|
116
|
-
return @before_execute unless block_given?
|
|
117
|
-
|
|
118
|
-
@before_execute = block
|
|
119
|
-
end
|
|
89
|
+
def compatible_with_spec = declared(:@compatible_with_spec)
|
|
120
90
|
end
|
|
121
91
|
|
|
122
92
|
def execute(**kwargs)
|
data/lib/smith/version.rb
CHANGED
|
@@ -37,7 +37,14 @@ module Smith
|
|
|
37
37
|
def budget_pausable? = !@budget_pause_attempts.nil? && @step_attempt.to_i < @budget_pause_attempts
|
|
38
38
|
|
|
39
39
|
def keep_budget_continuation(error)
|
|
40
|
-
@budget_continuation = error.continuation if
|
|
40
|
+
@budget_continuation = error.continuation if own_pause?(error)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A tool loop this run's own step paused, which its retry continues
|
|
44
|
+
# from its tools' results. One a nested workflow paused is not: the
|
|
45
|
+
# retry starts the child over, and its tools run again.
|
|
46
|
+
def own_pause?(error)
|
|
47
|
+
error.is_a?(BudgetUnavailable) && !error.continuation.nil? && error.paused_by.equal?(self)
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# The paused loop's messages, after the attempt's prepared input.
|
|
@@ -141,7 +141,8 @@ module Smith
|
|
|
141
141
|
check_deadline!
|
|
142
142
|
namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
|
|
143
143
|
token = IdempotencyKey.__send__(:root_scope, namespace)
|
|
144
|
-
.within(:step, @step_count + 1, transition.name
|
|
144
|
+
.within(:step, @step_count + 1, self.class.transition_key_name(transition.name))
|
|
145
|
+
.within(:spawn).key
|
|
145
146
|
launch_child_runs(transition, token)
|
|
146
147
|
nil
|
|
147
148
|
end
|
data/lib/smith/workflow/dsl.rb
CHANGED
|
@@ -33,6 +33,8 @@ module Smith
|
|
|
33
33
|
subclass.instance_variable_set(:@persistence_key_builder, @persistence_key_builder)
|
|
34
34
|
subclass.instance_variable_set(:@persistence_schema_version, @persistence_schema_version)
|
|
35
35
|
subclass.instance_variable_set(:@migrations, (@migrations || {}).dup)
|
|
36
|
+
subclass.instance_variable_set(:@renamed_states, @renamed_states)
|
|
37
|
+
subclass.instance_variable_set(:@renamed_transitions, @renamed_transitions)
|
|
36
38
|
subclass.instance_variable_set(:@seed_validation_mode, @seed_validation_mode)
|
|
37
39
|
subclass.instance_variable_set(:@idempotency_mode, @idempotency_mode)
|
|
38
40
|
subclass.instance_variable_set(:@session_mode, @session_mode)
|
|
@@ -165,6 +167,77 @@ module Smith
|
|
|
165
167
|
@migrations || {}
|
|
166
168
|
end
|
|
167
169
|
|
|
170
|
+
# A state a later version renamed: a run persisted in `old`
|
|
171
|
+
# restores in `to` and carries on from there. Renames chain (a to
|
|
172
|
+
# b, then b to c); a name is renamed at most once and never back
|
|
173
|
+
# to a name it had. A name the class still declares is never taken
|
|
174
|
+
# for an old one (validate_graph warns of such a rename). The
|
|
175
|
+
# persisted run is mapped after migrate_from, before anything
|
|
176
|
+
# checks its names, so a rename needs no persistence_schema_version.
|
|
177
|
+
# A rename stays declared while runs persisted under its old name
|
|
178
|
+
# may remain.
|
|
179
|
+
def renamed_state(old, to:)
|
|
180
|
+
@renamed_states = renamed_name_map(renamed_states, old, to, kind: "state")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A transition a later version renamed: a run persisted about to
|
|
184
|
+
# take `old`, or waiting in it (its wait, pending retry or batch),
|
|
185
|
+
# restores at `to`, as renamed_state. A renamed transition keeps
|
|
186
|
+
# its keys: its steps' idempotency keys and wait tokens are made
|
|
187
|
+
# under the first name in its chain (transition_key_name), so a
|
|
188
|
+
# step a deploy cut short, a retry or an early delivery meets the
|
|
189
|
+
# keys its earlier attempt used. Hence at most one name is renamed
|
|
190
|
+
# to a transition, and the rename stays declared for good: dropping
|
|
191
|
+
# it moves the transition's keys to its new name.
|
|
192
|
+
def renamed_transition(old, to:)
|
|
193
|
+
renames = renamed_name_map(renamed_transitions, old, to, kind: "transition")
|
|
194
|
+
earlier = renamed_transitions.key(renames.fetch(old.to_s))
|
|
195
|
+
if earlier
|
|
196
|
+
raise ArgumentError, "transition #{to.inspect} is already the new name of #{earlier.inspect}; a " \
|
|
197
|
+
"transition keeps the keys of one first name, so only one name is renamed to it"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
@renamed_transitions = renames
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def renamed_states
|
|
204
|
+
@renamed_states || {}.freeze
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def renamed_transitions
|
|
208
|
+
@renamed_transitions || {}.freeze
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The state a persisted state name stands for now, renames
|
|
212
|
+
# followed, or nil when the class knows none. `O(R·(S + T))` for
|
|
213
|
+
# `R` renamed states.
|
|
214
|
+
def current_state_name(name)
|
|
215
|
+
return if name.nil?
|
|
216
|
+
|
|
217
|
+
resolve_state_name(follow_renames(name, renamed_states) { |candidate| resolve_state_name(candidate) })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The transition a persisted transition name stands for now,
|
|
221
|
+
# renames followed, or nil when the class knows none. `O(R·T)` for
|
|
222
|
+
# `R` renamed transitions.
|
|
223
|
+
def current_transition_name(name)
|
|
224
|
+
return if name.nil?
|
|
225
|
+
|
|
226
|
+
transition_named(follow_renames(name, renamed_transitions) { |candidate| transition_named(candidate) })&.name
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The name a transition's steps are keyed under (their idempotency
|
|
230
|
+
# keys and wait tokens): the first name in its rename chain, so a
|
|
231
|
+
# renamed transition keeps its keys. Renames have no cycles and at
|
|
232
|
+
# most one name renamed to each, so the chain back is a line.
|
|
233
|
+
# `O(R)` for `R` renamed transitions.
|
|
234
|
+
def transition_key_name(name)
|
|
235
|
+
earlier = renamed_transitions.invert
|
|
236
|
+
current = name.to_s
|
|
237
|
+
current = earlier[current] while earlier.key?(current)
|
|
238
|
+
current
|
|
239
|
+
end
|
|
240
|
+
|
|
168
241
|
# Controls whether restore validates that the seed_messages
|
|
169
242
|
# builder still produces the same digest as when this workflow
|
|
170
243
|
# was originally persisted.
|
|
@@ -313,6 +386,56 @@ module Smith
|
|
|
313
386
|
Identifier.normalize(identifier, label: "workflow identifier")
|
|
314
387
|
end
|
|
315
388
|
|
|
389
|
+
# The renames with `old` renamed to `to`: refused when `old` is
|
|
390
|
+
# already renamed, or when the chain from `to` leads back to `old`
|
|
391
|
+
# (a rename back), so every chain is a line. `O(R)`.
|
|
392
|
+
def renamed_name_map(renames, old, to, kind:)
|
|
393
|
+
old_name = renamed_identifier(old, kind:)
|
|
394
|
+
new_name = renamed_identifier(to, kind:)
|
|
395
|
+
check_rename!(renames, old_name, new_name, kind:)
|
|
396
|
+
renames.merge(old_name => new_name).freeze
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def check_rename!(renames, old_name, new_name, kind:)
|
|
400
|
+
if old_name == new_name
|
|
401
|
+
raise ArgumentError, "a renamed #{kind} must differ from its new name, got #{old_name.inspect}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
if renames.key?(old_name)
|
|
405
|
+
raise ArgumentError, "#{kind} #{old_name.inspect} is already renamed to #{renames[old_name].inspect}; " \
|
|
406
|
+
"rename #{renames[old_name].inspect} instead"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
return unless renamed_back?(renames, old_name, new_name)
|
|
410
|
+
|
|
411
|
+
raise ArgumentError, "#{kind} #{new_name.inspect} was renamed from #{old_name.inspect}; a #{kind} is " \
|
|
412
|
+
"never renamed back to a name it had"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def renamed_back?(renames, old_name, new_name)
|
|
416
|
+
current = new_name
|
|
417
|
+
current = renames[current] while renames.key?(current) && current != old_name
|
|
418
|
+
current == old_name
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def renamed_identifier(name, kind:)
|
|
422
|
+
unless (name.is_a?(Symbol) || name.is_a?(String)) && !name.to_s.strip.empty?
|
|
423
|
+
raise ArgumentError, "a renamed #{kind} must be a non-blank Symbol or String, got #{name.inspect}"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
name.to_s
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# The name `name` stands for now: itself while the class declares
|
|
430
|
+
# it (`declared` says so), else where its renames lead. Chains are
|
|
431
|
+
# lines (renamed_name_map), so the walk ends. `O(R)` calls of
|
|
432
|
+
# `declared`.
|
|
433
|
+
def follow_renames(name, renames, &declared)
|
|
434
|
+
current = name.to_s
|
|
435
|
+
current = renames[current] until declared.call(current) || !renames.key?(current)
|
|
436
|
+
current
|
|
437
|
+
end
|
|
438
|
+
|
|
316
439
|
def transitions_by_state
|
|
317
440
|
@transitions_by_state ||= Hash.new { |index, state| index[state] = [] }
|
|
318
441
|
end
|
|
@@ -244,10 +244,11 @@ module Smith
|
|
|
244
244
|
false
|
|
245
245
|
end
|
|
246
246
|
|
|
247
|
-
# A state the class no longer knows can never
|
|
248
|
-
# it is not terminal: an old one reports as
|
|
247
|
+
# A state the class no longer knows, renames followed, can never
|
|
248
|
+
# advance or restore, so it is not terminal: an old one reports as
|
|
249
|
+
# stuck.
|
|
249
250
|
def terminal_payload_state?(klass, state_name, next_transition)
|
|
250
|
-
state = klass.
|
|
251
|
+
state = klass.current_state_name(state_name)
|
|
251
252
|
!state.nil? && !klass.transition_from?(state) && next_transition.nil?
|
|
252
253
|
end
|
|
253
254
|
|