llm.rb 13.0.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.
data/bin/llm.rb ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "llm"
4
+ require "fileutils"
5
+
6
+ ##
7
+ # utils
8
+
9
+ def keys
10
+ @keys ||= Dir[File.join(__dir__, "..", "lib", "llm", "providers", "*")]
11
+ .select { File.file?(_1) }
12
+ .map { File.basename(_1, ".rb") }
13
+ .sort_by { _1 == "deepseek" ? 0 : 1 }
14
+ .map { "#{_1.upcase}_API_KEY" } - ["BEDROCK_API_KEY"]
15
+ end
16
+
17
+ def help
18
+ prog = File.basename($PROGRAM_NAME)
19
+ warn ""
20
+ warn "Usage: #{prog} [options]"
21
+ warn ""
22
+ warn "Options:"
23
+ warn " -p PROVIDER Choose a provider"
24
+ warn " -t Temporary session that doesn't persist to disk"
25
+ warn " -h Show this help"
26
+ warn ""
27
+ warn "Examples:"
28
+ warn " #{prog} # auto-detect provider from $PROVIDER_API_KEY"
29
+ warn " #{prog} -p openai # use OpenAI"
30
+ warn " #{prog} -h # this help"
31
+ warn ""
32
+ end
33
+
34
+ def loaderror(ex)
35
+ gem, = ex.message.split(" is an optional runtime dependency")
36
+ warn ""
37
+ warn " ── llm.rb ──────────────────────────────────────────────"
38
+ warn ""
39
+ warn " ✖ Missing dependency: #{gem}"
40
+ warn ""
41
+ warn " The repl needs this gem, but it's not installed."
42
+ warn ""
43
+ warn " Fix: gem install #{gem}"
44
+ warn " Or: bundle add #{gem}"
45
+ warn ""
46
+ warn " Tip: If you don't need the repl, you can use the"
47
+ warn " library directly with: require \"llm\""
48
+ warn ""
49
+ warn " ───────────────────────────────────────────────────────"
50
+ warn ""
51
+ end
52
+
53
+ ##
54
+ # main
55
+
56
+ def main(argv)
57
+ ##
58
+ # Make sure the dependencies are satisified first
59
+ begin
60
+ require "llm/tools"
61
+ require "llm/repl"
62
+ rescue LLM::LoadError => ex
63
+ loaderror(ex)
64
+ exit 1
65
+ end
66
+
67
+ ##
68
+ # C-Style option parser
69
+ # No external dep
70
+ while option = argv.shift
71
+ case option
72
+ when '-h'
73
+ help
74
+ exit 0
75
+ when '-t'
76
+ temp = true
77
+ when '-p'
78
+ provider = argv.shift
79
+ else
80
+ warn "llm.rb: unknown option #{option}"
81
+ end
82
+ end
83
+
84
+ ##
85
+ # Setup the home directory
86
+ # But only if the `-t` switch has not been provided
87
+ if temp.nil?
88
+ home = File.join(Dir.home, ".llm.rb")
89
+ FileUtils.mkdir_p(File.join(home, Dir.getwd))
90
+ session = File.join(home, Dir.getwd, "session.json")
91
+ end
92
+
93
+ ##
94
+ # No provider has been given.
95
+ # Try to infer one.
96
+ if provider.nil?
97
+ key = keys.find { ENV[_1] }
98
+ if key.nil?
99
+ warn "llm.rb: provide a provider with the -p switch"
100
+ exit 1
101
+ else
102
+ provider, = key.split("_")
103
+ end
104
+ end
105
+
106
+ ##
107
+ # We're ready to start the REPL
108
+ # This should always succeed unless -p gave garbage
109
+ provider = provider.downcase
110
+ if LLM.respond_to?(provider)
111
+ key ||= "#{provider.upcase}_API_KEY"
112
+ if ENV[key].nil? || ENV[key].to_s.empty?
113
+ warn "llm.rb: set #{key} to use #{provider}"
114
+ exit 1
115
+ end
116
+ llm = LLM.method(provider).call(key: ENV[key])
117
+ agent = LLM::Agent.new(llm, path: temp ? nil : session, tools: LLM::Tool.subclasses)
118
+ agent.repl
119
+ else
120
+ warn "llm.rb: #{provider} was not recognized"
121
+ exit 1
122
+ end
123
+ end
124
+ main(ARGV)
data/lib/llm/agent.rb CHANGED
@@ -11,8 +11,8 @@ module LLM
11
11
  # {LLM::Context LLM::Context}: message history, usage, persistence,
