llm.rb 15.0.3 → 15.2.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -3
  3. data/README.md +188 -71
  4. data/bin/llm.rb +50 -7
  5. data/data/alibaba.json +912 -823
  6. data/data/anthropic.json +234 -187
  7. data/data/bedrock.json +3702 -2058
  8. data/data/deepinfra.json +1288 -951
  9. data/data/deepseek.json +87 -53
  10. data/data/google.json +670 -670
  11. data/data/mistral.json +501 -460
  12. data/data/moonshot.json +43 -248
  13. data/data/openai.json +1008 -914
  14. data/data/openrouter.json +14417 -0
  15. data/data/xai.json +213 -201
  16. data/data/zai.json +242 -149
  17. data/docs/deepdive/advanced/compaction.md +5 -5
  18. data/docs/deepdive/advanced/context.md +8 -6
  19. data/docs/deepdive/advanced/guard.md +2 -2
  20. data/docs/deepdive/features/builtin_tools.md +93 -22
  21. data/docs/deepdive/features/{repl.md → console.md} +28 -28
  22. data/docs/deepdive/features/database.md +3 -3
  23. data/docs/deepdive/fundamentals/agents.md +13 -12
  24. data/docs/deepdive/fundamentals/providers.md +91 -6
  25. data/docs/deepdive/fundamentals/skills.md +14 -6
  26. data/docs/deepdive/fundamentals/stream.md +4 -4
  27. data/docs/deepdive/fundamentals/tools.md +63 -31
  28. data/docs/deepdive/reference/cost.md +2 -2
  29. data/docs/deepdive/reference/model_registry.md +2 -2
  30. data/docs/deepdive/reference/tracer.md +15 -13
  31. data/docs/deepdive.md +2 -2
  32. data/lib/llm/active_record/acts_as_agent.rb +9 -5
  33. data/lib/llm/agent.rb +40 -15
  34. data/lib/llm/{repl → console}/bar.rb +3 -3
  35. data/lib/llm/{repl → console}/buffer.rb +24 -9
  36. data/lib/llm/{repl → console}/color.rb +2 -2
  37. data/lib/llm/{repl → console}/command.rb +12 -12
  38. data/lib/llm/{repl → console}/commands/exit.rb +4 -4
  39. data/lib/llm/{repl → console}/commands/help.rb +1 -1
  40. data/lib/llm/{repl/commands/compact.rb → console/commands/keep.rb} +11 -9
  41. data/lib/llm/{repl → console}/commands/model.rb +2 -2
  42. data/lib/llm/{repl → console}/input/cache.rb +2 -2
  43. data/lib/llm/{repl → console}/input/char.rb +2 -2
  44. data/lib/llm/{repl → console}/input/row.rb +1 -1
  45. data/lib/llm/{repl → console}/input.rb +18 -10
  46. data/lib/llm/console/markdown/parser.rb +78 -0
  47. data/lib/llm/{repl → console}/markdown/table.rb +8 -5
  48. data/lib/llm/{repl → console}/markdown.rb +13 -30
  49. data/lib/llm/console/node.rb +69 -0
  50. data/lib/llm/{repl → console}/status.rb +11 -11
  51. data/lib/llm/{repl → console}/stream.rb +36 -9
  52. data/lib/llm/{repl → console}/walker.rb +1 -1
  53. data/lib/llm/{repl → console}/window.rb +17 -17
  54. data/lib/llm/{repl.rb → console.rb} +39 -19
  55. data/lib/llm/context/deserializer.rb +2 -1
  56. data/lib/llm/context.rb +29 -13
  57. data/lib/llm/cost.rb +13 -0
  58. data/lib/llm/function/async/reactor.rb +20 -1
  59. data/lib/llm/function/fork/task.rb +14 -10
  60. data/lib/llm/function.rb +1 -1
  61. data/lib/llm/json_adapter.rb +40 -28
  62. data/lib/llm/message.rb +7 -0
  63. data/lib/llm/provider.rb +31 -10
  64. data/lib/llm/providers/alibaba.rb +1 -1
  65. data/lib/llm/providers/anthropic.rb +1 -1
  66. data/lib/llm/providers/bedrock/models.rb +2 -2
  67. data/lib/llm/providers/bedrock.rb +1 -1
  68. data/lib/llm/providers/deepseek.rb +1 -1
  69. data/lib/llm/providers/google.rb +1 -1
  70. data/lib/llm/providers/ollama.rb +1 -1
  71. data/lib/llm/providers/openai/responses.rb +2 -1
  72. data/lib/llm/providers/openai.rb +2 -1
  73. data/lib/llm/providers/openrouter.rb +87 -0
  74. data/lib/llm/schema/leaf.rb +34 -2
  75. data/lib/llm/schema.rb +4 -2
  76. data/lib/llm/sequel/agent.rb +9 -5
  77. data/lib/llm/skill.rb +7 -1
  78. data/lib/llm/stream.rb +8 -3
  79. data/lib/llm/tool/param.rb +5 -1
  80. data/lib/llm/tool.rb +5 -0
  81. data/lib/llm/tools/bundle.rb +53 -0
  82. data/lib/llm/tools/edit-file.rb +7 -2
  83. data/lib/llm/tools/exec.rb +78 -0
  84. data/lib/llm/tools/git.rb +27 -26
  85. data/lib/llm/tools/mkdir.rb +12 -19
  86. data/lib/llm/tools/read_file.rb +69 -9
  87. data/lib/llm/tools/rg.rb +20 -24
  88. data/lib/llm/tools/ruby.rb +17 -25
  89. data/lib/llm/tools/utils.rb +75 -2
  90. data/lib/llm/tools/write_file.rb +4 -1
  91. data/lib/llm/tracer/logger.rb +2 -2
  92. data/lib/llm/tracer/pretty_logger.rb +4 -4
  93. data/lib/llm/tracer/telemetry.rb +2 -2
  94. data/lib/llm/tracer.rb +33 -0
  95. data/lib/llm/transport/curb.rb +5 -3
  96. data/lib/llm/transport/http.rb +5 -2
  97. data/lib/llm/transport/persistent_http.rb +6 -4
  98. data/lib/llm/transport/utils.rb +8 -6
  99. data/lib/llm/version.rb +1 -1
  100. data/lib/llm.rb +18 -12
  101. data/llm.gemspec +8 -8
  102. metadata +80 -37
  103. data/lib/llm/repl/node.rb +0 -44
  104. data/lib/llm/tools/shell.rb +0 -55
@@ -1,25 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
- # The 'compact' command frees space in the
5
+ # The 'keep' command frees space in the
6
6
  # context window and llm.rb is designed to
7
7
  # support multiple compaction strategies with
8
8
  # different trade offs. This command, though,
9
9
  # uses the 'truncate' strategy. See
10
10
  # {LLM::Compactor::Truncate LLM::Compactor::Truncate}
11
11
  # for more details.
12
- class Command::Compact < Command
13
- name "compact"
14
- description "frees space in the context window"
15
- parameter :n, String, "the number of messages to keep"
12
+ class Command::Keep < Command
13
+ name "keep"
14
+ description "free space in the context window"
15
+ parameter :n, String, "the number of messages to keep\n" \
16
+ "it can also be given as a percentage."
17
+ required %i[n]
16
18
 
17
19
  ##
18
20
  # @return [void]
19
- def call(n: 128)
20
- write "compact in progress"
21
+ def call(n:)
22
+ write "keep in progress"
21
23
  compactor.call(keep: n)
22
- write "compact complete"
24
+ write "keep complete"
23
25
  end
24
26
 
25
27
  private
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
5
  # Switches the active model for the REPL.
6
- # @see LLM::Repl#model=
6
+ # @see LLM::Console#model=
7
7
  class Model < Command
8
8
  name "model"
9
9
  description "switch the active model"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl::Input
3
+ class LLM::Console::Input
4
4
  ##
5
5
  # Tracks the state of the active completion so that repeated
6
6
  # TAB presses can cycle through the candidate list.
@@ -9,7 +9,7 @@ class LLM::Repl::Input
9
9
  ##
10
10
  # @param [Array<String>] candidates
11
11
  # @param [Integer] index
