smith-agents 0.6.0 → 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 +47 -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/split_step_persistence/preparation_claim.rb +8 -0
- 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,53 @@ 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
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Persist a composite transition's execution namespace with its prepared
|
|
49
|
+
split-step snapshot so prepared- and dispatch-state recovery reproduce the
|
|
50
|
+
exact same plan.
|
|
51
|
+
- Pin composite namespace preparation to Smith-owned implementations so
|
|
52
|
+
subclass method collisions cannot bypass durable plan identity, and release
|
|
53
|
+
the preparation claim cleanly when namespace generation fails.
|
|
8
54
|
|
|
9
55
|
## [0.6.0] - 2026-07-20
|
|
10
56
|
|
|
@@ -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)
|