llm.rb 16.0.0 → 17.0.1

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 +535 -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 +242 -268
  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
data/lib/llm/context.rb CHANGED
@@ -39,15 +39,6 @@ module LLM
39
39
  include Serializer
40
40
  include Deserializer
41
41
 
42
- TRY_ERRORS = [
43
- "LLM::InsufficientQuotaError",
44
- "LLM::RateLimitError",
45
- "Net::ReadTimeout",
46
- "Net::WriteTimeout",
47
- "Net::OpenTimeout"
48
- ]
49
- private_constant :TRY_ERRORS
50
-
51
42
  ##
52
43
  # Returns the set of runtime parameters that
53
44
  # configure this context and must never be forwarded
@@ -55,7 +46,7 @@ module LLM
55
46
  # @api private
56
47
  # @return [Array<Symbol>]
57
48
  def self.params
58
- %w[guard retry_budget concurrency transformer compactor record id]
49
+ %w[intercept retry_budget concurrency transformer compactor record id]
59
50
  end
60
51
 
61
52
  ##
@@ -80,7 +71,9 @@ module LLM
80
71
 
81
72
  ##
82
73
  # Returns a stable id for this context. It is generated once
83
- # on creation and restored with the runtime state on load.
74
+ # on creation and restored with the runtime state on load - a
75
+ # context bound to a record takes the record's id instead, when
76
+ # that id is a UUIDv7 string - and it is always a UUIDv7 string.
84
77
  # @return [String]
85
78
  attr_reader :id
86
79
 
@@ -96,7 +89,10 @@ module LLM
96
89
  # to `:completions`.
97
90
  # @option params [String] :model Defaults to the provider's default model
98
91
  # @option params [String] :id
99
- # A stable id for the context. Defaults to a UUID.
92
+ # A stable id for the context. It must be a UUIDv7 string, and
93
+ # anything else raises {LLM::Error}. Defaults to the record's id
94
+ # when the context is bound to a record whose id is a UUIDv7, and
95
+ # to a generated UUIDv7 otherwise.
100
96
  # @option params [Class<LLM::Compactor>, nil] :compactor
101
97
  # A compactor class to use for context compaction. Defaults to
102
98
  # {LLM::Compactor::Null}.
@@ -107,18 +103,18 @@ module LLM
107
103
  # {LLM::Transformer::Null}.
108
104
  # @option params [Hash] :transformer_options
109
105
  # Options passed to the transformer's `call` method. Defaults to `{}`.
110
- # @option params [Class<LLM::Guard>, nil] :guard
111
- # A guard class to supervise agentic tool execution. Defaults to
112
- # {LLM::Guard::Null}.
113
- # @option params [Hash] :guard_options
114
- # Options passed to the guard's `call` method. Defaults to `{}`.
106
+ # @option params [Class<LLM::Intercept>, nil] :intercept
107
+ # Defaults to {LLM::Intercept::Null LLM::Intercept::Null}.
108
+ # @option params [Hash] :intercept_options
109
+ # Options passed to the intercept's `call` method. Defaults to `{}`.
115
110
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
116
111
  # @option params [Array<String>, nil] :skills Defaults to nil
117
112
  def initialize(llm, params = {})
118
113
  params = {}.merge!(params)
119
114
  @llm = llm
120
- @id = params.delete(:id) || SecureRandom.uuid_v7
121
115
  @record = params.delete(:record)
116
+ @id = params.delete(:id) || makeid
117
+ raise LLM::Error, "an id must be a UUIDv7 string" unless LLM::Utils.uuidv7?(@id)
122
118
  @mode = params.delete(:mode) || (llm.name == :openai ? :responses : :completions)
123
119
  tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
124
120
  @params = {model: llm.default_model, schema: nil}.compact.merge!(params)
@@ -135,9 +131,9 @@ module LLM
135
131
  klass: params.delete(:transformer) || LLM::Transformer::Null,
136
132
  options: params.delete(:transformer_options) || {}
137
133
  }
138
- @guard = {
139
- klass: params.delete(:guard) || LLM::Guard::Null,
140
- options: params.delete(:guard_options) || {}
134
+ @intercept = {
135
+ klass: params.delete(:intercept) || LLM::Intercept::Null,
136
+ options: params.delete(:intercept_options) || {}
141
137
  }
142
138
  @retry_budget = params.delete(:retry_budget) || 0
143
139
  end
@@ -172,20 +168,15 @@ module LLM
172
168
  alias_method :compacted?, :compacted
173
169
 
174
170
  ##
175
- # Returns the configured guard class.
176
- #
177
- # Guards are context-level supervisors for agentic execution. A guard can
178
- # inspect the runtime state and decide whether pending tool work should be
179
- # blocked before the context keeps looping.
180
- #
181
- # The guard is stamped onto the functions the context binds, so it runs
182
- # whenever a task is spawned — including tool calls queued from a stream
183
- # via {LLM::Stream#on_tool_call}. A blocked call yields its in-band
184
- # `guard_error` return without executing.
185
- #
186
- # @return [Class<LLM::Guard>]
187
- def guard
188
- @guard[:klass]
171
+ # @see LLM::Intercept
172
+ # @return [LLM::Intercept]
173
+ def intercept
174
+ klass = @intercept[:klass]
175
+ intercept = Class === klass ? klass.new(llm) : klass
176
+ intercept.tap do
177
+ it.instance_variable_set(:@context, self)
178
+ it.instance_variable_set(:@options, @intercept[:options])
179
+ end
189
180
  end
190
181
 
191
182
  ##
@@ -230,8 +221,8 @@ module LLM
230
221
 
231
222
  ##
232
223
  # Ask a question and return the content string directly.
233
- # Accepts `with:` for file attachments and a block for streaming.
234
- # This interface is compatible with RubyLLM's `ask` method.
224
+ # Accepts `with:` for file attachments and a block for streaming,
225
+ # the way {#talk} does.
235
226
  #
236
227
  # @param [String] prompt
237
228
  # @param [Hash] options
@@ -268,15 +259,14 @@ module LLM
268
259
  # @return [Array<LLM::Function>]
269
260
  def pending_functions
270
261
  return_ids = returns.map(&:id)
271
- guard = @guard[:klass].new(self)
272
262
  @messages
273
263
  .select(&:assistant?)
274
264
  .flat_map do |msg|
275
- fns = msg.functions.select { _1.pending? && !return_ids.include?(_1.id) }
265
+ fns = msg.functions.select { _1.pending? and !return_ids.include?(_1.id) }
276
266
  fns.each do |fn|
277
267
  fn.tracer = tracer
278
268
  fn.model = msg.model
279
- fn.guard = guard
269
+ fn.intercept = intercept
280
270
  end
281
271
  end.extend(LLM::Function::Array)
282
272
  end
@@ -333,8 +323,8 @@ module LLM
333
323
  if stream.queue.empty?
334
324
  ##
335
325
  # Every pending function is spawned as a task that checks its own
336
- # guard (stamped on the function) before running. Blocked tasks
337
- # yield their guard's return, so all pending calls still close.
326
+ # intercept (stamped on the function) before running. Blocked tasks
327
+ # yield their intercept's return, so all pending calls still close.
338
328
  tools = except.empty? ? pending_functions : pending_functions - except
339
329
  @queue = tools.task(strategy)
340
330
  returns = @queue.wait
@@ -597,6 +587,29 @@ module LLM
597
587
  [@queue, stream.queue].compact.first
598
588
  end
599
589
 
590
+ ##
591
+ # The id a context is born with.
592
+ #
593
+ # The record's id when the context is bound to a record and that id
594
+ # is a UUIDv7, so that an agent and the row it came from answer the
595
+ # same name - and a generated UUIDv7 otherwise, which is every
596
+ # context with no record, every record whose id is an integer or a
597
+ # slug, and every row that is not saved yet.
598
+ #
599
+ # The check is {LLM::Utils.uuidv7?}, which is what
600
+ # {LLM::Utils.timestamp} reads a time out of as well - rather than a
601
+ # second opinion about what a UUIDv7 looks like, an id this accepts
602
+ # is an id `created_at` can answer for.
603
+ # @api private
604
+ # @return [Object]
605
+ def makeid
606
+ if record
607
+ LLM::Utils.uuidv7?(record.id) ? record.id : SecureRandom.uuid_v7
608
+ else
609
+ SecureRandom.uuid_v7
610
+ end
611
+ end
612
+
600
613
  ##
601
614
  # Loads skill directories and adapts them into tools.
602
615
  # @api private
@@ -641,17 +654,12 @@ module LLM
641
654
  def try
642
655
  attempts ||= 0
643
656
  yield
644
- rescue => ex
645
- case ex.class.to_s
646
- when *TRY_ERRORS
647
- raise if attempts >= retry_budget
648
- attempts += 1
649
- stream.on_retry(ex, attempts)
650
- sleep 2.0 * attempts
651
- retry
652
- else
653
- raise(ex)
654
- end
657
+ rescue *llm.retry_errors => ex
658
+ raise if attempts >= retry_budget
659
+ attempts += 1
660
+ stream.on_retry(ex, attempts)
661
+ sleep 2.0 * attempts
662
+ retry
655
663
  end
656
664
 
657
665
  # Executes a turn through the Responses API.
@@ -660,7 +668,7 @@ module LLM
660
668
  @llm.with(**headers) do
661
669
  history = @messages.to_a
662
670
  params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
663
- extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
671
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, intercept:)
664
672
  params[:stream] = LLM::Stream.try(params[:stream], extra:)
665
673
  res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
666
674
  input = res_id ? [] : history
@@ -681,7 +689,7 @@ module LLM
681
689
  history = @messages.to_a
682
690
  params = params.merge(messages: history)
683
691
  params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
684
- extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
692
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, intercept:)
685
693
  params[:stream] = LLM::Stream.try(params[:stream], extra:)
686
694
  messages = transform(prompt, params)
687
695
  @stream = params[:stream]
@@ -10,10 +10,11 @@ class LLM::Function
10
10
  module Array
11
11
  ##
12
12
  # Calls all functions in a collection sequentially.
13
+ #
13
14
  # @return [Array<LLM::Function::Return>]
14
15
  # Returns values to be reported back to the LLM.
15
16
  def call
16
- map(&:call)
17
+ task(:sequential).wait
17
18
  end
18
19
 
19
20
  ##
@@ -42,10 +42,10 @@ module LLM::Function::Async
42
42
  alias_method :cancel!, :interrupt!
43
43
 
44
44
  ##
45
+ # @see LLM::Function::Group#wait
45
46
  # @return [Array<LLM::Function::Return>]
46
47
  def wait
47
- spawn unless @spawned
48
- @tasks.map(&:wait)
48
+ super
49
49
  ensure
50
50
  @reactor.stop
51
51
  end
@@ -68,7 +68,13 @@ module LLM::Function::Async
68
68
  # `StandardError`.
69
69
  # @return [nil]
70
70
  def spawn
71
- return if @guarded
71
+ @span = @tracer&.on_tool_start(
72
+ id: @function.id,
73
+ name: @function.name,
74
+ arguments: @function.arguments,
75
+ model: @function.model
76
+ )
77
+ return if intercepted?
72
78
  @queue = Queue.new
73
79
  @alive = !@cancelled
74
80
  @reactor.submit do
@@ -84,21 +90,20 @@ module LLM::Function::Async
84
90
  # tool.
85
91
  @window.interrupt!(wait: false) if @cancelled
86
92
  task.defer_cancel do
87
- result = begin
88
- ##
89
- # The call opens here, and it is what a deferred ask is issued
90
- # against. What the window guarantees is that the raise is not
91
- # asked for before the call; the dispatch itself is code, and a
92
- # raise can land in it, which is the same gap `Fork::Job` has
93
- # between `running!` and `runner.call`.
93
+ begin
94
94
  @window.running!
95
- function.call
96
- rescue LLM::Interrupt => ex
97
95
  ##
98
- # The interrupt is the block's result, and the queue carries it
99
- # to the caller.
100
- ex
96
+ # The 'function.call' method call will rescue
97
+ # subclasses of StandardError and turn it into
98
+ # a LLM::Function::Return object. An LLM::Interrupt
99
+ # escapes this method call, and it is rescued
100
+ # here and pushed onto the queue.
101
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ @queue << function.call
103
+ rescue LLM::Interrupt => ex
104
+ @queue << ex
101
105
  ensure
106
+ @finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
107
  ##
103
108
  # The tool has answered, so a cancel that arrives from here on
104
109
  # asks nothing - which is the no-op a cancel that arrives once
@@ -112,17 +117,9 @@ module LLM::Function::Async
112
117
  # `interrupted?` means.
113
118
  function.interrupt! if @window.interrupted?
114
119
  end
115
- @queue << result
116
120
  end
117
- rescue LLM::Interrupt, StandardError => e
118
- ##
119
- # This runs after the `defer_cancel` block's `ensure`, so the tool is
120
- # told first and the caller second. It answers a hook whose own
121
- # error unwound past the push, and it is the backstop for an
122
- # interrupt that arrived from somewhere other than the call above -
123
- # which is why the interrupt is pushed and not raised on.
121
+ rescue LLM::Interrupt => e
124
122
  @queue << e
125
- raise unless LLM::Interrupt === e
126
123
  end
127
124
  nil
128
125
  end
@@ -165,36 +162,16 @@ module LLM::Function::Async
165
162
  alias_method :cancel!, :interrupt!
166
163
 
167
164
  ##
168
- # Wait for the result queue to contain a value.
169
- #
170
- # It stops the reactor on the way out, which is also where a cancel's
171
- # cost is paid: `interrupt!` is a message and a return like every other
172
- # strategy's, and a tool that will not stop meets the join and the kill
173
- # here rather than in the caller's cancel.
174
- #
175
- # **A second wait is answered from what the first one took.** `pop` takes
176
- # the item, so holding it is what makes a task answerable more than once -
177
- # which is what `Thread#value` does for the other in-process strategy, and
178
- # what the ractor's task is asserted to do. The exception case is held the
179
- # same way, so a call that was interrupted re-raises on every wait rather
180
- # than raising once and then blocking on an empty queue.
181
- #
182
- # Anything that is an exception is raised rather than returned, which is
183
- # what `Thread#value` and `Fiber#value` do. An interrupt is the usual
184
- # one, and a hook that raised from the block's `ensure` is the other.
185
- #
186
- # The guarded path returns before any of that, so a task whose guard
187
- # blocked it does not stop a reactor it never used - which matters in a
188
- # group, where the reactor is not its own.
189
- # @return [LLM::Function::Return]
165
+ # @see LLM::Function::Task#wait
166
+ # @return (see LLM::Function::Task#wait)
190
167
  def wait
191
- return @guarded if @guarded
168
+ return @intercepted if intercepted?
169
+ return @result.unbox if @result
192
170
  begin
193
171
  spawn unless @queue
194
- @result ||= @queue.pop
172
+ @result = LLM::Object.from(unbox: @queue.pop)
195
173
  @alive = false
196
- raise @result if Exception === @result
197
- @result
174
+ @result.unbox
198
175
  ensure
199
176
  @reactor&.stop
200
177
  end
@@ -36,10 +36,10 @@ module LLM::Function::Fiber
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
@@ -36,7 +36,13 @@ module LLM::Function::Fiber
36
36
  ##
37
37
  # @return [nil]
38
38
  def spawn
