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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ on:
branches: [ main ]

jobs:
# activeagent + actionagent against solid_agent, in both the combination
# this repository develops against and the one users install today. See
# .github/workflows/integration.yml.
integration:
uses: ./.github/workflows/integration.yml
secrets: inherit

lint:
runs-on: ubuntu-latest
steps:
Expand Down
128 changes: 128 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: Cross-repo integration

# activeagent, actionagent and solid_agent release independently, and
# solid_agent depends on the framework — so each repository's own suite can
# be green while the combination people install is broken. This workflow is
# the thing that runs them together.
#
# Two configurations, both meaningful:
#
# source this checkout against solid_agent's main branch, with
# SOLID_AGENT_STRICT=1 so a test that would skip fails instead.
# This is what the two repositories are developing toward.
#
# released this checkout against whatever solid_agent Bundler resolves
# from RubyGems — the combination a user gets today. Tests skip
# what the released gem cannot do, and the skips are printed to
# the job summary, so the gap between the gem and its source is
# visible rather than assumed.
#
# Called by ci.yml on every pull request, by release.yml before publishing,
# by solid_agent's CI (via repository_dispatch) when that repo changes, and
# nightly so drift surfaces without anyone pushing.

on:
workflow_call:
inputs:
solid_agent_ref:
description: solid_agent branch, tag or SHA to test against
type: string
default: main
workflow_dispatch:
inputs:
solid_agent_ref:
description: solid_agent branch, tag or SHA to test against
type: string
default: main
repository_dispatch:
types: [ solid-agent-changed ]
schedule:
- cron: "0 6 * * *"

jobs:
integration:
name: ${{ matrix.configuration }} solid_agent
runs-on: ubuntu-latest
env:
BUNDLE_JOBS: 4
BUNDLE_RETRY: 3
CI: true
RAILS_ENV: test
ANTHROPIC_API_KEY: ANTHROPIC_API_KEY
OPEN_AI_API_KEY: OPEN_AI_API_KEY
OPEN_ROUTER_API_KEY: OPEN_ROUTER_API_KEY
strategy:
fail-fast: false
matrix:
include:
- configuration: source
gemfile: gemfiles/solid_agent_main.gemfile
strict: "1"
- configuration: released
gemfile: gemfiles/rails8.gemfile
strict: ""
steps:
- uses: actions/checkout@v6

- name: Install system deps
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config

- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
SOLID_AGENT_REF: ${{ inputs.solid_agent_ref || github.event.client_payload.solid_agent_ref || 'main' }}

