diff --git a/lib/generators/solid_agent/install/install_generator.rb b/lib/generators/solid_agent/install/install_generator.rb index 74f1ecb..31e7381 100644 --- a/lib/generators/solid_agent/install/install_generator.rb +++ b/lib/generators/solid_agent/install/install_generator.rb @@ -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 @@ -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 diff --git a/lib/generators/solid_agent/install/templates/agent_memory.rb.erb b/lib/generators/solid_agent/install/templates/agent_memory.rb.erb new file mode 100644 index 0000000..196ec77 --- /dev/null +++ b/lib/generators/solid_agent/install/templates/agent_memory.rb.erb @@ -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 diff --git a/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb b/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb new file mode 100644 index 0000000..62434c8 --- /dev/null +++ b/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb @@ -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 diff --git a/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb b/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb new file mode 100644 index 0000000..e0d098e --- /dev/null +++ b/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb @@ -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 diff --git a/lib/solid_agent.rb b/lib/solid_agent.rb index 6227845..ff92eb3 100644 --- a/lib/solid_agent.rb +++ b/lib/solid_agent.rb @@ -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 @@ -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" diff --git a/lib/solid_agent/has_context.rb b/lib/solid_agent/has_context.rb index 881a378..f036150 100644 --- a/lib/solid_agent/has_context.rb +++ b/lib/solid_agent/has_context.rb @@ -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 @@ -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 diff --git a/lib/solid_agent/has_memory.rb b/lib/solid_agent/has_memory.rb new file mode 100644 index 0000000..2cefdbe --- /dev/null +++ b/lib/solid_agent/has_memory.rb @@ -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 diff --git a/lib/solid_agent/tool_cache.rb b/lib/solid_agent/tool_cache.rb new file mode 100644 index 0000000..62fc3f4 --- /dev/null +++ b/lib/solid_agent/tool_cache.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "digest" +require "json" + +# Caches the results of tool / MCP / service interactions so repeated calls +# with the same arguments reuse a persisted result instead of re-running the +# side effect (HTTP fetch, search, remote MCP call, ...). +# +# Works out of the box in Rails apps (backed by Rails.cache); any object +# responding to read/write can be substituted, so tests and non-Rails +# runtimes can inject their own store. +# +# @example Caching a tool implementation +# def fetch_url(url:) +# SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: url }) do +# Net::HTTP.get_response(URI(url)).body +# end +# end +# +# @example Disabling globally (e.g. in tests) +# SolidAgent::ToolCache.enabled = false +module SolidAgent + module ToolCache + DEFAULT_TTL = 300 # seconds + + class << self + attr_writer :enabled, :default_ttl, :store + + def enabled + defined?(@enabled) ? @enabled : true + end + + def default_ttl + @default_ttl || DEFAULT_TTL + end + + def store + @store || (defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : nil) + end + + # Returns the cached result for (tool, args) or yields, caching the + # fresh result. Results that look like errors ({ error: ... }) are + # never cached, so transient failures don't stick. + # + # The returned hash is tagged with cached: true on cache hits so + # callers (and the model) can tell a replayed result from a fresh one. + def fetch(tool:, args: {}, ttl: nil, cache: store) + return yield unless enabled && cache + + key = cache_key(tool, args) + cached = cache.read(key) + return tag_cached(cached) unless cached.nil? + + result = yield + cache.write(key, result, expires_in: ttl || default_ttl) if cacheable?(result) + result + end + + def cache_key(tool, args) + digest = Digest::SHA256.hexdigest(normalize_args(args).to_json) + "solid_agent:tool_cache:#{tool}:#{digest}" + end + + private + + def cacheable?(result) + return false if result.nil? + return !(result.key?(:error) || result.key?("error")) if result.respond_to?(:key?) + + true + end + + def tag_cached(result) + result.respond_to?(:merge) ? result.merge(cached: true) : result + end + + # Stable key material regardless of hash ordering or string/symbol keys. + def normalize_args(args) + case args + when Hash + args.map { |k, v| [ k.to_s, normalize_args(v) ] }.sort_by(&:first) + when Array + args.map { |v| normalize_args(v) } + else + args + end + end + end + end +end diff --git a/test/solid_agent/has_memory_test.rb b/test/solid_agent/has_memory_test.rb new file mode 100644 index 0000000..4822408 --- /dev/null +++ b/test/solid_agent/has_memory_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# test_helper stubs ActiveSupport::Concern and loads the full gem against +# it — do NOT require the real active_support here: its Concern would +# override the stub's append_features mid-suite and break every include. +require "test_helper" + +class SolidAgent::HasMemoryTest < Minitest::Test + FakeEntry = Struct.new(:id, :content, :category, :source_agent, :created_at) + + # Implements the memory-model contract HasMemory duck-types against. + class FakeMemory + class << self + attr_accessor :instances + + def for(memorable, scope:) + self.instances ||= {} + self.instances[[ memorable, scope ]] ||= new(memorable, scope) + end + end + + attr_reader :memorable, :scope, :entries + + def initialize(memorable, scope) + @memorable = memorable + @scope = scope + @entries = [] + end + + def remember(content, source_agent: nil, category: nil) + entry = FakeEntry.new(@entries.size + 1, content, category, source_agent, Time.now) + @entries << entry + entry + end + + def recall(limit: 20, category: nil) + list = @entries.reverse + list = list.select { |e| e.category == category } if category + list.first(limit || 20) + end + end + + class FakeAgent + include SolidAgent::HasMemory + has_memory class_name: "SolidAgent::HasMemoryTest::FakeMemory" + + attr_reader :params + + def initialize(params = {}) + @params = params + end + end + + def setup + FakeMemory.instances = {} + end + + def test_tool_definitions_expose_save_and_recall + names = SolidAgent::HasMemory.tool_definitions.map { |d| d[:name] } + assert_equal %w[save_memory recall_memory], names + + agent = FakeAgent.new(memorable: "subject-1") + assert_equal names, agent.memory_tool_definitions.map { |d| d[:name] } + end + + def test_save_and_recall_roundtrip + agent = FakeAgent.new(memorable: "subject-1") + + saved = agent.save_memory(content: "User prefers terse answers", category: "fact") + assert saved[:saved] + + recalled = agent.recall_memory + assert_equal 1, recalled[:count] + assert_equal "User prefers terse answers", recalled[:entries].first[:content] + assert_equal "SolidAgent::HasMemoryTest::FakeAgent", recalled[:entries].first[:source_agent] + end + + def test_memory_is_shared_across_agent_classes_for_handoff + other_agent_class = Class.new do + include SolidAgent::HasMemory + has_memory class_name: "SolidAgent::HasMemoryTest::FakeMemory" + def self.name = "SecondAgent" + attr_reader :params + def initialize(params) = @params = params + end + + FakeAgent.new(memorable: "shared-subject").save_memory(content: "Research done: use approach B") + recalled = other_agent_class.new({ memorable: "shared-subject" }).recall_memory + + assert_equal 1, recalled[:count] + assert_equal "Research done: use approach B", recalled[:entries].first[:content] + end + + def test_category_filter_passes_through + agent = FakeAgent.new(memorable: "s") + agent.save_memory(content: "a fact", category: "fact") + agent.save_memory(content: "a task", category: "task") + + recalled = agent.recall_memory(category: "task") + assert_equal [ "a task" ], recalled[:entries].map { |e| e[:content] } + end + + def test_errors_without_a_subject + agent = FakeAgent.new({}) + + assert_equal({ error: "No memory subject available" }, agent.save_memory(content: "x")) + assert_equal({ error: "No memory subject available" }, agent.recall_memory) + end +end diff --git a/test/solid_agent/tool_cache_test.rb b/test/solid_agent/tool_cache_test.rb new file mode 100644 index 0000000..a434925 --- /dev/null +++ b/test/solid_agent/tool_cache_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "test_helper" +require "solid_agent/tool_cache" + +class SolidAgent::ToolCacheTest < Minitest::Test + # Minimal store with the read/write contract ToolCache needs. + class MemoryStore + attr_reader :writes + + def initialize + @data = {} + @writes = [] + end + + def read(key) + @data[key] + end + + def write(key, value, **options) + @writes << [ key, value, options ] + @data[key] = value + end + end + + def setup + @store = MemoryStore.new + end + + def test_caches_and_replays_results + calls = 0 + 2.times do + @result = SolidAgent::ToolCache.fetch(tool: "web_search", args: { query: "rails" }, cache: @store) do + calls += 1 + { answer: 42 } + end + end + + assert_equal 1, calls + assert_equal 42, @result[:answer] + assert_equal true, @result[:cached], "replayed results should be tagged cached: true" + end + + def test_key_is_stable_across_argument_ordering_and_key_types + key_a = SolidAgent::ToolCache.cache_key("t", { a: 1, b: 2 }) + key_b = SolidAgent::ToolCache.cache_key("t", { "b" => 2, "a" => 1 }) + key_c = SolidAgent::ToolCache.cache_key("t", { a: 1, b: 3 }) + + assert_equal key_a, key_b + refute_equal key_a, key_c + end + + def test_does_not_cache_error_results + 2.times do + SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: "x" }, cache: @store) do + { error: "boom" } + end + end + + assert_empty @store.writes + end + + def test_respects_ttl_option + SolidAgent::ToolCache.fetch(tool: "t", args: {}, ttl: 60, cache: @store) { { ok: true } } + + assert_equal 60, @store.writes.first.last[:expires_in] + end + + def test_disabled_bypasses_cache + SolidAgent::ToolCache.enabled = false + calls = 0 + 2.times do + SolidAgent::ToolCache.fetch(tool: "t", args: {}, cache: @store) { calls += 1; { ok: true } } + end + + assert_equal 2, calls + assert_empty @store.writes + ensure + SolidAgent::ToolCache.enabled = true + end + + def test_yields_without_a_store + result = SolidAgent::ToolCache.fetch(tool: "t", args: {}, cache: nil) { { ok: true } } + + assert_equal({ ok: true }, result) + end +end