llm.rb 13.0.0 → 13.1.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.
@@ -42,9 +42,9 @@ class LLM::Repl
42
42
  end
43
43
 
44
44
  ##
45
- # Empty the accumulated buffer
45
+ # Clear the accumulated buffer
46
46
  # @return [void]
47
- def empty!
47
+ def clear
48
48
  @buffer.clear
49
49
  end
50
50
 
@@ -11,22 +11,21 @@ class LLM::Repl
11
11
  attr_reader :status
12
12
 
13
13
  ##
14
- # @return [LLM::Repl::Transcript]
15
- attr_reader :transcript
14
+ # @return [LLM::Repl::Buffer]
15
+ attr_reader :buffer
16
16
 
17
17
  ##
18
18
  # @return [LLM::Repl::Input]
19
19
  attr_reader :input
20
20
 
21
21
  ##
22
- # @param [LLM::Repl::Status] status
23
- # @param [LLM::Repl::Transcript] transcript
24
- # @param [LLM::Repl::Input] input
22
+ # @param [LLM::Repl] repl
23
+ # A read-eval-print loop.
25
24
  # @return [LLM::Repl::Window]
26
- def initialize(status, transcript, input)
27
- @status = status
28
- @transcript = transcript
29
- @input = input
25
+ def initialize(repl)
26
+ @status = repl.status
27
+ @buffer = repl.buffer
28
+ @input = repl.input
30
29
  end
31
30
 
32
31
  ##
@@ -48,7 +47,7 @@ class LLM::Repl
48
47
  def redraw
49
48
  draw_status(offset: input.height + 1)
50
49
  draw_divider(offset: 5)
51
- draw_transcript(offset: 0)
50
+ draw_buffer(offset: 0)
52
51
  draw_input
53
52
  Curses.refresh
54
53
  end
@@ -86,23 +85,42 @@ class LLM::Repl
86
85
  ##
87
86
  # @return [void]
88
87
  def scroll_up
89
- transcript.scroll_up(rows)
88
+ buffer.scroll_up(rows)
90
89
  end
91
90
 
92
91
  ##
93
92
  # @return [void]
94
93
  def scroll_down
95
- transcript.scroll_down
94
+ buffer.scroll_down
96
95
  end
97
96
 
98
97
  ##
99
98
  # @return [void]
100
99
  def scroll_to_bottom
101
- transcript.scroll_to_bottom
100
+ buffer.scroll_to_bottom
102
101
  end
103
102
 
104
103
  private
105
104
 
105
+ def draw_buffer(offset:)
106
+ rows = buffer.visible(self.rows)
107
+ rows.each.with_index(offset) do |row, index|
108
+ Curses.setpos(index, 0)
109
+ Curses.clrtoeol
110
+ row.each do |chunk|
111
+ text, attrs = chunk.text, chunk.attrs
112
+ Curses.attron(attrs) if attrs
113
+ Curses.addstr(text)
114
+ Curses.attroff(attrs) if attrs
115
+ end
116
+ end
117
+ last_drawn = offset + rows.size
118
+ (last_drawn...self.rows).each do |line|
119
+ Curses.setpos(line, 0)
120
+ Curses.clrtoeol
121
+ end
122
+ end
123
+
106
124
  def draw_status(offset:)
107
125
  Curses.setpos(Curses.lines - offset, 0)
108
126
  Curses.clrtoeol
@@ -132,25 +150,6 @@ class LLM::Repl
132
150
  Curses.setpos((Curses.lines - input.height) + line, col)
133
151
  end
134
152
 
135
- def draw_transcript(offset:)
136
- rows = transcript.visible(self.rows)
137
- rows.each.with_index(offset) do |row, index|
138
- Curses.setpos(index, 0)
139
- Curses.clrtoeol
140
- row.each do |chunk|
141
- text, attrs = chunk.values_at(:text, :attrs)
142
- Curses.attron(attrs) if attrs
143
- Curses.addstr(text)
144
- Curses.attroff(attrs) if attrs
145
- end
146
- end
147
- last_drawn = offset + rows.size
148
- (last_drawn...self.rows).each do |line|
149
- Curses.setpos(line, 0)
150
- Curses.clrtoeol
151
- end
152
- end
153
-
154
153
  ##
155
154
  # @return [Integer]
156
155
  def columns
data/lib/llm/repl.rb CHANGED
@@ -17,18 +17,19 @@ module LLM
17
17
  class Repl
18
18
  require_relative "repl/window"
