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,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
|