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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ExecutionBatchSources
8
+ extend Dry::Initializer
9
+
10
+ param :entries
11
+
12
+ def call
13
+ bytes = 0
14
+ metadata = entries.each_with_object([]) do |(key, tool_call), captured|
15
+ source = ExecutionBatchSourceMetadata.capture(key:, tool_call:)
16
+ bytes += source.metadata_byte_count
17
+ validate_bytes!(bytes)
18
+ captured << source
19
+ end.freeze
20
+ metadata.map(&:to_source_call).freeze
21
+ end
22
+
23
+ private
24
+
25
+ def validate_bytes!(bytes)
26
+ return if bytes <= ExecutionBatchSourceCall::MAX_METADATA_BYTES
27
+
28
+ raise Error, "provider tool batch metadata exceeds #{ExecutionBatchSourceCall::MAX_METADATA_BYTES} bytes"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Tool < RubyLLM::Tool
7
+ class ExecutionBatchState
8
+ SETTLED_STATES = %i[executed failure_notified failure_notifying].freeze
9
+ private_constant :SETTLED_STATES
10
+
11
+ extend Dry::Initializer
12
+
13
+ option :requests
14
+
15
+ def initialize(...)
16
+ super
17
+ @states = requests.each_key.to_h { [_1, :pending] }.compare_by_identity
18
+ @dispatch_claims = {}.compare_by_identity
19
+ @notification_attempted = {}.compare_by_identity
20
+ @unsettled_tool_calls = requests.each_key.to_a.freeze
21
+ @unsettled_index = 0
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ def claim_dispatch!(tool_call)
26
+ @mutex.synchronize do
27
+ unless @states[tool_call] == :pending
28
+ raise ToolDispatchRejected, "admitted tool invocation was already claimed"
29
+ end
30
+
31
+ claim = Object.new.freeze
32
+ @dispatch_claims[tool_call] = claim
33
+ @states[tool_call] = :claimed
34
+ claim
35
+ end
36
+ end
37
+
38
+ def dispatch_claimed?(tool_call, claim)
39
+ @mutex.synchronize do
40
+ @dispatch_claims[tool_call].equal?(claim) && %i[claimed started].include?(@states[tool_call])
41
+ end
42
+ end
43
+
44
+ def mark_started!(tool_call, claim:) = transition_dispatch!(tool_call, claim, from: :claimed, to: :started)
45
+
46
+ def mark_executed!(tool_call, claim:) = transition_dispatch!(tool_call, claim, from: :started, to: :executed)
47
+
48
+ def started?(tool_call) = @mutex.synchronize { %i[started executed].include?(@states[tool_call]) }
49
+
50
+ def claim_failure_request(tool_call)
51
+ @mutex.synchronize do
52
+ state = @states[tool_call]
53
+ return if state.nil? || SETTLED_STATES.include?(state) || @notification_attempted.key?(tool_call)
54
+
55
+ claim_notification!(tool_call)
56
+ [requests.fetch(tool_call), state]
57
+ end
58
+ end
59
+
60
+ def claim_unsettled_request
61
+ @mutex.synchronize do
62
+ while @unsettled_index < @unsettled_tool_calls.length
63
+ tool_call = next_unsettled_tool_call
64
+ state = @states[tool_call]
65
+ next if SETTLED_STATES.include?(state) || @notification_attempted.key?(tool_call)
66
+
67
+ claim_notification!(tool_call)
68
+ return [tool_call, requests.fetch(tool_call), state]
69
+ end
70
+ nil
71
+ end
72
+ end
73
+
74
+ def complete_failure_notification!(tool_call)
75
+ @mutex.synchronize do
76
+ @states[tool_call] = :failure_notified if @states[tool_call] == :failure_notifying
77
+ end
78
+ end
79
+
80
+ def release_failure_notification!(tool_call, state)
81
+ @mutex.synchronize do
82
+ @states[tool_call] = state if @states[tool_call] == :failure_notifying
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def next_unsettled_tool_call
89
+ tool_call = @unsettled_tool_calls[@unsettled_index]
90
+ @unsettled_index += 1
91
+ tool_call
92
+ end
93
+
94
+ def claim_notification!(tool_call)
95
+ @notification_attempted[tool_call] = true
96
+ @states[tool_call] = :failure_notifying
97
+ end
98
+
99
+ def transition_dispatch!(tool_call, claim, from:, to:)
100
+ @mutex.synchronize do
101
+ unless @dispatch_claims[tool_call].equal?(claim) && @states[tool_call] == from
102
+ raise ToolDispatchRejected, "admitted tool invocation has an invalid dispatch state"
103
+ end
104
+
105
+ @states[tool_call] = to
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ExecutionDispatch
6
+ private
7
+
8
+ def execute_tool(tool_call)
9
+ batch = execution_batch(tool_call)
10
+ return super unless batch
11
+
12
+ dispatch_call = batch.dispatch_for(tool_call)
13
+ return execute_unmanaged_tool(batch, tool_call) { super } unless batch.request_for(dispatch_call)
14
+
15
+ execute_managed_tool(batch, dispatch_call) { super(dispatch_call) }
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ record_batch_failure(batch, e) if batch
18
+ raise
19
+ end
20
+
21
+ def execute_managed_tool(batch, dispatch_call, &)
22
+ current_tool = tools[dispatch_call.name.to_sym]
23
+ claim = dispatch_claim(batch, dispatch_call)
24
+ Tool::ScopedContext.around(dispatch_context(batch, dispatch_call, current_tool, claim)) do
25
+ execute_admitted_tool(batch, dispatch_call, current_tool:, claim:, &)
26
+ end
27
+ end
28
+
29
+ def dispatch_claim(batch, dispatch_call)
30
+ Tool::ScopedContext.capture.fetch(:current_tool_dispatch_claim) || batch.claim_dispatch!(dispatch_call)
31
+ end
32
+
33
+ def dispatch_context(batch, dispatch_call, current_tool, claim)
34
+ request = batch.request_for(dispatch_call)
35
+ batch.context.merge(
36
+ current_invocation: request.invocation,
37
+ current_tool_dispatch_claim: claim,
38
+ current_tool_dispatch_start_handler: dispatch_start_handler(batch, dispatch_call, current_tool, claim)
39
+ ).freeze
40
+ end
41
+
42
+ def dispatch_start_handler(batch, dispatch_call, current_tool, claim)
43
+ lambda do |executing_tool|
44
+ batch.mark_started!(dispatch_call, claim:) if executing_tool.equal?(current_tool)
45
+ end
46
+ end
47
+
48
+ def execute_unmanaged_tool(batch, tool_call)
49
+ Tool::ScopedContext.around(batch.context) do
50
+ mark_unmanaged_tool_dispatch_started!(tool_call)
51
+ yield
52
+ end
53
+ end
54
+
55
+ def execute_admitted_tool(batch, tool_call, current_tool:, claim:, &)
56
+ batch.verify_dispatch!(tool_call, current_tool:, claim:)
57
+ return yield if current_tool.__send__(:invocation_argument_error, tool_call.arguments)
58
+
59
+ result = dispatch_with_admission(current_tool, claim, batch.admission_for(tool_call), &)
60
+ batch.mark_executed!(tool_call, claim:)
61
+ result
62
+ end
63
+
64
+ def dispatch_with_admission(tool, claim, admission, &block)
65
+ ExecutionAuthority.around(tool:, dispatch_claim: claim) do
66
+ admission ? CallAdmission.around(admission, &block) : block.call
67
+ end
68
+ end
69
+
70
+ def mark_unmanaged_tool_dispatch_started!(tool_call)
71
+ tool = tools[tool_call.name.to_sym]
72
+ return unless tool.is_a?(RubyLLM::Tool) && !tool.is_a?(Smith::Tool)
73
+
74
+ Tool.current_tool_execution_tracker&.mark_started!
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ExecutionFailureHandling
6
+ private
7
+
8
+ def notify_invocation_failure(batch, tool_call, error)
9
+ return unless batch&.host_admitted?
10
+
11
+ entry = batch&.claim_failure_request(tool_call)
12
+ return unless entry
13
+
14
+ request, state = entry
15
+ batch.context.fetch(:current_invocation_failure_handler)&.call(request:, error:)
16
+ batch.complete_failure_notification!(tool_call)
17
+ rescue StandardError => e
18
+ batch.release_failure_notification!(tool_call, state)
19
+ raise ToolFailureNotificationFailed.new(
20
+ dispatch_error: error,
21
+ notification_error: e
22
+ ), cause: e
23
+ end
24
+
25
+ def notify_unsettled_batch_failures(batch, error)
26
+ return unless batch&.host_admitted?
27
+
28
+ handler = batch.context.fetch(:current_invocation_failure_handler)
29
+ handler_error = notify_unsettled_requests(batch, error, handler)
30
+ raise handler_error if handler_error
31
+ end
32
+
33
+ def notify_unsettled_requests(batch, error, handler)
34
+ first_error = nil
35
+ while (entry = batch.claim_unsettled_request)
36
+ tool_call, request, state = entry
37
+ failure = batch_dispatch_failure(error, state)
38
+ begin
39
+ handler&.call(request:, error: failure)
40
+ batch.complete_failure_notification!(tool_call)
41
+ rescue StandardError => e
42
+ batch.release_failure_notification!(tool_call, state)
43
+ first_error ||= ToolFailureNotificationFailed.new(
44
+ dispatch_error: failure,
45
+ notification_error: e
46
+ )
47
+ end
48
+ end
49
+ first_error
50
+ end
51
+
52
+ def batch_dispatch_failure(error, state)
53
+ return error.dispatch_error if error.is_a?(ToolFailureNotificationFailed)
54
+ return error if state == :started || exact_dispatch_failure?(error)
55
+
56
+ ToolDispatchRejected.new("tool batch dispatch failed before execution")
57
+ end
58
+
59
+ def invocation_dispatch_failure(batch, tool_call, error)
60
+ return error if exact_dispatch_failure?(error) || batch&.started?(tool_call)
61
+ return error unless batch&.host_admitted?
62
+
63
+ ToolDispatchRejected.new("tool invocation failed before execution")
64
+ end
65
+
66
+ def exact_dispatch_failure?(error)
67
+ error.is_a?(ToolDispatchRejected) || error.is_a?(ToolExecutionNotAdmitted)
68
+ end
69
+
70
+ def record_batch_failure(batch, error)
71
+ if !error.is_a?(StandardError)
72
+ batch.fatal_failures.push(error)
73
+ elsif error.is_a?(ToolFailureNotificationFailed)
74
+ batch.notification_failures.push(error)
75
+ elsif error.is_a?(ToolCaptureFailed)
76
+ batch.capture_failures.push(error)
77
+ elsif Smith::Errors.retry_forbidden?(error)
78
+ batch.terminal_failures.push(error)
79
+ end
80
+ end
81
+
82
+ def prioritized_batch_failure(batch)
83
+ return unless batch
84
+
85
+ %i[fatal_failures notification_failures capture_failures terminal_failures].each do |queue|
86
+ failure = first_failure(batch.public_send(queue))
87
+ return failure if failure
88
+ end
89
+ nil
90
+ end
91
+
92
+ def first_failure(queue)
93
+ return unless queue
94
+
95
+ queue.pop(true)
96
+ rescue ThreadError
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module ExecutionLifecycle
6
+ private
7
+
8
+ def prepare_tool_execution!(kwargs)
9
+ ensure_capture_ready!
10
+ run_before_execute_hook!(kwargs)
11
+ check_dispatch_deadline!
12
+ check_privilege!(kwargs)
13
+ check_authorization!(kwargs)
14
+ run_tool_guardrails!(kwargs)
15
+ check_dispatch_deadline!
16
+ charge_tool_call!
17
+ mark_tool_execution_started!
18
+ end
19
+
20
+ def perform_with_duration(kwargs)
21
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ result = perform(**kwargs)
23
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
24
+ [result, duration]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class ExecutionTracker
6
+ def initialize
7
+ @started = false
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def mark_started!
12
+ @mutex.synchronize { @started = true }
13
+ end
14
+
15
+ def started?
16
+ @mutex.synchronize { @started }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module FailFastCompletion
6
+ private
7
+
8
+ def complete_with_fail_fast_policy(state, &)
9
+ with_bounded_tool_call_preference(state.allowance.remaining, &)
10
+ end
11
+
12
+ def dispatch_fail_fast_calls(response, tool_calls, state)
13
+ result = if concurrency
14
+ handle_concurrent_tool_calls(tool_calls)
15
+ else
16
+ handle_sequential_tool_calls(tool_calls)
17
+ end
18
+ reset_tool_choice if forced_tool_choice?
19
+ state.request_continuation! unless result
20
+ result || response
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ module GracefulCompletion
6
+ private
7
+
8
+ def complete_without_tools(state, &)
9
+ unless state.begin_finalization == :started
10
+ raise BoundedCompletionError, "tool-disabled budget finalization may run only once"
11
+ end
12
+
13
+ with_tools_disabled(&)
14
+ rescue Exception # rubocop:disable Lint/RescueException
15
+ state.abort_finalization!
16
+ raise
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
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 Invocation < Dry::Struct
10
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
11
+ private_constant :OwnedString
12
+
13
+ attribute :tool_call_id, OwnedString.optional
14
+ attribute :tool_name, OwnedString.constrained(min_size: 1)
15
+ attribute :ordinal, Types::Integer.constrained(gt: 0)
16
+ attribute :batch_ordinal, Types::Integer.constrained(gt: 0)
17
+ attribute :batch_size, Types::Integer.constrained(gt: 0)
18
+
19
+ def initialize(...)
20
+ super
21
+ raise ArgumentError, "tool invocation batch ordinal exceeds its batch size" if batch_ordinal > batch_size
22
+
23
+ freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
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 InvocationRequest < Dry::Struct
10
+ MAX_ARGUMENT_DEPTH = ArgumentSnapshot::MAX_DEPTH
11
+ MAX_ARGUMENT_NODES = ArgumentSnapshot::MAX_NODES
12
+ MAX_ARGUMENT_BYTES = ArgumentSnapshot::MAX_BYTES
13
+
14
+ ToolClass = Types::Any.constructor do |value|
15
+ raise TypeError, "expected a Smith::Tool subclass" unless value.is_a?(Class) && value <= Smith::Tool
16
+
17
+ value
18
+ end
19
+ private_constant :ToolClass
20
+
21
+ attribute :invocation, Types.Instance(Invocation)
22
+ attribute :tool_class, ToolClass
23
+ attribute :arguments, Types::Hash
24
+
25
+ attr_reader :argument_node_count, :argument_byte_count
26
+
27
+ def initialize(...)
28
+ super
29
+ snapshot = ArgumentSnapshot.new(arguments).call
30
+ @argument_node_count = snapshot.node_count
31
+ @argument_byte_count = snapshot.byte_count
32
+ @attributes = @attributes.merge(arguments: snapshot.value).freeze
33
+ freeze
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class InvocationSequence
6
+ def initialize(next_ordinal: 1)
7
+ unless next_ordinal.is_a?(Integer) && next_ordinal.positive?
8
+ raise ArgumentError, "next tool invocation ordinal must be positive"
9
+ end
10
+
11
+ @next_ordinal = next_ordinal
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def reserve(size)
16
+ raise ArgumentError, "tool invocation batch size must be positive" unless size.is_a?(Integer) && size.positive?
17
+
18
+ @mutex.synchronize do
19
+ first = @next_ordinal
20
+ @next_ordinal += size
21
+ first
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ class LegacyCallAllowance
6
+ MUTEX = Mutex.new
7
+ ALLOWANCE_MUTEXES = ObjectSpace::WeakMap.new
8
+ private_constant :MUTEX, :ALLOWANCE_MUTEXES
9
+
10
+ def self.charge!(allowance)
11
+ allowance_mutex(allowance).synchronize do
12
+ Thread.handle_interrupt(Object => :never) do
13
+ remaining = allowance[:remaining]
14
+ unless remaining.is_a?(Integer) && remaining.positive?
15
+ raise BudgetExceeded, "agent tool_calls budget exceeded"
16
+ end
17
+
18
+ yield if block_given?
19
+ allowance[:remaining] = remaining - 1
20
+ end
21
+ end
22
+ end
23
+
24
+ def self.reserve_batch(allowance, size, ledger: nil)
25
+ allowance_mutex(allowance).synchronize do
26
+ Thread.handle_interrupt(Object => :never) do
27
+ reserve_synchronized(allowance, size, ledger)
28
+ end
29
+ end
30
+ rescue BudgetExceeded
31
+ nil
32
+ end
33
+
34
+ def self.allowance_mutex(allowance)
35
+ MUTEX.synchronize { ALLOWANCE_MUTEXES[allowance] ||= Mutex.new }
36
+ end
37
+ private_class_method :allowance_mutex
38
+
39
+ def self.reserve_synchronized(allowance, size, ledger)
40
+ remaining = allowance[:remaining]
41
+ return unless remaining.is_a?(Integer) && remaining >= size
42
+
43
+ workflow_ledger = ledger if ledger&.limits&.key?(:tool_calls)
44
+ ledger_reservation = workflow_ledger&.reserve!(:tool_calls, size)
45
+ allowance[:remaining] = remaining - size
46
+ CallReservation.new(
47
+ limit: size,
48
+ ledger: workflow_ledger,
49
+ ledger_reservation:
50
+ )
51
+ end
52
+ private_class_method :reserve_synchronized
53
+
54
+ private_class_method :new
55
+ end
56
+ end
57
+ end
@@ -8,8 +8,15 @@ module Smith
8
8
  current_deadline: :smith_tool_deadline,
