llm.rb 12.5.1 → 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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +482 -0
  3. data/LICENSE +21 -93
  4. data/README.md +49 -159
  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_agent.rb +32 -0
  9. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  10. data/lib/llm/agent.rb +101 -26
  11. data/lib/llm/buffer.rb +85 -3
  12. data/lib/llm/compactor/null.rb +19 -0
  13. data/lib/llm/compactor/truncate.rb +80 -0
  14. data/lib/llm/compactor.rb +42 -124
  15. data/lib/llm/context.rb +33 -37
  16. data/lib/llm/contract.rb +4 -25
  17. data/lib/llm/function/array.rb +15 -14
  18. data/lib/llm/function/async/group.rb +54 -0
  19. data/lib/llm/function/async/reactor.rb +48 -0
  20. data/lib/llm/function/async/task.rb +83 -0
  21. data/lib/llm/function/fiber/group.rb +46 -0
  22. data/lib/llm/function/fiber/task.rb +62 -0
  23. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  24. data/lib/llm/function/fork/job.rb +4 -3
  25. data/lib/llm/function/fork/task.rb +20 -10
  26. data/lib/llm/function/group.rb +40 -0
  27. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  28. data/lib/llm/function/ractor/job.rb +19 -3
  29. data/lib/llm/function/ractor/mailbox.rb +9 -0
  30. data/lib/llm/function/ractor/task.rb +24 -15
  31. data/lib/llm/function/{call_group.rb → sequential/group.rb} +17 -8
  32. data/lib/llm/function/sequential/task.rb +49 -0
  33. data/lib/llm/function/task.rb +25 -37
  34. data/lib/llm/function/thread/group.rb +46 -0
  35. data/lib/llm/function/thread/task.rb +60 -0
  36. data/lib/llm/function/tracing.rb +2 -0
  37. data/lib/llm/function.rb +56 -64
  38. data/lib/llm/loop_guard.rb +1 -2
  39. data/lib/llm/mcp.rb +22 -0
  40. data/lib/llm/object.rb +2 -1
  41. data/lib/llm/provider.rb +6 -3
  42. data/lib/llm/providers/google.rb +2 -2
  43. data/lib/llm/repl/command.rb +35 -8
  44. data/lib/llm/repl/commands/compact.rb +33 -0
  45. data/lib/llm/repl/input.rb +80 -15
  46. data/lib/llm/repl/markdown/table.rb +76 -0
  47. data/lib/llm/repl/markdown.rb +31 -1
  48. data/lib/llm/repl/status.rb +1 -1
  49. data/lib/llm/repl/stream.rb +10 -3
  50. data/lib/llm/repl/transcript.rb +1 -1
  51. data/lib/llm/repl/walker.rb +46 -0
  52. data/lib/llm/repl.rb +18 -12
  53. data/lib/llm/response.rb +10 -0
  54. data/lib/llm/schema/leaf.rb +5 -0
  55. data/lib/llm/schema/object.rb +11 -5
  56. data/lib/llm/sequel/agent.rb +32 -0
  57. data/lib/llm/sequel/plugin.rb +6 -6
  58. data/lib/llm/stream.rb +24 -17
  59. data/lib/llm/tool/param.rb +12 -0
  60. data/lib/llm/tool.rb +20 -4
  61. data/lib/llm/tools/chdir.rb +0 -2
  62. data/lib/llm/tools/git.rb +8 -4
  63. data/lib/llm/tools/mkdir.rb +1 -1
  64. data/lib/llm/tools/pwd.rb +0 -2
  65. data/lib/llm/tools/read_file.rb +0 -2
  66. data/lib/llm/tools/rg.rb +8 -4
  67. data/lib/llm/tools/shell.rb +8 -4
  68. data/lib/llm/tools/utils.rb +31 -0
  69. data/lib/llm/version.rb +1 -1
  70. data/lib/llm.rb +25 -5
  71. data/llm.gemspec +3 -3
  72. data/resources/deepdive.md +693 -57
  73. metadata +24 -13
  74. data/lib/llm/function/call_task.rb +0 -46
  75. data/lib/llm/function/fiber_group.rb +0 -105
  76. data/lib/llm/function/task_group.rb +0 -97
  77. data/lib/llm/function/thread_group.rb +0 -102
