smith-agents 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 988c5dc6e794da7b78889dc17db5801c9ad8ebf99a008a0b043bdc56e7115126
4
- data.tar.gz: ad4ecd254dd213b16e2b5e27730fed50fdd6491b86d45d5d76f40ba620a0bc33
3
+ metadata.gz: 450ce06c1d96f41a8b6ae6d0c0dd1b8dcc88b58d3da786d8536a26ecc8f111bb
4
+ data.tar.gz: 7b0696ff9aaccc3016ebef5223c4ded71aeeb883d1a1b70e6f2ab5959657e1da
5
5
  SHA512:
6
- metadata.gz: e439e1c6cc4cae2feffdb3a258e9abe94dd6f90a7641e76e36784921f09864d53d4d96616823ef2bfb43c56aa5cc9082eb689fffd4fc1464fa51e752f724139f
7
- data.tar.gz: 6d7bbf59a8b7c5297078af12b08a2b092287c5def28df0dac69cc10553e308abb238512e275166e372b08dcd155b6583e898d91b25b89ef4303ea5da09476eb4
6
+ metadata.gz: da3c5cb55ef7efac304f223eb7ce468f058b4d85c9c4b60ea102f50882789d067eaf9c2a212871eb66a215f7f1cce2baa29de945dd3c0f2c0cc084d187fec1ae
7
+ data.tar.gz: 910ec83bc644cdbb5ad1a737ef1aa95af3d5ca784d952fea9179ae4b7e44d49a1edf7a7ae2b067c7a83a227bb623d3d6c798cc45a8d254501ff397f99e876b69
data/CHANGELOG.md CHANGED
@@ -4,7 +4,44 @@ All notable changes to Smith are documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
6
6
 
7
- ## [Unreleased]
7
+ ## [0.7.0] - 2026-07-21
8
+
9
+ ### Added
10
+
11
+ - Add an opaque host invocation context for tool adapters, propagated through
12
+ Smith parallel/fan-out branches and concurrent tools on individual chats
13
+ created by `Smith::Agent` without globally changing RubyLLM behavior. Durable
14
+ workers receive it only when the host installs a fresh process-local context.
15
+ - Add opt-in strict tool-result capture with typed failure diagnostics and
16
+ persistence-safe reconstruction.
17
+
18
+ ### Changed
19
+
20
+ - Bump `Smith::EXECUTION_SEMANTICS_VERSION` to `3` because composite branch
21
+ failure transport now preserves strict tool-capture uncertainty and its
22
+ typed tool metadata across persistence.
23
+ - Support RubyLLM `>= 1.15, < 1.17`; Smith's per-chat concurrent tool context
24
+ integration is verified against those execution interfaces and fails closed
25
+ when the required chat hook is unavailable.
26
+
27
+ ### Fixed
28
+
29
+ - Make agent tool-call allowance atomic across concurrent tool calls.
30
+ - Install Smith's tool execution boundary on direct and persisted agent chat
31
+ entry points (`chat`, `create`, `create!`, and `find`) without modifying raw
32
+ RubyLLM chats globally, and capture invocation context independently for each
33
+ tool execution batch.
34
+ - Give strict capture uncertainty precedence over sibling concurrent failures,
35
+ reject unusable collectors before host hooks or tool execution, and prevent
36
+ transition retries from replaying an uncertain tool outcome.
37
+ - Preserve process-fatal failures across concurrent tool callbacks and root
38
+ workflow branches regardless of sibling completion order.
39
+ - Reject malformed or future strict-capture diagnostics at persistence
40
+ boundaries, while preserving legacy non-capture composite error transport.
41
+ - Preserve the existing per-concrete-class `capture_result` opt-in behavior and
42
+ the public `BranchEnv` member shape.
43
+
44
+ ## [0.6.1] - 2026-07-20
8
45
 
9
46
  ### Fixed
10
47
 
@@ -80,6 +80,67 @@ Captured tool results survive persistence — they are included in `to_state` an
80
80
 
81
81
  `tool_results` is designed for compact structured evidence (URLs, metadata, refs). Hosts should avoid storing large raw payloads there. If large tool outputs are needed, use artifacts and capture refs or metadata instead.
82
82
 
83
+ Capture is best-effort by default for backward compatibility. Hosts that cannot
84
+ complete a workflow checkpoint without captured evidence can opt into strict
85
+ capture:
86
+
87
+ ```ruby
88
+ class VerifiedSearch < Smith::Tool
89
+ capture_result(strict: true) do |kwargs, result|
90
+ { query: kwargs.fetch(:query), urls: extract_urls(result) }
91
+ end
92
+ end
93
+ ```
94
+
95
+ Strict capture raises `Smith::ToolCaptureFailed` when no workflow collector is
96
+ active, the collector is not callable, the capture block fails or returns `nil`, or the collector rejects the
97
+ entry by raising an exception. A collector return value of `nil` or `false` is
98
+ accepted; delivery is rejected only when the collector raises. Best-effort
99
+ capture logs and continues as before.
100
+
101
+ Capture policy is declared per concrete tool class and is not inherited. This
102
+ preserves the historical opt-in contract and prevents a subclass from beginning
103
+ to capture or fail strictly without its own declaration.
104
+
105
+ Strict capture validates that a collector exists before `perform`, but the
106
+ capture block and collector run after `perform` returns. It is therefore
107
+ post-result evidence, not a pre-effect transaction or durable operation receipt.
108
+ `Smith::ToolCaptureFailed` is terminal: direct retry declarations are rejected,
109
+ and broad explicit retry classes such as `StandardError` do not override that
110
+ classification, because the external outcome may already have occurred. Hosts
111
+ must give side-effecting tools their own idempotent operation receipt and
112
+ reconciliation boundary before retrying them. Read-only tools can use strict
113
+ capture to fail closed when their result cannot be checkpointed.
114
+
115
+ ### Host Invocation Context
116
+
117
+ Hosts can expose one opaque, execution-scoped value to tool adapters without
118
+ placing host identity or credentials in model arguments:
119
+
120
+ ```ruby
121
+ Smith::Tool.with_invocation_context(execution_context) do
122
+ workflow.advance
123
+ end
124
+
125
+ Smith::Tool.current_invocation_context
126
+ ```
127
+
128
+ Smith does not inspect, serialize, or persist this value. The scope restores the
129
+ previous value even when execution raises, and the value propagates through
130
+ Smith-managed same-agent and heterogeneous parallel branches, durable composite
131
+ branch workers, and concurrent tool calls on chats returned by the Smith agent
132
+ entry points `chat`, `create`, `create!`, and `find`. Invocation context is
133
+ captured independently for each concurrent tool batch, so reusing a chat cannot
134
+ retain a previous execution's scope. Smith scopes only those chat instances;
135
+ requiring Smith does not alter raw RubyLLM chats. Arbitrary chats, threads, or
136
+ fibers created by host code are outside this boundary and must install their own
137
+ scope. Hosts remain responsible for using an immutable value and installing a
138
+ fresh context after durable resume; context-dependent adapters must fail closed
139
+ when no context is installed. Smith supports RubyLLM `>= 1.15, < 1.17` for this
140
+ integration and fails closed if the required per-chat tool execution hook is
141
+ unavailable. RubyLLM fiber concurrency still requires its optional `async`
142
+ dependency; Smith does not make that scheduler a runtime dependency.
143
+
83
144
  You can still use RubyLLM agent tool wiring on your agents:
