Class: RubyLLM::Modes::Router

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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.

Raises:

  • (ArgumentError)


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, **options)
  @classifier_spec = { with: backend, model: model, options: 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, 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 message_limit = 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: message_limit, history_entry: history_entry_limit)
  @message_limit = limit_value(:message, message)
  @history_entry_limit = limit_value(:history_entry, history_entry)
end

.validate! ⇒ Object

Checks the declaration; raises DeclarationError on the first problem.

Raises:



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.

Raises:



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)
  message, history = split_conversation(messages)
  available = modes
  return fallback_route(chat, "No other mode available", duration_ms: 0) if available.size == 1

  backend = classifier || self.classifier
  request = {
    message: truncate_message(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