llm.rb 4.21.0 → 4.23.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.
@@ -22,6 +22,76 @@ module LLM::Sequel
22
22
  output_tokens: :output_tokens,
23
23
  total_tokens: :total_tokens
24
24
  }.freeze
25
+
26
+ ##
27
+ # Shared helper methods for the ORM wrapper.
28
+ #
29
+ # These utilities keep persistence plumbing out of the wrapped model's
30
+ # method namespace so the injected surface stays focused on the runtime
31
+ # API itself.
32
+ # @api private
33
+ module Utils
34
+ ##
35
+ # Resolves a single configured option against a model instance.
36
+ # @return [Object]
37
+ def self.resolve_option(obj, option)
38
+ case option
39
+ when Proc then obj.instance_exec(&option)
40
+ when Symbol then obj.send(option)
41
+ when Hash then option.dup
42
+ else option
43
+ end
44
+ end
45
+
46
+ ##
47
+ # Resolves hash-like wrapper options against a model instance.
48
+ # @return [Hash]
49
+ def self.resolve_options(obj, option, empty_hash)
50
+ case option
51
+ when Proc, Symbol, Hash then resolve_option(obj, option)
52
+ else empty_hash.dup
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Serializes the runtime into the configured storage format.
58
+ # @return [String, Hash]
59
+ def self.serialize_context(ctx, format)
60
+ case format
61
+ when :string then ctx.to_json
62
+ when :json, :jsonb then ctx.to_h
63
+ else raise ArgumentError, "Unknown format: #{format.inspect}"
64
+ end
65
+ end
66
+
67
+ ##
68
+ # Maps wrapper options onto the record's storage columns.
69
+ # @return [Hash]
70
+ def self.columns(options)
71
+ usage_columns = options[:usage_columns]
72
+ {
73
+ provider_column: options[:provider_column],
74
+ model_column: options[:model_column],
75
+ data_column: options[:data_column],
76
+ input_tokens: usage_columns[:input_tokens],
77
+ output_tokens: usage_columns[:output_tokens],
78
+ total_tokens: usage_columns[:total_tokens]
79
+ }.freeze
80
+ end
81
+
82
+ ##
83
+ # Persists the runtime state and usage columns back onto the record.
84
+ # @return [void]
85
+ def self.save(obj, ctx, options)
86
+ columns = self.columns(options)
87
+ obj.update(
88
+ columns[:data_column] => serialize_context(ctx, options[:format]),
89
+ columns[:input_tokens] => ctx.usage.input_tokens,
90
+ columns[:output_tokens] => ctx.usage.output_tokens,
91
+ columns[:total_tokens] => ctx.usage.total_tokens
92
+ )
93
+ end
94
+ end
25
95
  DEFAULTS = {
26
96
  provider_column: :provider,
27
97
  model_column: :model,
@@ -84,12 +154,15 @@ module LLM::Sequel
84
154
  end
85
155
 
86
156
  module Plugin::InstanceMethods
157
+ Utils = Plugin::Utils
158
+
87
159
  ##
88
160
  # Continues the stored context with new input and flushes it.
89
161
  # @see LLM::Context#talk
90
162
  # @return [LLM::Response]
91
163
  def talk(...)
92
- ctx.talk(...).tap { flush }
164
+ options = self.class.llm_plugin_options
165
+ ctx.talk(...).tap { Utils.save(self, ctx, options) }
93
166
  end
94
167
 
95
168
  ##
@@ -97,7 +170,8 @@ module LLM::Sequel
97
170
  # @see LLM::Context#respond
98
171
  # @return [LLM::Response]
99
172
  def respond(...)
100
- ctx.respond(...).tap { flush }
173
+ options = self.class.llm_plugin_options
174
+ ctx.respond(...).tap { Utils.save(self, ctx, options) }
101
175
  end
102
176
 
103
177
  ##
@@ -173,6 +247,7 @@ module LLM::Sequel
173
247
  # Returns usage from the mapped usage columns.
174
248
  # @return [LLM::Object]
175
249
  def usage
250
+ columns = Utils.columns(self.class.llm_plugin_options)
176
251
  LLM::Object.from(
177
252
  input_tokens: self[columns[:input_tokens]] || 0,
178
253
  output_tokens: self[columns[:output_tokens]] || 0,
@@ -229,11 +304,12 @@ module LLM::Sequel
229
304
  # @return [LLM::Provider]
230
305
  def llm
231
306
  options = self.class.llm_plugin_options
307
+ columns = Utils.columns(options)
232
308
  provider = self[columns[:provider_column]]
233
- kwargs = resolve_options(options[:provider])
309
+ kwargs = Utils.resolve_options(self, options[:provider], Plugin::EMPTY_HASH)
234
310
  return @llm if @llm
235
311
  @llm = LLM.method(provider).call(**kwargs)
236
- @llm.tracer = resolve_option(options[:tracer]) if options[:tracer]
312
+ @llm.tracer = Utils.resolve_option(self, options[:tracer]) if options[:tracer]
237
313
  @llm
238
314
  end
239
315
 
@@ -244,7 +320,8 @@ module LLM::Sequel
244
320
  def ctx
245
321
  @ctx ||= begin
246
322
  options = self.class.llm_plugin_options
247
- params = resolve_options(options[:context]).dup
323
+ columns = Utils.columns(options)
324
+ params = Utils.resolve_options(self, options[:context], Plugin::EMPTY_HASH).dup
248
325
  params[:model] ||= self[columns[:model_column]]
249
326
  ctx = LLM::Context.new(llm, params.compact)
250
327
  data = self[columns[:data_column]]
@@ -259,60 +336,5 @@ module LLM::Sequel
259
336
  end
260
337
  end
261
338
  end
262
-
263
- ##
264
- # @return [void]
265
- def flush
266
- options = self.class.llm_plugin_options
267
- update({
268
- columns[:data_column] => serialize_context(options[:format]),
269
- columns[:input_tokens] => ctx.usage.input_tokens,
270
- columns[:output_tokens] => ctx.usage.output_tokens,
271
- columns[:total_tokens] => ctx.usage.total_tokens
272
- })
273
- end
274
-
275
- ##
276
- # @return [Hash]
277
- def resolve_option(option)
278
- case option
279
- when Proc then instance_exec(&option)
280
- when Symbol then send(option)
281
- when Hash then option.dup
282
- else option
283
- end
284
- end
285
-
286
- ##
287
- # @return [Hash]
288
- def resolve_options(option)
289
- case option
290
- when Proc, Symbol, Hash then resolve_option(option)
291
- else Plugin::EMPTY_HASH.dup
292
- end
293
- end
294
-
295
- def serialize_context(format)
296
- case format
297
- when :string then ctx.to_json
298
- when :json, :jsonb then ctx.to_h
299
- else raise ArgumentError, "Unknown format: #{format.inspect}"
300
- end
301
- end
302
-
303
- def columns
304
- @columns ||= begin
305
- options = self.class.llm_plugin_options
306
- usage_columns = options[:usage_columns]
307
- {
308
- provider_column: options[:provider_column],
309
- model_column: options[:model_column],
310
- data_column: options[:data_column],
311
- input_tokens: usage_columns[:input_tokens],
312
- output_tokens: usage_columns[:output_tokens],
313
- total_tokens: usage_columns[:total_tokens]
314
- }.freeze
315
- end
316
- end
317
339
  end
318
340
  end
data/lib/llm/skill.rb CHANGED
@@ -45,6 +45,10 @@ module LLM
45
45
  # @return [Array<Class<LLM::Tool>>]
46
46
  attr_reader :tools
47
47
 
48
+ ##
49
+ # @param [String] path
50
+ # The path to a directory
51
+ # @return [LLM::Skill]
48
52
  def initialize(path)
49
53
  @path = path.to_s
50
54
  @name = ::File.basename(@path)
@@ -65,40 +69,51 @@ module LLM
65
69
 
66
70
  ##
67
71
  # Execute the skill by wrapping it in a small agent with the skill
68
- # instructions. The provider is bound explicitly by the caller.
69
- # @param [LLM::Provider] llm
70
- # @param [Hash] input
72
+ # instructions. The context is bound explicitly by the caller so the
73
+ # nested agent can inherit context-level behavior such as streaming.
74
+ # @param [LLM::Context] ctx
71
75
  # @return [Hash]
72
- def call(llm, **)
73
- instructions = self.instructions
74
- tools = self.tools
76
+ def call(ctx)
77
+ instructions, tools = self.instructions, self.tools
78
+ params = ctx.params.merge(mode: ctx.mode).reject { [:tools, :schema].include?(_1) }
75
79
  agent = Class.new(LLM::Agent) do
76
- instructions instructions
80
+ instructions(instructions)
77
81
  tools(*tools)
78
- end.new(llm)
79
- res = agent.talk(instructions)
82
+ end.new(ctx.llm, params)
83
+ agent.messages.concat(messages_for(ctx))
84
+ res = agent.talk("Solve the user's query.")
80
85
  {content: res.content}
81
86
  end
82
87
 
83
88
  ##
84
- # Expose the skill as a normal LLM::Tool. The provider is bound explicitly
89
+ # Expose the skill as a normal LLM::Tool. The context is bound explicitly
85
90
  # when the tool class is built.
86
- # @param [LLM::Provider] llm
91
+ # @param [LLM::Context] ctx
87
92
  # @return [Class<LLM::Tool>]
88
- def to_tool(llm)
93
+ def to_tool(ctx)
89
94
  skill = self
90
95
  Class.new(LLM::Tool) do
91
96
  name skill.name
92
97
  description skill.description
93
98
 
94
- define_method(:call) do |**input|
95
- skill.call(llm, **input)
99
+ define_method(:call) do
100
+ skill.call(ctx)
96
101
  end
97
102
  end
98
103
  end
99
104
 
100
105
  private
101
106
 
107
+ def messages_for(ctx)
108
+ messages = ctx.messages
109
+ .to_a
110
+ .select { _1.user? || _1.assistant? }
111
+ .reject { _1.tool_call? || _1.tool_return? }
112
+ .last(8)
113
+ return messages if messages.empty?
114
+ [LLM::Message.new(:user, "Recent context:"), *messages]
115
+ end
116
+
102
117
  def parse(content)
103
118
  match = content.match(/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m)
104
119
  unless match
data/lib/llm/stream.rb CHANGED
@@ -18,7 +18,8 @@ module LLM
18
18
  #
19
19
  # The most common callback is {#on_content}, which also maps to {#<<}.
20
20
  # Providers may also call {#on_reasoning_content} and {#on_tool_call} when
21
- # that data is available.
21
+ # that data is available. Runtime features such as context compaction may
22
+ # also emit lifecycle callbacks like {#on_compaction}.
22
23
  class Stream
23
24
  require_relative "stream/queue"
24
25
 
@@ -103,6 +104,24 @@ module LLM
103
104
  nil
104
105
  end
105
106
 
107
+ ##
108
+ # Called before a context compaction starts.
109
+ # @param [LLM::Context] ctx
110
+ # @param [LLM::Compactor] compactor
111
+ # @return [nil]
112
+ def on_compaction(ctx, compactor)
113
+ nil
114
+ end
115
+
116
+ ##
117
+ # Called after a context compaction finishes.
118
+ # @param [LLM::Context] ctx
119
+ # @param [LLM::Compactor] compactor
120
+ # @return [nil]
121
+ def on_compaction_finish(ctx, compactor)
122
+ nil
123
+ end
124
+
106
125
  # @endgroup
107
126
 
108
127
  # @group Error handlers
data/lib/llm/tool.rb CHANGED
@@ -171,4 +171,18 @@ class LLM::Tool
171
171
  def self.mcp?
172
172
  false
173
173
  end
174
+
175
+ ##
176
+ # Returns a function bound to this tool instance.
177
+ # @return [LLM::Function]
178
+ def function
179
+ @function ||= self.class.function.dup.tap { _1.register(self) }
180
+ end
181
+
182
+ ##
183
+ # Returns true if the tool is an MCP tool
184
+ # @return [Boolean]
185
+ def mcp?
186
+ self.class.mcp?
187
+ end
174
188
  end
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "4.21.0"
4
+ VERSION = "4.23.0"
5
5
  end
data/llm.gemspec CHANGED
@@ -54,4 +54,7 @@ Gem::Specification.new do |spec|
54
54
  spec.add_development_dependency "net-http-persistent", "~> 4.0"
55
55
  spec.add_development_dependency "opentelemetry-sdk", "~> 1.10"
56
56
  spec.add_development_dependency "logger", "~> 1.7"
57
+ spec.add_development_dependency "activerecord", "~> 8.0"
58
+ spec.add_development_dependency "sequel", "~> 5.0"
59
+ spec.add_development_dependency "sqlite3", "~> 2.0"
57
60
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.21.0
4
+ version: 4.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri
@@ -194,6 +194,48 @@ dependencies:
194
194
  - - "~>"
195
195
  - !ruby/object:Gem::Version
196
196
  version: '1.7'
197
+ - !ruby/object:Gem::Dependency
198
+ name: activerecord
199
+ requirement: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - "~>"
202
+ - !ruby/object:Gem::Version
203
+ version: '8.0'
204
+ type: :development
205
+ prerelease: false
206
+ version_requirements: !ruby/object:Gem::Requirement
207
+ requirements:
208
+ - - "~>"
209
+ - !ruby/object:Gem::Version
210
+ version: '8.0'
211
+ - !ruby/object:Gem::Dependency
212
+ name: sequel
213
+ requirement: !ruby/object:Gem::Requirement
214
+ requirements:
215
+ - - "~>"
216
+ - !ruby/object:Gem::Version
217
+ version: '5.0'
218
+ type: :development
219
+ prerelease: false
220
+ version_requirements: !ruby/object:Gem::Requirement
221
+ requirements:
222
+ - - "~>"
223
+ - !ruby/object:Gem::Version
224
+ version: '5.0'
225
+ - !ruby/object:Gem::Dependency
226
+ name: sqlite3
227
+ requirement: !ruby/object:Gem::Requirement
228
+ requirements:
229
+ - - "~>"
230
+ - !ruby/object:Gem::Version
231
+ version: '2.0'
232
+ type: :development
233
+ prerelease: false
234
+ version_requirements: !ruby/object:Gem::Requirement
235
+ requirements:
236
+ - - "~>"
237
+ - !ruby/object:Gem::Version
238
+ version: '2.0'
197
239
  description: |
198
240
  llm.rb is a lightweight runtime for building capable AI systems in Ruby.
199
241
  It is not just an API wrapper. llm.rb gives you one runtime for providers,
@@ -229,6 +271,7 @@ files:
229
271
  - lib/llm/agent.rb
230
272
  - lib/llm/bot.rb
231
273
  - lib/llm/buffer.rb
274
+ - lib/llm/compactor.rb
232
275
  - lib/llm/context.rb
233
276
  - lib/llm/context/deserializer.rb
234
277
  - lib/llm/context/serializer.rb