llm.rb 6.1.0 → 8.0.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: 57b39b3b4b79d1d9f8cfd10426ad233d698dd6e3ed84bfef887c8c63f543f40f
4
- data.tar.gz: 443ed7e2a04259c69d41b1da7a42e7637efaa4ab1075548706ce349bced7ed51
3
+ metadata.gz: 4d726213f6b63342582738a133f7f82c1158934d6f25a48ae6b6c9e59a8f8262
4
+ data.tar.gz: 6288d177adc7a07a37368066329c882f746747d5bed9ffba7cb50d2bcbd1d98c
5
5
  SHA512:
6
- metadata.gz: f8e53dc41eacf16cea35f64a6048aa77852fcf7a135676b2b9c02e37beff174b5a500948477c4f931ff0a71d20c4503ba3e9eef19358d3aaa204040e77fe14c5
7
- data.tar.gz: 358ce7f33d2dca51365f6581867006970fd66079dcaa189268e2deff2f297c89b8332fd11b714bedfd89124413b7a9e12fc09d928c2c28f2e9cb2368f2bc3e24
6
+ metadata.gz: 4ae089f4117dc384000a70500c40ebadf48f42d1bd820d0840568b3b31b0197e51c65e9f60fe65d0e75c23aa4c7eac977be928a38969580174169bd0efe39912
7
+ data.tar.gz: 9653135f93b9b2b722102f055dc961346949368dab161a3cff64e99ddfc6781933a94b527151da9a24ff39451814f76c5409389f91c3692852eb17bd5d3d11f9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,135 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## v8.0.0
6
+
7
+ Changes since `v7.0.0`.
8
+
9
+ This release adds Unix-fork concurrency for process-isolated tool
10
+ execution, extends `LLM::Object` with `#merge` and `#delete`, and drops
11
+ Ruby 3.2 support due to segfaults observed with the `:fork` path. It
12
+ promotes `LLM::Pipe` to the top-level namespace and adds
13
+ `persistent: true` on `LLM::MCP.http` for direct persistent transport
14
+ configuration. `LLM::Function#runner` is exposed as public API, agent
15
+ tracer overrides are supported, fiber execution now uses `Fiber.schedule`,
16
+ missing optional dependencies raise clearer `LLM::LoadError` guidance,
17
+ and ActiveRecord wrapper plumbing is deduplicated between `acts_as_llm`
18
+ and `acts_as_agent`.
19
+
20
+ ### Breaking
21
+
22
+ * **Drop Ruby 3.2 support** <br>
23
+ Stop supporting Ruby 3.2 due to a segfault observed with the `:fork`
24
+ tool concurrency strategy.
25
+
26
+ ### Add
27
+
28
+ * **Add `LLM::Object#merge`** <br>
29
+ Let `LLM::Object` return a new wrapped object when merging hash-like
30
+ data through `#merge`.
31
+
32
+ * **Add `LLM::Object#delete`** <br>
33
+ Let `LLM::Object` delete keys directly through `#delete`.
34
+
35
+ ### Change
36
+
37
+ * **Add fork-based tool concurrency** <br>
38
+ Add `:fork` as a new concurrency strategy for `LLM::Function#spawn`,
39
+ `LLM::Function::Array#wait`, and `LLM::Agent.concurrency` that runs
40
+ class-based tools in isolated child processes. Fork-backed tools support
41
+ tracer callbacks, `on_interrupt`/`on_cancel` hooks, and `alive?` checks.
42
+ Requires the `xchan` gem for inter-process communication with `:fork`.
43
+ This is especially useful for tools that need process isolation, such as
44
+ running shell commands or handling unsafe data.
45
+
46
+ * **Promote `LLM::Pipe` from MCP namespace to top-level** <br>
47
+ Move `LLM::MCP::Pipe` to `LLM::Pipe` so the pipe abstraction is available
48
+ outside MCP internals. The new class adds a `binmode:` option for binary
49
+ pipes. `LLM::MCP::Command` and related MCP transport code have been updated
50
+ to use `LLM::Pipe`.
51
+
52
+ * **Allow `persistent: true` on `LLM::MCP.http`** <br>
53
+ Let `LLM::MCP.http(...)` enable persistent HTTP transport directly
54
+ through `persistent: true`, instead of requiring a separate
55
+ `.persistent` call after construction.
56
+
57
+ * **Expose `LLM::Function#runner` as public API** <br>
58
+ Promote the internal runner instantiation to a public `runner` method on
59
+ `LLM::Function`, so callers can inspect or reuse the resolved tool instance
60
+ that a function wraps.
61
+
62
+ * **Allow agent instance tracer overrides** <br>
63
+ Let `LLM::Agent.new(..., tracer: ...)` override the class-level tracer
64
+ for that agent instance.
65
+
66
+ * **Make `:fiber` use scheduler-backed fibers** <br>
67
+ Change `:fiber` tool execution to use `Fiber.schedule` and require
68
+ `Fiber.scheduler`, instead of wrapping direct calls in raw fibers. This
69
+ gives `:fiber` a real cooperative concurrency model instead of acting as
70
+ a thin wrapper around sequential execution.
71
+
72
+ * **Read stored values from zero-argument `LLM::Object` method calls** <br>
73
+ Let calls like `obj.delete`, `obj.fetch`, `obj.merge`, `obj.key?`,
74
+ `obj.dig`, `obj.slice`, or `obj.keys` return a stored value when that
75
+ method name exists as a key and no arguments are given.
76
+
77
+ * **Harden `LLM::Object` against arbitrary key names** <br>
78
+ Move internal lookup logic off `LLM::Object` instances and onto the
79
+ singleton class instead, making stored keys like `method_missing`
80
+ more resilient while preserving normal dynamic field access.
81
+
82
+ * **Deduplicate ActiveRecord wrapper plumbing** <br>
83
+ Move shared ActiveRecord wrapper defaults and utility methods into
84
+ `LLM::ActiveRecord`, reducing duplication between `acts_as_llm` and
85
+ `acts_as_agent`.
86
+
87
+ * **Raise clearer errors for missing optional runtime dependencies** <br>
88
+ Route optional `async`, `xchan`, and `net/http/persistent` loads
89
+ through `LLM.require` so missing runtime gems raise `LLM::LoadError`
90
+ with installation guidance instead of leaking raw `LoadError`
91
+ exceptions.
92
+
93
+ ### Fix
94
+
95
+ * **Avoid `RuntimeError` from `Async::Task.current` lookups** <br>
96
+ Check `Async::Task.current?` before reading the current Async task so
97
+ provider transports fall back to `Fiber.current` without raising when
98
+ no Async task is active.
99
+
100
+ * **Serialize `LLM::Object` values correctly through `LLM.json`** <br>
101
+ Make `LLM::Object#to_json` call `LLM.json.dump(to_h, ...)` so
102
+ `LLM::Object` values serialize through the llm.rb JSON adapter.
103
+
104
+ ## v7.0.0
105
+
106
+ Changes since `v6.1.0`.
107
+
108
+ This release turns agent tool-loop limit errors into in-band advisory
109
+ returns so the LLM can react to rate limits and continue the loop. It
110
+ adds `tool_attempts: nil` as a way to opt out of advisory tool-limit
111
+ returns entirely, and fixes the default provider HTTP path to keep
112
+ `net-http-persistent` optional when not explicitly enabled.
113
+
114
+ ### Breaking
115
+
116
+ * **Return in-band tool-loop limit errors from agents** <br>
117
+ Stop raising `LLM::ToolLoopError` when an agent exhausts its tool loop
118
+ attempt budget, and instead send advisory `LLM::Function::Return`
119
+ errors back through the model so the LLM can react to the rate limit
120
+ in-band and continue the loop.
121
+
122
+ * **Allow `tool_attempts: nil` to disable advisory tool-limit returns** <br>
123
+ Keep the default `tool_attempts` budget at `25`, but treat an explicit
124
+ `tool_attempts: nil` as an opt-out that disables advisory tool-limit
125
+ returns entirely.
126
+
127
+ ### Fix
128
+
129
+ * **Keep `net-http-persistent` optional on normal HTTP requests** <br>
130
+ Stop the default provider HTTP path from loading `net/http/persistent`
131
+ unless persistent transport support is explicitly enabled.
132
+
3
133
  ## v6.1.0
4
134
 
5
135
  Changes since `v6.0.0`.
@@ -90,6 +220,12 @@ and `LLM::RactorError` is raised for unsupported ractor tool work.
90
220
  for unsupported tool types such as skill-backed tools, instead of letting
91
221
  deeper Ruby isolation errors leak out later in execution.
92
222
 
223
+ * **Delegate interrupt to concurrent task implementations** <br>
224
+ Make `LLM::Function::Task#interrupt!` delegate to the underlying fork or
225
+ ractor task when it supports interruption, so `ctx.interrupt!` and
226
+ `task.interrupt!` work correctly for fork- and ractor-backed tool
227
+ execution.
228
+
93
229
  ## v5.4.0
94
230
 
95
231
  Changes since `v5.3.0`.
@@ -797,7 +933,7 @@ Changes since `v4.9.0`.
797
933
 
798
934
  - Add HTTP transport for MCP with `LLM::MCP::Transport::HTTP` for remote servers
799
935
  - Add JSON Schema union types (`any_of`, `all_of`, `one_of`) with parser integration
800
- - Add JSON Schema type array union support (e.g., `"type": ["object", "null"]`)
936
+ - Add JSON Schema type array union support (e.g., `"type\": [\"object\", \"null\"]`)
801
937
  - Add JSON Schema type inference from `const`, `enum`, or `default` fields
802
938
 
803
939
  ### Change
data/README.md CHANGED
@@ -4,7 +4,7 @@
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-6.1.0-green.svg?" alt="Version"></a>
7
+ <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-8.0.0-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
@@ -24,6 +24,11 @@ It provides one runtime for providers, agents, tools, skills, MCP servers, strea
24
24
  schemas, files, and persisted state, so real systems can be built out of one coherent
25
25
  execution model instead of a pile of adapters.
26
26
 
27
+ It provides concurrent tool execution with multiple strategies exposed through a single
28
+ runtime: async-task, threads, fibers, ractors and processes (fork). The first three are
29
+ good for IO-bound work and the last two are good for CPU-bound work. Ractor support is
30
+ experimental and comes with limitations.
31
+
27
32
  Want to see some code? Jump to [the examples](#examples) section. <br>
28
33
  Want to see a self-hosted LLM environment built on llm.rb? Check out [Relay](https://github.com/llmrb/relay).
29
34
 
@@ -287,8 +292,13 @@ end
287
292
  #### Concurrency
288
293
 
289
294
  Tool execution can run sequentially with `:call` or concurrently through
290
- `:thread`, `:task`, `:fiber`, and experimental `:ractor`, without rewriting
291
- your tool layer.
295
+ `:thread`, `:task`, `:fiber`, `:fork`, and experimental `:ractor`, without
296
+ rewriting your tool layer. Async tasks, threads, and fibers are the
297
+ I/O-bound options. Fork and ractor are the CPU-bound options. `:fork`
298
+ requires [`xchan.rb`](https://github.com/0x1eef/xchan.rb#readme) support,
299
+ and `:ractor` is still experimental.
300
+
301
+ `:fiber` uses `Fiber.schedule`, so it requires `Fiber.scheduler`.
292
302
 
293
303
  ```ruby
294
304
  class Agent < LLM::Agent
@@ -311,8 +321,9 @@ finer sequential control across several steps before shutting the client down.
311
321
  ```ruby
312
322
  mcp = LLM::MCP.http(
313
323
  url: "https://api.githubcopilot.com/mcp/",
314
- headers: {"Authorization" => "Bearer #{ENV["GITHUB_PAT"]}"}
315
- ).persistent
324
+ headers: {"Authorization" => "Bearer #{ENV["GITHUB_PAT"]}"},
325
+ persistent: true
326
+ )
316
327
  mcp.run do
317
328
  ctx = LLM::Context.new(llm, tools: mcp.tools)
318
329
  end
@@ -367,9 +378,13 @@ worker.join
367
378
  Use `LLM::Agent` when you want the same stateful runtime surface as
368
379
  `LLM::Context`, but with tool loops executed automatically according to a
369
380
  configured concurrency mode such as `:call`, `:thread`, `:task`, `:fiber`,
370
- or experimental `:ractor` support for class-based tools. MCP tools are not
371
- supported by the current `:ractor` mode, but mixed tool sets can still
372
- route MCP tools and local tools through different strategies at runtime.
381
+ `:fork`, or experimental `:ractor` support for class-based tools. MCP tools
382
+ are not supported by the current `:ractor` mode, but mixed tool sets can
383
+ still route MCP tools and local tools through different strategies at
384
+ runtime. By default, the tool attempt budget is `25`. When an agent
385
+ exhausts that budget, it sends advisory tool errors back through the model
386
+ instead of raising out of the runtime. Set `tool_attempts: nil` to disable
387
+ that advisory behavior.
373
388
  - **Tool calls have an explicit lifecycle** <br>
374
389
  A tool call can be executed, cancelled through
375
390
  [`LLM::Function#cancel`](https://0x1eef.github.io/x/llm.rb/LLM/Function.html#cancel-instance_method),
@@ -381,13 +396,15 @@ worker.join
381
396
  [`LLM::Context#cancel!`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html#cancel-21-instance_method)
382
397
  is inspired by Go's context cancellation model.
383
398
  - **Concurrency is a first-class feature** <br>
384
- Use threads, fibers, async tasks, or experimental ractors without
385
- rewriting your tool layer. The current `:ractor` mode is for class-based
386
- tools and does not support MCP tools, but mixed workloads can branch on
387
- `tool.mcp?` and choose a supported strategy per tool. Class-based
388
- `:ractor` tools still emit normal tool tracer callbacks. `:ractor` is
389
- especially useful for CPU-bound tools, while `:task`, `:fiber`, or
390
- `:thread` may be a better fit for I/O-bound work.
399
+ Use async tasks, threads, fibers, forks, or experimental ractors without
400
+ rewriting your tool layer. Async tasks, threads, and fibers are the
401
+ I/O-bound options. Fork and ractor are the CPU-bound options. `:fork`
402
+ requires [`xchan.rb`](https://github.com/0x1eef/xchan.rb#readme) support.
403
+ The current `:ractor` mode is for class-based tools, and MCP tools are
404
+ not supported by ractor, but mixed workloads can branch on `tool.mcp?`
405
+ and choose a supported strategy per tool. Class-based `:ractor` tools
406
+ still emit normal tool tracer callbacks. `:fiber` uses `Fiber.schedule`,
407
+ so it requires `Fiber.scheduler`.
391
408
  - **Advanced workloads are built in, not bolted on** <br>
392
409
  Streaming, concurrent tool execution, persistence, tracing, and MCP support
393
410
  all fit the same runtime model.
@@ -625,7 +642,7 @@ This example uses [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context
625
642
  [`LLM::Stream`](https://0x1eef.github.io/x/llm.rb/LLM/Stream.html) together so
626
643
  long-lived contexts can summarize older history and expose the lifecycle
627
644
  through stream hooks. This approach is inspired by General Intelligence
628
- Systems' [Brute](https://github.com/general-intelligence-systems/brute). The
645
+ Systems. The
629
646
  compactor can also use its own `model:` if you want summarization to run on a
630
647
  different model from the main context. `token_threshold:` accepts either a
631
648
  fixed token count or a percentage string like `"90%"`, which resolves
@@ -861,8 +878,9 @@ require "net/http/persistent"
861
878
  llm = LLM.openai(key: ENV["KEY"])
862
879
  mcp = LLM::MCP.http(
863
880
  url: "https://api.githubcopilot.com/mcp/",
864
- headers: {"Authorization" => "Bearer #{ENV["GITHUB_PAT"]}"}
865
- ).persistent
881
+ headers: {"Authorization" => "Bearer #{ENV["GITHUB_PAT"]}"},
882
+ persistent: true
883
+ )
866
884
 
867
885
  mcp.start
868
886
  ctx = LLM::Context.new(llm, stream: $stdout, tools: mcp.tools)
@@ -876,8 +894,9 @@ For scoped work, `mcp.run do ... end` is shorter and handles cleanup for you:
876
894
  ```ruby
877
895
  mcp = LLM::MCP.http(
878
896
  url: "https://api.githubcopilot.com/mcp/",
879
- headers: {"Authorization" => "Bearer #{ENV["GITHUB_PAT"]}"}
880
- ).persistent
897
+ headers: {"Authorization" => "Bearer #{ENV["GITHUB_PAT"]}"},
898
+ persistent: true
899
+ )
881
900
  mcp.run do
882
901
  ctx = LLM::Context.new(llm, stream: $stdout, tools: mcp.tools)
883
902
  ctx.talk("Pull information about my GitHub account.")
@@ -10,10 +10,6 @@ module LLM::ActiveRecord
10
10
  # tools, schema, instructions, and concurrency are configured on the model
11
11
  # class and forwarded to an internal agent subclass.
12
12
  module ActsAsAgent
13
- EMPTY_HASH = LLM::ActiveRecord::ActsAsLLM::EMPTY_HASH
14
- DEFAULTS = LLM::ActiveRecord::ActsAsLLM::DEFAULTS
15
- Utils = LLM::ActiveRecord::ActsAsLLM::Utils
16
-
17
13
  module ClassMethods
18
14
  def model(model = nil)
19
15
  return agent.model if model.nil?
@@ -96,7 +92,7 @@ module LLM::ActiveRecord
96
92
  def llm
97
93
  options = self.class.llm_plugin_options
98
94
  return @llm if @llm
99
- @llm = Utils.resolve_provider(self, options, ActsAsAgent::EMPTY_HASH)
95
+ @llm = Utils.resolve_provider(self, options, EMPTY_HASH)
100
96
  @llm.tracer = Utils.resolve_option(self, options[:tracer]) if options[:tracer]
101
97
  @llm
102
98
  end
@@ -108,7 +104,7 @@ module LLM::ActiveRecord
108
104
  def ctx
109
105
  @ctx ||= begin
110
106
  options = self.class.llm_plugin_options
111
- params = Utils.resolve_options(self, options[:context], ActsAsAgent::EMPTY_HASH).dup
107
+ params = Utils.resolve_options(self, options[:context], EMPTY_HASH).dup
112
108
  ctx = self.class.agent.new(llm, params.compact)
113
109
  columns = Utils.columns(options)
114
110
  data = self[columns[:data_column]]
@@ -16,84 +16,6 @@ module LLM::ActiveRecord
16
16
  # handling JSON typecasting for the model. `provider:`, `context:`, and
17
17
  # `tracer:` can also be configured as symbols that are called on the model.
18
18
  module ActsAsLLM
19
- EMPTY_HASH = {}.freeze
20
- DEFAULTS = {
21
- data_column: :data,
22
- format: :string,
23
- tracer: nil,
24
- provider: nil,
25
- context: EMPTY_HASH
26
- }.freeze
27
-
28
- ##
29
- # Shared helper methods for the ORM wrapper.
30
- #
31
- # These utilities keep persistence plumbing out of the wrapped model's
32
- # method namespace so the injected surface stays focused on the runtime
33
- # API itself.
34
- # @api private
35
- module Utils
36
- ##
37
- # Resolves a single configured option against a model instance.
38
- # @return [Object]
39
- def self.resolve_option(obj, option)
40
- case option
41
- when Proc then obj.instance_exec(&option)
42
- when Symbol then obj.send(option)
43
- when Hash then option.dup
44
- else option
45
- end
46
- end
47
-
48
- ##
49
- # Resolves hash-like wrapper options against a model instance.
50
- # @return [Hash]
51
- def self.resolve_options(obj, option, empty_hash)
52
- case option
53
- when Proc, Symbol, Hash then resolve_option(obj, option)
54
- else empty_hash.dup
55
- end
56
- end
57
-
58
- ##
59
- # Serializes the runtime into the configured storage format.
60
- # @return [String, Hash]
61
- def self.serialize_context(ctx, format)
62
- case format
63
- when :string then ctx.to_json
64
- when :json, :jsonb then ctx.to_h
65
- else raise ArgumentError, "Unknown format: #{format.inspect}"
66
- end
67
- end
68
-
69
- ##
70
- # Maps wrapper options onto the record's storage columns.
71
- # @return [Hash]
72
- def self.columns(options)
73
- {
74
- data_column: options[:data_column]
75
- }.freeze
76
- end
77
-
78
- ##
79
- # Resolves the provider runtime for a record.
80
- # @return [LLM::Provider]
81
- def self.resolve_provider(obj, options, empty_hash)
82
- provider = resolve_option(obj, options[:provider])
83
- return provider if LLM::Provider === provider
84
- raise ArgumentError, "provider: must resolve to an LLM::Provider instance"
85
- end
86
-
87
- ##
88
- # Persists the runtime state and usage columns back onto the record.
89
- # @return [void]
90
- def self.save(obj, ctx, options)
91
- columns = self.columns(options)
92
- obj.assign_attributes(columns[:data_column] => serialize_context(ctx, options[:format]))
93
- obj.save!
94
- end
95
- end
96
-
97
19
  module Hooks
98
20
  ##
99
21
  # Called when hooks are extended onto an ActiveRecord model.
@@ -133,7 +55,7 @@ module LLM::ActiveRecord
133
55
  # @return [LLM::Response]
134
56
  def talk(...)
135
57
  options = self.class.llm_plugin_options
136
- ctx.talk(...).tap { Utils.save(self, ctx, options) }
58
+ ctx.talk(...).tap { Utils.save!(self, ctx, options) }
137
59
  end
138
60
 
139
61
  ##
@@ -142,7 +64,7 @@ module LLM::ActiveRecord
142
64
  # @return [LLM::Response]
143
65
  def respond(...)
144
66
  options = self.class.llm_plugin_options
145
- ctx.respond(...).tap { Utils.save(self, ctx, options) }
67
+ ctx.respond(...).tap { Utils.save!(self, ctx, options) }
146
68
  end
147
69
 
148
70
  ##
@@ -270,7 +192,7 @@ module LLM::ActiveRecord
270
192
  def llm
271
193
  options = self.class.llm_plugin_options
272
194
  return @llm if @llm
273
- @llm = Utils.resolve_provider(self, options, ActsAsLLM::EMPTY_HASH)
195
+ @llm = Utils.resolve_provider(self, options, EMPTY_HASH)
274
196
  @llm.tracer = Utils.resolve_option(self, options[:tracer]) if options[:tracer]
275
197
  @llm
276
198
  end
@@ -283,7 +205,7 @@ module LLM::ActiveRecord
283
205
  @ctx ||= begin
284
206
  options = self.class.llm_plugin_options
285
207
  columns = Utils.columns(options)
286
- params = Utils.resolve_options(self, options[:context], ActsAsLLM::EMPTY_HASH).dup
208
+ params = Utils.resolve_options(self, options[:context], EMPTY_HASH).dup
287
209
  ctx = LLM::Context.new(llm, params.compact)
288
210
  data = self[columns[:data_column]]
289
211
  if data.nil? || data == ""
@@ -1,4 +1,82 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm/active_record/acts_as_llm"
4
- require "llm/active_record/acts_as_agent"
3
+ module LLM::ActiveRecord
4
+ EMPTY_HASH = {}.freeze
5
+ DEFAULTS = {
6
+ data_column: :data,
7
+ format: :string,
8
+ tracer: nil,
9
+ provider: nil,
10
+ context: EMPTY_HASH
11
+ }.freeze
12
+
13
+ ##
14
+ # These utilities keep persistence plumbing out of the wrapped model's
15
+ # method namespace so the injected surface stays focused on the runtime
16
+ # API itself.
17
+ # @api private
18
+ module Utils
19
+ ##
20
+ # Resolves a single configured option against a model instance.
21
+ # @return [Object]
22
+ def self.resolve_option(obj, option)
23
+ case option
24
+ when Proc then obj.instance_exec(&option)
25
+ when Symbol then obj.send(option)
26
+ when Hash then option.dup
27
+ else option
28
+ end
29
+ end
30
+
31
+ ##
32
+ # Resolves hash-like wrapper options against a model instance.
33
+ # @return [Hash]
34
+ def self.resolve_options(obj, option, empty_hash)
35
+ case option
36
+ when Proc, Symbol, Hash then resolve_option(obj, option)
37
+ else empty_hash.dup
38
+ end
39
+ end
40
+
41
+ ##
42
+ # Serializes the runtime into the configured storage format.
43
+ # @return [String, Hash]
44
+ def self.serialize_context(ctx, format)
45
+ case format
46
+ when :string then ctx.to_json
47
+ when :json, :jsonb then ctx.to_h
48
+ else raise ArgumentError, "Unknown format: #{format.inspect}"
49
+ end
50
+ end
51
+
52
+ ##
53
+ # Maps wrapper options onto the record's storage columns.
54
+ # @return [Hash]
55
+ def self.columns(options)
56
+ {
57
+ data_column: options[:data_column]
58
+ }.freeze
59
+ end
60
+
61
+ ##
62
+ # Resolves the provider runtime for a record.
63
+ # @return [LLM::Provider]
64
+ def self.resolve_provider(obj, options, empty_hash)
65
+ provider = resolve_option(obj, options[:provider])
66
+ return provider if LLM::Provider === provider
67
+ raise ArgumentError, "provider: must resolve to an LLM::Provider instance"
68
+ end
69
+
70
+ ##
71
+ # Persists the runtime state and usage columns back onto the record.
72
+ # @return [void]
73
+ def self.save!(obj, ctx, options)
74
+ columns = self.columns(options)
75
+ obj.assign_attributes(columns[:data_column] => serialize_context(ctx, options[:format]))
76
+ obj.save!
77
+ end
78
+ end
79
+
80
+ require "llm/active_record/acts_as_llm"
81
+ require "llm/active_record/acts_as_agent"
82
+ end
data/lib/llm/agent.rb CHANGED
@@ -19,6 +19,9 @@ module LLM
19
19
  # * The automatic tool loop enables the wrapped context's `guard` by default.
20
20
  # The built-in {LLM::LoopGuard LLM::LoopGuard} detects repeated tool-call
21
21
  # patterns and blocks stuck execution before more tool work is queued.
22
+ # * The default tool attempt budget is `25`. After that, the agent sends
23
+ # advisory tool errors back through the model and keeps the loop in-band.
24
+ # Set `tool_attempts: nil` to disable that advisory behavior.
22
25
  # * Tool loop execution can be configured with `concurrency :call`,
23
26
  # `:thread`, `:task`, `:fiber`, `:ractor`, or a list of queued task
24
27
  # types such as `[:thread, :ractor]`.
@@ -103,7 +106,8 @@ module LLM
103
106
  # - `:call`: sequential calls
104
107
  # - `:thread`: concurrent threads
105
108
  # - `:task`: concurrent async tasks
106
- # - `:fiber`: concurrent raw fibers
109
+ # - `:fiber`: concurrent scheduler-backed fibers
110
+ # - `:fork`: forked child processes
107
111
  # - `:ractor`: concurrent Ruby ractors for class-based tools; MCP tools are not supported,
108
112
  # and this mode is especially useful for CPU-bound tool work
109
113
  # - `[:thread, :ractor]`: the possible concurrency strategies to wait on, in the
@@ -146,12 +150,14 @@ module LLM
146
150
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
147
151
  # @option params [Array<String>, nil] :skills Defaults to nil
148
152
  # @option params [#to_json, nil] :schema Defaults to nil
153
+ # @option params [LLM::Tracer, Proc, nil] :tracer Optional tracer override for this agent instance
149
154
  # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
150
155
  def initialize(llm, params = {})
151
156
  defaults = {model: self.class.model, tools: self.class.tools, skills: self.class.skills, schema: self.class.schema}.compact
152
157
  @concurrency = params.delete(:concurrency) || self.class.concurrency
153
158
  @llm = llm
154
- @tracer = resolve_option(self.class.tracer) unless self.class.tracer.nil?
159
+ tracer = params.key?(:tracer) ? params.delete(:tracer) : self.class.tracer
160
+ @tracer = resolve_option(tracer) unless tracer.nil?
155
161
  @ctx = LLM::Context.new(llm, defaults.merge({guard: true}).merge(params))
156
162
  end
157
163
 
@@ -161,7 +167,10 @@ module LLM
161
167
  #
162
168
  # @param prompt (see LLM::Provider#complete)
163
169
  # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
164
- # @option params [Integer] :tool_attempts The maxinum number of tool call iterations (default 25)
170
+ # @option params [Integer] :tool_attempts
171
+ # The maxinum number of tool call iterations before the agent sends
172
+ # in-band advisory tool errors back through the model (default 25).
173
+ # Set to `nil` to disable advisory tool-limit returns.
165
174
  # @return [LLM::Response] Returns the LLM's response for this turn.
166
175
  # @example
167
176
  # llm = LLM.openai(key: ENV["KEY"])
@@ -180,7 +189,10 @@ module LLM
180
189
  # @note Not all LLM providers support this API
181
190
  # @param prompt (see LLM::Provider#complete)
182
191
  # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
183
- # @option params [Integer] :tool_attempts The maxinum number of tool call iterations (default 25)
192
+ # @option params [Integer] :tool_attempts
193
+ # The maxinum number of tool call iterations before the agent sends
194
+ # in-band advisory tool errors back through the model (default 25).
195
+ # Set to `nil` to disable advisory tool-limit returns.
184
196
  # @return [LLM::Response] Returns the LLM's response for this turn.
185
197
  # @example
186
198
  # llm = LLM.openai(key: ENV["KEY"])
@@ -386,27 +398,46 @@ module LLM
386
398
  def call_functions
387
399
  case concurrency || :call
388
400
  when :call then call(:functions)
389
- when :thread, :task, :fiber, :ractor, Array then wait(concurrency)
390
- else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. Expected :call, :thread, :task, :fiber, :ractor, or an array of queued task types"
401
+ when :thread, :task, :fiber, :fork, :ractor, Array then wait(concurrency)
402
+ else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. " \
403
+ "Expected :call, :thread, :task, :fiber, :fork, :ractor, " \
404
+ "or an array of the mentioned options"
391
405
  end
392
406
  end
393
407
 
394
408
  def run_loop(method, prompt, params)
395
409
  loop = proc do
396
- max = Integer(params.delete(:tool_attempts) || 25)
410
+ max = params.key?(:tool_attempts) ? params.delete(:tool_attempts) : 25
411
+ max = Integer(max) if max
397
412
  stream = params[:stream] || @ctx.params[:stream]
398
413
  stream.extra[:concurrency] = concurrency if LLM::Stream === stream
399
414
  res = @ctx.public_send(method, apply_instructions(prompt), params)
400
- max.times do
415
+ loop do
401
416
  break if @ctx.functions.empty?
402
- res = @ctx.public_send(method, call_functions, params)
417
+ if max
418
+ max.times do
419
+ break if @ctx.functions.empty?
420
+ res = @ctx.public_send(method, call_functions, params)
421
+ end
422
+ break if @ctx.functions.empty?
423
+ res = @ctx.public_send(method, @ctx.functions.map { rate_limit(_1) }, params)
424
+ else
425
+ res = @ctx.public_send(method, call_functions, params)
426
+ end
403
427
  end
404
- raise LLM::ToolLoopError, "pending tool calls remain" unless @ctx.functions.empty?
405
428
  res
406
429
  end
407
430
  @tracer ? @llm.with_tracer(@tracer, &loop) : loop.call
408
431
  end
409
432
 
433
+ def rate_limit(function)
434
+ LLM::Function::Return.new(function.id, function.name, {
435
+ error: true,
436
+ type: LLM::ToolLoopError.name,
437
+ message: "tool loop rate limit reached"
438
+ })
439
+ end
440
+
410
441
  def resolve_option(option)
411
442
  Proc === option ? instance_exec(&option) : option
412
443
  end
data/lib/llm/compactor.rb CHANGED
@@ -5,8 +5,7 @@
5
5
  # smaller replacement message when a context grows too large.
6
6
  #
7
7
  # This work is directly inspired by the compaction approach developed by
8
- # General Intelligence Systems in
9
- # [Brute](https://github.com/general-intelligence-systems/brute).
8
+ # General Intelligence Systems.
10
9
  #
11
10
  # The compactor can also use a different model from the main context by
12
11
  # setting `model:` in the compactor config. Compaction thresholds are opt-in:
data/lib/llm/context.rb CHANGED
@@ -96,8 +96,7 @@ module LLM
96
96
  ##
97
97
  # Returns a context compactor
98
98
  # This feature is inspired by the compaction approach developed by
99
- # General Intelligence Systems in
100
- # [Brute](https://github.com/general-intelligence-systems/brute).
99
+ # General Intelligence Systems.
101
100
  # @return [LLM::Compactor]
102
101
  def compactor
103
102
  @compactor = LLM::Compactor.new(self, @compactor || {}) unless LLM::Compactor === @compactor
data/lib/llm/error.rb CHANGED
@@ -78,4 +78,8 @@ module LLM
78
78
  ##
79
79
  # When {LLM::Registry} can't map a registry
80
80
  NoSuchRegistryError = Class.new(Error)
81
+
82
+ ##
83
+ # When an optional runtime dependency cannot be required
84
+ LoadError = Class.new(Error)
81
85
  end