Project

ruby-pi

0.0
A long-lived project that still receives updates
RubyPi is a minimal, composable AI agent harness for Ruby. Build production-ready LLM agents and AI agents with a unified provider interface across OpenAI, Anthropic Claude, and Google Gemini, plus first-class support for tool calling (function calling), streaming responses, automatic retries, provider fallback, context compaction, and a think-act-observe agent loop. Anti-framework design — small, idiomatic, and explicit. Ideal for building autonomous AI agents, ReAct agents, tool-using LLM agents, and chatbots in Ruby.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.12
~> 3.18

Runtime

 Project Readme

RubyPi

Gem Version CI License: MIT Ruby

A minimal, composable toolkit for building LLM-powered agents in Ruby.

RubyPi is an anti-framework. Instead of imposing a sprawling abstraction layer, it gives you small, focused modules you can compose however you like: swap providers, define tools, run agent loops, manage context -- all without buying into a monolithic architecture. Every piece works on its own, and they snap together when you need them to.


Table of Contents

  • Installation
  • Quick Start
  • API Reference
    • RubyPi::LLM
    • RubyPi::Tools
    • RubyPi::Agent
    • RubyPi::Context
    • RubyPi::Extensions
  • Configuration
  • Contributing
  • License

Installation

Add to your Gemfile:

gem "ruby-pi"

Then run:

bundle install

Or install directly:

gem install ruby-pi

Quick Start

require "ruby_pi"

# 1. Configure
RubyPi.configure do |c|
  c.gemini_api_key = ENV["GEMINI_API_KEY"]
end

# 2. Define a tool
weather = RubyPi::Tool.define(
  name: :get_weather,
  description: "Get the current weather for a city",
  parameters: RubyPi::Schema.object(
    city: RubyPi::Schema.string("City name", required: true)
  )
) { |args| { temp: 72, condition: "sunny", city: args["city"] } }

# 3. Build a registry and an agent
registry = RubyPi::Tools::Registry.new
registry.register(weather)

model = RubyPi::LLM.model(:gemini, "gemini-2.0-flash")
agent = RubyPi::Agent.new(
  system_prompt: "You are a helpful weather assistant.",
  model: model,
  tools: registry
)

# 4. Subscribe to events
agent.on(:text_delta) { |e| print e[:content] }
agent.on(:tool_execution_end) { |e| puts "\n[Tool] #{e[:tool_name]} => #{e[:result].value}" }

# 5. Run
result = agent.run("What's the weather in San Francisco?")
puts "\nDone: #{result.content}"

API Reference

RubyPi::LLM

The LLM module provides a provider-agnostic interface for text generation, streaming, and tool calling across Gemini, Anthropic, and OpenAI.

Model Factory

# Build a provider instance
model = RubyPi::LLM.model(:gemini, "gemini-2.0-flash")
model = RubyPi::LLM.model(:anthropic, "claude-sonnet-4-20250514")
model = RubyPi::LLM.model(:openai, "gpt-4o")

Completions

response = model.complete(
  messages: [{ role: "user", content: "Hello!" }],
  tools: [],       # optional tool definitions
  stream: false    # set true for streaming
)

response.content       # => "Hi there!"
response.tool_calls    # => [] or [ToolCall, ...]
response.tool_calls?   # => false
response.usage         # => { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
response.finish_reason # => "stop"

Streaming

model.complete(messages: messages, stream: true) do |event|
  case event.type
  when :text_delta
    print event.data           # incremental text chunk
  when :tool_call_delta
    handle_fragment(event.data) # partial tool call JSON
  when :fallback_start
    # Only emitted by RubyPi::LLM::Fallback when the primary provider
    # fails mid-stream. Discard any partial output rendered from the
    # primary; the fallback provider is about to stream its full reply.
    # Payload: { failed_provider:, error:, fallback_provider: }
    clear_partial_output
  when :done
    puts "\nStream finished"
  end
end

When using RubyPi::Agent, the loop translates a :fallback_start stream event into an agent-level :provider_fallback event you can subscribe to with agent.on(:provider_fallback) { |e| ... }. The agent also discards any partial text it accumulated from the failed primary so the recorded response reflects only the fallback's output.

Response & ToolCall

Class Attributes
RubyPi::LLM::Response content, tool_calls, usage, finish_reason, tool_calls?
RubyPi::LLM::ToolCall id, name, arguments
RubyPi::LLM::StreamEvent type, data, text_delta?, tool_call_delta?, done?