data/lib/llm/buffer.rb CHANGED
@@ -3,10 +3,24 @@
3
3
  module LLM
4
4
  ##
5
5
  # {LLM::Buffer LLM::Buffer} provides an Enumerable object that
6
- # tracks messages in a conversation thread.
6
+ # tracks messages in a conversation thread. Access it through
7
+ # {LLM::Context#messages}.
8
+ #
9
+ # @example Working with message history
10
+ # ctx.messages.last # => most recent message
11
+ # ctx.messages.first # => oldest message
12
+ # ctx.messages.select! { |m| m.assistant? }
13
+ # ctx.messages.reverse # => reversed copy
14
+ # ctx.messages.reject! { |m| m.compaction? }
15
+ #
16
+ # @see LLM::Message Individual messages in the buffer
17
+ # @see LLM::Context Where the buffer lives (ctx.messages)
7
18
  class Buffer
8
19
  include Enumerable
9
20
 
21
+ UNDEFINED = Object.new
22
+ private_constant :UNDEFINED
23
+
10
24
  ##
11
25
  # @param [LLM::Provider] provider
12
26
  # @return [LLM::Buffer]
@@ -65,8 +79,69 @@ module LLM
65
79
  # @param [Integer, nil] n
66
80
  # The number of messages to return
67
81
  # @return [LLM::Message, Array<LLM::Message>, nil]
68
- def last(n = nil)
69
- n.nil? ? @messages.last : @messages.last(n)
82
+ def last(n = UNDEFINED)
83
+ n.equal?(UNDEFINED) ? @messages.last : @messages.last(n)
84
+ end
85
+
86
+ ##
87
+ # Returns the first message(s) in the buffer
88
+ # @param [Integer, nil] n
89
+ # The number of messages to return
90
+ # @return [LLM::Message, Array<LLM::Message>, nil]
91
+ def first(n = UNDEFINED)
92
+ n.equal?(UNDEFINED) ? @messages.first : @messages.first(n)
93
+ end
94
+
95
+ ##
96
+ # Removes messages matching the block in-place.
97
+ # @yield [LLM::Message]
98
+ # @return [LLM::Buffer]
99
+ def reject!(&)
100
+ @messages.reject!(&)
101
+ self
102
+ end
103
+ alias_method :delete_if, :reject!
104
+
105
+ ##
106
+ # Keeps messages matching the block in-place.
107
+ # @yield [LLM::Message]
108
+ # @return [LLM::Buffer]
109
+ def select!(&)
110
+ @messages.select!(&)
111
+ self
112
+ end
113
+
114
+ ##
115
+ # Removes and returns the first message.
116
+ # @return [LLM::Message, nil]
117
+ def shift
118
+ @messages.shift
119
+ end
120
+
121
+ ##
122
+ # Removes all messages.
123
+ # @return [LLM::Buffer]
124
+ def clear
125
+ @messages.clear
126
+ self
127
+ end
128
+
129
+ ##
130
+ # Returns all elements after the first n.
131
+ # @param [Integer] n
132
+ # The number of messages to skip
133
+ # @return [Array<LLM::Message>]
134
+ def drop(n)
135
+ @messages.drop(n)
136
+ end
137
+
138
+ ##
139
+ # Returns the first n elements without removing them.
140
+ # @param [Integer] n
141
+ # The number of messages to return
142
+ # @return [Array<LLM::Message>]
143
+ def take(n)
144
+ @messages.take(n)
70
145
  end
71
146
 
72
147
  ##