12
- # @return [LLM::Repl::Input::Cache]
12
+ # @return [LLM::Console::Input::Cache]
13
13
  def initialize(candidates, index)
14
14
  @candidates = candidates
15
15
  @index = index
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl::Input
3
+ class LLM::Console::Input
4
4
  ##
5
5
  # A single editable character of the input.
6
6
  class Char
7
7
  ##
8
8
  # @param [String] char
9
- # @return [LLM::Repl::Input::Char]
9
+ # @return [LLM::Console::Input::Char]
10
10
  def initialize(char)
11
11
  @char = char
12
12
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl::Input
3
+ class LLM::Console::Input
4
4
  ##
5
5
  # One line of the input. `chars` holds the editable characters.
6
6
  # `break_type` records how this line got started: an auto-wrap
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
- # The {LLM::Repl::Input LLM::Repl::Input} class manages
5
+ # The {LLM::Console::Input LLM::Console::Input} class manages
6
6
  # the editable input line shown at the bottom of the REPL.
7
7
  # @api private
8
8
  class Input
@@ -55,8 +55,8 @@ class LLM::Repl
55
55
  attr_writer :paste
56
56
 
57
57
  ##
58
- # @param [LLM::Repl] repl
59
- # @return [LLM::Repl::Input]
58
+ # @param [LLM::Console] console
59
+ # @return [LLM::Console::Input]
60
60
  def initialize(repl, options = {})
61
61
  @repl = repl
62
62
  @name = repl.name
@@ -80,7 +80,7 @@ class LLM::Repl
80
80
  end
81
81
 
82
82
  ##
83
- # @param [LLM::Repl::Window] window
83
+ # @param [LLM::Console::Window] window
84
84
  # @param [Object] char
85
85
  # @return [Symbol, nil]
86
86
  def on_char(window, char, now)
@@ -99,6 +99,13 @@ class LLM::Repl
99
99
  :tab
100
100
  elsif ESC == char
101
101
  @agent.cancel!
102
+ ##
103
+ # '@agent.cancel!' might do this for us already
104
+ # but we can't rely on it alone. There are moments
105
+ # in time where '@agent.cancel!' is essentially a
106
+ # noop.
107
+ thread = @repl.thread
108
+ thread&.raise(LLM::Interrupt) if thread&.alive?
102
109
  elsif CTRL[:P] == char
103
110
  set(text: @walker.prev.dup)
104
111
  :ctrl_p
@@ -207,7 +214,8 @@ class LLM::Repl
207
214
  def cursor_pos
208
215
  scroll!
209
216
  row, col = @cursor
210
- col += prompt.size if row.zero?
217
+ col = Node.width(@rows[row].chars[0...col].map(&:to_s).join)
218
+ col += Node.width(prompt) if row.zero?
211
219
  [row - @scroll, col]
212
220
  end
213
221
 
@@ -435,7 +443,7 @@ class LLM::Repl
435
443
  if char == "\n"
436
444
  @rows.insert(row + 1, Row.new(:newline))
437
445
  @cursor = [row + 1, 0]
438
- elsif current_line.size >= Curses.cols
446
+ elsif Node.width(current_line) >= Curses.cols
439
447
  if char == " "
440
448
  @rows.insert(row + 1, Row.new(:space))
441
449
  @cursor = [row + 1, 0]
@@ -510,8 +518,8 @@ class LLM::Repl
510
518
  ##
511
519
  # Only the first row shares its columns with the prompt,
512
520
  # so it has fewer columns left for text.
513
- width = Curses.cols - (row.equal?(@rows.first) ? prompt.size : 0)
514
- if row.chars.any? and row.to_s.length + 1 > width
521
+ width = Curses.cols - (row.equal?(@rows.first) ? Node.width(prompt) : 0)
522
+ if row.chars.any? and Node.width(row.to_s) + 1 > width
515
523
  wrap(row)
516
524
  end
517
525
  @rows.last.chars << Char.new(char)
@@ -520,7 +528,7 @@ class LLM::Repl
520
528
  ##
521
529
  # Moves the word tail of a full row onto a new
522
530
  # continuation row, keeping the overflow word whole.
