Messages

Messages represent the conversation between users and the assistant. Riffer uses strongly-typed message objects to ensure consistency and type safety.

Message Types

System

System messages provide instructions to the LLM:

msg = Riffer::Messages::System.new("You are a helpful assistant.")
msg.role     # => :system
msg.content  # => "You are a helpful assistant."
msg.to_h     # => {role: :system, content: "You are a helpful assistant."}

System messages are typically set via agent instructions and automatically prepended to conversations.

User

User messages represent input from the user:

msg = Riffer::Messages::User.new("Hello, how are you?")
msg.role     # => :user
msg.content  # => "Hello, how are you?"
msg.files    # => []
msg.to_h     # => {role: :user, content: "Hello, how are you?"}

User messages can include file attachments:

file = Riffer::Messages::FilePart.from_path("photo.jpg")
msg = Riffer::Messages::User.new("Describe this image", files: [file])
msg.files    # => [#<Riffer::Messages::FilePart ...>]
msg.to_h     # => {role: :user, content: "Describe this image", files: [{...}]}

Assistant

Assistant messages represent LLM responses, potentially including tool calls and token usage data:

# Text-only response
msg = Riffer::Messages::Assistant.new("I'm doing well, thank you!")
msg.role         # => :assistant
msg.content      # => "I'm doing well, thank you!"
msg.tool_calls   # => []
msg.token_usage  # => nil or Riffer::Providers::TokenUsage

# Response with tool calls
msg = Riffer::Messages::Assistant.new("", tool_calls: [
  {id: "call_123", call_id: "call_123", name: "weather_tool", arguments: '{"city":"Tokyo"}'}
])
msg.tool_calls  # => [{id: "call_123", ...}]
msg.to_h        # => {role: "assistant", content: "", tool_calls: [...]}

# Accessing token usage data (when available from provider)
if msg.token_usage
  puts "Input tokens: #{msg.token_usage.input_tokens}"
  puts "Output tokens: #{msg.token_usage.output_tokens}"
  puts "Total tokens: #{msg.token_usage.total_tokens}"
end

Structured Output on Messages

When an agent has structured_output configured, the final assistant message stores the parsed hash directly. The structured_output? predicate checks for a non-nil value:

msg = Riffer::Messages::Assistant.new('{"sentiment":"positive"}', structured_output: {sentiment: "positive"})
msg.structured_output?    # => true
msg.structured_output     # => {sentiment: "positive"}

# When not provided, structured_output returns nil
msg = Riffer::Messages::Assistant.new('{"sentiment":"positive"}')
msg.structured_output?    # => false
msg.structured_output     # => nil

The to_h representation includes structured_output only when present:

msg = Riffer::Messages::Assistant.new('{"sentiment":"positive"}', structured_output: {sentiment: "positive"})
msg.to_h  # => {role: :assistant, content: '{"sentiment":"positive"}', structured_output: {sentiment: "positive"}}

Tool

Tool messages contain the results of tool executions:

msg = Riffer::Messages::Tool.new(
  "The weather in Tokyo is 22C and sunny.",
  tool_call_id: "call_123",
  name: "weather_tool"
)
msg.role          # => :tool
msg.content       # => "The weather in Tokyo is 22C and sunny."
msg.tool_call_id  # => "call_123"
msg.name          # => "weather_tool"
msg.error?        # => false

# Error result
msg = Riffer::Messages::Tool.new(
  "API rate limit exceeded",
  tool_call_id: "call_123",
  name: "weather_tool",
  error: "API rate limit exceeded",
  error_type: :execution_error
)
msg.error?      # => true
msg.error       # => "API rate limit exceeded"
msg.error_type  # => :execution_error

File Parts

Riffer::Messages::FilePart represents a file attachment (image or document) that can be included with user messages.

Supported Media Types

Images: image/jpeg, image/png, image/gif, image/webp

Documents: application/pdf, text/plain, text/csv, text/html

Creating File Parts

# From a file path (reads eagerly, detects media type from extension)
file = Riffer::Messages::FilePart.from_path("photo.jpg")
file.media_type  # => "image/jpeg"
file.filename    # => "photo.jpg"
file.image?      # => true

# From a URL (stored directly, resolved lazily if provider needs bytes)
file = Riffer::Messages::FilePart.from_url("https://example.com/doc.pdf")
file.url?        # => true
file.document?   # => true

# From raw base64 data
file = Riffer::Messages::FilePart.new(media_type: "image/png", data: base64_string, filename: "chart.png")

Hash Shorthand

When passing files to agents or messages, hashes are automatically converted:

# Path shorthand
{path: "photo.jpg"}

# URL shorthand (media_type auto-detected from extension, or provide explicitly)
{url: "https://example.com/photo.jpg"}
{url: "https://example.com/file", media_type: "application/pdf"}

# Data shorthand
{data: base64_string, media_type: "image/png", filename: "chart.png"}

Predicates

file.image?     # true for image/* media types
file.document?  # true for non-image media types
file.url?       # true when source was a URL

Using Messages with Agents

