llm.rb 12.6.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +571 -13
  3. data/LICENSE +21 -93
  4. data/README.md +183 -167
  5. data/bin/llm.rb +124 -0
  6. data/data/deepinfra.json +3 -0
  7. data/data/xai.json +1 -1
  8. data/lib/llm/a2a.rb +1 -1
  9. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  10. data/lib/llm/agent.rb +136 -27
  11. data/lib/llm/buffer.rb +85 -3
  12. data/lib/llm/compactor/null.rb +19 -0
  13. data/lib/llm/compactor/truncate.rb +80 -0
  14. data/lib/llm/compactor.rb +42 -124
  15. data/lib/llm/context.rb +31 -37
  16. data/lib/llm/contract.rb +4 -25
  17. data/lib/llm/function/array.rb +18 -17
  18. data/lib/llm/function/async/group.rb +54 -0
  19. data/lib/llm/function/async/reactor.rb +48 -0
  20. data/lib/llm/function/async/task.rb +83 -0
  21. data/lib/llm/function/fiber/group.rb +46 -0
  22. data/lib/llm/function/fiber/task.rb +62 -0
  23. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  24. data/lib/llm/function/fork/job.rb +2 -2
  25. data/lib/llm/function/fork/task.rb +19 -10
  26. data/lib/llm/function/group.rb +40 -0
  27. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  28. data/lib/llm/function/ractor/job.rb +9 -3
  29. data/lib/llm/function/ractor/mailbox.rb +2 -0
  30. data/lib/llm/function/ractor/task.rb +23 -15
  31. data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
  32. data/lib/llm/function/sequential/task.rb +49 -0
  33. data/lib/llm/function/task.rb +25 -48
  34. data/lib/llm/function/thread/group.rb +46 -0
  35. data/lib/llm/function/thread/task.rb +60 -0
  36. data/lib/llm/function.rb +54 -65
  37. data/lib/llm/loop_guard.rb +1 -2
  38. data/lib/llm/mcp.rb +22 -0
  39. data/lib/llm/object.rb +2 -1
  40. data/lib/llm/provider.rb +6 -3
  41. data/lib/llm/providers/anthropic.rb +1 -1
  42. data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
  43. data/lib/llm/providers/google.rb +2 -2
  44. data/lib/llm/providers/mistral.rb +1 -1
  45. data/lib/llm/providers/ollama.rb +1 -1
  46. data/lib/llm/providers/openai/responses.rb +1 -1
  47. data/lib/llm/providers/openai.rb +1 -1
  48. data/lib/llm/repl/{transcript.rb → buffer.rb} +34 -21
  49. data/lib/llm/repl/command.rb +47 -13
  50. data/lib/llm/repl/commands/compact.rb +33 -0
  51. data/lib/llm/repl/commands/help.rb +3 -5
  52. data/lib/llm/repl/input.rb +80 -15
  53. data/lib/llm/repl/markdown/table.rb +80 -0
  54. data/lib/llm/repl/markdown.rb +33 -3
  55. data/lib/llm/repl/node.rb +37 -0
  56. data/lib/llm/repl/status.rb +4 -4
  57. data/lib/llm/repl/stream.rb +12 -5
  58. data/lib/llm/repl/walker.rb +46 -0
  59. data/lib/llm/repl/window.rb +31 -32
  60. data/lib/llm/repl.rb +70 -38
  61. data/lib/llm/response.rb +10 -0
  62. data/lib/llm/schema/leaf.rb +5 -0
  63. data/lib/llm/schema/object.rb +11 -5
  64. data/lib/llm/sequel/plugin.rb +6 -6
  65. data/lib/llm/skill.rb +20 -4
  66. data/lib/llm/stream.rb +24 -17
  67. data/lib/llm/tool.rb +20 -4
  68. data/lib/llm/tools/chdir.rb +0 -2
  69. data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
  70. data/lib/llm/tools/git.rb +11 -4
  71. data/lib/llm/tools/mkdir.rb +4 -1
  72. data/lib/llm/tools/pwd.rb +0 -2
  73. data/lib/llm/tools/read_file.rb +0 -2
  74. data/lib/llm/tools/rg.rb +11 -4
  75. data/lib/llm/tools/ruby.rb +46 -0
  76. data/lib/llm/tools/shell.rb +11 -4
  77. data/lib/llm/tools/utils.rb +31 -0
  78. data/lib/llm/tracer/pretty_logger.rb +127 -0
  79. data/lib/llm/tracer.rb +1 -0
  80. data/lib/llm/version.rb +1 -1
  81. data/lib/llm.rb +25 -5
  82. data/llm.gemspec +11 -5
  83. data/resources/deepdive.md +45 -1198
  84. metadata +39 -17
  85. data/lib/llm/function/call_task.rb +0 -46
  86. data/lib/llm/function/fiber_group.rb +0 -105
  87. data/lib/llm/function/task_group.rb +0 -97
  88. data/lib/llm/function/thread_group.rb +0 -102
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/data/deepinfra.json CHANGED
@@ -503,6 +503,7 @@
503
503
  "context": 131072,