523
- # @param [LLM::Repl::Input::Row] row
531
+ # @param [LLM::Console::Input::Row] row
524
532
  # @return [void]
525
533
  def wrap(row)
526
534
  index = row.to_s.rindex(" ")
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Console
4
+ ##
5
+ # Treats HTML and smart punctuation as plain text. Keep
6
+ # each parser registered (so kramdown's sub-parser lists,
7
+ # eg tables, still resolve) but point it at a no-op that
8
+ # emits the matched text literally instead of parsing it.
9
+ #
10
+ # Tables are enabled but only match when a header row is
11
+ # followed by a delimiter row (---). This breaks from
12
+ # default kramdown behavior which parses |foo| as a
13
+ # table.
14
+ class Markdown::Parser < ::Kramdown::Parser::Kramdown
15
+ ##
16
+ # Swap these parser slots for literal-text no-ops.
17
+ LITERAL = %i[span_html block_html smart_quotes typographic_syms]
18
+
19
+ ##
20
+ # A table starts with a header row that holds a pipe and
21
+ # a delimiter row (`---`). A lone `|foo|` in prose is not
22
+ # a table, so this matches only when a delimiter follows.
23
+ TABLE_START = /^[ \t]*(?=\S)#{TABLE_LINE}(?=#{TABLE_SEP_LINE})/m
24
+
25
+ def configure_parser
26
+ super
27
+ LITERAL.each { |name| swap(name, :"parse_#{name}") }
28
+ swap_table!
29
+ end
30
+
31
+ private
32
+
33
+ ##
34
+ # Replace a parser method with a no-op that emits its
35
+ # matched input literally, keeping the slot registered.
36
+ # @param [Symbol] name
37
+ # @param [Symbol] method
38
+ def swap(name, method)
39
+ data = @parsers[name]
40
+ @parsers[name] = Data.new(name, data.start_re, data.span_start, method)
41
+ end
42
+
43
+ ##
44
+ # Use a stricter start regexp for the table parser so bare
45
+ # pipe lines are not misread as tables, while kramdown's
46
+ # own parse_table still renders real tables.
47
+ def swap_table!
48
+ data = @parsers[:table]
49
+ @parsers[:table] = Data.new(:table, TABLE_START, data.span_start, data.method)
50
+ end
51
+
52
+ ##
53
+ # Emit the matched HTML tag literally. Falls back to the
54
+ # rest of the `<...` run when there is no closing `>`.
55
+ def parse_span_html
56
+ text = @src.scan_until(/>/)
57
+ add_text(text || @src.scan(/<[^>]*/))
58
+ end
59
+
60
+ ##
61
+ # Emit the matched HTML line literally.
62
+ def parse_block_html
63
+ add_text(@src.scan(/[^\n]*/))
64
+ end
65
+
66
+ ##
67
+ # Emit the smart quote literally instead of substituting.
68
+ def parse_smart_quotes
69
+ add_text(@src.scan(SMART_QUOTES_RE))
70
+ end
71
+
72
+ ##
73
+ # Emit the typographic symbol literally instead of mapping.
74
+ def parse_typographic_syms
75
+ add_text(@src.scan(TYPOGRAPHIC_SYMS_RE))
76
+ end
77
+ end
78
+ end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl::Markdown
3
+ class LLM::Console::Markdown
4
4
  ##
5
5
  # Renders Kramdown `:table` nodes as aligned columns.
6
6
  module Table
7
7
  ##
8
8
  # @api private
9
- Node = LLM::Repl::Node
10
- Color = LLM::Repl::Color
9
+ Node = LLM::Console::Node
10
+ Color = LLM::Console::Color
11
11
  private_constant :Node
12
12
  private_constant :Color
13
13
 
@@ -23,7 +23,10 @@ class LLM::Repl::Markdown
23
23
  emit("| ", attrs)
24
24
  row.each_with_index do |chunks, i|
25
25
  width = widths[i]
26
- chunks.each { |c| emit(c[:text].ljust(width), c[:attrs]) }
26
+ chunks.each do |c|
27
+ text = c[:text].to_s
28
+ emit(text + (" " * (width - Node.width(text))), c[:attrs])
29
+ end
27
30
  emit(" | ", attrs) unless i == row.size - 1
