agentic examples

The CLI Contract

The CLI Contract: a command-line tool is an API whose clients are shell scripts, cron, CI, and a tired human at 2am - and each of those clients reads a different channel. Data goes to stdout, diagnostics to stderr, the verdict goes in the EXIT CODE, and --format json exists because your most important user is a pipe. This wraps a plan in a CLI that honors all four, then proves it by invoking itself the way scripts would.

Data & Pipelines Round 13 David Bryant Copeland exit 0

source on github

bundle exec ruby examples/cli_contract.rb

a real captured run

THE CLI CONTRACT (four channels, each with one job)

  $ digest   (human at a terminal)
    stdout | story-a
    stdout | story-b
    stdout | story-c
    stderr | digest: running 2 tasks...
    stderr | digest: done (3 stories)
    exit   | 0

  $ digest --format=json --quiet   (pipe to jq)
    stdout | {"stories":["story-a","story-b","story-c"],"count":3}
    exit   | 0

  $ digest --quiet --fail   (cron (quiet until it matters))
    stderr | digest: FAILED at rank: ranker 503
    stderr | digest: hint: transient upstream error - rerun, or check the ranker's status page
    exit   | 1

  $ digest --formt=json   (typo'd flag)
    stderr | error: unknown option --formt=json
    stderr | usage: digest [--format=json|text] [--quiet]
    exit   | 64

  read the invocations like their consumers would: the human got
  progress on stderr and stories on stdout (so `digest > out.txt`
  captures DATA, not chatter). the pipe got pure JSON and silence -
  jq never chokes on a progress message. cron stayed quiet until
  failure, then got a diagnosis AND a hint on stderr with exit 1
  (so || alerting fires). and the typo got exit 64 - EX_USAGE -
  because "you called me wrong" and "the work failed" are
  different facts and scripts deserve to tell them apart. none of
  this is glamorous; all of it is the difference between a CLI
  people script against and one they script AROUND.

source

# frozen_string_literal: true

# The CLI Contract: a command-line tool is an API whose clients are
# shell scripts, cron, CI, and a tired human at 2am - and each of
# those clients reads a different channel. Data goes to stdout,
# diagnostics to stderr, the verdict goes in the EXIT CODE, and
# --format json exists because your most important user is a pipe.
# This wraps a plan in a CLI that honors all four, then proves it
# by invoking itself the way scripts would.
#
#   bundle exec ruby examples/cli_contract.rb
#
# Runs offline; each invocation is captured like a shell would see it.

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

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

# The tool: `digest [--format json|text] [--quiet] [--fail]`
module DigestCLI
  EXIT_OK = 0
  EXIT_PARTIAL = 1
  EXIT_USAGE = 64 # EX_USAGE from sysexits.h - scripts can tell class="s">"it failed" from class="s">"I called it wrong"

  def self.run(argv, stdout:, stderr:)
    options = {format: class="s">"text", quiet: false, fail: false}
    argv.each do |arg|
      case arg
      when class="s">"--format=json" then options[class="y">:format] = class="s">"json"
      when class="s">"--format=text" then options[class="y">:format] = class="s">"text"
      when class="s">"--quiet" then options[class="y">:quiet] = true
      when class="s">"--fail" then options[class="y">:fail] = true # scripted failure, for the demo
      else
        stderr.puts class="s">"error: unknown option #{arg}"
        stderr.puts class="s">"usage: digest [--format=json|text] [--quiet]"
        return EXIT_USAGE
      end
    end

    orchestrator = Agentic:class="y">:PlanOrchestrator.new(retry_policy: {max_retries: 0, retryable_errors: []})
    fetch = Agentic:class="y">:Task.new(description: class="s">"fetch", agent_spec: {class="s">"name" => class="s">"f", class="s">"instructions" => class="s">"w"})
    rank = Agentic:class="y">:Task.new(description: class="s">"rank", agent_spec: {class="s">"name" => class="s">"r", class="s">"instructions" => class="s">"w"})
    orchestrator.add_task(fetch, agent: ->(_t) { %w[story-a story-b story-c] })
    orchestrator.add_task(rank, [fetch], agent: ->(t) {
      raise Agentic:class="y">:Errors:class="y">:LlmServerError, class="s">"ranker 503" if options[class="y">:fail]

      t.previous_output.sort
    })

    stderr.puts class="s">"digest: running 2 tasks..." unless options[class="y">:quiet]
    result = orchestrator.execute_plan

    if result.successful?
      stories = result.task_result(rank.id).output
      if options[class="y">:format] == class="s">"json"
        stdout.puts JSON.generate({stories: stories, count: stories.size})
      else
        stories.each { |s| stdout.puts s }
      end
      stderr.puts class="s">"digest: done (#{stories.size} stories)" unless options[class="y">:quiet]
      EXIT_OK
    else
      failure = result.results.values.find { |r| !r.successful? }.failure
      stderr.puts class="s">"digest: FAILED at rank: #{failure.message}"
      stderr.puts class="s">"digest: hint: transient upstream error - rerun, or check the ranker's status page"
      EXIT_PARTIAL
    end
  end
end

def invoke(argv)
  out = StringIO.new
  err = StringIO.new
  code = DigestCLI.run(argv, stdout: out, stderr: err)
  [code, out.string, err.string]
end

puts class="s">"THE CLI CONTRACT (four channels, each with one job)"
puts
INVOCATIONS = [
  [class="s">"human at a terminal", []],
  [class="s">"pipe to jq", [class="s">"--format=json", class="s">"--quiet"]],
  [class="s">"cron (quiet until it matters)", [class="s">"--quiet", class="s">"--fail"]],
  [class="s">"typo'd flag", [class="s">"--formt=json"]]
].freeze

INVOCATIONS.each do |label, argv|
  code, out, err = invoke(argv)
  puts class="s">"  $ digest #{argv.join(" class="s">")}".rstrip + class="s">"   (#{label})"
  out.lines.each { |l| puts class="s">"    stdout | #{l}" }
  err.lines.each { |l| puts class="s">"    stderr | #{l}" }
  puts class="s">"    exit   | #{code}"
  puts
end

puts class="s">"  read the invocations like their consumers would: the human got"
puts class="s">"  progress on stderr and stories on stdout (so `digest > out.txt`"
puts class="s">"  captures DATA, not chatter). the pipe got pure JSON and silence -"
puts class="s">"  jq never chokes on a progress message. cron stayed quiet until"
puts class="s">"  failure, then got a diagnosis AND a hint on stderr with exit 1"
puts class="s">"  (so || alerting fires). and the typo got exit 64 - EX_USAGE -"
puts class="s">"  because \"you called me wrong\class="s">" and \"the work failed\class="s">" are"
puts class="s">"  different facts and scripts deserve to tell them apart. none of"
puts class="s">"  this is glamorous; all of it is the difference between a CLI"
puts class="s">"  people script against and one they script AROUND."