Skip to content

Latest commit

 

History

History
430 lines (312 loc) · 9.42 KB

File metadata and controls

430 lines (312 loc) · 9.42 KB

RubyLLM Coding Agent Tutorial

A guide for building an AI-powered coding agent using the RubyLLM gem.

Goal

Build an interactive command-line coding agent


Part 1: Basic Shell Script & Configure Ruby Environment

Goal: Create the self-contained executable script with isolated Ruby/Bundler context

Create Project:

mkdir coding_agent

Create Gemfile:

# Gemfile
source "https://rubygems.org"

gem "dotenv" # environment variables
gem "json" # json utilities
gem "pastel" # text coloring
gem "rspec" # testing framework
gem "ruby_llm" # rubyLLM framework

Install gems:

bundle install

Create executable script:

touch run_agent.rb ; chmod +x run_agent.rb

Create run_agent.rb:

#!/usr/bin/env ruby

require 'ruby_llm'
require 'dotenv'
Dotenv.load('.env.local')

welcome_message = ENV.fetch 'WELCOME_MESSAGE'
puts welcome_message

Create .env.local:

WELCOME_MESSAGE=Hello World
OPENAI_API_KEY=YOUR_OPEN_API_KEY
OPENROUTER_KEY=YOUR_OPENROUTER_KEY

Run script: ./run_agent.rb

coding_agent> ./run_agent.rb
Hello World

Part 2: RubyLLM Setup & Authentication

Goal: Configure RubyLLM with API credentials

Add open router API key:

https://openrouter.ai/

Replace OPENROUTER_KEY value with your API key from OpenRouter in .env.local

Alternatively configure OPENAI_API_KEY, ANTHROPIC_API_KEY, or any other supported API key you own:

(See https://rubyllm.com/configuration/#provider-configuration)

Add configure block to run_agent.rb:

RubyLLM.configure do |config|
  config.openrouter_api_key = ENV.fetch 'OPENROUTER_KEY'
  config.openai_api_base = 'https://openrouter.ai/api.v1'
  config.default_model = 'anthropic/claude-sonnet-4.5'
  config.log_file = './log/ruby_llm.log'
  config.log_level = :debug
end

Replace previous welcome message with SimpleAgent inside run_agent.rb:

## Remove ##
welcome_message = ENV.fetch 'WELCOME_MESSAGE'
puts welcome_message

## Add ##
require_relative './simple_agent'
SimpleAgent.new.run

Create simple_agent.rb:

# frozen_string_literal: true

require 'ruby_llm'

class SimpleAgent
  def initialize
    welcome_message = ENV.fetch 'WELCOME_MESSAGE'
    puts welcome_message
  end

  def run
    # Create a chat instance
    chat = RubyLLM.chat

    # Ask a question
    response = chat.ask "What is your purpose?"

    # Output the response
    puts response.content
  end
end

Run the agent: ./run_agent.rb


Part 3: Interactive Conversation Loop

Goal: Allow back and forth conversation with model

Remove chat:

## Remove
  def run
    ...
  end

Add interactive run loop:

  def run
    @chat = RubyLLM.chat
    puts "Chat with agent, type 'exit' to exit:"
    loop do
      puts ">>"
      input = gets.chomp
      break if ['exit', 'quit', 'bye'].include?(input.downcase)

      response = @chat.ask(input)

      puts "AI:\n#{response.content}"
    end
  end

Part 4: Directory Listing Tool

Goal: Allow AI to list directory contents

More info on rubyLLM tools here: https://rubyllm.com/tools/

Create tools directory:

mkdir tools

Create directory_listing_tool.rb inheriting from RubyLLM::Tool:

# frozen_string_literal: true
require "ruby_llm/tool"

class DirectoryListingTool < RubyLLM::Tool
  description "List files and directories in a given path"
  param :path, type: :string, desc: "Directory path to list"


  def execute(path: "")
    working_path = "./" + path
    puts " -- executing -- #{self.class} -- #{working_path}"
    directory_contents = Dir.glob(File.join(working_path, '*'))

    content_data = directory_contents.map do |entry|
      {
        name: File.basename(entry),
        type: File.directory?(entry) ? 'directory' : 'file',
        size: File.size(entry)
      }
    end

    content_data.to_json
  end
end

Register tool with the chat in simple_agent.rb's run method:

  def run
    @chat = RubyLLM.chat
    @chat.with_tool(DirectoryListingTool)

    # other code ....

Run agent and ask a question:

> ./run_agent.rb
Hello World
Chat with agent, type 'exit' to exit:
>>
What files are in this directory?

 -- executing -- DirectoryListingTool -- ./.
AI:
The current directory contains the following files:

1. **Gemfile** (192 bytes) - Ruby dependency configuration file
2. **Gemfile.lock** (1,387 bytes) - Locked versions of Ruby dependencies
3. **directory_listing_tool.rb** (643 bytes) - Ruby script for directory listing functionality
...

Note: The current agent is unable to read the actual content of these files and is just guessing at the purpose of each entry by its filename.


Part 5: File Content Reader

Goal: Allow AI to read file contents

Create read_file_tool.rb:

We can provide longer descriptions than a single line by using <<~EOS strings.

require "ruby_llm/tool"

class ReadFileTool < RubyLLM::Tool
  description <<~EOS
    Read the contents of a given relative file path.
    Use when you want to see what's inside a file.
    Do not use this with directory names.
  EOS
  param :path, type: :string, desc: "Relative path to file in the working directory."

  def execute(path:)
    puts " -- execute -- ReadFile #{path}"
    return { error: "Reading dotfiles is not permitted." } if path.start_with? "."
    return { error: "File not found" } unless File.exist?(path)

    File.read(path).dump
  rescue StandardError => error
    { error: error.message }
  end
end

Register new tool in simple_agent.rb:

Add ReadFileTool after our other tool registration.

    @chat = RubyLLM.chat
    @chat.with_tool(DirectoryListingTool)
    @chat.with_tool(ReadFileTool)

Ask a question:

>./run_agent.rb
Chat with agent, type 'exit' to exit:
>>
What gems does this project use?
 -- executing -- DirectoryListingTool -- ./.
 -- execute -- ReadFile Gemfile
AI:
This project uses the following gems:

1. **dotenv** - For managing environment variables
2. **json** - JSON utilities
...

We can also ask What versions am I using? to see a list of each gem's current version.

--

Part 6: Tracking Token Usage

Goal: Display token usage and estimated costs for each request.

Add new display_cost method to simple_agent.rb

  def display_cost(response)
    puts "---"
    model = RubyLLM.models.find(response.model_id)
    if model.input_price_per_million && model.output_price_per_million
      in_cost  = response.input_tokens  * model.input_price_per_million  / 1_000_000.0
      out_cost = response.output_tokens * model.output_price_per_million / 1_000_000.0
      puts "Estimated cost: $#{format('%.6f', in_cost + out_cost)}"
    end

    # Totals so far in the conversation
    total = @chat.messages.sum { |m| (m.input_tokens || 0) + (m.output_tokens || 0) }
    puts "Conversation total tokens: #{total}"
  end

Add call to display_cost in main loop:

      response = @chat.ask(input)
      puts "AI:\n#{response.content}"
      display_cost(response)

Part 7: File Editing

Goal: Allow AI to modify file contents

Implementation Notes:

edit_file_tool.rb:

require "ruby_llm/tool"

class EditFileTool < RubyLLM::Tool
  description <<~DESCRIPTION
    Make edits to a text file.

    Replace 'old_string' with 'new_string' in the given file.
    'old_string' and 'new_string' MUST be different.

    If the file specified by 'path' doesn't exist, it will be created.

    This tool MUST only update files in the current directory or its subdirectories.
  DESCRIPTION

  param :path, desc: "The path to the file."
  param :old_string, desc: "Text to search for - must match exactly and must only have one match exactly."
  param :new_string, desc: "Text to replace old_str with"

  def execute(path:, old_string:, new_string:)
    puts " -- execute -- EditFile #{path}"
    return { error: "Editing dotfiles is not permitted." } if path.start_with? "."

    # enure the directory exists
    FileUtils.mkdir_p(File.dirname(path))
    # create empty content if this is a new file
    content = File.exist?(path) ? File.read(path) : ""

    File.write(path, content.sub(old_string, new_string))
  rescue => e
    puts "ERROR: " + e.message
    { error: e.message }
  end
end

Ask the AI to create a README

> ./run_agent.rb
Chat with agent, type 'exit' to exit:
>>
create a README.md describing the contents on this project
...

Part 8: Git Commit Tool

Goal: Give AI tool to create git commits

Implementation Notes:

class GitCommit < RubyLLM::Tool
  description "Create a git commit with staged changes"
  param :message, type: :string, description: "Commit message"
  param :files, type: :array, required: false, description: "Files to stage"

  def execute(message:, files: nil)
    return "Not a git repository" unless Dir.exist?('.git')

    # Stage files
    if files
      system("git add #{files.join(' ')}")
    end

    # Create commit
    result = system("git commit -m '#{message}'")
    result ? "Commit created successfully" : "Commit failed"
  end
end

Part 9: Potential Enhancements

  • Add colorized output (using colorize gem)
  • Create configuration file support (~/.coding_agent.yml)
  • Test coverage for all tools
  • Linter tool

Resources