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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ module Classifiers
6
+ # The +:judge+ backend: one RubyLLM.judge call with a single +choice+
7
+ # question over the available modes.
8
+ #
9
+ # The state is the routing input as data (instructions, conversation,
10
+ # latest message); the mode descriptions are the choice options. The
11
+ # answer carries a probability per mode and a +confidence+ reported
12
+ # by the judgment model with it: how concentrated the distribution
13
+ # is on one mode, not a self-report, and a different scale from the
14
+ # chat backend's. It is passed through as is. There is no free text,
15
+ # so +reason+ is always nil.
16
+ #
17
+ # +model+ is passed to RubyLLM.judge as given (nil selects RubyLLM's
18
+ # default judgment model); +provider+ only when given. +judge:+ replaces
19
+ # RubyLLM.judge; it is called with the same arguments and must return
20
+ # a Judgment.
21
+ #
22
+ # RubyLLM.judge is not in every RubyLLM release. Without it the
23
+ # backend cannot run, and the router says so when it is built.
24
+ class Judge
25
+ QUESTION = <<~TEXT.strip
26
+ Which mode should answer the latest user message? Use the conversation
27
+ only to understand what the latest message refers to.
28
+ TEXT
29
+
30
+ attr_reader :model, :provider
31
+
32
+ def self.available?
33
+ RubyLLM.respond_to?(:judge)
34
+ end
35
+
36
+ def initialize(model: nil, provider: nil, judge: nil)
37
+ @model = model
38
+ @provider = provider
39
+ @judge = judge
40
+ end
41
+
42
+ def call(message:, history:, modes:, instructions:, inputs:)
43
+ @resolved_model = nil
44
+ state = self.class.state(message:, history:, instructions:)
45
+ judgment = judge.call(state, questions: self.class.questions(modes), **model_options)
46
+ decision_from(judgment)
47
+ end
48
+
49
+ # What ran, for Route#classifier: the model the provider reported
50
+ # once a judgment came back, else the declared model.
51
+ def trace
52
+ { with: "judge", model: @resolved_model || model }
53
+ end
54
+
55
+ # The judgment state: +instructions+ when present, the normalised
56
+ # +history+ as a conversation, and the latest +message+.
57
+ def self.state(message:, history:, instructions: nil)
58
+ state = {}
59
+ state["instructions"] = instructions unless instructions.nil? || instructions.empty?
60
+ state["conversation"] = history.map { |entry| transcript_entry(entry) } if history.any?
61
+ state["latest_message"] = message.to_s
62
+ state
63
+ end
64
+
65
+ # One choice question whose options are the modes (Registration
66
+ # values), name to description.
67
+ def self.questions(modes)
68
+ { mode: { type: :choice, instructions: QUESTION, options: modes.to_h { |mode| [ mode.name.to_s, mode.description.to_s ] } } }
69
+ end
70
+
71
+ def self.transcript_entry(entry)
72
+ entry[:role] ? { "role" => entry[:role].to_s, "content" => entry[:content].to_s } : entry[:content].to_s
73
+ end
74
+
75
+ private_class_method :transcript_entry
76
+
77
+ private
78
+
79
+ def judge
80
+ return @judge if @judge
81
+ raise DeclarationError, "RubyLLM.judge is not available in ruby_llm #{RubyLLM::VERSION}" unless self.class.available?
82
+
83
+ RubyLLM.method(:judge)
84
+ end
85
+
86
+ def model_options
87
+ options = { model: model }
88
+ options[:provider] = provider if provider
89
+ options
90
+ end
91
+
92
+ def decision_from(judgment)
93
+ answer = judgment[:mode] if judgment.respond_to?(:[])
94
+ unless answer.respond_to?(:choice) && answer.respond_to?(:probabilities) && answer.respond_to?(:confidence)
95
+ raise ContractError, "judge backend returned #{answer.class} for the mode question, expected a choice answer"
96
+ end
97
+
98
+ resolved = judgment.model if judgment.respond_to?(:model)
99
+ @resolved_model = resolved if resolved.is_a?(String)
100
+
101
+ confidence = answer.confidence
102
+ Decision.new(
103
+ mode_name: answer.choice&.to_s,
104
+ confidence: confidence.is_a?(Numeric) ? confidence.to_f : confidence,
105
+ reason: nil,
106
+ probabilities: answer.probabilities&.to_h&.transform_keys(&:to_s)
107
+ )
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ # What the classifier said, untouched.
6
+ #
7
+ # mode_name: String or nil
8
+ # confidence: Float 0..1, or nil for "not scored"
9
+ # reason: String or nil
10
+ # probabilities: { name => Float } or nil
11
+ Decision = Data.define(:mode_name, :confidence, :reason, :probabilities) do
12
+ def initialize(mode_name: nil, confidence: nil, reason: nil, probabilities: nil)
13
+ super
14
+ end
15
+
16
+ # The fields with string keys, for logs and serialisation.
17
+ # "probabilities" is present only when the classifier set them.
18
+ def to_h
19
+ hash = super.transform_keys(&:to_s)
20
+ probabilities ? hash.merge("probabilities" => probabilities.transform_keys(&:to_s)) : hash.except("probabilities")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ # Base class for the gem's own errors.
6
+ class Error < StandardError; end
7
+
8
+ # A router declaration is invalid. Raised by Router.new.
9
+ class DeclarationError < Error; end
10
+
11
+ # Router#force was asked for a name that is not registered or not
12
+ # available for this call.
13
+ class UnknownMode < KeyError; end
14
+
15
+ # A classifier returned something outside the classifier contract: not a
16
+ # Decision, a mode_name or reason that is not a String, a confidence
17
+ # that is NaN or outside 0..1, probabilities that are not a Hash of
18
+ # numbers, or a chat response that is not a JSON object.
19
+ class ContractError < Error; end
20
+ end
21
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ # Extend into an Agent class to make it routable.
6
+ #
7
+ # class TutorAgent < RubyLLM::Agent
8
+ # extend RubyLLM::Modes::Mode
9
+ # description "Explains words and grammar."
10
+ # mode_name "tutor" # optional
11
+ # end
12
+ #
13
+ # Neither value is inherited. A subclass declares its own description,
14
+ # and its name is derived from its own class name unless overridden.
15
+ #
16
+ # Both texts follow Agent's prompt convention. A class that declares no
17
+ # +description+ reads <tt>app/prompts/<agent path>/description.txt.erb</tt>
18
+ # when the file exists, rendered without locals; a class that declares
19
+ # no +instructions+ reads <tt>instructions.txt.erb</tt> next to it, as
20
+ # any Agent does.
21
+ #
22
+ # A mode takes one turn of a chat that already has its own system
23
+ # prompt, so its +instructions+ default to <tt>append: true</tt> (added
24
+ # after the chat's prompt) and <tt>persist: false</tt> (kept out of a
25
+ # Rails record's history), for the template as well as for an explicit
26
+ # declaration. Declare either option to override.
27
+ module Mode
28
+ # Agent's +instructions+ with mode defaults: <tt>append: true</tt> and
29
+ # <tt>persist: false</tt>. Everything else, including the getter form
30
+ # and prompt locals, is Agent's.
31
+ def instructions(text = nil, append: true, persist: false, **options, &block)
32
+ super
33
+ end
34
+
35
+ # Tells the router what the mode does and when to pick it, as a
36
+ # Tool's +description+ tells the model when to call the tool. Sets
37
+ # the text, or returns this class's own one: the declaration, else
38
+ # the +description+ prompt file when it exists. Multi-line text is
39
+ # fine; surrounding whitespace is removed.
40
+ def description(text = nil)
41
+ return @description || description_from_prompt if text.nil?
42
+
43
+ @description = text.to_s.strip
44
+ end
45
+
46
+ # Sets the registration name, or returns it: the override declared on
47
+ # this class, else the name derived from the class name (see
48
+ # Mode.derive_name).
49
+ def mode_name(name = nil)
50
+ return @mode_name || Mode.derive_name(self) if name.nil?
51
+
52
+ @mode_name = name.to_s
53
+ end
54
+
55
+ # Derives a registration name from a class name: the trailing "Agent"
56
+ # removed (a segment that is only "Agent" stays), namespaces kept as
57
+ # path segments, the rest underscored.
58
+ #
59
+ # TutorAgent -> "tutor"
60
+ # Chat::TutorAgent -> "chat/tutor"
61
+ # TutorModeAgent -> "tutor_mode"
62
+ #
63
+ # Returns nil for an anonymous class.
64
+ def self.derive_name(klass)
65
+ return if klass.name.nil?
66
+
67
+ base = klass.name.sub(/(?<=\w)Agent\z/, "")
68
+ RubyLLM::Support::Utils.underscore(base.gsub("::", "/"))
69
+ end
70
+
71
+ private
72
+
73
+ # Agent's conventional +instructions+ template with the mode defaults
74
+ # instead of Agent's. Explicit declarations are returned as they are.
75
+ def instructions_config
76
+ config = super
77
+ return config if instruction_declarations.any?
78
+
79
+ config.map { |declaration| declaration.merge(append: true, persist: false) }
80
+ end
81
+
82
+ def description_from_prompt
83
+ return if name.nil?
84
+
85
+ prompt = RubyLLM::Prompt.new("#{prompt_agent_path}/description")
86
+ return unless File.exist?(prompt.path)
87
+
88
+ prompt.render.strip
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ # One +mode+ declaration of a router, resolved. +klass+ is the agent
6
+ # class, +name+ the registration name, +description+ the routing
7
+ # description, and +condition+ the +if:+ lambda or nil.
8
+ #
9
+ # Router#modes returns the registrations available for a call, and a
10
+ # classifier receives the same objects as +modes:+.
11
+ Registration = Data.define(:klass, :name, :description, :condition) do
12
+ def available_on?(router)
13
+ condition.nil? || !!router.instance_exec(&condition)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Modes
5
+ # What the router decided for one chat, with the classifier's
6
+ # decision attached.
7
+ #
8
+ # mode_class: the agent class
9
+ # mode_name: its registration name
10
+ # decided_by: :caller, :classifier, or :fallback
11
+ # reason: why this mode
12
+ # decision: Decision, or nil when no classifier ran
13
+ # duration_ms: Integer, nil on caller-decided routes
14
+ # classifier: { with: "chat" | "judge" | "custom", model: String | nil },
15
+ # or nil when no backend was called
16
+ # error: the exception a failed classifier raised, or nil
17
+ # chat: the chat the route was decided for
18
+ # inputs: the router's inputs, handed to the mode's agent
19
+ Route = Data.define(:mode_class, :mode_name, :decided_by, :reason, :decision, :duration_ms, :classifier, :error,
20
+ :chat, :inputs) do
21
+ def initialize(mode_class:, mode_name:, decided_by:, reason:, decision: nil, duration_ms: nil, classifier: nil,
22
+ error: nil, chat: nil, inputs: {})
23
+ super
24
+ end
25
+
26
+ # The mode as an agent on the route's chat: Agent.new applies the
27
+ # mode's configuration to the chat and returns the agent wrapping
28
+ # it, so call this once per turn. The router's inputs are the
29
+ # agent's +inputs:+; the agent takes the names it declared and
30
+ # ignores the rest. Extra keywords go to Agent.new as given, except
31
+ # +chat:+: the route was decided for its own chat.
32
+ def mode(**options)
33
+ raise ArgumentError, "the route is bound to its chat; mode takes no chat:" if options.key?(:chat)
34
+
35
+ mode_class.new(chat:, inputs:, **options)
36
+ end
37
+
38
+ # The fields with string keys, for logs. Drops the mode class, the
39
+ # error, the chat, and the inputs; the nested decision and classifier
40
+ # trace get string keys too. Serializes decided_by as a string.
41
+ def to_h
42
+ super.except(:mode_class, :error, :chat, :inputs).transform_keys(&:to_s).tap do |hash|
43
+ hash["decided_by"] = decided_by.to_s
44
+ hash["decision"] = decision&.to_h
45
+ hash["classifier"] = classifier&.transform_keys(&:to_s)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end