llm.rb 16.0.0.pre.2 → 16.0.0.pre.4

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.
data/lib/llm/context.rb CHANGED
@@ -222,6 +222,7 @@ module LLM
222
222
  @messages.concat(prompt)
223
223
  end
224
224
  @messages.concat([res.choices[-1]].compact)
225
+ stream.on_step(self, res)
225
226
  res
226
227
  ensure
227
228
  @owner = nil
@@ -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
  ##
@@ -155,21 +155,23 @@ module LLM::Sequel
155
155
  Utils = Plugin::Utils
156
156
 
157
157
  ##
158
- # Continues the stored context with new input and flushes it.
158
+ # Continues the stored context with new input.
159
+ #
160
+ # The conversation is saved by {LLM::Step LLM::Step} at each request.
159
161
  # @see LLM::Context#talk
160
162
  # @return [LLM::Response]
161
163
  def talk(...)
162
- options = self.class.llm_plugin_options
163
- ctx.talk(...).tap { Utils.save!(self, ctx, options) }
164
+ ctx.talk(...)
164
165
  end
165
166
 
166
167
  ##
167
- # Continues the stored context with new input and flushes it.
168
+ # Continues the stored context with new input.
169
+ #
170
+ # The conversation is saved by {LLM::Step LLM::Step} at each request.
168
171
  # @see LLM::Context#ask
169
172
  # @return [LLM::Response]
170
173
  def ask(...)
171
- options = self.class.llm_plugin_options
172
- ctx.ask(...).tap { Utils.save!(self, ctx, options) }
174
+ ctx.ask(...)
173
175
  end
174
176
 
175
177
  ##
@@ -188,12 +190,28 @@ module LLM::Sequel
188
190
  end
189
191
 
190
192
  ##
193
+ # @note There is no view over the jsonb column on this side, so a record
194
+ # always loads the runtime to answer this - unlike
195
+ # {LLM::ActiveRecord}, where a jsonb record reads the column directly.
196
+ # The two wrappers are written to mirror each other, and this is the
197
+ # one place they do not.
191
198
  # @see LLM::Context#messages
192
- # @return [Array<LLM::Message>]
199
+ # @return [LLM::Buffer]
193
200
  def messages
194
201
  ctx.messages
195
202
  end
196
203
 
204
+ ##
205
+ # @note The same call {#messages} makes here, and here for the sake of the
206
+ # name: on {LLM::ActiveRecord} the two differ for a jsonb record, and a
207
+ # wrapper that answered to a name the other one does not is the next
208
+ # reader's surprise.
209
+ # @see LLM::Context#messages
210
+ # @return [LLM::Buffer]
211
+ def messages!
212
+ ctx.messages
213
+ end
214
+
197
215
  ##
198
216
  # @note The bang is used because Sequel reserves `model` for the
199
217
  # underlying model class on instances.
data/lib/llm/step.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # Writes the conversation down when a request completes.
6
+ #
7
+ # {LLM::Step LLM::Step} is prepended onto a stream's singleton class by
8
+ # {LLM::Stream.try}, so a conversation bound to a record is saved as it goes:
9
+ # a turn that is interrupted - by a crash, by a deploy, or by a provider that
10
+ # refuses - can be continued rather than started over.
11
+ #
12
+ # It saves through the record the context is bound to ({LLM::Context#record}),
13
+ # and it knows both the ORMs llm.rb speaks: an ActiveRecord model is saved
14
+ # with {LLM::ActiveRecord::Utils.save!} and a Sequel model with
15
+ # {LLM::Sequel::Plugin::Utils.save!}. A context with no record - which is
16
+ # every context that is not backed by a model - has nothing to save, and
17
+ # neither has a record that carries neither plugin, so the step is passed
18
+ # along untouched.
19
+ #
20
+ # All of this is one method on purpose. The module is put onto an object the
21
+ # caller owns, and a helper would be one more name on that object for as long
22
+ # as it lives.
23
+ #
24
+ # The write is best effort: a failure is swallowed rather than raised, because
25
+ # this runs inside the request that has just completed, and a stale checkpoint
26
+ # is a smaller failure than a turn that ends because the database was busy.
27
+ # Only the write is covered, so a stream's own `on_step` is reached whether or
28
+ # not the write lands - a step that a tracer hears about does not depend on a
29
+ # database having been free.
30
+ #
31
+ # @see LLM::Stream#on_step
32
+ # @see LLM::ActiveRecord::Utils.save!
33
+ module Step
34
+ ##
35
+ # Saves the conversation the context holds, then passes the step along.
36
+ #
37
+ # @param [LLM::Context] ctx
38
+ # The context the request belongs to
39
+ # @param [LLM::Response] res
40
+ # The response for the request that completed
41
+ # @return [nil]
42
+ def on_step(ctx, res)
43
+ record = ctx.record
44
+ if record and record.class.respond_to?(:llm_plugin_options)
45
+ options = record.class.llm_plugin_options
46
+ begin
47
+ if defined?(::ActiveRecord::Base) and ::ActiveRecord::Base === record
48
+ LLM::ActiveRecord::Utils.save!(record, ctx, options)
49
+ elsif defined?(::Sequel::Model) and ::Sequel::Model === record
50
+ LLM::Sequel::Plugin::Utils.save!(record, ctx, options)
51
+ end
52
+ rescue
53
+ nil
54
+ end
55
+ end
56
+ super
57
+ end
58
+ end
59
+ end