llm.rb 4.16.1 → 4.17.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: 6119e0673d055f50139fd33a4523bd99ffabb0fc2688a361f23297ef9ac428fd
4
- data.tar.gz: bfa5943241a2e9944245f0976458cfdd4895e7b72dbb83fd186f9d7ad2b987ee
3
+ metadata.gz: d834b30dd18d6bf83ccd2334bbedba0ee5a9f75d0d6d0616aefb7baa6be68ad0
4
+ data.tar.gz: d8c8a1e43cc89d888ab1ea008baddce7e1427fc3598daf68ca04fd0eff1351b0
5
5
  SHA512:
6
- metadata.gz: e139622d5cbaa8f42a1a6739b9792119f9086ae0b11ae896794953d9bb0fa9ed46159da7f10f7a8b619ef468404ae425b2158b359c940a11220dd48b0b230e2e
7
- data.tar.gz: 563f48928b6836d2bbcbc8d72bd594aa265fa285ad99cb8ff0c9574f9e67b7da60f9d42c2eeb1bddce0ba72e1425dab260bc65670fa7d812f7a77dc8016262d7
6
+ metadata.gz: f9fbe6f7080294c0500153dbfeb3c721407553289a2f34cd37e63552a84171bdc1bc130fb325aaf164f099c5fd6bfb417f550eea60dc126c20f940a7737e393a
7
+ data.tar.gz: 67cef120d84c98ea695caab059d2bb008ca1cbdeb15a1b2f13c819166b1edd7a523c7213acabed5b35c44e7123ae85481c0eaff815d6254088c858e5a0516ae3
data/CHANGELOG.md CHANGED
@@ -2,8 +2,53 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ Changes since `v4.17.0`.
6
+
7
+ ## v4.17.0
8
+
5
9
  Changes since `v4.16.1`.
6
10
 
11
+ This release expands agent support across llm.rb. It brings `LLM::Agent`
12
+ closer to `LLM::Context`, adds configurable automatic tool concurrency,
13
+ extends persisted ORM wrappers with more of the context runtime surface and
14
+ fiber-local tracer hooks, and introduces built-in ActiveRecord agent
15
+ persistence through `acts_as_agent`.
16
+
17
+ ### Change
18
+
19
+ * **Add configurable tool concurrency to `LLM::Agent`** <br>
20
+ Add the class-level `concurrency` DSL to `LLM::Agent` so automatic
21
+ tool loops can run with `:call`, `:thread`, `:task`, or `:fiber`
22
+ instead of always executing sequentially.
23
+
24
+ * **Bring `LLM::Agent` closer to `LLM::Context`** <br>
25
+ Expand `LLM::Agent` so it exposes more of the same runtime surface as
26
+ `LLM::Context`, including returns, interruption, mode, cost, context
27
+ window, structured serialization, and other context-backed helpers,
28
+ while still auto-managing tool loops.
29
+
30
+ * **Refresh agent docs and coverage** <br>
31
+ Update the README and deep dive to explain the current role of
32
+ `LLM::Agent`, add examples that show automatic tool execution and
33
+ concurrency, and add focused specs for the expanded agent surface and
34
+ tool-loop behavior.
35
+
36
+ * **Add ORM tracer hooks for persisted contexts** <br>
37
+ Add `tracer:` to both the Sequel plugin and `acts_as_llm` so models
38
+ can resolve and assign fiber-local tracers onto the provider used by
39
+ their persisted `LLM::Context`.
40
+
41
+ * **Bring persisted ORM wrappers closer to `LLM::Context`** <br>
42
+ Expand both the Sequel plugin and `acts_as_llm` so record-backed
43
+ contexts expose more of the same runtime surface as `LLM::Context`,
44
+ including mode, returns, interruption, prompt helpers, file helpers,
45
+ and tracer access.
46
+
47
+ * **Add ActiveRecord agent persistence with `acts_as_agent`** <br>
48
+ Add `acts_as_agent` for ActiveRecord models that should wrap
49
+ `LLM::Agent`, reusing the same record-backed runtime shape as
50
+ `acts_as_llm` while letting tool execution be managed by the agent.
51
+
7
52
  ## v4.16.1
8
53
 
9
54
  Changes since `v4.16.0`.
data/README.md CHANGED
@@ -4,27 +4,24 @@
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.16.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.17.0-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
11
11
 
12
- llm.rb is a runtime for building AI systems that integrate directly with your
13
- application. It is not just an API wrapper. It provides a unified execution
14
- model for providers, tools, MCP servers, streaming, schemas, files, and
15
- state.
12
+ llm.rb is a lightweight runtime for building capable AI systems in Ruby.
16
13
 
17
- It is built for engineers who want control over how these systems run. llm.rb
18
- stays close to Ruby, runs on the standard library by default, loads optional
19
- pieces only when needed, and remains easy to extend. It also works well in
20
- Rails or ActiveRecord applications, with built-in `acts_as_llm`, and includes
21
- built-in Sequel support through `plugin :llm`, so long-lived context state can
22
- be saved and restored across requests, jobs, or retries.
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.
23
18
 
