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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  5. data/docs/PERSISTENCE.md +27 -1
  6. data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
  7. data/lib/smith/agent/chat_construction.rb +112 -0
  8. data/lib/smith/agent/completion.rb +46 -0
  9. data/lib/smith/agent/completion_usage_recording.rb +25 -0
  10. data/lib/smith/agent/dynamic_configuration.rb +69 -0
  11. data/lib/smith/agent/fallback_configuration.rb +26 -0
  12. data/lib/smith/agent/invocation_preparation.rb +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +29 -197
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +15 -7
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +101 -28
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  60. data/lib/smith/tool/chat_execution_context.rb +7 -88
  61. data/lib/smith/tool/execution_authority.rb +42 -0
  62. data/lib/smith/tool/execution_authorization.rb +17 -0
  63. data/lib/smith/tool/execution_batch.rb +100 -0
  64. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  65. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  66. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  67. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  68. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  69. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  70. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  71. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  72. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  73. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  74. data/lib/smith/tool/execution_batch_state.rb +110 -0
  75. data/lib/smith/tool/execution_dispatch.rb +78 -0
  76. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  77. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  78. data/lib/smith/tool/execution_tracker.rb +20 -0
  79. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  80. data/lib/smith/tool/graceful_completion.rb +20 -0
  81. data/lib/smith/tool/invocation.rb +27 -0
  82. data/lib/smith/tool/invocation_request.rb +37 -0
  83. data/lib/smith/tool/invocation_sequence.rb +26 -0
  84. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  85. data/lib/smith/tool/scoped_context.rb +60 -8
  86. data/lib/smith/tool.rb +71 -12
  87. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  88. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  89. data/lib/smith/version.rb +2 -2
  90. data/lib/smith/workflow/agent_result.rb +4 -3
  91. data/lib/smith/workflow/artifact_integration.rb +2 -1
  92. data/lib/smith/workflow/composite/effects.rb +3 -1
  93. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  94. data/lib/smith/workflow/execution.rb +2 -9
  95. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  96. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  97. data/lib/smith/workflow/failure_record.rb +139 -0
  98. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  99. data/lib/smith/workflow/failure_record_text.rb +83 -0
  100. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  101. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  102. data/lib/smith/workflow/parallel.rb +14 -3
  103. data/lib/smith/workflow/persistence.rb +22 -19
  104. data/lib/smith/workflow/retry_execution.rb +5 -1
  105. data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
  106. data/lib/smith/workflow/transition.rb +9 -4
  107. data/lib/smith/workflow/usage_entry.rb +12 -2
  108. data/lib/smith/workflow.rb +17 -130
  109. data/lib/smith.rb +3 -0
  110. 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