84
145
 
85
146
  ```ruby
@@ -137,4 +198,3 @@ class GuardedWorkflow < Smith::Workflow
137
198
  end
138
199
  end
139
200
  ```
140
-
data/lib/smith/agent.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "ruby_llm"
4
4
 
5
+ require_relative "tool"
6
+
5
7
  module Smith
6
8
  class Agent < RubyLLM::Agent
7
9
  EXECUTION_IDENTITY_UNSET = Object.new.freeze
@@ -195,11 +197,23 @@ module Smith
195
197
  kwargs = inject_reserved_inputs(kwargs, profile)
196
198
  kwargs = nil_fill_declared_inputs(kwargs)
197
199
 
198
- llm_chat = super
200
+ llm_chat = install_tool_execution_context(super)
199
201
  Smith::Models::Normalizer.apply!(llm_chat, profile: profile) if profile
200
202
  llm_chat
201
203
  end
202
204
 
205
+ def create(**kwargs)
206
+ install_tool_execution_context(super)
207
+ end
208
+
209
+ def create!(**kwargs)
210
+ install_tool_execution_context(super)
211
+ end
212
+
213
+ def find(id, **kwargs)
214
+ install_tool_execution_context(super)
215
+ end
216
+
203
217
  # Normalizes the |ctx| DSL across RubyLLM's block-form attribute setters.
204
218
  #
205
219
  # RubyLLM evaluates these blocks via `runtime.instance_exec(&block)`,
@@ -255,6 +269,14 @@ module Smith
255
269
 
256
270
  private
257
271
 
272
+ def install_tool_execution_context(chat_object)
273
+ return unless chat_object
274
+
275
+ llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
276
+ Tool::ChatExecutionContext.install(llm_chat)
277
+ chat_object
278
+ end
279
+
258
280
  def resolve_profile(model_id)
259
281
  return nil unless model_id
260
282
  return nil unless defined?(Smith::Models)
@@ -10,23 +10,21 @@ module Smith
10
10
  ledger = self.class.current_ledger
11
11
  workflow_active = ledger&.limits&.key?(:tool_calls)
12
12
 
13
- check_agent_tool_calls!(allowance)
14
- commit_tool_call_charges!(ledger, allowance, workflow_active)
15
- end
16
-
17
- def check_agent_tool_calls!(allowance)
18
- return unless allowance
13
+ if allowance.is_a?(CallAllowance)
14
+ return allowance.charge! { commit_workflow_tool_call!(ledger, workflow_active) }
15
+ end
16
+ if allowance.is_a?(Hash)
17
+ return CallAllowance.charge_legacy!(allowance) { commit_workflow_tool_call!(ledger, workflow_active) }
18
+ end
19
19
 
20
- raise BudgetExceeded, "agent tool_calls budget exceeded" if allowance[:remaining] <= 0
20
+ commit_workflow_tool_call!(ledger, workflow_active)
21
21
  end
22
22
 
23
- def commit_tool_call_charges!(ledger, allowance, workflow_active)
24
- if workflow_active
25
- reservation = ledger.reserve!(:tool_calls, 1)
26
- ledger.reconcile!(reservation, 1)
27
- end
23
+ def commit_workflow_tool_call!(ledger, workflow_active)
24
+ return unless workflow_active
28
25
 
29
- allowance[:remaining] -= 1 if allowance
26
+ reservation = ledger.reserve!(:tool_calls, 1)
27
+ ledger.reconcile!(reservation, 1)
30
28
  end
31
29
  end
32
30
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class CallAllowance
6
+ LEGACY_MUTEX = Mutex.new
7
+ LEGACY_ALLOWANCE_MUTEXES = ObjectSpace::WeakMap.new
8
+ private_constant :LEGACY_MUTEX
9
+ private_constant :LEGACY_ALLOWANCE_MUTEXES
10
+
11
+ def self.charge_legacy!(allowance)
12
+ legacy_allowance_mutex(allowance).synchronize do
13
+ Thread.handle_interrupt(Object => :never) do
14
+ remaining = allowance[:remaining]
15
+ unless remaining.is_a?(Integer) && remaining.positive?
16
+ raise BudgetExceeded, "agent tool_calls budget exceeded"
17
+ end
18
+
19
+ yield if block_given?
20
+ allowance[:remaining] = remaining - 1
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.legacy_allowance_mutex(allowance)
26
+ LEGACY_MUTEX.synchronize do
27
+ LEGACY_ALLOWANCE_MUTEXES[allowance] ||= Mutex.new
28
+ end
29
+ end
30
+ private_class_method :legacy_allowance_mutex
31
+
32
+ def initialize(remaining)
33
+ unless remaining.is_a?(Integer) && remaining >= 0
34
+ raise ArgumentError, "tool call allowance must be a non-negative integer"
35
+ end
36
+
37
+ @remaining = remaining
38
+ @mutex = Mutex.new
39
+ end
40
+
41
+ def charge!
42
+ @mutex.synchronize do
43
+ Thread.handle_interrupt(Object => :never) do
44
+ raise BudgetExceeded, "agent tool_calls budget exceeded" unless @remaining.positive?
45
+
46
+ yield if block_given?
47
+ @remaining -= 1
48
+ end
49
+ end
50
+ end
51
+
52
+ def remaining
53
+ @mutex.synchronize { @remaining }
54
+ end
55
+
56
+ def [](key)
57
+ remaining if key == :remaining
58
+ end
59
+ end
60
+ end
61
+ end
@@ -3,19 +3,75 @@
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
5
  module Capture
6
+ CAPTURE_FAILED = Object.new.freeze
7
+ private_constant :CAPTURE_FAILED
8
+
6
9
  private
7
10
 
11
+ def ensure_capture_ready!
12
+ return unless self.class.capture_result && self.class.capture_result_strict?
13
+
14
+ capture_collector(true)
15
+ end
16
+
8
17
  def capture_result_if_configured(kwargs, result)
9
18
  block = self.class.capture_result
10
19
  return unless block
11
20
 
12
- collector = self.class.current_tool_result_collector
21
+ capture_with_policy(block, kwargs, result, self.class.capture_result_strict?)
22
+ end
23
+
24
+ def capture_with_policy(block, kwargs, result, strict)
25
+ collector = capture_collector(strict)
13
26
  return unless collector
14
27
 
15
- captured = block.call(kwargs, result)
16
- collector.call({ tool: name.to_s, captured: captured }) if captured
28
+ captured = captured_value(block, kwargs, result, strict)
29
+ return if captured.equal?(CAPTURE_FAILED)
30
+
31
+ append_capture(collector, captured, strict)
32
+ end
33
+
34
+ def capture_collector(strict)
35
+ collector = self.class.current_tool_result_collector
36
+ return collector unless strict
37
+ return collector if collector.respond_to?(:call)
38
+
39
+ reason = collector.nil? ? :collector_missing : :collector_invalid
40
+ raise tool_capture_failed(reason)
41
+ end
42
+
43
+ def captured_value(block, kwargs, result, strict)
44
+ captured = call_capture_block(block, kwargs, result, strict)
45
+ return captured if captured.equal?(CAPTURE_FAILED)
46
+ return captured unless captured.nil? && strict
47
+
48
+ raise tool_capture_failed(:capture_empty)
49
+ end
50
+
51
+ def call_capture_block(block, kwargs, result, strict)
52
+ block.call(kwargs, result)
17
53
  rescue StandardError => e
18
- Smith.config.logger&.warn("[Smith] capture_result failed for #{name}: #{e.message}")
54
+ capture_failure(e, strict, reason: :capture_block_failed)
55
+ end
56
+
57
+ def append_capture(collector, captured, strict)
58
+ collector.call({ tool: name.to_s, captured: captured }) if strict || captured
59
+ rescue StandardError => e
60
+ capture_failure(e, strict, reason: :collector_failed)
61
+ end
62
+
63
+ def capture_failure(error, strict, reason:)
64
+ return CAPTURE_FAILED.tap { log_capture_failure(error) } unless strict
65
+
66
+ raise tool_capture_failed(reason), cause: error
67
+ end
68
+
69
+ def tool_capture_failed(reason)
70
+ ToolCaptureFailed.for_runtime(tool_name: name, reason:)
71
+ end
72
+
73
+ def log_capture_failure(error)
74
+ Smith.config.logger&.warn("[Smith] capture_result failed for #{name}: #{error.message}")
19
75
  end
20
76
  end
21
77
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module CaptureConfiguration
6
+ def capture_result(strict: false, &block)
7
+ return @capture_result unless block
8
+
9
+ raise ArgumentError, "capture_result strict must be true or false" unless [true, false].include?(strict)
10
+
11
+ @capture_result_strict = strict
12
+ @capture_result = block
13
+ end
14
+
15
+ def capture_result_strict?
16
+ @capture_result_strict == true
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ChatExecutionContext
6
+ def self.install(chat)
7
+ return chat unless chat.respond_to?(:tools) && chat.tools.respond_to?(:values)
8
+ unless chat.respond_to?(:execute_tool, true)
9
+ raise Error, "unsupported RubyLLM chat execution interface: missing #execute_tool"
10
+ end
11
+
12
+ chat.extend(self) unless chat.singleton_class < self
13
+ chat.__send__(:install_smith_tool_execution_context)
14
+ chat
15
+ end
16
+
17
+ private
18
+
19
+ def install_smith_tool_execution_context
20
+ return if defined?(@smith_tool_execution_batches_mutex)
21
+
22
+ @smith_tool_execution_batches = {}.compare_by_identity
23
+ @smith_tool_execution_batches_mutex = Mutex.new
24
+ end
25
+
26
+ def execute_tool(tool_call)
27
+ batch = execution_batch(tool_call)
28
+ return super unless batch
29
+
30
+ Tool::ScopedContext.around(batch.fetch(:context)) { super }
31
+ rescue Exception => e # rubocop:disable Lint/RescueException
32
+ record_batch_failure(batch, e) if batch
33
+ raise
34
+ end
35
+
36
+ def execute_tools_concurrently(tool_calls, ...)
37
+ batch = execution_batch_context
38
+ register_execution_batch(tool_calls, batch)
39
+ super
40
+ rescue Exception => e # rubocop:disable Lint/RescueException
41
+ raise e unless e.is_a?(StandardError)
42
+
43
+ fatal_failure = first_failure(batch&.fetch(:fatal_failures))
44
+ raise fatal_failure if fatal_failure
45
+
46
+ capture_failure = first_failure(batch&.fetch(:capture_failures))
47
+ raise capture_failure if capture_failure
48
+
49
+ raise
50
+ ensure
51
+ unregister_execution_batch(tool_calls, batch)
52
+ end
53
+
54
+ def execution_batch_context
55
+ {
56
+ context: Tool::ScopedContext.capture,
57
+ capture_failures: Queue.new,
58
+ fatal_failures: Queue.new
59
+ }.freeze
60
+ end
61
+
62
+ def register_execution_batch(tool_calls, batch)
63
+ @smith_tool_execution_batches_mutex.synchronize do
64
+ tool_calls.each_value do |tool_call|
65
+ raise Error, "tool call is already active on this chat" if @smith_tool_execution_batches.key?(tool_call)
66
+
67
+ @smith_tool_execution_batches[tool_call] = batch
68
+ end
69
+ end
70
+ end
71
+
72
+ def unregister_execution_batch(tool_calls, batch)
73
+ return unless tool_calls && batch
74
+
75
+ @smith_tool_execution_batches_mutex.synchronize do
76
+ tool_calls.each_value do |tool_call|
77
+ @smith_tool_execution_batches.delete(tool_call) if @smith_tool_execution_batches[tool_call].equal?(batch)
78
+ end
79
+ end
80
+ end
81
+
82
+ def execution_batch(tool_call)
83
+ @smith_tool_execution_batches_mutex.synchronize do
84
+ @smith_tool_execution_batches[tool_call]
85
+ end
86
+ end
87
+
88
+ def record_batch_failure(batch, error)
89
+ if error.is_a?(ToolCaptureFailed)
90
+ batch.fetch(:capture_failures).push(error)
91
+ elsif !error.is_a?(StandardError)
92
+ batch.fetch(:fatal_failures).push(error)
93
+ end
94
+ end
95
+
96
+ def first_failure(queue)
97
+ return unless queue
98
+
99
+ queue.pop(true)
100
+ rescue ThreadError
101
+ nil
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ScopedContext
6
+ CONTEXT_KEYS = {
7
+ current_guardrails: :smith_tool_guardrails,
8
+ current_deadline: :smith_tool_deadline,
9
+ current_ledger: :smith_tool_ledger,
10
+ current_tool_call_allowance: :smith_tool_call_allowance,
11
+ current_tool_result_collector: :smith_tool_result_collector,
12
+ current_invocation_context: :smith_tool_invocation_context
13
+ }.freeze
14
+
15
+ CONTEXT_KEYS.each do |reader, key|
16
+ define_method(reader) { Thread.current[key] }
17
+ define_method("#{reader}=") { |value| Thread.current[key] = value }
18
+ end
19
+
20
+ def self.capture
21
+ CONTEXT_KEYS.to_h { |reader, key| [reader, Thread.current[key]] }.freeze
22
+ end
23
+
24
+ def self.around(values, &block)
25
+ raise ArgumentError, "block required" unless block
26
+
27
+ validate!(values)
28
+ previous = capture
29
+ Thread.handle_interrupt(Object => :never) do
30
+ install(values)
31
+ begin
32
+ Thread.handle_interrupt(Object => :immediate, &block)
33
+ ensure
34
+ install(previous)
35
+ end
36
+ end
37
+ end
38
+
39
+ def with_invocation_context(value, &block)
40
+ raise ArgumentError, "block required" unless block
41
+
42
+ previous = current_invocation_context
43
+ Thread.handle_interrupt(Object => :never) do
44
+ self.current_invocation_context = value
45
+ begin
46
+ Thread.handle_interrupt(Object => :immediate, &block)
47
+ ensure
48
+ self.current_invocation_context = previous
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.install(values)
54
+ CONTEXT_KEYS.each do |reader, key|
55
+ Thread.current[key] = values.fetch(reader)
56
+ end
57
+ end
58
+ private_class_method :install
59
+
60
+ def self.validate!(values)
61
+ complete = values.is_a?(Hash) && values.length == CONTEXT_KEYS.length && CONTEXT_KEYS.each_key.all? do |key|
62
+ values.key?(key)
63
+ end
64
+ return if complete
65
+
66
+ raise ArgumentError, "tool context must contain the complete scoped context"
67
+ end
68
+ private_class_method :validate!
69
+ end
70
+ end
71
+ end
data/lib/smith/tool.rb CHANGED
@@ -4,15 +4,22 @@ require "ruby_llm"
4
4
 
