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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61578ce1082ecd25f86574ef19e1ffd4cc2b68c6689b705f0544f4aa3e391ede
4
- data.tar.gz: 742b8d66f49113389c5ff08772e79498334eb6d5afe9821f05c2b8d1851cf4e8
3
+ metadata.gz: a8bd3b4f303036ba390981de3ec63ea0bb523cc653c4b77b221e4591e9b9a446
4
+ data.tar.gz: 3036ce3c4248e3ddad524657f31ea2c300f98282ad0aa68791848f56cd1b91f5
5
5
  SHA512:
6
- metadata.gz: d0024edabc9ac7360a71a485362aed854644187c116777623b340e5fbd391ae257139aefe07ba29ce7fe9bf04573e66ff09c0f48024153833cbb28b385e13280
7
- data.tar.gz: ec0b4f0b2ba6a66b38cd324c5c3ea6daccd4ff76cde43a1fa8c272c20da89bec735ff7b7bae3afb516d4b5a3131b4e383abe70f48365d6ba9c8a61028e233c48
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` and `openai.gpt-6-luna`; and DeepInfra adds
101
- `tencent/Hy4-preview` and the Xiaomi MiMo V2.6 Pro and V2.6 Flash models.
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 four models, drops six, and reprices
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 [Array<LLM::Message>]
106
+ # @return [ActiveRecord::Relation, LLM::Buffer]
94
107
  def messages
95
- ctx.messages
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 { @queue << function.call }
57
- rescue LLM::Interrupt => e
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 LLM::Interrupt === result
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 { function.call }
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
- @fiber&.raise(LLM::Interrupt) if @fiber&.alive?
44
- function.interrupt!
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
- # The window decides whether this is the tool's to handle, or
83
- # whether the tool has already been and gone.
84
- @window.interrupt!
85
- rescue IOError, ArgumentError
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
- # The window decides whether this is the tool's to handle, or
86
- # whether the tool has already been and gone.
87
- window.interrupt!
88
- rescue ::Ractor::Error
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. The tool is built outside
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
- # Sequential functions run on the caller's thread,
39
- # and this methods raises {LLM::Interrupt} on that
40
- # thread.
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!
@@ -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. When set, the
21
- # task yields it without running the tool.
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 { function.call }
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
- @thread&.raise(LLM::Interrupt) if @thread&.alive?
42
- function.interrupt!
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
- hook = %i[on_cancel on_interrupt].find { @runner.respond_to?(_1) }
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
- runner = Class === @runner ? @runner.new : @runner
399
- runner.tracer = @tracer if runner.respond_to?(:tracer=)
400
- runner
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.
@@ -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 = true }
102
+ def required(value = true)
103
+ tap { @required = value }
102
104
  end
103
105
 
104
106
  ##
@@ -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 [Array<LLM::Message>]
197
+ # @return [LLM::Buffer]
193
198
  def messages
194
199
  ctx.messages
195
200
  end
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "16.0.0.pre.2"
4
+ VERSION = "16.0.0.pre.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.0.0.pre.2
4
+ version: 16.0.0.pre.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson