llm.rb 16.0.0.pre.2 → 16.0.0.pre.3
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 +54 -3
- data/docs/deepdive/features/database.md +19 -0
- data/lib/llm/active_record/acts_as_llm.rb +20 -2
- data/lib/llm/function/async/task.rb +46 -3
- data/lib/llm/function/fiber/task.rb +17 -3
- data/lib/llm/function/fork/job.rb +32 -6
- data/lib/llm/function/ractor/job.rb +33 -9
- data/lib/llm/function/sequential/group.rb +9 -4
- data/lib/llm/function/task.rb +3 -2
- data/lib/llm/function/thread/task.rb +30 -3
- data/lib/llm/function.rb +156 -7
- data/lib/llm/schema/leaf.rb +4 -2
- data/lib/llm/sequel/plugin.rb +6 -1
- data/lib/llm/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8bd3b4f303036ba390981de3ec63ea0bb523cc653c4b77b221e4591e9b9a446
|
|
4
|
+
data.tar.gz: 3036ce3c4248e3ddad524657f31ea2c300f98282ad0aa68791848f56cd1b91f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 604bafd36e8a55405e3fc487957ae97b9e4ffd67777711c071f7bfb9cfe005e0400294c089e72fbec3019538f4706c92cfe5bb15f3d0c2f994a324b9ba1a3322
|
|
7
|
+
data.tar.gz: d3bf4d7e1f9337cd7bdedb40ac4cfb4f95b6195e6df3360d428bfa6c220994edfaf8c5f053256e968c1feb9ae025b29854586dd9834dc84948729644f7a7b8ff
|
data/CHANGELOG.md
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
| Old | New |
|
|
23
23
|
|-----|-----|
|
|
24
24
|
| Ruby 3.3 or later | Ruby 3.4 or later |
|
|
25
|
+
| `record.messages` returns the messages the runtime holds | a `:jsonb` record returns a relation of `LLM::ActiveRecord::Message` rows |
|
|
25
26
|
|
|
26
27
|
* **drop Ruby 3.3 support** <br>
|
|
27
28
|
The gem now requires Ruby 3.4 or later: `required_ruby_version` is
|
|
@@ -30,6 +31,21 @@
|
|
|
30
31
|
cancel that arrives before the tool starts is not delivered there, while
|
|
31
32
|
Ruby 3.4 and 4.0 both deliver it.
|
|
32
33
|
|
|
34
|
+
* **activerecord: read a `:jsonb` record's messages from the column** <br>
|
|
35
|
+
[`LLM::ActiveRecord`](https://r.uby.dev/api-docs/llm.rb/LLM/ActiveRecord.html)
|
|
36
|
+
now answers `#messages` for a record whose `format` is `:jsonb` with an
|
|
37
|
+
[`ActiveRecord::Relation`](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html)
|
|
38
|
+
over the column, through
|
|
39
|
+
[`LLM::ActiveRecord::Message`](https://r.uby.dev/api-docs/llm.rb/LLM/ActiveRecord/Message.html),
|
|
40
|
+
so a conversation can be filtered, counted, and ordered in SQL, and reading it
|
|
41
|
+
needs no provider and no credentials. Before this, every record loaded the
|
|
42
|
+
runtime and returned the messages it held, whatever the format. The relation
|
|
43
|
+
is not a drop-in replacement for that buffer: it reads what is persisted
|
|
44
|
+
rather than what a runtime holds in memory, it is unordered, so order by
|
|
45
|
+
`position`, and its rows answer `unwrap!` rather than being
|
|
46
|
+
[`LLM::Message`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html) objects.
|
|
47
|
+
Every other format, and Sequel, still loads the runtime.
|
|
48
|
+
|
|
33
49
|
### Console
|
|
34
50
|
|
|
35
51
|
* **console: erase with the backspace key on OpenBSD** <br>
|
|
@@ -39,6 +55,13 @@
|
|
|
39
55
|
terminal's terminfo describes the key. All three erase. Before this only 127
|
|
40
56
|
did, so the backspace key did nothing on the OpenBSD console.
|
|
41
57
|
|
|
58
|
+
### Fix
|
|
59
|
+
|
|
60
|
+
* **fork: require xchan.rb `~> 0.24`** <br>
|
|
61
|
+
The `:fork` concurrency strategy now requires the `xchan.rb` gem at `~> 0.24`
|
|
62
|
+
instead of `~> 0.23`, and the gemspec's development dependency follows. An
|
|
63
|
+
application that uses `:fork` needs `xchan.rb` 0.24 or later.
|
|
64
|
+
|
|
42
65
|
### Function
|
|
43
66
|
|
|
44
67
|
* **function: deliver an interrupt to the tool, not to whatever is running** <br>
|
|
@@ -77,6 +100,21 @@
|
|
|
77
100
|
side effects happening and its result written to a queue nobody read, and the
|
|
78
101
|
reactor thread stayed alive until a group's `wait` stopped it.
|
|
79
102
|
|
|
103
|
+
* **function: run the interrupt hook on every strategy** <br>
|
|
104
|
+
A tool that implements
|
|
105
|
+
[`LLM::Tool#on_interrupt`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html#on_interrupt-instance_method)
|
|
106
|
+
or
|
|
107
|
+
[`LLM::Tool#on_cancel`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html#on_cancel-instance_method)
|
|
108
|
+
is now told on every concurrency strategy. `:thread`, `:fiber`, and `:async`
|
|
109
|
+
run the hook on the thread or fiber that ran the call, `:fork` and `:ractor`
|
|
110
|
+
run it inside the child or the ractor before the interrupt is delivered, and
|
|
111
|
+
a sequential group's cancel tells every task it holds. Before this, `:async`,
|
|
112
|
+
`:fork`, and `:ractor` never ran the hook, the others ran it on the thread
|
|
113
|
+
that cancelled, and a class-backed tool ran it on no strategy at all, because
|
|
114
|
+
`interrupt!` read the hook off the tool's definition and a class answers
|
|
115
|
+
`respond_to?` with false for an instance method. A hook that raises becomes
|
|
116
|
+
what the caller sees in place of the call's result.
|
|
117
|
+
|
|
80
118
|
### Provider
|
|
81
119
|
|
|
82
120
|
* **deepseek: support image attachments in chat completions** <br>
|
|
@@ -97,13 +135,26 @@
|
|
|
97
135
|
`qwen3.5-flash`, `qwen3.7-flash`, and `qwen3.8-omni-flash`, and gives
|
|
98
136
|
`qwen3.7-plus` attachments, structured output, and a lower price; OpenAI adds
|
|
99
137
|
`gpt-daybreak-blue-latest` and `gpt-daybreak-red-latest`; Bedrock adds
|
|
100
|
-
`openai.gpt-6-sol
|
|
101
|
-
`tencent/Hy4-preview`
|
|
138
|
+
`openai.gpt-6-sol`, `openai.gpt-6-luna`, and
|
|
139
|
+
`global.anthropic.claude-sonnet-5-5`; and DeepInfra adds `tencent/Hy4-preview`
|
|
140
|
+
and the Xiaomi MiMo V2.6 Pro and V2.6 Flash models.
|
|
102
141
|
Bedrock also corrects the context and output limits of fifteen models, Google
|
|
103
142
|
lowers the limits of `gemini-2.5-computer-use-preview-10-2025` and
|
|
104
|
-
`gemini-3-pro-image`, and OpenRouter adds
|
|
143
|
+
`gemini-3-pro-image`, and OpenRouter adds five models, drops six, and reprices
|
|
105
144
|
many of the DeepSeek, Z.ai, and Qwen entries.
|
|
106
145
|
|
|
146
|
+
### Schema
|
|
147
|
+
|
|
148
|
+
* **schema: apply every `parameter` option to the leaf** <br>
|
|
149
|
+
[`LLM::Tool`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html) now applies each
|
|
150
|
+
option given to `parameter` to the leaf its type resolves to, so settings such
|
|
151
|
+
as `min:`, `max:`, `multiple_of:`, and `const:` reach the schema instead of
|
|
152
|
+
being dropped. Before this only `required`, `default`, and `enum` were read, so
|
|
153
|
+
`parameter :age, Integer, "Age", min: 1, max: 9` sent a plain integer and lost
|
|
154
|
+
the range. A `default:` of `false` survives now as well, where the old code
|
|
155
|
+
discarded a false default, and `required: false` marks the parameter optional
|
|
156
|
+
rather than being ignored.
|
|
157
|
+
|
|
107
158
|
## v15.5.0
|
|
108
159
|
|
|
109
160
|
Changes since `v15.4.1`.
|
|
@@ -407,6 +407,25 @@ you write across every agent is not covered by default; index the state
|
|
|
407
407
|
column for those, for example with a GIN index on
|
|
408
408
|
`data jsonb_path_ops`.
|
|
409
409
|
|
|
410
|
+
A record whose `format` is `:jsonb` also returns this view from
|
|
411
|
+
`#messages`, so a conversation can be read without building a runtime:
|
|
412
|
+
no provider, and no credentials. What it returns differs from the
|
|
413
|
+
runtime's own `#messages` in three ways. It reads what is persisted
|
|
414
|
+
rather than the state a context holds in memory and has not saved. It
|
|
415
|
+
is unordered, and conversation order is `position` rather than `id`, so
|
|
416
|
+
order it explicitly. And its rows are
|
|
417
|
+
[`LLM::ActiveRecord::Message`](https://r.uby.dev/api-docs/llm.rb/LLM/ActiveRecord/Message.html)
|
|
418
|
+
records rather than
|
|
419
|
+
[`LLM::Message`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html)
|
|
420
|
+
objects, until
|
|
421
|
+
[`#unwrap!`](https://r.uby.dev/api-docs/llm.rb/LLM/ActiveRecord/Message.html#unwrap!-instance_method)
|
|
422
|
+
turns them back.
|
|
423
|
+
|
|
424
|
+
Every other format loads the runtime and returns the messages it
|
|
425
|
+
holds, as it always did, and that is the default. Sequel has no
|
|
426
|
+
equivalent: its plugin persists the same state, and there is no view to
|
|
427
|
+
read it back in the database.
|
|
428
|
+
|
|
410
429
|
### Sequel
|
|
411
430
|
|
|
412
431
|
#### Overview
|
|
@@ -89,10 +89,28 @@ module LLM::ActiveRecord
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
##
|
|
92
|
+
# Returns the messages this record holds.
|
|
93
|
+
#
|
|
94
|
+
# A record that stores its state as jsonb reads them from the column,
|
|
95
|
+
# as a relation: the conversation can be filtered, counted and ordered
|
|
96
|
+
# in the database, and nothing here needs a provider to be built. Every
|
|
97
|
+
# other format loads the runtime and hands back the messages it holds.
|
|
98
|
+
#
|
|
99
|
+
# The two do not agree about what is visible. The relation reads what is
|
|
100
|
+
# persisted rather than what the runtime holds in memory, it is
|
|
101
|
+
# unordered - conversation order is `position`, not `id` - and its rows
|
|
102
|
+
# are {LLM::ActiveRecord::Message} records until
|
|
103
|
+
# {LLM::ActiveRecord::Message#unwrap!} turns them back into
|
|
104
|
+
# {LLM::Message} objects.
|
|
92
105
|
# @see LLM::Context#messages
|
|
93
|
-
# @return [
|
|
106
|
+
# @return [ActiveRecord::Relation, LLM::Buffer]
|
|
94
107
|
def messages
|
|
95
|
-
|
|
108
|
+
options = self.class.llm_plugin_options
|
|
109
|
+
if options[:format] == :jsonb
|
|
110
|
+
LLM::ActiveRecord::Message.for(agent: self)
|
|
111
|
+
else
|
|
112
|
+
ctx.messages
|
|
113
|
+
end
|
|
96
114
|
end
|
|
97
115
|
|
|
98
116
|
##
|
|
@@ -43,6 +43,19 @@ module LLM::Function::Async
|
|
|
43
43
|
# which one that saves its work does, is torn down with `Cancel`, which
|
|
44
44
|
# nothing here catches and nothing pushes, leaving a caller blocked on a
|
|
45
45
|
# queue that will never fill.
|
|
46
|
+
#
|
|
47
|
+
# The tool's hook runs from inside that block, so it runs on the
|
|
48
|
+
# reactor's thread, and the result is held in a local until it has: a
|
|
49
|
+
# hook that runs after the queue was pushed is a hook the caller can
|
|
50
|
+
# race past.
|
|
51
|
+
#
|
|
52
|
+
# The rescue below is the other half of that promise, and it is written
|
|
53
|
+
# to catch everything rather than an interrupt. A hook that raises from
|
|
54
|
+
# the block's `ensure` unwinds past the push, so the queue would be left
|
|
55
|
+
# empty and `#wait` would wait on it forever - the outcome this comment
|
|
56
|
+
# already names. Whatever comes out of the block is pushed, and `#wait`
|
|
57
|
+
# hands anything that is an exception to the caller the way `Thread#value`
|
|
58
|
+
# and `Fiber#value` do for the other in-process strategies.
|
|
46
59
|
# @return [nil]
|
|
47
60
|
def spawn
|
|
48
61
|
return if @guarded
|
|
@@ -53,8 +66,27 @@ module LLM::Function::Async
|
|
|
53
66
|
@task = task
|
|
54
67
|
@scheduler = Fiber.scheduler
|
|
55
68
|
raise LLM::Interrupt if @cancelled
|
|
56
|
-
task.defer_cancel
|
|
57
|
-
|
|
69
|
+
task.defer_cancel do
|
|
70
|
+
result = begin
|
|
71
|
+
function.call
|
|
72
|
+
ensure
|
|
73
|
+
##
|
|
74
|
+
# The hook runs on the reactor's thread rather than on the one
|
|
75
|
+
# that cancelled. See the note on
|
|
76
|
+
# `LLM::Function::Thread::Task#spawn` for why it cannot run
|
|
77
|
+
# before the call's frame has ended, and what `@delivered`
|
|
78
|
+
# means.
|
|
79
|
+
function.interrupt! if @delivered
|
|
80
|
+
end
|
|
81
|
+
@queue << result
|
|
82
|
+
end
|
|
83
|
+
rescue => e
|
|
84
|
+
##
|
|
85
|
+
# This runs after the `defer_cancel` block's `ensure`, so the tool is
|
|
86
|
+
# told first and the caller second. It answers an interrupt, a
|
|
87
|
+
# cancel that arrived before `spawn` - the block raises before it
|
|
88
|
+
# reaches a tool, and there is no result to push - and a hook whose
|
|
89
|
+
# own error unwound past the push.
|
|
58
90
|
@queue << e
|
|
59
91
|
raise
|
|
60
92
|
end
|
|
@@ -74,6 +106,12 @@ module LLM::Function::Async
|
|
|
74
106
|
# it lets it raise, and either way the block above is what the caller
|
|
75
107
|
# hears from - its value, or the exception it forwards.
|
|
76
108
|
#
|
|
109
|
+
# The tool is told from inside the reactor, not here, so that a tool
|
|
110
|
+
# whose state belongs to the reactor's thread sees that thread.
|
|
111
|
+
# `@delivered` is set where the raise is issued into a live fiber: a
|
|
112
|
+
# cancel that arrives before the task starts, or after it has finished,
|
|
113
|
+
# interrupted nothing and tells nobody.
|
|
114
|
+
#
|
|
77
115
|
# Nothing is done to the reactor here. Where it is stopped is `#wait`'s
|
|
78
116
|
# own `ensure`, the only point at which it is known to be idle, and the
|
|
79
117
|
# point a group's `wait` already stops it from: a task cannot tell
|
|
@@ -89,6 +127,7 @@ module LLM::Function::Async
|
|
|
89
127
|
@alive = false
|
|
90
128
|
@cancelled = true
|
|
91
129
|
if @task&.fiber&.alive?
|
|
130
|
+
@delivered = true
|
|
92
131
|
@scheduler.fiber_interrupt(@task.fiber, LLM::Interrupt.new)
|
|
93
132
|
elsif @task.nil? && @queue
|
|
94
133
|
##
|
|
@@ -110,6 +149,10 @@ module LLM::Function::Async
|
|
|
110
149
|
# strategy's, and a tool that will not stop meets the join and the kill
|
|
111
150
|
# here rather than in the caller's cancel.
|
|
112
151
|
#
|
|
152
|
+
# Anything that is an exception is raised rather than returned, which is
|
|
153
|
+
# what `Thread#value` and `Fiber#value` do. An interrupt is the usual
|
|
154
|
+
# one, and a hook that raised from the block's `ensure` is the other.
|
|
155
|
+
#
|
|
113
156
|
# The guarded path returns before any of that, so a task whose guard
|
|
114
157
|
# blocked it does not stop a reactor it never used - which matters in a
|
|
115
158
|
# group, where the reactor is not its own.
|
|
@@ -120,7 +163,7 @@ module LLM::Function::Async
|
|
|
120
163
|
spawn unless @queue
|
|
121
164
|
result = @queue.pop
|
|
122
165
|
@alive = false
|
|
123
|
-
raise result if
|
|
166
|
+
raise result if Exception === result
|
|
124
167
|
result
|
|
125
168
|
ensure
|
|
126
169
|
@reactor&.stop
|
|
@@ -11,6 +11,9 @@ module LLM::Function::Fiber
|
|
|
11
11
|
# {#wait}. Interrupting a running task raises
|
|
12
12
|
# {LLM::Interrupt} on the fiber, which stops it at the next
|
|
13
13
|
# yield point.
|
|
14
|
+
#
|
|
15
|
+
# A tool that implements `on_interrupt` is told on that fiber, once the
|
|
16
|
+
# call has ended, rather than on the thread that cancelled it.
|
|
14
17
|
class Task < LLM::Function::Task
|
|
15
18
|
##
|
|
16
19
|
# @param [LLM::Function] fn
|
|
@@ -26,7 +29,16 @@ module LLM::Function::Fiber
|
|
|
26
29
|
if Fiber.scheduler.nil?
|
|
27
30
|
raise ArgumentError, "Fiber concurrency requires Fiber.scheduler"
|
|
28
31
|
else
|
|
29
|
-
@fiber = Fiber.schedule
|
|
32
|
+
@fiber = Fiber.schedule do
|
|
33
|
+
function.call
|
|
34
|
+
ensure
|
|
35
|
+
##
|
|
36
|
+
# The hook runs on the fiber the call runs on, once the call has
|
|
37
|
+
# ended, rather than on the thread that cancelled. See the note on
|
|
38
|
+
# `LLM::Function::Thread::Task#spawn` for why it cannot run before
|
|
39
|
+
# the call's frame has ended, and what `@delivered` means.
|
|
40
|
+
function.interrupt! if @delivered
|
|
41
|
+
end
|
|
30
42
|
nil
|
|
31
43
|
end
|
|
32
44
|
end
|
|
@@ -40,8 +52,10 @@ module LLM::Function::Fiber
|
|
|
40
52
|
##
|
|
41
53
|
# @return [nil]
|
|
42
54
|
def interrupt!
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
if @fiber&.alive?
|
|
56
|
+
@delivered = true
|
|
57
|
+
@fiber.raise(LLM::Interrupt)
|
|
58
|
+
end
|
|
45
59
|
nil
|
|
46
60
|
end
|
|
47
61
|
alias_method :cancel!, :interrupt!
|
|
@@ -72,17 +72,43 @@ class LLM::Function
|
|
|
72
72
|
}
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
##
|
|
76
|
+
# Starts the watcher that turns a control message into an interrupt.
|
|
77
|
+
#
|
|
78
|
+
# The watcher is the only thread in the child that has the tool in hand,
|
|
79
|
+
# which is why the runner is passed to it rather than resolved again: a
|
|
80
|
+
# fork copies it, and the copy the parent holds is not the object that
|
|
81
|
+
# runs here.
|
|
82
|
+
# @param [Object] runner
|
|
83
|
+
# @return [Thread]
|
|
75
84
|
def setup(runner)
|
|
76
85
|
ready = Queue.new
|
|
77
86
|
thread = ::Thread.new do
|
|
78
87
|
ready << true
|
|
79
|
-
kind = @ch.control.recv
|
|
80
|
-
next unless kind == :interrupt
|
|
81
88
|
##
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
# Only the receive is guarded: it raises when the parent has gone,
|
|
90
|
+
# and nothing else in this block should be answerable to that
|
|
91
|
+
# rescue. A hook that raises must not be swallowed by it, and must
|
|
92
|
+
# not take the raise with it either - hence the `ensure` below,
|
|
93
|
+
# which delivers whatever the hook does.
|
|
94
|
+
kind = begin
|
|
95
|
+
@ch.control.recv
|
|
96
|
+
rescue IOError, ArgumentError
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
next unless kind == :interrupt
|
|
100
|
+
begin
|
|
101
|
+
##
|
|
102
|
+
# The tool is told first, so a tool that releases a resource has
|
|
103
|
+
# done so by the time the raise lands on it, and so that a tool
|
|
104
|
+
# which rescues `LLM::Interrupt` reads what its hook wrote.
|
|
105
|
+
LLM::Function.interrupt(runner)
|
|
106
|
+
ensure
|
|
107
|
+
##
|
|
108
|
+
# The window decides whether the raise is the tool's to handle,
|
|
109
|
+
# or whether the tool has already been and gone.
|
|
110
|
+
@window.interrupt!
|
|
111
|
+
end
|
|
86
112
|
end
|
|
87
113
|
ready.pop
|
|
88
114
|
thread
|
|
@@ -79,22 +79,46 @@ class LLM::Function
|
|
|
79
79
|
# a caller: it is `Thread.current`, the ractor's own main
|
|
80
80
|
# thread, and the thread the tool runs on.
|
|
81
81
|
window = LLM::Function::Window.new(thread: ::Thread.current)
|
|
82
|
+
##
|
|
83
|
+
# The tool is built outside the window, so a raise from
|
|
84
|
+
# `initialize` is not answered as an interrupt, and before the
|
|
85
|
+
# watcher, so that the watcher has something to tell: a ractor
|
|
86
|
+
# cannot be handed the function, and the instance the parent
|
|
87
|
+
# holds is not this one.
|
|
88
|
+
runner = runner_class.new
|
|
82
89
|
::Thread.new do
|
|
83
|
-
::Ractor.receive == :interrupt or next
|
|
84
90
|
##
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
# Only the receive is guarded: it raises when the ractor this
|
|
92
|
+
# one talks to has gone, and nothing else here should be
|
|
93
|
+
# answerable to that rescue. A hook that raises must not be
|
|
94
|
+
# swallowed by it, and must not take the raise with it either -
|
|
95
|
+
# hence the `ensure` below, which delivers whatever the hook
|
|
96
|
+
# does.
|
|
97
|
+
kind = begin
|
|
98
|
+
::Ractor.receive
|
|
99
|
+
rescue ::Ractor::Error
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
next unless kind == :interrupt
|
|
103
|
+
begin
|
|
104
|
+
##
|
|
105
|
+
# The tool is told first, so a tool that releases a resource
|
|
106
|
+
# has done so by the time the raise lands on it, and so that
|
|
107
|
+
# a tool which rescues `LLM::Interrupt` reads what its hook
|
|
108
|
+
# wrote.
|
|
109
|
+
LLM::Function.interrupt(runner)
|
|
110
|
+
ensure
|
|
111
|
+
##
|
|
112
|
+
# The window decides whether the raise is the tool's to
|
|
113
|
+
# handle, or whether the tool has already been and gone.
|
|
114
|
+
window.interrupt!
|
|
115
|
+
end
|
|
89
116
|
end
|
|
90
117
|
##
|
|
91
118
|
# Everything the call needs is prepared outside the window, so
|
|
92
119
|
# the distance from `running!` to the tool's first instruction is
|
|
93
|
-
# the method dispatch and nothing else.
|
|
94
|
-
# it too: a raise from `initialize` is not an interrupt to
|
|
95
|
-
# answer, and inside the window it would be answered as one.
|
|
120
|
+
# the method dispatch and nothing else.
|
|
96
121
|
kwargs = Hash === arguments ? arguments.transform_keys(&:to_sym) : arguments
|
|
97
|
-
runner = runner_class.new
|
|
98
122
|
window.running!
|
|
99
123
|
result = runner.call(**kwargs)
|
|
100
124
|
##
|
|
@@ -34,13 +34,18 @@ module LLM::Function::Sequential
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
##
|
|
37
|
-
# Interrupts the thread blocked in {#wait}.
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
37
|
+
# Interrupts the thread blocked in {#wait}, and tells the tasks.
|
|
38
|
+
#
|
|
39
|
+
# Sequential functions run on the caller's thread, so the raise is what
|
|
40
|
+
# interrupts the call and it has to land there. It is issued first, and
|
|
41
|
+
# the tasks are told second: the delivery is this group's whole
|
|
42
|
+
# interrupt, and a hook that raises must not be able to take it with it
|
|
43
|
+
# or to leave the remaining tasks untold. That is also the order the
|
|
44
|
+
# in-process strategies use, where the hook runs after the raise.
|
|
41
45
|
# @return [nil]
|
|
42
46
|
def interrupt!
|
|
43
47
|
@owner&.raise(LLM::Interrupt)
|
|
48
|
+
@tasks.each(&:interrupt!)
|
|
44
49
|
nil
|
|
45
50
|
end
|
|
46
51
|
alias_method :cancel!, :interrupt!
|
data/lib/llm/function/task.rb
CHANGED
|
@@ -17,8 +17,9 @@ class LLM::Function
|
|
|
17
17
|
# An optional set of options that are specific
|
|
18
18
|
# to a given concurrency strategy.
|
|
19
19
|
# @option options [LLM::Function::Return, nil] :guarded
|
|
20
|
-
# A blocked return produced by the function's guard
|
|
21
|
-
#
|
|
20
|
+
# A blocked return produced by the function's guard, or the in-band
|
|
21
|
+
# error for a tool that cannot be built. When set, the task yields it
|
|
22
|
+
# without running the tool.
|
|
22
23
|
def initialize(fn, options = {})
|
|
23
24
|
@function = fn
|
|
24
25
|
@guarded = options[:guarded]
|
|
@@ -12,6 +12,9 @@ module LLM::Function::Thread
|
|
|
12
12
|
# the thread, which stops the tool call mid-flight. The thread
|
|
13
13
|
# is created with `report_on_exception` disabled so unhandled
|
|
14
14
|
# exceptions propagate through {#wait} instead of to stderr.
|
|
15
|
+
#
|
|
16
|
+
# A tool that implements `on_interrupt` is told on that thread, once
|
|
17
|
+
# the call has ended, rather than on the thread that cancelled it.
|
|
15
18
|
class Task < LLM::Function::Task
|
|
16
19
|
##
|
|
17
20
|
# @param [LLM::Function] fn
|
|
@@ -24,7 +27,29 @@ module LLM::Function::Thread
|
|
|
24
27
|
# @return [nil]
|
|
25
28
|
def spawn
|
|
26
29
|
return if @guarded
|
|
27
|
-
@thread = ::Thread.new
|
|
30
|
+
@thread = ::Thread.new do
|
|
31
|
+
function.call
|
|
32
|
+
ensure
|
|
33
|
+
##
|
|
34
|
+
# The hook runs on this thread rather than on the one that
|
|
35
|
+
# cancelled, because a tool's state belongs to the thread its call
|
|
36
|
+
# runs on, and the caller's thread is not that thread.
|
|
37
|
+
#
|
|
38
|
+
# On the job's own thread the hook can only run after the call's
|
|
39
|
+
# frame has ended - you cannot run code on a thread blocked inside
|
|
40
|
+
# a method it owns except by raising into it - and `@delivered` is
|
|
41
|
+
# written before the raise and read after it, so the flag says what
|
|
42
|
+
# it means. It is set only where the raise was issued into a live
|
|
43
|
+
# thread: a cancel that arrives before the call starts, or after it
|
|
44
|
+
# has finished, interrupted nothing and tells nobody.
|
|
45
|
+
#
|
|
46
|
+
# The hook runs before this thread ends, so it has run before
|
|
47
|
+
# `#wait` can return - which is the other half of telling the tool
|
|
48
|
+
# before the caller. A hook that raises becomes what this thread
|
|
49
|
+
# returns instead, so the error reaches the caller in place of the
|
|
50
|
+
# call's result.
|
|
51
|
+
function.interrupt! if @delivered
|
|
52
|
+
end
|
|
28
53
|
@thread.report_on_exception = false
|
|
29
54
|
nil
|
|
30
55
|
end
|
|
@@ -38,8 +63,10 @@ module LLM::Function::Thread
|
|
|
38
63
|
##
|
|
39
64
|
# @return [nil]
|
|
40
65
|
def interrupt!
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
if @thread&.alive?
|
|
67
|
+
@delivered = true
|
|
68
|
+
@thread.raise(LLM::Interrupt)
|
|
69
|
+
end
|
|
43
70
|
nil
|
|
44
71
|
end
|
|
45
72
|
alias_method :cancel!, :interrupt!
|
data/lib/llm/function.rb
CHANGED
|
@@ -53,6 +53,32 @@ class LLM::Function
|
|
|
53
53
|
extend LLM::Function::Registry
|
|
54
54
|
prepend LLM::Function::Tracing
|
|
55
55
|
|
|
56
|
+
##
|
|
57
|
+
# Returns strategies that execute in an isolated environment.
|
|
58
|
+
# @return [Array<Symbol>]
|
|
59
|
+
def self.isolated
|
|
60
|
+
%i[fork ractor]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# Tells a runner that its call was interrupted, if it wants telling.
|
|
65
|
+
#
|
|
66
|
+
# Internal to the strategies that run the hook from their own side, where
|
|
67
|
+
# the runner is a local rather than something {#interrupt!} can resolve:
|
|
68
|
+
# the child's copy in `:fork` is not the parent's, and a ractor cannot be
|
|
69
|
+
# handed the function at all. The argument is the runner and not the
|
|
70
|
+
# function, which is what separates this from {#interrupt!}, and one lookup
|
|
71
|
+
# means `on_cancel` keeps its precedence over `on_interrupt` everywhere.
|
|
72
|
+
# @param [Object, nil] runner
|
|
73
|
+
# @return [nil]
|
|
74
|
+
# @api private
|
|
75
|
+
def self.interrupt(runner)
|
|
76
|
+
return nil unless runner
|
|
77
|
+
hook = %i[on_cancel on_interrupt].find { runner.respond_to?(_1) }
|
|
78
|
+
runner.public_send(hook) if hook
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
56
82
|
##
|
|
57
83
|
# {LLM::Function::Return LLM::Function::Return} represents the result of a
|
|
58
84
|
# tool call.
|
|
@@ -252,6 +278,13 @@ class LLM::Function
|
|
|
252
278
|
# task = tool.task(:thread)
|
|
253
279
|
# result = task.value
|
|
254
280
|
#
|
|
281
|
+
# @note
|
|
282
|
+
# An in-process strategy resolves the tool here, so a task that has
|
|
283
|
+
# not been spawned already holds a live instance, and
|
|
284
|
+
# {LLM::Function::Array#task} builds one for every pending function at
|
|
285
|
+
# once. A tool that cannot be built carries the same in-band error the
|
|
286
|
+
# guard produces rather than raising, so a constructor's failure
|
|
287
|
+
# answers the model on every strategy.
|
|
255
288
|
# @param [Symbol] strategy
|
|
256
289
|
# Controls concurrency strategy:
|
|
257
290
|
# - `:sequential`: Call the function sequentially
|
|
@@ -269,6 +302,25 @@ class LLM::Function
|
|
|
269
302
|
# the tool to the strategy. The task carries the blocked result and
|
|
270
303
|
# returns it without running if the guard intervenes.
|
|
271
304
|
options = options.merge(guarded: @guard&.call(function: self))
|
|
305
|
+
##
|
|
306
|
+
# Resolve the runner here, on the calling thread, before the task
|
|
307
|
+
# returned below exists for another thread to interrupt.
|
|
308
|
+
#
|
|
309
|
+
# `#interrupt!` resolves the same instance, and `@_runner ||=` is a
|
|
310
|
+
# check-then-create: reached from two threads at once it builds two,
|
|
311
|
+
# runs the call on one and tells the hook on the other.
|
|
312
|
+
#
|
|
313
|
+
# `:async` is in the list because its reactor runs on a background
|
|
314
|
+
# thread, which is the exposure `:thread` has. The cost is that the
|
|
315
|
+
# tool is built on the calling thread rather than inside the reactor,
|
|
316
|
+
# so a tool whose `initialize` wants a current `Async::Task` or a
|
|
317
|
+
# scheduler-installed fiber does not get one.
|
|
318
|
+
#
|
|
319
|
+
# A guarded task runs nothing, so it resolves nothing. A strategy in
|
|
320
|
+
# {LLM::Function.isolated} stays lazy on purpose, because there the
|
|
321
|
+
# tool is built in the child process or the ractor and this instance
|
|
322
|
+
# is not the one that runs.
|
|
323
|
+
options = options.merge(guarded: options[:guarded] || resolve(strategy))
|
|
272
324
|
case strategy
|
|
273
325
|
when :sequential
|
|
274
326
|
Sequential::Task.new(self, options)
|
|
@@ -310,12 +362,39 @@ class LLM::Function
|
|
|
310
362
|
##
|
|
311
363
|
# Notifies the function runner that the call was interrupted.
|
|
312
364
|
# This is cooperative and only applies to runners that implement
|
|
313
|
-
# `on_interrupt`.
|
|
365
|
+
# `on_cancel` or `on_interrupt`.
|
|
366
|
+
#
|
|
367
|
+
# Resolved through {#runner} rather than read off `@runner`, so that a
|
|
368
|
+
# class-backed tool is told on the object the call will run on. Read off
|
|
369
|
+
# the definition instead, a class answers `respond_to?` with false for
|
|
370
|
+
# both hooks, and a tool that implements one is never told.
|
|
371
|
+
#
|
|
372
|
+
# An in-process strategy has already resolved the runner in {#task}, on
|
|
373
|
+
# the thread that built the task, so this reads a memo rather than
|
|
374
|
+
# creating one. Where there is no tool to tell - one that cannot be
|
|
375
|
+
# built, or a function whose task was never made - this is a no-op
|
|
376
|
+
# rather than a raise, because the call path is where a constructor's
|
|
377
|
+
# failure belongs.
|
|
378
|
+
#
|
|
379
|
+
# Six routes reach the hook, and they differ in where it runs and in
|
|
380
|
+
# whether an interrupt has to have been delivered first:
|
|
381
|
+
#
|
|
382
|
+
# - `:thread`, `:fiber` and `:async` run it on the thread or fiber that ran
|
|
383
|
+
# the call, once the call has ended, and only where the raise was issued
|
|
384
|
+
# into a live job.
|
|
385
|
+
# - `:fork` and `:ractor` run it inside the child or the ractor, before the
|
|
386
|
+
# interrupt is delivered, and only for a message that arrived.
|
|
387
|
+
# - `:sequential` is the exception, and this method is how it is reached:
|
|
388
|
+
# nothing is raised into a sequential tool, so the hook is its only
|
|
389
|
+
# notification and it runs on the thread that cancelled, through
|
|
390
|
+
# {Sequential::Task#interrupt!}.
|
|
391
|
+
#
|
|
392
|
+
# A caller that reaches this directly - `LLM::Context#interrupt!` over
|
|
393
|
+
# pending functions, say - takes the last route, with no delivery to speak
|
|
394
|
+
# of and nothing running.
|
|
314
395
|
# @return [nil]
|
|
315
396
|
def interrupt!
|
|
316
|
-
|
|
317
|
-
@runner.public_send(hook) if hook
|
|
318
|
-
nil
|
|
397
|
+
LLM::Function.interrupt(runner_or_nil)
|
|
319
398
|
end
|
|
320
399
|
alias_method :cancel!, :interrupt!
|
|
321
400
|
|
|
@@ -393,15 +472,85 @@ class LLM::Function
|
|
|
393
472
|
|
|
394
473
|
##
|
|
395
474
|
# Returns the bound function runner instance.
|
|
475
|
+
#
|
|
476
|
+
# Resolved once and kept, so that the object an interrupt has to reach is
|
|
477
|
+
# the object that runs the call. A class-backed tool would otherwise build
|
|
478
|
+
# an instance here and discard it, leaving {#interrupt!} with a class to
|
|
479
|
+
# send the hook to.
|
|
480
|
+
#
|
|
481
|
+
# An in-process strategy resolves it in {#task}, on the calling thread,
|
|
482
|
+
# before the task exists for another thread to interrupt. Every other
|
|
483
|
+
# path resolves it at the first call.
|
|
484
|
+
#
|
|
485
|
+
# The tracer is assigned when the instance is resolved, so a tracer set
|
|
486
|
+
# after that does not reach it.
|
|
487
|
+
#
|
|
488
|
+
# The instance belongs to this function object rather than to a call. The
|
|
489
|
+
# per-call copy is the one {LLM::Message#functions} makes, and that is
|
|
490
|
+
# what keeps the usual path to one call per instance; a second {#task} on
|
|
491
|
+
# one function, or a retry, reuses the instance and whatever its ivars
|
|
492
|
+
# hold.
|
|
396
493
|
# @return [Object]
|
|
397
494
|
def runner
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
495
|
+
@_runner ||= begin
|
|
496
|
+
_runner = Class === @runner ? @runner.new : @runner
|
|
497
|
+
_runner.tracer = @tracer if _runner.respond_to?(:tracer=)
|
|
498
|
+
_runner
|
|
499
|
+
end
|
|
401
500
|
end
|
|
402
501
|
|
|
403
502
|
private
|
|
404
503
|
|
|
504
|
+
##
|
|
505
|
+
# Resolves the runner for a strategy that runs in this process, and
|
|
506
|
+
# answers with an in-band error when the tool cannot be built.
|
|
507
|
+
#
|
|
508
|
+
# {#call_function} resolves inside its own rescue, and that rescue is what
|
|
509
|
+
# answers the model with an error rather than raising into the turn.
|
|
510
|
+
# Resolving earlier keeps that promise by producing the return the guard
|
|
511
|
+
# would have produced.
|
|
512
|
+
#
|
|
513
|
+
# An interrupt is re-raised rather than answered, which is what
|
|
514
|
+
# {#call_function} does for the same reason: an interrupt is not a tool's
|
|
515
|
+
# failure, and the runner is the thing it is aimed at.
|
|
516
|
+
# @param [Symbol] strategy
|
|
517
|
+
# @return [LLM::Function::Return, nil]
|
|
518
|
+
def resolve(strategy)
|
|
519
|
+
return nil if LLM::Function.isolated.include?(strategy)
|
|
520
|
+
runner
|
|
521
|
+
nil
|
|
522
|
+
rescue LLM::Interrupt
|
|
523
|
+
raise
|
|
524
|
+
rescue => ex
|
|
525
|
+
Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
##
|
|
529
|
+
# The runner, or nil when it cannot be built.
|
|
530
|
+
#
|
|
531
|
+
# An interrupt has nothing to tell if there is no tool to tell, and a
|
|
532
|
+
# cancel is the wrong place to raise a constructor's failure, which the
|
|
533
|
+
# call path answers in-band.
|
|
534
|
+
# @return [Object, nil]
|
|
535
|
+
def runner_or_nil
|
|
536
|
+
runner
|
|
537
|
+
rescue StandardError
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
##
|
|
542
|
+
# A duplicate resolves its own runner.
|
|
543
|
+
#
|
|
544
|
+
# The copy a task runs belongs to one call, and the instance that call
|
|
545
|
+
# resolves belongs to that copy - never to the definition it was
|
|
546
|
+
# duplicated from, which is shared and globally registered.
|
|
547
|
+
# @param [LLM::Function] other
|
|
548
|
+
# @return [void]
|
|
549
|
+
def initialize_copy(other)
|
|
550
|
+
super
|
|
551
|
+
@_runner = nil
|
|
552
|
+
end
|
|
553
|
+
|
|
405
554
|
##
|
|
406
555
|
# Internal method that calls the function and returns a Return object.
|
|
407
556
|
# Handles both class-based and proc-based runners, and rescues exceptions.
|
data/lib/llm/schema/leaf.rb
CHANGED
|
@@ -96,9 +96,11 @@ class LLM::Schema
|
|
|
96
96
|
|
|
97
97
|
##
|
|
98
98
|
# Mark a leaf as required
|
|
99
|
+
# @param [Boolean] value
|
|
100
|
+
# When false, marks the leaf as optional
|
|
99
101
|
# @return [LLM::Schema::Leaf]
|
|
100
|
-
def required
|
|
101
|
-
tap { @required =
|
|
102
|
+
def required(value = true)
|
|
103
|
+
tap { @required = value }
|
|
102
104
|
end
|
|
103
105
|
|
|
104
106
|
##
|
data/lib/llm/sequel/plugin.rb
CHANGED
|
@@ -188,8 +188,13 @@ module LLM::Sequel
|
|
|
188
188
|
end
|
|
189
189
|
|
|
190
190
|
##
|
|
191
|
+
# @note There is no view over the jsonb column on this side, so a record
|
|
192
|
+
# always loads the runtime to answer this - unlike
|
|
193
|
+
# {LLM::ActiveRecord}, where a jsonb record reads the column directly.
|
|
194
|
+
# The two wrappers are written to mirror each other, and this is the
|
|
195
|
+
# one place they do not.
|
|
191
196
|
# @see LLM::Context#messages
|
|
192
|
-
# @return [
|
|
197
|
+
# @return [LLM::Buffer]
|
|
193
198
|
def messages
|
|
194
199
|
ctx.messages
|
|
195
200
|
end
|
data/lib/llm/version.rb
CHANGED