llm.rb 4.20.2 → 4.22.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.
@@ -13,6 +13,7 @@ module LLM::ActiveRecord
13
13
  EMPTY_HASH = LLM::ActiveRecord::ActsAsLLM::EMPTY_HASH
14
14
  DEFAULT_USAGE_COLUMNS = LLM::ActiveRecord::ActsAsLLM::DEFAULT_USAGE_COLUMNS
15
15
  DEFAULTS = LLM::ActiveRecord::ActsAsLLM::DEFAULTS
16
+ Utils = LLM::ActiveRecord::ActsAsLLM::Utils
16
17
 
17
18
  module ClassMethods
18
19
  def model(model = nil)
@@ -52,7 +53,7 @@ module LLM::ActiveRecord
52
53
  # @param [Class] model
53
54
  # @return [void]
54
55
  def self.extended(model)
55
- options = model.llm_agent_options
56
+ options = model.llm_plugin_options
56
57
  model.validates options[:provider_column], options[:model_column], presence: true
57
58
  model.include LLM::ActiveRecord::ActsAsLLM::InstanceMethods unless model.ancestors.include?(LLM::ActiveRecord::ActsAsLLM::InstanceMethods)
58
59
  model.include InstanceMethods unless model.ancestors.include?(InstanceMethods)
@@ -79,8 +80,8 @@ module LLM::ActiveRecord
79
80
  def acts_as_agent(options = EMPTY_HASH, &block)
80
81
  options = DEFAULTS.merge(options)
81
82
  usage_columns = DEFAULT_USAGE_COLUMNS.merge(options[:usage_columns] || EMPTY_HASH)
82
- class_attribute :llm_agent_options, instance_accessor: false, default: DEFAULTS unless respond_to?(:llm_agent_options)
83
- self.llm_agent_options = options.merge(usage_columns: usage_columns.freeze).freeze
83
+ class_attribute :llm_plugin_options, instance_accessor: false, default: DEFAULTS unless respond_to?(:llm_plugin_options)
84
+ self.llm_plugin_options = options.merge(usage_columns: usage_columns.freeze).freeze
84
85
  extend Hooks
85
86
  class_exec(&block) if block
86
87
  end
@@ -90,12 +91,13 @@ module LLM::ActiveRecord
90
91
  # Returns the resolved provider instance for this record.
91
92
  # @return [LLM::Provider]
92
93
  def llm
93
- options = self.class.llm_agent_options
94
+ options = self.class.llm_plugin_options
95
+ columns = Utils.columns(options)
94
96
  provider = self[columns[:provider_column]]
95
- kwargs = resolve_options(options[:provider])
97
+ kwargs = Utils.resolve_options(self, options[:provider], ActsAsAgent::EMPTY_HASH)
96
98
  return @llm if @llm
97
99
  @llm = LLM.method(provider).call(**kwargs)
98
- @llm.tracer = resolve_option(options[:tracer]) if options[:tracer]
100
+ @llm.tracer = Utils.resolve_option(self, options[:tracer]) if options[:tracer]
99
101
  @llm
100
102
  end
101
103
 
@@ -105,8 +107,9 @@ module LLM::ActiveRecord
105
107
  # @return [LLM::Agent]
106
108
  def ctx
107
109
  @ctx ||= begin
108
- options = self.class.llm_agent_options
109
- params = resolve_options(options[:context]).dup
110
+ options = self.class.llm_plugin_options
111
+ columns = Utils.columns(options)
112
+ params = Utils.resolve_options(self, options[:context], ActsAsAgent::EMPTY_HASH).dup
110
113
  params[:model] ||= self[columns[:model_column]]
111
114
  ctx = self.class.agent.new(llm, params.compact)
112
115
  data = self[columns[:data_column]]
@@ -121,62 +124,6 @@ module LLM::ActiveRecord
121
124
  end
122
125
  end
123
126
  end
