ruby_llm-modes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b5c39593331641f16a7a0f41b3467091e82f4aad359be7be1aa96a9a07074094
4
+ data.tar.gz: b075c2888ddae01f0197e76893be2c04c8ead01c9362395f55f011e6407d3db5
5
+ SHA512:
6
+ metadata.gz: 8f42966bb7c51173c3326170a1d1d0eda2364964ae4ebd69b05184726127497d1ab6a489ec341bde98d7d20f92a55e686378d089ffa04e187e6d6a7c269ac55d
7
+ data.tar.gz: ec5b553284f1c6ff88c6295e82ab7ea0592d3831c02e8b2441258690ae375d0a118359aee4a89d83978c5dd6efa4e8fac3c28cd06bb080cdf85e8c0817e13943
data/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-26
11
+
12
+ ### Added
13
+
14
+ - `RubyLLM::Modes::Mode` with `description` and `mode_name`, and
15
+ `RubyLLM::ModeAgent`, an Agent with Mode extended. A mode that declares
16
+ no `description` reads `app/prompts/<agent path>/description.txt.erb`,
17
+ following Agent's convention for `instructions`.
18
+ - `Mode#instructions` defaults to `append: true, persist: false`, for the
19
+ conventional `instructions.txt.erb` template as well: a mode's prompt
20
+ follows the chat's own and stays out of a Rails record's history, so the
21
+ call site is `route.mode.complete`.
22
+ - `RubyLLM::Modes::Router` with the declaration DSL (`inputs`, `mode`,
23
+ `instructions`, `history`, `fallback`, `classify_with`, `on_error`),
24
+ inheritance that copies declarations, validation in `new`, availability
25
+ via `if:`, `route(chat)`, `force(name, chat:)`, and the ordered outcome
26
+ table. `route` reads the chat with `each` and routes the last non-system
27
+ entry, which must be a user message. History keeps nonblank user/assistant
28
+ text and plain strings, excluding tool results and metadata before
29
+ applying `history last: n` / `history :all`. Assistant text is kept even
30
+ alongside tool calls.
31
+ - `Router#route(chat, messages: transcript)` accepts a custom transcript
32
+ with the same formats, filtering, and limits. The route and its agent
33
+ stay bound to `chat`.
34
+ - `Registration` value objects: what `Router#modes` returns and what a
35
+ classifier receives as `modes:`.
36
+ - `Decision` and `Route` value objects; `Route#decided_by` (`:caller`,
37
+ `:classifier`, `:fallback`), `Route#duration_ms`, and `to_h` on both with
38
+ the field names as string keys, for logs.
39
+ - `Route#chat` is the chat the route was decided for; `Route#mode` builds
40
+ the mode's agent on it with the router's inputs as the agent's `inputs:`;
41
+ `Route#mode_class` is the class.
42
+ - `Router.instructions` mirrors `Agent.instructions`: a string, a block, or
43
+ the conventional template `app/prompts/<router_path>/instructions.txt.erb`
44
+ with keyword locals; `prompt(name, **locals)` inside a block renders a
45
+ template from the same directory.
46
+ - The `:chat` classifier backend with the built-in routing frame, the
47
+ selection schema, and `chat_factory:`.
48
+ - `DeclarationError`, `UnknownMode` (a `KeyError`), and `ContractError`.
49
+ - The `:judge` classifier backend on `RubyLLM.judge`: one `choice` question
50
+ over the modes, `confidence` as the concentration of the probability
51
+ distribution, `provider:` and `judge:` options. A RubyLLM release without
52
+ `RubyLLM.judge` rejects the backend at `new` unless `judge:` is given.
53
+ - A classifier that responds to `trace` is traced by its own answer.
54
+ - `truncate message:, history_entry:` caps, in characters, what reaches any
55
+ classifier: the message keeps its head and tail, each history entry its
56
+ head, with a marker for the cut. Defaults 30,000 and 2,000; `nil`
57
+ disables a cap. Jev rejects a request over about 170k characters.
58
+ - The `Classifier failed` reason carries the first line of the exception's
59
+ message after the class, so a provider's error body reaches the logged
60
+ route.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrey Samsonov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,268 @@
1
+ # RubyLLM::Modes
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/ruby_llm-modes.svg)](https://rubygems.org/gems/ruby_llm-modes)
4
+ [![CI](https://github.com/kryzhovnik/ruby_llm-modes/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/kryzhovnik/ruby_llm-modes/actions/workflows/ci.yml)
5
+ [![Ruby >= 3.2](https://img.shields.io/badge/ruby-%3E%3D%203.2-ruby.svg)](#installation)
6
+
7
+ One chat, many modes: a cheap classifier picks the configuration of each turn before the answer.
8
+
9
+ Two messages in the same chat can need different configurations. A question about sizes calls for a quick answer; returning a jacket needs order lookup, return tools, and more time. With one configuration for both, every turn carries every tool description and uses the same model and effort level. Similar tools compete, and the choice of what to do stays inside the answering model, where you cannot log or test it separately.
10
+
11
+ ![Two turns in an expanded support chat. With one configuration, both turns carry Help, Returns, Orders, and Escalation instructions and tools. With modes, each turn carries only its selected mode. The earlier conversation remains available.](assets/one-configuration-vs-modes.svg)
12
+
13
+ *The diagram shows an expanded support router; the example below keeps two modes.*
14
+
15
+ A **mode** is a named configuration of one turn: instructions, tools, model, thinking. All modes share one history. Before each answer a small classifier reads the latest user message and a window of history and answers one cheap question: *which mode should take this turn?* The model that answers never sees another mode's tools or instructions. The decision is a value you can log and test: which mode, why, and what the classifier actually said. A judgment model or a small chat model answers the question in a fraction of a second, for less than the full toolset costs on every turn.
16
+
17
+ ```ruby
18
+ class HelpAgent < RubyLLM::ModeAgent
19
+ description "Answers questions about delivery, payment, sizes, and store policy."
20
+ thinking effort: :low
21
+ # instructions from app/prompts/help_agent/instructions.txt.erb
22
+ end
23
+
24
+ class ReturnsAgent < RubyLLM::ModeAgent
25
+ description "Returns, exchanges, and refunds for an order the customer already has."
26
+ tools FindOrder, CreateReturn
27
+ thinking effort: :high
28
+ end
29
+
30
+ class SupportRouter < RubyLLM::Modes::Router
31
+ mode HelpAgent
32
+ mode ReturnsAgent
33
+ fallback HelpAgent, below_confidence: 0.6
34
+ classify_with :judge
35
+ end
36
+
37
+ chat.ask_later("The jacket is too small, I want to send it back")
38
+ route = SupportRouter.new.route(chat)
39
+ route.mode.complete
40
+
41
+ route.to_h
42
+ # {
43
+ # "mode_name" => "returns",
44
+ # "decided_by" => "classifier",
45
+ # "reason" => nil,
46
+ # "decision" => {
47
+ # "mode_name" => "returns",
48
+ # "confidence" => 0.88,
49
+ # "reason" => nil,
50
+ # "probabilities" => { "help" => 0.06, "returns" => 0.94 }
51
+ # },
52
+ # "duration_ms" => 351,
53
+ # "classifier" => { "with" => "judge", "model" => "jev-1.13.0" }
54
+ # }
55
+ ```
56
+
57
+ ## Installation
58
+
59
+ ```ruby
60
+ gem "ruby_llm-modes"
61
+ ```
62
+
63
+ Requires [RubyLLM](https://rubyllm.com) 2.0 or newer and Ruby 3.2 or newer.
64
+
65
+ The `:judge` example above currently requires RubyLLM from its `main` branch; RubyLLM 2.0.0 supports `:chat`.
66
+
67
+ ## Modes
68
+
69
+ A mode is a `RubyLLM::Agent` with a `description`. Subclass `RubyLLM::ModeAgent`, or extend `RubyLLM::Modes::Mode` into your own agent base class.
70
+
71
+ ```ruby
72
+ class HelpAgent < RubyLLM::ModeAgent
73
+ description <<~TEXT
74
+ Answers questions about delivery, payment, sizes, and store policy.
75
+ No account access: a question about a specific order is not for this mode.
76
+ TEXT
77
+
78
+ thinking effort: :low
79
+ end
80
+
81
+ class ReturnsAndExchangesAgent < RubyLLM::ModeAgent
82
+ description "Returns, exchanges, and refunds for an order the customer already has. Only when the customer asks for one."
83
+ mode_name "returns" # optional; default derived from the class name
84
+
85
+ tools FindOrder, CreateReturn, ExchangeItem
86
+ thinking effort: :high
87
+ end
88
+ ```
89
+
90
+ `description` tells the router what the mode does and when to pick it, as a Tool's `description` tells the model when to call the tool. `instructions` are for the model that answers, and the router never reads them. Write the description from the customer's side: what they ask for, and what is not for this mode.
91
+
92
+ A mode that declares no `description` reads `app/prompts/help_agent/description.txt.erb`, rendered without locals: the convention Agent has for `instructions`, applied to the other text. The router resolves each description when its `mode` declaration runs.
93
+
94
+ The default `mode_name` is the class name with the trailing `Agent` removed, namespaces kept, underscored: `HelpAgent` is `"help"`, `Support::HelpAgent` is `"support/help"`. Neither `description` nor `mode_name` is inherited.
95
+
96
+ A mode takes one turn of a chat that already has its own system prompt, so `instructions` in a mode defaults to `append: true, persist: false`: the mode's prompt follows the chat's and stays out of a Rails record's stored history. Declare either option to override. The defaults apply to the prompt file as well as to an explicit declaration.
97
+
98
+ One of the modes can be for clarifying questions: a short prompt, low effort, no tools, and a description like "Asks one question when the request is ambiguous on its own." It is not the fallback. The fallback takes the turn when the classifier could not choose; the clarifying mode is what the classifier chooses confidently when asking is the right answer.
99
+
100
+ ## The declaration
101
+
102
+ ```ruby
103
+ class SupportRouter < RubyLLM::Modes::Router
104
+ inputs :customer, :order # runtime context, as in Agent
105
+
106
+ mode HelpAgent # description from the class
107
+ mode ClarifyAgent
108
+ mode ReturnsAndExchangesAgent
109
+ mode OrdersAgent, "Order status, tracking, and delivery dates" # inline description wins
110
+ mode ConciergeAgent, if: -> { customer.vip? } # availability per call
111
+ mode Support::EscalationAgent, as: :escalate # registration name
112
+
113
+ instructions do # optional; string, block, or template
114
+ text = "Route by what the customer wants done now."
115
+ text += "\nThe customer is looking at order #{order.number}." if order
116
+ text
117
+ end
118
+
119
+ history last: 6 # optional; default: all given
120
+ truncate message: 30_000, history_entry: 2_000 # optional; these are the defaults
121
+ fallback HelpAgent, below_confidence: 0.6
122
+ classify_with :chat, model: "gemini-3.5-flash-lite"
123
+ on_error { |error| Rails.error.report(error, handled: true) } # optional
124
+ end
125
+ ```
126
+
127
+ - `inputs` are required keywords of `new` (`order: nil` counts as passed) and become methods inside `if:` and `instructions` blocks.
128
+ - `instructions` takes the same forms as in an Agent: a string, a block, or the conventional `app/prompts/support_router/instructions.txt.erb` template with keyword locals. It resolves on the router instance, and every backend receives the same string.
129
+ - `history last: n` keeps the last `n` history entries after filtering. `history :all` is the default; it lets a subclass undo an inherited `last:`.
130
+ - `truncate` cuts the routed message to its first and last half and each history entry to its head, with a marker for the cut. `nil` disables a cap. Keep `entries × history_entry + message` under your provider's request limit.
131
+ - `fallback` is the mode used whenever the classifier is ignored (see the [outcome table](#outcomes)). Pass no threshold to accept any confidence.
132
+ - Subclassing copies the declarations. `mode` appends to the inherited list; the other macros replace.
133
+
134
+ The declaration is validated when the router is built with `new`; every problem raises `DeclarationError`.
135
+
136
+ ## Routing a turn
137
+
138
+ By default, the router reads the conversation from the chat itself. Stage the user's message with `ask_later` (RubyLLM's `ask` is `ask_later` followed by `complete`), route, then let the mode complete the turn:
139
+
140
+ ```ruby
141
+ router = SupportRouter.new(customer: current_customer, order: current_order)
142
+ router.modes # modes available for this call
143
+
144
+ chat.ask_later(text) # a RubyLLM::Chat, or a Rails chat record
145
+ route = router.route(chat)
146
+ route.mode.complete
147
+ ```
148
+
149
+ `route` reads messages with `each`, as supported by `RubyLLM::Chat`, Rails chat records, and agents. Entries can be `RubyLLM::Message` objects, records responding to `to_llm`, `{ role:, content: }` hashes, or plain strings in history. The last entry after removing system messages must be a user message (`ArgumentError` otherwise). It becomes the routed message.
150
+
151
+ History keeps only nonblank user/assistant text and plain strings. Assistant text is kept even alongside tool calls; tool results and metadata are excluded. Filtering happens before `history last:` and `truncate`. The classifier is skipped when only the fallback mode is available.
152
+
153
+ Pass `messages:` for a custom transcript: text extracted from JSON or UI cards, relevant tool state, or a snapshot ending at the current user message. The router cannot infer UI visibility; remove internal user/assistant text here.
154
+
155
+ ```ruby
156
+ route = router.route(chat, messages: transcript)
157
+ route.mode.complete
158
+ ```
159
+
160
+ `messages:` uses the same formats, filtering, and limits. Include extra context as user/assistant text or plain strings. It can be combined with `classifier:`. Only the classifier reads this transcript; `route.chat` and the answering agent still use the original chat.
161
+
162
+ Omit `messages:` to read the chat. Passing `messages: nil` raises `ArgumentError`; it does not select the default. If an optional transcript uses `nil` to mean "read the chat", pass `messages: transcript || chat`.
163
+
164
+ `force` is for the turns the app has already decided, such as a button that starts a mode by name: no classifier runs and no messages are read, `if:` still applies, and the result is a `Route` like any other. It raises `UnknownMode` when the name is not available.
165
+
166
+ ```ruby
167
+ route = router.force("returns", chat:) # the customer pressed "Return an item"
168
+ ```
169
+
170
+ ## Applying a mode
171
+
172
+ `route.mode` is the mode's `Agent.new(chat: route.chat, inputs: route.inputs)`: it configures the chat the route was decided for and returns the agent wrapping it. The agent takes the inputs it declared and ignores the rest. Extra keywords go to `Agent.new` as given: `route.mode(session:)`. A `chat:` keyword is refused: the route is bound to its chat.
173
+
174
+ ```ruby
175
+ route.mode.complete
176
+ ```
177
+
178
+ Run the turn through the agent, not the chat, so the agent's `rescue_from` handlers apply. Agent's constructor **adds** configuration to the chat, so call `mode` once per turn: mode instructions append to the system prompt; tools, schema, and thinking are set only when the mode declares them. A chat built for the turn (a Rails chat record, or a fresh `RubyLLM.chat`) needs nothing else. A chat object reused across turns keeps the previous mode's configuration and needs a reset before the next mode.
179
+
180
+ ## Backends and what `confidence` means
181
+
182
+ ### `:chat`
183
+
184
+ One structured-output turn on `RubyLLM.chat(model:)`. The system prompt is a fixed frame around your `instructions`, the modes, and the conversation; the latest message is the user turn. The model returns `mode` (an enum of the available names), `confidence`, and `reason`.
185
+
186
+ `confidence` is the model's **self-report** from 0 to 1. It tells a hedge from a clear call, but it is not calibrated, and its scale depends on the model. Tune `below_confidence` per model from your logs, and do not carry a threshold from one backend to another.
187
+
188
+ `chat_factory: ->(model:) { ... }` replaces the chat constructor. The frame is not configurable: subclass `RubyLLM::Modes::Classifiers::Chat` and override `self.prompt`, or plug a custom classifier.
189
+
190
+ ### `:judge`
191
+
192
+ One `RubyLLM.judge` call with a single `choice` question whose options are the modes and their descriptions. The answer is a probability per mode: the decision's `mode_name` is the most likely one and `probabilities` carries the distribution.
193
+
194
+ `confidence` comes from the judgment model with the distribution and reflects how **concentrated** it is on one mode, not a self-report. The gem passes it through as is. There is no free text, so `reason` is nil and a clarification has to be a mode of its own.
195
+
196
+ `model:` and `provider:` are passed to `RubyLLM.judge` as given; without them RubyLLM's defaults apply. `judge:` replaces `RubyLLM.judge` with any callable taking the same arguments and returning a `RubyLLM::Judgment`. `RubyLLM.judge` is not in ruby_llm 2.0.0; it is on RubyLLM's main branch. On a release without it, `classify_with :judge` raises `DeclarationError` unless `judge:` is given.
197
+
198
+ ## Custom classifiers
199
+
200
+ A classifier is any object with this method:
201
+
202
+ ```ruby
203
+ class KeywordClassifier
204
+ def call(message:, history:, modes:, instructions:, inputs:)
205
+ name = modes.map(&:name).find { |candidate| message.downcase.include?(candidate) }
206
+ RubyLLM::Modes::Decision.new(mode_name: name, confidence: name ? 1.0 : nil, reason: "keyword match")
207
+ end
208
+ end
209
+
210
+ classify_with KeywordClassifier.new
211
+ ```
212
+
213
+ A class is accepted only if the class itself responds to `call`; the router never calls `new`. Pass `classifier:` to `route` to replace the declared backend for one call, for tests or shadow runs:
214
+
215
+ ```ruby
216
+ router.route(chat, classifier: FakeClassifier.new(decision))
217
+ ```
218
+
219
+ ## Reference
220
+
221
+ ### Classifier contract
222
+
223
+ `call(message:, history:, modes:, instructions:, inputs:)` returns a `Decision`.
224
+
225
+ - `message` is the content of the routed message, cut to the `truncate` cap.
226
+ - `history` is `[{ role:, content: }]`: the filtered history after `history last:` and `truncate`. Plain strings have `role: nil`.
227
+ - `modes` is what `router.modes` returns: the modes available on this call, each responding to `name`, `description`, and `klass`.
228
+ - `instructions` is the resolved string or nil; `inputs` is the hash passed to `new`.
229
+ - `Decision`: `mode_name` is a String or nil, `confidence` a number from 0 to 1 or nil for "not scored", `reason` and `probabilities` optional. Anything else is a `ContractError` and routes to the fallback.
230
+ - A classifier responding to `trace` is traced by its own `{ with:, model: }`; otherwise as `{ with: "custom", model: nil }`.
231
+
232
+ ### Route
233
+
234
+ | Field | Value |
235
+ |---------------|--------------------------------------------------------------|
236
+ | `mode_class` | the agent class |
237
+ | `mode_name` | its registration name |
238
+ | `decided_by` | `:caller`, `:classifier`, or `:fallback` |
239
+ | `reason` | why this mode (see below) |
240
+ | `decision` | the classifier's `Decision`, or nil when none ran |
241
+ | `duration_ms` | classifier time, nil on caller-decided routes |
242
+ | `classifier` | `{ with:, model: }`, or nil when no backend was called |
243
+
244
+ `route.chat` is the chat the route was decided for, `route.error` the exception a failed classifier raised, and `route.inputs` the router's inputs; `route.mode(**options)` is `mode_class.new(chat:, inputs:, **options)`. `to_h` is the fields above with string keys and `decided_by` as a string, minus `mode_class`; `Decision#to_h` has string keys and omits `probabilities` when nil.
245
+
246
+ ### Outcomes
247
+
248
+ The route is decided by the first rule that applies:
249
+
250
+ | Situation | decided_by | reason |
251
+ |-----------------------------------------------|----------------|----------------------------------------|
252
+ | `force(name, chat:)` | `:caller` | `"Mode requested by caller"` |
253
+ | only the fallback is available | `:fallback` | `"No other mode available"` |
254
+ | classifier raised, or violated the contract | `:fallback` | `"Classifier failed: <class>: <message>"` |
255
+ | decision names an unknown or unavailable mode | `:fallback` | `"Unknown mode <name>"` |
256
+ | threshold on, confidence nil | `:fallback` | `"Confidence not scored"` |
257
+ | threshold on, confidence below it | `:fallback` | `"Below confidence threshold"` |
258
+ | otherwise | `:classifier` | the decision's reason |
259
+
260
+ `<message>` is the first line of the exception's message, cut at 200 characters, so a provider's error body reaches the log.
261
+
262
+ ### Errors
263
+
264
+ `Router.new` raises `DeclarationError` for any problem in the declaration and says which. `route` raises `ArgumentError` when the chat has no user message to route; `force` raises `UnknownMode`, a `KeyError`. A classifier that raised, or broke the contract (a `ContractError`), does not fail the turn: the route falls back and the exception is on `route.error`. To fail instead, raise from `on_error`; whatever the handler raises escapes `route`. An exception from an `instructions` block or template is a declaration bug and escapes `route` as well.
265
+
266
+ ## License
267
+
268
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,89 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="980" height="530" viewBox="0 0 980 530" role="img" aria-labelledby="title desc">
2
+ <title id="title">Context sent with one configuration and with modes</title>
3
+ <desc id="desc">Two turns in an expanded support chat. Each horizontal bar represents the total input context for one turn: instructions, tool declarations, earlier messages, and the new user message. Without modes, one prompt contains sections for Help, Returns, Orders, and Escalation, separated by dashed lines, followed by the tools for all modes. With modes, each turn includes only the selected mode's instructions and tools. The second turn's history contains the complete earlier user message and reply. Widths are illustrative.</desc>
4
+ <defs>
5
+ <style>
6
+ text { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #17212f; }
7
+ .heading { font-size: 21px; font-weight: 700; }
8
+ .subtitle, .note { font-size: 14px; fill: #475569; }
9
+ .message { font-size: 15px; }
10
+ .route { font-size: 13px; font-weight: 600; fill: #1d4ed8; }
11
+ .cell-label { font-size: 12px; font-weight: 600; }
12
+ .outline { stroke: #64748b; stroke-width: 1; }
13
+ .divider { stroke: #64748b; stroke-width: 1.2; stroke-dasharray: 4 3; }
14
+ </style>
15
+ <g id="all-instructions">
16
+ <rect width="405" height="34" fill="#dbeafe" class="outline"/>
17
+ <rect width="50" height="34" fill="#bfdbfe"/>
18
+ <line x1="50" y1="3" x2="50" y2="31" class="divider"/>
19
+ <line x1="125" y1="3" x2="125" y2="31" class="divider"/>
20
+ <line x1="220" y1="3" x2="220" y2="31" class="divider"/>
21
+ <line x1="305" y1="3" x2="305" y2="31" class="divider"/>
22
+ <text x="25" y="22" text-anchor="middle" class="cell-label">Base</text>
23
+ <text x="87.5" y="22" text-anchor="middle" class="cell-label">Help</text>
24
+ <text x="172.5" y="22" text-anchor="middle" class="cell-label">Returns</text>
25
+ <text x="262.5" y="22" text-anchor="middle" class="cell-label">Orders</text>
26
+ <text x="355" y="22" text-anchor="middle" class="cell-label">Escalation</text>
27
+ </g>
28
+ <g id="all-tools">
29
+ <rect width="18" height="34" fill="#fde68a" class="outline"/>
30
+ <rect x="18" width="25" height="34" fill="#fde68a" class="outline"/>
31
+ <rect x="43" width="22" height="34" fill="#fde68a" class="outline"/>
32
+ <rect x="65" width="28" height="34" fill="#fde68a" class="outline"/>
33
+ <rect x="93" width="19" height="34" fill="#fde68a" class="outline"/>
34
+ </g>
35
+ <g id="help-config">
36
+ <rect width="50" height="34" fill="#bfdbfe" class="outline"/>
37
+ <rect x="50" width="75" height="34" fill="#dbeafe" class="outline"/>
38
+ <text x="25" y="22" text-anchor="middle" class="cell-label">Base</text>
39
+ <text x="87.5" y="22" text-anchor="middle" class="cell-label">Help</text>
40
+ </g>
41
+ <g id="returns-config">
42
+ <rect width="50" height="34" fill="#bfdbfe" class="outline"/>
43
+ <rect x="50" width="95" height="34" fill="#dbeafe" class="outline"/>
44
+ <rect x="145" width="18" height="34" fill="#fde68a" class="outline"/>
45
+ <rect x="163" width="25" height="34" fill="#fde68a" class="outline"/>
46
+ <text x="25" y="22" text-anchor="middle" class="cell-label">Base</text>
47
+ <text x="97.5" y="22" text-anchor="middle" class="cell-label">Returns</text>
48
+ </g>
49
+ <g id="first-message">
50
+ <rect width="120" height="34" fill="#334155" class="outline"/>
51
+ <text x="60" y="22" text-anchor="middle" class="cell-label" style="fill:#fff">New user</text>
52
+ </g>
53
+ <g id="second-message">
54
+ <rect width="200" height="34" fill="#e2e8f0" class="outline"/>
55
+ <rect x="200" width="180" height="34" fill="#334155" class="outline"/>
56
+ <text x="100" y="22" text-anchor="middle" class="cell-label">History</text>
57
+ <text x="290" y="22" text-anchor="middle" class="cell-label" style="fill:#fff">New user</text>
58
+ </g>
59
+ </defs>
60
+ <rect width="980" height="530" fill="#fff"/>
61
+ <text x="32" y="38" class="heading">One configuration</text>
62
+ <text x="32" y="61" class="subtitle">One prompt covers every mode; all tools are sent on every turn</text>
63
+
64
+ <text x="32" y="103" class="message">“What sizes do you have?”</text>
65
+ <use href="#all-instructions" x="32" y="114"/>
66
+ <use href="#all-tools" x="437" y="114"/>
67
+ <use href="#first-message" x="549" y="114"/>
68
+
69
+ <text x="32" y="184" class="message">“The jacket is too small, I want to send it back”</text>
70
+ <use href="#all-instructions" x="32" y="195"/>
71
+ <use href="#all-tools" x="437" y="195"/>
72
+ <use href="#second-message" x="549" y="195"/>
73
+
74
+ <line x1="32" y1="260" x2="929" y2="260" stroke="#cbd5e1"/>
75
+ <text x="32" y="299" class="heading">With modes</text>
76
+ <text x="32" y="322" class="subtitle">Only the selected mode's instructions and tools are sent</text>
77
+
78
+ <text x="32" y="364" class="message">“What sizes do you have?”</text>
79
+ <text x="258" y="364" class="route">router → help</text>
80
+ <use href="#help-config" x="32" y="375"/>
81
+ <use href="#first-message" x="157" y="375"/>
82
+
83
+ <text x="32" y="445" class="message">“The jacket is too small, I want to send it back”</text>
84
+ <text x="436" y="445" class="route">router → returns</text>
85
+ <use href="#returns-config" x="32" y="456"/>
86
+ <use href="#second-message" x="220" y="456"/>
87
+
88
+ <text x="32" y="513" class="note">Total context, shown schematically. Yellow blocks are separate tool declarations; gray blocks are history.</text>
89
+ </svg>
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A RubyLLM::Agent that is routable: subclass it and declare a
5
+ # description. Its instructions append to the chat's system prompt
6
+ # and are not persisted (see Modes::Mode). Apps with their own agent base
7
+ # class extend RubyLLM::Modes::Mode into that base instead.
8
+ class ModeAgent < Agent
9
+ extend Modes::Mode
10
+ end
11
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ module Classifiers
6
+ # The +:chat+ backend: one structured-output turn on a RubyLLM chat.
7
+ #
8
+ # The system prompt is the built-in frame (Chat.prompt): the router's
9
+ # instructions, the modes, and the conversation.
10
+ # +chat_factory:+ replaces RubyLLM.chat; it is called with +model:+
11
+ # and must return a chat. +confidence+ is the model's self-report.
12
+ #
13
+ # Any object with the same +call+ signature is a valid classifier:
14
+ #
15
+ # call(message:, history:, modes:, instructions:, inputs:) # => Decision
16
+ #
17
+ class Chat
18
+ FRAME_HEAD = <<~TEXT.strip
19
+ You route the latest user message to one of the modes below.
20
+ Choose exactly one. Use the conversation only to understand what the
21
+ latest message refers to. Do not answer the user.
22
+ TEXT
23
+
24
+ FRAME_TAIL = "Return the structured selection: mode, confidence from 0 to 1, reason."
25
+
26
+ attr_reader :model, :chat_factory
27
+
28
+ def initialize(model: nil, chat_factory: nil)
29
+ @model = model
30
+ @chat_factory = chat_factory
31
+ end
32
+
33
+ def call(message:, history:, modes:, instructions:, inputs:)
34
+ @resolved_model = nil
35
+ text = self.class.prompt(message:, history:, modes:, instructions:)
36
+ chat = build_chat
37
+ response = chat.with_instructions(text).with_schema(self.class.schema_for(modes)).ask(message)
38
+ decision_from(response)
39
+ end
40
+
41
+ # What ran, for Route#classifier: the resolved model id once a chat
42
+ # was built, else the declared model.
43
+ def trace
44
+ { with: "chat", model: @resolved_model || model }
45
+ end
46
+
47
+ # The system prompt for +modes+ (Registration values) and the
48
+ # normalised +history+, with the router's +instructions+ between
49
+ # the frame and the modes. The latest +message+ is the user turn,
50
+ # so it is not repeated here.
51
+ def self.prompt(message:, history:, modes:, instructions: nil)
52
+ sections = [ FRAME_HEAD ]
53
+ sections << instructions unless instructions.nil? || instructions.empty?
54
+ sections << "Modes:\n#{modes.map { |mode| "- #{mode.name}: #{indent(mode.description)}" }.join("\n")}"
55
+ sections << "Conversation:\n#{history.map { |entry| transcript_line(entry) }.join("\n")}" if history.any?
56
+ sections << FRAME_TAIL
57
+ sections.join("\n\n")
58
+ end
59
+
60
+ # The selection schema: +mode+ is an enum of the available names.
61
+ def self.schema_for(modes)
62
+ names = modes.map(&:name)
63
+ Schematist::Schema.create do
64
+ string :mode, enum: names
65
+ number :confidence, minimum: 0, maximum: 1
66
+ string :reason
67
+ end
68
+ end
69
+
70
+ def self.indent(description)
71
+ description.to_s.lines.map(&:chomp).join("\n ")
72
+ end
73
+
74
+ def self.transcript_line(entry)
75
+ entry[:role] ? "#{entry[:role]}: #{entry[:content]}" : entry[:content].to_s
76
+ end
77
+
78
+ private_class_method :indent, :transcript_line
79
+
80
+ private
81
+
82
+ # The trace is plain data for logs, so only a String model id is
83
+ # kept: a stand-in chat that answers every message with itself must
84
+ # not end up serialised into a route.
85
+ def build_chat
86
+ chat = chat_factory ? chat_factory.call(model: model) : RubyLLM.chat(model: model)
87
+ resolved = chat.model.id if chat.respond_to?(:model) && chat.model.respond_to?(:id)
88
+ @resolved_model = resolved if resolved.is_a?(String)
89
+ chat
90
+ end
91
+
92
+ def decision_from(response)
93
+ selection = response.parsed
94
+ raise ContractError, "chat backend returned #{selection.class}, expected a JSON object" unless selection.is_a?(Hash)
95
+
96
+ confidence = selection["confidence"]
97
+ Decision.new(
98
+ mode_name: selection["mode"]&.to_s,
99
+ confidence: confidence.is_a?(Numeric) ? confidence.to_f : confidence,
100
+ reason: selection["reason"]&.to_s,
101
+ probabilities: nil
102
+ )
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end