9
9
  current_ledger: :smith_tool_ledger,
10
10
  current_tool_call_allowance: :smith_tool_call_allowance,
11
+ current_tool_execution_tracker: :smith_tool_execution_tracker,
12
+ current_tool_dispatch_claim: :smith_tool_dispatch_claim,
13
+ current_tool_dispatch_start_handler: :smith_tool_dispatch_start_handler,
11
14
  current_tool_result_collector: :smith_tool_result_collector,
12
- current_invocation_context: :smith_tool_invocation_context
15
+ current_invocation_context: :smith_tool_invocation_context,
16
+ current_invocation_batch_admitter: :smith_tool_invocation_batch_admitter,
17
+ current_invocation_failure_handler: :smith_tool_invocation_failure_handler,
18
+ current_invocation_sequence: :smith_tool_invocation_sequence,
19
+ current_invocation: :smith_tool_invocation
13
20
  }.freeze
14
21
 
15
22
  CONTEXT_KEYS.each do |reader, key|
@@ -17,6 +24,18 @@ module Smith
17
24
  define_method("#{reader}=") { |value| Thread.current[key] = value }
18
25
  end
19
26
 
27
+ private :current_invocation_batch_admitter,
28
+ :current_invocation_batch_admitter=,
29
+ :current_invocation_failure_handler,
30
+ :current_invocation_failure_handler=,
31
+ :current_invocation_sequence,
32
+ :current_invocation_sequence=,
33
+ :current_tool_dispatch_claim,
34
+ :current_tool_dispatch_claim=,
35
+ :current_tool_dispatch_start_handler,
36
+ :current_tool_dispatch_start_handler=,
37
+ :current_invocation=
38
+
20
39
  def self.capture
