llm.rb 11.0.0 → 11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24c3c2930dd3ab321999075b34ef2e5c6d445fec5c873b00ef071caeef3c1406
4
- data.tar.gz: 0d6921f20dc327f7c424f7282ff3c76f5073ad3eec7f21d483ee7623e4c782f7
3
+ metadata.gz: 5bb91948d8cfa006f7512dd0a4fa62f90b42360e3f11a57074870470fdc70d3f
4
+ data.tar.gz: 64b49f633318bc0439252cebca4c3886db4c7676f3cb9a78f4945eefe58b4356
5
5
  SHA512:
6
- metadata.gz: 3b96ea3336114822ccb2defee4da43089df6e004cebdf27987562f7f339bc2a733cc169d64f9752cc51d0346a069ba3921e99db69c2de1f795abfa69f260a730
7
- data.tar.gz: b4135fad5bd1c5499b1177c27d6eb9c2da5a84b50a5492e82fe6396821c2b4a5e0891e55cb557d07e5ee4ec912b7ecdd8c7df223ebe76109aa74b7ede5227195
6
+ metadata.gz: c56b48185604b22c44f7b4697da56a5fda8359a69e110392abf2510b47a4dc9aedbe5c6a9e64e733fafb5672c34d4a0833448fab5ca9de52c453c8a906080174
7
+ data.tar.gz: a2a9241da0e8749569111573451c99c92ed3569fa7cee0c9178b8aa884a72b27a24f18e19566cef8a16c2e460420a392b83d441708da67a571a9e2648d4d81e2
data/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v11.1.0
6
+
7
+ Changes since `v11.0.0`.
8
+
9
+ This release adds the `inherit` directive for skill sub-agents so they can
10
+ inherit access to the local, MCP, and A2A tools available to their parent
11
+ agent. It introduces class-level `required %i[...]` declarations to
12
+ `LLM::Schema` and wraps `LLM::Function#arguments` in `LLM::Object` for
13
+ method-style argument access. The OpenTelemetry tracer now samples all spans
14
+ regardless of environment, and the tool-call loop repair step prevents stale
15
+ history from being sent on follow-up requests.
16
+
17
+ ### Add
18
+
19
+ * **Add support for the `inherit` directive in skills** <br>
20
+ Add support for the `inherit` directive so a skill sub-agent can
21
+ inherit access to the local, MCP, and A2A tools available to its
22
+ parent agent.
23
+
24
+ * **Add class-level `required %i[...]` support to `LLM::Schema`** <br>
25
+ Add class-level `required %i[...]` declarations to `LLM::Schema`, so
26
+ schema classes can mark existing properties as required the same way
27
+ `LLM::Tool` params already can.
28
+
29
+ * **Wrap function arguments in `LLM::Object`** <br>
30
+ Wrap `LLM::Function#arguments` in `LLM::Object`, so function
31
+ implementations can read arguments with method-style access while
32
+ still invoking runners with keyword arguments.
33
+
34
+ ### Fix
35
+
36
+ * **Ensure all traces are sampled regardless of environment** <br>
37
+ Explicitly pass `Samplers::ALWAYS_ON` when creating the OpenTelemetry
38
+ `TracerProvider` so the in-memory exporter always captures every span,
39
+ regardless of the `OTEL_TRACES_SAMPLER` environment variable.
40
+
41
+ * **Always close the tool call loop before sending follow-up requests** <br>
42
+ Add a repair step in `Context#talk` that closes assistant tool-call
43
+ messages without matching tool responses before the next provider
44
+ request is sent. This prevents stale tool-call history from being sent
45
+ on follow-up requests, which some providers reject as invalid.
46
+
5
47
  ## v11.0.0
6
48
 
7
49
  Changes since `v10.0.0`.
data/README.md CHANGED
@@ -4,14 +4,14 @@
4
4
  </a>
5
5
  </p>
6
6
  <p align="center">
7
- <a href="https://0x1eef.github.io/x/llm.rb?rebuild=1">
8
- <img src="https://img.shields.io/badge/docs-0x1eef.github.io-blue.svg" alt="RubyDoc">
7
+ <a href="https://llmrb.github.io/llm.rb">
8
+ <img src="https://img.shields.io/badge/docs-llmrb.github.io-blue.svg" alt="Official llm.rb website">
9
9
  </a>
10
10
  <a href="https://opensource.org/license/0bsd">
11
11
  <img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License">
12
12
  </a>
13
13
  <a href="https://github.com/llmrb/llm.rb/tags">
14
- <img src="https://img.shields.io/badge/version-11.0.0-green.svg?" alt="Version">
14
+ <img src="https://img.shields.io/badge/version-11.1.0-green.svg?" alt="Version">
15
15
  </a>
16
16
  </p>
17
17
 
@@ -22,8 +22,7 @@ llm.rb is Ruby's most capable AI runtime.
22
22
  It runs on Ruby's standard library by default. loads optional pieces
23
23
  only when needed, and offers a single runtime for providers, agents,
24
24
  tools, skills, MCP, A2A (Agent2Agent), RAG (vector stores & embeddings),
25
- streaming, files, and persisted state. As a bonus, llm.rb is also
26
- [available for mruby](https://github.com/llmrb/mruby-llm).
25
+ streaming, files, and persisted state.
27
26
 
28
27
  It supports OpenAI, OpenAI-compatible endpoints, Anthropic, Google
29
28
  Gemini, DeepSeek, xAI, Z.ai, AWS Bedrock, Ollama, and llama.cpp. It
@@ -31,6 +30,11 @@ also includes built-in ActiveRecord and Sequel support, plus concurrent
31
30
  tool execution through threads, tasks (via async gem), fibers, ractors,
32
31
  and fork (via xchan.rb gem).
33
32
 
33
+ As a bonus, llm.rb is also available to embedded systems [via mruby](https://github.com/llmrb/mruby-llm#readme),
34
+ to the browser and edge devices [via WebAssembly](https://github.com/llmrb/wasm-llm#readme),
35
+ and has first-class [Rails support](https://github.com/llmrb/rails-llm#readme)
36
+ via a separate gem.
37
+
34
38
  ## Quick start
35
39
 
36
40
  #### LLM::Context
@@ -90,7 +94,7 @@ class Agent < LLM::Agent
90
94
  confirm "delete-file"
91
95
 
92
96
  def on_tool_confirmation(fn, strategy)
93
- path = fn.arguments["path"] || fn.arguments[:path]
97
+ path = fn.arguments.path
94
98
  if path.start_with?("/tmp/")
95
99
  fn.spawn(strategy).wait
96
100
  else
@@ -260,6 +264,19 @@ llm = LLM.openai(key: ENV["KEY"])
260
264
  ReleaseAgent.new(llm, stream: $stdout).talk("Prepare the next release.")
261
265
  ```
262
266
 
267
+ A skill can also have its sub-agent inherit the parents tools through the
268
+ `inherit` directive. The `inherit` directive has coverage for the "classic"
269
+ tools (a subclass of [LLM::Tool](https://0x1eef.github.io/x/llm.rb/LLM/Tool.html)),
270
+ MCP tools, and A2A tools that a parent context or agent has access to:
271
+
272
+ ```yaml
273
+ ---
274
+ name: release
275
+ description: Prepare a release
276
+ tools: inherit
277
+ ---
278
+ ```
279
+
263
280
  #### LLM::Stream
264
281
 
265
282
  The
@@ -389,7 +406,7 @@ gem install llm.rb
389
406
 
390
407
  This example uses [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html)
391
408
  directly for an interactive REPL. <br> See the
392
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
409
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
393
410
  [deepdive (markdown)](resources/deepdive.md) for more examples.
394
411
 
395
412
  ```ruby
@@ -442,7 +459,7 @@ different model from the main context. `token_threshold:` accepts either a
442
459
  fixed token count or a percentage string like `"90%"`, which resolves
443
460
  against the active model context window and triggers compaction once total
444
461
  token usage goes over that percentage. See the
445
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
462
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
446
463
  [deepdive (markdown)](resources/deepdive.md) for more examples.
447
464
 
448
465
  ```ruby
@@ -475,7 +492,7 @@ ctx = LLM::Context.new(
475
492
  This example uses [`LLM::Stream`](https://0x1eef.github.io/x/llm.rb/LLM/Stream.html)
476
493
  with the OpenAI Responses API so reasoning output is streamed separately from
477
494
  visible assistant output. See the
478
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
495
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
479
496
  [deepdive (markdown)](resources/deepdive.md) for more examples.
480
497
 
481
498
  To use the Responses API (OpenAI-specific), initialize a
@@ -510,7 +527,7 @@ ctx.talk("Solve 17 * 19 and show your work.")
510
527
 
511
528
  Need to cancel a stream? llm.rb has you covered through
512
529
  [`LLM::Context#interrupt!`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html#interrupt-21-instance_method).
513
- <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html)
530
+ <br> See the [deepdive (web)](https://llmrb.github.io/llm.rb/)
514
531
  or [deepdive (markdown)](resources/deepdive.md) for more examples.
515
532
 
516
533
  ```ruby
@@ -538,7 +555,7 @@ The `plugin :llm` integration wraps
538
555
  wrappers, its built-in persistence contract is the serialized `data` column,
539
556
  while `provider:` resolves a real `LLM::Provider` instance and `context:`
540
557
  injects defaults such as `model:`. <br> See the
541
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
558
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
542
559
  [deepdive (markdown)](resources/deepdive.md) for more examples.
543
560
 
544
561
  ```ruby
@@ -574,7 +591,7 @@ one serialized `data` column. If your app has provider, model, or usage
574
591
  columns, provide them to llm.rb through `provider:` and `context:` instead of
575
592
  relying on reserved wrapper columns.
576
593
 
577
- See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html)
594
+ See the [deepdive (web)](https://llmrb.github.io/llm.rb/)
578
595
  or [deepdive (markdown)](resources/deepdive.md) for more examples.
579
596
 
580
597
  ```ruby
@@ -631,7 +648,7 @@ manages tool execution for you. Like `acts_as_llm`, its built-in persistence
631
648
  contract is one serialized `data` column. If your app has provider or model
632
649
  columns, provide them to llm.rb through your hooks and agent DSL.
633
650
 
634
- See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html)
651
+ See the [deepdive (web)](https://llmrb.github.io/llm.rb/)
635
652
  or [deepdive (markdown)](resources/deepdive.md) for more examples.
636
653
 
637
654
  ```ruby
@@ -689,7 +706,7 @@ This example uses [`LLM::MCP`](https://0x1eef.github.io/x/llm.rb/LLM/MCP.html)
689
706
  over HTTP so remote GitHub MCP tools run through the same
690
707
  `LLM::Context` tool path as local tools. It expects a GitHub token in
691
708
  `ENV["GITHUB_PAT"]`. See the
692
- [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or
709
+ [deepdive (web)](https://llmrb.github.io/llm.rb/) or
693
710
  [deepdive (markdown)](resources/deepdive.md) for more examples.
694
711
 
695
712
  ```ruby
@@ -710,7 +727,7 @@ ctx.talk(ctx.wait(:call)) while ctx.functions?
710
727
 
711
728
  ## Resources
712
729
 
713
- - [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) and
730
+ - [deepdive (web)](https://llmrb.github.io/llm.rb/) and
714
731
  [deepdive (markdown)](resources/deepdive.md) are the examples guide.
715
732
  - [relay](https://github.com/llmrb/relay) shows a real application built on
716
733
  top of llm.rb.
data/lib/llm/context.rb CHANGED
@@ -193,6 +193,7 @@ module LLM
193
193
  def talk(prompt, params = {})
194
194
  @owner = @llm.request_owner
195
195
  compactor.compact!(prompt) if compactor.compact?(prompt)
196
+ repair!(@messages, prompt)
196
197
  prompt, params, res = mode == :responses ? respond(prompt, params) : complete(prompt, params)
197
198
  self.compacted = false
198
199
  role = params[:role] || @llm.user_role
@@ -566,5 +567,25 @@ module LLM
566
567
  message: warning
567
568
  })
568
569
  end
570
+
571
+ ##
572
+ # Closes assistant tool-call messages that do not have matching tool
573
+ # responses. This can happen when a turn is interrupted while a tool
574
+ # call is streaming or waiting for user confirmation.
575
+ # @param [Array<LLM::Message>] messages
576
+ # @param [Object] prompt
577
+ # @return [void]
578
+ def repair!(messages, prompt)
579
+ message = messages.last
580
+ return unless message&.tool_call?
581
+ returns = self.returns + [*prompt].grep(LLM::Function::Return)
582
+ cancelled = []
583
+ [*message.extra.tool_calls].each do |tool|
584
+ next if returns.any? { _1.id == tool[:id] }
585
+ attrs = {cancelled: true, reason: "function call cancelled"}
586
+ cancelled << LLM::Function::Return.new(tool.id, tool.name, attrs)
587
+ end
588
+ messages << LLM::Message.new(@llm.tool_role, cancelled) unless cancelled.empty?
589
+ end
569
590
  end
570
591
  end
data/lib/llm/function.rb CHANGED
@@ -109,8 +109,16 @@ class LLM::Function
109
109
 
110
110
  ##
111
111
  # Returns function arguments
112
- # @return [Array, nil]
113
- attr_accessor :arguments
112
+ # @return [Hash, Array, LLM::Object, nil]
113
+ attr_reader :arguments
114
+
115
+ ##
116
+ # Sets function arguments, wrapping them in an LLM::Object
117
+ # @param [Hash, LLM::Object] other
118
+ # @return [void]
119
+ def arguments=(other)
120
+ @arguments = LLM::Object.from(other)
121
+ end
114
122
 
115
123
  ##
116
124
  # Returns a tracer, or nil
@@ -373,10 +381,10 @@ class LLM::Function
373
381
  # Returns a Return object with either the function result or error information.
374
382
  def call_function
375
383
  runner = self.runner
376
- kwargs = Hash === arguments ? arguments.transform_keys(&:to_sym) : arguments
384
+ kwargs = arguments.respond_to?(:to_h) ? arguments.to_h.transform_keys(&:to_sym) : arguments
377
385
  Return.new(id, name, runner.call(**kwargs))
378
386
  rescue => ex
379
- Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
387
+ Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
380
388
  end
381
389
 
382
390
  def call!
@@ -17,6 +17,7 @@ class LLM::Object
17
17
  case obj
18
18
  when self then from(obj.to_h)
19
19
  when Array then obj.map { |v| from(v) }
20
+ when String then obj
20
21
  else
21
22
  visited = {}
22
23
  obj.each { visited[_1] = visit(_2) }
data/lib/llm/object.rb CHANGED
@@ -184,6 +184,15 @@ class LLM::Object < BasicObject
184
184
  @h.slice(*args)
185
185
  end
186
186
 
187
+ ##
188
+ # @param [Hash, #to_h] other
189
+ # @return [Boolean]
190
+ def ==(other)
191
+ return false unless other.respond_to?(:to_h)
192
+ to_h == other.to_h || to_hash == other.to_h
193
+ end
194
+ alias_method :eql?, :==
195
+
187
196
  private
188
197
 
189
198
  def method_missing(m, *args, &b)
data/lib/llm/schema.rb CHANGED
@@ -20,14 +20,16 @@
20
20
  #
21
21
  # @example Ruby-style
22
22
  # class Address < LLM::Schema
23
- # property :street, String, "Street address", required: true
23
+ # property :street, String, "Street address"
24
+ # required %i[street]
24
25
  # end
25
26
  #
26
27
  # class Person < LLM::Schema
27
- # property :name, String, "Person's name", required: true
28
- # property :age, Integer, "Person's age", required: true
29
- # property :hobbies, Array[String], "Person's hobbies", required: true
30
- # property :address, Address, "Person's address", required: true
28
+ # property :name, String, "Person's name"
29
+ # property :age, Integer, "Person's age"
30
+ # property :hobbies, Array[String], "Person's hobbies"
31
+ # property :address, Address, "Person's address"
32
+ # required %i[name age hobbies address]
31
33
  # end
32
34
  class LLM::Schema
33
35
  require_relative "schema/version"
@@ -74,6 +76,10 @@ class LLM::Schema
74
76
  end
75
77
  schema.array(item)
76
78
  end
79
+
80
+ def fetch(properties, name)
81
+ properties[name] || properties.fetch(name.to_s)
82
+ end
77
83
  end
78
84
 
79
85
  ##
@@ -103,6 +109,18 @@ class LLM::Schema
103
109
  end
104
110
  end
105
111
 
112
+ ##
113
+ # Mark existing properties as required.
114
+ # @param names [Array<Symbol,String>]
115
+ # @return [LLM::Schema::Object]
116
+ def self.required(names)
117
+ lock do
118
+ object.tap do |schema|
119
+ [*names].each { Utils.fetch(schema.properties, _1).required }
120
+ end
121
+ end
122
+ end
123
+
106
124
  ##
107
125
  # @api private
108
126
  # @return [LLM::Schema]
data/lib/llm/skill.rb CHANGED
@@ -56,6 +56,7 @@ module LLM
56
56
  @instructions = ""
57
57
  @frontmatter = LLM::Object.from({})
58
58
  @tools = []
59
+ @inherit_tools = false
59
60
  end
60
61
 
61
62
  ##
@@ -74,18 +75,8 @@ module LLM
74
75
  # @param [LLM::Context] ctx
75
76
  # @return [Hash]
76
77
  def call(ctx)
77
- instructions, tools, tracer = self.instructions, self.tools, ctx.llm.tracer
78
- params = ctx.params.merge(mode: ctx.mode).reject { [:tools, :schema].include?(_1) }
79
- concurrency = params[:stream].extra[:concurrency] if LLM::Stream === params[:stream]
80
- params[:concurrency] = concurrency if concurrency
81
- agent = Class.new(LLM::Agent) do
82
- instructions(instructions)
83
- tools(*tools)
84
- tracer(tracer)
85
- end.new(ctx.llm, params)
86
- agent.messages.concat(messages_for(ctx))
87
- res = agent.talk("Solve the user's query.")
88
- {content: res.content}
78
+ content = agent(ctx).talk("Solve the user's query.").content
79
+ {content:}
89
80
  end
90
81
 
91
82
  ##
@@ -96,9 +87,10 @@ module LLM
96
87
  def to_tool(ctx)
97
88
  skill = self
98
89
  Class.new(LLM::Tool) do
90
+ attr_accessor :tracer
91
+
99
92
  name skill.name
100
93
  description skill.description
101
- attr_accessor :tracer
102
94
 
103
95
  define_singleton_method(:skill?) do
104
96
  true
@@ -110,6 +102,13 @@ module LLM
110
102
  end
111
103
  end
112
104
 
105
+ ##
106
+ # Returns true when a skill should inherit tools from its parent
107
+ # @return [Boolean]
108
+ def inherit_tools?
109
+ @inherit_tools
110
+ end
111
+
113
112
  private
114
113
 
115
114
  def messages_for(ctx)
@@ -132,8 +131,39 @@ module LLM
132
131
  @frontmatter = LLM::Object.from(YAML.safe_load(match[1]) || {})
133
132
  @name = @frontmatter.name || @name
134
133
  @description = @frontmatter.description || @description
135
- @tools = [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }
136
134
  @instructions = match[2]
135
+ @inherit_tools, @tools = parse_tools(@frontmatter.tools)
136
+ end
137
+
138
+ def parse_tools(tools)
139
+ case tools
140
+ when String
141
+ tools == "inherit" ? [true, []] : raise_invalid_error!(tools)
142
+ when Array
143
+ [false, [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }]
144
+ when NilClass
145
+ [false, []]
146
+ else
147
+ raise_invalid_error!(tools)
148
+ end
149
+ end
150
+
151
+ def raise_invalid_error!(tools)
152
+ raise LLM::Error, "invalid value for tools key: '#{tools}'"
153
+ end
154
+
155
+ def agent(ctx)
156
+ instructions, tools, tracer, inherit_tools = self.instructions, self.tools, ctx.llm.tracer, inherit_tools?
157
+ params = ctx.params.merge(mode: ctx.mode).reject { [:tools, :schema].include?(_1) }
158
+ concurrency = params[:stream].extra[:concurrency] if LLM::Stream === params[:stream]
159
+ params[:concurrency] = concurrency if concurrency
160
+ agent = Class.new(LLM::Agent) do
161
+ instructions(instructions)
162
+ tools(inherit_tools ? ctx.params[:tools] : tools)
163
+ tracer(tracer)
164
+ end.new(ctx.llm, params)
165
+ agent.messages.concat(messages_for(ctx))
166
+ agent
137
167
  end
138
168
  end
139
169
  end
@@ -223,7 +223,9 @@ module LLM
223
223
  require "opentelemetry/sdk" unless defined?(OpenTelemetry)
224
224
  @exporter ||= OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
225
225
  processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(@exporter)
226
- @tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
226
+ @tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
227
+ sampler: OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON
228
+ )
227
229
  @tracer_provider.add_span_processor(processor)
228
230
  @tracer = @tracer_provider.tracer("llm.rb", LLM::VERSION)
229
231
  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 = "11.0.0"
4
+ VERSION = "11.1.0"
5
5
  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: 11.0.0
4
+ version: 11.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri