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,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class ExecutionBatchSources
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
param :entries
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
bytes = 0
|
|
14
|
+
metadata = entries.each_with_object([]) do |(key, tool_call), captured|
|
|
15
|
+
source = ExecutionBatchSourceMetadata.capture(key:, tool_call:)
|
|
16
|
+
bytes += source.metadata_byte_count
|
|
17
|
+
validate_bytes!(bytes)
|
|
18
|
+
captured << source
|
|
19
|
+
end.freeze
|
|
20
|
+
metadata.map(&:to_source_call).freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_bytes!(bytes)
|
|
26
|
+
return if bytes <= ExecutionBatchSourceCall::MAX_METADATA_BYTES
|
|
27
|
+
|
|
28
|
+
raise Error, "provider tool batch metadata exceeds #{ExecutionBatchSourceCall::MAX_METADATA_BYTES} bytes"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class ExecutionBatchState
|
|
8
|
+
SETTLED_STATES = %i[executed failure_notified failure_notifying].freeze
|
|
9
|
+
private_constant :SETTLED_STATES
|
|
10
|
+
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
option :requests
|
|
14
|
+
|
|
15
|
+
def initialize(...)
|
|
16
|
+
super
|
|
17
|
+
@states = requests.each_key.to_h { [_1, :pending] }.compare_by_identity
|
|
18
|
+
@dispatch_claims = {}.compare_by_identity
|
|
19
|
+
@notification_attempted = {}.compare_by_identity
|
|
20
|
+
@unsettled_tool_calls = requests.each_key.to_a.freeze
|
|
21
|
+
@unsettled_index = 0
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def claim_dispatch!(tool_call)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
unless @states[tool_call] == :pending
|
|
28
|
+
raise ToolDispatchRejected, "admitted tool invocation was already claimed"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
claim = Object.new.freeze
|
|
32
|
+
@dispatch_claims[tool_call] = claim
|
|
33
|
+
@states[tool_call] = :claimed
|
|
34
|
+
claim
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def dispatch_claimed?(tool_call, claim)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@dispatch_claims[tool_call].equal?(claim) && %i[claimed started].include?(@states[tool_call])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mark_started!(tool_call, claim:) = transition_dispatch!(tool_call, claim, from: :claimed, to: :started)
|
|
45
|
+
|
|
46
|
+
def mark_executed!(tool_call, claim:) = transition_dispatch!(tool_call, claim, from: :started, to: :executed)
|
|
47
|
+
|
|
48
|
+
def started?(tool_call) = @mutex.synchronize { %i[started executed].include?(@states[tool_call]) }
|
|
49
|
+
|
|
50
|
+
def claim_failure_request(tool_call)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
state = @states[tool_call]
|
|
53
|
+
return if state.nil? || SETTLED_STATES.include?(state) || @notification_attempted.key?(tool_call)
|
|
54
|
+
|
|
55
|
+
claim_notification!(tool_call)
|
|
56
|
+
[requests.fetch(tool_call), state]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def claim_unsettled_request
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
while @unsettled_index < @unsettled_tool_calls.length
|
|
63
|
+
tool_call = next_unsettled_tool_call
|
|
64
|
+
state = @states[tool_call]
|
|
65
|
+
next if SETTLED_STATES.include?(state) || @notification_attempted.key?(tool_call)
|
|
66
|
+
|
|
67
|
+
claim_notification!(tool_call)
|
|
68
|
+
return [tool_call, requests.fetch(tool_call), state]
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def complete_failure_notification!(tool_call)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@states[tool_call] = :failure_notified if @states[tool_call] == :failure_notifying
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def release_failure_notification!(tool_call, state)
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
@states[tool_call] = state if @states[tool_call] == :failure_notifying
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def next_unsettled_tool_call
|
|
89
|
+
tool_call = @unsettled_tool_calls[@unsettled_index]
|
|
90
|
+
@unsettled_index += 1
|
|
91
|
+
tool_call
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def claim_notification!(tool_call)
|
|
95
|
+
@notification_attempted[tool_call] = true
|
|
96
|
+
@states[tool_call] = :failure_notifying
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def transition_dispatch!(tool_call, claim, from:, to:)
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
unless @dispatch_claims[tool_call].equal?(claim) && @states[tool_call] == from
|
|
102
|
+
raise ToolDispatchRejected, "admitted tool invocation has an invalid dispatch state"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@states[tool_call] = to
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module ExecutionDispatch
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def execute_tool(tool_call)
|
|
9
|
+
batch = execution_batch(tool_call)
|
|
10
|
+
return super unless batch
|
|
11
|
+
|
|
12
|
+
dispatch_call = batch.dispatch_for(tool_call)
|
|
13
|
+
return execute_unmanaged_tool(batch, tool_call) { super } unless batch.request_for(dispatch_call)
|
|
14
|
+
|
|
15
|
+
execute_managed_tool(batch, dispatch_call) { super(dispatch_call) }
|
|
16
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
17
|
+
record_batch_failure(batch, e) if batch
|
|
18
|
+
raise
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute_managed_tool(batch, dispatch_call, &)
|
|
22
|
+
current_tool = tools[dispatch_call.name.to_sym]
|
|
23
|
+
claim = dispatch_claim(batch, dispatch_call)
|
|
24
|
+
Tool::ScopedContext.around(dispatch_context(batch, dispatch_call, current_tool, claim)) do
|
|
25
|
+
execute_admitted_tool(batch, dispatch_call, current_tool:, claim:, &)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dispatch_claim(batch, dispatch_call)
|
|
30
|
+
Tool::ScopedContext.capture.fetch(:current_tool_dispatch_claim) || batch.claim_dispatch!(dispatch_call)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dispatch_context(batch, dispatch_call, current_tool, claim)
|
|
34
|
+
request = batch.request_for(dispatch_call)
|
|
35
|
+
batch.context.merge(
|
|
36
|
+
current_invocation: request.invocation,
|
|
37
|
+
current_tool_dispatch_claim: claim,
|
|
38
|
+
current_tool_dispatch_start_handler: dispatch_start_handler(batch, dispatch_call, current_tool, claim)
|
|
39
|
+
).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dispatch_start_handler(batch, dispatch_call, current_tool, claim)
|
|
43
|
+
lambda do |executing_tool|
|
|
44
|
+
batch.mark_started!(dispatch_call, claim:) if executing_tool.equal?(current_tool)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def execute_unmanaged_tool(batch, tool_call)
|
|
49
|
+
Tool::ScopedContext.around(batch.context) do
|
|
50
|
+
mark_unmanaged_tool_dispatch_started!(tool_call)
|
|
51
|
+
yield
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def execute_admitted_tool(batch, tool_call, current_tool:, claim:, &)
|
|
56
|
+
batch.verify_dispatch!(tool_call, current_tool:, claim:)
|
|
57
|
+
return yield if current_tool.__send__(:invocation_argument_error, tool_call.arguments)
|
|
58
|
+
|
|
59
|
+
result = dispatch_with_admission(current_tool, claim, batch.admission_for(tool_call), &)
|
|
60
|
+
batch.mark_executed!(tool_call, claim:)
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dispatch_with_admission(tool, claim, admission, &block)
|
|
65
|
+
ExecutionAuthority.around(tool:, dispatch_claim: claim) do
|
|
66
|
+
admission ? CallAdmission.around(admission, &block) : block.call
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def mark_unmanaged_tool_dispatch_started!(tool_call)
|
|
71
|
+
tool = tools[tool_call.name.to_sym]
|
|
72
|
+
return unless tool.is_a?(RubyLLM::Tool) && !tool.is_a?(Smith::Tool)
|
|
73
|
+
|
|
74
|
+
Tool.current_tool_execution_tracker&.mark_started!
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module ExecutionFailureHandling
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def notify_invocation_failure(batch, tool_call, error)
|
|
9
|
+
return unless batch&.host_admitted?
|
|
10
|
+
|
|
11
|
+
entry = batch&.claim_failure_request(tool_call)
|
|
12
|
+
return unless entry
|
|
13
|
+
|
|
14
|
+
request, state = entry
|
|
15
|
+
batch.context.fetch(:current_invocation_failure_handler)&.call(request:, error:)
|
|
16
|
+
batch.complete_failure_notification!(tool_call)
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
batch.release_failure_notification!(tool_call, state)
|
|
19
|
+
raise ToolFailureNotificationFailed.new(
|
|
20
|
+
dispatch_error: error,
|
|
21
|
+
notification_error: e
|
|
22
|
+
), cause: e
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def notify_unsettled_batch_failures(batch, error)
|
|
26
|
+
return unless batch&.host_admitted?
|
|
27
|
+
|
|
28
|
+
handler = batch.context.fetch(:current_invocation_failure_handler)
|
|
29
|
+
handler_error = notify_unsettled_requests(batch, error, handler)
|
|
30
|
+
raise handler_error if handler_error
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def notify_unsettled_requests(batch, error, handler)
|
|
34
|
+
first_error = nil
|
|
35
|
+
while (entry = batch.claim_unsettled_request)
|
|
36
|
+
tool_call, request, state = entry
|
|
37
|
+
failure = batch_dispatch_failure(error, state)
|
|
38
|
+
begin
|
|
39
|
+
handler&.call(request:, error: failure)
|
|
40
|
+
batch.complete_failure_notification!(tool_call)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
batch.release_failure_notification!(tool_call, state)
|
|
43
|
+
first_error ||= ToolFailureNotificationFailed.new(
|
|
44
|
+
dispatch_error: failure,
|
|
45
|
+
notification_error: e
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
first_error
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def batch_dispatch_failure(error, state)
|
|
53
|
+
return error.dispatch_error if error.is_a?(ToolFailureNotificationFailed)
|
|
54
|
+
return error if state == :started || exact_dispatch_failure?(error)
|
|
55
|
+
|
|
56
|
+
ToolDispatchRejected.new("tool batch dispatch failed before execution")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invocation_dispatch_failure(batch, tool_call, error)
|
|
60
|
+
return error if exact_dispatch_failure?(error) || batch&.started?(tool_call)
|
|
61
|
+
return error unless batch&.host_admitted?
|
|
62
|
+
|
|
63
|
+
ToolDispatchRejected.new("tool invocation failed before execution")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def exact_dispatch_failure?(error)
|
|
67
|
+
error.is_a?(ToolDispatchRejected) || error.is_a?(ToolExecutionNotAdmitted)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record_batch_failure(batch, error)
|
|
71
|
+
if !error.is_a?(StandardError)
|
|
72
|
+
batch.fatal_failures.push(error)
|
|
73
|
+
elsif error.is_a?(ToolFailureNotificationFailed)
|
|
74
|
+
batch.notification_failures.push(error)
|
|
75
|
+
elsif error.is_a?(ToolCaptureFailed)
|
|
76
|
+
batch.capture_failures.push(error)
|
|
77
|
+
elsif Smith::Errors.retry_forbidden?(error)
|
|
78
|
+
batch.terminal_failures.push(error)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def prioritized_batch_failure(batch)
|
|
83
|
+
return unless batch
|
|
84
|
+
|
|
85
|
+
%i[fatal_failures notification_failures capture_failures terminal_failures].each do |queue|
|
|
86
|
+
failure = first_failure(batch.public_send(queue))
|
|
87
|
+
return failure if failure
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def first_failure(queue)
|
|
93
|
+
return unless queue
|
|
94
|
+
|
|
95
|
+
queue.pop(true)
|
|
96
|
+
rescue ThreadError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module ExecutionLifecycle
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def prepare_tool_execution!(kwargs)
|
|
9
|
+
ensure_capture_ready!
|
|
10
|
+
run_before_execute_hook!(kwargs)
|
|
11
|
+
check_dispatch_deadline!
|
|
12
|
+
check_privilege!(kwargs)
|
|
13
|
+
check_authorization!(kwargs)
|
|
14
|
+
run_tool_guardrails!(kwargs)
|
|
15
|
+
check_dispatch_deadline!
|
|
16
|
+
charge_tool_call!
|
|
17
|
+
mark_tool_execution_started!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def perform_with_duration(kwargs)
|
|
21
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
result = perform(**kwargs)
|
|
23
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
24
|
+
[result, duration]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class ExecutionTracker
|
|
6
|
+
def initialize
|
|
7
|
+
@started = false
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def mark_started!
|
|
12
|
+
@mutex.synchronize { @started = true }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def started?
|
|
16
|
+
@mutex.synchronize { @started }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module FailFastCompletion
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def complete_with_fail_fast_policy(state, &)
|
|
9
|
+
with_bounded_tool_call_preference(state.allowance.remaining, &)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def dispatch_fail_fast_calls(response, tool_calls, state)
|
|
13
|
+
result = if concurrency
|
|
14
|
+
handle_concurrent_tool_calls(tool_calls)
|
|
15
|
+
else
|
|
16
|
+
handle_sequential_tool_calls(tool_calls)
|
|
17
|
+
end
|
|
18
|
+
reset_tool_choice if forced_tool_choice?
|
|
19
|
+
state.request_continuation! unless result
|
|
20
|
+
result || response
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module GracefulCompletion
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def complete_without_tools(state, &)
|
|
9
|
+
unless state.begin_finalization == :started
|
|
10
|
+
raise BoundedCompletionError, "tool-disabled budget finalization may run only once"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
with_tools_disabled(&)
|
|
14
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
15
|
+
state.abort_finalization!
|
|
16
|
+
raise
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
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 Invocation < Dry::Struct
|
|
10
|
+
OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
11
|
+
private_constant :OwnedString
|
|
12
|
+
|
|
13
|
+
attribute :tool_call_id, OwnedString.optional
|
|
14
|
+
attribute :tool_name, OwnedString.constrained(min_size: 1)
|
|
15
|
+
attribute :ordinal, Types::Integer.constrained(gt: 0)
|
|
16
|
+
attribute :batch_ordinal, Types::Integer.constrained(gt: 0)
|
|
17
|
+
attribute :batch_size, Types::Integer.constrained(gt: 0)
|
|
18
|
+
|
|
19
|
+
def initialize(...)
|
|
20
|
+
super
|
|
21
|
+
raise ArgumentError, "tool invocation batch ordinal exceeds its batch size" if batch_ordinal > batch_size
|
|
22
|
+
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
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 InvocationRequest < Dry::Struct
|
|
10
|
+
MAX_ARGUMENT_DEPTH = ArgumentSnapshot::MAX_DEPTH
|
|
11
|
+
MAX_ARGUMENT_NODES = ArgumentSnapshot::MAX_NODES
|
|
12
|
+
MAX_ARGUMENT_BYTES = ArgumentSnapshot::MAX_BYTES
|
|
13
|
+
|
|
14
|
+
ToolClass = Types::Any.constructor do |value|
|
|
15
|
+
raise TypeError, "expected a Smith::Tool subclass" unless value.is_a?(Class) && value <= Smith::Tool
|
|
16
|
+
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
private_constant :ToolClass
|
|
20
|
+
|
|
21
|
+
attribute :invocation, Types.Instance(Invocation)
|
|
22
|
+
attribute :tool_class, ToolClass
|
|
23
|
+
attribute :arguments, Types::Hash
|
|
24
|
+
|
|
25
|
+
attr_reader :argument_node_count, :argument_byte_count
|
|
26
|
+
|
|
27
|
+
def initialize(...)
|
|
28
|
+
super
|
|
29
|
+
snapshot = ArgumentSnapshot.new(arguments).call
|
|
30
|
+
@argument_node_count = snapshot.node_count
|
|
31
|
+
@argument_byte_count = snapshot.byte_count
|
|
32
|
+
@attributes = @attributes.merge(arguments: snapshot.value).freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class InvocationSequence
|
|
6
|
+
def initialize(next_ordinal: 1)
|
|
7
|
+
unless next_ordinal.is_a?(Integer) && next_ordinal.positive?
|
|
8
|
+
raise ArgumentError, "next tool invocation ordinal must be positive"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@next_ordinal = next_ordinal
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reserve(size)
|
|
16
|
+
raise ArgumentError, "tool invocation batch size must be positive" unless size.is_a?(Integer) && size.positive?
|
|
17
|
+
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
first = @next_ordinal
|
|
20
|
+
@next_ordinal += size
|
|
21
|
+
first
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class LegacyCallAllowance
|
|
6
|
+
MUTEX = Mutex.new
|
|
7
|
+
ALLOWANCE_MUTEXES = ObjectSpace::WeakMap.new
|
|
8
|
+
private_constant :MUTEX, :ALLOWANCE_MUTEXES
|
|
9
|
+
|
|
10
|
+
def self.charge!(allowance)
|
|
11
|
+
allowance_mutex(allowance).synchronize do
|
|
12
|
+
Thread.handle_interrupt(Object => :never) do
|
|
13
|
+
remaining = allowance[:remaining]
|
|
14
|
+
unless remaining.is_a?(Integer) && remaining.positive?
|
|
15
|
+
raise BudgetExceeded, "agent tool_calls budget exceeded"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
yield if block_given?
|
|
19
|
+
allowance[:remaining] = remaining - 1
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.reserve_batch(allowance, size, ledger: nil)
|
|
25
|
+
allowance_mutex(allowance).synchronize do
|
|
26
|
+
Thread.handle_interrupt(Object => :never) do
|
|
27
|
+
reserve_synchronized(allowance, size, ledger)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
rescue BudgetExceeded
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.allowance_mutex(allowance)
|
|
35
|
+
MUTEX.synchronize { ALLOWANCE_MUTEXES[allowance] ||= Mutex.new }
|
|
36
|
+
end
|
|
37
|
+
private_class_method :allowance_mutex
|
|
38
|
+
|
|
39
|
+
def self.reserve_synchronized(allowance, size, ledger)
|
|
40
|
+
remaining = allowance[:remaining]
|
|
41
|
+
return unless remaining.is_a?(Integer) && remaining >= size
|
|
42
|
+
|
|
43
|
+
workflow_ledger = ledger if ledger&.limits&.key?(:tool_calls)
|
|
44
|
+
ledger_reservation = workflow_ledger&.reserve!(:tool_calls, size)
|
|
45
|
+
allowance[:remaining] = remaining - size
|
|
46
|
+
CallReservation.new(
|
|
47
|
+
limit: size,
|
|
48
|
+
ledger: workflow_ledger,
|
|
49
|
+
ledger_reservation:
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :reserve_synchronized
|
|
53
|
+
|
|
54
|
+
private_class_method :new
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -8,8 +8,15 @@ module Smith
|
|
|
8
8
|
current_deadline: :smith_tool_deadline,
|
|
9
9
|
current_ledger: :smith_tool_ledger,
|
|
10
10
|
current_tool_call_allowance: :smith_tool_call_allowance,
|
|
11
|
+
current_tool_execution_tracker: :smith_tool_execution_tracker,
|
|
12
|
+
current_tool_dispatch_claim: :smith_tool_dispatch_claim,
|
|
13
|
+
current_tool_dispatch_start_handler: :smith_tool_dispatch_start_handler,
|
|
11
14
|
current_tool_result_collector: :smith_tool_result_collector,
|
|
12
|
-
current_invocation_context: :smith_tool_invocation_context
|
|
15
|
+
current_invocation_context: :smith_tool_invocation_context,
|
|
16
|
+
current_invocation_batch_admitter: :smith_tool_invocation_batch_admitter,
|
|
17
|
+
current_invocation_failure_handler: :smith_tool_invocation_failure_handler,
|
|
18
|
+
current_invocation_sequence: :smith_tool_invocation_sequence,
|
|
19
|
+
current_invocation: :smith_tool_invocation
|
|
13
20
|
}.freeze
|
|
14
21
|
|
|
15
22
|
CONTEXT_KEYS.each do |reader, key|
|
|
@@ -17,6 +24,18 @@ module Smith
|
|
|
17
24
|
define_method("#{reader}=") { |value| Thread.current[key] = value }
|
|
18
25
|
end
|
|
19
26
|
|
|
27
|
+
private :current_invocation_batch_admitter,
|
|
28
|
+
:current_invocation_batch_admitter=,
|
|
29
|
+
:current_invocation_failure_handler,
|
|
30
|
+
:current_invocation_failure_handler=,
|
|
31
|
+
:current_invocation_sequence,
|
|
32
|
+
:current_invocation_sequence=,
|
|
33
|
+
:current_tool_dispatch_claim,
|
|
34
|
+
:current_tool_dispatch_claim=,
|
|
35
|
+
:current_tool_dispatch_start_handler,
|
|
36
|
+
:current_tool_dispatch_start_handler=,
|
|
37
|
+
:current_invocation=
|
|
38
|
+
|
|
20
39
|
def self.capture
|
|
21
40
|
CONTEXT_KEYS.to_h { |reader, key| [reader, Thread.current[key]] }.freeze
|
|
22
41
|
end
|
|
@@ -36,27 +55,60 @@ module Smith
|
|
|
36
55
|
end
|
|
37
56
|
end
|
|
38
57
|
|
|
39
|
-
def with_invocation_context(value,
|
|
58
|
+
def with_invocation_context(value, invocation_sequence: InvocationSequence.new, batch_admitter: nil,
|
|
59
|
+
failure_handler: nil, &block)
|
|
60
|
+
raise ArgumentError, "block required" unless block
|
|
61
|
+
unless invocation_sequence.is_a?(InvocationSequence)
|
|
62
|
+
raise ArgumentError, "invocation sequence must be a Smith::Tool::InvocationSequence"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
validate_callback!(batch_admitter, "batch admitter")
|
|
66
|
+
validate_callback!(failure_handler, "failure handler")
|
|
67
|
+
if batch_admitter && !failure_handler
|
|
68
|
+
raise ArgumentError, "invocation failure handler is required when a batch admitter is configured"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
values = ScopedContext.capture.merge(
|
|
72
|
+
current_invocation_context: value,
|
|
73
|
+
current_invocation_batch_admitter: batch_admitter,
|
|
74
|
+
current_invocation_failure_handler: failure_handler,
|
|
75
|
+
current_invocation_sequence: invocation_sequence,
|
|
76
|
+
current_invocation: nil
|
|
77
|
+
).freeze
|
|
78
|
+
ScopedContext.around(values, &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def with_call_budget(budget, on_exhaustion: :raise, &block)
|
|
40
82
|
raise ArgumentError, "block required" unless block
|
|
41
83
|
|
|
42
|
-
previous =
|
|
84
|
+
previous = current_tool_call_allowance
|
|
85
|
+
if previous && !previous.is_a?(CallAllowance)
|
|
86
|
+
raise ArgumentError, "a scoped tool call budget cannot nest under a legacy Hash tool call allowance"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
allowance = previous ? previous.scope(budget, on_exhaustion:) : CallAllowance.new(budget, on_exhaustion:)
|
|
43
90
|
Thread.handle_interrupt(Object => :never) do
|
|
44
|
-
self.
|
|
91
|
+
self.current_tool_call_allowance = allowance
|
|
45
92
|
begin
|
|
46
93
|
Thread.handle_interrupt(Object => :immediate, &block)
|
|
47
94
|
ensure
|
|
48
|
-
self.
|
|
95
|
+
self.current_tool_call_allowance = previous
|
|
49
96
|
end
|
|
50
97
|
end
|
|
51
98
|
end
|
|
52
99
|
|
|
53
100
|
def self.install(values)
|
|
54
|
-
CONTEXT_KEYS.each
|
|
55
|
-
Thread.current[key] = values.fetch(reader)
|
|
56
|
-
end
|
|
101
|
+
CONTEXT_KEYS.each { |reader, key| Thread.current[key] = values.fetch(reader) }
|
|
57
102
|
end
|
|
58
103
|
private_class_method :install
|
|
59
104
|
|
|
105
|
+
def validate_callback!(callback, name)
|
|
106
|
+
return if callback.nil? || callback.respond_to?(:call)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "invocation #{name} must respond to #call"
|
|
109
|
+
end
|
|
110
|
+
private :validate_callback!
|
|
111
|
+
|
|
60
112
|
def self.validate!(values)
|
|
61
113
|
complete = values.is_a?(Hash) && values.length == CONTEXT_KEYS.length && CONTEXT_KEYS.each_key.all? do |key|
|
|
62
114
|
values.key?(key)
|