llm.rb 16.0.0 → 17.0.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 +4 -4
- data/CHANGELOG.md +510 -2
- data/README.md +857 -501
- data/data/alibaba.json +2 -2
- data/data/anthropic.json +66 -3
- data/data/bedrock.json +781 -12
- data/data/deepinfra.json +157 -1
- data/data/google.json +48 -0
- data/data/mistral.json +326 -16
- data/data/moonshot.json +2 -1
- data/data/openai.json +13 -0
- data/data/openrouter.json +1078 -1235
- data/data/xai.json +4 -2
- data/docs/deepdive/advanced/cancellation.md +35 -7
- data/docs/deepdive/advanced/compaction.md +5 -5
- data/docs/deepdive/advanced/context.md +45 -24
- data/docs/deepdive/advanced/{guard.md → intercept.md} +87 -83
- data/docs/deepdive/features/builtin_tools.md +1 -1
- data/docs/deepdive/features/concurrency.md +15 -10
- data/docs/deepdive/features/database.md +19 -19
- data/docs/deepdive/features/embeddings.md +4 -4
- data/docs/deepdive/features/files.md +7 -5
- data/docs/deepdive/fundamentals/agents.md +13 -8
- data/docs/deepdive/fundamentals/providers.md +1 -1
- data/docs/deepdive/fundamentals/schema.md +2 -2
- data/docs/deepdive/fundamentals/stream.md +8 -8
- data/docs/deepdive/fundamentals/tools.md +14 -14
- data/docs/deepdive/media/ocr.md +1 -1
- data/docs/deepdive/protocols/a2a.md +3 -3
- data/docs/deepdive/protocols/mcp.md +116 -7
- data/docs/deepdive/reference/cost.md +1 -1
- data/docs/deepdive/reference/response.md +1 -1
- data/docs/deepdive/reference/tracer.md +90 -22
- data/docs/deepdive.md +1 -1
- data/lib/llm/agent/interrupt.rb +35 -46
- data/lib/llm/agent/registry.rb +106 -0
- data/lib/llm/agent.rb +244 -269
- data/lib/llm/context.rb +63 -55
- data/lib/llm/function/array.rb +2 -1
- data/lib/llm/function/async/group.rb +2 -2
- data/lib/llm/function/async/task.rb +25 -48
- data/lib/llm/function/fiber/group.rb +2 -2
- data/lib/llm/function/fiber/task.rb +16 -30
- data/lib/llm/function/fork/group.rb +2 -2
- data/lib/llm/function/fork/job.rb +8 -4
- data/lib/llm/function/fork/task.rb +25 -30
- data/lib/llm/function/group.rb +33 -5
- data/lib/llm/function/ractor/group.rb +2 -2
- data/lib/llm/function/ractor/job.rb +8 -6
- data/lib/llm/function/ractor/task.rb +32 -9
- data/lib/llm/function/sequential/group.rb +2 -1
- data/lib/llm/function/sequential/task.rb +8 -1
- data/lib/llm/function/task.rb +52 -4
- data/lib/llm/function/thread/group.rb +2 -2
- data/lib/llm/function/thread/task.rb +36 -10
- data/lib/llm/function/window.rb +16 -0
- data/lib/llm/function.rb +14 -43
- data/lib/llm/intercept/multi.rb +40 -0
- data/lib/llm/intercept/null.rb +18 -0
- data/lib/llm/intercept.rb +96 -0
- data/lib/llm/message.rb +1 -1
- data/lib/llm/provider.rb +16 -1
- data/lib/llm/providers/anthropic/stream_parser.rb +1 -1
- data/lib/llm/providers/bedrock/stream_parser.rb +1 -1
- data/lib/llm/providers/deepseek/files.rb +49 -0
- data/lib/llm/providers/deepseek/request_adapter/completion.rb +15 -13
- data/lib/llm/providers/deepseek.rb +16 -2
- data/lib/llm/providers/google/stream_parser.rb +1 -1
- data/lib/llm/providers/openai/responses/stream_parser.rb +1 -1
- data/lib/llm/providers/openai/stream_parser.rb +1 -1
- data/lib/llm/stream.rb +8 -7
- data/lib/llm/tool.rb +1 -1
- data/lib/llm/tools/utils.rb +1 -1
- data/lib/llm/tracer/logger.rb +42 -1
- data/lib/llm/tracer/multi.rb +144 -0
- data/lib/llm/tracer/null.rb +20 -0
- data/lib/llm/tracer/pretty_logger.rb +33 -2
- data/lib/llm/tracer/rescue.rb +110 -0
- data/lib/llm/tracer/telemetry.rb +69 -4
- data/lib/llm/tracer.rb +125 -48
- data/lib/llm/utils.rb +12 -2
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +36 -1
- data/llm.gemspec +9 -4
- metadata +16 -35
- data/lib/llm/function/tracing.rb +0 -21
- data/lib/llm/guard/null.rb +0 -19
- data/lib/llm/guard.rb +0 -55
|
@@ -35,10 +35,10 @@ module LLM::Function::Ractor
|
|
|
35
35
|
alias_method :cancel!, :interrupt!
|
|
36
36
|
|
|
37
37
|
##
|
|
38
|
+
# @see LLM::Function::Group#wait
|
|
38
39
|
# @return [Array<LLM::Function::Return>]
|
|
39
40
|
def wait
|
|
40
|
-
|
|
41
|
-
@tasks.map(&:wait)
|
|
41
|
+
super
|
|
42
42
|
end
|
|
43
43
|
alias_method :value, :wait
|
|
44
44
|
end
|
|
@@ -14,13 +14,14 @@ class LLM::Function
|
|
|
14
14
|
# @param [String] name
|
|
15
15
|
# @param [Hash, Array, nil] arguments
|
|
16
16
|
# @return [LLM::Function::Ractor::Job]
|
|
17
|
-
def initialize(mailbox, result, runner_class, id, name, arguments)
|
|
17
|
+
def initialize(mailbox, result, runner_class, id, name, arguments, cookie)
|
|
18
18
|
@mailbox = mailbox
|
|
19
19
|
@result = result
|
|
20
20
|
@runner_class = runner_class
|
|
21
21
|
@id = id
|
|
22
22
|
@name = name
|
|
23
23
|
@arguments = arguments
|
|
24
|
+
@cookie = cookie
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
##
|
|
@@ -70,7 +71,8 @@ class LLM::Function
|
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
def spawn
|
|
73
|
-
@tool = ::Ractor.new(@mailbox, @runner_class, @id, @name, @arguments) do |mailbox, runner_class, id, name, arguments|
|
|
74
|
+
@tool = ::Ractor.new(@mailbox, @runner_class, @id, @name, @arguments, @cookie) do |mailbox, runner_class, id, name, arguments, cookie|
|
|
75
|
+
finished_at = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
74
76
|
##
|
|
75
77
|
# Before the watcher exists, because an interrupt can arrive
|
|
76
78
|
# first: it is a message, and it waits in the inbox until the
|
|
@@ -88,7 +90,7 @@ class LLM::Function
|
|
|
88
90
|
runner = runner_class.new
|
|
89
91
|
::Thread.new do
|
|
90
92
|
##
|
|
91
|
-
# Only the receive is
|
|
93
|
+
# Only the receive is rescued: it raises when the ractor this
|
|
92
94
|
# one talks to has gone, and nothing else here should be
|
|
93
95
|
# answerable to that rescue. A hook that raises must not be
|
|
94
96
|
# swallowed by it, and must not take the raise with it either -
|
|
@@ -127,11 +129,11 @@ class LLM::Function
|
|
|
127
129
|
# work is done is a no-op rather than a reason to throw the
|
|
128
130
|
# result away.
|
|
129
131
|
window.finished!
|
|
130
|
-
mailbox.send([:done, id, name, result])
|
|
132
|
+
mailbox.send([:done, id, name, result, finished_at.()])
|
|
131
133
|
rescue LLM::Interrupt
|
|
132
|
-
mailbox.send([:done, id, name, {
|
|
134
|
+
mailbox.send([:done, id, name, {interrupt: true, cookie:}, finished_at.()])
|
|
133
135
|
rescue => ex
|
|
134
|
-
mailbox.send([:done, id, name, {error: true, type: ex.class.name, message: ex.message}])
|
|
136
|
+
mailbox.send([:done, id, name, {error: true, type: ex.class.name, message: ex.message}, finished_at.()])
|
|
135
137
|
end
|
|
136
138
|
end
|
|
137
139
|
end
|
|
@@ -10,6 +10,18 @@ class LLM::Function
|
|
|
10
10
|
# @return [LLM::Function::Ractor::Mailbox]
|
|
11
11
|
attr_reader :mailbox
|
|
12
12
|
|
|
13
|
+
##
|
|
14
|
+
# @return [LLM::Tracer, nil]
|
|
15
|
+
attr_reader :tracer
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @return [String]
|
|
19
|
+
attr_reader :cookie
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @return [Object]
|
|
23
|
+
attr_reader :span
|
|
24
|
+
|
|
13
25
|
##
|
|
14
26
|
# @param [LLM::Function] fn
|
|
15
27
|
# @param [Hash] options
|
|
@@ -27,18 +39,18 @@ class LLM::Function
|
|
|
27
39
|
@name = options.fetch(:name)
|
|
28
40
|
@arguments = options.fetch(:arguments)
|
|
29
41
|
@model = options.fetch(:model, nil)
|
|
30
|
-
@tracer = options.fetch(:tracer, nil)
|
|
31
42
|
@cancelled = false
|
|
43
|
+
@cookie = SecureRandom.hex
|
|
32
44
|
end
|
|
33
45
|
|
|
34
46
|
##
|
|
35
47
|
# @return [LLM::Function::Ractor::Task]
|
|
36
48
|
def spawn
|
|
37
|
-
return if @guarded
|
|
38
49
|
@span = @tracer&.on_tool_start(
|
|
39
50
|
id: @id, name: @name,
|
|
40
51
|
arguments: @arguments, model: @model
|
|
41
52
|
)
|
|
53
|
+
return if intercepted?
|
|
42
54
|
##
|
|
43
55
|
# The result is delivered to a ractor of this task's own rather
|
|
44
56
|
# than through its mailbox. The mailbox ractor answers `alive?`
|
|
@@ -101,13 +113,15 @@ class LLM::Function
|
|
|
101
113
|
# {LLM::Function::Thread::Task#wait} answers from the thread's value.
|
|
102
114
|
# @return [LLM::Function::Return]
|
|
103
115
|
def wait
|
|
104
|
-
return @
|
|
116
|
+
return @intercepted if intercepted?
|
|
105
117
|
@result ||= begin
|
|
106
118
|
spawn unless @mailbox
|
|
107
|
-
id, name, value = mailbox.wait
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
id, name, value, @finished_at = mailbox.wait
|
|
120
|
+
if Hash === value and value[:interrupt] and value[:cookie] == @cookie
|
|
121
|
+
LLM::Interrupt.new
|
|
122
|
+
else
|
|
123
|
+
Return.new(id, name, value)
|
|
124
|
+
end
|
|
111
125
|
end
|
|
112
126
|
end
|
|
113
127
|
alias_method :value, :wait
|
|
@@ -121,8 +135,17 @@ class LLM::Function
|
|
|
121
135
|
private
|
|
122
136
|
|
|
123
137
|
def build_task(result)
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
::Ractor.new(result, @runner_class, @id, @name, @arguments, @cookie) do |result, runner_class, id, name, arguments, cookie|
|
|
140
|
+
LLM::Function::Ractor::Job.new(
|
|
141
|
+
::Ractor.current,
|
|
142
|
+
result,
|
|
143
|
+
runner_class,
|
|
144
|
+
id,
|
|
145
|
+
name,
|
|
146
|
+
arguments,
|
|
147
|
+
cookie
|
|
148
|
+
).call
|
|
126
149
|
end
|
|
127
150
|
end
|
|
128
151
|
end
|
|
@@ -51,10 +51,11 @@ module LLM::Function::Sequential
|
|
|
51
51
|
alias_method :cancel!, :interrupt!
|
|
52
52
|
|
|
53
53
|
##
|
|
54
|
+
# @see LLM::Function::Group#wait
|
|
54
55
|
# @return [Array<LLM::Function::Return>]
|
|
55
56
|
def wait
|
|
56
57
|
@owner = Thread.current
|
|
57
|
-
|
|
58
|
+
super
|
|
58
59
|
ensure
|
|
59
60
|
@owner = nil
|
|
60
61
|
end
|
|
@@ -29,7 +29,14 @@ module LLM::Function::Sequential
|
|
|
29
29
|
##
|
|
30
30
|
# @return [LLM::Function::Return]
|
|
31
31
|
def wait
|
|
32
|
-
|
|
32
|
+
return @intercepted if intercepted?
|
|
33
|
+
@result = @result ? @result : begin
|
|
34
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
@result = LLM::Object.from(unbox: function.call)
|
|
36
|
+
@finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
@result
|
|
38
|
+
end
|
|
39
|
+
@result&.unbox
|
|
33
40
|
end
|
|
34
41
|
alias_method :value, :wait
|
|
35
42
|
|
data/lib/llm/function/task.rb
CHANGED
|
@@ -11,18 +11,48 @@ class LLM::Function
|
|
|
11
11
|
# @return [LLM::Function]
|
|
12
12
|
attr_reader :function
|
|
13
13
|
|
|
14
|
+
##
|
|
15
|
+
# The tracer for the task
|
|
16
|
+
# @return [LLM::Tracer, nil]
|
|
17
|
+
attr_reader :tracer
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# The span returned by {LLM::Tracer#on_tool_start}
|
|
21
|
+
# @return [Object]
|
|
22
|
+
attr_reader :span
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Returns when a task started, or nil
|
|
26
|
+
# @note
|
|
27
|
+
# This method captures a monotonic clock
|
|
28
|
+
# time (Process::CLOCK_MONOTONIC) rather
|
|
29
|
+
# than a Time object.
|
|
30
|
+
# @return [Float, nil]
|
|
31
|
+
attr_reader :started_at
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# Returns when a task finished, or nil
|
|
35
|
+
# @note
|
|
36
|
+
# This method captures a monotonic clock
|
|
37
|
+
# time (Process::CLOCK_MONOTONIC) rather
|
|
38
|
+
# than a Time object.
|
|
39
|
+
# @return [Float, nil]
|
|
40
|
+
attr_reader :finished_at
|
|
41
|
+
|
|
14
42
|
##
|
|
15
43
|
# @param [LLM::Function] fn
|
|
16
44
|
# @param [Hash] options
|
|
17
45
|
# An optional set of options that are specific
|
|
18
46
|
# to a given concurrency strategy.
|
|
19
|
-
# @option options [LLM::Function::Return, nil] :
|
|
20
|
-
# A blocked return produced by the function's
|
|
47
|
+
# @option options [LLM::Function::Return, nil] :intercepted
|
|
48
|
+
# A blocked return produced by the function's intercept, or the in-band
|
|
21
49
|
# error for a tool that cannot be built. When set, the task yields it
|
|
22
50
|
# without running the tool.
|
|
23
51
|
def initialize(fn, options = {})
|
|
24
52
|
@function = fn
|
|
25
|
-
@
|
|
53
|
+
@intercepted = options[:intercepted]
|
|
54
|
+
@tracer = options[:tracer]
|
|
55
|
+
@finished_at = nil
|
|
26
56
|
end
|
|
27
57
|
|
|
28
58
|
##
|
|
@@ -48,8 +78,18 @@ class LLM::Function
|
|
|
48
78
|
alias_method :cancel!, :interrupt!
|
|
49
79
|
|
|
50
80
|
##
|
|
81
|
+
# This method returns an instance of
|
|
82
|
+
# {LLM::Function::Return} or {LLM::Interrupt}
|
|
83
|
+
# when the task has received an interrupt.
|
|
84
|
+
#
|
|
85
|
+
# An exception that occurs inside a tool is
|
|
86
|
+
# rescued, and returned by this method as a
|
|
87
|
+
# {LLM::Function::Return} object. It can be
|
|
88
|
+
# distinguished from other returns through
|
|
89
|
+
# the {LLM::Function::Return#error?} method.
|
|
90
|
+
#
|
|
51
91
|
# @abstract
|
|
52
|
-
# @return [LLM::Function::Return]
|
|
92
|
+
# @return [LLM::Function::Return, LLM::Interrupt]
|
|
53
93
|
def wait
|
|
54
94
|
raise NotImplementedError
|
|
55
95
|
end
|
|
@@ -61,5 +101,13 @@ class LLM::Function
|
|
|
61
101
|
def group_class
|
|
62
102
|
raise NotImplementedError
|
|
63
103
|
end
|
|
104
|
+
|
|
105
|
+
##
|
|
106
|
+
# Returns true when an intercept has
|
|
107
|
+
# returned {LLM::Function::Return}.
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def intercepted?
|
|
110
|
+
LLM::Function::Return === @intercepted
|
|
111
|
+
end
|
|
64
112
|
end
|
|
65
113
|
end
|
|
@@ -36,10 +36,10 @@ module LLM::Function::Thread
|
|
|
36
36
|
alias_method :cancel!, :interrupt!
|
|
37
37
|
|
|
38
38
|
##
|
|
39
|
+
# @see LLM::Function::Group#wait
|
|
39
40
|
# @return [Array<LLM::Function::Return>]
|
|
40
41
|
def wait
|
|
41
|
-
|
|
42
|
-
@tasks.map(&:wait)
|
|
42
|
+
super
|
|
43
43
|
end
|
|
44
44
|
alias_method :value, :wait
|
|
45
45
|
end
|
|
@@ -31,7 +31,13 @@ module LLM::Function::Thread
|
|
|
31
31
|
##
|
|
32
32
|
# @return [nil]
|
|
33
33
|
def spawn
|
|
34
|
-
|
|
34
|
+
@span = @tracer&.on_tool_start(
|
|
35
|
+
id: @function.id,
|
|
36
|
+
name: @function.name,
|
|
37
|
+
arguments: @function.arguments,
|
|
38
|
+
model: @function.model
|
|
39
|
+
)
|
|
40
|
+
return if intercepted?
|
|
35
41
|
@ready = Queue.new
|
|
36
42
|
@thread = ::Thread.new do
|
|
37
43
|
##
|
|
@@ -58,8 +64,10 @@ module LLM::Function::Thread
|
|
|
58
64
|
# instead, and opens on the call.
|
|
59
65
|
::Thread.new { @window.interrupt! } if @cancelled
|
|
60
66
|
@window.running!
|
|
67
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
68
|
function.call
|
|
62
69
|
ensure
|
|
70
|
+
@finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
71
|
##
|
|
64
72
|
# The window closes before the hook runs, so a cancel that arrives
|
|
65
73
|
# once the call has returned finds nothing to interrupt - which is
|
|
@@ -70,13 +78,25 @@ module LLM::Function::Thread
|
|
|
70
78
|
# cancelled, because a tool's state belongs to the thread its call
|
|
71
79
|
# runs on, and the caller's thread is not that thread.
|
|
72
80
|
#
|
|
73
|
-
# It runs
|
|
74
|
-
#
|
|
75
|
-
# raise is issued, so it is the frame that knows,
|
|
76
|
-
# `Window#interrupted?` is true
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
81
|
+
# It runs where an ask was taken up for this call, and not for a
|
|
82
|
+
# cancel the window never asked about: the window is the frame that
|
|
83
|
+
# decides whether a raise is issued, so it is the frame that knows,
|
|
84
|
+
# and `Window#interrupted?` is true where one was issued.
|
|
85
|
+
#
|
|
86
|
+
# **Issued is not delivered, and this strategy is the one where the
|
|
87
|
+
# two come apart least.** The raise here is a `Thread#raise`, and a
|
|
88
|
+
# thread can be interrupted where it stands, so a cancel that the
|
|
89
|
+
# window asks about lands on the call. `:fiber` and `:async` ask a
|
|
90
|
+
# scheduler instead, which places the raise at the tool's next
|
|
91
|
+
# suspension - so there a tool that never suspends is asked about
|
|
92
|
+
# and never reached, and the hook runs for it all the same.
|
|
93
|
+
#
|
|
94
|
+
# That is the side to be on, and a tool whose own rescue answers the
|
|
95
|
+
# interrupt rather than raising is why: it was interrupted, and its
|
|
96
|
+
# hook is the only thing left that can say so on its own thread.
|
|
97
|
+
# What does not tell the tool is a cancel the window never asked
|
|
98
|
+
# about - one that arrives once the call has returned - which is the
|
|
99
|
+
# no-op {LLM::Function::Return#interrupt!} already says one is.
|
|
80
100
|
#
|
|
81
101
|
# On the job's own thread the hook can only run after the call's
|
|
82
102
|
# frame has ended - you cannot run code on a thread blocked inside
|
|
@@ -127,9 +147,15 @@ module LLM::Function::Thread
|
|
|
127
147
|
##
|
|
128
148
|
# @return [LLM::Function::Return]
|
|
129
149
|
def wait
|
|
130
|
-
return @
|
|
150
|
+
return @intercepted if intercepted?
|
|
151
|
+
return @result.unbox if @result
|
|
131
152
|
spawn unless @thread
|
|
132
|
-
|
|
153
|
+
begin
|
|
154
|
+
@result = LLM::Object.from(unbox: @thread.value)
|
|
155
|
+
rescue LLM::Interrupt => ex
|
|
156
|
+
@result = LLM::Object.from(unbox: ex)
|
|
157
|
+
end
|
|
158
|
+
@result.unbox
|
|
133
159
|
end
|
|
134
160
|
alias_method :value, :wait
|
|
135
161
|
|
data/lib/llm/function/window.rb
CHANGED
|
@@ -23,6 +23,22 @@ class LLM::Function
|
|
|
23
23
|
# are one call here, and everything else - the three states, and what
|
|
24
24
|
# "this call was interrupted" means - is this object's, so the strategies
|
|
25
25
|
# agree about where a call begins and ends.
|
|
26
|
+
#
|
|
27
|
+
# **A raise a call cannot take is not delivered outside the call.** The
|
|
28
|
+
# scheduler places a scheduled raise, and a tool that never suspends
|
|
29
|
+
# gives it no suspension inside the call to be placed at - so one is left
|
|
30
|
+
# over. What is asserted is the half anything outside the call could
|
|
31
|
+
# notice, and it is the caller's frame rather than the tool's: the frame
|
|
32
|
+
# that waits on the task ends cleanly, and a sibling task on the same
|
|
33
|
+
# reactor runs.
|
|
34
|
+
#
|
|
35
|
+
# **Where the leftover goes is reasoned, not observed.** The raise is
|
|
36
|
+
# aimed at the fibre the tool runs in, and the frame those examples watch
|
|
37
|
+
# is a different fibre - a raise in one is not the other's exception, so
|
|
38
|
+
# they cannot see it either way. That it is discarded rather than
|
|
39
|
+
# delivered follows from what the scheduler can place: a raise scheduled
|
|
40
|
+
# for a fibre is placed when that fibre resumes, and a fibre whose tool
|
|
41
|
+
# never suspends does not resume before it ends.
|
|
26
42
|
class Window
|
|
27
43
|
##
|
|
28
44
|
# @param [Thread, nil] thread
|
data/lib/llm/function.rb
CHANGED
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
# end
|
|
32
32
|
class LLM::Function
|
|
33
33
|
require_relative "function/registry"
|
|
34
|
-
require_relative "function/tracing"
|
|
35
34
|
require_relative "function/array"
|
|
36
35
|
require_relative "function/group"
|
|
37
36
|
require_relative "function/sequential/group"
|
|
@@ -51,7 +50,6 @@ class LLM::Function
|
|
|
51
50
|
require_relative "function/ractor/group"
|
|
52
51
|
|
|
53
52
|
extend LLM::Function::Registry
|
|
54
|
-
prepend LLM::Function::Tracing
|
|
55
53
|
|
|
56
54
|
##
|
|
57
55
|
# Returns strategies that execute in an isolated environment.
|
|
@@ -183,11 +181,11 @@ class LLM::Function
|
|
|
183
181
|
attr_accessor :model
|
|
184
182
|
|
|
185
183
|
##
|
|
186
|
-
# Returns the
|
|
187
|
-
# The context stamps the
|
|
188
|
-
# built from this function checks it before the tool runs.
|
|
189
|
-
# @return [Class<LLM::
|
|
190
|
-
attr_accessor :
|
|
184
|
+
# Returns the intercept class that supervises this function, or nil.
|
|
185
|
+
# The context stamps the intercept onto the functions it binds, so any
|
|
186
|
+
# task built from this function checks it before the tool runs.
|
|
187
|
+
# @return [Class<LLM::Intercept>, nil]
|
|
188
|
+
attr_accessor :intercept
|
|
191
189
|
|
|
192
190
|
##
|
|
193
191
|
# @param [String] name The function name
|
|
@@ -278,49 +276,22 @@ class LLM::Function
|
|
|
278
276
|
# task = tool.task(:thread)
|
|
279
277
|
# result = task.value
|
|
280
278
|
#
|
|
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.
|
|
288
279
|
# @param [Symbol] strategy
|
|
289
280
|
# Controls concurrency strategy:
|
|
290
281
|
# - `:sequential`: Call the function sequentially
|
|
291
282
|
# - `:thread`: Use threads
|
|
292
283
|
# - `:async`: Use async tasks (requires async gem)
|
|
293
|
-
# - `:fork`: Use a forked child process (requires xchan.rb
|
|
284
|
+
# - `:fork`: Use a forked child process (requires xchan.rb gem)
|
|
294
285
|
# - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
|
|
295
|
-
# - `:ractor`: Use Ruby ractors
|
|
286
|
+
# - `:ractor`: Use Ruby ractors
|
|
296
287
|
#
|
|
297
288
|
# @return [LLM::Function::Task]
|
|
298
289
|
# Returns a task whose `#value` is an {LLM::Function::Return}.
|
|
299
290
|
def task(strategy, options = {})
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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))
|
|
291
|
+
ioptions = @intercept&.options
|
|
292
|
+
options = options.merge(intercepted: @intercept&.call(function: self, **ioptions))
|
|
293
|
+
options = options.merge(tracer:, intercepted: options[:intercepted] || resolve(strategy))
|
|
294
|
+
|
|
324
295
|
case strategy
|
|
325
296
|
when :sequential
|
|
326
297
|
Sequential::Task.new(self, options)
|
|
@@ -333,13 +304,13 @@ class LLM::Function
|
|
|
333
304
|
Fiber::Task.new(self, options)
|
|
334
305
|
when :fork
|
|
335
306
|
LLM.require "xchan", "~> 0.24" unless defined?(::Chan::UNIXSocket)
|
|
336
|
-
Fork::Task.new(self, options
|
|
307
|
+
Fork::Task.new(self, options)
|
|
337
308
|
when :ractor
|
|
338
309
|
raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
|
|
339
310
|
if @runner.respond_to?(:skill?) && @runner.skill?
|
|
340
311
|
raise LLM::RactorError, "Ractor concurrency does not support skill-backed tools"
|
|
341
312
|
end
|
|
342
|
-
Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:,
|
|
313
|
+
Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:, model:))
|
|
343
314
|
else
|
|
344
315
|
raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :sequential, :thread, :fiber, :async, :fork, or :ractor"
|
|
345
316
|
end
|
|
@@ -507,7 +478,7 @@ class LLM::Function
|
|
|
507
478
|
#
|
|
508
479
|
# {#call_function} resolves inside its own rescue, and that rescue is what
|
|
509
480
|
# answers the model with an error rather than raising into the turn.
|
|
510
|
-
# Resolving earlier keeps that promise by producing the return the
|
|
481
|
+
# Resolving earlier keeps that promise by producing the return the intercept
|
|
511
482
|
# would have produced.
|
|
512
483
|
#
|
|
513
484
|
# An interrupt is re-raised rather than answered, which is what
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Intercept
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Intercept::Multi LLM::Intercept::Multi}
|
|
6
|
+
# class combines multiple intercepts into a single
|
|
7
|
+
# intercept.
|
|
8
|
+
#
|
|
9
|
+
# The first intercept that returns an instance of
|
|
10
|
+
# {LLM::Function::Return LLM::Function::Return}
|
|
11
|
+
# stops all other intercepts from running, and when
|
|
12
|
+
# an intercept returns `nil` the next one in the list
|
|
13
|
+
# is called. When all intercepts return nil, the tool
|
|
14
|
+
# is called.
|
|
15
|
+
#
|
|
16
|
+
# @see LLM::Intercept
|
|
17
|
+
class Multi < self
|
|
18
|
+
##
|
|
19
|
+
# @param [LLM::Provider] llm
|
|
20
|
+
# An {LLM::Provider LLM::Provider}.
|
|
21
|
+
# @param [Array<LLM::Intercept>] intercepts
|
|
22
|
+
# One or more subclasses of {LLM::Intercept LLM::Intercept}.
|
|
23
|
+
def initialize(llm, *intercepts)
|
|
24
|
+
@llm = llm
|
|
25
|
+
@intercepts = intercepts
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param (see LLM::Intercept#call)
|
|
30
|
+
# @return (see LLM::Intercept#call)
|
|
31
|
+
def call(function:, **opts)
|
|
32
|
+
@intercepts.each do |intercept|
|
|
33
|
+
intercept = intercept.new(llm).tap { _1.instance_variable_set(:@context, context) }
|
|
34
|
+
result = intercept.call(function:, **opts)
|
|
35
|
+
return result if LLM::Function::Return === result
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Intercept
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Intercept::Null LLM::Intercept::Null} class
|
|
6
|
+
# is the default intercept, and it does nothing but allow
|
|
7
|
+
# the tool call through.
|
|
8
|
+
#
|
|
9
|
+
# @see LLM::Intercept
|
|
10
|
+
class Null < self
|
|
11
|
+
##
|
|
12
|
+
# @param (see LLM::Intercept#call)
|
|
13
|
+
# @return (see LLM::Intercept#call)
|
|
14
|
+
def call(function:, **opts)
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
##
|
|
5
|
+
# When a model requests a tool call the
|
|
6
|
+
# runtime represents it as an {LLM::Function}
|
|
7
|
+
# that an intercept can see before it is
|
|
8
|
+
# called. An intercept can return its own
|
|
9
|
+
# {LLM::Function::Return} object that acts
|
|
10
|
+
# as the response to the tool call, or it
|
|
11
|
+
# can return `nil` to let the tool call
|
|
12
|
+
# execute as normal.
|
|
13
|
+
#
|
|
14
|
+
# An intercept can also see the parameters
|
|
15
|
+
# a model has requested to send to a tool,
|
|
16
|
+
# and it can modify them in-place before the
|
|
17
|
+
# tool is called.
|
|
18
|
+
#
|
|
19
|
+
# The default intercept is
|
|
20
|
+
# {LLM::Intercept::Null LLM::Intercept::Null}
|
|
21
|
+
# and it is a noop.
|
|
22
|
+
class Intercept
|
|
23
|
+
require_relative "intercept/null"
|
|
24
|
+
require_relative "intercept/multi"
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @param [LLM::Provider] llm
|
|
28
|
+
# An {LLM::Provider LLM::Provider}.
|
|
29
|
+
# @param [Array<LLM::Intercept>] intercepts
|
|
30
|
+
# One or more subclasses of {LLM::Intercept LLM::Intercept}
|
|
31
|
+
# @return [LLM::Intercept::Multi]
|
|
32
|
+
def self.multi(llm, *intercepts)
|
|
33
|
+
Multi.new(
|
|
34
|
+
llm,
|
|
35
|
+
*intercepts
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @return [LLM::Provider]
|
|
41
|
+
attr_reader :llm
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param [LLM::Provider] llm
|
|
45
|
+
# @return [LLM::Intercept]
|
|
46
|
+
def initialize(llm)
|
|
47
|
+
@llm = llm
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @abstract
|
|
52
|
+
# @param [LLM::Function] function
|
|
53
|
+
# A pending function
|
|
54
|
+
# @param opts [Hash]
|
|
55
|
+
# The contents of `intercept_options:`
|
|
56
|
+
# @return [LLM::Function::Return, Object]
|
|
57
|
+
# When this method returns a
|
|
58
|
+
# {LLM::Function::Return LLM::Function::Return},
|
|
59
|
+
# object it stops a tool call
|
|
60
|
+
# in its tracks and the Return
|
|
61
|
+
# object is the answer we give
|
|
62
|
+
# to the model for the tool call
|
|
63
|
+
# that was stopped. Any other return
|
|
64
|
+
# value lets the tool call through.
|
|
65
|
+
def call(function:, **opts)
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Returns the options the context passed to `intercept_options:`.
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def options
|
|
73
|
+
@options || {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# @return [LLM::Context]
|
|
80
|
+
def context
|
|
81
|
+
@context
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# @return [LLM::Stream]
|
|
86
|
+
def stream
|
|
87
|
+
context.params[:stream]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# @return [LLM::Buffer]
|
|
92
|
+
def messages
|
|
93
|
+
context.messages
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/llm/message.rb
CHANGED