24
- Most LLM libraries stop at request/response APIs. Building real systems means
25
- stitching together streaming, tools, state, persistence, and external
26
- services by hand. llm.rb provides a single execution model for all of these,
27
- so they compose naturally instead of becoming separate subsystems.
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
21
+ `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
23
+ long-lived, tool-capable, stateful AI workflows instead of just
24
+ request/response helpers.
28
25
 
29
26
  ## Architecture
30
27
 
@@ -66,6 +63,10 @@ same context object.
66
63
  - **Streaming and tool execution work together** <br>
67
64
  Start tool work while output is still streaming so you can hide latency
68
65
  instead of waiting for turns to finish.
66
+ - **Agents auto-manage tool execution** <br>
67
+ Use `LLM::Agent` when you want the same stateful runtime surface as
68
+ `LLM::Context`, but with tool loops executed automatically according to a
69
+ configured concurrency mode such as `:call`, `:thread`, `:task`, or `:fiber`.
69
70
  - **Tool calls have an explicit lifecycle** <br>
70
71
  A tool call can be executed, cancelled through
71
72
  [`LLM::Function#cancel`](https://0x1eef.github.io/x/llm.rb/LLM/Function.html#cancel-instance_method),
@@ -88,11 +89,14 @@ same context object.
88
89
  Connect to MCP servers over stdio or HTTP without bolting on a separate
89
90
  integration stack.
90
91
  - **ActiveRecord and Sequel persistence are built in** <br>
91
- Use `acts_as_llm` on ActiveRecord models or `plugin :llm` on Sequel models
92
- to persist `LLM::Context` state with sensible default columns. Both support
93
- `provider:` and `context:` hooks, plus `format: :string` for text columns
94
- or `format: :jsonb` for native PostgreSQL JSON storage when ORM JSON
95
- typecasting support is enabled.
92
+ llm.rb includes built-in ActiveRecord support through `acts_as_llm` and
93
+ `acts_as_agent`, plus built-in Sequel support through `plugin :llm`.
94
+ Use `acts_as_llm` when you want to wrap `LLM::Context`, `acts_as_agent`
95
+ when you want to wrap `LLM::Agent`, or `plugin :llm` on Sequel models to
96
+ persist `LLM::Context` state with sensible default columns. These
97
+ integrations support `provider:` and `context:` hooks, plus `format:
98
+ :string` for text columns or `format: :jsonb` for native PostgreSQL JSON
99
+ storage when ORM JSON typecasting support is enabled.
96
100
  - **Persistent HTTP pooling is shared process-wide** <br>
97
101
  When enabled, separate
98
102
  [`LLM::Provider`](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html)
@@ -174,7 +178,7 @@ gem install llm.rb
174
178
 
175
179
  **REPL**
176
180
 
177
- See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
181
+ 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.
178
182
 
179
183
  ```ruby
180
184
  require "llm"
@@ -191,7 +195,7 @@ end
191
195
 
192
196
  **Sequel (ORM)**
193
197
 
194
- See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
198
+ 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.
195
199
 
196
200
  ```ruby
197
201
  require "llm"
@@ -207,9 +211,10 @@ ctx.talk("Remember that my favorite language is Ruby")
207
211
  puts ctx.talk("What is my favorite language?").content
208
212
  ```
209
213
 
210
- **ActiveRecord (ORM)**
214
+ **ActiveRecord (ORM): acts_as_llm**
211
215
 
212
- See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
216
+ The `acts_as_llm` method wraps [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) and
217
+ provides full control over tool execution. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
213
218
 
214
219
  ```ruby
215
220
  require "llm"
@@ -225,6 +230,47 @@ ctx.talk("Remember that my favorite language is Ruby")
225
230
  puts ctx.talk("What is my favorite language?").content
226
231
  ```
227
232
 
233
+ **ActiveRecord (ORM): acts_as_agent**
234
+
235
+ The `acts_as_agent` method wraps [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) and
236
+ manages tool execution for you. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
237
+
238
+ ```ruby
239
+ require "llm"
240
+ require "active_record"
241
+ require "llm/active_record"
242
+
243
+ class Ticket < ApplicationRecord
244
+ acts_as_agent provider: -> { { key: ENV["#{provider.upcase}_SECRET"], persistent: true } }
245
+ model "gpt-5.4-mini"
246
+ instructions "You are a concise support assistant."
247
+ tools SearchDocs, Escalate
248
+ concurrency :thread
249
+ end
250
+
251
+ ticket = Ticket.create!(provider: "openai", model: "gpt-5.4-mini")
252
+ puts ticket.talk("How do I rotate my API key?").content
253
+ ```
254
+
255
+ **Agent**
256
+
257
+ 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.
258
+
259
+ ```ruby
260
+ require "llm"
261
+
262
+ class ShellAgent < LLM::Agent
263
+ model "gpt-5.4-mini"
264
+ instructions "You are a Linux system assistant."
265
+ tools Shell
266
+ concurrency :thread
267
+ end
268
+
269
+ llm = LLM.openai(key: ENV["KEY"])
270
+ agent = ShellAgent.new(llm)
271
+ puts agent.talk("What time is it on this system?").content
272
+ ```
273
+
228
274
  ## Resources
229
275
 
230
276
  - [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) is the
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::ActiveRecord
4
+ ##
5
+ # ActiveRecord integration for persisting {LLM::Agent LLM::Agent} state.
6
+ #
7
+ # This wrapper reuses the same record-backed runtime surface as
8
+ # {LLM::ActiveRecord::ActsAsLLM}, but builds an {LLM::Agent LLM::Agent}
9
+ # instead of an {LLM::Context LLM::Context}. Agent defaults such as model,
10
+ # tools, schema, instructions, and concurrency are configured on the model
11
+ # class and forwarded to an internal agent subclass.
12
+ module ActsAsAgent
13
+ EMPTY_HASH = LLM::ActiveRecord::ActsAsLLM::EMPTY_HASH
14
+ DEFAULT_USAGE_COLUMNS = LLM::ActiveRecord::ActsAsLLM::DEFAULT_USAGE_COLUMNS
15
+ DEFAULTS = LLM::ActiveRecord::ActsAsLLM::DEFAULTS
16
+
17
+ module ClassMethods
18
+ def model(model = nil)
19
+ return agent.model if model.nil?
20
+ agent.model(model)
21
+ end
22
+
23
+ def tools(*tools)
24
+ return agent.tools if tools.empty?
25
+ agent.tools(*tools)
26
+ end
27
+
28
+ def schema(schema = nil)
29
+ return agent.schema if schema.nil?
30
+ agent.schema(schema)
31
+ end
32
+
33
+ def instructions(instructions = nil)
34
+ return agent.instructions if instructions.nil?
35
+ agent.instructions(instructions)
36
+ end
37
+
38
+ def concurrency(concurrency = nil)
39
+ return agent.concurrency if concurrency.nil?
40
+ agent.concurrency(concurrency)
41
+ end
42
+
43
+ def agent
44
+ @agent ||= Class.new(LLM::Agent)
45
+ end
46
+ end
47
+
48
+ module Hooks
49
+ ##
50
+ # Called when hooks are extended onto an ActiveRecord model.
51
+ #
52
+ # @param [Class] model
53
+ # @return [void]
54
+ def self.extended(model)
55
+ options = model.llm_agent_options
56
+ model.validates options[:provider_column], options[:model_column], presence: true
57
+ model.include LLM::ActiveRecord::ActsAsLLM::InstanceMethods unless model.ancestors.include?(LLM::ActiveRecord::ActsAsLLM::InstanceMethods)
58
+ model.include InstanceMethods unless model.ancestors.include?(InstanceMethods)
59
+ model.extend ClassMethods unless model.singleton_class.ancestors.include?(ClassMethods)
60
+ end
61
+ end
62
+
63
+ ##
64
+ # Installs the `acts_as_agent` wrapper on an ActiveRecord model.
65
+ #
66
+ # @param [Hash] options
67
+ # @option options [Symbol] :format
68
+ # Storage format for the serialized agent state. Use `:string` for text
69
+ # columns, or `:json` / `:jsonb` for structured JSON columns with
70
+ # ActiveRecord JSON typecasting enabled.
71
+ # @option options [Proc, LLM::Tracer, nil] :tracer
72
+ # Optional tracer or proc that resolves to one and is assigned through
73
+ # `llm.tracer = ...` on the resolved provider.
74
+ # @return [void]
75
+ def acts_as_agent(options = EMPTY_HASH)
76
+ options = DEFAULTS.merge(options)
77
+ usage_columns = DEFAULT_USAGE_COLUMNS.merge(options[:usage_columns] || EMPTY_HASH)
78
+ class_attribute :llm_agent_options, instance_accessor: false, default: DEFAULTS unless respond_to?(:llm_agent_options)
79
+ self.llm_agent_options = options.merge(usage_columns: usage_columns.freeze).freeze
80
+ extend Hooks
81
+ end
82
+
83
+ module InstanceMethods
84
+ private
85
+
86
+ ##
87
+ # Returns the resolved provider instance for this record.
88
+ # @return [LLM::Provider]
89
+ def llm
90
+ options = self.class.llm_agent_options
91
+ provider = self[columns[:provider_column]]
92
+ kwargs = resolve_options(options[:provider])
93
+ @llm ||= LLM.method(provider).call(**kwargs)
94
+ @llm.tracer = resolve_option(options[:tracer]) if options[:tracer]
95
+ @llm
96
+ end
97
+
98
+ ##
99
+ # @return [LLM::Agent]
100
+ def ctx
101
+ @ctx ||= begin
102
+ options = self.class.llm_agent_options
103
+ params = resolve_options(options[:context]).dup
104
+ params[:model] ||= self[columns[:model_column]]
105
+ ctx = self.class.agent.new(llm, params.compact)
106
+ data = self[columns[:data_column]]
107
+ if data.nil? || data == ""
108
+ ctx
109
+ else
110
+ case options[:format]
111
+ when :string then ctx.restore(string: data)
112
+ when :json, :jsonb then ctx.restore(data:)
113
+ else raise ArgumentError, "Unknown format: #{options[:format].inspect}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ ##
120
+ # @return [void]
121
+ def flush
122
+ attrs = {
123
+ columns[:data_column] => serialize_context(self.class.llm_agent_options[:format]),
124
+ columns[:input_tokens] => ctx.usage.input_tokens,
125
+ columns[:output_tokens] => ctx.usage.output_tokens,
126
+ columns[:total_tokens] => ctx.usage.total_tokens
127
+ }
128
+ assign_attributes(attrs)
129
+ save!
130
+ end
131
+
132
+ ##
133
+ # @return [Hash]
134
+ def resolve_option(option)
135
+ case option
136
+ when Proc then instance_exec(&option)
137
+ when Hash then option.dup
138
+ else option
139
+ end
140
+ end
141
+
142
+ ##
143
+ # @return [Hash]
144
+ def resolve_options(option)
145
+ case option
146
+ when Proc, Hash then resolve_option(option)
147
+ else EMPTY_HASH.dup
148
+ end
149
+ end
150
+
151
+ def serialize_context(format)
152
+ case format
153
+ when :string then ctx.to_json
154
+ when :json, :jsonb then ctx.to_h
155
+ else raise ArgumentError, "Unknown format: #{format.inspect}"
156
+ end
157
+ end
158
+
159
+ def columns
160
+ @columns ||= begin
161
+ options = self.class.llm_agent_options
162
+ usage_columns = options[:usage_columns]
163
+ {
164
+ provider_column: options[:provider_column],
165
+ model_column: options[:model_column],
166
+ data_column: options[:data_column],
167
+ input_tokens: usage_columns[:input_tokens],
168
+ output_tokens: usage_columns[:output_tokens],
169
+ total_tokens: usage_columns[:total_tokens]
170
+ }.freeze
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ ::ActiveRecord::Base.extend(LLM::ActiveRecord::ActsAsAgent)
@@ -13,7 +13,8 @@ module LLM::ActiveRecord
13
13
  # default) or as a structured object (`format: :json` / `:jsonb`) for
14
14
  # databases such as PostgreSQL that can persist JSON natively.
15
15
  # `:json` and `:jsonb` expect a real JSON column type with ActiveRecord
16
- # handling JSON typecasting for the model.
16
+ # handling JSON typecasting for the model. A `tracer:` proc can also be
17
+ # configured to assign a fiber-local tracer onto the resolved provider.
17
18
  module ActsAsLLM
18
19
  EMPTY_HASH = {}.freeze
19
20
  DEFAULT_USAGE_COLUMNS = {
@@ -27,6 +28,7 @@ module LLM::ActiveRecord
27
28
  data_column: :data,
28
29
  format: :string,
29
30
  usage_columns: DEFAULT_USAGE_COLUMNS,
31
+ tracer: nil,
30
32
  provider: EMPTY_HASH,
31
33
  context: EMPTY_HASH
32
34
  }.freeze
@@ -52,6 +54,9 @@ module LLM::ActiveRecord
52
54
  # Storage format for the serialized context. Use `:string` for text
53
55
  # columns, or `:json` / `:jsonb` for structured JSON columns with
54
56
  # ActiveRecord JSON typecasting enabled.
57
+ # @option options [Proc, LLM::Tracer, nil] :tracer
58
+ # Optional tracer or proc that resolves to one and is assigned through
59
+ # `llm.tracer = ...` on the resolved provider.
55
60
  # @return [void]
56
61
  def acts_as_llm(options = EMPTY_HASH)
57
62
  options = DEFAULTS.merge(options)
@@ -94,6 +99,13 @@ module LLM::ActiveRecord
94
99
  ctx.call(...)
95
100
  end
96
101
 
102
+ ##
103
+ # @see LLM::Context#mode
104
+ # @return [Symbol]
105
+ def mode
106
+ ctx.mode
107
+ end
108
+
97
109
  ##
98
110
  # @see LLM::Context#messages
99
111
  # @return [Array<LLM::Message>]
@@ -116,6 +128,13 @@ module LLM::ActiveRecord
116
128
  ctx.functions
117
129
  end
118
130
 
131
+ ##
132
+ # @see LLM::Context#returns
133
+ # @return [Array<LLM::Function::Return>]
134
+ def returns
135
+ ctx.returns
136
+ end
137
+
119
138
  ##
120
139
  # @see LLM::Context#cost
121
140
  # @return [LLM::Cost]
@@ -143,6 +162,50 @@ module LLM::ActiveRecord
143
162
  )
144
163
  end
145
164
 
165
+ ##
166
+ # @see LLM::Context#interrupt!
167
+ # @return [nil]
168
+ def interrupt!
169
+ ctx.interrupt!
170
+ end
171
+ alias_method :cancel!, :interrupt!
172
+
173
+ ##
174
+ # @see LLM::Context#prompt
175
+ # @return [LLM::Prompt]
176
+ def prompt(&)
177
+ ctx.prompt(&)
178
+ end
179
+ alias_method :build_prompt, :prompt
180
+
181
+ ##
182
+ # @see LLM::Context#image_url
183
+ # @return [LLM::Object]
184
+ def image_url(...)
185
+ ctx.image_url(...)
186
+ end
187
+
188
+ ##
189
+ # @see LLM::Context#local_file
190
+ # @return [LLM::Object]
191
+ def local_file(...)
192
+ ctx.local_file(...)
193
+ end
194
+
195
+ ##
196
+ # @see LLM::Context#remote_file
197
+ # @return [LLM::Object]
198
+ def remote_file(...)
199
+ ctx.remote_file(...)
200
+ end
201
+
202
+ ##
203
+ # @see LLM::Context#tracer
204
+ # @return [LLM::Tracer]
205
+ def tracer
206
+ ctx.tracer
207
+ end
208
+
146
209
  private
147
210
 
148
211
  ##
@@ -153,6 +216,8 @@ module LLM::ActiveRecord
153
216
  provider = self[columns[:provider_column]]
154
217
  kwargs = resolve_options(options[:provider])
155
218
  @llm ||= LLM.method(provider).call(**kwargs)
219
+ @llm.tracer = resolve_option(options[:tracer]) if options[:tracer]
220
+ @llm
156
221
  end
157
222
 
158
223
  ##
@@ -1,3 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "llm/active_record/acts_as_llm"
4
+ require "llm/active_record/acts_as_agent"
data/lib/llm/agent.rb CHANGED
@@ -6,10 +6,18 @@ module LLM
6
6
  # reusable, preconfigured assistants with defaults for model,
7
7
  # tools, schema, and instructions.
8
8
  #
9
+ # It wraps the same stateful runtime surface as
10
+ # {LLM::Context LLM::Context}: message history, usage, persistence,
11
+ # streaming parameters, and provider-backed requests still flow through
12
+ # an underlying context. The defining behavior of an agent is that it
13
+ # automatically resolves pending tool calls for you during `talk` and
14
+ # `respond`, instead of leaving tool loops to the caller.
15
+ #
9
16
  # **Notes:**
10
17
  # * Instructions are injected only on the first request.
11
- # * An agent will automatically execute tool calls (unlike {LLM::Context LLM::Context}).
12
- # * The idea originally came from RubyLLM and was adapted to llm.rb.
18
+ # * An agent automatically executes tool loops (unlike {LLM::Context LLM::Context}).
19
+ # * Tool loop execution can be configured with `concurrency :call`,
20
+ # `:thread`, `:task`, or `:fiber`.
13
21
  #
14
22
  # @example
15
23
  # class SystemAdmin < LLM::Agent
@@ -72,6 +80,21 @@ module LLM
72
80
  @instructions = instructions
73
81
  end
74
82
 
83
+ ##
84
+ # Set or get the tool execution concurrency.
85
+ #
86
+ # @param [Symbol, nil] concurrency
87
+ # Controls how pending tool loops are executed:
88
+ # - `:call`: sequential calls
89
+ # - `:thread`: concurrent threads
90
+ # - `:task`: concurrent async tasks
91
+ # - `:fiber`: concurrent raw fibers
92
+ # @return [Symbol, nil]
93
+ def self.concurrency(concurrency = nil)
94
+ return @concurrency if concurrency.nil?
95
+ @concurrency = concurrency
96
+ end
97
+
75
98
  ##
76
99
  # @param [LLM::Provider] provider
77
100
  # A provider
@@ -82,8 +105,10 @@ module LLM
82
105
  # @option params [String] :model Defaults to the provider's default model
83
106
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
84
107
  # @option params [#to_json, nil] :schema Defaults to nil
108
+ # @option params [Symbol, nil] :concurrency Defaults to the agent class concurrency
85
109
  def initialize(llm, params = {})
86
110
  defaults = {model: self.class.model, tools: self.class.tools, schema: self.class.schema}.compact
111
+ @concurrency = params.delete(:concurrency) || self.class.concurrency
87
112
  @llm = llm
88
113
  @ctx = LLM::Context.new(llm, defaults.merge(params))
89
114
  end
@@ -94,7 +119,7 @@ module LLM
94
119
  #
95
120
  # @param prompt (see LLM::Provider#complete)
96
121
  # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
97
- # @option params [Integer] :max_tool_rounds The maxinum number of tool call iterations (default 10)
122
+ # @option params [Integer] :tool_attempts The maxinum number of tool call iterations (default 10)
98
123
  # @return [LLM::Response] Returns the LLM's response for this turn.
99
124
  # @example
100
125
  # llm = LLM.openai(key: ENV["KEY"])
@@ -102,13 +127,13 @@ module LLM
102
127
  # response = agent.talk("Hello, what is your name?")
103
128
  # puts response.choices[0].content
104
129
  def talk(prompt, params = {})
105
- i, max = 0, Integer(params.delete(:max_tool_rounds) || 10)
130
+ max = Integer(params.delete(:tool_attempts) || 10)
106
131
  res = @ctx.talk(apply_instructions(prompt), params)
107
- until @ctx.functions.empty?
108
- raise LLM::ToolLoopError, "pending tool calls remain" if i >= max
109
- res = @ctx.talk @ctx.functions.map(&:call), params
110
- i += 1
132
+ max.times do
133
+ break if @ctx.functions.empty?
134
+ res = @ctx.talk(call_functions, params)
111
135
  end
136
+ raise LLM::ToolLoopError, "pending tool calls remain" unless @ctx.functions.empty?
112
137
  res
113
138
  end
114
139
  alias_method :chat, :talk
@@ -120,7 +145,7 @@ module LLM
120
145
  # @note Not all LLM providers support this API
121
146
  # @param prompt (see LLM::Provider#complete)
122
147
  # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
123
- # @option params [Integer] :max_tool_rounds The maxinum number of tool call iterations (default 10)
148
+ # @option params [Integer] :tool_attempts The maxinum number of tool call iterations (default 10)
124
149
  # @return [LLM::Response] Returns the LLM's response for this turn.
125
150
  # @example
126
151
  # llm = LLM.openai(key: ENV["KEY"])
@@ -128,13 +153,13 @@ module LLM
128
153
  # res = agent.respond("What is the capital of France?")
129
154
  # puts res.output_text
130
155
  def respond(prompt, params = {})
131
- i, max = 0, Integer(params.delete(:max_tool_rounds) || 10)
156
+ max = Integer(params.delete(:tool_attempts) || 10)
132
157
  res = @ctx.respond(apply_instructions(prompt), params)
133
- until @ctx.functions.empty?
134
- raise LLM::ToolLoopError, "pending tool calls remain" if i >= max
135
- res = @ctx.respond @ctx.functions.map(&:call), params
136
- i += 1
158
+ max.times do
159
+ break if @ctx.functions.empty?
160
+ res = @ctx.respond(call_functions, params)
137
161
  end
162
+ raise LLM::ToolLoopError, "pending tool calls remain" unless @ctx.functions.empty?
138
163
  res
139
164
  end
140
165
 
@@ -150,12 +175,41 @@ module LLM
150
175
  @ctx.functions
151
176
  end
152
177
 
178
+ ##
179
+ # @see LLM::Context#returns
180
+ # @return [Array<LLM::Function::Return>]
181
+ def returns
182
+ @ctx.returns
183
+ end
184
+
185
+ ##
186
+ # @see LLM::Context#call
187
+ # @return [Object]
188
+ def call(...)
189
+ @ctx.call(...)
190
+ end
191
+
192
+ ##
193
+ # @see LLM::Context#wait
194
+ # @return [Array<LLM::Function::Return>]
195
+ def wait(...)
196
+ @ctx.wait(...)
197
+ end
198
+
153
199
  ##
154
200
  # @return [LLM::Object]
155
201
  def usage
156
202
  @ctx.usage
157
203
  end
158
204
 
205
+ ##
206
+ # Interrupt the active request, if any.
207
+ # @return [nil]
208
+ def interrupt!
209
+ @ctx.interrupt!
210
+ end
211
+ alias_method :cancel!, :interrupt!
212
+
159
213
  ##
160
214
  # @param (see LLM::Context#prompt)
161
215
  # @return (see LLM::Context#prompt)
@@ -206,6 +260,53 @@ module LLM
206
260
  @ctx.model
207
261
  end
208
262
 
263
+ ##
264
+ # @return [Symbol]
265
+ def mode
266
+ @ctx.mode
267
+ end
268
+
269
+ ##
270
+ # Returns the configured tool execution concurrency.
271
+ # @return [Symbol, nil]
272
+ def concurrency
273
+ @concurrency
274
+ end
275
+
276
+ ##
277
+ # @see LLM::Context#cost
278
+ # @return [LLM::Cost]
279
+ def cost
280
+ @ctx.cost
281
+ end
282
+
283
+ ##
284
+ # @see LLM::Context#context_window
285
+ # @return [Integer]
286
+ def context_window
287
+ @ctx.context_window
288
+ end
289
+
290
+ ##
291
+ # @see LLM::Context#to_h
292
+ # @return [Hash]
293
+ def to_h
294
+ @ctx.to_h
295
+ end
296
+
297
+ ##
298
+ # @return [String]
299
+ def to_json(...)
300
+ to_h.to_json(...)
301
+ end
302
+
303
+ ##
304
+ # @return [String]
305
+ def inspect
306
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
307
+ "@llm=#{@llm.class}, @mode=#{mode.inspect}, @messages=#{messages.inspect}>"
308
+ end
309
+
209
310
  ##
210
311
  # @param (see LLM::Context#serialize)
211
312
  # @return (see LLM::Context#serialize)
@@ -230,14 +331,24 @@ module LLM
230
331
  instr = self.class.instructions
231
332
  return new_prompt unless instr
232
333
  if LLM::Prompt === new_prompt
233
- @ctx.messages.empty? ? new_prompt.system(instr) : nil
334
+ new_prompt.system(instr) if @ctx.messages.empty?
234
335
  new_prompt
235
336
  else
236
337
  prompt do
237
- @ctx.messages.empty? ? _1.system(instr) : nil
338
+ _1.system(instr) if @ctx.messages.empty?
238
339
  _1.user(new_prompt)
239
340
  end
240
341
  end
241
342
  end
343
+
344
+ ##
345
+ # @return [Array<LLM::Function::Return>]
346
+ def call_functions
347
+ case concurrency || :call
348
+ when :call then call(:functions)
349
+ when :thread, :task, :fiber then wait(concurrency)
350
+ else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. Expected :call, :thread, :task, or :fiber"
351
+ end
352
+ end
242
353
  end
243
354
  end
@@ -13,7 +13,8 @@ module LLM::Sequel
13
13
  # default) or as a structured object (`format: :json` / `:jsonb`) for
14
14
  # databases such as PostgreSQL that can persist JSON natively.
15
15
  # `:json` and `:jsonb` expect a real JSON column type with Sequel handling
16
- # JSON typecasting for the model.
16
+ # JSON typecasting for the model. A `tracer:` proc can also be configured
17
+ # to assign a fiber-local tracer onto the resolved provider.
17
18
  module Plugin
18
19
  EMPTY_HASH = {}.freeze
19
20
  DEFAULT_USAGE_COLUMNS = {
@@ -27,6 +28,7 @@ module LLM::Sequel
27
28
  data_column: :data,
28
29
  format: :string,
29
30
  usage_columns: DEFAULT_USAGE_COLUMNS,
31
+ tracer: nil,
30
32
  provider: EMPTY_HASH,
31
33
  context: EMPTY_HASH
32
34
  }.freeze
@@ -59,6 +61,9 @@ module LLM::Sequel
59
61
  # Storage format for the serialized context. Use `:string` for text
60
62
  # columns, or `:json` / `:jsonb` for structured JSON columns with Sequel
61
63
  # JSON typecasting enabled.
64
+ # @option options [Proc, LLM::Tracer, nil] :tracer
65
+ # Optional tracer or proc that resolves to one and is assigned through
66
+ # `llm.tracer = ...` on the resolved provider.
62
67
  # @return [void]
63
68
  def self.configure(model, options = EMPTY_HASH)
64
69
  options = DEFAULTS.merge(options)
@@ -111,6 +116,13 @@ module LLM::Sequel
111
116
  ctx.call(...)
112
117
  end
113
118
 
119
+ ##
120
+ # @see LLM::Context#mode
121
+ # @return [Symbol]
122
+ def mode
123
+ ctx.mode
124
+ end
125
+
114
126
  ##
115
127
  # @see LLM::Context#messages
116
128
  # @return [Array<LLM::Message>]
@@ -134,6 +146,13 @@ module LLM::Sequel
134
146
  ctx.functions
135
147
  end
136
148
 
149
+ ##
150
+ # @see LLM::Context#returns
151
+ # @return [Array<LLM::Function::Return>]
152
+ def returns
153
+ ctx.returns
154
+ end
155
+
137
156
  ##
138
157
  # @see LLM::Context#cost
139
158
  # @return [LLM::Cost]
@@ -161,6 +180,50 @@ module LLM::Sequel
161
180
  )
162
181
  end
163
182
 
183
+ ##
184
+ # @see LLM::Context#interrupt!
185
+ # @return [nil]
186
+ def interrupt!
187
+ ctx.interrupt!
188
+ end
189
+ alias_method :cancel!, :interrupt!
190
+
191
+ ##
192
+ # @see LLM::Context#prompt
193
+ # @return [LLM::Prompt]
194
+ def prompt(&)
195
+ ctx.prompt(&)
196
+ end
197
+ alias_method :build_prompt, :prompt
198
+
199
+ ##
200
+ # @see LLM::Context#image_url
201
+ # @return [LLM::Object]
202
+ def image_url(...)
203
+ ctx.image_url(...)
204
+ end
205
+
206
+ ##
207
+ # @see LLM::Context#local_file
208
+ # @return [LLM::Object]
209
+ def local_file(...)
210
+ ctx.local_file(...)
211
+ end
212
+
213
+ ##
214
+ # @see LLM::Context#remote_file
215
+ # @return [LLM::Object]
216
+ def remote_file(...)
217
+ ctx.remote_file(...)
218
+ end
219
+
220
+ ##
221
+ # @see LLM::Context#tracer
222
+ # @return [LLM::Tracer]
223
+ def tracer
224
+ ctx.tracer
225
+ end
226
+
164
227
  private
165
228
 
166
229
  ##
@@ -171,6 +234,8 @@ module LLM::Sequel
171
234
  provider = self[columns[:provider_column]]
172
235
  kwargs = resolve_options(options[:provider])
173
236
  @llm ||= LLM.method(provider).call(**kwargs)
237
+ @llm.tracer = resolve_option(options[:tracer]) if options[:tracer]
238
+ @llm
174
239
  end
175
240
 
176
241
  ##
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.16.1"
4
+ VERSION = "4.17.0"
5
5
  end
data/llm.gemspec CHANGED
@@ -8,25 +8,19 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Antar Azri", "0x1eef", "Christos Maris", "Rodrigo Serrano"]
9
9
  spec.email = ["azantar@proton.me", "0x1eef@hardenedbsd.org"]
10
10
 
11
- spec.summary = "System integration layer for LLMs, tools, MCP, and APIs in Ruby."
11
+ spec.summary = "Lightweight runtime for building capable AI systems in Ruby."
12
12
 
13
13
  spec.description = <<~DESCRIPTION
14
- llm.rb is a runtime for building AI systems that integrate directly with your
15
- application. It is not just an API wrapper. It provides a unified execution
16
- model for providers, tools, MCP servers, streaming, schemas, files, and
17
- state.
18
-
19
- It is built for engineers who want control over how these systems run.
20
- llm.rb stays close to Ruby, runs on the standard library by default, loads
21
- optional pieces only when needed, and remains easy to extend. It also works
22
- well in Rails or ActiveRecord applications, where a small wrapper around
23
- context persistence is enough to save and restore long-lived conversation
24
- state across requests, jobs, or retries.
25
-
26
- Most LLM libraries stop at request/response APIs. Building real systems
27
- means stitching together streaming, tools, state, persistence, and external
28
- services by hand. llm.rb provides a single execution model for all of these,
29
- so they compose naturally instead of becoming separate subsystems.
14
+ llm.rb is a lightweight runtime for building capable AI systems in Ruby.
15
+ It is not just an API wrapper. llm.rb gives you one runtime for providers,
16
+ contexts, agents, tools, MCP servers, streaming, schemas, files, and
17
+ persisted state, so real systems can be built out of one coherent
18
+ execution model instead of a pile of adapters. It stays close to Ruby, runs
19
+ on the standard library by default, loads optional pieces only when needed,
20
+ works naturally in Rails or ActiveRecord through acts_as_llm, includes
21
+ built-in Sequel support through plugin :llm, and is designed for
22
+ engineers who want control over long-lived, tool-capable, stateful AI
23
+ workflows instead of just request/response helpers.
30
24
  DESCRIPTION
31
25
 
32
26
  spec.license = "0BSD"
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.16.1
4
+ version: 4.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri
@@ -195,22 +195,16 @@ dependencies:
195
195
  - !ruby/object:Gem::Version
196
196
  version: '1.7'
197
197
  description: |
198
- llm.rb is a runtime for building AI systems that integrate directly with your
199
- application. It is not just an API wrapper. It provides a unified execution
200
- model for providers, tools, MCP servers, streaming, schemas, files, and
201
- state.
202
-
203
- It is built for engineers who want control over how these systems run.
204
- llm.rb stays close to Ruby, runs on the standard library by default, loads
205
- optional pieces only when needed, and remains easy to extend. It also works
206
- well in Rails or ActiveRecord applications, where a small wrapper around
207
- context persistence is enough to save and restore long-lived conversation
208
- state across requests, jobs, or retries.
209
-
210
- Most LLM libraries stop at request/response APIs. Building real systems
211
- means stitching together streaming, tools, state, persistence, and external
212
- services by hand. llm.rb provides a single execution model for all of these,
213
- so they compose naturally instead of becoming separate subsystems.
198
+ llm.rb is a lightweight runtime for building capable AI systems in Ruby.
199
+ It is not just an API wrapper. llm.rb gives you one runtime for providers,
200
+ contexts, agents, tools, MCP servers, streaming, schemas, files, and
201
+ persisted state, so real systems can be built out of one coherent
202
+ execution model instead of a pile of adapters. It stays close to Ruby, runs
203
+ on the standard library by default, loads optional pieces only when needed,
204
+ works naturally in Rails or ActiveRecord through acts_as_llm, includes
205
+ built-in Sequel support through plugin :llm, and is designed for
206
+ engineers who want control over long-lived, tool-capable, stateful AI
207
+ workflows instead of just request/response helpers.
214
208
  email:
215
209
  - azantar@proton.me
216
210
  - 0x1eef@hardenedbsd.org
@@ -229,6 +223,7 @@ files:
229
223
  - data/zai.json
230
224
  - lib/llm.rb
231
225
  - lib/llm/active_record.rb
226
+ - lib/llm/active_record/acts_as_agent.rb
232
227
  - lib/llm/active_record/acts_as_llm.rb
233
228
  - lib/llm/agent.rb
234
229
  - lib/llm/bot.rb
@@ -410,5 +405,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
410
405
  requirements: []
411
406
  rubygems_version: 3.6.9
412
407
  specification_version: 4
413
- summary: System integration layer for LLMs, tools, MCP, and APIs in Ruby.
408
+ summary: Lightweight runtime for building capable AI systems in Ruby.
414
409
  test_files: []