llm.rb 16.0.0.pre.9 → 16.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.
data/README.md CHANGED
@@ -235,9 +235,13 @@ isolation from its parent.
235
235
  A couple of concurrency strategies require optional, opt-in dependencies.
236
236
  The `async` strategy requires the [async](https://github.com/socketry/async)
237
237
  gem and the `fork` strategy requires the [xchan.rb](https://github.com/r-uby-dev/xchan.rb)
238
- gem. The `fiber` strategy requires a scheduler (`Fiber.scheduler`) but by
238
+ gem (`~> 0.24`). The `fiber` strategy requires a scheduler (`Fiber.scheduler`) but by
239
239
  default Ruby does not provide one.
240
240
 
241
+ The `:ractor` strategy is the least interchangeable of the six. It runs
242
+ class-based tools only, and a tool's arguments have to be
243
+ ractor-shareable.
244
+
241
245
  ```ruby
242
246
  require "llm"
243
247
  require "llm/tools"
@@ -349,6 +353,7 @@ llm.rb -n curb # use libcurl as the HTTP transport
349
353
  llm.rb -x 900 # read timeout of 15 minutes
350
354
  llm.rb -t # temporary session, no persistence
351
355
  llm.rb -v # print the version
356
+ llm.rb -h # print usage
352
357
  ```
353
358
  </details>
354
359
  <details>
@@ -762,6 +767,41 @@ agent.talk "Hello"
762
767
  </details>
763
768
 
764
769
 
770
+ <details>
771
+ <summary>Usage and cost</summary>
772
+ <br>
773
+
774
+ Every context and agent reports what a conversation has spent and how
775
+ much room is left, and the numbers answer different questions. A
776
+ `token_usage` is the whole conversation, summed as an
777
+ [LLM::Usage](https://r.uby.dev/api-docs/llm.rb/LLM/Usage.html), and it
778
+ is what [LLM::Cost](https://r.uby.dev/api-docs/llm.rb/LLM/Cost.html)
779
+ prices against the model registry:
780
+
781
+ ```ruby
782
+ require "llm"
783
+
784
+ llm = LLM.deepseek(key: ENV["KEY"])
785
+ agent = LLM::Agent.new(llm)
786
+ agent.talk "Hello"
787
+
788
+ agent.token_usage # => LLM::Usage for the whole conversation
789
+ agent.cost # => LLM::Cost, priced from the registry
790
+ ```
791
+
792
+ A `context_used` is one turn's worth - the live size of the most recent
793
+ assistant message - so it is what a context window is really being
794
+ spent on, and `context_usage` is that as a fraction of the window:
795
+
796
+ ```ruby
797
+ agent.context_used # => tokens in the latest turn
798
+ agent.context_window # => the model's limit, or nil when unknown
799
+ agent.context_usage # => Rational, eg Rational(100, 10_000)
800
+ ```
801
+
802
+ </details>
803
+
804
+
765
805
  <details>
766
806
  <summary>Observability</summary>
767
807
  <br>
@@ -892,6 +932,11 @@ llm = LLM.moonshot(key: ENV["MOONSHOT_API_KEY"])
892
932
  llm = LLM.openrouter(key: ENV["OPENROUTER_API_KEY"])
893
933
  llm = LLM.alibaba(key: ENV["DASHSCOPE_API_KEY"]) # also: LLM.aliyun
894
934
  llm = LLM.mistral(key: ENV["MISTRAL_API_KEY"])
935
+ llm = LLM.bedrock(
936
+ access_key_id: ENV["AWS_ACCESS_KEY_ID"],
937
+ secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
938
+ region: ENV["AWS_REGION"]
939
+ )
895
940
  ```
896
941
  </details>
897
942
 
@@ -85,7 +85,8 @@ make is one nobody asked for any more.
85
85
 
86
86
  How an interrupt reaches a tool depends on the strategy. The
87
87
  `:ractor` strategy delivers it through ractor message passing, and
88
- `:fork` via a message over the xchan control channel. The `:thread`
88
+ `:fork` via a message over the control channel the `xchan.rb` gem
89
+ provides. The `:thread`
89
90
  strategy raises it on the thread that runs the tool. The `:fiber`
90
91
  and `:async` strategies ask the fiber scheduler for the raise,
91
92
  because a scheduled fiber cannot be entered from another thread -
@@ -109,3 +110,19 @@ The runtime's own path out of a turn does not swallow an interrupt:
109
110
  [`LLM::Context#try`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#try)
110
111
  re-raises anything that is not a retryable failure, and the tracer
111
112
  interface has no broad rescues of its own.
113
+
114
+ The names are worth separating.
115
+ [`LLM::Context#interrupt!`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#interrupt!-instance_method)
116
+ and
117
+ [`LLM::Function#interrupt!`](https://r.uby.dev/api-docs/llm.rb/LLM/Function.html#interrupt!-instance_method)
118
+ are each aliased to `cancel!`, so `cancel!` ends what is in flight and
119
+ tells a tool, and the two spellings are the same call.
120
+ [`LLM::Function#cancel`](https://r.uby.dev/api-docs/llm.rb/LLM/Function.html#cancel-instance_method)
121
+ is something else: it answers a call that has not run with a cancelled
122
+ return and marks it cancelled, which is how a caller declines a pending
123
+ tool rather than interrupting one. A tool hears about an interrupt
124
+ through
125
+ [`LLM::Tool#on_interrupt`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html#on_interrupt-instance_method),
126
+ or
127
+ [`LLM::Tool#on_cancel`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html#on_cancel-instance_method),
128
+ which takes precedence when both are defined.
@@ -24,7 +24,10 @@ It preserves tool call/return pairs so the conversation never
24
24
  contains an orphaned result.
25
25
 
26
26
  The `keep:` parameter accepts an integer count or a percentage
27
- string like `"80%"`. The default compactor is
27
+ string like `"80%"`, and defaults to `64`. When `Truncate` is used
28
+ through a context or an agent, `keep:` is given as
29
+ `compactor_options: {keep: N}` rather than to the compactor itself.
30
+ The default compactor is
28
31
  [`LLM::Compactor::Null`](https://r.uby.dev/api-docs/llm.rb/LLM/Compactor/Null.html),
29
32
  which does nothing. A compactor can also be used standalone:
30
33
 
@@ -332,7 +332,8 @@ which can be helpful when the interrupt is temporary.
332
332
 
333
333
  The mechanism is the same across all six concurrency strategies.
334
334
  The `:ractor` strategy delivers the interrupt through ractor
335
- message passing. The `:fork` strategy delivers it via xchan.
335
+ message passing. The `:fork` strategy delivers it over the control
336
+ channel the `xchan.rb` gem provides.
336
337
 
337
338
  ### Messages
338
339
 
@@ -94,7 +94,7 @@ inside a reactor. Requires the `async` gem.
94
94
  **fork**: Each tool runs in a forked child process. True parallelism
95
95
  and process isolation. Best for shell commands, native extensions,
96
96
  or anything you do not want touching the parent's memory. Requires
97
- the `xchan` gem.
97
+ the `xchan.rb` gem (`~> 0.24`).
98
98
 
99
99
  **ractor**: Each class-based tool runs in a Ruby Ractor. True
100
100
  parallelism without the overhead of forking full processes. Only
@@ -106,5 +106,5 @@ class-based tools are supported. Arguments must be ractor-shareable.
106
106
  | `:thread` | `Thread` | IO only (GVL) | No | None |
107
107
  | `:fiber` | `Fiber.schedule` | Cooperative | No | `Fiber.scheduler` |
108
108
  | `:async` | `Async::Reactor` | Cooperative | No | `async` gem |
109
- | `:fork` | `Kernel.fork` | Yes (process) | Yes (memory) | `xchan` gem |
109
+ | `:fork` | `Kernel.fork` | Yes (process) | Yes (memory) | `xchan.rb` gem |
110
110
  | `:ractor` | `Ractor` | Yes (CPU) | Limited | None |
@@ -79,6 +79,46 @@ and
79
79
  helpers write a formatted `user:` message with a trailing newline,
80
80
  and `LLM::Command#write_message` matches the same interface.
81
81
 
82
+ ### The `llm.rb` executable
83
+
84
+ #### Overview
85
+
86
+ The console also starts from a shell. After the gem is installed,
87
+ `llm.rb` puts a console on your PATH and opens a session in the current
88
+ directory, so there is no Ruby file to write.
89
+
90
+ #### How it works
91
+
92
+ Run `llm.rb` with no arguments to auto-detect a provider from the usual
93
+ environment variables, or name one with `-p`. The session for the
94
+ current directory is kept under `~/.llm.rb/` and restored on the next
95
+ visit, unless `-t` asks for a temporary one:
96
+
97
+ ```bash
98
+ llm.rb # auto-detect from $PROVIDER_API_KEY
99
+ llm.rb -p openai # use OpenAI explicitly
100
+ llm.rb -m gpt-5.6 # use a model other than the provider default
101
+ llm.rb -c thread # run tool calls on a separate thread
102
+ llm.rb -n curb # use libcurl as the HTTP transport
103
+ llm.rb -x 900 # read timeout of 15 minutes
104
+ llm.rb -t # temporary session, no persistence
105
+ llm.rb -v # print the version
106
+ llm.rb -h # print usage
107
+ ```
108
+
109
+ #### Why would I use it?
110
+
111
+ It is the shortest path to a working session: no script, no `require`,
112
+ and a conversation that is still there tomorrow. Reach for
113
+ `agent.console` instead when the agent needs a provider, tools, or a
114
+ tracer that only your application can supply.
115
+
116
+ #### Notes
117
+
118
+ The switches are the console's own, so `-c`, `-m`, and `-p` set the
119
+ same things `agent.console` takes from its agent. `-v` and `-h` print
120
+ and exit.
121
+
82
122
  ### Switch the model
83
123
 
84
124
  The active model is exposed through
@@ -42,8 +42,8 @@ pattern is one of the most common AI workflows.
42
42
 
43
43
  #### Notes
44
44
 
45
- Not all providers support embeddings. The following providers have
46
- an `embed` method:
45
+ Not all providers support embeddings. The following providers have an
46
+ `embed` method of their own:
47
47
 
48
48
  - OpenAI (`text-embedding-3-small`)
49
49
  - DeepInfra (`BAAI/bge-m3`)
@@ -53,6 +53,11 @@ an `embed` method:
53
53
  - OpenRouter (`openai/text-embedding-3-small`)
54
54
  - Bedrock (the model is given per call)
55
55
 
56
+ The providers built on OpenAI inherit its `embed`, so DeepSeek, xAI,
57
+ Z.ai, Moonshot, Alibaba, and llama.cpp answer it too, against their own
58
+ OpenAI-compatible endpoint. Anthropic is the exception: it defines no
59
+ `embed`, so the call raises `NotImplementedError`.
60
+
56
61
  ### Vector stores (OpenAI)
57
62
 
58
63
  #### Overview
@@ -45,8 +45,10 @@ assistants that read them, get their content.
45
45
 
46
46
  #### Notes
47
47
 
48
- OpenAI, Anthropic, and Google implement the Files API; a provider that
49
- does not raises `NotImplementedError`. `download` is OpenAI-only. A
50
- file id belongs to the provider that minted it: DeepSeek, for example,
51
- has no Files API and raises `LLM::PromptError` when it is handed a file
52
- id from another provider.
48
+ Anthropic, Google, and OpenAI implement the Files API, and so do the
49
+ providers built on OpenAI's - Moonshot and Alibaba among them, which
50
+ inherit it. A provider that has no Files API of its own, such as
51
+ DeepSeek, Mistral, or xAI, raises `NotImplementedError`. `download` is
52
+ OpenAI-only, so the OpenAI-compatible providers inherit that too. A file
53
+ id belongs to the provider that minted it: DeepSeek, for example, raises
54
+ `LLM::PromptError` when it is handed a file id from another provider.
@@ -318,4 +318,55 @@ endpoint. Token Plan users should point the provider at their own
318
318
  Token Plan URL via `DASHSCOPE_API_HOST` or `host:`. Check your
319
319
  Model Studio dashboard for the correct endpoint.
320
320
 
321
- Model metadata ships in `data/alibaba.json` for the registry.
321
+ Model metadata ships in `data/alibaba.json` for the registry.
322
+
323
+ ### Bedrock
324
+
325
+ #### Overview
326
+
327
+ [`LLM::Bedrock`](https://r.uby.dev/api-docs/llm.rb/LLM/Bedrock.html)
328
+ talks to [Amazon Bedrock](https://aws.amazon.com/bedrock/) through the
329
+ [Converse API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html),
330
+ which reaches models from Anthropic, Meta, Mistral, Cohere, and others
331
+ behind one endpoint. It is created with
332
+ [`LLM.bedrock`](https://r.uby.dev/api-docs/llm.rb/LLM.html#bedrock-class_method).
333
+
334
+ #### How it works
335
+
336
+ Bedrock is the one provider that does not take a `key:`. It signs each
337
+ request with AWS Signature V4, so it wants an access key, a secret key,
338
+ and a region:
339
+
340
+ ```ruby
341
+ require "llm"
342
+
343
+ llm = LLM.bedrock(
344
+ access_key_id: ENV["AWS_ACCESS_KEY_ID"],
345
+ secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
346
+ region: "us-east-1"
347
+ )
348
+ ctx = LLM::Context.new(llm)
349
+ ctx.talk "Hello"
350
+ ```
351
+
352
+ Each of the three falls back to its own environment variable
353
+ (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`), so
354
+ `LLM.bedrock` alone works in an environment that already has
355
+ credentials. `region:` defaults to `us-east-1`, and the host is
356
+ `bedrock-runtime.<region>.amazonaws.com`.
357
+
358
+ #### Why would I use it?
359
+
360
+ One credential reaches every model Bedrock serves, so a model can be
361
+ swapped with `model:` alone rather than by moving to another vendor's
362
+ API. For an application that already runs on AWS, it also keeps the
363
+ traffic inside the account's own region.
364
+
365
+ #### Notes
366
+
367
+ A model is named by its regional id rather than a short name - the
368
+ entries in `data/bedrock.json` look like
369
+ `anthropic.claude-sonnet-4-20250514-v1:0` or `google.gemma-4-31b`, and
370
+ some are prefixed by the region that serves them (`us-gov.`, `eu.`,
371
+ `global.`). Passing `key:` raises `ArgumentError`, because there is no
372
+ API key to pass.
@@ -133,6 +133,23 @@ renders the schema as a prompt-friendly string. `required` and
133
133
  `defaults` refer to properties that already exist, so declare the
134
134
  property first and mark it afterwards.
135
135
 
136
+ A property also takes the constraints its type supports, as options.
137
+ `min:` and `max:` bound a number's value or a string's length,
138
+ `multiple_of:` constrains a number, `const:` fixes one value, `enum:`
139
+ lists the allowed values, and `default:` gives one. Pass
140
+ `required: true`, or `required: false`, to mark a single property
141
+ without the class-level call:
142
+
143
+ ```ruby
144
+ class Form < LLM::Schema
145
+ property :name, String, "The name", min: 2, max: 8
146
+ property :age, Integer, "The age", min: 0, max: 120
147
+ property :currency, String, "The currency", enum: %w[usd eur]
148
+ property :note, String, "An optional note", required: false
149
+ required %i[name age currency]
150
+ end
151
+ ```
152
+
136
153
  ### Parsing
137
154
 
138
155
  #### Overview
@@ -151,3 +151,60 @@ agent.talk "Explain Ruby fibers."
151
151
  A stream subclass gives you visibility into more than just content chunks. React to tool
152
152
  calls as they happen, show compaction progress, or integrate with
153
153
  an existing observability stack.
154
+
155
+ ### Streamed tool execution
156
+
157
+ #### Overview
158
+
159
+ A tool call is a round trip. By default the runtime makes it after the
160
+ response has finished streaming, so the model sits idle for the length
161
+ of the tool. A stream can start the tool the moment its call is parsed
162
+ instead, and hand the result to the turn when it is asked for.
163
+
164
+ #### How it works
165
+
166
+ Start the tool from
167
+ [`LLM::Stream#on_tool_call`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_tool_call)
168
+ and enqueue its task on
169
+ [`LLM::Stream#queue`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#queue-instance_method).
170
+ [`LLM::Context#wait`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#wait-instance_method)
171
+ drains that queue in preference to the pending functions, so a tool that
172
+ was started early is waited for, not run twice:
173
+
174
+ ```ruby
175
+ class FastStream < LLM::Stream
176
+ def on_tool_call(tool)
177
+ queue << tool.task(:thread).tap(&:spawn)
178
+ end
179
+ end
180
+
181
+ llm = LLM.deepseek(key: ENV["KEY"])
182
+ agent = LLM::Agent.new(llm, stream: FastStream.new)
183
+ agent.talk "Read every file in lib/ and summarise them."
184
+ ```
185
+
186
+ [`LLM::Stream::Queue`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream/Queue.html)
187
+ takes either a running task or an immediate
188
+ [`LLM::Function::Return`](https://r.uby.dev/api-docs/llm.rb/LLM/Function/Return.html),
189
+ and
190
+ [`LLM::Stream#wait`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#wait-instance_method)
191
+ resolves whatever it holds and returns the returns. The task type is
192
+ remembered, so no strategy is passed at wait time.
193
+
194
+ #### Why would I use it?
195
+
196
+ A turn that calls several tools spends most of its wall-clock time
197
+ waiting. Starting each tool as its call arrives overlaps that wait with
198
+ the rest of the stream, so a turn that reads ten files costs roughly one
199
+ file's latency rather than ten.
200
+
201
+ #### Notes
202
+
203
+ The queue is only consulted when it is non-empty. A turn that enqueues
204
+ nothing behaves exactly as before, and a turn that enqueues from
205
+ `on_tool_call` still runs any *other* pending function through the
206
+ strategy given to
207
+ [`LLM::Context#wait`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#wait-instance_method).
208
+ A cancel reaches the queue
209
+ ([`LLM::Stream::Queue#interrupt!`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream/Queue.html#interrupt!-instance_method)),
210
+ so tools started this way are interrupted with the rest of the turn.
@@ -108,6 +108,14 @@ in the How it works section) or with
108
108
  [`LLM::Tool.set`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html#set-class_method)
109
109
  (see the Set subsection). Both approaches work the same way.
110
110
 
111
+ A parameter takes the same constraints a schema property does, as
112
+ options: `min:` and `max:` bound a number or a string's length,
113
+ `multiple_of:` constrains a number, `const:` fixes one value, and
114
+ `enum:` lists the allowed values. So
115
+ `parameter :age, Integer, "Age", min: 0, max: 120` sends the range to
116
+ the model rather than a bare integer. The Schema topic covers the
117
+ keywords in full.
118
+
111
119
  ### Confirmation
112
120
 
113
121
  #### Overview
@@ -48,12 +48,22 @@ class LLM::Tool
48
48
  # Wait for a command to finish, or abort
49
49
  # with an error when it exceeds the
50
50
  # specified timeout.
51
+ #
52
+ # **A command is waited on until its status is decided, not only until it
53
+ # has stopped running.** `running?` answers whether the process is there,
54
+ # and `success?` answers `nil` until the command has been reaped - so a
55
+ # loop that trusts `running?` alone can leave before the status exists, and
56
+ # the caller reads `ok: nil` beside an output the read had waited for.
57
+ #
58
+ # **`running?` is what decides it.** The command reaps the process as it
59
+ # answers, so every turn of this loop is what makes a status exist - which
60
+ # is why the condition is asked again on each turn rather than read once.
51
61
  # @param [Test::Command] command
52
62
  # @param [Integer] timeout
53
63
  # @return [void]
54
64
  def wait(command:, timeout:)
55
65
  start = now
56
- while command.running?
66
+ while command.running? || status_unavailable?(command)
57
67
  if now - start > timeout
58
68
  command.kill!
59
69
  raise "command timed out after #{timeout}s"
@@ -96,6 +106,20 @@ class LLM::Tool
96
106
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
107
  end
98
108
 
109
+ private
110
+
111
+ ##
112
+ # Whether a command has not been reaped yet.
113
+ #
114
+ # `success?` answers `nil` until the status exists, and a command that was
115
+ # never found is the one case where it never will: there was nothing to
116
+ # reap, and `not_found?` is the answer the caller wants.
117
+ # @param [Test::Command] command
118
+ # @return [Boolean]
119
+ def status_unavailable?(command)
120
+ command.success?.nil? and !command.not_found?
121
+ end
122
+
99
123
  ##
100
124
  # requires test-cmd.rb
101
125
  LLM.require "test-cmd.rb", "~> 2.7.1"
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "16.0.0.pre.9"
4
+ VERSION = "16.0.0"
5
5
  end
data/llm.gemspec CHANGED
@@ -10,9 +10,11 @@ Gem::Specification.new do |spec|
10
10
 
11
11
  spec.summary = "An agentic runtime for Ruby"
12
12
  spec.description = <<~DESCRIPTION
13
- llm.rb is a runtime for building agentic AI applications on CRuby.
14
- It has zero runtime dependencies by default, supports concurrent and parallel
15
- tool execution and has a single coherent API that spans 14+ providers.
13
+ llm.rb is an agentic runtime for CRuby.
14
+ It has zero runtime dependencies by default,
15
+ supports concurrent and parallel tool execution
16
+ and has a single coherent API that spans
17
+ 14+ providers.
16
18
  DESCRIPTION
17
19
 
18
20
  spec.license = "MIT"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.0.0.pre.9
4
+ version: 16.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson
@@ -362,9 +362,11 @@ dependencies:
362
362
  - !ruby/object:Gem::Version
363
363
  version: '2.45'
364
364
  description: |
365
- llm.rb is a runtime for building agentic AI applications on CRuby.
366
- It has zero runtime dependencies by default, supports concurrent and parallel
367
- tool execution and has a single coherent API that spans 14+ providers.
365
+ llm.rb is an agentic runtime for CRuby.
366
+ It has zero runtime dependencies by default,
367
+ supports concurrent and parallel tool execution
368
+ and has a single coherent API that spans
369
+ 14+ providers.
368
370
  email:
369
371
  - robert@r.uby.dev
370
372
  executables: