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 +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/assets/one-configuration-vs-modes.svg +89 -0
- data/lib/ruby_llm/mode_agent.rb +11 -0
- data/lib/ruby_llm/modes/classifiers/chat.rb +107 -0
- data/lib/ruby_llm/modes/classifiers/judge.rb +112 -0
- data/lib/ruby_llm/modes/decision.rb +24 -0
- data/lib/ruby_llm/modes/errors.rb +21 -0
- data/lib/ruby_llm/modes/mode.rb +92 -0
- data/lib/ruby_llm/modes/registration.rb +17 -0
- data/lib/ruby_llm/modes/route.rb +50 -0
- data/lib/ruby_llm/modes/router.rb +499 -0
- data/lib/ruby_llm/modes/truncation.rb +41 -0
- data/lib/ruby_llm/modes/version.rb +7 -0
- data/lib/ruby_llm/modes.rb +23 -0
- data/ruby_llm-modes.gemspec +33 -0
- metadata +98 -0
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
|
+
[](https://rubygems.org/gems/ruby_llm-modes)
|
|
4
|
+
[](https://github.com/kryzhovnik/ruby_llm-modes/actions/workflows/ci.yml)
|
|
5
|
+
[](#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
|
+

|
|
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
|