ask-agent

Agent runtime for the ask-rb ecosystem. The core agent loop: think → call tools → execute → feed back → repeat.

Ported from RubyLLM::Conductor into the Ask::Agent namespace.

Installation

gem "ask-agent"

Quick Start

require "ask-agent"

session = Ask::Agent::Session.new(
  model: "gpt-4o",
  tools: [Ask::Tools::Shell::Bash, Ask::Tools::Shell::Read]
)

response = session.run("What files are in the current directory?")
puts response

Components

Component File Purpose
Ask::Agent::Session session.rb Full agent loop — message → tool calls → results → follow-up
Ask::Agent::Loop loop.rb Turn management, loop detection, max-turn guard
Ask::Agent::ToolExecutor tool_executor.rb Parallel/sequential tool execution with retry and abort
Ask::Agent::Compactor compactor.rb Context window management with proactive/overflow compaction
Ask::Agent::Hooks hooks.rb Before/after tool lifecycle callbacks
Ask::Agent::Events events.rb Data.define event types for streaming and monitoring
Ask::Agent::Telemetry telemetry.rb File-backed telemetry for error tracking
Ask::Agent::Reflector reflector.rb Assistant response self-evaluation
Ask::Agent::MetaAgent meta_agent.rb LLM-powered self-improvement from telemetry
Ask::Agent::Evaluator evaluator.rb Independent response evaluation with structured rubric — different model, isolated context
Ask::Agent::Configuration configuration.rb Global config: model, turns, concurrency, evaluator

Evaluator

Independent response evaluation with generator/evaluator separation. The evaluator uses a separate model (different from the session's model) and an isolated context to judge the agent's output — preventing the anti-pattern of a model grading its own work.

Quick start

session = Ask::Agent::Session.new(
  model: "gpt-4o",
  evaluator: { model: "claude-sonnet-4", goal: "Write an email validator" }
)
session.run("Write email validation")

Verdicts

Verdict Behavior
:accept Output passes — falls through to reflection
:revise Evaluator provides feedback; session runs another turn with it injected
:block Output is fundamentally wrong — returns blocked message, emits EvaluationBlocked

Configuration

# Set a global default evaluator model
Ask::Agent.configure do |c|
  c.default_evaluator_model = "claude-sonnet-4"
end

# Then use evaluator: true to enable with the default
session = Ask::Agent::Session.new(model: "gpt-4o", evaluator: true)

Custom rubric

evaluator = Ask::Agent::Evaluator.new(
  model: "claude-sonnet-4",
  rubric: [
    Ask::Agent::Evaluator::Dimension.new(
      name: "performance",
      description: "Is the implementation efficient?",
      weight: 2
    )
  ]
)

result = evaluator.evaluate(
  goal: "Write an email validator",
  response: agent_output
)
result.accept?  # => true/false
result.scores   # => { performance: 2 }
result.feedback # => "Add edge case for unicode characters"

Events

The evaluator emits its own events during evaluation:

session.on_event do |event|
  case event
  when Ask::Agent::Events::EvaluationStart
    puts "Evaluating against: #{event.dimensions.join(', ')}"
  when Ask::Agent::Events::EvaluationDelta
    print event.content
  when Ask::Agent::Events::EvaluationEnd
    puts "Decision: #{event.decision}"
    puts "Scores: #{event.scores}"
  when Ask::Agent::Events::EvaluationBlocked
    puts "Blocked: #{event.feedback}"
  end
end

Events

Stream session execution in real-time:

session.on_event do |event|
  case event
  when Ask::Agent::Events::TextDelta
    print event.content
  when Ask::Agent::Events::ToolExecutionStart
    puts "\nRunning #{event.name}..."
  when Ask::Agent::Events::ToolExecutionEnd
    puts "#{event.duration_ms}ms #{event.is_error ? 'error' : 'ok'}"
  end
end

Extensions

Opt-in safety modules:

  • Permissions — Access control for tools. Supports named access modes (:full_access, :read_only, :ask_before_changes) or custom blocked-tool lists.
  • RateLimiter — Prevent runaway tool calls (configurable per-minute and per-turn limits)
  • AuditLog — Immutable, append-only log of every tool call
extensions = [
  Ask::Agent::Extensions::Permissions.new(mode: :read_only),
  Ask::Agent::Extensions::RateLimiter.new(max_calls_per_minute: 30),
  Ask::Agent::Extensions::AuditLog.new(path: "agent.log")
]

session = Ask::Agent::Session.new(
  model: "gpt-4o",
  tools: [...],
  hooks: {
    before_tool: extensions.map(&:method(:before_tool_call)),
    after_tool: extensions.select { |e| e.respond_to?(:after_tool_call) }.map(&:method(:after_tool_call))
  }
)

Middleware

Wrapping LLM provider calls with cross-cutting behavior:

  • RetryOnFailure — Retry on rate limits and server errors with exponential backoff
  • ModelFallback — Switch to a fallback model+provider on transient errors
  • LogCalls — Log every LLM provider call
  • DefaultSettings — Inject default generation parameters
Ask::Agent.configure do |c|
  c.middleware.use :retry_on_failure, max_retries: 3
  c.middleware.use :model_fallback, fallbacks: [
    { model: "claude-sonnet-4",  provider: :anthropic },
    { model: "gemini-2.0-flash", provider: :google }
  ]
  c.middleware.use :log_calls, logger: Rails.logger
  c.middleware.use :default_settings, temperature: 0.7
end

ModelFallback

When the primary LLM is overloaded or down, ModelFallback transparently switches to a backup model+provider. Credentials for each provider are resolved automatically.

Static fallbacks — ordered list tried in sequence:

c.middleware.use :model_fallback, fallbacks: [
  { model: "claude-sonnet-4",  provider: :anthropic },
  { model: "gemini-2.0-flash", provider: :google }
]

Dynamic fallbacks — lambda that receives the error and request:

c.middleware.use :model_fallback, fallbacks: ->(error, request) {
  if request[:messages].sum { |m| m[:content].to_s.length } > 100_000
    [{ model: "claude-sonnet-4", provider: :anthropic }]  # long-context
  else
    [{ model: "gpt-4o-mini", provider: :openai }]           # cheaper
  end
}

Custom eligible errors — by default rate limits, server errors, and service unavailable:

c.middleware.use :model_fallback,
  fallbacks: [{ model: "claude-sonnet-4", provider: :anthropic }],
  eligible_errors: [Ask::RateLimitError, Ask::ServerError]

Configuration

Ask::Agent.configure do |c|
  c.default_model = "claude-sonnet-4"
  c.default_max_turns = 50
  c.compactor_enabled = true
  c.compactor_threshold = 0.8
  c.parallel_tool_execution = true
  c.max_tool_retries = 3
end

Persistence

store = Ask::Agent::Persistence::InMemory.new
session = Ask::Agent::Session.new(model: "gpt-4o", persistence: store)
session.run("Hello")
session.save  # persisted to store

Development

bundle exec rake test

License

MIT