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::FilePart.from_path("photo.jpg")
msg = Riffer::Messages::User.new("Describe this image", files: [file])
msg.files    # => [#<Riffer::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::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::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::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::FilePart.from_url("https://example.com/doc.pdf")
file.url?        # => true
file.document?   # => true

# From raw base64 data
file = Riffer::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, pass an array of messages:

messages = [
  {role: :user, content: "What's the weather?"},
  {role: :assistant, content: "I'll check that for you."},
  {role: :user, content: "Thanks, I meant in Tokyo specifically."}
]

response = agent.generate(messages)

Messages can be hashes or Riffer::Messages::Base objects:

messages = [
  Riffer::Messages::User.new("Hello"),
  Riffer::Messages::Assistant.new("Hi there!"),
  Riffer::Messages::User.new("How are you?")
]

response = agent.generate(messages)

Accessing Message History

After calling generate or stream, access the full conversation:

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

agent.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?

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:

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.")
]

agent.generate(messages)
# 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 agent’s messages array still contains the original separate messages for logging, evals, and debugging.

Base Class

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

class Riffer::Messages::Base
  attr_reader :content

  def role
    raise NotImplementedError
  end

  def to_h
    {role: role, content: content}
  end
end

Subclasses implement role and optionally extend to_h with additional fields.