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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +395 -0
  3. data/README.md +103 -16
  4. data/data/anthropic.json +249 -249
  5. data/data/bedrock.json +2038 -1879
  6. data/data/deepinfra.json +591 -591
  7. data/data/deepseek.json +31 -31
  8. data/data/google.json +332 -329
  9. data/data/mistral.json +381 -381
  10. data/data/openai.json +1132 -1132
  11. data/data/xai.json +138 -138
  12. data/data/zai.json +165 -165
  13. data/lib/llm/agent.rb +12 -7
  14. data/lib/llm/buffer.rb +15 -0
  15. data/lib/llm/context/deserializer.rb +2 -5
  16. data/lib/llm/context.rb +4 -5
  17. data/lib/llm/function.rb +2 -35
  18. data/lib/llm/message.rb +12 -2
  19. data/lib/llm/provider.rb +11 -1
  20. data/lib/llm/providers/anthropic/stream_parser.rb +3 -12
  21. data/lib/llm/providers/anthropic.rb +11 -0
  22. data/lib/llm/providers/bedrock/stream_parser.rb +4 -13
  23. data/lib/llm/providers/google/stream_parser.rb +3 -12
  24. data/lib/llm/providers/google.rb +7 -0
  25. data/lib/llm/providers/mistral.rb +11 -0
  26. data/lib/llm/providers/ollama/stream_parser.rb +4 -4
  27. data/lib/llm/providers/ollama.rb +11 -0
  28. data/lib/llm/providers/openai/responses/stream_parser.rb +4 -14
  29. data/lib/llm/providers/openai/responses.rb +10 -0
  30. data/lib/llm/providers/openai/stream_parser.rb +5 -15
  31. data/lib/llm/providers/openai.rb +11 -0
  32. data/lib/llm/repl/command.rb +201 -0
  33. data/lib/llm/repl/commands/exit.rb +23 -0
  34. data/lib/llm/repl/commands/help.rb +24 -0
  35. data/lib/llm/repl/input.rb +118 -35
  36. data/lib/llm/repl/stream.rb +43 -8
  37. data/lib/llm/repl/transcript.rb +6 -0
  38. data/lib/llm/repl/window.rb +28 -6
  39. data/lib/llm/repl.rb +125 -27
  40. data/lib/llm/stream.rb +2 -7
  41. data/lib/llm/tools/ls.rb +30 -0
  42. data/lib/llm/tools/which.rb +38 -0
  43. data/lib/llm/version.rb +1 -1
  44. data/resources/deepdive.md +67 -1
  45. 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
@@ -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
- CTRL_A = 1
10
- CTRL_E = 5
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
- EOF = [4]
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
- if EOF.include?(char)
37
- :exit
38
- elsif BACKSPACE.include?(char)
39
- backspace
40
- :backspace
41
- elsif ENTER.include?(char)
42
- :submit
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 char == CTRL_E
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
- # wrapped at the given column width. The viewport
90
- # follows the cursor so the cursor line is always
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(cols)
95
- sync_scroll(cols)
96
- text = to_s
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(cols)
108
- sync_scroll(cols)
109
- [(cursor / cols) - @scroll, cursor % cols]
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 sync_scroll(cols)
152
- total_lines = [1, (to_s.length.to_f / cols).ceil].max
153
- cursor_line = cursor / cols
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
@@ -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, error)
34
- if error
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, "tool done: #{result.name}"]
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
@@ -75,6 +75,12 @@ class LLM::Repl
75
75
  @offset = [@offset - 1, 0].max
76
76
  end
77
77
 
78
+ ##
79
+ # @return [void]
80
+ def scroll_to_bottom
81
+ @offset = 0
82
+ end
83
+
78
84
  ##
79
85
  # @param [Integer] height
80
86
  # @return [Array<String>]
@@ -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
- cols = columns
103
- rows = input.lines(cols)
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(line)
129
+ Curses.addstr(rows[idx]) if idx < rows.length
108
130
  end
109
- line, col = input.cursor_pos(cols)
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:, skills:)
34
- @agent = agent
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
- loop do
60
- case input.on_char(window, window.getch)
61
- when :exit then break
62
- when :submit then submit
63
- when :up, :down, :backspace, :char then window.redraw
64
- else
65
- window.redraw
66
- read!
67
- sleep 0.01
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 = input.take
109
- return if text.empty?
110
- status.text = "thinking"
111
- write("user: ", Curses::A_BOLD)
112
- markdown(text)
113
- write("\nagent: ", Curses::A_BOLD)
114
- @thread = Thread.new do
115
- @queue << [:start]
116
- agent.talk(text, tools:, stream:)
117
- @queue << [:done]
118
- rescue => e
119
- @queue << [:error, e]
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
- @thread = nil
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
- # When a streamed tool cannot be resolved, `error` is passed as an
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, error)
125
+ def on_tool_call(tool)
131
126
  nil
132
127
  end
133
128
 
@@ -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