Agent Lifecycle
Instance Methods
generate
Generates a response synchronously. Returns a Riffer::Agent::Response object.
The behavior depends on what you pass and the agentโs current state:
| Input | Agent state | Behavior |
|---|---|---|
| String | No prior messages | New conversation. Builds system messages (instructions + skills), adds user message, calls the LLM. |
| String | Has messages from a prior call | Continue conversation. Appends the user message to the existing history and re-enters the LLM loop. Pending tool calls from a prior interrupt are executed first. |
| Array | No prior messages | Restore from persisted data. Uses the array as-is (no system messages added). Pending tool calls are executed. This is for cross-process resume. |
| Array | Has messages from a prior call | Raises Riffer::ArgumentError. Use a string to continue, or a new agent instance to start from a persisted array. |
State reset per call: Each call to generate or stream resets context, tools, tool runtime, model, skills state, and the interrupted flag. This means context: must be passed on every call โ it is not carried over from a previous call. The only state that persists across calls is the message history and cumulative token_usage.
agent.generate('Hello', context: {user_id: 123}) agent.generate('Follow up') # context is nil here โ pass it again if needed agent.generate('More', context: {user_id: 123}) # context is restored
# New conversation (class method โ recommended for simple calls) response = MyAgent.generate('Hello') puts response.content # Access the response text puts response.blocked? # Check if guardrail blocked (always false without guardrails) puts response.interrupted? # Check if a callback interrupted the loop # New conversation (instance method โ when you need message history or callbacks) agent = MyAgent.new agent.on_message { |msg| log(msg) } response = agent.generate('Hello') agent.messages # Access message history # Multi-turn conversation agent = MyAgent.new agent.generate('Hello') agent.generate('Tell me more') # continues with full history # Restore from persisted messages (cross-process resume) agent = MyAgent.new response = agent.generate(persisted_messages, context: {user_id: 123}) # With context response = MyAgent.generate('Look up my orders', context: {user_id: 123}) # With files (string prompt + files shorthand) response = MyAgent.generate('What is in this image?', files: [ {data: base64_data, media_type: 'image/jpeg'} ]) # With files in messages array (per-message) response = MyAgent.generate([ {role: 'user', content: 'Describe this document', files: [ {url: 'https://example.com/report.pdf', media_type: 'application/pdf'} ]} ])
stream
Streams a response as an Enumerator. Follows the same input rules as generate โ a string starts a new conversation or continues an existing one, an array restores from persisted data.
# 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.on_message { |msg| persist_message(msg) } agent.stream('Tell me a story').each { |event| handle(event) } agent.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
messages
Access the message history after a generate/stream call:
agent = MyAgent.new agent.generate('Hello') agent.messages.each do |msg| puts "#{msg.role}: #{msg.content}" end
on_message
Registers a callback to receive messages as theyโre added during generation:
agent.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 .on_message { |msg| persist_message(msg) } .on_message { |msg| log_message(msg) } .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 will have interrupted? set to true and contain the accumulated content up to the point of interruption.
An optional reason can be passed to interrupt!. It is available via interrupt_reason on the response (generate) or reason on the Interrupt event (stream):
agent = MyAgent.new agent.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.interrupted? # => true response.interrupt_reason # => "needs human approval" response.content # => last assistant content before interrupt
Streaming โ interrupts emit an Interrupt event:
agent = MyAgent.new agent.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 string. The agent keeps its message history, so a new string appends a user message and continues the loop. Pending tool calls from the interrupt are automatically executed first.
agent = MyAgent.new agent.on_message { |msg| throw :riffer_interrupt if needs_approval?(msg) } response = agent.generate('Do something risky') if response.interrupted? approve_action(agent.messages) response = agent.generate('Approved, go ahead') # executes pending tools, then calls the LLM end
You can also resume without adding a new user message by passing a continuation like 'Continue' โ the LLM will pick up from the existing context.
Cross-process resume โ when the agent is gone (process restart, async approval, etc.), create a new agent and pass the persisted messages as an array. Array input uses messages as-is (no system messages added) and executes any pending tool calls.
# During generation, persist messages via on_message callback # Later, in a new process: agent = MyAgent.new response = agent.generate(persisted_messages, context: {user_id: 123}) # Or resume in streaming mode: agent = MyAgent.new agent.stream(persisted_messages).each do |event| # handle stream events end
Important: You cannot pass an array to an agent that already has messages. This raises Riffer::ArgumentError because it would silently discard the existing history. Use a string to continue, or create a new agent instance for cross-process resume.
Building System Messages for Persistence
Use generate_instruction_message and generate_skills_message to generate system messages independently. This is useful for database persistence workflows where you need to store and later reconstruct message histories.
Both methods return a Riffer::Messages::System or nil (when unconfigured). They accept an optional context: keyword, just like generate.
agent = MyAgent.new sys = agent.generate_instruction_message(context: ctx) # => Riffer::Messages::System or nil skills = agent.generate_skills_message(context: ctx) # => Riffer::Messages::System or nil # Store in DB, then later resume in a new process: messages = [sys, skills, user_msg].compact MyAgent.new.generate(messages, context: ctx)
interrupt!
Interrupts the agent loop from an on_message callback. Equivalent to throw :riffer_interrupt, reason:
agent.on_message do |msg| agent.interrupt!(:needs_approval) if requires_approval?(msg) end
token_usage
Access cumulative token usage across all LLM calls:
agent = MyAgent.new agent.generate("Hello!") if agent.token_usage puts "Total tokens: #{agent.token_usage.total_tokens}" puts "Input: #{agent.token_usage.input_tokens}" puts "Output: #{agent.token_usage.output_tokens}" end
Returns nil if the provider doesnโt report usage, or a Riffer::TokenUsage object with accumulated totals.
Response Attributes
Riffer::Agent::Response is returned by generate:
| Attribute | Type | Description |
|---|---|---|
content |
String |
The response text |
| structured_output | Hash / nil |
Parsed and validated structured output (see below) |
blocked? |
Boolean |
true if a guardrail tripwire fired |
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 |
interrupted? |
Boolean |
true if the loop was interrupted |
interrupt_reason |
String / Symbol / nil |
The reason passed to throw :riffer_interrupt |
messages |
Array |
Full message history from the conversation |
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 validation fails.
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.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:
agent_class = Riffer::Agent.find('my_agent') agent = agent_class.new
all
List all agent subclasses:
Riffer::Agent.all.each do |agent_class| puts agent_class.identifier end