llm.rb 15.4.1 → 15.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4647447cce1ccfc7e4f062cfa46b6dd0363ffd1a86537200a7877daff983cda3
4
- data.tar.gz: 31502899d4a6443b893a48baf18c75d02311920166a6d791d671fa33600e93b9
3
+ metadata.gz: df1a9b7fbf194f568b95f3f1b2d0314bb703027bc89e4432109d118f238ea2ba
4
+ data.tar.gz: 679938a2f6538f9c1999d31870462498393e1b3a7c7203630c5e8706c893e9ce
5
5
  SHA512:
6
- metadata.gz: ee244a4b0d7d317632e1ee2f3dc44b82926086a71f2ded6ba7e3189eff9636dba2eed5f97d4ed6c83140584d3807b4872303a20b369a123be2336c32671758a1
7
- data.tar.gz: 7ed45ea9770afabb6522b6939c9d333c28a5892e40e32de271d03a8c5fb3f9fa15b5c2ace19e74ac80342bc65e04a7b5edb48b6799a612f5754b8ccccd33bdaf
6
+ metadata.gz: 49151bad60a22a06b119e08062e58c6d35727d59d10e07d9921166a23b5817083ea11fe942a090bc0e64b68a06340b9cf175a87ecfa81775dffcc7d88a8abb0c
7
+ data.tar.gz: 92ddc3cd73f7af70f25e735fd8ac63dcbca6ed48fd78ba3510be0171ea180d006f399ff60e215bdf1b9159e52b721916c59fcb7278c322fb0ffe789db5900602
data/CHANGELOG.md CHANGED
@@ -17,6 +17,59 @@
17
17
 
18
18
  *No unreleased changes yet. Check back after the next release.*
19
19
 
