llm.rb 16.0.0 → 17.0.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +510 -2
  3. data/README.md +857 -501
  4. data/data/alibaba.json +2 -2
  5. data/data/anthropic.json +66 -3
  6. data/data/bedrock.json +781 -12
  7. data/data/deepinfra.json +157 -1
  8. data/data/google.json +48 -0
  9. data/data/mistral.json +326 -16
  10. data/data/moonshot.json +2 -1
  11. data/data/openai.json +13 -0
  12. data/data/openrouter.json +1078 -1235
  13. data/data/xai.json +4 -2
  14. data/docs/deepdive/advanced/cancellation.md +35 -7
  15. data/docs/deepdive/advanced/compaction.md +5 -5
  16. data/docs/deepdive/advanced/context.md +45 -24
  17. data/docs/deepdive/advanced/{guard.md → intercept.md} +87 -83
  18. data/docs/deepdive/features/builtin_tools.md +1 -1
  19. data/docs/deepdive/features/concurrency.md +15 -10
  20. data/docs/deepdive/features/database.md +19 -19
  21. data/docs/deepdive/features/embeddings.md +4 -4
  22. data/docs/deepdive/features/files.md +7 -5
  23. data/docs/deepdive/fundamentals/agents.md +13 -8
  24. data/docs/deepdive/fundamentals/providers.md +1 -1
  25. data/docs/deepdive/fundamentals/schema.md +2 -2
  26. data/docs/deepdive/fundamentals/stream.md +8 -8
  27. data/docs/deepdive/fundamentals/tools.md +14 -14
  28. data/docs/deepdive/media/ocr.md +1 -1
  29. data/docs/deepdive/protocols/a2a.md +3 -3
  30. data/docs/deepdive/protocols/mcp.md +116 -7
  31. data/docs/deepdive/reference/cost.md +1 -1
  32. data/docs/deepdive/reference/response.md +1 -1
  33. data/docs/deepdive/reference/tracer.md +90 -22
  34. data/docs/deepdive.md +1 -1
  35. data/lib/llm/agent/interrupt.rb +35 -46
  36. data/lib/llm/agent/registry.rb +106 -0
  37. data/lib/llm/agent.rb +244 -269
  38. data/lib/llm/context.rb +63 -55
  39. data/lib/llm/function/array.rb +2 -1
  40. data/lib/llm/function/async/group.rb +2 -2
  41. data/lib/llm/function/async/task.rb +25 -48
  42. data/lib/llm/function/fiber/group.rb +2 -2
  43. data/lib/llm/function/fiber/task.rb +16 -30
  44. data/lib/llm/function/fork/group.rb +2 -2
  45. data/lib/llm/function/fork/job.rb +8 -4
  46. data/lib/llm/function/fork/task.rb +25 -30
  47. data/lib/llm/function/group.rb +33 -5
  48. data/lib/llm/function/ractor/group.rb +2 -2
  49. data/lib/llm/function/ractor/job.rb +8 -6
  50. data/lib/llm/function/ractor/task.rb +32 -9
  51. data/lib/llm/function/sequential/group.rb +2 -1
  52. data/lib/llm/function/sequential/task.rb +8 -1
  53. data/lib/llm/function/task.rb +52 -4
  54. data/lib/llm/function/thread/group.rb +2 -2
  55. data/lib/llm/function/thread/task.rb +36 -10
  56. data/lib/llm/function/window.rb +16 -0
  57. data/lib/llm/function.rb +14 -43
  58. data/lib/llm/intercept/multi.rb +40 -0
  59. data/lib/llm/intercept/null.rb +18 -0
  60. data/lib/llm/intercept.rb +96 -0
  61. data/lib/llm/message.rb +1 -1
  62. data/lib/llm/provider.rb +16 -1
  63. data/lib/llm/providers/anthropic/stream_parser.rb +1 -1
  64. data/lib/llm/providers/bedrock/stream_parser.rb +1 -1
  65. data/lib/llm/providers/deepseek/files.rb +49 -0
  66. data/lib/llm/providers/deepseek/request_adapter/completion.rb +15 -13
  67. data/lib/llm/providers/deepseek.rb +16 -2
  68. data/lib/llm/providers/google/stream_parser.rb +1 -1
  69. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -1
  70. data/lib/llm/providers/openai/stream_parser.rb +1 -1
  71. data/lib/llm/stream.rb +8 -7
  72. data/lib/llm/tool.rb +1 -1
  73. data/lib/llm/tools/utils.rb +1 -1
  74. data/lib/llm/tracer/logger.rb +42 -1
  75. data/lib/llm/tracer/multi.rb +144 -0
  76. data/lib/llm/tracer/null.rb +20 -0
  77. data/lib/llm/tracer/pretty_logger.rb +33 -2
  78. data/lib/llm/tracer/rescue.rb +110 -0
  79. data/lib/llm/tracer/telemetry.rb +69 -4
  80. data/lib/llm/tracer.rb +125 -48
  81. data/lib/llm/utils.rb +12 -2
  82. data/lib/llm/version.rb +1 -1
  83. data/lib/llm.rb +36 -1
  84. data/llm.gemspec +9 -4
  85. metadata +16 -35
  86. data/lib/llm/function/tracing.rb +0 -21
  87. data/lib/llm/guard/null.rb +0 -19
  88. data/lib/llm/guard.rb +0 -55
