llm.rb 12.6.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +571 -13
  3. data/LICENSE +21 -93
  4. data/README.md +183 -167
  5. data/bin/llm.rb +124 -0
  6. data/data/deepinfra.json +3 -0
  7. data/data/xai.json +1 -1
  8. data/lib/llm/a2a.rb +1 -1
  9. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  10. data/lib/llm/agent.rb +136 -27
  11. data/lib/llm/buffer.rb +85 -3
  12. data/lib/llm/compactor/null.rb +19 -0
  13. data/lib/llm/compactor/truncate.rb +80 -0
  14. data/lib/llm/compactor.rb +42 -124
  15. data/lib/llm/context.rb +31 -37
  16. data/lib/llm/contract.rb +4 -25
  17. data/lib/llm/function/array.rb +18 -17
  18. data/lib/llm/function/async/group.rb +54 -0
  19. data/lib/llm/function/async/reactor.rb +48 -0
  20. data/lib/llm/function/async/task.rb +83 -0
  21. data/lib/llm/function/fiber/group.rb +46 -0
  22. data/lib/llm/function/fiber/task.rb +62 -0
  23. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  24. data/lib/llm/function/fork/job.rb +2 -2
  25. data/lib/llm/function/fork/task.rb +19 -10
  26. data/lib/llm/function/group.rb +40 -0
  27. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  28. data/lib/llm/function/ractor/job.rb +9 -3
  29. data/lib/llm/function/ractor/mailbox.rb +2 -0
  30. data/lib/llm/function/ractor/task.rb +23 -15
  31. data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
  32. data/lib/llm/function/sequential/task.rb +49 -0
  33. data/lib/llm/function/task.rb +25 -48
  34. data/lib/llm/function/thread/group.rb +46 -0
  35. data/lib/llm/function/thread/task.rb +60 -0
  36. data/lib/llm/function.rb +54 -65
  37. data/lib/llm/loop_guard.rb +1 -2
  38. data/lib/llm/mcp.rb +22 -0
  39. data/lib/llm/object.rb +2 -1
  40. data/lib/llm/provider.rb +6 -3
  41. data/lib/llm/providers/anthropic.rb +1 -1
  42. data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
  43. data/lib/llm/providers/google.rb +2 -2
  44. data/lib/llm/providers/mistral.rb +1 -1
  45. data/lib/llm/providers/ollama.rb +1 -1
  46. data/lib/llm/providers/openai/responses.rb +1 -1
  47. data/lib/llm/providers/openai.rb +1 -1
  48. data/lib/llm/repl/{transcript.rb → buffer.rb} +34 -21
  49. data/lib/llm/repl/command.rb +47 -13
  50. data/lib/llm/repl/commands/compact.rb +33 -0
  51. data/lib/llm/repl/commands/help.rb +3 -5
  52. data/lib/llm/repl/input.rb +80 -15
  53. data/lib/llm/repl/markdown/table.rb +80 -0
  54. data/lib/llm/repl/markdown.rb +33 -3
  55. data/lib/llm/repl/node.rb +37 -0
  56. data/lib/llm/repl/status.rb +4 -4
  57. data/lib/llm/repl/stream.rb +12 -5
  58. data/lib/llm/repl/walker.rb +46 -0
  59. data/lib/llm/repl/window.rb +31 -32
  60. data/lib/llm/repl.rb +70 -38
  61. data/lib/llm/response.rb +10 -0
  62. data/lib/llm/schema/leaf.rb +5 -0
  63. data/lib/llm/schema/object.rb +11 -5
  64. data/lib/llm/sequel/plugin.rb +6 -6
  65. data/lib/llm/skill.rb +20 -4
  66. data/lib/llm/stream.rb +24 -17
  67. data/lib/llm/tool.rb +20 -4
  68. data/lib/llm/tools/chdir.rb +0 -2
  69. data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
  70. data/lib/llm/tools/git.rb +11 -4
  71. data/lib/llm/tools/mkdir.rb +4 -1
  72. data/lib/llm/tools/pwd.rb +0 -2
  73. data/lib/llm/tools/read_file.rb +0 -2
  74. data/lib/llm/tools/rg.rb +11 -4
  75. data/lib/llm/tools/ruby.rb +46 -0
  76. data/lib/llm/tools/shell.rb +11 -4
  77. data/lib/llm/tools/utils.rb +31 -0
  78. data/lib/llm/tracer/pretty_logger.rb +127 -0
  79. data/lib/llm/tracer.rb +1 -0
  80. data/lib/llm/version.rb +1 -1
  81. data/lib/llm.rb +25 -5
  82. data/llm.gemspec +11 -5
  83. data/resources/deepdive.md +45 -1198
  84. metadata +39 -17
  85. data/lib/llm/function/call_task.rb +0 -46
  86. data/lib/llm/function/fiber_group.rb +0 -105
  87. data/lib/llm/function/task_group.rb +0 -97
  88. data/lib/llm/function/thread_group.rb +0 -102
