llm.rb 12.3.1 → 12.5.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 +395 -0
- data/README.md +103 -16
- data/data/anthropic.json +249 -249
- data/data/bedrock.json +2038 -1879
- data/data/deepinfra.json +591 -591
- data/data/deepseek.json +31 -31
- data/data/google.json +332 -329
- data/data/mistral.json +381 -381
- data/data/openai.json +1132 -1132
- data/data/xai.json +138 -138
- data/data/zai.json +165 -165
- data/lib/llm/agent.rb +12 -7
- data/lib/llm/buffer.rb +15 -0
- data/lib/llm/context/deserializer.rb +2 -5
- data/lib/llm/context.rb +4 -5
- data/lib/llm/function.rb +2 -35
- data/lib/llm/message.rb +12 -2
- data/lib/llm/provider.rb +11 -1
- data/lib/llm/providers/anthropic/stream_parser.rb +3 -12
- data/lib/llm/providers/anthropic.rb +11 -0
- data/lib/llm/providers/bedrock/stream_parser.rb +4 -13
- data/lib/llm/providers/google/stream_parser.rb +3 -12
- data/lib/llm/providers/google.rb +7 -0
- data/lib/llm/providers/mistral.rb +11 -0
- data/lib/llm/providers/ollama/stream_parser.rb +4 -4
- data/lib/llm/providers/ollama.rb +11 -0
- data/lib/llm/providers/openai/responses/stream_parser.rb +4 -14
- data/lib/llm/providers/openai/responses.rb +10 -0
- data/lib/llm/providers/openai/stream_parser.rb +5 -15
- data/lib/llm/providers/openai.rb +11 -0
- data/lib/llm/repl/command.rb +201 -0
- data/lib/llm/repl/commands/exit.rb +23 -0
- data/lib/llm/repl/commands/help.rb +24 -0
- data/lib/llm/repl/input.rb +118 -35
- data/lib/llm/repl/stream.rb +43 -8
- data/lib/llm/repl/transcript.rb +6 -0
- data/lib/llm/repl/window.rb +28 -6
- data/lib/llm/repl.rb +125 -27
- data/lib/llm/stream.rb +2 -7
- data/lib/llm/tools/ls.rb +30 -0
- data/lib/llm/tools/which.rb +38 -0
- data/lib/llm/version.rb +1 -1
- data/resources/deepdive.md +67 -1
- metadata +6 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The 'exit' command exits the read-eval-print loop
|
|
6
|
+
# by throwing. The {LLM::Repl LLM::Repl} class covers
|
|
7
|
+
# the loop with a catch that gracefully recovers and
|
|
8
|
+
# exits the loop.
|
|
9
|
+
class Command::Exit < Command
|
|
10
|
+
name "exit"
|
|
11
|
+
description "exits the repl"
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @return [void]
|
|
15
|
+
def call
|
|
16
|
+
throw(:exit)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Command::Quit < Command::Exit
|
|
21
|
+
name "quit"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
class Help < Command
|
|
5
|
+
name "help"
|
|
6
|
+
description "show help for a given command"
|
|
7
|
+
parameter :name, String, "The name of a command"
|
|
8
|
+
|
|
9
|
+
##
|
|
10
|
+
# @param [String] name
|
|
11
|
+
# @return [void]
|
|
12
|
+
def call(name: nil)
|
|
13
|
+
if name.nil?
|
|
14
|
+
write("\n#{self.class.help}\n\n")
|
|
15
|
+
elsif command = LLM::Command.find_by(name:)
|
|
16
|
+
write("\n#{command.help}\n\n")
|
|
17
|
+
else
|
|
18
|
+
write "\nNo help for #{name} was found" \
|
|
19
|
+
"\nThat command doesn't exist." \
|
|
20
|
+
"\n\n"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/llm/repl/input.rb
CHANGED
|
@@ -6,15 +6,38 @@ class LLM::Repl
|
|
|
6
6
|
# the editable input line shown at the bottom of the REPL.
|
|
7
7
|
# @api private
|
|
8
8
|
class Input
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
CTRL = {
|
|
10
|
+
A: Curses::KEY_CTRL_A,
|
|
11
|
+
E: Curses::KEY_CTRL_E,
|
|
12
|
+
F: Curses::KEY_CTRL_F,
|
|
13
|
+
K: Curses::KEY_CTRL_K,
|
|
14
|
+
Y: Curses::KEY_CTRL_Y,
|
|
15
|
+
D: Curses::KEY_CTRL_D
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
UP = Curses::Key::UP
|
|
12
19
|
DOWN = Curses::Key::DOWN
|
|
13
20
|
LEFT = Curses::Key::LEFT
|
|
14
21
|
RIGHT = Curses::Key::RIGHT
|
|
22
|
+
ESC = 27
|
|
15
23
|
ENTER = [Curses::Key::ENTER, 10, 13]
|
|
16
24
|
BACKSPACE = [Curses::Key::BACKSPACE, 127]
|
|
17
|
-
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Threshold in seconds. If characters arrive faster than
|
|
28
|
+
# this, we assume the user is pasting multi-line text.
|
|
29
|
+
# Human typing is ~150–300ms per key, so 50ms reliably
|
|
30
|
+
# distinguishes a paste from manual typing.
|
|
31
|
+
PASTE_THRESHOLD = 0.05
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @return [String]
|
|
35
|
+
attr_reader :buffer
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param [Boolean] bool
|
|
39
|
+
# @return [void]
|
|
40
|
+
attr_writer :paste
|
|
18
41
|
|
|
19
42
|
##
|
|
20
43
|
# @param [LLM::Agent] agent
|
|
@@ -26,44 +49,66 @@ class LLM::Repl
|
|
|
26
49
|
@cursor = 0
|
|
27
50
|
@scroll = 0
|
|
28
51
|
@height = options.fetch(:height, 3)
|
|
52
|
+
@last_char_at = nil
|
|
53
|
+
@paste = false
|
|
29
54
|
end
|
|
30
55
|
|
|
31
56
|
##
|
|
32
57
|
# @param [LLM::Repl::Window] window
|
|
33
58
|
# @param [Object] char
|
|
34
59
|
# @return [Symbol, nil]
|
|
35
|
-
def on_char(window, char)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
elsif char == UP
|
|
44
|
-
window.scroll_up
|
|
45
|
-
:up
|
|
46
|
-
elsif char == DOWN
|
|
47
|
-
window.scroll_down
|
|
48
|
-
:down
|
|
49
|
-
elsif char == CTRL_A
|
|
60
|
+
def on_char(window, char, now)
|
|
61
|
+
is_paste = lambda { @last_char_at and (now - @last_char_at) < PASTE_THRESHOLD }
|
|
62
|
+
if ESC == char
|
|
63
|
+
@agent.cancel!
|
|
64
|
+
elsif CTRL[:D] == char
|
|
65
|
+
delete
|
|
66
|
+
:ctrl_d
|
|
67
|
+
elsif CTRL[:A] == char
|
|
50
68
|
move_start
|
|
51
69
|
:ctrl_a
|
|
52
|
-
elsif
|
|
70
|
+
elsif CTRL[:E] == char
|
|
53
71
|
move_end
|
|
54
72
|
:ctrl_e
|
|
73
|
+
elsif CTRL[:F] == char
|
|
74
|
+
move_forward
|
|
75
|
+
:ctrl_f
|
|
76
|
+
elsif CTRL[:Y] == char
|
|
77
|
+
restore
|
|
78
|
+
:ctrl_y
|
|
79
|
+
elsif CTRL[:K] == char
|
|
80
|
+
kill
|
|
81
|
+
:ctrl_k
|
|
55
82
|
elsif char == LEFT
|
|
56
83
|
move_left
|
|
57
84
|
:left
|
|
58
85
|
elsif char == RIGHT
|
|
59
86
|
move_right
|
|
60
87
|
:right
|
|
88
|
+
elsif BACKSPACE.include?(char)
|
|
89
|
+
backspace
|
|
90
|
+
:backspace
|
|
91
|
+
elsif ENTER.include?(char)
|
|
92
|
+
if @paste = is_paste.()
|
|
93
|
+
insert("\n")
|
|
94
|
+
:char
|
|
95
|
+
else
|
|
96
|
+
:submit
|
|
97
|
+
end
|
|
98
|
+
elsif char == UP
|
|
99
|
+
window.scroll_up
|
|
100
|
+
:up
|
|
101
|
+
elsif char == DOWN
|
|
102
|
+
window.scroll_down
|
|
103
|
+
:down
|
|
61
104
|
elsif String === char
|
|
62
105
|
insert(char)
|
|
63
106
|
:char
|
|
64
107
|
else
|
|
65
108
|
nil
|
|
66
109
|
end
|
|
110
|
+
ensure
|
|
111
|
+
@last_char_at = now if char
|
|
67
112
|
end
|
|
68
113
|
|
|
69
114
|
##
|
|
@@ -86,27 +131,25 @@ class LLM::Repl
|
|
|
86
131
|
|
|
87
132
|
##
|
|
88
133
|
# Returns the visible lines of the input buffer,
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# visible.
|
|
92
|
-
# @param [Integer] cols
|
|
134
|
+
# split by newlines. The viewport follows the cursor
|
|
135
|
+
# so the cursor line is always visible.
|
|
93
136
|
# @return [Array<String>]
|
|
94
|
-
def lines
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
chunks = text.chars.each_slice(cols).map(&:join)
|
|
98
|
-
chunks = [""] if chunks.empty?
|
|
137
|
+
def lines
|
|
138
|
+
scroll!
|
|
139
|
+
chunks = to_s.split("\n", -1)
|
|
99
140
|
chunks[@scroll, height] || []
|
|
100
141
|
end
|
|
101
142
|
|
|
102
143
|
##
|
|
103
144
|
# Returns the cursor position as [line, column] within
|
|
104
145
|
# the visible viewport.
|
|
105
|
-
# @param [Integer] cols
|
|
106
146
|
# @return [Array(Integer, Integer)]
|
|
107
|
-
def cursor_pos
|
|
108
|
-
|
|
109
|
-
|
|
147
|
+
def cursor_pos
|
|
148
|
+
scroll!
|
|
149
|
+
before = to_s[0...cursor]
|
|
150
|
+
line = before.count("\n")
|
|
151
|
+
col = cursor - (before.rindex("\n") || -1) - 1
|
|
152
|
+
[line - @scroll, col]
|
|
110
153
|
end
|
|
111
154
|
|
|
112
155
|
##
|
|
@@ -133,6 +176,35 @@ class LLM::Repl
|
|
|
133
176
|
@cursor = [@cursor + 1, @buffer.size].min
|
|
134
177
|
end
|
|
135
178
|
|
|
179
|
+
##
|
|
180
|
+
# @return [void]
|
|
181
|
+
def move_forward
|
|
182
|
+
@cursor = [0, @cursor + 1].max
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
##
|
|
186
|
+
# @return [void]
|
|
187
|
+
def kill
|
|
188
|
+
@copy = @buffer.slice(@cursor, @buffer.size)
|
|
189
|
+
@buffer[@cursor, @buffer.size] = ""
|
|
190
|
+
@cursor = @buffer.size
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
##
|
|
194
|
+
# @return [void]
|
|
195
|
+
def delete
|
|
196
|
+
@buffer[@cursor] = ""
|
|
197
|
+
@cursor = [0, @cursor].max
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
##
|
|
201
|
+
# @return [void]
|
|
202
|
+
def restore
|
|
203
|
+
return unless @copy
|
|
204
|
+
@buffer.insert(@cursor, @copy)
|
|
205
|
+
@cursor += @copy.size
|
|
206
|
+
end
|
|
207
|
+
|
|
136
208
|
##
|
|
137
209
|
# @return [String]
|
|
138
210
|
def take
|
|
@@ -143,14 +215,20 @@ class LLM::Repl
|
|
|
143
215
|
end
|
|
144
216
|
end
|
|
145
217
|
|
|
218
|
+
##
|
|
219
|
+
# @return [Boolean]
|
|
220
|
+
def paste?
|
|
221
|
+
@paste
|
|
222
|
+
end
|
|
223
|
+
|
|
146
224
|
private
|
|
147
225
|
|
|
148
226
|
##
|
|
149
227
|
# Adjusts @scroll so the cursor line is visible within
|
|
150
228
|
# the viewport.
|
|
151
|
-
def
|
|
152
|
-
total_lines
|
|
153
|
-
cursor_line = cursor
|
|
229
|
+
def scroll!(total_lines = nil)
|
|
230
|
+
total_lines ||= to_s.split("\n", -1).size
|
|
231
|
+
cursor_line = to_s[0...cursor].count("\n")
|
|
154
232
|
if cursor_line < @scroll
|
|
155
233
|
@scroll = cursor_line
|
|
156
234
|
elsif cursor_line >= (@scroll + height)
|
|
@@ -164,8 +242,13 @@ class LLM::Repl
|
|
|
164
242
|
end
|
|
165
243
|
|
|
166
244
|
def insert(char)
|
|
245
|
+
if lines[-1].size >= Curses.cols
|
|
246
|
+
@buffer.insert(@cursor, "\n")
|
|
247
|
+
@cursor += 1
|
|
248
|
+
end
|
|
167
249
|
@buffer.insert(@cursor, char)
|
|
168
250
|
@cursor += char.length
|
|
251
|
+
scroll!
|
|
169
252
|
end
|
|
170
253
|
|
|
171
254
|
def backspace
|
data/lib/llm/repl/stream.rb
CHANGED
|
@@ -28,14 +28,9 @@ class LLM::Repl
|
|
|
28
28
|
|
|
29
29
|
##
|
|
30
30
|
# @param [LLM::Function] tool
|
|
31
|
-
# @param [LLM::Function::Return, nil] error
|
|
32
31
|
# @return [void]
|
|
33
|
-
def on_tool_call(tool
|
|
34
|
-
|
|
35
|
-
@_queue.push [:status, "tool not found: #{tool.name}"]
|
|
36
|
-
else
|
|
37
|
-
@_queue.push [:status, "tool: #{tool.name}"]
|
|
38
|
-
end
|
|
32
|
+
def on_tool_call(tool)
|
|
33
|
+
@_queue.push [:status, "#{tool.name}(#{format_args(tool)})"]
|
|
39
34
|
end
|
|
40
35
|
|
|
41
36
|
##
|
|
@@ -43,7 +38,7 @@ class LLM::Repl
|
|
|
43
38
|
# @param [LLM::Function::Return] result
|
|
44
39
|
# @return [void]
|
|
45
40
|
def on_tool_return(_tool, result)
|
|
46
|
-
@_queue.push [:status,
|
|
41
|
+
@_queue.push [:status, @repl.thinking_text]
|
|
47
42
|
end
|
|
48
43
|
|
|
49
44
|
##
|
|
@@ -52,5 +47,45 @@ class LLM::Repl
|
|
|
52
47
|
def empty!
|
|
53
48
|
@buffer.clear
|
|
54
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# Formats tool arguments as compact key: value pairs
|
|
55
|
+
# suitable for the status line. Strings are quoted and
|
|
56
|
+
# truncated, arrays show their first two elements, and
|
|
57
|
+
# hashes collapse to `{…}`. The whole string is capped
|
|
58
|
+
# so it fits alongside the context-usage bar.
|
|
59
|
+
# @param [LLM::Function] tool
|
|
60
|
+
# @param [Integer] max
|
|
61
|
+
# @return [String]
|
|
62
|
+
def format_args(tool, max: 50)
|
|
63
|
+
args = tool.arguments
|
|
64
|
+
pairs = args.to_h.map { "#{_1}: #{format_value(_2)}" }
|
|
65
|
+
result = pairs.join(", ")
|
|
66
|
+
result.size > max ? "#{result[0...max - 1]}…" : result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @param [Object] value
|
|
71
|
+
# @param [Integer] max
|
|
72
|
+
# @return [String]
|
|
73
|
+
def format_value(value, max: 18)
|
|
74
|
+
case value
|
|
75
|
+
when String
|
|
76
|
+
value.size > max ? "#{value[0...max]}…".inspect : value.inspect
|
|
77
|
+
when Array
|
|
78
|
+
items = value.take(2).map { format_value(_1, max: 10) }
|
|
79
|
+
items << "…" if value.size > 2
|
|
80
|
+
"[#{items.join(", ")}]"
|
|
81
|
+
when Hash
|
|
82
|
+
"{…}"
|
|
83
|
+
when nil
|
|
84
|
+
"nil"
|
|
85
|
+
else
|
|
86
|
+
str = value.inspect
|
|
87
|
+
str.size > max ? "#{str[0...max]}…" : str
|
|
88
|
+
end
|
|
89
|
+
end
|
|
55
90
|
end
|
|
56
91
|
end
|
data/lib/llm/repl/transcript.rb
CHANGED
data/lib/llm/repl/window.rb
CHANGED
|
@@ -46,7 +46,6 @@ class LLM::Repl
|
|
|
46
46
|
##
|
|
47
47
|
# @return [void]
|
|
48
48
|
def redraw
|
|
49
|
-
Curses.curs_set(0)
|
|
50
49
|
draw_status(offset: input.height + 1)
|
|
51
50
|
draw_divider(offset: 5)
|
|
52
51
|
draw_transcript(offset: 0)
|
|
@@ -66,6 +65,24 @@ class LLM::Repl
|
|
|
66
65
|
Curses.getch
|
|
67
66
|
end
|
|
68
67
|
|
|
68
|
+
##
|
|
69
|
+
# Drains all available characters from the terminal input
|
|
70
|
+
# buffer without blocking. Used in place of `Curses.getstr`
|
|
71
|
+
# when the paste flag is set, so that a huge multi-line
|
|
72
|
+
# paste is consumed in a single shot instead of being
|
|
73
|
+
# processed character-by-character.
|
|
74
|
+
# @return [String]
|
|
75
|
+
def read_paste
|
|
76
|
+
chars = +""
|
|
77
|
+
loop do
|
|
78
|
+
ch = Curses.getch
|
|
79
|
+
break unless ch and ch != -1
|
|
80
|
+
chars << ch
|
|
81
|
+
end
|
|
82
|
+
input.paste = false
|
|
83
|
+
chars
|
|
84
|
+
end
|
|
85
|
+
|
|
69
86
|
##
|
|
70
87
|
# @return [void]
|
|
71
88
|
def scroll_up
|
|
@@ -78,6 +95,12 @@ class LLM::Repl
|
|
|
78
95
|
transcript.scroll_down
|
|
79
96
|
end
|
|
80
97
|
|
|
98
|
+
##
|
|
99
|
+
# @return [void]
|
|
100
|
+
def scroll_to_bottom
|
|
101
|
+
transcript.scroll_to_bottom
|
|
102
|
+
end
|
|
103
|
+
|
|
81
104
|
private
|
|
82
105
|
|
|
83
106
|
def draw_status(offset:)
|
|
@@ -99,14 +122,13 @@ class LLM::Repl
|
|
|
99
122
|
end
|
|
100
123
|
|
|
101
124
|
def draw_input
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
rows.each.with_index do |line, idx|
|
|
125
|
+
rows = input.lines
|
|
126
|
+
(0...input.height).each do |idx|
|
|
105
127
|
Curses.setpos((Curses.lines - input.height) + idx, 0)
|
|
106
128
|
Curses.clrtoeol
|
|
107
|
-
Curses.addstr(
|
|
129
|
+
Curses.addstr(rows[idx]) if idx < rows.length
|
|
108
130
|
end
|
|
109
|
-
line, col = input.cursor_pos
|
|
131
|
+
line, col = input.cursor_pos
|
|
110
132
|
Curses.setpos((Curses.lines - input.height) + line, col)
|
|
111
133
|
end
|
|
112
134
|
|
data/lib/llm/repl.rb
CHANGED
|
@@ -22,16 +22,20 @@ module LLM
|
|
|
22
22
|
require_relative "repl/bar"
|
|
23
23
|
require_relative "repl/stream"
|
|
24
24
|
require_relative "repl/markdown"
|
|
25
|
+
require_relative "repl/command"
|
|
25
26
|
|
|
26
27
|
##
|
|
27
28
|
# @param [LLM::Agent] agent
|
|
29
|
+
# @param [String, nil] path
|
|
30
|
+
# The path where to maintain runtime state
|
|
28
31
|
# @param [Array<LLM::Tool>] tools
|
|
29
32
|
# Zero or more tools
|
|
30
33
|
# @param [Array<String>] skills
|
|
31
34
|
# Zero or more skills
|
|
32
35
|
# @return [LLM::Repl]
|
|
33
|
-
def initialize(agent:, tools
|
|
34
|
-
@
|
|
36
|
+
def initialize(agent:, tools: [], skills: [], path: nil)
|
|
37
|
+
@path = path
|
|
38
|
+
@agent = configure(agent:, path:)
|
|
35
39
|
@provider = agent.llm.name
|
|
36
40
|
@status = Status.new(@agent)
|
|
37
41
|
@transcript = Transcript.new
|
|
@@ -56,15 +60,17 @@ module LLM
|
|
|
56
60
|
# @return [void]
|
|
57
61
|
def start
|
|
58
62
|
window.open do
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
catch(:exit) do
|
|
64
|
+
loop do
|
|
65
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
case input.on_char(window, input.paste? ? window.read_paste : window.getch, now)
|
|
67
|
+
when :submit then submit
|
|
68
|
+
when Symbol then window.redraw
|
|
69
|
+
else
|
|
70
|
+
window.redraw
|
|
71
|
+
read!
|
|
72
|
+
sleep 0.01
|
|
73
|
+
end
|
|
68
74
|
end
|
|
69
75
|
end
|
|
70
76
|
end
|
|
@@ -95,8 +101,36 @@ module LLM
|
|
|
95
101
|
window.redraw
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
##
|
|
105
|
+
# @return [String]
|
|
106
|
+
def thinking_text
|
|
107
|
+
"thinking • Esc to cancel"
|
|
108
|
+
end
|
|
109
|
+
|
|
98
110
|
private
|
|
99
111
|
|
|
112
|
+
##
|
|
113
|
+
# @param [LLM::Agent] agent
|
|
114
|
+
# An agent
|
|
115
|
+
# @param [String] path
|
|
116
|
+
# A path
|
|
117
|
+
# @raise [LLM::Error]
|
|
118
|
+
# When given an unusable path
|
|
119
|
+
# @return [LLM::Agent]
|
|
120
|
+
def configure(agent:, path:)
|
|
121
|
+
if path.nil?
|
|
122
|
+
agent
|
|
123
|
+
elsif !File.exist?(path)
|
|
124
|
+
File.write path, "{}"
|
|
125
|
+
agent
|
|
126
|
+
elsif restore?
|
|
127
|
+
agent.restore(path:)
|
|
128
|
+
else
|
|
129
|
+
raise LLM::Error, "I can't use '#{path}' - " \
|
|
130
|
+
"it should be both readable and writable"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
100
134
|
##
|
|
101
135
|
# This method is called when the user submits their input.
|
|
102
136
|
# It spawns a second thread that maintains a line of
|
|
@@ -104,19 +138,58 @@ module LLM
|
|
|
104
138
|
# the UI runs - remains responsive.
|
|
105
139
|
# @api private
|
|
106
140
|
def submit
|
|
107
|
-
return if thread&.alive?
|
|
108
|
-
text
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
return if thread&.alive? || (text = input.take).empty?
|
|
142
|
+
case on_text(text)
|
|
143
|
+
in [:command, Command => command, Hash => parameters]
|
|
144
|
+
command.call(**parameters)
|
|
145
|
+
in [:error, String => who, String => text]
|
|
146
|
+
write(who, Curses::A_BOLD)
|
|
147
|
+
write(text)
|
|
148
|
+
in [:input, String => text]
|
|
149
|
+
window.scroll_to_bottom
|
|
150
|
+
status.text = thinking_text
|
|
151
|
+
write("user: ", Curses::A_BOLD)
|
|
152
|
+
markdown(text)
|
|
153
|
+
write("\nagent: ", Curses::A_BOLD)
|
|
154
|
+
@thread = Thread.new do
|
|
155
|
+
@queue << [:start]
|
|
156
|
+
agent.talk(text, tools:, stream:)
|
|
157
|
+
agent.save(path:) if save?
|
|
158
|
+
@queue << [:done]
|
|
159
|
+
rescue LLM::Interrupt => e
|
|
160
|
+
@queue << [:cancel, e]
|
|
161
|
+
rescue => e
|
|
162
|
+
@queue << [:error, e]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
##
|
|
168
|
+
# Receives user input and determines what codepath
|
|
169
|
+
# it should follow - either executing a command,
|
|
170
|
+
# or sending a string to the model.
|
|
171
|
+
# @param [String] text
|
|
172
|
+
# @return [[Symbol, Command|String]]
|
|
173
|
+
def on_text(text)
|
|
174
|
+
command = Command.find_by(input: text)
|
|
175
|
+
if command
|
|
176
|
+
parameters = command.parameters.map { [_1, _2.dup] }.to_h
|
|
177
|
+
args = text.split(" ")[1..].reject(&:empty?)
|
|
178
|
+
reqc = parameters.values.count(&:required?)
|
|
179
|
+
if args.size < reqc
|
|
180
|
+
[:error,
|
|
181
|
+
"command(#{command.name}): ",
|
|
182
|
+
"too few arguments: expected #{reqc} but got #{args.size}\n\n"]
|
|
183
|
+
else
|
|
184
|
+
parameters.each_value do |parameter|
|
|
185
|
+
if args[parameter.index]
|
|
186
|
+
parameter.value = args[parameter.index]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
[:command, command.new(self), parameters.transform_values(&:value)]
|
|
190
|
+
end
|
|
191
|
+
else
|
|
192
|
+
[:input, text]
|
|
120
193
|
end
|
|
121
194
|
end
|
|
122
195
|
|
|
@@ -138,22 +211,47 @@ module LLM
|
|
|
138
211
|
self.status = value
|
|
139
212
|
when :done
|
|
140
213
|
status.text = "idle"
|
|
141
|
-
|
|
214
|
+
write("\n\n")
|
|
142
215
|
transcript.finish
|
|
216
|
+
@thread = nil
|
|
217
|
+
when :cancel
|
|
218
|
+
status.text = "Idle"
|
|
219
|
+
write("\n\nagent: ", Curses::A_BOLD)
|
|
220
|
+
write("request cancelled!")
|
|
143
221
|
write("\n\n")
|
|
222
|
+
transcript.finish
|
|
223
|
+
@thread = nil
|
|
144
224
|
when :error
|
|
145
225
|
# Do this better
|
|
146
226
|
status.text = "error"
|
|
147
|
-
transcript.finish
|
|
148
227
|
write("\nerror: #{value.message}\n", Curses::A_BOLD)
|
|
228
|
+
transcript.finish
|
|
149
229
|
@thread = nil
|
|
150
230
|
end
|
|
151
231
|
end
|
|
152
232
|
rescue ThreadError
|
|
153
233
|
end
|
|
154
234
|
|
|
235
|
+
##
|
|
236
|
+
# @return [Boolean]
|
|
237
|
+
def restore?
|
|
238
|
+
return false if path.nil?
|
|
239
|
+
File.writable?(path)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
##
|
|
243
|
+
# @return [Boolean]
|
|
244
|
+
def save?
|
|
245
|
+
return false if path.nil?
|
|
246
|
+
File.readable?(path)
|
|
247
|
+
end
|
|
248
|
+
|
|
155
249
|
attr_reader :agent, :provider, :stream,
|
|
156
250
|
:status, :transcript, :input,
|
|
157
|
-
:window, :tools, :thread
|
|
251
|
+
:window, :tools, :thread,
|
|
252
|
+
:path
|
|
253
|
+
|
|
254
|
+
File = ::File
|
|
255
|
+
private_constant :File
|
|
158
256
|
end
|
|
159
257
|
end
|
data/lib/llm/stream.rb
CHANGED
|
@@ -114,20 +114,15 @@ module LLM
|
|
|
114
114
|
# `ctx.spawn(tool, :fiber)`, or `ctx.spawn(tool, :task)` onto {#queue}.
|
|
115
115
|
# Mixed strategies can also be selected per tool, such as
|
|
116
116
|
# `tool.mcp? ? ctx.spawn(tool, :task) : ctx.spawn(tool, :ractor)`.
|
|
117
|
-
#
|
|
118
|
-
# {LLM::Function::Return}. It can be sent back to the model, allowing
|
|
119
|
-
# the tool-call path to recover and the session to continue. Streamed
|
|
120
|
-
# tool resolution now prefers the current request tools, so
|
|
117
|
+
# Streamed tool resolution now prefers the current request tools, so
|
|
121
118
|
# {LLM.function}, MCP tools, bound tool instances, and normal
|
|
122
119
|
# {LLM::Tool LLM::Tool} classes can all resolve through the same
|
|
123
120
|
# request-local path. The current `:ractor` mode is for class-based
|
|
124
121
|
# tools and does not support MCP tools.
|
|
125
122
|
# @param [LLM::Function] tool
|
|
126
123
|
# The parsed tool call.
|
|
127
|
-
# @param [LLM::Function::Return, nil] error
|
|
128
|
-
# An in-band tool error for unresolved tool calls.
|
|
129
124
|
# @return [nil]
|
|
130
|
-
def on_tool_call(tool
|
|
125
|
+
def on_tool_call(tool)
|
|
131
126
|
nil
|
|
132
127
|
end
|
|
133
128
|
|
data/lib/llm/tools/ls.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Tool
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Tool::Ls LLM::Tool::Ls} class implements
|
|
6
|
+
# a tool that can list files and directories, with an
|
|
7
|
+
# optional glob pattern to filter results.
|
|
8
|
+
class Ls < self
|
|
9
|
+
name "ls"
|
|
10
|
+
description "list files and directories, optionally matching a glob pattern"
|
|
11
|
+
parameter :path, String, "the directory to list (default is cwd)"
|
|
12
|
+
parameter :glob, String, "an optional glob pattern (e.g. '*.rb', '**/*.md')"
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param [String] path
|
|
16
|
+
# @param [String, nil] glob
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def call(path: Dir.getwd, glob: "*")
|
|
19
|
+
validate!(path:)
|
|
20
|
+
entries = Dir.glob(File.join(path, glob))
|
|
21
|
+
{ok: true, entries:, count: entries.size}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate!(path:)
|
|
27
|
+
raise "path does not exist: #{path}" unless Dir.exist?(path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|