@@ -35,10 +35,10 @@ module LLM::Function::Ractor
35
35
  alias_method :cancel!, :interrupt!
36
36
 
37
37
  ##
38
+ # @see LLM::Function::Group#wait
38
39
  # @return [Array<LLM::Function::Return>]
39
40
  def wait
40
- spawn unless @spawned
41
- @tasks.map(&:wait)
41
+ super
42
42
  end
43
43
  alias_method :value, :wait
44
44
  end
@@ -14,13 +14,14 @@ class LLM::Function
14
14
  # @param [String] name
15
15
  # @param [Hash, Array, nil] arguments
16
16
  # @return [LLM::Function::Ractor::Job]
17
- def initialize(mailbox, result, runner_class, id, name, arguments)
17
+ def initialize(mailbox, result, runner_class, id, name, arguments, cookie)
18
18
  @mailbox = mailbox
19
19
  @result = result
20
20
  @runner_class = runner_class
21
21
  @id = id
22
22
  @name = name
23
23
  @arguments = arguments
24
+ @cookie = cookie
24
25
  end
25
26
 
26
27
  ##
@@ -70,7 +71,8 @@ class LLM::Function
70
71
  end
71
72
 
72
73
  def spawn
73
- @tool = ::Ractor.new(@mailbox, @runner_class, @id, @name, @arguments) do |mailbox, runner_class, id, name, arguments|
74
+ @tool = ::Ractor.new(@mailbox, @runner_class, @id, @name, @arguments, @cookie) do |mailbox, runner_class, id, name, arguments, cookie|
75
+ finished_at = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
74
76
  ##
75
77
  # Before the watcher exists, because an interrupt can arrive
76
78
  # first: it is a message, and it waits in the inbox until the
@@ -88,7 +90,7 @@ class LLM::Function
88
90
  runner = runner_class.new
89
91
  ::Thread.new do
90
92
  ##
91
- # Only the receive is guarded: it raises when the ractor this
93
+ # Only the receive is rescued: it raises when the ractor this
92
94
  # one talks to has gone, and nothing else here should be
93
95
  # answerable to that rescue. A hook that raises must not be
94
96
  # swallowed by it, and must not take the raise with it either -
@@ -127,11 +129,11 @@ class LLM::Function
127
129
  # work is done is a no-op rather than a reason to throw the
128
130
  # result away.
129
131
  window.finished!
130
- mailbox.send([:done, id, name, result])
132
+ mailbox.send([:done, id, name, result, finished_at.()])
131
133
  rescue LLM::Interrupt
132
- mailbox.send([:done, id, name, {cancelled: true, reason: "interrupted"}])
134
+ mailbox.send([:done, id, name, {interrupt: true, cookie:}, finished_at.()])
133
135
  rescue => ex
134
- mailbox.send([:done, id, name, {error: true, type: ex.class.name, message: ex.message}])
136
+ mailbox.send([:done, id, name, {error: true, type: ex.class.name, message: ex.message}, finished_at.()])
135
137
  end
136
138
  end
137
139
  end
@@ -10,6 +10,18 @@ class LLM::Function
10
10
  # @return [LLM::Function::Ractor::Mailbox]
11
11
  attr_reader :mailbox
12
12
 
13
+ ##
14
+ # @return [LLM::Tracer, nil]
15
+ attr_reader :tracer
16
+
17
+ ##
18
+ # @return [String]
19
+ attr_reader :cookie
20
+
21
+ ##
22
+ # @return [Object]
23
+ attr_reader :span
24
+
13
25
  ##
14
26
  # @param [LLM::Function] fn
15
27
  # @param [Hash] options
@@ -27,18 +39,18 @@ class LLM::Function
27
39
  @name = options.fetch(:name)
28
40
  @arguments = options.fetch(:arguments)
29
41
  @model = options.fetch(:model, nil)
30
- @tracer = options.fetch(:tracer, nil)
31
42
  @cancelled = false
43
+ @cookie = SecureRandom.hex
32
44
  end
33
45
 
34
46
  ##
35
47
  # @return [LLM::Function::Ractor::Task]
36
48
  def spawn
37
- return if @guarded
38
49
  @span = @tracer&.on_tool_start(
39
50
  id: @id, name: @name,
40
51
  arguments: @arguments, model: @model
41
52
  )
53
+ return if intercepted?
42
54
  ##
43
55
  # The result is delivered to a ractor of this task's own rather
44
56
  # than through its mailbox. The mailbox ractor answers `alive?`
@@ -101,13 +113,15 @@ class LLM::Function
101
113
  # {LLM::Function::Thread::Task#wait} answers from the thread's value.
102
114
  # @return [LLM::Function::Return]
103
115
  def wait
104
- return @guarded if @guarded
116
+ return @intercepted if intercepted?
105
117
  @result ||= begin
106
118
  spawn unless @mailbox
107
- id, name, value = mailbox.wait
108
- result = Return.new(id, name, value)
109
- @tracer&.on_tool_finish(result:, span: @span)
110
- result
119
+ id, name, value, @finished_at = mailbox.wait
120
+ if Hash === value and value[:interrupt] and value[:cookie] == @cookie
121
+ LLM::Interrupt.new
122
+ else
123
+ Return.new(id, name, value)
124
+ end
111
125
  end
112
126
  end
113
127
  alias_method :value, :wait
@@ -121,8 +135,17 @@ class LLM::Function
121
135
  private
122
136
 
123
137
  def build_task(result)
124
- ::Ractor.new(result, @runner_class, @id, @name, @arguments) do |result, runner_class, id, name, arguments|
125
- LLM::Function::Ractor::Job.new(::Ractor.current, result, runner_class, id, name, arguments).call
138
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ ::Ractor.new(result, @runner_class, @id, @name, @arguments, @cookie) do |result, runner_class, id, name, arguments, cookie|
140
+ LLM::Function::Ractor::Job.new(
141
+ ::Ractor.current,
142
+ result,
143
+ runner_class,
144
+ id,
145
+ name,
146
+ arguments,
147
+ cookie
148
+ ).call
126
149
  end
127
150
  end
128
151
  end
@@ -51,10 +51,11 @@ module LLM::Function::Sequential
51
51
  alias_method :cancel!, :interrupt!
52
52
 
53
53
  ##
54
+ # @see LLM::Function::Group#wait
54
55
  # @return [Array<LLM::Function::Return>]
