Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_agent (0.1.1)
solid_agent (0.2.0)
activeagent (>= 1.0.0)
activerecord (>= 7.0)
activesupport (>= 7.0)
Expand Down
94 changes: 84 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,26 @@

# SolidAgent

SolidAgent extends the [ActiveAgent](https://github.com/activeagents/activeagent) framework with enterprise-grade features for building robust AI agents in Rails applications. It provides three core concerns that add database-backed persistence, declarative tool schemas, and real-time streaming capabilities to your agents.
SolidAgent extends the [ActiveAgent](https://github.com/activeagents/activeagent) framework with database-backed persistence for everything an agent does in a Rails application: conversations, generations, tool/MCP interactions, reasoning, and long-term memory.

## Features

- **HasContext** - Database-backed prompt context management for maintaining conversation history and agent state
Agent-side concerns:

- **HasContext** - Database-backed prompt context management for maintaining conversation history and agent state, including the full tool/MCP interaction stream
- **HasMemory** - An agent-curated summary list the model reads/writes via `save_memory`/`recall_memory` function-calling tools; scoped to a subject record so agents hand off to each other through shared memory
- **HasTools** - Declarative, schema-based tool definitions compatible with LLM function-calling APIs
- **HasReasons** - Capture and inspect extended-thinking/reasoning output across a generation
- **StreamsToolUpdates** - Real-time UI feedback during tool execution via ActionCable

Model-side and standalone:

- **Reasonable** - Persist reasoning content/tokens/metadata on your generation records
- **AgentRun** - Durable run records (installed by the generator): lifecycle status, append-only progress events for live UIs, token/duration accounting, and instruction-fingerprint cohorts for comparing configuration changes
- **ToolCache** - Cache tool/MCP/service results by `(tool, normalized args)` with TTL, backed by `Rails.cache`; error results are never cached and replays are tagged `cached: true`
- **ModelPricing** - Token-count → estimated USD cost, using RubyLLM's model registry when available with a static pattern-table fallback
- **AgentManifest** - Load, validate, export, and build agent classes from portable manifests (`.agent.md`, dotprompt, CrewAI)

## Installation

Add this line to your application's Gemfile:
Expand All @@ -26,19 +38,15 @@ And then execute:
$ bundle install
```

Or install it yourself as:

```bash
$ gem install solid_agent
```

## Usage

### Quick Start

Generate a new agent with context support:
Install the persistence tables and models (`AgentContext`, `AgentMessage`, `AgentGeneration`, `AgentMemory`, `AgentMemoryEntry`, `AgentRun`), then generate an agent with context support:

```bash
$ rails generate solid_agent:install
$ rails db:migrate
$ rails generate solid_agent:agent WritingAssistant --context --context_name conversation --contextual user
```

Expand Down Expand Up @@ -130,9 +138,69 @@ class BrowserAgent < ApplicationAgent
end
```

### HasMemory - Agent-Curated Long-Term Memory

Give an agent a durable summary list it decides when to read and write, scoped to a subject record rather than the agent class — so different agents operating on the same subject share memory, with `source_agent` provenance on every entry:

```ruby
class SupportAgent < ApplicationAgent
include SolidAgent::HasContext
include SolidAgent::HasMemory

has_context contextual: :user
has_memory # scope: "default", class_name: "AgentMemory"

def assist
load_context(contextable: params[:user])
prompt messages: context_messages, tools: memory_tool_definitions
end
end
```

The model calls `save_memory(content:, category:)` and `recall_memory(category:, limit:)` as ordinary function-calling tools. `SolidAgent::HasMemory.tool_definitions` exposes the same schemas module-level for non-agent executors (platform services, MCP servers). Inject `agent.memory.to_prompt` into instructions to prime a handoff.

### ToolCache - Cached Tool Results

```ruby
result = SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: url }, ttl: 300) do
expensive_call(url)
end
result[:cached] # => true on a replay
```

Error-shaped results (`{ error: ... }`) are never cached, so transient failures don't stick; cache keys are stable across argument ordering and symbol/string keys.

### AgentRun - Durable Run Records

Executors record each agent execution as an `AgentRun`: lifecycle (`start!`/`complete!`/`fail!`/`cancel!`), correlation with contexts, generations, and telemetry via `trace_id`, and an append-only progress-event stream a UI can poll mid-run:

```ruby
run = AgentRun.create!(runnable: document, agent_name: "SupportAgent", input_prompt: message)
run.record_instructions(agent.instructions) # cohort fingerprint ("calm-heron")
run.start!
run.append_event(kind: "tool", label: "fetch_url", eid: "e1", status: "started")
# ... execute ...
run.append_event(kind: "tool", label: "fetch_url", eid: "e1", status: "done", duration_ms: 120)
run.complete!(output: response.message.content, input_tokens: usage.input_tokens, output_tokens: usage.output_tokens)
```

`AgentRun#instructions_codename` names each instruction cohort deterministically (`SolidAgent::RunFingerprint`), so comparing "what changed between these two batches of runs" reads as `calm-heron` vs `misty-atoll` instead of hex digests.

### ModelPricing - Estimated Spend

```ruby
SolidAgent::ModelPricing.estimate(model: "claude-sonnet-5", input_tokens: 12_000, output_tokens: 800)
# => 0.048 (USD, estimated)
```

The generated `AgentGeneration#estimated_cost` uses this automatically. Rates come from RubyLLM's registry when that gem is present, else a static pattern table.

### Generators

```bash
# Install persistence tables + models (contexts, messages, generations, memories)
$ rails generate solid_agent:install

# Generate a new agent
$ rails generate solid_agent:agent MyAgent

Expand All @@ -142,8 +210,14 @@ $ rails generate solid_agent:agent MyAgent --context --context_name session
# Generate a tool template
$ rails generate solid_agent:tool search MyAgent --parameters query:string:required

# Generate context models
# Generate custom-named context models
$ rails generate solid_agent:context conversation

# Add reasoning columns to a generation model
$ rails generate solid_agent:reasons AgentGeneration

# Scaffold an agent manifest (.agent.md)
$ rails generate solid_agent:manifest research
```

## Example Apps
Expand Down
9 changes: 9 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,12 @@ 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"

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

def create_models
Expand All @@ -37,6 +43,9 @@ 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"
template "agent_run.rb.erb", "app/models/agent_run.rb"
end

def create_initializer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,14 @@ class AgentContext < ApplicationRecord
# @param tool_name [String] the name of the tool
# @param result [Hash, String] the tool result
# @return [AgentMessage] the created message
def add_tool_message(tool_call_id:, tool_name:, result:)
def add_tool_message(tool_call_id:, tool_name:, result:, arguments: nil, duration_ms: nil)
messages.create!(
role: "tool",
tool_call_id: tool_call_id,
tool_name: tool_name,
tool_result: result,
tool_arguments: arguments.presence || {},
metadata: duration_ms ? { "duration_ms" => duration_ms } : {},
content: result.is_a?(String) ? result : result.to_json
)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,17 @@ class AgentGeneration < ApplicationRecord
# Returns the cost estimate based on token usage (override with your pricing)
#
# @param input_price_per_million [Float] price per million input tokens
# @param output_price_per_million [Float] price per million output tokens
# @return [Float] estimated cost in dollars
def estimated_cost(input_price_per_million: 3.0, output_price_per_million: 15.0)
input_cost = (input_tokens / 1_000_000.0) * input_price_per_million
output_cost = (output_tokens / 1_000_000.0) * output_price_per_million
input_cost + output_cost
# @param output_price_per_million [Float, nil] price per million output tokens
# @return [Float, nil] estimated cost in dollars, nil when nothing to price
def estimated_cost(input_price_per_million: nil, output_price_per_million: nil)
if input_price_per_million && output_price_per_million
input_cost = (input_tokens / 1_000_000.0) * input_price_per_million
output_cost = (output_tokens / 1_000_000.0) * output_price_per_million
input_cost + output_cost
else
# Per-model rates from SolidAgent::ModelPricing (RubyLLM registry
# when available, static table otherwise)
SolidAgent::ModelPricing.estimate(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
end
end
end
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
116 changes: 116 additions & 0 deletions lib/generators/solid_agent/install/templates/agent_run.rb.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# frozen_string_literal: true

# A single agent execution: lifecycle status, inputs/outputs, usage, and
# an append-only stream of progress events. Correlates with AgentContext/
# AgentGeneration rows and telemetry traces via trace_id.
class AgentRun < ApplicationRecord
belongs_to :runnable, polymorphic: true, optional: true

STATUSES = %w[pending running complete failed cancelled].freeze

validates :status, inclusion: { in: STATUSES }

scope :recent, -> { order(created_at: :desc) }
scope :for_agent, ->(agent_name) { where(agent_name: agent_name) }
scope :for_action, ->(action_name) { where(action_name: action_name) }
scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
scope :for_status, ->(status) { where(status: status) }

STATUSES.each do |status_name|
define_method("#{status_name}?") { status == status_name }
end

def in_progress?
pending? || running?
end

def finished?
complete? || failed? || cancelled?
end

# === Lifecycle ===

def start!
update!(status: "running", started_at: Time.current)
end

def complete!(output: nil, metadata: {}, input_tokens: nil, output_tokens: nil)
update!(
status: "complete",
output: output,
output_metadata: (output_metadata || {}).merge(metadata),
input_tokens: input_tokens || self.input_tokens,
output_tokens: output_tokens || self.output_tokens,
completed_at: Time.current,
duration_ms: calculated_duration_ms(fallback_end: Time.current)
)
end

def fail!(error)
update!(
status: "failed",
error_message: error.respond_to?(:message) ? error.message : error.to_s,
completed_at: Time.current,
duration_ms: calculated_duration_ms(fallback_end: Time.current)
)
end

def cancel!
return false if finished?

update!(status: "cancelled", completed_at: Time.current)
true
end

# === Progress events ===

# Appends a progress event mid-run so pollers can stream what the agent
# is doing (pending llm/tool/agent calls). Events pair up by eid: a
# "started" event is pending until a "done"/"error" with the same eid
# lands. update_column: no validations/callbacks, safe from the run's
# own execution thread; reads current DB state so concurrent appends
# interleave safely.
def append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil)
event = {
"at" => Time.current.iso8601(3),
"eid" => eid,
"kind" => kind.to_s,
"label" => label.to_s,
"status" => status.to_s
}.compact
event["detail"] = detail.to_s.byteslice(0, 1200).to_s.scrub if detail
event["duration_ms"] = duration_ms if duration_ms
current = self.class.where(id: id).pick(:events) || []
update_column(:events, current + [ event ])
event
end

# === Cohort fingerprinting ===

# Records the instructions this run executed under as a stable digest —
# the grouping key (with model) for configuration cohorts.
def record_instructions(instructions)
self.instructions_digest = SolidAgent::RunFingerprint.digest(instructions)
end

# Deterministic memorable name for the digest ("calm-heron") — reads far
# better than hex when comparing cohorts.
def instructions_codename
SolidAgent::RunFingerprint.codename(instructions_digest)
end

# === Usage ===

def total_tokens
input_tokens.to_i + output_tokens.to_i
end

def calculated_duration_ms(fallback_end: nil)
return duration_ms if duration_ms.present?

finish = completed_at || fallback_end
return nil unless started_at && finish

((finish - started_at) * 1000).to_i
end
end
Loading