39
- return if @guarded
39
+ @span = @tracer&.on_tool_start(
40
+ id: @function.id,
41
+ name: @function.name,
42
+ arguments: @function.arguments,
43
+ model: @function.model
44
+ )
45
+ return if intercepted?
40
46
  if Fiber.scheduler.nil?
41
47
  raise ArgumentError, "Fiber concurrency requires Fiber.scheduler"
42
48
  elsif !Fiber.scheduler.respond_to?(:fiber_interrupt)
@@ -94,22 +100,12 @@ module LLM::Function::Fiber
94
100
  # to prevent - this fiber raised before the call was reached, so
95
101
  # the tool's own `rescue` never saw the interrupt.
96
102
  @window.running!
103
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
104
  @queue << function.call
98
- rescue LLM::Interrupt, StandardError => ex
99
- ##
100
- # The interrupt is stored on the queue rather than raised on.
101
- # `LLM::Interrupt` is a subclass of `Exception`, and one that is
102
- # left to raise kills the scheduler's thread and takes the tasks
103
- # running on it with it - the caller reads the queue and raises
104
- # the interrupt on its own thread or fiber, and this task exits
105
- # silently.
106
- #
107
- # The rescue names the interrupt rather than leaving it to a bare
108
- # rescue, because a bare rescue - and `rescue => ex` - reaches
109
- # only `StandardError`.
105
+ rescue LLM::Interrupt => ex
110
106
  @queue << ex
111
- raise unless LLM::Interrupt === ex
112
107
  ensure
108
+ @finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
109
  ##
114
110
  # The tool has answered, so a cancel that arrives from here on
115
111
  # asks nothing - which is the no-op a cancel that arrives once
@@ -158,24 +154,14 @@ module LLM::Function::Fiber
158
154
  alias_method :cancel!, :interrupt!
159
155
 
160
156
  ##
161
- # Wait for the body to hand its result back.
162
- #
163
- # **It answers more than once.** `Queue#pop` takes the item, so the value
164
- # is kept rather than popped again, and a second wait is answered from
165
- # what the first one took - the same way `Thread#value` answers, and the
166
- # way `LLM::Function::Ractor::Task` is expected to.
167
- #
168
- # Anything that is an exception is raised rather than returned. An
169
- # interrupt is the usual one: a held cancel arrives here as the exception
170
- # the block ended with, and so does one raised at a call that was already
171
- # running.
172
- # @return [LLM::Function::Return]
157
+ # @see LLM::Function::Task#wait
158
+ # @return (see LLM::Function::Task#wait)
173
159
  def wait
174
- return @guarded if @guarded
160
+ return @intercepted if intercepted?
161
+ return @result.unbox if @result
175
162
  spawn unless @fiber
176
- @result ||= @queue.pop
177
- raise @result if Exception === @result
178
- @result
163
+ @result = LLM::Object.from(unbox: @queue.pop)
164
+ @result.unbox
179
165
  end
180
166
  alias_method :value, :wait
181
167
 
@@ -35,10 +35,10 @@ module LLM::Function::Fork
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
@@ -40,11 +40,11 @@ class LLM::Function
40
40
  # result is written, so an interrupt that arrives once the work is
41
41
  # done is a no-op rather than a reason to throw the result away.
42
42
  @window.finished!
43
- @ch.result.write([:result, result])
43
+ @ch.result.write([:result, result, finished_at])
44
44
  rescue LLM::Interrupt
45
- @ch.result.write([:interrupt])
45
+ @ch.result.write([:interrupt, nil, finished_at])
46
46
  rescue => ex
47
- @ch.result.write([:result, error(ex)])
47
+ @ch.result.write([:result, error(ex), finished_at])
48
48
  ensure
49
49
  ##
50
50
  # For the paths above that did not reach the line, and nil-safe for
@@ -86,7 +86,7 @@ class LLM::Function
86
86
  thread = ::Thread.new do
87
87
  ready << true
88
88
  ##
89
- # Only the receive is guarded: it raises when the parent has gone,
89
+ # Only the receive is rescued: it raises when the parent has gone,
90
90
  # and nothing else in this block should be answerable to that
91
91
  # rescue. A hook that raises must not be swallowed by it, and must
92
92
  # not take the raise with it either - hence the `ensure` below,
@@ -113,5 +113,9 @@ class LLM::Function
113
113
  ready.pop
114
114
  thread
115
115
  end
116
+
117
+ def finished_at
118
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ end
116
120
  end
117
121
  end
@@ -12,7 +12,6 @@ class LLM::Function
12
12
  # @return [LLM::Function::Fork::Task]
13
13
  def initialize(fn, options = {})
14
14
  super
15
- @tracer = options.fetch(:tracer, nil)
16
15
  @spawned = false
17
16
  @waited = false
18
17
  @cancelled = false
@@ -21,11 +20,13 @@ class LLM::Function
21
20
  ##
22
21
  # @return [LLM::Function::Fork::Task]
23
22
  def spawn
24
- return if @guarded
25
23
  @span = @tracer&.on_tool_start(
26
- id: @function.id, name: @function.name,
27
- arguments: @function.arguments, model: @function.model
24
+ id: @function.id,
25
+ name: @function.name,
26
+ arguments: @function.arguments,
27
+ model: @function.model
28
28
  )
29
+ return if intercepted?
29
30
  @ch = channels
30
31
  ##
31
32
  # **A cancel that arrived before this is written now, before the fork.**
@@ -38,6 +39,7 @@ class LLM::Function
38
39
  @cancelled = false
39
40
  @ch.control.write(:interrupt)
40
41
  end
42
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
43
  @pid = Kernel.fork do
42
44
  ##
43
45
  # The child inherits the parent's terminal. When
@@ -95,12 +97,12 @@ class LLM::Function
95
97
  # until its watcher looks, which is how a cancel that arrives a moment
96
98
  # later is delivered too.
97
99
  #
98
- # A task the guard blocked never forks, and one that has answered has
100
+ # A task the intercept blocked never forks, and one that has answered has
99
101
  # nothing left to tell: both are a no-op, the way
100
102
  # {LLM::Function::Return#interrupt!} says one is.
101
103
  # @return [nil]
102
104
  def interrupt!
103
- return nil if @waited || @guarded
105
+ return nil if @waited || @intercepted
104
106
  if @ch
105
107
  @ch.control.write(:interrupt)
106
108
  else
@@ -132,34 +134,27 @@ class LLM::Function
132
134
  # it would be an exception to the rule that a tool's failure is in band.
133
135
  # @return [LLM::Function::Return]
134
136
  def wait
135
- return @guarded if @guarded
136
- raise @result if Exception === @result
137
- return @result if @result
137
+ return @intercepted if intercepted?
138
+ return @result.unbox if @result
138
139
  spawn unless @spawned
139
- kind, data = @ch.result.recv
140
+ kind, data, @finished_at = @ch.result.recv
140
141
  @result = case kind
141
- when :interrupt then LLM::Interrupt.new
142
- when :result then Return.new(data[:id], data[:name], data[:value])
143
- else raise ArgumentError, "Unknown fork message: #{kind.inspect}"
142
+ when :interrupt then LLM::Object.from(unbox: LLM::Interrupt.new)
143
+ when :result then LLM::Object.from(unbox: Return.new(data[:id], data[:name], data[:value]))
144
+ else raise ArgumentError, "unknown fork message: #{kind.inspect}"
144
145
  end
145
- raise @result if Exception === @result
146
146
  reap
147
- @tracer&.on_tool_finish(result: @result, span: @span)
148
- @result
147
+ @result.unbox
149
148
  rescue EOFError
150
149
  ##
151
- # Held in `@result` rather than answered once, so a second wait is given
152
- # the same return the way it is for every other ending.
150
+ # Stored in `@result` so a second wait is
151
+ # given the same return as the first call.
153
152
  reap