28
31
  end
29
32
  emit(" |", attrs)
@@ -77,7 +80,7 @@ class LLM::Repl::Markdown
77
80
  return [] if rows.empty?
78
81
  cols = rows.first.size
79
82
  (0...cols).map do |i|
80
- rows.map { |r| r[i].map { _1[:text] }.join.length }.max
83
+ rows.map { |r| Node.width(r[i].map { _1[:text] }.join) }.max
81
84
  end
82
85
  end
83
86
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
5
  # This class is designed to represent a markdown
6
6
  # string (typically from a model's response) as a
@@ -9,34 +9,21 @@ class LLM::Repl
9
9
  # that text (eg bold, underscore, ...)
10
10
  class Markdown
11
11
  require_relative "markdown/table"
12
+ require_relative "markdown/parser"
12
13
  include Table
13
14
 
14
- ##
15
- # Kramdown goes a bit beyond a standard markdown
16
- # parser by representing certain characters or
17
- # character sequences as distinct node types that are
18
- # represented by `:typographic_sym`, and `:smart_quote`.
19
- #
20
- # The node's value maps back to one of the keys in
21
- # this Hash, and the values are unicode characters
22
- # that provide a visual representation of the node.
23
- #
24
- # @api private
25
- SYMBOLS = {
26
- hellip: "…",
27
- ndash: "–", mdash: "—",
28
- laquo: "«", raquo: "»",
29
- laquo_space: "« ", raquo_space: "» ",
30
- lsquo: "‘", rsquo: "’",
31
- ldquo: "“", rdquo: "”"
32
- }
33
-
34
15
  ##
35
16
  # @param [String] text
36
17
  # @param [Integer] width
37
- # @return [LLM::Repl::Markdown]
18
+ # @return [LLM::Console::Markdown]
38
19
  def initialize(text, width)
39
- @doc = Kramdown::Document.new(fenced_code_blocks(text))
20
+ ##
21
+ # 'Parser' treats HTML, smart quotes,
22
+ # and typographic symbols as literal
23
+ # text, so the original input comes
24
+ # through unchanged.
25
+ options = {input: Parser}
26
+ @doc = Kramdown::Document.new(fenced_code_blocks(text), options)
40
27
  @width = width
41
28
  @ast = []
42
29
  end
@@ -46,7 +33,7 @@ class LLM::Repl
46
33
  def ast
47
34
  @ast.tap do
48
35
  ##
49
- # Recurisvely travels the markdown document and
36
+ # Recursively travels the markdown document and
50
37
  # populates the `@ast` variable along the way.
51
38
  # The AST is composed of structured data that
52
39
  # carries both text and styling information that
@@ -97,8 +84,8 @@ class LLM::Repl
97
84
  emit("#{lang(node)}\n", Curses::A_BOLD | Color.white) unless lang(node).empty?
98
85
  emit(node.value, Color.green)
99
86
  emit("\n\n", attrs)
100
- when :typographic_sym, :smart_quote
101
- emit(symbol(node), attrs)
87
+ when :entity
88
+ emit(node.value.char, attrs)
102
89
  when :br
103
90
  emit("\n", attrs)
104
91
  when :ul, :ol
@@ -135,10 +122,6 @@ class LLM::Repl
135
122
  @ast.push(Node.new(text.to_s, attrs))
136
123
  end
137
124
 
138
- def symbol(node)
139
- SYMBOLS[node.value]
140
- end
141
-
142
125
  ##
143
126
  # Kramdown's native fenced-code syntax uses