Fallback

Automatically fail over to a backup provider when the primary is unavailable:

primary  = RubyPi::LLM.model(:gemini, "gemini-2.0-flash")
backup   = RubyPi::LLM.model(:openai, "gpt-4o")
provider = RubyPi::LLM::Fallback.new(primary: primary, fallback: backup)

# Uses Gemini; if it 500s or times out, retries with OpenAI
response = provider.complete(messages: messages)

Authentication errors (401/403) are not retried with the fallback -- they indicate a configuration problem, not a transient failure.


RubyPi::Tools

A lightweight DSL for defining tools (functions) that LLMs can call, plus a registry and executor for dispatching them.

RubyPi::Tool vs RubyPi::Tools: RubyPi::Tool.define(...) is the convenience API for creating tool definitions with a clean syntax. RubyPi::Tools is the internal namespace containing the supporting classes (Definition, Registry, Executor, Result, Schema). Use RubyPi::Tool.define in application code; reference RubyPi::Tools::* when you need direct access to the underlying components.

Defining Tools

tool = RubyPi::Tool.define(
  name: :create_post,
  description: "Create a social media post",
  category: :content,
  parameters: RubyPi::Schema.object(
    content: RubyPi::Schema.string("Post body", required: true),
    tags: RubyPi::Schema.array(
      description: "Tags",
      items: RubyPi::Schema.string("A tag")
    )
  )
) do |args|
  { post_id: SecureRandom.uuid, status: "published" }
end

Schema DSL

Build JSON Schema hashes with a fluent Ruby API:

RubyPi::Schema.string("A label", required: true, enum: ["a", "b"])
RubyPi::Schema.integer("Count", minimum: 0, maximum: 100)
RubyPi::Schema.number("Price", minimum: 0.0)
RubyPi::Schema.boolean("Active")
RubyPi::Schema.array(description: "Items", items: RubyPi::Schema.string)
RubyPi::Schema.object(name: RubyPi::Schema.string("Name", required: true))

Registry

registry = RubyPi::Tools::Registry.new
registry.register(tool)

registry.find(:create_post)          # => Definition or nil
registry.registered?(:create_post)   # => true
registry.names                       # => [:create_post]
registry.size                        # => 1
registry.by_category(:content)       # => [Definition, ...]
registry.subset([:create_post])      # => new Registry with just that tool
registry.all                         # => [Definition, ...]

Executor

Run tool calls in parallel or sequentially with automatic error handling and timeouts:

executor = RubyPi::Tools::Executor.new(registry, mode: :parallel, timeout: 30)

results = executor.execute([
  { name: "create_post", arguments: { content: "Hello" } },
  { name: "get_analytics", arguments: { period: "7d" } }
])

results.each do |r|
  if r.success?
    puts "#{r.name}: #{r.value}"
  else
    puts "#{r.name} failed: #{r.error} (#{r.duration_ms}ms)"
  end
end
Result attribute Description
name Tool name
success? Whether the call succeeded
value Return value (on success)
error Error message (on failure)
duration_ms Execution time in milliseconds

RubyPi::Agent

The Agent implements a think-act-observe loop: send messages to the LLM, execute any tool calls it requests, feed results back, and repeat until the model produces a final text response or hits the iteration limit.

Creating an Agent

agent = RubyPi::Agent.new(
  system_prompt: "You are a helpful assistant.",  # required: system-level instruction
  model: model,                        # required: an LLM provider instance
  tools: registry,                     # optional: a Tools::Registry
  max_iterations: 10,                  # optional: loop safety limit (default: 10)
  compaction: compaction,              # optional: Context::Compaction instance
  transform_context: transform,        # optional: context transform callable
  before_tool_call: ->(tc) { },        # optional: pre-tool-execution hook
  after_tool_call: ->(tc, r) { },      # optional: post-tool-execution hook
  user_data: {}                        # optional: arbitrary data for transforms/extensions
)

# Extensions are registered after construction via `use` (takes a CLASS, not an instance):
agent.use(MetricsExtension)

Running the Agent

# Single run
result = agent.run("What is the weather in Tokyo?")
result.content           # => "The weather in Tokyo is..."
result.messages          # => full conversation history
result.tool_calls_made   # => [{ name: "get_weather", ... }, ...]
result.turns             # => 2

# Continue the conversation
result2 = agent.continue("And in London?")

Event Subscriptions

