llm.rb 16.0.0.pre.7 → 16.0.0.pre.8

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: 0a92555a0d4e73fd37604103300639f048607b332a106a042615ff76880b9665
4
- data.tar.gz: de618250fdd9cdf37c928e2753e6be173201f482ce30ae97ce0e080bea3c8a24
3
+ metadata.gz: d91d2b022e12ba720029c2083969db3438a9c449e733caa6a992a63baacde3b8
4
+ data.tar.gz: 498c7b90c52d7d642bfec0baa89ea27b86a5d9e6bf3e5eefb0abfebf8dc03d72
5
5
  SHA512:
6
- metadata.gz: 8739cb9db9168ea2a6daedb82a068b51085509ffc0447e0d4f7415fc7b3c02051d9a26b85f3a0a0b7cda7e7cb4ae47f50f5a7f181a56b1f97d516e4b34a99117
7
- data.tar.gz: 6c615f5ac41d186a2215db2f3d64fe68896f960bab8154629ac3322666e350bbe29dce80622cac190f6e1b8b57827413545cc41d5098f7d88867f0a9469ced2a
6
+ metadata.gz: 9a5d53788c58de77e215369a3a6fd77485e8fd8fccc1107218fac2d5f6bbc417dd44ccba9e37344ffa9084ded1949c33c516f708f6f6d50aaf6a5baacf8fec2b
7
+ data.tar.gz: 3d29709c6a8fd38b3631464adb21c22a7bfa491f5a7e365f38b52a2f2a6c07da3f63443e27530de19c2ce5446ce35a778b1f200cfcef4b554e65ef8bf1ee35f7
data/CHANGELOG.md CHANGED
@@ -29,6 +29,7 @@ Releases before v15 are kept in [changelog/old.md](changelog/old.md).
29
29
  | a `set_tracer` method on an ORM model | `set tracer:` in the block, or `tracer:` on the wrapper |
30
30
  | a `:thread` or `:fiber` cancel that arrives before the tool starts is dropped | the cancel is held and raised once the tool starts |
31
31
  | a cancel that arrives between two requests does nothing | the turn ends there, and the caller sees `LLM::Interrupt` |
32
+ | a bare `rescue`, or `rescue => ex`, catches a cancel | `LLM::Interrupt` is outside `StandardError`, so both forms pass it through |
32
33
 
33
34
  * **drop Ruby 3.3 support** <br>
34
35
  The gem now requires Ruby 3.4 or later. Ruby 3.3 cannot hold an
@@ -57,8 +58,19 @@ Releases before v15 are kept in [changelog/old.md](changelog/old.md).
57
58
  first. `LLM::Agent#run_loop` records that caller for as long as the turn
58
59
  lasts, and it answers `interrupt!` through
59
60
  [`LLM::Agent::Interrupt`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent/Interrupt.html).
60
- `LLM::Interrupt` is a `StandardError`, so a broad `rescue` catches it
61
- wherever the turn had reached.
61
+ The raise lands in the thread or fiber the turn runs on, so it unwinds out
62
+ of the turn to whoever called it.
63
+
64
+ * **interrupt: move `LLM::Interrupt` outside `StandardError`** <br>
65
+ `LLM::Interrupt` is now an `Exception` rather than an `LLM::Error`, so a
66
+ bare `rescue` and a `rescue => ex` pass a cancel through instead of
67
+ swallowing it. Before, a broad rescue in a tool, a tracer, or an
68
+ application could eat an interrupt, and the turn looked like one that had
69
+ ignored its cancel. It is not a `SignalException`, since an interrupt has
70
+ to be catchable and a signal is a framework's own condition. A caller that
71
+ handles a cancel names `LLM::Interrupt`; `#wait` and the tool's
72
+ `on_interrupt` hook are unchanged, and a task still carries the interrupt
73
+ back to the caller rather than raising it inside its reactor.
62
74
 
63
75
  ### ActiveRecord
64
76
 
@@ -93,9 +93,12 @@ A cancel is delivered at the point the turn has reached, which can be
93
93
  any point of it: inside a stream callback, a compactor, a transformer,
94
94
  a guard, or the persistence write a step makes.
95
95
  [`LLM::Interrupt`](https://r.uby.dev/api-docs/llm.rb/LLM/Interrupt.html)
96
- is a `StandardError`, so a `rescue` that names no class catches it too.
97
- Code that has to see the end of a turn should re-raise what it does not
98
- recognize rather than absorb it.
96
+ sits outside `StandardError`, so neither a bare `rescue` nor a
97
+ `rescue => ex` catches it, and the language is what keeps a cancel from
98
+ being eaten rather than a convention every rescue has to remember. Code
99
+ that handles a cancel names `LLM::Interrupt`, the way the example above
100
+ does; it is not an `LLM::Error` either, so a rescue of the runtime's
101
+ error superclass does not catch it.
99
102
 
100
103
  The runtime's own path out of a turn does not swallow an interrupt:
101
104
  [`LLM::Context#try`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#try)
@@ -157,13 +157,15 @@ the tool runs and returns the span that `on_tool_finish` and
157
157
  `on_tool_error` receive.
158
158
 
159
159
  An interrupt is neither a finish nor an error, and it is reported to a
160
- hook of its own. `on_interrupt` is called with a `scope:` of `:request`
161
- or `:tool` before the caller is given the exception, so a tracer that
162
- records what happened to a turn records it while the work is still in
163
- flight. The hook receives the span that `on_request_start` returned and
164
- its `request_id`; a tool pass carries neither, because a tool's span
165
- belongs to the call. A cancel reaches every tool that is running, so the
166
- tool phase is announced once rather than once per tool.
160
+ hook of its own. `on_interrupt` is called with a `scope:` of `:request`,
161
+ `:tool`, or `:agent` before the caller is given the exception, so a
162
+ tracer that records what happened to a turn records it while the work is
163
+ still in flight. The hook receives the span that `on_request_start`
164
+ returned and its `request_id`; a tool pass carries neither, because a
165
+ tool's span belongs to the call, and an `:agent` pass is one that landed
166
+ between a turn's requests, where there was nothing more precise to
167
+ interrupt. A cancel reaches every tool that is running, so the tool phase
168
+ is announced once rather than once per tool.
167
169
 
168
170
  A tracer's own lifetime is bracketed as well. `on_exit` fires once,
169
171
  when the last scope that is open for that tracer ends. That scope can
data/lib/llm/error.rb CHANGED
@@ -63,8 +63,19 @@ module LLM
63
63
  ContextWindowError = Class.new(InvalidRequestError)
64
64
 
65
65
  ##
66
- # When a request is interrupted
67
- Interrupt = Class.new(Error)
66
+ # When a request is interrupted.
67
+ #
68
+ # It sits outside `StandardError`, so that neither a bare `rescue` nor a
69
+ # `rescue => e` can swallow it by mistake: an interrupt is a request to
70
+ # stop, and a turn whose cancel was eaten looks like a turn that ignored
71
+ # one.
72
+ #
73
+ # It is not a `SignalException` either. A signal is a framework's own
74
+ # condition rather than a task's - RSpec re-raises one that escapes an
75
+ # example, and an async reactor ends its own thread rather than failing
76
+ # the task that raised it - and an interrupt has to be something a caller
77
+ # can catch.
78
+ Interrupt = Class.new(Exception)
68
79
 
69
80
  ##
70
81
  # When a concurrency strategy cannot execute a given tool
@@ -49,13 +49,15 @@ module LLM::Function::Async
49
49
  # hook that runs after the queue was pushed is a hook the caller can
50
50
  # race past.
51
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.
52
+ # `LLM::Interrupt` is a subclass of `Exception`, and one that is left to
53
+ # raise kills the reactor's thread and takes every other task on that
54
+ # reactor with it. So the task rescues it, stores it on its own queue,
55
+ # and exits silently - and the caller reads the queue and raises the
56
+ # interrupt on its own thread or fiber, which is where it was asked for.
57
+ #
58
+ # The rescue below names the interrupt rather than leaving it to a bare
59
+ # rescue, because a bare rescue - and `rescue => e` - reaches only
60
+ # `StandardError`.
59
61
  # @return [nil]
60
62
  def spawn
61
63
  return if @guarded
@@ -65,10 +67,22 @@ module LLM::Function::Async
65
67
  task = Async::Task.current
66
68
  @task = task
67
69
  @scheduler = Fiber.scheduler
68
- raise LLM::Interrupt if @cancelled
70
+ ##
71
+ # A cancel that arrived before this ran. There is no tool to tell,
72
+ # so the caller is given it and the task ends - for the same reason
73
+ # the block below answers rather than raises.
74
+ if @cancelled
75
+ @queue << LLM::Interrupt.new
76
+ next
77
+ end
69
78
  task.defer_cancel do
70
79
  result = begin
71
80
  function.call
81
+ rescue LLM::Interrupt => ex
82
+ ##
83
+ # The interrupt is the block's result, and the queue carries it
84
+ # to the caller.
85
+ ex
72
86
  ensure
73
87
  ##
74
88
  # The hook runs on the reactor's thread rather than on the one
@@ -80,15 +94,15 @@ module LLM::Function::Async
80
94
  end
81
95
  @queue << result
82
96
  end
83
- rescue => e
97
+ rescue LLM::Interrupt, StandardError => e
84
98
  ##
85
99
  # 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.
100
+ # told first and the caller second. It answers a hook whose own
101
+ # error unwound past the push, and it is the backstop for an
102
+ # interrupt that arrived from somewhere other than the call above -
103
+ # which is why the interrupt is pushed and not raised on.
90
104
  @queue << e
91
- raise
105
+ raise unless LLM::Interrupt === e
92
106
  end
93
107
  nil
94
108
  end
@@ -72,9 +72,20 @@ module LLM::Function::Fiber
72
72
  raise LLM::Interrupt
73
73
  end
74
74
  @queue << function.call
75
- rescue => ex
75
+ rescue LLM::Interrupt, StandardError => ex
76
+ ##
77
+ # The interrupt is stored on the queue rather than raised on.
78
+ # `LLM::Interrupt` is a subclass of `Exception`, and one that is
79
+ # left to raise kills the scheduler's thread and takes the tasks
80
+ # running on it with it - the caller reads the queue and raises
81
+ # the interrupt on its own thread or fiber, and this task exits
82
+ # silently.
83
+ #
84
+ # The rescue names the interrupt rather than leaving it to a bare
85
+ # rescue, because a bare rescue - and `rescue => ex` - reaches
86
+ # only `StandardError`.
76
87
  @queue << ex
77
- raise
88
+ raise unless LLM::Interrupt === ex
78
89
  ensure
79
90
  ##
80
91
  # The hook runs on the fiber the call runs on, once the call has
data/lib/llm/tracer.rb CHANGED
@@ -195,7 +195,7 @@ module LLM
195
195
  end
196
196
 
197
197
  ##
198
- # Called when a request or a tool is interrupted.
198
+ # Called when a request, a tool, or a turn is interrupted.
199
199
  #
200
200
  # An interrupt is not a failure, so this is not {#on_request_error} or
201
201
  # {#on_tool_error} under another name: it is the third ending either
@@ -206,7 +206,9 @@ module LLM
206
206
  # A request is announced once, by the transport. A tool is announced
207
207
  # once for the phase rather than once per tool: a cancel reaches every
208
208
  # tool that is running and the caller hears one exception, so there is
209
- # no one tool the announcement belongs to.
209
+ # no one tool the announcement belongs to. A turn is announced as
210
+ # `:agent` when the interrupt lands between two of its requests, which
211
+ # is the one point of a turn that has no span of its own.
210
212
  #
211
213
  # The default does nothing, which is the difference from the rest of the
212
214
  # lifecycle. Every other hook raises, because a tracer that means to draw
@@ -214,7 +216,8 @@ module LLM
214
216
  # whatever tracer happens to be bound, and a hook that raised here would
215
217
  # replace the interrupt every caller is written against.
216
218
  # @param [Symbol] scope
217
- # :request for a request, :tool for a tool
219
+ # :request for a request, :tool for a tool, :agent for a turn that is
220
+ # between its requests
218
221
  # @param [Object, nil] span
219
222
  # The span that {#on_request_start} returned. A tool pass has none:
220
223
  # a tool's span belongs to the call, and the announcement is not one
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.7"
4
+ VERSION = "16.0.0.pre.8"
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.7
4
+ version: 16.0.0.pre.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson