llm.rb 12.2.0 → 12.3.1

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.
@@ -2,30 +2,70 @@
2
2
 
3
3
  class LLM::Repl
4
4
  ##
5
- # The {LLM::Repl::Transcript LLM::Repl::Transcript} class
6
- # stores streamed output for the REPL.
7
- # @api private
5
+ # This class maintains conversation state that includes
6
+ # the conversation itself, and metadata associated with
7
+ # the conversation.
8
+ #
9
+ # Internally it maintains an array where each element
10
+ # represents a row, and each element in a row is a Hash
11
+ # that describes a piece of text and any styles that might
12
+ # be applied to it by the UI thread.
13
+ #
14
+ # It also maintains a cursor that tracks the active row
15
+ # by its index number. The streaming path reuses a single
16
+ # row by overwriting its contents repeatedly.
8
17
  class Transcript
9
18
  WIDTH = 80
10
19
 
11
20
  ##
12
21
  # @return [LLM::Repl::Transcript]
13
22
  def initialize
14
- @lines = [+""]
23
+ @rows = [[]]
24
+ @cursor = nil
25
+ @snapshot = nil
15
26
  @offset = 0
16
27
  end
17
28
 
18
29
  ##
19
30
  # @param [String] chars
31
+ # @param [Object] attrs
32
+ # @param [Symbol] method
20
33
  # @return [void]
21
- def write(chars)
22
- chars.each_char { write_char(_1) }
34
+ def write(chars, attrs = nil, method: :append)
35
+ chunks = [{text: chars.to_s, attrs:}.compact]
36
+ self.method(method).call(chunks)
37
+ end
38
+
39
+ ##
40
+ # Appends Markdown to the transcript.
41
+ # @param [String] chars
42
+ # @param [Symbol] method
43
+ # @return [void]
44
+ def markdown(chars, method: :append)
45
+ chunks = LLM::Repl::Markdown.new(chars).ast
46
+ self.method(method).call(chunks)
47
+ end
48
+
49
+ ##
50
+ # Start the transcript.
51
+ # @return [void]
52
+ def start
53
+ @cursor = @rows.size - 1
54
+ @snapshot = @rows.map(&:dup)
55
+ end
56
+
57
+ ##
58
+ # Finish the transcript.
59
+ # @return [void]
60
+ def finish
61
+ @cursor = nil
62
+ @snapshot = nil
23
63
  end
24
64
 
25
65
  ##
26
66
  # @return [void]
27
67
  def scroll_up(height)
28
- max = [@lines.size - height, 0].max
68
+ max = [rows.size - height, 0].max
29
69
  @offset = [@offset + 1, max].min
30
70
  end
31
71
 
@@ -39,23 +79,72 @@ class LLM::Repl
39
79
  # @param [Integer] height
40
80
  # @return [Array<String>]
41
81
  def visible(height)
42
- last = @lines.size - 1 - @offset
82
+ all = rows
83
+ last = all.size - 1 - @offset
43
84
  first = [last - height + 1, 0].max
44
- @lines[first..last] || []
85
+ all[first..last] || []
45
86
  end
46
87
 
47
88
  private
48
89
 
49
- def write_char(char)
50
- if char == "\n"
51
- @offset += 1 if @offset > 0
52
- @lines << +""
53
- elsif char == " " and @lines.last.length >= WIDTH
54
- @offset += 1 if @offset > 0
55
- @lines << +""
56
- else
57
- @lines.last << char
90
+ ##
91
+ # Appends a new row
92
+ # @param [Array<{text: String, attrs?: Integer}>] chunks
93
+ # One or more chunks.
94
+ # @return [void]
95
+ def append(chunks)
96
+ chunks.each { wrap(_1, @rows) }
97
+ end
98
+
99
+ ##
100
+ # Replaces the content of the active row
101
+ # @param [Array<{text: String, attrs?: Integer}>] chunks
102
+ # One or more chunks.
103
+ # @return [void]
104
+ def replace(chunks)
105
+ @rows = @snapshot.map(&:dup)
106
+ append(chunks)
107
+ end
108
+
109
+ ##
110
+ # Given a chunk this method wraps text at
111
+ # around 80 columns: a new row starts when
112
+ # the current character is " " and the sum
113
+ # of all characters in that row is greater
114
+ # than 80 columns.
115
+ def wrap(chunk, rows)
116
+ attrs = chunk[:attrs]
117
+ chunk[:text].to_s.each_char do |char|
118
+ if char == "\n"
119
+ rows << []
120
+ elsif char == " " and sum(rows.last) >= WIDTH
121
+ rows << []
122
+ else
123
+ rows.last << {text: char, attrs:}.compact
124
+ end
58
125
  end
59
126
  end
127
+
128
+ ##
129
+ # @api private
130
+ def rows
131
+ @rows.dup.tap do |rows|
132
+ ##
133
+ # Discard empty rows that would otherwise
134
+ # be rendered as newlines by the UI thread.
135
+ # It's not the most elegant way to deal with
136
+ # this and we probably shouldn't allow it to
137
+ # happen in the first place.
138
+ while rows.size > 1 and rows.last.empty?
139
+ rows.pop
140
+ end
141
+ end
142
+ end
143
+
144
+ ##
145
+ # @api private
146
+ def sum(row)
147
+ row.sum { _1[:text].to_s.length }
148
+ end
60
149
  end
61
150
  end
@@ -37,6 +37,7 @@ class LLM::Repl
37
37
  Curses.cbreak
38
38
  Curses.noecho
39
39
  Curses.stdscr.keypad(true)
40
+ Curses.stdscr.nodelay = true
40
41
  yield
41
42
  ensure
42
43
  Curses.close_screen
@@ -45,9 +46,10 @@ class LLM::Repl
45
46
  ##
46
47
  # @return [void]
47
48
  def redraw
48
- Curses.clear
49
- draw_status
50
- draw_transcript
49
+ Curses.curs_set(0)
50
+ draw_status(offset: input.height + 1)
51
+ draw_divider(offset: 5)
52
+ draw_transcript(offset: 0)
51
53
  draw_input
52
54
  Curses.refresh
53
55
  end
@@ -55,7 +57,7 @@ class LLM::Repl
55
57
  ##
56
58
  # @return [Integer]
57
59
  def rows
58
- [Curses.lines - 3, 1].max
60
+ [Curses.lines - (input.height + 4), 1].max
59
61
  end
60
62
 
61
63
  ##
@@ -78,28 +80,57 @@ class LLM::Repl
78
80
 
79
81
  private
80
82
 
81
- def draw_status
82
- Curses.setpos(0, 0)
83
+ def draw_status(offset:)
84
+ Curses.setpos(Curses.lines - offset, 0)
85
+ Curses.clrtoeol
83
86
  Curses.addstr(status.to_s)
84
- provider = status.provider.to_s
85
- Curses.setpos(0, [columns - provider.length, 0].max)
86
- Curses.addstr(provider)
87
+ context = status.context_bar
88
+ Curses.setpos(Curses.lines - offset, [(columns - context.length) / 2, 0].max)
89
+ Curses.addstr(context)
90
+ cost = status.cost.to_s
91
+ Curses.setpos(Curses.lines - offset, [columns - cost.length, 0].max)
92
+ Curses.addstr(cost)
87
93
  end
88
94
 
89
- def draw_transcript
90
- visible = transcript.visible(rows)
91
- visible.each_with_index do |row, index|
92
- Curses.setpos(index + 2, 0)
93
- Curses.addstr(row)
94
- end
95
+ def draw_divider(offset:)
96
+ Curses.setpos(Curses.lines - offset, 0)
97
+ Curses.clrtoeol
98
+ Curses.addstr("─" * Curses.cols)
95
99
  end
96
100
 
97
101
  def draw_input
98
- Curses.setpos(Curses.lines - 1, 0)
99
- Curses.clrtoeol
100
- Curses.addstr(input.to_s)
102
+ cols = columns
103
+ rows = input.lines(cols)
104
+ rows.each.with_index do |line, idx|
105
+ Curses.setpos((Curses.lines - input.height) + idx, 0)
106
+ Curses.clrtoeol
107
+ Curses.addstr(line)
108
+ end
109
+ line, col = input.cursor_pos(cols)
110
+ Curses.setpos((Curses.lines - input.height) + line, col)
101
111
  end
102
112
 
113
+ def draw_transcript(offset:)
114
+ rows = transcript.visible(self.rows)
115
+ rows.each.with_index(offset) do |row, index|
116
+ Curses.setpos(index, 0)
117
+ Curses.clrtoeol
118
+ row.each do |chunk|
119
+ text, attrs = chunk.values_at(:text, :attrs)
120
+ Curses.attron(attrs) if attrs
121
+ Curses.addstr(text)
122
+ Curses.attroff(attrs) if attrs
123
+ end
124
+ end
125
+ last_drawn = offset + rows.size
126
+ (last_drawn...self.rows).each do |line|
127
+ Curses.setpos(line, 0)
128
+ Curses.clrtoeol
129
+ end
130
+ end
131
+
132
+ ##
133
+ # @return [Integer]
103
134
  def columns
104
135
  Curses.cols
105
136
  end
data/lib/llm/repl.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  LLM.require "curses"
4
+ LLM.require "kramdown"
4
5
 
5
6
  module LLM
6
7
  ##
@@ -18,19 +19,37 @@ module LLM
18
19
  require_relative "repl/status"
19
20
  require_relative "repl/transcript"
20
21
  require_relative "repl/input"
22
+ require_relative "repl/bar"
21
23
  require_relative "repl/stream"
24
+ require_relative "repl/markdown"
22
25
 
23
26
  ##
24
27
  # @param [LLM::Agent] agent
28
+ # @param [Array<LLM::Tool>] tools
29
+ # Zero or more tools
30
+ # @param [Array<String>] skills
31
+ # Zero or more skills
25
32
  # @return [LLM::Repl]
26
- def initialize(agent)
33
+ def initialize(agent:, tools:, skills:)
27
34
  @agent = agent
28
35
  @provider = agent.llm.name
29
- @status = Status.new(@provider)
36
+ @status = Status.new(@agent)
30
37
  @transcript = Transcript.new
31
- @input = Input.new(@provider)
38
+ @input = Input.new(@agent, height: 3)
32
39
  @window = Window.new(@status, @transcript, @input)
33
- @stream = Stream.new(self)
40
+ @thread = nil
41
+ @queue = Queue.new
42
+ @stream = Stream.new(self, @queue)
43
+ @skills = skills.map do |path|
44
+ ##
45
+ # I'm not sure it would make sense to expose
46
+ # the underlying context or not. In the meantime,
47
+ # this works and meets the expectations of the
48
+ # LLM::Skill class.
49
+ ctx = agent.instance_variable_get(:@ctx)
50
+ LLM::Skill.load(path).to_tool(ctx)
51
+ end
52
+ @tools = [agent.params[:tools], @skills, tools].flatten.compact
34
53
  end
35
54
 
36
55
  ##
@@ -38,26 +57,33 @@ module LLM
38
57
  def start
39
58
  window.open do
40
59
  loop do
41
- window.redraw
42
- text = input.readline(window)
43
- break if text.nil?
44
- next if text.empty?
45
- status.text = "thinking"
46
- write("user: #{text}\n")
47
- window.redraw
48
- write("agent: ")
49
- agent.talk(text, stream:)
50
- status.text = "idle"
51
- write("\n\n")
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
68
+ end
52
69
  end
53
70
  end
54
71
  end
55
72
 
73
+ ##
74
+ # @param [String] chars
75
+ # @param [Object] attrs
76
+ # @return [void]
77
+ def write(chars, attrs = nil)
78
+ transcript.write(chars, attrs)
79
+ window.redraw
80
+ end
81
+
56
82
  ##
57
83
  # @param [String] chars
58
84
  # @return [void]
59
- def write(chars)
60
- transcript.write(chars)
85
+ def markdown(chars)
86
+ transcript.markdown(chars)
61
87
  window.redraw
62
88
  end
63
89
 
@@ -71,8 +97,63 @@ module LLM
71
97
 
72
98
  private
73
99
 
100
+ ##
101
+ # This method is called when the user submits their input.
102
+ # It spawns a second thread that maintains a line of
103
+ # communication with a model and the main thread - where
104
+ # the UI runs - remains responsive.
105
+ # @api private
106
+ 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]
120
+ end
121
+ end
122
+
123
+ ##
124
+ # This method reads from the queue that is written to
125
+ # by a second thread. The queue is managed or written
126
+ # to by a subclass of {LLM::Stream LLM::Stream}.
127
+ # @api private
128
+ def read!
129
+ loop do
130
+ type, value = @queue.pop(true)
131
+ case type
132
+ when :start
133
+ transcript.start
134
+ stream.empty!
135
+ when :stream
136
+ transcript.markdown(value, method: :replace)
137
+ when :status
138
+ self.status = value
139
+ when :done
140
+ status.text = "idle"
141
+ @thread = nil
142
+ transcript.finish
143
+ write("\n\n")
144
+ when :error
145
+ # Do this better
146
+ status.text = "error"
147
+ transcript.finish
148
+ write("\nerror: #{value.message}\n", Curses::A_BOLD)
149
+ @thread = nil
150
+ end
151
+ end
152
+ rescue ThreadError
153
+ end
154
+
74
155
  attr_reader :agent, :provider, :stream,
75
156
  :status, :transcript, :input,
76
- :window
157
+ :window, :tools, :thread
77
158
  end
78
159
  end
@@ -38,6 +38,17 @@ module LLM::Sequel
38
38
  end
39
39
 
40
40
  module InstanceMethods
41
+ ##
42
+ # @note
43
+ # This method does not persist to the database,
44
+ # but it can inspect and alter runtime state in
45
+ # a way that is temporary.
46
+ # @param (see LLM::Agent#repl)
47
+ # @return (see LLM::Agent#repl)
48
+ def repl(**params)
49
+ ctx.repl(**params)
50
+ end
51
+
41
52
  private
42
53
 
43
54
  def ctx
data/lib/llm/skill.rb CHANGED
@@ -155,7 +155,7 @@ module LLM
155
155
  def agent(ctx)
156
156
  instructions, tools, tracer, inherit_tools = self.instructions, self.tools, ctx.llm.tracer, inherit_tools?
157
157
  params = ctx.params.merge(mode: ctx.mode).reject { [:tools, :schema].include?(_1) }
158
- concurrency = params[:stream].extra[:concurrency] if LLM::Stream === params[:stream]
158
+ concurrency = params[:stream].extra[:concurrency]
159
159
  params[:concurrency] = concurrency if concurrency
160
160
  agent = Class.new(LLM::Agent) do
161
161
  instructions(instructions)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Stream
4
+ ##
5
+ # An {LLM::Stream::Disabled LLM::Stream::Disabled} represents a stream
6
+ # that is explicitly turned off.
7
+ #
8
+ # {LLM::Stream.try} returns an instance of this class when given `false`
9
+ # or `nil`. Providers check {#enabled?} to decide whether to request a
10
+ # streaming response from the API. When disabled, the stream is replaced
11
+ # with `nil` before the transport layer executes the request.
12
+ #
13
+ # All callback methods inherited from {LLM::Stream LLM::Stream} are no-ops,
14
+ # so a disabled stream can be used safely wherever a stream object is
15
+ # expected.
16
+ class Disabled < self
17
+ ##
18
+ # @return [false]
19
+ def enabled?
20
+ false
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Stream
4
+ ##
5
+ # An {LLM::Stream::IO LLM::Stream::IO} wraps an object that responds to
6
+ # `#<<` and forwards streamed content to it.
7
+ #
8
+ # This enables any object that implements `#<<` - such as an IO, StringIO,
9
+ # or a custom logger - to be used as a stream target. {LLM::Stream.try}
10
+ # creates instances of this class automatically when given an IO-like
11
+ # object.
12
+ #
13
+ # @example Using an IO object as a stream
14
+ # File.open("output.txt", "w") do |file|
15
+ # ctx.ask("Tell me a story", stream: file)
16
+ # end
17
+ class IO < self
18
+ ##
19
+ # @param [#<<] io
20
+ # Any object that implements `#<<`.
21
+ def initialize(io)
22
+ @io = io
23
+ end
24
+
25
+ ##
26
+ # Writes a chunk of content to the wrapped IO object.
27
+ # @param [String] content
28
+ # @return [void]
29
+ def on_content(content)
30
+ @io << content
31
+ end
32
+
33
+ ##
34
+ # Forwards streamed content to the wrapped IO object.
35
+ # Redefined here because +alias_method+ in the parent class
36
+ # captures the base implementation, not the overridden one.
37
+ # @param [String] content
38
+ # @return [void]
39
+ def <<(content)
40
+ on_content(content)
41
+ end
42
+ end
43
+ end
data/lib/llm/stream.rb CHANGED
@@ -21,6 +21,34 @@ module LLM
21
21
  # also emit lifecycle callbacks like {#on_transform} or {#on_compaction}.
22
22
  class Stream
23
23
  require_relative "stream/queue"
24
+ require_relative "stream/io"
25
+ require_relative "stream/disabled"
26
+
27
+ ##
28
+ # This method will try to convert its argument into
29
+ # an instance of {LLM::Stream LLM::Stream} or a
30
+ # subclass of it.
31
+ #
32
+ # Acceptable inputs include: {LLM::Stream LLM::Stream}
33
+ # objects, IO objects who implement `#<<`, true, false,
34
+ # and nil. Anything else raises a TypeError.
35
+ #
36
+ # @raise [TypeError]
37
+ # @param [LLM::Stream, #<<, Boolean, NilClass] obj
38
+ # @return [LLM::Stream]
39
+ def self.try(obj, extra: {})
40
+ if LLM::Stream === obj
41
+ obj.tap { _1.extra.merge!(extra) }
42
+ elsif obj.respond_to?(:<<)
43
+ LLM::Stream::IO.new(obj).tap { _1.extra.merge!(extra) }
44
+ elsif obj == true
45
+ LLM::Stream.new.tap { _1.extra.merge!(extra) }
46
+ elsif obj.nil? || obj == false
47
+ LLM::Stream::Disabled.new.tap { _1.extra.merge!(extra) }
48
+ else
49
+ raise TypeError, "invalid stream object"
50
+ end
51
+ end
24
52
 
25
53
  ##
26
54
  # Returns extra context associated with the current streamed request.
@@ -52,6 +80,12 @@ module LLM
52
80
  queue.wait
53
81
  end
54
82
 
83
+ ##
84
+ # @return [Boolean]
85
+ def enabled?
86
+ true
87
+ end
88
+
55
89
  # @group Public callbacks
56
90
 
57
91
  ##
data/lib/llm/tools/git.rb CHANGED
@@ -16,9 +16,8 @@ class LLM::Tool
16
16
  required %i[action]
17
17
 
18
18
  ##
19
- # @param [String] path
20
- # @param [Integer] start
21
- # @param [Integer] stop
19
+ # @param [String] action
20
+ # @param [Array<String>, nil] arguments
22
21
  # @return [Hash]
23
22
  def call(action:, arguments: nil)
24
23
  command = spawn(action:, arguments:)
data/lib/llm/tools/pwd.rb CHANGED
@@ -11,7 +11,6 @@ class LLM::Tool
11
11
  description "returns the current working directory"
12
12
 
13
13
  ##
14
- # @param [String] path
15
14
  # @return [Hash]
16
15
  def call
17
16
  {ok: true, cwd: Dir.getwd}
data/lib/llm/tools/rg.rb CHANGED
@@ -16,7 +16,8 @@ class LLM::Tool
16
16
  required %i[patterns]
17
17
 
18
18
  ##
19
- # @param [String] pattern
19
+ # @param [Array<String>] patterns
20
+ # @param [String] path
20
21
  # @return [Hash]
21
22
  def call(patterns:, path: Dir.getwd)
22
23
  validate!(patterns:, path:)
@@ -14,6 +14,12 @@ class LLM::Tool
14
14
  parameter :expected_count, Integer, "How many matches should be replaced"
15
15
  required %i[path before after]
16
16
 
17
+ ##
18
+ # @param [String] path
19
+ # @param [String] before
20
+ # @param [String] after
21
+ # @param [Integer] expected_count
22
+ # @return [Hash]
17
23
  def call(path:, before:, after:, expected_count: 1)
18
24
  content = File.read(path)
19
25
  count = content.scan(before).length
@@ -29,6 +29,7 @@ class LLM::Transport
29
29
  # When there is a network error at the operating system level
30
30
  # @return [LLM::Transport::Response]
31
31
  def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, inputs: nil, &b)
32
+ stream = nil if !stream&.enabled?
32
33
  stream &&= LLM::Object.from(streamer: stream, parser: stream_parser, decoder: stream_decoder)
33
34
  owner = transport.request_owner
34
35
  tracer = self.tracer
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "12.2.0"
4
+ VERSION = "12.3.1"
5
5
  end
data/lib/llm.rb CHANGED
@@ -252,6 +252,16 @@ module LLM
252
252
  LLM::Function.new(key, &b)
253
253
  end
254
254
 
255
+ ##
256
+ # @param [LLM::Provider] llm
257
+ # A provider
258
+ # @param [Hash] params
259
+ # Forwarded to LLM::Tracer::Logger
260
+ # @return [LLM::Tracer::Logger]
261
+ def logger(llm, **params)
262
+ LLM::Tracer::Logger.new(llm, params)
263
+ end
264
+
255
265
  ##
256
266
  # Provides a thread-safe lock
257
267
  # @param [Symbol] name The name of the lock
data/llm.gemspec CHANGED
@@ -9,9 +9,13 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["robert@r.uby.dev"]
10
10
 
11
11
  spec.summary = "Ruby's capable AI runtime"
12
- spec.description = "llm.rb is not a library, framework or toolkit but " \
13
- "an advanced runtime for building highly capable AI " \
14
- "applications on CRuby."
12
+ spec.description = <<~DESCRIPTION
13
+ llm.rb is an advanced runtime for building capable AI applications
14
+ on CRuby. By default it has zero runtime dependencies although certain
15
+ functionality &ndash; such as ActiveRecord support &ndash; require
16
+ optional dependencies that are opt-in.
17
+ DESCRIPTION
18
+
15
19
  spec.license = "BUSL-1.1"
16
20
  spec.required_ruby_version = ">= 3.3.0"
17
21