5
5
  require_relative "tool/capability_builder"
6
6
  require_relative "tool/policy"
7
+ require_relative "tool/call_allowance"
7
8
  require_relative "tool/budget_enforcement"
9
+ require_relative "tool_capture_failed"
8
10
  require_relative "tool/capture"
11
+ require_relative "tool/capture_configuration"
9
12
  require_relative "tool/compatibility"
13
+ require_relative "tool/scoped_context"
14
+ require_relative "tool/chat_execution_context"
10
15
 
11
16
  module Smith
12
17
  class Tool < RubyLLM::Tool
13
18
  include Policy
14
19
  include BudgetEnforcement
15
20
  include Capture
21
+ extend CaptureConfiguration
22
+ extend ScopedContext
16
23
 
17
24
  class << self
18
25
  # Tool subclasses inherit the parent's compatible_with spec by
@@ -40,46 +47,6 @@ module Smith
40
47
 
41
48
  attr_reader :compatible_with_spec
42
49
 
43
- def current_guardrails
44
- Thread.current[:smith_tool_guardrails]
45
- end
46
-
47
- def current_guardrails=(value)
48
- Thread.current[:smith_tool_guardrails] = value
49
- end
50
-
51
- def current_deadline
52
- Thread.current[:smith_tool_deadline]
53
- end
54
-
55
- def current_deadline=(value)
56
- Thread.current[:smith_tool_deadline] = value
57
- end
58
-
59
- def current_ledger
60
- Thread.current[:smith_tool_ledger]
61
- end
62
-
63
- def current_ledger=(value)
64
- Thread.current[:smith_tool_ledger] = value
65
- end
66
-
67
- def current_tool_call_allowance
68
- Thread.current[:smith_tool_call_allowance]
69
- end
70
-
71
- def current_tool_call_allowance=(value)
72
- Thread.current[:smith_tool_call_allowance] = value
73
- end
74
-
75
- def current_tool_result_collector
76
- Thread.current[:smith_tool_result_collector]
77
- end
78
-
79
- def current_tool_result_collector=(value)
80
- Thread.current[:smith_tool_result_collector] = value
81
- end
82
-
83
50
  def category(value = nil)
84
51
  return @category if value.nil?
85
52
 
@@ -105,15 +72,10 @@ module Smith
105
72
 
106
73
  @before_execute = block
107
74
  end
108
-
109
- def capture_result(&block)
110
- return @capture_result unless block_given?
111
-
112
- @capture_result = block
113
- end
114
75
  end
115
76
 
116
77
  def execute(**kwargs)
78
+ ensure_capture_ready!
117
79
  run_before_execute_hook!(kwargs)
118
80
  check_tool_deadline!
119
81
  check_privilege!(kwargs)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "error"
