llm.rb 13.1.0 → 14.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +320 -0
  3. data/README.md +340 -31
  4. data/bin/llm.rb +36 -12
  5. data/data/anthropic.json +206 -263
  6. data/data/bedrock.json +2138 -1860
  7. data/data/deepinfra.json +1003 -624
  8. data/data/deepseek.json +38 -34
  9. data/data/google.json +1079 -371
  10. data/data/mistral.json +448 -368
  11. data/data/moonshot.json +384 -0
  12. data/data/openai.json +974 -1343
  13. data/data/xai.json +154 -126
  14. data/data/zai.json +191 -191
  15. data/lib/llm/agent.rb +47 -14
  16. data/lib/llm/context.rb +71 -88
  17. data/lib/llm/cost.rb +23 -17
  18. data/lib/llm/error.rb +0 -8
  19. data/lib/llm/function/async/task.rb +2 -0
  20. data/lib/llm/function/fiber/task.rb +2 -0
  21. data/lib/llm/function/fork/task.rb +2 -0
  22. data/lib/llm/function/ractor/task.rb +2 -0
  23. data/lib/llm/function/sequential/group.rb +4 -1
  24. data/lib/llm/function/sequential/task.rb +1 -1
  25. data/lib/llm/function/task.rb +4 -0
  26. data/lib/llm/function/thread/task.rb +2 -0
  27. data/lib/llm/function.rb +32 -4
  28. data/lib/llm/guard/loop.rb +89 -0
  29. data/lib/llm/guard/null.rb +19 -0
  30. data/lib/llm/guard.rb +61 -0
  31. data/lib/llm/provider.rb +36 -0
  32. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  33. data/lib/llm/providers/anthropic.rb +1 -8
  34. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  35. data/lib/llm/providers/bedrock.rb +1 -8
  36. data/lib/llm/providers/google/stream_parser.rb +1 -0
  37. data/lib/llm/providers/google.rb +1 -8
  38. data/lib/llm/providers/moonshot.rb +76 -0
  39. data/lib/llm/providers/ollama.rb +1 -8
  40. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  41. data/lib/llm/providers/openai/responses.rb +6 -8
  42. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  43. data/lib/llm/providers/openai.rb +3 -10
  44. data/lib/llm/repl/bar.rb +4 -3
  45. data/lib/llm/repl/buffer.rb +42 -15
  46. data/lib/llm/repl/color.rb +78 -0
  47. data/lib/llm/repl/input/char.rb +46 -0
  48. data/lib/llm/repl/input/row.rb +39 -0
  49. data/lib/llm/repl/input.rb +251 -66
  50. data/lib/llm/repl/markdown/table.rb +6 -2
  51. data/lib/llm/repl/markdown.rb +31 -5
  52. data/lib/llm/repl/status.rb +38 -3
  53. data/lib/llm/repl/stream.rb +16 -4
  54. data/lib/llm/repl/walker.rb +3 -2
  55. data/lib/llm/repl/window.rb +25 -5
  56. data/lib/llm/repl.rb +29 -13
  57. data/lib/llm/stream.rb +8 -7
  58. data/lib/llm/tool.rb +29 -0
  59. data/lib/llm/transformer/null.rb +21 -0
  60. data/lib/llm/transformer.rb +55 -0
  61. data/lib/llm/version.rb +1 -1
  62. data/lib/llm.rb +12 -2
  63. data/llm.gemspec +1 -0
  64. data/resources/deepdive/advanced/cancellation.md +74 -0
  65. data/resources/deepdive/advanced/compaction.md +83 -0
  66. data/resources/deepdive/advanced/context.md +267 -0
  67. data/resources/deepdive/advanced/guard.md +371 -0
  68. data/resources/deepdive/advanced/tracer.md +180 -0
  69. data/resources/deepdive/advanced/transformer.md +67 -0
  70. data/resources/deepdive/advanced/transports.md +45 -0
  71. data/resources/deepdive/everything_else/audio.md +122 -0
  72. data/resources/deepdive/everything_else/cost.md +99 -0
  73. data/resources/deepdive/everything_else/images.md +89 -0
  74. data/resources/deepdive/everything_else/object.md +108 -0
  75. data/resources/deepdive/everything_else/ocr.md +48 -0
  76. data/resources/deepdive/fundamentals/agents.md +202 -0
  77. data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
  78. data/resources/deepdive/fundamentals/concurrency.md +104 -0
  79. data/resources/deepdive/fundamentals/database.md +449 -0
  80. data/resources/deepdive/fundamentals/embeddings.md +157 -0
  81. data/resources/deepdive/fundamentals/repl.md +87 -0
  82. data/resources/deepdive/fundamentals/schema.md +61 -0
  83. data/resources/deepdive/fundamentals/skills.md +106 -0
  84. data/resources/deepdive/fundamentals/stream.md +110 -0
  85. data/resources/deepdive/fundamentals/tools.md +265 -0
  86. data/resources/deepdive/protocols/a2a.md +106 -0
  87. data/resources/deepdive/protocols/mcp.md +111 -0
  88. data/resources/deepdive.md +7 -1
  89. metadata +36 -3
  90. data/lib/llm/loop_guard.rb +0 -107
