llm.rb 12.1.0 → 12.3.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +248 -0
  3. data/README.md +77 -27
  4. data/data/anthropic.json +3 -2
  5. data/data/bedrock.json +47 -0
  6. data/data/deepinfra.json +2 -2
  7. data/data/google.json +32 -0
  8. data/data/mistral.json +968 -0
  9. data/data/openai.json +422 -0
  10. data/data/xai.json +60 -1
  11. data/lib/llm/active_record/acts_as_agent.rb +11 -0
  12. data/lib/llm/agent.rb +27 -3
  13. data/lib/llm/compactor.rb +2 -2
  14. data/lib/llm/context.rb +18 -26
  15. data/lib/llm/function.rb +6 -0
  16. data/lib/llm/object.rb +13 -0
  17. data/lib/llm/provider.rb +12 -9
  18. data/lib/llm/providers/anthropic.rb +4 -5
  19. data/lib/llm/providers/bedrock.rb +4 -5
  20. data/lib/llm/providers/google.rb +7 -3
  21. data/lib/llm/providers/mistral/request_adapter/completion.rb +122 -0
  22. data/lib/llm/providers/mistral/request_adapter.rb +20 -0
  23. data/lib/llm/providers/mistral.rb +145 -0
  24. data/lib/llm/providers/ollama/response_adapter/completion.rb +39 -0
  25. data/lib/llm/providers/ollama.rb +2 -3
  26. data/lib/llm/providers/openai/responses.rb +6 -3
  27. data/lib/llm/providers/openai.rb +2 -3
  28. data/lib/llm/repl/bar.rb +52 -0
  29. data/lib/llm/repl/input.rb +143 -30
  30. data/lib/llm/repl/markdown.rb +85 -0
  31. data/lib/llm/repl/status.rb +16 -5
  32. data/lib/llm/repl/stream.rb +15 -5
  33. data/lib/llm/repl/transcript.rb +107 -18
  34. data/lib/llm/repl/window.rb +43 -17
  35. data/lib/llm/repl.rb +99 -18
  36. data/lib/llm/sequel/agent.rb +11 -0
  37. data/lib/llm/skill.rb +1 -1
  38. data/lib/llm/stream/disabled.rb +23 -0
  39. data/lib/llm/stream/io.rb +43 -0
  40. data/lib/llm/stream.rb +34 -0
  41. data/lib/llm/tools/git.rb +2 -3
  42. data/lib/llm/tools/pwd.rb +0 -1
  43. data/lib/llm/tools/rg.rb +2 -1
  44. data/lib/llm/tools/swap_text.rb +6 -0
  45. data/lib/llm/transport/execution.rb +1 -0
  46. data/lib/llm/version.rb +1 -1
  47. data/lib/llm.rb +19 -0
  48. data/llm.gemspec +8 -4
  49. data/resources/deepdive.md +170 -50
  50. metadata +15 -4
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Bar LLM::Repl::Bar} class renders a
6
+ # small progress bar for the REPL. It is used to show
7
+ # the remaining size of the model's context window in
8
+ # a compact form near the input line.
9
+ # @api private
10
+ class Bar
11
+ ##
12
+ # @return [String]
13
+ OCCUPIED = "█"
14
+
15
+ ##
16
+ # @return [String]
17
+ FREE = " "
18
+
19
+ ##
20
+ # @param [Integer] used
21
+ # @param [Integer] total
22
+ # @param [Integer] width
23
+ # @return [LLM::Repl::Bar]
24
+ def initialize(used:, total:, width: 10)
25
+ @width = width
26
+ @label, @filled = remainder(used, total)
27
+ end
28
+
29
+ ##
30
+ # @return [String]
31
+ def to_s
32
+ bar = "#{OCCUPIED * filled}#{FREE * (width - filled)}"
33
+ "│#{bar}│ #{label}"
34
+ end
35
+
36
+ private
37
+
38
+ ##
39
+ # @param [Integer] used
40
+ # @param [Integer] total
41
+ # @return [[String, Integer]]
42
+ def remainder(used, total)
43
+ return ["???", width] if total <= 0
44
+ diff = total - used
45
+ return ["0%", 0] if diff <= 0
46
+ remaining = (diff.fdiv(total) * 100).round(2)
47
+ ["#{remaining}%", ((remaining / 100) * width).round]
48
+ end
49
+
50
+ attr_reader :label, :filled, :width
51
+ end
52
+ end
@@ -6,59 +6,172 @@ 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
11
  UP = Curses::Key::UP
10
12
  DOWN = Curses::Key::DOWN
13
+ LEFT = Curses::Key::LEFT
14
+ RIGHT = Curses::Key::RIGHT
11
15
  ENTER = [Curses::Key::ENTER, 10, 13]
12
16
  BACKSPACE = [Curses::Key::BACKSPACE, 127]
13
- EOF = [nil, 4]
17
+ EOF = [4]
14
18
 
15
19
  ##
16
- # @param [String, Symbol] provider
20
+ # @param [LLM::Agent] agent
17
21
  # @return [LLM::Repl::Input]
18
- def initialize(provider)
19
- @provider = provider
22
+ def initialize(agent, options = {})
23
+ @agent = agent
24
+ @provider = agent.llm.name
20
25
  @buffer = +""
26
+ @cursor = 0
27
+ @scroll = 0
28
+ @height = options.fetch(:height, 3)
21
29
  end
22
30
 
23
31
  ##
24
32
  # @param [LLM::Repl::Window] window
25
- # @return [String, nil]
26
- def readline(window)
27
- catch(:done) do
28
- @buffer.clear
29
- loop do
30
- on_char(window, window.getch)
31
- window.redraw
32
- end
33
- end
34
- end
35
-
36
- ##
37
- # @return [String]
38
- def to_s
39
- "> #{@buffer}"
40
- end
41
-
42
- private
43
-
33
+ # @param [Object] char
34
+ # @return [Symbol, nil]
44
35
  def on_char(window, char)
45
36
  if EOF.include?(char)
46
- throw(:done, nil)
37
+ :exit
47
38
  elsif BACKSPACE.include?(char)
48
- @buffer.chop!
39
+ backspace
40
+ :backspace
49
41
  elsif ENTER.include?(char)
50
- buf = @buffer.dup
51
- @buffer.clear
52
- throw(:done, buf)
42
+ :submit
53
43
  elsif char == UP
54
44
  window.scroll_up
45
+ :up
55
46
  elsif char == DOWN
56
47
  window.scroll_down
48
+ :down
49
+ elsif char == CTRL_A
50
+ move_start
51
+ :ctrl_a
52
+ elsif char == CTRL_E
53
+ move_end
54
+ :ctrl_e
55
+ elsif char == LEFT
56
+ move_left
57
+ :left
58
+ elsif char == RIGHT
59
+ move_right
60
+ :right
57
61
  elsif String === char
58
- @buffer << char
62
+ insert(char)
63
+ :char
59
64
  else
60
- # ???
65
+ nil
61
66
  end
62
67
  end
68
+
69
+ ##
70
+ # @return [String]
71
+ def to_s
72
+ "#{@provider}> #{@buffer}"
73
+ end
74
+
75
+ ##
76
+ # @return [Integer]
77
+ def cursor
78
+ prompt.length + @cursor
79
+ end
80
+
81
+ ##
82
+ # @return [Integer]
83
+ def height
84
+ @height
85
+ end
86
+
87
+ ##
88
+ # 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
93
+ # @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?
99
+ chunks[@scroll, height] || []
100
+ end
101
+
102
+ ##
103
+ # Returns the cursor position as [line, column] within
104
+ # the visible viewport.
105
+ # @param [Integer] cols
106
+ # @return [Array(Integer, Integer)]
107
+ def cursor_pos(cols)
108
+ sync_scroll(cols)
109
+ [(cursor / cols) - @scroll, cursor % cols]
110
+ end
111
+
112
+ ##
113
+ # @return [void]
114
+ def move_start
115
+ @cursor = 0
116
+ end
117
+
118
+ ##
119
+ # @return [void]
120
+ def move_end
121
+ @cursor = [0, @buffer.size].max
122
+ end
123
+
124
+ ##
125
+ # @return [void]
126
+ def move_left
127
+ @cursor = [@cursor - 1, 0].max
128
+ end
129
+
130
+ ##
131
+ # @return [void]
132
+ def move_right
133
+ @cursor = [@cursor + 1, @buffer.size].min
134
+ end
135
+
136
+ ##
137
+ # @return [String]
138
+ def take
139
+ @buffer.dup.tap do
140
+ @buffer.clear
141
+ @cursor = 0
142
+ @scroll = 0
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ ##
149
+ # Adjusts @scroll so the cursor line is visible within
150
+ # the viewport.
151
+ def sync_scroll(cols)
152
+ total_lines = [1, (to_s.length.to_f / cols).ceil].max
153
+ cursor_line = cursor / cols
154
+ if cursor_line < @scroll
155
+ @scroll = cursor_line
156
+ elsif cursor_line >= (@scroll + height)
157
+ @scroll = (cursor_line - height) + 1
158
+ end
159
+ @scroll = [[@scroll, (total_lines - height)].min, 0].max
160
+ end
161
+
162
+ def prompt
163
+ "#{@provider}> "
164
+ end
165
+
166
+ def insert(char)
167
+ @buffer.insert(@cursor, char)
168
+ @cursor += char.length
169
+ end
170
+
171
+ def backspace
172
+ return if @cursor <= 0
173
+ @buffer.slice!(@cursor - 1)
174
+ @cursor -= 1
175
+ end
63
176
  end
64
177
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # This class is designed to represent a markdown
6
+ # string (typically from a model's response) as a
7
+ # tree of objects where each object contains a piece
8
+ # of text, and also optional style information for
9
+ # that text (eg bold, underscore, ...)
10
+ class Markdown
11
+ ##
12
+ # @param [String] text
13
+ # @return [LLM::Repl::Markdown]
14
+ def initialize(text)
15
+ @doc = Kramdown::Document.new(text)
16
+ @ast = []
17
+ end
18
+
19
+ ##
20
+ # @return [Array<Hash>]
21
+ def ast
22
+ @ast.tap do
23
+ ##
24
+ # Recurisvely travels the markdown document and
25
+ # populates the `@ast` variable along the way.
26
+ # The AST is composed of structured data that
27
+ # carries both text and styling information that
28
+ # is applied by the UI thread.
29
+ walk(@doc.root)
30
+
31
+ ##
32
+ # This is required because the AST collects
33
+ # empty nodes towards the end of the tree.
34
+ # If we don't pop them we end up with excessive
35
+ # amount of newlines between turns.
36
+ last = @ast.last
37
+ while last and last[:text].to_s.strip.empty?
38
+ @ast.pop
39
+ last = @ast.last
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ ##
47
+ # Recursively walk from the head node to the
48
+ # tail node. This method mutates the `@ast`
49
+ # variable. A future refactor might be worthwhile
50
+ # since this method is implemented with side effects,
51
+ # but it probably could return the ast instead.
52
+ def walk(node, attrs = nil)
53
+ case node.type
54
+ when :root
55
+ node.children.each { walk(_1, attrs) }
56
+ when :text
57
+ emit(node.value.to_s, attrs)
58
+ when :p
59
+ node.children.each { walk(_1, attrs) }
60
+ emit("\n\n", attrs)
61
+ when :header
62
+ emit("\n", attrs)
63
+ node.children.each { walk(_1, Curses::A_BOLD) }
64
+ emit("\n", attrs)
65
+ when :strong
66
+ node.children.each { walk(_1, Curses::A_BOLD) }
67
+ when :em
68
+ node.children.each { walk(_1, Curses::A_UNDERLINE) }
69
+ when :codespan
70
+ emit(node.value, Curses::A_REVERSE)
71
+ when :codeblock
72
+ emit(node.value, Curses::A_REVERSE)
73
+ emit("\n\n", attrs)
74
+ when :br
75
+ emit("\n", attrs)
76
+ else
77
+ node.children.each { walk(_1, attrs) }
78
+ end
79
+ end
80
+
81
+ def emit(text, attrs)
82
+ @ast.push({text: text.to_s, attrs:}.compact)
83
+ end
84
+ end
85
+ end
@@ -9,18 +9,29 @@ class LLM::Repl
9
9
  ##
10
10
  # @param [String, Symbol] provider
11
11
  # @return [LLM::Repl::Status]
12
- def initialize(provider)
13
- @provider = provider
12
+ def initialize(agent)
13
+ @agent = agent
14
+ @provider = agent.llm.name
14
15
  @text = "idle"
15
16
  end
16
17
 
17
18
  ##
18
19
  # @return [String]
19
- attr_reader :provider
20
+ def context_bar
21
+ LLM::Repl::Bar.new(
22
+ used: @agent.usage.total_tokens,
23
+ total: @agent.context_window
24
+ ).to_s
25
+ end
26
+
27
+ ##
28
+ # @return [String]
29
+ def cost
30
+ "$#{@agent.cost}"
31
+ end
20
32
 
21
33
  ##
22
- # @param [String] value
23
- # @return [void]
34
+ # @return [String]
24
35
  attr_accessor :text
25
36
 
26
37
  ##
@@ -11,8 +11,10 @@ class LLM::Repl
11
11
  ##
12
12
  # @param [LLM::Repl] repl
13
13
  # @return [LLM::Repl::Stream]
14
- def initialize(repl)
14
+ def initialize(repl, queue)
15
15
  @repl = repl
16
+ @_queue = queue
17
+ @buffer = +""
16
18
  end
17
19
 
18
20
  ##
@@ -20,7 +22,8 @@ class LLM::Repl
20
22
  # One or more chars
21
23
  # @return [void]
22
24
  def on_content(chars)
23
- @repl.write(chars)
25
+ @buffer << chars
26
+ @_queue.push [:stream, @buffer]
24
27
  end
25
28
 
26
29
  ##
@@ -29,9 +32,9 @@ class LLM::Repl
29
32
  # @return [void]
30
33
  def on_tool_call(tool, error)
31
34
  if error
32
- @repl.status = "tool error: #{tool.name}"
35
+ @_queue.push [:status, "tool not found: #{tool.name}"]
33
36
  else
34
- @repl.status = "tool: #{tool.name}"
37
+ @_queue.push [:status, "tool: #{tool.name}"]
35
38
  end
36
39
  end
37
40
 
@@ -40,7 +43,14 @@ class LLM::Repl
40
43
  # @param [LLM::Function::Return] result
41
44
  # @return [void]
42
45
  def on_tool_return(_tool, result)
43
- @repl.status = "tool done: #{result.name}"
46
+ @_queue.push [:status, "tool done: #{result.name}"]
47
+ end
48
+
49
+ ##
50
+ # Empty the accumulated buffer
51
+ # @return [void]
52
+ def empty!
53
+ @buffer.clear
44
54
  end
45
55
  end
46
56
  end
@@ -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
@@ -46,8 +47,9 @@ class LLM::Repl
46
47
  # @return [void]
47
48
  def redraw
48
49
  Curses.clear
49
- draw_status
50
- draw_transcript
50
+ draw_divider(offset: 5)
51
+ draw_status(offset: input.height + 1)
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,52 @@ 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
+ end
126
+
127
+ ##
128
+ # @return [Integer]
103
129
  def columns
104
130
  Curses.cols
105
131
  end