RubyLLM::Prompt クラスの抽出で Agent 外からテンプレートが利用可能に
RubyLLM では、プロンプトテンプレートのパス解決と ERB 描画が Agent クラスに閉じていたが、今回の PR で RubyLLM::Prompt クラスへ切り出し、Agent を継承しなくても同一規約でテンプレートを描画できるようになった。
背景
Agent に埋め込まれていたプロンプト描画ロジックは、外部から再利用したいケースに対して壁となっていました。Issue #667 で指摘されたように、app/prompts/ 配下の ERB テンプレートを直接呼び出す手段がなく、利用者は同様のパス解決や ERB 処理を自前で実装する必要がありました。
この制約は、Agent を継承しないシンプルな RubyLLM.chat 呼び出しでもプロンプト管理の一貫性を求めるユーザーにとって不便でした。そのため、テンプレート解決とレンダリングを公開 API として提供する方向で設計が検討されました。
技術的な変更
RubyLLM::Prompt クラスが新規追加され、プロンプトのルート解決、パス構築、ERB 描画を単一責務で担います。クラスは root メソッドで Rails 環境かカレントディレクトリの app/prompts を基準にし、render メソッドでローカル変数を渡してテンプレートを評価します。
# frozen_string_literal: true
require 'erb'
require 'pathname'
module RubyLLM
# Renders ERB prompt templates from the prompts directory.
class Prompt
attr_reader :name, :path
def initialize(name)
@name = name.to_s
@path = self.class.root.join(
@name.end_with?('.txt.erb') ? @name : "#{@name}.txt.erb"
)
end
def render(**locals)
raise PromptNotFoundError, "Prompt file not found: #{@path}" unless File.exist?(@path)
ERB.new(File.read(@path)).result_with_hash(locals)
end
def self.render(name, **locals)
new(name).render(**locals)
end
def self.root
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
Rails.root.join('app/prompts')
else
Pathname.new(Dir.pwd).join('app/prompts')
end
end
end
end
RubyLLM モジュールにも render_prompt ヘルパーが追加され、内部的に Prompt.render へ委譲します。
def render_prompt(name, **locals)
Prompt.render(name, **locals)
end
Agent クラス内の render_prompt 実装は大幅に簡素化され、ファイルパス計算と ERB 評価の部分が RubyLLM::Prompt に委譲されました。削除前後のコードは以下の通りです。
def render_prompt(name, chat:, inputs:, locals:)
path = prompt_path_for(name)
unless File.exist?(path)
raise RubyLLM::PromptNotFoundError,
"Prompt file not found for #{self}: #{path}. Create the file or use inline instructions."
end
resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:)
ERB.new(File.read(path)).result_with_hash(resolved_locals)
end
def render_prompt(name, chat:, inputs:, locals:)
resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:)
RubyLLM.render_prompt("#{prompt_agent_path}/#{name}", **resolved_locals)
end
また、prompt_path_for、prompt_root、prompt_root(Rails/Dir 判定)といったプライベートメソッドは削除され、パス解決は RubyLLM::Prompt.root が一元管理します。
設計判断
内部実装を公開 API として切り出す方針が採用されました。新しい設定キーを増やす代わりに、既存の Prompt 名前空間に機能を集中させることで、後方互換性とコードベースのシンプルさを両立させています。Agent はクラス名ベースのスコーピングとランタイムコンテキスト解決のみを保持し、具体的なテンプレート処理は Prompt に委譲します。
この設計は 単一責務の原則 に沿い、プロンプト管理を独立したコンポーネントとしてテストしやすくしています。テスト (spec/ruby_llm/prompt_spec.rb) でも Prompt.root のモック化や一時ディレクトリでの実ファイル検証が行えるため、将来的な拡張やバグ修正が局所化しやすくなります。
まとめ
今回の変更により、RubyLLM::Prompt.render('friend', name: 'Andrey') のように、Agent を継承しなくても同一のテンプレート規約でプロンプトを利用できるようになりました。機能は Prompt クラス に集約され、Agent からは委譲のみを残すことで拡張性と保守性が向上しています。