llm.rb 12.6.0 → 13.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +387 -0
  3. data/LICENSE +21 -93
  4. data/README.md +46 -155
  5. data/data/deepinfra.json +3 -0
  6. data/data/xai.json +1 -1
  7. data/lib/llm/a2a.rb +1 -1
  8. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  9. data/lib/llm/agent.rb +62 -23
  10. data/lib/llm/buffer.rb +85 -3
  11. data/lib/llm/compactor/null.rb +19 -0
  12. data/lib/llm/compactor/truncate.rb +80 -0
  13. data/lib/llm/compactor.rb +42 -124
  14. data/lib/llm/context.rb +31 -37
  15. data/lib/llm/contract.rb +4 -25
  16. data/lib/llm/function/array.rb +15 -14
  17. data/lib/llm/function/async/group.rb +54 -0
  18. data/lib/llm/function/async/reactor.rb +48 -0
  19. data/lib/llm/function/async/task.rb +83 -0
  20. data/lib/llm/function/fiber/group.rb +46 -0
  21. data/lib/llm/function/fiber/task.rb +62 -0
  22. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  23. data/lib/llm/function/fork/job.rb +2 -2
  24. data/lib/llm/function/fork/task.rb +19 -10
  25. data/lib/llm/function/group.rb +40 -0
  26. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  27. data/lib/llm/function/ractor/job.rb +9 -3
  28. data/lib/llm/function/ractor/mailbox.rb +2 -0
  29. data/lib/llm/function/ractor/task.rb +23 -15
  30. data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
  31. data/lib/llm/function/sequential/task.rb +49 -0
  32. data/lib/llm/function/task.rb +25 -48
  33. data/lib/llm/function/thread/group.rb +46 -0
  34. data/lib/llm/function/thread/task.rb +60 -0
  35. data/lib/llm/function.rb +54 -64
  36. data/lib/llm/loop_guard.rb +1 -2
  37. data/lib/llm/mcp.rb +22 -0
  38. data/lib/llm/object.rb +2 -1
  39. data/lib/llm/provider.rb +6 -3
  40. data/lib/llm/providers/google.rb +2 -2
  41. data/lib/llm/repl/command.rb +35 -8
  42. data/lib/llm/repl/commands/compact.rb +33 -0
  43. data/lib/llm/repl/input.rb +80 -15
  44. data/lib/llm/repl/markdown/table.rb +76 -0
  45. data/lib/llm/repl/markdown.rb +31 -1
  46. data/lib/llm/repl/status.rb +1 -1
  47. data/lib/llm/repl/stream.rb +10 -3
  48. data/lib/llm/repl/transcript.rb +1 -1
  49. data/lib/llm/repl/walker.rb +46 -0
  50. data/lib/llm/repl.rb +18 -12
  51. data/lib/llm/response.rb +10 -0
  52. data/lib/llm/schema/leaf.rb +5 -0
  53. data/lib/llm/schema/object.rb +11 -5
  54. data/lib/llm/sequel/plugin.rb +6 -6
  55. data/lib/llm/stream.rb +24 -17
  56. data/lib/llm/tool.rb +20 -4
  57. data/lib/llm/tools/chdir.rb +0 -2
  58. data/lib/llm/tools/git.rb +8 -4
  59. data/lib/llm/tools/mkdir.rb +1 -1
  60. data/lib/llm/tools/pwd.rb +0 -2
  61. data/lib/llm/tools/read_file.rb +0 -2
  62. data/lib/llm/tools/rg.rb +8 -4
  63. data/lib/llm/tools/shell.rb +8 -4
  64. data/lib/llm/tools/utils.rb +31 -0
  65. data/lib/llm/version.rb +1 -1
  66. data/lib/llm.rb +25 -5
  67. data/llm.gemspec +3 -3
  68. data/resources/deepdive.md +645 -58
  69. metadata +24 -13
  70. data/lib/llm/function/call_task.rb +0 -46
  71. data/lib/llm/function/fiber_group.rb +0 -105
  72. data/lib/llm/function/task_group.rb +0 -97
  73. data/lib/llm/function/thread_group.rb +0 -102
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Compactor
4
+ ##
5
+ # An {LLM::Compactor::Truncate LLM::Compactor::Truncate}
6
+ # drops the oldest messages when the conversation grows
7
+ # beyond a configured size, keeping only the N most recent
8
+ # messages.
9
+ #
10
+ # No LLM call is made but this strategy is purely lossy. It
11
+ # also fast - no network required and operates purely on
12
+ # memory.
13
+ class Truncate < self
14
+ ##
15
+ # @param [String, Integer] keep
16
+ # The last (approx) n number of messages to keep.
17
+ # This parameter can also be a percentage: eg "80%"
18
+ # to keep 80% of the most recent messages.
19
+ # @return [Array<LLM::Message>, nil]
20
+ def call(keep: 64)
21
+ keep = parse(keep)
22
+ if keep <= 0 || keep > messages.reject(&:system?).size
23
+ nil
24
+ else
25
+ stream.on_compaction(self)
26
+ kept = take(messages, keep)
27
+ messages.replace([messages.select(&:system?).first, *kept].compact)
28
+ ctx.compacted = true
29
+ stream.on_compaction_finish(self)
30
+ kept
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ ##
37
+ # @param [String, Integer] input
38
+ # The given input
39
+ # @return [Integer]
40
+ # Returns the number of messages to keep
41
+ def parse(input)
42
+ if String === input
43
+ if input.end_with?("%")
44
+ count = ctx.messages.reject(&:system?).size
45
+ (count * (Float(input[0..-2]) / 100)).round
46
+ else
47
+ Integer(input)
48
+ end
49
+ else
50
+ Integer(input)
51
+ end
52
+ end
53
+
54
+ def take(messages, limit)
55
+ subset, in_tool_call = [], false
56
+ messages.reverse_each.with_index(1) do |m, index|
57
+ # We travel backwards - so we see a
58
+ # tool return before we see a tool
59
+ # call.
60
+ #
61
+ # When we see a tool return, our next
62
+ # task is to find where it was called
63
+ # from, and we will even override the
64
+ # limit to do this.
65
+ #
66
+ # Otherwise, the conversation will become
67
+ # corrupted and any attempt to use it will
68
+ # be an API-level error.
69
+ in_tool_call = m.tool_return?
70
+ if index >= limit
71
+ subset.unshift(m)
72
+ in_tool_call ? next : break
73
+ else
74
+ subset.unshift(m)
75
+ end
76
+ end
77
+ subset
78
+ end
79
+ end
80
+ end
data/lib/llm/compactor.rb CHANGED
@@ -1,135 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- ##
4
- # {LLM::Compactor LLM::Compactor} summarizes older context messages into a
5
- # smaller replacement message when a context grows too large.
6
- #
7
- # This work is directly inspired by the compaction approach developed by
8
- # General Intelligence Systems.
9
- #
10
- # The compactor can also use a different model from the main context by
11
- # setting `model:` in the compactor config. Compaction thresholds are opt-in:
12
- # provide `message_threshold:` and/or `token_threshold:` to enable policy-
13
- # driven compaction. `token_threshold:` accepts either an integer token count
14
- # or a percentage string like `"90%"`, which resolves against the current
15
- # model context window.
16
- class LLM::Compactor
17
- DEFAULTS = {
18
- retention_window: 8,
19
- model: nil
20
- }.freeze
21
-
22
- ##
23
- # @return [Hash]
24
- attr_reader :config
25
-
26
- ##
27
- # @param [LLM::Context] ctx
28
- # @param [Hash] config
29
- # @option config [Integer, String, nil] :token_threshold
30
- # Enables token-based compaction. Integer values are treated as a fixed
31
- # token count. Percentage strings like `"90%"` are resolved against
32
- # {LLM::Context#context_window}; if the context window is unknown, the
33
- # percentage threshold is treated as disabled.
34
- # @option config [Integer, nil] :message_threshold
35
- # Enables message-count-based compaction.
36
- # @option config [Integer] :retention_window
37
- # @option config [String, nil] :model
38
- # The model to use for the summarization request. Defaults to the current
39
- # context model.
40
- def initialize(ctx, config = {})
41
- @ctx = ctx
42
- @config = DEFAULTS.merge(config)
43
- end
44
-
3
+ module LLM
45
4
  ##
