Smith

Workflow-first multi-agent orchestration for Ruby. Smith sits on top of RubyLLM and adds explicit state machines, typed contracts, budgets, guardrails, persistence, tools, and tracing for production agent systems.

[!WARNING] Smith is pre-1.0. Expect contract tightening between minor versions. Pin to an exact version in production.

Installation

# Gemfile
gem "smith-agents", "~> 0.2.0", require: "smith"
bundle install

The Ruby module namespace stays Smith::; only the gem name is namespaced because smith on RubyGems is taken. The require: "smith" in the Gemfile tells bundler to load the actual file name.

Quickstart

require "ruby_llm"
require "smith"

RubyLLM.configure do |config|
  config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
end

class ReplyAgent < Smith::Agent
  register_as :reply_agent
  model "gpt-4.1-nano"

  instructions { "Write a concise, professional reply." }
end

class ReplyContext < Smith::Context
  persist :user_message
  inject_state { |p| "User message: #{p[:user_message]}" }
end

class ReplyWorkflow < Smith::Workflow
  context_manager ReplyContext
  initial_state :idle
  state :done
  state :failed

  transition :reply, from: :idle, to: :done do
    execute :reply_agent
    on_failure :fail
  end
end

result = ReplyWorkflow.new(context: { user_message: "Charged twice." }).run!
result.state    # => :done
result.output   # => assistant reply
result.steps    # => [{ transition: :reply, from: :idle, to: :done, output: ... }]

Core Concepts

Concept Purpose
Smith::Agent A RubyLLM agent plus model, instructions, output schema, tools, budget, and fallback models. Identifies itself to the workflow via register_as :name.
Smith::Workflow A state machine of named transitions. Each transition calls an agent, runs deterministic code, routes, or composes a nested workflow.
Smith::Context Declares which workflow context keys persist across restore, and how those keys become agent-visible input via inject_state.
Smith::Tool A RubyLLM tool plus provider-compatibility metadata and guardrail hooks.
Persistence adapters Host-owned storage. Smith ships Memory, RedisStore, CacheStore, RailsCache, ActiveRecordStore.
Trace adapters Host-owned observability. Smith ships Memory, Logger, OpenTelemetry.

Agents register at class load. In Rails, register workflow-facing agents in a to_prepare hook so autoload doesn't drop them:

# config/initializers/smith_agents.rb
Rails.application.config.to_prepare do
  ReplyAgent
  TriageAgent
end

Patterns

Pattern DSL Use case
Single execute execute :agent One agent call per transition.
Pipeline sequential transitions Multi-step workflow with explicit success/failure routing.
Router route :classifier, routes: {...} Branch on a classifier agent's output.
Parallel fan-out execute :agent, parallel: true Concurrent agent calls under one ledger.
Nested workflow workflow OtherWorkflow Reuse a subflow as one transition.
Evaluator-Optimizer optimize generator:, evaluator:, ... Generate-then-critique refinement loops.
Orchestrator-Worker orchestrate orchestrator:, worker:, ... Dynamic task fan-out with delegation rounds.
Deterministic `compute { step

The full pattern guide with working examples for each lives in docs/PATTERNS.md.

Configuration

require "logger"
require "smith"

Smith.configure do |config|
  config.logger = Logger.new($stdout)
  config.trace_adapter = Smith::Trace::Memory.new
  config.artifact_store = Smith::Artifacts::Memory.new

  # Persistence
  config.persistence_adapter = :rails_cache
  config.persistence_options = { namespace: "smith" }
  config.persistence_ttl = 1.day.to_i
  config.persistence_retry_policy = { attempts: 3, base_delay: 0.1, max_delay: 1.0 }

  # OpenAI /v1/responses routing for gpt-5 + tools + thinking. :auto (default) or :off.
  config.openai_api_mode = :auto

  config.pricing = {
    "gpt-4.1-nano" => { input_cost_per_token: 1.0e-7, output_cost_per_token: 4.0e-7 }
  }
end

All settings are optional for a first run. See docs/CONFIGURATION.md for the full reference.

Persistence and Resume

# Persist after every advance
result = ReplyWorkflow.run_persisted!(
  context: { user_message: "..." },
  adapter: Smith.persistence_adapter
)

# Resume later
result = ReplyWorkflow.run_persisted!(
  key: "ticket:T-1042",
  adapter: Smith.persistence_adapter
)

Built-in adapters (all support TTL where the backend allows; Redis, ActiveRecord, Memory also support optimistic locking via store_versioned):

  • :memory — in-process Hash, intended for tests and test_mode = true
  • :redis — Redis client; uses WATCH/MULTI/EXEC for CAS
  • :rails_cache, :solid_cache — Rails cache backends
  • :cache_store — any object responding to write/read/delete
  • :active_record — keyed ActiveRecord model with lock_version column for CAS

See docs/PERSISTENCE.md for schema versioning, seed-drift validation, and the idempotency_mode :strict step-in-progress contract.

Tools and Guardrails

Smith ships Tools::WebSearch, Tools::UrlFetcher, and Tools::Think. Tools declare provider compatibility via compatible_with; Smith's normalizer routes or drops them per-attempt.

class SearchAgent < Smith::Agent
  register_as :search_agent
  model "claude-opus-4-7"
  tools Smith::Tools::WebSearch, Smith::Tools::UrlFetcher
end

Guardrails run as input/output gates around agent calls. See docs/TOOLS_AND_GUARDRAILS.md.

Budgets and Deadlines

class BudgetedWorkflow < Smith::Workflow
  budget total_tokens: 10_000, total_cost: 0.50, wall_clock_ms: 30_000
end

Budgets reserve serially at each step and reconcile after the agent call. Parallel branches reserve scoped envelopes that release back to the parent ledger. The Workflow::RunResult carries total_tokens, total_cost, and per-call usage_entries.

Doctor

After adding Smith, verify the integration:

# Plain Ruby
smith doctor              # offline checks
smith doctor --live       # live provider call
smith doctor --durability # persistence round-trip
smith install             # scaffold config/smith.rb

# Rails
bin/rails smith:doctor
bin/rails smith:doctor:live
bin/rails smith:doctor:durability
bin/rails generate smith:install

Doctor verifies: Smith loads, RubyLLM loads, minimal workflow boots, configuration is non-empty, serialization round-trips, persistence adapter works, and (with --live) a real provider call succeeds.

Capability-aware request shaping

Smith ships a per-attempt normalizer that translates the request payload to whatever the resolved model's provider family expects:

  • Anthropic Opus 4.7+ adaptive thinking via output_config[:effort]
  • Anthropic 4.0–4.6 budget_tokens
  • OpenAI gpt-5 family reasoning_effort with /v1/responses routing when tools + thinking are combined
  • Gemini 2.5+ budget_tokens

Override the inferred profile per-app via Smith::Models.register(Profile.new(...)). Hosts pin to specific model_ids by registering profiles; Smith never hardcodes model_ids in the library.

Errors and retry

Smith::Errors.retryable?(error)
# AgentError, DeadlineExceeded => true (always)
# DeterministicStepFailure, ToolGuardrailFailed => honors error.retryable
# everything else => false

Smith::Errors.retryable_classes
# => [Smith::AgentError, Smith::DeadlineExceeded]  (for ActiveJob retry_on)

Development

bundle install
bundle exec rspec
bundle exec rubocop

770 examples, MIT licensed. See CHANGELOG.md for the 0.2.0 surface and UPSTREAM_PROPOSAL.md for the vendored Responses adapter retirement path.