The fundamentals · 04

Agent Lifecycle

Construction

Agent.new

Agent.new(session: nil, context: nil)
  • session: — an existing Riffer::Agent::Session. When given, the agent uses it as-is (no system/skills seeding). Typical use case: cross-process resume from persisted history. A provided session is repaired at construction time so the tool_use ↔ tool_result invariant holds before the next inference call: orphaned tool_use exchanges (an assistant tool_call with no matching Tool result) and parentless Tool messages are dropped. Pending tool calls on the resume boundary — the last assistant whose tail is purely Tool results (or none) — are preserved so generate/stream can execute them.
  • context: — a Hash carried for the lifetime of the agent. Used to evaluate Proc-based instructions, model, uses_tools, and skill activation at construction time, and threaded through tool execution and guardrails on every generate/stream call.

When session: is omitted, the agent constructs a fresh session and seeds it with [instruction_message, skills_message].compact eagerly. To swap context, construct a new agent — context is fixed for the lifetime of an agent instance.

Instance Methods

generate

Generates a response synchronously. Returns a Riffer::Agent::Response object.

agent.generate(prompt = nil, files: nil)
  • prompt — when given, a new Riffer::Messages::User is silently appended to the session (no on_message callbacks fire for user inputs) and the inference loop runs.
  • prompt omitted — the loop runs against the current session. Useful when the seeded session’s last turn is already a user message, or when picking up pending tool calls from a prior interrupt.
  • files: — requires prompt. Attached to the new user message.
# New conversation (class method — recommended for simple calls)
response = MyAgent.generate('Hello', context: {user_id: 123})
puts response.content         # Access the response text
puts response.outcome.reason  # How the run ended (:completed, :guardrail_blocked, :interrupted, ...)

# New conversation (instance method — when you need message history or callbacks)
agent = MyAgent.new(context: {user_id: 123})
agent.session.on_message { |msg| log(msg) }
response = agent.generate('Hello')
agent.session.messages  # Access message history

# Multi-turn conversation
agent = MyAgent.new
agent.generate('Hello')
agent.generate('Tell me more')   # continues with full history

# Resume from persisted messages (cross-process)
session = Riffer::Agent::Session.new(messages: persisted_messages)
agent = MyAgent.new(session: session, context: {user_id: 123})
response = agent.generate  # no prompt — session already has the last user message

# With files
response = MyAgent.generate('What is in this image?', files: [
  {data: base64_data, media_type: 'image/jpeg'}
])

stream

Streams a response as an Enumerator. Same prompt/files semantics as generate.

Consuming the enumerator with a block returns the same Riffer::Agent::Response that generate would, so you can stream events to the user and still inspect the final outcome:

response = MyAgent.stream('Tell me a story').each { |event| handle(event) }
response.outcome.reason  # => :completed
response.content
# New conversation (class method — recommended for simple calls)
MyAgent.stream('Tell me a story').each do |event|
  case event
  when Riffer::StreamEvents::TextDelta
    print event.content
  when Riffer::StreamEvents::TextDone
    puts "\n"
  when Riffer::StreamEvents::ToolCallDone
    puts "[Tool: #{event.name}]"
  end
end

# New conversation (instance method — when you need message history or callbacks)
agent = MyAgent.new
agent.session.on_message { |msg| persist_message(msg) }
agent.stream('Tell me a story').each { |event| handle(event) }
agent.session.messages  # Access message history

# Multi-turn conversation
agent = MyAgent.new
agent.stream('Hello').each { |event| handle(event) }
agent.stream('Tell me more').each { |event| handle(event) }

# With files
MyAgent.stream('What is in this image?', files: [{data: base64_data, media_type: 'image/jpeg'}]).each do |event|
  print event.content if event.is_a?(Riffer::StreamEvents::TextDelta)
end

session

Conversation state lives on agent.session — a Riffer::Agent::Session instance that owns the message array, the on_message callback list, and the tool_use ↔ tool_result invariant. The methods below are all on the session, not on the agent itself.

Access the message history after a generate/stream call:

agent = MyAgent.new
agent.generate('Hello')