504
504
  "output": 131072
505
505
  },
506
+ "status": "deprecated",
506
507
  "cost": {
507
508
  "input": 0.4,
508
509
  "output": 0.4
@@ -537,6 +538,7 @@
537
538
  "context": 262144,
538
539
  "output": 65536
539
540
  },
541
+ "status": "deprecated",
540
542
  "cost": {
541
543
  "input": 0.2,
542
544
  "output": 0.8
@@ -832,6 +834,7 @@
832
834
  "context": 196608,
833
835
  "output": 131072
834
836
  },
837
+ "status": "deprecated",
835
838
  "cost": {
836
839
  "input": 0.15,
837
840
  "output": 1.15,
data/data/xai.json CHANGED
@@ -312,7 +312,7 @@
312
312
  "cost": {
313
313
  "input": 2,
314
314
  "output": 6,
315
- "cache_read": 0.5,
315
+ "cache_read": 0.3,
316
316
  "tiers": [
317
317
  {
318
318
  "input": 4,
data/lib/llm/a2a.rb CHANGED
@@ -27,7 +27,7 @@
27
27
  # a2a = LLM::A2A.rest(url: "https://agent.example.com")
28
28
  # ctx = LLM::Context.new(llm, tools: a2a.skills)
29
29
  # ctx.talk("Analyze this data using the remote agent.")
30
- # ctx.talk(ctx.wait(:call)) while ctx.functions?
30
+ # ctx.talk(ctx.wait(:sequential)) while ctx.pending_functions?
31
31
  class LLM::A2A
32
32
  require_relative "a2a/card"
33
33
  require_relative "a2a/error"
@@ -104,17 +104,17 @@ module LLM::ActiveRecord
104
104
  end
105
105
 
106
106
  ##
107
- # @see LLM::Context#functions
107
+ # @see LLM::Context#pending_functions
108
108
  # @return [Array<LLM::Function>]
109
- def functions
110
- ctx.functions
109
+ def pending_functions
110
+ ctx.pending_functions
111
111
  end
112
112
 
113
113
  ##
114
- # @see LLM::Context#functions?
114
+ # @see LLM::Context#pending_functions?
115
115
  # @return [Boolean]
116
- def functions?
117
- ctx.functions?
116
+ def pending_functions?
117
+ ctx.pending_functions?
118
118
  end
119
119
 
120
120
  ##
data/lib/llm/agent.rb CHANGED
@@ -2,16 +2,17 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # {LLM::Agent LLM::Agent} provides a class-level DSL for defining
6
- # reusable, preconfigured assistants with defaults for model,
7
- # tools, schema, and instructions.
5
+ # {LLM::Agent LLM::Agent} is the recommended entry point for most
6
+ # use-cases. It provides a class-level DSL for defining reusable,
7
+ # preconfigured assistants with defaults for model, tools, schema,
8
+ # and instructions.
8
9
  #
9
10
  # It wraps the same stateful runtime surface as
10
11
  # {LLM::Context LLM::Context}: message history, usage, persistence,
11
12
  # streaming parameters, and provider-backed requests still flow through
12
13
  # an underlying context. The defining behavior of an agent is that it
13
- # automatically resolves pending tool calls for you during `talk` and
14
- # `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.
15
16
  #
16
17
  # **Notes:**
17
18
  # * Instructions are injected once unless a system message is already present.
@@ -22,10 +23,10 @@ module LLM
22
23
  # * The default tool attempt budget is `25`. After that, the agent sends
23
24
  # advisory tool errors back through the model and keeps the loop in-band.
24
25
  # Set `tool_attempts: nil` to disable that advisory behavior.
25
- # * Tool loop execution can be configured with `concurrency :call`,
26
- # `:thread`, `:task`, `:fiber`, or `:ractor`.
26
+ # * Tool loop execution can be configured with `concurrency :sequential`,
27
+ # `:thread`, `:async`, `:fiber`, `:fork`, or `:ractor`.
27
28
  #
28
- # @example
29
+ # @example Subclass with defaults
29
30
  # class SystemAdmin < LLM::Agent
30
31
  # set model: "gpt-4.1-nano",
31
32
  # instructions: "You are a Linux system admin",
@@ -36,7 +37,31 @@ module LLM
36
37
  # llm = LLM.openai(key: ENV["KEY"])
37
38
  # agent = SystemAdmin.new(llm)
38
39
  # agent.talk("Run 'date'")
40
+ #
41
+ # @example Direct instance
42
+ # llm = LLM.deepseek(key: ENV["KEY"])
43
+ # agent = LLM::Agent.new(llm, stream: $stdout)
44
+ # agent.talk "Hello world"
45
+ #
46
+ # @see LLM::Context The low-level runtime that Agent wraps
47
+ # @see LLM::Tool Tools that Agent can call on your behalf
48
+ # @see LLM::Stream Stream callbacks for model output
39
49
  class Agent
50
+ ##
51
+ # @api private
52
+ UNDEFINED = Object.new
53
+ private_constant :UNDEFINED
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
+
40
65
  ##
41
66
  # Returns a provider
42
67
  # @return [LLM::Provider]
@@ -51,7 +76,8 @@ module LLM
51
76
  #
52
77
  # @example
53
78
  # class AdminAgent < LLM::Agent
54
- # set instructions: "You are a system administrator",
79
+ # set name: "admin",
80
+ # instructions: "You are a system administrator",
55
81
  # model: "gpt-4.1-nano",
56
82
  # tools: [Shell, ReadFile]
57
83
  # end
@@ -78,6 +104,46 @@ module LLM
78
104
  end
79
105
  end
80
106
 
107
+ ##
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.
113
+ # @param [String] name
114
+ # The agent name
115
+ # @return [String]
116
+ # Return's the agents name
117
+ def self.name(name = UNDEFINED, &block)
118
+ if name.equal?(UNDEFINED)
119
+ if @name.nil?
120
+ name = to_s.split("::").last
121
+ @name = name.gsub(CASE_PATTERN, "-").downcase
122
+ else
123
+ @name
124
+ end
125
+ else
126
+ @name = block || name
127
+ end
128
+ end
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
+
81
147
  ##
82
148
  # Set or get the default model
83
149
  # @param [String, nil] model
@@ -146,9 +212,9 @@ module LLM
146
212
  #
147
213
  # @param [Symbol, Array<Symbol>, nil] concurrency
148
214
  # Controls how pending tool loops are executed:
149
- # - `:call`: sequential calls
215
+ # - `:sequential`: sequential calls
150
216
  # - `:thread`: concurrent threads
151
- # - `:task`: concurrent async tasks
217
+ # - `:async`: concurrent async tasks
152
218
  # - `:fiber`: concurrent scheduler-backed fibers
153
219
  # - `:fork`: forked child processes
154
220
  # - `:ractor`: concurrent Ruby ractors for class-based tools; MCP tools are not supported,
@@ -231,6 +297,20 @@ module LLM
231
297
  end
232
298
  end
233
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
+
234
314
  ##
235
315
  # @param [LLM::Provider] llm
236
316
  # A provider
@@ -247,8 +327,8 @@ module LLM
247
327
  # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
248
328
  def initialize(llm, params = {})
249
329
  @llm = llm
250
- fields = %i[model skills schema tracer stream tools concurrency instructions confirm]
251
- fields_ivar = %i[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]
252
332
  fields.each do |field|
253
333
  resolvable = params.key?(field) ? params.delete(field) : self.class.public_send(field)
254
334
  resolve_symbol = !%i[concurrency].include?(field)
@@ -263,6 +343,29 @@ module LLM
263
343
  end
264
344
  end
265
345
  @ctx = LLM::Context.new(llm, {guard: true}.merge(params))
346
+ @path and File.readable?(@path) ? @ctx.restore(path:) : nil
347
+ end
348
+
349
+ ##
350
+ # Returns the agent's name
351
+ # @return [String]
352
+ def name
353
+ @name
354
+ end
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
266
369
  end
267
370
 
268
371
  ##
@@ -282,13 +385,17 @@ module LLM
282
385
  # response = agent.talk("Hello, what is your name?")
283
386
  # puts response.choices[0].content
284
387
  def talk(prompt, params = {})
285
- run_loop(prompt, params, :talk)
388
+ res = run_loop(prompt, params, :talk)
389
+ path ? @ctx.save(path:) : nil
390
+ res
286
391
  end
287
392
 
288
393
  ##
289
394
  # @see LLM::Context#ask
290
395
  def ask(prompt, params = {})
291
- run_loop(prompt, params, :ask)
396
+ res = run_loop(prompt, params, :ask)
397
+ path ? @ctx.save(path:) : nil
398
+ res
292
399
  end
293
400
 
294
401
  ##
@@ -299,10 +406,9 @@ module LLM
299
406
 
300
407
  ##
301
408
  # @return [Array<LLM::Function>]
302
- def functions
303
- @tracer ? @llm.with_tracer(@tracer) { @ctx.functions } : @ctx.functions
409
+ def pending_functions
410
+ @tracer ? @llm.with_tracer(@tracer) { @ctx.pending_functions } : @ctx.pending_functions
304
411
  end
305
- alias_method :pending_functions, :functions
306
412
 
307
413
  ##
308
414
  # @see LLM::Context#returns
@@ -435,6 +541,9 @@ module LLM
435
541
  # By default this method disables the tracer for
436
542
  # the duration of the repl session, and restores
437
543
  # it afterwards.
544
+ # @param [String] name
545
+ # The agent's name.
546
+ # Defaults to {LLM::Agent#name}.
438
547
  # @param [String] path
439
548
  # The path to a file where runtime state is read
440
549
  # from, and written to
@@ -446,7 +555,7 @@ module LLM
446
555
  # When true, the tracer is kept alive during the
447
556
  # repl session. Default is false.
448
557
  # @return [void]
449
- def repl(path: nil, tools: [], skills: [], tracer: false, trace: nil)
558
+ def repl(name: self.name, path: nil, tools: [], skills: [], tracer: false, trace: nil)
450
559
  if trace != nil
451
560
  warn "llm.rb: trace option is deprecated, use tracer instead"
452
561
  tracer = trace
@@ -456,7 +565,7 @@ module LLM
456
565
  self.tracer = nil
457
566
  end
458
567
  require_relative "repl" unless defined?(::LLM::Repl)
459
- LLM::Repl.new(agent: self, path:, tools:, skills:).start
568
+ LLM::Repl.new(agent: self, name:, path:, tools:, skills:).start
460
569
  ensure
461
570
  if !tracer
462
571
  self.tracer = previous
@@ -516,7 +625,7 @@ module LLM
516
625
  # @param [Symbol, Array<Symbol>] strategy
517
626
  # The execution strategy that would be used for the tool call.
518
627
  # @return [LLM::Function::Return]
519
- # Return either `fn.spawn(strategy).wait` to approve execution or
628
+ # Return either `fn.task(strategy).wait` to approve execution or
520
629
  # `fn.cancel(...)` to cancel the call.
521
630
  def on_tool_confirmation(fn, strategy)
522
631
  fn.cancel
@@ -554,12 +663,12 @@ module LLM
554
663
  ##
555
664
  # @return [Array<LLM::Function::Return>]
556
665
  def call_functions
557
- strategy = concurrency || :call
666
+ strategy = concurrency || :sequential
558
667
  return wait(strategy) unless @confirm&.any?
559
- confirmables = @ctx.functions.select { @confirm.include?(_1.name.to_s) }
668
+ confirmables = @ctx.pending_functions.select { @confirm.include?(_1.name.to_s) }
560
669
  results = confirmables.map { method(:on_tool_confirmation).call(_1, strategy) }
561
670
  @ctx.method(:emit_tool_returns).call(confirmables, results)
562
- if (@ctx.functions - confirmables).any?
671
+ if (@ctx.pending_functions - confirmables).any?
563
672
  [*results, *wait(strategy, except: confirmables)]
564
673
  else
565
674
  results
@@ -577,13 +686,13 @@ module LLM
577
686
  stream = params[:stream] || @ctx.params[:stream]
578
687
  params[:stream] = LLM::Stream.try(stream, extra: {concurrency:})
579
688
  res = talk.call(apply_instructions(prompt), params)
580
- while @ctx.functions?
689
+ while @ctx.pending_functions?
581
690
  if max
582
691
  max.times do
583
- break unless @ctx.functions?
692
+ break unless @ctx.pending_functions?
584
693
  res = talk.call(call_functions, params)
585
694
  end
586
- res = talk.call(@ctx.functions.map(&:rate_limit), params) if @ctx.functions?
695
+ res = talk.call(@ctx.pending_functions.map(&:rate_limit), params) if @ctx.pending_functions?
587
696
  else
588
697
  res = talk.call(call_functions, params)
589
698
  end
data/lib/llm/buffer.rb CHANGED
@@ -3,10 +3,24 @@
3
3
  module LLM
4
4
  ##
5
5
  # {LLM::Buffer LLM::Buffer} provides an Enumerable object that
6
- # tracks messages in a conversation thread.
6
+ # tracks messages in a conversation thread. Access it through
7
+ # {LLM::Context#messages}.
8
+ #
9
+ # @example Working with message history
10
+ # ctx.messages.last # => most recent message
11
+ # ctx.messages.first # => oldest message
12
+ # ctx.messages.select! { |m| m.assistant? }
13
+ # ctx.messages.reverse # => reversed copy
14
+ # ctx.messages.reject! { |m| m.compaction? }
15
+ #
16
+ # @see LLM::Message Individual messages in the buffer
17
+ # @see LLM::Context Where the buffer lives (ctx.messages)
7
18
  class Buffer
8
19
  include Enumerable
9
20
 
21
+ UNDEFINED = Object.new
22
+ private_constant :UNDEFINED
23
+
10
24
  ##
11
25
  # @param [LLM::Provider] provider
12
26
  # @return [LLM::Buffer]
@@ -65,8 +79,69 @@ module LLM
65
79
  # @param [Integer, nil] n
66
80
  # The number of messages to return
67
81
  # @return [LLM::Message, Array<LLM::Message>, nil]
68
- def last(n = nil)
69
- n.nil? ? @messages.last : @messages.last(n)
82
+ def last(n = UNDEFINED)
83
+ n.equal?(UNDEFINED) ? @messages.last : @messages.last(n)
84
+ end
85
+
86
+ ##
87
+ # Returns the first message(s) in the buffer
88
+ # @param [Integer, nil] n
89
+ # The number of messages to return
90
+ # @return [LLM::Message, Array<LLM::Message>, nil]
91
+ def first(n = UNDEFINED)
92
+ n.equal?(UNDEFINED) ? @messages.first : @messages.first(n)
93
+ end
94
+
95
+ ##
96
+ # Removes messages matching the block in-place.
97
+ # @yield [LLM::Message]
98
+ # @return [LLM::Buffer]
99
+ def reject!(&)
100
+ @messages.reject!(&)
101
+ self
102
+ end
103
+ alias_method :delete_if, :reject!
104
+
105
+ ##
106
+ # Keeps messages matching the block in-place.
107
+ # @yield [LLM::Message]
108
+ # @return [LLM::Buffer]
109
+ def select!(&)
110
+ @messages.select!(&)
111
+ self
112
+ end
113
+
114
+ ##
115
+ # Removes and returns the first message.
116
+ # @return [LLM::Message, nil]
117
+ def shift
118
+ @messages.shift
119
+ end
120
+
121
+ ##
122
+ # Removes all messages.
123
+ # @return [LLM::Buffer]
124
+ def clear
125
+ @messages.clear
126
+ self
127
+ end
128
+
129
+ ##
130
+ # Returns all elements after the first n.
131
+ # @param [Integer] n
132
+ # The number of messages to skip
133
+ # @return [Array<LLM::Message>]
134
+ def drop(n)
135
+ @messages.drop(n)
136
+ end
137
+
138
+ ##
139
+ # Returns the first n elements without removing them.
140
+ # @param [Integer] n
141
+ # The number of messages to return
142
+ # @return [Array<LLM::Message>]
143
+ def take(n)
144
+ @messages.take(n)
70
145
  end
71
146
 
72
147
  ##
@@ -103,6 +178,13 @@ module LLM
103
178
  @messages[index]
104
179
  end
105
180
 
181
+ ##
182
+ # Returns a reversed copy of the internal array.
183
+ # @return [Array]
184
+ def reverse
185
+ @messages.reverse
186
+ end
187
+
106
188
  ##
107
189
  # @return [String]
108
190
  def to_json(...)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Compactor
4
+ ##
5
+ # An {LLM::Compactor::Null LLM::Compactor::Null} is a compactor that
6
+ # does nothing. It is used as the default when no compactor strategy
7
+ # is configured.
8
+ #
9
+ # All methods return nil and produce no side effects.
10
+ class Null < self
11
+ ##
12
+ # @param [Hash] opts
13
+ # Ignored
14
+ # @return [nil]
15
+ def call(**opts)
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Compactor
4
+ ##
5
+ # An {LLM::Compactor::Truncate LLM::Compactor::Truncate}
6
+ # drops the oldest messages when the conversation grows
7
+ # beyond a configured size, keeping only the N most recent
8
+ # messages.
9
+ #
10
+ # No LLM call is made but this strategy is purely lossy. It
11
+ # also fast - no network required and operates purely on
12
+ # memory.
13
+ class Truncate < self
14
+ ##
15
+ # @param [String, Integer] keep
16
+ # The last (approx) n number of messages to keep.
17
+ # This parameter can also be a percentage: eg "80%"
18
+ # to keep 80% of the most recent messages.
19
+ # @return [Array<LLM::Message>, nil]
20
+ def call(keep: 64)
21
+ keep = parse(keep)
22
+ if keep <= 0 || keep > messages.reject(&:system?).size
23
+ nil
24
+ else
25
+ stream.on_compaction(self)
26
+ kept = take(messages, keep)
27
+ messages.replace([messages.select(&:system?).first, *kept].compact)
28
+ ctx.compacted = true
29
+ stream.on_compaction_finish(self)
30
+ kept
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ ##
37
+ # @param [String, Integer] input
38
+ # The given input
39
+ # @return [Integer]
40
+ # Returns the number of messages to keep
41
+ def parse(input)
42
+ if String === input
43
+ if input.end_with?("%")
44
+ count = ctx.messages.reject(&:system?).size
45
+ (count * (Float(input[0..-2]) / 100)).round
46
+ else
47
+ Integer(input)
48
+ end
49
+ else
50
+ Integer(input)
51
+ end
52
+ end
53
+
54
+ def take(messages, limit)
55
+ subset, in_tool_call = [], false
56
+ messages.reverse_each.with_index(1) do |m, index|
57
+ # We travel backwards - so we see a
58
+ # tool return before we see a tool
59
+ # call.
60
+ #
61
+ # When we see a tool return, our next
62
+ # task is to find where it was called
63
+ # from, and we will even override the
64
+ # limit to do this.
65
+ #
66
+ # Otherwise, the conversation will become
67
+ # corrupted and any attempt to use it will
68
+ # be an API-level error.
69
+ in_tool_call = m.tool_return?
70
+ if index >= limit
71
+ subset.unshift(m)
72
+ in_tool_call ? next : break
73
+ else
74
+ subset.unshift(m)
75
+ end
76
+ end
77
+ subset
78
+ end
79
+ end
80
+ end