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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +482 -0
- data/LICENSE +21 -93
- data/README.md +49 -159
- 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_agent.rb +32 -0
- data/lib/llm/active_record/acts_as_llm.rb +6 -6
- data/lib/llm/agent.rb +101 -26
- 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 +33 -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 +4 -3
- data/lib/llm/function/fork/task.rb +20 -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 +19 -3
- data/lib/llm/function/ractor/mailbox.rb +9 -0
- data/lib/llm/function/ractor/task.rb +24 -15
- data/lib/llm/function/{call_group.rb → sequential/group.rb} +17 -8
- data/lib/llm/function/sequential/task.rb +49 -0
- data/lib/llm/function/task.rb +25 -37
- data/lib/llm/function/thread/group.rb +46 -0
- data/lib/llm/function/thread/task.rb +60 -0
- data/lib/llm/function/tracing.rb +2 -0
- data/lib/llm/function.rb +56 -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/agent.rb +32 -0
- data/lib/llm/sequel/plugin.rb +6 -6
- data/lib/llm/stream.rb +24 -17
- data/lib/llm/tool/param.rb +12 -0
- 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 +693 -57
- 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
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 =
|
|
69
|
-
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
|
-
#
|
|
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
|
|
@@ -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
|
|
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
|
|
263
|
+
def pending_functions?
|
|
268
264
|
pending = queue
|
|
269
|
-
(pending && !pending.empty?) ||
|
|
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.
|
|
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 `:
|
|
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? ?
|
|
313
|
+
tools = except.empty? ? pending_functions : pending_functions - except
|
|
318
314
|
guards = guarded_returns(tools:)
|
|
319
315
|
return guards if guards
|
|
320
|
-
@queue = tools.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
##
|