llm.rb 9.0.0 → 11.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -4
  3. data/README.md +194 -42
  4. data/data/anthropic.json +278 -258
  5. data/data/bedrock.json +1288 -1561
  6. data/data/deepseek.json +38 -38
  7. data/data/google.json +656 -579
  8. data/data/openai.json +860 -818
  9. data/data/xai.json +243 -552
  10. data/data/zai.json +168 -168
  11. data/lib/llm/a2a/card/capabilities.rb +41 -0
  12. data/lib/llm/a2a/card/interface.rb +34 -0
  13. data/lib/llm/a2a/card/provider.rb +27 -0
  14. data/lib/llm/a2a/card/skill.rb +68 -0
  15. data/lib/llm/a2a/card.rb +144 -0
  16. data/lib/llm/a2a/error.rb +49 -0
  17. data/lib/llm/a2a/notifications.rb +53 -0
  18. data/lib/llm/a2a/tasks.rb +55 -0
  19. data/lib/llm/a2a/transport/http.rb +131 -0
  20. data/lib/llm/a2a.rb +452 -0
  21. data/lib/llm/active_record/acts_as_agent.rb +20 -9
  22. data/lib/llm/active_record/acts_as_llm.rb +4 -4
  23. data/lib/llm/active_record.rb +1 -6
  24. data/lib/llm/agent.rb +96 -71
  25. data/lib/llm/buffer.rb +1 -2
  26. data/lib/llm/context.rb +77 -50
  27. data/lib/llm/file.rb +7 -0
  28. data/lib/llm/function/call_task.rb +46 -0
  29. data/lib/llm/function.rb +28 -2
  30. data/lib/llm/mcp/transport/http.rb +5 -18
  31. data/lib/llm/mcp/transport/stdio.rb +7 -0
  32. data/lib/llm/mcp.rb +20 -17
  33. data/lib/llm/message.rb +1 -1
  34. data/lib/llm/object/kernel.rb +1 -1
  35. data/lib/llm/provider.rb +9 -9
  36. data/lib/llm/providers/anthropic/stream_parser.rb +2 -2
  37. data/lib/llm/providers/bedrock/stream_parser.rb +2 -2
  38. data/lib/llm/providers/google/stream_parser.rb +2 -2
  39. data/lib/llm/providers/openai/responses/stream_parser.rb +2 -2
  40. data/lib/llm/providers/openai/stream_parser.rb +2 -2
  41. data/lib/llm/response.rb +1 -1
  42. data/lib/llm/schema.rb +11 -0
  43. data/lib/llm/sequel/agent.rb +19 -9
  44. data/lib/llm/sequel/plugin.rb +9 -13
  45. data/lib/llm/stream.rb +11 -36
  46. data/lib/llm/tool/param.rb +1 -8
  47. data/lib/llm/tool.rb +57 -27
  48. data/lib/llm/tracer.rb +1 -1
  49. data/lib/llm/transport/http.rb +1 -1
  50. data/lib/llm/transport/stream_decoder.rb +6 -3
  51. data/lib/llm/transport/utils.rb +35 -0
  52. data/lib/llm/transport.rb +1 -0
  53. data/lib/llm/utils.rb +73 -0
  54. data/lib/llm/version.rb +1 -1
  55. data/lib/llm.rb +24 -4
  56. data/llm.gemspec +16 -1
  57. metadata +29 -5
  58. data/lib/llm/bot.rb +0 -3
  59. data/lib/llm/mcp/transport/http/event_handler.rb +0 -68
data/lib/llm/agent.rb CHANGED
@@ -48,9 +48,9 @@ module LLM
48
48
  # The model identifier
49
49
  # @return [String, nil]
50
50
  # Returns the current model when no argument is provided
51
- def self.model(model = nil)
52
- return @model if model.nil?
53
- @model = model
51
+ def self.model(model = nil, &block)
52
+ return @model if model.nil? && !block
53
+ @model = block || model
54
54
  end
55
55
 
56
56
  ##
@@ -59,9 +59,9 @@ module LLM
59
59
  # One or more tools
60
60
  # @return [Array<LLM::Function>]
61
61
  # Returns the current tools when no argument is provided
62
- def self.tools(*tools)
63
- return @tools || [] if tools.empty?
64
- @tools = tools.flatten
62
+ def self.tools(*tools, &block)
63
+ return @tools || [] if tools.empty? && !block
64
+ @tools = block || tools.flatten
65
65
  end
