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 +4 -4
- data/CHANGELOG.md +38 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +61 -1
- data/lib/smith/agent.rb +23 -1
- data/lib/smith/tool/budget_enforcement.rb +11 -13
- data/lib/smith/tool/call_allowance.rb +61 -0
- data/lib/smith/tool/capture.rb +60 -4
- data/lib/smith/tool/capture_configuration.rb +20 -0
- data/lib/smith/tool/chat_execution_context.rb +105 -0
- data/lib/smith/tool/scoped_context.rb +71 -0
- data/lib/smith/tool.rb +8 -46
- data/lib/smith/tool_capture_failed.rb +106 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/composite/branch_failure.rb +34 -13
- data/lib/smith/workflow/composite/branch_outcome.rb +19 -2
- data/lib/smith/workflow/composite/error.rb +53 -5
- data/lib/smith/workflow/composite/error_evidence.rb +10 -2
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/fanout_execution.rb +12 -11
- data/lib/smith/workflow/parallel/cancellation_signal.rb +1 -1
- data/lib/smith/workflow/parallel/root_execution.rb +1 -1
- data/lib/smith/workflow/parallel.rb +6 -1
- data/lib/smith/workflow/prepared_branch_execution.rb +9 -2
- data/lib/smith/workflow/retry_execution.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +13 -4
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/transition.rb +6 -0
- data/lib/smith/workflow.rb +6 -0
- metadata +14 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 450ce06c1d96f41a8b6ae6d0c0dd1b8dcc88b58d3da786d8536a26ecc8f111bb
|
|
4
|
+
data.tar.gz: 7b0696ff9aaccc3016ebef5223c4ded71aeeb883d1a1b70e6f2ab5959657e1da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
20
|
+
commit_workflow_tool_call!(ledger, workflow_active)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
|
|
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
|
-
|
|
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
|
data/lib/smith/tool/capture.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
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:
|
|
107
|
+
branch_count: branches.length,
|
|
107
108
|
agent_budget: agent_class&.budget
|
|
108
109
|
)
|
|
109
110
|
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
|
|
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?(
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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:
|
|
75
|
+
branch_estimates: composite_branch_estimates(execution, transition, budget),
|
|
78
76
|
deadline: wall_clock_deadline,
|
|
79
|
-
agent_class: execution
|
|
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
|
|
|
@@ -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:,
|
data/lib/smith/workflow.rb
CHANGED
|
@@ -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.
|
|
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
|