agent.session.messages.each do |msg|
  puts "#{msg.role}: #{msg.content}"
end

Riffer::Agent::Session includes Enumerable, so find, select, count, reverse_each all work directly on the session:

agent.session.find { |m| m.id == 'a_1' }
agent.session.count { |m| m.is_a?(Riffer::Messages::Assistant) }

on_message

Registers a callback to receive messages as they’re added during generation:

agent.session.on_message do |message|
  case message.role
  when :assistant
    puts "[Assistant] #{message.content}"
  when :tool
    puts "[Tool:#{message.name}] #{message.content}"
  end
end

Multiple callbacks can be registered. Returns self for method chaining:

agent.session
  .on_message { |msg| persist_message(msg) }
  .on_message { |msg| log_message(msg) }
agent.generate('Hello')

Works with both generate and stream. Only emits agent-generated messages (Assistant, Tool), not inputs (System, User).

Interrupting the Agent Loop

Callbacks can interrupt the agent loop. This is useful for human-in-the-loop approval, cost limits, or content filtering.

Use agent.interrupt! (or the lower-level throw :riffer_interrupt) to stop the loop. The response’s outcome.reason will be :interrupted and content will hold the accumulated content up to the point of interruption.

An optional reason can be passed to interrupt!. It is available via outcome.detail on the response (generate) or reason on the Interrupt event (stream):

agent = MyAgent.new
agent.session.on_message do |msg|
  if msg.is_a?(Riffer::Messages::Tool)
    agent.interrupt!("needs human approval")
  end
end

response = agent.generate('Call the tool')
response.outcome.reason  # => :interrupted
response.outcome.detail  # => "needs human approval"
response.content         # => last assistant content before interrupt

Streaming — interrupts emit an Interrupt event:

agent = MyAgent.new
agent.session.on_message { |msg| throw :riffer_interrupt, "budget exceeded" }

agent.stream('Hello').each do |event|
  case event
  when Riffer::StreamEvents::Interrupt
    puts "Loop was interrupted: #{event.reason}"
  end
end

Partial tool execution — tool calls are executed one at a time. When an interrupt fires during tool execution, only the completed tool results remain in the message history. For example, if an assistant message requests two tool calls and the callback interrupts after the first tool result, only that first result will be in the message history.

Resuming an Interrupted Loop

There are two ways to resume after an interrupt, depending on whether the agent is still in memory or you’re restoring from persisted data.

In-memory resume — call generate (or stream) again. With a prompt, the new user message is appended and the loop runs; results for any pending tool calls are placed ahead of it, directly after the assistant message that requested them. Without a prompt, the loop runs against the current session — useful for picking up pending tool calls after the user has approved.

agent = MyAgent.new(context: {user_id: 123})
agent.session.on_message { |msg| throw :riffer_interrupt if needs_approval?(msg) }

response = agent.generate('Do something risky')

if response.outcome.reason == :interrupted
  approve_action(agent.session.messages)
  response = agent.generate('Approved, go ahead')  # executes pending tools, then calls the LLM
  # or: agent.generate                              # resume without a new turn
end

Cross-process resume — when the agent is gone (process restart, async approval, etc.), construct a Riffer::Agent::Session from the persisted messages and pass it to a new agent. The agent uses the session as-is (no system messages added). Pending tool calls on the resume boundary are executed on the next generate/stream.

# During generation, persist each new message via on_message
# Later, in a new process:
session = Riffer::Agent::Session.new(messages: persisted_messages)
agent = MyAgent.new(session: session, context: {user_id: 123})
response = agent.generate  # session already has the last user turn

# Or resume in streaming mode:
agent = MyAgent.new(session: session, context: {user_id: 123})
agent.stream.each do |event|
  # handle stream events
end

Reading System Messages for Persistence

Read the agent’s instruction and skills system messages from agent.instruction_message and agent.skills_message. Both are built once at Agent.new time using the constructor context: and cached — they reflect the agent’s configured instructions and skills DSL output. Useful for database persistence workflows where you need to store and later reconstruct message histories.

Both return Riffer::Messages::System or nil (when unconfigured / empty).

agent = MyAgent.new(context: ctx)
sys = agent.instruction_message     # => Riffer::Messages::System or nil
skills = agent.skills_message       # => Riffer::Messages::System or nil

# Store in DB, then later resume in a new process:
session = Riffer::Agent::Session.new(messages: [sys, skills, user_msg].compact)
MyAgent.new(session: session, context: ctx).generate

interrupt!

Interrupts the agent loop from an on_message callback. Equivalent to throw :riffer_interrupt, reason:

agent.session.on_message do |msg|
  agent.interrupt!(:needs_approval) if requires_approval?(msg)
end

Discarding pending tool calls after an interrupt

An interrupt only stops the loop. Any tool_use the assistant emitted that hasn’t been answered yet stays in history, and the next generate/stream call executes it (see “Resuming an Interrupted Loop” above). This applies to caller-issued interrupt! and the built-in INTERRUPT_MAX_STEPS ceiling alike.

When the interrupt represents a course-change rather than a pause — e.g. a voice barge-in or a cancel where the user has moved on — re-execution is the wrong behavior. Call agent.session.discard_pending_tool_calls to answer every unanswered tool_use with a placeholder Riffer::Messages::Tool carrying error_type: :interrupted, leaving history valid for the next turn. It returns the filled call_ids:

agent.session.on_message do |msg|
  agent.interrupt!(:user_interrupt) if msg.is_a?(Riffer::Messages::Assistant) && barge_in?
end

response = agent.generate("Tell me a story")
agent.session.discard_pending_tool_calls  # => ["call_abc123", ...]
agent.generate("Actually, tell me a joke")

The placeholder content is fixed: "Tool call interrupted before completion." with error_type: :interrupted. Each placeholder is inserted immediately after its parent assistant message. When nothing is pending, the method returns [] and leaves history untouched.

The method works purely on the session’s current messages, so it also applies when a run was cancelled without going through riffer’s interrupt handling (e.g. the surrounding task was stopped with Async::Stop or an exception escaped a callback).

If you need finer control over placeholder content (per-call shape, structured metadata, etc.), use the update mutator below to upgrade a placeholder afterwards.

Mutating history

The session exposes a small set of in-place mutators that enforce the tool_use ↔ tool_result invariant on every operation. Use these to align history with external state (persisted transcript, partial output that wasn’t actually delivered, etc.) without rebuilding the agent.

  • agent.session.update(id:, **attrs) — In-place partial update. Looks up by message id:; builds a replacement of the same type with attrs overlaid on the existing fields. Use this to edit assistant content (update(id:, content:)), restate a system message, etc. When the target is an assistant and the update drops entries from tool_calls, matching Tool children are removed atomically.
  • agent.session.update(tool_call_id:, **attrs) — Same as above but looks up the tool result by tool_call_id:. Preserves name and id. Use this to upgrade a discard_pending_tool_calls placeholder once the real result is available (update(tool_call_id:, content:, error: nil, error_type: nil)).
  • agent.session.remove(id:) — Removes a message; cascades to its Tool children when the target carries tool_calls. Raises if called on a Tool message (use update(tool_call_id:, ...) to rewrite a tool result instead).
  • agent.session.discard_pending_tool_calls — Answers every unanswered tool_use with an :interrupted placeholder result and returns the filled call_ids (see “Discarding pending tool calls after an interrupt” above).

Lookup patterns that pair with the mutators (via Enumerable):

agent.session.find { |m| m.id == id }                                           # message by id
agent.session.reverse_each.find { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == call_id }
agent.session.reverse_each.find { |m| m.is_a?(Riffer::Messages::Assistant) }    # last assistant
agent.session.orphaned_tool_call_ids                                            # Array[String], zero-cost validation

Mutating history while a stream enumerator is being consumed is undefined; mutators are intended for use between turns.

Mutators do not fire on_message — that callback is reserved for messages produced by inference (LLM responses, tool execution results). Placeholders added by discard_pending_tool_calls bypass on_message for the same reason; use its return value to learn which calls were filled.

context

