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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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