55
56
  def wait
56
57
  @owner = Thread.current
57
- @tasks.map(&:wait)
58
+ super
58
59
  ensure
59
60
  @owner = nil
60
61
  end
@@ -29,7 +29,14 @@ module LLM::Function::Sequential
29
29
  ##
30
30
  # @return [LLM::Function::Return]
31
31
  def wait
32
- @result ||= @guarded || function.call
32
+ return @intercepted if intercepted?
33
+ @result = @result ? @result : begin
34
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ @result = LLM::Object.from(unbox: function.call)
36
+ @finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ @result
38
+ end
39
+ @result&.unbox
33
40
  end
34
41
  alias_method :value, :wait
35
42
 
@@ -11,18 +11,48 @@ class LLM::Function
11
11
  # @return [LLM::Function]
12
12
  attr_reader :function
13
13
 
14
+ ##
15
+ # The tracer for the task
16
+ # @return [LLM::Tracer, nil]
17
+ attr_reader :tracer
18
+
19
+ ##
20
+ # The span returned by {LLM::Tracer#on_tool_start}
21
+ # @return [Object]
22
+ attr_reader :span
23
+
24
+ ##
25
+ # Returns when a task started, or nil
26
+ # @note
27
+ # This method captures a monotonic clock
28
+ # time (Process::CLOCK_MONOTONIC) rather
29
+ # than a Time object.
30
+ # @return [Float, nil]
31
+ attr_reader :started_at
32
+
33
+ ##
34
+ # Returns when a task finished, or nil
35
+ # @note
36
+ # This method captures a monotonic clock
37
+ # time (Process::CLOCK_MONOTONIC) rather
38
+ # than a Time object.
39
+ # @return [Float, nil]
40
+ attr_reader :finished_at
41
+
14
42
  ##
15
43
  # @param [LLM::Function] fn
16
44
  # @param [Hash] options
17
45
  # An optional set of options that are specific
18
46
  # to a given concurrency strategy.
19
- # @option options [LLM::Function::Return, nil] :guarded
20
- # A blocked return produced by the function's guard, or the in-band
47
+ # @option options [LLM::Function::Return, nil] :intercepted
48
+ # A blocked return produced by the function's intercept, or the in-band
21
49
  # error for a tool that cannot be built. When set, the task yields it
22
50
  # without running the tool.
23
51
  def initialize(fn, options = {})
24
52
  @function = fn
25
- @guarded = options[:guarded]
53
+ @intercepted = options[:intercepted]
54
+ @tracer = options[:tracer]
55
+ @finished_at = nil
26
56
  end
27
57
 
28
58
  ##
@@ -48,8 +78,18 @@ class LLM::Function
48
78
  alias_method :cancel!, :interrupt!
49
79
 
50
80
  ##
81
+ # This method returns an instance of
82
+ # {LLM::Function::Return} or {LLM::Interrupt}
83
+ # when the task has received an interrupt.
84
+ #
85
+ # An exception that occurs inside a tool is
86
+ # rescued, and returned by this method as a
87
+ # {LLM::Function::Return} object. It can be
88
+ # distinguished from other returns through
89
+ # the {LLM::Function::Return#error?} method.
90
+ #
51
91
  # @abstract
52
- # @return [LLM::Function::Return]
92
+ # @return [LLM::Function::Return, LLM::Interrupt]
53
93
  def wait
54
94
  raise NotImplementedError
55
95
  end
@@ -61,5 +101,13 @@ class LLM::Function
61
101
  def group_class
62
102
  raise NotImplementedError
63
103
  end
104
+
105
+ ##
106
+ # Returns true when an intercept has
107
+ # returned {LLM::Function::Return}.
108
+ # @return [Boolean]
109
+ def intercepted?
110
+ LLM::Function::Return === @intercepted
111
+ end
64
112
  end
65
113
  end
@@ -36,10 +36,10 @@ module LLM::Function::Thread
36
36
  alias_method :cancel!, :interrupt!
