smith-agents 0.6.1 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -1
  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 +293 -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 +31 -177
  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 +17 -11
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +134 -0
  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/capture.rb +60 -4
  60. data/lib/smith/tool/capture_configuration.rb +20 -0
  61. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  62. data/lib/smith/tool/chat_execution_context.rb +24 -0
  63. data/lib/smith/tool/execution_authority.rb +42 -0
  64. data/lib/smith/tool/execution_authorization.rb +17 -0
  65. data/lib/smith/tool/execution_batch.rb +100 -0
  66. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  67. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  68. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  69. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  70. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  71. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  72. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  73. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  74. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  75. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  76. data/lib/smith/tool/execution_batch_state.rb +110 -0
  77. data/lib/smith/tool/execution_dispatch.rb +78 -0
  78. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  79. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  80. data/lib/smith/tool/execution_tracker.rb +20 -0
  81. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  82. data/lib/smith/tool/graceful_completion.rb +20 -0
  83. data/lib/smith/tool/invocation.rb +27 -0
  84. data/lib/smith/tool/invocation_request.rb +37 -0
  85. data/lib/smith/tool/invocation_sequence.rb +26 -0
  86. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  87. data/lib/smith/tool/scoped_context.rb +123 -0
  88. data/lib/smith/tool.rb +78 -57
  89. data/lib/smith/tool_capture_failed.rb +106 -0
  90. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  91. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  92. data/lib/smith/version.rb +2 -2
  93. data/lib/smith/workflow/agent_result.rb +4 -3
  94. data/lib/smith/workflow/artifact_integration.rb +2 -1
  95. data/lib/smith/workflow/composite/branch_failure.rb +34 -13
  96. data/lib/smith/workflow/composite/branch_outcome.rb +19 -2
  97. data/lib/smith/workflow/composite/effects.rb +3 -1
  98. data/lib/smith/workflow/composite/error.rb +53 -5
  99. data/lib/smith/workflow/composite/error_evidence.rb +10 -2
  100. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  101. data/lib/smith/workflow/execution.rb +2 -9
  102. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  103. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  104. data/lib/smith/workflow/failure_record.rb +139 -0
  105. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  106. data/lib/smith/workflow/failure_record_text.rb +83 -0
  107. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  108. data/lib/smith/workflow/fanout_execution.rb +12 -11
  109. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  110. data/lib/smith/workflow/parallel/cancellation_signal.rb +1 -1
  111. data/lib/smith/workflow/parallel/root_execution.rb +1 -1
  112. data/lib/smith/workflow/parallel.rb +17 -1
  113. data/lib/smith/workflow/persistence.rb +22 -19
  114. data/lib/smith/workflow/prepared_branch_execution.rb +9 -2
  115. data/lib/smith/workflow/retry_execution.rb +5 -0
  116. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +13 -4
  117. data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
  118. data/lib/smith/workflow/transition.rb +11 -0
  119. data/lib/smith/workflow/usage_entry.rb +12 -2
  120. data/lib/smith/workflow.rb +17 -124
  121. data/lib/smith.rb +3 -0
  122. metadata +83 -5
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class CallAllowance
6
+ EXHAUSTION_POLICIES = %i[raise complete].freeze
7
+
8
+ def self.charge_legacy!(allowance)
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
30
+ Thread.handle_interrupt(Object => :never) do
31
+ raise BudgetExceeded, "agent tool_calls budget exceeded" unless reservable?(batch)
32
+
33
+ yield if block_given?
34
+ consume!(batch)
35
+ end
36
+ end
37
+ end
38
+
39
+ def remaining
40
+ synchronize { lineage.map { _1.__send__(:counter).remaining }.min }
41
+ end
42
+
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?
48
+
49
+ exact_scopes.map { _1.__send__(:counter).remaining_for(name) }.min
50
+ end
51
+ end
52
+
53
+ def reserve_batch(tool_names_or_size, ledger: nil)
54
+ batch = CallBatch.coerce(tool_names_or_size, exact: exact?)
55
+
56
+ synchronize do
57
+ Thread.handle_interrupt(Object => :never) do
58
+ return unless reservable?(batch)
59
+
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
70
+ end
71
+ end
72
+ rescue BudgetExceeded
73
+ nil
74
+ end
75
+
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? }
86
+ end
87
+
88
+ def [](key)
89
+ remaining if key == :remaining
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
132
+ end
133
+ end
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
@@ -3,19 +3,75 @@
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
5
  module Capture
6
+ CAPTURE_FAILED = Object.new.freeze
7
+ private_constant :CAPTURE_FAILED
8
+
6
9
  private
7
10
 
11
+ def ensure_capture_ready!
12
+ return unless self.class.capture_result && self.class.capture_result_strict?
13
+
14
+ capture_collector(true)
15
+ end
16
+
8
17
  def capture_result_if_configured(kwargs, result)
9
18
  block = self.class.capture_result
10
19
  return unless block
11
20
 
12
- collector = self.class.current_tool_result_collector
21
+ capture_with_policy(block, kwargs, result, self.class.capture_result_strict?)
22
+ end
23
+
24
+ def capture_with_policy(block, kwargs, result, strict)
25
+ collector = capture_collector(strict)
13
26
  return unless collector
14
27
 
15
- captured = block.call(kwargs, result)
16
- collector.call({ tool: name.to_s, captured: captured }) if captured
28
+ captured = captured_value(block, kwargs, result, strict)
29
+ return if captured.equal?(CAPTURE_FAILED)
30
+
31
+ append_capture(collector, captured, strict)
32
+ end
33
+
34
+ def capture_collector(strict)
35
+ collector = self.class.current_tool_result_collector
36
+ return collector unless strict
37
+ return collector if collector.respond_to?(:call)
38
+
39
+ reason = collector.nil? ? :collector_missing : :collector_invalid
40
+ raise tool_capture_failed(reason)
41
+ end
42
+
43
+ def captured_value(block, kwargs, result, strict)
44
+ captured = call_capture_block(block, kwargs, result, strict)
45
+ return captured if captured.equal?(CAPTURE_FAILED)
46
+ return captured unless captured.nil? && strict
47
+
48
+ raise tool_capture_failed(:capture_empty)
49
+ end
50
+
51
+ def call_capture_block(block, kwargs, result, strict)
52
+ block.call(kwargs, result)
17
53
  rescue StandardError => e
18
- Smith.config.logger&.warn("[Smith] capture_result failed for #{name}: #{e.message}")
54
+ capture_failure(e, strict, reason: :capture_block_failed)
55
+ end
56
+
57
+ def append_capture(collector, captured, strict)
58
+ collector.call({ tool: name.to_s, captured: captured }) if strict || captured
59
+ rescue StandardError => e
60
+ capture_failure(e, strict, reason: :collector_failed)
61
+ end
62
+
63
+ def capture_failure(error, strict, reason:)
64
+ return CAPTURE_FAILED.tap { log_capture_failure(error) } unless strict
65
+
66
+ raise tool_capture_failed(reason), cause: error
67
+ end
68
+
69
+ def tool_capture_failed(reason)
70
+ ToolCaptureFailed.for_runtime(tool_name: name, reason:)
71
+ end
72
+
73
+ def log_capture_failure(error)
74
+ Smith.config.logger&.warn("[Smith] capture_result failed for #{name}: #{error.message}")
19
75
  end
20
76
  end
21
77
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module CaptureConfiguration
6
+ def capture_result(strict: false, &block)
7
+ return @capture_result unless block
8
+
9
+ raise ArgumentError, "capture_result strict must be true or false" unless [true, false].include?(strict)
10
+
11
+ @capture_result_strict = strict
12
+ @capture_result = block
13
+ end
14
+
15
+ def capture_result_strict?
16
+ @capture_result_strict == true
17
+ end
18
+ end
19
+ end
20
+ 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
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ChatExecutionContext
6
+ include BoundedCompletionInstallation
7
+ include ExecutionFailureHandling
8
+ include ExecutionDispatch
9
+ include ChatExecutionCallbacks
10
+ include ExecutionBatchLifecycle
11
+
12
+ def self.install(chat)
13
+ return chat unless chat.respond_to?(:tools) && chat.tools.respond_to?(:values)
14
+ unless chat.respond_to?(:execute_tool, true)
15
+ raise Error, "unsupported RubyLLM chat execution interface: missing #execute_tool"
16
+ end
17
+
18
+ chat.extend(self) unless chat.singleton_class < self
19
+ chat.__send__(:smith_tool_execution_batches)
20
+ chat
21
+ end
22
+ end
23
+ end
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