Class: Riffer::Agent

Inherits:
Object
  • Object
show all
Extended by:
Helpers::ClassNameConverter, Helpers::Validations
Includes:
Messages::Converter
Defined in:
lib/riffer/agent.rb

Overview

Riffer::Agent is the base class for all agents in the Riffer framework.

Provides orchestration for LLM calls, tool use, and message management. Subclass this to create your own agents.

See Riffer::Messages and Riffer::Providers.

class MyAgent < Riffer::Agent
  model 'openai/gpt-4o'
  instructions 'You are a helpful assistant.'
end

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

Defined Under Namespace

Classes: Response

Constant Summary collapse

DEFAULT_MAX_STEPS =

: Integer

16
INTERRUPT_MAX_STEPS =

: Symbol

:max_steps
HEALING_PLACEHOLDER =

Placeholder used to fill orphan tool_use blocks when Riffer.config.experimental_history_healing is enabled and an interrupt fires mid-tool-use.

->(_tool_call) {
  Riffer::Tools::Response.error("Tool call interrupted before completion.", type: :interrupted)
}

Constants included from Helpers::ClassNameConverter

Helpers::ClassNameConverter::DEFAULT_SEPARATOR

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Helpers::ClassNameConverter

class_name_to_path

Methods included from Helpers::Validations

validate_is_string!

Methods included from Messages::Converter

#convert_to_file_part, #convert_to_message_object

Constructor Details

#initializeAgent

Initializes a new agent.

Raises Riffer::ArgumentError if the configured model string is invalid (must be “provider/model” format).

– : () -> void



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/riffer/agent.rb', line 337

def initialize
  @messages = []
  @message_callbacks = []
  @token_usage = nil
  @interrupted = false
  @model_config = self.class.model
  @instructions_config = self.class.instructions

  if @model_config.is_a?(Proc)
    @provider_name = nil
    @model_name = nil
  else
    parse_model_string!(@model_config)
  end
end

Instance Attribute Details

#messagesObject (readonly)

The message history for the agent.



325
326
327
# File 'lib/riffer/agent.rb', line 325

def messages
  @messages
end

#token_usageObject (readonly)

Cumulative token usage across all LLM calls.



328
329
330
# File 'lib/riffer/agent.rb', line 328

def token_usage
  @token_usage
end

Class Method Details

.allObject

Returns all agent subclasses.

– : () -> Array



261
262
263
# File 'lib/riffer/agent.rb', line 261

def self.all
  subclasses #: Array[singleton(Riffer::Agent)]
end

.find(identifier) ⇒ Object

Finds an agent class by identifier.

– : (String) -> singleton(Riffer::Agent)?



253
254
255
# File 'lib/riffer/agent.rb', line 253

def self.find(identifier)
  all.find { |agent_class| agent_class.identifier == identifier.to_s }
end

.generateObject

Generates a response using a new agent instance.

See #generate for parameters and return value.

– : (*untyped, **untyped) -> Riffer::Agent::Response



271
272
273
# File 'lib/riffer/agent.rb', line 271

def self.generate(...)
  new.generate(...)
end

.guardrail(phase, with:, **options) ⇒ Object

Registers a guardrail for input, output, or both phases.

phase

:before, :after, or :around.

with

the guardrail class (must be subclass of Riffer::Guardrail).

options

additional options passed to the guardrail.

Raises Riffer::ArgumentError if phase is invalid or guardrail is not a Guardrail class. – : (Symbol, with: singleton(Riffer::Guardrail), **untyped) -> void



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/riffer/agent.rb', line 294

def self.guardrail(phase, with:, **options)
  valid_phases = [*Riffer::Guardrails::PHASES, :around]
  raise Riffer::ArgumentError, "Invalid guardrail phase: #{phase}" unless valid_phases.include?(phase)
  raise Riffer::ArgumentError, "Guardrail must be a Riffer::Guardrail subclass" unless with.is_a?(Class) && with <= Riffer::Guardrail

  @guardrails ||= {before: [], after: []}
  config = {class: with, options: options}

  case phase
  when :before
    @guardrails[:before] << config
  when :after
    @guardrails[:after] << config
  when :around
    @guardrails[:before] << config
    @guardrails[:after] << config
  end
end

.guardrails_for(phase) ⇒ Object

Returns the registered guardrail configs for a given phase.

phase

:before or :after.

– : (Symbol) -> Array[Hash[Symbol, untyped]]



319
320
321
322
# File 'lib/riffer/agent.rb', line 319

def self.guardrails_for(phase)
  @guardrails ||= {before: [], after: []}
  @guardrails[phase] || []
end

.identifier(value = nil) ⇒ Object

Gets or sets the agent identifier.

– : (?String?) -> String



40
41
42
43
# File 'lib/riffer/agent.rb', line 40

def self.identifier(value = nil)
  return @identifier || class_name_to_path(name) if value.nil?
  @identifier = value.to_s
end

.instructions(instructions_or_proc = nil) ⇒ Object

Gets or sets the agent instructions.

Accepts a static string or a Proc for dynamic instructions. When a Proc is given, it is called at generate time and receives the context hash (which may be nil).

instructions "You are a helpful assistant."

instructions -> (context) {
  "You are assisting #{context[:name]}"
}

– : (?(String | Proc)?) -> (String | Proc)?



74
75
76
77
78
79
80
81
82
83
# File 'lib/riffer/agent.rb', line 74

def self.instructions(instructions_or_proc = nil)
  return @instructions if instructions_or_proc.nil?

  if instructions_or_proc.is_a?(Proc)
    @instructions = instructions_or_proc
  else
    validate_is_string!(instructions_or_proc, "instructions")
    @instructions = instructions_or_proc
  end
end

.max_steps(value = nil) ⇒ Object

Gets or sets the maximum number of LLM call steps in the tool-use loop.

Defaults to DEFAULT_MAX_STEPS (16). Set to Float::INFINITY for unlimited steps.

– : (?Numeric?) -> Numeric



128
129
130
131
# File 'lib/riffer/agent.rb', line 128

def self.max_steps(value = nil)
  return @max_steps || DEFAULT_MAX_STEPS if value.nil?
  @max_steps = value
end

.mcp_configsObject

Returns the accumulated use_mcp configurations for this agent class.

: () -> Array[Hash[Symbol, untyped]]



206
207
208
# File 'lib/riffer/agent.rb', line 206

def self.mcp_configs
  @mcp_configs || []
end

.model(model_string_or_proc = nil) ⇒ Object

Gets or sets the model string (e.g., “openai/gpt-4o”) or Proc.

– : (?(String | Proc)?) -> (String | Proc)?



49
50
51
52
53
54
55
56
57
58
# File 'lib/riffer/agent.rb', line 49

def self.model(model_string_or_proc = nil)
  return @model if model_string_or_proc.nil?

  if model_string_or_proc.is_a?(Proc)
    @model = model_string_or_proc
  else
    validate_is_string!(model_string_or_proc, "model")
    @model = model_string_or_proc
  end
end

.model_options(options = nil) ⇒ Object

Gets or sets model options passed to generate_text/stream_text.

– : (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]



98
99
100
101
# File 'lib/riffer/agent.rb', line 98

def self.model_options(options = nil)
  return @model_options || {} if options.nil?
  @model_options = options
end

.provider_options(options = nil) ⇒ Object

Gets or sets provider options passed to the provider client.

– : (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]



89
90
91
92
# File 'lib/riffer/agent.rb', line 89

def self.provider_options(options = nil)
  return @provider_options || {} if options.nil?
  @provider_options = options
end

.resolved_tool_classes(context: nil) ⇒ Object

Returns the tool classes the LLM should see for this agent.

Class-level companion to the instance #resolved_tools. Resolves the Proc form of uses_tools and appends the skill activation tool when a skills block is configured. Does not read the skills backend —the LLM-facing tool schema reflects class-level intent, not the runtime state of any backend.

When uses_tools is a Proc, context is forwarded to it.

The activation tool class is resolved from the agent’s skills do; activate_tool ...; end override when set, otherwise from Riffer.config.skills.default_activate_tool.

Each returned tool class is validated via validate_as_tool!, so callers serializing this list to a provider can rely on every entry having the metadata required for tool use (name + description).

Raises Riffer::ArgumentError on tool name conflicts with the skill activation tool, or when a tool class fails validate_as_tool!.

– : (?context: Hash[Symbol, untyped]?) -> Array



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/riffer/agent.rb', line 165

def self.resolved_tool_classes(context: nil)
  base = resolve_uses_tools_config(context)

  tools = if skills
    skill_activate_tool_class = skills.activate_tool || Riffer.config.skills.default_activate_tool
    if base.any? { |t| t.name == skill_activate_tool_class.name }
      raise Riffer::ArgumentError, "Tool name conflict with skill tools: #{skill_activate_tool_class.name}"
    end
    base + [skill_activate_tool_class]
  else
    base
  end

  tools.each(&:validate_as_tool!)
  tools
end

.skills(&block) ⇒ Object

Configures skills for this agent via a block DSL.

Returns the current Riffer::Skills::Config when called without a block.

skills do
  backend Riffer::Skills::FilesystemBackend.new(".skills")
  adapter Riffer::Skills::XmlAdapter
  activate ["code-review"]
end

– : () ?{ () -> void } -> Riffer::Skills::Config?



241
242
243
244
245
246
247
# File 'lib/riffer/agent.rb', line 241

def self.skills(&block)
  if block
    @skills_config = Riffer::Skills::Config.new
    @skills_config.instance_eval(&block)
  end
  @skills_config
end

.streamObject

Streams a response using a new agent instance.

See #stream for parameters and return value.

– : (*untyped, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]



281
282
283
# File 'lib/riffer/agent.rb', line 281

def self.stream(...)
  new.stream(...)
end

.structured_output(params = nil, &block) ⇒ Object

Gets or sets the structured output schema for this agent.

Accepts a Riffer::Params instance or a block evaluated against a new Params.

– : (?Riffer::Params?) ?{ () -> void } -> Riffer::Params?



109
110
111
112
113
114
115
116
117
118
119
# File 'lib/riffer/agent.rb', line 109

def self.structured_output(params = nil, &block)
  if block
    @structured_output = Riffer::Params.new
    @structured_output.instance_eval(&block)
  elsif params.nil?
    @structured_output
  else
    raise Riffer::ArgumentError, "structured_output must be a Riffer::Params" unless params.is_a?(Riffer::Params)
    @structured_output = params
  end
end

.tool_runtime(value = nil) ⇒ Object

Gets or sets the tool runtime for this agent.

Accepts a Riffer::ToolRuntime subclass, a Riffer::ToolRuntime instance, or a Proc.

Inherited by subclasses. When unset, walks the ancestor chain and falls back to the global Riffer.config.tool_runtime.

– : (?(singleton(Riffer::ToolRuntime) | Riffer::ToolRuntime | Proc)?) -> (singleton(Riffer::ToolRuntime) | Riffer::ToolRuntime | Proc)?



220
221
222
223
224
225
226
227
# File 'lib/riffer/agent.rb', line 220

def self.tool_runtime(value = nil)
  if value.nil?
    return @tool_runtime if instance_variable_defined?(:@tool_runtime)
    superclass.respond_to?(:tool_runtime) ? superclass.tool_runtime : nil
  else
    @tool_runtime = value
  end
end

.use_mcp(tag) ⇒ Object

Opts this agent into tools from all MCP registrations that share any of the given tag(s).

tag - a String or Symbol; matched against registration manifest tags.

: (String | Symbol) -> void



198
199
200
201
# File 'lib/riffer/agent.rb', line 198

def self.use_mcp(tag)
  @mcp_configs ||= []
  @mcp_configs << {tags: [tag.to_sym]}
end

.uses_tools(tools_or_lambda = nil) ⇒ Object

Gets or sets the tools used by this agent.

– : (?(Array | Proc)?) -> (Array | Proc)?



137
138
139
140
# File 'lib/riffer/agent.rb', line 137

def self.uses_tools(tools_or_lambda = nil)
  return @tools_config if tools_or_lambda.nil?
  @tools_config = tools_or_lambda
end

Instance Method Details

#generate(prompt_or_messages, files: nil, context: nil) ⇒ Object

Generates a response from the agent.

When Riffer.config.experimental_history_healing is enabled, seeded message arrays that violate the tool_usetool_result invariant are silently repaired before the run begins.

– : ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response



361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/riffer/agent.rb', line 361

def generate(prompt_or_messages, files: nil, context: nil)
  @context = context
  prepare_run
  @structured_output = resolve_structured_output
  initialize_messages(prompt_or_messages, files: files)

  all_modifications = [] #: Array[Riffer::Guardrails::Modification]

  tripwire, modifications = run_before_guardrails
  all_modifications.concat(modifications)
  return build_response("", tripwire: tripwire, modifications: all_modifications) if tripwire

  run_generate_loop(all_modifications)
end

#generate_instruction_message(context: nil) ⇒ Object

Generates the instruction system message for this agent.

Useful for database persistence workflows where the system messages need to be stored independently.

Returns nil when no instructions are configured.

– : (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?



426
427
428
# File 'lib/riffer/agent.rb', line 426

def generate_instruction_message(context: nil)
  build_instruction_message(context)
end

#generate_skills_message(context: nil) ⇒ Object

Generates the skills catalog system message for this agent.

Useful for database persistence workflows where the system messages need to be stored independently.

Returns nil when no skills are configured or the catalog is empty.

– : (?context: Hash[Symbol, untyped]?) -> Riffer::Messages::System?



439
440
441
# File 'lib/riffer/agent.rb', line 439

def generate_skills_message(context: nil)
  build_skills_message(resolve_skills(context))
end

#interrupt!(reason = nil) ⇒ Object

Interrupts the agent loop.

Call from an on_message callback to cleanly interrupt the loop. Equivalent to throw :riffer_interrupt, reason.

When Riffer.config.experimental_history_healing is enabled, riffer fills any orphaned tool_use on the way out with a placeholder Riffer::Messages::Tool carrying error_type: :interrupted. The filled call_ids are exposed on Riffer::Agent::Response#healed_tool_call_ids (and the streaming Riffer::StreamEvents::Interrupt event).

– : (?(String | Symbol)?) -> void



457
458
459
# File 'lib/riffer/agent.rb', line 457

def interrupt!(reason = nil)
  throw :riffer_interrupt, reason
end

#last_assistantObject

Returns the most recent Riffer::Messages::Assistant in history, or nil when no assistant message has been recorded yet.

– : () -> Riffer::Messages::Assistant? TODO: Replace with rfind when minimum Ruby is 4.0+ rubocop:disable Style/ReverseFind



488
489
490
# File 'lib/riffer/agent.rb', line 488

def last_assistant
  @messages.reverse.find { |m| m.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
end

#message_by_id(id) ⇒ Object

Returns the message with the given id, or nil when no message matches.

– : (String) -> Riffer::Messages::Base?



465
466
467
# File 'lib/riffer/agent.rb', line 465

def message_by_id(id)
  @messages.find { |m| m.id == id }
end

#on_message(&block) ⇒ Object

Registers a callback to be invoked when messages are added during generation.

Raises Riffer::ArgumentError if no block is given.

– : () { (Riffer::Messages::Base) -> void } -> self



411
412
413
414
415
# File 'lib/riffer/agent.rb', line 411

def on_message(&block)
  raise Riffer::ArgumentError, "on_message requires a block" unless block_given?
  @message_callbacks << block
  self
end

#orphaned_tool_call_idsObject

Returns the call_ids of every tool_call on any assistant message that has no matching Riffer::Messages::Tool result anywhere in history.

Zero-cost validation hook for callers that want to check the tool_usetool_result invariant before mutating or persisting.

– : () -> Array



501
502
503
504
505
506
507
# File 'lib/riffer/agent.rb', line 501

def orphaned_tool_call_ids
  result_ids = @messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
  @messages.flat_map { |m|
    next [] unless m.is_a?(Riffer::Messages::Assistant)
    m.tool_calls.reject { |tc| result_ids.include?(tc.call_id) }.map(&:call_id)
  }
end

#remove_message(id:) ⇒ Object

Removes a message by id. When the target is an assistant message that carries tool_calls, every Riffer::Messages::Tool result whose tool_call_id matches one of those calls is removed atomically — keeping the tool_usetool_result invariant intact.

Raises Riffer::ArgumentError when called on a Riffer::Messages::Tool message — that would orphan the parent’s tool_use. Use replace_tool_result instead.

Returns the removed message, or nil when no message has the given id (idempotent).

– : (id: String) -> Riffer::Messages::Base?



551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
# File 'lib/riffer/agent.rb', line 551

def remove_message(id:)
  idx = @messages.index { |m| m.id == id }
  return nil unless idx

  target = @messages[idx]
  if target.is_a?(Riffer::Messages::Tool)
    raise Riffer::ArgumentError,
      "remove_message cannot drop a Tool message (would orphan the parent's tool_use); use replace_tool_result instead"
  end

  if target.is_a?(Riffer::Messages::Assistant) && !target.tool_calls.empty?
    child_ids = target.tool_calls.map(&:call_id)
    @messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && child_ids.include?(m.tool_call_id) }
    @messages.delete(target)
  else
    @messages.delete_at(idx)
  end
  target
end

#replace_assistant_content(id:, content:) ⇒ Object

Replaces the content of an assistant message in place. Preserves id, tool_calls, token_usage, and structured_output. Lookup is by id.

When content is empty, delegates to remove_message — including the cascade that drops dependent Riffer::Messages::Tool children.

Raises Riffer::ArgumentError when no assistant message has the given id.

– : (id: String, content: String) -> Riffer::Messages::Base?



519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
# File 'lib/riffer/agent.rb', line 519

def replace_assistant_content(id:, content:)
  return remove_message(id: id) if content.empty?

  idx = @messages.index { |m| m.is_a?(Riffer::Messages::Assistant) && m.id == id }
  raise Riffer::ArgumentError, "no assistant message with id #{id.inspect}" unless idx

  old = @messages[idx] #: Riffer::Messages::Assistant
  replacement = Riffer::Messages::Assistant.new(
    content,
    id: old.id,
    tool_calls: old.tool_calls,
    token_usage: old.token_usage,
    structured_output: old.structured_output
  )
  @messages[idx] = replacement
  replacement
end

#replace_tool_result(tool_call_id:, content:, error: nil, error_type: nil) ⇒ Object

Replaces a tool result’s content (and optional error fields) in place. Lookup is by tool_call_id. Preserves the existing message’s name and id.

Raises Riffer::ArgumentError when no Tool message exists for the given tool_call_id.

– : (tool_call_id: String, content: String, ?error: String?, ?error_type: Symbol?) -> Riffer::Messages::Tool



580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
# File 'lib/riffer/agent.rb', line 580

def replace_tool_result(tool_call_id:, content:, error: nil, error_type: nil)
  idx = @messages.index { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id }
  raise Riffer::ArgumentError, "no tool result for tool_call_id #{tool_call_id.inspect}" unless idx

  old = @messages[idx] #: Riffer::Messages::Tool
  replacement = Riffer::Messages::Tool.new(
    content,
    id: old.id,
    tool_call_id: old.tool_call_id,
    name: old.name,
    error: error,
    error_type: error_type
  )
  @messages[idx] = replacement
  replacement
end

#stream(prompt_or_messages, files: nil, context: nil) ⇒ Object

Streams a response from the agent.

Raises Riffer::ArgumentError if structured output is configured.

See #generate for the experimental_history_healing behavior on seeded arrays.

– : ((String | Array[Hash[Symbol, untyped] | Riffer::Messages::Base]), ?files: Array[Hash[Symbol, untyped] | Riffer::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/riffer/agent.rb', line 385

def stream(prompt_or_messages, files: nil, context: nil)
  raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if self.class.structured_output

  @context = context
  prepare_run
  initialize_messages(prompt_or_messages, files: files)

  Enumerator.new do |yielder|
    tripwire, modifications = run_before_guardrails
    modifications.each { |m| yielder << Riffer::StreamEvents::GuardrailModification.new(m) }

    if tripwire
      yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire)
      next
    end

    run_stream_loop(yielder)
  end
end

#tool_message_for(tool_call_id) ⇒ Object

Returns the Riffer::Messages::Tool message that satisfies tool_call_id, or nil when no such tool result exists in history.

– : (String) -> Riffer::Messages::Tool? TODO: Replace with rfind when minimum Ruby is 4.0+ rubocop:disable Style/ReverseFind



476
477
478
# File 'lib/riffer/agent.rb', line 476

def tool_message_for(tool_call_id)
  @messages.reverse.find { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id } #: Riffer::Messages::Tool?
end