Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/spec/reports/
/tmp/
/spec/dummy/log/
/spec/dummy/tmp/

# rspec failure tracking
.rspec_status
Expand Down
2 changes: 2 additions & 0 deletions app/models/ruby_llm/agents/execution_detail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class ExecutionDetail < ::ActiveRecord::Base

belongs_to :execution, class_name: "RubyLLM::Agents::Execution"

has_many_attached :user_attachments

validates :execution_id, presence: true
end
end
Expand Down
30 changes: 30 additions & 0 deletions app/views/ruby_llm/agents/executions/_user_attachments.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<% attachments = @execution.detail&.user_attachments %>
<% if attachments.present? && attachments.any? %>
<div class="flex items-center gap-3 mt-6 mb-3">
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">user attachments (<%= attachments.size %>)</span>
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
</div>

<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-3">
<% attachments.each do |attachment| %>
<div class="border border-gray-200 dark:border-gray-800 rounded p-3 flex flex-col gap-2">
<% if attachment.image? %>
<%= image_tag(main_app.url_for(attachment), class: "w-full h-32 object-contain bg-gray-50 dark:bg-gray-900 rounded") %>
<% elsif attachment.content_type == "application/pdf" %>
<div class="w-full h-32 bg-gray-50 dark:bg-gray-900 rounded flex items-center justify-center">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">PDF</span>
</div>
<% else %>
<div class="w-full h-32 bg-gray-50 dark:bg-gray-900 rounded flex items-center justify-center">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400"><%= attachment.content_type&.split("/")&.last || "file" %></span>
</div>
<% end %>
<div class="flex flex-col gap-0.5 font-mono text-xs">
<span class="text-gray-800 dark:text-gray-200 truncate" title="<%= attachment.filename %>"><%= attachment.filename %></span>
<span class="text-gray-400 dark:text-gray-600"><%= number_to_human_size(attachment.byte_size) %> &middot; <%= attachment.content_type %></span>
<%= link_to "download", main_app.url_for(attachment), class: "text-blue-600 dark:text-blue-400 hover:underline", target: "_blank", rel: "noopener" %>
</div>
</div>
<% end %>
</div>
<% end %>
3 changes: 3 additions & 0 deletions app/views/ruby_llm/agents/executions/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
<%= render "ruby_llm/agents/executions/audio_player" %>
<% end %>

<!-- ── user attachments ──────────────── -->
<%= render "ruby_llm/agents/executions/user_attachments" %>

<!-- ── tokens ──────────────────────── -->
<%
input_tokens = @execution.input_tokens || 0
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/agents/base_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class BaseAgent
extend DSL::Caching
extend DSL::Queryable
extend DSL::Knowledge
extend DSL::Attachments
include DSL::Knowledge::InstanceMethods
include CacheHelper

Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/agents/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative "dsl/caching"
require_relative "dsl/queryable"
require_relative "dsl/knowledge"
require_relative "dsl/attachments"

module RubyLLM
module Agents
Expand Down
64 changes: 64 additions & 0 deletions lib/ruby_llm/agents/dsl/attachments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module RubyLLM
module Agents
module DSL
# DSL for persisting user-supplied input attachments alongside an
# execution record, so the original files can be inspected later from
# the dashboard.
#
# Opt-in per agent. When enabled, any files passed via the `with:`
# keyword to `.call` are attached to the Execution's detail record.
#
# @example Enable with the Active Storage backend
# class DiagramImportAgent < ApplicationAgent
# store_attachments :active_storage
# end
#
# DiagramImportAgent.call(with: "path/to/diagram.png")
# # => The file is attached to execution.detail.user_attachments
#
# Only `:active_storage` is supported. Agents that do not declare
# `store_attachments` are unchanged — `with:` continues to work as
# before, just without persistence.
module Attachments
SUPPORTED_BACKENDS = %i[active_storage].freeze

# Sets or returns the attachment storage backend for this agent.
#
# @param backend [Symbol, nil] `:active_storage` or nil to read the current value
# @return [Symbol, nil] The configured backend, or nil if disabled
# @raise [ArgumentError] If an unsupported backend is given
def store_attachments(backend = nil)
if backend
unless SUPPORTED_BACKENDS.include?(backend)
raise ArgumentError,
"Unsupported store_attachments backend #{backend.inspect}. Supported: #{SUPPORTED_BACKENDS.inspect}"
end

@store_attachments = backend
end

return @store_attachments if defined?(@store_attachments) && !@store_attachments.nil?

inherited_store_attachments
end

# Whether any attachment persistence is enabled on this agent.
#
# @return [Boolean]
def store_attachments_enabled?
!store_attachments.nil?
end

private

def inherited_store_attachments
return nil unless superclass.respond_to?(:store_attachments)

superclass.store_attachments
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/ruby_llm/agents/pipeline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
require_relative "pipeline/middleware/cache"
require_relative "pipeline/middleware/instrumentation"
require_relative "pipeline/middleware/reliability"
require_relative "pipeline/middleware/attachment_persistence"

module RubyLLM
module Agents
Expand Down
16 changes: 16 additions & 0 deletions lib/ruby_llm/agents/pipeline/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ def for(agent_class)
# Instrumentation (always - for tracking, must be before Cache)
builder.use(Middleware::Instrumentation)

# Attachment persistence (if agent opts in via store_attachments)
# Runs after Instrumentation so execution_id is available.
builder.use(Middleware::AttachmentPersistence) if attachments_enabled?(agent_class)

# Caching (if enabled on the agent)
builder.use(Middleware::Cache) if cache_enabled?(agent_class)

Expand Down Expand Up @@ -214,6 +218,18 @@ def budgets_enabled?
false
end

# Check if attachment persistence is enabled for an agent
#
# @param agent_class [Class] The agent class
# @return [Boolean]
def attachments_enabled?(agent_class)
return false unless agent_class

agent_class.respond_to?(:store_attachments_enabled?) && agent_class.store_attachments_enabled?
rescue
false
end

# Check if caching is enabled for an agent
#
# @param agent_class [Class] The agent class
Expand Down
116 changes: 116 additions & 0 deletions lib/ruby_llm/agents/pipeline/middleware/attachment_persistence.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# frozen_string_literal: true

module RubyLLM
module Agents
module Pipeline
module Middleware
# Persists input attachments (files passed via `with:`) to the
# execution's detail record using the configured storage backend.
#
# Activates when the agent opts in with `store_attachments :active_storage`
# and the call includes a `with:` argument. Runs after Instrumentation
# so an Execution record already exists.
#
# Attachment is performed after the downstream call succeeds. On
# failure, attachments are not persisted (keeps the dashboard
# record focused on what the LLM actually saw for a completed run).
# Any error during attachment is logged and swallowed so it never
# breaks the agent call.
class AttachmentPersistence < Base
def call(context)
result = @app.call(context)
persist_attachments(context) if should_persist?(context)
result
end

private

def should_persist?(context)
return false unless @agent_class.respond_to?(:store_attachments_enabled?)
return false unless @agent_class.store_attachments_enabled?
return false unless @agent_class.store_attachments == :active_storage
return false if context.failed?
return false if context.execution_id.nil?

attachment_inputs(context).any?
end

def persist_attachments(context)
execution = RubyLLM::Agents::Execution.find_by(id: context.execution_id)
return unless execution

detail = execution.detail || execution.create_detail!

attachment_inputs(context).each do |input|
attach(detail, input)
end
rescue => e
error("Failed to persist user attachments: #{e.message}", context)
end

# Reads the caller's `with:` argument from the context.
#
# BaseAgent#build_context nests the raw `with:` value under
# `context.options[:options][:attachments]` (see
# BaseAgent#execution_options). We look there, and also fall back
# to `context.options[:with]` for any caller that builds a
# Context directly.
def attachment_inputs(context)
nested = context.options.dig(:options, :attachments)
direct = context.options[:with]
Array(nested || direct).compact
end

def attach(detail, input)
payload = build_attachment_payload(input)
return unless payload

detail.user_attachments.attach(payload)
end

# Normalises a `with:` entry into a Hash suitable for
# ActiveStorage::Attached#attach, handling the common input types.
#
# Skips URLs (strings that parse as http/https) since they are not
# local files — the LLM fetches them directly.
def build_attachment_payload(input)
case input
when String
return nil if url?(input)

file_payload(input)
when Pathname
file_payload(input.to_s)
when File, Tempfile
{io: File.open(input.path), filename: File.basename(input.path), content_type: detect_content_type(input.path)}
else
return input if uploaded_file?(input)

nil
end
end

def file_payload(path)
return nil unless File.exist?(path)

{io: File.open(path), filename: File.basename(path), content_type: detect_content_type(path)}
end

def uploaded_file?(input)
defined?(ActionDispatch::Http::UploadedFile) && input.is_a?(ActionDispatch::Http::UploadedFile)
end

def url?(string)
string.start_with?("http://", "https://")
end

def detect_content_type(path)
Marcel::MimeType.for(Pathname.new(path))
rescue
"application/octet-stream"
end
end
end
end
end
end
87 changes: 87 additions & 0 deletions spec/agents/store_attachments_integration_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

require "rails_helper"

# End-to-end spec for `store_attachments :active_storage`.
#
# This exercises the full BaseAgent.call → pipeline → attachment flow so
# that a rename of the key BaseAgent#execution_options uses to forward
# `with:` into the context (or any other break in that contract) is
# caught, not silently no-op'd the way earlier middleware-only specs
# allowed.
RSpec.describe "Store attachments integration" do
let(:file_path) { File.join(Dir.tmpdir, "store_attachments_integration_spec_#{SecureRandom.hex(4)}.txt") }

before do
File.write(file_path, "diagram content")

RubyLLM::Agents.reset_configuration!
config = RubyLLM::Agents.configuration
config.track_executions = true
config.persist_prompts = true
config.persist_responses = false

stub_agent_configuration
response = build_real_response(content: "ok", input_tokens: 10, output_tokens: 5)
stub_ruby_llm_chat(build_mock_chat_client(response: response))
end

after do
File.delete(file_path) if File.exist?(file_path)
end

let(:enabled_agent_class) do
Class.new(RubyLLM::Agents::Base) do
def self.name
"EnabledAttachmentsAgent"
end

model "gpt-4o"
system "You are a test agent."
user "Process: {query}"
param :query, required: true

store_attachments :active_storage
end
end

let(:disabled_agent_class) do
Class.new(RubyLLM::Agents::Base) do
def self.name
"DisabledAttachmentsAgent"
end

model "gpt-4o"
system "You are a test agent."
user "Process: {query}"
param :query, required: true
end
end

it "attaches the file passed via with: onto the execution's detail" do
enabled_agent_class.call(query: "hello", with: file_path)

execution = RubyLLM::Agents::Execution.last
expect(execution).to be_present
expect(execution.status).to eq("success")

detail = execution.detail
expect(detail).to be_present
expect(detail.user_attachments.count).to eq(1)
expect(detail.user_attachments.first.filename.to_s).to eq(File.basename(file_path))
end

it "does not attach when store_attachments is not declared" do
disabled_agent_class.call(query: "hello", with: file_path)

execution = RubyLLM::Agents::Execution.last
expect(execution.detail&.user_attachments&.count.to_i).to eq(0)
end

it "does not attach when with: is omitted" do
enabled_agent_class.call(query: "hello")

execution = RubyLLM::Agents::Execution.last
expect(execution.detail&.user_attachments&.count.to_i).to eq(0)
end
end
Loading