String Prompts

The simplest way to interact with an agent:

agent = MyAgent.new
response = agent.generate("Hello!")

This creates a User message internally.

Message Arrays

For multi-turn conversations restored from persisted state, construct a Riffer::Agent::Session with the message history and hand it to a new agent:

session = Riffer::Agent::Session.new(messages: [
  Riffer::Messages::User.new("What's the weather?"),
  Riffer::Messages::Assistant.new("I'll check that for you."),
  Riffer::Messages::User.new("Thanks, I meant in Tokyo specifically.")
])

agent = MyAgent.new(session: session)
response = agent.generate   # session already carries the last user turn

Riffer::Agent::Session.new(messages:) accepts Riffer::Messages::Base objects. If your persistence layer hands back hashes, normalize them first via Riffer::Messages::Converter#convert_to_message_object or your own adapter (e.g. jane’s to_riffer).

Accessing Message History

Conversation state lives on agent.session — a Riffer::Agent::Session instance. After calling generate or stream, access the full conversation:

agent = MyAgent.new
agent.generate("Hello!")

agent.session.messages.each do |msg|
  puts "[#{msg.role}] #{msg.content}"
end
# [system] You are a helpful assistant.
# [user] Hello!
# [assistant] Hi there! How can I help you today?

Riffer::Agent::Session includes Enumerable, so find, select, count, reverse_each etc. work directly on the session without going through .messages.

Tool Call Structure

Tool calls in assistant messages have this structure:

{
  id: "item_123",       # Item identifier
  call_id: "call_456",  # Call identifier for response matching
  name: "weather_tool", # Tool name
  arguments: '{"city":"Tokyo"}'  # JSON string of arguments
}

When creating tool result messages, use the id as tool_call_id.

Message Emission

Agents can emit messages as they’re added during generation via the on_message callback. This is useful for persistence or real-time logging. Only agent-generated messages (Assistant, Tool) are emitted—not inputs (System, User).

See Agent Lifecycle - on_message for details.

Consecutive Message Merging

Before messages reach a provider adapter, Riffer merges consecutive messages that share the same role into a single message. This guarantees every provider receives an identical message list, regardless of how it handles consecutive same-role messages internally.

Without this step, the same model can receive different input depending on the provider. Anthropic’s API silently merges consecutive user messages server-side, while Bedrock and Gemini reject them outright. Normalizing at the Riffer level removes that divergence.

Merge rules

Message type Content Auxiliary data Merged?
System Joined with "\n\n" Yes
User Joined with "\n\n" files arrays concatenated Yes
Assistant Joined with "\n\n" tool_calls arrays concatenated Yes
Tool Never (each has a unique tool_call_id)

Example

When a context message is injected before the user’s turn, two consecutive user messages are merged into one:

session = Riffer::Agent::Session.new(messages: [
  Riffer::Messages::System.new("You are a code reviewer."),
  Riffer::Messages::User.new("The repository uses RSpec for testing."),
  Riffer::Messages::User.new("Review this pull request.")
])

MyAgent.new(session: session).generate
# The provider receives two messages:
#   1. System  — "You are a code reviewer."
#   2. User    — "The repository uses RSpec for testing.\n\nReview this pull request."

Merging happens at serialization time only. The session’s messages array still contains the original separate messages for logging, evals, and debugging.

IDs

Every message carries an optional id attribute. By default ids are disabled (message.id returns nil and :id is omitted from to_h). Enable them globally by setting Riffer.config.message_id_strategy:

Riffer.configure { |c| c.message_id_strategy = :uuidv7 }

msg = Riffer::Messages::User.new("Hello")
msg.id     # => "0195a2e1-..." (auto-generated UUIDv7)
msg.to_h   # => {role: :user, content: "Hello", id: "0195a2e1-..."}

Supported strategies: :none (default), :uuid, :uuidv7. See Configuration — Message ID Strategy for the full reference.

Ids pass through to subclass constructors via an id: kwarg and are preserved when set explicitly:

msg = Riffer::Messages::Assistant.new("Done.", id: "reply-42")
msg.id  # => "reply-42"

When seeding an agent with existing conversation history and the strategy is enabled, every seeded message must include an id — Riffer raises Riffer::ArgumentError on missing ids rather than fabricating them.

Base Class

All messages inherit from Riffer::Messages::Base:

class Riffer::Messages::Base
  attr_reader :content, :id

  def initialize(content, id: nil)
    @content = content
    @id = id || generate_id  # uses Riffer.config.message_id_strategy
  end

  def role
    raise NotImplementedError
  end

  def to_h
    hash = {role: role, content: content}
    hash[:id] = id unless id.nil?
    hash
  end
end

Subclasses implement role and optionally extend to_h with additional fields.

Editing history after the fact

The session’s messages array is mutable, but the message value objects themselves are immutable. To edit recorded history — truncate an assistant message, rewrite a tool result, fill an orphan tool_use — use the mutators on agent.session (update, remove). Each one enforces the tool_usetool_result invariant. See Mutating history for the full list.