19
19
  require_relative "repl/status"
20
- require_relative "repl/transcript"
20
+ require_relative "repl/buffer"
21
21
  require_relative "repl/input"
22
22
  require_relative "repl/bar"
23
23
  require_relative "repl/stream"
24
+ require_relative "repl/node"
24
25
  require_relative "repl/markdown"
25
26
  require_relative "repl/command"
26
27
  require_relative "repl/walker"
27
28
 
28
29
  attr_reader :agent, :provider, :stream,
29
- :status, :transcript, :input,
30
+ :status, :buffer, :input,
30
31
  :window, :tools, :thread,
31
- :name, :path
32
+ :name, :path, :width
32
33
 
33
34
  ##
34
35
  # @param [LLM::Agent] agent
@@ -42,14 +43,15 @@ module LLM
42
43
  # Zero or more skills
43
44
  # @return [LLM::Repl]
44
45
  def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
46
+ @width = 80
45
47
  @path = path
46
48
  @name = name || "agent"
47
49
  @agent = configure(agent:, path:)
48
50
  @provider = agent.llm.name
49
- @status = Status.new(@agent)
50
- @transcript = Transcript.new
51
+ @status = Status.new(self)
52
+ @buffer = Buffer.new(self)
51
53
  @input = Input.new(self, height: 3)
52
- @window = Window.new(@status, @transcript, @input)
54
+ @window = Window.new(self)
53
55
  @thread = nil
54
56
  @queue = Queue.new
55
57
  @stream = Stream.new(self, @queue)
@@ -70,6 +72,7 @@ module LLM
70
72
  def start
71
73
  window.open do
72
74
  catch(:exit) do
75
+ write tree(agent.messages)
73
76
  loop do
74
77
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
78
  case input.on_char(window, input.paste? ? window.read_paste : window.getch, now)
@@ -86,22 +89,32 @@ module LLM
86
89
  end
87
90
 
88
91
  ##
89
- # @param [String] chars
92
+ # @param [String, Array] chars
90
93
  # @param [Object] attrs
94
+ # @param [Symbol] method (:append, :replace)
91
95
  # @return [void]
92
- def write(chars, attrs = nil)
93
- transcript.write(chars, attrs)
96
+ def write(chars, attrs = nil, method: :append)
97
+ buffer.write(chars, attrs, method:)
94
98
  window.redraw
95
99
  end
96
100
 
97
101
  ##
98
- # @param [String] chars
102
+ # @param [String] user
103
+ # @param [String] content
99
104
  # @return [void]
100
- def markdown(chars)
101
- transcript.markdown(chars)
105
+ def write_message(user, content, method: :append)
106
+ buffer.write_message(user, content, method:)
102
107
  window.redraw
103
108
  end
104
109
 
110
+ ##
111
+ # Returns an AST
112
+ # @param [String] chars
113
+ # @return [Array<Node>]
114
+ def markdown(chars)
115
+ LLM::Repl::Markdown.new(chars, width).ast
116
+ end
117
+
105
118
  ##
106
119
  # @param [String] value
107
120
  # @return [void]
@@ -118,6 +131,22 @@ module LLM
118
131
 
119
132
  private
120
133
 
134
+ ##
135
+ # @param [LLM::Buffer] messages
136
+ # A message buffer
137
+ # @return [Array<Node>]
138
+ def tree(messages)
139
+ messages.flat_map do |message|
140
+ next if message.tool_call? || message.tool_return?
141
+ user = message.assistant? ? name : "user"
142
+ [
143
+ Node.new("#{user}: ", Curses::A_BOLD),
144
+ Node.new(message.content),
145
+ Node.new(user == name ? "\n\n" : "\n")
146
+ ]
147
+ end.compact
148
+ end
149
+
121
150
  ##
122
151
  # @param [LLM::Agent] agent
123
152
  # An agent
@@ -151,15 +180,14 @@ module LLM
151
180
  case on_text(text)
152
181
  in [:command, Command => command, Hash => parameters]
153
182
  command.call(**parameters)
154
- in [:error, String => who, String => text]
155
- write(who, Curses::A_BOLD)
156
- write(text)
183
+ write("\n")
184
+ in [:error, String => user, String => text]
185
+ write_message(user, text)
186
+ write("\n")
157
187
  in [:input, String => text]
158
188
  window.scroll_to_bottom
159
189
  status.text = thinking_text