21
40
  CONTEXT_KEYS.to_h { |reader, key| [reader, Thread.current[key]] }.freeze
22
41
  end
@@ -36,27 +55,60 @@ module Smith
36
55
  end
37
56
  end
38
57
 
39
- def with_invocation_context(value, &block)
58
+ def with_invocation_context(value, invocation_sequence: InvocationSequence.new, batch_admitter: nil,
59
+ failure_handler: nil, &block)
60
+ raise ArgumentError, "block required" unless block
61
+ unless invocation_sequence.is_a?(InvocationSequence)
62
+ raise ArgumentError, "invocation sequence must be a Smith::Tool::InvocationSequence"
63
+ end
64
+
65
+ validate_callback!(batch_admitter, "batch admitter")
66
+ validate_callback!(failure_handler, "failure handler")
67
+ if batch_admitter && !failure_handler
68
+ raise ArgumentError, "invocation failure handler is required when a batch admitter is configured"
69
+ end
70
+
71
+ values = ScopedContext.capture.merge(
72
+ current_invocation_context: value,
73
+ current_invocation_batch_admitter: batch_admitter,
74
+ current_invocation_failure_handler: failure_handler,
75
+ current_invocation_sequence: invocation_sequence,
76
+ current_invocation: nil
77
+ ).freeze
78
+ ScopedContext.around(values, &block)
79
+ end
80
+
81
+ def with_call_budget(budget, on_exhaustion: :raise, &block)
40
82
  raise ArgumentError, "block required" unless block
41
83
 
42
- previous = current_invocation_context
84
+ previous = current_tool_call_allowance
85
+ if previous && !previous.is_a?(CallAllowance)
86
+ raise ArgumentError, "a scoped tool call budget cannot nest under a legacy Hash tool call allowance"
87
+ end
88
+
89
+ allowance = previous ? previous.scope(budget, on_exhaustion:) : CallAllowance.new(budget, on_exhaustion:)
43
90
  Thread.handle_interrupt(Object => :never) do
44
- self.current_invocation_context = value
91
+ self.current_tool_call_allowance = allowance
45
92
  begin
46
93
  Thread.handle_interrupt(Object => :immediate, &block)
47
94
  ensure
48
- self.current_invocation_context = previous
95
+ self.current_tool_call_allowance = previous
49
96
  end
50
97
  end
51
98
  end
52
99
 
53
100
  def self.install(values)
54
- CONTEXT_KEYS.each do |reader, key|
55
- Thread.current[key] = values.fetch(reader)
56
- end
101
+ CONTEXT_KEYS.each { |reader, key| Thread.current[key] = values.fetch(reader) }
57
102
  end
58
103
  private_class_method :install
59
104
 
105
+ def validate_callback!(callback, name)
106
+ return if callback.nil? || callback.respond_to?(:call)
107
+
108
+ raise ArgumentError, "invocation #{name} must respond to #call"
109
+ end
110
+ private :validate_callback!
111
+
60
112
  def self.validate!(values)
61
113
  complete = values.is_a?(Hash) && values.length == CONTEXT_KEYS.length && CONTEXT_KEYS.each_key.all? do |key|
62
114
  values.key?(key)