12
12
  # streaming parameters, and provider-backed requests still flow through
13
13
  # an underlying context. The defining behavior of an agent is that it
14
- # automatically resolves pending tool calls for you during `talk` and
15
- # `respond`, instead of leaving tool loops to the caller.
14
+ # automatically resolves pending tool calls for you during `talk`,
15
+ # instead of leaving tool loops to the caller.
16
16
  #
17
17
  # **Notes:**
18
18
  # * Instructions are injected once unless a system message is already present.
@@ -24,7 +24,7 @@ module LLM
24
24
  # advisory tool errors back through the model and keeps the loop in-band.
25
25
  # Set `tool_attempts: nil` to disable that advisory behavior.
26
26
  # * Tool loop execution can be configured with `concurrency :sequential`,
27
- # `:thread`, `:async`, `:fiber`, or `:ractor`.
27
+ # `:thread`, `:async`, `:fiber`, `:fork`, or `:ractor`.
28
28
  #
29
29
  # @example Subclass with defaults
30
30
  # class SystemAdmin < LLM::Agent
@@ -52,6 +52,16 @@ module LLM
52
52
  UNDEFINED = Object.new
53
53
  private_constant :UNDEFINED
54
54
 
55
+ ##
56
+ # @api private
57
+ CASE_PATTERN = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/
58
+ private_constant :CASE_PATTERN
59
+
60
+ ##
61
+ # @api private
62
+ File = ::File
63
+ private_constant :File
64
+
55
65
  ##
56
66
  # Returns a provider
57
67
  # @return [LLM::Provider]
@@ -96,18 +106,44 @@ module LLM
96
106
 
97
107
  ##
98
108
  # Set or get an agent's name
109
+ # @note
110
+ # This method serves as a self-documenting string
111
+ # and it is used by {LLM::Repl LLM::Repl}. It is
112
+ # optional but recommended.
99
113
  # @param [String] name
100
114
  # The agent name
101
115
  # @return [String]
102
116
  # Return's the agents name
103
117
  def self.name(name = UNDEFINED, &block)
104
118
  if name.equal?(UNDEFINED)
105
- @name || self.to_s.gsub(/(.)([A-Z])/, '\\1-\\2').downcase
119
+ if @name.nil?
120
+ name = to_s.split("::").last
121
+ @name = name.gsub(CASE_PATTERN, "-").downcase
122
+ else
123
+ @name
124
+ end
106
125
  else
107
126
  @name = block || name
108
127
  end
109
128
  end
110
129
 
130
+ ##
131
+ # Set or get an agent's description
132
+ # @note
133
+ # This method serves as a self-documenting string.
134
+ # It is optional but recommended.
135
+ # @param [String] desc
136
+ # The agent's description
137
+ # @return [String, nil]
138
+ # Returns the agent's description
139
+ def self.description(desc = UNDEFINED, &block)
140
+ if desc.equal?(UNDEFINED)
141
+ @desc
142
+ else
143
+ @desc = block || desc
144
+ end
145
+ end
146
+
111
147
  ##
112
148
  # Set or get the default model
113
149
  # @param [String, nil] model
@@ -261,6 +297,20 @@ module LLM
261
297
  end
262
298
  end
263
299
 
300
+ ##
301
+ # Set the file path where an agent's memory
302
+ # can be restored from, and written to.
303
+ # @param [String] path
304
+ # The path to a file
305
+ # @return [String, nil]
306
+ def self.path(path = UNDEFINED, &block)
307
+ if path.equal?(UNDEFINED)
308
+ @path
309
+ else
310
+ @path = path || block
311
+ end
312
+ end
313
+
264
314
  ##
265
315
  # @param [LLM::Provider] llm
266
316
  # A provider
@@ -277,8 +327,8 @@ module LLM
277
327
  # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
278
328
  def initialize(llm, params = {})
279
329
  @llm = llm
280
- fields = %i[name model skills schema tracer stream tools concurrency instructions confirm]
281
- fields_ivar = %i[name tracer concurrency instructions confirm]
330
+ fields = %i[name description path model skills schema tracer stream tools concurrency instructions confirm]
331
+ fields_ivar = %i[name description path tracer concurrency instructions confirm]
282
332
  fields.each do |field|
283
333
  resolvable = params.key?(field) ? params.delete(field) : self.class.public_send(field)
284
334
  resolve_symbol = !%i[concurrency].include?(field)
@@ -293,6 +343,7 @@ module LLM
293
343
  end
294
344
  end
295
345
  @ctx = LLM::Context.new(llm, {guard: true}.merge(params))
346
+ @path and File.readable?(@path) ? @ctx.restore(path:) : nil
296
347
  end
297
348
 
298
349
  ##
@@ -302,6 +353,21 @@ module LLM
302
353
  @name
303
354
  end
304
355
 
356
+ ##
357
+ # Returns a file path where an agent's memory is
358
+ # restored from, and written to after each turn.
359
+ # @return [String, nil]
360
+ def path
361
+ @path
362
+ end
363
+
364
+ ##
365
+ # Returns the agent's description
366
+ # @return [String, nil]
367
+ def description
368
+ @description
369
+ end
370
+
305
371
  ##
306
372
  # Maintain a conversation via the chat completions API.
307
373
  # This method immediately sends a request to the LLM and returns the response.
@@ -319,13 +385,17 @@ module LLM
319
385
  # response = agent.talk("Hello, what is your name?")
320
386
  # puts response.choices[0].content
321
387
  def talk(prompt, params = {})
322
- run_loop(prompt, params, :talk)
388
+ res = run_loop(prompt, params, :talk)
389
+ path ? @ctx.save(path:) : nil
390
+ res
323
391
  end
324
392
 
325
393
  ##
326
394
  # @see LLM::Context#ask
327
395
  def ask(prompt, params = {})
328
- run_loop(prompt, params, :ask)
396
+ res = run_loop(prompt, params, :ask)
397
+ path ? @ctx.save(path:) : nil
398
+ res
329
399
  end
330
400
 
331
401
  ##
@@ -3,7 +3,7 @@
3
3
  class LLM::Function
4
4
  ##
5
5
  # The {LLM::Function::Array} module extends the array
6
- # returned by {LLM::Context#functions} with methods
6
+ # returned by {LLM::Context#pending_functions} with methods
7
7
  # that can call all pending functions sequentially or
8
8
  # concurrently. The return values can be reported back
9
9
  # to the LLM on the next turn.
@@ -57,9 +57,9 @@ class LLM::Function
57
57
  #
58
58
  # @param [Symbol] strategy
59
59
  # Controls concurrency strategy:
60
- # - `:call`: Call each function sequentially through a call group
60
+ # - `:sequential`: Call functions sequentially without spawning
61
61
  # - `:thread`: Use threads
62
- # - `:task`: Use async tasks (requires async gem)
62
+ # - `:async`: Use async tasks (requires async gem)
63
63
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
64
64
  # - `:fork`: Use forked child processes
65
65
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
data/lib/llm/function.rb CHANGED
@@ -204,7 +204,7 @@ class LLM::Function
204
204
  @params = params
205
205
  end
206
206
  else
207
- @params
207
+ @params || LLM::Schema::Object.new({})
208
208
  end
209
209
  end
210
210
 
@@ -227,7 +227,6 @@ class LLM::Function
227
227
  @called = true
228
228
  end
229
229
 
230
-
231
230
  ##
232
231
  # Returns a function as a {LLM::Function::Task LLM::Function::Task}.
233
232
  #
@@ -88,7 +88,7 @@ module LLM
88
88
  {
89
89
  name: fn.name,
90
90
  description: fn.description,
91
- input_schema: fn.params || {type: "object", properties: {}}
91
+ input_schema: fn.params.to_h
92
92
  }.compact
93
93
  end
94
94
 
@@ -74,7 +74,7 @@ class LLM::Bedrock
74
74
  name: function.name,
75
75
  description: function.description,
76
76
  inputSchema: {
77
- json: function.params || default_input_schema
77
+ json: function.params.to_h
78
78
  }
79
79
  }
80
80
  }
@@ -123,7 +123,7 @@ module LLM
123
123
  # @param [LLM::Function] fn
124
124
  # @return [Hash]
125
125
  def adapt_function(fn)