@@ -12,16 +12,30 @@ class LLM::Repl
12
12
  F: Curses::KEY_CTRL_F,
13
13
  K: Curses::KEY_CTRL_K,
14
14
  Y: Curses::KEY_CTRL_Y,
15
- D: Curses::KEY_CTRL_D
15
+ D: Curses::KEY_CTRL_D,
16
+ P: Curses::KEY_CTRL_P,
17
+ N: Curses::KEY_CTRL_N
16
18
  }
17
19
 
20
+ ##
21
+ # This hash tracks how many times a given key
22
+ # was pressed repeatedly without being
23
+ # interrupted by another key. The previous key
24
+ # is reset to 0 when a different key is pressed.
25
+ REPEATS = {}
26
+ REPEATS.default = 0
27
+
18
28
  UP = Curses::Key::UP
19
29
  DOWN = Curses::Key::DOWN
20
30
  LEFT = Curses::Key::LEFT
21
31
  RIGHT = Curses::Key::RIGHT
32
+ PGUP = Curses::KEY_PPAGE
33
+ PGDOWN = Curses::KEY_NPAGE
34
+
35
+ TAB = 9
22
36
  ESC = 27
23
- ENTER = [Curses::Key::ENTER, 10, 13]
24
- BACKSPACE = [Curses::Key::BACKSPACE, 127]
37
+ ENTER = 10
38
+ BACKSPACE = 127
25
39
 
26
40
  ##
27
41
  # Threshold in seconds. If characters arrive faster than
@@ -35,21 +49,24 @@ class LLM::Repl
35
49
  attr_reader :buffer
36
50
 
37
51
  ##
38
- # @param [Boolean] bool
52
+ # @param [Boolean] value
39
53
  # @return [void]
40
54
  attr_writer :paste
41
55
 
42
56
  ##
43
- # @param [LLM::Agent] agent
57
+ # @param [LLM::Repl] repl
44
58
  # @return [LLM::Repl::Input]
45
- def initialize(agent, options = {})
46
- @agent = agent
47
- @provider = agent.llm.name
59
+ def initialize(repl, options = {})
60
+ @name = repl.name
61
+ @agent = repl.agent
62
+ @provider = @agent.llm.name
48
63
  @buffer = +""
49
64
  @cursor = 0
50
65
  @scroll = 0
51
66
  @height = options.fetch(:height, 3)
52
67
  @last_char_at = nil
68
+ @memory = @agent.messages.select(&:user?).map(&:content)
69
+ @walker = Walker.new(@memory)
53
70
  @paste = false
54
71
  end
55
72
 
@@ -59,8 +76,28 @@ class LLM::Repl
59
76
  # @return [Symbol, nil]
60
77
  def on_char(window, char, now)
61
78
  is_paste = lambda { @last_char_at and (now - @last_char_at) < PASTE_THRESHOLD }