data/lib/llm/agent.rb CHANGED
@@ -18,11 +18,13 @@ module LLM
18
18
  # * Instructions are injected once unless a system message is already present.
19
19
  # * An agent automatically executes tool loops (unlike {LLM::Context LLM::Context}).
20
20
  # * The automatic tool loop enables the wrapped context's `guard` by default.
21
- # The built-in {LLM::LoopGuard LLM::LoopGuard} detects repeated tool-call
22
- # patterns and blocks stuck execution before more tool work is queued.
23
- # * The default tool attempt budget is `25`. After that, the agent sends
24
- # advisory tool errors back through the model and keeps the loop in-band.
25
- # Set `tool_attempts: nil` to disable that advisory behavior.
21
+ # The built-in {LLM::Guard::Loop LLM::Guard::Loop} detects repeated
22
+ # tool-call patterns and blocks stuck execution before more tool work is
23
+ # queued.
24
+ # * The tool loop can be bounded with `tool_budget`. Once the budget is
25
+ # spent, the agent sends an in-band advisory message back through the
26
+ # model and keeps the loop in-band. By default no budget is set
27
+ # (`nil`), so the feature is disabled.
26
28
  # * Tool loop execution can be configured with `concurrency :sequential`,
27
29
  # `:thread`, `:async`, `:fiber`, `:fork`, or `:ractor`.
28
30
  #
@@ -311,6 +313,28 @@ module LLM
311
313
  end
312
314
  end
313
315
 
316
+ ##
317
+ # Set or get the maximum number of tool calls
318
+ # that are allowed in a single turn. Once the
319
+ # budget is spent, we will return an in-band
320
+ # message that informs the model it has spent
321
+ # its tool call budget - and usually a model
322
+ # will change course afterwards.
323
+ # @note
324
+ # By default this feature is disabled
325
+ # (set to `nil`).
326
+ # @param [Integer] budget
327
+ # The maximum number of tool calls to allow in
328
+ # a single turn.
329
+ # @return [Integer, nil]
330
+ def self.tool_budget(budget = UNDEFINED, &block)
331
+ if budget.equal?(UNDEFINED)
332
+ @tool_budget
333
+ else
334
+ @tool_budget = budget || block
335
+ end
336
+ end
337
+
314
338
  ##
315
339
  # @param [LLM::Provider] llm
316
340
  # A provider
@@ -326,9 +350,10 @@ module LLM
326
350
  # @option params [LLM::Tracer, Proc, nil] :tracer Optional tracer override for this agent instance
327
351
  # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
328
352
  def initialize(llm, params = {})
353
+ params = {}.merge!(params)
329
354
  @llm = llm
330
- fields = %i[name description path model skills schema tracer stream tools concurrency instructions confirm]
331
- fields_ivar = %i[name description path tracer concurrency instructions confirm]
355
+ fields = %i[name description path tool_budget model skills schema tracer stream tools concurrency instructions confirm]
356
+ fields_ivar = %i[name description path tool_budget tracer concurrency instructions confirm]
332
357
  fields.each do |field|
333
358
  resolvable = params.key?(field) ? params.delete(field) : self.class.public_send(field)
334
359
  resolve_symbol = !%i[concurrency].include?(field)
