Extending and embedding · 18

Hosts

A host is a front end for a Runtime: the terminal, headless mode and the ACP adapter are hosts, and so is anything you build — a Rails controller, a Slack bot, a job. It has two parts:

  • a host object, which the Runtime asks questions and gives notices to;
  • a loop, which builds a Runtime, sends it prompts and renders its events.

A minimal host

require 'riffer/rig'

class ConsoleHost
  include Riffer::Rig::Hosts::NonInteractive

  def capabilities = Set[:notify].freeze
  def notify(message = nil, level: :info) = warn("[#{level}] #{message}")
end

def render(event)
  case event
  when Riffer::StreamEvents::TextDelta then print event.content
  when Riffer::Rig::Events::CommandOutput then puts event.text
  when Riffer::Rig::Events::TurnEnd then puts "\n(#{event.stop_reason})"
  end
end

runtime = Riffer::Rig::Loader.runtime(cwd: Dir.pwd, host: ConsoleHost.new)
trap('INT') { Thread.new { runtime.cancel } }

while (line = $stdin.gets&.chomp)
  next if line.empty?

  if line.start_with?('/')
    name, args = line.delete_prefix('/').split(' ', 2)
    runtime.run_command(name, args.to_s) { |event| render(event) }
  else
    runtime.prompt(line) { |event| render(event) }
  end
end

runtime.close

cancel is called from a new thread because it takes a lock, which Ruby doesn’t allow inside a signal handler. This host can’t ask questions, so a missing model or credential raises a ConfigurationError at startup, and an untrusted project rig.rb is skipped.

The host object

A host object implements every method of the RBS interface Riffer::Rig::Hosts::_Host:

Method Called for
ask(question, options:, secret:) free text, or a choice from options; secret: hides input
confirm(question) yes or no: true, false, or nil when left unanswered
notify(message, level:) a notice for the user (:info, :warning, :error); never shown to the model
progress(label) { } slow work, such as installing a gem; must run the block
capabilities the Set of the methods above the host really supports

Callers check capabilities before calling ask or confirm, so a host that can’t ask is never asked and the caller carries on without an answer. Return nil from confirm when the user walks away rather than answering: a nil trust answer asks again next time, while false is remembered as a no.

Riffer::Rig::Hosts::NonInteractive supplies the declining versions — ask returns nil, confirm returns false, progress runs the block — for a host that leaves :ask and :confirm out of its capabilities. Riffer::Rig::Hosts::Null is the do-nothing host the Runtime uses by default.

runtime.host returns the host wrapped so that each notify also appears on the event stream; runtime.host.host is the object you passed.

The loop

Task Use
build Loader.runtime, turning your flags into Loader::Options
send a message runtime.prompt(text) { |event| … }
run a slash command runtime.run_command(name, args) { |event| … }; an unknown name is reported through notify, so you need no list of your own
stop a turn runtime.cancel, from another thread
save as you go runtime.on_message, or let the Loader’s store do it
follow background jobs on_job_exit, follow_up and friends
end the session runtime.close

Two helpers do what the shipped hosts do:

Helper Gives
Riffer::Rig::Runtime::ErrorMessage.of(error) the message to show for an error a turn raised, with what the provider’s API answered when the error carries it
Riffer::Rig::Command::INVOCATION a Regexp matching a slash-command line, capturing the name and the arguments

A host that offers /exit, /new, /resume or /help implements them itself: they end, replace or describe the Runtime. A host that swaps one Runtime for another closes the old one and subscribes again on the new one.

Use only the public API of the Runtime, the Loader, the host classes and the events; anything else a host needs should become part of that API, so every embedder gets it.

The shipped hosts

Host Capabilities ask confirm notify
terminal all four reads a line, without echo for a secret; numbered options [y/N] a line above the input, red for errors
headless notify, progress declined declined stderr
ACP notify, then ask and confirm once the session has started relayed to the client relayed to the client an agent_message_chunk