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 +4 -4
- data/CHANGELOG.md +137 -1
- data/README.md +39 -20
- data/lib/llm/active_record/acts_as_agent.rb +2 -6
- data/lib/llm/active_record/acts_as_llm.rb +4 -82
- data/lib/llm/active_record.rb +80 -2
- data/lib/llm/agent.rb +41 -10
- data/lib/llm/compactor.rb +1 -2
- data/lib/llm/context.rb +1 -2
- data/lib/llm/error.rb +4 -0
- data/lib/llm/function/array.rb +7 -3
- data/lib/llm/function/fiber_group.rb +9 -3
- data/lib/llm/function/fork/job.rb +67 -0
- data/lib/llm/function/fork/task.rb +76 -0
- data/lib/llm/function/fork.rb +8 -0
- data/lib/llm/function/fork_group.rb +36 -0
- data/lib/llm/function/ractor/task.rb +13 -3
- data/lib/llm/function/task.rb +10 -2
- data/lib/llm/function.rb +24 -11
- data/lib/llm/loop_guard.rb +1 -10
- data/lib/llm/mcp/command.rb +1 -1
- data/lib/llm/mcp/transport/http.rb +2 -2
- data/lib/llm/mcp.rb +7 -4
- data/lib/llm/object/kernel.rb +8 -2
- data/lib/llm/object.rb +67 -21
- data/lib/llm/{mcp/pipe.rb → pipe.rb} +9 -8
- data/lib/llm/provider/transport/http.rb +2 -2
- data/lib/llm/stream/queue.rb +1 -1
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +19 -1
- data/llm.gemspec +2 -1
- metadata +21 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d726213f6b63342582738a133f7f82c1158934d6f25a48ae6b6c9e59a8f8262
|
|
4
|
+
data.tar.gz: 6288d177adc7a07a37368066329c882f746747d5bed9ffba7cb50d2bcbd1d98c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
371
|
-
supported by the current `:ractor` mode, but mixed tool sets can
|
|
372
|
-
route MCP tools and local tools through different strategies at
|
|
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,
|
|
385
|
-
rewriting your tool layer.
|
|
386
|
-
|
|
387
|
-
`
|
|
388
|
-
`:ractor`
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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],
|
|
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,
|
|
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],
|
|
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 == ""
|
data/lib/llm/active_record.rb
CHANGED
|
@@ -1,4 +1,82 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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}.
|
|
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 =
|
|
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
|
-
|
|
415
|
+
loop do
|
|
401
416
|
break if @ctx.functions.empty?
|
|
402
|
-
|
|
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
|
|
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
|
|
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
|