62
- if ESC == char
79
+ if char and @char != char
80
+ REPEATS[@char] = 0
81
+ end
82
+ if PGUP == char
83
+ (window.rows - 3).times { window.scroll_up }
84
+ :pageup
85
+ elsif PGDOWN == char
86
+ (window.rows - 3).times { window.scroll_down }
87
+ :pagedown
88
+ elsif TAB == char
89
+ autocomplete
90
+ :tab
91
+ elsif ESC == char
63
92
  @agent.cancel!
93
+ elsif CTRL[:P] == char
94
+ @buffer = @walker.prev.dup
95
+ @cursor = @buffer.size
96
+ :ctrl_p
97
+ elsif CTRL[:N] == char
98
+ @buffer = @walker.next.dup
99
+ @cursor = @buffer.size
100
+ :ctrl_n
64
101
  elsif CTRL[:D] == char
65
102
  delete
66
103
  :ctrl_d
@@ -85,14 +122,16 @@ class LLM::Repl
85
122
  elsif char == RIGHT
86
123
  move_right
87
124
  :right
88
- elsif BACKSPACE.include?(char)
125
+ elsif BACKSPACE == char
89
126
  backspace
90
127
  :backspace
91
- elsif ENTER.include?(char)
128
+ elsif ENTER == char
92
129
  if @paste = is_paste.()
93
130
  insert("\n")
94
131
  :char
95
132
  else
133
+ @memory.push(@buffer.dup)
134
+ @walker.cursor = @memory.size
96
135
  :submit
97
136
  end
98
137
  elsif char == UP
@@ -108,19 +147,23 @@ class LLM::Repl
108
147
  nil
109
148
  end
110
149
  ensure
111
- @last_char_at = now if char
150
+ if char
151
+ REPEATS[char] += 1
152
+ @last_char_at = now
153
+ @char = char
154
+ end
112
155
  end
113
156
 
114
157
  ##
115
158
  # @return [String]
116
159
  def to_s
117
- "#{@provider}> #{@buffer}"
160
+ "#{prompt}#{@buffer}"
118
161
  end
119
162
 
120
163
  ##
121
164
  # @return [Integer]
122
165
  def cursor
123
- prompt.length + @cursor
166
+ prompt.size + @cursor
124
167
  end
125
168
 
126
169
  ##
@@ -182,6 +225,28 @@ class LLM::Repl
182
225
  @cursor = [0, @cursor + 1].max
183
226
  end
184
227
 
228
+ ##
229
+ # @return [void]
230
+ def autocomplete
231
+ return unless @buffer[0] == "/"
232
+ ##
233
+ # This method implements a simple autocomplete
234
+ # that supports cycling through all known
235
+ # commands. When given tab in quick succession,
236
+ # we cycle to the nearest neighbour for the last
237
+ # full match. However, it's not based on similarity,
238
+ # it's just the next element in the array.
239
+ keys = LLM::Command.registry.keys
240
+ candidates = LLM::Command.complete(@buffer)
241
+ if REPEATS[TAB] >= 1
242
+ candidate = keys[keys.index(candidates[0]) + 1] || keys[0]
243
+ else
244
+ candidate = candidates[0]
245
+ end
246
+ @buffer = "/#{candidate}"
247
+ @cursor = @buffer.size
248
+ end
249
+
185
250
  ##
186
251
  # @return [void]
187
252
  def kill
@@ -238,7 +303,7 @@ class LLM::Repl
238
303
  end
239
304
 
240
305
  def prompt
241
- "#{@provider}> "
306
+ "#{@provider}(#{@name})> "
242
307
  end
243
308
 
244
309
  def insert(char)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl::Markdown
4
+ ##
5
+ # Renders Kramdown `:table` nodes as aligned columns.
6
+ module Table
7
+ ##
8
+ # @api private
9
+ Node = LLM::Repl::Node
10
+ private_constant :Node
11
+
12
+ ##
13
+ # Renders a table node by collecting all cells first to
14
+ # compute column widths, then emitting each row with
15
+ # padded text.
16
+ def walk_table(node, attrs)
17
+ rows = collect_rows(node, attrs)
18
+ return if rows.empty?
19
+ widths = column_widths(rows)
20
+ rows.each do |row|
21
+ emit("| ", attrs)
22
+ row.each_with_index do |chunks, i|
23
+ width = widths[i]
24
+ chunks.each { |c| emit(c[:text].ljust(width), c[:attrs]) }
25
+ emit(" | ", attrs) unless i == row.size - 1
26
+ end
27
+ emit(" |", attrs)
28
+ emit("\n", attrs)
29
+ end
30
+ emit("\n", attrs)
31
+ end
32
+
33
+ private
34
+
35
+ def collect_rows(node, attrs)
36
+ node.children.each_with_object([]) do |section, rows|
37
+ next unless [:thead, :tbody].include?(section.type)
38
+ section.children.each do |tr|
39
+ next unless tr.type == :tr
40
+ cells = tr.children.filter_map do |td|
41
+ next unless [:td, :th].include?(td.type)
42
+ collect_chunks(td, attrs)
43
+ end
44
+ rows << cells
45
+ end
46
+ end
47
+ end
48
+
49
+ def collect_chunks(node, attrs)
50
+ [].tap do |chunks|
51
+ walk_collect(node, attrs, chunks)
52
+ end
53
+ end
54
+
55
+ def walk_collect(node, attrs, chunks)
56
+ case node.type
57
+ when :text
58
+ chunks << Node.new(node.value.to_s, attrs)
59
+ when :strong
60
+ node.children.each { walk_collect(_1, Curses::A_BOLD, chunks) }
61
+ when :em
62
+ node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
63
+ when :codespan
64
+ chunks << Node.new(node.value, Curses::A_REVERSE)
65
+ when :a
66
+ node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
67
+ else
68
+ node.children.each { walk_collect(_1, attrs, chunks) }
69
+ end
70
+ end
71
+
72
+ def column_widths(rows)
73
+ return [] if rows.empty?
74
+ cols = rows.first.size
75
+ (0...cols).map do |i|
76
+ rows.map { |r| r[i].map { _1[:text] }.join.length }.max
77
+ end
78
+ end
79
+ end
80
+ end
@@ -8,16 +8,21 @@ class LLM::Repl
8
8
  # of text, and also optional style information for
9
9
  # that text (eg bold, underscore, ...)
10
10
  class Markdown
11
+ require_relative "markdown/table"
12
+ include Table
13
+
11
14
  ##
12
15
  # @param [String] text
16
+ # @param [Integer] width
13
17
  # @return [LLM::Repl::Markdown]
14
- def initialize(text)
18
+ def initialize(text, width)
15
19
  @doc = Kramdown::Document.new(text)
20
+ @width = width
16
21
  @ast = []
17
22
  end
18
23
 
19
24
  ##
20
- # @return [Array<Hash>]
25
+ # @return [Array<Node>]
21
26
  def ast
22
27
  @ast.tap do
23
28
  ##
@@ -73,13 +78,38 @@ class LLM::Repl
73
78
  emit("\n\n", attrs)
74
79
  when :br
75
80
  emit("\n", attrs)
81
+ when :ul, :ol
82
+ node.children.each { walk(_1, attrs) }
83
+ when :li
84
+ node.children.each { walk(_1, attrs) }
85
+ when :table
86
+ walk_table(node, attrs)
87
+ when :thead, :tbody
88
+ node.children.each { walk(_1, attrs) }
89
+ when :tr
90
+ emit("| ", attrs)
91
+ node.children.each { walk(_1, attrs) }
92
+ emit("\n", attrs)
93
+ when :td, :th
94
+ node.children.each { walk(_1, attrs) }
95
+ emit(" | ", attrs)
96
+ when :blockquote
97
+ emit("> ", attrs)
98
+ node.children.each { walk(_1, attrs) }
99
+ when :hr
100
+ emit("─" * @width, attrs)
101
+ emit("\n\n", attrs)
102
+ when :a
103
+ node.children.each { walk(_1, Curses::A_UNDERLINE) }
104
+ when :img
105
+ emit("[image: #{node.attr["alt"]}]", attrs)
76
106
  else
