smith-agents 0.7.0 → 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 +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +233 -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 +29 -197
- 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 +15 -7
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +101 -28
- 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/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +7 -88
- 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 +60 -8
- data/lib/smith/tool.rb +71 -12
- 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/effects.rb +3 -1
- 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/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- metadata +78 -11
|
@@ -3,59 +3,132 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Tool < RubyLLM::Tool
|
|
5
5
|
class CallAllowance
|
|
6
|
-
|
|
7
|
-
LEGACY_ALLOWANCE_MUTEXES = ObjectSpace::WeakMap.new
|
|
8
|
-
private_constant :LEGACY_MUTEX
|
|
9
|
-
private_constant :LEGACY_ALLOWANCE_MUTEXES
|
|
6
|
+
EXHAUSTION_POLICIES = %i[raise complete].freeze
|
|
10
7
|
|
|
11
8
|
def self.charge_legacy!(allowance)
|
|
12
|
-
|
|
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
|
|
13
30
|
Thread.handle_interrupt(Object => :never) do
|
|
14
|
-
|
|
15
|
-
unless remaining.is_a?(Integer) && remaining.positive?
|
|
16
|
-
raise BudgetExceeded, "agent tool_calls budget exceeded"
|
|
17
|
-
end
|
|
31
|
+
raise BudgetExceeded, "agent tool_calls budget exceeded" unless reservable?(batch)
|
|
18
32
|
|
|
19
33
|
yield if block_given?
|
|
20
|
-
|
|
34
|
+
consume!(batch)
|
|
21
35
|
end
|
|
22
36
|
end
|
|
23
37
|
end
|
|
24
38
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
LEGACY_ALLOWANCE_MUTEXES[allowance] ||= Mutex.new
|
|
28
|
-
end
|
|
39
|
+
def remaining
|
|
40
|
+
synchronize { lineage.map { _1.__send__(:counter).remaining }.min }
|
|
29
41
|
end
|
|
30
|
-
private_class_method :legacy_allowance_mutex
|
|
31
42
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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?
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
exact_scopes.map { _1.__send__(:counter).remaining_for(name) }.min
|
|
50
|
+
end
|
|
39
51
|
end
|
|
40
52
|
|
|
41
|
-
def
|
|
42
|
-
|
|
53
|
+
def reserve_batch(tool_names_or_size, ledger: nil)
|
|
54
|
+
batch = CallBatch.coerce(tool_names_or_size, exact: exact?)
|
|
55
|
+
|
|
56
|
+
synchronize do
|
|
43
57
|
Thread.handle_interrupt(Object => :never) do
|
|
44
|
-
|
|
58
|
+
return unless reservable?(batch)
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
48
70
|
end
|
|
49
71
|
end
|
|
72
|
+
rescue BudgetExceeded
|
|
73
|
+
nil
|
|
50
74
|
end
|
|
51
75
|
|
|
52
|
-
def
|
|
53
|
-
@
|
|
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? }
|
|
54
86
|
end
|
|
55
87
|
|
|
56
88
|
def [](key)
|
|
57
89
|
remaining if key == :remaining
|
|
58
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
|
|
59
132
|
end
|
|
60
133
|
end
|
|
61
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
|
|
@@ -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
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Tool < RubyLLM::Tool
|
|
5
5
|
module ChatExecutionContext
|
|
6
|
+
include BoundedCompletionInstallation
|
|
7
|
+
include ExecutionFailureHandling
|
|
8
|
+
include ExecutionDispatch
|
|
9
|
+
include ChatExecutionCallbacks
|
|
10
|
+
include ExecutionBatchLifecycle
|
|
11
|
+
|
|
6
12
|
def self.install(chat)
|
|
7
13
|
return chat unless chat.respond_to?(:tools) && chat.tools.respond_to?(:values)
|
|
8
14
|
unless chat.respond_to?(:execute_tool, true)
|
|
@@ -10,96 +16,9 @@ module Smith
|
|
|
10
16
|
end
|
|
11
17
|
|
|
12
18
|
chat.extend(self) unless chat.singleton_class < self
|
|
13
|
-
chat.__send__(:
|
|
19
|
+
chat.__send__(:smith_tool_execution_batches)
|
|
14
20
|
chat
|
|
15
21
|
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
22
|
end
|
|
104
23
|
end
|
|
105
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
|