66
66
 
67
67
  ##
@@ -70,9 +70,9 @@ module LLM
70
70
  # One or more skill directories
71
71
  # @return [Array<String>, nil]
72
72
  # Returns the current skills when no argument is provided
73
- def self.skills(*skills)
74
- return @skills if skills.empty?
75
- @skills = skills.flatten
73
+ def self.skills(*skills, &block)
74
+ return @skills if skills.empty? && !block
75
+ @skills = block || skills.flatten
76
76
  end
77
77
 
78
78
  ##
@@ -81,9 +81,9 @@ module LLM
81
81
  # The schema
82
82
  # @return [#to_json, nil]
83
83
  # Returns the current schema when no argument is provided
84
- def self.schema(schema = nil)
85
- return @schema if schema.nil?
86
- @schema = schema
84
+ def self.schema(schema = nil, &block)
85
+ return @schema if schema.nil? && !block
86
+ @schema = block || schema
87
87
  end
88
88
 
89
89
  ##
@@ -157,6 +157,19 @@ module LLM
157
157
  @stream = block || stream
158
158
  end
159
159
 
160
+ ##
161
+ # Set or get the tool names that require confirmation before they can run.
162
+ #
163
+ # @param [String, Symbol, Array<String, Symbol>, Proc] tool_names
164
+ # One or more tool names.
165
+ # @param [Proc] block
166
+ # An optional, lazy-evaluated Proc
167
+ # @return [Array<String>, Proc, nil]
168
+ def self.confirm(*tool_names, &block)
169
+ return @confirm if tool_names.empty? && !block
170
+ @confirm = block || tool_names.flatten.map(&:to_s)
171
+ end
172
+
160
173
  ##
161
174
  # @param [LLM::Provider] provider
162
175
  # A provider
@@ -168,17 +181,27 @@ module LLM
168
181
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
169
182
  # @option params [Array<String>, nil] :skills Defaults to nil
170
183
  # @option params [#to_json, nil] :schema Defaults to nil
184
+ # @option params [Object, Proc, nil] :stream Optional stream override for this agent instance
171
185
  # @option params [LLM::Tracer, Proc, nil] :tracer Optional tracer override for this agent instance
172
186
  # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
173
187
  def initialize(llm, params = {})
174
- defaults = {model: self.class.model, tools: self.class.tools, skills: self.class.skills, schema: self.class.schema}.compact
175
- @concurrency = params.delete(:concurrency) || self.class.concurrency
176
188
  @llm = llm
177
- tracer = params.key?(:tracer) ? params.delete(:tracer) : self.class.tracer
178
- stream = params.key?(:stream) ? params.delete(:stream) : self.class.stream
179
- @tracer = resolve_option(tracer) unless tracer.nil?
180
- params[:stream] = resolve_option(stream) unless stream.nil?
181
- @ctx = LLM::Context.new(llm, defaults.merge({guard: true}).merge(params))
189
+ fields = %i[model skills schema tracer stream tools concurrency instructions confirm]
190
+ fields_ivar = %i[tracer concurrency instructions confirm]
191
+ fields.each do |field|
192
+ resolvable = params.key?(field) ? params.delete(field) : self.class.public_send(field)
193
+ resolve_symbol = !%i[concurrency confirm].include?(field)
194
+ resolved = resolvable != nil ? resolve_option(self, resolvable, resolve_symbol:) : resolvable
195
+ resolved = [*resolved].map(&:to_s) if field == :confirm && resolved
196
+ if field == :model
197
+ params[field] = resolved unless resolved.nil? || params.key?(field)
198
+ elsif resolved && !fields_ivar.include?(field)
199
+ params[field] ||= resolved
200
+ elsif fields_ivar.include?(field)
201
+ instance_variable_set(:"@#{field}", resolved)
202
+ end
203
+ end
204
+ @ctx = LLM::Context.new(llm, {guard: true}.merge(params))
182
205
  end
183
206
 
184
207
  ##
@@ -198,29 +221,13 @@ module LLM
198
221
  # response = agent.talk("Hello, what is your name?")
199
222
  # puts response.choices[0].content
200
223
  def talk(prompt, params = {})
201
- run_loop(:talk, prompt, params)
224
+ run_loop(prompt, params, :talk)
202
225
  end
203
- alias_method :chat, :talk
204
226
 
205
227
  ##
206
- # Maintain a conversation via the responses API.
207
- # This method immediately sends a request to the LLM and returns the response.
208
- #
209
- # @note Not all LLM providers support this API
210
- # @param prompt (see LLM::Provider#complete)
211
- # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
212
- # @option params [Integer] :tool_attempts
213
- # The maxinum number of tool call iterations before the agent sends
214
- # in-band advisory tool errors back through the model (default 25).
215
- # Set to `nil` to disable advisory tool-limit returns.
216
- # @return [LLM::Response] Returns the LLM's response for this turn.
217
- # @example
218
- # llm = LLM.openai(key: ENV["KEY"])
219
- # agent = LLM::Agent.new(llm)
220
- # res = agent.respond("What is the capital of France?")
221
- # puts res.output_text
222
- def respond(prompt, params = {})
223
- run_loop(:respond, prompt, params)
228
+ # @see LLM::Context#ask
229
+ def ask(prompt, params = {})
230
+ run_loop(prompt, params, :ask)
224
231
  end
225
232
 
226
233
  ##
@@ -347,6 +354,13 @@ module LLM
347
354
  @ctx.context_window
348
355
  end
349
356
 
357
+ ##
358
+ # @see LLM::Context#params
359
+ # @return [Hash]
360
+ def params
361
+ @ctx.params
362
+ end
363
+
350
364
  ##
351
365
  # @see LLM::Context#to_h
352
366
  # @return [Hash]
@@ -357,13 +371,13 @@ module LLM
357
371
  ##
358
372
  # @return [String]
359
373
  def to_json(...)
360
- to_h.to_json(...)
374
+ LLM.json.dump(to_h, ...)
361
375
  end
362
376
 
363
377
  ##
364
378
  # @return [String]
365
379
  def inspect
366
- "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
380
+ "#<#{LLM::Utils.object_id(self)} " \
367
381
  "@llm=#{@llm.class}, @mode=#{mode.inspect}, @messages=#{messages.inspect}>"
368
382
  end
369
383
 
@@ -383,19 +397,33 @@ module LLM
383
397
  end
384
398
  alias_method :restore, :deserialize
385
399
 
400
+ ##
401
+ # This method is called when confirmation is required before a tool can run.
402
+ #
403
+ # @param [LLM::Function] fn
404
+ # The pending function call. It can be cancelled through the
405
+ # {LLM::Function#cancel} method.
406
+ # @param [Symbol, Array<Symbol>] strategy
407
+ # The execution strategy that would be used for the tool call.
408
+ # @return [LLM::Function::Return]
409
+ # Return either `fn.spawn(strategy).wait` to approve execution or
410
+ # `fn.cancel(...)` to cancel the call.
411
+ def on_tool_confirmation(fn, strategy)
412
+ fn.cancel
413
+ end
414
+
386
415
  private
387
416
 
388
417
  ##
389
418
  # @return [LLM::Prompt]
390
419
  def apply_instructions(new_prompt)
391
- instr = self.class.instructions
392
- return new_prompt unless instr
420
+ return new_prompt unless @instructions
393
421
  if LLM::Prompt === new_prompt
394
- new_prompt.system(instr) if inject_instructions?(new_prompt)
422
+ new_prompt.system(@instructions) if inject_instructions?(new_prompt)
395
423
  new_prompt
396
424
  else
397
425
  prompt do
398
- _1.system(instr) if inject_instructions?
426
+ _1.system(@instructions) if inject_instructions?
399
427
  _1.user(new_prompt)
400
428
  end
401
429
  end
@@ -416,50 +444,47 @@ module LLM
416
444
  ##
417
445
  # @return [Array<LLM::Function::Return>]
418
446
  def call_functions
419
- case concurrency || :call
420
- when :call then wait(:call)
421
- when :thread, :task, :fiber, :fork, :ractor, Array then wait(concurrency)
422
- else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. " \
423
- "Expected :call, :thread, :task, :fiber, :fork, :ractor, " \
424
- "or an array of the mentioned options"
447
+ strategy = concurrency || :call
448
+ return wait(strategy) unless @confirm&.any?
449
+ confirmables = @ctx.functions.select { @confirm.include?(_1.name.to_s) }
450
+ results = confirmables.map do |tool|
451
+ send(:on_tool_confirmation, tool, strategy)
425
452
  end
