agentic examples

The Job Adapter

The Job Adapter: your Rails app already has a vocabulary for background work - perform_later, retry_on, discard_on - and the fastest way to adopt a new tool is to let it speak that vocabulary. This wraps a plan in an ActiveJob-shaped class: retry_on maps to the framework's retry policy, discard_on maps to the hopeless convention, and your team learns nothing new until they want to.

Testing & Verification Round 13 Chris Oliver exit 0

source on github

bundle exec ruby examples/job_adapter.rb

a real captured run

THE JOB ADAPTER (ActiveJob's vocabulary, Agentic underneath)

  DigestJob(user: rosa)            -> {:status=>:ok}
  DigestJob(user: sam, flaky: 2)   -> {:status=>:ok}
  DigestJob(user: kim, revoked: true) -> {:status=>:discarded, :reason=>"401 key revoked"}

  read the mapping, because it's the whole example: retry_on
  became the orchestrator's retry_policy (attempts: 3 means two
  retries - same accounting as ActiveJob), so sam's double-429
  healed INSIDE the plan without ever bouncing off the queue.
  discard_on became a check on the failure's type PLUS the
  hopeless? convention, so kim's revoked key discards even if
  nobody remembered to list AuthenticationError - the error's own
  testimony backstops the macro. and the adapter is 40 lines
  because both vocabularies were already talking about the same
  three ideas: try again, give up, or ask a human. meet your
  team where they are; the framework doesn't mind the costume.

source

# frozen_string_literal: true

# The Job Adapter: your Rails app already has a vocabulary for
# background work - perform_later, retry_on, discard_on - and the
# fastest way to adopt a new tool is to let it speak that vocabulary.
# This wraps a plan in an ActiveJob-shaped class: retry_on maps to
# the framework's retry policy, discard_on maps to the hopeless
# convention, and your team learns nothing new until they want to.
#
#   bundle exec ruby examples/job_adapter.rb
#
# Runs offline; the queue is an array, the lessons are real.

require class="s">"bundler/setup"
require class="s">"agentic"

Agentic.logger.level = class="y">:fatal

# ActiveJob's essential shape, mapped onto Agentic underneath
class PlanJob
  class << self
    attr_reader class="y">:retried, class="y">:discarded

    def retry_on(*errors, attempts: 3)
      @retried = {errors: errors, attempts: attempts}
    end

    def discard_on(*errors)
      @discarded = errors
    end

    def perform_later(**args)
      QUEUE << [self, args]
    end
  end

  def execute(**args)
    orchestrator = Agentic:class="y">:PlanOrchestrator.new(
      retry_policy: {
        max_retries: self.class.retried[class="y">:attempts] - 1,
        retryable_errors: self.class.retried[class="y">:errors].map(&class="y">:name)
      }
    )
    build_plan(orchestrator, **args)
    result = orchestrator.execute_plan

    return {status: class="y">:ok} if result.successful?

    failure = result.results.values.find { |r| !r.successful? }.failure
    if self.class.discarded.any? { |e| failure.type == e.name } || failure.hopeless?
      {status: class="y">:discarded, reason: failure.message}
    else
      {status: class="y">:failed_will_requeue, reason: failure.message}
    end
  end
end

QUEUE = []

# --- the job your app would actually write ---------------------------------------
class DigestJob < PlanJob
  retry_on Agentic:class="y">:Errors:class="y">:LlmRateLimitError, attempts: 3
  discard_on Agentic:class="y">:Errors:class="y">:LlmAuthenticationError

  def build_plan(orchestrator, user:, flaky: 0, revoked: false)
    attempts = 0
    fetch = Agentic:class="y">:Task.new(description: class="s">"fetch:#{user}", agent_spec: {class="s">"name" => class="s">"f", class="s">"instructions" => class="s">"w"})
    send_task = Agentic:class="y">:Task.new(description: class="s">"send:#{user}", agent_spec: {class="s">"name" => class="s">"s", class="s">"instructions" => class="s">"w"})
    orchestrator.add_task(fetch, agent: ->(_t) {
      raise Agentic:class="y">:Errors:class="y">:LlmAuthenticationError, class="s">"401 key revoked" if revoked

      attempts += 1
      raise Agentic:class="y">:Errors:class="y">:LlmRateLimitError, class="s">"429" if attempts <= flaky

      class="s">"stories for #{user}"
    })
    orchestrator.add_task(send_task, [fetch], agent: ->(t) { class="s">"sent: #{t.previous_output}" })
  end
end

puts class="s">"THE JOB ADAPTER (ActiveJob's vocabulary, Agentic underneath)"
puts
DigestJob.perform_later(user: class="s">"rosa")
DigestJob.perform_later(user: class="s">"sam", flaky: 2)     # succeeds on 3rd try
DigestJob.perform_later(user: class="s">"kim", revoked: true) # hopeless

QUEUE.each do |job_class, args|
  outcome = job_class.new.execute(**args)
  puts format(class="s">"  %-32s -> %s", class="s">"#{job_class}(#{args.map { |k, v| "#{k}: #{v}class="s">" }.join(", class="s">")})", outcome.inspect)
end

puts
puts class="s">"  read the mapping, because it's the whole example: retry_on"
puts class="s">"  became the orchestrator's retry_policy (attempts: 3 means two"
puts class="s">"  retries - same accounting as ActiveJob), so sam's double-429"
puts class="s">"  healed INSIDE the plan without ever bouncing off the queue."
puts class="s">"  discard_on became a check on the failure's type PLUS the"
puts class="s">"  hopeless? convention, so kim's revoked key discards even if"
puts class="s">"  nobody remembered to list AuthenticationError - the error's own"
puts class="s">"  testimony backstops the macro. and the adapter is 40 lines"
puts class="s">"  because both vocabularies were already talking about the same"
puts class="s">"  three ideas: try again, give up, or ask a human. meet your"
puts class="s">"  team where they are; the framework doesn't mind the costume."