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
@@ -3,59 +3,132 @@
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
5
  class CallAllowance
6
- LEGACY_MUTEX = Mutex.new
7
- LEGACY_ALLOWANCE_MUTEXES = ObjectSpace::WeakMap.new
8
- private_constant :LEGACY_MUTEX
9
- private_constant :LEGACY_ALLOWANCE_MUTEXES
6
+ EXHAUSTION_POLICIES = %i[raise complete].freeze
10
7
 
11
8
  def self.charge_legacy!(allowance)
12
- legacy_allowance_mutex(allowance).synchronize do
9
+ LegacyCallAllowance.charge!(allowance) { yield if block_given? }
10
+ end
11
+
12
+ def initialize(remaining, on_exhaustion: :raise)
13
+ invalid = (remaining.is_a?(Integer) && remaining.negative?) ||
14
+ (!remaining.is_a?(Integer) && !remaining.is_a?(CallBudget))
15
+ raise ArgumentError, "tool call allowance must be a non-negative integer" if invalid
16
+
17
+ initialize_scope(CallBudget.coerce(remaining), on_exhaustion:, parent: nil)
18
+ end
19
+
20
+ def scope(budget, on_exhaustion: @on_exhaustion)
21
+ self.class.allocate.tap do |allowance|
22
+ allowance.__send__(:initialize_scope, CallBudget.coerce(budget), on_exhaustion:, parent: self)
23
+ end
24
+ end
25
+
26
+ def charge!(tool_name = nil)
27
+ batch = CallBatch.coerce(exact? ? [tool_name] : 1, exact: exact?)
28
+
29
+ synchronize do
13
30
  Thread.handle_interrupt(Object => :never) do
14
- remaining = allowance[:remaining]
15
- unless remaining.is_a?(Integer) && remaining.positive?
16
- raise BudgetExceeded, "agent tool_calls budget exceeded"
17
- end
31
+ raise BudgetExceeded, "agent tool_calls budget exceeded" unless reservable?(batch)
18
32
 
19
33
  yield if block_given?
20
- allowance[:remaining] = remaining - 1
34
+ consume!(batch)
21
35
  end
22
36
  end
23
37
  end
24
38
 
25
- def self.legacy_allowance_mutex(allowance)
26
- LEGACY_MUTEX.synchronize do
27
- LEGACY_ALLOWANCE_MUTEXES[allowance] ||= Mutex.new
28
- end
39
+ def remaining
40
+ synchronize { lineage.map { _1.__send__(:counter).remaining }.min }
29
41
  end
30
- private_class_method :legacy_allowance_mutex
31
42
 
32
- def initialize(remaining)
33
- unless remaining.is_a?(Integer) && remaining >= 0
34
- raise ArgumentError, "tool call allowance must be a non-negative integer"
35
- end
43
+ def remaining_for(tool_name)
44
+ name = canonical_tool_name(tool_name)
45
+ synchronize do
46
+ exact_scopes = lineage.select { _1.__send__(:budget).exact? }
47
+ return nil if exact_scopes.empty?
36
48
 
37
- @remaining = remaining
38
- @mutex = Mutex.new
49
+ exact_scopes.map { _1.__send__(:counter).remaining_for(name) }.min
50
+ end
39
51
  end
40
52
 
41
- def charge!
42
- @mutex.synchronize do
53
+ def reserve_batch(tool_names_or_size, ledger: nil)
54
+ batch = CallBatch.coerce(tool_names_or_size, exact: exact?)
55
+
56
+ synchronize do
43
57
  Thread.handle_interrupt(Object => :never) do
44
- raise BudgetExceeded, "agent tool_calls budget exceeded" unless @remaining.positive?
58
+ return unless reservable?(batch)
45
59
 
46
- yield if block_given?
47
- @remaining -= 1
60
+ workflow_ledger = ledger if ledger&.limits&.key?(:tool_calls)
61
+ ledger_reservation = workflow_ledger&.reserve!(:tool_calls, batch.size)
62
+ reservation = CallReservation.new(
63
+ limit: batch.size,
64
+ ledger: workflow_ledger,
65
+ ledger_reservation: ledger_reservation
66
+ )
67
+
68
+ consume!(batch)
69
+ reservation
48
70
  end
49
71
  end
72
+ rescue BudgetExceeded
73
+ nil
50
74
  end
51
75
 
52
- def remaining
53
- @mutex.synchronize { @remaining }
76
+ def complete_on_exhaustion?
77
+ @on_exhaustion == :complete
78
+ end
79
+
80
+ def exact?
81
+ lineage.any? { _1.__send__(:budget).exact? }
82
+ end
83
+
84
+ def used?
85
+ synchronize { counter.used? }
54
86
  end
55
87
 
56
88
  def [](key)
57
89
  remaining if key == :remaining
58
90
  end
91
+
92
+ protected
93
+
94
+ attr_reader :budget
95
+
96
+ private
97
+
98
+ def initialize_scope(budget, on_exhaustion:, parent:)
99
+ unless EXHAUSTION_POLICIES.include?(on_exhaustion)
100
+ raise ArgumentError, "tool call exhaustion policy must be :raise or :complete"
101
+ end
102
+
103
+ @budget = budget
104
+ @parent = parent
105
+ @lineage = [self, *Array(parent&.__send__(:lineage))].freeze
106
+ @mutex = parent ? parent.__send__(:shared_mutex) : Mutex.new
107
+ @counter = CallAllowanceCounter.new(budget)
108
+ @on_exhaustion = on_exhaustion
109
+ self
110
+ end
111
+
112
+ attr_reader :lineage, :counter
113
+
114
+ def shared_mutex = @mutex
115
+
116
+ def synchronize(&) = @mutex.synchronize(&)
117
+
118
+ def canonical_tool_name(value)
119
+ name = value.to_s
120
+ raise ArgumentError, "tool name must not be empty" if name.empty?
121
+
122
+ name
123
+ end
124
+
125
+ def reservable?(batch)
126
+ lineage.all? { _1.__send__(:counter).available?(batch) }
127
+ end
128
+
129
+ def consume!(batch)
130
+ lineage.each { _1.__send__(:counter).consume!(batch) }
131
+ end
59
132
  end
60
133
  end