124
-
125
- ##
126
- # @return [void]
127
- def flush
128
- attrs = {
129
- columns[:data_column] => serialize_context(self.class.llm_agent_options[:format]),
130
- columns[:input_tokens] => ctx.usage.input_tokens,
131
- columns[:output_tokens] => ctx.usage.output_tokens,
132
- columns[:total_tokens] => ctx.usage.total_tokens
133
- }
134
- assign_attributes(attrs)
135
- save!
136
- end
137
-
138
- ##
139
- # @return [Hash]
140
- def resolve_option(option)
141
- case option
142
- when Proc then instance_exec(&option)
143
- when Symbol then send(option)
144
- when Hash then option.dup
145
- else option
146
- end
147
- end
148
-
149
- ##
150
- # @return [Hash]
151
- def resolve_options(option)
152
- case option
153
- when Proc, Symbol, Hash then resolve_option(option)
154
- else ActsAsAgent::EMPTY_HASH.dup
155
- end
156
- end
157
-
158
- def serialize_context(format)
159
- case format
160
- when :string then ctx.to_json
161
- when :json, :jsonb then ctx.to_h
162
- else raise ArgumentError, "Unknown format: #{format.inspect}"
163
- end
164
- end
165
-
166
- def columns
167
- @columns ||= begin
168
- options = self.class.llm_agent_options
169
- usage_columns = options[:usage_columns]
170
- {
171
- provider_column: options[:provider_column],
172
- model_column: options[:model_column],
173
- data_column: options[:data_column],
174
- input_tokens: usage_columns[:input_tokens],
175
- output_tokens: usage_columns[:output_tokens],
176
- total_tokens: usage_columns[:total_tokens]
177
- }.freeze
178
- end
179
- end
180
127
  end
181
128
  end
182
129
  end
@@ -33,6 +33,77 @@ module LLM::ActiveRecord
33
33
  context: EMPTY_HASH
34
34
  }.freeze
35
35
 
36
+ ##
37
+ # Shared helper methods for the ORM wrapper.
38
+ #
39
+ # These utilities keep persistence plumbing out of the wrapped model's
40
+ # method namespace so the injected surface stays focused on the runtime
41
+ # API itself.
42
+ # @api private
43
+ module Utils
44
+ ##
45
+ # Resolves a single configured option against a model instance.
46
+ # @return [Object]
47
+ def self.resolve_option(obj, option)
48
+ case option
49
+ when Proc then obj.instance_exec(&option)
50
+ when Symbol then obj.send(option)
51
+ when Hash then option.dup
52
+ else option
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Resolves hash-like wrapper options against a model instance.
58
+ # @return [Hash]
59
+ def self.resolve_options(obj, option, empty_hash)
60
+ case option
61
+ when Proc, Symbol, Hash then resolve_option(obj, option)
62
+ else empty_hash.dup
63
+ end
64
+ end
65
+
66
+ ##
67
+ # Serializes the runtime into the configured storage format.
68
+ # @return [String, Hash]
69
+ def self.serialize_context(ctx, format)
70
+ case format
71
+ when :string then ctx.to_json
72
+ when :json, :jsonb then ctx.to_h
73
+ else raise ArgumentError, "Unknown format: #{format.inspect}"
74
+ end
75
+ end
76
+
77
+ ##
78
+ # Maps wrapper options onto the record's storage columns.
79
+ # @return [Hash]
80
+ def self.columns(options)
81
+ usage_columns = options[:usage_columns]
82
+ {
83
+ provider_column: options[:provider_column],
84
+ model_column: options[:model_column],
85
+ data_column: options[:data_column],
86
+ input_tokens: usage_columns[:input_tokens],
87
+ output_tokens: usage_columns[:output_tokens],
88
+ total_tokens: usage_columns[:total_tokens]
89
+ }.freeze
90
+ end
91
+
92
+ ##
93
+ # Persists the runtime state and usage columns back onto the record.
94
+ # @return [void]
95
+ def self.save(obj, ctx, options)
96
+ columns = self.columns(options)
97
+ obj.assign_attributes(
98
+ columns[:data_column] => serialize_context(ctx, options[:format]),
99
+ columns[:input_tokens] => ctx.usage.input_tokens,
100
+ columns[:output_tokens] => ctx.usage.output_tokens,
101
+ columns[:total_tokens] => ctx.usage.total_tokens
102
+ )
103
+ obj.save!
104
+ end
105
+ end
106
+
36
107
  module Hooks
37
108
  ##
38
109
  # Called when hooks are extended onto an ActiveRecord model.
@@ -72,7 +143,8 @@ module LLM::ActiveRecord
72
143
  # @see LLM::Context#talk
