This file contains essential context for developers (and AI agents) working on the ruby-pi gem. Read this before making changes.
RubyPi is a minimal, composable Ruby gem for building LLM-powered agents. It provides:
- A unified interface across Gemini, Anthropic, and OpenAI
- A tool definition DSL with JSON Schema parameter validation
- A think-act-observe agent loop with streaming and events
- Context management (compaction and transforms)
- An extension hook system for cross-cutting concerns
The design philosophy is anti-framework: small modules, no global state beyond configuration, explicit over magical.
RubyPi.configure Global config (API keys, retries, timeouts)
|
RubyPi::LLM Provider abstraction layer
|-- BaseProvider Abstract base with retry logic
|-- Gemini Google Gemini REST API
|-- Anthropic Anthropic Messages API
|-- OpenAI OpenAI Chat Completions API
|-- Fallback Primary/fallback provider chain
|-- Response Normalized completion result
|-- ToolCall Structured tool invocation
+-- StreamEvent Streaming event (:text_delta, :tool_call_delta, :retry_start, :fallback_start, :done)
RubyPi::Tools Tool definition and execution
|-- Definition Name, description, parameters, callable block
|-- Schema JSON Schema DSL builder
|-- Registry Thread-safe tool store
|-- Executor Parallel/sequential tool dispatch
+-- Result Execution outcome (value or error + timing)
RubyPi::Agent Think-act-observe loop
|-- Core Public API: run/continue, extensions, event subscription
|-- Loop The think-act-observe cycle itself
|-- State Mutable run state (messages, iteration, hooks)
|-- Events EVENTS list + EventEmitter
+-- Result Agent run outcome (output, messages, turns)
RubyPi::Context Conversation context management
|-- Compaction Truncate/summarize to fit token limits
+-- Transform Arbitrary message list transformations
RubyPi::Extensions Hook system for agent events
+-- Base DSL for subscribing to lifecycle events
lib/
ruby_pi.rb # Entry point, requires everything, LLM.model factory
ruby_pi/
version.rb # RubyPi::VERSION (single source of truth; see CHANGELOG.md)
configuration.rb # RubyPi::Configuration (API keys, retries, defaults)
errors.rb # Error hierarchy (ApiError, AuthenticationError, etc.)
llm/
base_provider.rb # Abstract base: retry, backoff, SSE parsing, Faraday
gemini.rb # Google Gemini provider
anthropic.rb # Anthropic Claude provider
openai.rb # OpenAI provider
fallback.rb # Fallback wrapper (primary + backup)
model.rb # Model descriptor (provider + name)
response.rb # Normalized Response object
tool_call.rb # ToolCall value object
stream_event.rb # StreamEvent value object
tools/
definition.rb # Tool Definition + RubyPi::Tool.define
schema.rb # RubyPi::Schema DSL
registry.rb # Thread-safe Registry
executor.rb # Parallel/sequential Executor
result.rb # Execution Result
agent/
core.rb # Agent::Core — public API (run, continue, use, on)
loop.rb # Agent::Loop — the think-act-observe cycle
state.rb # Agent::State — messages, iteration counter, hooks
events.rb # EVENTS constant + EventEmitter
result.rb # Agent::Result (output, messages, turns)
context/
compaction.rb # Context::Compaction
transform.rb # Context::Transform
extensions/
base.rb # Extensions::Base (on_event DSL)
spec/
spec_helper.rb # RSpec + WebMock setup
ruby_pi/
agent/ # Unit tests for core, loop, state, events, result
llm/ # Unit tests for each LLM provider
tools/ # Unit tests for tools, registry, executor
fixes/ # Regression specs pinned to review-round fixes
integration/
agent_integration_spec.rb # End-to-end agent loop tests
- No global mutable state beyond
RubyPi.configuration. Everything else is instance-scoped. - Explicit dependencies. Agent receives model, tools, extensions as constructor args -- no service locator.
- Provider-agnostic responses.
Response,ToolCall, andStreamEventare the same regardless of which LLM you use. - Composable, not coupled. You can use
RubyPi::LLMwithoutAgent, orToolswithoutLLM. - Errors are typed. The error hierarchy (
ApiError,RateLimitError,AuthenticationError,TimeoutError) lets callers rescue precisely. - Thread safety. Registry uses a Mutex; Executor uses
concurrent-rubyfutures. - Test-friendly. All HTTP goes through Faraday, which WebMock intercepts cleanly.
# Install dependencies
bundle install
# Run the full test suite
bundle exec rspec
# Run tests with documentation format
bundle exec rspec --format documentation
# Run only integration tests
bundle exec rspec spec/integration/
# Run a specific test file
bundle exec rspec spec/ruby_pi/llm/gemini_spec.rb
# Run tests matching a pattern
bundle exec rspec -e "streaming"
# Lint (if rubocop is added)
bundle exec rubocop- Create
lib/ruby_pi/llm/my_provider.rb - Subclass
RubyPi::LLM::BaseProvider - Implement three methods:
model_name-- returns the model stringprovider_name-- returns a Symbol (e.g.,:my_provider)perform_complete(messages:, tools:, stream:, &block)-- makes the HTTP call and returns aResponse
- Use
build_connection(base_url:, headers:)for Faraday setup - Use
handle_error_response(response)to raise typed errors. When usingreq.options.on_datafor streaming, the response body is consumed by the callback — accumulate error chunks separately when status >= 400 and pass them viahandle_error_response(response, override_body: error_body)soApiError#response_bodycarries the real server message (see Anthropic, Gemini, and OpenAI providers for the pattern) - Implement streaming via
req.options.on_data = proc { |chunk, _, env| ... }and feed successful chunks throughRubyPi::LLM::SSEParser. Enforce the provider's terminal event before yielding:done; yieldStreamEvent.new(type: :text_delta, data: chunk)andStreamEvent.new(type: :tool_call_delta, data: ...)as complete events arrive - Add the provider to the
casestatement inRubyPi::LLM.model(inlib/ruby_pi.rb) - Add
require_relativefor the new file inlib/ruby_pi.rb - Write specs in
spec/ruby_pi/llm/my_provider_spec.rbfollowing the existing pattern - Add a config attribute (e.g.,
my_provider_api_key) toConfigurationif needed
RubyPi::Context::Transform is a module with factory methods, not a class. Use the built-in
transforms or write a custom lambda:
# Using built-in transforms:
transform = RubyPi::Context::Transform.compose(
RubyPi::Context::Transform.inject_datetime,
RubyPi::Context::Transform.inject_user_preferences { |state| state.user_data[:prefs] }
)
# Or a custom lambda (receives Agent::State, mutates in place):
transform = ->(state) { state.system_prompt += "\nExtra context." }Pass it to Agent.new(transform_context: transform).
The callable receives the Agent::State object and can mutate it (typically appending to system_prompt).
- Subclass
RubyPi::Extensions::Base - Use
on_event :event_name do |data, agent| ... endto subscribe to events (handlers receive the event payload hash and the agent instance) - Available events:
:turn_start,:turn_end,:text_delta,:tool_call_delta,:tool_execution_start,:tool_execution_end,:agent_end,:error,:compaction,:provider_retry,:provider_fallbackNote::before_tool_calland:after_tool_callare constructor hooks (Procs on Agent::Core), not subscribable events. - Register the extension:
agent.use(MyExtension)(pass the CLASS, not an instance)
class MyExtension < RubyPi::Extensions::Base
on_event :agent_end do |data, agent|
# The :agent_end payload is { result:, success: }; the iteration count
# lives on the Result object as `turns`.
puts "Agent finished after #{data[:result].turns} turns"
end
end- Unit tests (
spec/ruby_pi/): One spec file per source file. Mock HTTP responses with WebMock. Test each class in isolation. - Integration tests (
spec/integration/): End-to-end agent loop tests. Stub all HTTP but let the real code path execute through Agent -> LLM -> Tools -> Context. - No live API calls.
WebMock.disable_net_connect!is set globally inspec_helper.rb. - Fast retries in tests.
retry_base_delayis set to0.01andretry_max_delayto0.05in the before-each block. - CI matrix. GitHub Actions runs against Ruby 3.2, 3.3, and 3.4.
- Update
lib/ruby_pi/version.rbwith the new version number - Update
CHANGELOG.mdwith the new version's changes - Commit:
git commit -am "Release vX.Y.Z" - Tag:
git tag vX.Y.Z - Push:
git push origin main --tags - Build and publish:
gem build ruby_pi.gemspec && gem push ruby_pi-X.Y.Z.gem
We follow Semantic Versioning:
- PATCH (0.1.x): Bug fixes, documentation updates
- MINOR (0.x.0): New features, new providers, new extensions (backward compatible)
- MAJOR (x.0.0): Breaking API changes