llm.rb 4.20.1 → 4.21.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: 58f2ff0f8147443face2f3d7c48b249b8aec30de4345fa286f87c622853cb516
4
- data.tar.gz: 9dba9a0609fff95e141ee5a819ff454a9dbd5ecb9c987a1a3e3b73822431d6d2
3
+ metadata.gz: f0bca66b2bd8873cf39abb3be19dc99ca20d558e40ef3e9f475bf1f33faef6b6
4
+ data.tar.gz: c73a2c5093e7e09557242919feb5a377f25b0fa8a11249a9f346673ad7d3a921
5
5
  SHA512:
6
- metadata.gz: 172de04003136f5b599f5b2c274d9354ca576512bc35e9af85c5672f32bd3ad5f85a8a0b7e60e29c60b8fa7e6bd8d39ed5d23692c60a4b6de0f2c941d542fd41
7
- data.tar.gz: 9a3ef1da238e38ab51af3a20f235201bca736a41753c9848a589467676a979829dca6c7e5708ee12be344e9e0686ba4652504514b72769ff5d511c5d752dd9f2
6
+ metadata.gz: 2a00191aaab47702a794f9fa86d782f21832be2a7ef309bd558aa482100d7c66ddbdf3320e89c80af2942c6e33295f10d387702130162fbac7cc98fd9b24c9a8
7
+ data.tar.gz: a6709f6fd265af673da771f635f34c68e28e490405700c1a59b18253391dbbcae09ce677a4251994d898a851ec08dc598c5ff858e516e25b1206948f509abf67
data/CHANGELOG.md CHANGED
@@ -2,8 +2,55 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ Changes since `v4.21.0`.
6
+
7
+ ## v4.21.0
8
+
9
+ Changes since `v4.20.2`.
10
+
11
+ This release expands higher-level composition in llm.rb. It adds Sequel agent
12
+ persistence through `plugin :agent` and introduces directory-backed skills
13
+ that load from `SKILL.md`, resolve named tools, and plug directly into
14
+ `LLM::Context` and `LLM::Agent`.
15
+
16
+ ### Change
17
+
18
+ * **Add `plugin :agent` for Sequel models** <br>
19
+ Add Sequel support for `plugin :agent`, similar to ActiveRecord's
20
+ `acts_as_agent`, so models can wrap `LLM::Agent` with built-in
21
+ persistence.
22
+
23
+ * **Load directory-backed skills through `LLM::Context` and `LLM::Agent`** <br>
24
+ Add `skills:` to `LLM::Context` and `skills ...` to `LLM::Agent` so
25
+ directories with `SKILL.md` can be loaded, resolved into tools, and run
26
+ through the normal llm.rb tool path.
27
+
28
+ ## v4.20.2
29
+
5
30
  Changes since `v4.20.1`.
6
31
 
32
+ This patch release improves runtime behavior around interruption and mixed
33
+ concurrency waits. It also rounds out response API uniformity for Google
34
+ completion responses.
35
+
36
+ ### Fix
37
+
38
+ * **Expose Google completion response IDs through `.id`** <br>
39
+ Add `LLM::Response#id` support to Google completion responses so tracer
40
+ and caller code can rely on the same API used by other providers.
41
+
42
+ * **Track interrupt ownership on the active request** <br>
43
+ Bind `LLM::Context` interruption to the fiber running `talk` or `respond`
44
+ so `interrupt!` works correctly when requests are started outside the
45
+ context's initialization fiber.
46
+
47
+ ### Change
48
+
49
+ * **Allow mixed concurrency strategies in `wait(...)`** <br>
50
+ Let `LLM::Context#wait`, `LLM::Stream#wait`, and `LLM::Agent.concurrency`
51
+ accept arrays such as `[:thread, :ractor]` so mixed tool sets can wait on
52
+ more than one concurrency strategy.
53
+
7
54
  ## v4.20.1
8
55
 
9
56
  Changes since `v4.20.0`.
data/README.md CHANGED
@@ -4,26 +4,33 @@
4
4
  <p align="center">
5
5
  <a href="https://0x1eef.github.io/x/llm.rb?rebuild=1"><img src="https://img.shields.io/badge/docs-0x1eef.github.io-blue.svg" alt="RubyDoc"></a>
6
6
  <a href="https://opensource.org/license/0bsd"><img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License"></a>
7
- <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.20.1-green.svg?" alt="Version"></a>
7
+ <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.21.0-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
11
11
 
12
12
  llm.rb is a lightweight runtime for building capable AI systems in Ruby.
13
+ <br>
14
+
15
+ It is also the most capable AI Ruby runtime that exists _today_, and that claim is
16
+ backed up by research. Maybe it won't always be true, and that would be good news too -
17
+ because it would mean the Ruby ecosystem is getting stronger.
13
18
 
14
- It is not just an API wrapper. llm.rb gives you one runtime for providers,
15
- contexts, agents, tools, MCP servers, streaming, schemas, files, and persisted
16
- state, so real systems can be built out of one coherent execution model instead
17
- of a pile of adapters.
19
+ llm.rb is not just an API wrapper: it gives you one runtime for providers,
20
+ contexts, agents, tools, skills, MCP servers, streaming, schemas, files, and
21
+ persisted state, so real systems can be built out of one coherent execution
22
+ model instead of a pile of adapters.
18
23
 
19
- It stays close to Ruby, runs on the standard library by default, loads optional
20
- pieces only when needed, includes built-in ActiveRecord support through
24
+ llm.rb is designed for Ruby, and although it works great in Rails, it is not tightly
25
+ coupled to it. It runs on the standard library by default (zero dependencies),
26
+ loads optional pieces only when needed, includes built-in ActiveRecord support through
21
27
  `acts_as_llm` and `acts_as_agent`, includes built-in Sequel support through
22
- `plugin :llm`, and is designed for engineers who want control over
28
+ `plugin :llm` and `plugin :agent`, and is designed for engineers who want control over
23
29
  long-lived, tool-capable, stateful AI workflows instead of just
24
30
  request/response helpers.
25
31
 
26
- Want to see some code? Jump to [the examples](#examples) section.
32
+ Want to see some code? Jump to [the examples](#examples) section. <br>
33
+ Want a taste of what llm.rb can build? See [the screencast](#screencast).
27
34
 
28
35
  ## Architecture
29
36
 
@@ -100,13 +107,18 @@ same context object.
100
107
  integration stack.
101
108
  - **ActiveRecord and Sequel persistence are built in** <br>
102
109
  llm.rb includes built-in ActiveRecord support through `acts_as_llm` and
103
- `acts_as_agent`, plus built-in Sequel support through `plugin :llm`.
110
+ `acts_as_agent`, plus built-in Sequel support through `plugin :llm` and
111
+ `plugin :agent`.
104
112
  Use `acts_as_llm` when you want to wrap `LLM::Context`, `acts_as_agent`
105
- when you want to wrap `LLM::Agent`, or `plugin :llm` on Sequel models to
106
- persist `LLM::Context` state with sensible default columns. These
107
- integrations support `provider:` and `context:` hooks, plus `format:
108
- :string` for text columns or `format: :jsonb` for native PostgreSQL JSON
109
- storage when ORM JSON typecasting support is enabled.
113
+ when you want to wrap `LLM::Agent`, `plugin :llm` when you want a
114
+ `LLM::Context` on a Sequel model, or `plugin :agent` when you want an
115
+ `LLM::Agent`. These integrations support `provider:` and `context:` hooks,
116
+ plus `format: :string` for text columns or `format: :jsonb` for native
117
+ PostgreSQL JSON storage when ORM JSON typecasting support is enabled.
118
+ - **ORM models can become persistent agents** <br>
119
+ Turn an ActiveRecord or Sequel model into an agent-capable model with
120
+ built-in persistence, stored on the same table, with `jsonb` support when
121
+ your ORM and database support native JSON columns.
110
122
  - **Persistent HTTP pooling is shared process-wide** <br>
111
123
  When enabled, separate
112
124
  [`LLM::Provider`](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html)
@@ -125,6 +137,11 @@ same context object.
125
137
  - **Tools are explicit** <br>
126
138
  Run local tools, provider-native tools, and MCP tools through the same path
127
139
  with fewer special cases.
140
+ - **Skills are just tools loaded from directories** <br>
141
+ Point llm.rb at directories with a `SKILL.md`, resolve named tools through
142
+ the registry, and run those skills through `LLM::Context` or `LLM::Agent`
143
+ without creating a second execution model. If you are familiar with skills
144
+ in Claude or Codex, llm.rb supports the same general idea.
128
145
  - **Providers are normalized, not flattened** <br>
129
146
  Share one API surface across providers without losing access to provider-
130
147
  specific capabilities where they matter.
@@ -164,6 +181,7 @@ same context object.
164
181
  - **Run Tools While Streaming** — overlap model output with tool latency
165
182
  - **Concurrent Execution** — threads, async tasks, and fibers
166
183
  - **Agents** — reusable assistants with tool auto-execution
184
+ - **Skills** — directory-backed capabilities loaded from `SKILL.md`
167
185
  - **Structured Outputs** — JSON Schema-based responses
168
186
  - **Responses API** — stateful response workflows where providers support them
169
187
  - **MCP Support** — stdio and HTTP MCP clients with prompt and tool support
@@ -186,9 +204,9 @@ gem install llm.rb
186
204
 
187
205
  ## Examples
188
206
 
189
- **REPL**
207
+ #### REPL
190
208
 
191
- This example uses [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) directly for an interactive REPL. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
209
+ This example uses [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) directly for an interactive REPL. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
192
210
 
193
211
  ```ruby
194
212
  require "llm"
@@ -203,9 +221,91 @@ loop do
203
221
  end
204
222
  ```
205
223
 
206
- **Sequel (ORM)**
224
+ #### Streaming
225
+
226
+ This example uses [`LLM::Stream`](https://0x1eef.github.io/x/llm.rb/LLM/Stream.html) directly so visible output and tool execution can happen together. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
227
+
228
+ ```ruby
229
+ require "llm"
230
+
231
+ class Stream < LLM::Stream
232
+ def on_content(content)
233
+ $stdout << content
234
+ end
235
+
236
+ def on_tool_call(tool, error)
237
+ return queue << error if error
238
+ $stdout << "\nRunning tool #{tool.name}...\n"
239
+ queue << tool.spawn(:thread)
240
+ end
241
+
242
+ def on_tool_return(tool, result)
243
+ if result.error?
244
+ $stdout << "Tool #{tool.name} failed\n"
245
+ else
246
+ $stdout << "Finished tool #{tool.name}\n"
247
+ end
248
+ end
249
+ end
250
+
251
+ llm = LLM.openai(key: ENV["KEY"])
252
+ ctx = LLM::Context.new(llm, stream: Stream.new, tools: [System])
253
+
254
+ ctx.talk("Run `date` and `uname -a`.")
255
+ ctx.talk(ctx.wait(:thread)) while ctx.functions.any?
256
+ ```
257
+
258
+ #### Reasoning
259
+
260
+ This example uses [`LLM::Stream`](https://0x1eef.github.io/x/llm.rb/LLM/Stream.html) with the OpenAI Responses API so reasoning output is streamed separately from visible assistant output. See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
261
+
262
+ ```ruby
263
+ require "llm"
264
+
265
+ class Stream < LLM::Stream
266
+ def on_content(content)
267
+ $stdout << content
268
+ end
269
+
270
+ def on_reasoning_content(content)
271
+ $stderr << content
272
+ end
273
+ end
274
+
275
+ llm = LLM.openai(key: ENV["KEY"])
276
+ ctx = LLM::Context.new(
277
+ llm,
278
+ model: "gpt-5.4-mini",
279
+ mode: :responses,
280
+ reasoning: {effort: "medium"},
281
+ stream: Stream.new
282
+ )
283
+ ctx.talk("Solve 17 * 19 and show your work.")
284
+ ```
285
+
286
+ #### Request Cancellation
207
287
 
208
- The `plugin :llm` integration wraps [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) on a `Sequel::Model` and keeps tool execution explicit. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
288
+ Need to cancel a stream? llm.rb has you covered through [`LLM::Context#interrupt!`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html#interrupt-21-instance_method). <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
289
+
290
+ ```ruby
291
+ require "llm"
292
+ require "io/console"
293
+
294
+ llm = LLM.openai(key: ENV["KEY"])
295
+ ctx = LLM::Context.new(llm, stream: $stdout)
296
+
297
+ worker = Thread.new do
298
+ ctx.talk("Write a very long essay about network protocols.")
299
+ end
300
+
301
+ STDIN.getch
302
+ ctx.interrupt!
303
+ worker.join
304
+ ```
305
+
306
+ #### Sequel (ORM)
307
+
308
+ The `plugin :llm` integration wraps [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) on a `Sequel::Model` and keeps tool execution explicit. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
209
309
 
210
310
  ```ruby
211
311
  require "llm"
@@ -222,10 +322,10 @@ ctx.talk("Remember that my favorite language is Ruby")
222
322
  puts ctx.talk("What is my favorite language?").content
223
323
  ```
224
324
 
225
- **ActiveRecord (ORM): acts_as_llm**
325
+ #### ActiveRecord (ORM): acts_as_llm
226
326
 
227
327
  The `acts_as_llm` method wraps [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) and
228
- provides full control over tool execution. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
328
+ provides full control over tool execution. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
229
329
 
230
330
  ```ruby
231
331
  require "llm"
@@ -242,10 +342,10 @@ ctx.talk("Remember that my favorite language is Ruby")
242
342
  puts ctx.talk("What is my favorite language?").content
243
343
  ```
244
344
 
245
- **ActiveRecord (ORM): acts_as_agent**
345
+ #### ActiveRecord (ORM): acts_as_agent
246
346
 
247
347
  The `acts_as_agent` method wraps [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) and
248
- manages tool execution for you. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
348
+ manages tool execution for you. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
249
349
 
250
350
  ```ruby
251
351
  require "llm"
@@ -272,9 +372,9 @@ ticket = Ticket.create!(provider: "openai", model: "gpt-5.4-mini")
272
372
  puts ticket.talk("How do I rotate my API key?").content
273
373
  ```
274
374
 
275
- **Agent**
375
+ #### Agent
276
376
 
277
- This example uses [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) directly and lets the agent manage tool execution. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
377
+ This example uses [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) directly and lets the agent manage tool execution. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
278
378
 
279
379
  ```ruby
280
380
  require "llm"
@@ -291,10 +391,58 @@ agent = ShellAgent.new(llm)
291
391
  puts agent.talk("What time is it on this system?").content
292
392
  ```
293
393
 
394
+ #### Skills
395
+
396
+ This example uses [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) with directory-backed skills so `SKILL.md` capabilities run through the normal tool path. If you have used skills in Claude or Codex, this is the same kind of building block. <br> See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
397
+
398
+ ```ruby
399
+ require "llm"
400
+
401
+ class Agent < LLM::Agent
402
+ model "gpt-5.4-mini"
403
+ instructions "You are a concise release assistant."
404
+ skills "./skills/release", "./skills/review"
405
+ end
406
+
407
+ llm = LLM.openai(key: ENV["KEY"])
408
+ puts Agent.new(llm).talk("Use the review skill.").content
409
+ ```
410
+
411
+ #### MCP
412
+
413
+ This example uses [`LLM::MCP`](https://0x1eef.github.io/x/llm.rb/LLM/MCP.html) over HTTP so remote GitHub MCP tools run through the same `LLM::Context` tool path as local tools. See the [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) or [deepdive (markdown)](resources/deepdive.md) for more examples.
414
+
415
+ ```ruby
416
+ require "llm"
417
+ require "net/http/persistent"
418
+
419
+ llm = LLM.openai(key: ENV["KEY"])
420
+ mcp = LLM::MCP.http(
421
+ url: "https://api.githubcopilot.com/mcp/",
422
+ headers: {"Authorization" => "Bearer #{ENV.fetch("GITHUB_PAT")}"}
423
+ ).persistent
424
+
425
+ begin
426
+ mcp.start
427
+ ctx = LLM::Context.new(llm, stream: $stdout, tools: mcp.tools)
428
+ ctx.talk("Pull information about my GitHub account.")
429
+ ctx.talk(ctx.call(:functions)) while ctx.functions.any?
430
+ ensure
431
+ mcp.stop
432
+ end
433
+ ```
434
+
435
+ ## Screencast
436
+
437
+ This screencast was built on an older version of llm.rb, but it still shows
438
+ how capable the runtime can be in a real application:
439
+
440
+ [![Watch the llm.rb screencast](https://img.youtube.com/vi/Jb7LNUYlCf4/maxresdefault.jpg)](https://www.youtube.com/watch?v=x1K4wMeO_QA)
441
+
294
442
  ## Resources
295
443
 
296
- - [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) is the
297
- examples guide.
444
+ - [deepdive (web)](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) and
445
+ [deepdive (markdown)](resources/deepdive.md) are the examples guide.
298
446
  - [relay](https://github.com/llmrb/relay) shows a real application built on
299
447
  top of llm.rb.
300
448
  - [doc site](https://0x1eef.github.io/x/llm.rb?rebuild=1) has the API docs.
data/lib/llm/agent.rb CHANGED
@@ -17,7 +17,8 @@ module LLM
17
17
  # * Instructions are injected only on the first request.
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
- # `:thread`, `:task`, `:fiber`, or `:ractor`.
20
+ # `:thread`, `:task`, `:fiber`, `:ractor`, or a list of queued task
21
+ # types such as `[:thread, :ractor]`.
21
22
  #
22
23
  # @example
23
24
  # class SystemAdmin < LLM::Agent
@@ -58,6 +59,17 @@ module LLM
58
59
  @tools = tools.flatten
59
60
  end
60
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
+
61
73
  ##
62
74
  # Set or get the default schema
63
75
  # @param [#to_json, nil] schema
@@ -83,7 +95,7 @@ module LLM
83
95
  ##
84
96
  # Set or get the tool execution concurrency.
85
97
  #
86
- # @param [Symbol, nil] concurrency
98
+ # @param [Symbol, Array<Symbol>, nil] concurrency
87
99
  # Controls how pending tool loops are executed:
88
100
  # - `:call`: sequential calls
89
101
  # - `:thread`: concurrent threads
@@ -91,7 +103,10 @@ module LLM
91
103
  # - `:fiber`: concurrent raw fibers
92
104
  # - `:ractor`: concurrent Ruby ractors for class-based tools; MCP tools are not supported,
93
105
  # and this mode is especially useful for CPU-bound tool work
94
- # @return [Symbol, nil]
106
+ # - `[:thread, :ractor]`: the possible concurrency strategies to wait on, in the
107
+ # given order. This is useful for mixed tool sets or when work may have been
108
+ # spawned with more than one concurrency strategy.
109
+ # @return [Symbol, Array<Symbol>, nil]
95
110
  def self.concurrency(concurrency = nil)
96
111
  return @concurrency if concurrency.nil?
97
112
  @concurrency = concurrency
@@ -106,10 +121,11 @@ module LLM
106
121
  # not only those listed here.
107
122
  # @option params [String] :model Defaults to the provider's default model
108
123
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
124
+ # @option params [Array<String>, nil] :skills Defaults to nil
109
125
  # @option params [#to_json, nil] :schema Defaults to nil
110
- # @option params [Symbol, nil] :concurrency Defaults to the agent class concurrency
126
+ # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
111
127
  def initialize(llm, params = {})
112
- 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
113
129
  @concurrency = params.delete(:concurrency) || self.class.concurrency
114
130
  @llm = llm
115
131
  @ctx = LLM::Context.new(llm, defaults.merge(params))
@@ -270,7 +286,7 @@ module LLM
270
286
 
271
287
  ##
272
288
  # Returns the configured tool execution concurrency.
273
- # @return [Symbol, nil]
289
+ # @return [Symbol, Array<Symbol>, nil]
274
290
  def concurrency
275
291
  @concurrency
276
292
  end
@@ -348,8 +364,8 @@ module LLM
348
364
  def call_functions
349
365
  case concurrency || :call
350
366
  when :call then call(:functions)
351
- when :thread, :task, :fiber, :ractor then wait(concurrency)
352
- else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. Expected :call, :thread, :task, :fiber, or :ractor"
367
+ when :thread, :task, :fiber, :ractor, Array then wait(concurrency)
368
+ else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. Expected :call, :thread, :task, :fiber, :ractor, or an array of queued task types"
353
369
  end
354
370
  end
355
371
  end
data/lib/llm/context.rb CHANGED
@@ -64,12 +64,14 @@ module LLM
64
64
  # @option params [Symbol] :mode Defaults to :completions
65
65
  # @option params [String] :model Defaults to the provider's default model
66
66
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
67
+ # @option params [Array<String>, nil] :skills Defaults to nil
67
68
  def initialize(llm, params = {})
68
69
  @llm = llm
69
70
  @mode = params.delete(:mode) || :completions
71
+ tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
70
72
  @params = {model: llm.default_model, schema: nil}.compact.merge!(params)
73
+ @params[:tools] = tools unless tools.empty?
71
74
  @messages = LLM::Buffer.new(llm)
72
- @owner = Fiber.current
73
75
  end
74
76
 
75
77
  ##
@@ -86,6 +88,7 @@ module LLM
86
88
  # puts res.messages[0].content
87
89
  def talk(prompt, params = {})
88
90
  return respond(prompt, params) if mode == :responses
91
+ @owner = Fiber.current
89
92
  params = params.merge(messages: @messages.to_a)
90
93
  params = @params.merge(params)
91
94
  bind!(params[:stream], params[:model])
@@ -112,6 +115,7 @@ module LLM
112
115
  # res = ctx.respond("What is the capital of France?")
113
116
  # puts res.output_text
114
117
  def respond(prompt, params = {})
118
+ @owner = Fiber.current
115
119
  params = @params.merge(params)
116
120
  bind!(params[:stream], params[:model])
117
121
  res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
@@ -182,8 +186,10 @@ module LLM
182
186
  # exposes a non-empty queue. Otherwise it falls back to waiting on
183
187
  # the context's pending functions directly.
184
188
  #
185
- # @param [Symbol] strategy
186
- # The concurrency strategy to use
189
+ # @param [Symbol, Array<Symbol>] strategy
190
+ # The concurrency strategy to use, or the possible concurrency strategies to
191
+ # wait on. For example, `[:thread, :ractor]` waits for any queued thread or
192
+ # ractor work, in that order.
187
193
  # @return [Array<LLM::Function::Return>]
188
194
  def wait(strategy)
189
195
  stream = @params[:stream]
@@ -342,6 +348,10 @@ module LLM
342
348
  stream.extra[:tracer] = tracer
343
349
  stream.extra[:model] = model
344
350
  end
351
+
352
+ def load_skills(skills)
353
+ [*skills].map { LLM::Skill.load(_1).to_tool(llm) }
354
+ end
345
355
  end
346
356
 
347
357
  # Backward-compatible alias
@@ -9,6 +9,12 @@ module LLM::Google::ResponseAdapter
9
9
  end
10
10
  alias_method :choices, :messages
11
11
 
12
+ ##
13
+ # (see LLM::Contract::Completion#id)
14
+ def id
15
+ body["responseId"]
16
+ end
17
+
12
18
  ##
13
19
  # (see LLM::Contract::Completion#input_tokens)
14
20
  def input_tokens
@@ -0,0 +1,107 @@
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
+ EMPTY_HASH = LLM::Sequel::Plugin::EMPTY_HASH
14
+ DEFAULT_USAGE_COLUMNS = LLM::Sequel::Plugin::DEFAULT_USAGE_COLUMNS
15
+ DEFAULTS = LLM::Sequel::Plugin::DEFAULTS
16
+
17
+ def self.apply(model, **)
18
+ model.extend ClassMethods
19
+ model.include LLM::Sequel::Plugin::InstanceMethods
20
+ model.include InstanceMethods
21
+ end
22
+
23
+ def self.configure(model, options = EMPTY_HASH, &block)
24
+ options = DEFAULTS.merge(options)
25
+ usage_columns = DEFAULT_USAGE_COLUMNS.merge(options[:usage_columns] || EMPTY_HASH)
26
+ model.instance_variable_set(
27
+ :@llm_agent_options,
28
+ options.merge(usage_columns: usage_columns.freeze).freeze
29
+ )
30
+ model.instance_exec(&block) if block
31
+ end
32
+
33
+ module ClassMethods
34
+ def llm_plugin_options
35
+ @llm_agent_options || Agent::DEFAULTS
36
+ end
37
+
38
+ def model(model = nil)
39
+ return agent.model if model.nil?
40
+ agent.model(model)
41
+ end
42
+
43
+ def tools(*tools)
44
+ return agent.tools if tools.empty?
45
+ agent.tools(*tools)
46
+ end
47
+
48
+ def schema(schema = nil)
49
+ return agent.schema if schema.nil?
50
+ agent.schema(schema)
51
+ end
52
+
53
+ def instructions(instructions = nil)
54
+ return agent.instructions if instructions.nil?
55
+ agent.instructions(instructions)
56
+ end
57
+
58
+ def concurrency(concurrency = nil)
59
+ return agent.concurrency if concurrency.nil?
60
+ agent.concurrency(concurrency)
61
+ end
62
+
63
+ def agent
64
+ @agent ||= Class.new(LLM::Agent)
65
+ end
66
+ end
67
+
68
+ module InstanceMethods
69
+ private
70
+
71
+ def ctx
72
+ @ctx ||= begin
73
+ options = self.class.llm_plugin_options
74
+ params = resolve_options(options[:context]).dup
75
+ params[:model] ||= self[columns[:model_column]]
76
+ ctx = self.class.agent.new(llm, params.compact)
77
+ data = self[columns[:data_column]]
78
+ if data.nil? || data == ""
79
+ ctx
80
+ else
81
+ case options[:format]
82
+ when :string then ctx.restore(string: data)
83
+ when :json, :jsonb then ctx.restore(data:)
84
+ else raise ArgumentError, "Unknown format: #{options[:format].inspect}"
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ def resolve_option(option)
91
+ case option
92
+ when Proc then instance_exec(&option)
93
+ when Symbol then send(option)
94
+ when Hash then option.dup
95
+ else option
96
+ end
97
+ end
98
+
99
+ def resolve_options(option)
100
+ case option
101
+ when Proc, Symbol, Hash then resolve_option(option)
102
+ else Agent::EMPTY_HASH.dup
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/llm/skill.rb ADDED
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # {LLM::Skill LLM::Skill} represents a directory-backed packaged capability.
6
+ # A skill directory must contain a `SKILL.md` file with YAML frontmatter.
7
+ # Skills can expose themselves as normal {LLM::Tool LLM::Tool} classes through
8
+ # {#to_tool}. This keeps skills on the same execution path as local tools.
9
+ class Skill
10
+ ##
11
+ # Load a skill from a directory.
12
+ # @param [String, Pathname] path
13
+ # @return [LLM::Skill]
14
+ def self.load(path)
15
+ new(path).tap(&:load!)
16
+ end
17
+
18
+ ##
19
+ # Returns the skill directory.
20
+ # @return [String]
21
+ attr_reader :path
22
+
23
+ ##
24
+ # Returns the skill name.
25
+ # @return [String]
26
+ attr_reader :name
27
+
28
+ ##
29
+ # Returns the skill description.
30
+ # @return [String]
31
+ attr_reader :description
32
+
33
+ ##
34
+ # Returns the skill instructions.
35
+ # @return [String]
36
+ attr_reader :instructions
37
+
38
+ ##
39
+ # Returns the skill frontmatter.
40
+ # @return [LLM::Object]
41
+ attr_reader :frontmatter
42
+
43
+ ##
44
+ # Returns the skill tools.
45
+ # @return [Array<Class<LLM::Tool>>]
46
+ attr_reader :tools
47
+
48
+ def initialize(path)
49
+ @path = path.to_s
50
+ @name = ::File.basename(@path)
51
+ @description = "Skill: #{@name}"
52
+ @instructions = ""
53
+ @frontmatter = LLM::Object.from({})
54
+ @tools = []
55
+ end
56
+
57
+ ##
58
+ # Load and parse the skill.
59
+ # @return [LLM::Skill]
60
+ def load!
61
+ path = ::File.join(@path, "SKILL.md")
62
+ parse(::File.read(path))
63
+ self
64
+ end
65
+
66
+ ##
67
+ # 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
71
+ # @return [Hash]
72
+ def call(llm, **)
73
+ instructions = self.instructions
74
+ tools = self.tools
75
+ agent = Class.new(LLM::Agent) do
76
+ instructions instructions
77
+ tools(*tools)
78
+ end.new(llm)
79
+ res = agent.talk(instructions)
80
+ {content: res.content}
81
+ end
82
+
83
+ ##
84
+ # Expose the skill as a normal LLM::Tool. The provider is bound explicitly
85
+ # when the tool class is built.
86
+ # @param [LLM::Provider] llm
87
+ # @return [Class<LLM::Tool>]
88
+ def to_tool(llm)
89
+ skill = self
90
+ Class.new(LLM::Tool) do
91
+ name skill.name
92
+ description skill.description
93
+
94
+ define_method(:call) do |**input|
95
+ skill.call(llm, **input)
96
+ end
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def parse(content)
103
+ match = content.match(/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m)
104
+ unless match
105
+ @instructions = content
106
+ return
107
+ end
108
+ require "yaml" unless defined?(::YAML)
109
+ @frontmatter = LLM::Object.from(YAML.safe_load(match[1]) || {})
110
+ @name = @frontmatter.name || @name
111
+ @description = @frontmatter.description || @description
112
+ @tools = [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }
113
+ @instructions = match[2]
114
+ end
115
+ end
116
+ end
@@ -33,27 +33,57 @@ class LLM::Stream
33
33
 
34
34
  ##
35
35
  # Waits for queued work to finish and returns function results.
36
- # @param [Symbol] strategy
37
- # Controls concurrency strategy:
36
+ # @param [Symbol, Array<Symbol>] strategy
37
+ # Controls concurrency strategy, or lists the possible concurrency strategies
38
+ # to wait on:
38
39
  # - `:thread`: Use threads
39
40
  # - `:task`: Use async tasks (requires async gem)
40
41
  # - `:fiber`: Use raw fibers
41
42
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
43
+ # - `[:thread, :ractor]`: Wait for any queued thread or ractor work, in the
44
+ # given order. This is useful when different tools were spawned with
45
+ # different concurrency strategies.
42
46
  # @return [Array<LLM::Function::Return>]
43
47
  def wait(strategy)
44
48
  returns, tasks = @items.shift(@items.length).partition { LLM::Function::Return === _1 }
45
- results = case strategy
49
+ results = wait_tasks(tasks, strategy)
50
+ returns.concat fire_hooks(tasks, results)
51
+ end
52
+ alias_method :value, :wait
53
+
54
+ private
55
+
56
+ def wait_tasks(tasks, strategy)
57
+ strategies = Array(strategy)
58
+ return wait_group(tasks, strategies.first) unless strategies.length > 1
59
+ grouped = strategies.to_h { [_1, []] }
60
+ tasks.each do |task|
61
+ grouped[task_strategy(task)] << task
62
+ end
63
+ strategies.flat_map do |name|
64
+ selected = grouped.fetch(name)
65
+ selected.empty? ? [] : wait_group(selected, name)
66
+ end
67
+ end
68
+
69
+ def wait_group(tasks, strategy)
70
+ case strategy
46
71
  when :thread then LLM::Function::ThreadGroup.new(tasks).wait
47
72
  when :task then LLM::Function::TaskGroup.new(tasks).wait
48
73
  when :fiber then LLM::Function::FiberGroup.new(tasks).wait
49
74
  when :ractor then LLM::Function::Ractor::Group.new(tasks).wait
50
75
  else raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, :fiber, or :ractor"
51
76
  end
52
- returns.concat fire_hooks(tasks, results)
53
77
  end
54
- alias_method :value, :wait
55
78
 
56
- private
79
+ def task_strategy(task)
80
+ case task.task
81
+ when Thread then :thread
82
+ when Fiber then :fiber
83
+ when LLM::Function::Ractor::Task then :ractor
84
+ else :task
85
+ end
86
+ end
57
87
 
58
88
  def fire_hooks(tasks, results)
59
89
  results.each_with_index do |result, idx|
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.20.1"
4
+ VERSION = "4.21.0"
5
5
  end
data/lib/llm.rb CHANGED
@@ -29,6 +29,7 @@ module LLM
29
29
  require_relative "llm/eventstream"
30
30
  require_relative "llm/eventhandler"
31
31
  require_relative "llm/tool"
32
+ require_relative "llm/skill"
32
33
  require_relative "llm/server_tool"
33
34
  require_relative "llm/mcp"
34
35
 
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Plugins
5
+ require "llm/sequel/agent"
6
+ Agent = LLM::Sequel::Agent
7
+ end
8
+ 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.20.1
4
+ version: 4.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri
@@ -371,9 +371,11 @@ files:
371
371
  - lib/llm/schema/parser.rb
372
372
  - lib/llm/schema/string.rb
373
373
  - lib/llm/schema/version.rb
374
+ - lib/llm/sequel/agent.rb
374
375
  - lib/llm/sequel/plugin.rb
375
376
  - lib/llm/server_tool.rb
376
377
  - lib/llm/session.rb
378
+ - lib/llm/skill.rb
377
379
  - lib/llm/stream.rb
378
380
  - lib/llm/stream/queue.rb
379
381
  - lib/llm/tool.rb
@@ -386,6 +388,7 @@ files:
386
388
  - lib/llm/usage.rb
387
389
  - lib/llm/utils.rb
388
390
  - lib/llm/version.rb
391
+ - lib/sequel/plugins/agent.rb
389
392
  - lib/sequel/plugins/llm.rb
390
393
  - llm.gemspec
391
394
  homepage: https://github.com/llmrb/llm.rb