6
+
7
+ module Smith
8
+ class ToolCaptureFailed < Error
9
+ REASONS = %i[
10
+ collector_missing collector_invalid capture_empty capture_block_failed collector_failed
11
+ ].freeze
12
+ REASONS_BY_NAME = REASONS.to_h { |reason| [reason.to_s.freeze, reason] }.freeze
13
+ DETAIL_NAMES = %i[tool_name reason].freeze
14
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
15
+ MAX_TOOL_NAME_BYTES = 256
16
+ private_constant :REASONS, :REASONS_BY_NAME, :DETAIL_NAMES, :DETAIL_KEYS, :MAX_TOOL_NAME_BYTES
17
+
18
+ attr_reader :tool_name, :reason
19
+
20
+ def initialize(tool_name:, reason:)
21
+ @tool_name = normalize_tool_name(tool_name)
22
+ @reason = normalize_reason(reason)
23
+ super("strict result capture failed for #{@tool_name}: #{@reason}")
24
+ end
25
+
26
+ def details
27
+ { tool_name:, reason: }.freeze
28
+ end
29
+
30
+ def self.from_details(details)
31
+ values = normalize_details(details)
32
+ new(tool_name: values.fetch(:tool_name), reason: values.fetch(:reason))
33
+ end
34
+
35
+ def self.for_runtime(tool_name:, reason:)
36
+ new(tool_name:, reason:)
37
+ rescue ArgumentError
38
+ new(tool_name: diagnostic_tool_name(tool_name), reason:)
39
+ end
40
+
41
+ def self.normalize_details(details)
42
+ raise ArgumentError, "tool capture failure details must be a Hash" unless details.is_a?(Hash)
43
+
44
+ values = {}
45
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
46
+ name = normalize_detail_name(key)
47
+ unless DETAIL_NAMES.include?(name)
48
+ raise ArgumentError, "tool capture failure details contain an unknown attribute"
49
+ end
50
+ raise ArgumentError, "tool capture failure details contain a duplicate attribute" if values.key?(name)
51
+
52
+ values[name] = value
53
+ end
54
+ missing = DETAIL_NAMES - values.keys
55
+ raise ArgumentError, "tool capture failure details are missing required attributes" if missing.any?
56
+
57
+ values
58
+ end
59
+
60
+ def self.normalize_detail_name(key)
61
+ return key if key.is_a?(Symbol)
62
+
63
+ DETAIL_KEYS[key] if key.is_a?(String)
64
+ end
65
+
66
+ def self.diagnostic_tool_name(value)
67
+ bytes = value.to_s.b
68
+ return "anonymous_tool" if bytes.empty?
69
+
70
+ "tool_#{Digest::SHA256.hexdigest(bytes)}"
71
+ end
72
+ private_class_method :normalize_details, :normalize_detail_name, :diagnostic_tool_name
73
+
74
+ private
75
+
76
+ def normalize_tool_name(value)
77
+ unless value.is_a?(String) || value.is_a?(Symbol)
78
+ raise ArgumentError, "tool capture failure tool name must be a String or Symbol"
79
+ end
80
+
81
+ name = value.to_s
82
+ raise ArgumentError, "tool capture failure tool name must be valid UTF-8" unless name.valid_encoding?
83
+
84
+ name = name.encode(Encoding::UTF_8)
85
+ raise ArgumentError, "tool capture failure tool name must be valid UTF-8" unless name.valid_encoding?
86
+
87
+ unless name.bytesize.between?(1, MAX_TOOL_NAME_BYTES)
88
+ raise ArgumentError, "tool capture failure tool name must be a bounded non-empty value"
89
+ end
90
+
91
+ name.dup.freeze
92
+ rescue EncodingError
93
+ raise ArgumentError, "tool capture failure tool name must be valid UTF-8"
94
+ end
95
+
96
+ def normalize_reason(value)
97
+ unless value.is_a?(String) || value.is_a?(Symbol)
98
+ raise ArgumentError, "tool capture failure reason must be a String or Symbol"
99
+ end
100
+
101
+ REASONS_BY_NAME.fetch(value.to_s) do
102
+ raise ArgumentError, "tool capture failure reason is not recognized"
103
+ end
104
+ end
105
+ end
106
+ end
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.6.1"
5
- EXECUTION_SEMANTICS_VERSION = "2"
4
+ VERSION = "0.7.0"
5
+ EXECUTION_SEMANTICS_VERSION = "3"
6
6
  end
@@ -12,21 +12,29 @@ module Smith
12
12
  "error_class" => :error_class,
13
13
  "error_family" => :error_family,
14
14
  "retryable" => :retryable,
15
- "kind" => :kind
15
+ "kind" => :kind,
16
+ "tool_name" => :tool_name,
17
+ "reason" => :reason
16
18
  }.freeze
17
- DETAIL_NAMES = DETAIL_KEYS.values.freeze
18
- private_constant :DETAIL_KEYS, :DETAIL_NAMES
19
+ REQUIRED_DETAIL_NAMES = %i[branch_key error_class error_family retryable kind].freeze
20
+ OPTIONAL_DETAIL_NAMES = %i[tool_name reason].freeze
21
+ DETAIL_NAMES = (REQUIRED_DETAIL_NAMES + OPTIONAL_DETAIL_NAMES).freeze
22
+ private_constant :DETAIL_KEYS, :REQUIRED_DETAIL_NAMES, :OPTIONAL_DETAIL_NAMES, :DETAIL_NAMES
19
23
 
20
- attr_reader :branch_key, :error_class, :error_family, :retryable, :kind, :details
24
+ attr_reader :branch_key, :error_class, :error_family, :retryable, :kind, :tool_name, :reason, :details
21
25
 
22
26
  def self.from_details(details)
23
27
  values = normalize_details(details)
24
- error = Error.new(
28
+ error_attributes = {
25
29
  class_name: values.fetch(:error_class),
26
30
  family: values.fetch(:error_family),
27
31
  retryable: values.fetch(:retryable),
28
32
  kind: values.fetch(:kind)
29
- )
33
+ }
34
+ OPTIONAL_DETAIL_NAMES.each do |name|
35
+ error_attributes[name] = values[name] if values.key?(name)
36
+ end
37
+ error = Error.new(error_attributes)
30
38
  new(branch_key: values.fetch(:branch_key), error:)
31
39
  end
32
40
 
@@ -42,7 +50,7 @@ module Smith
42
50
 
43
51
  normalized[name] = value
44
52
  end
45
- missing = DETAIL_NAMES - normalized.keys
53
+ missing = REQUIRED_DETAIL_NAMES - normalized.keys
46
54
  raise ArgumentError, "composite branch failure details are missing required attributes" if missing.any?
47
55
 
48
56
  normalized
@@ -60,10 +68,7 @@ module Smith
60
68
  def initialize(branch_key:, error:)
61
69
  validate_arguments!(branch_key, error)
62
70
  @branch_key = branch_key.dup.freeze
63
- @error_class = error.class_name.dup.freeze
64
- @error_family = error.family.dup.freeze
65
- @retryable = error.retryable
66
- @kind = error.kind&.dup&.freeze
71
+ copy_error_attributes(error)
67
72
  @details = failure_details
68
73
  super("composite branch #{branch_key.inspect} failed")
69
74
  end
@@ -76,14 +81,30 @@ module Smith
76
81
  raise ArgumentError, "composite branch failure requires typed error evidence" unless error.is_a?(Error)
77
82
  end
78
83
 