160
- write("user: ", Curses::A_BOLD)
161
- markdown(text)
162
- write("\n#{name}: ", Curses::A_BOLD)
190
+ write_message("user", markdown(text))
163
191
  @thread = Thread.new do
164
192
  @queue << [:start]
165
193
  agent.talk(text, tools:, stream:)
@@ -214,29 +242,27 @@ module LLM
214
242
  type, value = @queue.pop(true)
215
243
  case type
216
244
  when :start
217
- transcript.start
218
- stream.empty!
245
+ buffer.open
246
+ stream.clear
219
247
  when :stream
220
- transcript.markdown(value, method: :replace)
248
+ write_message name, markdown(value), method: :replace
221
249
  when :status
222
250
  self.status = value
223
251
  when :done
224
252
  status.text = "idle"
225
- write("\n\n")
226
- transcript.finish
253
+ buffer.close
227
254
  @thread = nil
255
+ write("\n")
228
256
  when :cancel
257
+ buffer.close
229
258
  status.text = "Idle"
230
- write("\n\n#{name}: ", Curses::A_BOLD)
231
- write("request cancelled!")
232
- write("\n\n")
233
- transcript.finish
259
+ write_message(name, "Request cancelled")
234
260
  @thread = nil
261
+ write("\n")
235
262
  when :error
236
- # Do this better
237
263
  status.text = "error"
238
- write("\nerror: #{value.message}\n", Curses::A_BOLD)
239
- transcript.finish
264
+ write_message(name, "(#{value.class}): #{value.message}")
265
+ buffer.close
240
266
  @thread = nil
241
267
  end
242
268
  end
data/lib/llm/skill.rb CHANGED
@@ -7,6 +7,11 @@ module LLM
7
7
  # Skills can expose themselves as normal {LLM::Tool LLM::Tool} classes through
8
8
  # {#to_tool}. This keeps skills on the same execution path as local tools.
9
9
  class Skill
10
+ ##
11
+ # @api private
12
+ File = ::File
13
+ private_constant :File
14
+
10
15
  ##
11
16
  # Load a skill from a directory.
12
17
  # @param [String, Pathname] path
@@ -63,8 +68,14 @@ module LLM
63
68
  # Load and parse the skill.
64
69
  # @return [LLM::Skill]
65
70
  def load!
66
- path = ::File.join(@path, "SKILL.md")
67
- parse(::File.read(path))
71
+ if File.file?(@path)
72
+ parse(File.read(@path))
73
+ elsif File.directory?(@path)
74
+ path = File.join(@path, "SKILL.md")
75
+ parse(File.read(path))
76
+ else
77
+ raise LLM::Error, "'#{@path}' is neither a file or directory"
78
+ end
68
79
  self
69
80
  end
70
81
 
@@ -103,7 +114,8 @@ module LLM
103
114
  end
104
115
 
105
116
  ##
106
- # Returns true when a skill should inherit tools from its parent
117
+ # Returns true when a skill should inherit tools
118
+ # from its parent
107
119
  # @return [Boolean]
108
120
  def inherit_tools?
109
121
  @inherit_tools
@@ -138,7 +150,11 @@ module LLM
138
150
  def parse_tools(tools)
139
151
  case tools
140
152
  when String
141
- tools == "inherit" ? [true, []] : raise_invalid_error!(tools)
153
+ case tools
154
+ when "inherit" then [true, []]
155
+ when "all", "*" then [false, LLM::Tool.registry]
156
+ else raise_invalid_error!(tools)
157
+ end
142
158
  when Array
143
159
  [false, [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }]
144
160
  when NilClass
@@ -2,11 +2,11 @@
2
2
 
3
3
  class LLM::Tool
4
4
  ##
5
- # The {LLM::Tool::SwapText LLM::Tool::SwapText} class
5
+ # The {LLM::Tool::EditFile LLM::Tool::EditFile} class
6
6
  # implements a tool that can substitute one piece of
7
7
  # text for another piece of text in a given file.
8
- class SwapText < self
9
- name "swap-text"
8
+ class EditFile < self
9
+ name "edit-file"
10
10
  description "Replace an exact snippet in a file"
11
11
  parameter :path, String, "Path to file"
12
12
  parameter :before, String, "Exact text to replace"
data/lib/llm/tools/git.rb CHANGED
@@ -26,6 +26,9 @@ class LLM::Tool
26
26
  command = spawn(action:, arguments:)
27
27
  wait(command:, timeout:)
