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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class ArgumentSnapshotTraversal
|
|
8
|
+
MAX_DEPTH = 100
|
|
9
|
+
MAX_NODES = 100_000
|
|
10
|
+
MAX_BYTES = 1 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
param :value
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
initialize_traversal
|
|
18
|
+
result = copy
|
|
19
|
+
ArgumentSnapshotResult.new(
|
|
20
|
+
value: result,
|
|
21
|
+
node_count: @accounting.node_count,
|
|
22
|
+
byte_count: @accounting.byte_count
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def initialize_traversal
|
|
29
|
+
@active = {}.compare_by_identity
|
|
30
|
+
@copies = {}.compare_by_identity
|
|
31
|
+
@metrics = {}.compare_by_identity
|
|
32
|
+
@accounting = ArgumentSnapshotAccounting.new(
|
|
33
|
+
max_depth: MAX_DEPTH,
|
|
34
|
+
max_nodes: MAX_NODES,
|
|
35
|
+
max_bytes: MAX_BYTES
|
|
36
|
+
)
|
|
37
|
+
@scalar_snapshot = ArgumentScalarSnapshot.new(
|
|
38
|
+
byte_counter: @accounting.method(:add_bytes!),
|
|
39
|
+
byte_validator: @accounting.method(:validate_bytes!)
|
|
40
|
+
)
|
|
41
|
+
@container_reader = ArgumentContainerReader.new(
|
|
42
|
+
scalar_snapshot: @scalar_snapshot,
|
|
43
|
+
byte_count: -> { @accounting.byte_count }
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def copy
|
|
48
|
+
result = nil
|
|
49
|
+
pending = [[:visit, value, nil, nil, 0, nil]]
|
|
50
|
+
|
|
51
|
+
until pending.empty?
|
|
52
|
+
entry = pending.pop
|
|
53
|
+
if entry.first == :finish
|
|
54
|
+
finish_container!(entry)
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
_, source, parent, key, depth, parent_metrics = entry
|
|
59
|
+
@accounting.visit!(depth)
|
|
60
|
+
type = @container_reader.type(source)
|
|
61
|
+
copied = if type
|
|
62
|
+
copy_container(source, pending, depth, type, parent_metrics)
|
|
63
|
+
else
|
|
64
|
+
copy_scalar(source, parent_metrics)
|
|
65
|
+
end
|
|
66
|
+
parent ? parent[key] = copied : result = copied
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def copy_container(source, pending, depth, type, parent_metrics)
|
|
73
|
+
raise Error, "tool arguments contain a cyclic value" if @active.key?(source)
|
|
74
|
+
return copy_reused_container(source, depth, parent_metrics) if @copies.key?(source)
|
|
75
|
+
|
|
76
|
+
size = @container_reader.size(source, type)
|
|
77
|
+
@accounting.reserve_children!(size)
|
|
78
|
+
captured = @container_reader.snapshot(source, type)
|
|
79
|
+
unless @container_reader.size(captured, type) == size
|
|
80
|
+
raise Error, "tool argument container changed while it was being captured"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
target = @container_reader.target(type, size)
|
|
84
|
+
metrics = { node_count: 1, byte_count: 0, max_depth: 0 }
|
|
85
|
+
@copies[source] = target
|
|
86
|
+
@active[source] = true
|
|
87
|
+
pending << [:finish, source, target, parent_metrics, metrics]
|
|
88
|
+
@container_reader.append_children(pending:, source: captured, target:, depth:, metrics:)
|
|
89
|
+
target
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def finish_container!(entry)
|
|
93
|
+
_, source, target, parent_metrics, metrics = entry
|
|
94
|
+
@active.delete(source)
|
|
95
|
+
target.freeze
|
|
96
|
+
@metrics[source] = metrics.freeze
|
|
97
|
+
@accounting.propagate!(parent_metrics, metrics)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def copy_reused_container(source, depth, parent_metrics)
|
|
101
|
+
metrics = @metrics.fetch(source)
|
|
102
|
+
@accounting.account_reused!(metrics, depth)
|
|
103
|
+
@accounting.propagate!(parent_metrics, metrics)
|
|
104
|
+
@copies.fetch(source)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def copy_scalar(source, parent_metrics)
|
|
108
|
+
previous_bytes = @accounting.byte_count
|
|
109
|
+
copied = @scalar_snapshot.copy(source)
|
|
110
|
+
if parent_metrics
|
|
111
|
+
parent_metrics[:node_count] += 1
|
|
112
|
+
parent_metrics[:byte_count] += @accounting.byte_count - previous_bytes
|
|
113
|
+
parent_metrics[:max_depth] = [parent_metrics[:max_depth], 1].max
|
|
114
|
+
end
|
|
115
|
+
copied
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module BoundedCompletionContext
|
|
6
|
+
include BoundedCompletionControls
|
|
7
|
+
include FailFastCompletion
|
|
8
|
+
include GracefulCompletion
|
|
9
|
+
|
|
10
|
+
SUPPORTED_RUBY_LLM = Gem::Requirement.new("= 1.16.0")
|
|
11
|
+
REQUIRED_HOOKS = %i[
|
|
12
|
+
add_tool_result_message forced_tool_choice? handle_concurrent_tool_calls handle_sequential_tool_calls
|
|
13
|
+
params reset_tool_choice run_callbacks
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
private_constant :SUPPORTED_RUBY_LLM, :REQUIRED_HOOKS
|
|
17
|
+
|
|
18
|
+
def self.install(chat)
|
|
19
|
+
version = Gem::Version.new(RubyLLM::VERSION)
|
|
20
|
+
raise Error, "bounded completion requires RubyLLM 1.16.0" unless SUPPORTED_RUBY_LLM.satisfied_by?(version)
|
|
21
|
+
|
|
22
|
+
missing = REQUIRED_HOOKS.reject { |hook| chat.respond_to?(hook, true) }
|
|
23
|
+
valid = missing.empty? && chat.respond_to?(:tool_prefs) && chat.tool_prefs.respond_to?(:replace)
|
|
24
|
+
raise Error, "unsupported RubyLLM tool-loop interface for bounded completion" unless valid
|
|
25
|
+
|
|
26
|
+
chat.extend(self) unless chat.singleton_class < self
|
|
27
|
+
chat.__send__(:install_bounded_completion_context)
|
|
28
|
+
chat
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def complete(&stream)
|
|
32
|
+
allowance = Tool.current_tool_call_allowance
|
|
33
|
+
owner = [Thread.current, Fiber.current]
|
|
34
|
+
iterative = allowance.is_a?(CallAllowance)
|
|
35
|
+
|
|
36
|
+
bounded_completion_guard.around_completion(owner, reentrant: !iterative) do
|
|
37
|
+
return super(&stream) unless iterative
|
|
38
|
+
|
|
39
|
+
validate_reserved_params!
|
|
40
|
+
validate_supported_tools! if allowance.complete_on_exhaustion?
|
|
41
|
+
state = bounded_completion_guard.state_for(allowance)
|
|
42
|
+
loop do
|
|
43
|
+
result = complete_with_budget_policy(state) { super(&stream) }
|
|
44
|
+
return result unless state.consume_continuation!
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def install_bounded_completion_context
|
|
52
|
+
return if @smith_bounded_completion_guard
|
|
53
|
+
|
|
54
|
+
@smith_bounded_completion_guard = BoundedCompletionGuard.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def complete_with_budget_policy(state, &completion)
|
|
58
|
+
return complete_with_fail_fast_policy(state, &completion) unless state.allowance.complete_on_exhaustion?
|
|
59
|
+
|
|
60
|
+
if state.finalization_required? || state.allowance.remaining.zero?
|
|
61
|
+
complete_without_tools(state, &completion)
|
|
62
|
+
else
|
|
63
|
+
with_bounded_tool_call_preference(state.allowance.remaining) do
|
|
64
|
+
with_sequential_tool_execution(&completion)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handle_tool_calls(response, &)
|
|
70
|
+
state = current_bounded_completion_state
|
|
71
|
+
return super unless state
|
|
72
|
+
|
|
73
|
+
tool_calls = validated_tool_calls(response)
|
|
74
|
+
return dispatch_fail_fast_calls(response, tool_calls, state) unless state.allowance.complete_on_exhaustion?
|
|
75
|
+
return reject_finalization_violation(tool_calls, state) if state.finalization_started?
|
|
76
|
+
|
|
77
|
+
admitted, result = dispatch_admitted_calls(response, tool_calls, state)
|
|
78
|
+
return result if admitted
|
|
79
|
+
|
|
80
|
+
complete_rejected_batch(response, tool_calls, state)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def execute_tool(tool_call)
|
|
84
|
+
admission = bounded_completion_guard.admission_for(tool_call)
|
|
85
|
+
return super unless admission
|
|
86
|
+
|
|
87
|
+
CallAdmission.around(admission) { super }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def current_bounded_completion_state
|
|
91
|
+
allowance = Tool.current_tool_call_allowance
|
|
92
|
+
return unless allowance.is_a?(CallAllowance)
|
|
93
|
+
|
|
94
|
+
bounded_completion_guard.state_for(allowance)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def dispatch_admitted_calls(response, tool_calls, state)
|
|
98
|
+
result = nil
|
|
99
|
+
admitted = bounded_completion_guard.with_admitted_calls(
|
|
100
|
+
tool_calls,
|
|
101
|
+
tools:,
|
|
102
|
+
allowance: state.allowance,
|
|
103
|
+
ledger: Tool.current_ledger
|
|
104
|
+
) do
|
|
105
|
+
result = handle_sequential_tool_calls(tool_calls)
|
|
106
|
+
end
|
|
107
|
+
return [false, nil] unless admitted
|
|
108
|
+
|
|
109
|
+
reset_tool_choice if forced_tool_choice?
|
|
110
|
+
state.request_continuation! unless result
|
|
111
|
+
[true, result || response]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def complete_rejected_batch(response, tool_calls, state)
|
|
115
|
+
state.request_finalization!
|
|
116
|
+
append_budget_rejections(tool_calls, state.allowance)
|
|
117
|
+
reset_tool_choice if forced_tool_choice?
|
|
118
|
+
state.request_continuation!
|
|
119
|
+
response
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def bounded_completion_guard
|
|
123
|
+
@smith_bounded_completion_guard || raise(Error, "bounded completion context is not installed")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def smith_tool_call_admissions(tool_calls)
|
|
127
|
+
bounded_completion_guard.admissions_for(tool_calls)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module BoundedCompletionControls
|
|
6
|
+
RESERVED_TOOL_PARAM_KEYS = %w[
|
|
7
|
+
parallel_tool_calls tool_choice tool_config toolConfig tools
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
private_constant :RESERVED_TOOL_PARAM_KEYS
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def append_budget_rejections(tool_calls, allowance)
|
|
15
|
+
error = nil
|
|
16
|
+
Thread.handle_interrupt(Object => :never) do
|
|
17
|
+
tool_calls.each_value do |tool_call|
|
|
18
|
+
begin
|
|
19
|
+
run_callbacks(:before_message, :new_message)
|
|
20
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
21
|
+
error ||= e
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
add_tool_result_message(tool_call, budget_rejection(allowance))
|
|
26
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
27
|
+
error ||= e
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
raise error if error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def budget_rejection(allowance)
|
|
35
|
+
{
|
|
36
|
+
error: {
|
|
37
|
+
code: "tool_call_budget_exhausted",
|
|
38
|
+
message: "Tool call was not executed. Complete from available tool results.",
|
|
39
|
+
remaining: allowance.remaining
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validated_tool_calls(response)
|
|
45
|
+
ExecutionBatchCollection.capture(response.tool_calls)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reject_finalization_violation(tool_calls, state)
|
|
49
|
+
append_budget_rejections(tool_calls, state.allowance)
|
|
50
|
+
raise BoundedCompletionError, "provider requested a tool during tool-disabled budget finalization"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_supported_tools!
|
|
54
|
+
unsupported = tools.values.grep_v(Smith::Tool)
|
|
55
|
+
return if unsupported.empty?
|
|
56
|
+
|
|
57
|
+
names = unsupported.map(&:name).sort.join(", ")
|
|
58
|
+
raise BoundedCompletionError,
|
|
59
|
+
"tool_budget_exhaustion :complete requires Smith::Tool bindings; unsupported: #{names}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_reserved_params!
|
|
63
|
+
keys = params.respond_to?(:keys) ? params.keys.map(&:to_s) : []
|
|
64
|
+
reserved = keys & RESERVED_TOOL_PARAM_KEYS
|
|
65
|
+
return if reserved.empty?
|
|
66
|
+
|
|
67
|
+
raise BoundedCompletionError,
|
|
68
|
+
"bounded completion reserves provider tool params: #{reserved.sort.join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def with_bounded_tool_call_preference(remaining)
|
|
72
|
+
return yield unless tool_call_cardinality_supported?
|
|
73
|
+
|
|
74
|
+
original_calls = tool_prefs[:calls]
|
|
75
|
+
begin
|
|
76
|
+
tool_prefs[:calls] = remaining > 1 ? :many : :one
|
|
77
|
+
yield
|
|
78
|
+
ensure
|
|
79
|
+
tool_prefs[:calls] = original_calls
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tool_call_cardinality_supported?
|
|
84
|
+
current_params = params || {}
|
|
85
|
+
mode = current_params[:openai_api_mode] || current_params["openai_api_mode"]
|
|
86
|
+
return true if mode.to_s == "responses"
|
|
87
|
+
|
|
88
|
+
selected_model = respond_to?(:model) ? model : nil
|
|
89
|
+
selected_model&.supports?(:parallel_tool_calls) || false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def with_tools_disabled
|
|
93
|
+
original_tools = tools.dup
|
|
94
|
+
original_preferences = tool_prefs.dup
|
|
95
|
+
tools.clear
|
|
96
|
+
tool_prefs[:choice] = :none
|
|
97
|
+
tool_prefs[:calls] = tool_call_cardinality_supported? ? :one : nil
|
|
98
|
+
yield
|
|
99
|
+
ensure
|
|
100
|
+
tools.replace(original_tools) if original_tools
|
|
101
|
+
tool_prefs.replace(original_preferences) if original_preferences
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def with_sequential_tool_execution
|
|
105
|
+
original = @concurrency
|
|
106
|
+
@concurrency = nil
|
|
107
|
+
yield
|
|
108
|
+
ensure
|
|
109
|
+
@concurrency = original
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class BoundedCompletionGuard
|
|
6
|
+
def initialize
|
|
7
|
+
@state = nil
|
|
8
|
+
@state_mutex = Mutex.new
|
|
9
|
+
@owner = nil
|
|
10
|
+
@depth = 0
|
|
11
|
+
@owner_mutex = Mutex.new
|
|
12
|
+
@admissions = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def around_completion(owner, reentrant:, &block)
|
|
16
|
+
Thread.handle_interrupt(Object => :never) do
|
|
17
|
+
enter(owner, reentrant:)
|
|
18
|
+
begin
|
|
19
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
20
|
+
ensure
|
|
21
|
+
leave(owner)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def state_for(allowance)
|
|
27
|
+
@state_mutex.synchronize do
|
|
28
|
+
return @state if @state&.allowance.equal?(allowance)
|
|
29
|
+
|
|
30
|
+
@state = BoundedCompletionState.new(allowance: allowance)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def with_admitted_calls(tool_calls, tools:, allowance:, ledger:, &block)
|
|
35
|
+
Thread.handle_interrupt(Object => :never) do
|
|
36
|
+
reservation = allowance.reserve_batch(tool_calls.each_value.map(&:name), ledger:)
|
|
37
|
+
return false unless reservation
|
|
38
|
+
|
|
39
|
+
previous = @admissions
|
|
40
|
+
@admissions = build_admissions(tool_calls, tools, reservation)
|
|
41
|
+
begin
|
|
42
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
43
|
+
ensure
|
|
44
|
+
begin
|
|
45
|
+
reservation.settle!
|
|
46
|
+
ensure
|
|
47
|
+
@admissions = previous
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def admission_for(tool_call)
|
|
55
|
+
@admissions&.fetch(tool_call, nil)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def admissions_for(tool_calls)
|
|
59
|
+
tool_calls.each_with_object({}.compare_by_identity) do |tool_call, admissions|
|
|
60
|
+
admission = admission_for(tool_call)
|
|
61
|
+
admissions[tool_call] = admission if admission
|
|
62
|
+
end.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_admissions(tool_calls, tools, reservation)
|
|
68
|
+
tool_calls.each_value.with_object({}.compare_by_identity) do |tool_call, admissions|
|
|
69
|
+
tool = tools[tool_call.name.to_sym]
|
|
70
|
+
next unless tool
|
|
71
|
+
|
|
72
|
+
admissions[tool_call] = CallAdmission.new(tool:, reservation:)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def enter(owner, reentrant:)
|
|
77
|
+
@owner_mutex.synchronize do
|
|
78
|
+
if @owner && (@owner != owner || !reentrant)
|
|
79
|
+
raise Error, "concurrent or reentrant completion on one bounded Smith chat is unsupported"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Each outer completion derives fresh budget policy state, so a
|
|
83
|
+
# finalized earlier ask can never leak its terminal state into a
|
|
84
|
+
# later independently bounded completion on the same chat.
|
|
85
|
+
@state_mutex.synchronize { @state = nil } if @depth.zero?
|
|
86
|
+
@owner = owner
|
|
87
|
+
@depth += 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def leave(owner)
|
|
92
|
+
@owner_mutex.synchronize do
|
|
93
|
+
return unless @owner == owner
|
|
94
|
+
|
|
95
|
+
@depth -= 1
|
|
96
|
+
@owner = nil if @depth.zero?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module BoundedCompletionInstallation
|
|
6
|
+
def complete(&)
|
|
7
|
+
allowance = Tool.current_tool_call_allowance
|
|
8
|
+
return super unless allowance.is_a?(CallAllowance)
|
|
9
|
+
return super if singleton_class < BoundedCompletionContext
|
|
10
|
+
|
|
11
|
+
BoundedCompletionContext.install(self)
|
|
12
|
+
complete(&)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def smith_tool_call_admissions(_tool_calls) = nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class BoundedCompletionState
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :allowance
|
|
11
|
+
|
|
12
|
+
def initialize(...)
|
|
13
|
+
super
|
|
14
|
+
@state = :open
|
|
15
|
+
@continuation_required = false
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def request_continuation!
|
|
20
|
+
@mutex.synchronize { @continuation_required = true }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def consume_continuation!
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
required = @continuation_required
|
|
26
|
+
@continuation_required = false
|
|
27
|
+
required
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request_finalization!
|
|
32
|
+
@mutex.synchronize { @state = :required if @state == :open }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def begin_finalization
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
return :active if @state == :finalizing
|
|
38
|
+
|
|
39
|
+
@state = :finalizing
|
|
40
|
+
:started
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def abort_finalization!
|
|
45
|
+
@mutex.synchronize { @state = :required if @state == :finalizing }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def finalization_required?
|
|
49
|
+
@mutex.synchronize { @state == :required }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def finalization_started?
|
|
53
|
+
@mutex.synchronize { @state == :finalizing }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -9,15 +9,18 @@ module Smith
|
|
|
9
9
|
allowance = self.class.current_tool_call_allowance
|
|
10
10
|
ledger = self.class.current_ledger
|
|
11
11
|
workflow_active = ledger&.limits&.key?(:tool_calls)
|
|
12
|
+
admission = CallAdmission.current
|
|
12
13
|
|
|
13
|
-
if
|
|
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
|
|
14
|
+
return if admission&.claim(self)
|
|
19
15
|
|
|
20
|
-
commit_workflow_tool_call!(ledger, workflow_active)
|
|
16
|
+
charge_agent_allowance!(allowance) { commit_workflow_tool_call!(ledger, workflow_active) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def charge_agent_allowance!(allowance, &)
|
|
20
|
+
return allowance.charge!(name, &) if allowance.is_a?(CallAllowance)
|
|
21
|
+
return CallAllowance.charge_legacy!(allowance, &) if allowance.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
yield
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def commit_workflow_tool_call!(ledger, workflow_active)
|
|
@@ -26,6 +29,11 @@ module Smith
|
|
|
26
29
|
reservation = ledger.reserve!(:tool_calls, 1)
|
|
27
30
|
ledger.reconcile!(reservation, 1)
|
|
28
31
|
end
|
|
32
|
+
|
|
33
|
+
def mark_tool_execution_started!
|
|
34
|
+
self.class.current_tool_execution_tracker&.mark_started!
|
|
35
|
+
self.class.__send__(:current_tool_dispatch_start_handler)&.call(self)
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
end
|
|
31
39
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class CallAdmission
|
|
6
|
+
THREAD_KEY = :smith_tool_call_admission
|
|
7
|
+
private_constant :THREAD_KEY
|
|
8
|
+
|
|
9
|
+
def self.current
|
|
10
|
+
Thread.current[THREAD_KEY]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.around(admission, &block)
|
|
14
|
+
raise ArgumentError, "block required" unless block
|
|
15
|
+
|
|
16
|
+
previous = current
|
|
17
|
+
Thread.handle_interrupt(Object => :never) do
|
|
18
|
+
Thread.current[THREAD_KEY] = admission
|
|
19
|
+
begin
|
|
20
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
21
|
+
ensure
|
|
22
|
+
Thread.current[THREAD_KEY] = previous
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(tool:, reservation:)
|
|
28
|
+
@tool = tool
|
|
29
|
+
@reservation = reservation
|
|
30
|
+
@available = true
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def claim(tool)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
return false unless @available && @tool.equal?(tool)
|
|
37
|
+
|
|
38
|
+
@available = false
|
|
39
|
+
@reservation.claim
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|