37
37
 
38
38
  ##
39
+ # @see LLM::Function::Group#wait
39
40
  # @return [Array<LLM::Function::Return>]
40
41
  def wait
41
- spawn unless @spawned
42
- @tasks.map(&:wait)
42
+ super
43
43
  end
44
44
  alias_method :value, :wait
45
45
  end
@@ -31,7 +31,13 @@ module LLM::Function::Thread
31
31
  ##
32
32
  # @return [nil]
33
33
  def spawn
34
- return if @guarded
34
+ @span = @tracer&.on_tool_start(
35
+ id: @function.id,
36
+ name: @function.name,
37
+ arguments: @function.arguments,
38
+ model: @function.model
39
+ )
40
+ return if intercepted?
35
41
  @ready = Queue.new
36
42
  @thread = ::Thread.new do
37
43
  ##
@@ -58,8 +64,10 @@ module LLM::Function::Thread
58
64
  # instead, and opens on the call.
59
65
  ::Thread.new { @window.interrupt! } if @cancelled
60
66
  @window.running!
67
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
68
  function.call
62
69
  ensure
70
+ @finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
71
  ##
64
72
  # The window closes before the hook runs, so a cancel that arrives
65
73
  # once the call has returned finds nothing to interrupt - which is
@@ -70,13 +78,25 @@ module LLM::Function::Thread
70
78
  # cancelled, because a tool's state belongs to the thread its call
71
79
  # runs on, and the caller's thread is not that thread.
72
80
  #
73
- # It runs for a call that was interrupted and not for a call that
74
- # was asked about: the window is the frame that decides whether a
75
- # raise is issued, so it is the frame that knows, and
76
- # `Window#interrupted?` is true only where the raise was. A cancel
77
- # that arrives while the call is returning, and a held cancel whose
78
- # tool finished before the watcher was scheduled, are both cancels
79
- # that interrupted nothing - and neither tells the tool.
81
+ # It runs where an ask was taken up for this call, and not for a
82
+ # cancel the window never asked about: the window is the frame that
83
+ # decides whether a raise is issued, so it is the frame that knows,
84
+ # and `Window#interrupted?` is true where one was issued.
85
+ #
86
+ # **Issued is not delivered, and this strategy is the one where the
87
+ # two come apart least.** The raise here is a `Thread#raise`, and a
88
+ # thread can be interrupted where it stands, so a cancel that the
89
+ # window asks about lands on the call. `:fiber` and `:async` ask a
90
+ # scheduler instead, which places the raise at the tool's next
91
+ # suspension - so there a tool that never suspends is asked about
92
+ # and never reached, and the hook runs for it all the same.
93
+ #
94
+ # That is the side to be on, and a tool whose own rescue answers the
95
+ # interrupt rather than raising is why: it was interrupted, and its
96
+ # hook is the only thing left that can say so on its own thread.
97
+ # What does not tell the tool is a cancel the window never asked
98
+ # about - one that arrives once the call has returned - which is the
99
+ # no-op {LLM::Function::Return#interrupt!} already says one is.
80
100
  #
81
101
  # On the job's own thread the hook can only run after the call's
82
102
  # frame has ended - you cannot run code on a thread blocked inside
@@ -127,9 +147,15 @@ module LLM::Function::Thread
127
147
  ##
128
148
  # @return [LLM::Function::Return]
129
149
  def wait
130
- return @guarded if @guarded
150
+ return @intercepted if intercepted?
151
+ return @result.unbox if @result
131
152
  spawn unless @thread
132
- @thread.value
153
+ begin
154
+ @result = LLM::Object.from(unbox: @thread.value)
155
+ rescue LLM::Interrupt => ex
156
+ @result = LLM::Object.from(unbox: ex)
157
+ end
158
+ @result.unbox
133
159
  end
134
160
  alias_method :value, :wait
135
161
 
@@ -23,6 +23,22 @@ class LLM::Function
23
23
  # are one call here, and everything else - the three states, and what
