smith-agents 0.6.1 → 0.8.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 +264 -1
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +293 -2
- data/lib/smith/agent/chat_construction.rb +112 -0
- data/lib/smith/agent/completion.rb +46 -0
- data/lib/smith/agent/completion_usage_recording.rb +25 -0
- data/lib/smith/agent/dynamic_configuration.rb +69 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -0
- data/lib/smith/agent/invocation_preparation.rb +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -0
- data/lib/smith/agent/provider_failure_handling.rb +93 -0
- data/lib/smith/agent/provider_usage.rb +28 -0
- data/lib/smith/agent/reserved_input_bridge.rb +40 -0
- data/lib/smith/agent/usage_entry_recording.rb +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +31 -177
- data/lib/smith/context/observation_masking.rb +28 -4
- data/lib/smith/context/session.rb +7 -2
- data/lib/smith/diagnostic_text.rb +67 -0
- data/lib/smith/doctor/checks/configuration.rb +16 -3
- data/lib/smith/doctor/checks/models_registry.rb +75 -17
- data/lib/smith/errors.rb +34 -1
- data/lib/smith/models/ambiguous_profile_error.rb +9 -0
- data/lib/smith/models/collision_error.rb +9 -0
- data/lib/smith/models/inference.rb +14 -0
- data/lib/smith/models/normalizer.rb +11 -67
- data/lib/smith/models/provider_qualified_registry.rb +101 -0
- data/lib/smith/models/tool_routing.rb +140 -0
- data/lib/smith/models.rb +24 -78
- data/lib/smith/persisted_failure_invalid.rb +7 -0
- data/lib/smith/pricing.rb +138 -17
- data/lib/smith/pricing_configuration_error.rb +7 -0
- data/lib/smith/provider_permanent_failure.rb +16 -0
- data/lib/smith/tool/argument_container_reader.rb +97 -0
- data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
- data/lib/smith/tool/argument_snapshot.rb +19 -0
- data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
- data/lib/smith/tool/argument_snapshot_result.rb +15 -0
- data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
- data/lib/smith/tool/bounded_completion_context.rb +131 -0
- data/lib/smith/tool/bounded_completion_controls.rb +113 -0
- data/lib/smith/tool/bounded_completion_guard.rb +101 -0
- data/lib/smith/tool/bounded_completion_installation.rb +20 -0
- data/lib/smith/tool/bounded_completion_state.rb +57 -0
- data/lib/smith/tool/budget_enforcement.rb +17 -11
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +134 -0
- data/lib/smith/tool/call_allowance_counter.rb +43 -0
- data/lib/smith/tool/call_batch.rb +47 -0
- data/lib/smith/tool/call_budget.rb +51 -0
- data/lib/smith/tool/call_reservation.rb +42 -0
- data/lib/smith/tool/capture.rb +60 -4
- data/lib/smith/tool/capture_configuration.rb +20 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +24 -0
- data/lib/smith/tool/execution_authority.rb +42 -0
- data/lib/smith/tool/execution_authorization.rb +17 -0
- data/lib/smith/tool/execution_batch.rb +100 -0
- data/lib/smith/tool/execution_batch_admission.rb +56 -0
- data/lib/smith/tool/execution_batch_builder.rb +122 -0
- data/lib/smith/tool/execution_batch_collection.rb +70 -0
- data/lib/smith/tool/execution_batch_invocations.rb +28 -0
- data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
- data/lib/smith/tool/execution_batch_registry.rb +99 -0
- data/lib/smith/tool/execution_batch_requests.rb +59 -0
- data/lib/smith/tool/execution_batch_source_call.rb +36 -0
- data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
- data/lib/smith/tool/execution_batch_sources.rb +32 -0
- data/lib/smith/tool/execution_batch_state.rb +110 -0
- data/lib/smith/tool/execution_dispatch.rb +78 -0
- data/lib/smith/tool/execution_failure_handling.rb +101 -0
- data/lib/smith/tool/execution_lifecycle.rb +28 -0
- data/lib/smith/tool/execution_tracker.rb +20 -0
- data/lib/smith/tool/fail_fast_completion.rb +24 -0
- data/lib/smith/tool/graceful_completion.rb +20 -0
- data/lib/smith/tool/invocation.rb +27 -0
- data/lib/smith/tool/invocation_request.rb +37 -0
- data/lib/smith/tool/invocation_sequence.rb +26 -0
- data/lib/smith/tool/legacy_call_allowance.rb +57 -0
- data/lib/smith/tool/scoped_context.rb +123 -0
- data/lib/smith/tool.rb +78 -57
- data/lib/smith/tool_capture_failed.rb +106 -0
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/agent_result.rb +4 -3
- data/lib/smith/workflow/artifact_integration.rb +2 -1
- 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/effects.rb +3 -1
- 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 +20 -2
- data/lib/smith/workflow/execution.rb +2 -9
- data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
- data/lib/smith/workflow/failure_reconstructor.rb +79 -0
- data/lib/smith/workflow/failure_record.rb +139 -0
- data/lib/smith/workflow/failure_record_restore.rb +145 -0
- data/lib/smith/workflow/failure_record_text.rb +83 -0
- data/lib/smith/workflow/failure_record_validator.rb +113 -0
- data/lib/smith/workflow/fanout_execution.rb +12 -11
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- 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 +17 -1
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/prepared_branch_execution.rb +9 -2
- data/lib/smith/workflow/retry_execution.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +13 -4
- data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
- data/lib/smith/workflow/transition.rb +11 -0
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -124
- data/lib/smith.rb +3 -0
- metadata +83 -5
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class CallAllowance
|
|
6
|
+
EXHAUSTION_POLICIES = %i[raise complete].freeze
|
|
7
|
+
|
|
8
|
+
def self.charge_legacy!(allowance)
|
|
9
|
+
LegacyCallAllowance.charge!(allowance) { yield if block_given? }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(remaining, on_exhaustion: :raise)
|
|
13
|
+
invalid = (remaining.is_a?(Integer) && remaining.negative?) ||
|
|
14
|
+
(!remaining.is_a?(Integer) && !remaining.is_a?(CallBudget))
|
|
15
|
+
raise ArgumentError, "tool call allowance must be a non-negative integer" if invalid
|
|
16
|
+
|
|
17
|
+
initialize_scope(CallBudget.coerce(remaining), on_exhaustion:, parent: nil)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scope(budget, on_exhaustion: @on_exhaustion)
|
|
21
|
+
self.class.allocate.tap do |allowance|
|
|
22
|
+
allowance.__send__(:initialize_scope, CallBudget.coerce(budget), on_exhaustion:, parent: self)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def charge!(tool_name = nil)
|
|
27
|
+
batch = CallBatch.coerce(exact? ? [tool_name] : 1, exact: exact?)
|
|
28
|
+
|
|
29
|
+
synchronize do
|
|
30
|
+
Thread.handle_interrupt(Object => :never) do
|
|
31
|
+
raise BudgetExceeded, "agent tool_calls budget exceeded" unless reservable?(batch)
|
|
32
|
+
|
|
33
|
+
yield if block_given?
|
|
34
|
+
consume!(batch)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def remaining
|
|
40
|
+
synchronize { lineage.map { _1.__send__(:counter).remaining }.min }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remaining_for(tool_name)
|
|
44
|
+
name = canonical_tool_name(tool_name)
|
|
45
|
+
synchronize do
|
|
46
|
+
exact_scopes = lineage.select { _1.__send__(:budget).exact? }
|
|
47
|
+
return nil if exact_scopes.empty?
|
|
48
|
+
|
|
49
|
+
exact_scopes.map { _1.__send__(:counter).remaining_for(name) }.min
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reserve_batch(tool_names_or_size, ledger: nil)
|
|
54
|
+
batch = CallBatch.coerce(tool_names_or_size, exact: exact?)
|
|
55
|
+
|
|
56
|
+
synchronize do
|
|
57
|
+
Thread.handle_interrupt(Object => :never) do
|
|
58
|
+
return unless reservable?(batch)
|
|
59
|
+
|
|
60
|
+
workflow_ledger = ledger if ledger&.limits&.key?(:tool_calls)
|
|
61
|
+
ledger_reservation = workflow_ledger&.reserve!(:tool_calls, batch.size)
|
|
62
|
+
reservation = CallReservation.new(
|
|
63
|
+
limit: batch.size,
|
|
64
|
+
ledger: workflow_ledger,
|
|
65
|
+
ledger_reservation: ledger_reservation
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
consume!(batch)
|
|
69
|
+
reservation
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue BudgetExceeded
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def complete_on_exhaustion?
|
|
77
|
+
@on_exhaustion == :complete
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def exact?
|
|
81
|
+
lineage.any? { _1.__send__(:budget).exact? }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def used?
|
|
85
|
+
synchronize { counter.used? }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def [](key)
|
|
89
|
+
remaining if key == :remaining
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
protected
|
|
93
|
+
|
|
94
|
+
attr_reader :budget
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def initialize_scope(budget, on_exhaustion:, parent:)
|
|
99
|
+
unless EXHAUSTION_POLICIES.include?(on_exhaustion)
|
|
100
|
+
raise ArgumentError, "tool call exhaustion policy must be :raise or :complete"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@budget = budget
|
|
104
|
+
@parent = parent
|
|
105
|
+
@lineage = [self, *Array(parent&.__send__(:lineage))].freeze
|
|
106
|
+
@mutex = parent ? parent.__send__(:shared_mutex) : Mutex.new
|
|
107
|
+
@counter = CallAllowanceCounter.new(budget)
|
|
108
|
+
@on_exhaustion = on_exhaustion
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
attr_reader :lineage, :counter
|
|
113
|
+
|
|
114
|
+
def shared_mutex = @mutex
|
|
115
|
+
|
|
116
|
+
def synchronize(&) = @mutex.synchronize(&)
|
|
117
|
+
|
|
118
|
+
def canonical_tool_name(value)
|
|
119
|
+
name = value.to_s
|
|
120
|
+
raise ArgumentError, "tool name must not be empty" if name.empty?
|
|
121
|
+
|
|
122
|
+
name
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def reservable?(batch)
|
|
126
|
+
lineage.all? { _1.__send__(:counter).available?(batch) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def consume!(batch)
|
|
130
|
+
lineage.each { _1.__send__(:counter).consume!(batch) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class CallAllowanceCounter
|
|
6
|
+
def initialize(budget)
|
|
7
|
+
@budget = budget
|
|
8
|
+
@remaining = budget.total
|
|
9
|
+
@remaining_by_tool = budget.tool_limits&.dup
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :remaining
|
|
13
|
+
|
|
14
|
+
def remaining_for(tool_name)
|
|
15
|
+
@remaining_by_tool.fetch(tool_name, 0)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def used?
|
|
19
|
+
remaining < @budget.total
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def available?(batch)
|
|
23
|
+
remaining >= batch.size && tool_counts_available?(batch.counts)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def consume!(batch)
|
|
27
|
+
@remaining -= batch.size
|
|
28
|
+
return unless @budget.exact?
|
|
29
|
+
|
|
30
|
+
batch.counts.each { |name, count| @remaining_by_tool[name] -= count }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def tool_counts_available?(counts)
|
|
36
|
+
return true unless @budget.exact?
|
|
37
|
+
return false unless counts
|
|
38
|
+
|
|
39
|
+
counts.all? { |name, count| @remaining_by_tool.fetch(name, 0) >= count }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class CallBatch
|
|
6
|
+
attr_reader :size, :counts
|
|
7
|
+
|
|
8
|
+
def self.coerce(value, exact:)
|
|
9
|
+
return from_size(value, exact:) if value.is_a?(Integer)
|
|
10
|
+
raise ArgumentError, "tool call batch size must be a positive integer" unless value.is_a?(Array)
|
|
11
|
+
|
|
12
|
+
names = value.map { canonical_tool_name(_1) }
|
|
13
|
+
validate_size!(names.length)
|
|
14
|
+
new(size: names.length, counts: names.tally.freeze)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.from_size(size, exact:)
|
|
18
|
+
validate_size!(size)
|
|
19
|
+
raise ArgumentError, "exact tool call allowance requires tool names for batch reservation" if exact
|
|
20
|
+
|
|
21
|
+
new(size:, counts: nil)
|
|
22
|
+
end
|
|
23
|
+
private_class_method :from_size
|
|
24
|
+
|
|
25
|
+
def self.canonical_tool_name(value)
|
|
26
|
+
name = value.to_s
|
|
27
|
+
raise ArgumentError, "tool name must not be empty" if name.empty?
|
|
28
|
+
|
|
29
|
+
name
|
|
30
|
+
end
|
|
31
|
+
private_class_method :canonical_tool_name
|
|
32
|
+
|
|
33
|
+
def self.validate_size!(size)
|
|
34
|
+
return if size.is_a?(Integer) && size.positive?
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, "tool call batch size must be a positive integer"
|
|
37
|
+
end
|
|
38
|
+
private_class_method :validate_size!
|
|
39
|
+
|
|
40
|
+
def initialize(size:, counts:)
|
|
41
|
+
@size = size
|
|
42
|
+
@counts = counts
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
require_relative "../types"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Tool < RubyLLM::Tool
|
|
9
|
+
class CallBudget < Dry::Struct
|
|
10
|
+
OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
11
|
+
ToolName = OwnedString.constrained(min_size: 1)
|
|
12
|
+
Limit = Types::Integer.constrained(gteq: 0)
|
|
13
|
+
PositiveLimit = Types::Integer.constrained(gt: 0)
|
|
14
|
+
ToolLimits = Types::Hash.map(ToolName, PositiveLimit)
|
|
15
|
+
|
|
16
|
+
private_constant :OwnedString, :ToolName, :Limit, :PositiveLimit, :ToolLimits
|
|
17
|
+
|
|
18
|
+
attribute :total, Limit
|
|
19
|
+
attribute :tool_limits, ToolLimits.optional.default(nil)
|
|
20
|
+
|
|
21
|
+
def self.coerce(value)
|
|
22
|
+
return value if value.is_a?(self)
|
|
23
|
+
return new(total: value) if value.is_a?(Integer)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "tool call budget must be an integer or Smith::Tool::CallBudget"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(...)
|
|
29
|
+
super
|
|
30
|
+
validate_tool_limits!
|
|
31
|
+
tool_limits&.freeze
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exact? = !tool_limits.nil?
|
|
36
|
+
|
|
37
|
+
def limit_for(tool_name)
|
|
38
|
+
tool_limits&.fetch(tool_name.to_s, nil)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_tool_limits!
|
|
44
|
+
return unless tool_limits
|
|
45
|
+
return if total <= tool_limits.values.sum
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "aggregate tool call allowance exceeds the sum of its per-tool limits"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class CallReservation
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :limit
|
|
11
|
+
option :ledger
|
|
12
|
+
option :ledger_reservation
|
|
13
|
+
|
|
14
|
+
def initialize(...)
|
|
15
|
+
super
|
|
16
|
+
@claimed = 0
|
|
17
|
+
@settled = false
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def claim
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
return false if @settled || @claimed >= limit
|
|
24
|
+
|
|
25
|
+
@claimed += 1
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def settle!
|
|
31
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
return if @settled
|
|
34
|
+
|
|
35
|
+
ledger&.reconcile!(ledger_reservation, @claimed)
|
|
36
|
+
@settled = true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module ChatExecutionCallbacks
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def execute_tool_with_callbacks(tool_call)
|
|
9
|
+
batch = execution_batch(tool_call)
|
|
10
|
+
dispatch_call = batch&.dispatch_for(tool_call)
|
|
11
|
+
return super unless dispatch_call && batch.request_for(dispatch_call)
|
|
12
|
+
|
|
13
|
+
execute_managed_tool_callbacks(batch, dispatch_call) do
|
|
14
|
+
super(batch.source_for(dispatch_call))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execute_managed_tool_callbacks(batch, dispatch_call, &block)
|
|
19
|
+
claim = batch.claim_dispatch!(dispatch_call)
|
|
20
|
+
with_dispatch_claim(batch, claim) { complete_managed_callbacks(batch, dispatch_call, &block) }
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
handle_managed_callback_failure(batch, dispatch_call, claim, e)
|
|
23
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
24
|
+
record_batch_failure(batch, e)
|
|
25
|
+
raise
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_dispatch_claim(batch, claim, &)
|
|
29
|
+
context = batch.context.merge(current_tool_dispatch_claim: claim).freeze
|
|
30
|
+
Tool::ScopedContext.around(context, &)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def complete_managed_callbacks(batch, dispatch_call)
|
|
34
|
+
result = yield
|
|
35
|
+
notify_rejected_callback_dispatch(batch, dispatch_call) unless batch.started?(dispatch_call)
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_managed_callback_failure(batch, dispatch_call, claim, error)
|
|
40
|
+
record_batch_failure(batch, error)
|
|
41
|
+
raise error unless claim
|
|
42
|
+
raise error if error.is_a?(ToolFailureNotificationFailed)
|
|
43
|
+
|
|
44
|
+
failure = invocation_dispatch_failure(batch, dispatch_call, error)
|
|
45
|
+
notify_invocation_failure(batch, dispatch_call, failure)
|
|
46
|
+
raise failure
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def notify_rejected_callback_dispatch(batch, dispatch_call)
|
|
50
|
+
notify_invocation_failure(
|
|
51
|
+
batch,
|
|
52
|
+
dispatch_call,
|
|
53
|
+
ToolDispatchRejected.new("tool invocation arguments were rejected before execution")
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module ChatExecutionContext
|
|
6
|
+
include BoundedCompletionInstallation
|
|
7
|
+
include ExecutionFailureHandling
|
|
8
|
+
include ExecutionDispatch
|
|
9
|
+
include ChatExecutionCallbacks
|
|
10
|
+
include ExecutionBatchLifecycle
|
|
11
|
+
|
|
12
|
+
def self.install(chat)
|
|
13
|
+
return chat unless chat.respond_to?(:tools) && chat.tools.respond_to?(:values)
|
|
14
|
+
unless chat.respond_to?(:execute_tool, true)
|
|
15
|
+
raise Error, "unsupported RubyLLM chat execution interface: missing #execute_tool"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
chat.extend(self) unless chat.singleton_class < self
|
|
19
|
+
chat.__send__(:smith_tool_execution_batches)
|
|
20
|
+
chat
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class ExecutionAuthority
|
|
6
|
+
THREAD_KEY = :smith_tool_execution_authority
|
|
7
|
+
private_constant :THREAD_KEY
|
|
8
|
+
|
|
9
|
+
def self.current = Thread.current[THREAD_KEY]
|
|
10
|
+
|
|
11
|
+
def self.around(tool:, dispatch_claim:, &block)
|
|
12
|
+
raise ArgumentError, "block required" unless block
|
|
13
|
+
|
|
14
|
+
previous = current
|
|
15
|
+
Thread.handle_interrupt(Object => :never) do
|
|
16
|
+
Thread.current[THREAD_KEY] = new(tool:, dispatch_claim:)
|
|
17
|
+
begin
|
|
18
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
19
|
+
ensure
|
|
20
|
+
Thread.current[THREAD_KEY] = previous
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(tool:, dispatch_claim:)
|
|
26
|
+
@tool = tool
|
|
27
|
+
@dispatch_claim = dispatch_claim
|
|
28
|
+
@available = true
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def claim(tool, dispatch_claim)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
return false unless @available && @tool.equal?(tool) && @dispatch_claim.equal?(dispatch_claim)
|
|
35
|
+
|
|
36
|
+
@available = false
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module ExecutionAuthorization
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def authorize_tool_execution!
|
|
9
|
+
claim = self.class.__send__(:current_tool_dispatch_claim)
|
|
10
|
+
return unless claim
|
|
11
|
+
return if ExecutionAuthority.current&.claim(self, claim)
|
|
12
|
+
|
|
13
|
+
raise ToolExecutionNotAdmitted, "managed Smith tool execution requires its exact admitted dispatch authority"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class ExecutionBatch
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :context
|
|
11
|
+
option :tool_calls
|
|
12
|
+
option :source_calls
|
|
13
|
+
option :dispatch_calls
|
|
14
|
+
option :source_calls_by_dispatch
|
|
15
|
+
option :requests
|
|
16
|
+
option :targets
|
|
17
|
+
option :call_admissions, default: proc { {}.compare_by_identity.freeze }
|
|
18
|
+
option :call_reservation, optional: true
|
|
19
|
+
|
|
20
|
+
attr_reader :capture_failures, :fatal_failures, :notification_failures, :terminal_failures
|
|
21
|
+
|
|
22
|
+
def initialize(...)
|
|
23
|
+
super
|
|
24
|
+
@state = ExecutionBatchState.new(requests:)
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
initialize_failure_queues
|
|
27
|
+
@host_admitted = false
|
|
28
|
+
@host_admission_required = context.fetch(:current_invocation_batch_admitter) && requests.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request_for(tool_call) = requests[tool_call]
|
|
32
|
+
|
|
33
|
+
def target_for(tool_call) = targets[tool_call]
|
|
34
|
+
|
|
35
|
+
def admission_for(tool_call) = call_admissions[tool_call]
|
|
36
|
+
|
|
37
|
+
def dispatch_for(tool_call) = requests.key?(tool_call) ? tool_call : dispatch_calls[tool_call]
|
|
38
|
+
|
|
39
|
+
def source_for(tool_call) = source_calls_by_dispatch[tool_call]
|
|
40
|
+
|
|
41
|
+
def claim_dispatch!(tool_call) = @state.claim_dispatch!(tool_call)
|
|
42
|
+
|
|
43
|
+
def verify_dispatch!(tool_call, current_tool:, claim:)
|
|
44
|
+
request = requests.fetch(tool_call)
|
|
45
|
+
target = targets.fetch(tool_call)
|
|
46
|
+
return if dispatch_admitted? &&
|
|
47
|
+
@state.dispatch_claimed?(tool_call, claim) &&
|
|
48
|
+
target_unchanged?(current_tool, target, request) &&
|
|
49
|
+
call_unchanged?(tool_call, request)
|
|
50
|
+
|
|
51
|
+
raise ToolDispatchRejected, "admitted tool invocation changed before dispatch"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mark_started!(tool_call, claim:) = @state.mark_started!(tool_call, claim:)
|
|
55
|
+
|
|
56
|
+
def mark_executed!(tool_call, claim:) = @state.mark_executed!(tool_call, claim:)
|
|
57
|
+
|
|
58
|
+
def started?(tool_call) = @state.started?(tool_call)
|
|
59
|
+
|
|
60
|
+
def mark_host_admitted!
|
|
61
|
+
@mutex.synchronize { @host_admitted = true }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def host_admitted?
|
|
65
|
+
@mutex.synchronize { @host_admitted }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def claim_failure_request(tool_call) = @state.claim_failure_request(tool_call)
|
|
69
|
+
|
|
70
|
+
def claim_unsettled_request = @state.claim_unsettled_request
|
|
71
|
+
|
|
72
|
+
def complete_failure_notification!(tool_call) = @state.complete_failure_notification!(tool_call)
|
|
73
|
+
|
|
74
|
+
def release_failure_notification!(tool_call, state) = @state.release_failure_notification!(tool_call, state)
|
|
75
|
+
|
|
76
|
+
def settle! = call_reservation&.settle!
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def initialize_failure_queues
|
|
81
|
+
@capture_failures = Queue.new
|
|
82
|
+
@fatal_failures = Queue.new
|
|
83
|
+
@notification_failures = Queue.new
|
|
84
|
+
@terminal_failures = Queue.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dispatch_admitted? = !@host_admission_required || host_admitted?
|
|
88
|
+
|
|
89
|
+
def target_unchanged?(current_tool, target, request)
|
|
90
|
+
current_tool.equal?(target) && current_tool.instance_of?(request.tool_class)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def call_unchanged?(tool_call, request)
|
|
94
|
+
tool_call.id.equal?(request.invocation.tool_call_id) &&
|
|
95
|
+
tool_call.name.to_s == request.invocation.tool_name &&
|
|
96
|
+
tool_call.arguments.equal?(request.arguments)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|