46
- # Returns true when the context should be compacted.
5
+ # {LLM::Compactor LLM::Compactor} is the superclass for context compaction
6
+ # strategies in llm.rb.
47
7
  #
48
- # When `token_threshold:` is a percentage string such as `"90%"`, the
49
- # threshold is resolved against the current context window and compared to
50
- # the current total token usage.
51
- # @param [Object] prompt
52
- # The next prompt or turn input
53
- # @return [Boolean]
54
- def compactable?(prompt = nil)
55
- return false if ctx.functions.any? || [*prompt].grep(LLM::Function::Return).any?
56
- messages = ctx.messages.reject(&:system?)
57
- return true if config[:message_threshold] && messages.size > config[:message_threshold]
58
- return true if token_threshold and ctx.usage.total_tokens > token_threshold
59
- false
60
- end
61
- alias_method :compact?, :compactable?
62
-
63
- ##
64
- # Summarize older messages and replace them with a compact summary.
65
- # @param [Object] prompt
66
- # The next prompt or turn input
67
- # @return [LLM::Message, nil]
68
- def compact!(prompt = nil)
69
- return nil if ctx.functions.any? || [*prompt].grep(LLM::Function::Return).any?
70
- messages = ctx.messages.reject(&:system?)
71
- retention_window = [config[:retention_window], messages.size].min
72
- return nil unless messages.size > retention_window
73
- stream = ctx.params[:stream]
74
- stream.on_compaction(ctx, self)
75
- recent = retained_messages
76
- older = messages[0...(messages.size - recent.size)]
77
- summary = LLM::Message.new(ctx.llm.user_role, "[Previous conversation summary]\n\n#{summarize(older)}", {compaction: true})
78
- ctx.messages.replace([*ctx.messages.take_while(&:system?), summary, *recent])
79
- ctx.compacted = true
80
- stream.on_compaction_finish(ctx, self)
81
- summary
82
- end
83
-
84
- private
85
-
86
- attr_reader :ctx
87
-
88
- def retained_messages
89
- messages = ctx.messages.reject(&:system?)
90
- retention_window = [config[:retention_window], messages.size].min
91
- start = [messages.size - retention_window, 0].max
92
- start -= 1 while start > 0 && messages[start].tool_return?
93
- messages[start..] || []
94
- end
95
-
96
- def token_threshold
97
- @token_threshold ||= begin
98
- threshold = config[:token_threshold]
99
- return threshold unless threshold.to_s.end_with?("%")
100
- return if ctx.context_window <= 0
101
- (ctx.context_window * threshold.delete_suffix("%").to_f / 100).floor
8
+ # A compactor is bound to a context and decides whether and how to compact
9
+ # the conversation history when {#call} is invoked. Each subclass
10
+ # implements a different strategy: {LLM::Compactor::Truncate} drops the
11
+ # oldest messages, and {LLM::Compactor::Null} is a no-op (the default).
12
+ #
13
+ # The compactor does not have a separate `compact?` predicate. It inspects
14
+ # the context internally and returns `nil` when nothing needs to happen.
15
+ # Callers invoke {#call} unconditionally.
16
+ class Compactor
17
+ require_relative "compactor/truncate"
18
+ require_relative "compactor/null"
19
+
20
+ ##
21
+ # @return [LLM::Context]
22
+ attr_reader :ctx
23
+
24
+ ##
25
+ # @param ctx [LLM::Context, LLM::Agent]
26
+ # @return [LLM::Compactor]
27
+ def initialize(ctx)
28
+ @ctx = LLM::Agent === ctx ? ctx.instance_variable_get(:@ctx) : ctx
102
29
  end
103
- end
104
30
 
105
- def summarize(messages)
106
- model = config[:model] || ctx.params[:model] || ctx.llm.default_model
107
- ctx.llm.complete(summary_prompt(messages), model:).content
108
- end
109
-
110
- def summary_prompt(messages)
111
- <<~PROMPT
112
- Summarize this conversation history for context continuity.
113
- The summary will replace these messages in the context window.
31
+ ##
32
+ # @abstract
33
+ # @param opts [Hash] Per-call options
34
+ # @return [Object, nil]
35
+ def call(**opts)
36
+ raise NotImplementedError
37
+ end
114
38
 
115
- Focus on:
116
- - What the user asked for
117
- - Important facts and decisions
118
- - Tool calls and outcomes that still matter
119
- - What should happen next
39
+ private
120
40
 
121
- Conversation:
122
- #{serialize(messages)}
123
- PROMPT
124
- end
41
+ ##
42
+ # @return [LLM::Stream]
43
+ def stream
44
+ @ctx.params[:stream]
45
+ end
125
46
 
126
- def serialize(messages)
127
- messages.map do |message|
128
- content = case message.content
129
- when Array then message.content.map(&:inspect).join(", ")
130
- else message.content.to_s
131
- end
132
- "#{message.role}: #{content.empty? ? "(empty)" : content}"
133
- end.join("\n---\n")
47
+ ##
48
+ # @return [LLM::Buffer]
49
+ def messages
50
+ @ctx.messages
51
+ end
134
52
  end
135
53
  end
data/lib/llm/context.rb CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # {LLM::Context LLM::Context} is the stateful execution boundary in
6
- # llm.rb.
5
+ # {LLM::Context LLM::Context} is the low-level stateful execution
6
+ # boundary in llm.rb. Most users should start with {LLM::Agent}, which
7
+ # wraps Context and manages tool loops automatically. Use Context
8
+ # directly when you need manual control over tool execution.
7
9
  #
8
10
  # It holds the evolving runtime state for an LLM workflow:
9
11
  # conversation history, tool calls and returns, schema and streaming
@@ -22,19 +24,16 @@ module LLM
22
24
  # #!/usr/bin/env ruby
23
25
  # require "llm"
24
26
  #
25
- # llm = LLM.openai(key: ENV["KEY"])
26
- # ctx = LLM::Context.new(llm)
27
- #
28
- # prompt = LLM::Prompt.new(llm) do
29
- # system "Be concise and show your reasoning briefly."
30
- # user "If a train goes 60 mph for 1.5 hours, how far does it travel?"
31
- # user "Now double the speed for the same time."
32
- # end
33
- #
34
- # ctx.talk(prompt)
27
+ # llm = LLM.deepseek(key: ENV["KEY"])
28
+ # ctx = LLM::Context.new(llm, stream: $stdout)
29
+ # ctx.talk "If a train goes 60 mph for 1.5 hours, how far does it travel?"
35
30
  # ctx.messages.each { |m| puts "[#{m.role}] #{m.content}" }
31
+ #
32
+ # @see LLM::Agent The recommended high-level interface
33
+ # @see LLM::Buffer Message history (ctx.messages)
34
+ # @see LLM::Message Individual messages in the conversation
35
+ # @see LLM::Response Response returned by each turn
36
36
  class Context
37
- require_relative "compactor"
38
37
  require_relative "context/serializer"
39
38
  require_relative "context/deserializer"
40
39
  include Serializer
@@ -79,12 +78,16 @@ module LLM
79
78
  # Defaults to `:responses` for OpenAI, otherwise it defaults
80
79
  # to `:completions`.
81
80
  # @option params [String] :model Defaults to the provider's default model