73
144
  # @return [LLM::Response]
74
145
  def talk(...)
75
- ctx.talk(...).tap { flush }
146
+ options = self.class.llm_plugin_options
147
+ ctx.talk(...).tap { Utils.save(self, ctx, options) }
76
148
  end
77
149
 
78
150
  ##
@@ -80,7 +152,8 @@ module LLM::ActiveRecord
80
152
  # @see LLM::Context#respond
81
153
  # @return [LLM::Response]
82
154
  def respond(...)
83
- ctx.respond(...).tap { flush }
155
+ options = self.class.llm_plugin_options
156
+ ctx.respond(...).tap { Utils.save(self, ctx, options) }
84
157
  end
85
158
 
86
159
  ##
@@ -155,6 +228,7 @@ module LLM::ActiveRecord
155
228
  # Returns usage from the mapped usage columns.
156
229
  # @return [LLM::Object]
157
230
  def usage
231
+ columns = Utils.columns(self.class.llm_plugin_options)
158
232
  LLM::Object.from(
159
233
  input_tokens: self[columns[:input_tokens]] || 0,
160
234
  output_tokens: self[columns[:output_tokens]] || 0,
@@ -211,11 +285,12 @@ module LLM::ActiveRecord
211
285
  # @return [LLM::Provider]
212
286
  def llm
213
287
  options = self.class.llm_plugin_options
288
+ columns = Utils.columns(options)
214
289
  provider = self[columns[:provider_column]]
215
- kwargs = resolve_options(options[:provider])
290
+ kwargs = Utils.resolve_options(self, options[:provider], ActsAsLLM::EMPTY_HASH)
216
291
  return @llm if @llm
217
292
  @llm = LLM.method(provider).call(**kwargs)
218
- @llm.tracer = resolve_option(options[:tracer]) if options[:tracer]
293
+ @llm.tracer = Utils.resolve_option(self, options[:tracer]) if options[:tracer]
219
294
  @llm
220
295
  end
221
296
 
@@ -226,7 +301,8 @@ module LLM::ActiveRecord
226
301
  def ctx
227
302
  @ctx ||= begin
228
303
  options = self.class.llm_plugin_options
229
- params = resolve_options(options[:context]).dup
304
+ columns = Utils.columns(options)
305
+ params = Utils.resolve_options(self, options[:context], ActsAsLLM::EMPTY_HASH).dup
230
306
  params[:model] ||= self[columns[:model_column]]
231
307
  ctx = LLM::Context.new(llm, params.compact)
232
308
  data = self[columns[:data_column]]
@@ -241,62 +317,6 @@ module LLM::ActiveRecord
241
317
  end
242
318
  end
243
319
  end
244
-
245
- ##
246
- # @return [void]
247
- def flush
248
- attrs = {
249
- columns[:data_column] => serialize_context(self.class.llm_plugin_options[:format]),
250
- columns[:input_tokens] => ctx.usage.input_tokens,
251
- columns[:output_tokens] => ctx.usage.output_tokens,
252
- columns[:total_tokens] => ctx.usage.total_tokens
253
- }
254
- assign_attributes(attrs)
255
- save!
256
- end
257
-
258
- ##
259
- # @return [Hash]
260
- def resolve_option(option)
261
- case option
262
- when Proc then instance_exec(&option)
263
- when Symbol then send(option)
264
- when Hash then option.dup
265
- else option
266
- end
267
- end
268
-
269
- ##
270
- # @return [Hash]
271
- def resolve_options(option)
272
- case option
273
- when Proc, Symbol, Hash then resolve_option(option)
274
- else ActsAsLLM::EMPTY_HASH.dup
275
- end
276
- end
277
-
278
- def serialize_context(format)
279
- case format
280
- when :string then ctx.to_json
281
- when :json, :jsonb then ctx.to_h
282
- else raise ArgumentError, "Unknown format: #{format.inspect}"
283
- end
284
- end
285
-
286
- def columns
287
- @columns ||= begin
288
- options = self.class.llm_plugin_options
289
- usage_columns = options[:usage_columns]
290
- {
291
- provider_column: options[:provider_column],
292
- model_column: options[:model_column],
293
- data_column: options[:data_column],
294
- input_tokens: usage_columns[:input_tokens],
295
- output_tokens: usage_columns[:output_tokens],
296
- total_tokens: usage_columns[:total_tokens]
297
- }.freeze
298
- end
299
- end
300
320
  end
301
321
  end
302
322
  end
data/lib/llm/agent.rb CHANGED
@@ -14,7 +14,7 @@ module LLM
14
14
  # `respond`, instead of leaving tool loops to the caller.
15
15
  #
16
16
  # **Notes:**
17
- # * Instructions are injected only on the first request.
17
+ # * Instructions are injected once unless a system message is already present.
18
18
  # * An agent automatically executes tool loops (unlike {LLM::Context LLM::Context}).
19
19
  # * Tool loop execution can be configured with `concurrency :call`,
20
20
  # `:thread`, `:task`, `:fiber`, `:ractor`, or a list of queued task
@@ -59,6 +59,17 @@ module LLM
59
59
  @tools = tools.flatten
60
60
  end
61
61
 
62
+ ##
63
+ # Set or get the default skills
64
+ # @param [Array<String>, nil] skills
65
+ # One or more skill directories
66
+ # @return [Array<String>, nil]
67
+ # Returns the current skills when no argument is provided
68
+ def self.skills(*skills)
69
+ return @skills if skills.empty?
70
+ @skills = skills.flatten
71
+ end
72
+
62
73
  ##
63
74
  # Set or get the default schema
64
75
  # @param [#to_json, nil] schema
@@ -110,10 +121,11 @@ module LLM
110
121
  # not only those listed here.
111
122
  # @option params [String] :model Defaults to the provider's default model
112
123
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
124
+ # @option params [Array<String>, nil] :skills Defaults to nil
113
125
  # @option params [#to_json, nil] :schema Defaults to nil
114
126
  # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
115
127
  def initialize(llm, params = {})
116
- defaults = {model: self.class.model, tools: self.class.tools, schema: self.class.schema}.compact
128
+ defaults = {model: self.class.model, tools: self.class.tools, skills: self.class.skills, schema: self.class.schema}.compact
117
129
  @concurrency = params.delete(:concurrency) || self.class.concurrency
118
130
  @llm = llm
119
131
  @ctx = LLM::Context.new(llm, defaults.merge(params))
@@ -337,16 +349,28 @@ module LLM
337
349
  instr = self.class.instructions
338
350
  return new_prompt unless instr
339
351
  if LLM::Prompt === new_prompt
340
- new_prompt.system(instr) if @ctx.messages.empty?
352
+ new_prompt.system(instr) if inject_instructions?(new_prompt)
341
353
  new_prompt
342
354
  else
343
355
  prompt do
344
- _1.system(instr) if @ctx.messages.empty?
356
+ _1.system(instr) if inject_instructions?
345
357
  _1.user(new_prompt)
346
358
  end
347
359
  end
348
360
  end
349
361
 
362
+ ##
363
+ # Returns true when agent instructions should be injected for the turn.
364
+ # Instructions are injected once unless a system message is already
365
+ # present in the existing context or the prompt being sent.
366
+ # @param [LLM::Prompt, nil] prompt
367
+ # @return [Boolean]
368
+ def inject_instructions?(prompt = nil)
369
+ return false if @ctx.messages.any?(&:system?)
370
+ return true if prompt.nil?
371
+ !prompt.to_a.any?(&:system?)
372
+ end
373
+
350
374
  ##
351
375
  # @return [Array<LLM::Function::Return>]
352
376
  def call_functions
data/lib/llm/context.rb CHANGED
@@ -54,6 +54,13 @@ module LLM
54
54
  # @return [Symbol]
55
55
  attr_reader :mode
56
56
 
57
+ ##
58
+ # Returns the default params for this context
59
+ # @return [Hash]
60
+ def params
61
+ @params.dup
62
+ end
63
+
57
64
  ##
58
65
  # @param [LLM::Provider] llm
59
66
  # A provider
@@ -64,10 +71,13 @@ module LLM
64
71
  # @option params [Symbol] :mode Defaults to :completions
65
72
  # @option params [String] :model Defaults to the provider's default model
66
73
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
74
+ # @option params [Array<String>, nil] :skills Defaults to nil
67
75
  def initialize(llm, params = {})
68
76
  @llm = llm
69
77
  @mode = params.delete(:mode) || :completions
78
+ tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
70
79
  @params = {model: llm.default_model, schema: nil}.compact.merge!(params)
80
+ @params[:tools] = tools unless tools.empty?
71
81
  @messages = LLM::Buffer.new(llm)
72
82
  end
73
83
 
@@ -345,6 +355,10 @@ module LLM
345
355
  stream.extra[:tracer] = tracer
346
356
  stream.extra[:model] = model
347
357
  end
358
+
359
+ def load_skills(skills)
360
+ [*skills].map { LLM::Skill.load(_1).to_tool(self) }
361
+ end
348
362
  end
349
363
 
350
364
  # Backward-compatible alias
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Sequel
4
+ ##
5
+ # Sequel plugin for persisting {LLM::Agent LLM::Agent} state.
6
+ #
7
+ # This wrapper reuses the same record-backed runtime surface as
8
+ # {LLM::Sequel::Plugin}, but builds an {LLM::Agent LLM::Agent} instead of an
9
+ # {LLM::Context LLM::Context}. Agent defaults such as model, tools, schema,
10
+ # instructions, and concurrency are configured on the model class and
11
+ # forwarded to an internal agent subclass.
12
+ module Agent
13
+ require_relative "plugin"
14
+ EMPTY_HASH = LLM::Sequel::Plugin::EMPTY_HASH
15
+ DEFAULT_USAGE_COLUMNS = LLM::Sequel::Plugin::DEFAULT_USAGE_COLUMNS
16
+ DEFAULTS = LLM::Sequel::Plugin::DEFAULTS
17
+ Utils = LLM::Sequel::Plugin::Utils
18
+
19
+ def self.apply(model, **)
20
+ model.extend ClassMethods
21
+ model.include LLM::Sequel::Plugin::InstanceMethods
22
+ model.include InstanceMethods
23
+ end
24
+
25
+ def self.configure(model, options = EMPTY_HASH, &block)
26
+ options = DEFAULTS.merge(options)
27
+ usage_columns = DEFAULT_USAGE_COLUMNS.merge(options[:usage_columns] || EMPTY_HASH)
28
+ model.instance_variable_set(
29
+ :@llm_agent_options,
30
+ options.merge(usage_columns: usage_columns.freeze).freeze
31
+ )
32
+ model.instance_exec(&block) if block
33
+ end
34
+
35
+ module ClassMethods
36
+ def llm_plugin_options
37
+ @llm_agent_options || Agent::DEFAULTS
38
+ end
39
+
40
+ def model(model = nil)
41
+ return agent.model if model.nil?
42
+ agent.model(model)
43
+ end
44
+
45
+ def tools(*tools)
46
+ return agent.tools if tools.empty?
47
+ agent.tools(*tools)
48
+ end
49
+
50
+ def schema(schema = nil)
51
+ return agent.schema if schema.nil?
52
+ agent.schema(schema)
53
+ end
54
+
55
+ def instructions(instructions = nil)
56
+ return agent.instructions if instructions.nil?
57
+ agent.instructions(instructions)
58
+ end
59
+
60
+ def concurrency(concurrency = nil)
61
+ return agent.concurrency if concurrency.nil?
62
+ agent.concurrency(concurrency)
63
+ end
64
+
65
+ def agent
66
+ @agent ||= Class.new(LLM::Agent)
67
+ end
68
+ end
69
+
70
+ module InstanceMethods
71
+ private
72
+
73
+ def ctx
74
+ @ctx ||= begin
75
+ options = self.class.llm_plugin_options
76
+ columns = Agent::Utils.columns(options)
77
+ params = Agent::Utils.resolve_options(self, options[:context], Agent::EMPTY_HASH).dup
78
+ params[:model] ||= self[columns[:model_column]]
79
+ ctx = self.class.agent.new(llm, params.compact)
80
+ data = self[columns[:data_column]]
81
+ if data.nil? || data == ""
82
+ ctx
83
+ else
84
+ case options[:format]
85
+ when :string then ctx.restore(string: data)
86
+ when :json, :jsonb then ctx.restore(data:)
87
+ else raise ArgumentError, "Unknown format: #{options[:format].inspect}"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end