154
- @result = Return.new(@function.id, @function.name, {
155
- error: true,
156
- type: EOFError.name,
157
- message: "the tool exited unexpectedly"
158
- })
159
- @tracer&.on_tool_finish(result: @result, span: @span)
160
- @result
153
+ val = @function.return(error: true, type: EOFError.name, message: "the tool exited unexpectedly")
154
+ @result = LLM::Object.from(unbox: val)
155
+ @result.unbox
161
156
  ensure
162
- if @guarded.nil?
157
+ if @intercepted.nil?
163
158
  reap
164
159
  [@ch.control, @ch.result].each { _1.close unless _1.closed? } if @ch
165
160
  end
@@ -178,9 +173,9 @@ class LLM::Function
178
173
  # The controls and results channels.
179
174
  #
180
175
  # They are built by `spawn` and not by {#interrupt!}, so a task that never
181
- # forks never opens a socketpair - a guard blocks many of them, and that is
182
- # the common case. A cancel that arrives before `spawn` is recorded instead,
183
- # and written by it.
176
+ # forks never opens a socketpair - an intercept blocks many of them, and
177
+ # that is the common case. A cancel that arrives before `spawn` is recorded
178
+ # instead, and written by it.
184
179
  # @return [LLM::Object]
185
180
  def channels
186
181
  LLM::Object.from(
@@ -197,7 +192,7 @@ class LLM::Function
197
192
  # only be reaped once.
198
193
  # @return [void]
199
194
  def reap
200
- return if @waited || @guarded || !@pid
195
+ return if @waited || @intercepted || !@pid
201
196
  ::Process.waitpid(@pid)
202
197
  @waited = true
203
198
  rescue Errno::ECHILD
@@ -2,10 +2,11 @@
2
2
 
3
3
  class LLM::Function
4
4
  ##
5
- # Abstract base class for all concurrency strategy groups.
5
+ # Abstract superclass for all concurrency strategy groups.
6
6
  # Each strategy (sequential, thread, fiber, async, fork, ractor)
7
- # subclasses this and implements {#alive?}, {#interrupt!}, and
8
- # {#wait}.
7
+ # subclasses this and implements {#spawn}, {#alive?} and
8
+ # {#interrupt!}. {#wait} is shared, and a strategy whose tasks
9
+ # report themselves answers with one of its own.
9
10
  class Group
10
11
  ##
11
12
  # @abstract
@@ -30,10 +31,37 @@ class LLM::Function
30
31
  alias_method :cancel!, :interrupt!
31
32
 
32
33
  ##
33
- # @abstract
34
+ # This method waits for one or more tasks to
35
+ # return. Within this method, the tracer
36
+ # associated with a task closes the `on_tool_start`
37
+ # span that was opened when the task spawned.
38
+ #
39
+ # @note
40
+ # This method might raise an {LLM::Interrupt}
41
+ # if one or more tasks were interrupted but
42
+ # only after the tracer has accounted for each
43
+ # task and collected an exit status from each
44
+ # of them.
45
+ # @raise [LLM::Interrupt]
34
46
  # @return [Array<LLM::Function::Return>]
35
47
  def wait
36
- raise NotImplementedError
48
+ spawn unless @spawned
49
+ interrupt = nil
50
+ vals = @tasks.map do |task|
51
+ val, started_at, finished_at = task.wait, task.started_at, task.finished_at
52
+ if LLM::Interrupt === val
53
+ interrupt = val
54
+ task.tracer&.on_tool_interrupt(ex: val, span: task.span, started_at:, finished_at:)
55
+ else
56
+ if val.error?
57
+ task.tracer&.on_tool_error(ex: val, span: task.span, started_at:, finished_at:)
58
+ else
59
+ task.tracer&.on_tool_finish(result: val, span: task.span, started_at:, finished_at:)
60
+ end
61
+ val
62
+ end
63
+ end
64
+ interrupt ? raise(interrupt) : vals
37
65
  end
38
66
  alias_method :value, :wait
39
67
  end