llm.rb 12.3.1 → 12.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +395 -0
  3. data/README.md +103 -16
  4. data/data/anthropic.json +249 -249
  5. data/data/bedrock.json +2038 -1879
  6. data/data/deepinfra.json +591 -591
  7. data/data/deepseek.json +31 -31
  8. data/data/google.json +332 -329
  9. data/data/mistral.json +381 -381
  10. data/data/openai.json +1132 -1132
  11. data/data/xai.json +138 -138
  12. data/data/zai.json +165 -165
  13. data/lib/llm/agent.rb +12 -7
  14. data/lib/llm/buffer.rb +15 -0
  15. data/lib/llm/context/deserializer.rb +2 -5
  16. data/lib/llm/context.rb +4 -5
  17. data/lib/llm/function.rb +2 -35
  18. data/lib/llm/message.rb +12 -2
  19. data/lib/llm/provider.rb +11 -1
  20. data/lib/llm/providers/anthropic/stream_parser.rb +3 -12
  21. data/lib/llm/providers/anthropic.rb +11 -0
  22. data/lib/llm/providers/bedrock/stream_parser.rb +4 -13
  23. data/lib/llm/providers/google/stream_parser.rb +3 -12
  24. data/lib/llm/providers/google.rb +7 -0
  25. data/lib/llm/providers/mistral.rb +11 -0
  26. data/lib/llm/providers/ollama/stream_parser.rb +4 -4
  27. data/lib/llm/providers/ollama.rb +11 -0
  28. data/lib/llm/providers/openai/responses/stream_parser.rb +4 -14
  29. data/lib/llm/providers/openai/responses.rb +10 -0
  30. data/lib/llm/providers/openai/stream_parser.rb +5 -15
  31. data/lib/llm/providers/openai.rb +11 -0
  32. data/lib/llm/repl/command.rb +201 -0
  33. data/lib/llm/repl/commands/exit.rb +23 -0
  34. data/lib/llm/repl/commands/help.rb +24 -0
  35. data/lib/llm/repl/input.rb +118 -35
  36. data/lib/llm/repl/stream.rb +43 -8
  37. data/lib/llm/repl/transcript.rb +6 -0
  38. data/lib/llm/repl/window.rb +28 -6
  39. data/lib/llm/repl.rb +125 -27
  40. data/lib/llm/stream.rb +2 -7
  41. data/lib/llm/tools/ls.rb +30 -0
  42. data/lib/llm/tools/which.rb +38 -0
  43. data/lib/llm/version.rb +1 -1
  44. data/resources/deepdive.md +67 -1
  45. metadata +6 -1
data/lib/llm/function.rb CHANGED
@@ -117,7 +117,7 @@ class LLM::Function
117
117
  # @param [Hash, LLM::Object] other
118
118
  # @return [void]
119
119
  def arguments=(other)
120
- @arguments = LLM::Object.from(other)
120
+ @arguments = other.nil? ? nil : LLM::Object.from(other)
121
121
  end
122
122
 
123
123
  ##
@@ -358,18 +358,7 @@ class LLM::Function
358
358
  ##
359
359
  # @return [Hash]
360
360
  def adapt(provider)
361
- case provider.class.to_s
362
- when "LLM::Google"
363
- {name: @name, description: @description, parameters: @params}.compact
364
- when "LLM::Anthropic"
365
- {
366
- name: @name,
367
- description: @description,
368
- input_schema: @params || {type: "object", properties: {}}
369
- }.compact
370
- else
371
- format_openai(provider)
372
- end
361
+ provider.adapt_function(self)
373
362
  end
374
363
 
375
364
  ##
@@ -383,28 +372,6 @@ class LLM::Function
383
372
 
384
373
  private
385
374
 
