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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ArgumentSnapshotTraversal
8
+ MAX_DEPTH = 100
9
+ MAX_NODES = 100_000
10
+ MAX_BYTES = 1 * 1024 * 1024
11
+
12
+ extend Dry::Initializer
13
+
14
+ param :value
15
+
16
+ def call
17
+ initialize_traversal
18
+ result = copy
19
+ ArgumentSnapshotResult.new(
20
+ value: result,
21
+ node_count: @accounting.node_count,
22
+ byte_count: @accounting.byte_count
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def initialize_traversal
29
+ @active = {}.compare_by_identity
30
+ @copies = {}.compare_by_identity
31
+ @metrics = {}.compare_by_identity
32
+ @accounting = ArgumentSnapshotAccounting.new(
33
+ max_depth: MAX_DEPTH,
34
+ max_nodes: MAX_NODES,
35
+ max_bytes: MAX_BYTES
36
+ )
37
+ @scalar_snapshot = ArgumentScalarSnapshot.new(
38
+ byte_counter: @accounting.method(:add_bytes!),
39
+ byte_validator: @accounting.method(:validate_bytes!)
40
+ )
41
+ @container_reader = ArgumentContainerReader.new(
42
+ scalar_snapshot: @scalar_snapshot,
43
+ byte_count: -> { @accounting.byte_count }
44
+ )
45
+ end
46
+
47
+ def copy
48
+ result = nil
49
+ pending = [[:visit, value, nil, nil, 0, nil]]
50
+
51
+ until pending.empty?
52
+ entry = pending.pop
53
+ if entry.first == :finish
54
+ finish_container!(entry)
55
+ next
56
+ end
57
+
58
+ _, source, parent, key, depth, parent_metrics = entry
59
+ @accounting.visit!(depth)
60
+ type = @container_reader.type(source)
61
+ copied = if type
62
+ copy_container(source, pending, depth, type, parent_metrics)
63
+ else
64
+ copy_scalar(source, parent_metrics)
65
+ end
66
+ parent ? parent[key] = copied : result = copied
67
+ end
68
+
69
+ result
70
+ end
71
+
72
+ def copy_container(source, pending, depth, type, parent_metrics)
73
+ raise Error, "tool arguments contain a cyclic value" if @active.key?(source)
74
+ return copy_reused_container(source, depth, parent_metrics) if @copies.key?(source)
75
+
76
+ size = @container_reader.size(source, type)
77
+ @accounting.reserve_children!(size)
78
+ captured = @container_reader.snapshot(source, type)
79
+ unless @container_reader.size(captured, type) == size
80
+ raise Error, "tool argument container changed while it was being captured"
81
+ end
82
+
83
+ target = @container_reader.target(type, size)
84
+ metrics = { node_count: 1, byte_count: 0, max_depth: 0 }
85
+ @copies[source] = target
86
+ @active[source] = true
87
+ pending << [:finish, source, target, parent_metrics, metrics]
88
+ @container_reader.append_children(pending:, source: captured, target:, depth:, metrics:)
89
+ target
90
+ end
91
+
92
+ def finish_container!(entry)
93
+ _, source, target, parent_metrics, metrics = entry
94
+ @active.delete(source)
95
+ target.freeze
96
+ @metrics[source] = metrics.freeze
97
+ @accounting.propagate!(parent_metrics, metrics)
98
+ end
99
+
100
+ def copy_reused_container(source, depth, parent_metrics)
101
+ metrics = @metrics.fetch(source)
102
+ @accounting.account_reused!(metrics, depth)
103
+ @accounting.propagate!(parent_metrics, metrics)
104
+ @copies.fetch(source)
105
+ end
106
+
107
+ def copy_scalar(source, parent_metrics)
108
+ previous_bytes = @accounting.byte_count
109
+ copied = @scalar_snapshot.copy(source)
110
+ if parent_metrics
111
+ parent_metrics[:node_count] += 1
112
+ parent_metrics[:byte_count] += @accounting.byte_count - previous_bytes
113
+ parent_metrics[:max_depth] = [parent_metrics[:max_depth], 1].max
114
+ end
115
+ copied
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module BoundedCompletionContext
6
+ include BoundedCompletionControls
7
+ include FailFastCompletion
8
+ include GracefulCompletion
9
+
10
+ SUPPORTED_RUBY_LLM = Gem::Requirement.new("= 1.16.0")
11
+ REQUIRED_HOOKS = %i[
12
+ add_tool_result_message forced_tool_choice? handle_concurrent_tool_calls handle_sequential_tool_calls
13
+ params reset_tool_choice run_callbacks
14
+ ].freeze
15
+
16
+ private_constant :SUPPORTED_RUBY_LLM, :REQUIRED_HOOKS
17
+
18
+ def self.install(chat)
19
+ version = Gem::Version.new(RubyLLM::VERSION)
20
+ raise Error, "bounded completion requires RubyLLM 1.16.0" unless SUPPORTED_RUBY_LLM.satisfied_by?(version)
21
+
22
+ missing = REQUIRED_HOOKS.reject { |hook| chat.respond_to?(hook, true) }
23
+ valid = missing.empty? && chat.respond_to?(:tool_prefs) && chat.tool_prefs.respond_to?(:replace)
24
+ raise Error, "unsupported RubyLLM tool-loop interface for bounded completion" unless valid
25
+
26
+ chat.extend(self) unless chat.singleton_class < self
27
+ chat.__send__(:install_bounded_completion_context)
28
+ chat
29
+ end
30
+
31
+ def complete(&stream)
32
+ allowance = Tool.current_tool_call_allowance
33
+ owner = [Thread.current, Fiber.current]
34
+ iterative = allowance.is_a?(CallAllowance)
35
+
36
+ bounded_completion_guard.around_completion(owner, reentrant: !iterative) do
37
+ return super(&stream) unless iterative
38
+
39
+ validate_reserved_params!
40
+ validate_supported_tools! if allowance.complete_on_exhaustion?
41
+ state = bounded_completion_guard.state_for(allowance)
42
+ loop do
43
+ result = complete_with_budget_policy(state) { super(&stream) }
44
+ return result unless state.consume_continuation!
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def install_bounded_completion_context
52
+ return if @smith_bounded_completion_guard
53
+
54
+ @smith_bounded_completion_guard = BoundedCompletionGuard.new
55
+ end
56
+
57
+ def complete_with_budget_policy(state, &completion)
58
+ return complete_with_fail_fast_policy(state, &completion) unless state.allowance.complete_on_exhaustion?
59
+
60
+ if state.finalization_required? || state.allowance.remaining.zero?
61
+ complete_without_tools(state, &completion)
62
+ else
63
+ with_bounded_tool_call_preference(state.allowance.remaining) do
64
+ with_sequential_tool_execution(&completion)
65
+ end
66
+ end
67
+ end
68
+
69
+ def handle_tool_calls(response, &)
70
+ state = current_bounded_completion_state
71
+ return super unless state
72
+
73
+ tool_calls = validated_tool_calls(response)
74
+ return dispatch_fail_fast_calls(response, tool_calls, state) unless state.allowance.complete_on_exhaustion?
75
+ return reject_finalization_violation(tool_calls, state) if state.finalization_started?
76
+
77
+ admitted, result = dispatch_admitted_calls(response, tool_calls, state)
78
+ return result if admitted
79
+
80
+ complete_rejected_batch(response, tool_calls, state)
81
+ end
82
+
83
+ def execute_tool(tool_call)
84
+ admission = bounded_completion_guard.admission_for(tool_call)
85
+ return super unless admission
86
+
87
+ CallAdmission.around(admission) { super }
88
+ end
89
+
90
+ def current_bounded_completion_state
91
+ allowance = Tool.current_tool_call_allowance
92
+ return unless allowance.is_a?(CallAllowance)
93
+
94
+ bounded_completion_guard.state_for(allowance)
95
+ end
96
+
97
+ def dispatch_admitted_calls(response, tool_calls, state)
98
+ result = nil
99
+ admitted = bounded_completion_guard.with_admitted_calls(
100
+ tool_calls,
101
+ tools:,
102
+ allowance: state.allowance,
103
+ ledger: Tool.current_ledger
104
+ ) do
105
+ result = handle_sequential_tool_calls(tool_calls)
106
+ end
107
+ return [false, nil] unless admitted
108
+
109
+ reset_tool_choice if forced_tool_choice?
110
+ state.request_continuation! unless result
111
+ [true, result || response]
112
+ end
113
+
114
+ def complete_rejected_batch(response, tool_calls, state)
115
+ state.request_finalization!
116
+ append_budget_rejections(tool_calls, state.allowance)
117
+ reset_tool_choice if forced_tool_choice?
118
+ state.request_continuation!
119
+ response
120
+ end
121
+
122
+ def bounded_completion_guard
123
+ @smith_bounded_completion_guard || raise(Error, "bounded completion context is not installed")
124
+ end
125
+
126
+ def smith_tool_call_admissions(tool_calls)
127
+ bounded_completion_guard.admissions_for(tool_calls)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module BoundedCompletionControls
6
+ RESERVED_TOOL_PARAM_KEYS = %w[
7
+ parallel_tool_calls tool_choice tool_config toolConfig tools
8
+ ].freeze
9
+
10
+ private_constant :RESERVED_TOOL_PARAM_KEYS
11
+
12
+ private
13
+
14
+ def append_budget_rejections(tool_calls, allowance)
15
+ error = nil
16
+ Thread.handle_interrupt(Object => :never) do
17
+ tool_calls.each_value do |tool_call|
18
+ begin
19
+ run_callbacks(:before_message, :new_message)
20
+ rescue Exception => e # rubocop:disable Lint/RescueException
21
+ error ||= e
22
+ end
23
+
24
+ begin
25
+ add_tool_result_message(tool_call, budget_rejection(allowance))
26
+ rescue Exception => e # rubocop:disable Lint/RescueException
27
+ error ||= e
28
+ end
29
+ end
30
+ end
31
+ raise error if error
32
+ end
33
+
34
+ def budget_rejection(allowance)
35
+ {
36
+ error: {
37
+ code: "tool_call_budget_exhausted",
38
+ message: "Tool call was not executed. Complete from available tool results.",
39
+ remaining: allowance.remaining
40
+ }
41
+ }
42
+ end
43
+
44
+ def validated_tool_calls(response)
45
+ ExecutionBatchCollection.capture(response.tool_calls)
46
+ end
47
+
48
+ def reject_finalization_violation(tool_calls, state)
49
+ append_budget_rejections(tool_calls, state.allowance)
50
+ raise BoundedCompletionError, "provider requested a tool during tool-disabled budget finalization"
51
+ end
52
+
53
+ def validate_supported_tools!
54
+ unsupported = tools.values.grep_v(Smith::Tool)
55
+ return if unsupported.empty?
56
+
57
+ names = unsupported.map(&:name).sort.join(", ")
58
+ raise BoundedCompletionError,
59
+ "tool_budget_exhaustion :complete requires Smith::Tool bindings; unsupported: #{names}"
60
+ end
61
+
62
+ def validate_reserved_params!
63
+ keys = params.respond_to?(:keys) ? params.keys.map(&:to_s) : []
64
+ reserved = keys & RESERVED_TOOL_PARAM_KEYS
65
+ return if reserved.empty?
66
+
67
+ raise BoundedCompletionError,
68
+ "bounded completion reserves provider tool params: #{reserved.sort.join(", ")}"
69
+ end
70
+
71
+ def with_bounded_tool_call_preference(remaining)
72
+ return yield unless tool_call_cardinality_supported?
73
+
74
+ original_calls = tool_prefs[:calls]
75
+ begin
76
+ tool_prefs[:calls] = remaining > 1 ? :many : :one
77
+ yield
78
+ ensure
79
+ tool_prefs[:calls] = original_calls
80
+ end
81
+ end
82
+
83
+ def tool_call_cardinality_supported?
84
+ current_params = params || {}
85
+ mode = current_params[:openai_api_mode] || current_params["openai_api_mode"]
86
+ return true if mode.to_s == "responses"
87
+
88
+ selected_model = respond_to?(:model) ? model : nil
89
+ selected_model&.supports?(:parallel_tool_calls) || false
90
+ end
91
+
92
+ def with_tools_disabled
93
+ original_tools = tools.dup
94
+ original_preferences = tool_prefs.dup
95
+ tools.clear
96
+ tool_prefs[:choice] = :none
97
+ tool_prefs[:calls] = tool_call_cardinality_supported? ? :one : nil
98
+ yield
99
+ ensure
100
+ tools.replace(original_tools) if original_tools
101
+ tool_prefs.replace(original_preferences) if original_preferences
102
+ end
103
+
104
+ def with_sequential_tool_execution
105
+ original = @concurrency
106
+ @concurrency = nil
107
+ yield
108
+ ensure
109
+ @concurrency = original
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class BoundedCompletionGuard
6
+ def initialize
7
+ @state = nil
8
+ @state_mutex = Mutex.new
9
+ @owner = nil
10
+ @depth = 0
11
+ @owner_mutex = Mutex.new
12
+ @admissions = nil
13
+ end
14
+
15
+ def around_completion(owner, reentrant:, &block)
16
+ Thread.handle_interrupt(Object => :never) do
17
+ enter(owner, reentrant:)
18
+ begin
19
+ Thread.handle_interrupt(Object => :immediate, &block)
20
+ ensure
21
+ leave(owner)
22
+ end
23
+ end
24
+ end
25
+
26
+ def state_for(allowance)
27
+ @state_mutex.synchronize do
28
+ return @state if @state&.allowance.equal?(allowance)
29
+
30
+ @state = BoundedCompletionState.new(allowance: allowance)
31
+ end
32
+ end
33
+
34
+ def with_admitted_calls(tool_calls, tools:, allowance:, ledger:, &block)
35
+ Thread.handle_interrupt(Object => :never) do
36
+ reservation = allowance.reserve_batch(tool_calls.each_value.map(&:name), ledger:)
37
+ return false unless reservation
38
+
39
+ previous = @admissions
40
+ @admissions = build_admissions(tool_calls, tools, reservation)
41
+ begin
42
+ Thread.handle_interrupt(Object => :immediate, &block)
43
+ ensure
44
+ begin
45
+ reservation.settle!
46
+ ensure
47
+ @admissions = previous
48
+ end
49
+ end
50
+ true
51
+ end
52
+ end
53
+
54
+ def admission_for(tool_call)
55
+ @admissions&.fetch(tool_call, nil)
56
+ end
57
+
58
+ def admissions_for(tool_calls)
59
+ tool_calls.each_with_object({}.compare_by_identity) do |tool_call, admissions|
60
+ admission = admission_for(tool_call)
61
+ admissions[tool_call] = admission if admission
62
+ end.freeze
63
+ end
64
+
65
+ private
66
+
67
+ def build_admissions(tool_calls, tools, reservation)
68
+ tool_calls.each_value.with_object({}.compare_by_identity) do |tool_call, admissions|
69
+ tool = tools[tool_call.name.to_sym]
70
+ next unless tool
71
+
72
+ admissions[tool_call] = CallAdmission.new(tool:, reservation:)
73
+ end
74
+ end
75
+
76
+ def enter(owner, reentrant:)
77
+ @owner_mutex.synchronize do
78
+ if @owner && (@owner != owner || !reentrant)
79
+ raise Error, "concurrent or reentrant completion on one bounded Smith chat is unsupported"
80
+ end
81
+
82
+ # Each outer completion derives fresh budget policy state, so a
83
+ # finalized earlier ask can never leak its terminal state into a
84
+ # later independently bounded completion on the same chat.
85
+ @state_mutex.synchronize { @state = nil } if @depth.zero?
86
+ @owner = owner
87
+ @depth += 1
88
+ end
89
+ end
90
+
91
+ def leave(owner)
92
+ @owner_mutex.synchronize do
93
+ return unless @owner == owner
94
+
95
+ @depth -= 1
96
+ @owner = nil if @depth.zero?
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module BoundedCompletionInstallation
6
+ def complete(&)
7
+ allowance = Tool.current_tool_call_allowance
8
+ return super unless allowance.is_a?(CallAllowance)
9
+ return super if singleton_class < BoundedCompletionContext
10
+
11
+ BoundedCompletionContext.install(self)
12
+ complete(&)
13
+ end
14
+
15
+ private
16
+
17
+ def smith_tool_call_admissions(_tool_calls) = nil
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class BoundedCompletionState
8
+ extend Dry::Initializer
9
+
10
+ option :allowance
11
+
12
+ def initialize(...)
13
+ super
14
+ @state = :open
15
+ @continuation_required = false
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def request_continuation!
20
+ @mutex.synchronize { @continuation_required = true }
21
+ end
22
+
23
+ def consume_continuation!
24
+ @mutex.synchronize do
25
+ required = @continuation_required
26
+ @continuation_required = false
27
+ required
28
+ end
29
+ end
30
+
31
+ def request_finalization!
32
+ @mutex.synchronize { @state = :required if @state == :open }
33
+ end
34
+
35
+ def begin_finalization
36
+ @mutex.synchronize do
37
+ return :active if @state == :finalizing
38
+
39
+ @state = :finalizing
40
+ :started
41
+ end
42
+ end
43
+
44
+ def abort_finalization!
45
+ @mutex.synchronize { @state = :required if @state == :finalizing }
46
+ end
47
+
48
+ def finalization_required?
49
+ @mutex.synchronize { @state == :required }
50
+ end
51
+
52
+ def finalization_started?
53
+ @mutex.synchronize { @state == :finalizing }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -9,15 +9,18 @@ module Smith
9
9
  allowance = self.class.current_tool_call_allowance
10
10
  ledger = self.class.current_ledger
11
11
  workflow_active = ledger&.limits&.key?(:tool_calls)
12
+ admission = CallAdmission.current
12
13
 
13
- if allowance.is_a?(CallAllowance)
14
- return allowance.charge! { commit_workflow_tool_call!(ledger, workflow_active) }
15
- end
16
- if allowance.is_a?(Hash)
17
- return CallAllowance.charge_legacy!(allowance) { commit_workflow_tool_call!(ledger, workflow_active) }
18
- end
14
+ return if admission&.claim(self)
19
15
 
20
- commit_workflow_tool_call!(ledger, workflow_active)
16
+ charge_agent_allowance!(allowance) { commit_workflow_tool_call!(ledger, workflow_active) }
17
+ end
18
+
19
+ def charge_agent_allowance!(allowance, &)
20
+ return allowance.charge!(name, &) if allowance.is_a?(CallAllowance)
21
+ return CallAllowance.charge_legacy!(allowance, &) if allowance.is_a?(Hash)
22
+
23
+ yield
21
24
  end
22
25
 
23
26
  def commit_workflow_tool_call!(ledger, workflow_active)
@@ -26,6 +29,11 @@ module Smith
26
29
  reservation = ledger.reserve!(:tool_calls, 1)
27
30
  ledger.reconcile!(reservation, 1)
28
31
  end
32
+
33
+ def mark_tool_execution_started!
34
+ self.class.current_tool_execution_tracker&.mark_started!
35
+ self.class.__send__(:current_tool_dispatch_start_handler)&.call(self)
36
+ end
29
37
  end
30
38
  end
31
39
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class CallAdmission
6
+ THREAD_KEY = :smith_tool_call_admission
7
+ private_constant :THREAD_KEY
8
+
9
+ def self.current
10
+ Thread.current[THREAD_KEY]
11
+ end
12
+
13
+ def self.around(admission, &block)
14
+ raise ArgumentError, "block required" unless block
15
+
16
+ previous = current
17
+ Thread.handle_interrupt(Object => :never) do
18
+ Thread.current[THREAD_KEY] = admission
19
+ begin
20
+ Thread.handle_interrupt(Object => :immediate, &block)
21
+ ensure
22
+ Thread.current[THREAD_KEY] = previous
23
+ end
24
+ end
25
+ end
26
+
27
+ def initialize(tool:, reservation:)
28
+ @tool = tool
29
+ @reservation = reservation
30
+ @available = true
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ def claim(tool)
35
+ @mutex.synchronize do
36
+ return false unless @available && @tool.equal?(tool)
37
+
38
+ @available = false
39
+ @reservation.claim
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end