llm.rb 12.6.0 → 13.0.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 +387 -0
- data/LICENSE +21 -93
- data/README.md +46 -155
- data/data/deepinfra.json +3 -0
- data/data/xai.json +1 -1
- data/lib/llm/a2a.rb +1 -1
- data/lib/llm/active_record/acts_as_llm.rb +6 -6
- data/lib/llm/agent.rb +62 -23
- data/lib/llm/buffer.rb +85 -3
- data/lib/llm/compactor/null.rb +19 -0
- data/lib/llm/compactor/truncate.rb +80 -0
- data/lib/llm/compactor.rb +42 -124
- data/lib/llm/context.rb +31 -37
- data/lib/llm/contract.rb +4 -25
- data/lib/llm/function/array.rb +15 -14
- data/lib/llm/function/async/group.rb +54 -0
- data/lib/llm/function/async/reactor.rb +48 -0
- data/lib/llm/function/async/task.rb +83 -0
- data/lib/llm/function/fiber/group.rb +46 -0
- data/lib/llm/function/fiber/task.rb +62 -0
- data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
- data/lib/llm/function/fork/job.rb +2 -2
- data/lib/llm/function/fork/task.rb +19 -10
- data/lib/llm/function/group.rb +40 -0
- data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
- data/lib/llm/function/ractor/job.rb +9 -3
- data/lib/llm/function/ractor/mailbox.rb +2 -0
- data/lib/llm/function/ractor/task.rb +23 -15
- data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
- data/lib/llm/function/sequential/task.rb +49 -0
- data/lib/llm/function/task.rb +25 -48
- data/lib/llm/function/thread/group.rb +46 -0
- data/lib/llm/function/thread/task.rb +60 -0
- data/lib/llm/function.rb +54 -64
- data/lib/llm/loop_guard.rb +1 -2
- data/lib/llm/mcp.rb +22 -0
- data/lib/llm/object.rb +2 -1
- data/lib/llm/provider.rb +6 -3
- data/lib/llm/providers/google.rb +2 -2
- data/lib/llm/repl/command.rb +35 -8
- data/lib/llm/repl/commands/compact.rb +33 -0
- data/lib/llm/repl/input.rb +80 -15
- data/lib/llm/repl/markdown/table.rb +76 -0
- data/lib/llm/repl/markdown.rb +31 -1
- data/lib/llm/repl/status.rb +1 -1
- data/lib/llm/repl/stream.rb +10 -3
- data/lib/llm/repl/transcript.rb +1 -1
- data/lib/llm/repl/walker.rb +46 -0
- data/lib/llm/repl.rb +18 -12
- data/lib/llm/response.rb +10 -0
- data/lib/llm/schema/leaf.rb +5 -0
- data/lib/llm/schema/object.rb +11 -5
- data/lib/llm/sequel/plugin.rb +6 -6
- data/lib/llm/stream.rb +24 -17
- data/lib/llm/tool.rb +20 -4
- data/lib/llm/tools/chdir.rb +0 -2
- data/lib/llm/tools/git.rb +8 -4
- data/lib/llm/tools/mkdir.rb +1 -1
- data/lib/llm/tools/pwd.rb +0 -2
- data/lib/llm/tools/read_file.rb +0 -2
- data/lib/llm/tools/rg.rb +8 -4
- data/lib/llm/tools/shell.rb +8 -4
- data/lib/llm/tools/utils.rb +31 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +25 -5
- data/llm.gemspec +3 -3
- data/resources/deepdive.md +645 -58
- metadata +24 -13
- data/lib/llm/function/call_task.rb +0 -46
- data/lib/llm/function/fiber_group.rb +0 -105
- data/lib/llm/function/task_group.rb +0 -97
- data/lib/llm/function/thread_group.rb +0 -102
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM::Function::Thread
|
|
4
|
+
##
|
|
5
|
+
# Wraps an array of {Thread::Task} objects for concurrent
|
|
6
|
+
# thread-based execution. Interrupts all tasks and waits
|
|
7
|
+
# for them to complete.
|
|
8
|
+
class Group < LLM::Function::Group
|
|
9
|
+
##
|
|
10
|
+
# @param [Array<LLM::Function::Thread::Task>] tasks
|
|
11
|
+
def initialize(tasks)
|
|
12
|
+
@tasks = tasks
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @return [nil]
|
|
17
|
+
def spawn
|
|
18
|
+
@tasks.each(&:spawn)
|
|
19
|
+
nil
|
|
20
|
+
ensure
|
|
21
|
+
@spawned = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def alive?
|
|
27
|
+
@tasks.any?(&:alive?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @return [nil]
|
|
32
|
+
def interrupt!
|
|
33
|
+
@tasks.each(&:interrupt!)
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
alias_method :cancel!, :interrupt!
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @return [Array<LLM::Function::Return>]
|
|
40
|
+
def wait
|
|
41
|
+
spawn unless @spawned
|
|
42
|
+
@tasks.map(&:wait)
|
|
43
|
+
end
|
|
44
|
+
alias_method :value, :wait
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM::Function::Thread
|
|
4
|
+
##
|
|
5
|
+
# {LLM::Function::Thread::Task LLM::Function::Thread::Task}
|
|
6
|
+
# wraps a function call in a background thread for concurrent
|
|
7
|
+
# tool execution. The thread is created lazily when {#wait} is
|
|
8
|
+
# called, not when the task is constructed — so you can build
|
|
9
|
+
# a task, pass it around, and decide when to run it.
|
|
10
|
+
#
|
|
11
|
+
# Interrupting a running task raises {LLM::Interrupt} inside
|
|
12
|
+
# the thread, which stops the tool call mid-flight. The thread
|
|
13
|
+
# is created with `report_on_exception` disabled so unhandled
|
|
14
|
+
# exceptions propagate through {#wait} instead of to stderr.
|
|
15
|
+
class Task < LLM::Function::Task
|
|
16
|
+
##
|
|
17
|
+
# @param [LLM::Function] fn
|
|
18
|
+
# @param [Hash] options
|
|
19
|
+
def initialize(fn, options = {})
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @return [nil]
|
|
25
|
+
def spawn
|
|
26
|
+
@thread = ::Thread.new { function.call }
|
|
27
|
+
@thread.report_on_exception = false
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def alive?
|
|
34
|
+
@thread&.alive? || false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [nil]
|
|
39
|
+
def interrupt!
|
|
40
|
+
@thread&.raise(LLM::Interrupt) if @thread&.alive?
|
|
41
|
+
function.interrupt!
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
alias_method :cancel!, :interrupt!
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# @return [LLM::Function::Return]
|
|
48
|
+
def wait
|
|
49
|
+
spawn unless @thread
|
|
50
|
+
@thread.value
|
|
51
|
+
end
|
|
52
|
+
alias_method :value, :wait
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# @return [Class]
|
|
56
|
+
def group_class
|
|
57
|
+
LLM::Function::Thread::Group
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/llm/function.rb
CHANGED
|
@@ -2,29 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
##
|
|
4
4
|
# The {LLM::Function LLM::Function} class represents a local
|
|
5
|
-
# function that can be called by an LLM.
|
|
5
|
+
# function that can be called by an LLM. Most users should define
|
|
6
|
+
# tools as subclasses of {LLM::Tool} instead — Function is the
|
|
7
|
+
# lower-level building block that Tool wraps.
|
|
6
8
|
#
|
|
7
|
-
# @example
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# {
|
|
9
|
+
# @example Tool subclass (preferred for most users)
|
|
10
|
+
# class ReadFile < LLM::Tool
|
|
11
|
+
# name "read-file"
|
|
12
|
+
# description "Read a file from disk"
|
|
13
|
+
# parameter :path, String, "The filename or path"
|
|
14
|
+
# required %i[path]
|
|
15
|
+
#
|
|
16
|
+
# def call(path:)
|
|
17
|
+
# {contents: File.read(path)}
|
|
16
18
|
# end
|
|
17
19
|
# end
|
|
18
20
|
#
|
|
19
|
-
# @example
|
|
20
|
-
#
|
|
21
|
-
# name "
|
|
22
|
-
# description "Runs
|
|
23
|
-
# params do |schema|
|
|
21
|
+
# @example Inline function (block-form DSL)
|
|
22
|
+
# LLM.function(:run_command) do |fn|
|
|
23
|
+
# fn.name "run-command"
|
|
24
|
+
# fn.description "Runs a shell command"
|
|
25
|
+
# fn.params do |schema|
|
|
24
26
|
# schema.object(command: schema.string.required)
|
|
25
27
|
# end
|
|
26
|
-
#
|
|
27
|
-
# def call(command:)
|
|
28
|
+
# fn.define do |command:|
|
|
28
29
|
# {success: Kernel.system(command)}
|
|
29
30
|
# end
|
|
30
31
|
# end
|
|
@@ -32,16 +33,21 @@ class LLM::Function
|
|
|
32
33
|
require_relative "function/registry"
|
|
33
34
|
require_relative "function/tracing"
|
|
34
35
|
require_relative "function/array"
|
|
35
|
-
require_relative "function/
|
|
36
|
-
require_relative "function/
|
|
36
|
+
require_relative "function/group"
|
|
37
|
+
require_relative "function/sequential/group"
|
|
37
38
|
require_relative "function/task"
|
|
38
|
-
require_relative "function/
|
|
39
|
-
require_relative "function/
|
|
40
|
-
require_relative "function/
|
|
39
|
+
require_relative "function/sequential/task"
|
|
40
|
+
require_relative "function/thread/task"
|
|
41
|
+
require_relative "function/fiber/task"
|
|
42
|
+
require_relative "function/async/reactor"
|
|
43
|
+
require_relative "function/async/task"
|
|
44
|
+
require_relative "function/thread/group"
|
|
45
|
+
require_relative "function/fiber/group"
|
|
46
|
+
require_relative "function/async/group"
|
|
41
47
|
require_relative "function/fork"
|
|
42
|
-
require_relative "function/
|
|
48
|
+
require_relative "function/fork/group"
|
|
43
49
|
require_relative "function/ractor"
|
|
44
|
-
require_relative "function/
|
|
50
|
+
require_relative "function/ractor/group"
|
|
45
51
|
|
|
46
52
|
extend LLM::Function::Registry
|
|
47
53
|
prepend LLM::Function::Tracing
|
|
@@ -213,70 +219,60 @@ class LLM::Function
|
|
|
213
219
|
|
|
214
220
|
##
|
|
215
221
|
# Call the function
|
|
216
|
-
# @return [LLM::Function::Return]
|
|
222
|
+
# @return [LLM::Function::Return]
|
|
217
223
|
def call
|
|
218
|
-
|
|
224
|
+
llm = @tracer&.llm
|
|
225
|
+
llm ? llm.with_tracer(@tracer) { call_function } : call_function
|
|
219
226
|
ensure
|
|
220
227
|
@called = true
|
|
221
228
|
end
|
|
222
229
|
|
|
230
|
+
|
|
223
231
|
##
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
# This is the low-level method that powers concurrent tool execution.
|
|
227
|
-
# Prefer the collection methods on {LLM::Context#functions} for most
|
|
228
|
-
# use cases: {LLM::Function::Array#call}, {LLM::Function::Array#wait},
|
|
229
|
-
# or {LLM::Function::Array#spawn}.
|
|
232
|
+
# Returns a function as a {LLM::Function::Task LLM::Function::Task}.
|
|
230
233
|
#
|
|
231
234
|
# @example
|
|
232
|
-
# #
|
|
233
|
-
# ctx.talk(ctx.
|
|
235
|
+
# # As a group
|
|
236
|
+
# ctx.talk(ctx.pending_functions.wait)
|
|
234
237
|
#
|
|
235
|
-
# #
|
|
236
|
-
# task = tool.
|
|
238
|
+
# # As a task
|
|
239
|
+
# task = tool.task(:thread)
|
|
237
240
|
# result = task.value
|
|
238
241
|
#
|
|
239
242
|
# @param [Symbol] strategy
|
|
240
243
|
# Controls concurrency strategy:
|
|
241
|
-
# - `:
|
|
244
|
+
# - `:sequential`: Call the function sequentially
|
|
242
245
|
# - `:thread`: Use threads
|
|
243
|
-
# - `:
|
|
246
|
+
# - `:async`: Use async tasks (requires async gem)
|
|
244
247
|
# - `:fork`: Use a forked child process (requires xchan.rb support)
|
|
245
248
|
# - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
|
|
246
|
-
# - `:fork`: Use a forked child process (requires xchan.rb support)
|
|
247
249
|
# - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
|
|
248
250
|
#
|
|
249
251
|
# @return [LLM::Function::Task]
|
|
250
252
|
# Returns a task whose `#value` is an {LLM::Function::Return}.
|
|
251
|
-
def
|
|
252
|
-
|
|
253
|
-
when :
|
|
254
|
-
|
|
255
|
-
when :
|
|
253
|
+
def task(strategy, options = {})
|
|
254
|
+
case strategy
|
|
255
|
+
when :sequential
|
|
256
|
+
Sequential::Task.new(self, options)
|
|
257
|
+
when :async
|
|
256
258
|
LLM.require "async" unless defined?(::Async)
|
|
257
|
-
Async
|
|
259
|
+
Async::Task.new(self, options)
|
|
258
260
|
when :thread
|
|
259
|
-
Thread.new
|
|
261
|
+
Thread::Task.new(self, options)
|
|
260
262
|
when :fiber
|
|
261
|
-
|
|
262
|
-
Fiber.schedule { call! }
|
|
263
|
+
Fiber::Task.new(self, options)
|
|
263
264
|
when :fork
|
|
264
|
-
LLM.require "xchan" unless defined?(::Chan::UNIXSocket)
|
|
265
|
-
|
|
266
|
-
Fork::Task.new(self, tracer: @tracer, span:).spawn
|
|
265
|
+
LLM.require "xchan", "~> 0.22" unless defined?(::Chan::UNIXSocket)
|
|
266
|
+
Fork::Task.new(self, options.merge(tracer: @tracer))
|
|
267
267
|
when :ractor
|
|
268
268
|
raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
|
|
269
269
|
if @runner.respond_to?(:skill?) && @runner.skill?
|
|
270
270
|
raise LLM::RactorError, "Ractor concurrency does not support skill-backed tools"
|
|
271
271
|
end
|
|
272
|
-
|
|
273
|
-
Ractor::Task.new(@runner, id, name, arguments, tracer: @tracer, span:).spawn
|
|
272
|
+
Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:, tracer: @tracer, model:))
|
|
274
273
|
else
|
|
275
|
-
raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :
|
|
274
|
+
raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :sequential, :thread, :fiber, :async, :fork, or :ractor"
|
|
276
275
|
end
|
|
277
|
-
Task.new(task, self)
|
|
278
|
-
ensure
|
|
279
|
-
@called = true
|
|
280
276
|
end
|
|
281
277
|
|
|
282
278
|
##
|
|
@@ -285,7 +281,7 @@ class LLM::Function
|
|
|
285
281
|
# llm = LLM.openai(key: ENV["KEY"])
|
|
286
282
|
# ctx = LLM::Context.new(llm, tools: [fn1, fn2])
|
|
287
283
|
# ctx.talk "I want to run the functions"
|
|
288
|
-
# ctx.talk ctx.
|
|
284
|
+
# ctx.talk ctx.pending_functions.map(&:cancel)
|
|
289
285
|
# @return [LLM::Function::Return]
|
|
290
286
|
def cancel(reason: "function call cancelled")
|
|
291
287
|
Return.new(id, name, {cancelled: true, reason:})
|
|
@@ -387,10 +383,4 @@ class LLM::Function
|
|
|
387
383
|
rescue => ex
|
|
388
384
|
Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
|
|
389
385
|
end
|
|
390
|
-
|
|
391
|
-
def call!
|
|
392
|
-
llm = @tracer&.llm
|
|
393
|
-
return call unless llm.respond_to?(:with_tracer)
|
|
394
|
-
llm.with_tracer(@tracer) { call }
|
|
395
|
-
end
|
|
396
386
|
end
|
data/lib/llm/loop_guard.rb
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
# should be blocked before the loop keeps going.
|
|
10
10
|
#
|
|
11
11
|
# {LLM::LoopGuard LLM::LoopGuard} detects when a context is repeating the same
|
|
12
|
-
# tool-call pattern instead of making progress.
|
|
13
|
-
# General Intelligence Systems and its doom-loop detection approach.
|
|
12
|
+
# tool-call pattern instead of making progress.
|
|
14
13
|
#
|
|
15
14
|
# The public interface is intentionally small:
|
|
16
15
|
# - `call(ctx)` returns `nil` when no intervention is needed
|
data/lib/llm/mcp.rb
CHANGED
|
@@ -13,6 +13,28 @@
|
|
|
13
13
|
# An MCP client is stateful. Coordinate lifecycle operations such as
|
|
14
14
|
# {#start} and {#stop}; request methods can be issued concurrently and
|
|
15
15
|
# responses are matched by JSON-RPC id.
|
|
16
|
+
#
|
|
17
|
+
# @example stdio transport
|
|
18
|
+
# llm = LLM.deepseek(key: ENV["KEY"])
|
|
19
|
+
# mcp = LLM::MCP.stdio(argv: ["npx", "-y", "@forgejo/mcp-server"])
|
|
20
|
+
# agent = LLM::Agent.new(llm)
|
|
21
|
+
#
|
|
22
|
+
# # Preferred: session keeps one process alive across multiple calls
|
|
23
|
+
# mcp.session do
|
|
24
|
+
# agent.talk "What's happening on forgejo?", tools: mcp.tools
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # Also works: one-shot, spawns a new process per call
|
|
28
|
+
# agent.talk "What's happening on forgejo?", tools: mcp.tools
|
|
29
|
+
#
|
|
30
|
+
# @example HTTP transport
|
|
31
|
+
# mcp = LLM::MCP.http(
|
|
32
|
+
# url: "https://api.githubcopilot.com/mcp/",
|
|
33
|
+
# headers: {"Authorization" => "Bearer #{ENV.fetch('GITHUB_PAT')}"},
|
|
34
|
+
# transport: :net_http_persistent
|
|
35
|
+
# )
|
|
36
|
+
# agent = LLM::Agent.new(llm)
|
|
37
|
+
# agent.talk "What's happening on GitHub?", tools: mcp.tools
|
|
16
38
|
class LLM::MCP
|
|
17
39
|
require_relative "mcp/error"
|
|
18
40
|
require_relative "mcp/command"
|
data/lib/llm/object.rb
CHANGED
|
@@ -133,7 +133,8 @@ class LLM::Object < BasicObject
|
|
|
133
133
|
# @return [Object]
|
|
134
134
|
def fetch(k = UNDEFINED, *args, &b)
|
|
135
135
|
return SINGLETON.get(@h, :fetch) if k.equal?(UNDEFINED)
|
|
136
|
-
|
|
136
|
+
key = SINGLETON.key(@h, k)
|
|
137
|
+
@h.fetch(key || k, *args, &b)
|
|
137
138
|
end
|
|
138
139
|
|
|
139
140
|
##
|
data/lib/llm/provider.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
##
|
|
4
|
-
# The Provider class
|
|
5
|
-
#
|
|
4
|
+
# The Provider class is the abstract base for LLM service integrations.
|
|
5
|
+
# Most users interact with providers through {LLM::Agent} or
|
|
6
|
+
# {LLM::Context} rather than calling {#complete} directly.
|
|
6
7
|
#
|
|
7
8
|
# @abstract
|
|
8
9
|
class LLM::Provider
|
|
@@ -82,7 +83,9 @@ class LLM::Provider
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
##
|
|
85
|
-
# Provides an interface to the chat completions API
|
|
86
|
+
# Provides an interface to the chat completions API.
|
|
87
|
+
# Most users should use {LLM::Context#talk} or {LLM::Agent#talk} instead.
|
|
88
|
+
#
|
|
86
89
|
# @example
|
|
87
90
|
# llm = LLM.openai(key: ENV["KEY"])
|
|
88
91
|
# messages = [{role: "system", content: "Your task is to answer all of my questions"}]
|
data/lib/llm/providers/google.rb
CHANGED
|
@@ -209,11 +209,11 @@ module LLM
|
|
|
209
209
|
role, model, stream = params.delete(:role),
|
|
210
210
|
params.delete(:model),
|
|
211
211
|
LLM::Stream.try(params.delete(:stream))
|
|
212
|
-
[params
|
|
212
|
+
[params, stream, tools, role, model]
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
def build_complete_request(prompt, params, role, model, stream)
|
|
216
|
-
action = stream ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
|
|
216
|
+
action = stream.enabled? ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
|
|
217
217
|
model.respond_to?(:id) ? model.id : model
|
|
218
218
|
path = ["/v1beta/models/#{model}", action].join(":")
|
|
219
219
|
req = LLM::Transport::Request.post(path, headers)
|
data/lib/llm/repl/command.rb
CHANGED
|
@@ -11,6 +11,17 @@ class LLM::Repl
|
|
|
11
11
|
SINGLETON = self
|
|
12
12
|
private_constant :UNDEFINED, :SINGLETON
|
|
13
13
|
|
|
14
|
+
##
|
|
15
|
+
# @param [String] str
|
|
16
|
+
# An input string
|
|
17
|
+
# @return [Array<String>]
|
|
18
|
+
# An array of command names who match the input string
|
|
19
|
+
def self.complete(str)
|
|
20
|
+
registry.keys.select do |name|
|
|
21
|
+
name.start_with?(str[1..])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
14
25
|
##
|
|
15
26
|
# @api private
|
|
16
27
|
Parameter = Struct.new(:name, :type, :description, :options, :index, :value) do
|
|
@@ -62,31 +73,37 @@ class LLM::Repl
|
|
|
62
73
|
if input != UNDEFINED
|
|
63
74
|
return nil unless input[0] == "/"
|
|
64
75
|
n, = input.split(" ")
|
|
65
|
-
registry.find { n[1..] == _1.name }
|
|
76
|
+
registry.values.find { n[1..] == _1.name }
|
|
66
77
|
elsif name != UNDEFINED
|
|
67
|
-
registry.find { name == _1.name }
|
|
78
|
+
registry.values.find { name == _1.name }
|
|
68
79
|
else
|
|
69
80
|
raise ArgumentError, "provide either an input or a name"
|
|
70
81
|
end
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
##
|
|
74
|
-
# @param [LLM::Repl::Command]
|
|
85
|
+
# @param [LLM::Repl::Command] outer
|
|
75
86
|
# A new subclass
|
|
76
87
|
# @return [void]
|
|
77
|
-
def self.inherited(
|
|
88
|
+
def self.inherited(outer)
|
|
78
89
|
LLM.lock(:inherited) do
|
|
79
|
-
registry
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
@registry[outer] = outer
|
|
91
|
+
outer.instance_variable_set(:@parameters, {})
|
|
92
|
+
outer.define_singleton_method(:inherited) do |inner|
|
|
93
|
+
SINGLETON.inherited(inner)
|
|
94
|
+
inner.instance_variable_set(:@name, outer.instance_variable_get(:@name))
|
|
95
|
+
inner.instance_variable_set(:@description, outer.instance_variable_get(:@description))
|
|
96
|
+
inner.instance_variable_set(:@parameters, outer.instance_variable_get(:@parameters))
|
|
97
|
+
end
|
|
82
98
|
end
|
|
83
99
|
end
|
|
84
100
|
|
|
85
101
|
##
|
|
86
102
|
# @return [Array<LLM::Repl::Command]
|
|
87
103
|
def self.registry
|
|
88
|
-
@registry
|
|
104
|
+
@registry.transform_keys(&:name)
|
|
89
105
|
end
|
|
106
|
+
@registry = {}
|
|
90
107
|
|
|
91
108
|
##
|
|
92
109
|
# Set or get a command name.
|
|
@@ -142,11 +159,20 @@ class LLM::Repl
|
|
|
142
159
|
end
|
|
143
160
|
end
|
|
144
161
|
|
|
162
|
+
##
|
|
163
|
+
# @return [LLM::Repl]
|
|
164
|
+
attr_reader :repl
|
|
165
|
+
|
|
166
|
+
##
|
|
167
|
+
# @return [LLM::Agent]
|
|
168
|
+
attr_reader :agent
|
|
169
|
+
|
|
145
170
|
##
|
|
146
171
|
# @param [LLM::Repl] repl
|
|
147
172
|
# @return [LLM::Repl::Command]
|
|
148
173
|
def initialize(repl)
|
|
149
174
|
@repl = repl
|
|
175
|
+
@agent = repl.agent
|
|
150
176
|
end
|
|
151
177
|
|
|
152
178
|
##
|
|
@@ -191,6 +217,7 @@ class LLM::Repl
|
|
|
191
217
|
self.class.parameters
|
|
192
218
|
end
|
|
193
219
|
|
|
220
|
+
require_relative "commands/compact"
|
|
194
221
|
require_relative "commands/exit"
|
|
195
222
|
require_relative "commands/help"
|
|
196
223
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The 'compact' command frees space in the
|
|
6
|
+
# context window and llm.rb is designed to
|
|
7
|
+
# support multiple compaction strategies with
|
|
8
|
+
# different trade offs. This command, though,
|
|
9
|
+
# uses the 'truncate' strategy. See
|
|
10
|
+
# {LLM::Compactor::Truncate LLM::Compactor::Truncate}
|
|
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"
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @return [void]
|
|
19
|
+
def call(n: 128)
|
|
20
|
+
write("compact in progress\n")
|
|
21
|
+
compactor.call(keep: n)
|
|
22
|
+
write("compact complete\n\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @return [LLM::Compactor::Truncate]
|
|
29
|
+
def compactor
|
|
30
|
+
@compactor ||= LLM::Compactor::Truncate.new(agent)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/llm/repl/input.rb
CHANGED
|
@@ -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 =
|
|
24
|
-
BACKSPACE =
|
|
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]
|
|
52
|
+
# @param [Boolean] value
|
|
39
53
|
# @return [void]
|
|
40
54
|
attr_writer :paste
|
|
41
55
|
|
|
42
56
|
##
|
|
43
|
-
# @param [LLM::
|
|
57
|
+
# @param [LLM::Repl] repl
|
|
44
58
|
# @return [LLM::Repl::Input]
|
|
45
|
-
def initialize(
|
|
46
|
-
@
|
|
47
|
-
@
|
|
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
|
|
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
|
|
125
|
+
elsif BACKSPACE == char
|
|
89
126
|
backspace
|
|
90
127
|
:backspace
|
|
91
|
-
elsif ENTER
|
|
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
|
-
|
|
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
|
-
"#{
|
|
160
|
+
"#{prompt}#{@buffer}"
|
|
118
161
|
end
|
|
119
162
|
|
|
120
163
|
##
|
|
121
164
|
# @return [Integer]
|
|
122
165
|
def cursor
|
|
123
|
-
prompt.
|
|
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)
|