- name: Setup database
working-directory: test/dummy
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
SOLID_AGENT_REF: ${{ inputs.solid_agent_ref || github.event.client_payload.solid_agent_ref || 'main' }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: |
bundle exec ruby bin/rails db:create
bundle exec ruby bin/rails db:migrate

- name: Report the resolved versions
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
SOLID_AGENT_REF: ${{ inputs.solid_agent_ref || github.event.client_payload.solid_agent_ref || 'main' }}
run: |
{
echo "### ${{ matrix.configuration }} combination"
echo
echo '```'
bundle list | grep -E "activeagent|actionagent|solid_agent" || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

# The engine's own suite runs here too, not just the integration
# directory: ActionAgent::AgentExecutionService builds an agent class
# around SolidAgent::HasContext, and that path is only exercised by
# actionagent/test.
- name: Run the integration suite
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
SOLID_AGENT_REF: ${{ inputs.solid_agent_ref || github.event.client_payload.solid_agent_ref || 'main' }}
SOLID_AGENT_STRICT: ${{ matrix.strict }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: |
bin/test test/integration/solid_agent/*_test.rb \
actionagent/test/agent_execution_service_test.rb 2>&1 | tee integration.log

# Skips are the report: on the released combination they name every
# API the published gem is missing, which is the cue to cut a
# solid_agent release.
- name: Summarize what the resolved gem could not cover
if: always()
run: |
{
echo
echo '```'
grep -E "does not provide|skips" integration.log || echo "nothing skipped"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ on:
workflow_dispatch:

jobs:
# solid_agent depends on this framework, so a release can break it
# downstream without either repository's own suite noticing. Publishing
# waits on the combination being green.
integration:
uses: ./.github/workflows/integration.yml
secrets: inherit

build:
needs: integration
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,37 @@ bin/test

# Lint
bin/rubocop

# Cross-repo: this checkout against a local solid_agent (strict = no skips)
SOLID_AGENT_PATH=../solid_agent \
BUNDLE_GEMFILE=gemfiles/solid_agent_main.gemfile \
SOLID_AGENT_STRICT=1 \
bin/test test/integration/solid_agent/*_test.rb \
actionagent/test/agent_execution_service_test.rb
```

## Cross-repo testing (solid_agent)

`solid_agent` lives in its own repository, depends on this framework, and is
depended on by `actionagent` — so all three suites can be green while the
combination a user installs is broken. `test/integration/solid_agent/` runs
them together in the dummy app, against the models
`rails generate solid_agent:install` writes, using the mock provider.

- `gemfiles/solid_agent_main.gemfile` swaps the released gem for source:
`SOLID_AGENT_PATH` (local checkout) or `SOLID_AGENT_REF` (branch/tag/SHA).
- Tests declare what they need (`requires_solid_agent`,
`requires_solid_agent_capability`) and skip when the resolved gem lacks
it; `SOLID_AGENT_STRICT=1` turns those skips into failures.
- CI runs both configurations (`.github/workflows/integration.yml`), and
`release.yml` gates publishing on them. solid_agent's CI runs the same
suite against this repo's main branch and latest release tag.
- Version skew between the two gems is handled by feature detection rather
than a dependency-floor bump, since the floor can only move after the
dependency ships — see `ActionAgent.solid_agent_auto_context_keyword`.

Full write-up: `docs/contributing/releasing.md`.

## Dependencies

- Ruby 3.1+
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
>
> *Makes code [TonsOfFun](https://tonsoffun.github.io)!*

[![Gem Version](https://img.shields.io/gem/v/activeagent?label=activeagent&logo=rubygems&color=CC342D)](https://rubygems.org/gems/activeagent)
[![actionagent](https://img.shields.io/gem/v/actionagent?label=actionagent&logo=rubygems&color=CC342D)](https://rubygems.org/gems/actionagent)
[![Downloads](https://img.shields.io/gem/dt/activeagent?label=downloads)](https://rubygems.org/gems/activeagent)
[![CI](https://github.com/activeagents/activeagent/actions/workflows/ci.yml/badge.svg)](https://github.com/activeagents/activeagent/actions/workflows/ci.yml)
[![Cross-repo integration](https://github.com/activeagents/activeagent/actions/workflows/integration.yml/badge.svg)](https://github.com/activeagents/activeagent/actions/workflows/integration.yml)
[![Docs](https://img.shields.io/badge/docs-docs.activeagents.ai-2563eb)](https://docs.activeagents.ai)
[![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D)](https://www.ruby-lang.org)
[![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-D30001)](https://rubyonrails.org)
[![License](https://img.shields.io/github/license/activeagents/activeagent)](LICENSE)

# Active Agent
Active Agent provides that missing AI layer in the Rails framework, offering a structured approach to building AI-powered applications through Agent Oriented Programming. **Now Agents are Controllers!** Designing applications using agents allows developers to create modular, reusable components that can be easily integrated into existing systems. This approach promotes code reusability, maintainability, and scalability, making it easier to build complex AI-driven applications with the Object Oriented Ruby code you already use today.

Expand Down
14 changes: 11 additions & 3 deletions actionagent/app/services/action_agent/agent_execution_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -354,19 +354,27 @@ def generate!
define_singleton_method(:name) { klass_name }

# Persist the conversation (agent_contexts / agent_messages /
# agent_generations) via solid_agent. contextable: false — the context
# is loaded explicitly in the action below.
# agent_generations) via solid_agent. Auto-context is switched off —
# the context is loaded explicitly in the action below.
#
# The keyword that switches it off was renamed (contextable: ->
# contextual:) between solid_agent 0.1 and 0.2, and the gemspec floor
# admits both, so it is resolved from the installed method rather than
# hard-coded: passing the wrong one is an ArgumentError that only
# surfaces when a run executes.
#
# The model classes are named explicitly because solid_agent infers
# bare "AgentContext"/"AgentMessage"/"AgentGeneration" and resolves
# them against Object. The engine's models are namespaced, so the
# inferred names only resolve in a host app that happens to have
# top-level models of its own.
include SolidAgent::HasContext
has_context contextable: false,
has_context(
ActionAgent.solid_agent_auto_context_keyword => false,
class_name: "ActionAgent::AgentContext",
message_class: "ActionAgent::AgentMessage",
generation_class: "ActionAgent::AgentGeneration"
)

if effective_provider == :mock
# Test environment only (see #provider_available?).
Expand Down
25 changes: 24 additions & 1 deletion actionagent/app/services/action_agent/agent_toolbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ def cached_fetch(name, kwargs, &block)
if defined?(SolidAgent::ToolCache)
SolidAgent::ToolCache.fetch(tool: name.to_s, args: kwargs, ttl: CACHE_TTL, &block)
else
key = "solid_agent:tool_cache:#{name}:#{Digest::SHA256.hexdigest(kwargs.sort.to_h.to_json)}"
key = fallback_cache_key(name, kwargs)
cached = Rails.cache.read(key)
return cached.merge(cached: true) unless cached.nil?

Expand All @@ -411,6 +411,29 @@ def cached_fetch(name, kwargs, &block)
end
end

# Byte-for-byte the key SolidAgent::ToolCache would compute, so an app
# that upgrades solid_agent mid-TTL keeps reading what it already
# cached instead of silently starting over. Nested hashes and
# symbol/string keys have to normalize the same way, which a plain
# `kwargs.sort.to_h.to_json` does not do.
#
# test/integration/solid_agent/tool_cache_test.rb asserts the two
# schemes still agree.
def fallback_cache_key(name, kwargs)
"solid_agent:tool_cache:#{name}:#{Digest::SHA256.hexdigest(normalize_cache_args(kwargs).to_json)}"
end

def normalize_cache_args(args)
case args
when Hash
args.map { |key, value| [ key.to_s, normalize_cache_args(value) ] }.sort_by(&:first)
when Array
args.map { |value| normalize_cache_args(value) }
else
args
end
end

# SSRF guard for fetch_url: reject hosts that resolve to loopback,
# private, or link-local addresses.
def public_host?(host)
Expand Down
19 changes: 19 additions & 0 deletions actionagent/lib/action_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ def table_name_prefix
global = defined?(::ActiveRecord::Base) ? ::ActiveRecord::Base.table_name_prefix : ""
"#{global}#{@table_name_prefix ||= "active_agent_"}"
end

# Which keyword the installed solid_agent uses to switch has_context's
# auto-context off: `contextable:` up to 0.1, `contextual:` from 0.2. The
# gemspec floor admits both, and passing the wrong one raises an
# ArgumentError deep inside a run rather than at boot — so
# AgentExecutionService asks rather than assumes.
#
# Covered by test/integration/solid_agent, which runs this engine against
# solid_agent's main branch as well as the released gem.
def solid_agent_auto_context_keyword
@solid_agent_auto_context_keyword ||= begin
keywords = ::SolidAgent::HasContext::ClassMethods
.instance_method(:has_context).parameters
.select { |type, _| [ :key, :keyreq ].include?(type) }
.map(&:last)

keywords.include?(:contextual) ? :contextual : :contextable
end
end
end
end

Expand Down
15 changes: 15 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,31 @@ export default defineConfig({
{ text: 'Mock', link: '/providers/mock' },
]
},
{
text: 'Persistence (SolidAgent)',
items: [
{ text: 'Overview', link: '/solid_agent' },
{ text: 'Conversation Context', link: '/solid_agent/context' },
{ text: 'Long-Term Memory', link: '/solid_agent/memory' },
{ text: 'Tools, Streaming & Caching', link: '/solid_agent/tools' },
{ text: 'Reasoning', link: '/solid_agent/reasoning' },
{ text: 'Runs, Cohorts & Cost', link: '/solid_agent/runs' },
{ text: 'Agent Manifests', link: '/solid_agent/manifests' },
{ text: 'Examples', link: '/solid_agent/examples' },
]
},
{ text: 'Examples',
items: [
// { text: 'Browser Use', link: '/examples/browser-use-agent' },
{ text: 'Data Extraction', link: '/examples/data_extraction_agent' },
// { text: 'Translation', link: '/examples/translation-agent' },
{ text: 'SolidAgent Examples', link: '/solid_agent/examples' },
]
},
{ text: 'Contributing',
items: [
{ text: 'Documentation', link: '/contributing/documentation' },
{ text: 'Releasing & Cross-Repo Testing', link: '/contributing/releasing' },
]
},
],
Expand Down
1 change: 1 addition & 0 deletions docs/actions/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ end

- [Delegation](/actions/delegation) - Expose another agent to your model as a tool
- [MCP (Model Context Protocol)](/actions/mcps) - Connect to external services via MCP
- [Tools, Streaming & Caching (SolidAgent)](/solid_agent/tools) - Declarative tool schemas, live tool status, cached results
- [Agents](/agents) - Understand the agent lifecycle and callbacks
- [Generation](/agents/generation) - Execute tool-enabled generations
- [Messages](/actions/messages) - Learn about conversation structure
Expand Down
1 change: 1 addition & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ See [Streaming](/agents/streaming) for complete documentation.

**Related Topics:**
- [Tools](/actions/tools) - Use agent actions as AI-callable tools
- [Persistence (SolidAgent)](/solid_agent) - Persist conversations, memory and runs to your database
- [Structured Output](/actions/structured_output) - Extract typed data with schemas
- [Embeddings](/actions/embeddings) - Vector generation for semantic search
- [Testing](/framework/testing) - Test agents and concerns
Expand Down
16 changes: 16 additions & 0 deletions docs/contributing/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,22 @@ Use tabs to show different perspectives of the same data:
- `docs/` — All documentation markdown files
- `docs/parts/examples/` — Auto-generated outputs (naming: `{test-file}-{test-method}.md`)

## Documenting sibling gems

`docs/solid_agent/` documents
[solid_agent](https://github.com/activeagents/solid_agent), which lives in
its own repository — so there is nothing here for `<<<` to import, and
those pages carry their code inline. They are the exception, not a
loosening of the rule: examples on those pages mirror
[`examples/`](https://github.com/activeagents/solid_agent/tree/main/examples)
in that repo, where a test parses every Ruby file, validates every
manifest, and checks that every `SolidAgent::` constant they name still
exists. Change one of those pages and change the example it mirrors, so the
tested copy stays the source of truth.

Everything documenting code in *this* repo — the framework and the
`actionagent` engine — imports from tests as described above.

## Troubleshooting

### Import Not Showing
Expand Down
Loading