The fundamentals · 04
Agent Lifecycle
Construction
Agent.new
Agent.new(session: nil, context: nil)
session:— an existingRiffer::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 thetool_use↔tool_resultinvariant holds before the next inference call: orphanedtool_useexchanges (an assistanttool_callwith no matchingToolresult) and parentlessToolmessages are dropped. Pending tool calls on the resume boundary — the last assistant whose tail is purelyToolresults (or none) — are preserved sogenerate/streamcan execute them.context:— aHashcarried for the lifetime of the agent. Used to evaluate Proc-basedinstructions,model,uses_tools, and skill activation at construction time, and threaded through tool execution and guardrails on everygenerate/streamcall.
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 newRiffer::Messages::Useris silently appended to the session (noon_messagecallbacks fire for user inputs) and the inference loop runs.promptomitted — 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:— requiresprompt. 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 messageid:; builds a replacement of the same type withattrsoverlaid 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 fromtool_calls, matchingToolchildren are removed atomically.agent.session.update(tool_call_id:, **attrs)— Same as above but looks up the tool result bytool_call_id:. Preservesnameandid. Use this to upgrade adiscard_pending_tool_callsplaceholder 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 itsToolchildren when the target carriestool_calls. Raises if called on aToolmessage (useupdate(tool_call_id:, ...)to rewrite a tool result instead).agent.session.discard_pending_tool_calls— Answers every unansweredtool_usewith an:interruptedplaceholder result and returns the filledcall_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 resolvedRiffer::Skills::Contextwhen skills are configured.context[:token_usage]— the cumulativeRiffer::Providers::TokenUsage, mutated by each Run as the loop progresses. Per-run totals are onresponse.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