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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +387 -0
- data/LICENSE +21 -93
- data/README.md +46 -155
- data/data/deepinfra.json +3 -0
- data/data/xai.json +1 -1
- data/lib/llm/a2a.rb +1 -1
- data/lib/llm/active_record/acts_as_llm.rb +6 -6
- data/lib/llm/agent.rb +62 -23
- data/lib/llm/buffer.rb +85 -3
- data/lib/llm/compactor/null.rb +19 -0
- data/lib/llm/compactor/truncate.rb +80 -0
- data/lib/llm/compactor.rb +42 -124
- data/lib/llm/context.rb +31 -37
- data/lib/llm/contract.rb +4 -25
- data/lib/llm/function/array.rb +15 -14
- data/lib/llm/function/async/group.rb +54 -0
- data/lib/llm/function/async/reactor.rb +48 -0
- data/lib/llm/function/async/task.rb +83 -0
- data/lib/llm/function/fiber/group.rb +46 -0
- data/lib/llm/function/fiber/task.rb +62 -0
- data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
- data/lib/llm/function/fork/job.rb +2 -2
- data/lib/llm/function/fork/task.rb +19 -10
- data/lib/llm/function/group.rb +40 -0
- data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
- data/lib/llm/function/ractor/job.rb +9 -3
- data/lib/llm/function/ractor/mailbox.rb +2 -0
- data/lib/llm/function/ractor/task.rb +23 -15
- data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
- data/lib/llm/function/sequential/task.rb +49 -0
- data/lib/llm/function/task.rb +25 -48
- data/lib/llm/function/thread/group.rb +46 -0
- data/lib/llm/function/thread/task.rb +60 -0
- data/lib/llm/function.rb +54 -64
- data/lib/llm/loop_guard.rb +1 -2
- data/lib/llm/mcp.rb +22 -0
- data/lib/llm/object.rb +2 -1
- data/lib/llm/provider.rb +6 -3
- data/lib/llm/providers/google.rb +2 -2
- data/lib/llm/repl/command.rb +35 -8
- data/lib/llm/repl/commands/compact.rb +33 -0
- data/lib/llm/repl/input.rb +80 -15
- data/lib/llm/repl/markdown/table.rb +76 -0
- data/lib/llm/repl/markdown.rb +31 -1
- data/lib/llm/repl/status.rb +1 -1
- data/lib/llm/repl/stream.rb +10 -3
- data/lib/llm/repl/transcript.rb +1 -1
- data/lib/llm/repl/walker.rb +46 -0
- data/lib/llm/repl.rb +18 -12
- data/lib/llm/response.rb +10 -0
- data/lib/llm/schema/leaf.rb +5 -0
- data/lib/llm/schema/object.rb +11 -5
- data/lib/llm/sequel/plugin.rb +6 -6
- data/lib/llm/stream.rb +24 -17
- data/lib/llm/tool.rb +20 -4
- data/lib/llm/tools/chdir.rb +0 -2
- data/lib/llm/tools/git.rb +8 -4
- data/lib/llm/tools/mkdir.rb +1 -1
- data/lib/llm/tools/pwd.rb +0 -2
- data/lib/llm/tools/read_file.rb +0 -2
- data/lib/llm/tools/rg.rb +8 -4
- data/lib/llm/tools/shell.rb +8 -4
- data/lib/llm/tools/utils.rb +31 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +25 -5
- data/llm.gemspec +3 -3
- data/resources/deepdive.md +645 -58
- metadata +24 -13
- data/lib/llm/function/call_task.rb +0 -46
- data/lib/llm/function/fiber_group.rb +0 -105
- data/lib/llm/function/task_group.rb +0 -97
- 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
|
-
#
|
|
5
|
+
# {LLM::Compactor LLM::Compactor} is the superclass for context compaction
|
|
6
|
+
# strategies in llm.rb.
|
|
47
7
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
41
|
+
##
|
|
42
|
+
# @return [LLM::Stream]
|
|
43
|
+
def stream
|
|
44
|
+
@ctx.params[:stream]
|
|
45
|
+
end
|
|
125
46
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
263
|
+
def pending_functions?
|
|
270
264
|
pending = queue
|
|
271
|
-
(pending && !pending.empty?) ||
|
|
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.
|
|
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 `:
|
|
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? ?
|
|
313
|
+
tools = except.empty? ? pending_functions : pending_functions - except
|
|
320
314
|
guards = guarded_returns(tools:)
|
|
321
315
|
return guards if guards
|
|
322
|
-
@queue = tools.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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"
|
data/lib/llm/function/array.rb
CHANGED
|
@@ -23,30 +23,31 @@ class LLM::Function
|
|
|
23
23
|
#
|
|
24
24
|
# @param [Symbol] strategy
|
|
25
25
|
# Controls concurrency strategy:
|
|
26
|
-
# - `:
|
|
26
|
+
# - `:sequential`: Call functions sequentially without spawning
|
|
27
27
|
# - `:thread`: Use threads
|
|
28
|
-
# - `:
|
|
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::
|
|
34
|
-
def
|
|
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 :
|
|
37
|
-
|
|
38
|
-
when :
|
|
39
|
-
|
|
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
|
-
|
|
42
|
+
Thread::Group.new(map { |fn| fn.task(:thread) })
|
|
42
43
|
when :fiber
|
|
43
|
-
|
|
44
|
+
Fiber::Group.new(map { |fn| fn.task(:fiber) })
|
|
44
45
|
when :fork
|
|
45
|
-
Fork::Group.new(map { |fn| fn.
|
|
46
|
+
Fork::Group.new(map { |fn| fn.task(:fork) })
|
|
46
47
|
when :ractor
|
|
47
|
-
Ractor::Group.new(map { |fn| fn.
|
|
48
|
+
Ractor::Group.new(map { |fn| fn.task(:ractor) })
|
|
48
49
|
else
|
|
49
|
-
raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :
|
|
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
|
-
|
|
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
|