@@ -342,7 +367,7 @@ module LLM
342
367
  instance_variable_set(:"@#{field}", resolved)
343
368
  end
344
369
  end
345
- @ctx = LLM::Context.new(llm, {guard: true}.merge(params))
370
+ @ctx = LLM::Context.new(llm, {guard: LLM::Guard::Loop}.merge(params))
346
371
  @path and File.readable?(@path) ? @ctx.restore(path:) : nil
347
372
  end
348
373
 
@@ -374,10 +399,11 @@ module LLM
374
399
  #
375
400
  # @param prompt (see LLM::Provider#complete)
376
401
  # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
377
- # @option params [Integer] :tool_attempts
378
- # The maxinum number of tool call iterations before the agent sends
379
- # in-band advisory tool errors back through the model (default 25).
380
- # Set to `nil` to disable advisory tool-limit returns.
402
+ # @option params [Integer] :tool_budget
403
+ # The maximum number of tool calls that can be made in a single turn
404
+ # before the agent sends an in-band advisory message that tells the model
405
+ # it has spent its tool call budget - and usually the model will change
406
+ # course after that. By default this feature is disabled (set to `nil`).
381
407
  # @return [LLM::Response] Returns the LLM's response for this turn.
382
408
  # @example
383
409
  # llm = LLM.openai(key: ENV["KEY"])
@@ -531,6 +557,13 @@ module LLM
531
557
  @ctx.context_window
532
558
  end
533
559
 
560
+ ##
561
+ # @see LLM::Context#compacted?
562
+ # @return [Boolean]
563
+ def compacted?
564
+ @ctx.compacted?
565
+ end
566
+
534
567
  ##
535
568
  # Start a minimalist repl that can interact
536
569
  # with the agent and its current state. This
@@ -681,7 +714,7 @@ module LLM
681
714
  def run_loop(prompt, params, target)
682
715
  run = proc do
683
716
  talk = @ctx.method(target)
684
- max = params.key?(:tool_attempts) ? params.delete(:tool_attempts) : 25
717
+ max = params.key?(:tool_budget) ? params.delete(:tool_budget) : @tool_budget
685
718
  max = Integer(max) if max
686
719
  stream = params[:stream] || @ctx.params[:stream]
687
720
  params[:stream] = LLM::Stream.try(stream, extra: {concurrency:})
@@ -692,7 +725,7 @@ module LLM
692
725
  break unless @ctx.pending_functions?
693
726
  res = talk.call(call_functions, params)
694
727
  end
695
- res = talk.call(@ctx.pending_functions.map(&:rate_limit), params) if @ctx.pending_functions?
728
+ res = talk.call(@ctx.pending_functions.map(&:budget_spent), params) if @ctx.pending_functions?
696
729
  else
697
730
  res = talk.call(call_functions, params)
698
731
  end
data/lib/llm/context.rb CHANGED
@@ -83,13 +83,22 @@ module LLM
83
83
  # {LLM::Compactor::Null}.
84
84
  # @option params [Hash] :compactor_options
85
85
  # Options passed to the compactor's `call` method. Defaults to `{}`.
86
+ # @option params [Class<LLM::Transformer>, nil] :transformer
87
+ # A transformer class to use for message transformation. Defaults to
88
+ # {LLM::Transformer::Null}.
89
+ # @option params [Hash] :transformer_options
90
+ # Options passed to the transformer's `call` method. Defaults to `{}`.
91
+ # @option params [Class<LLM::Guard>, nil] :guard
92
+ # A guard class to supervise agentic tool execution. Defaults to
93
+ # {LLM::Guard::Null}.
94
+ # @option params [Hash] :guard_options
95
+ # Options passed to the guard's `call` method. Defaults to `{}`.
86
96
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
87
97
  # @option params [Array<String>, nil] :skills Defaults to nil
88
98
  def initialize(llm, params = {})
99
+ params = {}.merge!(params)
89
100
  @llm = llm
90
101
  @mode = params.delete(:mode) || (llm.name == :openai ? :responses : :completions)
91
- @guard = params.delete(:guard)
92
- @transformer = params.delete(:transformer)
93
102
  tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
94
103
  @params = {model: llm.default_model, schema: nil}.compact.merge!(params)
95
104
  @params[:tools] = tools unless tools.empty?
@@ -101,6 +110,14 @@ module LLM
101
110
  klass: params.delete(:compactor) || LLM::Compactor::Null,
102
111
  options: params.delete(:compactor_options) || {}
103
112
  }
113
+ @transformer = {
114
+ klass: params.delete(:transformer) || LLM::Transformer::Null,
115
+ options: params.delete(:transformer_options) || {}
116
+ }
117
+ @guard = {
118
+ klass: params.delete(:guard) || LLM::Guard::Null,
119
+ options: params.delete(:guard_options) || {}
120
+ }
104
121
  end
105
122
 
106
123
  ##
@@ -126,58 +143,35 @@ module LLM
126
143
  alias_method :compacted?, :compacted
127
144
 
128
145
  ##
129
- # Returns a guard, if configured.
146
+ # Returns the configured guard class.
130
147
  #
131
148
  # Guards are context-level supervisors for agentic execution. A guard can
132
149
  # inspect the runtime state and decide whether pending tool work should be
133
150
  # blocked before the context keeps looping.
134
151
  #
135
- # The built-in implementation is {LLM::LoopGuard LLM::LoopGuard}, which
152
+ # The guard is stamped onto the functions the context binds, so it runs
153
+ # whenever a task is spawned — including tool calls queued from a stream
154
+ # via {LLM::Stream#on_tool_call}. A blocked call yields its in-band
155
+ # `guard_error` return without executing.
156
+ #
157
+ # The built-in implementation is {LLM::Guard::Loop LLM::Guard::Loop}, which
136
158
  # detects repeated tool-call patterns and turns them into in-band
137
- # {LLM::GuardError LLM::GuardError} tool returns.
159
+ # `guard_error` tool returns.
138
160
  #
139
- # @return [#call, nil]
161
+ # @return [Class<LLM::Guard>]
140
162
  def guard
141
- return if @guard.nil? || @guard == false
142
- @guard = LLM::LoopGuard.new if @guard == true
143
- @guard = LLM::LoopGuard.new(@guard) if Hash === @guard
144
- @guard
145
- end
146
-
147
- ##
148
- # Sets a guard or guard config.
149
- #
150
- # Guards must implement `call(ctx)` and return either `nil` or a warning
151
- # string. Returning a warning tells the context to block pending tool work
152
- # with guarded tool errors instead of continuing the loop.
153
- #
154
- # @param [#call, Hash, Boolean, nil] guard
155
- # @return [#call, Hash, Boolean, nil]
156
- def guard=(guard)
157
- @guard = guard
163
+ @guard[:klass]
158
164
  end
159
165
 
160
166
  ##
161
- # Returns a transformer, if configured.
167
+ # Returns the configured transformer class.
162
168
  #
163
- # Transformers can rewrite outgoing prompts and params before a request is
164
- # sent to the provider.
169
+ # Transformers rewrite the most recent message before it is sent to the
170
+ # provider.
165
171
  #
166
- # @return [#call, nil]
172
+ # @return [Class<LLM::Transformer>]
167
173
  def transformer
168
- @transformer
169
- end
170
-
171
- ##
172
- # Sets a transformer.
173
- #
174
- # Transformers must implement `call(ctx, prompt, params)` and return a
175
- # two-element array of `[prompt, params]`.
176
- #
177
- # @param [#call, nil] transformer
178
- # @return [#call, nil]
179
- def transformer=(transformer)
180
- @transformer = transformer
174
+ @transformer[:klass]
181
175
  end
182
176
 
183
177
  # Interact with the context via the chat completions API.
@@ -197,10 +191,12 @@ module LLM
197
191
  repair!(@messages, prompt)
198
192
  prompt, params, res = mode == :responses ? respond(prompt, params) : complete(prompt, params)
199
193
  self.compacted = false
200
- role = params[:role] || @llm.user_role
201
- role = @llm.tool_role if params[:role].nil? && [*prompt].grep(LLM::Function::Return).any?
202
- @messages.concat LLM::Prompt === prompt ? prompt.to_a : [LLM::Message.new(role, prompt)]
203
- @messages.concat [res.choices[-1]].compact
194
+ if prompt.all?(&:tool_return?)
195
+ @messages.concat prompt.map { LLM::Message.new(@llm.tool_role, _1.content, _1.extra) }
196
+ else
197
+ @messages.concat(prompt)
198
+ end
199
+ @messages.concat([res.choices[-1]].compact)
204
200
  res
205
201
  ensure
206
202
  @owner = nil
@@ -245,6 +241,7 @@ module LLM
245
241
  # @return [Array<LLM::Function>]
246
242
  def pending_functions
247
243
  return_ids = returns.map(&:id)
244
+ guard = @guard[:klass].new(self)
248
245
  @messages
249
246
  .select(&:assistant?)
250
247
  .flat_map do |msg|
@@ -252,9 +249,11 @@ module LLM
252
249
  fns.each do |fn|
253
250
  fn.tracer = tracer
254
251
  fn.model = msg.model
252
+ fn.guard = guard
255
253
  end
256
254
  end.extend(LLM::Function::Array)
257
255
  end
256
+
258
257
  ##
259
258
  # Returns whether there is pending tool work in this context.
260
259
  # This prefers queued streamed tool work when present, and otherwise
@@ -268,15 +267,10 @@ module LLM
268
267
  ##
269
268
  # Spawns a function through the context.
270
269
  #
271
- # When a guard is configured, this method can return an in-band guarded
272
- # tool error instead of spawning work.
273
- #
274
270
  # @param [LLM::Function] function
275
271
  # @param [Symbol] strategy
276
- # @return [LLM::Function::Return, LLM::Function::Task]
272
+ # @return [LLM::Function::Task]
277
273
  def spawn(function, strategy)
278
- warning = guard&.call(self)
279
- return guarded_return_for(function, warning) if warning
280
274
  function.task(strategy)
281
275
  end
282
276
 
@@ -310,9 +304,11 @@ module LLM
310
304
  # @return [Array<LLM::Function::Return>]
311
305
  def wait(strategy, except: [])
312
306
  if stream.queue.empty?
313
- tools = except.empty? ? pending_functions : pending_functions - except
314
- guards = guarded_returns(tools:)
315
- return guards if guards
307
+ ##
308
+ # Every pending function is spawned as a task that checks its own
309
+ # guard (stamped on the function) before running. Blocked tasks
310
+ # yield their guard's return, so all pending calls still close.
311
+ tools = except.empty? ? pending_functions : pending_functions - except
316
312
  @queue = tools.task(strategy)
317
313
  returns = @queue.wait
318
314
  emit_tool_returns(tools, returns)
@@ -513,65 +509,52 @@ module LLM
513
509
  [*skills].map { LLM::Skill.load(_1).to_tool(self) }
514
510
  end
515
511
 
516
- ##
517
- # Builds in-band guarded returns when the guard blocks tool work.
518
- # @api private
519
- def guarded_returns(tools:)
520
- warning = guard&.call(self)
521
- return unless warning
522
- tools.map { guarded_return_for(_1, warning) }
523
- end
524
-
525
512
  ##
526
513
  # Rewrites a prompt and params through the configured transformer.
527
514
  # @api private
528
- def transform(prompt, params)
529
- transformer = self.transformer
530
- return [prompt, params] unless transformer
515
+ def transform(prompt, params, key: :messages)
516
+ transformer = @transformer[:klass].new(self)
531
517
  stream = params[:stream]
532
- stream.on_transform(self, transformer)
533
- transformer.call(self, prompt, params)
518
+ stream.on_transform(transformer)
519
+ role = params[:role] || @llm.user_role
520
+ messages = @llm.build_messages(prompt, params, role, key:)
521
+ messages[-1] = transformer.call(message: messages[-1], **@transformer[:options])
522
+ messages
534
523
  ensure
535
- stream.on_transform_finish(self, transformer) if transformer
524
+ stream.on_transform_finish(transformer)
536
525
  end
537
526
 
538
527
  ##
539
528
  # Executes a turn through the Responses API.
540
529
  # @api private
541
530
  def respond(prompt, params)
531
+ history = @messages.to_a
542
532
  params = @params.merge(params)
543
- extra = params.slice(:model, :tools).merge!(ctx: self, tracer:)
533
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
544
534
  params[:stream] = LLM::Stream.try(params[:stream], extra:)