453
+ @ctx.functions? ? [*results, *wait(strategy)] : results
426
454
  end
427
455
 
428
- def run_loop(method, prompt, params)
429
- loop = proc do
456
+ ##
457
+ # Runs the tool loop
458
+ # @api private
459
+ def run_loop(prompt, params, target)
460
+ run = proc do
461
+ talk = @ctx.method(target)
430
462
  max = params.key?(:tool_attempts) ? params.delete(:tool_attempts) : 25
431
463
  max = Integer(max) if max
432
464
  stream = params[:stream] || @ctx.params[:stream]
433
465
  stream.extra[:concurrency] = concurrency if LLM::Stream === stream
434
- res = @ctx.public_send(method, apply_instructions(prompt), params)
435
- loop do
436
- break unless @ctx.functions?
466
+ res = talk.call(apply_instructions(prompt), params)
467
+ while @ctx.functions?
437
468
  if max
438
469
  max.times do
439
470
  break unless @ctx.functions?
440
- res = @ctx.public_send(method, call_functions, params)
471
+ res = talk.call(call_functions, params)
441
472
  end
442
- break unless @ctx.functions?
443
- res = @ctx.public_send(method, @ctx.functions.map { rate_limit(_1) }, params)
473
+ res = talk.call(@ctx.functions.map(&:rate_limit), params) if @ctx.functions?
444
474
  else
445
- res = @ctx.public_send(method, call_functions, params)
475
+ res = talk.call(call_functions, params)
446
476
  end
447
477
  end
448
478
  res
449
479
  end
450
- @tracer ? @llm.with_tracer(@tracer, &loop) : loop.call
480
+ return run.call unless @tracer
481
+ @llm.with_tracer(@tracer, &run)
451
482
  end
452
483
 
453
- def rate_limit(function)
454
- LLM::Function::Return.new(function.id, function.name, {
455
- error: true,
456
- type: LLM::ToolLoopError.name,
457
- message: "tool loop rate limit reached"
458
- })
459
- end
460
-
461
- def resolve_option(option)
462
- Proc === option ? instance_exec(&option) : option
484
+ ##
485
+ # @api private
486
+ def resolve_option(...)
487
+ LLM::Utils.resolve_option(...)
463
488
  end
464
489
  end
465
490
  end
data/lib/llm/buffer.rb CHANGED
@@ -97,8 +97,7 @@ module LLM
97
97
  ##
98
98
  # @return [String]
99
99
  def inspect
100
- "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
101
- "message_count=#{@messages.size}>"
100
+ "#<#{LLM::Utils.object_id(self)} message_count=#{@messages.size}>"
102
101
  end
103
102
 
104
103
  ##
data/lib/llm/context.rb CHANGED
@@ -68,13 +68,6 @@ module LLM
68
68
  # @return [Symbol]
69
69
  attr_reader :mode
70
70
 
71
- ##
72
- # Returns the default params for this context
73
- # @return [Hash]
74
- def params
75
- @params.dup
76
- end
77
-
78
71
  ##
79
72
  # @param [LLM::Provider] llm
80
73
  # A provider
@@ -98,6 +91,13 @@ module LLM
98
91
  @messages = LLM::Buffer.new(llm)
99
92
  end
100
93
 
94
+ ##
95
+ # Returns the default params for this context
96
+ # @return [Hash]
97
+ def params
98
+ @params.dup
99
+ end
100
+
101
101
  ##
102
102
  # Returns a context compactor
103
103
  # This feature is inspired by the compaction approach developed by
@@ -191,14 +191,9 @@ module LLM
191
191
  # res = ctx.talk("Hello, what is your name?")
192
192
  # puts res.messages[0].content
193
193
  def talk(prompt, params = {})
194
- return respond(prompt, params) if mode == :responses
195
194
  @owner = @llm.request_owner
196
195
  compactor.compact!(prompt) if compactor.compact?(prompt)
197
- params = params.merge(messages: @messages.to_a)
198
- params = @params.merge(params)
199
- prompt, params = transform(prompt, params)
200
- bind!(params[:stream], params[:model], params[:tools])
201
- res = @llm.complete(prompt, params)
196
+ prompt, params, res = mode == :responses ? respond(prompt, params) : complete(prompt, params)
202
197
  self.compacted = false
203
198
  role = params[:role] || @llm.user_role
204
199
  role = @llm.tool_role if params[:role].nil? && [*prompt].grep(LLM::Function::Return).any?
@@ -206,41 +201,37 @@ module LLM
206
201
  @messages.concat [res.choices[-1]]
207
202
  res
208
203
  end
209
- alias_method :chat, :talk
210
204
 
211
205
  ##
212
- # Interact with the context via the responses API.
213
- # This method immediately sends a request to the LLM and returns the response.
214
- #
215
- # @note Not all LLM providers support this API
216
- # @param prompt (see LLM::Provider#complete)
217
- # @param params The params, including optional :role (defaults to :user), :stream, :tools, :schema etc.
218
- # @return [LLM::Response] Returns the LLM's response for this turn.
219
- # @example
220
- # llm = LLM.openai(key: ENV["KEY"])
221
- # ctx = LLM::Context.new(llm)
222
- # res = ctx.respond("What is the capital of France?")
223
- # puts res.output_text
224
- def respond(prompt, params = {})
225
- @owner = @llm.request_owner
226
- compactor.compact!(prompt) if compactor.compact?(prompt)
227
- params = @params.merge(params)
228
- prompt, params = transform(prompt, params)
229
- bind!(params[:stream], params[:model], params[:tools])
230
- res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
231
- params = params.merge(previous_response_id: res_id, input: @messages.to_a).compact
232
- res = @llm.responses.create(prompt, params)
233
- self.compacted = false
234
- role = params[:role] || @llm.user_role
235
- @messages.concat LLM::Prompt === prompt ? prompt.to_a : [LLM::Message.new(role, prompt)]
236
- @messages.concat [res.choices[-1]]
237
- res
206
+ # Ask a question and return the content string directly.
207
+ # Accepts `with:` for file attachments and a block for streaming.
208
+ # This interface is compatible with RubyLLM's `ask` method.
209
+ # @param [String] prompt
210
+ # @param [Hash] options
211
+ # @option options [String, Array<String>, nil] :with
212
+ # File path(s) to attach
213
+ # @option options [#<<, LLM::Stream, nil] :stream
214
+ # A stream target
215
+ # @yield [String] content chunks when streaming
216
+ # @return [LLM::Response]
217
+ def ask(prompt, options = {}, &block)
218
+ options = {with: nil, stream: nil}.merge!(options || {})
219
+ with, stream = options.values_at(:with, :stream)
220
+ prompt = with ? [prompt, [*with].map { local_file(_1) }] : prompt
221
+ target = if block
222
+ blk = block.dup
223
+ blk.singleton_class.alias_method(:<<, :call)
224
+ blk
225
+ else
226
+ stream
227
+ end
228
+ target ? talk(prompt, stream: target) : talk(prompt)
238
229
  end
239
230
 
240
231
  ##
241
232
  # @return [String]
242
233
  def inspect
243
- "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
234
+ "#<#{LLM::Utils.object_id(self)} " \
244
235
  "@llm=#{@llm.class}, @mode=#{@mode.inspect}, @params=#{@params.inspect}, " \
245
236
  "@messages=#{@messages.inspect}>"
246
237
  end
@@ -448,7 +439,7 @@ module LLM
448
439
  ##
449
440
  # @return [String]
450
441
  def to_json(...)
451
- to_h.to_json(...)
442
+ LLM.json.dump(to_h, ...)
452
443
  end
453
444
 
454
445
  ##
@@ -493,6 +484,9 @@ module LLM
493
484
 
494
485
  private
495
486
 
487
+ ##
488
+ # Binds runtime metadata onto an active stream.
489
+ # @api private
496
490
  def bind!(stream, model, tools)
497
491
  return unless LLM::Stream === stream
498
492
  @stream = stream
@@ -502,21 +496,34 @@ module LLM
502
496
  stream.extra[:tools] = tools
503
497
  end
504
498
 
499
+ ##
500
+ # Returns the bound stream queue, if available.
501
+ # @api private
505
502
  def queue
506
- return @queue if @queue
507
- stream.queue if LLM::Stream === stream
503
+ [@queue, stream&.queue].compact.first
504
+ rescue NoMethodError
505
+ nil
508
506
  end
509
507
 
508
+ ##
509
+ # Loads skill directories and adapts them into tools.
510
+ # @api private
510
511
  def load_skills(skills)
511
512
  [*skills].map { LLM::Skill.load(_1).to_tool(self) }
512
513
  end
513
514
 
515
+ ##
516
+ # Builds in-band guarded returns when the guard blocks tool work.
517
+ # @api private
514
518
  def guarded_returns
515
519
  warning = guard&.call(self)
516
520
  return unless warning
517
521
  functions.map { guarded_return_for(_1, warning) }
518
522
  end
519
523
 
524
+ ##
525
+ # Rewrites a prompt and params through the configured transformer.
526
+ # @api private
520
527
  def transform(prompt, params)
521
528
  return [prompt, params] unless transformer
522
529
  stream = params[:stream]
@@ -526,6 +533,32 @@ module LLM
526
533
  stream.on_transform_finish(self, transformer) if LLM::Stream === stream
527
534
  end
528
535
 
536
+ ##
537
+ # Executes a turn through the Responses API.
538
+ # @api private
539
+ def respond(prompt, params)
540
+ params = @params.merge(params)
541
+ prompt, params = transform(prompt, params)
542
+ bind!(params[:stream], params[:model], params[:tools])
543
+ res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
544
+ params = params.merge(previous_response_id: res_id, input: @messages.to_a).compact
545
+ [prompt, params, @llm.responses.create(prompt, params)]
546
+ end
547
+
548
+ ##
549
+ # Executes a turn through the chat completions API.
550
+ # @api private
551
+ def complete(prompt, params)
552
+ params = params.merge(messages: @messages.to_a)
553
+ params = @params.merge(params)
554
+ prompt, params = transform(prompt, params)
555
+ bind!(params[:stream], params[:model], params[:tools])
556
+ [prompt, params, @llm.complete(prompt, params)]
557
+ end
558
+
559
+ ##
560
+ # Builds one guarded tool return for a blocked function call.
561
+ # @api private
529
562
  def guarded_return_for(function, warning)
530
563
  LLM::Function::Return.new(function.id, function.name, {
531
564
  error: true,
@@ -534,10 +567,4 @@ module LLM
534
567
  })
535
568
  end
536
569
  end
537
-
538
- # Backward-compatible alias
539
- Bot = Context
540
-
541
- # Scheduled for removal in v6.0
542
- deprecate_constant :Bot
543
570
  end
data/lib/llm/file.rb CHANGED
@@ -43,6 +43,13 @@ class LLM::File
43
43
  mime_type == "application/pdf"
44
44
  end
45
45
 
46
+ ##
47
+ # @return [Boolean]
48
+ # Returns true if the file exists on disk
49
+ def exist?
50
+ File.exist?(path)
51
+ end
52
+
46
53
  ##
47
54
  # @return [Integer]
48
55
  # Returns the size of the file in bytes
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Function
4
+ ##
5
+ # The {LLM::Function::CallTask} class wraps a single direct function call
6
+ # behind the same task-like interface used by spawned concurrency modes.
7
+ class CallTask
8
+ ##
9
+ # @return [LLM::Function]
10
+ attr_reader :function
11
+
12
+ ##
13
+ # @param [LLM::Function] function
14
+ # @return [LLM::Function::CallTask]
15
+ def initialize(function)
16
+ @function = function
17
+ end
18
+
19
+ ##
20
+ # @return [Boolean]
21
+ def alive?
22
+ false
23
+ end
24
+
25
+ ##
26
+ # @return [nil]
27
+ def interrupt!
28
+ function.interrupt!
29
+ nil
30
+ end
31
+ alias_method :cancel!, :interrupt!
32
+
33
+ ##
34
+ # @return [LLM::Function::Return]
35
+ def wait
36
+ function.call
37
+ end
38
+ alias_method :value, :wait
39
+
40
+ ##
41
+ # @return [Class]
42
+ def group_class
43
+ LLM::Function::TaskGroup
44
+ end
45
+ end
46
+ end
data/lib/llm/function.rb CHANGED
@@ -33,6 +33,7 @@ class LLM::Function
33
33
  require_relative "function/tracing"
34
34
  require_relative "function/array"
35
35
  require_relative "function/call_group"
36
+ require_relative "function/call_task"
36
37
  require_relative "function/task"
37
38
  require_relative "function/thread_group"
38
39
  require_relative "function/fiber_group"
@@ -181,7 +182,7 @@ class LLM::Function
181
182
  def define(klass = nil, &b)
182
183
  @runner = klass || b
183
184
  end
184
- alias_method :register, :define
185
+ alias_method :def, :define
185
186
 
186
187
  ##
187
188
  # Call the function
@@ -210,6 +211,7 @@ class LLM::Function
210
211
  #
211
212
  # @param [Symbol] strategy
212
213
  # Controls concurrency strategy:
214
+ # - `:call`: Call the function sequentially without spawning
213
215
  # - `:thread`: Use threads
214
216
  # - `:task`: Use async tasks (requires async gem)
215
217
  # - `:fork`: Use a forked child process (requires xchan.rb support)
@@ -221,6 +223,8 @@ class LLM::Function
221
223
  # Returns a task whose `#value` is an {LLM::Function::Return}.
222
224
  def spawn(strategy)
223
225
  task = case strategy
226
+ when :call
227
+ CallTask.new(self)
224
228
  when :task
225
229
  LLM.require "async" unless defined?(::Async)
226
230
  Async { call! }
@@ -241,7 +245,7 @@ class LLM::Function
241
245
  span = @tracer&.on_tool_start(id:, name:, arguments:, model:)
242
246
  Ractor::Task.new(@runner, id, name, arguments, tracer: @tracer, span:).spawn
243
247
  else
244
- raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, :fiber, :fork, or :ractor"
248
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :call, :thread, :task, :fiber, :fork, or :ractor"
245
249
  end
246
250
  Task.new(task, self)
247
251
  ensure
@@ -295,6 +299,28 @@ class LLM::Function
295
299
  !@called && !@cancelled
296
300
  end
297
301
 
302
+ ##
303
+ # Returns an in-band error for an unresolved function call.
304
+ # @return [LLM::Function::Return]
305
+ def unavailable
306
+ Return.new(id, name, {
307
+ error: true,
308
+ type: LLM::NoSuchToolError.name,
309
+ message: "tool not found"
310
+ })
311
+ end
312
+
313
+ ##
314
+ # Returns an in-band error for a tool loop rate limit.
315
+ # @return [LLM::Function::Return]
316
+ def rate_limit
317
+ LLM::Function::Return.new(id, name, {
318
+ error: true,
319
+ type: LLM::ToolLoopError.name,
320
+ message: "tool loop rate limit reached"
321
+ })
322
+ end
323
+
298
324
  ##
299
325
  # @return [Hash]
300
326
  def adapt(provider)
@@ -7,7 +7,7 @@ module LLM::MCP::Transport
7
7
  # JSON-RPC messages with HTTP POST requests and buffers response
8
8
  # messages for non-blocking reads.
9
9
  class HTTP
10
- require_relative "http/event_handler"
10
+ include LLM::Transport::Utils
11
11
 
12
12
  ##
13
13
  # @param [String] url
@@ -22,7 +22,7 @@ module LLM::MCP::Transport
22
22
  def initialize(url:, headers: {}, timeout: nil, transport: nil)
23
23
  @uri = URI.parse(url)
24
24
  @headers = headers
25
- @transport = resolve_transport(transport, timeout:)
25
+ @transport = resolve_transport(uri, transport, timeout)
26
26
  @queue = []
27
27
  @monitor = Monitor.new
28
28
  @running = false
@@ -101,24 +101,11 @@ module LLM::MCP::Transport
101
101
  res
102
102
  end
103
103
 
104
- def resolve_transport(transport, timeout:)
105
- return default_transport(timeout:) if transport.nil?
106
- if Class === transport && transport <= LLM::Transport
107
- return transport.new(host: uri.host, port: uri.port, timeout:, ssl: uri.scheme == "https")
108
- end
109
- transport
110
- end
111
-
112
- def default_transport(timeout:)
113
- LLM::Transport::HTTP.new(host: uri.host, port: uri.port, timeout:, ssl: uri.scheme == "https")
114
- end
115
-
116
104
  def read(res)
117
105
  if res["content-type"].to_s.include?("text/event-stream")
118
- parser = LLM::EventStream::Parser.new
119
- parser.register EventHandler.new { enqueue(_1) }
120
- res.read_body { parser << _1 }
121
- parser.free
106
+ decoder = LLM::Transport::StreamDecoder.new { enqueue(_1) }
107
+ res.read_body { decoder << _1 }
108
+ decoder.free
122
109
  else
123
110
  body = +""
124
111
  res.read_body { body << _1 }