24
24
  # "this call was interrupted" means - is this object's, so the strategies
25
25
  # agree about where a call begins and ends.
26
+ #
27
+ # **A raise a call cannot take is not delivered outside the call.** The
28
+ # scheduler places a scheduled raise, and a tool that never suspends
29
+ # gives it no suspension inside the call to be placed at - so one is left
30
+ # over. What is asserted is the half anything outside the call could
31
+ # notice, and it is the caller's frame rather than the tool's: the frame
32
+ # that waits on the task ends cleanly, and a sibling task on the same
33
+ # reactor runs.
34
+ #
35
+ # **Where the leftover goes is reasoned, not observed.** The raise is
36
+ # aimed at the fibre the tool runs in, and the frame those examples watch
37
+ # is a different fibre - a raise in one is not the other's exception, so
38
+ # they cannot see it either way. That it is discarded rather than
39
+ # delivered follows from what the scheduler can place: a raise scheduled
40
+ # for a fibre is placed when that fibre resumes, and a fibre whose tool
41
+ # never suspends does not resume before it ends.
26
42
  class Window
27
43
  ##
28
44
  # @param [Thread, nil] thread
data/lib/llm/function.rb CHANGED
@@ -31,7 +31,6 @@
31
31
  # end
32
32
  class LLM::Function
33
33
  require_relative "function/registry"
34
- require_relative "function/tracing"
35
34
  require_relative "function/array"
36
35
  require_relative "function/group"
37
36
  require_relative "function/sequential/group"
@@ -51,7 +50,6 @@ class LLM::Function
51
50
  require_relative "function/ractor/group"
52
51
 
53
52
  extend LLM::Function::Registry
54
- prepend LLM::Function::Tracing
55
53
 
56
54
  ##
57
55
  # Returns strategies that execute in an isolated environment.
@@ -183,11 +181,11 @@ class LLM::Function
183
181
  attr_accessor :model
184
182
 
185
183
  ##
186
- # Returns the guard class that protects this function, or nil.
187
- # The context stamps the guard onto the functions it binds, so any task
188
- # built from this function checks it before the tool runs.
189
- # @return [Class<LLM::Guard>, nil]
190
- attr_accessor :guard
184
+ # Returns the intercept class that supervises this function, or nil.
185
+ # The context stamps the intercept onto the functions it binds, so any
186
+ # task built from this function checks it before the tool runs.
187
+ # @return [Class<LLM::Intercept>, nil]
188
+ attr_accessor :intercept
191
189
 
192
190
  ##
193
191
  # @param [String] name The function name
@@ -278,49 +276,22 @@ class LLM::Function
278
276
  # task = tool.task(:thread)
279
277
  # result = task.value
280
278
  #
281
- # @note
282
- # An in-process strategy resolves the tool here, so a task that has
283
- # not been spawned already holds a live instance, and
284
- # {LLM::Function::Array#task} builds one for every pending function at
285
- # once. A tool that cannot be built carries the same in-band error the
286
- # guard produces rather than raising, so a constructor's failure
287
- # answers the model on every strategy.
288
279
  # @param [Symbol] strategy
289
280
  # Controls concurrency strategy:
290
281
  # - `:sequential`: Call the function sequentially
291
282
  # - `:thread`: Use threads
292
283
  # - `:async`: Use async tasks (requires async gem)
293
- # - `:fork`: Use a forked child process (requires xchan.rb support)
284
+ # - `:fork`: Use a forked child process (requires xchan.rb gem)
294
285
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
295
- # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
286
+ # - `:ractor`: Use Ruby ractors
296
287
  #
297
288
  # @return [LLM::Function::Task]
298
289
  # Returns a task whose `#value` is an {LLM::Function::Return}.
299
290
  def task(strategy, options = {})
300
- ##
301
- # Check the function's guard on the calling thread before handing
302
- # the tool to the strategy. The task carries the blocked result and
303
- # returns it without running if the guard intervenes.
304
- options = options.merge(guarded: @guard&.call(function: self))
305
- ##
306
- # Resolve the runner here, on the calling thread, before the task
307
- # returned below exists for another thread to interrupt.
308
- #
309
- # `#interrupt!` resolves the same instance, and `@_runner ||=` is a
310
- # check-then-create: reached from two threads at once it builds two,
311
- # runs the call on one and tells the hook on the other.
312
- #
313
- # `:async` is in the list because its reactor runs on a background
314
- # thread, which is the exposure `:thread` has. The cost is that the
315
- # tool is built on the calling thread rather than inside the reactor,
316
- # so a tool whose `initialize` wants a current `Async::Task` or a
317
- # scheduler-installed fiber does not get one.
318
- #
319
- # A guarded task runs nothing, so it resolves nothing. A strategy in
320
- # {LLM::Function.isolated} stays lazy on purpose, because there the
321
- # tool is built in the child process or the ractor and this instance
322
- # is not the one that runs.
323
- options = options.merge(guarded: options[:guarded] || resolve(strategy))
291
+ ioptions = @intercept&.options
292
+ options = options.merge(intercepted: @intercept&.call(function: self, **ioptions))
293
+ options = options.merge(tracer:, intercepted: options[:intercepted] || resolve(strategy))
294
+
324
295
  case strategy
325
296
  when :sequential
326
297
  Sequential::Task.new(self, options)
@@ -333,13 +304,13 @@ class LLM::Function
333
304
  Fiber::Task.new(self, options)
334
305
  when :fork
335
306
  LLM.require "xchan", "~> 0.24" unless defined?(::Chan::UNIXSocket)
336
- Fork::Task.new(self, options.merge(tracer: @tracer))
307
+ Fork::Task.new(self, options)
337
308
  when :ractor
338
309
  raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
339
310
  if @runner.respond_to?(:skill?) && @runner.skill?
340
311
  raise LLM::RactorError, "Ractor concurrency does not support skill-backed tools"
341
312
  end
342
- Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:, tracer: @tracer, model:))
313
+ Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:, model:))
343
314
  else
344
315
  raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :sequential, :thread, :fiber, :async, :fork, or :ractor"
345
316
  end
@@ -507,7 +478,7 @@ class LLM::Function
507
478
  #
508
479
  # {#call_function} resolves inside its own rescue, and that rescue is what
509
480
  # answers the model with an error rather than raising into the turn.
510
- # Resolving earlier keeps that promise by producing the return the guard
481
+ # Resolving earlier keeps that promise by producing the return the intercept
511
482
  # would have produced.
512
483
  #