84
+ def copy_error_attributes(error)
85
+ @error_class = owned_string(error.class_name)
86
+ @error_family = owned_string(error.family)
87
+ @retryable = error.retryable
88
+ @kind = owned_string(error.kind)
89
+ @tool_name = owned_string(error.tool_name)
90
+ @reason = owned_string(error.reason)
91
+ end
92
+
93
+ def owned_string(value)
94
+ value&.dup&.freeze
95
+ end
96
+
79
97
  def failure_details
80
- {
98
+ values = {
81
99
  branch_key: @branch_key,
82
100
  error_class: @error_class,
83
101
  error_family: @error_family,
84
102
  retryable: @retryable,
85
103
  kind: @kind
86
- }.freeze
104
+ }
105
+ values[:tool_name] = @tool_name if @tool_name
106
+ values[:reason] = @reason if @reason
107
+ values.freeze
87
108
  end
88
109
  end
89
110
  end
@@ -82,6 +82,12 @@ module Smith
82
82
  def serializable(values)
83
83
  values.merge(error: values[:error]&.to_h, effects: values.fetch(:effects).to_h)
84
84
  end
85
+
86
+ def legacy_serializable(values)
87
+ serializable(values).tap do |payload|
88
+ payload[:error] = payload[:error]&.except(:tool_name, :reason)
89
+ end
90
+ end
85
91
  end
86
92
 
87
93
  def initialize(attributes)
@@ -89,8 +95,7 @@ module Smith
89
95
  owned[:output] = self.class.send(:normalize_output, owned[:output])
90
96
  super(owned)
91
97
  validate_shape!
92
- expected = PayloadDigest.call(self.class.send(:serializable, to_h.except(:digest)))
93
- raise ArgumentError, "composite branch outcome digest does not match" unless digest == expected
98
+ validate_digest!
94
99
  end
95
100
 
96
101
  def succeeded? = status == :succeeded
@@ -98,6 +103,18 @@ module Smith
98
103
 
99
104
  private
100
105
 
106
+ def validate_digest!
107
+ values = to_h.except(:digest)
108
+ payloads = [self.class.send(:serializable, values)]
109
+ payloads << self.class.send(:legacy_serializable, values) if legacy_digest_allowed?
110
+ expected = payloads.map { PayloadDigest.call(_1) }
111
+ raise ArgumentError, "composite branch outcome digest does not match" unless expected.include?(digest)
112
+ end
113
+
114
+ def legacy_digest_allowed?
115
+ error.nil? || (error.tool_name.nil? && error.reason.nil?)
116
+ end
117
+
101
118
  def validate_shape!
102
119
  valid = succeeded? ? error.nil? : error && output.nil?
103
120
  raise ArgumentError, "composite branch outcome fields do not match status" unless valid
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../types"
4
+ require_relative "../../tool_capture_failed"
4
5
  require_relative "payload"
5
6
 
6
7
  module Smith
@@ -8,12 +9,15 @@ module Smith
8
9
  module Composite
9
10
  class Error < Payload
10
11
  FAMILIES = %w[
11
- tool_guardrail_failed deterministic_step_failure deadline_exceeded
12
- agent_error workflow_error other
12
+ tool_capture_failed tool_guardrail_failed deterministic_step_failure
13
+ deadline_exceeded agent_error workflow_error other
13
14
  ].freeze
14
15
  ALWAYS_RETRYABLE_FAMILIES = %w[deadline_exceeded agent_error].freeze
15
16
  CONDITIONALLY_RETRYABLE_FAMILIES = %w[tool_guardrail_failed deterministic_step_failure].freeze
17
+ OPTIONAL_METADATA = %i[tool_name reason].freeze
18
+ STRING_METADATA = %i[kind tool_name reason].freeze
16
19
  private_constant :ALWAYS_RETRYABLE_FAMILIES, :CONDITIONALLY_RETRYABLE_FAMILIES
20
+ private_constant :OPTIONAL_METADATA, :STRING_METADATA
17
21
  OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
18
22
  private_constant :OwnedString
19
23
 
@@ -21,17 +25,43 @@ module Smith
21
25
  attribute :family, OwnedString.enum(*FAMILIES)
22
26
  attribute :retryable, Types::Bool
23
27
  attribute? :kind, OwnedString.constrained(min_size: 1, max_size: 128).optional
28
+ attribute? :tool_name, OwnedString.constrained(min_size: 1, max_size: 256).optional
29
+ attribute? :reason, OwnedString.constrained(min_size: 1, max_size: 128).optional
30
+
31
+ class << self
32
+ def normalize_attributes(attributes)
33
+ return super unless attributes.is_a?(Hash)
34
+
35
+ super(attributes_with_optional_metadata(attributes))
36
+ end
37
+
38
+ private
39
+
40
+ def attributes_with_optional_metadata(attributes)
41
+ values = {}
42
+ Hash.instance_method(:each_pair).bind_call(attributes) { |key, value| values[key] = value }
43
+ OPTIONAL_METADATA.each do |name|
44
+ values[name] = nil unless values.key?(name) || values.key?(name.to_s)
45
+ end
46
+ values
47
+ end
48
+ end
24
49
 
25
50
  def initialize(attributes)
26
- owned = self.class.normalize_attributes(attributes)
27
- owned[:kind] = owned[:kind].to_s if owned[:kind]
28
- super(owned)
51
+ super(normalize_error_attributes(attributes))
29
52
  validate_retryability!
30
53
  validate_kind!
54
+ validate_tool_capture_metadata!
31
55
  end
32
56
 
33
57
  private
34
58
 
59
+ def normalize_error_attributes(attributes)
60
+ self.class.normalize_attributes(attributes).tap do |normalized|
61
+ STRING_METADATA.each { |name| normalized[name] = normalized[name].to_s if normalized[name] }
62
+ end
63
+ end
64
+
35
65
  def validate_retryability!
36
66
  valid = if ALWAYS_RETRYABLE_FAMILIES.include?(family)
37
67
  retryable
@@ -48,6 +78,24 @@ module Smith
48
78
 
49
79
  raise ArgumentError, "composite error kind is not valid for its family"
50
80
  end
81
+
82
+ def validate_tool_capture_metadata!
83
+ valid = if family == "tool_capture_failed"
84
+ valid_tool_capture_metadata?
85
+ else
86
+ tool_name.nil? && reason.nil?
87
+ end
88
+ return if valid
89
+
90
+ raise ArgumentError, "composite error tool metadata does not match its family"
91
+ end
92
+
93
+ def valid_tool_capture_metadata?
94
+ ToolCaptureFailed.from_details(tool_name:, reason:)
95
+ true
96
+ rescue ArgumentError
97
+ false
98
+ end
51
99
  end
52
100
  end
53
101
  end
@@ -3,6 +3,7 @@
3
3
  require "dry-initializer"
4
4
 
5
5
  require_relative "../../errors"
6
+ require_relative "../../tool_capture_failed"
6
7
  require_relative "error"
7
8
 
8
9
  module Smith
@@ -16,12 +17,14 @@ module Smith
16
17
  def self.call(error) = new(error).call
17
18
 
18
19
  def call
19
- Error.new(
20
+ attributes = {
20
21
  class_name: error_class_name,
21
22
  family: error_family,
22
23
  retryable: retryable_error?,
23
24
  kind: error_kind
24
- )
25
+ }
26
+ attributes.merge!(tool_capture_metadata) if error.is_a?(ToolCaptureFailed)
27
+ Error.new(attributes)
25
28
  end
26
29
 
27
30
  private
@@ -47,6 +50,7 @@ module Smith
47
50
 
48
51
  def error_family
49
52
  case error
53
+ when ToolCaptureFailed then "tool_capture_failed"
50
54
  when ToolGuardrailFailed then "tool_guardrail_failed"
51
55
  when DeterministicStepFailure then "deterministic_step_failure"
52
56
  when DeadlineExceeded then "deadline_exceeded"
@@ -55,6 +59,10 @@ module Smith
55
59
  else "other"
56
60
  end
57
61
  end
62
+
63
+ def tool_capture_metadata
64
+ { tool_name: error.tool_name, reason: error.reason.to_s }
65
+ end
58
66
  end
59
67
  end
60
68
  end
@@ -74,7 +74,7 @@ module Smith
74
74
 
75
75
  def apply_agent_tool_calls(agent_class)
76
76
  agent_tc = agent_class&.budget&.dig(:tool_calls)
77
- Tool.current_tool_call_allowance = agent_tc ? { remaining: agent_tc } : nil
77
+ Tool.current_tool_call_allowance = agent_tc ? Tool::CallAllowance.new(agent_tc) : nil
78
78
  end
79
79
 
80
80
  def clear_agent_tool_calls
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "branch_env"
4
-
5
3
  module Smith
6
4
  class Workflow
7
5
  module FanoutExecution
8
6
  private
9
7
 
8
+ def fanout_branch_environment(branches, branch_agent_classes, prepared_input)
9
+ BranchEnv.new(
10
+ prepared_input:,
11
+ guardrail_sources: nil,
12
+ scoped_store: propagate_scoped_artifacts,
13
+ branch_estimates: fanout_branch_estimates(branches, branch_agent_classes),
14
+ deadline: wall_clock_deadline
15
+ )
16
+ end
17
+
10
18
  def run_guarded_fanout_step(transition)
11
19
  branches = transition.fanout_config.fetch(:branches)
12
20
  branch_agent_classes = fanout_agent_classes(transition, branches)
@@ -26,13 +34,7 @@ module Smith
26
34
  def execute_fanout_step(transition, branches: nil, branch_agent_classes: nil, prepared_input: nil)
27
35
  branches ||= transition.fanout_config.fetch(:branches)
28
36
  branch_agent_classes ||= fanout_agent_classes(transition, branches)
29
- env = BranchEnv.new(
30
- prepared_input: prepared_input,
31
- guardrail_sources: nil,
32
- scoped_store: propagate_scoped_artifacts,
33
- branch_estimates: fanout_branch_estimates(branches, branch_agent_classes),
34
- deadline: wall_clock_deadline
35
- )
37
+ env = fanout_branch_environment(branches, branch_agent_classes, prepared_input)
36
38
 
37
39
  branch_calls = branches.map do |branch_key, agent_name|
38
40
  PreparedBranchExecution.instance_method(:prepared_branch).bind_call(
@@ -98,12 +100,11 @@ module Smith
98
100
  def fanout_branch_estimates(branches, branch_agent_classes)
99
101
  return {} unless @ledger
100
102
 
101
- branch_count = branches.length
102
103
  branches.each_with_object({}) do |(branch_key, _agent_name), map|
103
104
  agent_class = branch_agent_classes.fetch(branch_key)
104
105
  map[branch_key] = compute_branch_estimates(
105
106
  @ledger,
106
- branch_count: branch_count,
107
+ branch_count: branches.length,
107
108
  agent_budget: agent_class&.budget
108
109
  )
109
110
  end
@@ -12,7 +12,7 @@ module Smith
12
12
 
13
13
  def cancel!(error = nil)
14
14
  @mutex.synchronize do
15
- @reason ||= error
15
+ @reason = Parallel.preferred_error([@reason, error])
16
16
  @cancelled = true
17
17
  end
18
18
  end
@@ -73,7 +73,7 @@ module Smith
73
73
 
74
74
  def resolve(futures)
75
75
  fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
76
- raise(@signal.reason || Parallel.preferred_error(reasons)) unless fulfilled
76
+ raise(Parallel.preferred_error([@signal.reason, *reasons])) unless fulfilled
77
77
 
78
78
  values
79
79
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
4
+ require_relative "../tool_capture_failed"
3
5
  require_relative "parallel/cancellation"
4
6
  require_relative "parallel/cancellation_signal"
5
7
  require_relative "parallel/execution_context"
@@ -36,7 +38,10 @@ module Smith
36
38
 
37
39
  def self.preferred_error(reasons)
38
40
  errors = Array(reasons).compact
39
- errors.find { |error| !error.is_a?(Cancellation) } || errors.first
41
+ errors.find { |error| !error.is_a?(StandardError) } ||
42
+ errors.find { |error| error.is_a?(ToolCaptureFailed) } ||
43
+ errors.find { |error| !error.is_a?(Cancellation) } ||
44
+ errors.first
40
45
  end
41
46
  end
42
47
  end
@@ -6,13 +6,20 @@ module Smith
6
6
  private
7
7
 
8
8
  def prepared_branch(implementation, *arguments)
9
+ tool_context = Tool::ScopedContext.capture
9
10
  unless @split_step_active_execution_authorization
10
- return proc { |signal| __send__(implementation.name, *arguments, signal) }
11
+ return proc do |signal|
12
+ Tool::ScopedContext.around(tool_context) do
13
+ __send__(implementation.name, *arguments, signal)
14
+ end
15
+ end
11
16
  end
12
17
 
13
18
  proc do |signal|
14
19
  run = proc { implementation.bind_call(self, *arguments, signal) }
15
- PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
20
+ Tool::ScopedContext.around(tool_context) do
21
+ PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
22
+ end
16
23
  end
17
24
  end
18
25
 
@@ -24,6 +24,7 @@ module Smith
24
24
 
25
25
  def retry_transition_error?(config, error, attempt)
26
26
  return false if attempt >= config.fetch(:attempts)
27
+ return false if error.is_a?(ToolCaptureFailed)
27
28
 
28
29
  classes = config.fetch(:error_classes)
29
30
  if classes.any?
@@ -68,18 +68,27 @@ module Smith
68
68
  def composite_branch_environment(execution, input, agent_class, transition)
69
69
  branch = execution.branch
70
70
  budget = branch.budget.transform_keys(&:to_sym)
71
- branch_key = fetch_composite_fanout_branch(transition, branch.key).first unless execution.kind == :parallel
72
- estimates = execution.kind == :parallel ? budget : { branch_key => budget }
73
71
  BranchEnv.new(
74
72
  prepared_input: input.agent_messages,
75
73
  guardrail_sources: Tool.current_guardrails,
76
74
  scoped_store: propagate_scoped_artifacts,
77
- branch_estimates: estimates,
75
+ branch_estimates: composite_branch_estimates(execution, transition, budget),
78
76
  deadline: wall_clock_deadline,
79
- agent_class: execution.kind == :parallel ? agent_class : nil
77
+ agent_class: composite_parallel_agent(execution, agent_class)
80
78
  )
81
79
  end
82
80
 
81
+ def composite_branch_estimates(execution, transition, budget)
82
+ return budget if execution.kind == :parallel
83
+
84
+ branch_key = fetch_composite_fanout_branch(transition, execution.branch.key).first
85
+ { branch_key => budget }
86
+ end
87
+
88
+ def composite_parallel_agent(execution, agent_class)
89
+ agent_class if execution.kind == :parallel
90
+ end
91
+
83
92
  def composite_branch_ledger(branch)
84
93
  return if branch.budget.empty?
85
94
 
@@ -16,6 +16,7 @@ module Smith
16
16
  current_ledger
17
17
  current_tool_call_allowance
18
18
  current_tool_result_collector
19
+ current_invocation_context
19
20
  ].freeze
20
21
  THREAD_KEYS = %i[
21
22
  smith_call_deadline
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../tool_capture_failed"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  class Transition
@@ -334,6 +336,10 @@ module Smith
334
336
 
335
337
  raise WorkflowError, "retry_on error classes must inherit from StandardError"
336
338
  end
339
+ if error_classes.any? { |error_class| error_class <= ToolCaptureFailed }
340
+ message = "retry_on cannot retry Smith::ToolCaptureFailed because the tool outcome may be uncertain"
341
+ raise WorkflowError, message
342
+ end
337
343
 
338
344
  ExponentialBackoff.new(
339
345
  attempts:,
@@ -38,6 +38,9 @@ module Smith
38
38
  # expose `attr_reader :retryable` only, with no setter, so kwargs
39
39
  # MUST flow through `initialize`.
40
40
  KNOWN_RECONSTRUCTORS = {
41
+ "Smith::ToolCaptureFailed" => ->(s) {
42
+ Smith::ToolCaptureFailed.from_details(s.fetch(:error_details))
43
+ },
41
44
  "Smith::ToolGuardrailFailed" => ->(s) {
42
45
  Smith::ToolGuardrailFailed.new(s[:error_message], retryable: s[:error_retryable])
43
46
  },
@@ -227,6 +230,7 @@ module Smith
227
230
  # so a real DSF doesn't get classified as workflow_error.
228
231
  error_family = case err
229
232
  when Smith::DeterministicStepFailure then "deterministic_step_failure"
233
+ when Smith::ToolCaptureFailed then "tool_capture_failed"
230
234
  when Smith::ToolGuardrailFailed then "tool_guardrail_failed"
231
235
  when Smith::DeadlineExceeded then "deadline_exceeded"
232
236
  when Smith::AgentError then "agent_error"
@@ -378,6 +382,8 @@ module Smith
378
382
  )
379
383
  when "tool_guardrail_failed"
380
384
  Smith::ToolGuardrailFailed.new(snap[:error_message], retryable: snap[:error_retryable])
385
+ when "tool_capture_failed"
386
+ Smith::ToolCaptureFailed.from_details(snap.fetch(:error_details))
381
387
  when "deadline_exceeded" then Smith::DeadlineExceeded.new(snap[:error_message])
382
388
  when "agent_error" then Smith::AgentError.new(snap[:error_message])
383
389
  when "workflow_error" then Smith::WorkflowError.new(snap[:error_message])
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -117,16 +117,22 @@ dependencies:
117
117
  name: ruby_llm
118
118
  requirement: !ruby/object:Gem::Requirement
119
119
  requirements:
120
- - - "~>"
120
+ - - ">="
121
121
  - !ruby/object:Gem::Version
122
122
  version: '1.15'
123
+ - - "<"
124
+ - !ruby/object:Gem::Version
125
+ version: '1.17'
123
126
  type: :runtime
124
127
  prerelease: false
125
128
  version_requirements: !ruby/object:Gem::Requirement
126
129
  requirements:
127
- - - "~>"
130
+ - - ">="
128
131
  - !ruby/object:Gem::Version
129
132
  version: '1.15'
133
+ - - "<"
134
+ - !ruby/object:Gem::Version
135
+ version: '1.17'
130
136
  description: Smith is a workflow-first multi-agent orchestration library built on
131
137
  RubyLLM. It provides state machine modeling, typed contracts, budget enforcement,
132
138
  guardrails, and observability for agent workflows.
@@ -237,10 +243,15 @@ files:
237
243
  - lib/smith/tasks/doctor.rake
238
244
  - lib/smith/tool.rb
239
245
  - lib/smith/tool/budget_enforcement.rb
246
+ - lib/smith/tool/call_allowance.rb
240
247
  - lib/smith/tool/capability_builder.rb
241
248
  - lib/smith/tool/capture.rb
249
+ - lib/smith/tool/capture_configuration.rb
250
+ - lib/smith/tool/chat_execution_context.rb
242
251
  - lib/smith/tool/compatibility.rb
243
252
  - lib/smith/tool/policy.rb
253
+ - lib/smith/tool/scoped_context.rb
254
+ - lib/smith/tool_capture_failed.rb
244
255
  - lib/smith/tools.rb
245
256
  - lib/smith/tools/think.rb
246
257
  - lib/smith/tools/url_fetcher.rb