126
- params = fn.params || {type: "object", properties: {}}
126
+ params = fn.params.to_h
127
127
  {
128
128
  type: "function",
129
129
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -102,7 +102,7 @@ module LLM
102
102
  # @param [LLM::Function] fn
103
103
  # @return [Hash]
104
104
  def adapt_function(fn)
105
- params = fn.params || {type: "object", properties: {}}
105
+ params = fn.params.to_h
106
106
  {
107
107
  type: "function", name: fn.name,
108
108
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -92,7 +92,7 @@ class LLM::OpenAI
92
92
  def adapt_function(fn)
93
93
  {
94
94
  type: "function", name: fn.name, description: fn.description,
95
- parameters: (fn.params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
95
+ parameters: fn.params.to_h.merge(additionalProperties: false), strict: false
96
96
  }.compact
97
97
  end
98
98
 
@@ -156,7 +156,7 @@ module LLM
156
156
  # @param [LLM::Function] fn
157
157
  # @return [Hash]
158
158
  def adapt_function(fn)
159
- params = fn.params || {type: "object", properties: {}}
159
+ params = fn.params.to_h
160
160
  {
161
161
  type: "function", name: fn.name,
162
162
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -14,12 +14,13 @@ class LLM::Repl
14
14
  # It also maintains a cursor that tracks the active row
15
15
  # by its index number. The streaming path reuses a single
16
16
  # row by overwriting its contents repeatedly.
17
- class Transcript
18
- WIDTH = 80
19
-
17
+ class Buffer
20
18
  ##
21
- # @return [LLM::Repl::Transcript]
22
- def initialize
19
+ # @param [LLM::Repl] repl
20
+ # An instance of {LLM::Repl LLM::Repl}.
21
+ # @return [LLM::Repl::Buffer]
22
+ def initialize(repl)
23
+ @repl = repl
23
24
  @rows = [[]]
24
25
  @cursor = nil
25
26
  @snapshot = nil
@@ -27,37 +28,45 @@ class LLM::Repl
27
28
  end
28
29
 
29
30
  ##
30
- # @param [String] chars
31
+ # @param [String, Array] chars
31
32
  # @param [Object] attrs
32
33
  # @param [Symbol] method
33
34
  # @return [void]
34
35
  def write(chars, attrs = nil, method: :append)
35
- chunks = [{text: chars.to_s, attrs:}.compact]
36
+ case chars
37
+ when Array then chunks = chars
38
+ else chunks = [Node.new(chars.to_s, attrs)]
39
+ end
36
40
  self.method(method).call(chunks)
37
41
  end
38
42
 
39
43
  ##
40
- # Appends Markdown to the transcript.
41
- # @param [String] chars
44
+ # @param [String] user
45
+ # @param [String, Array] content
42
46
  # @param [Symbol] method
43
47
  # @return [void]
44
- def markdown(chars, method: :append)
45
- chunks = LLM::Repl::Markdown.new(chars, WIDTH).ast
46
- self.method(method).call(chunks)
48
+ def write_message(user, content, method: :append)
49
+ chunks = [Node.new("#{user}: ", Curses::A_BOLD)]
50
+ case content
51
+ when Array then chunks.concat(content)
52
+ else chunks.push(Node.new(content))
53
+ end
54
+ chunks.push(Node.new("\n"))
55
+ write(chunks, method:)
47
56
  end
48
57
 
49
58
  ##
50
- # Start the transcript.
59
+ # Open the buffer.
51
60
  # @return [void]
52
- def start
61
+ def open
53
62
  @cursor = @rows.size - 1
54
63
  @snapshot = @rows.map(&:dup)
55
64
  end
56
65
 
57
66
  ##
58
- # Finish the transcript.
67
+ # Close the buffer.
59
68
  # @return [void]
60
- def finish
69
+ def close
61
70
  @cursor = nil
62
71
  @snapshot = nil
63
72
  end
@@ -93,9 +102,13 @@ class LLM::Repl
93
102
 
94
103
  private
95
104
 
105
+ ##
106
+ # @return [LLM::Repl]
107
+ attr_reader :repl
108
+
96
109
  ##
97
110
  # Appends a new row
98
- # @param [Array<{text: String, attrs?: Integer}>] chunks
111
+ # @param [Array<Node>] chunks
99
112
  # One or more chunks.
100
113
  # @return [void]
101
114
  def append(chunks)
@@ -104,12 +117,12 @@ class LLM::Repl
104
117
 
105
118
  ##
106
119
  # Replaces the content of the active row
107
- # @param [Array<{text: String, attrs?: Integer}>] chunks
120
+ # @param [Array<Node>] chunks
108
121
  # One or more chunks.
109
122
  # @return [void]
110
123
  def replace(chunks)
111
124
  @rows = @snapshot.map(&:dup)
112
- append(chunks)
125
+ chunks.each { wrap(_1, @rows) }
113
126
  end
114
127
 
115
128
  ##
@@ -123,10 +136,10 @@ class LLM::Repl
123
136
  chunk[:text].to_s.each_char do |char|
124
137
  if char == "\n"
125
138
  rows << []
126
- elsif char == " " and sum(rows.last) >= WIDTH
139
+ elsif char == " " and sum(rows.last) >= repl.width
127
140
  rows << []
128
141
  else
129
- rows.last << {text: char, attrs:}.compact
142
+ rows.last << Node.new(char, attrs)
130
143
  end
131
144
  end
132
145
  end
@@ -176,12 +176,19 @@ class LLM::Repl
176
176
  end
177
177
 
178
178
  ##
179
- # Write a string to the transcript
180
- # @param [String] str
179
+ # Write a string to the buffer
180
+ # @param [String] content
181
+ # @return [void]
182
+ def write(content)
183
+ write_message "command(#{self.class.name})", content
184
+ end
185
+
186
+ ##
187
+ # @param [String] user
188
+ # @param [String] content
181
189
  # @return [void]
182
- def write(str, who: "command(#{self.class.name}): ")
183
- @repl.write(who, Curses::A_BOLD)
184
- @repl.write(str)
190
+ def write_message(user, content)
191
+ @repl.write_message(user, content)
185
192
  end
186
193
 
187
194
  ##
@@ -17,9 +17,9 @@ class LLM::Repl
17
17
  ##
18
18
  # @return [void]
19
19
  def call(n: 128)
20
- write("compact in progress\n")
20
+ write "compact in progress"
21
21
  compactor.call(keep: n)
22
- write("compact complete\n\n")
22
+ write "compact complete"
23
23
  end
24
24
 
25
25
  private
@@ -11,13 +11,11 @@ class LLM::Repl
11
11
  # @return [void]
12
12
  def call(name: nil)
13
13
  if name.nil?
14
- write("\n#{self.class.help}\n\n")
14
+ write(self.class.help)
15
15
  elsif command = LLM::Command.find_by(name:)
16
- write("\n#{command.help}\n\n")
16
+ write(command.help)
17
17
  else
18
- write "\nNo help for #{name} was found" \
19
- "\nThat command doesn't exist." \
20
- "\n\n"
18
+ write "no help for #{name} was found"
21
19
  end
22
20
  end
23
21
  end
@@ -4,6 +4,11 @@ class LLM::Repl::Markdown
4
4
  ##
5
5
  # Renders Kramdown `:table` nodes as aligned columns.
6
6
  module Table
7
+ ##
8
+ # @api private
9
+ Node = LLM::Repl::Node
10
+ private_constant :Node
11
+
7
12
  ##
8
13
  # Renders a table node by collecting all cells first to
9
14
  # compute column widths, then emitting each row with
@@ -15,7 +20,6 @@ class LLM::Repl::Markdown
15
20
  rows.each do |row|
16
21
  emit("| ", attrs)
17
22
  row.each_with_index do |chunks, i|
18
- text = chunks.map { _1[:text] }.join
19
23
  width = widths[i]
20
24
  chunks.each { |c| emit(c[:text].ljust(width), c[:attrs]) }
21
25
  emit(" | ", attrs) unless i == row.size - 1
@@ -51,13 +55,13 @@ class LLM::Repl::Markdown
51
55
  def walk_collect(node, attrs, chunks)
52
56
  case node.type
53
57
  when :text
54
- chunks << {text: node.value.to_s, attrs:}
58
+ chunks << Node.new(node.value.to_s, attrs)
55
59
  when :strong
56
60
  node.children.each { walk_collect(_1, Curses::A_BOLD, chunks) }
57
61
  when :em
58
62
  node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
59
63
  when :codespan
60
- chunks << {text: node.value, attrs: Curses::A_REVERSE}
64
+ chunks << Node.new(node.value, Curses::A_REVERSE)
61
65
  when :a
62
66
  node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
63
67
  else
@@ -22,7 +22,7 @@ class LLM::Repl
22
22
  end
23
23
 
24
24
  ##
25
- # @return [Array<Hash>]
25
+ # @return [Array<Node>]
26
26
  def ast
27
27
  @ast.tap do
28
28
  ##
@@ -102,14 +102,14 @@ class LLM::Repl
102
102
  when :a
103
103
  node.children.each { walk(_1, Curses::A_UNDERLINE) }
104
104
  when :img
105
- emit("[image: #{node.attr['alt']}]", attrs)
105
+ emit("[image: #{node.attr["alt"]}]", attrs)
106
106
  else
107
107
  node.children.each { walk(_1, attrs) }
108
108
  end
109
109
  end
110
110
 
111
111
  def emit(text, attrs)
112
- @ast.push({text: text.to_s, attrs:}.compact)
112
+ @ast.push(Node.new(text.to_s, attrs))
113
113
  end
114
114
  end
115
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 [LLM::Agent] agent
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