61
134
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class CallAllowanceCounter
6
+ def initialize(budget)
7
+ @budget = budget
8
+ @remaining = budget.total
9
+ @remaining_by_tool = budget.tool_limits&.dup
10
+ end
11
+
12
+ attr_reader :remaining
13
+
14
+ def remaining_for(tool_name)
15
+ @remaining_by_tool.fetch(tool_name, 0)
16
+ end
17
+
18
+ def used?
19
+ remaining < @budget.total
20
+ end
21
+
22
+ def available?(batch)
23
+ remaining >= batch.size && tool_counts_available?(batch.counts)
24
+ end
25
+
26
+ def consume!(batch)
27
+ @remaining -= batch.size
28
+ return unless @budget.exact?
29
+
30
+ batch.counts.each { |name, count| @remaining_by_tool[name] -= count }
31
+ end
32
+
33
+ private
34
+
35
+ def tool_counts_available?(counts)
36
+ return true unless @budget.exact?
37
+ return false unless counts
38
+
39
+ counts.all? { |name, count| @remaining_by_tool.fetch(name, 0) >= count }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class CallBatch
6
+ attr_reader :size, :counts
7
+
8
+ def self.coerce(value, exact:)
9
+ return from_size(value, exact:) if value.is_a?(Integer)
10
+ raise ArgumentError, "tool call batch size must be a positive integer" unless value.is_a?(Array)
11
+
12
+ names = value.map { canonical_tool_name(_1) }
13
+ validate_size!(names.length)
14
+ new(size: names.length, counts: names.tally.freeze)
15
+ end
16
+
17
+ def self.from_size(size, exact:)
18
+ validate_size!(size)
19
+ raise ArgumentError, "exact tool call allowance requires tool names for batch reservation" if exact
20
+
21
+ new(size:, counts: nil)
22
+ end
23
+ private_class_method :from_size
24
+
25
+ def self.canonical_tool_name(value)
26
+ name = value.to_s
27
+ raise ArgumentError, "tool name must not be empty" if name.empty?
28
+
29
+ name
30
+ end
31
+ private_class_method :canonical_tool_name
32
+
33
+ def self.validate_size!(size)
34
+ return if size.is_a?(Integer) && size.positive?
35
+
36
+ raise ArgumentError, "tool call batch size must be a positive integer"
37
+ end
38
+ private_class_method :validate_size!
39
+
40
+ def initialize(size:, counts:)
41
+ @size = size
42
+ @counts = counts
43
+ freeze
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "../types"
6
+
7
+ module Smith
8
+ class Tool < RubyLLM::Tool
9
+ class CallBudget < Dry::Struct
10
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
11
+ ToolName = OwnedString.constrained(min_size: 1)
12
+ Limit = Types::Integer.constrained(gteq: 0)
13
+ PositiveLimit = Types::Integer.constrained(gt: 0)
14
+ ToolLimits = Types::Hash.map(ToolName, PositiveLimit)
15
+
16
+ private_constant :OwnedString, :ToolName, :Limit, :PositiveLimit, :ToolLimits
17
+
18
+ attribute :total, Limit
19
+ attribute :tool_limits, ToolLimits.optional.default(nil)
20
+
21
+ def self.coerce(value)
22
+ return value if value.is_a?(self)
23
+ return new(total: value) if value.is_a?(Integer)
24
+
25
+ raise ArgumentError, "tool call budget must be an integer or Smith::Tool::CallBudget"
26
+ end
27
+
28
+ def initialize(...)
29
+ super
30
+ validate_tool_limits!
31
+ tool_limits&.freeze
32
+ freeze
33
+ end
34
+
35
+ def exact? = !tool_limits.nil?
36
+
37
+ def limit_for(tool_name)
38
+ tool_limits&.fetch(tool_name.to_s, nil)
39
+ end
40
+
41
+ private
42
+
43
+ def validate_tool_limits!
44
+ return unless tool_limits
45
+ return if total <= tool_limits.values.sum
46
+
47
+ raise ArgumentError, "aggregate tool call allowance exceeds the sum of its per-tool limits"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class CallReservation
8
+ extend Dry::Initializer
9
+
10
+ option :limit
11
+ option :ledger
12
+ option :ledger_reservation
13
+
14
+ def initialize(...)
15
+ super
16
+ @claimed = 0
17
+ @settled = false
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def claim
22
+ @mutex.synchronize do
23
+ return false if @settled || @claimed >= limit
24
+
25
+ @claimed += 1
26
+ true
27
+ end
28
+ end
29
+
30
+ def settle!
31
+ Thread.handle_interrupt(Exception => :never) do
32
+ @mutex.synchronize do
33
+ return if @settled
34
+
35
+ ledger&.reconcile!(ledger_reservation, @claimed)
36
+ @settled = true
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ChatExecutionCallbacks
6
+ private
7
+
8
+ def execute_tool_with_callbacks(tool_call)
9
+ batch = execution_batch(tool_call)
10
+ dispatch_call = batch&.dispatch_for(tool_call)
11
+ return super unless dispatch_call && batch.request_for(dispatch_call)
12
+
13
+ execute_managed_tool_callbacks(batch, dispatch_call) do
14
+ super(batch.source_for(dispatch_call))
15
+ end
16
+ end
17
+
18
+ def execute_managed_tool_callbacks(batch, dispatch_call, &block)
19
+ claim = batch.claim_dispatch!(dispatch_call)
20
+ with_dispatch_claim(batch, claim) { complete_managed_callbacks(batch, dispatch_call, &block) }
21
+ rescue StandardError => e
22
+ handle_managed_callback_failure(batch, dispatch_call, claim, e)
23
+ rescue Exception => e # rubocop:disable Lint/RescueException
24
+ record_batch_failure(batch, e)
25
+ raise
26
+ end
27
+
28
+ def with_dispatch_claim(batch, claim, &)
29
+ context = batch.context.merge(current_tool_dispatch_claim: claim).freeze
30
+ Tool::ScopedContext.around(context, &)
31
+ end
32
+
33
+ def complete_managed_callbacks(batch, dispatch_call)
34
+ result = yield
35
+ notify_rejected_callback_dispatch(batch, dispatch_call) unless batch.started?(dispatch_call)
36
+ result
37
+ end
38
+
39
+ def handle_managed_callback_failure(batch, dispatch_call, claim, error)
40
+ record_batch_failure(batch, error)
41
+ raise error unless claim
42
+ raise error if error.is_a?(ToolFailureNotificationFailed)
43
+
44
+ failure = invocation_dispatch_failure(batch, dispatch_call, error)
45
+ notify_invocation_failure(batch, dispatch_call, failure)
46
+ raise failure
47
+ end
48
+
49
+ def notify_rejected_callback_dispatch(batch, dispatch_call)
50
+ notify_invocation_failure(
51
+ batch,
52
+ dispatch_call,
53
+ ToolDispatchRejected.new("tool invocation arguments were rejected before execution")
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -3,6 +3,12 @@
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
5
  module ChatExecutionContext
6
+ include BoundedCompletionInstallation
7
+ include ExecutionFailureHandling
8
+ include ExecutionDispatch
9
+ include ChatExecutionCallbacks
10
+ include ExecutionBatchLifecycle
11
+
6
12
  def self.install(chat)
7
13
  return chat unless chat.respond_to?(:tools) && chat.tools.respond_to?(:values)
8
14
  unless chat.respond_to?(:execute_tool, true)
@@ -10,96 +16,9 @@ module Smith
10
16
  end
11
17
 
12
18
  chat.extend(self) unless chat.singleton_class < self
13
- chat.__send__(:install_smith_tool_execution_context)
19
+ chat.__send__(:smith_tool_execution_batches)
14
20
  chat
15
21
  end
16
-
17
- private
18
-
19
- def install_smith_tool_execution_context
20
- return if defined?(@smith_tool_execution_batches_mutex)
21
-
22
- @smith_tool_execution_batches = {}.compare_by_identity
23
- @smith_tool_execution_batches_mutex = Mutex.new
24
- end
25
-
26
- def execute_tool(tool_call)
27
- batch = execution_batch(tool_call)
28
- return super unless batch
29
-
30
- Tool::ScopedContext.around(batch.fetch(:context)) { super }
31
- rescue Exception => e # rubocop:disable Lint/RescueException
32
- record_batch_failure(batch, e) if batch
33
- raise
34
- end
35
-
36
- def execute_tools_concurrently(tool_calls, ...)
37
- batch = execution_batch_context
38
- register_execution_batch(tool_calls, batch)
39
- super
40
- rescue Exception => e # rubocop:disable Lint/RescueException
41
- raise e unless e.is_a?(StandardError)
42
-
43
- fatal_failure = first_failure(batch&.fetch(:fatal_failures))
44
- raise fatal_failure if fatal_failure
45
-
46
- capture_failure = first_failure(batch&.fetch(:capture_failures))
47
- raise capture_failure if capture_failure
48
-
49
- raise
50
- ensure
51
- unregister_execution_batch(tool_calls, batch)
52
- end
53
-
54
- def execution_batch_context
55
- {
56
- context: Tool::ScopedContext.capture,
57
- capture_failures: Queue.new,
58
- fatal_failures: Queue.new
59
- }.freeze
60
- end
61
-
62
- def register_execution_batch(tool_calls, batch)
63
- @smith_tool_execution_batches_mutex.synchronize do
64
- tool_calls.each_value do |tool_call|
65
- raise Error, "tool call is already active on this chat" if @smith_tool_execution_batches.key?(tool_call)
66
-
67
- @smith_tool_execution_batches[tool_call] = batch
68
- end
69
- end
70
- end
71
-
72
- def unregister_execution_batch(tool_calls, batch)
73
- return unless tool_calls && batch
74
-
75
- @smith_tool_execution_batches_mutex.synchronize do
76
- tool_calls.each_value do |tool_call|
77
- @smith_tool_execution_batches.delete(tool_call) if @smith_tool_execution_batches[tool_call].equal?(batch)
78
- end
79
- end
80
- end
81
-
82
- def execution_batch(tool_call)
83
- @smith_tool_execution_batches_mutex.synchronize do
84
- @smith_tool_execution_batches[tool_call]
85
- end
86
- end
87
-
88
- def record_batch_failure(batch, error)
89
- if error.is_a?(ToolCaptureFailed)
90
- batch.fetch(:capture_failures).push(error)
91
- elsif !error.is_a?(StandardError)
92
- batch.fetch(:fatal_failures).push(error)
93
- end
94
- end
95
-
96
- def first_failure(queue)
97
- return unless queue
98
-
99
- queue.pop(true)
100
- rescue ThreadError
101
- nil
102
- end
103
22
  end
104
23
  end
105
24
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class ExecutionAuthority
6
+ THREAD_KEY = :smith_tool_execution_authority
7
+ private_constant :THREAD_KEY
8
+
9
+ def self.current = Thread.current[THREAD_KEY]
10
+
11
+ def self.around(tool:, dispatch_claim:, &block)
12
+ raise ArgumentError, "block required" unless block
13
+
14
+ previous = current
15
+ Thread.handle_interrupt(Object => :never) do
16
+ Thread.current[THREAD_KEY] = new(tool:, dispatch_claim:)
17
+ begin
18
+ Thread.handle_interrupt(Object => :immediate, &block)
19
+ ensure
20
+ Thread.current[THREAD_KEY] = previous
21
+ end
22
+ end
23
+ end
24
+
25
+ def initialize(tool:, dispatch_claim:)
26
+ @tool = tool
27
+ @dispatch_claim = dispatch_claim
28
+ @available = true
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def claim(tool, dispatch_claim)
33
+ @mutex.synchronize do
34
+ return false unless @available && @tool.equal?(tool) && @dispatch_claim.equal?(dispatch_claim)
35
+
36
+ @available = false
37
+ true
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ExecutionAuthorization
6
+ private
7
+
8
+ def authorize_tool_execution!
9
+ claim = self.class.__send__(:current_tool_dispatch_claim)
10
+ return unless claim
11
+ return if ExecutionAuthority.current&.claim(self, claim)
12
+
13
+ raise ToolExecutionNotAdmitted, "managed Smith tool execution requires its exact admitted dispatch authority"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ExecutionBatch
8
+ extend Dry::Initializer
9
+
10
+ option :context
11
+ option :tool_calls
12
+ option :source_calls
13
+ option :dispatch_calls
14
+ option :source_calls_by_dispatch
15
+ option :requests
16
+ option :targets
17
+ option :call_admissions, default: proc { {}.compare_by_identity.freeze }
18
+ option :call_reservation, optional: true
19
+
20
+ attr_reader :capture_failures, :fatal_failures, :notification_failures, :terminal_failures
21
+
22
+ def initialize(...)
23
+ super
24
+ @state = ExecutionBatchState.new(requests:)
25
+ @mutex = Mutex.new
26
+ initialize_failure_queues
27
+ @host_admitted = false
28
+ @host_admission_required = context.fetch(:current_invocation_batch_admitter) && requests.any?
29
+ end
30
+
31
+ def request_for(tool_call) = requests[tool_call]
32
+
33
+ def target_for(tool_call) = targets[tool_call]
34
+
35
+ def admission_for(tool_call) = call_admissions[tool_call]
36
+
37
+ def dispatch_for(tool_call) = requests.key?(tool_call) ? tool_call : dispatch_calls[tool_call]
38
+
39
+ def source_for(tool_call) = source_calls_by_dispatch[tool_call]
40
+
41
+ def claim_dispatch!(tool_call) = @state.claim_dispatch!(tool_call)
42
+
43
+ def verify_dispatch!(tool_call, current_tool:, claim:)
44
+ request = requests.fetch(tool_call)
45
+ target = targets.fetch(tool_call)
46
+ return if dispatch_admitted? &&
47
+ @state.dispatch_claimed?(tool_call, claim) &&
48
+ target_unchanged?(current_tool, target, request) &&
49
+ call_unchanged?(tool_call, request)
50
+
51
+ raise ToolDispatchRejected, "admitted tool invocation changed before dispatch"
52
+ end
53
+
54
+ def mark_started!(tool_call, claim:) = @state.mark_started!(tool_call, claim:)
55
+
56
+ def mark_executed!(tool_call, claim:) = @state.mark_executed!(tool_call, claim:)
57
+
58
+ def started?(tool_call) = @state.started?(tool_call)
59
+
60
+ def mark_host_admitted!
61
+ @mutex.synchronize { @host_admitted = true }
62
+ end
63
+
64
+ def host_admitted?
65
+ @mutex.synchronize { @host_admitted }
66
+ end
67
+
68
+ def claim_failure_request(tool_call) = @state.claim_failure_request(tool_call)
69
+
70
+ def claim_unsettled_request = @state.claim_unsettled_request
71
+
72
+ def complete_failure_notification!(tool_call) = @state.complete_failure_notification!(tool_call)
73
+
74
+ def release_failure_notification!(tool_call, state) = @state.release_failure_notification!(tool_call, state)
75
+
76
+ def settle! = call_reservation&.settle!
77
+
78
+ private
79
+
80
+ def initialize_failure_queues
81
+ @capture_failures = Queue.new
82
+ @fatal_failures = Queue.new
83
+ @notification_failures = Queue.new
84
+ @terminal_failures = Queue.new
85
+ end
86
+
87
+ def dispatch_admitted? = !@host_admission_required || host_admitted?
88
+
89
+ def target_unchanged?(current_tool, target, request)
90
+ current_tool.equal?(target) && current_tool.instance_of?(request.tool_class)
91
+ end
92
+
93
+ def call_unchanged?(tool_call, request)
94
+ tool_call.id.equal?(request.invocation.tool_call_id) &&
95
+ tool_call.name.to_s == request.invocation.tool_name &&
96
+ tool_call.arguments.equal?(request.arguments)
97
+ end
98
+ end
99
+ end
100
+ end