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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +433 -3
- data/README.md +188 -71
- data/bin/llm.rb +50 -7
- data/data/alibaba.json +912 -823
- data/data/anthropic.json +234 -187
- data/data/bedrock.json +3702 -2058
- data/data/deepinfra.json +1288 -951
- data/data/deepseek.json +87 -53
- data/data/google.json +670 -670
- data/data/mistral.json +501 -460
- data/data/moonshot.json +43 -248
- data/data/openai.json +1008 -914
- data/data/openrouter.json +14417 -0
- data/data/xai.json +213 -201
- data/data/zai.json +242 -149
- data/docs/deepdive/advanced/compaction.md +5 -5
- data/docs/deepdive/advanced/context.md +8 -6
- data/docs/deepdive/advanced/guard.md +2 -2
- data/docs/deepdive/features/builtin_tools.md +93 -22
- data/docs/deepdive/features/{repl.md → console.md} +28 -28
- data/docs/deepdive/features/database.md +3 -3
- data/docs/deepdive/fundamentals/agents.md +13 -12
- data/docs/deepdive/fundamentals/providers.md +91 -6
- data/docs/deepdive/fundamentals/skills.md +14 -6
- data/docs/deepdive/fundamentals/stream.md +4 -4
- data/docs/deepdive/fundamentals/tools.md +63 -31
- data/docs/deepdive/reference/cost.md +2 -2
- data/docs/deepdive/reference/model_registry.md +2 -2
- data/docs/deepdive/reference/tracer.md +15 -13
- data/docs/deepdive.md +2 -2
- data/lib/llm/active_record/acts_as_agent.rb +9 -5
- data/lib/llm/agent.rb +40 -15
- data/lib/llm/{repl → console}/bar.rb +3 -3
- data/lib/llm/{repl → console}/buffer.rb +24 -9
- data/lib/llm/{repl → console}/color.rb +2 -2
- data/lib/llm/{repl → console}/command.rb +12 -12
- data/lib/llm/{repl → console}/commands/exit.rb +4 -4
- data/lib/llm/{repl → console}/commands/help.rb +1 -1
- data/lib/llm/{repl/commands/compact.rb → console/commands/keep.rb} +11 -9
- data/lib/llm/{repl → console}/commands/model.rb +2 -2
- data/lib/llm/{repl → console}/input/cache.rb +2 -2
- data/lib/llm/{repl → console}/input/char.rb +2 -2
- data/lib/llm/{repl → console}/input/row.rb +1 -1
- data/lib/llm/{repl → console}/input.rb +18 -10
- data/lib/llm/console/markdown/parser.rb +78 -0
- data/lib/llm/{repl → console}/markdown/table.rb +8 -5
- data/lib/llm/{repl → console}/markdown.rb +13 -30
- data/lib/llm/console/node.rb +69 -0
- data/lib/llm/{repl → console}/status.rb +11 -11
- data/lib/llm/{repl → console}/stream.rb +36 -9
- data/lib/llm/{repl → console}/walker.rb +1 -1
- data/lib/llm/{repl → console}/window.rb +17 -17
- data/lib/llm/{repl.rb → console.rb} +39 -19
- data/lib/llm/context/deserializer.rb +2 -1
- data/lib/llm/context.rb +29 -13
- data/lib/llm/cost.rb +13 -0
- data/lib/llm/function/async/reactor.rb +20 -1
- data/lib/llm/function/fork/task.rb +14 -10
- data/lib/llm/function.rb +1 -1
- data/lib/llm/json_adapter.rb +40 -28
- data/lib/llm/message.rb +7 -0
- data/lib/llm/provider.rb +31 -10
- data/lib/llm/providers/alibaba.rb +1 -1
- data/lib/llm/providers/anthropic.rb +1 -1
- data/lib/llm/providers/bedrock/models.rb +2 -2
- data/lib/llm/providers/bedrock.rb +1 -1
- data/lib/llm/providers/deepseek.rb +1 -1
- data/lib/llm/providers/google.rb +1 -1
- data/lib/llm/providers/ollama.rb +1 -1
- data/lib/llm/providers/openai/responses.rb +2 -1
- data/lib/llm/providers/openai.rb +2 -1
- data/lib/llm/providers/openrouter.rb +87 -0
- data/lib/llm/schema/leaf.rb +34 -2
- data/lib/llm/schema.rb +4 -2
- data/lib/llm/sequel/agent.rb +9 -5
- data/lib/llm/skill.rb +7 -1
- data/lib/llm/stream.rb +8 -3
- data/lib/llm/tool/param.rb +5 -1
- data/lib/llm/tool.rb +5 -0
- data/lib/llm/tools/bundle.rb +53 -0
- data/lib/llm/tools/edit-file.rb +7 -2
- data/lib/llm/tools/exec.rb +78 -0
- data/lib/llm/tools/git.rb +27 -26
- data/lib/llm/tools/mkdir.rb +12 -19
- data/lib/llm/tools/read_file.rb +69 -9
- data/lib/llm/tools/rg.rb +20 -24
- data/lib/llm/tools/ruby.rb +17 -25
- data/lib/llm/tools/utils.rb +75 -2
- data/lib/llm/tools/write_file.rb +4 -1
- data/lib/llm/tracer/logger.rb +2 -2
- data/lib/llm/tracer/pretty_logger.rb +4 -4
- data/lib/llm/tracer/telemetry.rb +2 -2
- data/lib/llm/tracer.rb +33 -0
- data/lib/llm/transport/curb.rb +5 -3
- data/lib/llm/transport/http.rb +5 -2
- data/lib/llm/transport/persistent_http.rb +6 -4
- data/lib/llm/transport/utils.rb +8 -6
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +18 -12
- data/llm.gemspec +8 -8
- metadata +80 -37
- data/lib/llm/repl/node.rb +0 -44
- data/lib/llm/tools/shell.rb +0 -55
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class LLM::
|
|
3
|
+
class LLM::Console
|
|
4
4
|
##
|
|
5
|
-
# 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::
|
|
13
|
-
name "
|
|
14
|
-
description "
|
|
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:
|
|
20
|
-
write "
|
|
21
|
+
def call(n:)
|
|
22
|
+
write "keep in progress"
|
|
21
23
|
compactor.call(keep: n)
|
|
22
|
-
write "
|
|
24
|
+
write "keep complete"
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
private
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class LLM::
|
|
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::
|
|
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::
|
|
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::
|
|
9
|
+
# @return [LLM::Console::Input::Char]
|
|
10
10
|
def initialize(char)
|
|
11
11
|
@char = char
|
|
12
12
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class LLM::
|
|
3
|
+
class LLM::Console
|
|
4
4
|
##
|
|
5
|
-
# The {LLM::
|
|
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::
|
|
59
|
-
# @return [LLM::
|
|
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::
|
|
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
|
|
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
|
|
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
|
|
514
|
-
if row.chars.any? and row.to_s
|
|
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::
|
|
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::
|
|
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::
|
|
10
|
-
Color = LLM::
|
|
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
|
|
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
|
|
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::
|
|
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::
|
|
18
|
+
# @return [LLM::Console::Markdown]
|
|
38
19
|
def initialize(text, width)
|
|
39
|
-
|
|
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
|
-
#
|
|
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 :
|
|
101
|
-
emit(
|
|
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::
|
|
3
|
+
class LLM::Console
|
|
4
4
|
##
|
|
5
|
-
# The {LLM::
|
|
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::
|
|
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::
|
|
16
|
-
# @return [LLM::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
59
|
-
# @return [Array<LLM::
|
|
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::
|
|
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::
|
|
3
|
+
class LLM::Console
|
|
4
4
|
##
|
|
5
|
-
# The {LLM::
|
|
6
|
-
# the stream for the {LLM::
|
|
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::
|
|
17
|
-
# @return [LLM::
|
|
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::
|
|
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
|
|
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
|
|
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
|
|
133
|
+
Node.width(str) > max ? "#{Node.slice(str, max)}…" : str
|
|
107
134
|
end
|
|
108
135
|
end
|
|
109
136
|
end
|