Class: RubyLLM::Modes::Router
- Inherits:
-
Object
- Object
- RubyLLM::Modes::Router
- Defined in:
- lib/ruby_llm/modes/router.rb
Overview
The declaration: modes, a fallback, a classifier, and instructions.
class ChatModeRouter < RubyLLM::Modes::Router
inputs :user, :card
mode TutorAgent
mode ManageCardsAgent, "Manages flashcards"
mode ShowtimeAgent, if: -> { user.showtime_enabled? }
instructions { "The learner has a flashcard open." if card }
history last: 6
truncate message: 30_000, history_entry: 2_000
fallback TutorAgent, below_confidence: 0.6
classify_with :chat, model: "gemini-3.5-flash-lite"
end
chat.ask_later(text)
route = ChatModeRouter.new(user:, card:).route(chat)
route.mode.complete
Subclassing copies the declarations. mode appends to the inherited
list; the other macros replace. Declarations are validated when a
router is built with new, not when the class is defined.
Constant Summary collapse
- MESSAGE_LIMIT =
Default character caps on what reaches the classifier: the routed message keeps its head and tail, each history entry its head. Well under the smallest backend limit known (Jev: about 170k characters per request) with a history of a few dozen entries.
30_000- HISTORY_ENTRY_LIMIT =
2_000- REASON_LIMIT =
How much of a provider's message the fallback reason keeps.
200
Instance Attribute Summary collapse
-
#classifier ⇒ Object
readonly
The declared classifier backend for this router instance.
-
#inputs ⇒ Object
readonly
The inputs passed to
new.
Class Method Summary collapse
- .below_confidence ⇒ Object
- .classifier_spec ⇒ Object
-
.classify_with(backend, model: nil, **options) ⇒ Object
Picks the classifier:
:chat,:judge, or any object responding tocall(see Classifiers::Chat for the contract). - .error_handler ⇒ Object
-
.fallback(klass, below_confidence: nil) ⇒ Object
The mode used when the classifier is ignored.
- .fallback_class ⇒ Object
-
.history(scope = nil, last: nil) ⇒ Object
history last: 6 keeps the last six entries after filtering.
- .history_entry_limit ⇒ Object
- .history_limit ⇒ Object
-
.inherited(subclass) ⇒ Object
:nodoc:.
- .input_names ⇒ Object
-
.inputs(*names) ⇒ Object
Declares runtime inputs.
-
.instructions(text = nil, **locals, &block) ⇒ Object
The app's text for the classifier, ahead of the modes and the conversation.
- .instructions_source ⇒ Object
- .message_limit ⇒ Object
-
.mode(klass, description = nil, as: nil, if: nil) ⇒ Object
Registers a mode.
-
.on_error(&block) ⇒ Object
Receives every exception a classifier raises, including ContractError.
-
.prompt_path ⇒ Object
The directory under
app/prompts/for this router's templates:ChatModeRouterischat_mode_router,Duck::ChatRouterisduck/chat_router. - .registrations ⇒ Object
-
.truncate(message: message_limit, history_entry: history_entry_limit) ⇒ Object
Character caps on what reaches the classifier, whatever the backend.
-
.validate! ⇒ Object
Checks the declaration; raises DeclarationError on the first problem.
Instance Method Summary collapse
-
#force(name, chat:) ⇒ Object
Routes
chatto the mode registered asnamebecause the caller chose it; no classifier runs and the conversation is not read. -
#initialize(**inputs) ⇒ Router
constructor
Builds a router for one call.
-
#modes ⇒ Object
The Registration values available for this call, in declaration order.
-
#route(chat, messages: chat, classifier: nil) ⇒ Object
Routes the latest user message from
messages, which defaults tochat.
Constructor Details
#initialize(**inputs) ⇒ Router
Builds a router for one call. Every declared input must be present as a keyword (a nil value counts); raises ArgumentError otherwise. Raises DeclarationError when the class declaration is invalid.
264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/ruby_llm/modes/router.rb', line 264 def initialize(**inputs) self.class.validate! missing = self.class.input_names - inputs.keys raise ArgumentError, "missing input(s): #{missing.join(", ")}" if missing.any? unknown = inputs.keys - self.class.input_names raise ArgumentError, "unknown input(s): #{unknown.join(", ")}" if unknown.any? @inputs = inputs.freeze @inputs.each { |input_name, value| define_singleton_method(input_name) { value } } @classifier = build_classifier end |
Instance Attribute Details
#classifier ⇒ Object (readonly)
The declared classifier backend for this router instance.
282 283 284 |
# File 'lib/ruby_llm/modes/router.rb', line 282 def classifier @classifier end |
#inputs ⇒ Object (readonly)
The inputs passed to new.
279 280 281 |
# File 'lib/ruby_llm/modes/router.rb', line 279 def inputs @inputs end |
Class Method Details
.below_confidence ⇒ Object
162 |
# File 'lib/ruby_llm/modes/router.rb', line 162 def below_confidence = @below_confidence |
.classifier_spec ⇒ Object
163 |
# File 'lib/ruby_llm/modes/router.rb', line 163 def classifier_spec = @classifier_spec |
.classify_with(backend, model: nil, **options) ⇒ Object
Picks the classifier: :chat, :judge, or any object responding
to call (see Classifiers::Chat for the contract). Required.
Remaining options go to the built-in backend (+chat_factory:+ for
:chat; provider: and judge: for :judge).
146 147 148 |
# File 'lib/ruby_llm/modes/router.rb', line 146 def classify_with(backend, model: nil, **) @classifier_spec = { with: backend, model: model, options: } end |
.error_handler ⇒ Object
164 |
# File 'lib/ruby_llm/modes/router.rb', line 164 def error_handler = @error_handler |
.fallback(klass, below_confidence: nil) ⇒ Object
The mode used when the classifier is ignored. below_confidence:
sets the threshold under which a decision is ignored; nil disables it.
137 138 139 140 |
# File 'lib/ruby_llm/modes/router.rb', line 137 def fallback(klass, below_confidence: nil) @fallback_class = klass @below_confidence = below_confidence end |
.fallback_class ⇒ Object
161 |
# File 'lib/ruby_llm/modes/router.rb', line 161 def fallback_class = @fallback_class |
.history(scope = nil, last: nil) ⇒ Object
history last: 6 keeps the last six entries after filtering. history :all is the default and removes an inherited limit.
109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/ruby_llm/modes/router.rb', line 109 def history(scope = nil, last: nil) unless (scope == :all) ^ !last.nil? raise ArgumentError, "history takes :all or last: n, got #{[ scope, last ].compact.inspect}" end unless last.nil? || (last.is_a?(Integer) && last.positive?) raise ArgumentError, "history last: takes a positive Integer, got #{last.inspect}" end @history_limit = last end |
.history_entry_limit ⇒ Object
160 |
# File 'lib/ruby_llm/modes/router.rb', line 160 def history_entry_limit = defined?(@history_entry_limit) ? @history_entry_limit : HISTORY_ENTRY_LIMIT |
.history_limit ⇒ Object
158 |
# File 'lib/ruby_llm/modes/router.rb', line 158 def history_limit = @history_limit |
.inherited(subclass) ⇒ Object
:nodoc:
43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/ruby_llm/modes/router.rb', line 43 def inherited(subclass) # :nodoc: super subclass.instance_variable_set(:@registrations, registrations.dup) subclass.instance_variable_set(:@input_names, input_names.dup) subclass.instance_variable_set(:@instructions_source, @instructions_source) subclass.instance_variable_set(:@history_limit, @history_limit) subclass.instance_variable_set(:@message_limit, ) subclass.instance_variable_set(:@history_entry_limit, history_entry_limit) subclass.instance_variable_set(:@fallback_class, @fallback_class) subclass.instance_variable_set(:@below_confidence, @below_confidence) subclass.instance_variable_set(:@classifier_spec, @classifier_spec) subclass.instance_variable_set(:@error_handler, @error_handler) end |
.input_names ⇒ Object
157 |
# File 'lib/ruby_llm/modes/router.rb', line 157 def input_names = @input_names ||= [] |
.inputs(*names) ⇒ Object
Declares runtime inputs. Every declared name must be passed to
new; each is then a method on the router instance, visible in
if: and instructions blocks. Called with no arguments,
returns the declared names.
61 62 63 64 65 |
# File 'lib/ruby_llm/modes/router.rb', line 61 def inputs(*names) return input_names if names.empty? @input_names = names.flatten.map(&:to_sym) end |
.instructions(text = nil, **locals, &block) ⇒ Object
The app's text for the classifier, ahead of the modes and the
conversation. Like Agent#instructions it accepts a string, a
block run on the router instance (inputs are methods, and
prompt(name, **locals) renders a template next to the router's
own), or keyword locals for the conventional template
app/prompts/<router_path>/instructions.txt.erb, which a
bare instructions also selects. Procs among the locals run on
the router instance. Every backend receives the same resolved
string.
instructions "Route by the learner's intended action."
instructions { "The learner has a flashcard open." if card }
instructions # chat_mode_router/instructions.txt.erb
instructions deck: -> { card.deck.name } # the same template, with a local
96 97 98 |
# File 'lib/ruby_llm/modes/router.rb', line 96 def instructions(text = nil, **locals, &block) @instructions_source = block || text || { prompt: "instructions", locals: locals } end |
.instructions_source ⇒ Object
165 |
# File 'lib/ruby_llm/modes/router.rb', line 165 def instructions_source = @instructions_source |
.message_limit ⇒ Object
159 |
# File 'lib/ruby_llm/modes/router.rb', line 159 def = defined?(@message_limit) ? @message_limit : MESSAGE_LIMIT |
.mode(klass, description = nil, as: nil, if: nil) ⇒ Object
Registers a mode. klass is a RubyLLM::Agent subclass. The inline
description wins over klass.description. as: sets the
registration name (default: klass.mode_name, else the derivation
of Mode.derive_name). if: is a lambda run on the router instance
that decides availability per call.
72 73 74 75 76 77 78 79 80 |
# File 'lib/ruby_llm/modes/router.rb', line 72 def mode(klass, description = nil, as: nil, if: nil) condition = binding.local_variable_get(:if) registrations << Registration.new( klass: klass, name: as&.to_s || registration_name_for(klass), description: description&.to_s&.strip || description_for(klass), condition: condition ) end |
.on_error(&block) ⇒ Object
Receives every exception a classifier raises, including ContractError. Runs on the router instance. Default: nothing.
152 153 154 |
# File 'lib/ruby_llm/modes/router.rb', line 152 def on_error(&block) @error_handler = block end |
.prompt_path ⇒ Object
The directory under app/prompts/ for this router's templates:
ChatModeRouter is chat_mode_router, Duck::ChatRouter is
duck/chat_router.
103 104 105 |
# File 'lib/ruby_llm/modes/router.rb', line 103 def prompt_path RubyLLM::Support::Utils.underscore((name || "router").gsub("::", "/")) end |
.registrations ⇒ Object
156 |
# File 'lib/ruby_llm/modes/router.rb', line 156 def registrations = @registrations ||= [] |
.truncate(message: message_limit, history_entry: history_entry_limit) ⇒ Object
Character caps on what reaches the classifier, whatever the
backend. The routed message: keeps its first and last half
(the intent of a long paste is at one end); each history_entry:
keeps its head. A marker names how many characters were cut.
Defaults: MESSAGE_LIMIT and HISTORY_ENTRY_LIMIT; nil disables a
cap. Pass only the caps to change.
truncate message: 30_000, history_entry: 2_000
truncate history_entry: nil
130 131 132 133 |
# File 'lib/ruby_llm/modes/router.rb', line 130 def truncate(message: , history_entry: history_entry_limit) @message_limit = limit_value(:message, ) @history_entry_limit = limit_value(:history_entry, history_entry) end |
.validate! ⇒ Object
Checks the declaration; raises DeclarationError on the first problem.
168 169 170 171 172 173 174 175 176 177 |
# File 'lib/ruby_llm/modes/router.rb', line 168 def validate! raise DeclarationError, "#{name}: no fallback declared" if fallback_class.nil? validate_inputs! registrations.each { |registration| validate_registration!(registration) } validate_uniqueness! validate_fallback! validate_classifier! validate_instructions! end |
Instance Method Details
#force(name, chat:) ⇒ Object
Routes chat to the mode registered as name because the caller
chose it; no classifier runs and the conversation is not read.
Respects if: and raises UnknownMode when the name is not
registered or not available now.
331 332 333 334 335 336 |
# File 'lib/ruby_llm/modes/router.rb', line 331 def force(name, chat:) registration = modes.find { |candidate| candidate.name == name.to_s } raise UnknownMode.new("Unknown mode #{name}", receiver: self, key: name) unless registration Route.new(mode_class: registration.klass, mode_name: registration.name, chat:, inputs:, decided_by: :caller, reason: "Mode requested by caller") end |
#modes ⇒ Object
The Registration values available for this call, in declaration order.
285 286 287 |
# File 'lib/ruby_llm/modes/router.rb', line 285 def modes self.class.registrations.select { |registration| registration.available_on?(self) } end |
#route(chat, messages: chat, classifier: nil) ⇒ Object
Routes the latest user message from messages, which defaults to
chat. Returns a Route bound to chat, regardless of the message
source. The source must yield its entries with each, as
RubyLLM::Chat, a Rails chat record, and an Agent do.
The entries are RubyLLM::Message objects, records
responding to to_llm, { role:, content: } hashes, or
strings. System messages are left out; the last remaining entry
must be a user message (ArgumentError otherwise) and is the routed
message. History keeps nonblank user/assistant content and plain
text context; tool results and other roles are excluded. classifier:
replaces the declared backend for this call.
The message and the history entries are cut to the declared
truncate caps first.
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
# File 'lib/ruby_llm/modes/router.rb', line 303 def route(chat, messages: chat, classifier: nil) , history = split_conversation() available = modes return fallback_route(chat, "No other mode available", duration_ms: 0) if available.size == 1 backend = classifier || self.classifier request = { message: (), history: limit_history(history), modes: available, instructions: resolved_instructions, inputs: inputs } started = monotonic_ms decision, error = run_classifier(backend, request) duration_ms = monotonic_ms - started trace = trace_for(backend) return fallback_route(chat, failure_reason(error), duration_ms:, classifier: trace, error:) if error resolve(chat, decision, available, duration_ms:, classifier: trace) end |