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
5 changes: 5 additions & 0 deletions lib/generators/solid_agent/install/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def create_migrations

migration_template "create_agent_generations.rb.erb",
"db/migrate/create_agent_generations.rb"

migration_template "create_agent_memories.rb.erb",
"db/migrate/create_agent_memories.rb"
end

def create_models
Expand All @@ -37,6 +40,8 @@ def create_models
template "agent_context.rb.erb", "app/models/agent_context.rb"
template "agent_message.rb.erb", "app/models/agent_message.rb"
template "agent_generation.rb.erb", "app/models/agent_generation.rb"
template "agent_memory.rb.erb", "app/models/agent_memory.rb"
template "agent_memory_entry.rb.erb", "app/models/agent_memory_entry.rb"
end

def create_initializer
Expand Down
51 changes: 51 additions & 0 deletions lib/generators/solid_agent/install/templates/agent_memory.rb.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

# Agent-curated long-term memory for a subject record, written and read by
# agents through SolidAgent::HasMemory's save_memory/recall_memory tools.
#
# Memory is scoped to (memorable, scope) — not to an agent class — so any
# agent operating on the same subject shares it, making it a handoff
# channel between agents. Entry source_agent records who wrote each note.
class AgentMemory < ApplicationRecord
belongs_to :memorable, polymorphic: true, optional: true
has_many :entries, class_name: "AgentMemoryEntry", dependent: :destroy

validates :scope, presence: true

# Finds or creates the memory for a subject.
def self.for(memorable, scope: SolidAgent::HasMemory::DEFAULT_SCOPE)
find_or_create_by!(memorable: memorable, scope: scope.to_s)
end

# Appends a summary note.
def remember(content, source_agent: nil, category: nil)
entries.create!(content: content, source_agent: source_agent, category: category)
end

# Most recent notes first.
def recall(limit: 20, category: nil)
scope = entries.order(created_at: :desc)
scope = scope.where(category: category) if category.present?
scope.limit(limit || 20).to_a
end

def forget(entry_id)
entries.find(entry_id).destroy!
end

def summary_list
entries.order(:created_at).pluck(:content)
end

# Formatted block suitable for injecting into another agent's
# instructions when handing a subject off.
def to_prompt
notes = entries.order(:created_at).map do |entry|
source = entry.source_agent.present? ? " (#{entry.source_agent})" : ""
"- #{entry.content}#{source}"
end
return "" if notes.empty?

"Memory notes for this subject:\n#{notes.join("\n")}"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

# One agent-authored summary note in an AgentMemory.
class AgentMemoryEntry < ApplicationRecord
belongs_to :agent_memory

validates :content, presence: true

scope :chronological, -> { order(:created_at) }
scope :by_category, ->(category) { where(category: category) }
scope :from_agent, ->(agent_name) { where(source_agent: agent_name) }
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

class CreateAgentMemories < ActiveRecord::Migration<%= migration_version %>
def change
create_table :agent_memories do |t|
# The subject the memory is about (an Agent record, User, Project...).
# Any agent operating on the same subject + scope shares the memory,
# which is what makes it a handoff channel between agents.
t.references :memorable, polymorphic: true, index: true

# Namespace so a subject can carry independent memory streams.
t.string :scope, null: false, default: "default"

t.timestamps
end
add_index :agent_memories, [ :memorable_type, :memorable_id, :scope ], unique: true

create_table :agent_memory_entries do |t|
t.references :agent_memory, null: false, foreign_key: true

# The summary note the agent chose to persist.
t.text :content, null: false

# Which agent class wrote it (handoff provenance).
t.string :source_agent

# Optional label: fact, task, handoff, ...
t.string :category

t.timestamps
end
add_index :agent_memory_entries, [ :agent_memory_id, :created_at ]
add_index :agent_memory_entries, :category
end
end
2 changes: 2 additions & 0 deletions lib/solid_agent.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "solid_agent/version"
require_relative "solid_agent/tool_cache"

module SolidAgent
class Error < StandardError; end
Expand Down Expand Up @@ -59,6 +60,7 @@ def agents_from(directory, pattern: "**/*.agent.md", **options)
end

require_relative "solid_agent/has_context"
require_relative "solid_agent/has_memory"
require_relative "solid_agent/has_tools"
require_relative "solid_agent/streams_tool_updates"
require_relative "solid_agent/reasonable"
Expand Down
37 changes: 37 additions & 0 deletions lib/solid_agent/has_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,8 @@ def capture_and_persist_generation
def persist_generation_to_context
return unless context && generation_response

persist_tool_messages_to_context

begin
if generation_response.respond_to?(:message) && generation_response.message&.content.present?
# Include provenance if the context supports it
Expand All @@ -544,5 +546,40 @@ def persist_generation_to_context
Rails.logger.error e.backtrace.first(5).join("\n")
end
end

# Persists the tool/MCP interaction stream (tool result messages from
# the response's message stack) to the context, so conversations show
# the full agent <-> tool exchange, not just the final assistant text.
#
# Requires the context model to expose add_tool_message (the install
# generator's AgentContext does); contexts without it are skipped.
# Messages are deduped by tool_call_id so re-persisting a shared
# message stack (multi-turn conversations) doesn't duplicate rows.
def persist_tool_messages_to_context
return unless context.respond_to?(:add_tool_message)
return unless generation_response.respond_to?(:messages)

Array(generation_response.messages).each do |message|
next unless message.respond_to?(:role) && message.role.to_s == "tool"

tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
next if tool_call_id.present? && tool_message_persisted?(tool_call_id)

context.add_tool_message(
tool_call_id: tool_call_id,
tool_name: (message.name if message.respond_to?(:name)),
result: (message.content if message.respond_to?(:content))
)
end
rescue => e
Rails.logger.error "[SolidAgent] Failed to persist tool messages: #{e.message}"
end

def tool_message_persisted?(tool_call_id)
return false unless context.respond_to?(:messages)

scope = context.messages
scope.respond_to?(:exists?) && scope.exists?(role: "tool", tool_call_id: tool_call_id)
end
end
end
136 changes: 136 additions & 0 deletions lib/solid_agent/has_memory.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true

# HasMemory gives an agent a persistent, agent-curated summary list — the
# model decides when to read and write it while interacting with tools,
# other agents, and users.
#
# Memory is scoped to a subject record (any ActiveRecord model) plus a
# scope name, NOT to the agent class — so a memory written by one agent can
# be recalled by another operating on the same subject. That makes it a
# handoff channel: agent A records what it learned/did, agent B picks the
# subject up and recalls the summary before continuing.
#
# The concern is duck-typed against a memory model exposing:
# Model.for(memorable, scope:) -> memory record
# memory.remember(content, source_agent:, category:) -> entry
# memory.recall(limit:, category:) -> entries (responding to #content)
# The install generator's AgentMemory implements this contract.
#
# @example Give an agent memory tools the model can call
# class SupportAgent < ApplicationAgent
# include SolidAgent::HasMemory
# has_memory
#
# def handle
# prompt(message: params[:message], tools: memory_tool_definitions)
# end
# end
#
# @example Handoff between agents sharing a subject
# ResearchAgent.with(memorable: project).research.generate_now
# # later, a different agent class:
# WriterAgent.with(memorable: project).draft.generate_now
# # WriterAgent's recall_memory returns ResearchAgent's entries too.
module SolidAgent
module HasMemory
extend ActiveSupport::Concern

DEFAULT_SCOPE = "default"

# Function-calling schemas (common format) for the two memory tools.
# Exposed as a module method so non-agent callers (platform executors,
# MCP servers) can reuse the exact same contract.
def self.tool_definitions
[
{
name: "save_memory",
description: "Persist a short summary note to long-term memory. Use for facts, decisions, task outcomes, or anything a future agent or session should know. Keep each note self-contained.",
parameters: {
type: "object",
properties: {
content: { type: "string", description: "The summary note to remember" },
category: { type: "string", description: "Optional label, e.g. fact, task, handoff" }
},
required: [ "content" ]
}
},
{
name: "recall_memory",
description: "Read back previously saved memory notes for the current subject, most recent first. Use before starting work to pick up prior context or another agent's handoff.",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "Only return notes with this label" },
limit: { type: "integer", description: "Maximum notes to return (default 20)" }
},
required: []
}
}
]
end

included do
class_attribute :_memory_config, default: nil
end

class_methods do
# Configures memory for this agent.
#
# @param scope [String, Symbol] memory namespace (default "default")
# @param class_name [String] memory model (default "AgentMemory")
def has_memory(scope: DEFAULT_SCOPE, class_name: "AgentMemory")
self._memory_config = { scope: scope.to_s, class_name: class_name }
end
end

# The memory record for the current subject (or nil without a subject).
def memory
config = self.class._memory_config || { scope: DEFAULT_SCOPE, class_name: "AgentMemory" }
subject = memory_subject
return nil unless subject

@memory ||= config[:class_name].constantize.for(subject, scope: config[:scope])
end

# The record memory is attached to. Defaults to params[:memorable],
# falling back to the HasContext contextable when present. Override for
# custom subjects.
def memory_subject
return params[:memorable] if respond_to?(:params) && params.is_a?(Hash) && params[:memorable]

context.contextable if respond_to?(:context) && context.respond_to?(:contextable)
rescue StandardError
nil
end

def memory_tool_definitions
SolidAgent::HasMemory.tool_definitions
end

# Tool implementations — routed here by the provider's tool calls.

def save_memory(content:, category: nil)
return { error: "No memory subject available" } unless memory

entry = memory.remember(content, source_agent: self.class.name, category: category)
{ saved: true, id: entry.respond_to?(:id) ? entry.id : nil, content: content }
end

def recall_memory(category: nil, limit: 20)
return { error: "No memory subject available" } unless memory

entries = memory.recall(limit: limit, category: category)
{
count: entries.size,
entries: entries.map do |entry|
{
content: entry.content,
category: (entry.category if entry.respond_to?(:category)),
source_agent: (entry.source_agent if entry.respond_to?(:source_agent)),
created_at: (entry.created_at.iso8601 if entry.respond_to?(:created_at) && entry.created_at)
}.compact
end
}
end
end
end
Loading