28
28
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
29
+ rescue LLM::Interrupt
30
+ command.kill! if command&.running?
31
+ raise
29
32
  end
30
33
 
31
34
  private
@@ -15,6 +15,9 @@ class LLM::Tool
15
15
  def call(path:)
16
16
  command = spawn(path:)
17
17
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
18
+ rescue LLM::Interrupt
19
+ command.kill! if command&.running?
20
+ raise
18
21
  end
19
22
 
20
23
  private
data/lib/llm/tools/rg.rb CHANGED
@@ -27,6 +27,9 @@ class LLM::Tool
27
27
  command = spawn(patterns:, path:)
28
28
  wait(command:, timeout:)
29
29
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
30
+ rescue LLM::Interrupt
31
+ command.kill! if command&.running?
32
+ raise
30
33
  end
31
34
 
32
35
  private
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Tool
4
+ ##
5
+ # The {LLM::Tool::Ruby LLM::Tool::Ruby} class implements
6
+ # a tool that can execute an arbitrary string of Ruby code
7
+ # and return the result. It uses `test-cmd.rb` under the
8
+ # hood for process management.
9
+ class Ruby < self
10
+ require_relative "utils"
11
+ include Utils
12
+
13
+ name "ruby"
14
+ description "runs a string of ruby code"
15
+ parameter :code, String, "a string of ruby code"
16
+ parameter :timeout, Integer, "maximum runtime before timeout"
17
+ required %i[code]
18
+ defaults timeout: 15
19
+
20
+ ##
21
+ # @param [String] code
22
+ # Ruby code
23
+ # @param [Integer] timeout
24
+ # Runtime timeout
25
+ def call(code:, timeout: 15)
26
+ command = spawn(code:)
27
+ wait(command:, timeout:)
28
+ {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
29
+ rescue LLM::Interrupt
30
+ command.kill! if command&.running?
31
+ raise
32
+ end
33
+
34
+ private
35
+
36
+ def spawn(code:)
37
+ Command
38
+ .new(RbConfig.ruby)
39
+ .argv("-e", code)
40
+ end
41
+
42
+ require "rbconfig"
43
+ LLM.require "test-cmd.rb", "~> 1.1"
44
+ Command = Test::Cmd
45
+ end
46
+ end
@@ -31,6 +31,9 @@ class LLM::Tool
31
31
  command = spawn(name:, arguments:)
32
32
  wait(command:, timeout:)
33
33
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
34
+ rescue LLM::Interrupt
35
+ command.kill! if command&.running?
36
+ raise
34
37
  end
35
38
 
36
39
  private
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Tracer::PrettyLogger LLM::Tracer::PrettyLogger} class
6
+ # writes human-readable request and tool call logs to a console
7
+ # or file. Each event is a single line with the relevant context
8
+ # inline, no structured JSON.
9
+ #
10
+ # @example
11
+ # llm = LLM.openai(key: ENV["KEY"])
12
+ # llm.tracer = LLM::Tracer::PrettyLogger.new(llm)
13
+ #
14
+ # @example Writing to a file
15
+ # llm.tracer = LLM::Tracer::PrettyLogger.new(llm, io: File.open("log.txt", "a"))
16
+ class Tracer::PrettyLogger < Tracer
17
+ ##
18
+ # @param (see LLM::Tracer#initialize)
19
+ def initialize(provider, options = {})
20
+ super
21
+ setup!(**options)
22
+ end
23
+
24
+ ##
25
+ # @param (see LLM::Tracer#on_request_start)
26
+ # @return [void]
27
+ def on_request_start(operation:, model: nil, **)
28
+ @start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ name = operation == "chat" ? "chat" : operation
30
+ @io.puts "#{timestamp} #{provider_name} #{name} (#{model || "default"})"
31
+ end
32
+
33
+ ##
34
+ # @param (see LLM::Tracer#on_request_finish)
35
+ # @return [void]
36
+ def on_request_finish(operation:, res:, model: nil, **)
37
+ elapsed = @start ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start).round(2) : nil
38
+ tokens = format_tokens(res)
39
+ name = operation == "chat" ? "chat" : operation
40
+ parts = ["#{timestamp} #{provider_name} #{name} done"]
41
+ parts << tokens if tokens
42
+ parts << "#{elapsed}s" if elapsed
43
+ @io.puts parts.join(", ")
44
+ end
45
+
46
+ ##
47
+ # @param (see LLM::Tracer#on_request_error)
48
+ # @return [void]
49
+ def on_request_error(ex:, **)
50
+ @io.puts "#{timestamp} #{provider_name} error #{ex.class}: #{ex.message}"
51
+ end
52
+
53
+ ##
54
+ # @param (see LLM::Tracer#on_tool_start)
55
+ # @return [void]
56
+ def on_tool_start(id:, name:, arguments:, **)
57
+ @io.puts "#{timestamp} #{name}(#{format_arguments(arguments)})"
58
+ end
59
+
60
+ ##
61
+ # @param (see LLM::Tracer#on_tool_finish)
62
+ # @return [void]
63
+ def on_tool_finish(result:, **)
64
+ @io.puts "#{timestamp} #{result.name} -> #{format_value(result.value)}"
65
+ end
66
+
67
+ ##
68
+ # @param (see LLM::Tracer#on_tool_error)
69
+ # @return [void]
70
+ def on_tool_error(ex:, **)
71
+ @io.puts "#{timestamp} tool error #{ex.class}: #{ex.message}"
72
+ end
73
+
74
+ private
75
+
76
+ def setup!(io: $stderr)
77
+ @io = io
78
+ @start = nil
79
+ end
80
+
81
+ def timestamp
82
+ Time.now.strftime("%H:%M:%S")
83
+ end
84
+
85
+ def format_tokens(res)
86
+ usage = res.usage
87
+ if usage.input_tokens and usage.output_tokens
88
+ "in=#{usage.input_tokens} out=#{usage.output_tokens}"
89
+ elsif usage.input_tokens
90
+ "in=#{usage.input_tokens}"
91
+ elsif usage.output_tokens
92
+ "out=#{usage.output_tokens}"
93
+ end
94
+ end
95
+
96
+ def format_arguments(args, max: 50)
97
+ return "" unless args
98
+ case args
99
+ when Hash, LLM::Object
100
+ result = args.map { |k, v| "#{k}: #{format_value(v)}" }.join(", ")
101
+ when Array
102
+ result = args.map { |v| format_value(v) }.join(", ")
103
+ else
104
+ result = args.inspect
105
+ end
106
+ result.size > max ? "#{result[0...max - 1]}..." : result
107
+ end
108
+
109
+ def format_value(value, max: 18)
110
+ case value
111
+ when String
112
+ value.size > max ? "#{value[0...max]}...".inspect : value.inspect
113
+ when Array
114
+ items = value.take(2).map { format_value(_1, max: 10) }
115
+ items << "..." if value.size > 2
116
+ "[#{items.join(", ")}]"
117
+ when Hash
118
+ "{...}"
119
+ when nil
120
+ "nil"
121
+ else
122
+ str = value.inspect
123
+ str.size > max ? "#{str[0...max]}..." : str
124
+ end
125
+ end
126
+ end
127
+ end
data/lib/llm/tracer.rb CHANGED
@@ -12,6 +12,7 @@ module LLM
12
12
  require_relative "tracer/logger"
13
13
  require_relative "tracer/telemetry"
14
14
  require_relative "tracer/null"
15
+ require_relative "tracer/pretty_logger"
15
16
 
16
17
  ##
17
18
  # @return [LLM::Provider]
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "13.0.0"
4
+ VERSION = "13.1.0"
5
5
  end
data/llm.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = <<~DESCRIPTION
13
13
  llm.rb is an advanced runtime for building capable AI applications
14
14
  on CRuby. By default it has zero runtime dependencies although certain
15
- functionality &ndash; such as ActiveRecord support &ndash; require
15
+ functionality (such as ActiveRecord support) require
16
16
  optional dependencies that are opt-in.
17
17
  DESCRIPTION
18
18
 
@@ -30,9 +30,15 @@ DESCRIPTION
30
30
  "lib/*.rb", "lib/**/*.rb",
31
31
  "data/*.json", "CHANGELOG.md",
32
32
  "resources/deepdive.md",
33
- "llm.gemspec"
33
+ "llm.gemspec", "bin/llm.rb"
34
34
  ]
35
+ spec.executables = ["llm.rb"]
35
36
  spec.require_paths = ["lib"]
37
+ spec.post_install_message = "\n" \
38
+ "Learn more about llm.rb by reading the deepdive:" \
39
+ "\n" \
40
+ "https://r.uby.dev/llm/deepdive" \
41
+ "\n\n"
36
42
 
37
43
  spec.add_development_dependency "webmock", "~> 3.24.0"
38
44
  spec.add_development_dependency "yard", "~> 0.9.37"