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
data/lib/llm/context.rb
CHANGED
|
@@ -39,15 +39,6 @@ module LLM
|
|
|
39
39
|
include Serializer
|
|
40
40
|
include Deserializer
|
|
41
41
|
|
|
42
|
-
TRY_ERRORS = [
|
|
43
|
-
"LLM::InsufficientQuotaError",
|
|
44
|
-
"LLM::RateLimitError",
|
|
45
|
-
"Net::ReadTimeout",
|
|
46
|
-
"Net::WriteTimeout",
|
|
47
|
-
"Net::OpenTimeout"
|
|
48
|
-
]
|
|
49
|
-
private_constant :TRY_ERRORS
|
|
50
|
-
|
|
51
42
|
##
|
|
52
43
|
# Returns the set of runtime parameters that
|
|
53
44
|
# configure this context and must never be forwarded
|
|
@@ -55,7 +46,7 @@ module LLM
|
|
|
55
46
|
# @api private
|
|
56
47
|
# @return [Array<Symbol>]
|
|
57
48
|
def self.params
|
|
58
|
-
%w[
|
|
49
|
+
%w[intercept retry_budget concurrency transformer compactor record id]
|
|
59
50
|
end
|
|
60
51
|
|
|
61
52
|
##
|
|
@@ -80,7 +71,9 @@ module LLM
|
|
|
80
71
|
|
|
81
72
|
##
|
|
82
73
|
# Returns a stable id for this context. It is generated once
|
|
83
|
-
# on creation and restored with the runtime state on load
|
|
74
|
+
# on creation and restored with the runtime state on load - a
|
|
75
|
+
# context bound to a record takes the record's id instead, when
|
|
76
|
+
# that id is a UUIDv7 string - and it is always a UUIDv7 string.
|
|
84
77
|
# @return [String]
|
|
85
78
|
attr_reader :id
|
|
86
79
|
|
|
@@ -96,7 +89,10 @@ module LLM
|
|
|
96
89
|
# to `:completions`.
|
|
97
90
|
# @option params [String] :model Defaults to the provider's default model
|
|
98
91
|
# @option params [String] :id
|
|
99
|
-
# A stable id for the context.
|
|
92
|
+
# A stable id for the context. It must be a UUIDv7 string, and
|
|
93
|
+
# anything else raises {LLM::Error}. Defaults to the record's id
|
|
94
|
+
# when the context is bound to a record whose id is a UUIDv7, and
|
|
95
|
+
# to a generated UUIDv7 otherwise.
|
|
100
96
|
# @option params [Class<LLM::Compactor>, nil] :compactor
|
|
101
97
|
# A compactor class to use for context compaction. Defaults to
|
|
102
98
|
# {LLM::Compactor::Null}.
|
|
@@ -107,18 +103,18 @@ module LLM
|
|
|
107
103
|
# {LLM::Transformer::Null}.
|
|
108
104
|
# @option params [Hash] :transformer_options
|
|
109
105
|
# Options passed to the transformer's `call` method. Defaults to `{}`.
|
|
110
|
-
# @option params [Class<LLM::
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
# Options passed to the guard's `call` method. Defaults to `{}`.
|
|
106
|
+
# @option params [Class<LLM::Intercept>, nil] :intercept
|
|
107
|
+
# Defaults to {LLM::Intercept::Null LLM::Intercept::Null}.
|
|
108
|
+
# @option params [Hash] :intercept_options
|
|
109
|
+
# Options passed to the intercept's `call` method. Defaults to `{}`.
|
|
115
110
|
# @option params [Array<LLM::Function>, nil] :tools Defaults to nil
|
|
116
111
|
# @option params [Array<String>, nil] :skills Defaults to nil
|
|
117
112
|
def initialize(llm, params = {})
|
|
118
113
|
params = {}.merge!(params)
|
|
119
114
|
@llm = llm
|
|
120
|
-
@id = params.delete(:id) || SecureRandom.uuid_v7
|
|
121
115
|
@record = params.delete(:record)
|
|
116
|
+
@id = params.delete(:id) || makeid
|
|
117
|
+
raise LLM::Error, "an id must be a UUIDv7 string" unless LLM::Utils.uuidv7?(@id)
|
|
122
118
|
@mode = params.delete(:mode) || (llm.name == :openai ? :responses : :completions)
|
|
123
119
|
tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
|
|
124
120
|
@params = {model: llm.default_model, schema: nil}.compact.merge!(params)
|
|
@@ -135,9 +131,9 @@ module LLM
|
|
|
135
131
|
klass: params.delete(:transformer) || LLM::Transformer::Null,
|
|
136
132
|
options: params.delete(:transformer_options) || {}
|
|
137
133
|
}
|
|
138
|
-
@
|
|
139
|
-
klass: params.delete(:
|
|
140
|
-
options: params.delete(:
|
|
134
|
+
@intercept = {
|
|
135
|
+
klass: params.delete(:intercept) || LLM::Intercept::Null,
|
|
136
|
+
options: params.delete(:intercept_options) || {}
|
|
141
137
|
}
|
|
142
138
|
@retry_budget = params.delete(:retry_budget) || 0
|
|
143
139
|
end
|
|
@@ -172,20 +168,15 @@ module LLM
|
|
|
172
168
|
alias_method :compacted?, :compacted
|
|
173
169
|
|
|
174
170
|
##
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# `guard_error` return without executing.
|
|
185
|
-
#
|
|
186
|
-
# @return [Class<LLM::Guard>]
|
|
187
|
-
def guard
|
|
188
|
-
@guard[:klass]
|
|
171
|
+
# @see LLM::Intercept
|
|
172
|
+
# @return [LLM::Intercept]
|
|
173
|
+
def intercept
|
|
174
|
+
klass = @intercept[:klass]
|
|
175
|
+
intercept = Class === klass ? klass.new(llm) : klass
|
|
176
|
+
intercept.tap do
|
|
177
|
+
it.instance_variable_set(:@context, self)
|
|
178
|
+
it.instance_variable_set(:@options, @intercept[:options])
|
|
179
|
+
end
|
|
189
180
|
end
|
|
190
181
|
|
|
191
182
|
##
|
|
@@ -230,8 +221,8 @@ module LLM
|
|
|
230
221
|
|
|
231
222
|
##
|
|
232
223
|
# Ask a question and return the content string directly.
|
|
233
|
-
# Accepts `with:` for file attachments and a block for streaming
|
|
234
|
-
#
|
|
224
|
+
# Accepts `with:` for file attachments and a block for streaming,
|
|
225
|
+
# the way {#talk} does.
|
|
235
226
|
#
|
|
236
227
|
# @param [String] prompt
|
|
237
228
|
# @param [Hash] options
|
|
@@ -268,15 +259,14 @@ module LLM
|
|
|
268
259
|
# @return [Array<LLM::Function>]
|
|
269
260
|
def pending_functions
|
|
270
261
|
return_ids = returns.map(&:id)
|
|
271
|
-
guard = @guard[:klass].new(self)
|
|
272
262
|
@messages
|
|
273
263
|
.select(&:assistant?)
|
|
274
264
|
.flat_map do |msg|
|
|
275
|
-
fns = msg.functions.select { _1.pending?
|
|
265
|
+
fns = msg.functions.select { _1.pending? and !return_ids.include?(_1.id) }
|
|
276
266
|
fns.each do |fn|
|
|
277
267
|
fn.tracer = tracer
|
|
278
268
|
fn.model = msg.model
|
|
279
|
-
fn.
|
|
269
|
+
fn.intercept = intercept
|
|
280
270
|
end
|
|
281
271
|
end.extend(LLM::Function::Array)
|
|
282
272
|
end
|
|
@@ -333,8 +323,8 @@ module LLM
|
|
|
333
323
|
if stream.queue.empty?
|
|
334
324
|
##
|
|
335
325
|
# Every pending function is spawned as a task that checks its own
|
|
336
|
-
#
|
|
337
|
-
# yield their
|
|
326
|
+
# intercept (stamped on the function) before running. Blocked tasks
|
|
327
|
+
# yield their intercept's return, so all pending calls still close.
|
|
338
328
|
tools = except.empty? ? pending_functions : pending_functions - except
|
|
339
329
|
@queue = tools.task(strategy)
|
|
340
330
|
returns = @queue.wait
|
|
@@ -597,6 +587,29 @@ module LLM
|
|
|
597
587
|
[@queue, stream.queue].compact.first
|
|
598
588
|
end
|
|
599
589
|
|
|
590
|
+
##
|
|
591
|
+
# The id a context is born with.
|
|
592
|
+
#
|
|
593
|
+
# The record's id when the context is bound to a record and that id
|
|
594
|
+
# is a UUIDv7, so that an agent and the row it came from answer the
|
|
595
|
+
# same name - and a generated UUIDv7 otherwise, which is every
|
|
596
|
+
# context with no record, every record whose id is an integer or a
|
|
597
|
+
# slug, and every row that is not saved yet.
|
|
598
|
+
#
|
|
599
|
+
# The check is {LLM::Utils.uuidv7?}, which is what
|
|
600
|
+
# {LLM::Utils.timestamp} reads a time out of as well - rather than a
|
|
601
|
+
# second opinion about what a UUIDv7 looks like, an id this accepts
|
|
602
|
+
# is an id `created_at` can answer for.
|
|
603
|
+
# @api private
|
|
604
|
+
# @return [Object]
|
|
605
|
+
def makeid
|
|
606
|
+
if record
|
|
607
|
+
LLM::Utils.uuidv7?(record.id) ? record.id : SecureRandom.uuid_v7
|
|
608
|
+
else
|
|
609
|
+
SecureRandom.uuid_v7
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
600
613
|
##
|
|
601
614
|
# Loads skill directories and adapts them into tools.
|
|
602
615
|
# @api private
|
|
@@ -641,17 +654,12 @@ module LLM
|
|
|
641
654
|
def try
|
|
642
655
|
attempts ||= 0
|
|
643
656
|
yield
|
|
644
|
-
rescue => ex
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
sleep 2.0 * attempts
|
|
651
|
-
retry
|
|
652
|
-
else
|
|
653
|
-
raise(ex)
|
|
654
|
-
end
|
|
657
|
+
rescue *llm.retry_errors => ex
|
|
658
|
+
raise if attempts >= retry_budget
|
|
659
|
+
attempts += 1
|
|
660
|
+
stream.on_retry(ex, attempts)
|
|
661
|
+
sleep 2.0 * attempts
|
|
662
|
+
retry
|
|
655
663
|
end
|
|
656
664
|
|
|
657
665
|
# Executes a turn through the Responses API.
|
|
@@ -660,7 +668,7 @@ module LLM
|
|
|
660
668
|
@llm.with(**headers) do
|
|
661
669
|
history = @messages.to_a
|
|
662
670
|
params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
|
|
663
|
-
extra = params.slice(:model, :tools).merge!(ctx: self, tracer:,
|
|
671
|
+
extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, intercept:)
|
|
664
672
|
params[:stream] = LLM::Stream.try(params[:stream], extra:)
|
|
665
673
|
res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
|
|
666
674
|
input = res_id ? [] : history
|
|
@@ -681,7 +689,7 @@ module LLM
|
|
|
681
689
|
history = @messages.to_a
|
|
682
690
|
params = params.merge(messages: history)
|
|
683
691
|
params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
|
|
684
|
-
extra = params.slice(:model, :tools).merge!(ctx: self, tracer:,
|
|
692
|
+
extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, intercept:)
|
|
685
693
|
params[:stream] = LLM::Stream.try(params[:stream], extra:)
|
|
686
694
|
messages = transform(prompt, params)
|
|
687
695
|
@stream = params[:stream]
|
data/lib/llm/function/array.rb
CHANGED
|
@@ -10,10 +10,11 @@ class LLM::Function
|
|
|
10
10
|
module Array
|
|
11
11
|
##
|
|
12
12
|
# Calls all functions in a collection sequentially.
|
|
13
|
+
#
|
|
13
14
|
# @return [Array<LLM::Function::Return>]
|
|
14
15
|
# Returns values to be reported back to the LLM.
|
|
15
16
|
def call
|
|
16
|
-
|
|
17
|
+
task(:sequential).wait
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
##
|
|
@@ -42,10 +42,10 @@ module LLM::Function::Async
|
|
|
42
42
|
alias_method :cancel!, :interrupt!
|
|
43
43
|
|
|
44
44
|
##
|
|
45
|
+
# @see LLM::Function::Group#wait
|
|
45
46
|
# @return [Array<LLM::Function::Return>]
|
|
46
47
|
def wait
|
|
47
|
-
|
|
48
|
-
@tasks.map(&:wait)
|
|
48
|
+
super
|
|
49
49
|
ensure
|
|
50
50
|
@reactor.stop
|
|
51
51
|
end
|
|
@@ -68,7 +68,13 @@ module LLM::Function::Async
|
|
|
68
68
|
# `StandardError`.
|
|
69
69
|
# @return [nil]
|
|
70
70
|
def spawn
|
|
71
|
-
|
|
71
|
+
@span = @tracer&.on_tool_start(
|
|
72
|
+
id: @function.id,
|
|
73
|
+
name: @function.name,
|
|
74
|
+
arguments: @function.arguments,
|
|
75
|
+
model: @function.model
|
|
76
|
+
)
|
|
77
|
+
return if intercepted?
|
|
72
78
|
@queue = Queue.new
|
|
73
79
|
@alive = !@cancelled
|
|
74
80
|
@reactor.submit do
|
|
@@ -84,21 +90,20 @@ module LLM::Function::Async
|
|
|
84
90
|
# tool.
|
|
85
91
|
@window.interrupt!(wait: false) if @cancelled
|
|
86
92
|
task.defer_cancel do
|
|
87
|
-
|
|
88
|
-
##
|
|
89
|
-
# The call opens here, and it is what a deferred ask is issued
|
|
90
|
-
# against. What the window guarantees is that the raise is not
|
|
91
|
-
# asked for before the call; the dispatch itself is code, and a
|
|
92
|
-
# raise can land in it, which is the same gap `Fork::Job` has
|
|
93
|
-
# between `running!` and `runner.call`.
|
|
93
|
+
begin
|
|
94
94
|
@window.running!
|
|
95
|
-
function.call
|
|
96
|
-
rescue LLM::Interrupt => ex
|
|
97
95
|
##
|
|
98
|
-
# The
|
|
99
|
-
#
|
|
100
|
-
|
|
96
|
+
# The 'function.call' method call will rescue
|
|
97
|
+
# subclasses of StandardError and turn it into
|
|
98
|
+
# a LLM::Function::Return object. An LLM::Interrupt
|
|
99
|
+
# escapes this method call, and it is rescued
|
|
100
|
+
# here and pushed onto the queue.
|
|
101
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
+
@queue << function.call
|
|
103
|
+
rescue LLM::Interrupt => ex
|
|
104
|
+
@queue << ex
|
|
101
105
|
ensure
|
|
106
|
+
@finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
107
|
##
|
|
103
108
|
# The tool has answered, so a cancel that arrives from here on
|
|
104
109
|
# asks nothing - which is the no-op a cancel that arrives once
|
|
@@ -112,17 +117,9 @@ module LLM::Function::Async
|
|
|
112
117
|
# `interrupted?` means.
|
|
113
118
|
function.interrupt! if @window.interrupted?
|
|
114
119
|
end
|
|
115
|
-
@queue << result
|
|
116
120
|
end
|
|
117
|
-
rescue LLM::Interrupt
|
|
118
|
-
##
|
|
119
|
-
# This runs after the `defer_cancel` block's `ensure`, so the tool is
|
|
120
|
-
# told first and the caller second. It answers a hook whose own
|
|
121
|
-
# error unwound past the push, and it is the backstop for an
|
|
122
|
-
# interrupt that arrived from somewhere other than the call above -
|
|
123
|
-
# which is why the interrupt is pushed and not raised on.
|
|
121
|
+
rescue LLM::Interrupt => e
|
|
124
122
|
@queue << e
|
|
125
|
-
raise unless LLM::Interrupt === e
|
|
126
123
|
end
|
|
127
124
|
nil
|
|
128
125
|
end
|
|
@@ -165,36 +162,16 @@ module LLM::Function::Async
|
|
|
165
162
|
alias_method :cancel!, :interrupt!
|
|
166
163
|
|
|
167
164
|
##
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
# It stops the reactor on the way out, which is also where a cancel's
|
|
171
|
-
# cost is paid: `interrupt!` is a message and a return like every other
|
|
172
|
-
# strategy's, and a tool that will not stop meets the join and the kill
|
|
173
|
-
# here rather than in the caller's cancel.
|
|
174
|
-
#
|
|
175
|
-
# **A second wait is answered from what the first one took.** `pop` takes
|
|
176
|
-
# the item, so holding it is what makes a task answerable more than once -
|
|
177
|
-
# which is what `Thread#value` does for the other in-process strategy, and
|
|
178
|
-
# what the ractor's task is asserted to do. The exception case is held the
|
|
179
|
-
# same way, so a call that was interrupted re-raises on every wait rather
|
|
180
|
-
# than raising once and then blocking on an empty queue.
|
|
181
|
-
#
|
|
182
|
-
# Anything that is an exception is raised rather than returned, which is
|
|
183
|
-
# what `Thread#value` and `Fiber#value` do. An interrupt is the usual
|
|
184
|
-
# one, and a hook that raised from the block's `ensure` is the other.
|
|
185
|
-
#
|
|
186
|
-
# The guarded path returns before any of that, so a task whose guard
|
|
187
|
-
# blocked it does not stop a reactor it never used - which matters in a
|
|
188
|
-
# group, where the reactor is not its own.
|
|
189
|
-
# @return [LLM::Function::Return]
|
|
165
|
+
# @see LLM::Function::Task#wait
|
|
166
|
+
# @return (see LLM::Function::Task#wait)
|
|
190
167
|
def wait
|
|
191
|
-
return @
|
|
168
|
+
return @intercepted if intercepted?
|
|
169
|
+
return @result.unbox if @result
|
|
192
170
|
begin
|
|
193
171
|
spawn unless @queue
|
|
194
|
-
@result
|
|
172
|
+
@result = LLM::Object.from(unbox: @queue.pop)
|
|
195
173
|
@alive = false
|
|
196
|
-
|
|
197
|
-
@result
|
|
174
|
+
@result.unbox
|
|
198
175
|
ensure
|
|
199
176
|
@reactor&.stop
|
|
200
177
|
end
|
|
@@ -36,10 +36,10 @@ module LLM::Function::Fiber
|
|
|
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
|
|
@@ -36,7 +36,13 @@ module LLM::Function::Fiber
|
|
|
36
36
|
##
|
|
37
37
|
# @return [nil]
|
|
38
38
|
def spawn
|
|
39
|
-
|
|
39
|
+
@span = @tracer&.on_tool_start(
|
|
40
|
+
id: @function.id,
|
|
41
|
+
name: @function.name,
|
|
42
|
+
arguments: @function.arguments,
|
|
43
|
+
model: @function.model
|
|
44
|
+
)
|
|
45
|
+
return if intercepted?
|
|
40
46
|
if Fiber.scheduler.nil?
|
|
41
47
|
raise ArgumentError, "Fiber concurrency requires Fiber.scheduler"
|
|
42
48
|
elsif !Fiber.scheduler.respond_to?(:fiber_interrupt)
|
|
@@ -94,22 +100,12 @@ module LLM::Function::Fiber
|
|
|
94
100
|
# to prevent - this fiber raised before the call was reached, so
|
|
95
101
|
# the tool's own `rescue` never saw the interrupt.
|
|
96
102
|
@window.running!
|
|
103
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
104
|
@queue << function.call
|
|
98
|
-
rescue LLM::Interrupt
|
|
99
|
-
##
|
|
100
|
-
# The interrupt is stored on the queue rather than raised on.
|
|
101
|
-
# `LLM::Interrupt` is a subclass of `Exception`, and one that is
|
|
102
|
-
# left to raise kills the scheduler's thread and takes the tasks
|
|
103
|
-
# running on it with it - the caller reads the queue and raises
|
|
104
|
-
# the interrupt on its own thread or fiber, and this task exits
|
|
105
|
-
# silently.
|
|
106
|
-
#
|
|
107
|
-
# The rescue names the interrupt rather than leaving it to a bare
|
|
108
|
-
# rescue, because a bare rescue - and `rescue => ex` - reaches
|
|
109
|
-
# only `StandardError`.
|
|
105
|
+
rescue LLM::Interrupt => ex
|
|
110
106
|
@queue << ex
|
|
111
|
-
raise unless LLM::Interrupt === ex
|
|
112
107
|
ensure
|
|
108
|
+
@finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
109
|
##
|
|
114
110
|
# The tool has answered, so a cancel that arrives from here on
|
|
115
111
|
# asks nothing - which is the no-op a cancel that arrives once
|
|
@@ -158,24 +154,14 @@ module LLM::Function::Fiber
|
|
|
158
154
|
alias_method :cancel!, :interrupt!
|
|
159
155
|
|
|
160
156
|
##
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
# **It answers more than once.** `Queue#pop` takes the item, so the value
|
|
164
|
-
# is kept rather than popped again, and a second wait is answered from
|
|
165
|
-
# what the first one took - the same way `Thread#value` answers, and the
|
|
166
|
-
# way `LLM::Function::Ractor::Task` is expected to.
|
|
167
|
-
#
|
|
168
|
-
# Anything that is an exception is raised rather than returned. An
|
|
169
|
-
# interrupt is the usual one: a held cancel arrives here as the exception
|
|
170
|
-
# the block ended with, and so does one raised at a call that was already
|
|
171
|
-
# running.
|
|
172
|
-
# @return [LLM::Function::Return]
|
|
157
|
+
# @see LLM::Function::Task#wait
|
|
158
|
+
# @return (see LLM::Function::Task#wait)
|
|
173
159
|
def wait
|
|
174
|
-
return @
|
|
160
|
+
return @intercepted if intercepted?
|
|
161
|
+
return @result.unbox if @result
|
|
175
162
|
spawn unless @fiber
|
|
176
|
-
@result
|
|
177
|
-
|
|
178
|
-
@result
|
|
163
|
+
@result = LLM::Object.from(unbox: @queue.pop)
|
|
164
|
+
@result.unbox
|
|
179
165
|
end
|
|
180
166
|
alias_method :value, :wait
|
|
181
167
|
|
|
@@ -35,10 +35,10 @@ module LLM::Function::Fork
|
|
|
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
|
|
@@ -40,11 +40,11 @@ class LLM::Function
|
|
|
40
40
|
# result is written, so an interrupt that arrives once the work is
|
|
41
41
|
# done is a no-op rather than a reason to throw the result away.
|
|
42
42
|
@window.finished!
|
|
43
|
-
@ch.result.write([:result, result])
|
|
43
|
+
@ch.result.write([:result, result, finished_at])
|
|
44
44
|
rescue LLM::Interrupt
|
|
45
|
-
@ch.result.write([:interrupt])
|
|
45
|
+
@ch.result.write([:interrupt, nil, finished_at])
|
|
46
46
|
rescue => ex
|
|
47
|
-
@ch.result.write([:result, error(ex)])
|
|
47
|
+
@ch.result.write([:result, error(ex), finished_at])
|
|
48
48
|
ensure
|
|
49
49
|
##
|
|
50
50
|
# For the paths above that did not reach the line, and nil-safe for
|
|
@@ -86,7 +86,7 @@ class LLM::Function
|
|
|
86
86
|
thread = ::Thread.new do
|
|
87
87
|
ready << true
|
|
88
88
|
##
|
|
89
|
-
# Only the receive is
|
|
89
|
+
# Only the receive is rescued: it raises when the parent has gone,
|
|
90
90
|
# and nothing else in this block should be answerable to that
|
|
91
91
|
# rescue. A hook that raises must not be swallowed by it, and must
|
|
92
92
|
# not take the raise with it either - hence the `ensure` below,
|
|
@@ -113,5 +113,9 @@ class LLM::Function
|
|
|
113
113
|
ready.pop
|
|
114
114
|
thread
|
|
115
115
|
end
|
|
116
|
+
|
|
117
|
+
def finished_at
|
|
118
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
119
|
+
end
|
|
116
120
|
end
|
|
117
121
|
end
|
|
@@ -12,7 +12,6 @@ class LLM::Function
|
|
|
12
12
|
# @return [LLM::Function::Fork::Task]
|
|
13
13
|
def initialize(fn, options = {})
|
|
14
14
|
super
|
|
15
|
-
@tracer = options.fetch(:tracer, nil)
|
|
16
15
|
@spawned = false
|
|
17
16
|
@waited = false
|
|
18
17
|
@cancelled = false
|
|
@@ -21,11 +20,13 @@ class LLM::Function
|
|
|
21
20
|
##
|
|
22
21
|
# @return [LLM::Function::Fork::Task]
|
|
23
22
|
def spawn
|
|
24
|
-
return if @guarded
|
|
25
23
|
@span = @tracer&.on_tool_start(
|
|
26
|
-
id: @function.id,
|
|
27
|
-
|
|
24
|
+
id: @function.id,
|
|
25
|
+
name: @function.name,
|
|
26
|
+
arguments: @function.arguments,
|
|
27
|
+
model: @function.model
|
|
28
28
|
)
|
|
29
|
+
return if intercepted?
|
|
29
30
|
@ch = channels
|
|
30
31
|
##
|
|
31
32
|
# **A cancel that arrived before this is written now, before the fork.**
|
|
@@ -38,6 +39,7 @@ class LLM::Function
|
|
|
38
39
|
@cancelled = false
|
|
39
40
|
@ch.control.write(:interrupt)
|
|
40
41
|
end
|
|
42
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
43
|
@pid = Kernel.fork do
|
|
42
44
|
##
|
|
43
45
|
# The child inherits the parent's terminal. When
|
|
@@ -95,12 +97,12 @@ class LLM::Function
|
|
|
95
97
|
# until its watcher looks, which is how a cancel that arrives a moment
|
|
96
98
|
# later is delivered too.
|
|
97
99
|
#
|
|
98
|
-
# A task the
|
|
100
|
+
# A task the intercept blocked never forks, and one that has answered has
|
|
99
101
|
# nothing left to tell: both are a no-op, the way
|
|
100
102
|
# {LLM::Function::Return#interrupt!} says one is.
|
|
101
103
|
# @return [nil]
|
|
102
104
|
def interrupt!
|
|
103
|
-
return nil if @waited || @
|
|
105
|
+
return nil if @waited || @intercepted
|
|
104
106
|
if @ch
|
|
105
107
|
@ch.control.write(:interrupt)
|
|
106
108
|
else
|
|
@@ -132,34 +134,27 @@ class LLM::Function
|
|
|
132
134
|
# it would be an exception to the rule that a tool's failure is in band.
|
|
133
135
|
# @return [LLM::Function::Return]
|
|
134
136
|
def wait
|
|
135
|
-
return @
|
|
136
|
-
|
|
137
|
-
return @result if @result
|
|
137
|
+
return @intercepted if intercepted?
|
|
138
|
+
return @result.unbox if @result
|
|
138
139
|
spawn unless @spawned
|
|
139
|
-
kind, data = @ch.result.recv
|
|
140
|
+
kind, data, @finished_at = @ch.result.recv
|
|
140
141
|
@result = case kind
|
|
141
|
-
when :interrupt then LLM::Interrupt.new
|
|
142
|
-
when :result then Return.new(data[:id], data[:name], data[:value])
|
|
143
|
-
else raise ArgumentError, "
|
|
142
|
+
when :interrupt then LLM::Object.from(unbox: LLM::Interrupt.new)
|
|
143
|
+
when :result then LLM::Object.from(unbox: Return.new(data[:id], data[:name], data[:value]))
|
|
144
|
+
else raise ArgumentError, "unknown fork message: #{kind.inspect}"
|
|
144
145
|
end
|
|
145
|
-
raise @result if Exception === @result
|
|
146
146
|
reap
|
|
147
|
-
@
|
|
148
|
-
@result
|
|
147
|
+
@result.unbox
|
|
149
148
|
rescue EOFError
|
|
150
149
|
##
|
|
151
|
-
#
|
|
152
|
-
# the same return the
|
|
150
|
+
# Stored in `@result` so a second wait is
|
|
151
|
+
# given the same return as the first call.
|
|
153
152
|
reap
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
message: "the tool exited unexpectedly"
|
|
158
|
-
})
|
|
159
|
-
@tracer&.on_tool_finish(result: @result, span: @span)
|
|
160
|
-
@result
|
|
153
|
+
val = @function.return(error: true, type: EOFError.name, message: "the tool exited unexpectedly")
|
|
154
|
+
@result = LLM::Object.from(unbox: val)
|
|
155
|
+
@result.unbox
|
|
161
156
|
ensure
|
|
162
|
-
if @
|
|
157
|
+
if @intercepted.nil?
|
|
163
158
|
reap
|
|
164
159
|
[@ch.control, @ch.result].each { _1.close unless _1.closed? } if @ch
|
|
165
160
|
end
|
|
@@ -178,9 +173,9 @@ class LLM::Function
|
|
|
178
173
|
# The controls and results channels.
|
|
179
174
|
#
|
|
180
175
|
# They are built by `spawn` and not by {#interrupt!}, so a task that never
|
|
181
|
-
# forks never opens a socketpair -
|
|
182
|
-
# the common case. A cancel that arrives before `spawn` is recorded
|
|
183
|
-
# and written by it.
|
|
176
|
+
# forks never opens a socketpair - an intercept blocks many of them, and
|
|
177
|
+
# that is the common case. A cancel that arrives before `spawn` is recorded
|
|
178
|
+
# instead, and written by it.
|
|
184
179
|
# @return [LLM::Object]
|
|
185
180
|
def channels
|
|
186
181
|
LLM::Object.from(
|
|
@@ -197,7 +192,7 @@ class LLM::Function
|
|
|
197
192
|
# only be reaped once.
|
|
198
193
|
# @return [void]
|
|
199
194
|
def reap
|
|
200
|
-
return if @waited || @
|
|
195
|
+
return if @waited || @intercepted || !@pid
|
|
201
196
|
::Process.waitpid(@pid)
|
|
202
197
|
@waited = true
|
|
203
198
|
rescue Errno::ECHILD
|
data/lib/llm/function/group.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
class LLM::Function
|
|
4
4
|
##
|
|
5
|
-
# Abstract
|
|
5
|
+
# Abstract superclass for all concurrency strategy groups.
|
|
6
6
|
# Each strategy (sequential, thread, fiber, async, fork, ractor)
|
|
7
|
-
# subclasses this and implements {#
|
|
8
|
-
# {#wait}
|
|
7
|
+
# subclasses this and implements {#spawn}, {#alive?} and
|
|
8
|
+
# {#interrupt!}. {#wait} is shared, and a strategy whose tasks
|
|
9
|
+
# report themselves answers with one of its own.
|
|
9
10
|
class Group
|
|
10
11
|
##
|
|
11
12
|
# @abstract
|
|
@@ -30,10 +31,37 @@ class LLM::Function
|
|
|
30
31
|
alias_method :cancel!, :interrupt!
|
|
31
32
|
|
|
32
33
|
##
|
|
33
|
-
#
|
|
34
|
+
# This method waits for one or more tasks to
|
|
35
|
+
# return. Within this method, the tracer
|
|
36
|
+
# associated with a task closes the `on_tool_start`
|
|
37
|
+
# span that was opened when the task spawned.
|
|
38
|
+
#
|
|
39
|
+
# @note
|
|
40
|
+
# This method might raise an {LLM::Interrupt}
|
|
41
|
+
# if one or more tasks were interrupted but
|
|
42
|
+
# only after the tracer has accounted for each
|
|
43
|
+
# task and collected an exit status from each
|
|
44
|
+
# of them.
|
|
45
|
+
# @raise [LLM::Interrupt]
|
|
34
46
|
# @return [Array<LLM::Function::Return>]
|
|
35
47
|
def wait
|
|
36
|
-
|
|
48
|
+
spawn unless @spawned
|
|
49
|
+
interrupt = nil
|
|
50
|
+
vals = @tasks.map do |task|
|
|
51
|
+
val, started_at, finished_at = task.wait, task.started_at, task.finished_at
|
|
52
|
+
if LLM::Interrupt === val
|
|
53
|
+
interrupt = val
|
|
54
|
+
task.tracer&.on_tool_interrupt(ex: val, span: task.span, started_at:, finished_at:)
|
|
55
|
+
else
|
|
56
|
+
if val.error?
|
|
57
|
+
task.tracer&.on_tool_error(ex: val, span: task.span, started_at:, finished_at:)
|
|
58
|
+
else
|
|
59
|
+
task.tracer&.on_tool_finish(result: val, span: task.span, started_at:, finished_at:)
|
|
60
|
+
end
|
|
61
|
+
val
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
interrupt ? raise(interrupt) : vals
|
|
37
65
|
end
|
|
38
66
|
alias_method :value, :wait
|
|
39
67
|
end
|