386
- def format_openai(provider)
387
- case provider.class.to_s
388
- when "LLM::OpenAI::Responses"
389
- {
390
- type: "function", name: @name, description: @description,
391
- parameters: (@params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
392
- }.compact
393
- when "LLM::Mistral"
394
- params = @params || {type: "object", properties: {}}
395
- {
396
- type: "function",
397
- function: {name: @name, description: @description, parameters: params}
398
- }.compact
399
- else
400
- params = @params || {type: "object", properties: {}}
401
- {
402
- type: "function", name: @name,
403
- function: {name: @name, description: @description, parameters: params}
404
- }.compact
405
- end
406
- end
407
-
408
375
  ##
409
376
  # Internal method that calls the function and returns a Return object.
410
377
  # Handles both class-based and proc-based runners, and rescues exceptions.
data/lib/llm/message.rb CHANGED
@@ -120,7 +120,9 @@ module LLM
120
120
  # @return [Array<LLM::Function>]
121
121
  def functions
122
122
  @functions ||= tool_calls.filter_map do |fn|
123
- function = available_tools.find { _1.name.to_s == fn["name"] } || next
123
+ function = available_tools.find { _1.name.to_s == fn.name } ||
124
+ LLM::Function.find_by_name(fn.name) ||
125
+ function_missing(fn.name)
124
126
  function = function.dup
125
127
  function.tap { _1.id = fn.id }
126
128
  function.tap { _1.arguments = fn.arguments }
@@ -222,10 +224,18 @@ module LLM
222
224
  end
223
225
 
224
226
  def available_tools
225
- tools = extra.tools || response&.__tools__ || []
227
+ tools = extra.tools || []
228
+ tools = nil if tools.empty?
229
+ tools = tools || response&.__tools__ || []
226
230
  tools.map { _1.respond_to?(:function) ? _1.function : _1 }
227
231
  end
228
232
 
233
+ def function_missing(name)
234
+ LLM.function(name) do |fn|
235
+ fn.define { raise LLM::NoSuchToolError, "tool not found" }
236
+ end
237
+ end
238
+
229
239
  def content_items
230
240
  Array(content)
231
241
  end
data/lib/llm/provider.rb CHANGED
@@ -26,7 +26,7 @@ class LLM::Provider
26
26
  # Requires the net-http-persistent gem.
27
27
  # @param [LLM::Transport, Class, nil] transport
28
28
  # Optional override with any {LLM::Transport} instance or subclass.
29
- def initialize(key:, host:, port: 443, timeout: 180, ssl: true, base_path: "", persistent: false, transport: nil)
29
+ def initialize(key:, host:, port: 443, timeout: 900, ssl: true, base_path: "", persistent: false, transport: nil)
30
30
  @key = key
31
31
  @host = host
32
32
  @port = port
@@ -349,6 +349,16 @@ class LLM::Provider
349
349
  @key != nil && @key.to_s.strip.size > 0
350
350
  end
351
351
 
352
+ ##
353
+ # Adapt a {LLM::Function} to the provider-specific tool schema.
354
+ # @abstract
355
+ # @param [LLM::Function] fn
356
+ # @raise [NotImplementedError]
357
+ # @return [Hash]
358
+ def adapt_function(fn)
359
+ raise NotImplementedError
360
+ end
361
+
352
362
  private
353
363
 
354
364
  def path(suffix, base_path: true)
@@ -10,15 +10,11 @@ class LLM::Anthropic
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @param [#<<, LLM::Stream] stream
14
- # A stream sink that implements {#<<} or the {LLM::Stream} interface
13
+ # @param [LLM::Stream] stream
15
14
  # @return [LLM::Anthropic::StreamParser]
16
15
  def initialize(stream)
17
16
  @body = {"role" => "assistant", "content" => []}
18
17
  @stream = stream
19
- @can_emit_content = stream.respond_to?(:on_content)
20
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
21
- @can_push_content = stream.respond_to?(:<<)
22
18
  end
23
19
 
24
20
  ##
@@ -91,17 +87,12 @@ class LLM::Anthropic
91
87
  end
92
88
 
93
89
  def emit_content(value)
94
- if @can_emit_content
95
- @stream.on_content(value)
96
- elsif @can_push_content
97
- @stream << value
98
- end
90
+ @stream.on_content(value)
99
91
  end
100
92
 
101
93
  def emit_tool(tool)
102
- return unless @can_emit_tool_call
103
94
  function, error = resolve_tool(tool)
104
- @stream.on_tool_call(function, error)
95
+ error ? @stream.queue << error : @stream.on_tool_call(function)
105
96
  end
106
97
 
107
98
  def resolve_tool(tool)
@@ -81,6 +81,17 @@ module LLM
81
81
  "assistant"
82
82
  end
83
83
 
84
+ ##
85
+ # @param [LLM::Function] fn
86
+ # @return [Hash]
87
+ def adapt_function(fn)
88
+ {
89
+ name: fn.name,
90
+ description: fn.description,
91
+ input_schema: fn.params || {type: "object", properties: {}}
92
+ }.compact
93
+ end
94
+
84
95
  ##
85
96
  # Anthropic expects tool results to be sent as user messages
86
97
  # containing `tool_result` content blocks rather than a distinct
@@ -24,15 +24,11 @@ class LLM::Bedrock
24
24
  attr_reader :body
25
25
 
26
26
  ##
27
- # @param [#<<, LLM::Stream] stream
27
+ # @param [LLM::Stream] stream
28
28
  def initialize(stream)
29
29
  @body = {"output" => {"message" => {"role" => "assistant", "content" => []}}}
30
30
  @stream = stream
31
31
  @text_markers = {}
32
- @can_emit_content = stream.respond_to?(:on_content)
33
- @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
34
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
35
- @can_push_content = stream.respond_to?(:<<)
36
32
  end
37
33
 
38
34
  ##
@@ -165,21 +161,16 @@ class LLM::Bedrock
165
161
  end
166
162
 
167
163
  def emit_content(value)
168
- if @can_emit_content
169
- @stream.on_content(value)
170
- elsif @can_push_content
171
- @stream << value
172
- end
164
+ @stream.on_content(value)
173
165
  end
174
166
 
175
167
  def emit_reasoning_content(value)
176
- @stream.on_reasoning_content(value) if @can_emit_reasoning_content
168
+ @stream.on_reasoning_content(value)
177
169
  end
178
170
 
179
171
  def emit_tool(tool)
180
- return unless @can_emit_tool_call
181
172
  function, error = resolve_tool(tool)
182
- @stream.on_tool_call(function, error)
173
+ error ? @stream.queue << error : @stream.on_tool_call(function)
183
174
  end
184
175
 
185
176
  def resolve_tool(tool)
@@ -10,16 +10,12 @@ class LLM::Google
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @param [#<<, LLM::Stream] stream
14
- # A stream sink that implements {#<<} or the {LLM::Stream} interface
13
+ # @param [LLM::Stream] stream
15
14
  # @return [LLM::Google::StreamParser]
16
15
  def initialize(stream)
17
16
  @body = {"candidates" => []}
18
17
  @stream = stream
19
18
  @emits = {tools: []}
20
- @can_emit_content = stream.respond_to?(:on_content)
21
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
22
- @can_push_content = stream.respond_to?(:<<)
23
19
  end
24
20
 
25
21
  ##
@@ -129,21 +125,16 @@ class LLM::Google
129
125
  end
130
126
 
131
127
  def emit_content(value)
132
- if @can_emit_content
133
- @stream.on_content(value)
134
- elsif @can_push_content
135
- @stream << value
136
- end
128
+ @stream.on_content(value)
137
129
  end
138
130
 
139
131
  def emit_tool(pindex, cindex, part)
140
- return unless @can_emit_tool_call
141
132
  return unless complete_tool?(part)
142
133
  key = [cindex, pindex]
143
134
  return if @emits[:tools].include?(key)
144
135
  function, error = resolve_tool(part, cindex, pindex)
145
136
  @emits[:tools] << key
146
- @stream.on_tool_call(function, error)
137
+ error ? @stream.queue << error : @stream.on_tool_call(function)
147
138
  end
148
139
 
149
140
  def complete_tool?(part)
@@ -175,6 +175,13 @@ module LLM
175
175
  "model"
176
176
  end
177
177
 
178
+ ##
179
+ # @param [LLM::Function] fn
180
+ # @return [Hash]
181
+ def adapt_function(fn)
182
+ {name: fn.name, description: fn.description, parameters: fn.params}.compact
183
+ end
184
+
178
185
  private
179
186
 
180
187
  def headers
@@ -119,6 +119,17 @@ module LLM
119
119
  "mistral-large-latest"
120
120
  end
121
121
 
122
+ ##
123
+ # @param [LLM::Function] fn
124
+ # @return [Hash]
125
+ def adapt_function(fn)
126
+ params = fn.params || {type: "object", properties: {}}
127
+ {
128
+ type: "function",
129
+ function: {name: fn.name, description: fn.description, parameters: params}
130
+ }.compact
131
+ end
132
+
122
133
  private
123
134
 
124
135
  ##
@@ -10,11 +10,11 @@ class LLM::Ollama
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @return [LLM::OpenAI::Chunk]
13
+ # @param [LLM::Stream] stream
14
+ # @return [LLM::Ollama::StreamParser]
14
15
  def initialize(stream)
15
16
  @body = {}
16
17
  @stream = stream
17
- @can_push_content = stream.respond_to?(:<<)
18
18
  end
19
19
 
20
20
  ##
@@ -37,10 +37,10 @@ class LLM::Ollama
37
37
  if key == "message"
38
38
  if @body[key]
39
39
  @body[key]["content"] << value["content"]
40
- @stream << value["content"] if @can_push_content
40
+ @stream.on_content(value["content"])
41
41
  else
42
42
  @body[key] = value
43
- @stream << value["content"] if @can_push_content
43
+ @stream.on_content(value["content"])
44
44
  end
45
45
  else
46
46
  @body[key] = value
@@ -98,6 +98,17 @@ module LLM
98
98
  "qwen3:latest"
99
99
  end
100
100
 
101
+ ##
102
+ # @param [LLM::Function] fn
103
+ # @return [Hash]
104
+ def adapt_function(fn)
105
+ params = fn.params || {type: "object", properties: {}}
106
+ {
107
+ type: "function", name: fn.name,
108
+ function: {name: fn.name, description: fn.description, parameters: params}
109
+ }.compact
110
+ end
111
+
101
112
  private
102
113
 
103
114
  def headers
@@ -12,17 +12,12 @@ class LLM::OpenAI
12
12
  attr_reader :body
13
13
 
14
14
  ##
15
- # @param [#<<, LLM::Stream] stream
16
- # A stream sink that implements {#<<} or the {LLM::Stream} interface
15
+ # @param [LLM::Stream] stream
17
16
  # @return [LLM::OpenAI::Responses::StreamParser]
18
17
  def initialize(stream)
19
18
  @body = {"output" => []}
20
19
  @stream = stream
21
20
  @emits = {tools: {}}
22
- @can_emit_content = stream.respond_to?(:on_content)
23
- @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
24
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
25
- @can_push_content = stream.respond_to?(:<<)
26
21
  @cached_output_index = nil
27
22
  @cached_output_item = nil
28
23
  @cached_content_index = nil
@@ -240,26 +235,21 @@ class LLM::OpenAI
240
235
  # @group Emitters
241
236
 
242
237
  def emit_content(value)
243
- if @can_emit_content
244
- @stream.on_content(value)
245
- elsif @can_push_content
246
- @stream << value
247
- end
238
+ @stream.on_content(value)
248
239
  end
249
240
 
250
241
  def emit_reasoning_content(value)
251
- @stream.on_reasoning_content(value) if @can_emit_reasoning_content
242
+ @stream.on_reasoning_content(value)
252
243
  end
253
244
 
254
245
  def emit_tool(index, tool)
255
- return unless @can_emit_tool_call
256
246
  return if @emits[:tools][index]
257
247
  return unless tool["call_id"] && tool["name"]
258
248
  arguments = parse_arguments(tool["arguments"])
259
249
  return unless arguments
260
250
  function, error = resolve_tool(tool, arguments)
261
251
  @emits[:tools][index] = true
262
- @stream.on_tool_call(function, error)
252
+ error ? @stream.queue << error : @stream.on_tool_call(function)
263
253
  end
264
254
 
265
255
  ##
@@ -86,6 +86,16 @@ class LLM::OpenAI
86
86
  res
87
87
  end
88
88
 
89
+ ##
90
+ # @param [LLM::Function] fn
91
+ # @return [Hash]
92
+ def adapt_function(fn)
93
+ {
94
+ type: "function", name: fn.name, description: fn.description,
95
+ parameters: (fn.params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
96
+ }.compact
97
+ end
98
+
89
99
  private
90
100
 
91
101
  [:path, :headers, :execute, :transport, :resolve_tools].each do |m|
@@ -12,15 +12,12 @@ class LLM::OpenAI
12
12
  attr_reader :body
13
13
 
14
14
  ##
15
- # @return [LLM::OpenAI::Chunk]
15
+ # @param [LLM::Stream] stream
16
+ # @return [LLM::OpenAI::StreamParser]
16
17
  def initialize(stream)
17
18
  @body = {}
18
19
  @stream = stream
19
20
  @emits = {tools: {}}
20
- @can_emit_content = stream.respond_to?(:on_content)
21
- @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
22
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
23
- @can_push_content = stream.respond_to?(:<<)
24
21
  end
25
22
 
26
23
  ##
@@ -157,21 +154,14 @@ class LLM::OpenAI
157
154
  end
158
155
 
159
156
  def emit_content(value)
160
- if @can_emit_content
161
- @stream.on_content(value)
162
- elsif @can_push_content
163
- @stream << value
164
- end
157
+ @stream.on_content(value)
165
158
  end
166
159
 
167
160
  def emit_reasoning_content(value)
168
- if @can_emit_reasoning_content
169
- @stream.on_reasoning_content(value)
170
- end
161
+ @stream.on_reasoning_content(value)
171
162
  end
172
163
 
173
164
  def emit_tool(tool, tindex)
174
- return unless @can_emit_tool_call
175
165
  return if @emits[:tools][tindex]
176
166
  function = tool["function"]
177
167
  return unless function && tool["id"] && function["name"]
@@ -180,7 +170,7 @@ class LLM::OpenAI
180
170
  return unless arguments
181
171
  function, error = resolve_tool(tool, function, arguments)
182
172
  @emits[:tools][tindex] = true
183
- @stream.on_tool_call(function, error)
173
+ error ? @stream.queue << error : @stream.on_tool_call(function)
184
174
  end
185
175
 
186
176
  def resolve_tool(tool, function, arguments)
@@ -152,6 +152,17 @@ module LLM
152
152
  "gpt-5.4-mini"
153
153
  end
154
154
 
155
+ ##
156
+ # @param [LLM::Function] fn
157
+ # @return [Hash]
158
+ def adapt_function(fn)
159
+ params = fn.params || {type: "object", properties: {}}
160
+ {
161
+ type: "function", name: fn.name,
162
+ function: {name: fn.name, description: fn.description, parameters: params}
163
+ }.compact
164
+ end
165
+
155
166
  ##
156
167
  # @note
157
168
  # This method includes certain tools that require configuration
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Command LLM::Repl::Command} class is the superclass
6
+ # of all read-eval-print loop commands. A command has a name, and a
7
+ # description. This basic version does not implement parameters. A
8
+ # command is accessible via the `/` prefix: eg `/exit`.
9
+ class Command
10
+ UNDEFINED = Object.new
11
+ SINGLETON = self
12
+ private_constant :UNDEFINED, :SINGLETON
13
+
14
+ ##
15
+ # @api private
16
+ Parameter = Struct.new(:name, :type, :description, :options, :index, :value) do
17
+ ##
18
+ # @return [Boolean]
19
+ def required?
20
+ options[:required] == true
21
+ end
22
+
23
+ ##
24
+ # Mark the parameter as required
25
+ # @return [void]
26
+ def required!
27
+ options[:required] = true
28
+ end
29
+
30
+ ##
31
+ # @return [Boolean]
32
+ def optional?
33
+ !required?
34
+ end
35
+
36
+ ##
37
+ # Assign a parameter value - with type checks
38
+ # @param [String] other
39
+ # @return [void]
40
+ def value=(other)
41
+ raise TypeError, "#{other.class} is not a #{type}" unless type === other
42
+ self[:value] = other
43
+ end
44
+ end
45
+
46
+ ##
47
+ # Find a command by a name, or by an input string.
48
+ # @example find by name
49
+ # LLM::Repl::Command.find_by(name: "exit")
50
+ # @example find by input string
51
+ # LLM::Repl::Command.find_by(input: "/exit")
52
+ # @note
53
+ # The input string must be prefixed with "/"
54
+ # or it won't be matched. The match is made
55
+ # against the string before the first space -
56
+ # so "/exit foo" will match the "exit" command
57
+ # but "/exitnow" will not.
58
+ # @param [String] input
59
+ # @param [String] name
60
+ # @return [LLM::Repl::Command, nil]
61
+ def self.find_by(input: UNDEFINED, name: UNDEFINED)
62
+ if input != UNDEFINED
63
+ return nil unless input[0] == "/"
64
+ n, = input.split(" ")
65
+ registry.find { n[1..] == _1.name }
66
+ elsif name != UNDEFINED
67
+ registry.find { name == _1.name }
68
+ else
69
+ raise ArgumentError, "provide either an input or a name"
70
+ end
71
+ end
72
+
73
+ ##
74
+ # @param [LLM::Repl::Command] command
75
+ # A new subclass
76
+ # @return [void]
77
+ def self.inherited(command)
78
+ LLM.lock(:inherited) do
79
+ registry << command
80
+ command.instance_variable_set(:@parameters, {})
81
+ command.define_singleton_method(:inherited) { |command| SINGLETON.inherited(command) }
82
+ end
83
+ end
84
+
85
+ ##
86
+ # @return [Array<LLM::Repl::Command]
87
+ def self.registry
88
+ @registry ||= []
89
+ end
90
+
91
+ ##
92
+ # Set or get a command name.
93
+ # @param [String] name
94
+ # The command name.
95
+ # @return [String]
96
+ def self.name(name = UNDEFINED)
97
+ return @name if name == UNDEFINED
98
+ @name = name
99
+ end
100
+
101
+ ##
102
+ # Set or get a command description.
103
+ # @param [String] description
104
+ # The command description.
105
+ # @return [String]
106
+ def self.description(description = UNDEFINED)
107
+ return @description if description == UNDEFINED
108
+ @description = description
109
+ end
110
+
111
+ ##
112
+ # @param [Symbol] name
113
+ # @param [Class] type
114
+ # @param [String] description
115
+ # @param [Hash] options
116
+ # @return [void]
117
+ def self.parameter(name, type, description, options = {})
118
+ @parameters[name] = Parameter.new(
119
+ name, type,
120
+ description, options,
121
+ @parameters.size, nil
122
+ )
123
+ end
124
+
125
+ ##
126
+ # @return [Hash]
127
+ def self.parameters
128
+ @parameters
129
+ end
130
+
131
+ ##
132
+ # @param [Array<Symbol>] names
133
+ # One or more required names
134
+ # @return [void]
135
+ def self.required(names)
136
+ names.each do |name|
137
+ if @parameters.key?(name)
138
+ @parameters[name].required!
139
+ else
140
+ raise LLM::Error, "'#{name}' is not a known parameter"
141
+ end
142
+ end
143
+ end
144
+
145
+ ##
146
+ # @param [LLM::Repl] repl
147
+ # @return [LLM::Repl::Command]
148
+ def initialize(repl)
149
+ @repl = repl
150
+ end
151
+
152
+ ##
153
+ # Write a string to the transcript
154
+ # @param [String] str
155
+ # @return [void]
156
+ def write(str, who: "command(#{self.class.name}): ")
157
+ @repl.write(who, Curses::A_BOLD)
158
+ @repl.write(str)
159
+ end
160
+
161
+ ##
162
+ # Display a formatted help message for this command.
163
+ # Uses a single {#write} call to output the command name,
164
+ # description, and parameter details.
165
+ # @return [void]
166
+ def self.help
167
+ lines = []
168
+ lines << "Command: #{name}"
169
+ lines << "Description: #{description}"
170
+ unless parameters.empty?
171
+ lines << ""
172
+ lines << "Parameters:"
173
+ parameters.each_value do |param|
174
+ tag = param.required? ? "(required)" : "(optional)"
175
+ lines << " #{param.name} [#{param.type}] - #{param.description} #{tag}"
176
+ end
177
+ end
178
+ lines.join("\n")
179
+ end
180
+
181
+ ##
182
+ # This method should be implemented by subclasses.
183
+ # @raise [NotImplementedError]
184
+ def call(...)
185
+ raise NotImplementedError, "#{self.class}#call is not implemented"
186
+ end
187
+
188
+ ##
189
+ # @return [Hash<Symbol, Parameter>]
190
+ def parameters
191
+ self.class.parameters
192
+ end
193
+
194
+ require_relative "commands/exit"
195
+ require_relative "commands/help"
196
+ end
197
+ end
198
+
199
+ ##
200
+ # Convenience constant
201
+ LLM::Command = LLM::Repl::Command