20
+ ## v15.5.0
21
+
22
+ Changes since `v15.4.1`.
23
+
24
+ This release makes the exec-backed tools report how long a command ran, calls a
25
+ tracer's `on_exit` hook once when the last scope ends, and resolves a `Proc`
26
+ parameter type when a schema is serialized. It also fixes `LLM::Schema.to_s`
27
+ for a schema with a deferred type.
28
+
29
+ ### Tools
30
+
31
+ * **tools: report how long a command ran** <br>
32
+ [`LLM::Tool::Exec#call`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool/Exec.html#call-instance_method)
33
+ now includes a `duration` field in its result, a string such as `"0.4 seconds"`
34
+ that reports how long the command took. The tools that route through `exec`
35
+ (`git`, `rg`, `mkdir`, `ruby`, and `bundle`) return it as well. A command that
36
+ was not found still returns the error hash, which has no `duration`.
37
+
38
+ ### Tracers
39
+
40
+ * **tracer: call `on_exit` once, when the last scope ends** <br>
41
+ [`LLM::Tracer#on_exit`](https://r.uby.dev/api-docs/llm.rb/LLM/Tracer.html#on_exit-instance_method)
42
+ is a new hook, called once when the last scope that is open for a tracer
43
+ ends. A tracer that holds a connection, a file, or a span releases it here.
44
+ The count of open scopes belongs to the tracer rather than to the thread that
45
+ opened one, because a tool runs on a thread of its own and scopes the turn's
46
+ tracer while it does. Before this, a tool's scope looked like the outermost
47
+ one: a tracer that released its resource in `on_exit` lost it in the middle
48
+ of the turn, and the rest of the trace was written without it.
49
+
50
+ ### Schema
51
+
52
+ * **schema: resolve a parameter type when the schema is serialized** <br>
53
+ [`LLM::Schema`](https://r.uby.dev/api-docs/llm.rb/LLM/Schema.html) and
54
+ [`LLM::Tool`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html) accept a `Proc`
55
+ as the type of a parameter or a property, and the proc is called when the
56
+ schema is serialized rather than when the class that declares it is defined.
57
+ A type that is only known at runtime no longer has to be known at load time:
58
+ `parameter :kind, proc { Enum[*Article.kinds] }, "The article kind"` asks the
59
+ model for the values a store holds right now, and the proc runs again on the
60
+ next request, so it sees the values that are current then. Whatever the proc
61
+ returns is resolved in turn, so it may be a leaf, a class, or an array of
62
+ either, and the description, `required`, `default`, and `enum` given with the
63
+ parameter are applied to it.
64
+
65
+ * **schema: keep `to_s` working for a deferred type** <br>
66
+ Fix a bug where a schema with a proc-typed property raised from
67
+ [`LLM::Schema.to_s`](https://r.uby.dev/api-docs/llm.rb/LLM/Schema.html#to_s-class_method)
68
+ (and so from `inspect` and `p`) because the placeholder resolved its leaf
69
+ through a private method that shadowed the one `LLM::Schema::Leaf#default`
70
+ uses. Serialization, and therefore the request sent to a provider, was
71
+ unaffected.
72
+
20
73
  ## v15.4.1
21
74
 
22
75
  Changes since `v15.4.0`.
data/README.md CHANGED
@@ -1092,9 +1092,9 @@ web</a>.
1092
1092
 
1093
1093
  The llm.rb project was started more than three
1094
1094
  years ago by
1095
- [@0x1eef](https://github.com/0x1eef) and
1096
- [@antaz](https://github.com/0x1eef). The primary
1097
- maintainer is [@0x1eef](https://github.com/0x1eef).
1095
+ [@altruby](https://github.com/altruby) and
1096
+ [@antaz](https://github.com/altruby). The primary
1097
+ maintainer is [@altruby](https://github.com/altruby).
1098
1098
  Over those three years multiple other contributors have
1099
1099
  contributed to llm.rb as well, and new contributors are
1100
1100
  always welcome.
@@ -156,6 +156,16 @@ Three more hooks cover a local tool call. `on_tool_start` fires before
156
156
  the tool runs and returns the span that `on_tool_finish` and
157
157
  `on_tool_error` receive.
158
158
 
159
+ A tracer's own lifetime is bracketed as well. `on_exit` fires once,
160
+ when the last scope that is open for that tracer ends. That scope can
161
+ belong to a different thread than the one that opened the first: a tool
162
+ runs on a thread of its own and scopes the turn's tracer while it does.
163
+ `on_exit` is called after the scoped lookup has been restored, so a
164
+ tracer that asks its provider for the current tracer from inside it
165
+ sees the tracer the next request will see. A tracer can be scoped again
166
+ afterwards, so it has to remain usable after `on_exit`, and `on_exit`
167
+ may be called more than once over its life.
168
+
159
169
  A turn is additionally bracketed with `start_trace` and `stop_trace`.
160
170
  The runtime calls them around every agent turn with a `trace_group_id`,
161
171
  and a tracer that supports it (such as
data/lib/llm/function.rb CHANGED
@@ -199,6 +199,12 @@ class LLM::Function
199
199
 
200
200
  ##
201
201
  # Set (or get) the function parameters
202
+ #
203
+ # @note
204
+ # A parameter type that is given as a proc is resolved when the
205
+ # parameters are rendered rather than here, so a tool can say
206
+ # `parameter :fruit, proc { Enum[Fruit.keys] }` and ask the model
207
+ # for what is available at the time of the call.
202
208
  # @yieldparam [LLM::Schema] schema The schema object
203
209
  # @return [LLM::Schema::Leaf, nil]
204
210
  def params
data/lib/llm/provider.rb CHANGED
@@ -396,9 +396,18 @@ class LLM::Provider
396
396
  # Set the provider's default tracer
397
397
  # This tracer is shared by the provider instance and becomes the fallback
398
398
  # whenever no scoped override is active.
399
+ #
400
+ # A tracer assigned this way is not scoped, so nothing declares when it
401
+ # is finished and {LLM::Tracer#on_exit} never fires for it. Release what
402
+ # it holds when the provider is done with, or scope it with
403
+ # {#with_tracer} instead.
404
+ #
399
405
  # @example
400
406
  # llm = LLM.openai(key: ENV["KEY"])
401
407
  # llm.tracer = LLM::Tracer.logger(llm, path: "/path/to/log.txt")
408
+ # llm.with_tracer(llm.tracer) do
409
+ # # ...
410
+ # end
402
411
  # @param [LLM::Tracer] tracer
403
412
  # A tracer
404
413
  # @return [void]
@@ -418,19 +427,25 @@ class LLM::Provider
418
427
  # @yield
419
428
  # @return [Object]
420
429
  def with_tracer(tracer)
430
+ scoped = tracer || LLM::Tracer::Null.new(self)
421
431
  wm = weakmaps.tracer
422
432
  had_override = wm.key?(self)
423
433
  previous = wm[self]
424
- wm[self] = tracer || LLM::Tracer::Null.new(self)
434
+ wm[self] = scoped
435
+ entered = LLM::Tracer.enter(scoped)
425
436
  yield
426
437
  ensure
427
438
  if had_override
428
439
  wm[self] = previous
429
- elsif wm.respond_to?(:delete)
430
- wm.delete(self)
431
440
  else
432
- wm[self] = nil
441
+ wm.respond_to?(:delete) ? wm.delete(self) : wm[self] = nil
433
442
  end
443
+ ##
444
+ # The scope this tracer was opened for is over, and the tracer is
445
+ # told so when the last one that is open for it ends - on whichever
446
+ # thread that happens, because a tool opens a scope of its own for
447
+ # the turn's tracer.
448
+ LLM::Tracer.exit(scoped) if entered
434
449
  end
435
450
 
436
451
  ##
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Schema
4
+ ##
5
+ # {LLM::Schema::Deferred LLM::Schema::Deferred} is a placeholder for a
6
+ # leaf that is resolved when the schema is rendered.
7
+ #
8
+ # A parameter type that can only be known at runtime - an enum of the
9
+ # keys a store holds right now, for example - is given as a proc. The
10
+ # proc is called when the schema is rendered rather than when the class
11
+ # that declares the parameter is defined, so declaring a tool does not
12
+ # touch whatever the type depends on:
13
+ #
14
+ # parameter :fruit, proc { Enum[*Fruit.kinds]}, "A fruit", required: true
15
+ #
16
+ # Whatever the proc returns is resolved in turn, so it can return a
17
+ # leaf, a class, or an array of either.
18
+ #
19
+ # @api private
20
+ class Deferred < Leaf
21
+ ##
22
+ # @param [Proc] block
23
+ # A block that returns the leaf to resolve to
24
+ # @return [LLM::Schema::Deferred]
25
+ def initialize(&block)
26
+ super()
27
+ @block = block
28
+ end
29
+
30
+ ##
31
+ # @return [Hash]
32
+ def to_h
33
+ resolved_leaf.to_h
34
+ end
35
+
36
+ ##
37
+ # @param [Hash] options
38
+ # @return [String]
39
+ def to_json(options = {})
40
+ resolved_leaf.to_h.to_json(options)
41
+ end
42
+
43
+ ##
44
+ # @return [String]
45
+ def to_s
46
+ resolved_leaf.to_s
47
+ end
48
+
49
+ private
50
+
51
+ ##
52
+ # Returns the leaf the block resolves to, with the settings that
53
+ # were given to the placeholder applied to it.
54
+ #
55
+ # The settings live on the placeholder, because they are given
56
+ # before the leaf it stands for exists.
57
+ # @return [LLM::Schema::Leaf]
58
+ def resolved_leaf
59
+ leaf = @block.call
60
+ leaf.description(@description) if @description
61
+ leaf.default(@default) unless @default.nil?
62
+ leaf.enum(*@enum) if @enum
63
+ leaf.const(@const) unless @const.nil?
64
+ leaf.required if required?
65
+ leaf
66
+ end
67
+ end
68
+ end
@@ -12,6 +12,9 @@ class LLM::Schema
12
12
  # @return [Integer, nil]
13
13
  attr_accessor :index
14
14
 
15
+ ##
16
+ # Returns a leaf.
17
+ # @return [LLM::Schema::Leaf]
15
18
  def initialize
16
19
  @description = nil
17
20
  @default = nil
data/lib/llm/schema.rb CHANGED
@@ -36,6 +36,7 @@ class LLM::Schema
36
36
  require_relative "schema/parser"
37
37
  require_relative "schema/renderer"
38
38
  require_relative "schema/leaf"
39
+ require_relative "schema/deferred"
39
40
  require_relative "schema/object"
40
41
  require_relative "schema/array"
41
42
  require_relative "schema/all_of"
@@ -56,9 +57,21 @@ class LLM::Schema
56
57
  module Utils
57
58
  extend self
58
59
 
60
+ ##
61
+ # Resolves a parameter type into a leaf of the schema.
62
+ #
63
+ # A proc is called for its type, and whatever it returns is resolved
64
+ # in turn, so a type that can only be known at runtime - an enum of
65
+ # the keys a store holds right now - can be declared without being
66
+ # evaluated when the class that declares it is defined.
67
+ # @param [LLM::Schema] schema
68
+ # @param [Object] type
69
+ # @return [LLM::Schema::Leaf]
59
70
  def resolve(schema, type)
60
71
  if LLM::Schema::Leaf === type
61
72
  type
73
+ elsif ::Proc === type
74
+ Deferred.new { resolve(schema, type.call) }
62
75
  elsif ::Array === type
63
76
  resolve_array(schema, type)
64
77
  elsif Class === type && type.respond_to?(:object)
@@ -22,8 +22,10 @@ class LLM::Tool
22
22
  ##
23
23
  # @param name [Symbol]
24
24
  # The name of a parameter
25
- # @param type [LLM::Schema::Leaf, Class]
26
- # The parameter type (eg String)
25
+ # @param type [LLM::Schema::Leaf, Class, Proc]
26
+ # The parameter type (eg String). A proc is called for the type
27
+ # when the parameters are read, so a type that can only be known
28
+ # at runtime does not have to be known when the class is defined.
27
29
  # @param description [String]
28
30
  # The description of a property
29
31
  # @param options [Hash]
@@ -54,7 +54,8 @@ class LLM::Tool
54
54
  # the max number of bytes to emit
55
55
  # @return [Hash]
56
56
  def call(arguments: [], timeout: 60, max_bytes: self.class.max_bytes)
57
- name = arguments[0]
57
+ startat = now
58
+ name = arguments[0]
58
59
  command = spawn(name:, arguments: arguments[1..], env:, max_bytes:)
59
60
  wait(command:, timeout:)
60
61
  if command.not_found?
@@ -62,7 +63,8 @@ class LLM::Tool
62
63
  else
63
64
  {ok: command.success?,
64
65
  stdout: truncate(command.stdout, max_bytes:),
65
- stderr: truncate(command.stderr, max_bytes:)}
66
+ stderr: truncate(command.stderr, max_bytes:),
67
+ duration: "#{(now - startat).round(1)} seconds"}
66
68
  end
67
69
  rescue LLM::Interrupt
68
70
  command.kill! if command&.running?
@@ -72,5 +74,11 @@ class LLM::Tool
72
74
  private
73
75
 
74
76
  attr_reader :env
77
+
78
+ ##
79
+ # @return [Float]
80
+ def now
81
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ end
75
83
  end
76
84
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Tracer
4
+ ##
5
+ # {LLM::Tracer::Registry LLM::Tracer::Registry} counts the scopes that
6
+ # are open for each tracer.
7
+ #
8
+ # The count belongs to the tracer, not to the thread that opened a
9
+ # scope. A tool runs on a thread of its own and opens a scope there for
10
+ # the turn's tracer, and a count kept per thread would let that scope
11
+ # look like the outermost one: it would end the tracer while the turn
12
+ # is still running, and every write after it would fail.
13
+ #
14
+ # @api private
15
+ class Registry
16
+ ##
17
+ # @return [LLM::Tracer::Registry]
18
+ def initialize
19
+ @counts = ObjectSpace::WeakMap.new
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ ##
24
+ # Records that a scope was opened for a tracer.
25
+ # @param [LLM::Tracer] tracer
26
+ # @return [LLM::Tracer::Registry]
27
+ # Returns self, so a caller can tell the scope was recorded
28
+ def enter(tracer)
29
+ @mutex.synchronize do
30
+ @counts[tracer] = count(tracer) + 1
31
+ end
32
+ self
33
+ end
34
+
35
+ ##
36
+ # Records that a scope was closed, and tells the tracer it is
37
+ # finished when it was the last one that was open.
38
+ #
39
+ # The count is brought up to date under the lock, and the tracer is
40
+ # told after the lock is released: a tracer that calls back into the
41
+ # provider from `on_exit` would otherwise deadlock against itself.
42
+ #
43
+ # An `exit` without a matching `enter` is ignored: no scope is open,
44
+ # so there is nothing to end, and the tracer is not told it is
45
+ # finished when no scope was holding it.
46
+ # @param [LLM::Tracer] tracer
47
+ # @return [void]
48
+ def exit(tracer)
49
+ last = @mutex.synchronize do
50
+ open = count(tracer)
51
+ if open.zero?
52
+ false
53
+ elsif open == 1
54
+ forget(tracer)
55
+ true
56
+ else
57
+ @counts[tracer] = open - 1
58
+ false
59
+ end
60
+ end
61
+ tracer.on_exit if last
62
+ end
63
+
64
+ private
65
+
66
+ ##
67
+ # Drops a tracer that has no open scopes left.
68
+ # @param [LLM::Tracer] tracer
69
+ # @return [Boolean]
70
+ def forget(tracer)
71
+ if @counts.respond_to?(:delete)
72
+ @counts.delete(tracer)
73
+ else
74
+ @counts[tracer] = 0
75
+ end
76
+ true
77
+ end
78
+
79
+ ##
80
+ # @param [LLM::Tracer] tracer
81
+ # @return [Integer]
82
+ def count(tracer)
83
+ @counts[tracer] || 0
84
+ end
85
+ end
86
+ end
data/lib/llm/tracer.rb CHANGED
@@ -13,6 +13,39 @@ module LLM
13
13
  require_relative "tracer/telemetry"
14
14
  require_relative "tracer/null"
15
15
  require_relative "tracer/pretty_logger"
16
+ require_relative "tracer/registry"
17
+
18
+ ##
19
+ # Returns the registry that counts the open scopes for each tracer.
20
+ # @api private
21
+ # @return [LLM::Tracer::Registry]
22
+ def self.registry
23
+ LLM.lock(:registry) do
24
+ @registry ||= Registry.new
25
+ end
26
+ end
27
+
28
+ ##
29
+ # Records that a scope was opened for a tracer.
30
+ # @api private
31
+ # @see LLM::Tracer::Registry#enter
32
+ # @param [LLM::Tracer] tracer
33
+ # @return [LLM::Tracer::Registry]
34
+ # Returns the registry, so a caller can tell the scope was recorded
35
+ def self.enter(tracer)
36
+ registry.enter(tracer)
37
+ end
38
+
39
+ ##
40
+ # Records that a scope was closed for a tracer, and tells the tracer
41
+ # it is finished when it was the last one that was open.
42
+ # @api private
43
+ # @see LLM::Tracer::Registry#exit
44
+ # @param [LLM::Tracer] tracer
45
+ # @return [void]
46
+ def self.exit(tracer)
47
+ registry.exit(tracer)
48
+ end
16
49
 
17
50
  ##
18
51
  # Builds a {LLM::Tracer::PrettyLogger} for a provider.
@@ -61,6 +94,29 @@ module LLM
61
94
  @options = {}
62
95
  end
63
96
 
97
+ ##
98
+ # Called when the tracer is no longer in use.
99
+ #
100
+ # A tracer that holds something - a file, a span, a connection pool -
101
+ # releases it here. The default does nothing, because most tracers hold
102
+ # nothing that needs releasing.
103
+ #
104
+ # It is called once, when the last scope that is open for this tracer
105
+ # ends, and that scope can have been opened by another thread: a tool
106
+ # runs on a thread of its own and scopes the turn's tracer while it
107
+ # does. It is called after the scoped lookup is restored, so a tracer
108
+ # that reads {LLM::Provider#tracer} inside it sees the tracer that the
109
+ # next request will see.
110
+ #
111
+ # A tracer can be scoped again afterwards, on a later turn, so it has
112
+ # to stay usable after this call, and it has to tolerate the call
113
+ # happening more than once over its life.
114
+ # @see LLM::Tracer::Registry
115
+ # @see LLM::Provider#with_tracer
116
+ # @return [void]
117
+ def on_exit
118
+ end
119
+
64
120
  ##
65
121
  # Called before an LLM provider request is executed.
66
122
  # @param [String] operation
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "15.4.1"
4
+ VERSION = "15.5.0"
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: 15.4.1
4
+ version: 15.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson
@@ -624,6 +624,7 @@ files:
624
624
  - lib/llm/schema/any_of.rb
625
625
  - lib/llm/schema/array.rb
626
626
  - lib/llm/schema/boolean.rb
627
+ - lib/llm/schema/deferred.rb
627
628
  - lib/llm/schema/enum.rb
628
629
  - lib/llm/schema/integer.rb
629
630
  - lib/llm/schema/leaf.rb
@@ -665,6 +666,7 @@ files:
665
666
  - lib/llm/tracer/logger.rb
666
667
  - lib/llm/tracer/null.rb
667
668
  - lib/llm/tracer/pretty_logger.rb
669
+ - lib/llm/tracer/registry.rb
668
670
  - lib/llm/tracer/telemetry.rb
669
671
  - lib/llm/transformer.rb
670
672
  - lib/llm/transformer/null.rb