545
- prompt, params = transform(prompt, params)
546
- @stream = params[:stream]
547
535
  res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
548
- input = res_id ? [] : @messages.to_a
536
+ input = res_id ? [] : history
537
+ params[:input] = input
538
+ messages = transform(prompt, params, key: :input)
539
+ @stream = params[:stream]
540
+ new_messages = messages[input.size..]
549
541
  params = params.merge(previous_response_id: res_id, input:).compact
550
- [prompt, params, @llm.responses.create(prompt, params)]
542
+ [new_messages, params, @llm.responses.create(messages, params)]
551
543
  end
552
544
 
553
545
  ##
554
546
  # Executes a turn through the chat completions API.
555
547
  # @api private
556
548
  def complete(prompt, params)
557
- params = params.merge(messages: @messages.to_a)
549
+ history = @messages.to_a
550
+ params = params.merge(messages: history)
558
551
  params = @params.merge(params)
559
- extra = params.slice(:model, :tools).merge!(ctx: self, tracer:)
552
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
560
553
  params[:stream] = LLM::Stream.try(params[:stream], extra:)
561
- prompt, params = transform(prompt, params)
554
+ messages = transform(prompt, params)
562
555
  @stream = params[:stream]
563
- [prompt, params, @llm.complete(prompt, params)]
564
- end
565
-
566
- ##
567
- # Builds one guarded tool return for a blocked function call.
568
- # @api private
569
- def guarded_return_for(function, warning)
570
- LLM::Function::Return.new(function.id, function.name, {
571
- error: true,
572
- type: LLM::GuardError.name,
573
- message: warning
574
- })
556
+ new_messages = messages[history.size..]
557
+ [new_messages, params, @llm.complete(messages, params)]
575
558
  end
576
559
 
577
560
  ##
data/lib/llm/cost.rb CHANGED
@@ -7,27 +7,27 @@
7
7
  # and reasoning costs separately and can return the total.
8
8
  #
9
9
  # @attr [Float] input_costs
10
- # Returns the input cost
10
+ # Returns the input cost, aliased as `input`
11
11
  # @attr [Float] output_costs
12
- # Returns the output cost
12
+ # Returns the output cost, aliased as `output`
13
13
  # @attr [Float, nil] input_audio_costs
14
14
  # Returns the input audio cost, or nil when no input audio tokens
15
- # were used
15
+ # were used, aliased as `input_audio`
16
16
  # @attr [Float, nil] output_audio_costs
17
17
  # Returns the output audio cost, or nil when no output audio tokens
18
- # were used
18
+ # were used, aliased as `output_audio`
19
19
  # @attr [Float, nil] input_image_costs
20
20
  # Returns the input image cost, or nil when no input image tokens
21
- # were used
21
+ # were used, aliased as `input_image`
22
22
  # @attr [Float, nil] cache_read_costs
23
23
  # Returns the cache read cost, or nil when no cache tokens
24
- # were used
24
+ # were used, aliased as `cache_read`
25
25
  # @attr [Float, nil] cache_write_costs
26
26
  # Returns the cache write cost, or nil when no cache creation
27
- # tokens were used
27
+ # tokens were used, aliased as `cache_write`
28
28
  # @attr [Float, nil] reasoning_costs
29
29
  # Returns the reasoning cost, or nil when no reasoning tokens
