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
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Modes
|
|
5
|
+
# The declaration: modes, a fallback, a classifier, and instructions.
|
|
6
|
+
#
|
|
7
|
+
# class ChatModeRouter < RubyLLM::Modes::Router
|
|
8
|
+
# inputs :user, :card
|
|
9
|
+
#
|
|
10
|
+
# mode TutorAgent
|
|
11
|
+
# mode ManageCardsAgent, "Manages flashcards"
|
|
12
|
+
# mode ShowtimeAgent, if: -> { user.showtime_enabled? }
|
|
13
|
+
#
|
|
14
|
+
# instructions { "The learner has a flashcard open." if card }
|
|
15
|
+
# history last: 6
|
|
16
|
+
# truncate message: 30_000, history_entry: 2_000
|
|
17
|
+
# fallback TutorAgent, below_confidence: 0.6
|
|
18
|
+
# classify_with :chat, model: "gemini-3.5-flash-lite"
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# chat.ask_later(text)
|
|
22
|
+
# route = ChatModeRouter.new(user:, card:).route(chat)
|
|
23
|
+
# route.mode.complete
|
|
24
|
+
#
|
|
25
|
+
# Subclassing copies the declarations. +mode+ appends to the inherited
|
|
26
|
+
# list; the other macros replace. Declarations are validated when a
|
|
27
|
+
# router is built with +new+, not when the class is defined.
|
|
28
|
+
class Router
|
|
29
|
+
BACKENDS = %i[chat judge].freeze
|
|
30
|
+
private_constant :BACKENDS
|
|
31
|
+
|
|
32
|
+
# Default character caps on what reaches the classifier: the routed
|
|
33
|
+
# message keeps its head and tail, each history entry its head.
|
|
34
|
+
# Well under the smallest backend limit known (Jev: about 170k
|
|
35
|
+
# characters per request) with a history of a few dozen entries.
|
|
36
|
+
MESSAGE_LIMIT = 30_000
|
|
37
|
+
HISTORY_ENTRY_LIMIT = 2_000
|
|
38
|
+
|
|
39
|
+
# How much of a provider's message the fallback reason keeps.
|
|
40
|
+
REASON_LIMIT = 200
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def inherited(subclass) # :nodoc:
|
|
44
|
+
super
|
|
45
|
+
subclass.instance_variable_set(:@registrations, registrations.dup)
|
|
46
|
+
subclass.instance_variable_set(:@input_names, input_names.dup)
|
|
47
|
+
subclass.instance_variable_set(:@instructions_source, @instructions_source)
|
|
48
|
+
subclass.instance_variable_set(:@history_limit, @history_limit)
|
|
49
|
+
subclass.instance_variable_set(:@message_limit, message_limit)
|
|
50
|
+
subclass.instance_variable_set(:@history_entry_limit, history_entry_limit)
|
|
51
|
+
subclass.instance_variable_set(:@fallback_class, @fallback_class)
|
|
52
|
+
subclass.instance_variable_set(:@below_confidence, @below_confidence)
|
|
53
|
+
subclass.instance_variable_set(:@classifier_spec, @classifier_spec)
|
|
54
|
+
subclass.instance_variable_set(:@error_handler, @error_handler)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Declares runtime inputs. Every declared name must be passed to
|
|
58
|
+
# +new+; each is then a method on the router instance, visible in
|
|
59
|
+
# +if:+ and +instructions+ blocks. Called with no arguments,
|
|
60
|
+
# returns the declared names.
|
|
61
|
+
def inputs(*names)
|
|
62
|
+
return input_names if names.empty?
|
|
63
|
+
|
|
64
|
+
@input_names = names.flatten.map(&:to_sym)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Registers a mode. +klass+ is a RubyLLM::Agent subclass. The inline
|
|
68
|
+
# +description+ wins over +klass.description+. +as:+ sets the
|
|
69
|
+
# registration name (default: +klass.mode_name+, else the derivation
|
|
70
|
+
# of Mode.derive_name). +if:+ is a lambda run on the router instance
|
|
71
|
+
# that decides availability per call.
|
|
72
|
+
def mode(klass, description = nil, as: nil, if: nil)
|
|
73
|
+
condition = binding.local_variable_get(:if)
|
|
74
|
+
registrations << Registration.new(
|
|
75
|
+
klass: klass,
|
|
76
|
+
name: as&.to_s || registration_name_for(klass),
|
|
77
|
+
description: description&.to_s&.strip || description_for(klass),
|
|
78
|
+
condition: condition
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The app's text for the classifier, ahead of the modes and the
|
|
83
|
+
# conversation. Like Agent#instructions it accepts a string, a
|
|
84
|
+
# block run on the router instance (inputs are methods, and
|
|
85
|
+
# +prompt(name, **locals)+ renders a template next to the router's
|
|
86
|
+
# own), or keyword locals for the conventional template
|
|
87
|
+
# <tt>app/prompts/<router_path>/instructions.txt.erb</tt>, which a
|
|
88
|
+
# bare +instructions+ also selects. Procs among the locals run on
|
|
89
|
+
# the router instance. Every backend receives the same resolved
|
|
90
|
+
# string.
|
|
91
|
+
#
|
|
92
|
+
# instructions "Route by the learner's intended action."
|
|
93
|
+
# instructions { "The learner has a flashcard open." if card }
|
|
94
|
+
# instructions # chat_mode_router/instructions.txt.erb
|
|
95
|
+
# instructions deck: -> { card.deck.name } # the same template, with a local
|
|
96
|
+
def instructions(text = nil, **locals, &block)
|
|
97
|
+
@instructions_source = block || text || { prompt: "instructions", locals: locals }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The directory under +app/prompts/+ for this router's templates:
|
|
101
|
+
# +ChatModeRouter+ is +chat_mode_router+, +Duck::ChatRouter+ is
|
|
102
|
+
# +duck/chat_router+.
|
|
103
|
+
def prompt_path
|
|
104
|
+
RubyLLM::Support::Utils.underscore((name || "router").gsub("::", "/"))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# <tt>history last: 6</tt> keeps the last six entries after filtering.
|
|
108
|
+
# <tt>history :all</tt> is the default and removes an inherited limit.
|
|
109
|
+
def history(scope = nil, last: nil)
|
|
110
|
+
unless (scope == :all) ^ !last.nil?
|
|
111
|
+
raise ArgumentError, "history takes :all or last: n, got #{[ scope, last ].compact.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
unless last.nil? || (last.is_a?(Integer) && last.positive?)
|
|
115
|
+
raise ArgumentError, "history last: takes a positive Integer, got #{last.inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@history_limit = last
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Character caps on what reaches the classifier, whatever the
|
|
122
|
+
# backend. The routed +message:+ keeps its first and last half
|
|
123
|
+
# (the intent of a long paste is at one end); each +history_entry:+
|
|
124
|
+
# keeps its head. A marker names how many characters were cut.
|
|
125
|
+
# Defaults: MESSAGE_LIMIT and HISTORY_ENTRY_LIMIT; nil disables a
|
|
126
|
+
# cap. Pass only the caps to change.
|
|
127
|
+
#
|
|
128
|
+
# truncate message: 30_000, history_entry: 2_000
|
|
129
|
+
# truncate history_entry: nil
|
|
130
|
+
def truncate(message: message_limit, history_entry: history_entry_limit)
|
|
131
|
+
@message_limit = limit_value(:message, message)
|
|
132
|
+
@history_entry_limit = limit_value(:history_entry, history_entry)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The mode used when the classifier is ignored. +below_confidence:+
|
|
136
|
+
# sets the threshold under which a decision is ignored; nil disables it.
|
|
137
|
+
def fallback(klass, below_confidence: nil)
|
|
138
|
+
@fallback_class = klass
|
|
139
|
+
@below_confidence = below_confidence
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Picks the classifier: +:chat+, +:judge+, or any object responding
|
|
143
|
+
# to +call+ (see Classifiers::Chat for the contract). Required.
|
|
144
|
+
# Remaining options go to the built-in backend (+chat_factory:+ for
|
|
145
|
+
# +:chat+; +provider:+ and +judge:+ for +:judge+).
|
|
146
|
+
def classify_with(backend, model: nil, **options)
|
|
147
|
+
@classifier_spec = { with: backend, model: model, options: options }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Receives every exception a classifier raises, including
|
|
151
|
+
# ContractError. Runs on the router instance. Default: nothing.
|
|
152
|
+
def on_error(&block)
|
|
153
|
+
@error_handler = block
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def registrations = @registrations ||= []
|
|
157
|
+
def input_names = @input_names ||= []
|
|
158
|
+
def history_limit = @history_limit
|
|
159
|
+
def message_limit = defined?(@message_limit) ? @message_limit : MESSAGE_LIMIT
|
|
160
|
+
def history_entry_limit = defined?(@history_entry_limit) ? @history_entry_limit : HISTORY_ENTRY_LIMIT
|
|
161
|
+
def fallback_class = @fallback_class
|
|
162
|
+
def below_confidence = @below_confidence
|
|
163
|
+
def classifier_spec = @classifier_spec
|
|
164
|
+
def error_handler = @error_handler
|
|
165
|
+
def instructions_source = @instructions_source
|
|
166
|
+
|
|
167
|
+
# Checks the declaration; raises DeclarationError on the first problem.
|
|
168
|
+
def validate!
|
|
169
|
+
raise DeclarationError, "#{name}: no fallback declared" if fallback_class.nil?
|
|
170
|
+
|
|
171
|
+
validate_inputs!
|
|
172
|
+
registrations.each { |registration| validate_registration!(registration) }
|
|
173
|
+
validate_uniqueness!
|
|
174
|
+
validate_fallback!
|
|
175
|
+
validate_classifier!
|
|
176
|
+
validate_instructions!
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def limit_value(name, value)
|
|
182
|
+
return value if value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
183
|
+
|
|
184
|
+
raise ArgumentError, "truncate #{name}: takes a positive Integer or nil, got #{value.inspect}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def registration_name_for(klass)
|
|
188
|
+
klass.respond_to?(:mode_name) ? klass.mode_name : Mode.derive_name(klass)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def description_for(klass)
|
|
192
|
+
klass.description if klass.respond_to?(:description)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Inputs become methods on the router instance, so a name that the
|
|
196
|
+
# router (or Object) already answers to would shadow it.
|
|
197
|
+
def validate_inputs!
|
|
198
|
+
taken = input_names.find { |input_name| method_defined?(input_name) || private_method_defined?(input_name) }
|
|
199
|
+
raise DeclarationError, "#{name}: input #{taken.inspect} shadows a router method; pick another name" if taken
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_registration!(registration)
|
|
203
|
+
klass = registration.klass
|
|
204
|
+
unless klass.is_a?(Class) && klass < RubyLLM::Agent
|
|
205
|
+
raise DeclarationError, "#{name}: #{klass.inspect} is not a RubyLLM::Agent subclass"
|
|
206
|
+
end
|
|
207
|
+
if registration.name.nil? || registration.name.empty?
|
|
208
|
+
raise DeclarationError, "#{name}: #{klass.inspect} has no registration name; pass as: or set mode_name"
|
|
209
|
+
end
|
|
210
|
+
return unless registration.description.nil? || registration.description.empty?
|
|
211
|
+
|
|
212
|
+
raise DeclarationError,
|
|
213
|
+
"#{name}: mode #{registration.name} has no description; declare one on the class or pass it inline"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_uniqueness!
|
|
217
|
+
classes = registrations.map(&:klass)
|
|
218
|
+
duplicate = classes.find { |klass| classes.count(klass) > 1 }
|
|
219
|
+
raise DeclarationError, "#{name}: #{duplicate} is registered twice" if duplicate
|
|
220
|
+
|
|
221
|
+
names = registrations.map(&:name)
|
|
222
|
+
duplicate = names.find { |mode_name| names.count(mode_name) > 1 }
|
|
223
|
+
raise DeclarationError, "#{name}: duplicate registration name #{duplicate.inspect}" if duplicate
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def validate_fallback!
|
|
227
|
+
registration = registrations.find { |candidate| candidate.klass == fallback_class }
|
|
228
|
+
raise DeclarationError, "#{name}: fallback #{fallback_class} is not registered with mode" unless registration
|
|
229
|
+
raise DeclarationError, "#{name}: fallback #{fallback_class} must not have an if: condition" if registration.condition
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A conventional template is looked up here, not on the first call.
|
|
233
|
+
def validate_instructions!
|
|
234
|
+
return unless instructions_source.is_a?(Hash)
|
|
235
|
+
|
|
236
|
+
template = RubyLLM::Prompt.new("#{prompt_path}/#{instructions_source[:prompt]}")
|
|
237
|
+
return if File.exist?(template.path)
|
|
238
|
+
|
|
239
|
+
raise DeclarationError, "#{name}: instructions template not found at #{template.path}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def validate_classifier!
|
|
243
|
+
raise DeclarationError, "#{name}: no classifier declared; add classify_with :chat, :judge, or an object" if classifier_spec.nil?
|
|
244
|
+
|
|
245
|
+
backend = classifier_spec[:with]
|
|
246
|
+
case backend
|
|
247
|
+
when :chat
|
|
248
|
+
nil
|
|
249
|
+
when :judge
|
|
250
|
+
return if classifier_spec[:options][:judge] || Classifiers::Judge.available?
|
|
251
|
+
|
|
252
|
+
raise DeclarationError, "#{name}: RubyLLM.judge is not available in ruby_llm #{RubyLLM::VERSION}; the :judge backend needs a release that ships RubyLLM::Judge"
|
|
253
|
+
when Symbol
|
|
254
|
+
raise DeclarationError, "#{name}: unknown classifier backend #{backend.inspect}"
|
|
255
|
+
else
|
|
256
|
+
raise DeclarationError, "#{name}: classifier #{backend.inspect} does not respond to call" unless backend.respond_to?(:call)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Builds a router for one call. Every declared input must be present
|
|
262
|
+
# as a keyword (a nil value counts); raises ArgumentError otherwise.
|
|
263
|
+
# Raises DeclarationError when the class declaration is invalid.
|
|
264
|
+
def initialize(**inputs)
|
|
265
|
+
self.class.validate!
|
|
266
|
+
|
|
267
|
+
missing = self.class.input_names - inputs.keys
|
|
268
|
+
raise ArgumentError, "missing input(s): #{missing.join(", ")}" if missing.any?
|
|
269
|
+
|
|
270
|
+
unknown = inputs.keys - self.class.input_names
|
|
271
|
+
raise ArgumentError, "unknown input(s): #{unknown.join(", ")}" if unknown.any?
|
|
272
|
+
|
|
273
|
+
@inputs = inputs.freeze
|
|
274
|
+
@inputs.each { |input_name, value| define_singleton_method(input_name) { value } }
|
|
275
|
+
@classifier = build_classifier
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The inputs passed to +new+.
|
|
279
|
+
attr_reader :inputs
|
|
280
|
+
|
|
281
|
+
# The declared classifier backend for this router instance.
|
|
282
|
+
attr_reader :classifier
|
|
283
|
+
|
|
284
|
+
# The Registration values available for this call, in declaration order.
|
|
285
|
+
def modes
|
|
286
|
+
self.class.registrations.select { |registration| registration.available_on?(self) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Routes the latest user message from +messages+, which defaults to
|
|
290
|
+
# +chat+. Returns a Route bound to +chat+, regardless of the message
|
|
291
|
+
# source. The source must yield its entries with +each+, as
|
|
292
|
+
# RubyLLM::Chat, a Rails chat record, and an Agent do.
|
|
293
|
+
# The entries are RubyLLM::Message objects, records
|
|
294
|
+
# responding to +to_llm+, <tt>{ role:, content: }</tt> hashes, or
|
|
295
|
+
# strings. System messages are left out; the last remaining entry
|
|
296
|
+
# must be a user message (ArgumentError otherwise) and is the routed
|
|
297
|
+
# message. History keeps nonblank user/assistant content and plain
|
|
298
|
+
# text context; tool results and other roles are excluded. +classifier:+
|
|
299
|
+
# replaces the declared backend for this call.
|
|
300
|
+
#
|
|
301
|
+
# The message and the history entries are cut to the declared
|
|
302
|
+
# +truncate+ caps first.
|
|
303
|
+
def route(chat, messages: chat, classifier: nil)
|
|
304
|
+
message, history = split_conversation(messages)
|
|
305
|
+
available = modes
|
|
306
|
+
return fallback_route(chat, "No other mode available", duration_ms: 0) if available.size == 1
|
|
307
|
+
|
|
308
|
+
backend = classifier || self.classifier
|
|
309
|
+
request = {
|
|
310
|
+
message: truncate_message(message),
|
|
311
|
+
history: limit_history(history),
|
|
312
|
+
modes: available,
|
|
313
|
+
instructions: resolved_instructions,
|
|
314
|
+
inputs: inputs
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
started = monotonic_ms
|
|
318
|
+
decision, error = run_classifier(backend, request)
|
|
319
|
+
duration_ms = monotonic_ms - started
|
|
320
|
+
trace = trace_for(backend)
|
|
321
|
+
|
|
322
|
+
return fallback_route(chat, failure_reason(error), duration_ms:, classifier: trace, error:) if error
|
|
323
|
+
|
|
324
|
+
resolve(chat, decision, available, duration_ms:, classifier: trace)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Routes +chat+ to the mode registered as +name+ because the caller
|
|
328
|
+
# chose it; no classifier runs and the conversation is not read.
|
|
329
|
+
# Respects +if:+ and raises UnknownMode when the name is not
|
|
330
|
+
# registered or not available now.
|
|
331
|
+
def force(name, chat:)
|
|
332
|
+
registration = modes.find { |candidate| candidate.name == name.to_s }
|
|
333
|
+
raise UnknownMode.new("Unknown mode #{name}", receiver: self, key: name) unless registration
|
|
334
|
+
|
|
335
|
+
Route.new(mode_class: registration.klass, mode_name: registration.name, chat:, inputs:, decided_by: :caller, reason: "Mode requested by caller")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private
|
|
339
|
+
|
|
340
|
+
def resolve(chat, decision, available, duration_ms:, classifier:)
|
|
341
|
+
common = { duration_ms:, classifier:, decision: }
|
|
342
|
+
registration = available.find { |candidate| candidate.name == decision.mode_name }
|
|
343
|
+
return fallback_route(chat, "Unknown mode #{decision.mode_name.nil? ? "nil" : decision.mode_name}", **common) unless registration
|
|
344
|
+
|
|
345
|
+
threshold = self.class.below_confidence
|
|
346
|
+
if threshold
|
|
347
|
+
return fallback_route(chat, "Confidence not scored", **common) if decision.confidence.nil?
|
|
348
|
+
return fallback_route(chat, "Below confidence threshold", **common) if decision.confidence < threshold
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
Route.new(mode_class: registration.klass, mode_name: registration.name, chat:, inputs:, decided_by: :classifier, reason: decision.reason, **common)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def fallback_route(chat, reason, **attributes)
|
|
355
|
+
registration = self.class.registrations.find { |candidate| candidate.klass == self.class.fallback_class }
|
|
356
|
+
Route.new(mode_class: registration.klass, mode_name: registration.name, chat:, inputs:, decided_by: :fallback, reason: reason, **attributes)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# "Classifier failed: <class>: <first line of the message>", so a
|
|
360
|
+
# logged route says what the provider said (Jev's 400 body names
|
|
361
|
+
# +max_tokens_exceeded+). The message is left out when it is only
|
|
362
|
+
# the class name, Ruby's default.
|
|
363
|
+
def failure_reason(error)
|
|
364
|
+
reason = "Classifier failed: #{error.class}"
|
|
365
|
+
line = error.message.to_s.lines.first.to_s.strip
|
|
366
|
+
return reason if line.empty? || line == error.class.name
|
|
367
|
+
|
|
368
|
+
"#{reason}: #{line[0, REASON_LIMIT]}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def run_classifier(backend, request)
|
|
372
|
+
decision = backend.call(**request)
|
|
373
|
+
validate_decision!(decision)
|
|
374
|
+
[ decision, nil ]
|
|
375
|
+
rescue StandardError => error
|
|
376
|
+
handler = self.class.error_handler
|
|
377
|
+
instance_exec(error, &handler) if handler
|
|
378
|
+
[ nil, error ]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def validate_decision!(decision)
|
|
382
|
+
raise ContractError, "classifier returned #{decision.class}, expected a Decision" unless decision.is_a?(Decision)
|
|
383
|
+
|
|
384
|
+
mode_name = decision.mode_name
|
|
385
|
+
unless mode_name.nil? || mode_name.is_a?(String)
|
|
386
|
+
raise ContractError, "decision mode_name must be a String or nil, got #{mode_name.inspect}"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
reason = decision.reason
|
|
390
|
+
raise ContractError, "decision reason must be a String or nil, got #{reason.inspect}" unless reason.nil? || reason.is_a?(String)
|
|
391
|
+
|
|
392
|
+
validate_confidence!(decision.confidence)
|
|
393
|
+
validate_probabilities!(decision.probabilities)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def validate_confidence!(confidence)
|
|
397
|
+
return if confidence.nil?
|
|
398
|
+
return if confidence.is_a?(Numeric) && confidence.to_f.finite? && confidence.between?(0, 1)
|
|
399
|
+
|
|
400
|
+
raise ContractError, "decision confidence must be nil or a number from 0 to 1, got #{confidence.inspect}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def validate_probabilities!(probabilities)
|
|
404
|
+
return if probabilities.nil?
|
|
405
|
+
return if probabilities.is_a?(Hash) && probabilities.values.all? { |value| value.is_a?(Numeric) && value.to_f.finite? }
|
|
406
|
+
|
|
407
|
+
raise ContractError, "decision probabilities must be nil or a Hash of name => number, got #{probabilities.inspect}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def trace_for(backend)
|
|
411
|
+
backend.respond_to?(:trace) ? backend.trace : { with: "custom", model: nil }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def build_classifier
|
|
415
|
+
spec = self.class.classifier_spec
|
|
416
|
+
case spec[:with]
|
|
417
|
+
when :chat
|
|
418
|
+
Classifiers::Chat.new(model: spec[:model], **spec[:options])
|
|
419
|
+
when :judge
|
|
420
|
+
Classifiers::Judge.new(model: spec[:model], **spec[:options])
|
|
421
|
+
else
|
|
422
|
+
spec[:with]
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def resolved_instructions
|
|
427
|
+
source = self.class.instructions_source
|
|
428
|
+
text = case source
|
|
429
|
+
when Proc then instance_exec(&source)
|
|
430
|
+
when Hash then prompt(source[:prompt], **source[:locals])
|
|
431
|
+
else source
|
|
432
|
+
end
|
|
433
|
+
text = text&.to_s&.strip
|
|
434
|
+
text unless text.nil? || text.empty?
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Renders <tt>app/prompts/<prompt_path>/<name>.txt.erb</tt> with the
|
|
438
|
+
# inputs and +locals+; a Proc local runs on the router instance.
|
|
439
|
+
def prompt(name, **locals)
|
|
440
|
+
evaluated = locals.transform_values { |value| value.is_a?(Proc) ? instance_exec(&value) : value }
|
|
441
|
+
RubyLLM.render_prompt("#{self.class.prompt_path}/#{name}", **inputs, **evaluated)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# The classifier uses the router's instructions, not the chat's
|
|
445
|
+
# system prompt.
|
|
446
|
+
#
|
|
447
|
+
# The messages are read with +each+, not +messages+: a Rails chat
|
|
448
|
+
# record forwards +each+ to its RubyLLM::Chat, whose messages are
|
|
449
|
+
# loaded in one go, while +messages+ is the bare association, under
|
|
450
|
+
# whatever name +acts_as_chat+ gave it.
|
|
451
|
+
def split_conversation(messages)
|
|
452
|
+
raise ArgumentError, "the conversation must respond to each, got #{messages.class}" unless messages.respond_to?(:each)
|
|
453
|
+
|
|
454
|
+
entries = messages.each.map { |entry| normalize_entry(entry) }.reject { |entry| entry[:role] == :system }
|
|
455
|
+
last = entries.last
|
|
456
|
+
raise ArgumentError, "the conversation has no message to route" if last.nil?
|
|
457
|
+
raise ArgumentError, "the latest message must be a user message, got role #{last[:role].inspect}" unless last[:role] == :user
|
|
458
|
+
|
|
459
|
+
history = entries[0...-1].select do |entry|
|
|
460
|
+
[ nil, :user, :assistant ].include?(entry[:role]) && !entry[:content].strip.empty?
|
|
461
|
+
end
|
|
462
|
+
[ last[:content], history ]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# A message within the cap is passed to the classifier as given.
|
|
466
|
+
def truncate_message(message)
|
|
467
|
+
limit = self.class.message_limit
|
|
468
|
+
return message if limit.nil? || message.size <= limit
|
|
469
|
+
|
|
470
|
+
Truncation.head_and_tail(message, limit)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def limit_history(entries)
|
|
474
|
+
limit = self.class.history_limit
|
|
475
|
+
entries = entries.last(limit) if limit
|
|
476
|
+
entries.each { |entry| entry[:content] = Truncation.head(entry[:content], self.class.history_entry_limit) }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def normalize_entry(entry)
|
|
480
|
+
case entry
|
|
481
|
+
when String then { role: nil, content: entry }
|
|
482
|
+
when RubyLLM::Message then { role: entry.role, content: entry.content.to_s }
|
|
483
|
+
when Hash
|
|
484
|
+
role = entry[:role] || entry["role"]
|
|
485
|
+
content = entry[:content] || entry["content"]
|
|
486
|
+
{ role: role&.to_sym, content: content.to_s }
|
|
487
|
+
else
|
|
488
|
+
return normalize_entry(entry.to_llm) if entry.respond_to?(:to_llm)
|
|
489
|
+
|
|
490
|
+
raise ArgumentError, "messages must be Hashes, RubyLLM::Messages, or Strings, got #{entry.class}"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def monotonic_ms
|
|
495
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Modes
|
|
5
|
+
# Cuts text down to a character limit before it reaches a classifier.
|
|
6
|
+
# Every backend has an input limit (Jev rejects a request over roughly
|
|
7
|
+
# 170k characters with a 400), and one oversized entry would otherwise
|
|
8
|
+
# cost the whole turn its routing.
|
|
9
|
+
module Truncation
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The first +limit+ characters of +text+, with a marker for the rest.
|
|
13
|
+
# A history entry is cut this way: its opening says what the turn
|
|
14
|
+
# was about.
|
|
15
|
+
def head(text, limit)
|
|
16
|
+
return text if limit.nil? || text.size <= limit
|
|
17
|
+
|
|
18
|
+
text[0, limit] + marker(text.size - limit)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The first and the last +limit / 2+ characters of +text+, with a
|
|
22
|
+
# marker between them. The routed message is cut this way: the
|
|
23
|
+
# intent of a long paste ("make cards from this article: ..." or
|
|
24
|
+
# "... summarise the above") sits at one end or the other, never in
|
|
25
|
+
# the middle.
|
|
26
|
+
def head_and_tail(text, limit)
|
|
27
|
+
return text if limit.nil? || text.size <= limit
|
|
28
|
+
|
|
29
|
+
head_size = limit / 2
|
|
30
|
+
tail_size = limit - head_size
|
|
31
|
+
text[0, head_size] + marker(text.size - limit) + text[-tail_size, tail_size]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def marker(omitted)
|
|
35
|
+
"\n[... #{omitted} characters omitted ...]\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private_class_method :marker
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "schematist"
|
|
5
|
+
|
|
6
|
+
require_relative "modes/version"
|
|
7
|
+
require_relative "modes/errors"
|
|
8
|
+
require_relative "modes/decision"
|
|
9
|
+
require_relative "modes/route"
|
|
10
|
+
require_relative "modes/registration"
|
|
11
|
+
require_relative "modes/truncation"
|
|
12
|
+
require_relative "modes/classifiers/chat"
|
|
13
|
+
require_relative "modes/classifiers/judge"
|
|
14
|
+
require_relative "modes/router"
|
|
15
|
+
require_relative "modes/mode"
|
|
16
|
+
require_relative "mode_agent"
|
|
17
|
+
|
|
18
|
+
# One chat, one configuration per turn: a classifier picks the mode
|
|
19
|
+
# before each answer, and the decision is a value the app can log.
|
|
20
|
+
module RubyLLM
|
|
21
|
+
module Modes
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/ruby_llm/modes/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "ruby_llm-modes"
|
|
7
|
+
spec.version = RubyLLM::Modes::VERSION
|
|
8
|
+
spec.authors = [ "Andrey Samsonov" ]
|
|
9
|
+
spec.email = [ "me@samsonov.io" ]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Chat modes for RubyLLM: one chat, one configuration per turn, picked by a classifier"
|
|
12
|
+
spec.description = "A mode is the configuration of one turn: a RubyLLM agent with a routing description. Declare the modes, a fallback, and a classifier; before each answer the router returns a Route that says which mode takes the turn, why, and what the classifier actually said."
|
|
13
|
+
spec.homepage = "https://github.com/kryzhovnik/ruby_llm-modes"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
|
|
21
|
+
spec.files = Dir[
|
|
22
|
+
"lib/**/*",
|
|
23
|
+
"assets/**/*.svg",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"LICENSE*",
|
|
27
|
+
"ruby_llm-modes.gemspec"
|
|
28
|
+
]
|
|
29
|
+
spec.require_paths = [ "lib" ]
|
|
30
|
+
|
|
31
|
+
spec.add_dependency "ruby_llm", ">= 2.0.0", "< 3"
|
|
32
|
+
spec.add_dependency "schematist", "~> 1.1"
|
|
33
|
+
end
|