smith-agents 0.12.7 → 0.13.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 +126 -0
- data/README.md +6 -2
- data/docs/AGENTS.md +9 -5
- data/docs/ERRORS_AND_RETRIES.md +20 -4
- data/docs/PATTERNS.md +5 -3
- data/docs/PERSISTENCE.md +14 -1
- 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/failure_record_restore.rb +7 -1
- data/lib/smith/workflow/failure_record_text.rb +20 -12
- data/lib/smith/workflow/guarded_step_execution.rb +8 -8
- data/lib/smith/workflow/nested_execution.rb +3 -3
- 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/split_step_persistence/preparation.rb +1 -3
- data/lib/smith/workflow/split_step_persistence/unsupported_steps.rb +29 -0
- data/lib/smith/workflow/split_step_persistence.rb +2 -0
- data/lib/smith/workflow.rb +11 -0
- metadata +5 -1
|
@@ -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.
|
|
@@ -45,10 +45,12 @@ module Smith
|
|
|
45
45
|
identity_attributes(values).merge(error_attributes(values)).freeze
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# A step declared `from: nil` is recorded with that origin, as its
|
|
49
|
+
# step result is; any other `from` must be text of 1 to 256 bytes.
|
|
48
50
|
def identity_attributes(values)
|
|
49
51
|
{
|
|
50
52
|
transition: normalize_identifier(values.fetch(:transition), transition_normalizer, "transition"),
|
|
51
|
-
from:
|
|
53
|
+
from: optional_identifier(values.fetch(:from), state_normalizer, "from state"),
|
|
52
54
|
to: normalize_identifier(values.fetch(:to), state_normalizer, "to state")
|
|
53
55
|
}
|
|
54
56
|
end
|
|
@@ -98,6 +100,10 @@ module Smith
|
|
|
98
100
|
bounded_string(value, 256, label).then { normalizer.call(_1) }
|
|
99
101
|
end
|
|
100
102
|
|
|
103
|
+
def optional_identifier(value, normalizer, label)
|
|
104
|
+
value.nil? ? nil : normalize_identifier(value, normalizer, label)
|
|
105
|
+
end
|
|
106
|
+
|
|
101
107
|
def normalize_kind(value)
|
|
102
108
|
return if value.nil?
|
|
103
109
|
|
|
@@ -15,11 +15,13 @@ module Smith
|
|
|
15
15
|
MODULE_MATCH = Module.instance_method(:===)
|
|
16
16
|
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
17
17
|
STRING_ENCODING = String.instance_method(:encoding)
|
|
18
|
+
STRING_FORCE_ENCODING = String.instance_method(:force_encoding)
|
|
18
19
|
STRING_INITIALIZE_COPY = String.instance_method(:initialize_copy)
|
|
19
20
|
STRING_VALID_ENCODING = String.instance_method(:valid_encoding?)
|
|
20
21
|
SYMBOL_TO_S = Symbol.instance_method(:to_s)
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
TEXT_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
|
|
23
|
+
private_constant :MODULE_MATCH, :STRING_BYTESIZE, :STRING_ENCODING, :STRING_FORCE_ENCODING,
|
|
24
|
+
:STRING_INITIALIZE_COPY, :STRING_VALID_ENCODING, :SYMBOL_TO_S, :TEXT_ENCODINGS
|
|
23
25
|
|
|
24
26
|
extend Dry::Initializer
|
|
25
27
|
|
|
@@ -33,8 +35,7 @@ module Smith
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def call
|
|
36
|
-
text = owned_text
|
|
37
|
-
validate_encoding!(text)
|
|
38
|
+
text = utf8_text(owned_text)
|
|
38
39
|
size = STRING_BYTESIZE.bind_call(text)
|
|
39
40
|
return normalized(text, size) if normalize_length
|
|
40
41
|
raise PersistedFailureInvalid, "persisted workflow failure #{label} is invalid" unless size.between?(1, limit)
|
|
@@ -44,11 +45,16 @@ module Smith
|
|
|
44
45
|
|
|
45
46
|
private
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# US-ASCII text is UTF-8 text as it stands (a Symbol's name and a
|
|
49
|
+
# renamed state's or step's name are US-ASCII), so its owned copy is
|
|
50
|
+
# marked UTF-8; any other encoding, or bytes outside it, is refused.
|
|
51
|
+
def utf8_text(text)
|
|
52
|
+
encoding = STRING_ENCODING.bind_call(text)
|
|
53
|
+
unless STRING_VALID_ENCODING.bind_call(text) && TEXT_ENCODINGS.include?(encoding)
|
|
54
|
+
raise PersistedFailureInvalid, "persisted workflow failure #{label} is invalid"
|
|
55
|
+
end
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
encoding == Encoding::UTF_8 ? text : STRING_FORCE_ENCODING.bind_call(text, Encoding::UTF_8)
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# Message fields mirror capture normalization instead of rejecting on
|
|
@@ -65,14 +71,16 @@ module Smith
|
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
def owned_text
|
|
68
|
-
return
|
|
69
|
-
return SYMBOL_TO_S.bind_call(value) if symbol?
|
|
74
|
+
return owned_copy(value) if string?
|
|
75
|
+
return owned_copy(SYMBOL_TO_S.bind_call(value)) if symbol?
|
|
70
76
|
|
|
71
77
|
raise PersistedFailureInvalid, "persisted workflow failure #{label} must be text"
|
|
72
78
|
end
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
# A copy of its own, so marking it UTF-8 changes neither the caller's
|
|
81
|
+
# String nor the String a Symbol's name comes as (chilled, or frozen).
|
|
82
|
+
def owned_copy(source)
|
|
83
|
+
String.allocate.tap { STRING_INITIALIZE_COPY.bind_call(_1, source) }
|
|
76
84
|
end
|
|
77
85
|
|
|
78
86
|
def string? = MODULE_MATCH.bind_call(String, value)
|
|
@@ -22,20 +22,20 @@ module Smith
|
|
|
22
22
|
ensure
|
|
23
23
|
# Tool evidence travels up: a step running inside another step
|
|
24
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
|
-
|
|
25
|
+
# enclosing step is never retried over tools that already ran,
|
|
26
|
+
# unless they were all replay-safe.
|
|
27
|
+
previous_tracker&.mark_started!(replay_safe: !tracker.uncertain?) if tracker.started?
|
|
27
28
|
Tool.current_tool_execution_tracker = previous_tracker
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
# A failure after a tool ran leaves its outcome
|
|
33
|
-
# retry could not run the tool again: a terminal
|
|
34
|
-
# loop paused for budget, whose
|
|
35
|
-
# results.
|
|
33
|
+
# A failure after a tool that is not replay-safe ran leaves its outcome
|
|
34
|
+
# uncertain, unless its retry could not run the tool again: a terminal
|
|
35
|
+
# failure, or a tool loop this run's step paused for budget, whose
|
|
36
|
+
# retry continues from its tools' results (BudgetPause#own_pause?).
|
|
36
37
|
def uncertain_after_tools?(tracker, error)
|
|
37
|
-
tracker.
|
|
38
|
-
!(error.is_a?(BudgetUnavailable) && error.continuation)
|
|
38
|
+
tracker.uncertain? && !terminal_retry_error?(error) && !own_pause?(error)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def run_tracked_guarded_step(transition)
|
|
@@ -16,9 +16,9 @@ module Smith
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# A child that raises passes its error to the parent unchanged, after
|
|
19
|
-
# its billable usage is rolled up. If a tool
|
|
20
|
-
# parent's step already carries that evidence
|
|
21
|
-
# into ToolOutcomeUncertain (GuardedStepExecution).
|
|
19
|
+
# its billable usage is rolled up. If a tool that is not replay-safe
|
|
20
|
+
# ran in the child, the parent's step already carries that evidence
|
|
21
|
+
# and turns the failure into ToolOutcomeUncertain (GuardedStepExecution).
|
|
22
22
|
def run_child_workflow(child)
|
|
23
23
|
child.run!
|
|
24
24
|
rescue StandardError
|
|
@@ -53,12 +53,24 @@ module Smith
|
|
|
53
53
|
messages = kept.fetch(:messages)
|
|
54
54
|
messages.each { chat.add_message(_1) }
|
|
55
55
|
ran = messages.filter_map { _1.tool_call_id if _1.role == :tool }
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
calls = ran_calls(messages, ran)
|
|
57
|
+
mark_kept_tools(chat, ran, calls) if ran.any?
|
|
58
|
+
charge_kept_calls(calls)
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
def ran_calls(messages, ran) = messages.flat_map { _1.tool_calls&.values.to_a }.select { ran.include?(_1.id) }
|
|
61
62
|
|
|
63
|
+
# As the tools themselves would mark the step: replay-safe only when
|
|
64
|
+
# each kept result answers a call to a tool of the chat's that
|
|
65
|
+
# declares it.
|
|
66
|
+
def mark_kept_tools(chat, ran, calls)
|
|
67
|
+
tools = calls.to_h { [_1.id, chat.tools[_1.name.to_sym]] }
|
|
68
|
+
safe = ran.all? { replay_safe_tool?(tools[_1]) }
|
|
69
|
+
Tool.current_tool_execution_tracker&.mark_started!(replay_safe: safe)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def replay_safe_tool?(tool) = tool.is_a?(Tool) && tool.class.replay_safe?
|
|
73
|
+
|
|
62
74
|
# Each kept call against the agent's tool-call allowance and the
|
|
63
75
|
# workflow's `tool_calls` budget, as a call that runs is, so a replay
|
|
64
76
|
# runs no more tools than the step may. A call past them (a budget
|
|
@@ -34,9 +34,7 @@ module Smith
|
|
|
34
34
|
schedule = retry_schedule(config)
|
|
35
35
|
begin
|
|
36
36
|
attempt += 1
|
|
37
|
-
|
|
38
|
-
@step_key_generation = key_generation
|
|
39
|
-
attempt_scope = attempt_idempotency_scope(key_generation)
|
|
37
|
+
attempt_scope = enter_attempt(attempt, key_generation)
|
|
40
38
|
run_attempt_under_keys(transition, attempt_scope)
|
|
41
39
|
rescue StandardError => e
|
|
42
40
|
Interruptions.pass!(e)
|
|
@@ -48,10 +46,33 @@ module Smith
|
|
|
48
46
|
return wait_to_retry(transition, delay, attempt + 1, key_generation, e) if config[:durable]
|
|
49
47
|
|
|
50
48
|
sleep(delay) if delay.positive?
|
|
49
|
+
drop_attempt_tool_results(e)
|
|
51
50
|
retry
|
|
52
51
|
end
|
|
53
52
|
end
|
|
54
53
|
|
|
54
|
+
# The attempt under way, under `key_generation`; returns its
|
|
55
|
+
# idempotency scope. `@attempt_tool_results_from` (transient, like
|
|
56
|
+
# `@step_attempt`) is where its tool results start.
|
|
57
|
+
def enter_attempt(attempt, key_generation)
|
|
58
|
+
@step_attempt = attempt
|
|
59
|
+
@step_key_generation = key_generation
|
|
60
|
+
@attempt_tool_results_from = @tool_results_mutex.synchronize { @tool_results.length }
|
|
61
|
+
attempt_idempotency_scope(key_generation)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Once its retry is certain (in process, or waiting), a failed attempt's
|
|
65
|
+
# tool results go: its tools were replay-safe, or it would not be
|
|
66
|
+
# retried, and the next attempt runs them again. A tool loop this
|
|
67
|
+
# run's step paused for budget keeps them, as its retry goes on from
|
|
68
|
+
# them.
|
|
69
|
+
def drop_attempt_tool_results(error)
|
|
70
|
+
from = @attempt_tool_results_from
|
|
71
|
+
return if from.nil? || own_pause?(error)
|
|
72
|
+
|
|
73
|
+
@tool_results_mutex.synchronize { @tool_results.slice!(from..) }
|
|
74
|
+
end
|
|
75
|
+
|
|
55
76
|
def attempt_idempotency_scope(key_generation)
|
|
56
77
|
step_scope = IdempotencyKey.__send__(:current_scope)
|
|
57
78
|
return step_scope if step_scope.nil? || key_generation.zero?
|
|
@@ -24,6 +24,7 @@ module Smith
|
|
|
24
24
|
# it waits to make. Like any wait it is not a step. The pending retry
|
|
25
25
|
# is recorded before the wait, so an interrupt between the two leaves
|
|
26
26
|
# a step that ended without a wait, whose pending retry is cleared.
|
|
27
|
+
# The run waits without the failed attempt's tool captures.
|
|
27
28
|
def wait_to_retry(transition, seconds, attempt, key_generation, error)
|
|
28
29
|
if @split_step_phase == :executing
|
|
29
30
|
raise WorkflowError, "a durable retry cannot wait inside split-step execution"
|
|
@@ -38,6 +39,7 @@ module Smith
|
|
|
38
39
|
@awaiting = WaitRecord.build_retry(transition, wait_token_for(transition, ["retry", attempt]), seconds,
|
|
39
40
|
Time.now.utc)
|
|
40
41
|
record_retry_trace(transition, :retrying, attempt:, error: error_name, deadline_at: @awaiting["deadline_at"])
|
|
42
|
+
drop_attempt_tool_results(error)
|
|
41
43
|
WAIT_PENDING
|
|
42
44
|
end
|
|
43
45
|
|
|
@@ -76,9 +76,7 @@ module Smith
|
|
|
76
76
|
transition_name = @next_transition_name || transition&.name
|
|
77
77
|
raise WorkflowError, "workflow has no pending transition" unless transition_name
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
raise WorkflowError, refusal if transition&.waits_or_launches?
|
|
81
|
-
|
|
79
|
+
refuse_unsupported_split_step!(transition)
|
|
82
80
|
ensure_transition_budget!
|
|
83
81
|
resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
|
|
84
82
|
store = persistence_adapter!(adapter)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
# The steps split-step persistence does not run, refused when one is
|
|
7
|
+
# prepared, before anything is written.
|
|
8
|
+
module UnsupportedSteps
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def refuse_unsupported_split_step!(transition)
|
|
12
|
+
return unless transition
|
|
13
|
+
|
|
14
|
+
if transition.waits_or_launches?
|
|
15
|
+
raise WorkflowError, "split-step persistence does not run steps that wait or launch runs"
|
|
16
|
+
end
|
|
17
|
+
return unless transition.from.nil?
|
|
18
|
+
|
|
19
|
+
# A prepared step is checked against the state its step is declared
|
|
20
|
+
# from, which a step declared `from: nil` does not have; not
|
|
21
|
+
# supported yet.
|
|
22
|
+
raise WorkflowError, "split-step persistence does not run :#{transition.name}: it is declared from: nil, " \
|
|
23
|
+
"and a prepared step is checked against the state its step is declared from; " \
|
|
24
|
+
"run it with advance_persisted!"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|