30
- # were used
30
+ # were used, aliased as `reasoning`
31
31
  class LLM::Cost < Struct.new(
32
32
  :input_costs, :output_costs,
33
33
  :input_audio_costs, :output_audio_costs,
@@ -82,15 +82,10 @@ class LLM::Cost < Struct.new(
82
82
  # Returns a hash with the non-nil cost components and the total
83
83
  def to_h
84
84
  {
85
- input: input_costs,
86
- output: output_costs,
87
- input_audio: input_audio_costs,
88
- output_audio: output_audio_costs,
89
- input_image: input_image_costs,
90
- cache_read: cache_read_costs,
91
- cache_write: cache_write_costs,
92
- reasoning: reasoning_costs,
93
- total: total
85
+ input:, output:,
86
+ cache_read:, cache_write:,
87
+ input_audio:, output_audio:, input_image:,
88
+ reasoning:, total:
94
89
  }.compact
95
90
  end
96
91
 
@@ -100,4 +95,15 @@ class LLM::Cost < Struct.new(
100
95
  def to_s
101
96
  format("%.12f", total).sub(/\.?0+$/, "")
102
97
  end
98
+
99
+ ##
100
+ # Aliases
101
+ alias_method :input, :input_costs
102
+ alias_method :output, :output_costs
103
+ alias_method :input_audio, :input_audio_costs
104
+ alias_method :output_audio, :output_audio_costs
105
+ alias_method :cache_read, :cache_read_costs
106
+ alias_method :cache_write, :cache_write_costs
107
+ alias_method :input_image, :input_image_costs
108
+ alias_method :reasoning, :reasoning_costs
103
109
  end
data/lib/llm/error.rb CHANGED
@@ -55,14 +55,6 @@ module LLM
55
55
  # When the context window is exceeded
56
56
  ContextWindowError = Class.new(InvalidRequestError)
57
57
 
58
- ##
59
- # When stuck in a tool call loop
60
- ToolLoopError = Class.new(Error)
61
-
62
- ##
63
- # When a guard blocks pending tool execution
64
- GuardError = Class.new(Error)
65
-
66
58
  ##
67
59
  # When a request is interrupted
68
60
  Interrupt = Class.new(Error)
@@ -32,6 +32,7 @@ module LLM::Function::Async
32
32
  # pushed to a queue that {#wait} consumes.
33
33
  # @return [nil]
34
34
  def spawn
35
+ return if @guarded
35
36
  @queue = Queue.new
36
37
  @alive = true
37
38
  @reactor.submit do
@@ -66,6 +67,7 @@ module LLM::Function::Async
66
67
  # Wait for the result queue to contain a value.
67
68
  # @return [LLM::Function::Return]
68
69
  def wait
70
+ return @guarded if @guarded
69
71
  spawn unless @queue
70
72
  result = @queue.pop
71
73
  @alive = false
@@ -22,6 +22,7 @@ module LLM::Function::Fiber
22
22
  ##
23
23
  # @return [nil]
24
24
  def spawn
25
+ return if @guarded
25
26
  if Fiber.scheduler.nil?
26
27
  raise ArgumentError, "Fiber concurrency requires Fiber.scheduler"
27
28
  else
@@ -48,6 +49,7 @@ module LLM::Function::Fiber
48
49
  ##
49
50
  # @return [LLM::Function::Return]
50
51
  def wait
52
+ return @guarded if @guarded
51
53
  spawn unless @fiber
52
54
  @result ||= @fiber.value
53
55
  end
@@ -20,6 +20,7 @@ class LLM::Function
20
20
  ##
21
21
  # @return [LLM::Function::Fork::Task]
22
22
  def spawn
23
+ return if @guarded
23
24
  @span = @tracer&.on_tool_start(
24
25
  id: @function.id, name: @function.name,
25
26
  arguments: @function.arguments, model: @function.model
@@ -59,6 +60,7 @@ class LLM::Function
59
60
  ##
60
61
  # @return [LLM::Function::Return]
61
62
  def wait
63
+ return @guarded if @guarded
62
64
  spawn unless @spawned
63
65
  kind, data = @ch.result.recv
64
66
  raise LLM::Interrupt if kind == :interrupt
@@ -33,6 +33,7 @@ class LLM::Function
33
33
  ##
34
34
  # @return [LLM::Function::Ractor::Task]
35
35
  def spawn
36
+ return if @guarded
36
37
  @span = @tracer&.on_tool_start(
37
38
  id: @id, name: @name,
38
39
  arguments: @arguments, model: @model
@@ -58,6 +59,7 @@ class LLM::Function
58
59
  ##
59
60
  # @return [LLM::Function::Return]
60
61
  def wait
62
+ return @guarded if @guarded
61
63
  spawn unless @mailbox
62
64
  id, name, value = mailbox.wait
63
65
  result = Return.new(id, name, value)
@@ -39,7 +39,10 @@ module LLM::Function::Sequential
39
39
  # @return [Array<LLM::Function::Return>]
40
40
  def wait
41
41
  @owner = Thread.current
42
- @functions.map(&:call)
42
+ ##
43
+ # Sequential groups call functions directly (no tasks), so each
44
+ # function's guard is checked here instead.
45
+ @functions.map { |function| function.guard&.call(function:) || function.call }
43
46
  ensure
44
47
  @owner = nil
45
48
  end
@@ -36,7 +36,7 @@ module LLM::Function::Sequential
36
36
  ##
37
37
  # @return [LLM::Function::Return]
38
38
  def wait
39
- @result ||= function.call
39
+ @result ||= @guarded || function.call
40
40
  end
41
41
  alias_method :value, :wait
42
42
 
@@ -16,8 +16,12 @@ class LLM::Function
16
16
  # @param [Hash] options
17
17
  # An optional set of options that are specific
18
18
  # to a given concurrency strategy.
19
+ # @option options [LLM::Function::Return, nil] :guarded
20
+ # A blocked return produced by the function's guard. When set, the
21
+ # task yields it without running the tool.
19
22
  def initialize(fn, options = {})
20
23
  @function = fn
24
+ @guarded = options[:guarded]
21
25
  end
22
26
 
23
27
  ##
@@ -23,6 +23,7 @@ module LLM::Function::Thread
23
23
  ##
24
24
  # @return [nil]
25
25
  def spawn
26
+ return if @guarded
26
27
  @thread = ::Thread.new { function.call }
27
28
  @thread.report_on_exception = false
28
29
  nil
@@ -46,6 +47,7 @@ module LLM::Function::Thread
46
47
  ##
47
48
  # @return [LLM::Function::Return]
48
49
  def wait
50
+ return @guarded if @guarded
49
51
  spawn unless @thread
50
52
  @thread.value
51
53
  end
data/lib/llm/function.rb CHANGED
@@ -155,6 +155,13 @@ class LLM::Function
155
155
  # @return [String, nil]
156
156
  attr_accessor :model
157
157
 
158
+ ##
159
+ # Returns the guard class that protects this function, or nil.
160
+ # The context stamps the guard onto the functions it binds, so any task
161
+ # built from this function checks it before the tool runs.
162
+ # @return [Class<LLM::Guard>, nil]
163
+ attr_accessor :guard
164
+
158
165
  ##
159
166
  # @param [String] name The function name
160
167
  # @yieldparam [LLM::Function] self The function object
@@ -250,6 +257,11 @@ class LLM::Function
250
257
  # @return [LLM::Function::Task]
251
258
  # Returns a task whose `#value` is an {LLM::Function::Return}.
252
259
  def task(strategy, options = {})
260
+ ##
261
+ # Check the function's guard on the calling thread before handing
262
+ # the tool to the strategy. The task carries the blocked result and
263
+ # returns it without running if the guard intervenes.
264
+ options = options.merge(guarded: @guard&.call(function: self))
253
265
  case strategy
254
266
  when :sequential
255
267
  Sequential::Task.new(self, options)
@@ -340,16 +352,32 @@ class LLM::Function
340
352
  end
341
353
 
342
354
  ##
343
- # Returns an in-band error for a tool loop rate limit.
355
+ # Returns an in-band error that indicates the tool
356
+ # call budget has been spent.
344
357
  # @return [LLM::Function::Return]
345
- def rate_limit
358
+ def budget_spent
346
359
  LLM::Function::Return.new(id, name, {
347
360
  error: true,
348
- type: LLM::ToolLoopError.name,
349
- message: "tool loop rate limit reached"
361
+ type: "LLM::BudgetSpentError",
362
+ message: "the tool call budget for this turn has been spent. " \
363
+ "try to solve the problem with less tool calls."
350
364
  })
351
365
  end
352
366
 
367
+ ##
368
+ # Builds an {LLM::Function::Return LLM::Function::Return} for this
369
+ # function, using its own id and name. The given keywords become the
370
+ # return's value.
371
+ # @note
372
+ # `return` is a Ruby keyword, so this is defined via
373
+ # {Kernel#define_method Kernel#define_method}.
374
+ # @param [Hash] value
375
+ # The return content, eg `{error: true, type: ..., message: ...}`.
376
+ # @return [LLM::Function::Return]
377
+ define_method(:return) do |value|
378
+ Return.new(id, name, value)
379
+ end
380
+
353
381
  ##
354
382
  # @return [Hash]
355
383
  def adapt(provider)