513
484
  # An interrupt is re-raised rather than answered, which is what
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Intercept
4
+ ##
5
+ # The {LLM::Intercept::Multi LLM::Intercept::Multi}
6
+ # class combines multiple intercepts into a single
7
+ # intercept.
8
+ #
9
+ # The first intercept that returns an instance of
10
+ # {LLM::Function::Return LLM::Function::Return}
11
+ # stops all other intercepts from running, and when
12
+ # an intercept returns `nil` the next one in the list
13
+ # is called. When all intercepts return nil, the tool
14
+ # is called.
15
+ #
16
+ # @see LLM::Intercept
17
+ class Multi < self
18
+ ##
19
+ # @param [LLM::Provider] llm
20
+ # An {LLM::Provider LLM::Provider}.
21
+ # @param [Array<LLM::Intercept>] intercepts
22
+ # One or more subclasses of {LLM::Intercept LLM::Intercept}.
23
+ def initialize(llm, *intercepts)
24
+ @llm = llm
25
+ @intercepts = intercepts
26
+ end
27
+
28
+ ##
29
+ # @param (see LLM::Intercept#call)
30
+ # @return (see LLM::Intercept#call)
31
+ def call(function:, **opts)
32
+ @intercepts.each do |intercept|
33
+ intercept = intercept.new(llm).tap { _1.instance_variable_set(:@context, context) }
34
+ result = intercept.call(function:, **opts)
35
+ return result if LLM::Function::Return === result
36
+ end
37
+ nil
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Intercept
4
+ ##
5
+ # The {LLM::Intercept::Null LLM::Intercept::Null} class
6
+ # is the default intercept, and it does nothing but allow
7
+ # the tool call through.
8
+ #
9
+ # @see LLM::Intercept
10
+ class Null < self
11
+ ##
12
+ # @param (see LLM::Intercept#call)
13
+ # @return (see LLM::Intercept#call)
14
+ def call(function:, **opts)
15
+ nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # When a model requests a tool call the
6
+ # runtime represents it as an {LLM::Function}
7
+ # that an intercept can see before it is
8
+ # called. An intercept can return its own
9
+ # {LLM::Function::Return} object that acts
10
+ # as the response to the tool call, or it
11
+ # can return `nil` to let the tool call
12
+ # execute as normal.
13
+ #
14
+ # An intercept can also see the parameters
15
+ # a model has requested to send to a tool,
16
+ # and it can modify them in-place before the
17
+ # tool is called.
18
+ #
19
+ # The default intercept is
20
+ # {LLM::Intercept::Null LLM::Intercept::Null}
21
+ # and it is a noop.
22
+ class Intercept
23
+ require_relative "intercept/null"
24
+ require_relative "intercept/multi"
25
+
26
+ ##
27
+ # @param [LLM::Provider] llm
28
+ # An {LLM::Provider LLM::Provider}.
29
+ # @param [Array<LLM::Intercept>] intercepts
30
+ # One or more subclasses of {LLM::Intercept LLM::Intercept}
31
+ # @return [LLM::Intercept::Multi]
32
+ def self.multi(llm, *intercepts)
33
+ Multi.new(
34
+ llm,
35
+ *intercepts
36
+ )
37
+ end
38
+
39
+ ##
40
+ # @return [LLM::Provider]
41
+ attr_reader :llm
42
+
43
+ ##
44
+ # @param [LLM::Provider] llm
45
+ # @return [LLM::Intercept]
46
+ def initialize(llm)
47
+ @llm = llm
48
+ end
49
+
50
+ ##
51
+ # @abstract
52
+ # @param [LLM::Function] function
53
+ # A pending function
54
+ # @param opts [Hash]
55
+ # The contents of `intercept_options:`
56
+ # @return [LLM::Function::Return, Object]
57
+ # When this method returns a
58
+ # {LLM::Function::Return LLM::Function::Return},
59
+ # object it stops a tool call
60
+ # in its tracks and the Return
61
+ # object is the answer we give
62
+ # to the model for the tool call
63
+ # that was stopped. Any other return
64
+ # value lets the tool call through.
65
+ def call(function:, **opts)
66
+ raise NotImplementedError
67
+ end
68
+
69
+ ##
70
+ # Returns the options the context passed to `intercept_options:`.
71
+ # @return [Hash]
72
+ def options
73
+ @options || {}
74
+ end
75
+
76
+ private
77
+
78
+ ##
79
+ # @return [LLM::Context]
80
+ def context
81
+ @context
82
+ end
83
+
84
+ ##
85
+ # @return [LLM::Stream]
86
+ def stream
87
+ context.params[:stream]
88
+ end
89
+
90
+ ##
91
+ # @return [LLM::Buffer]
92
+ def messages
93
+ context.messages
94
+ end
95
+ end
96
+ end
data/lib/llm/message.rb CHANGED
@@ -256,7 +256,7 @@ module LLM
256
256
 
257
257
  def function_missing(name)
258
258
  LLM.function(name) do |fn|
259
- fn.define { raise LLM::NoSuchToolError, "tool not found" }
259
+ fn.define { raise LLM::NoSuchToolError, "tool '#{name}' not found" }
260
260
  end
261
261
  end
262
262