81
+ # @option params [Class<LLM::Compactor>, nil] :compactor
82
+ # A compactor class to use for context compaction. Defaults to
83
+ # {LLM::Compactor::Null}.
84
+ # @option params [Hash] :compactor_options
85
+ # Options passed to the compactor's `call` method. Defaults to `{}`.
82
86
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
83
87
  # @option params [Array<String>, nil] :skills Defaults to nil
84
88
  def initialize(llm, params = {})
85
89
  @llm = llm
86
90
  @mode = params.delete(:mode) || (llm.name == :openai ? :responses : :completions)
87
- @compactor = params.delete(:compactor)
88
91
  @guard = params.delete(:guard)
89
92
  @transformer = params.delete(:transformer)
90
93
  tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
@@ -94,6 +97,10 @@ module LLM
94
97
  @messages = LLM::Buffer.new(llm)
95
98
  extra = @params.slice(:model, :tools).merge!(ctx: self, tracer:)
96
99
  @params[:stream] = LLM::Stream.try(@params[:stream], extra:)
100
+ @compactor = {
101
+ klass: params.delete(:compactor) || LLM::Compactor::Null,
102
+ options: params.delete(:compactor_options) || {}
103
+ }
97
104
  end
98
105
 
99
106
  ##
@@ -105,20 +112,9 @@ module LLM
105
112
 
106
113
  ##
107
114
  # Returns a context compactor
108
- # This feature is inspired by the compaction approach developed by
109
- # General Intelligence Systems.
110
115
  # @return [LLM::Compactor]
111
116
  def compactor
112
- @compactor = LLM::Compactor.new(self, @compactor || {}) unless LLM::Compactor === @compactor
113
- @compactor
114
- end
115
-
116
- ##
117
- # Sets a context compactor or compactor config
118
- # @param [LLM::Compactor, Hash, nil] compactor
119
- # @return [LLM::Compactor, Hash, nil]
120
- def compactor=(compactor)
121
- @compactor = compactor
117
+ @compactor[:klass]
122
118
  end
123
119
 
124
120
  ##
@@ -197,7 +193,7 @@ module LLM
197
193
  # puts res.messages[0].content
198
194
  def talk(prompt, params = {})
199
195
  @owner = @llm.request_owner
200
- compactor.compact!(prompt) if compactor.compact?(prompt)
196
+ @compactor[:klass].new(self).call(**@compactor[:options])
201
197
  repair!(@messages, prompt)
202
198
  prompt, params, res = mode == :responses ? respond(prompt, params) : complete(prompt, params)
203
199
  self.compacted = false
@@ -247,7 +243,7 @@ module LLM
247
243
  ##
248
244
  # Returns an array of functions that can be called
249
245
  # @return [Array<LLM::Function>]
250
- def functions
246
+ def pending_functions
251
247
  return_ids = returns.map(&:id)
252
248
  @messages
253
249
  .select(&:assistant?)
@@ -259,16 +255,14 @@ module LLM
259
255
  end
260
256
  end.extend(LLM::Function::Array)
261
257
  end
262
- alias_method :pending_functions, :functions
263
-
264
258
  ##
265
259
  # Returns whether there is pending tool work in this context.
266
260
  # This prefers queued streamed tool work when present, and otherwise
267
261
  # falls back to unresolved functions derived from the message history.
268
262
  # @return [Boolean]
269
- def functions?
263
+ def pending_functions?
270
264
  pending = queue
271
- (pending && !pending.empty?) || functions.any?
265
+ (pending && !pending.empty?) || pending_functions.any?
272
266
  end
273
267
 
274
268
  ##
@@ -283,7 +277,7 @@ module LLM
283
277
  def spawn(function, strategy)
284
278
  warning = guard&.call(self)
285
279
  return guarded_return_for(function, warning) if warning
286
- function.spawn(strategy)
280
+ function.task(strategy)
287
281
  end
288
282
 
289
283
  ##
@@ -310,16 +304,16 @@ module LLM
310
304
  # If the stream queue already has tool work, `wait` will drain it
311
305
  # without using this argument.
