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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class ExecutionBatchAdmission
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :calls
|
|
11
|
+
option :context
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return if calls.empty? || bounded_completion_admits_calls?
|
|
15
|
+
|
|
16
|
+
reservation = reserve
|
|
17
|
+
return reservation if reservation
|
|
18
|
+
return unless call_budget || workflow_budgeted?
|
|
19
|
+
|
|
20
|
+
raise BudgetExceeded, "agent tool_calls budget exceeded"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def reserve
|
|
26
|
+
case call_budget
|
|
27
|
+
when CallAllowance
|
|
28
|
+
call_budget.reserve_batch(requested_tool_names, ledger:)
|
|
29
|
+
when Hash
|
|
30
|
+
LegacyCallAllowance.reserve_batch(call_budget, calls.length, ledger:)
|
|
31
|
+
else
|
|
32
|
+
reserve_workflow_budget
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reserve_workflow_budget
|
|
37
|
+
return unless workflow_budgeted?
|
|
38
|
+
|
|
39
|
+
ledger_reservation = ledger.reserve!(:tool_calls, calls.length)
|
|
40
|
+
CallReservation.new(limit: calls.length, ledger:, ledger_reservation:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bounded_completion_admits_calls?
|
|
44
|
+
call_budget.is_a?(CallAllowance) && call_budget.complete_on_exhaustion?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def workflow_budgeted? = ledger&.limits&.key?(:tool_calls)
|
|
48
|
+
|
|
49
|
+
def requested_tool_names = calls.map { _1.name.to_s }
|
|
50
|
+
|
|
51
|
+
def call_budget = context.fetch(:current_tool_call_allowance)
|
|
52
|
+
|
|
53
|
+
def ledger = context.fetch(:current_ledger)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class ExecutionBatchBuilder
|
|
6
|
+
def initialize
|
|
7
|
+
@default_sequence = InvocationSequence.new
|
|
8
|
+
@invocations = ExecutionBatchInvocations.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(entries:, tools:, context:, call_admissions:)
|
|
12
|
+
sources = ExecutionBatchSources.new(entries).call
|
|
13
|
+
sources_by_call = index_sources(sources)
|
|
14
|
+
source_targets = build_targets(sources, tools)
|
|
15
|
+
source_requests = build_requests(source_targets, sources_by_call, context)
|
|
16
|
+
dispatch_calls = build_dispatch_calls(source_requests, sources_by_call)
|
|
17
|
+
attributes = batch_attributes(sources, source_targets, source_requests, dispatch_calls, context)
|
|
18
|
+
inherited = call_admissions && remap(call_admissions, dispatch_calls)
|
|
19
|
+
build_batch(attributes, inherited)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_requests(targets, sources_by_call, context)
|
|
25
|
+
sequence = context.fetch(:current_invocation_sequence) || @default_sequence
|
|
26
|
+
target_sources = targets.each_key.map { sources_by_call.fetch(_1) }
|
|
27
|
+
invocations = @invocations.call(sources: target_sources, sequence:)
|
|
28
|
+
arguments = target_sources.each_with_object({}.compare_by_identity) do |source, indexed|
|
|
29
|
+
indexed[source.tool_call] = source.arguments
|
|
30
|
+
end.freeze
|
|
31
|
+
ExecutionBatchRequests.new(targets:, invocations:, arguments:).call
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def batch_attributes(sources, targets, requests, dispatch_calls, context)
|
|
35
|
+
{
|
|
36
|
+
context:,
|
|
37
|
+
tool_calls: build_tool_calls(sources, dispatch_calls),
|
|
38
|
+
source_calls: sources.map(&:tool_call).freeze,
|
|
39
|
+
dispatch_calls:,
|
|
40
|
+
source_calls_by_dispatch: invert(dispatch_calls),
|
|
41
|
+
requests: remap(requests, dispatch_calls),
|
|
42
|
+
targets: remap(targets, dispatch_calls)
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_batch(attributes, inherited_admissions)
|
|
47
|
+
tool_calls = attributes.fetch(:tool_calls)
|
|
48
|
+
reservation = ExecutionBatchAdmission.new(
|
|
49
|
+
calls: tool_calls.values.freeze,
|
|
50
|
+
context: attributes.fetch(:context)
|
|
51
|
+
).call
|
|
52
|
+
ExecutionBatch.new(
|
|
53
|
+
**attributes,
|
|
54
|
+
call_reservation: reservation,
|
|
55
|
+
call_admissions: build_call_admissions(attributes.fetch(:targets), reservation, inherited_admissions)
|
|
56
|
+
)
|
|
57
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
58
|
+
reservation&.settle!
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_dispatch_calls(requests, sources_by_call)
|
|
63
|
+
requests.each_with_object({}.compare_by_identity) do |(source_call, request), dispatch_calls|
|
|
64
|
+
invocation = request.invocation
|
|
65
|
+
source = sources_by_call.fetch(source_call)
|
|
66
|
+
dispatch_calls[source_call] = RubyLLM::ToolCall.new(
|
|
67
|
+
id: invocation.tool_call_id,
|
|
68
|
+
name: source.name,
|
|
69
|
+
arguments: request.arguments,
|
|
70
|
+
thought_signature: source.thought_signature
|
|
71
|
+
)
|
|
72
|
+
end.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_tool_calls(sources, dispatch_calls)
|
|
76
|
+
sources.to_h { |source| [source.key, dispatch_calls.fetch(source.tool_call, source.tool_call)] }.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def remap(source_values, dispatch_calls)
|
|
80
|
+
source_values.each_with_object({}.compare_by_identity) do |(source_call, value), mapped|
|
|
81
|
+
mapped[dispatch_calls.fetch(source_call)] = value
|
|
82
|
+
end.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def invert(source)
|
|
86
|
+
source.each_with_object({}.compare_by_identity) do |(key, value), inverted|
|
|
87
|
+
inverted[value] = key
|
|
88
|
+
end.freeze
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def index_sources(sources)
|
|
92
|
+
sources.each_with_object({}.compare_by_identity) { |source, indexed| indexed[source.tool_call] = source }.freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_targets(sources, tools)
|
|
96
|
+
sources.each_with_object({}.compare_by_identity) do |source, targets|
|
|
97
|
+
tool = tools[source.name.to_sym]
|
|
98
|
+
targets[source.tool_call] = tool if tool.is_a?(Smith::Tool)
|
|
99
|
+
end.freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_call_admissions(targets, reservation, inherited)
|
|
103
|
+
if reservation
|
|
104
|
+
raise Error, "tool call has overlapping admission owners" if inherited&.any?
|
|
105
|
+
|
|
106
|
+
return targets.each_with_object({}.compare_by_identity) do |(tool_call, tool), admissions|
|
|
107
|
+
admissions[tool_call] = CallAdmission.new(tool:, reservation:)
|
|
108
|
+
end.freeze
|
|
109
|
+
end
|
|
110
|
+
return {}.compare_by_identity.freeze unless inherited
|
|
111
|
+
|
|
112
|
+
validate_inherited_admissions!(targets, inherited)
|
|
113
|
+
inherited
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_inherited_admissions!(targets, inherited)
|
|
117
|
+
complete = targets.length == inherited.length && targets.each_key.all? { inherited.key?(_1) }
|
|
118
|
+
raise Error, "inherited tool admissions do not cover the immutable Smith batch" unless complete
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class ExecutionBatchCollection
|
|
6
|
+
MAX_CALLS = 100
|
|
7
|
+
|
|
8
|
+
HASH_MATCH = Module.instance_method(:===)
|
|
9
|
+
HASH_LENGTH = Hash.instance_method(:length)
|
|
10
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
11
|
+
HASH_INITIALIZE_COPY = Hash.instance_method(:initialize_copy)
|
|
12
|
+
OBJECT_RESPOND_TO = Object.instance_method(:respond_to?)
|
|
13
|
+
REQUIRED_CALL_READERS = %i[name arguments].freeze
|
|
14
|
+
private_constant :HASH_MATCH, :HASH_LENGTH, :HASH_EACH_PAIR, :HASH_INITIALIZE_COPY, :OBJECT_RESPOND_TO,
|
|
15
|
+
:REQUIRED_CALL_READERS
|
|
16
|
+
|
|
17
|
+
def self.capture(tool_calls)
|
|
18
|
+
size = admitted_size(tool_calls)
|
|
19
|
+
captured = owned_copy(tool_calls)
|
|
20
|
+
validate_copy_size!(captured, size)
|
|
21
|
+
indexed_calls(captured)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.admitted_size(tool_calls)
|
|
25
|
+
raise Error, "unsupported RubyLLM tool-call collection" unless HASH_MATCH.bind_call(Hash, tool_calls)
|
|
26
|
+
|
|
27
|
+
size = HASH_LENGTH.bind_call(tool_calls)
|
|
28
|
+
return size if size.positive? && size <= MAX_CALLS
|
|
29
|
+
|
|
30
|
+
raise Error, "provider tool batch must contain between 1 and #{MAX_CALLS} calls"
|
|
31
|
+
end
|
|
32
|
+
private_class_method :admitted_size
|
|
33
|
+
|
|
34
|
+
def self.owned_copy(tool_calls)
|
|
35
|
+
Hash.allocate.tap do |copy|
|
|
36
|
+
HASH_INITIALIZE_COPY.bind_call(copy, tool_calls)
|
|
37
|
+
copy.freeze
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :owned_copy
|
|
41
|
+
|
|
42
|
+
def self.validate_copy_size!(captured, expected_size)
|
|
43
|
+
return if HASH_LENGTH.bind_call(captured) == expected_size
|
|
44
|
+
|
|
45
|
+
raise Error, "provider tool batch changed while it was being captured"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :validate_copy_size!
|
|
48
|
+
|
|
49
|
+
def self.indexed_calls(captured)
|
|
50
|
+
snapshot = {}
|
|
51
|
+
HASH_EACH_PAIR.bind_call(captured) do |_key, tool_call|
|
|
52
|
+
validate_call!(tool_call)
|
|
53
|
+
snapshot[snapshot.length] = tool_call
|
|
54
|
+
end
|
|
55
|
+
snapshot.freeze
|
|
56
|
+
end
|
|
57
|
+
private_class_method :indexed_calls
|
|
58
|
+
|
|
59
|
+
def self.validate_call!(tool_call)
|
|
60
|
+
supported = REQUIRED_CALL_READERS.all? do |reader|
|
|
61
|
+
OBJECT_RESPOND_TO.bind_call(tool_call, reader)
|
|
62
|
+
rescue TypeError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
raise Error, "provider tool batch contains an unsupported call" unless supported
|
|
66
|
+
end
|
|
67
|
+
private_class_method :validate_call!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class ExecutionBatchInvocations
|
|
6
|
+
def call(sources:, sequence:)
|
|
7
|
+
return {}.compare_by_identity.freeze if sources.empty?
|
|
8
|
+
|
|
9
|
+
first_ordinal = sequence.reserve(sources.length)
|
|
10
|
+
sources.each_with_index.with_object({}.compare_by_identity) do |(source, index), invocations|
|
|
11
|
+
invocations[source.tool_call] = build_invocation(source, index, first_ordinal, sources.length)
|
|
12
|
+
end.freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_invocation(source, index, first_ordinal, batch_size)
|
|
18
|
+
Invocation.new(
|
|
19
|
+
tool_call_id: source.tool_call_id,
|
|
20
|
+
tool_name: source.canonical_name,
|
|
21
|
+
ordinal: first_ordinal + index,
|
|
22
|
+
batch_ordinal: index + 1,
|
|
23
|
+
batch_size:
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
module ExecutionBatchLifecycle
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smith_tool_execution_batches = @smith_tool_execution_batches ||= ExecutionBatchRegistry.new
|
|
11
|
+
|
|
12
|
+
def handle_sequential_tool_calls(tool_calls, ...)
|
|
13
|
+
batch = execution_batch_context(tool_calls)
|
|
14
|
+
admit_execution_batch(batch)
|
|
15
|
+
super(batch.tool_calls, ...)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
notify_unsettled_batch_failures(batch, e)
|
|
18
|
+
raise
|
|
19
|
+
ensure
|
|
20
|
+
settle_execution_batch(batch, active_error: $ERROR_INFO)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute_tools_concurrently(tool_calls, ...)
|
|
24
|
+
batch = execution_batch_context(tool_calls)
|
|
25
|
+
admit_execution_batch(batch)
|
|
26
|
+
super(batch.tool_calls, ...)
|
|
27
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
28
|
+
raise unless e.is_a?(StandardError)
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
notify_unsettled_batch_failures(batch, e)
|
|
32
|
+
rescue ToolFailureNotificationFailed => notification_failure
|
|
33
|
+
record_batch_failure(batch, notification_failure)
|
|
34
|
+
end
|
|
35
|
+
raise(prioritized_batch_failure(batch) || e)
|
|
36
|
+
ensure
|
|
37
|
+
settle_execution_batch(batch, active_error: $ERROR_INFO)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def execution_batch_context(tool_calls)
|
|
41
|
+
smith_tool_execution_batches.build_and_register(
|
|
42
|
+
tool_calls:,
|
|
43
|
+
tools:,
|
|
44
|
+
context: Tool::ScopedContext.capture,
|
|
45
|
+
admission_resolver: method(:smith_tool_call_admissions)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def admit_execution_batch(batch)
|
|
50
|
+
requests = batch.requests.values.freeze
|
|
51
|
+
admitter = batch.context.fetch(:current_invocation_batch_admitter)
|
|
52
|
+
return unless admitter && requests.any?
|
|
53
|
+
|
|
54
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
55
|
+
admitter.call(requests:)
|
|
56
|
+
batch.mark_host_admitted!
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def execution_batch(tool_call) = smith_tool_execution_batches.fetch(tool_call)
|
|
61
|
+
|
|
62
|
+
def settle_execution_batch(batch, active_error:)
|
|
63
|
+
return unless batch
|
|
64
|
+
|
|
65
|
+
settlement_error = nil
|
|
66
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
67
|
+
begin
|
|
68
|
+
batch.settle!
|
|
69
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
70
|
+
settlement_error = e
|
|
71
|
+
end
|
|
72
|
+
ensure
|
|
73
|
+
smith_tool_execution_batches.unregister(batch)
|
|
74
|
+
end
|
|
75
|
+
return unless settlement_error
|
|
76
|
+
raise settlement_error unless active_error
|
|
77
|
+
|
|
78
|
+
log_preserved_settlement_failure(active_error, settlement_error)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def log_preserved_settlement_failure(active_error, settlement_error)
|
|
82
|
+
Smith.config.logger&.error(
|
|
83
|
+
"Smith tool batch settlement failed while preserving " \
|
|
84
|
+
"#{active_error.class}: #{settlement_error.class}: #{settlement_error.message}"
|
|
85
|
+
)
|
|
86
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class ExecutionBatchRegistry
|
|
6
|
+
PENDING_BATCH = Object.new.freeze
|
|
7
|
+
private_constant :PENDING_BATCH
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@batches = {}.compare_by_identity
|
|
11
|
+
@builder = ExecutionBatchBuilder.new
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build_and_register(tool_calls:, tools:, context:, admission_resolver:)
|
|
16
|
+
captured_calls = ExecutionBatchCollection.capture(tool_calls)
|
|
17
|
+
entries = captured_calls.each_pair.map { |key, tool_call| [key, tool_call].freeze }.freeze
|
|
18
|
+
source_calls = entries.map(&:last).freeze
|
|
19
|
+
reserve_calls!(source_calls)
|
|
20
|
+
call_admissions = admission_resolver.call(source_calls)
|
|
21
|
+
batch = @builder.call(entries:, tools:, context:, call_admissions:)
|
|
22
|
+
publish_batch!(source_calls, batch)
|
|
23
|
+
batch
|
|
24
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
25
|
+
rollback_registration(source_calls, batch)
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unregister(batch)
|
|
30
|
+
return unless batch
|
|
31
|
+
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
unregister_calls(batch.source_calls, batch)
|
|
34
|
+
unregister_calls(batch.tool_calls.each_value, batch)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fetch(tool_call)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
batch = @batches[tool_call]
|
|
41
|
+
raise Error, "tool call batch registration is incomplete" if batch.equal?(PENDING_BATCH)
|
|
42
|
+
|
|
43
|
+
batch
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def reserve_calls!(calls)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
validate_inactive_calls!(calls)
|
|
52
|
+
calls.each { @batches[_1] = PENDING_BATCH }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def publish_batch!(calls, batch)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
unless calls.all? { @batches[_1].equal?(PENDING_BATCH) }
|
|
59
|
+
raise Error, "tool call batch registration changed before publication"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
calls.each { @batches[_1] = batch }
|
|
63
|
+
batch.tool_calls.each_value { @batches[_1] = batch }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unregister_calls(calls, batch)
|
|
68
|
+
calls.each { @batches.delete(_1) if @batches[_1].equal?(batch) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def release_calls!(calls)
|
|
72
|
+
return unless calls
|
|
73
|
+
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
calls.each { @batches.delete(_1) if @batches[_1].equal?(PENDING_BATCH) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rollback_registration(source_calls, batch)
|
|
80
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
81
|
+
batch&.settle!
|
|
82
|
+
ensure
|
|
83
|
+
unregister(batch)
|
|
84
|
+
release_calls!(source_calls)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_inactive_calls!(calls)
|
|
89
|
+
seen = {}.compare_by_identity
|
|
90
|
+
calls.each do |tool_call|
|
|
91
|
+
raise Error, "tool call appears more than once in one provider batch" if seen.key?(tool_call)
|
|
92
|
+
raise Error, "tool call is already active on this chat" if @batches.key?(tool_call)
|
|
93
|
+
|
|
94
|
+
seen[tool_call] = true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Tool < RubyLLM::Tool
|
|
7
|
+
class ExecutionBatchRequests
|
|
8
|
+
MAX_NODES = ArgumentSnapshot::MAX_NODES
|
|
9
|
+
MAX_BYTES = ArgumentSnapshot::MAX_BYTES
|
|
10
|
+
MAX_CALLS = ExecutionBatchCollection::MAX_CALLS
|
|
11
|
+
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
option :targets
|
|
15
|
+
option :invocations
|
|
16
|
+
option :arguments
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
raise Error, "tool batch exceeds #{MAX_CALLS} Smith tool calls" if targets.length > MAX_CALLS
|
|
20
|
+
|
|
21
|
+
totals = [0, 0]
|
|
22
|
+
targets.each_with_object({}.compare_by_identity) do |(tool_call, tool), requests|
|
|
23
|
+
append_request!(requests, tool_call, tool, totals)
|
|
24
|
+
end
|
|
25
|
+
.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_bounds!(node_count, byte_count)
|
|
31
|
+
raise Error, "tool batch arguments exceed #{MAX_NODES} values" if node_count > MAX_NODES
|
|
32
|
+
raise Error, "tool batch arguments exceed #{MAX_BYTES} bytes" if byte_count > MAX_BYTES
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def append_request!(requests, tool_call, tool, totals)
|
|
36
|
+
request = build_request(tool_call, tool)
|
|
37
|
+
totals[0] += request.argument_node_count
|
|
38
|
+
totals[1] += request.argument_byte_count
|
|
39
|
+
validate_bounds!(*totals)
|
|
40
|
+
requests[tool_call] = request
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_request(tool_call, tool)
|
|
44
|
+
InvocationRequest.new(
|
|
45
|
+
invocation: invocations.fetch(tool_call),
|
|
46
|
+
tool_class: tool.class,
|
|
47
|
+
arguments: normalized_arguments(arguments.fetch(tool_call))
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalized_arguments(value)
|
|
52
|
+
return {} if value.nil?
|
|
53
|
+
return value if value.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
raise Error, "tool call arguments must be an object"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
require_relative "../types"
|
|
6
|
+
require_relative "execution_batch_source_metadata"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Tool < RubyLLM::Tool
|
|
10
|
+
class ExecutionBatchSourceCall < Dry::Struct
|
|
11
|
+
MAX_NAME_BYTES = ExecutionBatchSourceMetadata::MAX_NAME_BYTES
|
|
12
|
+
MAX_METADATA_BYTES = ExecutionBatchSourceMetadata::MAX_METADATA_BYTES
|
|
13
|
+
|
|
14
|
+
Name = Types.Instance(String) | Types.Instance(Symbol)
|
|
15
|
+
private_constant :Name
|
|
16
|
+
|
|
17
|
+
attribute :key, Types::Any
|
|
18
|
+
attribute :tool_call, Types::Any
|
|
19
|
+
attribute :tool_call_id, Types::String.optional
|
|
20
|
+
attribute :name, Name
|
|
21
|
+
attribute :canonical_name, Types::String.constrained(min_size: 1)
|
|
22
|
+
attribute :arguments, Types::Any
|
|
23
|
+
attribute :thought_signature, Types::String.optional
|
|
24
|
+
attribute :metadata_byte_count, Types::Integer.constrained(gteq: 1)
|
|
25
|
+
|
|
26
|
+
def self.capture(key:, tool_call:)
|
|
27
|
+
ExecutionBatchSourceMetadata.capture(key:, tool_call:).to_source_call
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(...)
|
|
31
|
+
super
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
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 ExecutionBatchSourceMetadata < Dry::Struct
|
|
10
|
+
MAX_NAME_BYTES = 256
|
|
11
|
+
MAX_METADATA_BYTES = ArgumentSnapshot::MAX_BYTES
|
|
12
|
+
|
|
13
|
+
MODULE_MATCH = Module.instance_method(:===)
|
|
14
|
+
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
15
|
+
STRING_VALID_ENCODING = String.instance_method(:valid_encoding?)
|
|
16
|
+
SYMBOL_TO_S = Symbol.instance_method(:to_s)
|
|
17
|
+
private_constant :MODULE_MATCH, :STRING_BYTESIZE, :STRING_VALID_ENCODING, :SYMBOL_TO_S
|
|
18
|
+
|
|
19
|
+
Name = Types.Instance(String) | Types.Instance(Symbol)
|
|
20
|
+
private_constant :Name
|
|
21
|
+
|
|
22
|
+
attribute :key, Types::Any
|
|
23
|
+
attribute :tool_call, Types::Any
|
|
24
|
+
attribute :tool_call_id, Types::String.optional
|
|
25
|
+
attribute :name, Name
|
|
26
|
+
attribute :canonical_name, Types::String.constrained(min_size: 1)
|
|
27
|
+
attribute :thought_signature, Types::String.optional
|
|
28
|
+
attribute :metadata_byte_count, Types::Integer.constrained(gteq: 1)
|
|
29
|
+
|
|
30
|
+
def self.capture(key:, tool_call:)
|
|
31
|
+
name, canonical_name = owned_name(tool_call.name)
|
|
32
|
+
tool_call_id = owned_id(tool_call)
|
|
33
|
+
thought_signature = owned_thought_signature(tool_call)
|
|
34
|
+
new(
|
|
35
|
+
key:,
|
|
36
|
+
tool_call:,
|
|
37
|
+
tool_call_id:,
|
|
38
|
+
name:,
|
|
39
|
+
canonical_name:,
|
|
40
|
+
thought_signature:,
|
|
41
|
+
metadata_byte_count: string_bytes(canonical_name) + string_bytes(tool_call_id) +
|
|
42
|
+
string_bytes(thought_signature)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(...)
|
|
47
|
+
super
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_source_call
|
|
52
|
+
ExecutionBatchSourceCall.new(
|
|
53
|
+
**to_h,
|
|
54
|
+
arguments: tool_call.arguments
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.owned_id(tool_call)
|
|
59
|
+
return unless tool_call.respond_to?(:id)
|
|
60
|
+
|
|
61
|
+
value = tool_call.id
|
|
62
|
+
value && owned_string(value, "tool call id")
|
|
63
|
+
end
|
|
64
|
+
private_class_method :owned_id
|
|
65
|
+
|
|
66
|
+
def self.owned_thought_signature(tool_call)
|
|
67
|
+
return unless tool_call.respond_to?(:thought_signature)
|
|
68
|
+
|
|
69
|
+
value = tool_call.thought_signature
|
|
70
|
+
value && owned_string(value, "tool call thought signature")
|
|
71
|
+
end
|
|
72
|
+
private_class_method :owned_thought_signature
|
|
73
|
+
|
|
74
|
+
def self.owned_name(value)
|
|
75
|
+
raise Error, "tool call name must be a string or symbol" unless string?(value) || symbol?(value)
|
|
76
|
+
|
|
77
|
+
source = symbol?(value) ? SYMBOL_TO_S.bind_call(value) : value
|
|
78
|
+
canonical = owned_string(source, "tool call name", max_bytes: MAX_NAME_BYTES)
|
|
79
|
+
raise Error, "tool call name must be a bounded non-empty value" if canonical.empty?
|
|
80
|
+
|
|
81
|
+
[symbol?(value) ? canonical.to_sym : canonical, canonical]
|
|
82
|
+
end
|
|
83
|
+
private_class_method :owned_name
|
|
84
|
+
|
|
85
|
+
def self.owned_string(value, label, max_bytes: MAX_METADATA_BYTES)
|
|
86
|
+
raise Error, "#{label} must be a string" unless string?(value)
|
|
87
|
+
raise Error, "#{label} must contain valid encoded text" unless STRING_VALID_ENCODING.bind_call(value)
|
|
88
|
+
raise Error, "#{label} exceeds #{max_bytes} bytes" if STRING_BYTESIZE.bind_call(value) > max_bytes
|
|
89
|
+
|
|
90
|
+
normalized = String.new(value).encode(Encoding::UTF_8)
|
|
91
|
+
raise Error, "#{label} exceeds #{max_bytes} bytes" if STRING_BYTESIZE.bind_call(normalized) > max_bytes
|
|
92
|
+
|
|
93
|
+
normalized.freeze
|
|
94
|
+
rescue EncodingError
|
|
95
|
+
raise Error, "#{label} must contain valid UTF-8 text"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.string_bytes(value) = value ? STRING_BYTESIZE.bind_call(value) : 0
|
|
99
|
+
|
|
100
|
+
def self.string?(value) = MODULE_MATCH.bind_call(String, value)
|
|
101
|
+
|
|
102
|
+
def self.symbol?(value) = MODULE_MATCH.bind_call(Symbol, value)
|
|
103
|
+
|
|
104
|
+
private_class_method :owned_string, :string_bytes, :string?, :symbol?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|