144
127
  # `~~~`, not the GitHub-style ``` fences that
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Console
4
+ ##
5
+ # The {LLM::Console::Node LLM::Console::Node} class wraps a piece
6
+ # of text and optional curses attributes.
7
+ # @api private
8
+ class Node
9
+ ##
10
+ # Returns the display width of the given text, counting wide
11
+ # characters such as emoji as two columns.
12
+ # @param [#to_s] text
13
+ # @return [Integer]
14
+ def self.width(text)
15
+ Unicode::DisplayWidth.of(text.to_s)
16
+ end
17
+
18
+ ##
19
+ # Slices the given text so the returned piece has a display width
20
+ # of at most `width` columns.
21
+ # @param [#to_s] text
22
+ # @param [Integer] width
23
+ # @return [String]
24
+ def self.slice(text, width)
25
+ out = +""
26
+ text.to_s.each_char do |char|
27
+ break if self.width(out + char) > width
28
+ out << char
29
+ end
30
+ out
31
+ end
32
+
33
+ ##
34
+ # @return [String]
35
+ attr_reader :text
36
+
37
+ ##
38
+ # @return [Integer, nil]
39
+ attr_reader :attrs
40
+
41
+ ##
42
+ # @param [String] text
43
+ # @param [Integer, nil] attrs
44
+ # @return [LLM::Console::Node]
45
+ def initialize(text, attrs = nil)
46
+ @text = text.to_s
47
+ @attrs = attrs
48
+ end
49
+
50
+ ##
51
+ # Returns the display width of the text
52
+ # @return [Integer]
53
+ def size
54
+ self.class.width(@text)
55
+ end
56
+ alias_method :length, :size
57
+
58
+ ##
59
+ # Hash-like lookup.
60
+ # @param [Symbol] key
61
+ # @return [String, Integer, nil]
62
+ def [](key)
63
+ case key
64
+ when :text then @text
65
+ when :attrs then @attrs
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
- # The {LLM::Repl::Status LLM::Repl::Status} class stores
5
+ # The {LLM::Console::Status LLM::Console::Status} class stores
6
6
  # the small status line shown at the top of the REPL.
7
7
  #
8
8
  # The status text can be a plain String, a single
9
- # {LLM::Repl::Node LLM::Repl::Node} (text plus curses attributes),
9
+ # {LLM::Console::Node LLM::Console::Node} (text plus curses attributes),
10
10
  # or an Array of either. Attributes on nodes are applied when the
11
11
  # window draws the status line.
12
12
  # @api private
13
13
  class Status
14
14
  ##
15
- # @param [LLM::Repl] repl
16
- # @return [LLM::Repl::Status]
15
+ # @param [LLM::Console] console
16
+ # @return [LLM::Console::Status]
17
17
  def initialize(repl)
18
18
  @repl = repl
19
19
  @agent = repl.agent
@@ -24,7 +24,7 @@ class LLM::Repl
24
24
  ##
25
25
  # @return [String]
26
26
  def context_bar
27
- LLM::Repl::Bar.new(
27
+ LLM::Console::Bar.new(
28
28
  ##
29
29
  # After compaction the used context is unknown until the next
30
30
  # response, so the bar renders an unknown state instead of a
@@ -41,22 +41,22 @@ class LLM::Repl
41
41
 
42
42
  ##
43
43
  # Returns the current model in use.
44
- # @return [LLM::Repl::Node]
44
+ # @return [LLM::Console::Node]
45
45
  def model
46
46
  Node.new @repl.model.to_s, Curses::A_BOLD
47
47
  end
48
48
 
49
49
  ##
50
50
  # Returns the current working directory.
51
- # @return [LLM::Repl::Node]
51
+ # @return [LLM::Console::Node]
52
52
  def cwd
53
53
  Node.new Dir.pwd.sub(Dir.home, "~"), Curses::A_BOLD
54
54
  end
55
55
 
56
56
  ##
57
57
  # Sets the status text.
58
- # @param [String, LLM::Repl::Node, Array<String, LLM::Repl::Node>] value
59
- # @return [Array<LLM::Repl::Node>]
58
+ # @param [String, LLM::Console::Node, Array<String, LLM::Console::Node>] value
59
+ # @return [Array<LLM::Console::Node>]
60
60
  def text=(value)
61
61
  @nodes =
62
62
  case value
@@ -78,7 +78,7 @@ class LLM::Repl
78
78
 
79
79
  ##
80
80
  # The nodes making up the status line.
81
- # @return [Array<LLM::Repl::Node>]
81
+ # @return [Array<LLM::Console::Node>]
82
82
  def nodes
83
83
  if @agent.compacted?
84
84
  [Node.new("Context compacted")]
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
- # The {LLM::Repl::Stream LLM::Repl::Stream} class manages
6
- # the stream for the {LLM::Repl LLM::Repl} class. This class
5
+ # The {LLM::Console::Stream LLM::Console::Stream} class manages
6
+ # the stream for the {LLM::Console LLM::Console} class. This class
7
7
  # has defined hooks that receive text, tool calls, and
8
8
  # tool returns.
9
9
  # @api private
@@ -13,10 +13,11 @@ class LLM::Repl
13
13
  attr_reader :tools
14
14
 
15
15
  ##
16
- # @param [LLM::Repl] repl
17
- # @return [LLM::Repl::Stream]
16
+ # @param [LLM::Console] console
17
+ # @return [LLM::Console::Stream]
18
18
  def initialize(repl, queue)
19
19
  @repl = repl
20
+ @agent = @repl.agent
20
21
  @_queue = queue
21
22
  @buffer = +""
22
23
  @tools = {}
@@ -39,6 +40,14 @@ class LLM::Repl
39
40
  @_queue.push [:status, [" ", lambda, " • #{tool.name}(#{format_args(tool)})"]]
40
41
  end
41
42
 
43
+ ##
44
+ # @param [Exception] ex
45
+ # @param [Integer] attempt
46
+ # @return [void]
47
+ def on_retry(ex, attempt)
48
+ @_queue.push [:status, retry_error(ex, attempt)]
49
+ end
50
+
42
51
  ##
43
52
  # @param [LLM::Function] tool
44
53
  # @param [LLM::Function::Return] result
@@ -57,11 +66,29 @@ class LLM::Repl
57
66
  private
58
67
 
59
68
  ##
60
- # @return [LLM::Repl::Node]
69
+ # @return [LLM::Agent]
70
+ def agent
71
+ @agent
72
+ end
73
+
74
+ ##
75
+ # @return [LLM::Console::Node]
61
76
  def lambda
62
77
  Node.new("λ", Curses::A_BOLD | Color.red)
63
78
  end
64
79
 
80
+ ##
81
+ # @return [LLM::Console::Node]
82
+ def retry_error(ex, attempt)
83
+ err = case ex.class.to_s
84
+ when "LLM::RateLimitError", "LLM::InsufficientQuotaError" then "Rate limited"
85
+ when "Net::ReadTimeout", "Net::WriteTimeout", "Net::OpenTimeout" then "Timed out"
86
+ else ex.class.to_s
87
+ end
88
+ [Node.new(" 🔁 "),
89
+ Node.new("#{err} • attempt #{attempt} of #{agent.retry_budget}", Color.red | Curses::A_BOLD)]
90
+ end
91
+
65
92
  ##
66
93
  # Formats tool arguments as compact key: value pairs
67
94
  # suitable for the status line. Strings are quoted and
@@ -82,7 +109,7 @@ class LLM::Repl
82
109
  props = props.sort_by { tool.params.properties[_1].index }
83
110
  props = props.filter_map { args[_1] ? "#{_1}: #{format_value(args[_1])}" : nil }
84
111
  result = props.join(", ")
85
- result.size > max ? "#{result[0...max - 1]}…" : result
112
+ Node.width(result) > max ? "#{Node.slice(result, max - 1)}…" : result
86
113
  end
87
114
 
88
115
  ##
@@ -92,7 +119,7 @@ class LLM::Repl
92
119
  def format_value(value, max: 18)
93
120
  case value
94
121
  when String
95
- value.size > max ? "#{value[0...max]}…".inspect : value.inspect
122
+ Node.width(value) > max ? "#{Node.slice(value, max)}…".inspect : value.inspect
96
123
  when Array
97
124
  items = value.take(2).map { format_value(_1, max: 10) }
98
125
  items << "…" if value.size > 2
@@ -103,7 +130,7 @@ class LLM::Repl
103
130
  "nil"
104
131
  else
105
132
  str = value.inspect
106
- str.size > max ? "#{str[0...max]}…" : str
133
+ Node.width(str) > max ? "#{Node.slice(str, max)}…" : str
107
134
  end
108
135
  end
109
136
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
5
  # Walks through an array from the last element backward,
6
6
  # one step at a time. {#next} moves forward toward the end.