llm.rb 16.0.0.pre.3 → 16.0.0.pre.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8bd3b4f303036ba390981de3ec63ea0bb523cc653c4b77b221e4591e9b9a446
4
- data.tar.gz: 3036ce3c4248e3ddad524657f31ea2c300f98282ad0aa68791848f56cd1b91f5
3
+ metadata.gz: f5558a0514d6bd6b9648f743a308ce8e6dbb3802b8709edc6caf45d7c1ab3de8
4
+ data.tar.gz: a5d3178f0c79e152b8032cce974be69edd1e52cc629658034a750183ccb41ea8
5
5
  SHA512:
6
- metadata.gz: 604bafd36e8a55405e3fc487957ae97b9e4ffd67777711c071f7bfb9cfe005e0400294c089e72fbec3019538f4706c92cfe5bb15f3d0c2f994a324b9ba1a3322
7
- data.tar.gz: d3bf4d7e1f9337cd7bdedb40ac4cfb4f95b6195e6df3360d428bfa6c220994edfaf8c5f053256e968c1feb9ae025b29854586dd9834dc84948729644f7a7b8ff
6
+ metadata.gz: 3a0288112d140d3e0a3e34dc35e570ceb25ad62fd7f3c33a2942cb111432992b7194e18fb406bf55a25a945aff0e89ce055c292a1de50c00b66c801027b95c52
7
+ data.tar.gz: 454a1c74a59401689c88947714f741ada1d39f6ce95013bf8bdd6fab76690adafd3a4be8af02e9eace2a71ae26c3f4daf3a1751fdf326549f722da888cbcb309
data/CHANGELOG.md CHANGED
@@ -46,6 +46,39 @@
46
46
  [`LLM::Message`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html) objects.
47
47
  Every other format, and Sequel, still loads the runtime.
48
48
 
49
+ ### ActiveRecord
50
+
51
+ * **activerecord: add `messages!` for the runtime's own messages** <br>
52
+ [`LLM::ActiveRecord#messages!`](https://r.uby.dev/api-docs/llm.rb/LLM/ActiveRecord.html#messages!-instance_method)
53
+ returns the messages the runtime holds whatever the storage format is, so a
54
+ jsonb record can still reach them now that its `#messages` answers with the
55
+ view over the column. It reads what the context holds, including state that
56
+ has not been saved, where `#messages` reads the column. The Sequel plugin
57
+ answers to `#messages!` too, where it is the same call as `#messages`.
58
+
59
+ ### Agent
60
+
61
+ * **agent: keep the instructions it injected in step** <br>
62
+ [`LLM::Agent`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html) now brings
63
+ the instructions it injected up to date before a request goes out, so a
64
+ conversation restored from saved state runs on the agent's current
65
+ instructions rather than on the ones it was saved with. The message is
66
+ found by a mark the agent puts on it, so a message the caller composed is
67
+ never replaced, and the mark is written by
68
+ [`LLM::Message#to_h`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html#to_h-instance_method)
69
+ and read back on a restore, which is the conversation the refresh exists
70
+ for. Before this, the instructions were injected once and the message
71
+ stayed as it was for the life of the conversation.
72
+
73
+ * **agent: identify its own instructions by the mark, not by the role** <br>
74
+ Fix a bug where the agent decided whether it had already injected its
75
+ instructions by looking for a message with the `system` role. Google gives
76
+ instructions the `user` role, so the agent did not recognise the message it
77
+ had already injected and added another copy of the instructions on every
78
+ turn. It now asks the provider which role instructions are given, and
79
+ identifies the message it wrote by its mark, because on Google the message
80
+ a caller seeds a conversation with is a user message too.
81
+
49
82
  ### Console
50
83
 
51
84
  * **console: erase with the backspace key on OpenBSD** <br>
@@ -115,6 +148,35 @@
115
148
  `respond_to?` with false for an instance method. A hook that raises becomes
116
149
  what the caller sees in place of the call's result.
117
150
 
151
+ ### ORM
152
+
153
+ * **orm: save a conversation after each request, not once per turn** <br>
154
+ [`LLM::Step`](https://r.uby.dev/api-docs/llm.rb/LLM/Step.html) is prepended
155
+ onto every stream by
156
+ [`LLM::Stream.try`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#try-class_method),
157
+ so a conversation bound to a record is written down as it goes and a turn
158
+ that is interrupted can be continued rather than started over. It saves
159
+ through
160
+ [`LLM::Context#record`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#record-instance_method),
161
+ an ActiveRecord model through `LLM::ActiveRecord::Utils.save!` and a Sequel
162
+ model through `LLM::Sequel::Plugin::Utils.save!`, and the write is best
163
+ effort, so a failure is swallowed rather than raised inside the request that
164
+ has just completed. Before this, `talk` and `ask` on both wrappers saved
165
+ once, when the whole turn had finished, so an interrupted turn persisted
166
+ nothing.
167
+
168
+ ### Prompt
169
+
170
+ * **prompt: let a message carry fields a caller attaches to it** <br>
171
+ [`LLM::Prompt#system`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#system-instance_method),
172
+ [`#user`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#user-instance_method),
173
+ [`#developer`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#developer-instance_method),
174
+ and [`#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#talk-instance_method),
175
+ along with its `#chat` alias, now take an `extra:` keyword, so a message a
176
+ caller composes can carry fields of its own. The runtime reads those fields
177
+ back rather than inferring provenance from a role, which is how an agent
178
+ tells the instructions it injected from a message the caller wrote.
179
+
118
180
  ### Provider
119
181
 
120
182
  * **deepseek: support image attachments in chat completions** <br>
@@ -155,6 +217,19 @@
155
217
  discarded a false default, and `required: false` marks the parameter optional
156
218
  rather than being ignored.
157
219
 
220
+ ### Stream
221
+
222
+ * **stream: add `on_step`, called when a request completes** <br>
223
+ [`LLM::Stream#on_step`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_step-instance_method)
224
+ is a new callback, emitted from
225
+ [`LLM::Context#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#talk-instance_method)
226
+ once the prompt and the response are in the conversation. It marks the
227
+ boundary between one request and the next, which is the point a stream can
228
+ checkpoint the conversation at, because a provider will accept it again
229
+ there. It fires once per successful request, so a request retried after a
230
+ rate limit or a timeout calls it when it lands and not once per attempt, and
231
+ it fires even when the stream is disabled.
232
+
158
233
  ## v15.5.0
159
234
 
160
235
  Changes since `v15.4.1`.
data/README.md CHANGED
@@ -78,7 +78,8 @@ agent.talk "hello world"
78
78
  Streams can be simple IO objects or subclasses of
79
79
  [`LLM::Stream`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html)
80
80
  with structured callbacks for content,
81
- reasoning, tool calls, tool returns, and compaction.
81
+ reasoning, tool calls, tool returns, the boundary a completed
82
+ request marks, and compaction.
82
83
  Streams can also observe message transformers, which rewrite
83
84
  outgoing messages before they reach the provider.
84
85
 
@@ -102,6 +103,11 @@ class MyStream < LLM::Stream
102
103
  def on_tool_return(tool, result)
103
104
  end
104
105
 
106
+ # A request has completed: the response is in the conversation, and
107
+ # any tools it asked for run after this.
108
+ def on_step(ctx, res)
109
+ end
110
+
105
111
  # Before a transformer rewrites an outgoing message.
106
112
  def on_transform(transformer)
107
113
  end
@@ -81,9 +81,10 @@ Pass `retry_budget:` to retry a request that was rate limited
81
81
  Each retry sleeps a growing interval (2s, 4s, 6s, ...) and notifies
82
82
  the stream through
83
83
  [`LLM::Stream#on_retry`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_retry-instance_method)
84
- before trying again. An `LLM::Agent` enables a budget of 5 by
85
- default (or 8 on the Alibaba provider, which rate limits more
86
- often), so most users never touch this directly.
84
+ before trying again. The budget lives on the provider, where it
85
+ defaults to 5 (8 on Alibaba, which rate limits more often), and an
86
+ agent that sets none of its own takes the provider's, so most users
87
+ never touch this directly.
87
88
 
88
89
  ### Identity
89
90
 
@@ -423,7 +424,9 @@ The block receives the prompt object when it takes an argument, and
423
424
  otherwise runs in the prompt's context:
424
425
  [`LLM::Prompt#to_a`](https://r.uby.dev/api-docs/llm.rb/LLM/Prompt.html#to_a)
425
426
  returns the messages in order, and two prompts are equal when their
426
- messages match.
427
+ messages match. Each of `system`, `user`, `developer`, and `talk`
428
+ accepts an `extra:` hash, which is attached to the message it builds,
429
+ so a caller can carry a field of its own alongside the content.
427
430
 
428
431
  #### Why would I use it?
429
432
 
@@ -66,9 +66,9 @@ should use the `console` names.
66
66
  When `bin/llm.rb` starts, it looks for an `AGENTS.md` file in the
67
67
  current working directory. If one exists, its contents become the
68
68
  agent's instructions for the session, so a project can describe how it
69
- wants the agent to work without passing flags. The instructions are
70
- injected once, so a resumed session that already has a system message
71
- keeps the one it has.
69
+ wants the agent to work without passing flags. The agent brings its
70
+ instructions up to date before each request, so a resumed session runs
71
+ on the current file rather than the one it was saved with.
72
72
 
73
73
  The user-message label is exposed through
74
74
  [`LLM::Console#sender`](https://r.uby.dev/api-docs/llm.rb/LLM/Console.html#sender-instance_method),
@@ -34,13 +34,23 @@ and
34
34
  for serialization and
35
35
  [`LLM::Context#restore`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#restore)
36
36
  for deserialization. Save writes the current state, restore loads
37
- it back and picks up where the conversation left off. The ORM
38
- wrappers automate this; each
39
- [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
40
- or
41
- [`LLM::Agent#ask`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#ask)
42
- call persists the
43
- updated state back to the column automatically.
37
+ it back and picks up where the conversation left off.
38
+
39
+ The ORM wrappers write on a different boundary than the file does, and
40
+ the two are worth telling apart.
41
+ [`LLM::Step`](https://r.uby.dev/api-docs/llm.rb/LLM/Step.html) is
42
+ prepended onto the stream, and it saves the conversation through the
43
+ record the context is bound to each time
44
+ [`LLM::Stream#on_step`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_step-instance_method)
45
+ fires - after a response is in the conversation and before the tools it
46
+ asked for run. A turn that runs tools before it answers is therefore
47
+ saved more than once, and an application that writes it down can
48
+ continue an interrupted turn instead of redoing it.
49
+
50
+ A `path:` has no record behind it to write to, so that one saves once
51
+ per turn. The difference is what each is for: the column is a
52
+ checkpoint after every completed request, and the file is a session at
53
+ the end of one.
44
54
 
45
55
  #### Why would I use it?
46
56
 
@@ -242,12 +252,10 @@ first call, a fresh agent is created and the conversation starts
242
252
  from scratch.
243
253
 
244
254
  On subsequent calls, the stored state is restored and the
245
- conversation continues. Every
246
- [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
247
- or
248
- [`LLM::Agent#ask`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#ask)
249
- call persists the
250
- updated state back to the column automatically.
255
+ conversation continues. The column is written after each completed
256
+ request rather than at the end of a turn, so a turn that runs tools
257
+ before it answers is saved more than once - and a turn that is
258
+ interrupted can be resumed from its last completed request.
251
259
 
252
260
  The `data_column:` option lets you use a different column name.
253
261
  The `format:` option controls the storage type. Use `:string` for
@@ -327,7 +335,7 @@ agent.talk "perform research"
327
335
  #### Why would I use it?
328
336
 
329
337
  The ActiveRecord wrapper integrates with your existing models.
330
- Agent state is automatically persisted after each conversation turn
338
+ Agent state is automatically persisted after each completed request,
331
339
  using the same `create!`, `save!`, and query methods you already
332
340
  use.
333
341
 
@@ -419,11 +427,12 @@ records rather than
419
427
  [`LLM::Message`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html)
420
428
  objects, until
421
429
  [`#unwrap!`](https://r.uby.dev/api-docs/llm.rb/LLM/ActiveRecord/Message.html#unwrap!-instance_method)
422
- turns them back.
430
+ turns them back. `#messages!` is the runtime's own list, whatever the
431
+ format is, so a jsonb record reaches it in one call.
423
432
 
424
- Every other format loads the runtime and returns the messages it
425
- holds, as it always did, and that is the default. Sequel has no
426
- equivalent: its plugin persists the same state, and there is no view to
433
+ Every other format answers `#messages` with the runtime's messages, as
434
+ it always did, and that is the default. Sequel has no equivalent to any
435
+ of this: its plugin persists the same state, and there is no view to
427
436
  read it back in the database.
428
437
 
429
438
  ### Sequel
@@ -446,11 +455,8 @@ the plugin in the model class. The `data` column stores
446
455
  the full agent state as JSON, same structure as ActiveRecord.
447
456
  On first call, a fresh agent is created. On subsequent calls,
448
457
  the stored state is restored and the conversation continues.
449
- Every
450
- [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
451
- or
452
- [`LLM::Agent#ask`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#ask)
453
- call persists the updated state.
458
+ The state is written after each completed request, as it is under
459
+ ActiveRecord.
454
460
 
455
461
  The `data_column:` and `format:` options work identically to
456
462
  ActiveRecord. For `:jsonb`, Sequel loads the `pg_json` extension
@@ -50,6 +50,8 @@ an `embed` method:
50
50
  - Ollama (`qwen3:latest`)
51
51
  - Google (`gemini-embedding-2`)
52
52
  - Mistral (`mistral-embed`)
53
+ - OpenRouter (`openai/text-embedding-3-small`)
54
+ - Bedrock (the model is given per call)
53
55
 
54
56
  ### Vector stores (OpenAI)
55
57
 
@@ -0,0 +1,52 @@
1
+
2
+ ## Files
3
+
4
+ ### Introduction
5
+
6
+ #### Overview
7
+
8
+ Some providers keep files you upload, and let a later request refer to
9
+ one by id instead of resending its bytes. The Files API is how a file
10
+ is uploaded, listed, fetched, downloaded, and deleted, and a file id is
11
+ then passed to an endpoint that reads it.
12
+
13
+ #### How it works
14
+
15
+ Call the file methods on a provider that implements them. `create`
16
+ uploads a file and returns its record, `get` reads a record, `download`
17
+ returns the bytes, `all` lists the files, and `delete` removes one:
18
+
19
+ ```ruby
20
+ require "llm"
21
+
22
+ llm = LLM.openai(key: ENV["KEY"])
23
+ ctx = LLM::Context.new(llm)
24
+
25
+ file = llm.files.create(file: "/books/goodread.pdf")
26
+ ctx.talk ["Tell me about this PDF", file]
27
+
28
+ llm.files.all # every uploaded file
29
+ llm.files.get(file: file.id) # one file's record
30
+ llm.files.download(file: file.id) # its bytes, under #file
31
+ llm.files.delete(file: file.id)
32
+ ```
33
+
34
+ `create` takes a path, an `LLM::File`, or a `File` object, and a
35
+ `purpose:` that tells the provider what the file is for. An uploaded
36
+ file carries its id, so it can be passed to a turn directly, as above,
37
+ and a response from `download` exposes the bytes through `#file`.
38
+
39
+ #### Why would I use it?
40
+
41
+ A file that a provider stores can be reused across requests without
42
+ being uploaded each time. That keeps a large document or an image out
43
+ of the request body, and it is how OpenAI's vector stores, and the
44
+ assistants that read them, get their content.
45
+
46
+ #### Notes
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.
@@ -0,0 +1,48 @@
1
+
2
+ ## Moderations
3
+
4
+ ### Introduction
5
+
6
+ #### Overview
7
+
8
+ A moderation classifies text or an image against a set of harm
9
+ categories, so an application can decide what to do with it before
10
+ showing it, storing it, or sending it to a model. OpenAI hosts the
11
+ moderation endpoint.
12
+
13
+ #### How it works
14
+
15
+ Call `llm.moderations` with the content to check. The response lists a
16
+ moderation per input, and each one reports whether it was flagged, the
17
+ categories it matched, and their scores:
18
+
19
+ ```ruby
20
+ require "llm"
21
+
22
+ llm = LLM.openai(key: ENV["KEY"])
23
+
24
+ res = llm.moderations.create(input: "I hate you")
25
+ mod = res.moderations[0]
26
+
27
+ mod.flagged? # => true
28
+ mod.categories # the categories that matched
29
+ mod.scores # a score per category
30
+ ```
31
+
32
+ `input` accepts a string or a URL, so an image URL is classified the
33
+ same way text is.
34
+
35
+ #### Why would I use it?
36
+
37
+ A moderation is a cheap check that runs before an expensive or public
38
+ step. Screen user-generated content before publishing it, gate a
39
+ request before it reaches a model, or keep a record of what was
40
+ filtered and why.
41
+
42
+ #### Notes
43
+
44
+ Only OpenAI implements moderations; another provider raises
45
+ `NotImplementedError`. The default model is `omni-moderation-latest`,
46
+ and it classifies both text and images. The scores are model
47
+ estimates, so a threshold is a product decision rather than a fixed
48
+ value.
@@ -0,0 +1,54 @@
1
+
2
+ ## Responses
3
+
4
+ ### Introduction
5
+
6
+ #### Overview
7
+
8
+ OpenAI's Responses API gives every reply an id, and a later call can
9
+ continue from one by passing `previous_response_id:`. When the provider
10
+ is asked to store the reply, it keeps the history, so a client can hand
11
+ the thread back instead of resending it.
12
+
13
+ #### How it works
14
+
15
+ Create a response with `llm.responses.create`, then continue it by
16
+ passing `previous_response_id:` the id of the last one. A response can
17
+ be read back with `get` and removed with `delete`:
18
+
19
+ ```ruby
20
+ require "llm"
21
+
22
+ llm = LLM.openai(key: ENV["KEY"])
23
+
24
+ first = llm.responses.create "Your task is to answer the user's questions",
25
+ role: :developer
26
+ second = llm.responses.create "5 + 5 = X ?",
27
+ role: :user,
28
+ previous_response_id: first.id
29
+
30
+ puts second.content
31
+
32
+ llm.responses.get(second) # read it back
33
+ llm.responses.delete(second) # remove it
34
+ ```
35
+
36
+ `create` accepts the same prompt shapes as a completion (a string or a
37
+ list), plus `role:`, `model:`, `tools:`, and a `stream:` target. It
38
+ returns an `LLM::Response` like any other call.
39
+
40
+ #### Why would I use it?
41
+
42
+ Server-side state means the conversation lives with the provider
43
+ instead of in the request. Passing one id continues a thread without
44
+ rebuilding and resending the messages, which keeps requests small and
45
+ lets a client that lost its local history pick the thread back up.
46
+
47
+ #### Notes
48
+
49
+ Only OpenAI implements the Responses API. A context in `mode:
50
+ :responses` uses this endpoint and defaults to `store: false`, so the
51
+ conversation is not kept on the provider and the context rebuilds the
52
+ input itself; set `store: true` to keep it there and have the next turn
53
+ continue with `previous_response_id`. A plain `llm.responses.create`
54
+ call continues only when `previous_response_id:` is given.
@@ -21,8 +21,9 @@ the model responds, and if it requests tools the agent
21
21
  executes them automatically and feeds the results back. The tool
22
22
  loop can be bounded with
23
23
  [`LLM::Agent.tool_budget`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#tool_budget-class_method)
24
- (see the Tool budget section). Instructions are injected once
25
- unless a system message is already present.
24
+ (see the Tool budget section). Instructions are injected once and
25
+ brought up to date from the agent before each request, unless the
26
+ prompt already carries a system message of the caller's own.
26
27
 
27
28
  #### Why would I use it?
28
29
 
@@ -245,9 +246,12 @@ of hanging.
245
246
 
246
247
  The retry budget applies to rate-limited requests
247
248
  (`LLM::RateLimitError`) and timeouts (`Timeout::Error`, covering
248
- `Net::OpenTimeout` and `Net::ReadTimeout`), other errors are never
249
- retried. The budget defaults to five for agents, while a raw
249
+ `Net::OpenTimeout` and `Net::ReadTimeout`); other errors are never
250
+ retried. The budget lives on the provider, where it defaults to five
251
+ (eight on Alibaba, which rate limits more often), and an agent that
252
+ sets none of its own takes the provider's. A raw
250
253
  [`LLM::Context`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html)
251
- defaults to zero (`retry_budget: 0`). A 429 is refused before any
252
- content streams, so retrying the same request loses nothing. Pass
253
- `retry_budget: 0` to disable retries on an agent.
254
+ defaults to zero (`retry_budget: 0`), so retries are opt-in there. A
255
+ 429 is refused before any content streams, so retrying the same
256
+ request loses nothing. Pass `retry_budget: 0` to disable retries on
257
+ an agent.
@@ -92,6 +92,43 @@ missing model or registry raises `LLM::NoSuchModelError` or
92
92
  `LLM::NoSuchRegistryError`, which the runtime rescues to default
93
93
  gracefully (for example, an unknown context window reads as `nil`).
94
94
 
95
+ ### Model listings
96
+
97
+ #### Overview
98
+
99
+ The registry is the catalog llm.rb ships. A provider can also list the
100
+ models it serves right now, which shows a model added since your copy
101
+ of `data/` was written, or one available to your account alone.
102
+
103
+ #### How it works
104
+
105
+ A provider with a models endpoint exposes it through `llm.models`, and
106
+ `all` returns the list:
107
+
108
+ ```ruby
109
+ require "llm"
110
+
111
+ llm = LLM.openai(key: ENV["KEY"])
112
+ llm.models.all.each { puts _1.id }
113
+ ```
114
+
115
+ OpenAI, Anthropic, Google, Bedrock, and Ollama implement it. Bedrock
116
+ lists the foundation models in the configured region and accepts
117
+ `byProvider:` to narrow the list; Ollama lists the models pulled into
118
+ the local daemon.
119
+
120
+ #### Why would I use it?
121
+
122
+ A shipped catalog goes stale between releases. Asking the provider
123
+ keeps a model picker current and reveals models the catalog does not
124
+ know about.
125
+
126
+ #### Notes
127
+
128
+ Not every provider implements a models endpoint; calling `models` on
129
+ one that does not raises `NotImplementedError`. Unlike the registry,
130
+ the call needs credentials.
131
+
95
132
  ### Request headers
96
133
 
97
134
  #### Overview
@@ -178,3 +178,53 @@ DSL builds, so the result is used the same way.
178
178
  An unsupported `type`, an unresolvable `$ref`, or a `$ref` that does
179
179
  not start with `#/` raises `TypeError`. When a schema omits `type`,
180
180
  the parser infers it from `const`, `enum`, or `default`.
181
+
182
+ ### Deferred types
183
+
184
+ #### Overview
185
+
186
+ A property or parameter type can be given as a block, in which case the
187
+ type is worked out when the schema is serialized rather than when the
188
+ class that declares it is defined. That is how a type that only exists
189
+ at runtime, such as the values a store holds right now, is described.
190
+
191
+ #### How it works
192
+
193
+ Pass a block in place of the type. It is called each time the schema is
194
+ serialized, and whatever it returns is resolved in turn, so it can
195
+ return a leaf, a class, or an array of either:
196
+
197
+ ```ruby
198
+ class Article < LLM::Schema
199
+ property :kind, proc { LLM::Schema::Enum[*Article.kinds] }, "The article kind"
200
+ required %i[kind]
201
+ end
202
+ ```
203
+
204
+ The same works for a tool parameter:
205
+
206
+ ```ruby
207
+ class Publish < LLM::Tool
208
+ name "publish"
209
+ parameter :kind, proc { LLM::Schema::Enum[*Article.kinds] }, "The article kind"
210
+ required %i[kind]
211
+
212
+ def call(kind:)
213
+ Article.publish(kind:)
214
+ end
215
+ end
216
+ ```
217
+
218
+ #### Why would I use it?
219
+
220
+ A schema is a class-level definition, but the choices it offers are
221
+ sometimes only known when a request is made. A deferred type lets the
222
+ schema be declared once and still send the values that are current at
223
+ the time of the request.
224
+
225
+ #### Notes
226
+
227
+ The block is called on every serialization, so it runs once per
228
+ request and sees the latest values. Everything a leaf accepts, such as
229
+ `required`, `default`, and `enum`, is applied to the resolved leaf
230
+ after the block returns.
@@ -67,6 +67,17 @@ receives tokens as they arrive.
67
67
  fires when the model requests a tool.
68
68
  [`LLM::Stream#on_tool_return`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_tool_return)
69
69
  fires when the tool completes.
70
+ [`LLM::Stream#on_step`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_step-instance_method)
71
+ fires when a request completes, which is the boundary between one
72
+ request and the next: the response is in the conversation, and a
73
+ turn that asked for tools runs them after this while a turn that is
74
+ finished ends here. That boundary is where a conversation is whole,
75
+ and the runtime's
76
+ [`LLM::Step`](https://r.uby.dev/api-docs/llm.rb/LLM/Step.html) is
77
+ written against it - it is prepended onto a stream, and saves the
78
+ conversation through the record the context is bound to, so a turn
79
+ that is interrupted can be continued from its last completed request
80
+ rather than started over.
70
81
  [`LLM::Stream#on_retry`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_retry)
71
82
  fires each time a failed request is retried. Compaction hooks
72
83
  let you show progress or log what was trimmed. Skill hooks bracket a
@@ -96,6 +107,11 @@ class MyStream < LLM::Stream
96
107
  def on_tool_return(tool, result)
97
108
  end
98
109
 
110
+ # A request has completed: the response is in the conversation, and
111
+ # any tools it asked for run after this.
112
+ def on_step(ctx, res)
113
+ end
114
+
99
115
  # Before a transformer rewrites an outgoing message.
100
116
  def on_transform(transformer)
101
117
  end
@@ -135,9 +151,3 @@ agent.talk "Explain Ruby fibers."
135
151
  A stream subclass gives you visibility into more than just content chunks. React to tool
136
152
  calls as they happen, show compaction progress, or integrate with
137
153
  an existing observability stack.
138
-
139
- #### Notes
140
-
141
- The IO-like form is equivalent to
142
- [`LLM::Stream#on_content`](https://r.uby.dev/api-docs/llm.rb/LLM/Stream.html#on_content)
143
- and does not include the other hooks.
@@ -435,6 +435,12 @@ needs most. See the
435
435
  The tools that spawn subprocesses use the optional `test-cmd.rb`
436
436
  gem for process management and interrupt handling.
437
437
 
438
+ `LLM::Tool.subclasses` lists the direct subclasses of
439
+ [`LLM::Tool`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html), so a
440
+ tool defined under an intermediate base class is not in the list.
441
+ [`LLM::Tool.registry`](https://r.uby.dev/api-docs/llm.rb/LLM/Tool.html#registry-class_method)
442
+ lists every tool that has a name, wherever it sits in the hierarchy.
443
+
438
444
  ### Interrupts
439
445
 
440
446
  #### Overview
@@ -61,6 +61,12 @@ is defined explicitly and serializes through the configured JSON
61
61
  adapter via
62
62
  [`LLM.json.dump`](https://r.uby.dev/api-docs/llm.rb/LLM.html#json-class_method).
63
63
 
64
+ The adapter is `LLM::JSONAdapter::JSON`, the standard library, by
65
+ default. Set `LLM.json = :oj` or `LLM.json = :yajl` to use a faster
66
+ backend when the matching gem is present; every adapter normalizes
67
+ strings to valid UTF-8 before it serializes, so the choice affects
68
+ speed rather than behavior.
69
+
64
70
  ### Reading and writing
65
71
 
66
72
  #### Overview
@@ -0,0 +1,55 @@
1
+
2
+ ## LLM::Response
3
+
4
+ ### Introduction
5
+
6
+ #### Overview
7
+
8
+ Every call returns an
9
+ [`LLM::Response`](https://r.uby.dev/api-docs/llm.rb/LLM/Response.html).
10
+ It is one normalized shape that a provider, an endpoint, or a context
11
+ extends with the accessors that response needs, so chat completions,
12
+ embeddings, images, and OCR all share a common surface without losing
13
+ their own.
14
+
15
+ #### How it works
16
+
17
+ `LLM::Response` carries the transport response and a little common
18
+ state: `#res` (the underlying HTTP response), `#body` (the parsed body,
19
+ or a raw string), `#ok?`, `#id`, and `#file?`. A name the base class
20
+ does not define falls through to the body, so a provider field such as
21
+ `content` is reachable directly.
22
+
23
+ A completion response also includes
24
+ [`LLM::Contract::Completion`](https://r.uby.dev/api-docs/llm.rb/LLM/Contract/Completion.html),
25
+ which adds the pieces a chat reply needs:
26
+
27
+ ```ruby
28
+ res = agent.talk "Weather in Paris?", schema: Weather
29
+
30
+ res.content # the raw text, or the structured reply
31
+ res.content! # the reply parsed as an LLM::Object
32
+ res.messages # the messages this turn produced
33
+ res.usage # an LLM::Usage (input, output, reasoning, cache, ...)
34
+ res.reasoning_content # the model's reasoning, when the provider exposes it
35
+ res.model # the model that answered
36
+ ```
37
+
38
+ #### Why would I use it?
39
+
40
+ One shape means one set of methods to learn: the same `content!`,
41
+ `usage`, and `messages` work whatever the provider. When an endpoint
42
+ returns something shaped differently, it adds an accessor rather than
43
+ changing the contract.
44
+
45
+ #### Notes
46
+
47
+ `#usage` returns an
48
+ [`LLM::Usage`](https://r.uby.dev/api-docs/llm.rb/LLM/Usage.html), so
49
+ token counts are read the same way everywhere; the individual token
50
+ readers (`input_tokens`, `output_tokens`, `cache_read_tokens`, and so
51
+ on) live on the completion contract. Endpoint-specific accessors such
52
+ as `images`, `pages`, `audio`, or `embeddings` come from the adapter
53
+ that extends the response, so they are present only on responses that
54
+ carry that data. `#content!` parses the content as JSON, which is the
55
+ read to use with a schema.
data/docs/deepdive.md CHANGED
@@ -68,6 +68,9 @@ is the best place to start if you are new to llm.rb.
68
68
  - [Built-in tools](deepdive/features/builtin_tools.md)
69
69
  - [Concurrency](deepdive/features/concurrency.md)
70
70
  - [Embeddings](deepdive/features/embeddings.md)
71
+ - [Files](deepdive/features/files.md)
72
+ - [Moderations](deepdive/features/moderations.md)
73
+ - [Responses](deepdive/features/responses.md)
71
74
  - [Database](deepdive/features/database.md)
72
75
  - [Console](deepdive/features/console.md)
73
76
 
@@ -88,6 +91,7 @@ is the best place to start if you are new to llm.rb.
88
91
 
89
92
  ## Reference
90
93
 
94
+ - [LLM::Response](deepdive/reference/response.md)
91
95
  - [LLM::Object](deepdive/reference/object.md)
92
96
  - [LLM::Cost](deepdive/reference/cost.md)
93
97
  - [Tracer](deepdive/reference/tracer.md)
@@ -56,21 +56,23 @@ module LLM::ActiveRecord
56
56
 
57
57
  module InstanceMethods
58
58
  ##
59
- # Continues the stored context with new input and flushes it.
59
+ # Continues the stored context with new input.
60
+ #
61
+ # The conversation is saved by {LLM::Step LLM::Step} at each request.
60
62
  # @see LLM::Context#talk
61
63
  # @return [LLM::Response]
62
64
  def talk(...)
63
- options = self.class.llm_plugin_options
64
- ctx.talk(...).tap { Utils.save!(self, ctx, options) }
65
+ ctx.talk(...)
65
66
  end
66
67
 
67
68
  ##
68
- # Continues the stored context with new input and flushes it.
69
+ # Continues the stored context with new input.
70
+ #
71
+ # The conversation is saved by {LLM::Step LLM::Step} at each request.
69
72
  # @see LLM::Context#ask
70
73
  # @return [LLM::Response]
71
74
  def ask(...)
72
- options = self.class.llm_plugin_options
73
- ctx.ask(...).tap { Utils.save!(self, ctx, options) }
75
+ ctx.ask(...)
74
76
  end
75
77
 
76
78
  ##
@@ -101,7 +103,8 @@ module LLM::ActiveRecord
101
103
  # unordered - conversation order is `position`, not `id` - and its rows
102
104
  # are {LLM::ActiveRecord::Message} records until
103
105
  # {LLM::ActiveRecord::Message#unwrap!} turns them back into
104
- # {LLM::Message} objects.
106
+ # {LLM::Message} objects. {#messages!} is the runtime's own collection,
107
+ # for a caller who wants that instead.
105
108
  # @see LLM::Context#messages
106
109
  # @return [ActiveRecord::Relation, LLM::Buffer]
107
110
  def messages
@@ -113,6 +116,20 @@ module LLM::ActiveRecord
113
116
  end
114
117
  end
115
118
 
119
+ ##
120
+ # Returns the messages the runtime holds, whatever the storage format is.
121
+ #
122
+ # This is what {#messages} answers with for every format but jsonb, and
123
+ # it is the way back to it for a jsonb record. The difference between
124
+ # the two is what each reads: this reads what the context holds,
125
+ # including state that has not been saved, where {#messages} reads the
126
+ # column.
127
+ # @see LLM::Context#messages
128
+ # @return [LLM::Buffer]
129
+ def messages!
130
+ ctx.messages
131
+ end
132
+
116
133
  ##
117
134
  # @note The bang keeps the ActiveRecord and Sequel wrappers aligned.
118
135
  # @see LLM::Context#model
data/lib/llm/agent.rb CHANGED
@@ -15,7 +15,10 @@ module LLM
15
15
  # instead of leaving tool loops to the caller.
16
16
  #
17
17
  # **Notes:**
18
- # * Instructions are injected once unless a system message is already present.
18
+ # * Instructions are injected once, and kept in step after that. A
19
+ # restored conversation carries the instructions that were current
20
+ # when it started, and the message is brought up to date before the
21
+ # next request goes out.
19
22
  # * An agent automatically executes tool loops (unlike {LLM::Context LLM::Context}).
20
23
  # * The tool loop can be bounded with `tool_budget`. Once the budget is
21
24
  # spent, no further tool calls are run for that turn: the agent sends an
@@ -53,6 +56,14 @@ module LLM
53
56
  UNDEFINED = Object.new
54
57
  private_constant :UNDEFINED
55
58
 
59
+ ##
60
+ # Marks the message an agent injects as its own, so that a later
61
+ # turn can tell it from a message the caller composed. The role
62
+ # cannot do that: it is true of whatever the caller put there.
63
+ # @api private
64
+ INSTRUCTIONS = {instructions: true}.freeze
65
+ private_constant :INSTRUCTIONS
66
+
56
67
  ##
57
68
  # Ugh :)
58
69
  # @api private
@@ -441,7 +452,10 @@ module LLM
441
452
  # knows its own API, and how often it recovers from a
442
453
  # rate limit rather than failing outright.
443
454
  params[:retry_budget] = llm.retry_budget if params[:retry_budget].equal?(UNDEFINED)
444
- @ctx = LLM::Context.new(llm, params)
455
+ ##
456
+ # The context is bound to the same record the agent holds, so code
457
+ # written against a context alone can find it through `ctx.record`.
458
+ @ctx = LLM::Context.new(llm, params.merge(record: @record).compact)
445
459
  @path and File.readable?(@path) ? @ctx.restore(path:) : nil
446
460
  end
447
461
 
@@ -799,27 +813,92 @@ module LLM
799
813
  # @return [LLM::Prompt]
800
814
  def apply_instructions(new_prompt)
801
815
  return new_prompt unless @instructions
816
+ ##
817
+ # A prompt that carries a system message of its own is the
818
+ # caller's, and the instructions already in the conversation are
819
+ # left as they are. The context is deliberately not consulted: a
820
+ # restored context always holds the message this exists to bring
821
+ # up to date, which is what `inject_instructions?` tests for.
822
+ refresh_instructions! unless carries_system_message?(new_prompt)
802
823
  if LLM::Prompt === new_prompt
803
- new_prompt.system(@instructions) if inject_instructions?(new_prompt)
824
+ new_prompt.system(@instructions, extra: INSTRUCTIONS) if inject_instructions?(new_prompt)
804
825
  new_prompt
805
826
  else
806
827
  prompt do
807
- _1.system(@instructions) if inject_instructions?
828
+ _1.system(@instructions, extra: INSTRUCTIONS) if inject_instructions?
808
829
  _1.user(new_prompt)
809
830
  end
810
831
  end
811
832
  end
812
833
 
834
+ ##
835
+ # Brings the instructions stored in the conversation up to date.
836
+ #
837
+ # The message is identified by the mark the agent put on it when it
838
+ # injected it, and not by its role or its position. A role is true
839
+ # of whatever the caller put there - on a provider whose system role
840
+ # is `:user`, the first message of a conversation the caller seeded
841
+ # is a user message too - so a rule written on either of those
842
+ # replaces a message that was never the agent's to replace.
843
+ #
844
+ # A message with no mark is left alone, which is the safe direction:
845
+ # a conversation written before the mark existed keeps its stale
846
+ # instructions rather than losing a message. Stale is recoverable.
847
+ #
848
+ # An agent with no instructions returns first. The caller reaches
849
+ # this through `apply_instructions`, which already returns early for
850
+ # the same reason, and the guard is repeated here so that the method
851
+ # cannot replace a message with nil however it is reached.
852
+ #
853
+ # Comparing content rather than the message is deliberate.
854
+ # {LLM::Message#==} compares everything a message carries apart from
855
+ # its id, which drags fields that have nothing to do with
856
+ # instructions into the comparison. The mark is carried onto the
857
+ # replacement so that the turn after this one can still find it, and
858
+ # so is the role the message already had: it is the provider's, and
859
+ # rebuilding it here would undo the reason the replacement is safe.
860
+ # @api private
861
+ # @return [void]
862
+ def refresh_instructions!
863
+ return unless @instructions
864
+ message = @ctx.messages.first
865
+ return unless message&.extra&.key?(:instructions)
866
+ return if message.content == @instructions
867
+ @ctx.messages.replace(
868
+ [LLM::Message.new(message.role, @instructions, message.extra), *@ctx.messages.drop(1)]
869
+ )
870
+ end
871
+
872
+ ##
873
+ # Returns true when a prompt carries a system message of its own.
874
+ #
875
+ # This is the half of `inject_instructions?` that asks about the
876
+ # prompt rather than about the context, and it is the one a refresh
877
+ # needs. The context half says "do not inject again", which is not
878
+ # the same question as "whose instructions are these".
879
+ #
880
+ # The role asked about is the provider's: a prompt's `system` is
881
+ # built with the role the provider gives instructions, which is not
882
+ # the string "system" everywhere.
883
+ # @param [LLM::Prompt, Object] prompt
884
+ # @return [Boolean]
885
+ def carries_system_message?(prompt)
886
+ role = @llm.system_role.to_s
887
+ LLM::Prompt === prompt && prompt.to_a.any? { _1.role.to_s == role }
888
+ end
889
+
813
890
  ##
814
891
  # Returns true when agent instructions should be injected for the turn.
815
- # Instructions are injected once unless a system message is already
816
- # present in the existing context or the prompt being sent.
892
+ # Instructions are injected once unless a message with the provider's
893
+ # system role is already present in the existing context or the prompt
894
+ # being sent.
817
895
  # @param [LLM::Prompt, nil] prompt
818
896
  # @return [Boolean]
819
897
  def inject_instructions?(prompt = nil)
820
- return false if @ctx.messages.any?(&:system?)
898
+ role = @llm.system_role.to_s
899
+ return false if @ctx.messages.any? { _1.role.to_s == role }
821
900
  return true if prompt.nil?
822
- !prompt.to_a.any?(&:system?)
901
+ !prompt.to_a.any? { _1.role.to_s == role }
823
902
  end
824
903
 
825
904
  ##
@@ -40,6 +40,19 @@ class LLM::Context
40
40
 
41
41
  private
42
42
 
43
+ ##
44
+ # Rebuilds one message from a payload.
45
+ #
46
+ # The fields that describe a message are named here rather than
47
+ # taken from everything the payload holds, so a field that
48
+ # {LLM::Message#to_h} writes has to be read here as well - and one
49
+ # that is not named is dropped on the way in, whatever the payload
50
+ # carries. `instructions` marks the message an agent injected as its
51
+ # own, and the agent looks for that mark rather than for a role or a
52
+ # position when it refreshes the instructions in a restored
53
+ # conversation.
54
+ # @param [Hash] payload
55
+ # @return [LLM::Message]
43
56
  def deserialize_message(payload)
44
57
  tool_calls = deserialize_tool_calls(payload["tools"])
45
58
  returns = deserialize_returns(payload["content"]) if returns.nil?
@@ -47,8 +60,18 @@ class LLM::Context
47
60
  usage = payload["usage"]
48
61
  reasoning_content = payload["reasoning_content"]
49
62
  compaction = payload["compaction"]
63
+ instructions = payload["instructions"]
50
64
  id = payload["id"]
51
- extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:, id:}.compact
65
+ extra = {
66
+ tool_calls:,
67
+ original_tool_calls:,
68
+ tools: @params[:tools],
69
+ usage:,
70
+ reasoning_content:,
71
+ compaction:,
72
+ instructions:,
73
+ id:
74
+ }.compact
52
75
  content = returns.nil? ? deserialize_content(payload["content"]) : returns
53
76
  LLM::Message.new(payload["role"], content, extra)
54
77
  end
data/lib/llm/context.rb CHANGED
@@ -222,6 +222,7 @@ module LLM
222
222
  @messages.concat(prompt)
223
223
  end
224
224
  @messages.concat([res.choices[-1]].compact)
225
+ stream.on_step(self, res)
225
226
  res
226
227
  ensure
227
228
  @owner = nil
data/lib/llm/message.rb CHANGED
@@ -46,6 +46,12 @@ module LLM
46
46
 
47
47
  ##
48
48
  # Returns a Hash representation of the message.
49
+ #
50
+ # This is the whole of a message as far as storage is concerned:
51
+ # {LLM::Context#to_h} maps its messages through here, and restore
52
+ # reads them back from the same list. A field that is not named
53
+ # here does not survive a save, so a field the runtime relies on
54
+ # after a restore has to be named.
49
55
  # @return [Hash]
50
56
  def to_h
51
57
  {
@@ -54,6 +60,7 @@ module LLM
54
60
  content:,
55
61
  reasoning_content:,
56
62
  compaction: extra.compaction,
63
+ instructions: extra.instructions,
57
64
  tools: extra.tool_calls&.map { LLM::Object === _1 ? _1.to_h : _1 },
58
65
  usage:,
59
66
  original_tool_calls: extra.original_tool_calls
data/lib/llm/prompt.rb CHANGED
@@ -40,40 +40,51 @@ class LLM::Prompt
40
40
  # The message
41
41
  # @param [Symbol] role
42
42
  # The role (eg user, system)
43
+ # @param [Hash, nil] extra
44
+ # Fields carried alongside the message. A caller that composes a
45
+ # message of its own can mark it here, and the runtime reads the
46
+ # mark back rather than inferring it from the role.
43
47
  # @return [void]
44
- def talk(content, role: @provider.user_role)
48
+ def talk(content, role: @provider.user_role, extra: nil)
45
49
  role = case role.to_sym
46
50
  when :system then @provider.system_role
47
51
  when :user then @provider.user_role
48
52
  when :developer then @provider.developer_role
49
53
  else role
50
54
  end
51
- @buffer << LLM::Message.new(role, content)
55
+ message = extra ? LLM::Message.new(role, content, extra) : LLM::Message.new(role, content)
56
+ @buffer << message
52
57
  end
53
58
  alias_method :chat, :talk
54
59
 
55
60
  ##
56
61
  # @param [String] content
57
62
  # The message content
63
+ # @param [Hash, nil] extra
64
+ # Fields carried alongside the message
58
65
  # @return [void]
59
- def user(content)
60
- talk(content, role: @provider.user_role)
66
+ def user(content, extra: nil)
67
+ talk(content, role: @provider.user_role, extra:)
61
68
  end
62
69
 
63
70
  ##
64
71
  # @param [String] content
65
72
  # The message content
73
+ # @param [Hash, nil] extra
74
+ # Fields carried alongside the message
66
75
  # @return [void]
67
- def system(content)
68
- talk(content, role: @provider.system_role)
76
+ def system(content, extra: nil)
77
+ talk(content, role: @provider.system_role, extra:)
69
78
  end
70
79
 
71
80
  ##
72
81
  # @param [String] content
73
82
  # The message content
83
+ # @param [Hash, nil] extra
84
+ # Fields carried alongside the message
74
85
  # @return [void]
75
- def developer(content)
76
- talk(content, role: @provider.developer_role)
86
+ def developer(content, extra: nil)
87
+ talk(content, role: @provider.developer_role, extra:)
77
88
  end
78
89
 
79
90
  ##
@@ -155,21 +155,23 @@ module LLM::Sequel
155
155
  Utils = Plugin::Utils
156
156
 
157
157
  ##
158
- # Continues the stored context with new input and flushes it.
158
+ # Continues the stored context with new input.
159
+ #
160
+ # The conversation is saved by {LLM::Step LLM::Step} at each request.
159
161
  # @see LLM::Context#talk
160
162
  # @return [LLM::Response]
161
163
  def talk(...)
162
- options = self.class.llm_plugin_options
163
- ctx.talk(...).tap { Utils.save!(self, ctx, options) }
164
+ ctx.talk(...)
164
165
  end
165
166
 
166
167
  ##
167
- # Continues the stored context with new input and flushes it.
168
+ # Continues the stored context with new input.
169
+ #
170
+ # The conversation is saved by {LLM::Step LLM::Step} at each request.
168
171
  # @see LLM::Context#ask
169
172
  # @return [LLM::Response]
170
173
  def ask(...)
171
- options = self.class.llm_plugin_options
172
- ctx.ask(...).tap { Utils.save!(self, ctx, options) }
174
+ ctx.ask(...)
173
175
  end
174
176
 
175
177
  ##
@@ -199,6 +201,17 @@ module LLM::Sequel
199
201
  ctx.messages
200
202
  end
201
203
 
204
+ ##
205
+ # @note The same call {#messages} makes here, and here for the sake of the
206
+ # name: on {LLM::ActiveRecord} the two differ for a jsonb record, and a
207
+ # wrapper that answered to a name the other one does not is the next
208
+ # reader's surprise.
209
+ # @see LLM::Context#messages
210
+ # @return [LLM::Buffer]
211
+ def messages!
212
+ ctx.messages
213
+ end
214
+
202
215
  ##
203
216
  # @note The bang is used because Sequel reserves `model` for the
204
217
  # underlying model class on instances.
data/lib/llm/step.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # Writes the conversation down when a request completes.
6
+ #
7
+ # {LLM::Step LLM::Step} is prepended onto a stream's singleton class by
8
+ # {LLM::Stream.try}, so a conversation bound to a record is saved as it goes:
9
+ # a turn that is interrupted - by a crash, by a deploy, or by a provider that
10
+ # refuses - can be continued rather than started over.
11
+ #
12
+ # It saves through the record the context is bound to ({LLM::Context#record}),
13
+ # and it knows both the ORMs llm.rb speaks: an ActiveRecord model is saved
14
+ # with {LLM::ActiveRecord::Utils.save!} and a Sequel model with
15
+ # {LLM::Sequel::Plugin::Utils.save!}. A context with no record - which is
16
+ # every context that is not backed by a model - has nothing to save, and
17
+ # neither has a record that carries neither plugin, so the step is passed
18
+ # along untouched.
19
+ #
20
+ # All of this is one method on purpose. The module is put onto an object the
21
+ # caller owns, and a helper would be one more name on that object for as long
22
+ # as it lives.
23
+ #
24
+ # The write is best effort: a failure is swallowed rather than raised, because
25
+ # this runs inside the request that has just completed, and a stale checkpoint
26
+ # is a smaller failure than a turn that ends because the database was busy.
27
+ # Only the write is covered, so a stream's own `on_step` is reached whether or
28
+ # not the write lands - a step that a tracer hears about does not depend on a
29
+ # database having been free.
30
+ #
31
+ # @see LLM::Stream#on_step
32
+ # @see LLM::ActiveRecord::Utils.save!
33
+ module Step
34
+ ##
35
+ # Saves the conversation the context holds, then passes the step along.
36
+ #
37
+ # @param [LLM::Context] ctx
38
+ # The context the request belongs to
39
+ # @param [LLM::Response] res
40
+ # The response for the request that completed
41
+ # @return [nil]
42
+ def on_step(ctx, res)
43
+ record = ctx.record
44
+ if record and record.class.respond_to?(:llm_plugin_options)
45
+ options = record.class.llm_plugin_options
46
+ begin
47
+ if defined?(::ActiveRecord::Base) and ::ActiveRecord::Base === record
48
+ LLM::ActiveRecord::Utils.save!(record, ctx, options)
49
+ elsif defined?(::Sequel::Model) and ::Sequel::Model === record
50
+ LLM::Sequel::Plugin::Utils.save!(record, ctx, options)
51
+ end
52
+ rescue
53
+ nil
54
+ end
55
+ end
56
+ super
57
+ end
58
+ end
59
+ end
data/lib/llm/stream.rb CHANGED
@@ -37,10 +37,12 @@ module LLM
37
37
  #
38
38
  # @see LLM::Agent Where streams are typically attached
39
39
  # @see LLM::Context Where streams are bound per-turn
40
+ # @see LLM::Step An extension that saves the conversation at each step
40
41
  class Stream
41
42
  require_relative "stream/queue"
42
43
  require_relative "stream/io"
43
44
  require_relative "stream/disabled"
45
+ require_relative "step"
44
46
 
45
47
  ##
46
48
  # This method will try to convert its argument into
@@ -51,18 +53,22 @@ module LLM
51
53
  # objects, IO objects who implement `#<<`, true, false,
52
54
  # and nil. Anything else raises a TypeError.
53
55
  #
56
+ # The stream it answers with is ready to be told about a step:
57
+ # {LLM::Step LLM::Step} is prepended onto it here, once, rather than by
58
+ # every caller that means to save a conversation.
59
+ #
54
60
  # @raise [TypeError]
55
61
  # @param [LLM::Stream, #<<, Boolean, NilClass] obj
56
62
  # @return [LLM::Stream]
57
63
  def self.try(obj, extra: {})
58
64
  if LLM::Stream === obj
59
- obj.tap { _1.extra.merge!(extra) }
65
+ obj.tap { _1.extra.merge!(extra) }.tap { _1.singleton_class.prepend(LLM::Step) }
60
66
  elsif obj.respond_to?(:<<)
61
- LLM::Stream::IO.new(obj).tap { _1.extra.merge!(extra) }
67
+ LLM::Stream::IO.new(obj).tap { _1.extra.merge!(extra) }.tap { _1.singleton_class.prepend(LLM::Step) }
62
68
  elsif obj == true
63
- LLM::Stream.new.tap { _1.extra.merge!(extra) }
69
+ LLM::Stream.new.tap { _1.extra.merge!(extra) }.tap { _1.singleton_class.prepend(LLM::Step) }
64
70
  elsif obj.nil? || obj == false
65
- LLM::Stream::Disabled.new.tap { _1.extra.merge!(extra) }
71
+ LLM::Stream::Disabled.new.tap { _1.extra.merge!(extra) }.tap { _1.singleton_class.prepend(LLM::Step) }
66
72
  else
67
73
  raise TypeError, "invalid stream object"
68
74
  end
@@ -152,6 +158,32 @@ module LLM
152
158
  nil
153
159
  end
154
160
 
161
+ ##
162
+ # Called when a request in a turn has completed.
163
+ #
164
+ # This is the boundary between one request and the next: the response
165
+ # is in the context's message history, and a turn that asked for tools
166
+ # runs them after this while a turn that is finished ends here.
167
+ #
168
+ # It is emitted once per successful request, so a request that is
169
+ # retried after a rate limit or a timeout calls this when it lands and
170
+ # not once per attempt. It is also emitted when the stream is disabled:
171
+ # a disabled stream has no output to report, but it is still told that
172
+ # a step ended.
173
+ #
174
+ # The context is passed with the response so that a stream does not have
175
+ # to reach for it: {LLM::Step LLM::Step}, which saves the conversation the
176
+ # context holds, is written against this callback alone.
177
+ #
178
+ # @param [LLM::Context] ctx
179
+ # The context the request belongs to
180
+ # @param [LLM::Response] res
181
+ # The response for the request that completed
182
+ # @return [nil]
183
+ def on_step(ctx, res)
184
+ nil
185
+ end
186
+
155
187
  ##
156
188
  # Called before a context transformer rewrites a prompt.
157
189
  # @param [LLM::Transformer] transformer
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.3"
4
+ VERSION = "16.0.0.pre.5"
5
5
  end
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.3
4
+ version: 16.0.0.pre.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson
@@ -400,6 +400,9 @@ files:
400
400
  - docs/deepdive/features/console.md
401
401
  - docs/deepdive/features/database.md
402
402
  - docs/deepdive/features/embeddings.md
403
+ - docs/deepdive/features/files.md
404
+ - docs/deepdive/features/moderations.md
405
+ - docs/deepdive/features/responses.md
403
406
  - docs/deepdive/fundamentals/agents.md
404
407
  - docs/deepdive/fundamentals/providers.md
405
408
  - docs/deepdive/fundamentals/schema.md
@@ -414,6 +417,7 @@ files:
414
417
  - docs/deepdive/reference/cost.md
415
418
  - docs/deepdive/reference/model_registry.md
416
419
  - docs/deepdive/reference/object.md
420
+ - docs/deepdive/reference/response.md
417
421
  - docs/deepdive/reference/tracer.md
418
422
  - lib/llm.rb
419
423
  - lib/llm/a2a.rb
@@ -642,6 +646,7 @@ files:
642
646
  - lib/llm/server_tool.rb
643
647
  - lib/llm/session.rb
644
648
  - lib/llm/skill.rb
649
+ - lib/llm/step.rb
645
650
  - lib/llm/stream.rb
646
651
  - lib/llm/stream/disabled.rb
647
652
  - lib/llm/stream/io.rb