The mutable runtime context. A Hash threaded into every Proc-based DSL setting, guardrail, tool runtime, and skills resolution, and shared with every Riffer::Agent::Run this agent executes. Carries:

  • context[:skills] — the resolved Riffer::Skills::Context when skills are configured.
  • context[:token_usage] — the cumulative Riffer::Providers::TokenUsage, mutated by each Run as the loop progresses. Per-run totals are on response.token_usage.
  • any caller-provided keys passed via Agent.new(context: ...).
agent = MyAgent.new
agent.generate("Hello!")

agent.context[:token_usage]   # cumulative TokenUsage across all calls
agent.context[:skills]        # the Skills::Context, if skills configured

Response Attributes

Riffer::Agent::Response is returned by generate. Start with response.outcome: its reason says how the run ended, and everything else on the response is detail for that reason. response.content and response.structured_output are only meaningful when the reason is :completed; see response.outcome for the full vocabulary.

Attribute Type Description
content String The response text
outcome Outcome How the run ended — reason and optional detail (see below)
structured_output Hash / nil Parsed and validated structured output (see below)
reasoning Array[ReasoningPart] The reasoning parts on the final assistant message (else [])
tripwire Tripwire / nil The guardrail tripwire that blocked the request
modified? Boolean true if a guardrail modified the content
modifications Array List of guardrail modifications applied
messages Array Full message history from the conversation
token_usage TokenUsage / nil Aggregate Riffer::Providers::TokenUsage across this run’s LLM calls (nil when none reported)
steps Integer LLM calls made during this run (0 when a before-guardrail blocks first); not the session’s cumulative count

response.outcome

response.outcome is a Riffer::Agent::Outcome — the single place to read how the run ended. reason is always one of the values below; detail is a String with the specifics when there are any, else nil. outcome.success? is shorthand for reason == :completed.

Reason Source detail
:completed The loop ended normally nil
:guardrail_blocked A guardrail tripwire fired (tripwire is set) The tripwire reason
:max_steps The max_steps limit was reached nil
:interrupted A callback called interrupt! / throw :riffer_interrupt The interrupt reason, or nil
:length, :content_filter, :context_window, :malformed_output, :error, :other The assistant message’s normalized finish_reason (see Messages — Finish Reasons) The provider’s raw finish value (finish_reason_raw), or nil
:invalid_structured_output The final message failed JSON parsing or schema validation The parse or validation error

When several apply, the most causal wins: a guardrail block outranks an interrupt, an interrupt outranks the provider’s finish reason, and the provider’s finish reason outranks a structured output failure. A run that hit :length and therefore produced invalid JSON reports :length.

agent = MyAgent.new
response = agent.generate('Hello')

case response.outcome.reason
when :completed                 then puts response.content
when :guardrail_blocked         then puts "Blocked: #{response.outcome.detail}"
when :interrupted, :max_steps   then response = agent.generate('Continue')
when :invalid_structured_output then warn response.outcome.detail
else warn "Provider stopped early: #{response.outcome.reason}"
end

response.structured_output

When structured output is configured, the LLM response is parsed as JSON and validated against the schema. The validated result is available as response.structured_output:

response = SentimentAgent.generate('Analyze: "I love this!"')
response.content            # => raw JSON string from the LLM
response.structured_output  # => {sentiment: "positive", score: 0.95}

Returns nil when structured output is not configured, or when parsing or validation fails — in which case response.outcome.reason is :invalid_structured_output and response.outcome.detail carries the error.

The assistant message in the message history stores the parsed hash, so you can access structured output directly from persisted messages:

agent = SentimentAgent.new
agent.generate('Analyze: "I love this!"')

msg = agent.session.messages.last
msg.structured_output?    # => true
msg.structured_output     # => {sentiment: "positive", score: 0.95}

See Messages — Structured Output on Messages for details.

Class Methods

find

Find an agent class by identifier (named direct subclasses only — see Agents — Looking Up Agents):

agent_class = Riffer::Agent.find('my_agent')
agent = agent_class.new

all

List all registered agent subclasses (named direct subclasses only):

Riffer::Agent.all.each do |agent_class|
  puts agent_class.identifier
end