From 76314ef91398dde7610979b696dcbd5af9a1f873 Mon Sep 17 00:00:00 2001 From: Andrey Samsonov Date: Thu, 12 Mar 2026 09:20:20 +0100 Subject: [PATCH 1/2] Extract RubyLLM::Prompt from Agent's private prompt rendering Moves prompt root resolution, path construction, and ERB rendering into a standalone RubyLLM::Prompt class so prompts can be rendered without subclassing Agent. Closes crmne/ruby_llm#667 Co-Authored-By: Claude Opus 4.6 --- lib/ruby_llm/agent.rb | 22 +------------- lib/ruby_llm/prompt.rb | 33 ++++++++++++++++++++ spec/ruby_llm/prompt_spec.rb | 59 ++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 lib/ruby_llm/prompt.rb create mode 100644 spec/ruby_llm/prompt_spec.rb diff --git a/lib/ruby_llm/agent.rb b/lib/ruby_llm/agent.rb index abb392444..40bef037a 100644 --- a/lib/ruby_llm/agent.rb +++ b/lib/ruby_llm/agent.rb @@ -138,14 +138,8 @@ def sync_instructions!(chat_or_id, **kwargs) end 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) + Prompt.new("#{prompt_agent_path}/#{name}").render(**resolved_locals) end private @@ -304,12 +298,6 @@ def runtime_context(chat:, inputs:) end end - def prompt_path_for(name) - filename = name.to_s - filename += '.txt.erb' unless filename.end_with?('.txt.erb') - prompt_root.join(prompt_agent_path, filename) - end - def prompt_agent_path class_name = name || 'agent' class_name.gsub('::', '/') @@ -319,14 +307,6 @@ def prompt_agent_path .downcase end - def prompt_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 - def resolved_chat_model return @resolved_chat_model if defined?(@resolved_chat_model) diff --git a/lib/ruby_llm/prompt.rb b/lib/ruby_llm/prompt.rb new file mode 100644 index 000000000..2eb65c511 --- /dev/null +++ b/lib/ruby_llm/prompt.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +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 diff --git a/spec/ruby_llm/prompt_spec.rb b/spec/ruby_llm/prompt_spec.rb new file mode 100644 index 000000000..544eb34f3 --- /dev/null +++ b/spec/ruby_llm/prompt_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe RubyLLM::Prompt do + let(:tmpdir) { Dir.mktmpdir } + let(:prompt_dir) { Pathname.new(tmpdir).join('app/prompts') } + + before do + prompt_dir.mkpath + allow(described_class).to receive(:root).and_return(prompt_dir) + end + + after do + FileUtils.rm_rf(tmpdir) + end + + def create_prompt(name, content) + path = prompt_dir.join("#{name}.txt.erb") + path.dirname.mkpath + path.write(content) + end + + describe '.render' do + it 'renders a prompt with locals' do + create_prompt('friend', 'Hello, <%= name %>!') + expect(described_class.render('friend', name: 'Andrey')).to eq('Hello, Andrey!') + end + + it 'renders a nested prompt path' do + create_prompt('work_assistant/instructions', 'You assist <%= user %>.') + expect(described_class.render('work_assistant/instructions', user: 'Bob')).to eq('You assist Bob.') + end + + it 'renders without locals' do + create_prompt('simple', 'Just a static prompt.') + expect(described_class.render('simple')).to eq('Just a static prompt.') + end + + it 'raises PromptNotFoundError for missing prompts' do + expect { described_class.render('nonexistent') }.to raise_error(RubyLLM::PromptNotFoundError) + end + end + + describe '#render' do + it 'renders the prompt with locals' do + create_prompt('greeting', 'Hi <%= name %>, welcome!') + prompt = described_class.new('greeting') + expect(prompt.render(name: 'Andrey')).to eq('Hi Andrey, welcome!') + end + + it 'exposes name and path' do + prompt = described_class.new('greeting') + expect(prompt.name).to eq('greeting') + expect(prompt.path).to eq(prompt_dir.join('greeting.txt.erb')) + end + end +end From 3a99e7f9db4f163b11543f475f537ed6b09be5fe Mon Sep 17 00:00:00 2001 From: Andrey Samsonov Date: Tue, 23 Jun 2026 22:37:54 +0200 Subject: [PATCH 2/2] Expose RubyLLM.render_prompt as the public prompt entrypoint Add a top-level RubyLLM.render_prompt(name, **locals) verb alongside chat/embed/paint/transcribe, keeping RubyLLM::Prompt as internal implementation. Agent.render_prompt now delegates through it. Require erb and pathname in prompt.rb so standalone rendering works when Agent has not been loaded; drop the now-unused erb/pathname requires from agent.rb since rendering moved to Prompt. Co-Authored-By: Claude Opus 4.8 --- lib/ruby_llm.rb | 4 ++++ lib/ruby_llm/agent.rb | 4 +--- lib/ruby_llm/prompt.rb | 3 +++ spec/ruby_llm/prompt_spec.rb | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index 2ff192d65..6791eb74c 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -68,6 +68,10 @@ def transcribe(...) Transcription.transcribe(...) end + def render_prompt(name, **locals) + Prompt.render(name, **locals) + end + def models Models.instance end diff --git a/lib/ruby_llm/agent.rb b/lib/ruby_llm/agent.rb index f9d4a9764..b5ed8049d 100644 --- a/lib/ruby_llm/agent.rb +++ b/lib/ruby_llm/agent.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'erb' require 'forwardable' -require 'pathname' require 'ruby_llm/schema' module RubyLLM @@ -141,7 +139,7 @@ def sync_instructions!(chat_or_id, **kwargs) def render_prompt(name, chat:, inputs:, locals:) resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:) - Prompt.new("#{prompt_agent_path}/#{name}").render(**resolved_locals) + RubyLLM.render_prompt("#{prompt_agent_path}/#{name}", **resolved_locals) end private diff --git a/lib/ruby_llm/prompt.rb b/lib/ruby_llm/prompt.rb index 2eb65c511..7d23e4725 100644 --- a/lib/ruby_llm/prompt.rb +++ b/lib/ruby_llm/prompt.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'erb' +require 'pathname' + module RubyLLM # Renders ERB prompt templates from the prompts directory. class Prompt diff --git a/spec/ruby_llm/prompt_spec.rb b/spec/ruby_llm/prompt_spec.rb index 544eb34f3..9bdefa693 100644 --- a/spec/ruby_llm/prompt_spec.rb +++ b/spec/ruby_llm/prompt_spec.rb @@ -56,4 +56,20 @@ def create_prompt(name, content) expect(prompt.path).to eq(prompt_dir.join('greeting.txt.erb')) end end + + describe 'RubyLLM.render_prompt' do + it 'renders a prompt with locals through the top-level entrypoint' do + create_prompt('friend', 'Hello, <%= name %>!') + expect(RubyLLM.render_prompt('friend', name: 'Andrey')).to eq('Hello, Andrey!') + end + + it 'renders a nested prompt path' do + create_prompt('work_assistant/instructions', 'You assist <%= user %>.') + expect(RubyLLM.render_prompt('work_assistant/instructions', user: 'Bob')).to eq('You assist Bob.') + end + + it 'raises PromptNotFoundError for missing prompts' do + expect { RubyLLM.render_prompt('nonexistent') }.to raise_error(RubyLLM::PromptNotFoundError) + end + end end