Subscribe to lifecycle events for logging, monitoring, or custom behavior:

agent.on(:turn_start)          { |e| puts "Turn #{e[:turn]} starting" }
agent.on(:turn_end)            { |e| puts "Turn #{e[:turn]} ended" }
agent.on(:text_delta)          { |e| print e[:content] }
agent.on(:tool_execution_start){ |e| puts "Calling #{e[:tool_name]}" }
agent.on(:tool_execution_end)  { |e| puts "#{e[:tool_name]} => #{e[:result].value}" }
# Note: before_tool_call and after_tool_call are constructor hooks (Procs),
# not subscribable events. Use the constructor kwargs instead:
#   before_tool_call: ->(tc) { puts "About to call #{tc.name}" }
#   after_tool_call: ->(tc, result) { puts "Finished #{tc.name}" }
agent.on(:agent_end)           { |e| puts "Agent finished" }
agent.on(:error)               { |e| warn "Error: #{e[:error].message}" }

RubyPi::Context

Utilities for managing conversation context, especially in long-running or multi-turn agents.

Compaction

Prevent unbounded context growth by compacting older messages:

compaction = RubyPi::Context::Compaction.new(
  summary_model: summary_model,  # required: LLM provider for generating summaries
  max_tokens: 4000,              # optional: trigger compaction above this threshold (default: 8000)
  preserve_last_n: 4             # optional: always keep the last N messages (default: 4)
)

agent = RubyPi::Agent.new(
  system_prompt: "You are helpful.",
  model: model,
  compaction: compaction
)

Transform

Apply arbitrary transformations to the message list before each LLM call:

# Transform is a module with factory methods, not a class.
# Use the built-in transforms or compose your own:
transform = RubyPi::Context::Transform.compose(
  RubyPi::Context::Transform.inject_datetime,
  RubyPi::Context::Transform.inject_user_preferences { |state| state.user_data[:prefs] }
)

# Or write a custom transform as a lambda that receives the Agent::State:
custom_transform = ->(state) do
  state.system_prompt += "\n\nAdditional context here."
end

agent = RubyPi::Agent.new(
  system_prompt: "You are a helpful Ruby assistant.",
  model: model,
  transform_context: transform
)

RubyPi::Extensions

Extensions hook into the agent's event system to add cross-cutting behavior (logging, metrics, guardrails) without modifying the core loop.

Defining an Extension

class MetricsExtension < RubyPi::Extensions::Base
  on_event :turn_start do |event|
    @turn_timer = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  end

  on_event :turn_end do |event|
    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @turn_timer
    puts "Turn #{event[:turn]} took #{elapsed.round(2)}s"
  end

  on_event :agent_end do |event|
    puts "Agent completed in #{event[:result].turns} turns"
  end
end

Registering Extensions

agent = RubyPi::Agent.new(
  system_prompt: "You are helpful.",
  model: model
)

# Register extensions using `use` — pass the CLASS, not an instance:
agent.use(MetricsExtension)
agent.use(AnotherExtension)

Extensions receive the same event payloads as agent.on(...) callbacks. Use them when you want reusable, self-contained behavior modules.


Configuration

All settings are managed through a global configuration block:

RubyPi.configure do |config|
  # API keys
  config.gemini_api_key    = ENV["GEMINI_API_KEY"]
  config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
  config.openai_api_key    = ENV["OPENAI_API_KEY"]

  # Retry behavior
  config.max_retries      = 3      # retries for transient errors
  config.retry_base_delay = 1.0    # base delay (seconds) for exponential backoff
  config.retry_max_delay  = 30.0   # cap on retry delay

  # Timeouts
  config.request_timeout = 120     # HTTP request timeout (seconds)
  config.open_timeout    = 10      # connection open timeout (seconds)

  # Default models (used when you omit the model name)
  config.default_gemini_model    = "gemini-2.0-flash"
  config.default_anthropic_model = "claude-sonnet-4-20250514"
  config.default_openai_model    = "gpt-4o"

  # Logging
  config.logger = Logger.new($stdout)
end

You can also reset configuration to defaults:

RubyPi.reset_configuration!

Contributing

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Write tests for your changes
  4. Ensure all tests pass: bundle exec rspec
  5. Commit your changes (git commit -am 'Add my feature')
  6. Push to the branch (git push origin feature/my-feature)
  7. Open a Pull Request

Please follow the existing code style and include tests for any new functionality.


License

Released under the MIT License.