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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +6 -2
  4. data/docs/AGENTS.md +9 -5
  5. data/docs/ERRORS_AND_RETRIES.md +20 -4
  6. data/docs/PATTERNS.md +5 -3
  7. data/docs/PERSISTENCE.md +14 -1
  8. data/docs/TOOLS_AND_GUARDRAILS.md +86 -6
  9. data/lib/smith/agent/attempt_captures.rb +62 -0
  10. data/lib/smith/agent/lifecycle.rb +7 -3
  11. data/lib/smith/agent/output_integrity.rb +7 -6
  12. data/lib/smith/agent/output_recovery.rb +6 -4
  13. data/lib/smith/agent/provider_attempt.rb +3 -0
  14. data/lib/smith/agent/provider_completion.rb +18 -16
  15. data/lib/smith/agent/provider_failure_handling.rb +5 -3
  16. data/lib/smith/agent/provider_streaming.rb +3 -1
  17. data/lib/smith/errors.rb +6 -3
  18. data/lib/smith/response_memo.rb +2 -1
  19. data/lib/smith/tool/budget_enforcement.rb +1 -1
  20. data/lib/smith/tool/capability_builder.rb +14 -1
  21. data/lib/smith/tool/capability_declaration.rb +45 -0
  22. data/lib/smith/tool/capture_configuration.rb +5 -2
  23. data/lib/smith/tool/declarations.rb +73 -0
  24. data/lib/smith/tool/execution_tracker.rb +13 -2
  25. data/lib/smith/tool.rb +8 -38
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/budget_pause.rb +8 -1
  28. data/lib/smith/workflow/failure_record_restore.rb +7 -1
  29. data/lib/smith/workflow/failure_record_text.rb +20 -12
  30. data/lib/smith/workflow/guarded_step_execution.rb +8 -8
  31. data/lib/smith/workflow/nested_execution.rb +3 -3
  32. data/lib/smith/workflow/remembered_calls.rb +14 -2
  33. data/lib/smith/workflow/retry_execution.rb +24 -3
  34. data/lib/smith/workflow/retry_wait.rb +2 -0
  35. data/lib/smith/workflow/split_step_persistence/preparation.rb +1 -3
  36. data/lib/smith/workflow/split_step_persistence/unsupported_steps.rb +29 -0
  37. data/lib/smith/workflow/split_step_persistence.rb +2 -0
  38. data/lib/smith/workflow.rb +11 -0
  39. 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 completed_tool_calls?
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
- def completed_tool_calls?
48
- Tool.current_tool_execution_tracker&.started?
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
- BudgetUnavailable.new(error.message, continuation:) if continuation && storable_pause?(continuation)
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
@@ -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). A call whose
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
- def to_h = @capabilities
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 @capture_result unless block
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
- @capture_result_strict == true
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 { @started = true }
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
- attr_reader :compatible_with_spec
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.12.7"
4
+ VERSION = "0.13.1"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
@@ -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 error.is_a?(BudgetUnavailable) && error.continuation
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: normalize_identifier(values.fetch(:from), state_normalizer, "from state"),
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
- private_constant :MODULE_MATCH, :STRING_BYTESIZE, :STRING_ENCODING, :STRING_INITIALIZE_COPY,
22
- :STRING_VALID_ENCODING, :SYMBOL_TO_S
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
- def validate_encoding!(text)
48
- valid = STRING_VALID_ENCODING.bind_call(text) && STRING_ENCODING.bind_call(text) == Encoding::UTF_8
49
- return if valid
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
- raise PersistedFailureInvalid, "persisted workflow failure #{label} is invalid"
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 owned_string if string?
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
- def owned_string
75
- String.allocate.tap { STRING_INITIALIZE_COPY.bind_call(_1, value) }
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
- previous_tracker&.mark_started! if tracker.started?
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 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.
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.started? && !terminal_retry_error?(error) &&
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 ran in the child, the
20
- # parent's step already carries that evidence and turns the failure
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
- Tool.current_tool_execution_tracker&.mark_started! if ran.any?
57
- charge_kept_calls(ran_calls(messages, ran))
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
- @step_attempt = attempt
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
- refusal = "split-step persistence does not run steps that wait or launch runs"
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