@@ -103,6 +178,13 @@ module LLM
103
178
  @messages[index]
104
179
  end
105
180
 
181
+ ##
182
+ # Returns a reversed copy of the internal array.
183
+ # @return [Array]
184
+ def reverse
185
+ @messages.reverse
186
+ end
187
+
106
188
  ##
107
189
  # @return [String]
108
190
  def to_json(...)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Compactor
4
+ ##
5
+ # An {LLM::Compactor::Null LLM::Compactor::Null} is a compactor that
6
+ # does nothing. It is used as the default when no compactor strategy
7
+ # is configured.
8
+ #
9
+ # All methods return nil and produce no side effects.
10
+ class Null < self
11
+ ##
12
+ # @param [Hash] opts
13
+ # Ignored
14
+ # @return [nil]
15
+ def call(**opts)
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -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
@@ -206,6 +202,8 @@ module LLM
206
202
  @messages.concat LLM::Prompt === prompt ? prompt.to_a : [LLM::Message.new(role, prompt)]
207
203
  @messages.concat [res.choices[-1]].compact
208
204
  res
205
+ ensure
206
+ @owner = nil
209
207
  end
210
208
 
211
209
  ##
@@ -245,7 +243,7 @@ module LLM
245
243
  ##
246
244
  # Returns an array of functions that can be called
247
245
  # @return [Array<LLM::Function>]
248
- def functions
246
+ def pending_functions
249
247
  return_ids = returns.map(&:id)
250
248
  @messages
251
249
  .select(&:assistant?)
@@ -257,16 +255,14 @@ module LLM
257
255
  end
258
256
  end.extend(LLM::Function::Array)
259
257
  end
260
- alias_method :pending_functions, :functions
261
-
262
258
  ##
263
259
  # Returns whether there is pending tool work in this context.
264
260
  # This prefers queued streamed tool work when present, and otherwise
265
261
  # falls back to unresolved functions derived from the message history.
266
262
  # @return [Boolean]
267
- def functions?
263
+ def pending_functions?
268
264
  pending = queue
269
- (pending && !pending.empty?) || functions.any?
265
+ (pending && !pending.empty?) || pending_functions.any?
270
266
  end
271
267
 
272
268
  ##
@@ -281,7 +277,7 @@ module LLM
281
277
  def spawn(function, strategy)
282
278
  warning = guard&.call(self)
283
279
  return guarded_return_for(function, warning) if warning
284
- function.spawn(strategy)
280
+ function.task(strategy)
285
281
  end
286
282
 
287
283
  ##
@@ -308,16 +304,16 @@ module LLM
308
304
  # If the stream queue already has tool work, `wait` will drain it
309
305
  # without using this argument.
310
306
  # Otherwise, this controls how pending functions are resolved directly.
311
- # Use `:call` for sequential execution without spawning.
307
+ # Use `:sequential` for sequential execution without spawning.
312
308
  # @param [Array<LLM::Function>] except
313
309
  # A list of functions to exclude from the wait
314
310
  # @return [Array<LLM::Function::Return>]
315
311
  def wait(strategy, except: [])
316
312
  if stream.queue.empty?
317
- tools = except.empty? ? functions : functions - except
313
+ tools = except.empty? ? pending_functions : pending_functions - except
318
314
  guards = guarded_returns(tools:)
319
315
  return guards if guards
320
- @queue = tools.spawn(strategy)
316
+ @queue = tools.task(strategy)
321
317
  returns = @queue.wait
322
318
  emit_tool_returns(tools, returns)
323
319
  returns
@@ -337,7 +333,7 @@ module LLM
337
333
  def interrupt!
338
334
  llm.interrupt!(@owner)
339
335
  queue&.interrupt!
340
- functions.each(&:interrupt!)
336
+ pending_functions.each(&:interrupt!)
341
337
  @queue = nil
342
338
  @owner = nil
343
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
  ##