312
306
  # Otherwise, this controls how pending functions are resolved directly.
313
- # Use `:call` for sequential execution without spawning.
307
+ # Use `:sequential` for sequential execution without spawning.
314
308
  # @param [Array<LLM::Function>] except
315
309
  # A list of functions to exclude from the wait
316
310
  # @return [Array<LLM::Function::Return>]
317
311
  def wait(strategy, except: [])
318
312
  if stream.queue.empty?
319
- tools = except.empty? ? functions : functions - except
313
+ tools = except.empty? ? pending_functions : pending_functions - except
320
314
  guards = guarded_returns(tools:)
321
315
  return guards if guards
322
- @queue = tools.spawn(strategy)
316
+ @queue = tools.task(strategy)
323
317
  returns = @queue.wait
324
318
  emit_tool_returns(tools, returns)
325
319
  returns
@@ -339,7 +333,7 @@ module LLM
339
333
  def interrupt!
340
334
  llm.interrupt!(@owner)
341
335
  queue&.interrupt!
342
- functions.each(&:interrupt!)
336
+ pending_functions.each(&:interrupt!)
343
337
  @queue = nil
344
338
  @owner = nil
345
339
  nil
data/lib/llm/contract.rb CHANGED
@@ -2,32 +2,11 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # The `LLM::Contract` module provides the ability for modules
6
- # who are extended by it to implement contracts which must be
7
- # implemented by other modules who include a given contract.
5
+ # @api private
8
6
  #
9
- # @example
10
- # module LLM::Contract
11
- # # ..
12
- # end
13
- #
14
- # module LLM::Contract
15
- # module Completion
16
- # extend LLM::Contract
17
- # # inheriting modules must implement these methods
18
- # # otherwise an error is raised on include
19
- # def foo = nil
20
- # def bar = nil
21
- # end
22
- # end
23
- #
24
- # module LLM::OpenAI::ResponseAdapter
25
- # module Completion
26
- # def foo = nil
27
- # def bar = nil
28
- # include LLM::Contract::Completion
29
- # end
30
- # end
7
+ # The `LLM::Contract` module enforces API contracts between
8
+ # provider response adapters and the runtime. Users never
9
+ # interact with this module directly.
31
10
  module Contract
32
11
  ContractError = Class.new(LLM::Error)
33
12
  require_relative "contract/completion"
@@ -23,30 +23,31 @@ class LLM::Function
23
23
  #
24
24
  # @param [Symbol] strategy
25
25
  # Controls concurrency strategy:
26
- # - `:call`: Call functions sequentially without spawning
26
+ # - `:sequential`: Call functions sequentially without spawning
27
27
  # - `:thread`: Use threads
28
- # - `:task`: Use async tasks (requires async gem)
28
+ # - `:async`: Use async tasks (requires async gem)
29
29
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
30
30
  # - `:fork`: Use forked child processes
31
31
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
32
32
  #
33
- # @return [LLM::Function::CallGroup, LLM::Function::ThreadGroup, LLM::Function::TaskGroup, LLM::Function::FiberGroup, LLM::Function::Ractor::Group]
34
- def spawn(strategy)
33
+ # @return [LLM::Function::Sequential::Group, LLM::Function::Thread::Group, LLM::Function::Async::Group, LLM::Function::Fiber::Group, LLM::Function::Fork::Group, LLM::Function::Ractor::Group]
34
+ def task(strategy)
35
35
  case strategy
36
- when :call
37
- CallGroup.new(self)
38
- when :task
39
- TaskGroup.new(map { |fn| fn.spawn(:task) })
36
+ when :sequential
37
+ Sequential::Group.new(self)
38
+ when :async
39
+ LLM.require "async" unless defined?(::Async)
40
+ Async::Group.new(map { |fn| fn.task(:async) })
40
41
  when :thread
41
- ThreadGroup.new(map { |fn| fn.spawn(:thread) })
42
+ Thread::Group.new(map { |fn| fn.task(:thread) })
42
43
  when :fiber
43
- FiberGroup.new(map { |fn| fn.spawn(:fiber) })
44
+ Fiber::Group.new(map { |fn| fn.task(:fiber) })
44
45
  when :fork
45
- Fork::Group.new(map { |fn| fn.spawn(:fork) })
46
+ Fork::Group.new(map { |fn| fn.task(:fork) })
46
47
  when :ractor
47
- Ractor::Group.new(map { |fn| fn.spawn(:ractor) })
48
+ Ractor::Group.new(map { |fn| fn.task(:ractor) })
48
49
  else
49
- raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :call, :thread, :task, :fiber, :fork, or :ractor"
50
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :sequential, :thread, :async, :fiber, :fork, or :ractor"
50
51
  end
51
52
  end
52
53
 
@@ -66,7 +67,7 @@ class LLM::Function
66
67
  # @return [Array<LLM::Function::Return>]
67
68
  # Returns values to be reported back to the LLM.
68
69
  def wait(strategy)
69
- spawn(strategy).wait
70
+ task(strategy).wait
70
71
  end
71
72
 
72
73
  ##
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Function::Async
4
+ ##
5
+ # Wraps an array of {Async::Task} objects running on a shared
6
+ # {LLM::Function::Async::Reactor}. The reactor is created on
7
+ # demand if not provided.
8
+ class Group < LLM::Function::Group
9
+ ##
10
+ # @param [Array<Async::Task>] tasks
11
+ # @param [Hash] options
12
+ # @option options [LLM::Function::Async::Reactor] :reactor
13
+ def initialize(tasks, options = {})
14
+ @tasks = tasks
15
+ @reactor = options[:reactor] || LLM::Function::Async::Reactor.new
16
+ end
17
+
18
+ ##
19
+ # @return [nil]
20
+ def spawn
21
+ @tasks.each do |task|
22
+ task.reactor = @reactor
23
+ task.spawn
24
+ end
25
+ nil
26
+ ensure
27
+ @spawned = true
28
+ end
29
+
30
+ ##
31
+ # @return [Boolean]
32
+ def alive?
33
+ @tasks.any?(&:alive?)
34
+ end
35
+
36
+ ##
37
+ # @return [nil]
38
+ def interrupt!
39
+ @tasks.each(&:interrupt!)
40
+ nil
41
+ end
42
+ alias_method :cancel!, :interrupt!
43
+
44
+ ##
45
+ # @return [Array<LLM::Function::Return>]
46
+ def wait
47
+ spawn unless @spawned
48
+ @tasks.map(&:wait)
49
+ ensure
50
+ @reactor.stop
51
+ end
52
+ alias_method :value, :wait
53
+ end
54
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Function::Async
4
+ ##
5
+ # Manages an {::Async::Reactor} on a background thread. Work
6
+ # is submitted through a thread-safe queue and run inside the
7
+ # reactor. The reactor and its fibers stay on one thread.
8
+ class Reactor
9
+ ##
10
+ # @return [Thread]
11
+ attr_reader :thread
12
+
13
+ def initialize
14
+ @inbox = Queue.new
15
+ @thread = ::Thread.new { run }
16
+ end
17
+
18
+ ##
19
+ # Submit a block to run inside the reactor.
20
+ # @return [nil]
21
+ def submit(&block)
22
+ @inbox << block
23
+ nil
24
+ end
25
+
26
+ ##
27
+ # Stop the reactor and wait for the thread to finish.
28
+ def stop
29
+ @inbox << :stop
30
+ @thread.join(5)
31
+ @thread.kill if @thread.alive?
32
+ end
33
+
34
+ private
35
+
36
+ def run
37
+ reactor = ::Async::Reactor.new
38
+ reactor.async do
39
+ loop do
40
+ work = @inbox.pop
41
+ break if work == :stop
42
+ reactor.async { work.call }
43
+ end
44
+ end
45
+ reactor.run
46
+ end
47
+ end
48
+ end