agentic examples

The Circuit Breaker

The Circuit Breaker: when an upstream is down, the cheapest request is the one you don't send. The breaker trips after 3 consecutive retryable failures (or ONE non-retryable - no auth error deserves a second strike), fast-fails while open, and probes with a single request before closing again. Every skipped call is money.

Reliability & Recovery Round 9 Mike Perham exit 0

source on github

bundle exec ruby examples/circuit_breaker.rb

a real captured run

CIRCUIT BREAKER (trip after 3 strikes, cooldown 4 ticks)

  act one: 503s from tick 4 to 9
    tick   breaker     what happened
    0      closed      ok
    1      closed      ok
    2      closed      ok
    3      closed      ok
    4      closed      failed (retryable: true)
    5      closed      failed (retryable: true)
    6      open        failed (retryable: true) - breaker TRIPS
    7      open        call SKIPPED - not sent, not billed, not waited on
    8      open        call SKIPPED - not sent, not billed, not waited on
    9      open        call SKIPPED - not sent, not billed, not waited on
    10     closed      probe SUCCEEDED - breaker closes
    11     closed      ok
    12     closed      ok
    13     closed      ok
    14     closed      ok
    15     closed      ok

  act two: key revoked at tick 2
    tick   breaker     what happened
    0      closed      ok
    1      closed      ok
    2      open        failed (retryable: false) - breaker TRIPS
    3      open        call SKIPPED - not sent, not billed, not waited on
    4      open        call SKIPPED - not sent, not billed, not waited on
    5      open        call SKIPPED - not sent, not billed, not waited on
    6      open        failed (retryable: false) - breaker TRIPS
    7      open        call SKIPPED - not sent, not billed, not waited on

  act one's outage lasted 6 ticks but only 3 calls felt it: the
  breaker ate the middle as 3 skips (nothing sent, nothing billed,
  no timeout waited out) and spent one probe discovering the recovery.
  act two never reached three strikes - the 401's journaled verdict
  (retryable: false) tripped the breaker on FIRST contact, and the
  probe kept finding the same wall (2 real calls, 4 skipped).
  strike counts are for errors that might pass; testimony that the
  error can never pass deserves an instant trip.

source

# frozen_string_literal: true

# The Circuit Breaker: when an upstream is down, the cheapest request
# is the one you don't send. The breaker trips after 3 consecutive
# retryable failures (or ONE non-retryable - no auth error deserves a
# second strike), fast-fails while open, and probes with a single
# request before closing again. Every skipped call is money.
#
#   bundle exec ruby examples/circuit_breaker.rb
#
# Runs offline; act one is a 503 outage, act two a revoked key.

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

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

# A breaker is a tiny state machine: closed -> open -> half_open -> closed
class Breaker
  TRIP_AFTER = 3
  COOLDOWN_TICKS = 4

  attr_reader class="y">:state, class="y">:skipped

  def initialize
    @state = class="y">:closed
    @strikes = 0
    @cooldown = 0
    @skipped = 0
  end

  def allow?
    case @state
    when class="y">:closed then true
    when class="y">:open
      @cooldown -= 1
      if @cooldown <= 0
        @state = class="y">:half_open
        true # the probe
      else
        @skipped += 1
        false
      end
    when class="y">:half_open then true
    end
  end

  def record_success
    @state = class="y">:closed
    @strikes = 0
  end

  # The framework's nil convention (TaskFailure#hopeless?): only an
  # EXPLICIT false verdict trips instantly. An error that expressed no
  # opinion gets a strike - suspicion, not a death sentence.
  def record_failure(verdict)
    hopeless = verdict == false
    @strikes += hopeless ? TRIP_AFTER : 1
    return unless @strikes >= TRIP_AFTER || @state == class="y">:half_open

    @state = class="y">:open
    @strikes = 0
    @cooldown = COOLDOWN_TICKS
  end
end

def run_scenario(name, ticks, journal_path, &upstream)
  File.delete(journal_path) if File.exist?(journal_path)
  journal = Agentic:class="y">:ExecutionJournal.new(path: journal_path)
  breaker = Breaker.new

  puts class="s">"  #{name}"
  puts format(class="s">"    %-6s %-11s %s", class="s">"tick", class="s">"breaker", class="s">"what happened")

  ticks.times do |tick|
    unless breaker.allow?
      puts format(class="s">"    %-6d %-11s call SKIPPED - not sent, not billed, not waited on", tick, breaker.state)
      next
    end

    probe = breaker.state == class="y">:half_open
    orchestrator = Agentic:class="y">:PlanOrchestrator.new(
      lifecycle_hooks: journal.lifecycle_hooks,
      retry_policy: {max_retries: 0, retryable_errors: []}
    )
    orchestrator.add_task(Agentic:class="y">:Task.new(
      description: class="s">"call:#{tick}", agent_spec: {class="s">"name" => class="s">"caller", class="s">"instructions" => class="s">"call"}
    ), agent: ->(_t) { upstream.call(tick) })
    result = orchestrator.execute_plan

    if result.successful?
      breaker.record_success
      puts format(class="s">"    %-6d %-11s %s", tick, breaker.state, probe ? class="s">"probe SUCCEEDED - breaker closes" : class="s">"ok")
    else
      # The journal's write-time verdict feeds the breaker - the error
      # already testified whether retrying could ever help
      verdict = Agentic:class="y">:ExecutionJournal.replay(path: journal_path).events
        .reverse.find { |e| e[class="y">:event] == class="s">"task_failed" }[class="y">:retryable]
      breaker.record_failure(verdict)
      puts format(class="s">"    %-6d %-11s failed (retryable: %s)%s", tick, breaker.state, verdict,
        (breaker.state == class="y">:open) ? class="s">" - breaker TRIPS" : class="s">"")
    end
  end
  puts
  [Agentic:class="y">:ExecutionJournal.replay(path: journal_path), breaker]
end

puts class="s">"CIRCUIT BREAKER (trip after #{Breaker:class="y">:TRIP_AFTER} strikes, cooldown #{Breaker:class="y">:COOLDOWN_TICKS} ticks)"
puts

# Act one: a transient outage - three strikes, trip, skip, probe, recover
OUTAGE = (4..9)
state, breaker = run_scenario(class="s">"act one: 503s from tick 4 to 9", 16,
  File.join(Dir.tmpdir, class="s">"agentic_breaker_1.jsonl")) do |tick|
  raise Agentic:class="y">:Errors:class="y">:LlmServerError, class="s">"503 (tick #{tick})" if OUTAGE.cover?(tick)

  class="s">"ok"
end

# Act two: a revoked key - ONE strike, because the error testified
# that no retry can ever help
state2, breaker2 = run_scenario(class="s">"act two: key revoked at tick 2", 8,
  File.join(Dir.tmpdir, class="s">"agentic_breaker_2.jsonl")) do |tick|
  raise Agentic:class="y">:Errors:class="y">:LlmAuthenticationError, class="s">"401 key revoked" if tick >= 2

  class="s">"ok"
end

felt = state.events.count { |e| e[class="y">:event] == class="s">"task_failed" }
puts class="s">"  act one's outage lasted #{OUTAGE.size} ticks but only #{felt} calls felt it: the"
puts class="s">"  breaker ate the middle as #{breaker.skipped} skips (nothing sent, nothing billed,"
puts class="s">"  no timeout waited out) and spent one probe discovering the recovery."
puts class="s">"  act two never reached three strikes - the 401's journaled verdict"
puts class="s">"  (retryable: false) tripped the breaker on FIRST contact, and the"
puts class="s">"  probe kept finding the same wall (#{state2.events.count { |e| e[class="y">:event] == "task_failedclass="s">" }} real calls, #{breaker2.skipped} skipped)."
puts class="s">"  strike counts are for errors that might pass; testimony that the"
puts class="s">"  error can never pass deserves an instant trip."