77
107
  node.children.each { walk(_1, attrs) }
78
108
  end
79
109
  end
80
110
 
81
111
  def emit(text, attrs)
82
- @ast.push({text: text.to_s, attrs:}.compact)
112
+ @ast.push(Node.new(text.to_s, attrs))
83
113
  end
84
114
  end
85
115
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Node LLM::Repl::Node} class wraps a piece
6
+ # of text and optional curses attributes.
7
+ # @api private
8
+ class Node
9
+ ##
10
+ # @return [String]
11
+ attr_reader :text
12
+
13
+ ##
14
+ # @return [Integer, nil]
15
+ attr_reader :attrs
16
+
17
+ ##
18
+ # @param [String] text
19
+ # @param [Integer, nil] attrs
20
+ # @return [LLM::Repl::Node]
21
+ def initialize(text, attrs = nil)
22
+ @text = text.to_s
23
+ @attrs = attrs
24
+ end
25
+
26
+ ##
27
+ # Hash-like lookup.
28
+ # @param [Symbol] key
29
+ # @return [String, Integer, nil]
30
+ def [](key)
31
+ case key
32
+ when :text then @text
33
+ when :attrs then @attrs
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,11 +7,11 @@ class LLM::Repl
7
7
  # @api private
8
8
  class Status
9
9
  ##
10
- # @param [String, Symbol] provider
10
+ # @param [LLM::Repl] repl
11
11
  # @return [LLM::Repl::Status]
12
- def initialize(agent)
13
- @agent = agent
14
- @provider = agent.llm.name
12
+ def initialize(repl)
13
+ @agent = repl.agent
14
+ @provider = @agent.llm.name
15
15
  @text = "idle"
16
16
  end
17
17
 
@@ -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
 
@@ -60,9 +60,16 @@ class LLM::Repl
60
60
  # @param [Integer] max
61
61
  # @return [String]
62
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(", ")
63
+ ##
64
+ # 'tool.arguments' might be returned
65
+ # (by the model) in a different order
66
+ # than the tool definition - this code
67
+ # handles re-sorting.
68
+ args = tool.arguments
69
+ props = tool.params.properties.keys
70
+ props = props.sort_by { tool.params.properties[_1].index }
71
+ props = props.filter_map { args[_1] ? "#{_1}: #{format_value(args[_1])}" : nil }
72
+ result = props.join(", ")
66
73
  result.size > max ? "#{result[0...max - 1]}…" : result
67
74
  end
68
75
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # Walks through an array from the last element backward,
6
+ # one step at a time. {#next} moves forward toward the end.
7
+ # Both methods clamp at the array boundaries.
8
+ class Walker
9
+ ##
10
+ # Sets the cursor position
11
+ attr_writer :cursor
12
+
13
+ ##
14
+ # @param [Array] items
15
+ def initialize(items)
16
+ @items = items
17
+ @cursor = items.size
18
+ end
19
+
20
+ ##
21
+ # @return [Object, nil]
22
+ def next
23
+ if @items.empty?
24
+ nil
25
+ elsif @cursor >= @items.size - 1
26
+ @items[@cursor]
27
+ else
28
+ @cursor += 1
29
+ @items[@cursor]
30
+ end
31
+ end
32
+
33
+ ##
34
+ # @return [Object, nil]
35
+ def prev
36
+ if @items.empty?
37
+ nil
38
+ elsif @cursor <= 0
39
+ @items[@cursor]
40
+ else
41
+ @cursor -= 1
42
+ @items[@cursor]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -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