llm.rb 15.0.0 → 15.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f32731cd1933be73ca1297c6733b25ccc3873cb2d617bfbf5ae4d556714aaca
4
- data.tar.gz: cbe78b1776b8c4df9f60341b901436fbd69783fca251b3656cf4cd9851815b49
3
+ metadata.gz: c86e9ccde2cc0844b5172935bc222c1adf3cda8b0f49e84730179bcc635b2d2b
4
+ data.tar.gz: 60c740c3dc2c20d8b1d86de55ae5ef25eb7bbe5b5b567f3766c059b37520efcc
5
5
  SHA512:
6
- metadata.gz: 01e6e7d10f5359ae9ba5f7643c4c0679ef22c99deed608e45b44d2afd52f42f2069a317b4c56abd76bc569d40f070d8dc74a5d981c727fc23871a5d4df69aea6
7
- data.tar.gz: d2c61e22df6a2f5b4c206f4f13f3b8ec3b0e4014f1d0608ed78adadf598e00b576fd486c01040f655adec0af3efb8c3e1685fcb22ef31038a0917d13481072a2
6
+ metadata.gz: fd1901c5bebd659b242db55ed91ea4f538832c24b4ab119b80f46ae987fc5166823fa70174d9f545c079addd90487eb379b1dfd6eeea2365d045a831b861134e
7
+ data.tar.gz: 5949efbaa7381fb0cab7328e64c08af4022a8b976a24b8b24901c167c67cd060f912a7054956a7942cd7cf67b3f0aa226ad5aec608bcd273bfa1aa25d3ffb5d0
data/CHANGELOG.md CHANGED
@@ -15,6 +15,53 @@
15
15
 
16
16
  ## What's next
17
17
 
18
+ ## v15.0.2
19
+
20
+ Changes since `v15.0.1`.
21
+
22
+ This release fixes a concurrency race in MCP tool calls by
23
+ reference-counting the transport session, so overlapping tool calls
24
+ reuse the running transport instead of racing `start`/`stop`.
25
+
26
+ ### Fix
27
+
28
+ * **mcp: fix concurrent MCP tool call race** <br>
29
+ `LLM::MCP` now reference-counts its transport session: the first
30
+ caller starts the transport and the last caller stops it. Concurrent
31
+ or overlapping tool calls reuse the running transport instead of
32
+ racing `start`/`stop`, avoiding "MCP transport is not running" errors
33
+ that could occur with the `async` strategy. An externally started
34
+ transport is never stopped by a borrower.
35
+
36
+ ## v15.0.1
37
+
38
+ Changes since `v15.0.0`.
39
+
40
+ This release fixes agent `set` handling of single `Symbol`/`Proc`
41
+ values and resolves ORM options as `Symbol`s through the bound record
42
+ instead of the `LLM::Agent` instance.
43
+
44
+ ### Agent
45
+
46
+ * **agent: fix `set (skills|tools): Symbol|Proc`** <br>
47
+ The `skills`, `tools`, `confirm`, and `schema` class accessors now
48
+ consistently resolve a single `Symbol` or `Proc` lazily at agent
49
+ initialization, so `LLM::Agent.set(skills: proc { [...] })` and
50
+ `LLM::Agent.set(tools: :tools)` no longer raise. The `set` family
51
+ shares one `single_callable?` helper rather than each accessor
52
+ duplicating its own logic.
53
+
54
+ ### Fix
55
+
56
+ * **orm: resolve a `Symbol` through the bound record** <br>
57
+ When an ORM model using `acts_as_agent` (ActiveRecord), `plugin :agent`
58
+ (Sequel), or `acts_as_llm` / `plugin :llm` configures an option as a
59
+ `Symbol`, that symbol is now resolved on the model instance (or its
60
+ bound record) instead of the `LLM::Agent` instance. The contexts and
61
+ agents built by the wrappers are now bound to the record, so a model like
62
+ `agent.set :tools` with a `tools` method on the record works as
63
+ expected.
64
+
18
65
  ## v15.0.0
19
66
 
20
67
  Changes since `v14.0.0`.
data/README.md CHANGED
@@ -25,6 +25,13 @@ Once you learn the fundamentals, everything else falls into place
25
25
  naturally. Some features, such as ActiveRecord support, require
26
26
  optional dependencies that are opt-in.
27
27
 
28
+ The best way to learn about llm.rb is to ask [the official chatbot](https://r.uby.dev)
29
+ a question. It is connected to the llm.rb GitHub repository, backed by
30
+ ActiveRecord and uses the builtin MCP feature to connect to GitHub. If
31
+ you want to know more then simply [ask the chatbot](https://r.uby.dev) and
32
+ you should get an answer that is grounded in the source code. It is 100%
33
+ free to use and powered by DeepSeek under the hood.
34
+
28
35
  ## Install
29
36
 
30
37
  ```bash
@@ -126,7 +126,7 @@ module LLM::ActiveRecord
126
126
  @ctx ||= begin
127
127
  options = self.class.llm_plugin_options
128
128
  params = Utils.resolve_options(self, options[:context], EMPTY_HASH).dup
129
- ctx = self.class.agent.new(llm, params.compact)
129
+ ctx = self.class.agent.new(llm, params.compact.merge(record: self))
130
130
  columns = Utils.columns(options)
131
131
  data = self[columns[:data_column]]
132
132
  if data.nil? || data == ""
@@ -243,7 +243,7 @@ module LLM::ActiveRecord
243
243
  options = self.class.llm_plugin_options
244
244
  columns = Utils.columns(options)
245
245
  params = Utils.resolve_options(self, options[:context], EMPTY_HASH).dup
246
- ctx = LLM::Context.new(llm, params.compact)
246
+ ctx = LLM::Context.new(llm, params.compact.merge(record: self))
247
247
  data = self[columns[:data_column]]
248
248
  if data.nil? || data == ""
249
249
  ctx
data/lib/llm/agent.rb CHANGED
@@ -57,14 +57,16 @@ module LLM
57
57
  ##
58
58
  # Ugh :)
59
59
  # @api private
60
- FIELDS = %i[name description
60
+ FIELDS = %i[record
61
+ name description
61
62
  path tool_budget
62
63
  retry_budget model
63
64
  skills schema
64
65
  tracer stream
65
66
  tools concurrency
66
67
  instructions confirm]
67
- IVARS = %i[name description
68
+ IVARS = %i[record
69
+ name description
68
70
  path tool_budget
69
71
  tracer concurrency
70
72
  instructions confirm]
@@ -169,10 +171,21 @@ module LLM
169
171
  # @return [String, nil]
170
172
  # Returns the current model when no argument is provided
171
173
  def self.model(model = nil, &block)
172
- return @model if model.nil? && !block
174
+ return @model if model.nil? and !block
173
175
  @model = block || model
174
176
  end
175
177
 
178
+ ##
179
+ # Set or get the default schema
180
+ # @param [#to_json, nil] schema
181
+ # The schema
182
+ # @return [#to_json, nil]
183
+ # Returns the current schema when no argument is provided
184
+ def self.schema(schema = nil, &block)
185
+ return @schema if schema.nil? and !block
186
+ @schema = block || schema
187
+ end
188
+
176
189
  ##
177
190
  # Set or get the default tools
178
191
  # @param [Array<LLM::Function>, nil] tools
@@ -180,11 +193,13 @@ module LLM
180
193
  # @return [Array<LLM::Function>]
181
194
  # Returns the current tools when no argument is provided
182
195
  def self.tools(*tools, &block)
183
- return @tools || [] if tools.empty? && !block
184
- if tools.size == 1 and tools.grep(Symbol).any?
196
+ return @tools || [] if tools.empty? and !block
197
+ if block
198
+ @tools = block
199
+ elsif single_callable?(tools)
185
200
  @tools = tools.first
186
201
  else
187
- @tools = block || tools.flatten
202
+ @tools = tools.flatten
188
203
  end
189
204
  end
190
205
 
@@ -195,23 +210,46 @@ module LLM
195
210
  # @return [Array<String>, nil]
196
211
  # Returns the current skills when no argument is provided
197
212
  def self.skills(*skills, &block)
198
- return @skills if skills.empty? && !block
199
- if skills.size == 1 and skills.grep(Symbol).any?
213
+ return @skills if skills.empty? and !block
214
+ if block
215
+ @skills = block
216
+ elsif single_callable?(skills)
200
217
  @skills = skills.first
201
218
  else
202
- @skills = block || skills.flatten
219
+ @skills = skills.flatten
203
220
  end
204
221
  end
205
222
 
206
223
  ##
207
- # Set or get the default schema
208
- # @param [#to_json, nil] schema
209
- # The schema
210
- # @return [#to_json, nil]
211
- # Returns the current schema when no argument is provided
212
- def self.schema(schema = nil, &block)
213
- return @schema if schema.nil? && !block
214
- @schema = block || schema
224
+ # Set or get the tool names that require confirmation before they can run.
225
+ #
226
+ # When a single Symbol is given, it is stored as-is and resolved at
227
+ # initialization time by calling the method with that name on the agent
228
+ # instance. This allows dynamic tool confirmation lists.
229
+ #
230
+ # @example
231
+ # class MyAgent < LLM::Agent
232
+ # confirm :tools_that_need_confirmation
233
+ #
234
+ # def tools_that_need_confirmation
235
+ # some_condition ? %w[delete destroy] : %w[delete]
236
+ # end
237
+ # end
238
+ #
239
+ # @param [String, Symbol, Array<String, Symbol>, Proc] tool_names
240
+ # One or more tool names.
241
+ # @param [Proc] block
242
+ # An optional, lazy-evaluated Proc
243
+ # @return [Array<String>, Proc, Symbol, nil]
244
+ def self.confirm(*tool_names, &block)
245
+ return @confirm if tool_names.empty? and !block
246
+ if block
247
+ @confirm = block
248
+ elsif single_callable?(tool_names)
249
+ @confirm = tool_names.first
250
+ else
251
+ @confirm = tool_names.flatten.map(&:to_s)
252
+ end
215
253
  end
216
254
 
217
255
  ##
@@ -285,36 +323,6 @@ module LLM
285
323
  @stream = block || stream
286
324
  end
287
325
 
288
- ##
289
- # Set or get the tool names that require confirmation before they can run.
290
- #
291
- # When a single Symbol is given, it is stored as-is and resolved at
292
- # initialization time by calling the method with that name on the agent
293
- # instance. This allows dynamic tool confirmation lists.
294
- #
295
- # @example
296
- # class MyAgent < LLM::Agent
297
- # confirm :tools_that_need_confirmation
298
- #
299
- # def tools_that_need_confirmation
300
- # some_condition ? %w[delete destroy] : %w[delete]
301
- # end
302
- # end
303
- #
304
- # @param [String, Symbol, Array<String, Symbol>, Proc] tool_names
305
- # One or more tool names.
306
- # @param [Proc] block
307
- # An optional, lazy-evaluated Proc
308
- # @return [Array<String>, Proc, Symbol, nil]
309
- def self.confirm(*tool_names, &block)
310
- return @confirm if tool_names.empty? && !block
311
- if tool_names.size == 1 && tool_names.grep(Symbol).any?
312
- @confirm = tool_names.first
313
- else
314
- @confirm = block || tool_names.flatten.map(&:to_s)
315
- end
316
- end
317
-
318
326
  ##
319
327
  # Set the file path where an agent's memory
320
328
  # can be restored from, and written to.
@@ -370,6 +378,13 @@ module LLM
370
378
  end
371
379
  end
372
380
 
381
+ ##
382
+ # @api private
383
+ def self.single_callable?(callable)
384
+ callable.size == 1 and (Proc === callable[0] or Symbol === callable[0])
385
+ end
386
+ private_class_method :single_callable?
387
+
373
388
  ##
374
389
  # @param [LLM::Provider] llm
375
390
  # A provider
@@ -389,7 +404,7 @@ module LLM
389
404
  @llm = llm
390
405
  fields, fields_ivar = FIELDS, IVARS
391
406
  fields.each do |field|
392
- resolvable = params.key?(field) ? params.delete(field) : self.class.public_send(field)
407
+ resolvable = params.key?(field) ? params.delete(field) : (self.class.respond_to?(field) ? self.class.public_send(field) : nil)
393
408
  resolve_symbol = !%i[concurrency].include?(field)
394
409
  resolved = resolvable != nil ? resolve_option(self, resolvable, resolve_symbol:) : resolvable
395
410
  resolved = [*resolved].map(&:to_s) if field == :confirm && resolved
@@ -420,6 +435,13 @@ module LLM
420
435
  @path
421
436
  end
422
437
 
438
+ ##
439
+ # Returns the ORM record this agent is bound to, or nil.
440
+ # @return [Object, nil]
441
+ def record
442
+ @record
443
+ end
444
+
423
445
  ##
424
446
  # Returns the agent's description
425
447
  # @return [String, nil]
data/lib/llm/context.rb CHANGED
@@ -46,7 +46,7 @@ module LLM
46
46
  # @api private
47
47
  # @return [Array<Symbol>]
48
48
  def self.params
49
- %w[guard retry_budget concurrency transformer compactor]
49
+ %w[guard retry_budget concurrency transformer compactor record]
50
50
  end
51
51
 
52
52
  ##
@@ -64,6 +64,11 @@ module LLM
64
64
  # @return [Symbol]
65
65
  attr_reader :mode
66
66
 
67
+ ##
68
+ # Returns the ORM record this context is bound to, or nil.
69
+ # @return [Object, nil]
70
+ attr_reader :record
71
+
67
72
  ##
68
73
  # @param [LLM::Provider] llm
69
74
  # A provider
@@ -95,6 +100,7 @@ module LLM
95
100
  def initialize(llm, params = {})
96
101
  params = {}.merge!(params)
97
102
  @llm = llm
103
+ @record = params.delete(:record)
98
104
  @mode = params.delete(:mode) || (llm.name == :openai ? :responses : :completions)
99
105
  tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
100
106
  @params = {model: llm.default_model, schema: nil}.compact.merge!(params)
data/lib/llm/mcp.rb CHANGED
@@ -87,7 +87,10 @@ class LLM::MCP
87
87
  # @return [LLM::MCP] A new MCP instance
88
88
  def initialize(stdio: nil, http: nil, timeout: 30)
89
89
  @timeout = timeout
90
- if stdio && http
90
+ @lock = Mutex.new
91
+ @borrowers = 0
92
+ @owned = false
93
+ if stdio and http
91
94
  raise ArgumentError, "stdio and http are mutually exclusive"
92
95
  elsif stdio
93
96
  @command = Command.new(**stdio)
@@ -125,7 +128,10 @@ class LLM::MCP
125
128
  # Propagates errors raised by {#start}, the block itself, or {#stop}
126
129
  # @return [void]
127
130
  def run
128
- start
131
+ @lock.synchronize do
132
+ start
133
+ @owned = false
134
+ end
129
135
  yield
130
136
  ensure
131
137
  stop
@@ -182,13 +188,32 @@ class LLM::MCP
182
188
 
183
189
  attr_reader :command, :transport, :timeout
184
190
 
191
+ ##
192
+ # Borrows the transport for the duration of the block.
193
+ #
194
+ # The first borrower starts the transport, concurrent borrowers reuse
195
+ # it, and the last borrower stops it, so overlapping tool calls never
196
+ # race `start`/`stop` and trip over "MCP transport is not running".
197
+ # An externally started transport is never stopped by a borrower.
198
+ # @yield Runs while the transport is running
199
+ # @return [void]
185
200
  def with_session
186
- return yield if transport.running?
187
- session_started = true
188
- start
201
+ @lock.synchronize do
202
+ @borrowers += 1
203
+ unless transport.running?
204
+ start
205
+ @owned = true
206
+ end
207
+ end
189
208
  yield
190
209
  ensure
191
- stop if session_started
210
+ @lock.synchronize do
211
+ @borrowers -= 1
212
+ if @borrowers.zero? and @owned
213
+ @owned = false
214
+ stop
215
+ end
216
+ end
192
217
  end
193
218
 
194
219
  def adapt_content(content)
@@ -88,7 +88,7 @@ module LLM::Sequel
88
88
  options = self.class.llm_plugin_options
89
89
  columns = Agent::Utils.columns(options)
90
90
  params = Agent::Utils.resolve_options(self, options[:context], Agent::EMPTY_HASH).dup
91
- ctx = self.class.agent.new(llm, params.compact)
91
+ ctx = self.class.agent.new(llm, params.compact.merge(record: self))
92
92
  data = self[columns[:data_column]]
93
93
  if data.nil? || data == ""
94
94
  ctx
@@ -343,7 +343,7 @@ module LLM::Sequel
343
343
  options = self.class.llm_plugin_options
344
344
  columns = Utils.columns(options)
345
345
  params = Utils.resolve_options(self, options[:context], Plugin::EMPTY_HASH).dup
346
- ctx = LLM::Context.new(llm, params.compact)
346
+ ctx = LLM::Context.new(llm, params.compact.merge(record: self))
347
347
  data = self[columns[:data_column]]
348
348
  if data.nil? || data == ""
349
349
  ctx
data/lib/llm/utils.rb CHANGED
@@ -20,12 +20,34 @@ module LLM
20
20
  def resolve_option(obj, option, resolve_symbol: true)
21
21
  case option
22
22
  when Proc then obj.instance_exec(&option)
23
- when Symbol then resolve_symbol ? obj.send(option) : option
23
+ when Symbol
24
+ if resolve_symbol
25
+ if obj.respond_to?(option, true) and record?(obj)
26
+ obj.send(option)
27
+ elsif obj.respond_to?(:record) and obj.record.respond_to?(option, true)
28
+ obj.record.send(option)
29
+ elsif obj.respond_to?(option, true)
30
+ obj.send(option)
31
+ else
32
+ raise ArgumentError, "unable to resolve #{option}"
33
+ end
34
+ else
35
+ option
36
+ end
24
37
  when Hash then option.dup
25
38
  else option
26
39
  end
27
40
  end
28
41
 
42
+ ##
43
+ # Returns true when `obj` is an ActiveRecord or Sequel model.
44
+ # @param [Object] obj
45
+ # @api private
46
+ def record?(obj)
47
+ (defined?(::ActiveRecord::Base) and ::ActiveRecord::Base === obj) or
48
+ (defined?(::Sequel::Model) and ::Sequel::Model === obj)
49
+ end
50
+
29
51
  ##
30
52
  # Normalizes an HTTP API base path.
31
53
  #
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "15.0.0"
4
+ VERSION = "15.0.2"
5
5
  end
data/llm.gemspec CHANGED
@@ -21,7 +21,7 @@ DESCRIPTION
21
21
  spec.license = "MIT"
22
22
  spec.required_ruby_version = ">= 3.3.0"
23
23
 
24
- spec.homepage = "https://r.uby.dev/llm/"
24
+ spec.homepage = "https://r.uby.dev"
25
25
  spec.metadata["homepage_uri"] = spec.homepage
26
26
  spec.metadata["source_code_uri"] = "https://github.com/r-uby-dev/llm"
27
27
  spec.metadata["documentation_uri"] = spec.homepage
@@ -38,9 +38,15 @@ DESCRIPTION
38
38
  spec.executables = ["llm.rb"]
39
39
  spec.require_paths = ["lib"]
40
40
  spec.post_install_message = "\n" \
41
- "Learn more about llm.rb on GitHub:" \
41
+ "Got a question about llm.rb? " \
42
42
  "\n" \
43
- "https://github.com/r-uby-dev/llm#readme" \
43
+ "Ask the https://r.uby.dev chatbot." \
44
+ "\n" \
45
+ "It is connected to the official GitHub repository." \
46
+ "\n" \
47
+ "100% free to use." \
48
+ "\n" \
49
+ "Built with llm.rb and DeepSeek." \
44
50
  "\n\n"
45
51
 
46
52
  spec.add_development_dependency "webmock", "~> 3.24.0"
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: 15.0.0
4
+ version: 15.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson
@@ -644,19 +644,17 @@ files:
644
644
  - lib/sequel/plugins/agent.rb
645
645
  - lib/sequel/plugins/llm.rb
646
646
  - llm.gemspec
647
- homepage: https://r.uby.dev/llm/
647
+ homepage: https://r.uby.dev
648
648
  licenses:
649
649
  - MIT
650
650
  metadata:
651
- homepage_uri: https://r.uby.dev/llm/
651
+ homepage_uri: https://r.uby.dev
652
652
  source_code_uri: https://github.com/r-uby-dev/llm
653
- documentation_uri: https://r.uby.dev/llm/
653
+ documentation_uri: https://r.uby.dev
654
654
  changelog_uri: https://github.com/r-uby-dev/llm/blob/main/CHANGELOG.md
655
- post_install_message: |2+
656
-
657
- Learn more about llm.rb on GitHub:
658
- https://github.com/r-uby-dev/llm#readme
659
-
655
+ post_install_message: "\nGot a question about llm.rb? \nAsk the https://r.uby.dev
656
+ chatbot.\nIt is connected to the official GitHub repository.\n100% free to use.\nBuilt
657
+ with llm.rb and DeepSeek.\n\n"
660
658
  rdoc_options: []
661
659
  require_paths:
662
660
  - lib
@@ -675,4 +673,3 @@ rubygems_version: 4.0.16
675
673
  specification_version: 4
676
674
  summary: Ruby's capable AI runtime
677
675
  test_files: []
678
- ...